aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/background.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/background.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/background.js')
-rw-r--r--bluebey-studio/src/background.js666
1 files changed, 666 insertions, 0 deletions
diff --git a/bluebey-studio/src/background.js b/bluebey-studio/src/background.js
new file mode 100644
index 0000000..adc7f94
--- /dev/null
+++ b/bluebey-studio/src/background.js
@@ -0,0 +1,666 @@
+/**
+ * The studio backdrop, as a DOM layer that sits behind the WebGL canvas.
+ *
+ * The renderer is created with `alpha: true`, so whenever it is cleared to
+ * alpha 0 the page shows through. Painting the backdrop into a sibling layer
+ * placed *under* `#view` therefore gives the character a scene without touching
+ * the scene graph: the CC0 photo, the user's own picture and the phone's camera
+ * feed are all just CSS on one element, and no texture has to be uploaded per
+ * frame.
+ *
+ * `backdropStyle()` is deliberately pure. It is the only place that turns the
+ * `view.background*` slice into a CSS descriptor, so the behaviour `apply()`
+ * ships with is exactly the behaviour the unit tests cover.
+ */
+
+/** The library already downloaded into `assets/backgrounds/` (see CREDITS.json).
+ * Most are Poly Haven *HDRIs* - CC0 photographs of real places - so each one comes
+ * with a floor, a horizon and perspective, which is what gives a picture depth
+ * that a flat wall texture cannot. `mutoujima`, `dokan` and `uchu` are the
+ * exceptions: original illustrations drawn for this studio by the author, not
+ * CC0 photos. */
+export const BACKGROUND_PRESETS = [
+ { name: 'empty_warehouse_01', label: '倉庫' },
+ { name: 'ballroom', label: '広間' },
+ { name: 'kloppenheim_06_puresky', label: '空と雲' },
+ { name: 'autumn_park', label: '秋の公園' },
+ // The places a councillor explains things in, or stands in to make a point.
+ // Poly Haven has no true classroom, desert or 社長室, so these are the closest
+ // real places it does have.
+ { name: 'newman_cafeteria', label: '学校' },
+ { name: 'wooden_lounge', label: '社長室(木の部屋)' },
+ { name: 'minedump_flats', label: '砂漠' },
+ { name: 'spiaggia_di_mondello', label: '海岸' },
+ // ポリヘイブンの写真ではなく、作者(安竹洋平)が描き起こしたオリジナルの一枚絵。
+ { name: 'mutoujima', label: '無人島' },
+ { name: 'dokan', label: '土管のある空き地' },
+ { name: 'uchu', label: '宇宙船の中' },
+ // 「ぶるべーを探せ!」用。物がたくさん詰まっていて、同じ形が何度も出てくる場所。
+ { name: 'abandoned_factory_canteen_01', label: '廃工場の食堂' },
+ { name: 'basement_boxing_ring', label: '地下室のリング' },
+ { name: 'autoshop_01', label: '自動車工場' },
+ { name: 'urban_alley_01', label: '路地' },
+];
+
+const DEFAULT_PRESET = 'autumn_park';
+
+/**
+ * Manga effect-line backdrops, drawn *procedurally* (no file, no network).
+ *
+ * They are the classic stress marks: a burst of lines from behind the character
+ * (`focus`), lines raining down (`fall`), lines streaming sideways (`speed`) and
+ * short lines ringing a wide ellipse so the empty middle reads as isolation
+ * (`ellipse`).
+ * Each is a canvas painted black on white; the same generator runs for the
+ * preview, the PNG and the standalone build.
+ */
+export const EFFECT_PRESETS = [
+ { name: 'focus', label: '集中線' },
+ { name: 'fall', label: '落ち込み線' },
+ { name: 'speed', label: '疾走線' },
+ { name: 'ellipse', label: '楕円集中線' },
+];
+
+const DEFAULT_EFFECT = 'focus';
+
+/** The canvas every effect is painted on, so preview and export agree. */
+export const EFFECT_SIZE = { width: 1200, height: 800 };
+
+/**
+ * A small deterministic PRNG (FNV-1a seeding + mulberry32), so an effect looks
+ * the same on every run, in every build, and in the tests.
+ */
+function makeRandom(seedText) {
+ let seed = 2166136261;
+ for (let i = 0; i < seedText.length; i++) {
+ seed ^= seedText.charCodeAt(i);
+ seed = Math.imul(seed, 16777619);
+ }
+ return () => {
+ seed = (seed + 0x6d2b79f5) | 0;
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * Paint one effect-line backdrop onto `ctx`. Pure and deterministic: given the
+ * same name and size it always issues the same black-on-white strokes, so the
+ * unit tests can pin the look down and the cache below stays sound.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {'focus'|'fall'|'speed'|'ellipse'} name
+ * @param {{width:number,height:number}} [size]
+ */
+export function drawEffectLines(ctx, name, size = EFFECT_SIZE) {
+ const width = positiveOrOne(size?.width);
+ const height = positiveOrOne(size?.height);
+ const kind = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT;
+ const random = makeRandom(kind);
+
+ ctx.save();
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, width, height);
+ ctx.strokeStyle = '#111111';
+ ctx.lineCap = 'butt';
+
+ if (kind === 'focus') {
+ // A burst: lines radiate from a point behind the head, and stop short of it
+ // so the character's face is not crossed by ink.
+ const cx = width * 0.5;
+ const cy = height * 0.42;
+ const reach = Math.hypot(width, height) * 1.1;
+ const near = Math.min(width, height);
+ for (let i = 0; i < 150; i++) {
+ const angle = (i / 150) * Math.PI * 2 + (random() - 0.5) * 0.03;
+ const inner = near * (0.12 + random() * 0.5);
+ ctx.lineWidth = 1 + random() * 4;
+ ctx.beginPath();
+ ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner);
+ ctx.lineTo(cx + Math.cos(angle) * reach, cy + Math.sin(angle) * reach);
+ ctx.stroke();
+ }
+ } else if (kind === 'fall') {
+ // Lines raining down from the top edge, of uneven length.
+ for (let i = 0; i < 90; i++) {
+ const x = Math.min(width, Math.max(0, ((i + 0.5) / 90) * width + (random() - 0.5) * 8));
+ const length = height * (0.25 + random() * 0.7);
+ ctx.lineWidth = 1 + random() * 3;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, length);
+ ctx.stroke();
+ }
+ } else if (kind === 'speed') {
+ // Lines streaming in from one side or the other.
+ for (let i = 0; i < 70; i++) {
+ const y = Math.min(height, Math.max(0, ((i + 0.5) / 70) * height + (random() - 0.5) * 6));
+ const length = width * (0.35 + random() * 0.65);
+ const x = random() < 0.5 ? 0 : width - length;
+ ctx.lineWidth = 1 + random() * 3.5;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + length, y);
+ ctx.stroke();
+ }
+ } else if (kind === 'ellipse') {
+ // A dense ring of short lines *outside* a wide ellipse: the blank inside is
+ // the point (the 「ひとり」 panel), so nothing is drawn there. The start
+ // points are spaced by arc length, not by angle - an equal angular step
+ // bunches the lines at the two ends of a wide ellipse and leaves gaps along
+ // the flat top and bottom, which is what made the old ring look uneven. Each
+ // line then leaves along the ellipse's outward normal, so the inner edge
+ // stays a clean ellipse all the way round. Lengths vary (0.5-1.7x reach) and
+ // every line is anchored to the ring, so none reads as a stray scratch.
+ const cx = width * 0.5;
+ const cy = height * 0.5;
+ const rx = Math.min(width * 0.42, height * 0.9);
+ const ry = rx * 0.55;
+ const lines = 200;
+ const reach = Math.min(width, height) * 0.24;
+
+ // Cumulative arc length around the ring, so the lines can be spread evenly
+ // around it rather than evenly by angle.
+ const samples = 512;
+ const arc = [0];
+ for (let s = 1; s <= samples; s++) {
+ const prev = ((s - 1) / samples) * Math.PI * 2;
+ const next = (s / samples) * Math.PI * 2;
+ const dx = (Math.cos(next) - Math.cos(prev)) * rx;
+ const dy = (Math.sin(next) - Math.sin(prev)) * ry;
+ arc.push(arc[s - 1] + Math.hypot(dx, dy));
+ }
+ const total = arc[samples];
+
+ let s = 1;
+ for (let i = 0; i < lines; i++) {
+ const target = ((i + 0.5) / lines) * total;
+ while (s < samples && arc[s] < target) s++;
+ const span = arc[s] - arc[s - 1] || 1;
+ const t = (((s - 1) + (target - arc[s - 1]) / span) / samples) * Math.PI * 2;
+ const cos = Math.cos(t);
+ const sin = Math.sin(t);
+ // The outward normal of an ellipse points along (cos/rx, sin/ry), not along
+ // the radius from the centre, so the lines meet the ring at a right angle.
+ const nx = cos / rx;
+ const ny = sin / ry;
+ const length = (reach * (0.5 + random() * 1.1)) / (Math.hypot(nx, ny) || 1);
+ ctx.lineWidth = 1 + random() * 3.5;
+ const startX = cx + cos * rx;
+ const startY = cy + sin * ry;
+ ctx.beginPath();
+ ctx.moveTo(startX, startY);
+ ctx.lineTo(startX + nx * length, startY + ny * length);
+ ctx.stroke();
+ }
+ }
+ ctx.restore();
+}
+
+/** How each `backgroundFit` paints: a CSS size, plus whether the image tiles. */
+const FIT_STYLES = {
+ cover: { backgroundSize: 'cover', backgroundRepeat: 'no-repeat' },
+ contain: { backgroundSize: 'contain', backgroundRepeat: 'no-repeat' },
+ stretch: { backgroundSize: '100% 100%', backgroundRepeat: 'no-repeat' },
+ tile: { backgroundSize: 'auto', backgroundRepeat: 'repeat' },
+};
+const DEFAULT_FIT = 'cover';
+
+/** A live frame is a replaced element, so the same fit maps to `object-fit`. */
+const OBJECT_FIT = { cover: 'cover', contain: 'contain', '100% 100%': 'fill', auto: 'cover' };
+
+const MODES = new Set(['solid', 'transparent', 'preset', 'image', 'effect', 'camera']);
+
+/** Device orientation fires around 60 Hz; 30 Hz is plenty for swinging a camera. */
+const GYRO_INTERVAL_MS = 33;
+
+/** Coerce to a finite number, or `null` when the value is not one. */
+function finiteOrNull(value) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : null;
+}
+
+function normalizeMode(mode) {
+ return MODES.has(mode) ? mode : 'solid';
+}
+
+function clamp01(value) {
+ const n = finiteOrNull(value);
+ if (n == null) return 0;
+ return Math.min(1, Math.max(0, n));
+}
+
+function nonNegative(value) {
+ const n = finiteOrNull(value);
+ return n == null ? 0 : Math.max(0, n);
+}
+
+function positiveOrOne(value) {
+ const n = finiteOrNull(value);
+ return n == null || n <= 0 ? 1 : n;
+}
+
+/** The shortest signed distance between two angles, in degrees. */
+function wrapDeg(degrees) {
+ return ((degrees + 540) % 360) - 180;
+}
+
+/**
+ * Turn the `view` slice into the plain descriptor `apply()` renders from.
+ * Every field is optional, so a half-written state still yields a usable look;
+ * an unknown `background` falls back to `solid` and an unknown `backgroundFit`
+ * to `cover`.
+ *
+ * `offset` is in fractions of the layer size, and `visible` is false only for
+ * `transparent` (where the page background is meant to show through).
+ */
+export function backdropStyle(viewState) {
+ const state = viewState && typeof viewState === 'object' ? viewState : {};
+ const fit = FIT_STYLES[state.backgroundFit] ?? FIT_STYLES[DEFAULT_FIT];
+ const offset = state.backgroundOffset && typeof state.backgroundOffset === 'object'
+ ? state.backgroundOffset
+ : {};
+
+ return {
+ backgroundSize: fit.backgroundSize,
+ backgroundRepeat: fit.backgroundRepeat,
+ mirror: state.cameraMirror === true,
+ darken: clamp01(state.backgroundDarken),
+ blur: nonNegative(state.backgroundBlur),
+ offset: { x: finiteOrNull(offset.x) ?? 0, y: finiteOrNull(offset.y) ?? 0 },
+ scale: positiveOrOne(state.backgroundScale),
+ visible: normalizeMode(state.background) !== 'transparent',
+ };
+}
+
+/** `assets/backgrounds/<name>.webp` - what the multi-file build serves. */
+function defaultResolveUrl(name) {
+ return `assets/backgrounds/${name}.webp`;
+}
+
+/**
+ * Build the backdrop layer for a `#stage` element.
+ *
+ * `resolveUrl(name)` maps a preset name to an image URL; the single-file build
+ * passes one that reads an inlined map, while the default points at
+ * `assets/backgrounds/`. `onNeedsRender` is called whenever the layer changes so
+ * the host can repaint.
+ *
+ * The returned object is the only way in: the layer never reads or writes the
+ * studio state itself, `apply(viewState)` is the single entry point.
+ */
+export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) {
+ if (!stage || typeof stage.prepend !== 'function') {
+ throw new Error('createBackdrop: ステージ要素(#stage)が必要です');
+ }
+
+ const doc = stage.ownerDocument ?? globalThis.document;
+ const resolve = typeof resolveUrl === 'function' ? resolveUrl : defaultResolveUrl;
+ const needsRender = typeof onNeedsRender === 'function' ? onNeedsRender : () => {};
+
+ const el = doc.createElement('div');
+ el.className = 'backdrop';
+ el.setAttribute('aria-hidden', 'true');
+ Object.assign(el.style, {
+ position: 'absolute',
+ inset: '0',
+ zIndex: '0',
+ overflow: 'hidden',
+ pointerEvents: 'none',
+ transformOrigin: 'center',
+ });
+
+ // The image surface is a plain div so blur/scale/tint stay pure CSS.
+ const media = doc.createElement('div');
+ Object.assign(media.style, { position: 'absolute', inset: '0', backgroundPosition: 'center' });
+
+ // The camera feed is a muted, inline, autoplaying video for the AR mode.
+ const video = doc.createElement('video');
+ video.muted = true;
+ video.playsInline = true;
+ video.autoplay = true;
+ video.setAttribute('muted', '');
+ video.setAttribute('playsinline', '');
+ video.setAttribute('aria-hidden', 'true');
+ Object.assign(video.style, { position: 'absolute', inset: '0', display: 'none' });
+
+ // The dark veil sits on top of whichever surface is showing, so the character
+ // keeps its contrast against a busy photo.
+ const veil = doc.createElement('div');
+ Object.assign(veil.style, { position: 'absolute', inset: '0' });
+
+ el.append(media, video, veil);
+ // Prepended, i.e. before the canvas; the canvas is lifted back above it.
+ stage.prepend(el);
+
+ const canvas = stage.querySelector('canvas');
+ if (canvas) {
+ canvas.style.position = 'relative';
+ canvas.style.zIndex = '1';
+ }
+
+ /** The most recent state slice, so the imperative helpers keep its styling. */
+ let last = {};
+ let stream = null;
+
+ let gyroEnabled = false;
+ let gyroAttached = false;
+ let gyroBase = null;
+ let gyroCallback = null;
+ let lastGyroAt = 0;
+
+ const effectUrls = new Map();
+
+ /** The data URL for an effect-line backdrop, generated once and cached. */
+ function effectUrl(name) {
+ const key = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT;
+ if (effectUrls.has(key)) return effectUrls.get(key);
+ const surface = doc.createElement('canvas');
+ surface.width = EFFECT_SIZE.width;
+ surface.height = EFFECT_SIZE.height;
+ const surfaceCtx = surface.getContext('2d');
+ if (!surfaceCtx) return null;
+ drawEffectLines(surfaceCtx, key, EFFECT_SIZE);
+ const url = surface.toDataURL('image/png');
+ effectUrls.set(key, url);
+ return url;
+ }
+
+ function imageUrlOf(mode, viewState) {
+ if (mode === 'image') {
+ return typeof viewState.backgroundImage === 'string' && viewState.backgroundImage
+ ? viewState.backgroundImage
+ : null;
+ }
+ if (mode === 'effect') {
+ const name = typeof viewState.backgroundEffect === 'string' && viewState.backgroundEffect
+ ? viewState.backgroundEffect
+ : DEFAULT_EFFECT;
+ return effectUrl(name);
+ }
+ if (mode !== 'preset') return null;
+
+ const name = typeof viewState.backgroundPreset === 'string' && viewState.backgroundPreset
+ ? viewState.backgroundPreset
+ : DEFAULT_PRESET;
+ try {
+ const url = resolve(name);
+ if (typeof url === 'string' && url) return url;
+ } catch {
+ // Fall through to the file path: an unknown preset is not worth throwing.
+ }
+ return defaultResolveUrl(name);
+ }
+
+ function colorOr(value) {
+ return typeof value === 'string' && value ? value : '#ffffff';
+ }
+
+ /**
+ * Fit, mirror, blur and the blur bleeding past the edges (the negative inset
+ * keeps `filter: blur()` from fading the border to transparent).
+ */
+ function styleSurface(node, style) {
+ node.style.inset = style.blur > 0 ? `-${style.blur * 2}px` : '0';
+ node.style.filter = style.blur > 0 ? `blur(${style.blur}px)` : 'none';
+ node.style.transform = style.mirror ? 'scaleX(-1)' : 'none';
+
+ if (node === media) {
+ node.style.backgroundSize = style.backgroundSize;
+ node.style.backgroundRepeat = style.backgroundRepeat;
+ } else {
+ node.style.objectFit = OBJECT_FIT[style.backgroundSize] ?? 'cover';
+ }
+ }
+
+ function apply(viewState) {
+ last = viewState && typeof viewState === 'object' ? viewState : {};
+ const style = backdropStyle(last);
+
+ const wanted = normalizeMode(last.background);
+ const url = imageUrlOf(wanted, last);
+ // A preset that cannot be resolved is shown as a plain colour, never blank.
+ const drawable = wanted === 'preset' || wanted === 'image' || wanted === 'effect';
+ const mode = drawable && !url ? 'solid' : wanted;
+
+ el.style.display = style.visible ? 'block' : 'none';
+ el.style.backgroundColor = mode === 'solid' ? colorOr(last.backgroundColor) : 'transparent';
+ el.style.transform = style.scale === 1 && style.offset.x === 0 && style.offset.y === 0
+ ? 'none'
+ : `scale(${style.scale}) translate(${style.offset.x * 100}%, ${style.offset.y * 100}%)`;
+
+ veil.style.background = `rgba(0, 0, 0, ${style.darken})`;
+ veil.style.display = style.darken > 0 ? 'block' : 'none';
+
+ const showCamera = style.visible && mode === 'camera';
+ const showImage = style.visible && (mode === 'preset' || mode === 'image' || mode === 'effect');
+ // Leaving the AR mode must release the camera, not just hide the video.
+ if (!showCamera && stream) stopCamera();
+
+ styleSurface(media, style);
+ styleSurface(video, style);
+ media.style.display = showImage ? 'block' : 'none';
+ video.style.display = showCamera ? 'block' : 'none';
+ media.style.backgroundImage = showImage ? `url("${url}")` : 'none';
+
+ needsRender();
+ }
+
+ /** Show a built-in backdrop, keeping the fit/darken/blur already in play. */
+ function loadPreset(name) {
+ const preset = typeof name === 'string' && name ? name : DEFAULT_PRESET;
+ apply({ ...last, background: 'preset', backgroundPreset: preset });
+ }
+
+ /** Apply a stored data URL (as kept in `view.backgroundImage`). */
+ function applyImage(dataUrl) {
+ if (typeof dataUrl !== 'string' || !dataUrl) {
+ apply({ ...last, background: 'solid', backgroundImage: null });
+ return;
+ }
+ apply({ ...last, background: 'image', backgroundImage: dataUrl });
+ }
+
+ /** Read a user File into a data URL, show it, and hand the URL back to save. */
+ async function loadImageFile(file) {
+ const dataUrl = await new Promise((resolveUrl, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolveUrl(String(reader.result));
+ reader.onerror = () => reject(reader.error ?? new Error('画像を読み込めませんでした'));
+ reader.readAsDataURL(file);
+ });
+ applyImage(dataUrl);
+ return dataUrl;
+ }
+
+ /* Shown whenever the page is not a secure context. This is a fact about the
+ * web platform, not about any one browser, so the wording must not point a
+ * finger at the browser the user happens to be holding. */
+ const INSECURE_CAMERA_REASON =
+ 'この開き方(http のアドレス)では、どのブラウザでもカメラを使えません。https のサイトか localhost で開いてください。';
+
+ /**
+ * Japanese, actionable text for the ways `getUserMedia` usually fails. On a
+ * phone this toast is all the user has to go on, so each case names the exact
+ * thing to tap instead of just stating that something went wrong.
+ */
+ function cameraFailureReason(error) {
+ switch (error?.name) {
+ case 'NotAllowedError':
+ case 'PermissionDeniedError':
+ return 'カメラが許可されていません。URLバーのアイコン→カメラ→「許可」に変え(一度「ブロック」するとブラウザは覚えています)、端末の設定でもアプリに許可してください(Android: 設定→アプリ→Brave→権限→カメラ/iOS: 設定→Brave→カメラ)。';
+ case 'NotFoundError':
+ case 'DevicesNotFoundError':
+ return '使えるカメラが見つかりませんでした';
+ case 'NotReadableError':
+ case 'TrackStartError':
+ return 'カメラを起動できませんでした(他のアプリが使用中の可能性があります)';
+ case 'OverconstrainedError':
+ case 'ConstraintNotSatisfiedError':
+ return '指定したカメラを使用できません';
+ case 'SecurityError':
+ // No `mediaDevices` at all is the usual symptom, but this covers the
+ // same cause when a sandboxed frame raises the error instead.
+ return INSECURE_CAMERA_REASON;
+ default:
+ return 'カメラを起動できませんでした';
+ }
+ }
+
+ async function startCamera(facing = 'environment') {
+ const mediaDevices = globalThis.navigator?.mediaDevices;
+ if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') {
+ // An insecure page has no `mediaDevices` at all. Say so plainly rather
+ // than leaving the user to think their camera or browser is broken.
+ return {
+ ok: false,
+ reason: globalThis.isSecureContext === false
+ ? INSECURE_CAMERA_REASON
+ : 'カメラを使うには https か localhost が必要です',
+ };
+ }
+
+ stopCamera();
+ try {
+ const next = await mediaDevices.getUserMedia({ video: { facingMode: facing } });
+ stream = next;
+ video.srcObject = next;
+ try {
+ await video.play();
+ } catch {
+ // A blocked autoplay still resolves to a painting stream in practice.
+ }
+ apply({ ...last, background: 'camera', cameraFacing: facing });
+ return { ok: true };
+ } catch (error) {
+ stopCamera();
+ return { ok: false, reason: cameraFailureReason(error) };
+ }
+ }
+
+ function stopCamera() {
+ const active = stream;
+ stream = null;
+ if (active) {
+ try {
+ for (const track of active.getTracks()) track.stop();
+ } catch {
+ // A track that cannot be stopped is already gone as far as we care.
+ }
+ }
+ if (video) {
+ try {
+ video.pause();
+ } catch {
+ // Pausing a video without a source is expected to throw.
+ }
+ video.srcObject = null;
+ video.style.display = 'none';
+ }
+ }
+
+ function emitGyro(yaw, pitch, roll) {
+ gyroCallback?.({ yaw, pitch, roll });
+ }
+
+ /**
+ * Attach the orientation listener and take the current pose as zero, so the
+ * first reading a callback sees is `{ yaw: 0, pitch: 0, roll: 0 }`.
+ */
+ function enableGyro() {
+ gyroEnabled = true;
+ gyroBase = null;
+ if (!gyroAttached && typeof globalThis.addEventListener === 'function') {
+ globalThis.addEventListener('deviceorientation', handleOrientation);
+ gyroAttached = true;
+ }
+ emitGyro(0, 0, 0);
+ }
+
+ /**
+ * Degrees of turn from the pose gyro started at. `yaw` grows clockwise
+ * (turning the device to the right); `pitch` and `roll` follow the raw
+ * `beta` and `gamma` axes. Readings are throttled to roughly 30 Hz.
+ */
+ function handleOrientation(event) {
+ if (!gyroEnabled) return;
+
+ const alpha = typeof event?.alpha === 'number' ? event.alpha : null;
+ const beta = typeof event?.beta === 'number' ? event.beta : null;
+ const gamma = typeof event?.gamma === 'number' ? event.gamma : null;
+ if (alpha == null && beta == null && gamma == null) return;
+
+ if (!gyroBase) gyroBase = { alpha: alpha ?? 0, beta: beta ?? 0, gamma: gamma ?? 0 };
+
+ const now = Date.now();
+ if (now - lastGyroAt < GYRO_INTERVAL_MS) return;
+ lastGyroAt = now;
+
+ emitGyro(
+ alpha == null ? 0 : wrapDeg(gyroBase.alpha - alpha),
+ beta == null ? 0 : wrapDeg(beta - gyroBase.beta),
+ gamma == null ? 0 : wrapDeg(gamma - gyroBase.gamma),
+ );
+ }
+
+ /** Ask for orientation permission (iOS) and start listening. */
+ async function requestGyro() {
+ const OrientationEvent = globalThis.DeviceOrientationEvent;
+ if (!OrientationEvent || typeof globalThis.addEventListener !== 'function') return false;
+
+ if (typeof OrientationEvent.requestPermission === 'function') {
+ let granted = false;
+ try {
+ granted = (await OrientationEvent.requestPermission()) === 'granted';
+ } catch {
+ granted = false;
+ }
+ if (!granted) return false;
+ }
+
+ enableGyro();
+ return true;
+ }
+
+ /** Subscribe to `{ yaw, pitch, roll }`; returns an unsubscribe function. */
+ function onGyro(callback) {
+ gyroCallback = typeof callback === 'function' ? callback : null;
+ if (!gyroEnabled) enableGyro();
+ return () => {
+ if (gyroCallback === callback) gyroCallback = null;
+ };
+ }
+
+ function dispose() {
+ stopCamera();
+ if (gyroAttached) {
+ globalThis.removeEventListener?.('deviceorientation', handleOrientation);
+ gyroAttached = false;
+ }
+ gyroEnabled = false;
+ gyroCallback = null;
+ gyroBase = null;
+ el.remove();
+ }
+
+ return {
+ el,
+ apply,
+ loadPreset,
+ loadImageFile,
+ applyImage,
+ startCamera,
+ stopCamera,
+ /** The data URL of a drawn effect line, so exports can composite the same
+ * bitmap the preview shows. */
+ effectUrl,
+ get cameraActive() {
+ return stream !== null;
+ },
+ requestGyro,
+ onGyro,
+ snapshot() {},
+ dispose,
+ };
+}