@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
@@ -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";
@@ -0,0 +1,19 @@
1
+ import type { StoryGraph } from "../../scripts/story.mjs";
2
+
3
+ /**
4
+ * A branching story's segments for scene code, so scenes follow story.json when a segment is re-timed:
5
+ * `const beat = storyBeats(story); beat("forest").start`. choiceAt is when that segment's choices appear.
6
+ */
7
+ export function storyBeats(story: StoryGraph) {
8
+ const byId = new Map(story.segments.map((segment) => [segment.id, segment]));
9
+ return (id: string) => {
10
+ const segment = byId.get(id);
11
+ if (!segment) throw new Error(`No story segment "${id}". Known segments: ${[...byId.keys()].join(", ")}`);
12
+ return { ...segment, choiceAt: segment.choiceAt ?? segment.end, length: segment.end - segment.start };
13
+ };
14
+ }
15
+
16
+ /** The story's segments in timeline order as the player's chapters (label and transcript line). */
17
+ export function storyChapters(story: StoryGraph) {
18
+ return [...story.segments].sort((a, b) => a.start - b.start).map((segment) => ({ start: segment.start, end: segment.end, label: segment.label, caption: segment.label }));
19
+ }
@@ -1,5 +1,10 @@
1
1
  import config from "../film.config.json";
2
2
  import { Film, chapters } from "./film";
3
3
  import { mountPlayer } from "./player";
4
+ import type { StoryGraph } from "./story";
5
+ import type { Quiz } from "./quiz";
4
6
 
5
- void mountPlayer({ config, Film, chapters });
7
+ // story.json, when the film has one, makes it a branching story; quiz.json adds its questions.
8
+ const [story] = Object.values(import.meta.glob<StoryGraph>("../story.json", { eager: true, import: "default" }));
9
+ const [quiz] = Object.values(import.meta.glob<Quiz>("../quiz.json", { eager: true, import: "default" }));
10
+ void mountPlayer({ config, Film, chapters, story, quiz });
@@ -1,5 +1,7 @@
1
1
  import type { Application, Texture } from "pixi.js";
2
2
  import type { ResourceTrack } from "./timeline-model";
3
+ import type { StoryGraph } from "../scripts/story.mjs";
4
+ import type { Quiz } from "../scripts/quiz.mjs";
3
5
 
4
6
  export type FilmConfig = {
5
7
  title: string; slug: string; width: number; height: number; fps: number;
@@ -32,4 +34,8 @@ export type PlayerOptions = {
32
34
  branding?: Branding;
33
35
  exportEndpoint?: string;
34
36
  templateLabel?: string;
37
+ /** story.json: the film is a branching story whose viewers choose its path. */
38
+ story?: StoryGraph;
39
+ /** quiz.json: checkpoints pause the film to ask questions, and a final quiz and the score follow its end. */
40
+ quiz?: Quiz;
35
41
  };