@docyrus/animaguru 0.19.0 → 0.22.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 +61 -0
  2. package/dist/animaguru.js +366 -261
  3. package/package.json +1 -1
  4. package/skills/animaguru/SKILL.md +9 -1
  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 +213 -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 +106 -3
@@ -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");
@@ -53,6 +73,26 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
53
73
  type WebkitDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => void };
54
74
  type WebkitElement = HTMLElement & { webkitRequestFullscreen?: () => void };
55
75
  const fullscreenElement = () => document.fullscreenElement ?? (document as WebkitDocument).webkitFullscreenElement ?? null;
76
+ // The page's player fits the window (style.css). This measures the page above it and the controls under its picture,
77
+ // which change as the window resizes and the transport wraps. Full screen keeps the last measures for its way back; an
78
+ // embed or a capture fills its frame instead.
79
+ if (!embed && !capture) {
80
+ const main = theater.parentElement!, picture = element("canvas-host");
81
+ let frame = 0;
82
+ const measure = () => {
83
+ frame = 0;
84
+ if (fullscreenElement() || theater.classList.contains("expanded")) return;
85
+ const box = theater.getBoundingClientRect();
86
+ main.style.setProperty("--player-above", `${Math.round(box.top + scrollY)}px`);
87
+ main.style.setProperty("--player-controls", `${Math.ceil(box.height - picture.getBoundingClientRect().height)}px`);
88
+ };
89
+ main.style.setProperty("--film-aspect", `${config.width} / ${config.height}`);
90
+ main.classList.add("fit-window");
91
+ measure();
92
+ // A measure waits for the next frame, so the resize it causes is not reported as a loop.
93
+ const observer = new ResizeObserver(() => { frame ||= requestAnimationFrame(measure); });
94
+ for (const target of [theater, document.querySelector(".masthead")!]) observer.observe(target);
95
+ }
56
96
 
