@docyrus/animaguru 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +63 -1
  2. package/dist/animaguru.js +581 -268
  3. package/package.json +1 -1
  4. package/skills/animaguru/SKILL.md +16 -0
  5. package/skills/animaguru/references/sharing.md +4 -1
  6. package/skills/animaguru-branching-story/SKILL.md +72 -0
  7. package/skills/animaguru-general-video/SKILL.md +1 -0
  8. package/skills/animaguru-general-video/references/branching-story.md +13 -0
  9. package/skills/animaguru-quiz/SKILL.md +80 -0
  10. package/skills/animaguru-video/SKILL.md +16 -2
  11. package/skills/animaguru-video/references/short-form-ad.md +1 -0
  12. package/templates/video/AGENTS.md +2 -0
  13. package/templates/video/README.md +8 -0
  14. package/templates/video/WORKSPACE-AGENTS.md +3 -1
  15. package/templates/video/index.html +1 -1
  16. package/templates/video/scripts/build.mjs +4 -1
  17. package/templates/video/scripts/export-mp4.mjs +5 -0
  18. package/templates/video/scripts/html-export.mjs +12 -4
  19. package/templates/video/scripts/project-context.mjs +20 -3
  20. package/templates/video/scripts/quiz.d.mts +31 -0
  21. package/templates/video/scripts/quiz.mjs +126 -0
  22. package/templates/video/scripts/review.mjs +9 -1
  23. package/templates/video/scripts/story.d.mts +36 -0
  24. package/templates/video/scripts/story.mjs +139 -0
  25. package/templates/video/scripts/workspace-plugin.mjs +9 -4
  26. package/templates/video/shared/locales/en/player.json +30 -1
  27. package/templates/video/src/kit/index.ts +1 -0
  28. package/templates/video/src/kit/story.ts +19 -0
  29. package/templates/video/src/main.ts +6 -1
  30. package/templates/video/src/player-types.ts +6 -0
  31. package/templates/video/src/player.ts +232 -21
  32. package/templates/video/src/quiz.ts +315 -0
  33. package/templates/video/src/story.ts +112 -0
  34. package/templates/video/src/style.css +93 -1
@@ -5,9 +5,11 @@ import { publicAsset } from "./assets";
5
5
  import { downloadHtml, downloadUrl } from "./download";
6
6
  import { loadFonts } from "./kit/fonts";
7
7
  import type { ResourceTimeline } from "./resource-timeline";
8
+ import { StoryMap, StoryOverlay, type StorySegment, type StoryView } from "./story";
9
+ import { QuizOverlay, QuizRun, quizCopy } from "./quiz";
8
10
  import "./style.css";
9
11
 
