@docyrus/animaguru 0.19.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 (32) hide show
  1. package/README.md +36 -0
  2. package/dist/animaguru.js +101 -10
  3. package/package.json +1 -1
  4. package/skills/animaguru/SKILL.md +8 -0
  5. package/skills/animaguru/references/sharing.md +3 -0
  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 +9 -1
  11. package/templates/video/AGENTS.md +2 -0
  12. package/templates/video/README.md +8 -0
  13. package/templates/video/WORKSPACE-AGENTS.md +3 -1
  14. package/templates/video/scripts/build.mjs +4 -1
  15. package/templates/video/scripts/export-mp4.mjs +2 -0
  16. package/templates/video/scripts/html-export.mjs +12 -4
  17. package/templates/video/scripts/project-context.mjs +20 -3
  18. package/templates/video/scripts/quiz.d.mts +31 -0
  19. package/templates/video/scripts/quiz.mjs +126 -0
  20. package/templates/video/scripts/review.mjs +9 -1
  21. package/templates/video/scripts/story.d.mts +36 -0
  22. package/templates/video/scripts/story.mjs +139 -0
  23. package/templates/video/scripts/workspace-plugin.mjs +9 -4
  24. package/templates/video/shared/locales/en/player.json +29 -1
  25. package/templates/video/src/kit/index.ts +1 -0
  26. package/templates/video/src/kit/story.ts +19 -0
  27. package/templates/video/src/main.ts +6 -1
  28. package/templates/video/src/player-types.ts +6 -0
  29. package/templates/video/src/player.ts +193 -17
  30. package/templates/video/src/quiz.ts +315 -0
  31. package/templates/video/src/story.ts +112 -0
  32. package/templates/video/src/style.css +87 -0
@@ -0,0 +1,19 @@
1
+ import type { StoryGraph } from "../../scripts/story.mjs";
2
+
3
+ /**
4
+ * A branching story's segments for scene code, so scenes follow story.json when a segment is re-timed:
5
+ * `const beat = storyBeats(story); beat("forest").start`. choiceAt is when that segment's choices appear.
6
+ */
7
+ export function storyBeats(story: StoryGraph) {
8
+ const byId = new Map(story.segments.map((segment) => [segment.id, segment]));
9
+ return (id: string) => {
10
+ const segment = byId.get(id);
11
+ if (!segment) throw new Error(`No story segment "${id}". Known segments: ${[...byId.keys()].join(", ")}`);
12
+ return { ...segment, choiceAt: segment.choiceAt ?? segment.end, length: segment.end - segment.start };
13
+ };
14
+ }
15
+
16
+ /** The story's segments in timeline order as the player's chapters (label and transcript line). */
17
+ export function storyChapters(story: StoryGraph) {
18
+ return [...story.segments].sort((a, b) => a.start - b.start).map((segment) => ({ start: segment.start, end: segment.end, label: segment.label, caption: segment.label }));
19
+ }
@@ -1,5 +1,10 @@
1
1
  import config from "../film.config.json";
2
2
  import { Film, chapters } from "./film";
3
3
  import { mountPlayer } from "./player";
4
+ import type { StoryGraph } from "./story";
5
+ import type { Quiz } from "./quiz";
4
6
 
5
- void mountPlayer({ config, Film, chapters });
7
+ // story.json, when the film has one, makes it a branching story; quiz.json adds its questions.
8
+ const [story] = Object.values(import.meta.glob<StoryGraph>("../story.json", { eager: true, import: "default" }));
9
+ const [quiz] = Object.values(import.meta.glob<Quiz>("../quiz.json", { eager: true, import: "default" }));
10
+ void mountPlayer({ config, Film, chapters, story, quiz });
@@ -1,5 +1,7 @@
1
1
  import type { Application, Texture } from "pixi.js";
2
2
  import type { ResourceTrack } from "./timeline-model";
3
+ import type { StoryGraph } from "../scripts/story.mjs";
4
+ import type { Quiz } from "../scripts/quiz.mjs";
3
5
 
