@docyrus/animaguru 0.6.0 → 0.7.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 +21 -0
- package/dist/animaguru.js +189 -30
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +10 -1
- package/skills/animaguru-brand-kit/SKILL.md +30 -0
- package/skills/animaguru-video/SKILL.md +9 -2
- package/templates/video/AGENTS.md +1 -1
- package/templates/video/WORKSPACE-AGENTS.md +21 -0
- package/templates/video/gitignore +4 -0
- package/templates/video/index.html +1 -1
- package/templates/video/package.json +2 -2
- package/templates/video/scripts/build.mjs +23 -5
- package/templates/video/scripts/dev.mjs +13 -0
- package/templates/video/scripts/export-html.mjs +6 -2
- package/templates/video/scripts/export-mp4.mjs +11 -9
- package/templates/video/scripts/html-export-plugin.mjs +6 -3
- package/templates/video/scripts/html-export.mjs +10 -5
- package/templates/video/scripts/install-example.mjs +15 -10
- package/templates/video/scripts/preview.mjs +11 -0
- package/templates/video/scripts/project-context.mjs +91 -0
- package/templates/video/scripts/review.mjs +9 -5
- package/templates/video/scripts/starter-film.sha256 +1 -1
- package/templates/video/scripts/workspace-plugin.mjs +73 -0
- package/templates/video/shared/locales/en/player.json +1 -1
- package/templates/video/src/assets.ts +2 -2
- package/templates/video/src/download.ts +3 -3
- package/templates/video/src/film.ts +24 -10
- package/templates/video/src/main.ts +2 -216
- package/templates/video/src/player-types.ts +27 -0
- package/templates/video/src/player.ts +231 -0
- package/templates/video/src/style.css +3 -0
- package/templates/video/vite.config.mjs +6 -1
|
@@ -1,219 +1,5 @@
|
|
|
1
|
-
import { Application } from "pixi.js";
|
|
2
1
|
import config from "../film.config.json";
|
|
3
|
-
import copy from "../shared/locales/en/player.json";
|
|
4
2
|
import { Film, chapters } from "./film";
|
|
5
|
-
import {
|
|
6
|
-
import { downloadHtml, downloadUrl } from "./download";
|
|
7
|
-
import type { ResourceTimeline } from "./resource-timeline";
|
|
8
|
-
import "./style.css";
|
|
3
|
+
import { mountPlayer } from "./player";
|
|
9
4
|
|
|
10
|
-
|
|
11
|
-
// All variable copy is inserted as text, including agent-authored film titles.
|
|
12
|
-
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>
|
|
13
|
-
<main>
|
|
14
|
-
<section id="theater"><div id="canvas-host"><p id="loading" role="status" data-copy="loading"></p></div><div class="transport"><button id="play" class="primary" data-copy="play"></button><span id="time">0:00 / 0:24</span><input id="timeline" type="range" min="0" value="0" step="0.01"/><div class="tools"><button id="sound" data-copy="unmute" aria-pressed="false"></button><button id="captions" aria-pressed="true">CC</button><div class="download-control"><button id="download" class="icon-button" aria-haspopup="menu" aria-expanded="false" aria-controls="download-menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/></svg></button><div id="download-menu" role="menu" hidden><button id="save" role="menuitem" tabindex="-1"><span data-copy="saveFrame"></span><small data-copy="pngFormat"></small></button><button id="export-html" role="menuitem" tabindex="-1"><span data-copy="exportHtml"></span><small data-copy="htmlFormat"></small></button></div></div><button id="fullscreen" class="icon-button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5"/></svg></button></div></div></section>
|
|
15
|
-
<p class="player-note"><span class="preview-meta"><span class="specs"></span><span aria-hidden="true">·</span><span data-copy="starter"></span></span><span data-copy="exportHint"></span></p><p id="status" role="status"></p>
|
|
16
|
-
<section id="resource-timeline"></section>
|
|
17
|
-
<details><summary data-copy="transcript"></summary><div id="transcript"></div></details>
|
|
18
|
-
</main><footer><span data-copy="footer"></span><span data-copy="footerTagline"></span></footer>`;
|
|
19
|
-
for (const element of document.querySelectorAll<HTMLElement>("[data-copy]")) element.textContent = copy[element.dataset.copy as keyof typeof copy];
|
|
20
|
-
const element = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
|
21
|
-
const playButton = element<HTMLButtonElement>("play");
|
|
22
|
-
const soundButton = element<HTMLButtonElement>("sound");
|
|
23
|
-
const captionsButton = element<HTMLButtonElement>("captions");
|
|
24
|
-
const timeline = element<HTMLInputElement>("timeline");
|
|
25
|
-
const theater = element<HTMLElement>("theater");
|
|
26
|
-
const status = element<HTMLElement>("status");
|
|
27
|
-
const params = new URLSearchParams(location.search);
|
|
28
|
-
const embed = import.meta.env.MODE === "embed" || params.has("embed");
|
|
29
|
-
const capture = params.has("capture");
|
|
30
|
-
const exporting = !embed && params.has("export");
|
|
31
|
-
const autoplay = embed && params.get("autoplay") === "1" && !matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
32
|
-
const loop = embed && params.get("loop") === "1";
|
|
33
|
-
if (capture) document.body.classList.add("capture");
|
|
34
|
-
if (embed) document.body.classList.add("embed");
|
|
35
|
-
if (embed && autoplay && params.get("controls") === "0") document.body.classList.add("no-controls");
|
|
36
|
-
document.documentElement.lang = config.language;
|
|
37
|
-
document.title = `${config.title} · AnimaGuru`;
|
|
38
|
-
document.querySelector(".specs")!.textContent = `${config.width} × ${config.height} / ${config.fps} FPS`;
|
|
39
|
-
timeline.max = String(config.duration);
|
|
40
|
-
theater.setAttribute("aria-label", copy.videoPlayer);
|
|
41
|
-
element("canvas-host").style.aspectRatio = `${config.width} / ${config.height}`;
|
|
42
|
-
timeline.setAttribute("aria-label", copy.timeline);
|
|
43
|
-
captionsButton.setAttribute("aria-label", copy.captions);
|
|
44
|
-
for (const [id, label] of [["download", copy.download], ["fullscreen", copy.fullscreen], ["download-menu", copy.download]] as const) {
|
|
45
|
-
element(id).setAttribute("aria-label", label);
|
|
46
|
-
if (id !== "download-menu") element(id).title = label;
|
|
47
|
-
}
|
|
48
|
-
const format = (time: number) => `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
|
|
49
|
-
|
|
50
|
-
async function start() {
|
|
51
|
-
await document.fonts.ready;
|
|
52
|
-
const app = new Application();
|
|
53
|
-
await app.init({ width: config.width, height: config.height, resolution: 1, autoStart: false, antialias: true, background: "#F4F1E9", preference: "webgl", preserveDrawingBuffer: true });
|
|
54
|
-
app.canvas.setAttribute("aria-label", config.title);
|
|
55
|
-
element("canvas-host").append(app.canvas);
|
|
56
|
-
element("loading").remove();
|
|
57
|
-
const film = new Film(app);
|
|
58
|
-
const audio = config.audio ? new Audio(publicAsset(String(config.audio))) : null;
|
|
59
|
-
if (audio) { audio.preload = "auto"; audio.muted = true; }
|
|
60
|
-
let time = Math.max(0, Math.min(Number(params.get("t")) || 0, config.duration));
|
|
61
|
-
let playing = false;
|
|
62
|
-
let muted = true;
|
|
63
|
-
let last = performance.now();
|
|
64
|
-
let renderQueue = Promise.resolve();
|
|
65
|
-
let playbackRequest = 0;
|
|
66
|
-
let resources: ResourceTimeline | undefined;
|
|
67
|
-
const update = () => {
|
|
68
|
-
playButton.textContent = playing ? copy.pause : time >= config.duration ? copy.replay : copy.play;
|
|
69
|
-
playButton.setAttribute("aria-label", playButton.textContent);
|
|
70
|
-
element("time").textContent = `${format(time)} / ${format(config.duration)}`;
|
|
71
|
-
timeline.value = String(time);
|
|
72
|
-
timeline.setAttribute("aria-valuetext", `${format(time)} / ${format(config.duration)}`);
|
|
73
|
-
soundButton.textContent = !audio ? copy.noAudio : muted ? copy.unmute : copy.mute;
|
|
74
|
-
soundButton.disabled = !audio;
|
|
75
|
-
soundButton.setAttribute("aria-pressed", String(!muted));
|
|
76
|
-
captionsButton.setAttribute("aria-pressed", String(film.captions));
|
|
77
|
-
resources?.update({ time, playing, captions: film.captions, muted });
|
|
78
|
-
};
|
|
79
|
-
const render = (seconds: number) => {
|
|
80
|
-
// Seeking and frame capture are serialized so decoded video frames cannot race.
|
|
81
|
-
renderQueue = renderQueue.then(() => film.render(seconds));
|
|
82
|
-
return renderQueue;
|
|
83
|
-
};
|
|
84
|
-
function pause() { playbackRequest++; playing = false; audio?.pause(); update(); }
|
|
85
|
-
async function seek(seconds: number) {
|
|
86
|
-
if (!Number.isFinite(seconds)) throw new Error("Seek time must be finite.");
|
|
87
|
-
time = Math.max(0, Math.min(seconds, config.duration)); pause();
|
|
88
|
-
if (audio) audio.currentTime = time;
|
|
89
|
-
await render(time); update();
|
|
90
|
-
}
|
|
91
|
-
async function play() {
|
|
92
|
-
if (exporting) return;
|
|
93
|
-
if (time >= config.duration) await seek(0);
|
|
94
|
-
const request = ++playbackRequest;
|
|
95
|
-
if (audio) {
|
|
96
|
-
audio.currentTime = time;
|
|
97
|
-
try { await audio.play(); } catch { status.textContent = copy.audioFailed; }
|
|
98
|
-
}
|
|
99
|
-
if (request !== playbackRequest) { audio?.pause(); return; }
|
|
100
|
-
last = performance.now(); playing = true; update();
|
|
101
|
-
}
|
|
102
|
-
function toggleSound() {
|
|
103
|
-
if (!audio) return;
|
|
104
|
-
muted = !muted; audio.muted = muted; update();
|
|
105
|
-
}
|
|
106
|
-
function setCaptions(value: boolean) { film.captions = value; void render(time); update(); }
|
|
107
|
-
async function fullscreen() {
|
|
108
|
-
try { if (document.fullscreenElement) await document.exitFullscreen(); else await theater.requestFullscreen(); }
|
|
109
|
-
catch { status.textContent = copy.fullscreenFailed; }
|
|
110
|
-
}
|
|
111
|
-
async function saveFrame() {
|
|
112
|
-
pause(); await render(time);
|
|
113
|
-
app.canvas.toBlob((blob) => {
|
|
114
|
-
if (!blob) return;
|
|
115
|
-
const url = URL.createObjectURL(blob);
|
|
116
|
-
downloadUrl(url, `${config.slug}-${Math.round(time * config.fps)}.png`);
|
|
117
|
-
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
118
|
-
}, "image/png");
|
|
119
|
-
}
|
|
120
|
-
const downloadButton = element<HTMLButtonElement>("download");
|
|
121
|
-
const downloadMenu = element<HTMLElement>("download-menu");
|
|
122
|
-
const downloadItems = [...downloadMenu.querySelectorAll<HTMLButtonElement>("button")];
|
|
123
|
-
function closeDownloads(restoreFocus = false) {
|
|
124
|
-
downloadMenu.hidden = true;
|
|
125
|
-
downloadButton.setAttribute("aria-expanded", "false");
|
|
126
|
-
if (restoreFocus) downloadButton.focus();
|
|
127
|
-
}
|
|
128
|
-
function openDownloads(last = false) {
|
|
129
|
-
downloadMenu.hidden = false;
|
|
130
|
-
downloadButton.setAttribute("aria-expanded", "true");
|
|
131
|
-
downloadItems[last ? downloadItems.length - 1 : 0].focus();
|
|
132
|
-
}
|
|
133
|
-
downloadButton.addEventListener("click", () => { if (downloadMenu.hidden) openDownloads(); else closeDownloads(true); });
|
|
134
|
-
downloadButton.addEventListener("keydown", (event) => {
|
|
135
|
-
if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); openDownloads(event.key === "ArrowUp"); }
|
|
136
|
-
});
|
|
137
|
-
downloadMenu.addEventListener("keydown", (event) => {
|
|
138
|
-
if (event.key === "Escape") { event.preventDefault(); closeDownloads(true); }
|
|
139
|
-
if (event.key === "Tab") { event.preventDefault(); closeDownloads(true); }
|
|
140
|
-
const index = downloadItems.indexOf(document.activeElement as HTMLButtonElement);
|
|
141
|
-
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
142
|
-
event.preventDefault();
|
|
143
|
-
const next = event.key === "Home" ? 0 : event.key === "End" ? downloadItems.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + downloadItems.length) % downloadItems.length;
|
|
144
|
-
downloadItems[next].focus();
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
document.addEventListener("click", (event) => { if (event.target instanceof Node && !element("download").parentElement!.contains(event.target)) closeDownloads(); });
|
|
148
|
-
element("export-html").addEventListener("click", async () => {
|
|
149
|
-
closeDownloads(true);
|
|
150
|
-
downloadButton.disabled = true;
|
|
151
|
-
downloadButton.setAttribute("aria-busy", "true");
|
|
152
|
-
status.textContent = copy.exportPreparing;
|
|
153
|
-
try { await downloadHtml(); status.textContent = copy.exportReady; }
|
|
154
|
-
catch (error) { status.textContent = copy.exportFailed; console.error(error); }
|
|
155
|
-
finally { downloadButton.disabled = false; downloadButton.removeAttribute("aria-busy"); }
|
|
156
|
-
});
|
|
157
|
-
for (const chapter of chapters) {
|
|
158
|
-
const paragraph = document.createElement("p"); paragraph.textContent = `${format(chapter.start)} — ${chapter.caption}`;
|
|
159
|
-
element("transcript").append(paragraph);
|
|
160
|
-
}
|
|
161
|
-
if (!embed && !capture) {
|
|
162
|
-
const { ResourceTimeline } = await import("./resource-timeline");
|
|
163
|
-
resources = new ResourceTimeline(element("resource-timeline"), {
|
|
164
|
-
duration: config.duration, fps: config.fps, tracks: film.resources, seek,
|
|
165
|
-
onError: (error) => { status.textContent = copy.failed; console.error(error); },
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
playButton.addEventListener("click", () => { if (playing) pause(); else void play(); });
|
|
169
|
-
soundButton.addEventListener("click", toggleSound);
|
|
170
|
-
captionsButton.addEventListener("click", () => setCaptions(!film.captions));
|
|
171
|
-
element("fullscreen").addEventListener("click", () => { void fullscreen(); });
|
|
172
|
-
element("save").addEventListener("click", () => { closeDownloads(true); void saveFrame(); });
|
|
173
|
-
timeline.addEventListener("input", () => { void seek(Number(timeline.value)); });
|
|
174
|
-
document.addEventListener("visibilitychange", () => { if (document.hidden && !exporting) pause(); });
|
|
175
|
-
document.addEventListener("keydown", (event) => {
|
|
176
|
-
if (exporting || event.target instanceof HTMLElement && event.target.closest("button,input,a,summary,textarea,select,[contenteditable]")) return;
|
|
177
|
-
if (event.code === "Space") { event.preventDefault(); if (playing) pause(); else void play(); }
|
|
178
|
-
if (event.code === "ArrowRight") { event.preventDefault(); void seek(time + 5); }
|
|
179
|
-
if (event.code === "ArrowLeft") { event.preventDefault(); void seek(time - 5); }
|
|
180
|
-
if (event.key.toLowerCase() === "m") toggleSound();
|
|
181
|
-
if (event.key.toLowerCase() === "c") setCaptions(!film.captions);
|
|
182
|
-
if (event.key.toLowerCase() === "f") void fullscreen();
|
|
183
|
-
});
|
|
184
|
-
async function tick(now: number) {
|
|
185
|
-
try {
|
|
186
|
-
if (playing) {
|
|
187
|
-
time = Math.min(config.duration, audio && !audio.paused ? audio.currentTime : time + (now - last) / 1000);
|
|
188
|
-
await render(time);
|
|
189
|
-
if (time >= config.duration) { if (loop) { await seek(0); await play(); } else pause(); }
|
|
190
|
-
update();
|
|
191
|
-
}
|
|
192
|
-
last = now;
|
|
193
|
-
requestAnimationFrame((next) => { void tick(next); });
|
|
194
|
-
} catch (error) { pause(); status.textContent = copy.failed; console.error(error); }
|
|
195
|
-
}
|
|
196
|
-
await render(time); update();
|
|
197
|
-
const api = {
|
|
198
|
-
ready: true, width: config.width, height: config.height, duration: config.duration, fps: config.fps, language: config.language, canvas: app.canvas,
|
|
199
|
-
get state() { return { time, playing, muted, captions: film.captions }; },
|
|
200
|
-
resources: film.resources,
|
|
201
|
-
get texts() { return film.texts; },
|
|
202
|
-
get cues() { return film.cues; },
|
|
203
|
-
seek, play, pause, setCaptions, saveFrame,
|
|
204
|
-
async renderFrame(frame: number, fps = config.fps) { await seek(frame / fps); },
|
|
205
|
-
};
|
|
206
|
-
Object.assign(window, { animaguru: api });
|
|
207
|
-
window.dispatchEvent(new Event("animaguru:ready"));
|
|
208
|
-
if (!embed && params.has("review")) {
|
|
209
|
-
const { reviewFilm } = await import("./review");
|
|
210
|
-
await reviewFilm(api, params.get("review")!);
|
|
211
|
-
} else if (exporting) {
|
|
212
|
-
const { exportMp4 } = await import("./exporter");
|
|
213
|
-
await exportMp4(api, params.get("export")!);
|
|
214
|
-
} else {
|
|
215
|
-
if (autoplay) await play();
|
|
216
|
-
requestAnimationFrame((next) => { void tick(next); });
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
void start().catch((error) => { status.textContent = copy.failed; element("loading")?.remove(); console.error(error); });
|
|
5
|
+
void mountPlayer({ config, Film, chapters });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Application, Texture } from "pixi.js";
|
|
2
|
+
import type { ResourceTrack } from "./timeline-model";
|
|
3
|
+
|
|
4
|
+
export type FilmConfig = {
|
|
5
|
+
title: string; slug: string; width: number; height: number; fps: number;
|
|
6
|
+
duration: number; language: string; audio: string | null;
|
|
7
|
+
};
|
|
8
|
+
export type Branding = {
|
|
9
|
+
name: string; tagline: string; logo: string | null; assets?: Record<string, string>;
|
|
10
|
+
colors: { primary: string; background: string; text: string };
|
|
11
|
+
fonts: { heading: { family: string; file: string | null }; body: { family: string; file: string | null } };
|
|
12
|
+
};
|
|
13
|
+
export type FilmContext = { branding: Branding; logo: Texture | null };
|
|
14
|
+
export type FilmRenderer = {
|
|
15
|
+
captions: boolean; resources: ResourceTrack[];
|
|
16
|
+
texts: { id: string; text: string; x: number; y: number; width: number; height: number }[];
|
|
17
|
+
cues: unknown[];
|
|
18
|
+
render(seconds: number): void | Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
export type PlayerOptions = {
|
|
21
|
+
config: FilmConfig;
|
|
22
|
+
Film: new (app: Application, context?: FilmContext) => FilmRenderer;
|
|
23
|
+
chapters: { start: number; end: number; label: string; caption: string }[];
|
|
24
|
+
branding?: Branding;
|
|
25
|
+
exportEndpoint?: string;
|
|
26
|
+
templateLabel?: string;
|
|
27
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Application, Assets } from "pixi.js";
|
|
2
|
+
import type { PlayerOptions } from "./player-types";
|
|
3
|
+
import copy from "../shared/locales/en/player.json";
|
|
4
|
+
import { publicAsset } from "./assets";
|
|
5
|
+
import { downloadHtml, downloadUrl } from "./download";
|
|
6
|
+
import type { ResourceTimeline } from "./resource-timeline";
|
|
7
|
+
import "./style.css";
|
|
8
|
+
|
|
9
|
+
export async function mountPlayer({ config, Film, chapters, branding, exportEndpoint, templateLabel }: PlayerOptions) {
|
|
10
|
+
const root = document.querySelector<HTMLDivElement>("#app")!;
|
|
11
|
+
// All variable copy is inserted as text, including agent-authored film titles.
|
|
12
|
+
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>
|
|
13
|
+
<main>
|
|
14
|
+
<section id="theater"><div id="canvas-host"><p id="loading" role="status" data-copy="loading"></p></div><div class="transport"><button id="play" class="primary" data-copy="play"></button><span id="time">0:00 / 0:24</span><input id="timeline" type="range" min="0" value="0" step="0.01"/><div class="tools"><button id="sound" data-copy="unmute" aria-pressed="false"></button><button id="captions" aria-pressed="true">CC</button><div class="download-control"><button id="download" class="icon-button" aria-haspopup="menu" aria-expanded="false" aria-controls="download-menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/></svg></button><div id="download-menu" role="menu" hidden><button id="save" role="menuitem" tabindex="-1"><span data-copy="saveFrame"></span><small data-copy="pngFormat"></small></button><button id="export-html" role="menuitem" tabindex="-1"><span data-copy="exportHtml"></span><small data-copy="htmlFormat"></small></button></div></div><button id="fullscreen" class="icon-button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5"/></svg></button></div></div><p id="audio-status" role="status" hidden></p></section>
|
|
15
|
+
<p class="player-note"><span class="preview-meta"><span class="specs"></span><span aria-hidden="true">·</span><span data-copy="starter"></span></span><span data-copy="exportHint"></span></p><p id="status" role="status"></p>
|
|
16
|
+
<section id="resource-timeline"></section>
|
|
17
|
+
<details><summary data-copy="transcript"></summary><div id="transcript"></div></details>
|
|
18
|
+
</main><footer><span data-copy="footer"></span><span data-copy="footerTagline"></span></footer>`;
|
|
19
|
+
for (const element of document.querySelectorAll<HTMLElement>("[data-copy]")) element.textContent = copy[element.dataset.copy as keyof typeof copy];
|
|
20
|
+
const element = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
|
21
|
+
const playButton = element<HTMLButtonElement>("play");
|
|
22
|
+
const soundButton = element<HTMLButtonElement>("sound");
|
|
23
|
+
const captionsButton = element<HTMLButtonElement>("captions");
|
|
24
|
+
const timeline = element<HTMLInputElement>("timeline");
|
|
25
|
+
const theater = element<HTMLElement>("theater");
|
|
26
|
+
const status = element<HTMLElement>("status");
|
|
27
|
+
if (templateLabel) document.querySelector<HTMLElement>(".template")!.textContent = templateLabel;
|
|
28
|
+
const params = new URLSearchParams(location.search);
|
|
29
|
+
const embed = import.meta.env.MODE === "embed" || params.has("embed");
|
|
30
|
+
const capture = params.has("capture");
|
|
31
|
+
const exporting = !embed && params.has("export");
|
|
32
|
+
const autoplay = embed && params.get("autoplay") === "1" && !matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
33
|
+
const loop = embed && params.get("loop") === "1";
|
|
34
|
+
if (capture) document.body.classList.add("capture");
|
|
35
|
+
if (embed) document.body.classList.add("embed");
|
|
36
|
+
if (embed && autoplay && params.get("controls") === "0") document.body.classList.add("no-controls");
|
|
37
|
+
document.documentElement.lang = config.language;
|
|
38
|
+
document.title = `${config.title} · AnimaGuru`;
|
|
39
|
+
document.querySelector(".specs")!.textContent = `${config.width} × ${config.height} / ${config.fps} FPS`;
|
|
40
|
+
timeline.max = String(config.duration);
|
|
41
|
+
theater.setAttribute("aria-label", copy.videoPlayer);
|
|
42
|
+
element("canvas-host").style.aspectRatio = `${config.width} / ${config.height}`;
|
|
43
|
+
timeline.setAttribute("aria-label", copy.timeline);
|
|
44
|
+
captionsButton.setAttribute("aria-label", copy.captions);
|
|
45
|
+
for (const [id, label] of [["download", copy.download], ["fullscreen", copy.fullscreen], ["download-menu", copy.download]] as const) {
|
|
46
|
+
element(id).setAttribute("aria-label", label);
|
|
47
|
+
if (id !== "download-menu") element(id).title = label;
|
|
48
|
+
}
|
|
49
|
+
const format = (time: number) => `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
|
|
50
|
+
|
|
51
|
+
async function start() {
|
|
52
|
+
await document.fonts.ready;
|
|
53
|
+
const app = new Application();
|
|
54
|
+
await app.init({ width: config.width, height: config.height, resolution: 1, autoStart: false, antialias: true, background: "#F4F1E9", preference: "webgl", preserveDrawingBuffer: true });
|
|
55
|
+
app.canvas.setAttribute("aria-label", config.title);
|
|
56
|
+
element("canvas-host").append(app.canvas);
|
|
57
|
+
element("loading").remove();
|
|
58
|
+
if (branding) {
|
|
59
|
+
for (const font of Object.values(branding.fonts)) {
|
|
60
|
+
if (font.file) { const face = new FontFace(font.family, `url(${JSON.stringify(publicAsset(font.file))})`); document.fonts.add(await face.load()); }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const logo = branding?.logo ? await Assets.load(publicAsset(branding.logo)) : null;
|
|
64
|
+
const film = new Film(app, branding ? { branding, logo } : undefined);
|
|
65
|
+
const audio = config.audio ? new Audio() : null;
|
|
66
|
+
const audioStatus = element<HTMLElement>("audio-status");
|
|
67
|
+
const audioFailure = () => { audioStatus.hidden = false; audioStatus.textContent = copy.audioFailed; };
|
|
68
|
+
// User-initiated playback is audible. Browser autoplay and frame capture remain muted.
|
|
69
|
+
const initialMuted = autoplay || capture || exporting;
|
|
70
|
+
if (audio) { audio.crossOrigin = "anonymous"; audio.src = publicAsset(String(config.audio)); audio.preload = "auto"; audio.muted = initialMuted; audio.addEventListener("error", audioFailure); }
|
|
71
|
+
let time = Math.max(0, Math.min(Number(params.get("t")) || 0, config.duration));
|
|
72
|
+
let playing = false;
|
|
73
|
+
let muted = initialMuted;
|
|
74
|
+
let last = performance.now();
|
|
75
|
+
let renderQueue = Promise.resolve();
|
|
76
|
+
let playbackRequest = 0;
|
|
77
|
+
let resources: ResourceTimeline | undefined;
|
|
78
|
+
const update = () => {
|
|
79
|
+
playButton.textContent = playing ? copy.pause : time >= config.duration ? copy.replay : copy.play;
|
|
80
|
+
playButton.setAttribute("aria-label", playButton.textContent);
|
|
81
|
+
element("time").textContent = `${format(time)} / ${format(config.duration)}`;
|
|
82
|
+
timeline.value = String(time);
|
|
83
|
+
timeline.setAttribute("aria-valuetext", `${format(time)} / ${format(config.duration)}`);
|
|
84
|
+
soundButton.textContent = !audio ? copy.noAudio : muted ? copy.unmute : copy.mute;
|
|
85
|
+
soundButton.disabled = !audio;
|
|
86
|
+
soundButton.setAttribute("aria-pressed", String(Boolean(audio) && !muted));
|
|
87
|
+
captionsButton.setAttribute("aria-pressed", String(film.captions));
|
|
88
|
+
resources?.update({ time, playing, captions: film.captions, muted });
|
|
89
|
+
};
|
|
90
|
+
const render = (seconds: number) => {
|
|
91
|
+
// Seeking and frame capture are serialized so decoded video frames cannot race.
|
|
92
|
+
renderQueue = renderQueue.then(() => film.render(seconds));
|
|
93
|
+
return renderQueue;
|
|
94
|
+
};
|
|
95
|
+
function pause() { playbackRequest++; playing = false; audio?.pause(); update(); }
|
|
96
|
+
async function seek(seconds: number) {
|
|
97
|
+
if (!Number.isFinite(seconds)) throw new Error("Seek time must be finite.");
|
|
98
|
+
time = Math.max(0, Math.min(seconds, config.duration)); pause();
|
|
99
|
+
if (audio) audio.currentTime = time;
|
|
100
|
+
await render(time); update();
|
|
101
|
+
}
|
|
102
|
+
async function play() {
|
|
103
|
+
if (exporting) return;
|
|
104
|
+
if (time >= config.duration) await seek(0);
|
|
105
|
+
const request = ++playbackRequest;
|
|
106
|
+
if (audio) {
|
|
107
|
+
audio.currentTime = time;
|
|
108
|
+
try { await audio.play(); audioStatus.hidden = true; } catch { audioFailure(); }
|
|
109
|
+
}
|
|
110
|
+
if (request !== playbackRequest) { audio?.pause(); return; }
|
|
111
|
+
last = performance.now(); playing = true; update();
|
|
112
|
+
}
|
|
113
|
+
function toggleSound() {
|
|
114
|
+
if (!audio) return;
|
|
115
|
+
muted = !muted; audio.muted = muted; update();
|
|
116
|
+
}
|
|
117
|
+
function setCaptions(value: boolean) { film.captions = value; void render(time); update(); }
|
|
118
|
+
async function fullscreen() {
|
|
119
|
+
try { if (document.fullscreenElement) await document.exitFullscreen(); else await theater.requestFullscreen(); }
|
|
120
|
+
catch { status.textContent = copy.fullscreenFailed; }
|
|
121
|
+
}
|
|
122
|
+
async function saveFrame() {
|
|
123
|
+
pause(); await render(time);
|
|
124
|
+
app.canvas.toBlob((blob) => {
|
|
125
|
+
if (!blob) return;
|
|
126
|
+
const url = URL.createObjectURL(blob);
|
|
127
|
+
downloadUrl(url, `${config.slug}-${Math.round(time * config.fps)}.png`);
|
|
128
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
129
|
+
}, "image/png");
|
|
130
|
+
}
|
|
131
|
+
const downloadButton = element<HTMLButtonElement>("download");
|
|
132
|
+
const downloadMenu = element<HTMLElement>("download-menu");
|
|
133
|
+
const downloadItems = [...downloadMenu.querySelectorAll<HTMLButtonElement>("button")];
|
|
134
|
+
function closeDownloads(restoreFocus = false) {
|
|
135
|
+
downloadMenu.hidden = true;
|
|
136
|
+
downloadButton.setAttribute("aria-expanded", "false");
|
|
137
|
+
if (restoreFocus) downloadButton.focus();
|
|
138
|
+
}
|
|
139
|
+
function openDownloads(last = false) {
|
|
140
|
+
downloadMenu.hidden = false;
|
|
141
|
+
downloadButton.setAttribute("aria-expanded", "true");
|
|
142
|
+
downloadItems[last ? downloadItems.length - 1 : 0].focus();
|
|
143
|
+
}
|
|
144
|
+
downloadButton.addEventListener("click", () => { if (downloadMenu.hidden) openDownloads(); else closeDownloads(true); });
|
|
145
|
+
downloadButton.addEventListener("keydown", (event) => {
|
|
146
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); openDownloads(event.key === "ArrowUp"); }
|
|
147
|
+
});
|
|
148
|
+
downloadMenu.addEventListener("keydown", (event) => {
|
|
149
|
+
if (event.key === "Escape") { event.preventDefault(); closeDownloads(true); }
|
|
150
|
+
if (event.key === "Tab") { event.preventDefault(); closeDownloads(true); }
|
|
151
|
+
const index = downloadItems.indexOf(document.activeElement as HTMLButtonElement);
|
|
152
|
+
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? downloadItems.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + downloadItems.length) % downloadItems.length;
|
|
155
|
+
downloadItems[next].focus();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
document.addEventListener("click", (event) => { if (event.target instanceof Node && !element("download").parentElement!.contains(event.target)) closeDownloads(); });
|
|
159
|
+
element("export-html").addEventListener("click", async () => {
|
|
160
|
+
closeDownloads(true);
|
|
161
|
+
downloadButton.disabled = true;
|
|
162
|
+
downloadButton.setAttribute("aria-busy", "true");
|
|
163
|
+
status.textContent = copy.exportPreparing;
|
|
164
|
+
try { await downloadHtml(config, exportEndpoint); status.textContent = copy.exportReady; }
|
|
165
|
+
catch (error) { status.textContent = copy.exportFailed; console.error(error); }
|
|
166
|
+
finally { downloadButton.disabled = false; downloadButton.removeAttribute("aria-busy"); }
|
|
167
|
+
});
|
|
168
|
+
for (const chapter of chapters) {
|
|
169
|
+
const paragraph = document.createElement("p"); paragraph.textContent = `${format(chapter.start)} — ${chapter.caption}`;
|
|
170
|
+
element("transcript").append(paragraph);
|
|
171
|
+
}
|
|
172
|
+
if (!embed && !capture) {
|
|
173
|
+
const { ResourceTimeline } = await import("./resource-timeline");
|
|
174
|
+
resources = new ResourceTimeline(element("resource-timeline"), {
|
|
175
|
+
duration: config.duration, fps: config.fps, tracks: film.resources, seek,
|
|
176
|
+
onError: (error) => { status.textContent = copy.failed; console.error(error); },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
playButton.addEventListener("click", () => { if (playing) pause(); else void play(); });
|
|
180
|
+
soundButton.addEventListener("click", toggleSound);
|
|
181
|
+
captionsButton.addEventListener("click", () => setCaptions(!film.captions));
|
|
182
|
+
element("fullscreen").addEventListener("click", () => { void fullscreen(); });
|
|
183
|
+
element("save").addEventListener("click", () => { closeDownloads(true); void saveFrame(); });
|
|
184
|
+
timeline.addEventListener("input", () => { void seek(Number(timeline.value)); });
|
|
185
|
+
document.addEventListener("visibilitychange", () => { if (document.hidden && !exporting) pause(); });
|
|
186
|
+
document.addEventListener("keydown", (event) => {
|
|
187
|
+
if (exporting || event.target instanceof HTMLElement && event.target.closest("button,input,a,summary,textarea,select,[contenteditable]")) return;
|
|
188
|
+
if (event.code === "Space") { event.preventDefault(); if (playing) pause(); else void play(); }
|
|
189
|
+
if (event.code === "ArrowRight") { event.preventDefault(); void seek(time + 5); }
|
|
190
|
+
if (event.code === "ArrowLeft") { event.preventDefault(); void seek(time - 5); }
|
|
191
|
+
if (event.key.toLowerCase() === "m") toggleSound();
|
|
192
|
+
if (event.key.toLowerCase() === "c") setCaptions(!film.captions);
|
|
193
|
+
if (event.key.toLowerCase() === "f") void fullscreen();
|
|
194
|
+
});
|
|
195
|
+
async function tick(now: number) {
|
|
196
|
+
try {
|
|
197
|
+
if (playing) {
|
|
198
|
+
time = Math.min(config.duration, audio && !audio.paused ? audio.currentTime : time + (now - last) / 1000);
|
|
199
|
+
await render(time);
|
|
200
|
+
if (time >= config.duration) { if (loop) { await seek(0); await play(); } else pause(); }
|
|
201
|
+
update();
|
|
202
|
+
}
|
|
203
|
+
last = now;
|
|
204
|
+
requestAnimationFrame((next) => { void tick(next); });
|
|
205
|
+
} catch (error) { pause(); status.textContent = copy.failed; console.error(error); }
|
|
206
|
+
}
|
|
207
|
+
await render(time); update();
|
|
208
|
+
const api = {
|
|
209
|
+
ready: true, width: config.width, height: config.height, duration: config.duration, fps: config.fps, language: config.language, canvas: app.canvas,
|
|
210
|
+
get state() { return { time, playing, muted, captions: film.captions, audio: { configured: Boolean(audio), readyState: audio?.readyState ?? 0, error: audio?.error?.code ?? null } }; },
|
|
211
|
+
resources: film.resources,
|
|
212
|
+
get texts() { return film.texts; },
|
|
213
|
+
get cues() { return film.cues; },
|
|
214
|
+
seek, play, pause, setCaptions, saveFrame,
|
|
215
|
+
async renderFrame(frame: number, fps = config.fps) { await seek(frame / fps); },
|
|
216
|
+
};
|
|
217
|
+
Object.assign(window, { animaguru: api });
|
|
218
|
+
window.dispatchEvent(new Event("animaguru:ready"));
|
|
219
|
+
if (!embed && params.has("review")) {
|
|
220
|
+
const { reviewFilm } = await import("./review");
|
|
221
|
+
await reviewFilm(api, params.get("review")!);
|
|
222
|
+
} else if (exporting) {
|
|
223
|
+
const { exportMp4 } = await import("./exporter");
|
|
224
|
+
await exportMp4(api, params.get("export")!);
|
|
225
|
+
} else {
|
|
226
|
+
if (autoplay) await play();
|
|
227
|
+
requestAnimationFrame((next) => { void tick(next); });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
await start().catch((error) => { status.textContent = copy.failed; element("loading")?.remove(); console.error(error); });
|
|
231
|
+
}
|
|
@@ -109,3 +109,6 @@ footer span:last-child { font-size: 8px; letter-spacing: 1.5px; }
|
|
|
109
109
|
footer { padding: 25px 20px; }footer span:last-child { display: none; }
|
|
110
110
|
}
|
|
111
111
|
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto; } }
|
|
112
|
+
|
|
113
|
+
#audio-status { margin: 0; padding: 9px 16px; font-size: 12px; color: #8b2f25; background: #fff1eb; }
|
|
114
|
+
.capture #audio-status { display: none; }
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { defineConfig } from "vite";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { workspaceInfo } from "./scripts/project-context.mjs";
|
|
4
|
+
import { workspacePlugin } from "./scripts/workspace-plugin.mjs";
|
|
3
5
|
import { htmlExportPlugin } from "./scripts/html-export-plugin.mjs";
|
|
4
6
|
|
|
7
|
+
const root = fileURLToPath(new URL("./", import.meta.url));
|
|
8
|
+
const workspace = await workspaceInfo(root);
|
|
5
9
|
export default defineConfig({
|
|
6
10
|
base: "./",
|
|
11
|
+
publicDir: workspace ? false : undefined,
|
|
7
12
|
server: { watch: { ignored: ["**/production/exports/**"] } },
|
|
8
|
-
plugins: [
|
|
13
|
+
plugins: [...(workspace ? [workspacePlugin(root)] : []), htmlExportPlugin(root)],
|
|
9
14
|
});
|