@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.
Files changed (32) hide show
  1. package/README.md +36 -0
  2. package/dist/animaguru.js +232 -31
  3. package/package.json +1 -1
  4. package/skills/animaguru/SKILL.md +11 -2
  5. package/skills/animaguru-brand-kit/SKILL.md +30 -0
  6. package/skills/animaguru-video/SKILL.md +9 -2
  7. package/templates/video/AGENTS.md +1 -1
  8. package/templates/video/WORKSPACE-AGENTS.md +21 -0
  9. package/templates/video/gitignore +4 -0
  10. package/templates/video/index.html +1 -1
  11. package/templates/video/package.json +2 -2
  12. package/templates/video/scripts/build.mjs +23 -5
  13. package/templates/video/scripts/dev.mjs +13 -0
  14. package/templates/video/scripts/export-html.mjs +6 -2
  15. package/templates/video/scripts/export-mp4.mjs +11 -9
  16. package/templates/video/scripts/html-export-plugin.mjs +6 -3
  17. package/templates/video/scripts/html-export.mjs +10 -5
  18. package/templates/video/scripts/install-example.mjs +15 -10
  19. package/templates/video/scripts/preview.mjs +11 -0
  20. package/templates/video/scripts/project-context.mjs +91 -0
  21. package/templates/video/scripts/review.mjs +9 -5
  22. package/templates/video/scripts/starter-film.sha256 +1 -1
  23. package/templates/video/scripts/workspace-plugin.mjs +73 -0
  24. package/templates/video/shared/locales/en/player.json +1 -1
  25. package/templates/video/src/assets.ts +2 -2
  26. package/templates/video/src/download.ts +3 -3
  27. package/templates/video/src/film.ts +24 -10
  28. package/templates/video/src/main.ts +2 -216
  29. package/templates/video/src/player-types.ts +27 -0
  30. package/templates/video/src/player.ts +231 -0
  31. package/templates/video/src/style.css +3 -0
  32. package/templates/video/vite.config.mjs +6 -1
@@ -5,9 +5,11 @@ description: Build or revise an explainer, advertisement or general video in an
5
5
 
6
6
  # AnimaGuru video studio
7
7
 
8
- Read `animaguru.project.json` for saved choices and `animaguru.templates.json` for the installed categories and styles. New workspaces start with `category`, `template`, `skill` and optional `workflow` unset. The category describes project purpose; templates define visual and animation style. The audience, subject, tone, message and narrative structure come from the user's brief.
8
+ In a multi-animation workspace, select/create the film under `animations/NAME/` first and read root `branding/brand.json` and supplied brand guides. If the shared kit is still the scaffold placeholder or incomplete, read sibling `animaguru-brand-kit`: use a website or guide already supplied in the first prompt, or ask once for the official website/brand guide before researching and populating `branding/`. A user may choose to proceed without a website. Reuse the completed kit for later films. Production paths in this skill and style recipes are relative to that selected film; shared examples and player code are in root `runtime/`.
9
9
 
10
- For a new video, **complete category selection, then style selection, then the quick interview**, in that order. Keep each selection question separate and wait for its answer. Explicit valid choices in the user's message count as answers; do not ask them again. For revisions, reuse saved choices and the completed brief. Do not regenerate the starter or run `animaguru create` inside it.
10
+ Read the selected film's `animaguru.project.json` for saved choices and `animaguru.templates.json` for the installed categories and styles. New workspaces start with `category`, `template`, `skill` and optional `workflow` unset. The category describes project purpose; templates define visual and animation style. The audience, subject, tone, message and narrative structure come from the user's brief.
11
+
12
+ For a new video, after the shared brand source is established when needed, **complete category selection, then style selection, then the quick interview**, in that order. Keep each selection question separate and wait for its answer. Explicit valid choices in the user's message count as answers; do not ask them again. For revisions, reuse saved choices and the completed brief. Do not regenerate the starter or run `animaguru create` inside it.
11
13
 
12
14
  Read the sibling `animaguru` skill for authentication, model discovery, credit authorization, paid submissions, job recovery, and downloads. Read `pixijs` and the relevant specialized skills for implementation. Both are installed locally. Use ImageGen for supporting bitmap assets if it is available and appropriate; otherwise use AnimaGuru. Do not assume an image-generation tool is available in every agent environment.
13
15
 
@@ -77,3 +79,8 @@ Use `src/studies/timing.ts` for analytic easing, measured stroke reveals and cue
77
79
  ## Sharing a finished film
78
80
 
79
81
  When the user requests hosting or sharing, use `animaguru submit --yes` from this workspace; it builds and submits the portable HTML5 animation. For an MP4 use `--file production/exports/film.mp4`. Read the local AnimaGuru skill's submission section for audience, recipient and recovery rules. Private is the default; use `--share "reviewer@example.com"` for selected verified accounts or `--visibility public` for [the public Videos section](https://animaguru.com/videos). Preserve the user's audience choice and include a cover using `--poster` when available. Local creation or export alone does not imply publication.
82
+
83
+
84
+ ## Multi-animation workspace
85
+
86
+ Detect root `animaguru.workspace.json`. For a new film, use `animaguru create animation NAME`; preserve other animation folders. Keep scenes, story copy, local media and production outputs in the selected folder. Read root `AGENTS.md` for shared commands and path mapping. Do not duplicate the player, exporter, skills or dependencies into films. New schema 4 projects inherit root branding and keep their own brief/category/style. Work from the root and pass `--animation NAME` to build, HTML5/MP4 export, review and example commands. For generated media helpers in a film under animations/NAME/src/, import from ../../../runtime/. Narration must be a configured local public asset and should be checked in hosted/embed playback as well as the editor.
@@ -13,7 +13,7 @@ Selections and setup progress are recorded in `animaguru.project.json`. Availabl
13
13
  ## Files and commands
14
14
 
15
15
  - `film.config.json`: title, output slug, dimensions, fps, duration, language and optional audio path relative to `public/`.
16
- - `src/film.ts`: replace the demo with the actual deterministic scene timeline. `src/main.ts`: AnimaGuru player and capture API. `src/exporter.ts` and `scripts/export-mp4.mjs`: capture transport and verified encoding.
16
+ - `src/film.ts`: replace the demo with the actual deterministic scene timeline. `src/main.ts`: film entry; `src/player.ts`: reusable AnimaGuru player and capture API. `src/exporter.ts` and `scripts/export-mp4.mjs`: capture transport and verified encoding.
17
17
  - Keep `src/film-resources.ts` aligned with the real film: add every image/video/audio/graphic resource and text layer, its actual cue times and public-relative source path. `Film.resources` powers the full-width timeline below the player. Do not leave demo resources in a finished film or invent audio tracks when none exist. The timeline belongs to the authoring player, outside embed/capture mode.
