@docyrus/animaguru 0.13.0 → 0.15.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 +54 -3
- package/dist/animaguru.js +1025 -591
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +19 -89
- package/skills/animaguru/references/avatars.md +22 -0
- package/skills/animaguru/references/cloud.md +19 -0
- package/skills/animaguru/references/sharing.md +33 -0
- package/skills/animaguru/references/sprites.md +29 -0
- package/skills/animaguru-advertising/SKILL.md +2 -2
- package/skills/animaguru-analog-film-tape/SKILL.md +1 -0
- package/skills/animaguru-animated-data-visualization/SKILL.md +1 -0
- package/skills/animaguru-art-deco-motion/SKILL.md +1 -0
- package/skills/animaguru-ascii-crt/SKILL.md +1 -0
- package/skills/animaguru-blueprint-drafting/SKILL.md +1 -0
- package/skills/animaguru-brand-kit/SKILL.md +1 -1
- package/skills/animaguru-cartoon-character-animation/SKILL.md +1 -0
- package/skills/animaguru-cutout-animation/SKILL.md +1 -0
- package/skills/animaguru-dark-tech-stage/SKILL.md +2 -1
- package/skills/animaguru-dark-tech-stage/references/recipe.md +10 -0
- package/skills/animaguru-diagram-formula-motion/SKILL.md +1 -0
- package/skills/animaguru-engraved-relief-print/SKILL.md +1 -0
- package/skills/animaguru-flat-vector-motion/SKILL.md +1 -0
- package/skills/animaguru-generative-light-signal/SKILL.md +1 -0
- package/skills/animaguru-glass-mosaic/SKILL.md +1 -0
- package/skills/animaguru-halftone-editorial/SKILL.md +1 -0
- package/skills/animaguru-holographic-scan/SKILL.md +1 -0
- package/skills/animaguru-interface-motion/SKILL.md +1 -0
- package/skills/animaguru-isometric-worlds/SKILL.md +1 -0
- package/skills/animaguru-kinetic-typography/SKILL.md +1 -0
- package/skills/animaguru-miniature-diorama/SKILL.md +1 -0
- package/skills/animaguru-painterly-depth-animation/SKILL.md +1 -0
- package/skills/animaguru-paper-popup/SKILL.md +1 -0
- package/skills/animaguru-photo-footage-editorial/SKILL.md +1 -0
- package/skills/animaguru-pixel-art-animation/SKILL.md +1 -0
- package/skills/animaguru-portal-zoom/SKILL.md +1 -0
- package/skills/animaguru-risograph-print/SKILL.md +1 -0
- package/skills/animaguru-sand-drawing/SKILL.md +1 -0
- package/skills/animaguru-shadow-theatre/SKILL.md +1 -0
- package/skills/animaguru-silkscreen-poster/SKILL.md +1 -0
- package/skills/animaguru-stop-motion-objects/SKILL.md +1 -0
- package/skills/animaguru-studio-product-reveal/SKILL.md +1 -0
- package/skills/animaguru-video/SKILL.md +37 -5
- package/skills/animaguru-video/references/short-form-ad.md +57 -0
- package/skills/animaguru-whiteboard-line-drawing/SKILL.md +1 -0
- package/templates/video/AGENTS.md +2 -1
- package/templates/video/WORKSPACE-AGENTS.md +6 -2
- package/templates/video/package.json +7 -1
- package/templates/video/scripts/audio-mix.mjs +101 -0
- package/templates/video/scripts/audio-music.mjs +175 -0
- package/templates/video/scripts/audio-tools.mjs +241 -0
- package/templates/video/scripts/audio-vo.mjs +158 -0
- package/templates/video/scripts/export-mp4.mjs +5 -3
- package/templates/video/scripts/frames.mjs +81 -0
- package/templates/video/scripts/headless-browser.mjs +83 -10
- package/templates/video/scripts/review-analysis.mjs +71 -1
- package/templates/video/scripts/review-audio.mjs +2 -1
- package/templates/video/scripts/review.mjs +8 -5
- package/templates/video/scripts/site-inspect.mjs +128 -0
- package/templates/video/scripts/starter-film.sha256 +1 -1
- package/templates/video/scripts/workspace-plugin.mjs +2 -1
- package/templates/video/shared/safe-areas.json +7 -0
- package/templates/video/src/film.ts +97 -125
- package/templates/video/src/kit/components.ts +95 -0
- package/templates/video/src/kit/dark-stage.ts +55 -0
- package/templates/video/src/kit/film-base.ts +108 -0
- package/templates/video/src/kit/fonts.ts +29 -0
- package/templates/video/src/kit/index.ts +11 -0
- package/templates/video/src/kit/layout.ts +40 -0
- package/templates/video/src/kit/motion.ts +37 -0
- package/templates/video/src/kit/shapes.ts +68 -0
- package/templates/video/src/kit/text.ts +34 -0
- package/templates/video/src/kit/textures.ts +42 -0
- package/templates/video/src/kit/timing.ts +48 -0
- package/templates/video/src/player-types.ts +7 -1
- package/templates/video/src/player.ts +3 -5
- package/templates/video/src/review.ts +2 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/** Shared helpers for audio:vo, audio:music and audio:mix: the bundled ffmpeg, PCM decoding and small DSP routines. */
|
|
2
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const run = promisify(execFile);
|
|
8
|
+
let binaries;
|
|
9
|
+
|
|
10
|
+
/** The ffmpeg/ffprobe builds installed with the workspace; loaded lazily so the pure helpers import anywhere. */
|
|
11
|
+
export async function audioBinaries() {
|
|
12
|
+
if (!binaries) {
|
|
13
|
+
const [{ default: ffmpeg }, { default: ffprobe }] = await Promise.all([import("@ffmpeg-installer/ffmpeg"), import("@ffprobe-installer/ffprobe")]);
|
|
14
|
+
binaries = { ffmpeg: ffmpeg.path, ffprobe: ffprobe.path };
|
|
15
|
+
}
|
|
16
|
+
return binaries;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function ffmpeg(args) {
|
|
20
|
+
const { ffmpeg: binary } = await audioBinaries();
|
|
21
|
+
try { return await run(binary, ["-hide_banner", "-nostdin", ...args], { maxBuffer: 64 * 1024 * 1024 }); }
|
|
22
|
+
catch (error) { throw new Error(`ffmpeg failed: ${String(error.stderr ?? error.message).trim().split("\n").slice(-3).join(" ")}`); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function mediaDuration(file) {
|
|
26
|
+
const { ffprobe } = await audioBinaries();
|
|
27
|
+
const { stdout } = await run(ffprobe, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file]);
|
|
28
|
+
const duration = Number(stdout);
|
|
29
|
+
if (!Number.isFinite(duration)) throw new Error(`Could not read the duration of ${file}.`);
|
|
30
|
+
return duration;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Decodes any audio file to mono Float32 samples at the given rate. */
|
|
34
|
+
export async function decodePcm(file, sampleRate = 22050) {
|
|
35
|
+
const { ffmpeg: binary } = await audioBinaries();
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const child = spawn(binary, ["-v", "error", "-nostdin", "-i", file, "-ac", "1", "-ar", String(sampleRate), "-f", "f32le", "pipe:1"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
38
|
+
const chunks = []; let errors = "";
|
|
39
|
+
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
40
|
+
child.stderr.on("data", (chunk) => { errors += chunk; });
|
|
41
|
+
child.once("error", reject);
|
|
42
|
+
child.once("close", (code) => {
|
|
43
|
+
if (code !== 0) { reject(new Error(`Could not decode ${file}: ${errors.trim().split("\n").at(-1) ?? `exit ${code}`}`)); return; }
|
|
44
|
+
const bytes = Buffer.concat(chunks), pcm = new Float32Array(Math.floor(bytes.length / 4));
|
|
45
|
+
new Uint8Array(pcm.buffer).set(bytes.subarray(0, pcm.length * 4));
|
|
46
|
+
resolve(pcm);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** EBU R128 measurement with ffmpeg's loudnorm; returns the measured values its second pass needs. */
|
|
52
|
+
export async function measureLoudness(file, { i = -14, tp = -2, lra = 9, filter = "" } = {}) {
|
|
53
|
+
const { stderr } = await ffmpeg(["-nostats", "-i", file, "-af", `${filter ? `${filter},` : ""}loudnorm=I=${i}:TP=${tp}:LRA=${lra}:print_format=json`, "-f", "null", "-"]);
|
|
54
|
+
const json = stderr.match(/\{\s*"input_i"[\s\S]*?\}/)?.[0];
|
|
55
|
+
if (!json) throw new Error(`Loudness could not be measured for ${file}.`);
|
|
56
|
+
const values = JSON.parse(json);
|
|
57
|
+
return { integrated: Number(values.input_i), truePeak: Number(values.input_tp), range: Number(values.input_lra), threshold: Number(values.input_thresh), offset: Number(values.target_offset) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function readJson(file, fallback) {
|
|
61
|
+
try { return JSON.parse(await readFile(file, "utf8")); }
|
|
62
|
+
catch (error) { if (error.code === "ENOENT" && fallback !== undefined) return fallback; throw error.code === "ENOENT" ? new Error(`Missing ${file}.`) : error; }
|
|
63
|
+
}
|
|
64
|
+
export const writeJson = (file, value) => writeFile(file, JSON.stringify(value, null, 2) + "\n");
|
|
65
|
+
|
|
66
|
+
/** Resolves a path written in a config file, relative to the animation folder, and keeps it inside that folder. */
|
|
67
|
+
export function projectPath(projectRoot, relative, label = "path") {
|
|
68
|
+
if (typeof relative !== "string" || !relative.trim()) throw new Error(`Set ${label} to a path relative to the animation folder.`);
|
|
69
|
+
const resolved = path.resolve(projectRoot, relative);
|
|
70
|
+
if (resolved !== projectRoot && !resolved.startsWith(projectRoot + path.sep)) throw new Error(`${label} must stay inside the animation folder: ${relative}`);
|
|
71
|
+
return resolved;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parses `--name value` flags; booleans are flags without a value. */
|
|
75
|
+
export function parseFlags(args, spec) {
|
|
76
|
+
const flags = {};
|
|
77
|
+
for (let index = 0; index < args.length; index++) {
|
|
78
|
+
const name = args[index].replace(/^--/, "");
|
|
79
|
+
if (!args[index].startsWith("--") || !(name in spec)) throw new Error(`Unknown option ${args[index]}. Options: ${Object.keys(spec).map((key) => `--${key}`).join(", ")}`);
|
|
80
|
+
if (spec[name] === "boolean") { flags[name] = true; continue; }
|
|
81
|
+
const value = args[++index];
|
|
82
|
+
if (value === undefined) throw new Error(`Pass a value after --${name}.`);
|
|
83
|
+
if (spec[name] === "number") { flags[name] = Number(value); if (!Number.isFinite(flags[name])) throw new Error(`--${name} must be a number.`); }
|
|
84
|
+
else flags[name] = value;
|
|
85
|
+
}
|
|
86
|
+
return flags;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const round = (value, digits = 3) => Math.round(value * 10 ** digits) / 10 ** digits;
|
|
90
|
+
export const dbToGain = (db) => 10 ** (db / 20);
|
|
91
|
+
|
|
92
|
+
/** Peak level in dBFS of short windows (default 10 ms), like ffmpeg's silencedetect which compares sample peaks. */
|
|
93
|
+
export function peakLevels(pcm, sampleRate, hopSeconds = 0.01) {
|
|
94
|
+
const hop = Math.max(1, Math.round(hopSeconds * sampleRate)), count = Math.ceil(pcm.length / hop);
|
|
95
|
+
const levels = new Float32Array(count);
|
|
96
|
+
for (let frame = 0; frame < count; frame++) {
|
|
97
|
+
let peak = 0;
|
|
98
|
+
for (let index = frame * hop, end = Math.min(pcm.length, index + hop); index < end; index++) { const value = Math.abs(pcm[index]); if (value > peak) peak = value; }
|
|
99
|
+
levels[frame] = peak > 0 ? 20 * Math.log10(peak) : -120;
|
|
100
|
+
}
|
|
101
|
+
return { levels, hop: hop / sampleRate };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Speech regions separated by silences of at least `minSilence` seconds below `noiseDb`, in source seconds. */
|
|
105
|
+
export function detectPhrases(pcm, sampleRate, { noiseDb = -38, minSilence = 0.12, minPhrase = 0.04 } = {}) {
|
|
106
|
+
const { levels, hop } = peakLevels(pcm, sampleRate);
|
|
107
|
+
const runs = [];
|
|
108
|
+
let start = -1;
|
|
109
|
+
for (let frame = 0; frame <= levels.length; frame++) {
|
|
110
|
+
const voiced = frame < levels.length && levels[frame] >= noiseDb;
|
|
111
|
+
if (voiced && start < 0) start = frame;
|
|
112
|
+
if (!voiced && start >= 0) { runs.push({ start: start * hop, end: frame * hop }); start = -1; }
|
|
113
|
+
}
|
|
114
|
+
const merged = [];
|
|
115
|
+
for (const phrase of runs) {
|
|
116
|
+
const previous = merged.at(-1);
|
|
117
|
+
if (previous && phrase.start - previous.end < minSilence) previous.end = phrase.end;
|
|
118
|
+
else merged.push({ ...phrase });
|
|
119
|
+
}
|
|
120
|
+
const duration = pcm.length / sampleRate;
|
|
121
|
+
return merged.filter((phrase) => phrase.end - phrase.start >= minPhrase).map((phrase) => ({ start: round(phrase.start), end: round(Math.min(duration, phrase.end)) }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** In-place radix-2 FFT magnitudes of Hann-windowed frames; returns a function reused for every frame. */
|
|
125
|
+
export function createSpectrum(size) {
|
|
126
|
+
const levels = Math.log2(size);
|
|
127
|
+
if (!Number.isInteger(levels)) throw new Error("FFT size must be a power of two.");
|
|
128
|
+
const half = size / 2, cos = new Float64Array(half), sin = new Float64Array(half), reverse = new Uint32Array(size);
|
|
129
|
+
for (let index = 0; index < half; index++) { cos[index] = Math.cos(2 * Math.PI * index / size); sin[index] = Math.sin(2 * Math.PI * index / size); }
|
|
130
|
+
for (let index = 0; index < size; index++) { let value = 0; for (let bit = 0; bit < levels; bit++) value = (value << 1) | ((index >>> bit) & 1); reverse[index] = value; }
|
|
131
|
+
const window = Float64Array.from({ length: size }, (_, index) => 0.5 - 0.5 * Math.cos(2 * Math.PI * index / size));
|
|
132
|
+
const re = new Float64Array(size), im = new Float64Array(size), magnitudes = new Float64Array(half + 1);
|
|
133
|
+
return (samples, offset = 0) => {
|
|
134
|
+
for (let index = 0; index < size; index++) { const position = offset + index; re[reverse[index]] = (position >= 0 && position < samples.length ? samples[position] : 0) * window[index]; im[reverse[index]] = 0; }
|
|
135
|
+
for (let span = 2; span <= size; span <<= 1) {
|
|
136
|
+
const step = size / span, middle = span >> 1;
|
|
137
|
+
for (let base = 0; base < size; base += span) {
|
|
138
|
+
for (let offsetIndex = 0; offsetIndex < middle; offsetIndex++) {
|
|
139
|
+
const twiddle = offsetIndex * step, a = base + offsetIndex, b = a + middle;
|
|
140
|
+
const tr = re[b] * cos[twiddle] + im[b] * sin[twiddle], ti = im[b] * cos[twiddle] - re[b] * sin[twiddle];
|
|
141
|
+
re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (let bin = 0; bin <= half; bin++) magnitudes[bin] = Math.hypot(re[bin], im[bin]);
|
|
146
|
+
return magnitudes;
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Spectral flux (all bands and the 40–160 Hz kick band) and RMS per frame; frame times are window centres. */
|
|
151
|
+
export function onsetEnvelope(pcm, sampleRate, { size = 1024, hop = 512 } = {}) {
|
|
152
|
+
const spectrum = createSpectrum(size), count = Math.max(1, Math.floor((pcm.length - size) / hop) + 1);
|
|
153
|
+
const flux = new Float64Array(count), low = new Float64Array(count), rms = new Float64Array(count);
|
|
154
|
+
const binHz = sampleRate / size, lowFrom = Math.ceil(40 / binHz), lowTo = Math.floor(160 / binHz);
|
|
155
|
+
let previous = null, previousLow = 0;
|
|
156
|
+
for (let frame = 0; frame < count; frame++) {
|
|
157
|
+
const offset = frame * hop, magnitudes = spectrum(pcm, offset);
|
|
158
|
+
let energy = 0;
|
|
159
|
+
for (let index = offset; index < offset + size && index < pcm.length; index++) energy += pcm[index] * pcm[index];
|
|
160
|
+
rms[frame] = Math.sqrt(energy / size);
|
|
161
|
+
let lowSum = 0;
|
|
162
|
+
for (let bin = lowFrom; bin <= lowTo; bin++) lowSum += magnitudes[bin];
|
|
163
|
+
if (previous) {
|
|
164
|
+
let total = 0;
|
|
165
|
+
for (let bin = 1; bin < magnitudes.length; bin++) { const rise = Math.log1p(magnitudes[bin]) - previous[bin]; if (rise > 0) total += rise; }
|
|
166
|
+
flux[frame] = total; low[frame] = Math.max(0, lowSum - previousLow);
|
|
167
|
+
} else previous = new Float64Array(magnitudes.length);
|
|
168
|
+
for (let bin = 0; bin < magnitudes.length; bin++) previous[bin] = Math.log1p(magnitudes[bin]);
|
|
169
|
+
previousLow = lowSum;
|
|
170
|
+
}
|
|
171
|
+
return { flux, low, rms, fps: sampleRate / hop, offset: size / 2 / sampleRate };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Linear interpolation of an envelope at fractional frame positions. */
|
|
175
|
+
export function sampleAt(values, position) {
|
|
176
|
+
if (position <= 0) return values[0] ?? 0;
|
|
177
|
+
if (position >= values.length - 1) return values[values.length - 1] ?? 0;
|
|
178
|
+
const index = Math.floor(position), fraction = position - index;
|
|
179
|
+
return values[index] * (1 - fraction) + values[index + 1] * fraction;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Tempo from the onset envelope's autocorrelation (70–180 BPM), refined with a beat-grid search for period and phase.
|
|
184
|
+
* A candidate beat is scored with the correlation at one, two and four beats, so syncopations at 1.5 beats
|
|
185
|
+
* (which the plain autocorrelation peak often prefers) lose to the bar-level pulse.
|
|
186
|
+
*/
|
|
187
|
+
export function detectBeats(envelope, fps, offset = 0, { minBpm = 70, maxBpm = 180 } = {}) {
|
|
188
|
+
const mean = envelope.reduce((sum, value) => sum + value, 0) / envelope.length;
|
|
189
|
+
const centred = Float64Array.from(envelope, (value) => value - mean);
|
|
190
|
+
const memo = new Map();
|
|
191
|
+
const correlation = (lag) => {
|
|
192
|
+
if (lag >= centred.length - 1) return 0;
|
|
193
|
+
if (!memo.has(lag)) { let sum = 0; for (let index = lag; index < centred.length; index++) sum += centred[index] * centred[index - lag]; memo.set(lag, sum / (centred.length - lag)); }
|
|
194
|
+
return memo.get(lag);
|
|
195
|
+
};
|
|
196
|
+
const zero = correlation(0) || 1;
|
|
197
|
+
const at = (lag) => { const whole = Math.floor(lag), fraction = lag - whole; return ((1 - fraction) * correlation(whole) + fraction * correlation(whole + 1)) / zero; };
|
|
198
|
+
let coarse = 0.5, best = -Infinity;
|
|
199
|
+
for (let bpm = maxBpm; bpm >= minBpm; bpm -= 0.05) {
|
|
200
|
+
const lag = fps * 60 / bpm;
|
|
201
|
+
if (4 * lag > centred.length / 2) break;
|
|
202
|
+
const value = at(lag) + at(2 * lag) + at(4 * lag);
|
|
203
|
+
if (value > best) { best = value; coarse = lag / fps; }
|
|
204
|
+
}
|
|
205
|
+
// A small period error drifts the grid over a long bed, so search period and phase together.
|
|
206
|
+
const duration = envelope.length / fps;
|
|
207
|
+
const gridScore = (period, phase) => { let sum = 0; for (let time = phase; time < duration; time += period) sum += sampleAt(envelope, (time - offset) * fps); return sum; };
|
|
208
|
+
let period = coarse, phase = 0, score = -Infinity;
|
|
209
|
+
for (let step = -60; step <= 60; step++) {
|
|
210
|
+
const candidate = coarse * (1 + step * 0.0002);
|
|
211
|
+
for (let index = 0; index < 64; index++) {
|
|
212
|
+
const shift = candidate * index / 64, value = gridScore(candidate, shift);
|
|
213
|
+
if (value > score) { score = value; period = candidate; phase = shift; }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (let index = -8; index <= 8; index++) {
|
|
217
|
+
const shift = phase + period * index / 512, value = gridScore(period, shift);
|
|
218
|
+
if (shift >= 0 && value > score) { score = value; phase = shift; }
|
|
219
|
+
}
|
|
220
|
+
return { bpm: 60 / period, period, phase };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 12-bin pitch-class profile of each window (80–2000 Hz), from a 4096-point STFT. */
|
|
224
|
+
export function chromaFrames(pcm, sampleRate, { size = 4096, hop = 2048 } = {}) {
|
|
225
|
+
const spectrum = createSpectrum(size), count = Math.max(1, Math.floor((pcm.length - size) / hop) + 1), binHz = sampleRate / size;
|
|
226
|
+
const classes = new Int8Array(size / 2 + 1).fill(-1);
|
|
227
|
+
for (let bin = 1; bin <= size / 2; bin++) { const hz = bin * binHz; if (hz >= 80 && hz <= 2000) classes[bin] = ((Math.round(12 * Math.log2(hz / 440)) % 12) + 12) % 12; }
|
|
228
|
+
const frames = [];
|
|
229
|
+
for (let frame = 0; frame < count; frame++) {
|
|
230
|
+
const magnitudes = spectrum(pcm, frame * hop), chroma = new Float64Array(12);
|
|
231
|
+
for (let bin = 1; bin < magnitudes.length; bin++) if (classes[bin] >= 0) chroma[classes[bin]] += magnitudes[bin];
|
|
232
|
+
frames.push({ time: (frame * hop + size / 2) / sampleRate, chroma });
|
|
233
|
+
}
|
|
234
|
+
return frames;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function cosine(a, b) {
|
|
238
|
+
let dot = 0, na = 0, nb = 0;
|
|
239
|
+
for (let index = 0; index < a.length; index++) { dot += a[index] * b[index]; na += a[index] * a[index]; nb += b[index] * b[index]; }
|
|
240
|
+
return na && nb ? dot / Math.sqrt(na * nb) : 0;
|
|
241
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm run audio:vo -- [--animation NAME]
|
|
3
|
+
* Cuts, speeds up and places voiceover takes from production/audio/vo.json; writes production/audio/work/vo-track.wav
|
|
4
|
+
* and production/audio/timing.json (segment and caption times on the film timeline).
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir, access } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { projectArguments, projectContext } from "./project-context.mjs";
|
|
10
|
+
import { decodePcm, detectPhrases, ffmpeg, parseFlags, projectPath, readJson, round, writeJson } from "./audio-tools.mjs";
|
|
11
|
+
|
|
12
|
+
const SAMPLE_RATE = 48000;
|
|
13
|
+
|
|
14
|
+
/** Caption texts for a segment: `captions` as given, or `text` split after sentence punctuation. */
|
|
15
|
+
export function segmentTexts(segment) {
|
|
16
|
+
if (Array.isArray(segment.captions) && segment.captions.length) {
|
|
17
|
+
if (segment.captions.some((text) => typeof text !== "string" || !text.trim())) throw new Error(`Segment ${segment.id}: every caption must be non-empty text.`);
|
|
18
|
+
return segment.captions.map((text) => text.trim());
|
|
19
|
+
}
|
|
20
|
+
if (typeof segment.text === "string" && segment.text.trim()) return segment.text.trim().split(/(?<=[.!?:;])\s+/).filter(Boolean);
|
|
21
|
+
throw new Error(`Segment ${segment.id}: add "captions" (one string per on-screen caption) or "text" (the transcript) so captions can be timed.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Chooses caption boundaries among the pauses between phrases: k captions need k−1 pauses, preferring long pauses
|
|
26
|
+
* near where the text's character count says each caption should end.
|
|
27
|
+
*/
|
|
28
|
+
export function splitCaptions(phrases, texts, { distanceWeight = 0.35 } = {}) {
|
|
29
|
+
if (!phrases.length) throw new Error("No speech was detected.");
|
|
30
|
+
const start = phrases[0].start, end = phrases.at(-1).end;
|
|
31
|
+
if (texts.length === 1) return [{ text: texts[0], start, end }];
|
|
32
|
+
const pauses = phrases.slice(1).map((phrase, index) => ({ index, start: phrases[index].end, end: phrase.start }));
|
|
33
|
+
const needed = texts.length - 1;
|
|
34
|
+
if (pauses.length < needed) throw new Error(`Found ${pauses.length} pause(s) for ${texts.length} captions; merge captions or lower captionSilence.noiseDb/minSeconds.`);
|
|
35
|
+
const lengths = texts.map((text) => [...text.replace(/\s+/g, " ")].length), total = lengths.reduce((sum, value) => sum + value, 0);
|
|
36
|
+
const expected = lengths.slice(0, -1).map((_, index) => start + (end - start) * lengths.slice(0, index + 1).reduce((sum, value) => sum + value, 0) / total);
|
|
37
|
+
const score = (pause, boundary) => (pause.end - pause.start) - distanceWeight * Math.abs((pause.start + pause.end) / 2 - expected[boundary]);
|
|
38
|
+
// best[b][p]: best total choosing boundaries 0..b with boundary b at pause p (pauses stay in order).
|
|
39
|
+
const best = Array.from({ length: needed }, () => new Array(pauses.length).fill(-Infinity)), from = Array.from({ length: needed }, () => new Array(pauses.length).fill(-1));
|
|
40
|
+
for (let p = 0; p < pauses.length; p++) best[0][p] = score(pauses[p], 0);
|
|
41
|
+
for (let b = 1; b < needed; b++) {
|
|
42
|
+
for (let p = b; p < pauses.length; p++) {
|
|
43
|
+
for (let q = b - 1; q < p; q++) {
|
|
44
|
+
const value = best[b - 1][q] + score(pauses[p], b);
|
|
45
|
+
if (value > best[b][p]) { best[b][p] = value; from[b][p] = q; }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let last = -1, top = -Infinity;
|
|
50
|
+
for (let p = needed - 1; p < pauses.length; p++) if (best[needed - 1][p] > top) { top = best[needed - 1][p]; last = p; }
|
|
51
|
+
const chosen = [];
|
|
52
|
+
for (let b = needed - 1, p = last; b >= 0; p = from[b][p], b--) chosen.unshift(pauses[p]);
|
|
53
|
+
return texts.map((text, index) => ({ text, start: index ? chosen[index - 1].end : start, end: index < needed ? chosen[index].start : end }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Places segments on the film timeline. Pauses inside a take keep their sped-up length up to `maxPause` film seconds;
|
|
58
|
+
* longer ones are cut there, which splits the take into pieces. `start` is when the first word is heard.
|
|
59
|
+
*/
|
|
60
|
+
export function placeVoiceover(segments, { start = 0.25, gap = 0.35, tempo = 1, maxPause = 0.26, padIn = 0.03, padOut = 0.08 } = {}) {
|
|
61
|
+
if (!(tempo >= 0.5 && tempo <= 2)) throw new Error("tempo must be between 0.5 and 2.");
|
|
62
|
+
const placed = [], captions = [];
|
|
63
|
+
let cursor = start, previousEnd = null;
|
|
64
|
+
for (const segment of segments) {
|
|
65
|
+
if (!segment.phrases.length) throw new Error(`Segment ${segment.id}: no speech was detected; check the file or lower silence.noiseDb.`);
|
|
66
|
+
if (previousEnd !== null) cursor = previousEnd + (segment.gapBefore ?? gap);
|
|
67
|
+
else if (segment.gapBefore) cursor += segment.gapBefore;
|
|
68
|
+
const pieces = [];
|
|
69
|
+
let speechStart = cursor;
|
|
70
|
+
for (const [index, phrase] of segment.phrases.entries()) {
|
|
71
|
+
const piece = pieces.at(-1);
|
|
72
|
+
const pause = index ? (phrase.start - segment.phrases[index - 1].end) / tempo : 0;
|
|
73
|
+
if (piece && pause <= maxPause) { piece.source[1] = phrase.end; continue; }
|
|
74
|
+
if (piece) speechStart = piece.speech[0] + (piece.source[1] - piece.source[0]) / tempo + maxPause;
|
|
75
|
+
pieces.push({ source: [phrase.start, phrase.end], speech: [speechStart, 0] });
|
|
76
|
+
}
|
|
77
|
+
for (const piece of pieces) {
|
|
78
|
+
piece.speech[1] = piece.speech[0] + (piece.source[1] - piece.source[0]) / tempo;
|
|
79
|
+
const lead = Math.min(padIn, piece.source[0], piece.speech[0] * tempo);
|
|
80
|
+
piece.trim = [piece.source[0] - lead, Math.min(segment.duration ?? Infinity, piece.source[1] + padOut)];
|
|
81
|
+
piece.at = piece.speech[0] - lead / tempo;
|
|
82
|
+
}
|
|
83
|
+
const toFilm = (time) => {
|
|
84
|
+
const piece = pieces.find((item) => time <= item.source[1] + 1e-6) ?? pieces.at(-1);
|
|
85
|
+
return piece.speech[0] + (Math.min(Math.max(time, piece.source[0]), piece.source[1]) - piece.source[0]) / tempo;
|
|
86
|
+
};
|
|
87
|
+
for (const caption of segment.captions ?? []) captions.push({ start: round(toFilm(caption.start)), end: round(toFilm(caption.end)), text: caption.text, segment: segment.id });
|
|
88
|
+
previousEnd = pieces.at(-1).speech[1];
|
|
89
|
+
placed.push({ id: segment.id, source: segment.source, start: round(pieces[0].speech[0]), end: round(previousEnd),
|
|
90
|
+
pieces: pieces.map((piece) => ({ start: round(piece.speech[0]), end: round(piece.speech[1]), trim: piece.trim.map((value) => round(value)), at: round(piece.at) })) });
|
|
91
|
+
}
|
|
92
|
+
return { tempo, segments: placed, captions, voEnd: round(previousEnd ?? start) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function existing(...files) {
|
|
96
|
+
for (const file of files) { try { await access(file); return file; } catch { /* try the next candidate */ } }
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const root = fileURLToPath(new URL("../", import.meta.url));
|
|
102
|
+
const selection = projectArguments(process.argv.slice(2));
|
|
103
|
+
const flags = parseFlags(selection.rest, { config: "string" });
|
|
104
|
+
const context = await projectContext(root, selection.animation);
|
|
105
|
+
const base = context.projectRoot, configFile = projectPath(base, flags.config ?? "production/audio/vo.json", "--config");
|
|
106
|
+
const vo = await readJson(configFile);
|
|
107
|
+
if (!Array.isArray(vo.segments) || !vo.segments.length) throw new Error(`${path.relative(base, configFile)} needs a "segments" list.`);
|
|
108
|
+
const duration = Number(vo.duration ?? context.config.duration), tempo = Number(vo.tempo ?? 1);
|
|
109
|
+
const silence = { noiseDb: vo.silence?.noiseDb ?? -38, minSeconds: vo.silence?.minSeconds ?? 0.12 };
|
|
110
|
+
const captionSilence = { noiseDb: vo.captionSilence?.noiseDb ?? silence.noiseDb + 6, minSeconds: vo.captionSilence?.minSeconds ?? 0.06 };
|
|
111
|
+
const segments = [];
|
|
112
|
+
for (const segment of vo.segments) {
|
|
113
|
+
if (typeof segment.id !== "string" || !/^[\w.-]{1,80}$/.test(segment.id)) throw new Error("Each segment needs an id of letters, digits, dots, dashes or underscores.");
|
|
114
|
+
const texts = segmentTexts(segment);
|
|
115
|
+
const fallback = ["wav", "mp3", "m4a", "ogg"].map((extension) => path.join(base, `production/audio/vo/${segment.id}.${extension}`));
|
|
116
|
+
const file = segment.file ? projectPath(base, segment.file, `segment ${segment.id} file`) : await existing(...fallback);
|
|
117
|
+
if (!file || !await existing(file)) throw new Error(`Segment ${segment.id}: audio not found (${segment.file ?? "production/audio/vo/" + segment.id + ".wav"}).`);
|
|
118
|
+
const pcm = await decodePcm(file, SAMPLE_RATE);
|
|
119
|
+
const phrases = detectPhrases(pcm, SAMPLE_RATE, { noiseDb: silence.noiseDb, minSilence: silence.minSeconds });
|
|
120
|
+
const fine = detectPhrases(pcm, SAMPLE_RATE, { noiseDb: captionSilence.noiseDb, minSilence: captionSilence.minSeconds });
|
|
121
|
+
const usable = fine.length >= texts.length ? fine : phrases;
|
|
122
|
+
let captions;
|
|
123
|
+
try { captions = splitCaptions(usable, texts); } catch (error) { throw new Error(`Segment ${segment.id}: ${error.message}`); }
|
|
124
|
+
// The finer pass only places boundaries between captions; the segment's outer edges follow the trimmed speech.
|
|
125
|
+
if (phrases.length) { captions[0].start = phrases[0].start; captions.at(-1).end = phrases.at(-1).end; }
|
|
126
|
+
segments.push({ id: segment.id, file, source: path.relative(base, file).split(path.sep).join("/"), duration: pcm.length / SAMPLE_RATE, phrases, captions, gapBefore: segment.gapBefore });
|
|
127
|
+
}
|
|
128
|
+
const timing = placeVoiceover(segments, { start: vo.start ?? 0.25, gap: vo.gap ?? 0.35, tempo, maxPause: vo.maxPause ?? 0.26, padIn: vo.pad?.in ?? 0.03, padOut: vo.pad?.out ?? 0.08 });
|
|
129
|
+
const lastPiece = timing.segments.at(-1).pieces.at(-1);
|
|
130
|
+
if (lastPiece.end > duration) throw new Error(`The voiceover runs to ${lastPiece.end.toFixed(2)} s, past the ${duration} s film. Shorten gaps, raise tempo or trim the script.`);
|
|
131
|
+
|
|
132
|
+
const inputs = [], filters = [], labels = [];
|
|
133
|
+
for (const [index, segment] of timing.segments.entries()) {
|
|
134
|
+
inputs.push("-i", segments[index].file);
|
|
135
|
+
const parts = segment.pieces.map((_, piece) => `[s${index}p${piece}]`);
|
|
136
|
+
filters.push(`[${index}:a]aresample=${SAMPLE_RATE},aformat=channel_layouts=mono,asplit=${parts.length}${parts.join("")}`);
|
|
137
|
+
for (const [pieceIndex, piece] of segment.pieces.entries()) {
|
|
138
|
+
const label = `[v${labels.length}]`;
|
|
139
|
+
filters.push(`[s${index}p${pieceIndex}]atrim=${piece.trim[0]}:${piece.trim[1]},asetpts=PTS-STARTPTS,atempo=${tempo},afade=t=in:d=0.015,areverse,afade=t=in:d=0.04,areverse,adelay=${Math.round(piece.at * 1000)}:all=1${label}`);
|
|
140
|
+
labels.push(label);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0,apad=whole_dur=${duration},atrim=0:${duration}[out]`);
|
|
144
|
+
const work = path.join(base, "production/audio/work");
|
|
145
|
+
await mkdir(work, { recursive: true });
|
|
146
|
+
const track = path.join(work, "vo-track.wav");
|
|
147
|
+
await ffmpeg(["-y", "-loglevel", "error", ...inputs, "-filter_complex", filters.join(";"), "-map", "[out]", "-ac", "1", "-ar", String(SAMPLE_RATE), "-c:a", "pcm_s16le", track]);
|
|
148
|
+
const timingFile = path.join(base, "production/audio/timing.json");
|
|
149
|
+
await writeJson(timingFile, { tempo, start: timing.segments[0].start, segments: timing.segments, captions: timing.captions, voEnd: timing.voEnd });
|
|
150
|
+
|
|
151
|
+
const endHold = Number(vo.endHold ?? 3);
|
|
152
|
+
console.log(`Voiceover: ${timing.segments.length} segments, ${timing.captions.length} captions, tempo ${tempo}, ends at ${timing.voEnd.toFixed(2)} s of ${duration} s.`);
|
|
153
|
+
for (const segment of timing.segments.slice(0, 24)) console.log(` ${segment.id.padEnd(18)} ${segment.start.toFixed(2).padStart(6)} → ${segment.end.toFixed(2).padStart(6)} ${segment.pieces.length} piece(s)`);
|
|
154
|
+
if (timing.voEnd > duration - endHold) console.log(`Warning: the voiceover ends ${(duration - timing.voEnd).toFixed(2)} s before the film end; endHold asks for ${endHold} s. Tighten gaps or raise tempo.`);
|
|
155
|
+
console.log(`Wrote ${path.relative(base, track)} and ${path.relative(base, timingFile)}. Bind scene cues to timing.json captions; then run npm run audio:mix.`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
|
@@ -10,18 +10,20 @@ import ffprobe from "@ffprobe-installer/ffprobe";
|
|
|
10
10
|
import { projectArguments, projectContext, publicFile } from "./project-context.mjs";
|
|
11
11
|
import { workspacePlugin } from "./workspace-plugin.mjs";
|
|
12
12
|
import { subtitleCues, toSrt, toVtt } from "./subtitles.mjs";
|
|
13
|
-
import { captureHeadless } from "./headless-browser.mjs";
|
|
13
|
+
import { captureHeadless, takeHeadlessFlag } from "./headless-browser.mjs";
|
|
14
14
|
|
|
15
15
|
const root = fileURLToPath(new URL("../", import.meta.url));
|
|
16
16
|
const selection = projectArguments(process.argv.slice(2));
|
|
17
17
|
const args = selection.rest;
|
|
18
|
+
// --headless captures in a local headless Chrome instead of waiting for you to open the page.
|
|
19
|
+
const headless = takeHeadlessFlag(args);
|
|
18
20
|
let port = 5196;
|
|
19
21
|
// Captions become subtitle files beside the MP4 unless --burn-captions draws them into the picture, e.g. for social feeds.
|
|
20
22
|
let burnCaptions = false;
|
|
21
23
|
for (let index = 0; index < args.length; index++) {
|
|
22
24
|
if (args[index] === "--burn-captions") burnCaptions = true;
|
|
23
25
|
else if (args[index] === "--port" && /^\d+$/.test(args[index + 1] ?? "")) port = Number(args[++index]);
|
|
24
|
-
else throw new Error("Usage: npm run export:mp4 -- [--port 5196] [--burn-captions]");
|
|
26
|
+
else throw new Error("Usage: npm run export:mp4 -- [--animation NAME] [--headless] [--port 5196] [--burn-captions]");
|
|
25
27
|
}
|
|
26
28
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Export port must be 1024–65535.");
|
|
27
29
|
const context = await projectContext(root, selection.animation);
|
|
@@ -244,7 +246,7 @@ process.once("SIGINT", () => { void cleanup(new Error("Cancelled.")); });
|
|
|
244
246
|
process.once("SIGTERM", () => { void cleanup(new Error("Cancelled.")); });
|
|
245
247
|
try { await server.listen(); } catch (error) { await cleanup(error); throw error; }
|
|
246
248
|
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.`);
|
|
247
|
-
if (
|
|
249
|
+
if (headless) {
|
|
248
250
|
try { await captureHeadless(`http://127.0.0.1:${port}${context.route}?capture=1&export=${token}`, { endpoint: "/__export/finish" }); }
|
|
249
251
|
catch (error) { await cleanup(error); }
|
|
250
252
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createServer } from "vite";
|
|
5
|
+
import { projectArguments, projectContext } from "./project-context.mjs";
|
|
6
|
+
import { workspacePlugin } from "./workspace-plugin.mjs";
|
|
7
|
+
import { analyzeLayout, reviewLines } from "./review-analysis.mjs";
|
|
8
|
+
import { waitForPlayer, withPage } from "./headless-browser.mjs";
|
|
9
|
+
|
|
10
|
+
const usage = "Usage: npm run frames -- [--animation NAME] [--at 1.2,4.5,…] [--every SECONDS] [--count 12] [--width PX] [--columns N] [--no-captions] [--full] [--out DIR]";
|
|
11
|
+
const root = fileURLToPath(new URL("../", import.meta.url));
|
|
12
|
+
const selection = projectArguments(process.argv.slice(2));
|
|
13
|
+
const options = { at: null, every: null, count: 12, width: null, columns: null, captions: true, full: false, out: null };
|
|
14
|
+
for (let index = 0; index < selection.rest.length; index++) {
|
|
15
|
+
const flag = selection.rest[index], value = selection.rest[index + 1];
|
|
16
|
+
const number = () => { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${flag} needs a positive number.\n${usage}`); index++; return parsed; };
|
|
17
|
+
if (flag === "--at") { options.at = String(value ?? "").split(",").map(Number); if (!options.at.length || options.at.some((t) => !Number.isFinite(t) || t < 0)) throw new Error(`--at needs comma-separated seconds.\n${usage}`); index++; }
|
|
18
|
+
else if (flag === "--every") options.every = number();
|
|
19
|
+
else if (flag === "--count") options.count = Math.round(number());
|
|
20
|
+
else if (flag === "--width") options.width = Math.round(number());
|
|
21
|
+
else if (flag === "--columns") options.columns = Math.round(number());
|
|
22
|
+
else if (flag === "--no-captions") options.captions = false;
|
|
23
|
+
else if (flag === "--full") options.full = true;
|
|
24
|
+
else if (flag === "--out" && value) { options.out = value; index++; }
|
|
25
|
+
else throw new Error(usage);
|
|
26
|
+
}
|
|
27
|
+
const context = await projectContext(root, selection.animation), config = context.config;
|
|
28
|
+
const last = Math.max(0, config.duration - 1 / config.fps);
|
|
29
|
+
const times = (options.at ?? (options.every
|
|
30
|
+
? Array.from({ length: Math.floor(config.duration / options.every) }, (_, i) => (i + 0.5) * options.every)
|
|
31
|
+
: Array.from({ length: options.count }, (_, i) => (i + 0.5) * config.duration / options.count))).map((t) => Math.min(t, last));
|
|
32
|
+
if (!times.length || times.length > 120) throw new Error("Choose between 1 and 120 frames.");
|
|
33
|
+
const portrait = config.height > config.width;
|
|
34
|
+
const width = options.width ?? (portrait ? 270 : 400), columns = options.columns ?? (portrait ? 4 : 3), perSheet = columns * (portrait ? 3 : 4);
|
|
35
|
+
const output = path.resolve(context.projectRoot, options.out ?? "production/review/frames");
|
|
36
|
+
const server = await createServer({ configFile: false, root, base: "./", publicDir: context.workspace ? false : undefined, clearScreen: false, logLevel: "error",
|
|
37
|
+
server: { host: "127.0.0.1", port: Number(process.env.ANIMAGURU_FRAMES_PORT ?? 5198), strictPort: false }, plugins: context.workspace ? [workspacePlugin(root, { animation: context.name })] : [] });
|
|
38
|
+
try {
|
|
39
|
+
await server.listen();
|
|
40
|
+
const port = server.httpServer.address().port;
|
|
41
|
+
const result = await withPage(`http://127.0.0.1:${port}${context.route}?capture=1`, async (page, problems) => {
|
|
42
|
+
await waitForPlayer(page, problems);
|
|
43
|
+
return page.evaluate(async ({ times, width, columns, perSheet, captions, full }) => {
|
|
44
|
+
const api = window.animaguru;
|
|
45
|
+
api.setCaptions(captions);
|
|
46
|
+
const height = Math.round(api.height * width / api.width), label = 30, pad = 10;
|
|
47
|
+
const sheets = [], samples = [], frames = [];
|
|
48
|
+
for (let start = 0; start < times.length; start += perSheet) {
|
|
49
|
+
const chunk = times.slice(start, start + perSheet), rows = Math.ceil(chunk.length / columns);
|
|
50
|
+
const sheet = document.createElement("canvas");
|
|
51
|
+
sheet.width = pad + Math.min(columns, chunk.length) * (width + pad); sheet.height = pad + rows * (height + label + pad);
|
|
52
|
+
const draw = sheet.getContext("2d");
|
|
53
|
+
draw.fillStyle = "#ffffff"; draw.fillRect(0, 0, sheet.width, sheet.height);
|
|
54
|
+
for (const [index, time] of chunk.entries()) {
|
|
55
|
+
await api.seek(time);
|
|
56
|
+
const x = pad + (index % columns) * (width + pad), y = pad + Math.floor(index / columns) * (height + label + pad);
|
|
57
|
+
draw.drawImage(api.canvas, x, y + label, width, height);
|
|
58
|
+
draw.fillStyle = "#111111"; draw.font = "600 18px system-ui, sans-serif"; draw.fillText(`${time.toFixed(2)}s`, x + 2, y + 21);
|
|
59
|
+
samples.push({ time, texts: api.texts.map((box) => ({ ...box })) });
|
|
60
|
+
if (full) frames.push({ time, image: api.canvas.toDataURL("image/jpeg", 0.92) });
|
|
61
|
+
}
|
|
62
|
+
sheets.push(sheet.toDataURL("image/png"));
|
|
63
|
+
}
|
|
64
|
+
return { sheets, samples, frames };
|
|
65
|
+
}, { times, width, columns, perSheet, captions: options.captions, full: options.full });
|
|
66
|
+
}, { viewport: { width: 1280, height: 900 } });
|
|
67
|
+
await mkdir(output, { recursive: true });
|
|
68
|
+
// Only this tool's own previous output is replaced.
|
|
69
|
+
for (const file of await readdir(output)) if (/^(sheet-\d+\.png|frame-[\d.]+s\.jpg)$/.test(file)) await rm(path.join(output, file));
|
|
70
|
+
const written = [];
|
|
71
|
+
for (const [index, sheet] of result.sheets.entries()) {
|
|
72
|
+
const file = path.join(output, `sheet-${String(index + 1).padStart(2, "0")}.png`);
|
|
73
|
+
await writeFile(file, Buffer.from(sheet.split(",")[1], "base64")); written.push(file);
|
|
74
|
+
}
|
|
75
|
+
for (const frame of result.frames) await writeFile(path.join(output, `frame-${frame.time.toFixed(2)}s.jpg`), Buffer.from(frame.image.split(",")[1], "base64"));
|
|
76
|
+
console.log(`${times.length} frames at ${times.map((t) => t.toFixed(2)).join(", ")} s${options.captions ? "" : " (captions off)"}`);
|
|
77
|
+
for (const file of written) console.log(`Sheet: ${file}`);
|
|
78
|
+
if (result.frames.length) console.log(`Full-size frames: ${output}`);
|
|
79
|
+
const lines = reviewLines({ problems: [], warnings: analyzeLayout(result.samples, config) });
|
|
80
|
+
console.log(lines.length ? `Layout notes at these times:\n${lines.join("\n")}` : "No layout notes at these times (overlap, safe area, text size, captions).");
|
|
81
|
+
} finally { await server.close(); }
|
|
@@ -1,17 +1,90 @@
|
|
|
1
|
-
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const linuxBrowsers = ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"];
|
|
4
|
+
const firstLine = (message) => String(message).split("\n").find((line) => line.trim()) ?? String(message);
|
|
5
|
+
|
|
6
|
+
/** True for --headless (removed from args) or ANIMAGURU_HEADLESS=1. */
|
|
7
|
+
export function takeHeadlessFlag(args) {
|
|
8
|
+
const index = args.indexOf("--headless");
|
|
9
|
+
if (index >= 0) args.splice(index, 1);
|
|
10
|
+
return index >= 0 || process.env.ANIMAGURU_HEADLESS === "1";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Launches headless Chromium for capture, review and frame tools. Tries, in order:
|
|
15
|
+
* PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, a system Chromium on Linux, installed Google Chrome or Microsoft Edge,
|
|
16
|
+
* then a browser installed with `npx playwright install chromium`.
|
|
17
|
+
*/
|
|
18
|
+
export async function launchBrowser() {
|
|
19
|
+
let chromium;
|
|
20
|
+
try { ({ chromium } = await import("playwright-core")); }
|
|
21
|
+
catch { throw new Error("Headless mode needs playwright-core. Run npm install in this workspace."); }
|
|
22
|
+
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
|
23
|
+
const candidates = explicit ? [{ label: explicit, options: { executablePath: explicit } }] : [
|
|
24
|
+
...(process.platform === "linux" ? linuxBrowsers.filter((file) => existsSync(file)).map((file) => ({ label: file, options: { executablePath: file } })) : []),
|
|
25
|
+
{ label: "Google Chrome", options: { channel: "chrome" } },
|
|
26
|
+
{ label: "Microsoft Edge", options: { channel: "msedge" } },
|
|
27
|
+
{ label: "Playwright Chromium", options: {} },
|
|
28
|
+
];
|
|
29
|
+
// Containers usually run as root without /dev/shm space; desktop browsers keep their sandbox.
|
|
30
|
+
const args = ["--autoplay-policy=no-user-gesture-required", ...(process.platform === "linux" ? ["--no-sandbox", "--disable-dev-shm-usage"] : [])];
|
|
31
|
+
const failures = [];
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
try { return await chromium.launch({ headless: true, args, ...candidate.options }); }
|
|
34
|
+
catch (error) { failures.push(`${candidate.label}: ${firstLine(error.message)}`); }
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`No Chromium-based browser could start for headless mode. Install Google Chrome, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, or run npx playwright install chromium.\n${failures.join("\n")}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Collects page errors, console errors and failed requests, so a broken film fails with its cause instead of a timeout. */
|
|
40
|
+
function watch(page) {
|
|
41
|
+
const problems = [];
|
|
42
|
+
page.on("pageerror", (error) => { problems.push(`Page error: ${error.message}`); console.error(`Page error: ${error.message}`); });
|
|
43
|
+
page.on("console", (message) => { if (message.type() === "error") problems.push(`Console: ${message.text()}`); });
|
|
44
|
+
page.on("response", (response) => { if (response.status() >= 400) problems.push(`HTTP ${response.status()} ${new URL(response.url()).pathname}`); });
|
|
45
|
+
return problems;
|
|
46
|
+
}
|
|
47
|
+
const report = (problems) => problems.length ? `\n${[...new Set(problems)].slice(-8).join("\n")}` : "";
|
|
48
|
+
|
|
49
|
+
/** Opens a page and closes the browser after `use(page, problems)` settles. */
|
|
50
|
+
export async function withPage(url, use, { viewport = { width: 1280, height: 900 }, timeout = 60_000 } = {}) {
|
|
51
|
+
const browser = await launchBrowser();
|
|
52
|
+
try {
|
|
53
|
+
const page = await browser.newPage({ viewport, deviceScaleFactor: 1 });
|
|
54
|
+
const problems = watch(page);
|
|
55
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
56
|
+
return await use(page, problems);
|
|
57
|
+
} finally { await browser.close(); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Waits for the player's window.animaguru API (after fonts, textures and the first render), or fails with the page's errors. */
|
|
61
|
+
export async function waitForPlayer(page, problems = [], timeout = 120_000) {
|
|
62
|
+
const handle = await page.waitForFunction(() => window.animaguru?.ready ? "ready" : document.getElementById("status")?.textContent || null, undefined, { timeout, polling: 250 }).catch(() => null);
|
|
63
|
+
const state = await handle?.jsonValue();
|
|
64
|
+
if (state !== "ready") throw new Error(`The player did not start${state ? `: ${state}` : ` within ${timeout / 1000} s`}.${report(problems)}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Uses the same capture page and HTTP protocol as a locally opened browser; stops early when the player reports a failure. */
|
|
2
68
|
export async function captureHeadless(url, { endpoint, timeout = 30 * 60 * 1000 } = {}) {
|
|
3
|
-
const
|
|
4
|
-
const browser = await chromium.launch({
|
|
5
|
-
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? "/usr/bin/chromium",
|
|
6
|
-
headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage", "--autoplay-policy=no-user-gesture-required"],
|
|
7
|
-
});
|
|
69
|
+
const browser = await launchBrowser();
|
|
8
70
|
try {
|
|
9
71
|
const page = await browser.newPage({ deviceScaleFactor: 1 });
|
|
72
|
+
const problems = watch(page);
|
|
10
73
|
const finished = page.waitForResponse((response) => new URL(response.url()).pathname === endpoint, { timeout });
|
|
11
|
-
page.on("pageerror", (error) => console.error(`Capture page: ${error.message}`));
|
|
12
74
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
75
|
+
await waitForPlayer(page, problems);
|
|
76
|
+
let stop;
|
|
77
|
+
const failed = new Promise((_, reject) => {
|
|
78
|
+
const poll = setInterval(async () => {
|
|
79
|
+
const status = await page.evaluate(() => document.getElementById("status")?.textContent || "").catch(() => "");
|
|
80
|
+
if (status) { clearInterval(poll); reject(new Error(`Capture stopped: ${status}${report(problems)}`)); }
|
|
81
|
+
}, 1000);
|
|
82
|
+
stop = () => clearInterval(poll);
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
const response = await Promise.race([finished, failed]);
|
|
86
|
+
if (!response.ok()) throw new Error(`Capture failed (${response.status()}): ${await response.text()}`);
|
|
87
|
+
await response.finished();
|
|
88
|
+
} finally { stop(); }
|
|
16
89
|
} finally { await browser.close(); }
|
|
17
90
|
}
|