@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
@@ -2,6 +2,8 @@
2
2
 
3
3
  This root owns shared branding, installed skills, dependencies, the PixiJS player and export tools. Each film lives in `animations/NAME/`. Open this root in Codex or Claude Code.
4
4
 
5
+ - For a branching story, where viewers choose what happens next, run `animaguru create animation NAME --story` and read the installed `animaguru-branching-story` skill: `story.json` beside the film's `film.config.json` links its segments, and it plays as HTML5 only.
6
+ - For a quiz (an educational explainer or lesson whose film pauses for questions, with a final quiz and a score), run `animaguru create animation NAME --quiz` and read the installed `animaguru-quiz` skill: `quiz.json` beside `film.config.json` holds the questions, and the film plays as HTML5 only, never MP4.
5
7
  - For a new film, run `animaguru create animation NAME` here; infer a useful name from the request when none was supplied. Reuse an existing folder when the user is revising that film. If several films could match the request, clarify which one before editing it.
6
8
  - Read `branding/brand.json`, branding files/guides in `branding/`, and any relevant branding files the user placed at the root. If the kit is still the scaffold placeholder or incomplete, read `.agents/skills/animaguru-brand-kit/SKILL.md` (or `.claude/skills/animaguru-brand-kit/SKILL.md` in Claude Code). Use an official website or brand guide in the first prompt; if none was supplied, ask for one before researching the brand. Populate `branding/` and record sources in `branding/brand-guide.md`. Every animation inherits these shared settings. Keep guides local; exports include registered web assets, rather than the whole branding folder.
7
9
  - Read the installed `animaguru-video` skill and the selected film's `animaguru.project.json`. After establishing the shared brand source, first ask for **Explainer**, **Advertisement**, or **General Video**; then ask for a visual style using `animaguru.templates.json` and https://animaguru.com/animation-styles; then run the compact creative interview. Reuse explicit choices and supplied/completed answers. Save the brief inside that film's `production/` folder. Purpose and visual style are separate decisions.
@@ -14,7 +16,7 @@ Run commands from the workspace root:
14
16
  - `npm run dev`: the workspace hub and separate `/animations/NAME/` players.
15
17
  - `npm run build -- --animation NAME`: typecheck and build that film.
16
18
  - `npm run export:html -- --animation NAME`: portable HTML5 ZIP with local media and common branding.
17
- - `npm run export:mp4 -- --animation NAME`: verified MP4 without burned-in captions, plus `.srt`/`.vtt` subtitle files from the captions track (`--burn-captions` draws them into the picture instead, as `NAME-LANG-captioned.mp4`), and a `.jpg` thumbnail of the `poster` frame set in film.config.json. Add `--headless` to capture in local headless Chrome; otherwise open the printed capture page.
19
+ - `npm run export:mp4 -- --animation NAME` (not for a branching story or a quiz film): verified MP4 without burned-in captions, plus `.srt`/`.vtt` subtitle files from the captions track (`--burn-captions` draws them into the picture instead, as `NAME-LANG-captioned.mp4`), and a `.jpg` thumbnail of the `poster` frame set in film.config.json. Add `--headless` to capture in local headless Chrome; otherwise open the printed capture page.
18
20
  - `npm run review -- --animation NAME --headless`: frame, text, layout (overlap, safe area, text size), seeking and soundtrack checks, one line per finding.
19
21
  - `npm run frames -- --animation NAME --at 1.2,4.8`: labelled contact sheets in `production/review/frames/` plus layout notes for those times.
20
22
  - `npm run audio:vo|audio:music|audio:mix -- --animation NAME`: cut and place the voiceover (`timing.json`), fit the music bed, and master the soundtrack.
@@ -16,7 +16,10 @@ if (context.workspace) {
16
16
  const cache = path.join(root, ".animaguru/cache", context.name);
17
17
  await mkdir(cache, { recursive: true });
18
18
  const imported = (file) => JSON.stringify(file.split(path.sep).join("/"));
19
- await writeFile(path.join(cache, "contract.ts"), `import config from ${imported(path.join(context.projectRoot,"film.config.json"))};\nimport {Film,chapters} from ${imported(path.join(context.projectRoot,"src/film.ts"))};\nimport type {PlayerOptions} from ${imported(path.join(root,"runtime/player-types.ts"))};\nconst player: PlayerOptions = {config,Film,chapters};\nvoid player;\n`);
19
+ // A branching story's story.json and a quiz's quiz.json are checked against the player's types too.
20
+ const story = context.story ? `import story from ${imported(path.join(context.projectRoot,"story.json"))};\n` : "";
21
+ const quiz = context.quiz ? `import quiz from ${imported(path.join(context.projectRoot,"quiz.json"))};\n` : "";
22
+ await writeFile(path.join(cache, "contract.ts"), `import config from ${imported(path.join(context.projectRoot,"film.config.json"))};\nimport {Film,chapters} from ${imported(path.join(context.projectRoot,"src/film.ts"))};\n${story}${quiz}import type {PlayerOptions} from ${imported(path.join(root,"runtime/player-types.ts"))};\nconst player: PlayerOptions = {config,Film,chapters${context.story ? ",story" : ""}${context.quiz ? ",quiz" : ""}};\nvoid player;\n`);
20
23
  await writeFile(path.join(cache, "tsconfig.json"), JSON.stringify({ extends: path.join(root, "tsconfig.json"), include: [path.join(root,"runtime/**/*.ts"),path.join(context.projectRoot,"src/**/*.ts"),path.join(cache,"contract.ts")] }));
21
24
  typecheckArguments = ["--project", path.join(cache, "tsconfig.json"), "--noEmit"];
22
25
  }
@@ -30,6 +30,8 @@ for (let index = 0; index < args.length; index++) {
30
30
  }
31
31
  if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Export port must be 1024–65535.");
32
32
  const context = await projectContext(root, selection.animation);
33
+ if (context.story) throw new Error("This film is a branching story (story.json): it plays as HTML5 only, because an MP4 has one path. Deliver it with npm run export:html or animaguru submit.");
34
+ if (context.quiz) throw new Error("This film has a quiz (quiz.json): it plays as HTML5 only, because its questions wait for a viewer's answers. Deliver it with npm run export:html or animaguru submit.");
33
35
  const config = context.config;
34
36
  const { width, height, fps, duration, language } = config;
35
37
  if (![width, height].every((n) => Number.isInteger(n) && n >= 2 && n <= 4096 && n % 2 === 0) || ![24, 25, 30, 60].includes(fps) || !Number.isFinite(duration) || duration <= 0 || duration > 600 || Math.abs(duration * fps - Math.round(duration * fps)) > 0.000001) throw new Error("Use even dimensions up to 4096px, 24/25/30/60 fps, and a frame-aligned duration up to 600s in film.config.json.");
