@docyrus/animaguru 0.6.0 → 0.7.1
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 +232 -31
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +11 -2
- 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,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { FilmConfig } from "./player-types";
|
|
2
2
|
import { publicAsset } from "./assets";
|
|
3
3
|
|
|
4
4
|
export function downloadUrl(url: string, filename: string) {
|
|
@@ -10,14 +10,14 @@ export function downloadUrl(url: string, filename: string) {
|
|
|
10
10
|
link.remove();
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export async function downloadHtml() {
|
|
13
|
+
export async function downloadHtml(config: FilmConfig, endpoint = "/__animaguru/html") {
|
|
14
14
|
const filename = `${config.slug}-html5.zip`;
|
|
15
15
|
if (!import.meta.env.DEV) {
|
|
16
16
|
// npm run build prepares this alongside the static player. No server API is needed.
|
|
17
17
|
downloadUrl(publicAsset(`downloads/${filename}`), filename);
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
|
-
const response = await fetch(
|
|
20
|
+
const response = await fetch(endpoint, {
|
|
21
21
|
method: "POST", headers: { "X-AnimaGuru-Export": "html5" }, signal: AbortSignal.timeout(180000),
|
|
22
22
|
});
|
|
23
23
|
if (!response.ok) throw new Error(await response.text());
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { Application, Container, Graphics, Text } from "pixi.js";
|
|
1
|
+
import { Application, Container, Graphics, Sprite, Text } from "pixi.js";
|
|
2
2
|
import config from "../film.config.json";
|
|
3
3
|
import story from "../shared/locales/en/film.json";
|
|
4
4
|
import { filmResources } from "./film-resources";
|
|
5
|
+
import type { FilmContext } from "./player-types";
|
|
6
|
+
import type { ResourceTrack } from "./timeline-model";
|
|
5
7
|
|
|
6
8
|
export const chapters = story.chapters;
|
|
7
9
|
const clamp = (value: number) => Math.max(0, Math.min(1, value));
|
|
@@ -14,7 +16,7 @@ function text(value: string, size: number, color = "#16232A", weight: "400" | "7
|
|
|
14
16
|
/** Replace these demo scenes with the film. Keep all motion a function of seconds. */
|
|
15
17
|
export class Film {
|
|
16
18
|
readonly stage = new Container();
|
|
17
|
-
readonly resources
|
|
19
|
+
readonly resources: ResourceTrack[];
|
|
18
20
|
private readonly scenes: { root: Container; art: Container; headline: Text; detail: Text }[] = [];
|
|
19
21
|
private readonly caption = text("", 30, "#FFFFFF", "400");
|
|
20
22
|
private readonly captionBack = new Graphics();
|
|
@@ -34,10 +36,12 @@ export class Film {
|
|
|
34
36
|
visit(this.stage);return result;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
constructor(private readonly app: Application) {
|
|
39
|
+
constructor(private readonly app: Application, context?: FilmContext) {
|
|
40
|
+
const identity = { ...story, brand: context?.branding.name ?? story.brand, tagline: context?.branding.tagline ?? story.tagline };
|
|
41
|
+
this.resources = filmResources(identity, config);
|
|
38
42
|
app.stage.addChild(this.stage);
|
|
39
43
|
this.stage.scale.set(config.width / 1920, config.height / 1080);
|
|
40
|
-
this.stage.addChild(new Graphics().rect(0, 0, 1920, 1080).fill("#F4F1E9"));
|
|
44
|
+
this.stage.addChild(new Graphics().rect(0, 0, 1920, 1080).fill(context?.branding.colors.background ?? "#F4F1E9"));
|
|
41
45
|
// Deterministic paper flecks: never use Math.random() during frame rendering.
|
|
42
46
|
const grain = new Graphics();
|
|
43
47
|
for (let i = 0; i < 850; i++) grain.circle((i * 137.51) % 1920, (i * 71.73) % 1080, 1).fill({ color: "#23302D", alpha: 0.055 });
|
|
@@ -60,16 +64,16 @@ export class Film {
|
|
|
60
64
|
const shape = new Graphics();
|
|
61
65
|
if (index === 0) {
|
|
62
66
|
shape.ellipse(0, 160, 152, 20).fill({ color: "#000000", alpha: 0.07 });
|
|
63
|
-
shape.roundRect(-106, -126, 210, 290, 70).fill("#2C6BD0");
|
|
67
|
+
shape.roundRect(-106, -126, 210, 290, 70).fill(context?.branding.colors.primary ?? "#2C6BD0");
|
|
64
68
|
shape.star(0, -4, 8, 104, 52).fill("#F2D68B");
|
|
65
69
|
shape.circle(0, -4, 30).fill("#FDFBF4");
|
|
66
70
|
} else if (index === 1) {
|
|
67
71
|
shape.roundRect(-168, -144, 334, 270, 24).fill("#DCE7BC");
|
|
68
72
|
shape.circle(88, -67, 36).fill("#F3D88D");
|
|
69
|
-
shape.poly([-166, 108, -55, -54, 32, 67, 95, 4, 166, 126, -166, 126]).fill("#2C6BD0");
|
|
73
|
+
shape.poly([-166, 108, -55, -54, 32, 67, 95, 4, 166, 126, -166, 126]).fill(context?.branding.colors.primary ?? "#2C6BD0");
|
|
70
74
|
shape.star(-148, -137, 4, 62, 17).fill("#263B32");
|
|
71
75
|
} else {
|
|
72
|
-
shape.circle(0, 0, 155).fill("#2C6BD0");
|
|
76
|
+
shape.circle(0, 0, 155).fill(context?.branding.colors.primary ?? "#2C6BD0");
|
|
73
77
|
shape.poly([-34, -65, 75, 0, -34, 65]).fill("#FFFDFA");
|
|
74
78
|
}
|
|
75
79
|
paper.addChild(shape);
|
|
@@ -78,7 +82,7 @@ export class Film {
|
|
|
78
82
|
cardCaption.position.set(-7, 216);
|
|
79
83
|
paper.addChild(cardCaption);
|
|
80
84
|
art.addChild(paper);
|
|
81
|
-
const sparkle = new Graphics().star(236, -310, 4, 67, 19).fill("#2C6BD0").star(-305, 230, 4, 34, 10).fill("#314B38");
|
|
85
|
+
const sparkle = new Graphics().star(236, -310, 4, 67, 19).fill(context?.branding.colors.primary ?? "#2C6BD0").star(-305, 230, 4, 34, 10).fill("#314B38");
|
|
82
86
|
art.addChild(sparkle);
|
|
83
87
|
const eyebrow = text(chapter.eyebrow, 23, "#4D6052");
|
|
84
88
|
eyebrow.position.set(120, 250);
|
|
@@ -93,14 +97,24 @@ export class Film {
|
|
|
93
97
|
this.stage.addChild(root);
|
|
94
98
|
this.scenes.push({ root, art, headline, detail });
|
|
95
99
|
});
|
|
96
|
-
const mark = text(
|
|
100
|
+
const mark = context?.logo ? new Sprite(context.logo) : text(identity.brand, 22, context?.branding.colors.text ?? "#16232A");
|
|
101
|
+
if (context?.logo) mark.scale.set(Math.min(170 / mark.width, 54 / mark.height));
|
|
97
102
|
mark.position.set(120, 88);
|
|
98
|
-
const preview = text(
|
|
103
|
+
const preview = text(identity.tagline, 20, "#637166");
|
|
99
104
|
preview.anchor.set(1, 0);
|
|
100
105
|
preview.position.set(1800, 88);
|
|
101
106
|
this.caption.anchor.set(0.5);
|
|
102
107
|
this.caption.position.set(960, 991);
|
|
103
108
|
this.stage.addChild(mark, preview, this.captionBack, this.caption);
|
|
109
|
+
if (context) {
|
|
110
|
+
const visit = (node: Container) => {
|
|
111
|
+
if (node instanceof Text) node.style.fontFamily = node.style.fontWeight === "700" ? context.branding.fonts.heading.family : context.branding.fonts.body.family;
|
|
112
|
+
for (const child of node.children) visit(child);
|
|
113
|
+
};
|
|
114
|
+
visit(this.stage);
|
|
115
|
+
if (context.branding.logo) this.resources = this.resources.filter((track) => track.id !== "branding");
|
|
116
|
+
if (context.branding.logo) this.resources.push({ id: "brand-logo", label: identity.brand, kind: "image", clips: [{ id: "brand-logo", label: identity.brand, source: context.branding.logo, start: 0, end: config.duration }] });
|
|
117
|
+
}
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
/** Await video seeks/font readiness here before returning when adding media. */
|
|
@@ -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
|
});
|