57
97
  async function start() {
58
98
  await document.fonts.ready;
@@ -71,6 +111,34 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
71
111
  const initialMuted = autoplay || capture || exporting;
72
112
  if (audio) { audio.crossOrigin = "anonymous"; audio.src = publicAsset(String(config.audio)); audio.preload = "auto"; audio.muted = initialMuted; audio.addEventListener("error", audioFailure); }
73
113
  let time = Math.max(0, Math.min(Number(params.get("t")) || 0, config.duration));
114
+ // ?segment=ID starts a story at that segment; ?t= at that time of the timeline.
115
+ let segment: StorySegment | null = story ? story.graph.segments.find((item) => item.id === params.get("segment")) ?? (params.has("t") ? story.at(time) : story.first) : null;
116
+ if (segment && !params.has("t")) time = segment.start;
117
+ if (segment) visited.add(segment.id);
118
+ /** The choice taken while its segment still plays; the story follows it when the segment ends. */
119
+ let picked: number | null = null;
120
+ /** Seconds a timed choice has been on screen. */
121
+ let waited = 0;
122
+ /** The story is at an ending. */
123
+ let finished = false;
124
+ /** The segments the viewer chose at, for going back. */
125
+ const path: string[] = [];
126
+ /** A story's final quiz and score have shown at an ending in this playthrough. */
127
+ let quizEnded = false;
128
+ const overlay = story ? new StoryOverlay(element("canvas-host"), story.graph, {
129
+ theEnd: story.graph.ui?.theEnd ?? copy.storyEnd, playAgain: story.graph.ui?.playAgain ?? copy.storyPlayAgain,
130
+ back: story.graph.ui?.back ?? copy.storyBack, choose: story.graph.ui?.choose ?? copy.storyChoose,
131
+ }, branding, { choose, restart: () => { void restart(); }, back: () => { void back(); } }) : null;
132
+ const quizOverlay = quiz ? new QuizOverlay(element("canvas-host"), quiz.quiz, quizCopy(copy, quiz.quiz.ui), branding, {
133
+ pick, check: checkAnswer, next: () => { void nextQuestion(); }, watchAgain: () => { void watchAgain(); }, retake: retakeQuiz, finish: () => { void finishQuiz(); },
134
+ }) : null;
135
+ const span = () => segment ? { start: segment.start, end: segment.end } : { start: 0, end: config.duration };
136
+ const atEnd = () => time >= span().end - 0.0005;
137
+ const choosing = () => Boolean(story && segment?.choices && time >= story.choiceAt(segment) - 0.0005);
138
+ // A quiz card covers the story's panels until it closes.
139
+ const storyView = (): StoryView => !segment || quiz?.panel ? { kind: "none" }
140
+ : finished ? { kind: "ending", segment, canGoBack: path.length > 0 }
141
+ : choosing() ? { kind: "choices", segment, picked, remaining: segment.timeout ? 1 - waited / segment.timeout : null } : { kind: "none" };
74
142
  let playing = false;
75
143
  let muted = initialMuted;
76
144
  let last = performance.now();
@@ -78,32 +146,53 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
78
146
  let playbackRequest = 0;
79
147
  let resources: ResourceTimeline | undefined;
80
148
  const update = () => {
81
- playButton.textContent = playing ? copy.pause : time >= config.duration ? copy.replay : copy.play;
149
+ // A story's transport covers the segment playing, not the whole timeline of every path.
150
+ const { start: from, end: to } = span();
151
+ playButton.textContent = playing ? copy.pause : (segment ? finished : time >= config.duration) ? copy.replay : copy.play;
152
+ // A question showing holds the film: it continues once the viewer answers.
153
+ const asking = Boolean(quiz?.asking);
154
+ playButton.disabled = asking || Boolean(segment?.choices && !playing && atEnd() && picked === null);
155
+ timeline.disabled = asking;
82
156
  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)}`);
157
+ element("time").textContent = `${format(time - from)} / ${format(to - from)}`;
158
+ timeline.max = String(to - from);
159
+ timeline.value = String(time - from);
160
+ timeline.setAttribute("aria-valuetext", `${format(time - from)} / ${format(to - from)}`);
86
161
  soundButton.textContent = !audio ? copy.noAudio : muted ? copy.unmute : copy.mute;
87
162
  soundButton.disabled = !audio;
88
163
  soundButton.setAttribute("aria-pressed", String(Boolean(audio) && !muted));
89
164
  captionsButton.setAttribute("aria-pressed", String(film.captions));
90
165
  resources?.update({ time, playing, captions: film.captions, muted });
166
+ overlay?.render(storyView());
167
+ if (quiz) quizOverlay?.render(quiz.view());
91
168
  };
92
169
  const render = (seconds: number) => {
170
+ // A story holding at a segment's end shows that segment's last frame, not the next segment's first.
171
+ const at = segment ? Math.min(seconds, segment.end - 0.001) : seconds;
93
172
  // Seeking and frame capture are serialized so decoded video frames cannot race.
94
- renderQueue = renderQueue.then(() => film.render(seconds));
173
+ renderQueue = renderQueue.then(() => film.render(at));
95
174
  return renderQueue;
96
175
  };
97
176
  function pause() { playbackRequest++; playing = false; audio?.pause(); update(); }
98
177
  async function seek(seconds: number) {
99
178
  if (!Number.isFinite(seconds)) throw new Error("Seek time must be finite.");
100
179
  time = Math.max(0, Math.min(seconds, config.duration)); pause();
180
+ // A seek closes a quiz card unanswered; its checkpoint asks again when the film next reaches it.
181
+ quiz?.close();
182
+ // A seek stays in the story's segment while it can, up to its end; another time of the timeline enters its segment.
183
+ if (story && segment && !(time >= segment.start && time <= segment.end)) { segment = story.at(time); visited.add(segment.id); }
184
+ if (segment) { picked = null; waited = 0; finished = segment.ending !== undefined && atEnd(); }
101
185
  if (audio) audio.currentTime = time;
102
186
  await render(time); update();
103
187
  }
104
188
  async function play() {
105
- if (exporting) return;
106
- if (time >= config.duration) await seek(0);
189
+ if (exporting || quiz?.asking) return;
190
+ // Play over the score replays the film, or the story from its start.
191
+ quiz?.close();
192
+ if (segment) {
193
+ if (finished) { await restart(); return; }
194
+ if (segment.choices && picked === null && atEnd()) return;
195
+ } else if (time >= config.duration) await seek(0);
107
196
  const request = ++playbackRequest;
108
197
  if (audio) {
109
198
  audio.currentTime = time;
@@ -117,6 +206,82 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
117
206
  muted = value; audio.muted = muted; update();
118
207
  }
119
208
  function toggleSound() { setMuted(!muted); }
209
+ /** Plays a story segment from a time (its start by default); a choice shown at the segment's very end waits there. */
210
+ async function enter(target: StorySegment, at = target.start) {
211
+ segment = target; time = at; picked = null; waited = 0; finished = false; visited.add(target.id);
212
+ if (audio) audio.currentTime = time;
213
+ await render(time);
214
+ if (atEnd()) { pause(); return; }
215
+ if (playing) { last = performance.now(); update(); } else await play();
216
+ }
217
+ /** What a segment does at its end: continue, go where the viewer chose, wait for a choice, or end the story. */
218
+ async function arrive() {
219
+ if (!story || !segment) return;
220
+ if (segment.next) await enter(story.get(segment.next));
221
+ else if (segment.choices) {
222
+ if (picked !== null) await take(picked);
223
+ else { playbackRequest++; playing = false; audio?.pause(); }
224
+ } else {
225
+ finished = true; pause();
226
+ // A quiz's final round and score come before the story's ending card, once a playthrough.
227
+ if (quiz && !quizEnded) { quizEnded = true; if (quizEnd()) update(); }
228
+ }
229
+ }
230
+ function choose(index: number) {
231
+ if (!segment?.choices?.[index] || picked !== null || !choosing() || quiz?.panel) return;
232
+ picked = index;
233
+ // A choice made while the segment plays waits for its end, so the segment finishes as it was cut.
234
+ if (!playing || atEnd()) void take(index); else update();
235
+ }
236
+ async function take(index: number) {
237
+ path.push(segment!.id);
238
+ await enter(story!.get(segment!.choices![index].to));
239
+ }
240
+ async function restart() { path.length = 0; quiz?.reset(); visited.clear(); quizEnded = false; await enter(story!.first); }
241
+ /** Back to the last choice the viewer made, with its choices showing. */
242
+ async function back() {
243
+ const id = path.pop();
244
+ if (!id) { await restart(); return; }
245
+ const decision = story!.get(id);
246
+ await enter(decision, story!.choiceAt(decision));
247
+ }
248
+ /** A quiz event for a page hosting the film: each answer, and the score when the quiz is complete. */
249
+ const emit = (type: "answer" | "complete", detail: object | null) => window.dispatchEvent(new CustomEvent("animaguru:quiz", { detail: { type, ...detail } }));
250
+ function pick(index: number) {
251
+ if (!quiz?.pick(index)) return;
252
+ if (quiz.answered) emit("answer", quiz.lastAnswer);
253
+ update();
254
+ }
255
+ function checkAnswer() { if (quiz?.check()) { emit("answer", quiz.lastAnswer); update(); } }
256
+ /** The final quiz, or the score, at the film's end or a story's ending; false when the viewer met no question. */
257
+ function quizEnd() {
258
+ if (!quiz?.openEnd()) return false;
259
+ if (quiz.panel?.kind === "results") emit("complete", quiz.score());
260
+ return true;
261
+ }
262
+ /** After an answer: the next question, the score, or back to the film where the checkpoint paused it. */
263
+ async function nextQuestion() {
264
+ const step = quiz?.advance();
265
+ if (step === "results") emit("complete", quiz!.score());
266
+ update();
267
+ if (step !== "closed") return;
268
+ if (segment) { if (atEnd()) await arrive(); else await play(); }
269
+ else if (time >= config.duration - 0.0005) { if (quizEnd()) update(); }
270
+ else await play();
271
+ }
272
+ /** Back to the part a checkpoint covers, which asks again when the film reaches it. */
273
+ async function watchAgain() {
274
+ const panel = quiz?.panel;
275
+ if (panel?.kind !== "checkpoint") return;
276
+ quiz!.arm(panel.checkpoint);
277
+ await seek(quiz!.reviewTime(panel.checkpoint)); await play();
278
+ }
279
+ function retakeQuiz() { quiz?.retake(); update(); }
280
+ /** The score's last button: a story's ending card, or the film again from the start with every question fresh. */
281
+ async function finishQuiz() {
282
+ if (segment) { quiz?.close(); update(); return; }
283
+ quiz?.reset(); await seek(0); await play();
284
+ }
120
285
  function setCaptions(value: boolean) { film.captions = value; void render(time); update(); }
121
286
  const fullscreenButton = element<HTMLButtonElement>("fullscreen");
122
287
  function showFullscreen() {
@@ -141,8 +306,8 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
141
306
  let resumeAfterScrub = false;
142
307
  async function scrub(seconds: number, done = true) {
143
308
  resumeAfterScrub ||= playing;
144
- await seek(seconds);
145
- if (done && resumeAfterScrub) { resumeAfterScrub = false; await play(); }
309
+ await seek(segment ? Math.max(segment.start, Math.min(segment.end, seconds)) : seconds);
310
+ if (done && resumeAfterScrub) { resumeAfterScrub = false; if (!finished) await play(); }
146
311
  }
147
312
  async function saveFrame() {
148
313
  pause(); await render(time);
@@ -211,11 +376,21 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
211
376
  app.canvas.addEventListener("dblclick", () => { void fullscreen(); });
212
377
  }
213
378
  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)); });
379
+ timeline.addEventListener("input", () => { void scrub(span().start + Number(timeline.value), false); });
380
+ timeline.addEventListener("change", () => { void scrub(span().start + Number(timeline.value)); });
216
381
  document.addEventListener("visibilitychange", () => { if (document.hidden && !exporting) pause(); });
217
382
  document.addEventListener("keydown", (event) => {
218
- if (exporting || event.target instanceof HTMLElement && event.target.closest("button,input,a,summary,textarea,select,[contenteditable]")) return;
383
+ const target = event.target instanceof HTMLElement ? event.target : null;
384
+ if (exporting || target?.closest("input,textarea,select,[contenteditable]")) return;
385
+ // Number keys answer a question or pick a story's choice, also with focus on one of their buttons.
386
+ if (quiz?.asking && /^[1-6]$/.test(event.key)) { event.preventDefault(); pick(Number(event.key) - 1); return; }
387
+ if (segment && !quiz?.panel && /^[1-4]$/.test(event.key)) { choose(Number(event.key) - 1); return; }
388
+ if (target?.closest("button,a,summary")) return;
389
+ if (quiz?.asking) {
390
+ if (event.key === "Enter") { event.preventDefault(); if (quiz.answered) void nextQuestion(); else checkAnswer(); }
391
+ // The film waits for the answer: the play and seek keys do nothing.
392
+ if (["Space", "ArrowRight", "ArrowLeft"].includes(event.code)) { event.preventDefault(); return; }
393
+ }
219
394
  if (event.code === "Space") { event.preventDefault(); if (playing) pause(); else void play(); }
220
395
  if (event.code === "ArrowRight") { event.preventDefault(); void scrub(time + 5); }
221
396
  if (event.code === "ArrowLeft") { event.preventDefault(); void scrub(time - 5); }
@@ -227,11 +402,22 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
227
402
  async function tick(now: number) {
228
403
  try {
229
404
  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(); }
405
+ const end = span().end, from = time;
406
+ time = Math.min(end, audio && !audio.paused ? audio.currentTime : time + (now - last) / 1000);
407
+ // A checkpoint the film passes stops it on its own frame and asks.
408
+ const due = quiz?.due(from, time, segment ? span() : undefined);
409
+ if (due) { time = due.at; pause(); quiz!.open(due); await render(time); }
410
+ else {
411
+ await render(time);
412
+ if (time >= end) { if (segment) await arrive(); else if (quizEnd()) pause(); else if (loop) { await seek(0); await play(); } else pause(); }
413
+ }
233
414
  update();
234
415
  }
416
+ // A timed choice counts down while the film plays on and while it holds on the segment's last frame.
417
+ if (segment?.timeout !== undefined && picked === null && choosing() && (playing || atEnd()) && !quiz?.panel && !document.hidden) {
418
+ waited += (now - last) / 1000;
419
+ if (waited >= segment.timeout) choose(segment.default ?? 0); else if (!playing) update();
420
+ }
235
421
  last = now;
236
422
  requestAnimationFrame((next) => { void tick(next); });
237
423
  } catch (error) { pause(); status.textContent = copy.failed; console.error(error); }
@@ -241,8 +427,18 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
241
427
  element("loading")?.remove();
242
428
  const api = {
243
429
  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 } }; },
430
+ get state() {
431
+ return { time, playing, muted, captions: film.captions, audio: { configured: Boolean(audio), readyState: audio?.readyState ?? 0, error: audio?.error?.code ?? null },
432
+ // waiting: the segment has ended and only a choice continues the story, so play does nothing.
433
+ ...(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 } } : {}),
434
+ // open: a quiz card shows; asking: it asks a question, so play and seeking wait for the answer.
435
+ ...(quiz ? { quiz: { open: Boolean(quiz.panel), asking: quiz.asking, card: quiz.panel?.kind ?? null, score: quiz.score() } } : {}) };
436
+ },
245
437
  resources: film.resources,
438
+ // A branching story: its graph, the path taken, and the viewer's actions.
439
+ ...(story ? { story: { graph: story.graph, get path() { return [...path]; }, choose, back, restart, async goTo(id: string) { path.length = 0; await enter(story!.get(id)); } } } : {}),
440
+ // A quiz: its questions, the viewer's actions on the card, and the score so far.
441
+ ...(quiz ? { quiz: { data: quiz.quiz, pick, check: checkAnswer, next: nextQuestion, retake: retakeQuiz, reset() { quiz!.reset(); update(); }, get score() { return quiz!.score(); } } } : {}),
246
442
  get texts() { return film.texts; },
247
443
  get cues() { return film.cues; },
248
444
  seek, play, pause, setMuted, setCaptions, saveFrame,
@@ -0,0 +1,315 @@
1
+ import { checkpointSegment, QUIZ_UI_KEYS, validateQuiz, type Quiz, type QuizCheckpoint, type QuizQuestion, type QuizUiKey } from "../scripts/quiz.mjs";
2
+ import type { StorySegment } from "./story";
3
+ import type { Branding } from "./player-types";
4
+
5
+ export type { Quiz, QuizCheckpoint, QuizQuestion };
6
+ export type QuizCopy = Record<QuizUiKey, string>;
7
+ type Answer = { chosen: number[]; correct: boolean };
8
+ /** Where the quiz is: a checkpoint's question, the final quiz's question, or the score. */
9
+ export type QuizPanel = { kind: "checkpoint"; checkpoint: QuizCheckpoint; index: number } | { kind: "final"; index: number } | { kind: "results" };
10
+ export type QuizMark = "right" | "wrong" | "skipped";
11
+ export type QuizScore = { correct: number; total: number; skipped: number; marks: QuizMark[]; pass: number | null; passed: boolean | null };
12
+ /** What the card over the picture shows. */
13
+ export type QuizView =
14
+ | { kind: "none" }
15
+ | { kind: "question"; key: string; title: string | undefined; final: boolean; number: number; count: number; question: QuizQuestion; selection: number[]; answer: Answer | null; watchAgain: boolean }
16
+ | { kind: "results"; score: QuizScore; retake: boolean; story: boolean };
17
+
18
+ const EPSILON = 1e-6;
19
+ const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (match, name: string) => name in values ? String(values[name]) : match);
20
+
21
+ /** The cards' words: quiz.json's ui where it sets them, the player's (quizCheck, quizFinal…) otherwise. */
22
+ export function quizCopy(defaults: Record<string, string>, ui: Quiz["ui"] = {}): QuizCopy {
23
+ return Object.fromEntries(QUIZ_UI_KEYS.map((key) => [key, ui[key] ?? defaults[`quiz${key[0].toUpperCase()}${key.slice(1)}`]])) as QuizCopy;
24
+ }
25
+
26
+ /**
27
+ * A viewer's way through the quiz: which checkpoint is due as the film plays, the question showing, the answers and
28
+ * the score. A checkpoint asks once; Watch again asks it again, and its new answers replace the old. The score covers
29
+ * the questions on the viewer's path: every checkpoint of a linear film, those of the story segments visited, then the
30
+ * final quiz once it is done.
31
+ */
32
+ export class QuizRun {
33
+ panel: QuizPanel | null = null;
34
+ private readonly selection = new Set<number>();
35
+ private readonly answers = new Map<string, Answer>();
36
+ private readonly armed = new Set<string>();
37
+ private finalDone = false;
38
+ readonly checkpoints: QuizCheckpoint[];
39
+ private readonly segmentOf = new Map<string, StorySegment>();
40
+
41
+ constructor(readonly quiz: Quiz, duration: number, private readonly story?: { segments: StorySegment[]; visited: Set<string> }) {
42
+ validateQuiz(quiz, { duration }, story ? { segments: story.segments } : null);
43
+ this.checkpoints = quiz.checkpoints ?? [];
44
+ if (story) for (const checkpoint of this.checkpoints) this.segmentOf.set(checkpoint.id, checkpointSegment(checkpoint, story.segments)!);
45
+ }
46
+
47
+ /** Each question's id: its own, or its place ("orbit/1", "final/2"; ids cannot contain a slash). */
48
+ private keys(where: QuizCheckpoint | "final") {
49
+ const [name, questions] = where === "final" ? ["final", this.quiz.final?.questions ?? []] as const : [where.id, where.questions] as const;
50
+ return questions.map((question, index) => question.id ?? `${name}/${index + 1}`);
51
+ }
52
+ private get current() {
53
+ const panel = this.panel;
54
+ if (!panel || panel.kind === "results") return null;
55
+ const [questions, keys] = panel.kind === "final" ? [this.quiz.final!.questions, this.keys("final")] : [panel.checkpoint.questions, this.keys(panel.checkpoint)];
56
+ return { question: questions[panel.index], key: keys[panel.index], count: questions.length };
57
+ }
58
+ /** A question is showing: the film waits for its answer. */
59
+ get asking() { return this.current !== null; }
60
+ /** The question showing has its answer. */
61
+ get answered() { const current = this.current; return Boolean(current && this.answers.has(current.key)); }
62
+ /** The answer to the question showing, as the player's quiz events report it. */
63
+ get lastAnswer() {
64
+ const current = this.current; const answer = current && this.answers.get(current.key);
65
+ return current && answer ? { id: current.key, prompt: current.question.prompt, chosen: answer.chosen, correct: answer.correct } : null;
66
+ }
67
+
68
+ /** The checkpoint the film reaches playing from `from` to `to`, while it still has to ask; within a story segment's span. */
69
+ due(from: number, to: number, span?: { start: number; end: number }) {
70
+ return this.checkpoints.find((checkpoint) => checkpoint.at > from + EPSILON && checkpoint.at <= to + EPSILON
71
+ && (!span || (checkpoint.at > span.start + EPSILON && checkpoint.at <= span.end + EPSILON))
72
+ && (this.armed.has(checkpoint.id) || !this.keys(checkpoint).every((key) => this.answers.has(key)))) ?? null;
73
+ }
74
+ open(checkpoint: QuizCheckpoint) {
75
+ this.armed.delete(checkpoint.id);
76
+ for (const key of this.keys(checkpoint)) this.answers.delete(key);
77
+ this.show({ kind: "checkpoint", checkpoint, index: 0 });
78
+ }
79
+ /** At the film's end: the final quiz until it is done, then the score; false when no question was on the viewer's path. */
80
+ openEnd() {
81
+ if (this.quiz.final && !this.finalDone) {
82
+ for (const key of this.keys("final")) this.answers.delete(key);
83
+ this.show({ kind: "final", index: 0 });
84
+ return true;
85
+ }
86
+ if (!this.score().total) return false;
87
+ this.show({ kind: "results" });
88
+ return true;
89
+ }
90
+ /** Picks an option: answers a one-answer question at once, ticks or unticks one of a "select all that apply". */
91
+ pick(index: number) {
92
+ const current = this.current;
93
+ if (!current || this.answers.has(current.key) || !Number.isInteger(index) || index < 0 || index >= current.question.options.length) return false;
94
+ if (Array.isArray(current.question.answer)) { if (!this.selection.delete(index)) this.selection.add(index); }
95
+ else this.record([index]);
96
+ return true;
97
+ }
98
+ /** Answers a "select all that apply" with the options ticked. */
99
+ check() {
100
+ const current = this.current;
101
+ if (!current || !Array.isArray(current.question.answer) || this.answers.has(current.key) || !this.selection.size) return false;
102
+ this.record([...this.selection].sort((a, b) => a - b));
103
+ return true;
104
+ }
105
+ private record(chosen: number[]) {
106
+ const { question, key } = this.current!;
107
+ const right = Array.isArray(question.answer) ? question.answer : [question.answer];
108
+ this.answers.set(key, { chosen, correct: chosen.length === right.length && chosen.every((value, index) => value === right[index]) });
109
+ }
110
+ /** After an answer: the next question, the score after the final quiz's last, or closed after a checkpoint's last. */
111
+ advance(): "question" | "results" | "closed" | null {
112
+ const panel = this.panel; const current = this.current;
113
+ if (!panel || panel.kind === "results" || !current || !this.answers.has(current.key)) return null;
114
+ if (panel.index + 1 < current.count) { this.show({ ...panel, index: panel.index + 1 }); return "question"; }
115
+ if (panel.kind === "final") { this.finalDone = true; this.show({ kind: "results" }); return "results"; }
116
+ this.show(null);
117
+ return "closed";
118
+ }
119
+ /** Asks a checkpoint again the next time the film reaches it. */
120
+ arm(checkpoint: QuizCheckpoint) { this.armed.add(checkpoint.id); }
121
+ close() { this.show(null); }
122
+ retake() { if (!this.quiz.final) return; this.finalDone = false; this.openEnd(); }
123
+ /** Forgets every answer, for a fresh viewing. */
124
+ reset() { this.answers.clear(); this.armed.clear(); this.finalDone = false; this.show(null); }
125
+ private show(panel: QuizPanel | null) { this.panel = panel; this.selection.clear(); }
126
+
127
+ /** Where Watch again goes back to: the checkpoint's review, else the previous checkpoint (in its story segment) or the start. */
128
+ reviewTime(checkpoint: QuizCheckpoint) {
129
+ if (checkpoint.review !== undefined) return checkpoint.review;
130
+ const segment = this.segmentOf.get(checkpoint.id);
131
+ const earlier = this.checkpoints.filter((other) => other.at < checkpoint.at - EPSILON && (!segment || this.segmentOf.get(other.id) === segment));
132
+ return earlier.at(-1)?.at ?? segment?.start ?? 0;
133
+ }
134
+ score(): QuizScore {
135
+ const marks: QuizMark[] = [];
136
+ const mark = (key: string) => { const answer = this.answers.get(key); marks.push(!answer ? "skipped" : answer.correct ? "right" : "wrong"); };
137
+ for (const checkpoint of this.checkpoints) if (!this.story || this.story.visited.has(this.segmentOf.get(checkpoint.id)!.id)) this.keys(checkpoint).forEach(mark);
138
+ if (this.finalDone) this.keys("final").forEach(mark);
139
+ const correct = marks.filter((value) => value === "right").length;
140
+ const pass = this.quiz.final?.pass ?? null;
141
+ return { correct, total: marks.length, skipped: marks.filter((value) => value === "skipped").length, marks, pass, passed: pass === null || !marks.length ? null : correct * 100 >= pass * marks.length };
142
+ }
143
+ view(): QuizView {
144
+ const panel = this.panel; const current = this.current;
145
+ if (!panel) return { kind: "none" };
146
+ if (panel.kind === "results" || !current) return { kind: "results", score: this.score(), retake: Boolean(this.quiz.final), story: Boolean(this.story) };
147
+ return {
148
+ kind: "question", key: current.key, final: panel.kind === "final", title: panel.kind === "final" ? this.quiz.final!.title : panel.checkpoint.title,
149
+ number: panel.index + 1, count: current.count, question: current.question, selection: [...this.selection].sort((a, b) => a - b),
150
+ answer: this.answers.get(current.key) ?? null, watchAgain: panel.kind === "checkpoint",
151
+ };
152
+ }
153
+ }
154
+
155
+ const ICONS = { right: '<path d="M5 12.5l4.5 4.5L19 7.5"/>', wrong: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>', skipped: '<path d="M6 12h12"/>' };
156
+ function icon(kind: keyof typeof ICONS) {
157
+ const span = document.createElement("span");
158
+ span.className = "quiz-icon"; span.setAttribute("aria-hidden", "true");
159
+ span.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">${ICONS[kind]}</svg>`;
160
+ return span;
161
+ }
162
+ /** Black or white, whichever reads better on a color. */
163
+ function readableOn(hex: string) {
164
+ const channel = (offset: number) => { const value = parseInt(hex.slice(offset, offset + 2), 16) / 255; return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; };
165
+ const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
166
+ return luminance > 0.179 ? "#111418" : "#ffffff";
167
+ }
168
+
169
+ /**
170
+ * The question and score cards, centered over the dimmed picture. They are HTML buttons, so a viewer can tab to them,
171
+ * a screen reader reads them and a touch picks them; 1–6 on the keyboard pick too. A card is rebuilt only when its
172
+ * question or answer changes, so focus and hover survive every frame's update; ticks on a "select all" update in place.
173
+ */
174
+ export class QuizOverlay {
175
+ readonly element = document.createElement("div");
176
+ private readonly live = document.createElement("p");
177
+ private shown = "";
178
+ private ticks = "";
179
+ private returnFocus: Element | null = null;
180
+
181
+ constructor(host: HTMLElement, quiz: Quiz, private readonly copy: QuizCopy, branding: Branding | undefined,
182
+ private readonly actions: { pick(index: number): void; check(): void; next(): void; watchAgain(): void; retake(): void; finish(): void }) {
183
+ this.element.className = "quiz-layer"; this.element.hidden = true;
184
+ const theme = quiz.theme ?? {};
185
+ const accent = theme.accent ?? branding?.colors.primary ?? "#2c6bd0";
186
+ const surface = theme.surface ?? "#fffdfa";
187
+ // The brand's text color suits the brand's background, not necessarily this card: the card picks its own.
188
+ this.element.style.setProperty("--quiz-accent", accent);
189
+ this.element.style.setProperty("--quiz-on-accent", readableOn(accent));
190
+ this.element.style.setProperty("--quiz-surface", surface);
191
+ this.element.style.setProperty("--quiz-text", theme.text ?? (readableOn(surface) === "#ffffff" ? "#f7f5ef" : "#16232a"));
192
+ if (branding) this.element.style.setProperty("--quiz-font", branding.fonts.heading.family);
193
+ this.live.className = "story-live"; this.live.setAttribute("role", "status");
194
+ host.append(this.element, this.live);
195
+ }
196
+
197
+ render(view: QuizView) {
198
+ const key = view.kind === "none" ? "none" : view.kind === "results" ? `results:${view.score.marks.join()}` : `question:${view.key}:${view.answer ? "answered" : ""}`;
199
+ if (key !== this.shown) {
200
+ const opening = this.shown === "" || this.shown === "none";
201
+ if (opening && view.kind !== "none") this.returnFocus = document.activeElement;
202
+ const hadFocus = this.element.contains(document.activeElement);
203
+ this.shown = key; this.build(view, opening);
204
+ if (view.kind === "none") {
205
+ // Focus that was on the card goes back where it was before the card opened.
206
+ if (hadFocus && this.returnFocus instanceof HTMLElement && this.returnFocus.isConnected) this.returnFocus.focus({ preventScroll: true });
207
+ } else {
208
+ const target = this.element.querySelector<HTMLElement>(view.kind === "question" && !view.answer ? ".quiz-option" : ".quiz-actions .quiz-primary");
209
+ target?.focus({ preventScroll: true });
210
+ }
211
+ }
212
+ if (view.kind === "question" && !view.answer && view.selection.join() !== this.ticks) this.tick(view.selection);
213
+ }
214
+
215
+ /** Shows the ticked options of a "select all" and enables Check answer once one is ticked. */
216
+ private tick(selection: number[]) {
217
+ this.ticks = selection.join();
218
+ for (const option of this.element.querySelectorAll<HTMLButtonElement>(".quiz-multi .quiz-option")) {
219
+ const on = selection.includes(Number(option.dataset.index));
220
+ option.classList.toggle("selected", on); option.setAttribute("aria-pressed", String(on));
221
+ }
222
+ const check = this.element.querySelector<HTMLButtonElement>(".quiz-check");
223
+ if (check) check.disabled = selection.length === 0;
224
+ }
225
+
226
+ /** opening: the card appears over the film, rather than changing to the next question or the score. */
227
+ private build(view: QuizView, opening: boolean) {
228
+ this.element.replaceChildren(); this.ticks = "";
229
+ this.element.dataset.view = view.kind; this.element.hidden = view.kind === "none";
230
+ if (view.kind === "none") return;
231
+ const { copy } = this;
232
+ const node = <K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string) => {
233
+ const element = document.createElement(tag); element.className = className;
234
+ if (text !== undefined) element.textContent = text;
235
+ return element;
236
+ };
237
+ const button = (label: string, action: () => void, className: string) => {
238
+ const element = node("button", className, label); element.type = "button";
239
+ // A click on the card is not a click on the picture, which would pause the film.
240
+ element.addEventListener("click", (event) => { event.stopPropagation(); action(); });
241
+ return element;
242
+ };
243
+ const card = node("div", `quiz-card${view.kind === "results" ? " quiz-results" : ""}${view.kind === "question" && view.answer ? " answered" : ""}${opening ? " quiz-card-in" : ""}`);
244
+ card.setAttribute("role", "dialog");
245
+ const actions = node("div", "quiz-actions");
246
+ if (view.kind === "question") {
247
+ const { question, answer } = view;
248
+ const multiple = Array.isArray(question.answer);
249
+ const right = Array.isArray(question.answer) ? question.answer : [question.answer];
250
+ const title = view.title ?? (view.final ? copy.final : copy.check);
251
+ const eyebrow = view.count > 1 ? `${title} · ${fill(copy.progress, { n: view.number, total: view.count })}` : title;
252
+ const prompt = node("h2", "quiz-prompt", question.prompt);
253
+ card.setAttribute("aria-label", `${eyebrow}: ${question.prompt}`);
254
+ card.append(node("p", "quiz-eyebrow", eyebrow), prompt);
255
+ if (multiple && !answer) card.append(node("p", "quiz-hint", copy.selectAll));
256
+ // Long options, and an odd number of them on a wide card, read best as a list; others sit in two columns.
257
+ const long = question.options.some((option) => option.length > 32);
258
+ const options = node("div", `quiz-options${multiple ? " quiz-multi" : ""}${long ? " quiz-long" : question.options.length % 2 ? " quiz-odd" : ""}`);
259
+ options.setAttribute("role", "group"); options.setAttribute("aria-label", question.prompt);
260
+ question.options.forEach((label, index) => {
261
+ const option = button("", () => this.actions.pick(index), "quiz-option");
262
+ option.dataset.index = String(index);
263
+ const isRight = right.includes(index), chosen = Boolean(answer?.chosen.includes(index));
264
+ const state = !answer ? null : isRight ? "right" : chosen ? "wrong" : null;
265
+ const key = node("span", "quiz-key");
266
+ if (state) key.append(icon(state)); else key.textContent = String(index + 1);
267
+ option.append(key, node("span", "quiz-option-text", label));
268
+ if (answer) {
269
+ option.disabled = true;
270
+ option.classList.add(state === "right" ? "correct" : state === "wrong" ? "wrong" : "dim");
271
+ if (isRight && !chosen) option.classList.add("missed");
272
+ if (multiple || chosen) option.setAttribute("aria-pressed", String(chosen));
273
+ } else if (multiple) option.setAttribute("aria-pressed", "false");
274
+ options.append(option);
275
+ });
276
+ card.append(options);
277
+ if (!answer) {
278
+ if (multiple) { const check = button(copy.checkAnswer, () => this.actions.check(), "quiz-primary quiz-check"); check.disabled = true; actions.append(check); }
279
+ this.live.textContent = `${eyebrow}. ${question.prompt} ${multiple ? `${copy.selectAll} ` : ""}${question.options.map((option, index) => `${index + 1}: ${option}.`).join(" ")}`;
280
+ } else {
281
+ const feedback = node("p", `quiz-feedback ${answer.correct ? "correct" : "wrong"}`);
282
+ feedback.append(node("strong", "", answer.correct ? copy.correct : copy.incorrect));
283
+ if (!answer.correct) feedback.append(" ", node("span", "quiz-answer", fill(copy.answer, { answer: right.map((index) => question.options[index]).join(", ") })));
284
+ if (question.explanation) feedback.append(" ", node("span", "", question.explanation));
285
+ card.append(feedback);
286
+ const last = view.number === view.count;
287
+ actions.append(button(last ? (view.final ? copy.seeResults : copy.continue) : copy.next, () => this.actions.next(), "quiz-primary"));
288
+ if (view.watchAgain && !answer.correct) actions.append(button(copy.watchAgain, () => this.actions.watchAgain(), "quiz-secondary"));
289
+ this.live.textContent = feedback.textContent ?? "";
290
+ }
291
+ } else {
292
+ const { score } = view;
293
+ const perfect = score.total > 0 && score.correct === score.total;
294
+ const heading = fill(copy.score, { correct: score.correct, total: score.total });
295
+ card.setAttribute("aria-label", `${copy.complete}: ${heading}`);
296
+ const total = node("p", "quiz-score");
297
+ total.append(node("strong", "", String(score.correct)), node("span", "", `/${score.total}`));
298
+ card.append(node("p", "quiz-eyebrow", copy.complete), total, node("h2", "quiz-prompt", heading));
299
+ const verdict = perfect ? copy.perfect : score.passed === null ? null : score.passed ? copy.passed : fill(copy.notPassed, { pass: score.pass ?? 0 });
300
+ if (verdict) card.append(node("p", `quiz-verdict${perfect || score.passed ? " passed" : ""}`, verdict));
301
+ const marks = node("ol", "quiz-marks"); marks.setAttribute("aria-hidden", "true");
302
+ for (const mark of score.marks) { const item = node("li", mark); item.append(icon(mark)); marks.append(item); }
303
+ card.append(marks);
304
+ if (score.skipped) card.append(node("p", "quiz-hint", fill(copy.skipped, { count: score.skipped })));
305
+ // A viewer who has not passed is offered the retake first; a story's score gives way to its ending.
306
+ const retakeFirst = view.retake && !perfect && score.passed !== true;
307
+ const finish = button(view.story ? copy.continue : copy.restart, () => this.actions.finish(), retakeFirst ? "quiz-secondary" : "quiz-primary");
308
+ const retake = view.retake ? button(copy.retake, () => this.actions.retake(), retakeFirst ? "quiz-primary" : "quiz-secondary") : null;
309
+ actions.append(...(retakeFirst && retake ? [retake, finish] : retake ? [finish, retake] : [finish]));
310
+ this.live.textContent = `${heading}${verdict ? ` ${verdict}` : ""}`;
311
+ }
312
+ if (actions.childElementCount) card.append(actions);
313
+ this.element.append(card);
314
+ }
315
+ }