18
18
  - `shared/locales/`: all user-facing copy. `public/assets/`: local media. `production/`: brief, sources, storyboard, narration, requests and receipts.
19
19
  - Use `publicAsset("assets/file.ext")` from `src/assets.ts` for public media. Root-absolute paths break an HTML5 export hosted in a subfolder. `npm run export:html` produces a portable static ZIP with an iframe example; the download menu also supports this format. `npm run build` includes a ready-made HTML5 download for static hosting.
@@ -0,0 +1,21 @@
1
+ # AnimaGuru multi-animation workspace
2
+
3
+ This root owns shared branding, installed skills, dependencies, the PixiJS player and export tools. Each film lives in `animations/NAME/`. Open this root in Codex or Claude Code.
4
+
5
+ - For a new film, run `animaguru create animation NAME` here; infer a useful name from the request when none was supplied. Reuse an existing folder when the user is revising that film. If several films could match the request, clarify which one before editing it.
6
+ - Read `branding/brand.json`, branding files/guides in `branding/`, and any relevant branding files the user placed at the root. If the kit is still the scaffold placeholder or incomplete, read `.agents/skills/animaguru-brand-kit/SKILL.md` (or `.claude/skills/animaguru-brand-kit/SKILL.md` in Claude Code). Use an official website or brand guide in the first prompt; if none was supplied, ask for one before researching the brand. Populate `branding/` and record sources in `branding/brand-guide.md`. Every animation inherits these shared settings. Keep guides local; exports include registered web assets, rather than the whole branding folder.
7
+ - Read the installed `animaguru-video` skill and the selected film's `animaguru.project.json`. After establishing the shared brand source, first ask for **Explainer**, **Advertisement**, or **General Video**; then ask for a visual style using `animaguru.templates.json` and https://animaguru.com/animation-styles; then run the compact creative interview. Reuse explicit choices and supplied/completed answers. Save the brief inside that film's `production/` folder. Purpose and visual style are separate decisions.
8
+ - Production paths such as `film.config.json`, `src/film.ts`, `src/film-resources.ts`, `public/assets/`, `shared/locales/en/film.json` and `production/` in style recipes are relative to the selected animation. Shared runtime examples are in root `runtime/studies/`. Do not copy the player, scripts, skills or node_modules into each animation.
9
+ - Use the shared `runtime/assets.ts` helper for portable public media paths. A film constructor may receive a `FilmContext` with the shared branding, logo texture and registered asset paths. Imported artwork and audio belong in the selected film's public/assets/. Set its soundtrack in film.config.json; use null for an intentionally silent film. Narration in production/ is not automatically a web asset.
10
+
11
+ Run commands from the workspace root:
12
+
13
+ - `npm run dev`: the workspace hub and separate `/animations/NAME/` players.
14
+ - `npm run build -- --animation NAME`: typecheck and build that film.
15
+ - `npm run export:html -- --animation NAME`: portable HTML5 ZIP with local media and common branding.
16
+ - `npm run export:mp4 -- --animation NAME`: verified MP4; open the printed capture page.
17
+ - `npm run review -- --animation NAME`: frame, text, seeking and soundtrack checks.
18
+ - `npm run example -- STYLE_ID --animation NAME`: install a study only in an untouched selected film.
19
+ - `animaguru submit animations/NAME --yes`: private hosting; choose recipients/public sharing from the user's request.
20
+
21
+ Preserve the deterministic async seeking/frame API. Keep actual scene text and captions in the canvas, and align resources/cues with real assets and timing. Production files and export receipts stay with their animation. Shared infrastructure changes affect every film, so use those for reusable player/export improvements and keep film-specific direction inside its own folder. Follow the user's model and credit budget; workspace creation authorizes no paid generation.
@@ -9,3 +9,7 @@ production/frames/
9
9
  *secrets*.json
10
10
  *.log
11
11
  .DS_Store
12
+
13
+ .animaguru/cache/
14
+ animations/*/production/exports/
15
+ animations/*/production/frames/
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="theme-color" content="#f5f3ed" />
7
- <link rel="icon" type="image/svg+xml" href="/brand/logo-mark.svg" />
7
+ <link rel="icon" type="image/svg+xml" href="./brand/logo-mark.svg" />
8
8
  <title>AnimaGuru · Motion studio</title>
9
9
  </head>
10
10
  <body>
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "dev": "vite --host 127.0.0.1 --port 5195",
11
- "build": "tsc --noEmit && node scripts/build.mjs",
12
- "preview": "vite preview --host 127.0.0.1 --port 5195",
11
+ "build": "node scripts/build.mjs",
12
+ "preview": "node scripts/preview.mjs",
13
13
  "export:mp4": "node scripts/export-mp4.mjs",
14
14
  "export:html": "node scripts/export-html.mjs",
15
15
  "example": "node scripts/install-example.mjs",
@@ -1,12 +1,30 @@
1
1
  import { build } from "vite";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
+ import { spawnSync } from "node:child_process";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import path from "node:path";
5
6
  import { buildHtmlExport } from "./html-export.mjs";
7
+ import { projectArguments, projectContext } from "./project-context.mjs";
8
+ import { workspacePlugin } from "./workspace-plugin.mjs";
6
9
 
7
10
  const root = fileURLToPath(new URL("../", import.meta.url));