@@ -4,6 +4,8 @@ import { createHash } from "node:crypto";
4
4
  import { zipSync, strToU8 } from "fflate";
5
5
  import { projectContext, publicFile } from "./project-context.mjs";
6
6
  import { workspacePlugin } from "./workspace-plugin.mjs";
7
+ import { storySummary, validateStory } from "./story.mjs";
8
+ import { quizSummary, validateQuiz } from "./quiz.mjs";
7
9
 
8
10
  const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
9
11
 
@@ -13,9 +15,15 @@ export function validateHtmlConfig(config) {
13
15
  if (typeof config.title !== "string" || typeof config.language !== "string") throw new Error("Set the film title and language in film.config.json.");
14
16
  }
15
17
 
16
- /** Only the built website is packaged, never the project, skills, receipts, or dependencies. */
17
- export async function packageHtmlDirectory(directory, config) {
18
+ /**
19
+ * Only the built website is packaged, never the project, skills, receipts, or dependencies. A branching story's manifest
20
+ * says so ("story"), so a host plays it as an interactive film and never offers an MP4 of it; a quiz's ("quiz") counts
21
+ * its questions, and a quiz film has no MP4 either.
22
+ */
23
+ export async function packageHtmlDirectory(directory, config, story = null, quiz = null) {
18
24
  validateHtmlConfig(config);
25
+ if (story) validateStory(story, config);
26
+ if (quiz) validateQuiz(quiz, config, story);
19
27
  const files = {};
20
28
  const manifest = [];
21
29
  async function collect(relative = "") {
@@ -39,7 +47,7 @@ export async function packageHtmlDirectory(directory, config) {
39
47
  const embed = iframe.replace(`aspect-ratio:${config.width} / ${config.height}`, `height:100%`);
40
48
  files["embed-example.html"] = strToU8(`<!doctype html>\n<html lang="${escapeHtml(config.language)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title></head><body style="margin:0;background:#f5f3ed"><div style="position:relative;width:100%;height:calc(100vw * ${config.height} / ${config.width} + 112px);max-height:100vh">${embed}</div></body></html>\n`);
41
49
  files["README.txt"] = strToU8(`AnimaGuru HTML5 animation: ${config.title}\n\nUpload this entire folder to any static HTTP(S) host. Open index.html for the animation or embed-example.html for a working iframe example. ES modules and local media need an HTTP(S) server; do not open index.html with file://.\n\nEmbed the hosted animation:\n${iframe.replace('src="./index.html"', 'src="https://your-site.example/animation/index.html"')}\n\nAllow extra iframe height for controls, or add ?controls=0&autoplay=1&loop=1 for a canvas-only embed. Playback starts muted; viewers can unmute with the sound button. Autoplay respects reduced-motion preferences (controls remain available in that case).\n\nThis archive contains the compiled PixiJS animation and local media. It needs no Node.js, AnimaGuru account, API, or MP4 conversion on the host. Keep all files together; relative paths support nested hosting folders. External assets explicitly added by an author still require their external sources.\n`);
42
- files["manifest.json"] = strToU8(JSON.stringify({ format: "spriteguru-html5", version: 1, title: config.title, width: config.width, height: config.height, duration: config.duration, language: config.language, audio: config.audio ?? null, files: manifest }, null, 2) + "\n");
50
+ files["manifest.json"] = strToU8(JSON.stringify({ format: "spriteguru-html5", version: 1, title: config.title, width: config.width, height: config.height, duration: config.duration, language: config.language, audio: config.audio ?? null, ...(story ? { story: storySummary(story) } : {}), ...(quiz ? { quiz: quizSummary(quiz) } : {}), files: manifest }, null, 2) + "\n");
43
51
  return { filename: `${config.slug}-html5.zip`, bytes: zipSync(files), files: Object.keys(files).length };
44
52
  }
45
53
 
@@ -54,6 +62,6 @@ export async function buildHtmlExport(root, { animation } = {}) {
54
62
  try {
55
63
  const { build } = await import("vite");
56
64
  await build({ root, configFile: false, base: "./", publicDir: context.workspace ? false : undefined, plugins: context.workspace ? [workspacePlugin(root, { animation: context.name, mode: "embed" })] : [], mode: "embed", logLevel: "silent", build: { outDir: temporary, emptyOutDir: true, sourcemap: false } });
57
- return await packageHtmlDirectory(temporary, config);
65
+ return await packageHtmlDirectory(temporary, config, context.story, context.quiz);
58
66
  } finally { await rm(temporary, { recursive: true, force: true }); }
59
67
  }
@@ -1,6 +1,8 @@
1
1
  import { access, readFile, readdir, realpath, stat } from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import path from "node:path";
4
+ import { STORY_FILE, validateStory } from "./story.mjs";
5
+ import { QUIZ_FILE, validateQuiz } from "./quiz.mjs";
4
6
 
5
7
  export const animationName = (value) => typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,79}$/.test(value);
6
8
  const extensions = new Set(["png", "jpg", "jpeg", "webp", "svg", "gif", "woff", "woff2", "ttf", "otf", "mp3", "wav", "m4a", "ogg", "mp4", "webm"]);
@@ -18,7 +20,9 @@ export async function listAnimations(root) {
18
20
  if (!entry.isDirectory() || !animationName(entry.name)) continue;
19
21
  const config = await optionalJson(path.join(root, "animations", entry.name, "film.config.json"));
20
22
  const project = await optionalJson(path.join(root, "animations", entry.name, "animaguru.project.json"));
21
- if (config && project) result.push({ name: entry.name, title: config.title, duration: config.duration, category: project.category, template: project.template });
23
+ const exists = (file) => access(path.join(root, "animations", entry.name, file)).then(() => true, () => false);
24
+ const [story, quiz] = await Promise.all([exists(STORY_FILE), exists(QUIZ_FILE)]);
25
+ if (config && project) result.push({ name: entry.name, title: config.title, duration: config.duration, category: project.category, template: project.template, story, quiz });
22
26
  }
23
27
  return result.sort((a, b) => a.name.localeCompare(b.name));
24
28
  }
@@ -62,7 +66,9 @@ export async function projectContext(root, name) {
62
66
  const workspace = await workspaceInfo(root);
63
67
  if (!workspace) {
64
68
  if (name) throw new Error("--animation selects a project inside an AnimaGuru workspace.");
65
- return { workspace: false, root, projectRoot: root, publicRoot: path.join(root, "public"), output: path.join(root, "production/exports"), route: "/", config: JSON.parse(await readFile(path.join(root, "film.config.json"), "utf8")), branding: null, brandFiles: [] };
69
+ const config = JSON.parse(await readFile(path.join(root, "film.config.json"), "utf8"));
70
+ const story = await projectStory(root, config);
71
+ return { workspace: false, root, projectRoot: root, publicRoot: path.join(root, "public"), output: path.join(root, "production/exports"), route: "/", config, story, quiz: await projectQuiz(root, config, story), branding: null, brandFiles: [] };
66
72
  }
67
73
  if (!name) {
68
74
  const animations = await listAnimations(root);
@@ -77,7 +83,18 @@ export async function projectContext(root, name) {
77
83
  await access(path.join(projectRoot, "animaguru.project.json"));
78
84
  const config = JSON.parse(await readFile(path.join(projectRoot, "film.config.json"), "utf8"));
79
85
  const brand = await readBranding(root);
80
- return { workspace: true, name, root, projectRoot, publicRoot: path.join(projectRoot, "public"), output: path.join(projectRoot, "production/exports"), route: `/animations/${name}/`, config, branding: brand.branding, brandFiles: brand.files };
86
+ const story = await projectStory(projectRoot, config);
87
+ return { workspace: true, name, root, projectRoot, publicRoot: path.join(projectRoot, "public"), output: path.join(projectRoot, "production/exports"), route: `/animations/${name}/`, config, story, quiz: await projectQuiz(projectRoot, config, story), branding: brand.branding, brandFiles: brand.files };
88
+ }
89
+ /** The film's story.json, checked, when it is a branching story; null for a film with one path. */
90
+ async function projectStory(projectRoot, config) {
91
+ const story = await optionalJson(path.join(projectRoot, STORY_FILE));
92
+ return story === null ? null : validateStory(story, config);
93
+ }
94
+ /** The film's quiz.json, checked against its duration and story, when it asks questions; null otherwise. */
95
+ async function projectQuiz(projectRoot, config, story) {
96
+ const quiz = await optionalJson(path.join(projectRoot, QUIZ_FILE));
97
+ return quiz === null ? null : validateQuiz(quiz, config, story);
81
98
  }
82
99
  export async function publicFile(context, relative) {
83
100
  if (typeof relative !== "string" || !relative || relative.startsWith("/") || relative.includes("\\") || relative.split("/").some((segment) => !segment || segment === "." || segment === "..")) throw new Error("Use a local media path relative to the animation's public folder.");
@@ -0,0 +1,31 @@
1
+ import type { StorySegment } from "./story.mjs";
2
+
3
+ /**
4
+ * One question: 2–6 options and the right one (answer, an index from 0), or for "select all that apply" the right
5
+ * ones as indices in increasing order. The explanation shows after the viewer answers.
6
+ */
7
+ export type QuizQuestion = { id?: string; prompt: string; options: string[]; answer: number | number[]; explanation?: string };
8
+ /**
9
+ * A pause in the film for one question or a quick round of up to five. at: the second of the film's timeline it
10
+ * pauses at; review: where Watch again goes back to (the previous checkpoint, or the start, by default).
11
+ */
12
+ export type QuizCheckpoint = { id: string; at: number; title?: string; review?: number; questions: QuizQuestion[] };
13
+ /** The quiz after the film's end; pass is the percentage of right answers, over every question asked, that passes. */
14
+ export type QuizFinal = { title?: string; pass?: number; questions: QuizQuestion[] };
15
+ export type QuizUiKey = "check" | "final" | "progress" | "selectAll" | "checkAnswer" | "correct" | "incorrect" | "answer" | "continue" | "next" | "seeResults" | "watchAgain" | "score" | "passed" | "notPassed" | "perfect" | "retake" | "restart" | "complete" | "skipped";
16
+ export type Quiz = {
17
+ version: number;
18
+ checkpoints?: QuizCheckpoint[];
19
+ final?: QuizFinal;
20
+ /** The cards' colors; otherwise the brand's. */
21
+ theme?: { accent?: string; surface?: string; text?: string };
22
+ /** The player's own words on the cards, for a quiz in another language. */
23
+ ui?: Partial<Record<QuizUiKey, string>>;
24
+ };
25
+ export type QuizSummary = { checkpoints: number; questions: number; final: number; pass: number | null };
26
+ export const QUIZ_FILE: string;
27
+ export const QUIZ_UI_KEYS: QuizUiKey[];
28
+ export function validateQuiz(quiz: unknown, config?: { duration: number }, story?: { segments: StorySegment[] } | null): Quiz;
29
+ export function checkpointSegment(checkpoint: QuizCheckpoint, segments: StorySegment[]): StorySegment | null;
30
+ export function quizSummary(quiz: Quiz): QuizSummary;
31
+ export function quizNotes(quiz: Quiz): string[];
@@ -0,0 +1,126 @@
1
+ /**
2
+ * A quiz over a film: checkpoints pause it at chosen times to ask one question or a quick round, and a final quiz
3
+ * follows its end, then the viewer's score. quiz.json sits beside film.config.json and works with a linear film or a
4
+ * branching story. A quiz film plays as HTML5 only, because its questions wait for a viewer's answers; capture and
5
+ * review see the film without them.
6
+ */
7
+ export const QUIZ_FILE = "quiz.json";
8
+ const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
9
+ const HEX = /^#[0-9a-f]{6}$/i;
10
+ const EPSILON = 1e-6;
11
+ /** The player's own words on the cards, which `ui` translates for a quiz in another language. */
12
+ export const QUIZ_UI_KEYS = ["check", "final", "progress", "selectAll", "checkAnswer", "correct", "incorrect", "answer", "continue", "next", "seeResults", "watchAgain", "score", "passed", "notPassed", "perfect", "retake", "restart", "complete", "skipped"];
13
+
14
+ const text = (value, max) => typeof value === "string" && value.trim().length > 0 && value.length <= max;
15
+
16
+ /**
17
+ * Checks quiz.json against the film's duration and, for a branching story, its segments; throws an Error naming the
18
+ * first problem. Returns the quiz.
19
+ */
20
+ export function validateQuiz(quiz, config, story = null) {
21
+ const fail = (message) => { throw new Error(`quiz.json: ${message}`); };
22
+ if (!quiz || typeof quiz !== "object" || Array.isArray(quiz)) fail("expected an object.");
23
+ if (quiz.version !== 1) fail('set "version": 1.');
24
+ const duration = Number(config?.duration);
25
+ const questionIds = new Set();
26
+ const checkQuestions = (questions, where, max) => {
27
+ if (!Array.isArray(questions) || questions.length < 1 || questions.length > max) fail(`${where} needs 1 to ${max} questions.`);
28
+ for (const [index, question] of questions.entries()) {
29
+ const at = `${where}, question ${index + 1}`;
30
+ if (!question || typeof question !== "object" || Array.isArray(question)) fail(`${at} must be an object.`);
31
+ if (question.id !== undefined) {
32
+ if (typeof question.id !== "string" || !ID.test(question.id)) fail(`${at} needs a lowercase, hyphenated id of up to 40 characters, or none.`);
33
+ if (questionIds.has(question.id)) fail(`two questions use the id ${question.id}.`);
34
+ questionIds.add(question.id);
35
+ }
36
+ if (!text(question.prompt, 160)) fail(`${at} needs a prompt of up to 160 characters.`);
37
+ if (!Array.isArray(question.options) || question.options.length < 2 || question.options.length > 6) fail(`${at} needs 2 to 6 options.`);
38
+ for (const option of question.options) if (!text(option, 100)) fail(`${at} has an option that is not text of up to 100 characters.`);
39
+ if (new Set(question.options.map((option) => option.trim().toLowerCase())).size !== question.options.length) fail(`${at} has two options with the same text.`);
40
+ const count = question.options.length;
41
+ const option = (value) => Number.isInteger(value) && value >= 0 && value < count;
42
+ if (Array.isArray(question.answer)) {
43
+ // "Select all that apply": the right options, in order; not every option, or there is nothing to choose.
44
+ if (!question.answer.length || !question.answer.every(option) || question.answer.some((value, position) => position > 0 && value <= question.answer[position - 1])) fail(`${at} lists its right options (answer) as indices in increasing order, such as [0, 2].`);
45
+ if (question.answer.length === count) fail(`${at} makes every option right; leave at least one wrong option.`);
46
+ } else if (!option(question.answer)) fail(`${at} needs answer: the index of the right option (0 is the first), or a list of indices for "select all that apply".`);
47
+ if (question.explanation !== undefined && !text(question.explanation, 280)) fail(`${at} has an explanation that is not text of up to 280 characters.`);
48
+ }
49
+ };
50
+ const segments = story?.segments ?? null;
51
+ const checkpoints = quiz.checkpoints ?? [];
52
+ if (!Array.isArray(checkpoints) || checkpoints.length > 24) fail("list up to 24 checkpoints.");
53
+ const checkpointIds = new Set();
54
+ let previous = null;
55
+ for (const [index, checkpoint] of checkpoints.entries()) {
56
+ const where = `checkpoint ${index + 1}${checkpoint && typeof checkpoint.id === "string" ? ` (${checkpoint.id})` : ""}`;
57
+ if (!checkpoint || typeof checkpoint !== "object" || Array.isArray(checkpoint)) fail(`${where} must be an object.`);
58
+ if (typeof checkpoint.id !== "string" || !ID.test(checkpoint.id)) fail(`${where} needs a lowercase, hyphenated id of up to 40 characters.`);
59
+ if (checkpointIds.has(checkpoint.id)) fail(`two checkpoints use the id ${checkpoint.id}.`);
60
+ checkpointIds.add(checkpoint.id);
61
+ if (!Number.isFinite(checkpoint.at) || checkpoint.at < 0.5 || (Number.isFinite(duration) && checkpoint.at > duration + EPSILON)) fail(`${where} needs at: the second of the film's timeline it pauses at, from 0.5 to the film's duration.`);
62
+ if (previous && checkpoint.at - previous.at < 2 - EPSILON) fail(`${where} must come at least 2 s after ${previous.id} (${previous.at} s): list checkpoints in time order, or put both rounds of questions in one checkpoint.`);
63
+ if (checkpoint.title !== undefined && !text(checkpoint.title, 40)) fail(`${where} has a title that is not text of up to 40 characters.`);
64
+ let from = 0;
65
+ if (segments) {
66
+ const segment = checkpointSegment(checkpoint, segments);
67
+ if (!segment) fail(`${where} is not inside a story segment.`);
68
+ const choiceAt = segment.choiceAt ?? segment.end;
69
+ if (segment.choices && checkpoint.at > choiceAt + EPSILON) fail(`${where} comes after ${segment.id}'s choices appear (${choiceAt} s); ask before them.`);
70
+ from = segment.start;
71
+ }
72
+ if (checkpoint.review !== undefined && (!Number.isFinite(checkpoint.review) || checkpoint.review < from - EPSILON || checkpoint.review > checkpoint.at - 0.5)) fail(`${where} needs review: the second Watch again goes back to, at least 0.5 s before its own time${segments ? " and within its story segment" : ""}.`);
73
+ checkQuestions(checkpoint.questions, where, 5);
74
+ previous = checkpoint;
75
+ }
76
+ if (quiz.final !== undefined) {
77
+ const final = quiz.final;
78
+ if (!final || typeof final !== "object" || Array.isArray(final)) fail("final must be an object with questions.");
79
+ if (final.title !== undefined && !text(final.title, 40)) fail("final has a title that is not text of up to 40 characters.");
80
+ if (final.pass !== undefined && (!Number.isInteger(final.pass) || final.pass < 1 || final.pass > 100)) fail("final.pass is the percentage of right answers that passes, from 1 to 100.");
81
+ checkQuestions(final.questions, "the final quiz", 20);
82
+ }
83
+ if (!checkpoints.length && quiz.final === undefined) fail("add checkpoints, a final quiz, or both.");
84
+ if (quiz.theme !== undefined) {
85
+ if (!quiz.theme || typeof quiz.theme !== "object" || Array.isArray(quiz.theme)) fail("theme must be an object of colors.");
86
+ for (const [key, value] of Object.entries(quiz.theme)) if (!["accent", "surface", "text"].includes(key) || !HEX.test(value)) fail(`theme.${key} must be accent, surface or text as a six-digit hex color.`);
87
+ }
88
+ if (quiz.ui !== undefined) {
89
+ if (!quiz.ui || typeof quiz.ui !== "object" || Array.isArray(quiz.ui)) fail("ui must be an object of player labels.");
90
+ for (const [key, value] of Object.entries(quiz.ui)) if (!QUIZ_UI_KEYS.includes(key) || !text(value, 80)) fail(`ui.${key} must be one of ${QUIZ_UI_KEYS.join(", ")}, with up to 80 characters.`);
91
+ }
92
+ return quiz;
93
+ }
94
+
95
+ /** The story segment a checkpoint pauses in: a checkpoint at a segment's end belongs to that segment. */
96
+ export function checkpointSegment(checkpoint, segments) {
97
+ return segments.find((segment) => checkpoint.at > segment.start + EPSILON && checkpoint.at <= segment.end + EPSILON) ?? null;
98
+ }
99
+
100
+ /** What the manifest and review say about a valid quiz. */
101
+ export function quizSummary(quiz) {
102
+ const checkpoints = quiz.checkpoints ?? [];
103
+ const asked = checkpoints.reduce((total, checkpoint) => total + checkpoint.questions.length, 0);
104
+ const final = quiz.final?.questions.length ?? 0;
105
+ return { checkpoints: checkpoints.length, questions: asked + final, final, pass: quiz.final?.pass ?? null };
106
+ }
107
+
108
+ /** Review notes on a valid quiz: things that work but that a viewer may trip over. */
109
+ export function quizNotes(quiz) {
110
+ const notes = [];
111
+ const checkpoints = quiz.checkpoints ?? [];
112
+ const questions = [...checkpoints.flatMap((checkpoint) => checkpoint.questions), ...(quiz.final?.questions ?? [])];
113
+ const single = questions.filter((question) => !Array.isArray(question.answer));
114
+ if (single.length >= 3 && new Set(single.map((question) => question.answer)).size === 1) notes.push(`the right answer is option ${single[0].answer + 1} in every one-answer question; vary it so guessing the position does not work.`);
115
+ const unexplained = questions.filter((question) => question.explanation === undefined).length;
116
+ if (unexplained) notes.push(`${unexplained} question${unexplained === 1 ? " has" : "s have"} no explanation; a sentence on why teaches more than "Correct" or "Not quite".`);
117
+ for (let index = 1; index < checkpoints.length; index++) {
118
+ const gap = checkpoints[index].at - checkpoints[index - 1].at;
119
+ if (gap < 8) notes.push(`${checkpoints[index - 1].id} and ${checkpoints[index].id} pause only ${Math.round(gap * 10) / 10} s apart; viewers may feel interrupted.`);
120
+ }
121
+ for (const checkpoint of checkpoints) if (checkpoint.questions.length > 3) notes.push(`${checkpoint.id} asks ${checkpoint.questions.length} questions in the middle of the film; consider moving some to the final quiz.`);
122
+ const long = questions.filter((question) => question.prompt.length > 110 || question.options.some((option) => option.length > 60)).length;
123
+ if (long) notes.push(`${long} question${long === 1 ? " has" : "s have"} a prompt over 110 characters or an option over 60; check the card on a phone.`);
124
+ if (quiz.final?.pass !== undefined && quiz.final.questions.length < 3) notes.push(`a pass mark over ${quiz.final.questions.length} final question${quiz.final.questions.length === 1 ? "" : "s"} is coarse; it also counts the checkpoints' questions.`);
125
+ return notes;
126
+ }
@@ -8,6 +8,8 @@ import { workspacePlugin } from "./workspace-plugin.mjs";
8
8
  import { analyzeReview, reviewLines } from "./review-analysis.mjs";
9
9
  import { inspectAudio } from "./review-audio.mjs";
10
10
  import { captureHeadless, takeHeadlessFlag } from "./headless-browser.mjs";
11
+ import { storyNotes, storySummary } from "./story.mjs";
12
+ import { quizNotes, quizSummary } from "./quiz.mjs";
11
13
 
12
14
  const root=fileURLToPath(new URL("../",import.meta.url));
13
15
  const selection=projectArguments(process.argv.slice(2));
@@ -32,9 +34,15 @@ const server=await createServer({configFile:false,root,base:"./",publicDir:conte
32
34
  if(typeof frame.image!=="string"||!frame.image.startsWith("data:image/jpeg;base64,"))throw new Error("Expected JPEG review frame.");
33
35
  await writeFile(path.join(output,`frame-${String(i).padStart(2,"0")}.jpg`),Buffer.from(frame.image.split(",")[1],"base64"));
34
36
  }
35
- await writeFile(path.join(output,"report.json"),JSON.stringify({config,samples:report.samples,seekMatches:report.seekMatches,cues:report.cues,analysis,audio},null,2)+"\n");
37
+ // A branching story is reviewed as its whole timeline, every segment of every path; its graph was checked on load.
38
+ const story=context.story?{summary:storySummary(context.story),notes:storyNotes(context.story)}:null;
39
+ // A quiz's cards are HTML over the picture, so review checks the film under them and the questions' shape.
40
+ const quiz=context.quiz?{summary:quizSummary(context.quiz),notes:quizNotes(context.quiz)}:null;
41
+ await writeFile(path.join(output,"report.json"),JSON.stringify({config,samples:report.samples,seekMatches:report.seekMatches,cues:report.cues,analysis,audio,...(story?{story}:{}),...(quiz?{quiz}:{})},null,2)+"\n");
36
42
  console.log(`${analysis.pass?"Passed":"Failed"}: seek and text-bound checks. ${analysis.warnings.length} reading/layout notes. Frames and report: ${output}`);
37
43
  for(const line of reviewLines(analysis))console.log(line);
44
+ if(story){const{summary}=story;const count=(n,word)=>`${n} ${word}${n===1?"":"s"}`;console.log(`Story: ${count(summary.segments,"segment")}, ${count(summary.decisions,"choice")}, ${count(summary.endings,"ending")}; the shortest playthrough runs ${summary.shortestPlaythrough} s${summary.longestPlaythrough===null?" and a choice can loop back":`, the longest ${summary.longestPlaythrough} s`}, not counting time spent choosing.`);for(const note of story.notes)console.log(`Story note: ${note}`);}
45
+ if(quiz){const{summary}=quiz;const count=(n,word)=>`${n} ${word}${n===1?"":"s"}`;console.log(`Quiz: ${count(summary.questions,"question")}; ${count(summary.checkpoints,"checkpoint")} ${summary.checkpoints===1?"pauses":"pause"} the film${summary.final?` and the final quiz asks ${summary.final}`:""}${summary.pass===null?"":`; ${summary.pass}% passes`}.`);for(const note of quiz.notes)console.log(`Quiz note: ${note}`);}
38
46
  if(audio.status==="measured")console.log(`Audio: ${audio.integratedLufs} LUFS, true peak ${audio.truePeakDb} dBTP.${audio.notes.length?` ${audio.notes.join(" ")}`:""}`);
39
47
  console.log("Inspect the frames and moving playback with audio; numerical checks do not establish artistic quality or claim accuracy.");
40
48
  response.writeHead(200,{"Content-Type":"application/json"}).end(JSON.stringify(analysis));
@@ -0,0 +1,36 @@
1
+ /** A viewer's choice: its button label and the segment it leads to. */
2
+ export type StoryChoice = { label: string; to: string };
3
+ /**
4
+ * One stretch of the film's timeline, in seconds. It ends in exactly one way: choices (the viewer picks the next
5
+ * segment), next (it continues into another segment) or ending (the story ends with this title).
6
+ */
7
+ export type StorySegment = {
8
+ id: string; label: string; start: number; end: number;
9
+ choices?: StoryChoice[];
10
+ /** Asked above the choices. */
11
+ prompt?: string;
12
+ /** When the choices appear, in seconds of the film's timeline; the segment's end by default. */
13
+ choiceAt?: number;
14
+ /** Seconds the viewer has to choose before the default choice is taken; without it the player waits. */
15
+ timeout?: number;
16
+ /** The choice a timeout takes (an index into choices); the first by default. */
17
+ default?: number;
18
+ next?: string;
19
+ ending?: string;
20
+ };
21
+ export type StoryGraph = {
22
+ version: number; start: string; segments: StorySegment[];
23
+ /** The choice and ending panels' colors; otherwise the brand's. */
24
+ theme?: { accent?: string; surface?: string; text?: string };
25
+ /** The player's own words on the panels, for a story in another language. */
26
+ ui?: { theEnd?: string; playAgain?: string; back?: string; choose?: string };
27
+ };
28
+ export type StorySummary = {
29
+ start: string; segments: number; decisions: number; endings: number;
30
+ shortestPlaythrough: number; longestPlaythrough: number | null;
31
+ };
32
+ export const STORY_FILE: string;
33
+ export function validateStory(story: unknown, config?: { duration: number }): StoryGraph;
34
+ export function exitsOf(segment: StorySegment): string[];
35
+ export function storySummary(story: StoryGraph): StorySummary;
36
+ export function storyNotes(story: StoryGraph): string[];
@@ -0,0 +1,139 @@
1
+ /**
2
+ * A branching story: the film is one timeline of segments laid end to end, and story.json says where each segment goes
3
+ * next. The player plays one segment at a time and asks the viewer at a choice; review, frames and capture still see the
4
+ * whole timeline. A branching story plays as HTML5 only: an MP4 has one path.
5
+ */
6
+ export const STORY_FILE = "story.json";
7
+ const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
8
+ const HEX = /^#[0-9a-f]{6}$/i;
9
+ const EPSILON = 1e-6;
10
+
11
+ const text = (value, max) => typeof value === "string" && value.trim().length > 0 && value.length <= max;
12
+
13
+ /** Checks story.json against the film's duration; throws an Error naming the first problem. Returns the story. */
14
+ export function validateStory(story, config) {
15
+ const fail = (message) => { throw new Error(`story.json: ${message}`); };
16
+ if (!story || typeof story !== "object" || Array.isArray(story)) fail("expected an object.");
17
+ if (story.version !== 1) fail('set "version": 1.');
18
+ if (!Array.isArray(story.segments) || story.segments.length < 2 || story.segments.length > 64) fail("list 2 to 64 segments.");
19
+ const byId = new Map();
20
+ for (const [index, segment] of story.segments.entries()) {
21
+ const where = `segment ${index + 1}${segment && typeof segment.id === "string" ? ` (${segment.id})` : ""}`;
22
+ if (!segment || typeof segment !== "object") fail(`${where} must be an object.`);
23
+ if (typeof segment.id !== "string" || !ID.test(segment.id)) fail(`${where} needs a lowercase, hyphenated id of up to 40 characters.`);
24
+ if (byId.has(segment.id)) fail(`two segments use the id ${segment.id}.`);
25
+ byId.set(segment.id, segment);
26
+ if (!text(segment.label, 80)) fail(`${where} needs a label of up to 80 characters.`);
27
+ if (![segment.start, segment.end].every(Number.isFinite) || segment.end - segment.start < 0.5) fail(`${where} needs a start and an end at least 0.5 s apart, in seconds of the film's timeline.`);
28
+ const exits = ["choices", "next", "ending"].filter((key) => segment[key] !== undefined);
29
+ if (exits.length !== 1) fail(`${where} needs exactly one of choices, next or ending.`);
30
+ if (segment.choices !== undefined) {
31
+ if (!Array.isArray(segment.choices) || segment.choices.length < 2 || segment.choices.length > 4) fail(`${where} needs 2 to 4 choices.`);
32
+ for (const choice of segment.choices) {
33
+ if (!choice || !text(choice.label, 60)) fail(`${where} has a choice without a label of up to 60 characters.`);
34
+ if (typeof choice.to !== "string") fail(`${where} has a choice without a target segment ("to").`);
35
+ }
36
+ if (segment.prompt !== undefined && !text(segment.prompt, 120)) fail(`${where} has a prompt longer than 120 characters.`);
37
+ if (segment.choiceAt !== undefined && (!Number.isFinite(segment.choiceAt) || segment.choiceAt < segment.start || segment.choiceAt > segment.end)) fail(`${where} shows its choices (choiceAt) outside its own time.`);
38
+ if (segment.timeout !== undefined && (!Number.isFinite(segment.timeout) || segment.timeout < 3 || segment.timeout > 60)) fail(`${where} needs a timeout of 3 to 60 seconds, or none to wait for the viewer.`);
39
+ if (segment.default !== undefined && (!Number.isInteger(segment.default) || segment.default < 0 || segment.default >= segment.choices.length)) fail(`${where} names a default choice it does not have.`);
40
+ if (segment.default !== undefined && segment.timeout === undefined) fail(`${where} has a default choice but no timeout.`);
41
+ } else if (segment.ending !== undefined && !text(segment.ending, 80)) fail(`${where} needs an ending title of up to 80 characters.`);
42
+ else if (segment.next !== undefined && (typeof segment.next !== "string" || segment.next === segment.id)) fail(`${where} needs another segment as next.`);
43
+ for (const key of ["prompt", "choiceAt", "timeout", "default"]) if (segment[key] !== undefined && segment.choices === undefined) fail(`${where} has ${key} but no choices.`);
44
+ }
45
+ for (const segment of story.segments) {
46
+ for (const target of exitsOf(segment)) if (!byId.has(target)) fail(`${segment.id} leads to ${target}, which is not a segment.`);
47
+ }
48
+ if (typeof story.start !== "string" || !byId.has(story.start)) fail('set "start" to the id of the first segment the viewer sees.');
49
+ // The segments tile the film's timeline, so review and frame checks cover every one.
50
+ const ordered = [...story.segments].sort((a, b) => a.start - b.start);
51
+ if (Math.abs(ordered[0].start) > EPSILON) fail(`the first segment on the timeline (${ordered[0].id}) must start at 0.`);
52
+ for (let index = 1; index < ordered.length; index++) {
53
+ const before = ordered[index - 1], after = ordered[index];
54
+ if (Math.abs(after.start - before.end) > EPSILON) fail(`${after.id} must start where ${before.id} ends (${before.end} s): segments lie end to end on the timeline, without gaps or overlaps.`);
55
+ }
56
+ const duration = Number(config?.duration);
57
+ if (Number.isFinite(duration) && Math.abs(ordered.at(-1).end - duration) > EPSILON) fail(`the last segment (${ordered.at(-1).id}) ends at ${ordered.at(-1).end} s; set film.config.json's duration to ${ordered.at(-1).end}.`);
58
+ const reachable = walk(story.start, (id) => exitsOf(byId.get(id)));
59
+ const unreachable = story.segments.filter((segment) => !reachable.has(segment.id));
60
+ if (unreachable.length) fail(`no choice leads to ${unreachable.map((segment) => segment.id).join(", ")}.`);
61
+ const endings = story.segments.filter((segment) => segment.ending !== undefined).map((segment) => segment.id);
62
+ if (!endings.length) fail("give the story at least one ending.");
63
+ // Every segment can still reach an ending, so no path traps the viewer in a loop.
64
+ const into = new Map(story.segments.map((segment) => [segment.id, []]));
65
+ for (const segment of story.segments) for (const target of exitsOf(segment)) into.get(target).push(segment.id);
66
+ const finishing = new Set(endings.flatMap((id) => [...walk(id, (current) => into.get(current))]));
67
+ const trapped = story.segments.filter((segment) => !finishing.has(segment.id));
68
+ if (trapped.length) fail(`${trapped.map((segment) => segment.id).join(", ")} can never reach an ending.`);
69
+ if (story.theme !== undefined) {
70
+ if (!story.theme || typeof story.theme !== "object") fail("theme must be an object of colors.");
71
+ for (const [key, value] of Object.entries(story.theme)) if (!["accent", "surface", "text"].includes(key) || !HEX.test(value)) fail(`theme.${key} must be accent, surface or text as a six-digit hex color.`);
72
+ }
73
+ if (story.ui !== undefined) {
74
+ if (!story.ui || typeof story.ui !== "object") fail("ui must be an object of player labels.");
75
+ for (const [key, value] of Object.entries(story.ui)) if (!["theEnd", "playAgain", "back", "choose"].includes(key) || !text(value, 40)) fail(`ui.${key} must be theEnd, playAgain, back or choose, with up to 40 characters.`);
76
+ }
77
+ return story;
78
+ }
79
+
80
+ /** The segments a segment leads to. */
81
+ export function exitsOf(segment) {
82
+ return segment.choices ? segment.choices.map((choice) => choice.to) : segment.next ? [segment.next] : [];
83
+ }
84
+ function walk(from, next) {
85
+ const seen = new Set([from]); const queue = [from];
86
+ while (queue.length) for (const id of next(queue.shift())) if (!seen.has(id)) { seen.add(id); queue.push(id); }
87
+ return seen;
88
+ }
89
+
90
+ /**
91
+ * What the manifest and review say about a valid story: its size, and how long the shortest and longest playthroughs
92
+ * run, not counting time spent choosing (longest is null when a choice can loop back).
93
+ */
94
+ export function storySummary(story) {
95
+ const byId = new Map(story.segments.map((segment) => [segment.id, segment]));
96
+ const length = (id) => byId.get(id).end - byId.get(id).start;
97
+ const shortest = new Map([[story.start, length(story.start)]]); const queue = [story.start];
98
+ // Few segments: relaxing repeatedly is plenty.
99
+ while (queue.length) {
100
+ const id = queue.shift();
101
+ for (const target of exitsOf(byId.get(id))) {
102
+ const total = shortest.get(id) + length(target);
103
+ if (total < (shortest.get(target) ?? Infinity)) { shortest.set(target, total); queue.push(target); }
104
+ }
105
+ }
106
+ const endings = story.segments.filter((segment) => segment.ending !== undefined);
107
+ const longestFrom = new Map(); let cyclic = false;
108
+ const visiting = new Set();
109
+ const longest = (id) => {
110
+ if (longestFrom.has(id)) return longestFrom.get(id);
111
+ if (visiting.has(id)) { cyclic = true; return 0; }
112
+ visiting.add(id);
113
+ const best = length(id) + Math.max(0, ...exitsOf(byId.get(id)).map(longest));
114
+ visiting.delete(id); longestFrom.set(id, best);
115
+ return best;
116
+ };
117
+ const round = (value) => Math.round(value * 100) / 100;
118
+ const longestPath = longest(story.start);
119
+ return {
120
+ start: story.start, segments: story.segments.length,
121
+ decisions: story.segments.filter((segment) => segment.choices).length, endings: endings.length,
122
+ shortestPlaythrough: round(Math.min(...endings.map((segment) => shortest.get(segment.id)))),
123
+ longestPlaythrough: cyclic ? null : round(longestPath),
124
+ };
125
+ }
126
+
127
+ /** Review notes on a valid story: things that work but that a viewer may trip over. */
128
+ export function storyNotes(story) {
129
+ const notes = [];
130
+ for (const segment of story.segments) {
131
+ if (!segment.choices) continue;
132
+ const at = segment.choiceAt ?? segment.end;
133
+ if (segment.timeout !== undefined && segment.timeout < 6) notes.push(`${segment.id}: a ${segment.timeout} s timeout leaves little time to read ${segment.choices.length} choices; 8–12 s is usual.`);
134
+ if (segment.end - at > 15) notes.push(`${segment.id}: the choices show ${Math.round(segment.end - at)} s before the segment ends; viewers who choose early wait for the end.`);
135
+ const labels = segment.choices.map((choice) => choice.label.trim().toLowerCase());
136
+ if (new Set(labels).size !== labels.length) notes.push(`${segment.id}: two choices have the same label.`);
137
+ }
138
+ return notes;
139
+ }
@@ -2,6 +2,8 @@ import { readFile, cp, mkdir, stat } from "node:fs/promises";
2
2
  import { createReadStream } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { listAnimations, projectContext, publicFile, readBranding } from "./project-context.mjs";
5
+ import { STORY_FILE } from "./story.mjs";
6
+ import { QUIZ_FILE } from "./quiz.mjs";
5
7
 
6
8
  const htmlEscape = (value) => String(value).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
7
9
  export function workspacePlugin(root, { animation, mode = "development" } = {}) {
@@ -16,11 +18,14 @@ export function workspacePlugin(root, { animation, mode = "development" } = {})
16
18
  const name = id.slice(virtualPrefix.length);
17
19
  const context = await projectContext(root, name);
18
20
  const source = (file) => JSON.stringify(`/@fs${file.split(path.sep).join("/")}`);
21
+ // A branching story's graph and a quiz's questions come from their JSON files, so edits to them reload the player.
22
+ const story = context.story ? `import story from ${source(path.join(context.projectRoot, STORY_FILE))};\n` : "";
23
+ const quiz = context.quiz ? `import quiz from ${source(path.join(context.projectRoot, QUIZ_FILE))};\n` : "";
19
24
  return `import config from ${source(path.join(context.projectRoot, "film.config.json"))};
20
25
  import {Film,chapters} from ${source(path.join(context.projectRoot, "src/film.ts"))};
21
26
  import project from ${source(path.join(context.projectRoot, "animaguru.project.json"))};
22
- import {mountPlayer} from "/runtime/player.ts";
23
- void mountPlayer({config,Film,chapters,branding:${JSON.stringify(context.branding)},exportEndpoint:${JSON.stringify(`/__animaguru/html?animation=${name}`)},templateLabel:project.templateLabel??project.template??"Choose category & style with your agent"});`;
27
+ ${story}${quiz}import {mountPlayer} from "/runtime/player.ts";
28
+ void mountPlayer({config,Film,chapters,${context.story ? "story," : ""}${context.quiz ? "quiz," : ""}branding:${JSON.stringify(context.branding)},exportEndpoint:${JSON.stringify(`/__animaguru/html?animation=${name}`)},templateLabel:project.templateLabel??project.template??"Choose category & style with your agent"});`;
24
29
  },
25
30
  transformIndexHtml: { order: "pre", handler(html) {
26
31
  if (!animation) return html;
@@ -35,13 +40,13 @@ void mountPlayer({config,Film,chapters,branding:${JSON.stringify(context.brandin
35
40
  if (url.pathname === "/" && !animation) {
36
41
  void (async () => {
37
42
  const [animations, brand] = await Promise.all([listAnimations(root), readBranding(root)]);
38
- const content = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${htmlEscape(brand.branding.name)} · AnimaGuru workspace</title><style>body{margin:0;background:#edf0f4;color:#111;font:15px system-ui}main{max-width:960px;margin:auto;padding:48px 24px}h1{font-size:32px}a{color:#1d56ad}ul{padding:0;display:grid;gap:14px;list-style:none}li{background:white;border:1px solid #d9dee6;padding:22px;border-radius:14px}code{font-size:13px}p{color:#6b7382;line-height:1.7}</style></head><body><main><p>AnimaGuru · shared video workspace</p><h1>${htmlEscape(brand.branding.name)}</h1><p>One brand. Many animations. Shared player, skills and export tools.</p><ul>${animations.map((item) => `<li><a href="/animations/${item.name}/">${htmlEscape(item.title)}</a><p>${htmlEscape(item.name)} · ${Number(item.duration)}s</p></li>`).join("")}</ul><p>Create your next film: <code>animaguru create animation my-video</code></p><p>Keep logos, fonts and guides in <code>branding/</code>; configure reusable web assets in <code>branding/brand.json</code>.</p></main></body></html>`;
43
+ const content = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${htmlEscape(brand.branding.name)} · AnimaGuru workspace</title><style>body{margin:0;background:#edf0f4;color:#111;font:15px system-ui}main{max-width:960px;margin:auto;padding:48px 24px}h1{font-size:32px}a{color:#1d56ad}ul{padding:0;display:grid;gap:14px;list-style:none}li{background:white;border:1px solid #d9dee6;padding:22px;border-radius:14px}code{font-size:13px}p{color:#6b7382;line-height:1.7}</style></head><body><main><p>AnimaGuru · shared video workspace</p><h1>${htmlEscape(brand.branding.name)}</h1><p>One brand. Many animations. Shared player, skills and export tools.</p><ul>${animations.map((item) => `<li><a href="/animations/${item.name}/">${htmlEscape(item.title)}</a><p>${htmlEscape(item.name)} · ${item.story ? "branching story" : `${Number(item.duration)}s`}${item.quiz ? " · quiz" : ""}</p></li>`).join("")}</ul><p>Create your next film: <code>animaguru create animation my-video</code></p><p>Keep logos, fonts and guides in <code>branding/</code>; configure reusable web assets in <code>branding/brand.json</code>.</p></main></body></html>`;
39
44
  response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(content);
40
45
  })().catch((error) => response.writeHead(500).end(error.message)); return;
41
46
  }
42
47
  if (!match) return next();
43
48
  // Film modules and the JSON they import (including the voiceover cue map) go to Vite; everything else is public media.
44
- if (/^(?:src|shared)\//.test(match[2]) || ["film.config.json", "animaguru.project.json", "production/audio/timing.json"].includes(match[2])) return next();
49
+ if (/^(?:src|shared)\//.test(match[2]) || ["film.config.json", "animaguru.project.json", "production/audio/timing.json", STORY_FILE, QUIZ_FILE].includes(match[2])) return next();
45
50
  void (async () => {
46
51
  const context = await projectContext(root, match[1]);
47
52
  if (!match[2] || match[2] === "index.html") {
@@ -28,5 +28,33 @@
28
28
  "fullscreenFailed": "Fullscreen is unavailable in this browser.",
29
29
  "videoPlayer": "Video player",
30
30
  "footer": "Made with AnimaGuru × PixiJS",
31
- "footerTagline": "IMAGINE. CREATE. ANIMATE."
31
+ "footerTagline": "IMAGINE. CREATE. ANIMATE.",
32
+ "storyHint": "A branching story: viewers choose its path. It plays as HTML5; an MP4 has one path.",
33
+ "storyNoMp4": "This film is a branching story, so it plays as HTML5 only: an MP4 has one path.",
34
+ "storyChoose": "What happens next?",
35
+ "storyEnd": "The end",
36
+ "storyPlayAgain": "Play again",
37
+ "storyBack": "Back to the last choice",
38
+ "quizHint": "A quiz film: it pauses for questions, which viewers answer. It plays as HTML5 only.",
39
+ "quizNoMp4": "This film has a quiz, so it plays as HTML5 only: its questions wait for a viewer's answers.",
40
+ "quizCheck": "Quick check",
41
+ "quizFinal": "Quiz",
42
+ "quizProgress": "Question {n} of {total}",
43
+ "quizSelectAll": "Select all that apply.",
44
+ "quizCheckAnswer": "Check answer",
45
+ "quizCorrect": "Correct!",
46
+ "quizIncorrect": "Not quite.",
47
+ "quizAnswer": "The answer: {answer}.",
48
+ "quizContinue": "Continue",
49
+ "quizNext": "Next question",
50
+ "quizSeeResults": "See your score",
51
+ "quizWatchAgain": "Watch that part again",
52
+ "quizScore": "You got {correct} of {total} right",
53
+ "quizPassed": "You passed!",
54
+ "quizNotPassed": "{pass}% passes. Watch again or retake the quiz.",
55
+ "quizPerfect": "A perfect score!",
56
+ "quizRetake": "Retake the quiz",
57
+ "quizRestart": "Watch again",
58
+ "quizComplete": "Quiz complete",
59
+ "quizSkipped": "{count} not answered"
32
60
  }
@@ -6,6 +6,7 @@ export * from "./shapes";
6
6
  export * from "./textures";
7
7
  export * from "./components";
8
8
  export * from "./timing";
9
+ export * from "./story";
9
10
  export * from "./layout";
10
11
  export * from "./film-base";
11
12
  export * from "./dark-stage";