4
6
  export type FilmConfig = {
5
7
  title: string; slug: string; width: number; height: number; fps: number;
@@ -32,4 +34,8 @@ export type PlayerOptions = {
32
34
  branding?: Branding;
33
35
  exportEndpoint?: string;
34
36
  templateLabel?: string;
37
+ /** story.json: the film is a branching story whose viewers choose its path. */
38
+ story?: StoryGraph;
39
+ /** quiz.json: checkpoints pause the film to ask questions, and a final quiz and the score follow its end. */
40
+ quiz?: Quiz;
35
41
  };
@@ -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");
@@ -71,6 +91,34 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
71
91
  const initialMuted = autoplay || capture || exporting;
72
92
  if (audio) { audio.crossOrigin = "anonymous"; audio.src = publicAsset(String(config.audio)); audio.preload = "auto"; audio.muted = initialMuted; audio.addEventListener("error", audioFailure); }
73
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" };
74
122
  let playing = false;
75
123
  let muted = initialMuted;
76
124
  let last = performance.now();
@@ -78,32 +126,53 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
78
126
  let playbackRequest = 0;
79
127
  let resources: ResourceTimeline | undefined;
80
128
  const update = () => {
81
- 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;
82
136
  playButton.setAttribute("aria-label", playButton.textContent);
83
- element("time").textContent = `${format(time)} / ${format(config.duration)}`;
84
- timeline.value = String(time);
85
- 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)}`);
86
141
  soundButton.textContent = !audio ? copy.noAudio : muted ? copy.unmute : copy.mute;
87
142
  soundButton.disabled = !audio;
88
143
  soundButton.setAttribute("aria-pressed", String(Boolean(audio) && !muted));
89
144
  captionsButton.setAttribute("aria-pressed", String(film.captions));
90
145
  resources?.update({ time, playing, captions: film.captions, muted });
146
+ overlay?.render(storyView());
147
+ if (quiz) quizOverlay?.render(quiz.view());
91
148
  };
92
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;
93
152
  // Seeking and frame capture are serialized so decoded video frames cannot race.
94
- renderQueue = renderQueue.then(() => film.render(seconds));
153
+ renderQueue = renderQueue.then(() => film.render(at));
95
154
  return renderQueue;
96
155
  };
97
156
  function pause() { playbackRequest++; playing = false; audio?.pause(); update(); }
98
157
  async function seek(seconds: number) {
99
158
  if (!Number.isFinite(seconds)) throw new Error("Seek time must be finite.");
100
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(); }
101
165
  if (audio) audio.currentTime = time;
102
166
  await render(time); update();
103
167
  }
104
168
  async function play() {
105
- if (exporting) return;
106
- 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);
107
176
  const request = ++playbackRequest;
108
177
  if (audio) {
109
178
  audio.currentTime = time;
@@ -117,6 +186,82 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
117
186
  muted = value; audio.muted = muted; update();
118
187
  }
119
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
+ }
120
265
  function setCaptions(value: boolean) { film.captions = value; void render(time); update(); }
121
266
  const fullscreenButton = element<HTMLButtonElement>("fullscreen");
122
267
  function showFullscreen() {
@@ -141,8 +286,8 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
141
286
  let resumeAfterScrub = false;
142
287
  async function scrub(seconds: number, done = true) {
143
288
  resumeAfterScrub ||= playing;
144
- await seek(seconds);
145
- if (done && resumeAfterScrub) { resumeAfterScrub = false; await play(); }
289
+ await seek(segment ? Math.max(segment.start, Math.min(segment.end, seconds)) : seconds);
290
+ if (done && resumeAfterScrub) { resumeAfterScrub = false; if (!finished) await play(); }
146
291
  }
147
292
  async function saveFrame() {
148
293
  pause(); await render(time);
@@ -211,11 +356,21 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
211
356
  app.canvas.addEventListener("dblclick", () => { void fullscreen(); });
212
357
  }
213
358
  element("save").addEventListener("click", () => { closeDownloads(true); void saveFrame(); });
214
- timeline.addEventListener("input", () => { void scrub(Number(timeline.value), false); });
215
- timeline.addEventListener("change", () => { void scrub(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)); });
216
361
  document.addEventListener("visibilitychange", () => { if (document.hidden && !exporting) pause(); });
217
362
  document.addEventListener("keydown", (event) => {
218
- 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
+ }
219
374
  if (event.code === "Space") { event.preventDefault(); if (playing) pause(); else void play(); }
220
375
  if (event.code === "ArrowRight") { event.preventDefault(); void scrub(time + 5); }
221
376
  if (event.code === "ArrowLeft") { event.preventDefault(); void scrub(time - 5); }
@@ -227,11 +382,22 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
227
382
  async function tick(now: number) {
228
383
  try {
229
384
  if (playing) {
230
- time = Math.min(config.duration, audio && !audio.paused ? audio.currentTime : time + (now - last) / 1000);
231
- await render(time);
232
- 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
+ }
233
394
  update();
234
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
+ }
235
401
  last = now;
236
402
  requestAnimationFrame((next) => { void tick(next); });
237
403
  } catch (error) { pause(); status.textContent = copy.failed; console.error(error); }
@@ -241,8 +407,18 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
241
407
  element("loading")?.remove();
242
408
  const api = {
243
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,
244
- 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
+ },
245
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(); } } } : {}),
246
422
  get texts() { return film.texts; },
247
423
  get cues() { return film.cues; },
248
424
  seek, play, pause, setMuted, setCaptions, saveFrame,