8
- await build({ root });
9
- const html = await buildHtmlExport(root);
10
- await mkdir(path.join(root, "dist/downloads"), { recursive: true });
11
- await writeFile(path.join(root, "dist/downloads", html.filename), html.bytes);
12
- console.log(`HTML5 download ready: dist/downloads/${html.filename}`);
11
+ const { animation, rest } = projectArguments(process.argv.slice(2));
12
+ if (rest.length) throw new Error("Usage: npm run build -- [--animation NAME]");
13
+ const context = await projectContext(root, animation);
14
+ let typecheckArguments = ["--noEmit"];
15
+ if (context.workspace) {
16
+ const cache = path.join(root, ".animaguru/cache", context.name);
17
+ await mkdir(cache, { recursive: true });
18
+ const imported = (file) => JSON.stringify(file.split(path.sep).join("/"));
19
+ await writeFile(path.join(cache, "contract.ts"), `import config from ${imported(path.join(context.projectRoot,"film.config.json"))};\nimport {Film,chapters} from ${imported(path.join(context.projectRoot,"src/film.ts"))};\nimport type {PlayerOptions} from ${imported(path.join(root,"runtime/player-types.ts"))};\nconst player: PlayerOptions = {config,Film,chapters};\nvoid player;\n`);
20
+ await writeFile(path.join(cache, "tsconfig.json"), JSON.stringify({ extends: path.join(root, "tsconfig.json"), include: [path.join(root,"runtime/**/*.ts"),path.join(context.projectRoot,"src/**/*.ts"),path.join(cache,"contract.ts")] }));
21
+ typecheckArguments = ["--project", path.join(cache, "tsconfig.json"), "--noEmit"];
22
+ }
23
+ const tsc = spawnSync(process.execPath, [path.join(root, "node_modules/typescript/bin/tsc"), ...typecheckArguments], { cwd: root, stdio: "inherit" });
24
+ if (tsc.status !== 0) process.exit(tsc.status ?? 1);
25
+ const output = context.workspace ? path.join(root, "dist/animations", context.name) : path.join(root, "dist");
26
+ await build({ root, configFile: false, base: "./", publicDir: context.workspace ? false : undefined, plugins: context.workspace ? [workspacePlugin(root, { animation: context.name })] : [], build: { outDir: output, emptyOutDir: true } });
27
+ const html = await buildHtmlExport(root, { animation });
28
+ await mkdir(path.join(output, "downloads"), { recursive: true });
29
+ await writeFile(path.join(output, "downloads", html.filename), html.bytes);
30
+ console.log(`HTML5 download ready: ${path.join(output, "downloads", html.filename)}`);
@@ -0,0 +1,13 @@
1
+ import { createServer } from "vite";
2
+ import { fileURLToPath } from "node:url";
3
+ import { projectArguments, projectContext, workspaceInfo } from "./project-context.mjs";
4
+ import { workspacePlugin } from "./workspace-plugin.mjs";
5
+ import { htmlExportPlugin } from "./html-export-plugin.mjs";
6
+ const root = fileURLToPath(new URL("../", import.meta.url));
7
+ const { animation, rest } = projectArguments(process.argv.slice(2));
8
+ let port = 5195;
9
+ if (rest.length) { if (rest.length !== 2 || rest[0] !== "--port" || !/^\d+$/.test(rest[1])) throw new Error("Usage: npm run dev -- [--animation NAME] [--port NUMBER]"); port = Number(rest[1]); }
10
+ const workspace = await workspaceInfo(root);
11
+ const context = animation ? await projectContext(root, animation) : null;
12
+ const server = await createServer({ root, configFile: false, base: "./", publicDir: workspace ? false : undefined, server: { host: "127.0.0.1", port, strictPort: true }, plugins: [...(workspace ? [workspacePlugin(root)] : []), htmlExportPlugin(root)] });
13
+ await server.listen(); console.log(`AnimaGuru workspace: http://127.0.0.1:${port}${context?.route ?? "/"}`);
@@ -2,11 +2,15 @@ import { writeFile } from "node:fs/promises";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
+ import { projectArguments, projectContext } from "./project-context.mjs";
5
6
  import { buildHtmlExport } from "./html-export.mjs";
6
7
 
7
8
  const root = fileURLToPath(new URL("../", import.meta.url));
8
- const result = await buildHtmlExport(root);
9
- const destination = path.join(root, "production/exports", result.filename.replace(/\.zip$/, `-${randomUUID()}.zip`));
9
+ const { animation, rest } = projectArguments(process.argv.slice(2));
10
+ if (rest.some((value) => value !== "--json")) throw new Error("Usage: npm run export:html -- [--animation NAME] [--json]");
11
+ const context = await projectContext(root, animation);
12
+ const result = await buildHtmlExport(root, { animation });
13
+ const destination = path.join(context.output, result.filename.replace(/\.zip$/, `-${randomUUID()}.zip`));
10
14
  await writeFile(destination, result.bytes, { flag: "wx" });
11
15
  const metadata = { path: destination, filename: result.filename, bytes: result.bytes.length, files: result.files };
12
16
  console.log(process.argv.includes("--json") ? JSON.stringify(metadata) : `Saved HTML5 animation: ${destination}\nUnzip, upload the folder to a static host, and use embed-example.html for iframe markup.`);
@@ -7,13 +7,17 @@ import { fileURLToPath } from "node:url";
7
7
  import path from "node:path";
8
8
  import ffmpeg from "@ffmpeg-installer/ffmpeg";
9
9
  import ffprobe from "@ffprobe-installer/ffprobe";
10
+ import { projectArguments, projectContext, publicFile } from "./project-context.mjs";
11
+ import { workspacePlugin } from "./workspace-plugin.mjs";
10
12
 
11
13
  const root = fileURLToPath(new URL("../", import.meta.url));
12
- const args = process.argv.slice(2);
14
+ const selection = projectArguments(process.argv.slice(2));
15
+ const args = selection.rest;
13
16
  if (args.length && (args.length !== 2 || args[0] !== "--port" || !/^\d+$/.test(args[1]))) throw new Error("Usage: npm run export:mp4 -- [--port 5196]");
14
17
  const port = Number(args[1] ?? 5196);
15
18
  if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Export port must be 1024–65535.");
16
- const config = JSON.parse(await readFile(path.join(root, "film.config.json"), "utf8"));
19
+ const context = await projectContext(root, selection.animation);
20
+ const config = context.config;
17
21
  const { width, height, fps, duration, language } = config;
18
22
  if (![width, height].every((n) => Number.isInteger(n) && n >= 2 && n <= 4096 && n % 2 === 0) || ![24, 25, 30, 60].includes(fps) || !Number.isFinite(duration) || duration <= 0 || duration > 600 || Math.abs(duration * fps - Math.round(duration * fps)) > 0.000001) throw new Error("Use even dimensions up to 4096px, 24/25/30/60 fps, and a frame-aligned duration up to 600s in film.config.json.");
19
23
  if (typeof language !== "string" || !/^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/.test(language)) throw new Error("Set a language code in film.config.json.");
