aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/background.js
blob: adc7f945eaa1be4a77fb3de4e0e7ec3d9b09fd7a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
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,
  };
}