@docyrus/animaguru 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/animaguru.js +189 -30
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +10 -1
- package/skills/animaguru-brand-kit/SKILL.md +30 -0
- package/skills/animaguru-video/SKILL.md +9 -2
- package/templates/video/AGENTS.md +1 -1
- package/templates/video/WORKSPACE-AGENTS.md +21 -0
- package/templates/video/gitignore +4 -0
- package/templates/video/index.html +1 -1
- package/templates/video/package.json +2 -2
- package/templates/video/scripts/build.mjs +23 -5
- package/templates/video/scripts/dev.mjs +13 -0
- package/templates/video/scripts/export-html.mjs +6 -2
- package/templates/video/scripts/export-mp4.mjs +11 -9
- package/templates/video/scripts/html-export-plugin.mjs +6 -3
- package/templates/video/scripts/html-export.mjs +10 -5
- package/templates/video/scripts/install-example.mjs +15 -10
- package/templates/video/scripts/preview.mjs +11 -0
- package/templates/video/scripts/project-context.mjs +91 -0
- package/templates/video/scripts/review.mjs +9 -5
- package/templates/video/scripts/starter-film.sha256 +1 -1
- package/templates/video/scripts/workspace-plugin.mjs +73 -0
- package/templates/video/shared/locales/en/player.json +1 -1
- package/templates/video/src/assets.ts +2 -2
- package/templates/video/src/download.ts +3 -3
- package/templates/video/src/film.ts +24 -10
- package/templates/video/src/main.ts +2 -216
- package/templates/video/src/player-types.ts +27 -0
- package/templates/video/src/player.ts +231 -0
- package/templates/video/src/style.css +3 -0
- package/templates/video/vite.config.mjs +6 -1
|
@@ -1,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
|
-
|
|
9
|
-
|
|
10
|
-
await
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
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 =
|
|
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
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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}
|
|
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
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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": "
|
|
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
|
-
/**
|
|
1
|
+
/** Resolve relative to the player document, including workspace and hosted embed subfolders. */
|
|
2
2
|
export function publicAsset(path: string) {
|
|
3
|
-
return
|
|
3
|
+
return new URL(path.replace(/^\/+/, ""), new URL("./", document.baseURI)).href;
|
|
4
4
|
}
|
|
@@ -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. */
|