@@ -22,11 +26,9 @@ const languageTag = ({ en: "eng", tr: "tur", de: "deu", fr: "fra", es: "spa" })[
22
26
  let audioSource;
23
27
  if (config.audio !== null) {
24
28
  if (typeof config.audio !== "string" || !config.audio) throw new Error("audio must be null or a local path relative to public/.");
25
- const publicRoot = await realpath(path.join(root, "public"));
26
- audioSource = await realpath(path.resolve(publicRoot, config.audio));
27
- if (!audioSource.startsWith(publicRoot + path.sep)) throw new Error("The soundtrack must be inside public/.");
29
+ audioSource = await publicFile(context, config.audio);
28
30
  }
29
- const outputDirectory = path.join(root, "production/exports");
31
+ const outputDirectory = context.output;
30
32
  await mkdir(outputDirectory, { recursive: true });
31
33
  let destination = path.join(outputDirectory, `${config.slug}-${language}.mp4`);
32
34
  for (const candidate of [destination, destination.replace(/\.mp4$/, ".partial.mp4")]) {
@@ -186,9 +188,9 @@ async function route(request, response, url) {
186
188
  }
187
189
 
188
190
  server = await createServer({
189
- configFile: false, root, clearScreen: false,
191
+ configFile: false, root, base: "./", publicDir: context.workspace ? false : undefined, clearScreen: false,
190
192
  server: { host: "127.0.0.1", port, strictPort: true, fs: { allow: [root] } },
191
- plugins: [{ name: "animaguru-mp4-export", configureServer(vite) {
193
+ plugins: [...(context.workspace ? [workspacePlugin(root, { animation: context.name })] : []), { name: "animaguru-mp4-export", configureServer(vite) {
192
194
  vite.middlewares.use((request, response, next) => {
193
195
  const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
194
196
  if (!url.pathname.startsWith("/__export/")) return next();
@@ -206,4 +208,4 @@ const idleCheck = setInterval(() => {
206
208
  process.once("SIGINT", () => { void cleanup(new Error("Cancelled.")); });
207
209
  process.once("SIGTERM", () => { void cleanup(new Error("Cancelled.")); });
208
210
  try { await server.listen(); } catch (error) { await cleanup(error); throw error; }
209
- console.log(`Open this local export page and keep it open until complete:\nhttp://127.0.0.1:${port}/?capture=1&export=${token}\nOutput: ${destination}\nThe preview can stay open. This capture runs locally without generation credits.`);
211
+ console.log(`Open this local export page and keep it open until complete:\nhttp://127.0.0.1:${port}${context.route}?capture=1&export=${token}\nOutput: ${destination}\nThe preview can stay open. This capture runs locally without generation credits.`);
@@ -10,15 +10,18 @@ export function acceptsHtmlExport(request) {
10
10
  }
11
11
 
12
12
  export function htmlExportPlugin(root) {
13
- let pending;
13
+ const pendingByAnimation = new Map();
14
14
  return { name: "spriteguru-html5-export", configureServer(server) {
15
15
  server.middlewares.use((request, response, next) => {
16
16
  if (request.url?.split("?")[0] !== "/__animaguru/html") return next();
17
17
  if (!acceptsHtmlExport(request)) { response.writeHead(403).end("Export must be requested from this player."); return; }
18
18
  // A separate process keeps Vite's production build from changing the dev server's NODE_ENV.
19
- pending ??= promisify(execFile)(process.execPath, [path.join(root, "scripts/export-html.mjs"), "--json"], {
19
+ const animation = new URL(request.url, "http://localhost").searchParams.get("animation") ?? "standalone";
20
+ let pending = pendingByAnimation.get(animation);
21
+ pending ??= promisify(execFile)(process.execPath, [path.join(root, "scripts/export-html.mjs"), "--json", ...(new URL(request.url, "http://localhost").searchParams.get("animation") ? ["--animation", new URL(request.url, "http://localhost").searchParams.get("animation")] : [])], {
20
22
  cwd: root, env: { ...process.env, NODE_ENV: "production" }, timeout: 180000, maxBuffer: 1024 * 1024,
21
- }).then(({ stdout }) => JSON.parse(stdout)).finally(() => { pending = undefined; });
23
+ }).then(({ stdout }) => JSON.parse(stdout)).finally(() => { pendingByAnimation.delete(animation); });
24
+ pendingByAnimation.set(animation, pending);
22
25
  void pending.then((result) => {
23
26
  if (response.destroyed) return;
24
27
  response.writeHead(200, { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="${result.filename}"`, "Content-Length": result.bytes, "Cache-Control": "no-store" });
@@ -2,6 +2,8 @@ import { mkdtemp, mkdir, readFile, readdir, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { zipSync, strToU8 } from "fflate";
5
+ import { projectContext, publicFile } from "./project-context.mjs";
6
+ import { workspacePlugin } from "./workspace-plugin.mjs";
5
7
 
6
8
  const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
7
9
 
@@ -30,25 +32,28 @@ export async function packageHtmlDirectory(directory, config) {
30
32
  }
31
33
  await collect();
32
34
  if (!files["index.html"]) throw new Error("The HTML5 build did not produce index.html.");
35
+ if (config.audio && !files[config.audio]) throw new Error(`Configured soundtrack is missing from the HTML5 build: ${config.audio}. Put narration in public/ and use a public-relative path.`);
33
36
  const title = escapeHtml(config.title);
34
37
  const iframe = `<iframe src="./index.html" title="${title}" style="width:100%;aspect-ratio:${config.width} / ${config.height};border:0" allow="autoplay; fullscreen" allowfullscreen loading="lazy"></iframe>`;
35
38
  // Extra height keeps the embedded controls separate from the film's exact aspect ratio.
36
39
  const embed = iframe.replace(`aspect-ratio:${config.width} / ${config.height}`, `height:100%`);
37
40
  files["embed-example.html"] = strToU8(`<!doctype html>\n<html lang="${escapeHtml(config.language)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title></head><body style="margin:0;background:#f5f3ed"><div style="position:relative;width:100%;height:calc(100vw * ${config.height} / ${config.width} + 112px);max-height:100vh">${embed}</div></body></html>\n`);
38
41
  files["README.txt"] = strToU8(`AnimaGuru HTML5 animation: ${config.title}\n\nUpload this entire folder to any static HTTP(S) host. Open index.html for the animation or embed-example.html for a working iframe example. ES modules and local media need an HTTP(S) server; do not open index.html with file://.\n\nEmbed the hosted animation:\n${iframe.replace('src="./index.html"', 'src="https://your-site.example/animation/index.html"')}\n\nAllow extra iframe height for controls, or add ?controls=0&autoplay=1&loop=1 for a canvas-only embed. Playback starts muted; viewers can unmute with the sound button. Autoplay respects reduced-motion preferences (controls remain available in that case).\n\nThis archive contains the compiled PixiJS animation and local media. It needs no Node.js, AnimaGuru account, API, or MP4 conversion on the host. Keep all files together; relative paths support nested hosting folders. External assets explicitly added by an author still require their external sources.\n`);
39
- files["manifest.json"] = strToU8(JSON.stringify({ format: "spriteguru-html5", version: 1, title: config.title, width: config.width, height: config.height, duration: config.duration, language: config.language, files: manifest }, null, 2) + "\n");
42
+ files["manifest.json"] = strToU8(JSON.stringify({ format: "spriteguru-html5", version: 1, title: config.title, width: config.width, height: config.height, duration: config.duration, language: config.language, audio: config.audio ?? null, files: manifest }, null, 2) + "\n");
40
43
  return { filename: `${config.slug}-html5.zip`, bytes: zipSync(files), files: Object.keys(files).length };
41
44
  }
42
45
 
43
- export async function buildHtmlExport(root) {
44
- const config = JSON.parse(await readFile(path.join(root, "film.config.json"), "utf8"));
46
+ export async function buildHtmlExport(root, { animation } = {}) {
47
+ const context = await projectContext(root, animation);
48
+ const config = context.config;
49
+ if (config.audio) await publicFile(context, config.audio);
45
50
  validateHtmlConfig(config);
46
- const exports = path.join(root, "production/exports");
51
+ const exports = context.output;
47
52
  await mkdir(exports, { recursive: true });
48
53
  const temporary = await mkdtemp(path.join(exports, ".html5-build-"));
49
54
  try {
50
55
  const { build } = await import("vite");
51
- await build({ root, configFile: false, base: "./", mode: "embed", logLevel: "silent", build: { outDir: temporary, emptyOutDir: true, sourcemap: false } });
56
+ await build({ root, configFile: false, base: "./", publicDir: context.workspace ? false : undefined, plugins: context.workspace ? [workspacePlugin(root, { animation: context.name, mode: "embed" })] : [], mode: "embed", logLevel: "silent", build: { outDir: temporary, emptyOutDir: true, sourcemap: false } });
52
57
  return await packageHtmlDirectory(temporary, config);
53
58
  } finally { await rm(temporary, { recursive: true, force: true }); }
54
59
  }
@@ -2,13 +2,17 @@ import { createHash } from "node:crypto";
2
2
  import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { projectArguments, projectContext } from "./project-context.mjs";
5
6
 
6
- const root=fileURLToPath(new URL("../",import.meta.url));
7
- const copy=JSON.parse(await readFile(path.join(root,"shared/locales/en/style-studies.json"),"utf8"));
8
- const id=process.argv[2];
9
- if(process.argv.length!==3 || !Object.hasOwn(copy.styles,id)) throw new Error(`Choose an installed example: npm run example -- ${Object.keys(copy.styles).join(" | ")}`);
7
+ const workspaceRoot=fileURLToPath(new URL("../",import.meta.url));
8
+ const selection=projectArguments(process.argv.slice(2));
9
+ const context=await projectContext(workspaceRoot,selection.animation);
10
+ const root=context.projectRoot;
11
+ const copy=JSON.parse(await readFile(path.join(workspaceRoot,"shared/locales/en/style-studies.json"),"utf8"));
12
+ const id=selection.rest[0];
13
+ if(selection.rest.length!==1 || !Object.hasOwn(copy.styles,id)) throw new Error(`Choose an installed example: npm run example -- ${Object.keys(copy.styles).join(" | ")}`);
10
14
  const film=await readFile(path.join(root,"src/film.ts"),"utf8");
11
- const expected=(await readFile(path.join(root,"scripts/starter-film.sha256"),"utf8")).trim();
15
+ const expected=(await readFile(path.join(workspaceRoot,context.workspace?"templates/animation/starter-film.sha256":"scripts/starter-film.sha256"),"utf8")).trim();
12
16
  const digest=createHash("sha256").update(film).digest("hex");
13
17
  // Reruns also refuse edited studies; the exact last installer output is hashed.
14
18
  let previous;
@@ -16,17 +20,18 @@ try { previous=JSON.parse(await readFile(path.join(root,"production/example-sele
16
20
  if(digest!==expected && digest!==previous?.filmSha256) throw new Error("This film has been authored. Import the study into your timeline instead; example installation refuses to replace it.");
17
21
  const backup=path.join(root,"production/example-backup");
18
22
  await mkdir(backup,{recursive:true});
19
- for(const file of ["src/film.ts","film.config.json","shared/locales/en/player.json"]) {
23
+ for(const file of ["src/film.ts","film.config.json",context.workspace?"animaguru.project.json":"shared/locales/en/player.json"]) {
20
24
  const destination=path.join(backup,path.basename(file));
21
25
  try { await copyFile(path.join(root,file),destination,1); } catch(error) { if(error.code!=="EEXIST")throw error; }
22
26
  }
23
- const authored=`import { StyleStudyFilm, studyChapters } from "./studies/film";\nimport type { Application } from "pixi.js";\nexport const chapters = studyChapters(${JSON.stringify(id)});\nexport class Film extends StyleStudyFilm {\n constructor(app: Application) { super(app, ${JSON.stringify(id)}); }\n}\n`;
27
+ const studyImport=context.workspace?"../../../runtime/studies/film":"./studies/film";
28
+ const authored=`import { StyleStudyFilm, studyChapters } from "${studyImport}";\nimport type { Application } from "pixi.js";\nexport const chapters = studyChapters(${JSON.stringify(id)});\nexport class Film extends StyleStudyFilm {\n constructor(app: Application) { super(app, ${JSON.stringify(id)}); }\n}\n`;
24
29
  const config=JSON.parse(await readFile(path.join(root,"film.config.json"),"utf8"));
25
30
  Object.assign(config,{title:copy.styles[id].name,slug:id,duration:8,audio:null,language:"en"});
26
- const player=JSON.parse(await readFile(path.join(root,"shared/locales/en/player.json"),"utf8"));
27
- Object.assign(player,{template:copy.styles[id].name,starter:"Original style study · adapt to your brief"});
31
+ const player=JSON.parse(await readFile(path.join(root,context.workspace?"animaguru.project.json":"shared/locales/en/player.json"),"utf8"));
32
+ Object.assign(player,context.workspace?{template:id,templateLabel:copy.styles[id].name}:{template:copy.styles[id].name,starter:"Original style study · adapt to your brief"});
28
33
  await writeFile(path.join(root,"src/film.ts"),authored);
29
34
  await writeFile(path.join(root,"film.config.json"),JSON.stringify(config,null,2)+"\n");
30
- await writeFile(path.join(root,"shared/locales/en/player.json"),JSON.stringify(player,null,2)+"\n");
35
+ await writeFile(path.join(root,context.workspace?"animaguru.project.json":"shared/locales/en/player.json"),JSON.stringify(player,null,2)+"\n");
31
36
  await writeFile(path.join(root,"production/example-selection.json"),JSON.stringify({id,filmSha256:createHash("sha256").update(authored).digest("hex")},null,2)+"\n");
32
37
  console.log(`Installed ${copy.styles[id].name}. Run npm run dev. HTML5 and MP4 export use this film. Original starter files are in production/example-backup/.`);
@@ -0,0 +1,11 @@
1
+ import { preview } from "vite";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+ import { projectArguments, projectContext } from "./project-context.mjs";
5
+ const root = fileURLToPath(new URL("../", import.meta.url));
6
+ const { animation, rest } = projectArguments(process.argv.slice(2));
7
+ let port = 5195;
8
+ if (rest.length) { if (rest.length !== 2 || rest[0] !== "--port" || !/^\d+$/.test(rest[1])) throw new Error("Usage: npm run preview -- [--animation NAME] [--port NUMBER]"); port = Number(rest[1]); }
9
+ const context = await projectContext(root, animation);
10
+ await preview({ root, configFile: false, build: { outDir: context.workspace ? path.join(root, "dist/animations", context.name) : path.join(root, "dist") }, preview: { host: "127.0.0.1", port, strictPort: true } });
11
+ console.log(`Static animation preview: http://127.0.0.1:${port}/`);
@@ -0,0 +1,91 @@
1
+ import { access, readFile, readdir, realpath, stat } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import path from "node:path";
4
+
5
+ export const animationName = (value) => typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,79}$/.test(value);
6
+ const extensions = new Set(["png", "jpg", "jpeg", "webp", "svg", "gif", "woff", "woff2", "ttf", "otf", "mp3", "wav", "m4a", "ogg", "mp4", "webm"]);
7
+ async function optionalJson(file) { try { return JSON.parse(await readFile(file, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
8
+ export async function workspaceInfo(root) {
9
+ const info = await optionalJson(path.join(root, "animaguru.workspace.json"));
10
+ if (info && (info.schemaVersion !== 1 || info.animationsDirectory !== "animations" || info.branding !== "branding/brand.json")) throw new Error("Unsupported AnimaGuru workspace configuration.");
11
+ return info;
12
+ }
13
+ export async function listAnimations(root) {
14
+ if (!await workspaceInfo(root)) return [];
15
+ const entries = await readdir(path.join(root, "animations"), { withFileTypes: true });
16
+ const result = [];
17
+ for (const entry of entries) {
18
+ if (!entry.isDirectory() || !animationName(entry.name)) continue;
19
+ const config = await optionalJson(path.join(root, "animations", entry.name, "film.config.json"));
20
+ const project = await optionalJson(path.join(root, "animations", entry.name, "animaguru.project.json"));
21
+ if (config && project) result.push({ name: entry.name, title: config.title, duration: config.duration, category: project.category, template: project.template });
22
+ }
23
+ return result.sort((a, b) => a.name.localeCompare(b.name));
24
+ }
25
+ export async function readBranding(root) {
26
+ const config = await optionalJson(path.join(root, "branding/brand.json"));
27
+ if (!config || typeof config.name !== "string" || !config.name.trim() || typeof config.tagline !== "string") throw new Error("Set the shared name and tagline in branding/brand.json.");
28
+ for (const field of ["primary", "background", "text"]) if (!/^#[a-f0-9]{6}$/i.test(config.colors?.[field])) throw new Error(`Use a six-digit hex color for branding.colors.${field}.`);
29
+ const fonts = config.fonts;
30
+ if (!fonts || [fonts.heading, fonts.body].some((font) => !font || typeof font.family !== "string" || !font.family.trim())) throw new Error("Set heading and body font families in branding/brand.json.");
31
+ if (config.assets !== undefined && !Array.isArray(config.assets)) throw new Error("branding.assets must be a list of workspace-relative web asset paths.");
32
+ const requested = [...new Set([config.logo, fonts.heading.file, fonts.body.file, ...(config.assets ?? [])].filter(Boolean))];
33
+ const files = [];
34
+ const assetMap = {};
35
+ const actualRoot = await realpath(root);
36
+ for (const relative of requested) {
37
+ if (typeof relative !== "string" || path.isAbsolute(relative) || relative.includes("\\") || relative.split("/").some((segment) => !segment || segment === "." || segment === "..")) throw new Error("Branding assets must use paths relative to the workspace, such as branding/logo.svg.");
38
+ const extension = relative.split(".").at(-1).toLowerCase();
39
+ if (!extensions.has(extension)) throw new Error(`Unsupported web branding asset: ${relative}. Keep guides/documents local.`);
40
+ const source = await realpath(path.join(root, relative));
41
+ if (!source.startsWith(actualRoot + path.sep)) throw new Error("Branding assets must stay inside this workspace.");
42
+ const info = await stat(source);
43
+ if (!info.isFile() || info.size > 32 * 1024 * 1024) throw new Error("Branding web assets must be files up to 32 MiB.");
44
+ const digest = createHash("sha256").update(await readFile(source)).digest("hex").slice(0, 20);
45
+ const publicPath = `branding/${digest}.${extension}`;
46
+ assetMap[relative] = publicPath;
47
+ files.push({ source, publicPath });
48
+ }
49
+ return { files, branding: { name: config.name, tagline: config.tagline, colors: config.colors, logo: config.logo ? assetMap[config.logo] : null,
50
+ fonts: { heading: { ...fonts.heading, file: fonts.heading.file ? assetMap[fonts.heading.file] : null }, body: { ...fonts.body, file: fonts.body.file ? assetMap[fonts.body.file] : null } }, assets: assetMap } };
51
+ }
52
+ export function projectArguments(args) {
53
+ const rest = []; let animation;
54
+ for (let index = 0; index < args.length; index++) {
55
+ if (args[index] === "--animation") { animation = args[++index]; if (!animationName(animation)) throw new Error("Pass a valid animation name after --animation."); }
56
+ else rest.push(args[index]);
57
+ }
58
+ return { animation, rest };
59
+ }
60
+ export async function projectContext(root, name) {
61
+ root = path.resolve(root);
62
+ const workspace = await workspaceInfo(root);
63
+ if (!workspace) {
64
+ if (name) throw new Error("--animation selects a project inside an AnimaGuru workspace.");
65
+ return { workspace: false, root, projectRoot: root, publicRoot: path.join(root, "public"), output: path.join(root, "production/exports"), route: "/", config: JSON.parse(await readFile(path.join(root, "film.config.json"), "utf8")), branding: null, brandFiles: [] };
66
+ }
67
+ if (!name) {
68
+ const animations = await listAnimations(root);
69
+ if (animations.length !== 1) throw new Error("Select an animation with --animation NAME. Create one with animaguru create animation NAME.");
70
+ name = animations[0].name;
71
+ }
72
+ if (!animationName(name)) throw new Error("Use a lowercase, hyphenated animation name.");
73
+ const projectRoot = path.join(root, "animations", name);
74
+ const actual = await realpath(projectRoot);
75
+ const actualRoot = await realpath(root);
76
+ if (!actual.startsWith(actualRoot + path.sep)) throw new Error("Animation folders must stay inside their workspace.");
77
+ await access(path.join(projectRoot, "animaguru.project.json"));
78
+ const config = JSON.parse(await readFile(path.join(projectRoot, "film.config.json"), "utf8"));
79
+ const brand = await readBranding(root);
80
+ return { workspace: true, name, root, projectRoot, publicRoot: path.join(projectRoot, "public"), output: path.join(projectRoot, "production/exports"), route: `/animations/${name}/`, config, branding: brand.branding, brandFiles: brand.files };
81
+ }
82
+ export async function publicFile(context, relative) {
83
+ if (typeof relative !== "string" || !relative || relative.startsWith("/") || relative.includes("\\") || relative.split("/").some((segment) => !segment || segment === "." || segment === "..")) throw new Error("Use a local media path relative to the animation's public folder.");
84
+ const common = context.brandFiles.find((file) => file.publicPath === relative);
85
+ if (common) return common.source;
86
+ const publicDirectory = context.workspace && relative.startsWith("brand/") ? path.join(context.root, "public") : context.publicRoot;
87
+ const root = await realpath(publicDirectory);
88
+ const file = await realpath(path.join(root, relative));
89
+ if (!file.startsWith(root + path.sep)) throw new Error("Media must stay inside the animation's public folder.");
90
+ return file;
91
+ }
@@ -3,14 +3,18 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createServer } from "vite";
6
+ import { projectArguments, projectContext } from "./project-context.mjs";
7
+ import { workspacePlugin } from "./workspace-plugin.mjs";
6
8
  import { analyzeReview } from "./review-analysis.mjs";
7
9
  import { inspectAudio } from "./review-audio.mjs";
8
10
 
9
11
  const root=fileURLToPath(new URL("../",import.meta.url));
10
- const config=JSON.parse(await readFile(path.join(root,"film.config.json"),"utf8"));
12
+ const selection=projectArguments(process.argv.slice(2));
13
+ if(selection.rest.length)throw new Error("Usage: npm run review -- [--animation NAME]");
14
+ const context=await projectContext(root,selection.animation),config=context.config;
11
15
  const token=randomUUID(),port=Number(process.env.ANIMAGURU_REVIEW_PORT??5197);
12
- const output=path.join(root,"production/review");
13
- const server=await createServer({configFile:false,root,clearScreen:false,server:{host:"127.0.0.1",port,strictPort:true},plugins:[{name:"animaguru-review",configureServer(vite){
16
+ const output=path.join(context.projectRoot,"production/review");
17
+ const server=await createServer({configFile:false,root,base:"./",publicDir:context.workspace?false:undefined,clearScreen:false,server:{host:"127.0.0.1",port,strictPort:true},plugins:[...(context.workspace?[workspacePlugin(root,{animation:context.name})]:[]),{name:"animaguru-review",configureServer(vite){
14
18
  vite.middlewares.use((request,response,next)=>{
15
19
  if(request.url!=="/__review/report")return next();
16
20
  void (async()=>{
@@ -20,7 +24,7 @@ const server=await createServer({configFile:false,root,clearScreen:false,server:
20
24
  const report=JSON.parse(Buffer.concat(parts).toString());
21
25
  if(!Array.isArray(report.samples)||!Array.isArray(report.frames)||!Array.isArray(report.seekMatches)||report.frames.length>10)throw new Error("Invalid review report.");
22
26
  const analysis=analyzeReview(report,config);
23
- const audio=await inspectAudio(root,config);
27
+ const audio=await inspectAudio(context.projectRoot,config);
24
28
  await mkdir(output,{recursive:true});
25
29
  for(const [i,frame]of report.frames.entries()){
26
30
  if(typeof frame.image!=="string"||!frame.image.startsWith("data:image/jpeg;base64,"))throw new Error("Expected JPEG review frame.");
@@ -34,4 +38,4 @@ const server=await createServer({configFile:false,root,clearScreen:false,server:
34
38
  })().catch((error)=>{response.writeHead(500).end(error.message);void server.close();process.exitCode=1;});
35
39
  });
36
40
  }}]});
37
- await server.listen();console.log(`Open this review page and keep it open until completion:\nhttp://127.0.0.1:${port}/?review=${token}`);
41
+ await server.listen();console.log(`Open this review page and keep it open until completion:\nhttp://127.0.0.1:${port}${context.route}?review=${token}`);
@@ -1 +1 @@
1
- f82f05cc73b14a023396f06cf7589e13e3bcb9e5397323e59e2f598e1d1658d8
1
+ 31dec70bb78b4d3ecc34451c54aeef31366e38c966b6a61763cf6446b464750e
@@ -0,0 +1,73 @@
1
+ import { readFile, cp, mkdir } from "node:fs/promises";
2
+ import { createReadStream } from "node:fs";
3
+ import path from "node:path";
4
+ import { listAnimations, projectContext, publicFile, readBranding } from "./project-context.mjs";
5
+
6
+ const htmlEscape = (value) => String(value).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
7
+ export function workspacePlugin(root, { animation, mode = "development" } = {}) {
8
+ const virtualPrefix = "\0animaguru-animation:";
9
+ const sourcePrefix = "/@animaguru-entry/";
10
+ let server;
11
+ return {
12
+ name: "animaguru-workspace",
13
+ resolveId(id) { if (id.startsWith(sourcePrefix)) return virtualPrefix + id.slice(sourcePrefix.length); },
14
+ async load(id) {
15
+ if (!id.startsWith(virtualPrefix)) return;
16
+ const name = id.slice(virtualPrefix.length);
17
+ const context = await projectContext(root, name);
18
+ const source = (file) => JSON.stringify(`/@fs${file.split(path.sep).join("/")}`);
19
+ return `import config from ${source(path.join(context.projectRoot, "film.config.json"))};
20
+ import {Film,chapters} from ${source(path.join(context.projectRoot, "src/film.ts"))};
21
+ import project from ${source(path.join(context.projectRoot, "animaguru.project.json"))};
22
+ import {mountPlayer} from "/runtime/player.ts";
23
+ void mountPlayer({config,Film,chapters,branding:${JSON.stringify(context.branding)},exportEndpoint:${JSON.stringify(`/__animaguru/html?animation=${name}`)},templateLabel:project.templateLabel??project.template??"Choose category & style with your agent"});`;
24
+ },
25
+ transformIndexHtml: { order: "pre", handler(html) {
26
+ if (!animation) return html;
27
+ return html.replace('/src/main.ts', `${sourcePrefix}${animation}`);
28
+ } },
29
+ configureServer(vite) {
30
+ server = vite;
31
+ vite.middlewares.use((request, response, next) => {
32
+ const url = new URL(request.url ?? "/", "http://localhost");
33
+ const match = /^\/animations\/([a-z0-9][a-z0-9-]{0,79})\/(.*)$/.exec(decodeURIComponent(url.pathname));
34
+ if (request.method !== "GET" && request.method !== "HEAD") return next();
35
+ if (url.pathname === "/" && !animation) {
36
+ void (async () => {
37
+ const [animations, brand] = await Promise.all([listAnimations(root), readBranding(root)]);
38
+ const content = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${htmlEscape(brand.branding.name)} · AnimaGuru workspace</title><style>body{margin:0;background:#edf0f4;color:#111;font:15px system-ui}main{max-width:960px;margin:auto;padding:48px 24px}h1{font-size:32px}a{color:#1d56ad}ul{padding:0;display:grid;gap:14px;list-style:none}li{background:white;border:1px solid #d9dee6;padding:22px;border-radius:14px}code{font-size:13px}p{color:#6b7382;line-height:1.7}</style></head><body><main><p>AnimaGuru · shared video workspace</p><h1>${htmlEscape(brand.branding.name)}</h1><p>One brand. Many animations. Shared player, skills and export tools.</p><ul>${animations.map((item) => `<li><a href="/animations/${item.name}/">${htmlEscape(item.title)}</a><p>${htmlEscape(item.name)} · ${Number(item.duration)}s</p></li>`).join("")}</ul><p>Create your next film: <code>animaguru create animation my-video</code></p><p>Keep logos, fonts and guides in <code>branding/</code>; configure reusable web assets in <code>branding/brand.json</code>.</p></main></body></html>`;
39
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(content);
40
+ })().catch((error) => response.writeHead(500).end(error.message)); return;
41
+ }
42
+ if (!match) return next();
43
+ if (/^(?:src|shared)\//.test(match[2]) || ["film.config.json", "animaguru.project.json"].includes(match[2])) return next();
44
+ void (async () => {
45
+ const context = await projectContext(root, match[1]);
46
+ if (!match[2] || match[2] === "index.html") {
47
+ const html = (await readFile(path.join(root, "index.html"), "utf8")).replace('/src/main.ts', `${sourcePrefix}${context.name}`);
48
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(await vite.transformIndexHtml(request.url, html));
49
+ return;
50
+ }
51
+ const file = await publicFile(context, match[2]);
52
+ const type = ({svg:"image/svg+xml",png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",webp:"image/webp",mp3:"audio/mpeg",wav:"audio/wav",m4a:"audio/mp4",woff2:"font/woff2",woff:"font/woff",ttf:"font/ttf",mp4:"video/mp4",webm:"video/webm"})[file.split(".").at(-1).toLowerCase()] ?? "application/octet-stream";
53
+ response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" });
54
+ if (request.method === "HEAD") { response.end(); return; }
55
+ const stream = createReadStream(file); response.once("close", () => stream.destroy()); stream.pipe(response);
56
+ })().catch((error) => { if (!response.headersSent) response.writeHead(404).end(error.message); });
57
+ });
58
+ },
59
+ handleHotUpdate({ file }) {
60
+ if (file.includes(`${path.sep}branding${path.sep}`)) { server?.ws.send({ type: "full-reload" }); return []; }
61
+ },
62
+ async writeBundle(options) {
63
+ if (!animation || !options.dir) return;
64
+ const context = await projectContext(root, animation);
65
+ await cp(context.publicRoot, options.dir, { recursive: true });
66
+ await cp(path.join(root, "public/brand"), path.join(options.dir, "brand"), { recursive: true });
67
+ for (const file of context.brandFiles) {
68
+ await mkdir(path.dirname(path.join(options.dir, file.publicPath)), { recursive: true });
69
+ await cp(file.source, path.join(options.dir, file.publicPath));
70
+ }
71
+ },
72
+ };
73
+ }
@@ -23,7 +23,7 @@
23
23
  "exportHint": "Ready for the big screen? Run npm run export:mp4.",
24
24
  "loading": "Setting the stage…",
25
25
  "failed": "The player could not load. Check the browser console and reload.",
26
- "audioFailed": "Sound could not play. Check film.config.json and the local audio file.",
26
+ "audioFailed": "The soundtrack could not play. Try again or contact the creator.",
27
27
  "fullscreenFailed": "Fullscreen is unavailable in this browser.",
28
28
  "videoPlayer": "Video player",
29
29
  "footer": "Made with AnimaGuru × PixiJS",
@@ -1,4 +1,4 @@
1
- /** Public-relative media paths remain portable when an HTML5 export is embedded in a subfolder. */
1
+ /** Resolve relative to the player document, including workspace and hosted embed subfolders. */
2
2
  export function publicAsset(path: string) {
3
- return `${import.meta.env.BASE_URL}${path.replace(/^\/+/, "")}`;
3
+ return new URL(path.replace(/^\/+/, ""), new URL("./", document.baseURI)).href;
4
4
  }