10
- export async function mountPlayer({ config, Film, chapters, branding, exportEndpoint, templateLabel }: PlayerOptions) {
12
+ export async function mountPlayer({ config, Film, chapters, branding, exportEndpoint, templateLabel, story: storyGraph, quiz: quizData }: PlayerOptions) {
11
13
  const root = document.querySelector<HTMLDivElement>("#app")!;
12
14
  // All variable copy is inserted as text, including agent-authored film titles.
13
15
  root.innerHTML = `<header class="masthead"><a class="brand" href="https://animaguru.com" target="_blank" rel="noreferrer"><img src="${publicAsset("brand/logo-mark.svg")}" alt=""/><strong>AnimaGuru</strong><span data-copy="studio"></span></a><span class="template" data-copy="template"></span></header>
@@ -31,6 +33,24 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
31
33
  const capture = params.has("capture");
32
34
  const exporting = !embed && params.has("export");
33
35
  const autoplay = embed && params.get("autoplay") === "1" && !matchMedia("(prefers-reduced-motion: reduce)").matches;
36
+ // A branching story plays one segment at a time and asks the viewer at its choices. Capture and review see its whole
37
+ // timeline as one film; an MP4 has one path, so a story never exports one.
38
+ let story: StoryMap | null = null;
39
+ // A quiz pauses the film at its checkpoints and follows its end with a final round and the score. Capture and review
40
+ // see the film without it; its questions wait for a viewer's answers, so a quiz never exports an MP4. visited: the story segments played since the last restart, whose questions count.
41
+ let quiz: QuizRun | null = null;
42
+ const visited = new Set<string>();
43
+ if (storyGraph || quizData) {
44
+ document.querySelector<HTMLElement>('[data-copy="exportHint"]')!.textContent = storyGraph ? copy.storyHint : copy.quizHint;
45
+ try {
46
+ if (exporting) throw new Error(storyGraph ? copy.storyNoMp4 : copy.quizNoMp4);
47
+ if (storyGraph && !capture && !params.has("review")) story = new StoryMap(storyGraph, config.duration);
48
+ if (quizData && !capture && !params.has("review")) quiz = new QuizRun(quizData, config.duration, story ? { segments: story.graph.segments, visited } : undefined);
49
+ } catch (error) {
50
+ status.textContent = error instanceof Error ? error.message : copy.failed; element("loading")?.remove(); console.error(error);
51
+ return;
52
+ }
53
+ }
34
54
  const loop = embed && params.get("loop") === "1";
35
55
  if (capture) document.body.classList.add("capture");
36
56
  if (embed) document.body.classList.add("embed");
@@ -48,6 +68,11 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
48
68
  if (id !== "download-menu") element(id).title = label;
49
69
  }
50
70
  const format = (time: number) => `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
71
+ // Safari before 16.4 has only the prefixed Fullscreen API, and an iPhone has none for a page element: the player then
72
+ // fills the window instead.
73
+ type WebkitDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => void };
74
+ type WebkitElement = HTMLElement & { webkitRequestFullscreen?: () => void };
75
+ const fullscreenElement = () => document.fullscreenElement ?? (document as WebkitDocument).webkitFullscreenElement ?? null;
51
76
 
52
77
  async function start() {
53
78
  await document.fonts.ready;
@@ -55,7 +80,6 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
55
80
  await app.init({ width: config.width, height: config.height, resolution: 1, autoStart: false, antialias: true, background: "#F4F1E9", preference: "webgl", preserveDrawingBuffer: true });
56
81
  app.canvas.setAttribute("aria-label", config.title);
57
82
  element("canvas-host").append(app.canvas);
58
- element("loading").remove();
59
83
  // Variable brand fonts are registered with their weight range, so bold weights are real rather than synthesized.
60
84
  if (branding) await loadFonts(Object.values(branding.fonts).filter((font) => font.file).map((font) => ({ family: font.family.split(",")[0].trim().replace(/^["']|["']$/g, ""), url: publicAsset(font.file!) })));
61
85
  const logo = branding?.logo ? await Assets.load(publicAsset(branding.logo)) : null;
@@ -67,6 +91,34 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
67
91
  const initialMuted = autoplay || capture || exporting;
68
92
  if (audio) { audio.crossOrigin = "anonymous"; audio.src = publicAsset(String(config.audio)); audio.preload = "auto"; audio.muted = initialMuted; audio.addEventListener("error", audioFailure); }
69
93
  let time = Math.max(0, Math.min(Number(params.get("t")) || 0, config.duration));
94
+ // ?segment=ID starts a story at that segment; ?t= at that time of the timeline.
95
+ let segment: StorySegment | null = story ? story.graph.segments.find((item) => item.id === params.get("segment")) ?? (params.has("t") ? story.at(time) : story.first) : null;
96
+ if (segment && !params.has("t")) time = segment.start;
97
+ if (segment) visited.add(segment.id);
98
+ /** The choice taken while its segment still plays; the story follows it when the segment ends. */
99
+ let picked: number | null = null;
100
+ /** Seconds a timed choice has been on screen. */
101
+ let waited = 0;
102
+ /** The story is at an ending. */
103
+ let finished = false;
104
+ /** The segments the viewer chose at, for going back. */
105
+ const path: string[] = [];
106
+ /** A story's final quiz and score have shown at an ending in this playthrough. */
107
+ let quizEnded = false;
108
+ const overlay = story ? new StoryOverlay(element("canvas-host"), story.graph, {
109
+ theEnd: story.graph.ui?.theEnd ?? copy.storyEnd, playAgain: story.graph.ui?.playAgain ?? copy.storyPlayAgain,
110
+ back: story.graph.ui?.back ?? copy.storyBack, choose: story.graph.ui?.choose ?? copy.storyChoose,
111
+ }, branding, { choose, restart: () => { void restart(); }, back: () => { void back(); } }) : null;
112
+ const quizOverlay = quiz ? new QuizOverlay(element("canvas-host"), quiz.quiz, quizCopy(copy, quiz.quiz.ui), branding, {
113
+ pick, check: checkAnswer, next: () => { void nextQuestion(); }, watchAgain: () => { void watchAgain(); }, retake: retakeQuiz, finish: () => { void finishQuiz(); },
114
+ }) : null;
115
+ const span = () => segment ? { start: segment.start, end: segment.end } : { start: 0, end: config.duration };
116
+ const atEnd = () => time >= span().end - 0.0005;
117
+ const choosing = () => Boolean(story && segment?.choices && time >= story.choiceAt(segment) - 0.0005);
118
+ // A quiz card covers the story's panels until it closes.
119
+ const storyView = (): StoryView => !segment || quiz?.panel ? { kind: "none" }
120
+ : finished ? { kind: "ending", segment, canGoBack: path.length > 0 }
121
+ : choosing() ? { kind: "choices", segment, picked, remaining: segment.timeout ? 1 - waited / segment.timeout : null } : { kind: "none" };
70
122
  let playing = false;
71
123
  let muted = initialMuted;
72
124
  let last = performance.now();
@@ -74,32 +126,53 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
74
126
  let playbackRequest = 0;
75
127
  let resources: ResourceTimeline | undefined;
76
128
  const update = () => {
77
- playButton.textContent = playing ? copy.pause : time >= config.duration ? copy.replay : copy.play;
129
+ // A story's transport covers the segment playing, not the whole timeline of every path.
130
+ const { start: from, end: to } = span();
131
+ playButton.textContent = playing ? copy.pause : (segment ? finished : time >= config.duration) ? copy.replay : copy.play;
132
+ // A question showing holds the film: it continues once the viewer answers.
133
+ const asking = Boolean(quiz?.asking);
134
+ playButton.disabled = asking || Boolean(segment?.choices && !playing && atEnd() && picked === null);
135
+ timeline.disabled = asking;
78
136
  playButton.setAttribute("aria-label", playButton.textContent);
79
- element("time").textContent = `${format(time)} / ${format(config.duration)}`;
80
- timeline.value = String(time);
81
- timeline.setAttribute("aria-valuetext", `${format(time)} / ${format(config.duration)}`);
137
+ element("time").textContent = `${format(time - from)} / ${format(to - from)}`;
138
+ timeline.max = String(to - from);
139
+ timeline.value = String(time - from);
140
+ timeline.setAttribute("aria-valuetext", `${format(time - from)} / ${format(to - from)}`);
82
141
  soundButton.textContent = !audio ? copy.noAudio : muted ? copy.unmute : copy.mute;
83
142
  soundButton.disabled = !audio;
84
143
  soundButton.setAttribute("aria-pressed", String(Boolean(audio) && !muted));
85
144
  captionsButton.setAttribute("aria-pressed", String(film.captions));
86
145
  resources?.update({ time, playing, captions: film.captions, muted });
146
+ overlay?.render(storyView());
147
+ if (quiz) quizOverlay?.render(quiz.view());
87
148
  };
88
149
  const render = (seconds: number) => {
150
+ // A story holding at a segment's end shows that segment's last frame, not the next segment's first.
151
+ const at = segment ? Math.min(seconds, segment.end - 0.001) : seconds;
89
152
  // Seeking and frame capture are serialized so decoded video frames cannot race.
90
- renderQueue = renderQueue.then(() => film.render(seconds));
153
+ renderQueue = renderQueue.then(() => film.render(at));
91
154
  return renderQueue;
92
155
  };
93
156
  function pause() { playbackRequest++; playing = false; audio?.pause(); update(); }
94
157
  async function seek(seconds: number) {
95
158
  if (!Number.isFinite(seconds)) throw new Error("Seek time must be finite.");
96
159
  time = Math.max(0, Math.min(seconds, config.duration)); pause();
160
+ // A seek closes a quiz card unanswered; its checkpoint asks again when the film next reaches it.
161
+ quiz?.close();
162
+ // A seek stays in the story's segment while it can, up to its end; another time of the timeline enters its segment.
163
+ if (story && segment && !(time >= segment.start && time <= segment.end)) { segment = story.at(time); visited.add(segment.id); }
164
+ if (segment) { picked = null; waited = 0; finished = segment.ending !== undefined && atEnd(); }
97
165
  if (audio) audio.currentTime = time;
98
166
  await render(time); update();
99
167
  }
100
168
  async function play() {
101
- if (exporting) return;
102
- if (time >= config.duration) await seek(0);
169
+ if (exporting || quiz?.asking) return;
170
+ // Play over the score replays the film, or the story from its start.
171
+ quiz?.close();
172
+ if (segment) {
173
+ if (finished) { await restart(); return; }
174
+ if (segment.choices && picked === null && atEnd()) return;
175
+ } else if (time >= config.duration) await seek(0);
103
176
  const request = ++playbackRequest;
104
177
  if (audio) {
105
178
  audio.currentTime = time;
@@ -113,10 +186,108 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
113
186
  muted = value; audio.muted = muted; update();
114
187
  }
115
188
  function toggleSound() { setMuted(!muted); }
189
+ /** Plays a story segment from a time (its start by default); a choice shown at the segment's very end waits there. */
190
+ async function enter(target: StorySegment, at = target.start) {
191
+ segment = target; time = at; picked = null; waited = 0; finished = false; visited.add(target.id);
192
+ if (audio) audio.currentTime = time;
193
+ await render(time);
194
+ if (atEnd()) { pause(); return; }
195
+ if (playing) { last = performance.now(); update(); } else await play();
196
+ }
197
+ /** What a segment does at its end: continue, go where the viewer chose, wait for a choice, or end the story. */
198
+ async function arrive() {
199
+ if (!story || !segment) return;
200
+ if (segment.next) await enter(story.get(segment.next));
201
+ else if (segment.choices) {
202
+ if (picked !== null) await take(picked);
203
+ else { playbackRequest++; playing = false; audio?.pause(); }
204
+ } else {
205
+ finished = true; pause();
206
+ // A quiz's final round and score come before the story's ending card, once a playthrough.
207
+ if (quiz && !quizEnded) { quizEnded = true; if (quizEnd()) update(); }
208
+ }
209
+ }
210
+ function choose(index: number) {
211
+ if (!segment?.choices?.[index] || picked !== null || !choosing() || quiz?.panel) return;
212
+ picked = index;
213
+ // A choice made while the segment plays waits for its end, so the segment finishes as it was cut.
214
+ if (!playing || atEnd()) void take(index); else update();
215
+ }
216
+ async function take(index: number) {
217
+ path.push(segment!.id);
218
+ await enter(story!.get(segment!.choices![index].to));
219
+ }
220
+ async function restart() { path.length = 0; quiz?.reset(); visited.clear(); quizEnded = false; await enter(story!.first); }
221
+ /** Back to the last choice the viewer made, with its choices showing. */
222
+ async function back() {
223
+ const id = path.pop();
224
+ if (!id) { await restart(); return; }
225
+ const decision = story!.get(id);
226
+ await enter(decision, story!.choiceAt(decision));
227
+ }
228
+ /** A quiz event for a page hosting the film: each answer, and the score when the quiz is complete. */
229
+ const emit = (type: "answer" | "complete", detail: object | null) => window.dispatchEvent(new CustomEvent("animaguru:quiz", { detail: { type, ...detail } }));
230
+ function pick(index: number) {
231
+ if (!quiz?.pick(index)) return;
232
+ if (quiz.answered) emit("answer", quiz.lastAnswer);
233
+ update();
234
+ }
235
+ function checkAnswer() { if (quiz?.check()) { emit("answer", quiz.lastAnswer); update(); } }
236
+ /** The final quiz, or the score, at the film's end or a story's ending; false when the viewer met no question. */
237
+ function quizEnd() {
238
+ if (!quiz?.openEnd()) return false;
239
+ if (quiz.panel?.kind === "results") emit("complete", quiz.score());
240
+ return true;
241
+ }
242
+ /** After an answer: the next question, the score, or back to the film where the checkpoint paused it. */
243
+ async function nextQuestion() {
244
+ const step = quiz?.advance();
245
+ if (step === "results") emit("complete", quiz!.score());
246
+ update();
247
+ if (step !== "closed") return;
248
+ if (segment) { if (atEnd()) await arrive(); else await play(); }
249
+ else if (time >= config.duration - 0.0005) { if (quizEnd()) update(); }
250
+ else await play();
251
+ }
252
+ /** Back to the part a checkpoint covers, which asks again when the film reaches it. */
253
+ async function watchAgain() {
254
+ const panel = quiz?.panel;
255
+ if (panel?.kind !== "checkpoint") return;
256
+ quiz!.arm(panel.checkpoint);
257
+ await seek(quiz!.reviewTime(panel.checkpoint)); await play();
258
+ }
259
+ function retakeQuiz() { quiz?.retake(); update(); }
260
+ /** The score's last button: a story's ending card, or the film again from the start with every question fresh. */
261
+ async function finishQuiz() {
262
+ if (segment) { quiz?.close(); update(); return; }
263
+ quiz?.reset(); await seek(0); await play();
264
+ }
116
265
  function setCaptions(value: boolean) { film.captions = value; void render(time); update(); }
266
+ const fullscreenButton = element<HTMLButtonElement>("fullscreen");
267
+ function showFullscreen() {
268
+ const on = Boolean(fullscreenElement()) || theater.classList.contains("expanded");
269
+ fullscreenButton.setAttribute("aria-pressed", String(on));
270
+ fullscreenButton.setAttribute("aria-label", on ? copy.exitFullscreen : copy.fullscreen); fullscreenButton.title = fullscreenButton.getAttribute("aria-label")!;
271
+ }
272
+ function expand(on: boolean) { theater.classList.toggle("expanded", on); document.body.classList.toggle("theater-expanded", on); showFullscreen(); }
117
273
  async function fullscreen() {
118
- try { if (document.fullscreenElement) await document.exitFullscreen(); else await theater.requestFullscreen(); }
119
- catch { status.textContent = copy.fullscreenFailed; }
274
+ if (theater.classList.contains("expanded")) { expand(false); return; }
275
+ const page = document as WebkitDocument; const target = theater as WebkitElement;
276
+ try {
277
+ if (fullscreenElement()) { if (document.exitFullscreen) await document.exitFullscreen(); else page.webkitExitFullscreen?.(); }
278
+ else if (target.requestFullscreen) await target.requestFullscreen();
279
+ else if (target.webkitRequestFullscreen) target.webkitRequestFullscreen();
280
+ else expand(true);
281
+ } catch { expand(true); }
282
+ }
283
+ document.addEventListener("fullscreenchange", showFullscreen);
284
+ document.addEventListener("webkitfullscreenchange", showFullscreen);
285
+ /** A seek from the bar or the keys keeps a playing film playing; the API's seek pauses, for capture. */
286
+ let resumeAfterScrub = false;
287
+ async function scrub(seconds: number, done = true) {
288
+ resumeAfterScrub ||= playing;
289
+ await seek(segment ? Math.max(segment.start, Math.min(segment.end, seconds)) : seconds);
290
+ if (done && resumeAfterScrub) { resumeAfterScrub = false; if (!finished) await play(); }
120
291
  }
121
292
  async function saveFrame() {
122
293
  pause(); await render(time);
@@ -178,15 +349,32 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
178
349
  playButton.addEventListener("click", () => { if (playing) pause(); else void play(); });
179
350
  soundButton.addEventListener("click", toggleSound);
180
351
  captionsButton.addEventListener("click", () => setCaptions(!film.captions));
181
- element("fullscreen").addEventListener("click", () => { void fullscreen(); });
352
+ fullscreenButton.addEventListener("click", () => { void fullscreen(); });
353
+ // The picture plays or pauses on a click and goes full screen on a double click, as a video does.
354
+ if (!capture && !exporting) {
355
+ app.canvas.addEventListener("click", () => { if (playing) pause(); else void play(); });
356
+ app.canvas.addEventListener("dblclick", () => { void fullscreen(); });
357
+ }
182
358
  element("save").addEventListener("click", () => { closeDownloads(true); void saveFrame(); });
183
- timeline.addEventListener("input", () => { void seek(Number(timeline.value)); });
359
+ timeline.addEventListener("input", () => { void scrub(span().start + Number(timeline.value), false); });
360
+ timeline.addEventListener("change", () => { void scrub(span().start + Number(timeline.value)); });
184
361
  document.addEventListener("visibilitychange", () => { if (document.hidden && !exporting) pause(); });
185
362
  document.addEventListener("keydown", (event) => {
186
- if (exporting || event.target instanceof HTMLElement && event.target.closest("button,input,a,summary,textarea,select,[contenteditable]")) return;
363
+ const target = event.target instanceof HTMLElement ? event.target : null;
364
+ if (exporting || target?.closest("input,textarea,select,[contenteditable]")) return;
365
+ // Number keys answer a question or pick a story's choice, also with focus on one of their buttons.
366
+ if (quiz?.asking && /^[1-6]$/.test(event.key)) { event.preventDefault(); pick(Number(event.key) - 1); return; }
367
+ if (segment && !quiz?.panel && /^[1-4]$/.test(event.key)) { choose(Number(event.key) - 1); return; }
368
+ if (target?.closest("button,a,summary")) return;
369
+ if (quiz?.asking) {
370
+ if (event.key === "Enter") { event.preventDefault(); if (quiz.answered) void nextQuestion(); else checkAnswer(); }
371
+ // The film waits for the answer: the play and seek keys do nothing.
372
+ if (["Space", "ArrowRight", "ArrowLeft"].includes(event.code)) { event.preventDefault(); return; }
373
+ }
187
374
  if (event.code === "Space") { event.preventDefault(); if (playing) pause(); else void play(); }
188
- if (event.code === "ArrowRight") { event.preventDefault(); void seek(time + 5); }
189
- if (event.code === "ArrowLeft") { event.preventDefault(); void seek(time - 5); }
375
+ if (event.code === "ArrowRight") { event.preventDefault(); void scrub(time + 5); }
376
+ if (event.code === "ArrowLeft") { event.preventDefault(); void scrub(time - 5); }
377
+ if (event.key === "Escape" && theater.classList.contains("expanded")) expand(false);
190
378
  if (event.key.toLowerCase() === "m") toggleSound();
191
379
  if (event.key.toLowerCase() === "c") setCaptions(!film.captions);
192
380
  if (event.key.toLowerCase() === "f") void fullscreen();
@@ -194,20 +382,43 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
194
382
  async function tick(now: number) {
195
383
  try {
196
384
  if (playing) {
197
- time = Math.min(config.duration, audio && !audio.paused ? audio.currentTime : time + (now - last) / 1000);
198
- await render(time);
199
- if (time >= config.duration) { if (loop) { await seek(0); await play(); } else pause(); }
385
+ const end = span().end, from = time;
386
+ time = Math.min(end, audio && !audio.paused ? audio.currentTime : time + (now - last) / 1000);
387
+ // A checkpoint the film passes stops it on its own frame and asks.
388
+ const due = quiz?.due(from, time, segment ? span() : undefined);
389
+ if (due) { time = due.at; pause(); quiz!.open(due); await render(time); }
390
+ else {
391
+ await render(time);
392
+ if (time >= end) { if (segment) await arrive(); else if (quizEnd()) pause(); else if (loop) { await seek(0); await play(); } else pause(); }
393
+ }
200
394
  update();
201
395
  }
396
+ // A timed choice counts down while the film plays on and while it holds on the segment's last frame.
397
+ if (segment?.timeout !== undefined && picked === null && choosing() && (playing || atEnd()) && !quiz?.panel && !document.hidden) {
398
+ waited += (now - last) / 1000;
399
+ if (waited >= segment.timeout) choose(segment.default ?? 0); else if (!playing) update();
400
+ }
202
401
  last = now;
203
402
  requestAnimationFrame((next) => { void tick(next); });
204
403
  } catch (error) { pause(); status.textContent = copy.failed; console.error(error); }
205
404
  }
206
- await render(time); update();
405
+ await render(time); update(); showFullscreen();
406
+ // The first frame is drawn: the loading line gives way to it.
407
+ element("loading")?.remove();
207
408
  const api = {
208
409
  ready: true, width: config.width, height: config.height, duration: config.duration, fps: config.fps, language: config.language, languages: [{ code: config.language, name: config.language }], canvas: app.canvas,
209
- get state() { return { time, playing, muted, captions: film.captions, audio: { configured: Boolean(audio), readyState: audio?.readyState ?? 0, error: audio?.error?.code ?? null } }; },
410
+ get state() {
411
+ return { time, playing, muted, captions: film.captions, audio: { configured: Boolean(audio), readyState: audio?.readyState ?? 0, error: audio?.error?.code ?? null },
412
+ // waiting: the segment has ended and only a choice continues the story, so play does nothing.
413
+ ...(segment ? { story: { segment: segment.id, start: segment.start, end: segment.end, choosing: choosing() && picked === null, waiting: Boolean(segment.choices && !playing && atEnd() && picked === null), ended: finished } } : {}),
414
+ // open: a quiz card shows; asking: it asks a question, so play and seeking wait for the answer.
415
+ ...(quiz ? { quiz: { open: Boolean(quiz.panel), asking: quiz.asking, card: quiz.panel?.kind ?? null, score: quiz.score() } } : {}) };
416
+ },
210
417
  resources: film.resources,
418
+ // A branching story: its graph, the path taken, and the viewer's actions.
419
+ ...(story ? { story: { graph: story.graph, get path() { return [...path]; }, choose, back, restart, async goTo(id: string) { path.length = 0; await enter(story!.get(id)); } } } : {}),
420
+ // A quiz: its questions, the viewer's actions on the card, and the score so far.
421
+ ...(quiz ? { quiz: { data: quiz.quiz, pick, check: checkAnswer, next: nextQuestion, retake: retakeQuiz, reset() { quiz!.reset(); update(); }, get score() { return quiz!.score(); } } } : {}),
211
422
  get texts() { return film.texts; },
212
423
  get cues() { return film.cues; },
213
424
  seek, play, pause, setMuted, setCaptions, saveFrame,