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