diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
| commit | bc2821adb11a30244fc4663f4fafb756877d9508 (patch) | |
| tree | fea8d30d2eb1e67f895097dbf7ec4d152361243e /public/bluebey-studio/src/background.js | |
| parent | 0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff) | |
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'public/bluebey-studio/src/background.js')
| -rw-r--r-- | public/bluebey-studio/src/background.js | 666 |
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, - }; -} |
