aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/background.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/background.js')
-rw-r--r--public/bluebey-studio/src/background.js666
1 files changed, 0 insertions, 666 deletions
diff --git a/public/bluebey-studio/src/background.js b/public/bluebey-studio/src/background.js
deleted file mode 100644
index adc7f94..0000000
--- a/public/bluebey-studio/src/background.js
+++ /dev/null
@@ -1,666 +0,0 @@
-/**
- * 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,
- };
-}