@officexapp/vidfarm-devcli 0.21.56 → 0.21.58
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/.agents/skills/vidfarm/SKILL.md +26 -2
- package/.agents/skills/vidfarm/harnesses/README.md +1 -0
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +6 -1
- package/.agents/skills/vidfarm/references/reviewing-renders.md +11 -2
- package/SKILL.director.md +43 -5
- package/SKILL.md +5 -2
- package/dist/src/cli.js +96 -1
- package/dist/src/devcli/local-frontend-server.js +10 -2
- package/dist/src/devcli/local-render.js +28 -2
- package/dist/src/devcli/qa-check.js +27 -1
- package/dist/src/lib/engine-globals.js +138 -0
- package/dist/src/lib/frozen-render.js +130 -0
- package/dist/src/services/composition-lint.js +16 -0
- package/experimental/engaging-chat-convo.md +1370 -0
- package/package.json +7 -1
- package/update.md +15 -0
|
@@ -29,6 +29,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
29
29
|
import path from "node:path";
|
|
30
30
|
import { parseHTML } from "linkedom";
|
|
31
31
|
import { compositionDeclaresFont } from "./composition-edit.js";
|
|
32
|
+
import { ENGINE_GLOBALS_FIX, findEngineOwnedGlobalAssignments, usesTimelineLibraryWithoutRegistry } from "../lib/engine-globals.js";
|
|
32
33
|
// ── Revision governor ────────────────────────────────────────────────────────
|
|
33
34
|
//
|
|
34
35
|
// `vidfarm qa` is feedback, not a gate — which is exactly what makes it a loop
|
|
@@ -120,7 +121,7 @@ export function watchTheVideoDirective(dir = "<dir>", renderPath) {
|
|
|
120
121
|
`Look at the WHOLE thing: \`vidfarm stills ${dir} --sheet\` — then actually OPEN ${dir}/stills/contact-sheet.png and read it as an image. Reading the filenames is not looking at the video.`,
|
|
121
122
|
"Judge it as one sequence, not scene by scene: one type scale, steady margins, one palette, deliberate pacing, clean joins, no dead bands.",
|
|
122
123
|
"Judge every caption AGAINST ITS PICTURE: is the line sitting in the emptiest part of that frame, or on top of the subject? Is it small enough not to run edge-to-edge? Does it even need its plate? qa cannot see pixels — you can.",
|
|
123
|
-
|
|
124
|
+
`Prove it MOVES, don't assume it: \`vidfarm motion-check ${video}\` — a frozen render passes duration, frame-count and audio-hash checks and still ships a still image. Then compare frames from two DIFFERENT scenes by eye.`,
|
|
124
125
|
"Time it against your own thumb: name the seconds you would cut. Any beat you can delete without losing the payoff IS fluff — cut it and ripple the hole closed. Assume your first cut is 30–50% too long, and say out loud which beat you cut, or why nothing could go.",
|
|
125
126
|
`Measure the audio instead of vibing it: \`ffmpeg -i ${video} -af volumedetect -f null -\` (peak < 0 dBFS, speech ~12–15 dB over the bed).`,
|
|
126
127
|
"Report what you MEASURED separately from what you JUDGED, and say plainly if you did not watch it."
|
|
@@ -341,6 +342,31 @@ export function qaCompositionHtml(html) {
|
|
|
341
342
|
const textLayers = Array.from(root.querySelectorAll('[data-layer-kind="caption"], [data-layer-kind="text"]')).filter((node) => textOf(node).length > 0);
|
|
342
343
|
const canvasW = Number(root.getAttribute?.("data-width") ?? 0) || null;
|
|
343
344
|
const canvasH = Number(root.getAttribute?.("data-height") ?? 0) || null;
|
|
345
|
+
// ── Rule: engine-owned globals ─────────────────────────────────────────────
|
|
346
|
+
// Not a slop rule — a correctness one, and the only defect in this file that
|
|
347
|
+
// ships a technically-perfect MP4 containing a still image. A weaker model
|
|
348
|
+
// invents `window.__player = { seek }` as its own per-frame API; that kills
|
|
349
|
+
// the capture bridge, the render exits 0, and nothing downstream notices.
|
|
350
|
+
// Duplicated from `vidfarm lint` on purpose: an agent that runs only one of
|
|
351
|
+
// the two must still be caught. See lib/engine-globals.ts.
|
|
352
|
+
for (const hit of findEngineOwnedGlobalAssignments(html)) {
|
|
353
|
+
push({
|
|
354
|
+
rule: "engine-owned-global",
|
|
355
|
+
severity: "error",
|
|
356
|
+
message: `window.${hit.global} is assigned on line ${hit.line} — the engine owns that global. The render will succeed and NOTHING WILL MOVE.`,
|
|
357
|
+
where: hit.snippet || `line ${hit.line}`,
|
|
358
|
+
fix: ENGINE_GLOBALS_FIX
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (usesTimelineLibraryWithoutRegistry(html)) {
|
|
362
|
+
push({
|
|
363
|
+
rule: "missing-timeline-registry",
|
|
364
|
+
severity: "warn",
|
|
365
|
+
message: "A GSAP/anime timeline is built but never registered on window.__timelines — the engine has nothing to seek.",
|
|
366
|
+
where: "<script>",
|
|
367
|
+
fix: 'Register it: window.__timelines = window.__timelines || {}; window.__timelines["<data-composition-id>"] = tl; — the key must equal the root\'s data-composition-id exactly.'
|
|
368
|
+
});
|
|
369
|
+
}
|
|
344
370
|
// ── Rule: clickable elements ────────────────────────────────────────────────
|
|
345
371
|
// Nothing in a video is clickable. A <button>, a link, or a form control in a
|
|
346
372
|
// composition is web instinct leaking through, full stop.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Engine-owned browser globals — the ones a composition must never assign.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS (bug report, 2026-08-25):
|
|
4
|
+
// The HyperFrames runtime installs `window.__player` and the capture harness
|
|
5
|
+
// then builds the real seek bridge (`window.__hf`) ON TOP of that object. A
|
|
6
|
+
// composition that assigns `window.__player = { seek }` — an agent inventing
|
|
7
|
+
// its own per-frame API, which is exactly what a weaker model does when it is
|
|
8
|
+
// told "the page must expose window.__hf = { duration, seek }" — permanently
|
|
9
|
+
// destroys the bridge. The render does NOT fail: the harness accepts the
|
|
10
|
+
// forged `__hf` as proof the runtime is up, stalls 45s on
|
|
11
|
+
// `sub_timeline_readiness_timeout`, downgrades it to a warning, and writes a
|
|
12
|
+
// well-formed MP4 with the correct duration, frame count and audio in which
|
|
13
|
+
// NOTHING MOVES. Exit code 0. Four paid marketplace deliverables shipped that
|
|
14
|
+
// way before anyone noticed.
|
|
15
|
+
//
|
|
16
|
+
// The failure is undiagnosable at render time, so it has to be caught at
|
|
17
|
+
// AUTHORING time. This detector is the shared source of truth for:
|
|
18
|
+
// - `vidfarm lint` (services/composition-lint.ts — error)
|
|
19
|
+
// - `vidfarm qa` (devcli/qa-check.ts — error)
|
|
20
|
+
// - `vidfarm render --target local` preflight (devcli/local-render.ts — refuses to render)
|
|
21
|
+
// - the /editor chat's replace_composition_html preflight (via composition-lint)
|
|
22
|
+
//
|
|
23
|
+
// Zero dependencies on purpose: it runs inside the editor-chat Lambda too.
|
|
24
|
+
/**
|
|
25
|
+
* Globals the HyperFrames runtime + capture harness own. Assigning any of them
|
|
26
|
+
* from composition code is never correct.
|
|
27
|
+
*
|
|
28
|
+
* `__timelines` is deliberately ABSENT — `window.__timelines = window.__timelines || {}`
|
|
29
|
+
* is the documented, required registration idiom, and flagging it would train
|
|
30
|
+
* authors away from the one thing they must do.
|
|
31
|
+
*/
|
|
32
|
+
export const ENGINE_OWNED_GLOBALS = [
|
|
33
|
+
"__player",
|
|
34
|
+
"__playerReady",
|
|
35
|
+
"__hf",
|
|
36
|
+
"__renderReady",
|
|
37
|
+
"__hyperframes"
|
|
38
|
+
];
|
|
39
|
+
/** The one sentence every message about this should end with. */
|
|
40
|
+
export const ENGINE_GLOBALS_FIX = 'Delete the assignment. The engine installs window.__player / window.__hf itself and drives the composition by SEEKING a paused timeline you register: ' +
|
|
41
|
+
'window.__timelines = window.__timelines || {}; window.__timelines["<data-composition-id>"] = gsap.timeline({ paused: true }); ' +
|
|
42
|
+
'For imperative per-frame drawing, register a paused driver timeline with an onUpdate callback instead of inventing a seek API.';
|
|
43
|
+
const NAMES = ENGINE_OWNED_GLOBALS.join("|");
|
|
44
|
+
// window.__player = … window["__player"] = … globalThis.__hf = … self.__hf = …
|
|
45
|
+
// The `=(?!=)` tail keeps `window.__player === x` and `!==` out.
|
|
46
|
+
const ASSIGN_RE = new RegExp(String.raw `\b(?:window|globalThis|self)\s*(?:\.\s*(${NAMES})\b|\[\s*["'](${NAMES})["']\s*\])\s*(?:\|\||&&)?\s*=(?!=)`, "g");
|
|
47
|
+
// Object.defineProperty(window, "__player", …) — the same destruction, spelled politely.
|
|
48
|
+
const DEFINE_RE = new RegExp(String.raw `defineProperty\s*\(\s*(?:window|globalThis|self)\s*,\s*["'](${NAMES})["']`, "g");
|
|
49
|
+
/** Extract `<script>` bodies with their offset in the source document. */
|
|
50
|
+
function scriptBodies(html) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const re = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
|
|
53
|
+
let match;
|
|
54
|
+
while ((match = re.exec(html)) !== null) {
|
|
55
|
+
out.push({ body: match[1], offset: match.index + match[0].indexOf(match[1]) });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function lineOf(html, index) {
|
|
60
|
+
let line = 1;
|
|
61
|
+
for (let i = 0; i < index && i < html.length; i += 1)
|
|
62
|
+
if (html.charCodeAt(i) === 10)
|
|
63
|
+
line += 1;
|
|
64
|
+
return line;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A window of source AROUND the match, not the whole line. Compositions are
|
|
68
|
+
* routinely minified onto one line, where "the line" is the entire document and
|
|
69
|
+
* tells the author nothing.
|
|
70
|
+
*/
|
|
71
|
+
function snippetAround(html, index) {
|
|
72
|
+
const lineStart = html.lastIndexOf("\n", index) + 1;
|
|
73
|
+
const lineEndRaw = html.indexOf("\n", index);
|
|
74
|
+
const lineEnd = lineEndRaw === -1 ? html.length : lineEndRaw;
|
|
75
|
+
const from = Math.max(lineStart, index - 24);
|
|
76
|
+
const to = Math.min(lineEnd, index + 116);
|
|
77
|
+
return `${from > lineStart ? "…" : ""}${html.slice(from, to).trim()}${to < lineEnd ? "…" : ""}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Find every assignment to an engine-owned global inside the composition's
|
|
81
|
+
* inline `<script>` blocks. Only script bodies are scanned, so prose or a
|
|
82
|
+
* caption that happens to contain the words is never flagged.
|
|
83
|
+
*
|
|
84
|
+
* One hit per (global, line) — a loop that reassigns the same thing twice on
|
|
85
|
+
* one line is one defect, not two.
|
|
86
|
+
*/
|
|
87
|
+
export function findEngineOwnedGlobalAssignments(html) {
|
|
88
|
+
if (typeof html !== "string" || !html.includes("__"))
|
|
89
|
+
return [];
|
|
90
|
+
const hits = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const record = (name, absoluteIndex) => {
|
|
93
|
+
const line = lineOf(html, absoluteIndex);
|
|
94
|
+
const key = `${name}:${line}`;
|
|
95
|
+
if (seen.has(key))
|
|
96
|
+
return;
|
|
97
|
+
seen.add(key);
|
|
98
|
+
hits.push({ global: name, line, snippet: snippetAround(html, absoluteIndex) });
|
|
99
|
+
};
|
|
100
|
+
for (const { body, offset } of scriptBodies(html)) {
|
|
101
|
+
for (const re of [ASSIGN_RE, DEFINE_RE]) {
|
|
102
|
+
re.lastIndex = 0;
|
|
103
|
+
let match;
|
|
104
|
+
while ((match = re.exec(body)) !== null) {
|
|
105
|
+
const name = match[1] ?? match[2];
|
|
106
|
+
if (name)
|
|
107
|
+
record(name, offset + match.index);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return hits.sort((a, b) => a.line - b.line || a.global.localeCompare(b.global));
|
|
112
|
+
}
|
|
113
|
+
/** Human-readable one-liner for a set of hits. */
|
|
114
|
+
export function describeEngineGlobalHits(hits) {
|
|
115
|
+
const names = [...new Set(hits.map((h) => `window.${h.global}`))].join(", ");
|
|
116
|
+
const where = hits.map((h) => `line ${h.line}`).slice(0, 4).join(", ");
|
|
117
|
+
return `Composition assigns engine-owned global(s) ${names} (${where}). ` +
|
|
118
|
+
"This silently destroys the HyperFrames capture bridge: the render still exits 0 and writes a correct-length MP4 in which NOTHING MOVES. " +
|
|
119
|
+
ENGINE_GLOBALS_FIX;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A composition that builds a GSAP/anime timeline but never registers it on
|
|
123
|
+
* `window.__timelines` renders static for a different reason — the engine has
|
|
124
|
+
* nothing to seek. Cheap companion check; warning-level, because a sub-
|
|
125
|
+
* composition may legitimately be registered from a sibling file.
|
|
126
|
+
*/
|
|
127
|
+
export function usesTimelineLibraryWithoutRegistry(html) {
|
|
128
|
+
if (typeof html !== "string")
|
|
129
|
+
return false;
|
|
130
|
+
const bodies = scriptBodies(html).map((s) => s.body).join("\n");
|
|
131
|
+
if (!bodies)
|
|
132
|
+
return false;
|
|
133
|
+
const buildsTimeline = /\bgsap\s*\.\s*timeline\s*\(|\banime\s*\.\s*timeline\s*\(|\bnew\s+TimelineMax\b/.test(bodies);
|
|
134
|
+
if (!buildsTimeline)
|
|
135
|
+
return false;
|
|
136
|
+
return !/\b__timelines\b/.test(bodies);
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=engine-globals.js.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Frozen-render detection — "the MP4 is correct in every measurable way and
|
|
2
|
+
// nothing in it moves".
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS: whole classes of render bug produce a video where every
|
|
5
|
+
// frame is identical while duration, frame count, file size and audio all come
|
|
6
|
+
// out exactly right, and the renderer exits 0. Three known causes, all silent:
|
|
7
|
+
// 1. the composition assigned an engine-owned global (window.__player /
|
|
8
|
+
// window.__hf), which destroys the capture bridge — see lib/engine-globals.ts;
|
|
9
|
+
// 2. a watermark/overlay pass over a single-frame PNG without `-loop 1`,
|
|
10
|
+
// which collapses the whole video onto one frame;
|
|
11
|
+
// 3. assets (or the animation library) outside the composition root, so the
|
|
12
|
+
// timeline never starts — frame 0 still renders fine, because frame 0 IS
|
|
13
|
+
// the static DOM.
|
|
14
|
+
// Frame 0 looks perfect in all three, so every single-frame check passes and a
|
|
15
|
+
// still image ships as a video.
|
|
16
|
+
//
|
|
17
|
+
// METHOD — one ffmpeg pass, no temp files:
|
|
18
|
+
// fps=4 → scale → tblend=difference (each sample minus the previous one)
|
|
19
|
+
// → blackframe, which reports `pblack:<percent of near-black pixels>`
|
|
20
|
+
// A frame pair with real motion leaves bright pixels in the difference, so
|
|
21
|
+
// pblack drops well below 100. A frozen video leaves pblack at exactly 100 for
|
|
22
|
+
// every pair — encoder noise on a static source sits at 54–66 dB PSNR, far
|
|
23
|
+
// under the blackness threshold, so it does not register as motion.
|
|
24
|
+
// Measured on a real render: animating → pblack 87–100 (min 87); frozen →
|
|
25
|
+
// pblack 100 on every pair. The separation is not marginal.
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import { resolveFfmpeg } from "../services/clip-curation/ffmpeg.js";
|
|
28
|
+
const PBLACK_RE = /pblack:(\d+(?:\.\d+)?)/g;
|
|
29
|
+
function runFfmpeg(bin, args, timeoutMs) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
32
|
+
let stderr = "";
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
child.kill("SIGKILL");
|
|
35
|
+
}, timeoutMs);
|
|
36
|
+
child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
|
|
37
|
+
child.on("error", (error) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
reject(error);
|
|
40
|
+
});
|
|
41
|
+
child.on("close", (code) => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
resolve({ code: code ?? 1, stderr });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Decide whether a rendered video actually animates. Never throws: an
|
|
49
|
+
* unavailable ffmpeg comes back as `{ ok: true, skipped: true }` so a missing
|
|
50
|
+
* toolchain can never fail a render that is probably fine.
|
|
51
|
+
*/
|
|
52
|
+
export async function checkRenderMotion(videoPath, opts = {}) {
|
|
53
|
+
const fpsGrid = opts.fpsGrid ?? 4;
|
|
54
|
+
const width = opts.width ?? 192;
|
|
55
|
+
const staticPblack = opts.staticPblack ?? 99.9;
|
|
56
|
+
const base = {
|
|
57
|
+
ok: true,
|
|
58
|
+
frozen: false,
|
|
59
|
+
skipped: true,
|
|
60
|
+
video: videoPath,
|
|
61
|
+
compared_pairs: 0,
|
|
62
|
+
moving_pairs: 0,
|
|
63
|
+
min_pblack: 100,
|
|
64
|
+
motion_score: 0,
|
|
65
|
+
fps_grid: fpsGrid,
|
|
66
|
+
reason: "not run"
|
|
67
|
+
};
|
|
68
|
+
let bin;
|
|
69
|
+
try {
|
|
70
|
+
bin = await resolveFfmpeg();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { ...base, reason: "ffmpeg unavailable — motion not verified." };
|
|
74
|
+
}
|
|
75
|
+
const filter = `fps=${fpsGrid},scale=${width}:-2,tblend=all_mode=difference,blackframe=amount=0:threshold=24`;
|
|
76
|
+
let stderr;
|
|
77
|
+
try {
|
|
78
|
+
({ stderr } = await runFfmpeg(bin, ["-v", "info", "-nostdin", "-i", videoPath, "-an", "-vf", filter, "-f", "null", "-"], opts.timeoutMs ?? 120_000));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { ...base, reason: "ffmpeg could not be launched — motion not verified." };
|
|
82
|
+
}
|
|
83
|
+
const values = [];
|
|
84
|
+
let match;
|
|
85
|
+
PBLACK_RE.lastIndex = 0;
|
|
86
|
+
while ((match = PBLACK_RE.exec(stderr)) !== null)
|
|
87
|
+
values.push(Number(match[1]));
|
|
88
|
+
if (values.length === 0) {
|
|
89
|
+
return { ...base, reason: "ffmpeg reported no comparable frames — motion not verified." };
|
|
90
|
+
}
|
|
91
|
+
const minPblack = Math.min(...values);
|
|
92
|
+
const movingPairs = values.filter((v) => v < staticPblack).length;
|
|
93
|
+
const frozen = movingPairs === 0;
|
|
94
|
+
return {
|
|
95
|
+
ok: !frozen,
|
|
96
|
+
frozen,
|
|
97
|
+
skipped: false,
|
|
98
|
+
video: videoPath,
|
|
99
|
+
compared_pairs: values.length,
|
|
100
|
+
moving_pairs: movingPairs,
|
|
101
|
+
min_pblack: minPblack,
|
|
102
|
+
motion_score: Number((100 - minPblack).toFixed(3)),
|
|
103
|
+
fps_grid: fpsGrid,
|
|
104
|
+
reason: frozen
|
|
105
|
+
? `All ${values.length} sampled frame pairs are identical — this video never moves.`
|
|
106
|
+
: `${movingPairs}/${values.length} sampled frame pairs differ (max change ${(100 - minPblack).toFixed(1)}%).`
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Terminal block for a frozen result. Returns "" when the video is fine. */
|
|
110
|
+
export function formatFrozenRenderFailure(result, colors) {
|
|
111
|
+
if (!result.frozen)
|
|
112
|
+
return "";
|
|
113
|
+
const { red, bold, dim, reset } = colors;
|
|
114
|
+
return [
|
|
115
|
+
"",
|
|
116
|
+
`${red}${bold}■ FROZEN RENDER — the MP4 is a still image.${reset}`,
|
|
117
|
+
` ${dim}${result.compared_pairs} frame pairs sampled at ${result.fps_grid}fps; not one of them differed.${reset}`,
|
|
118
|
+
` ${dim}Duration, frame count and audio are all correct, which is why nothing else caught this.${reset}`,
|
|
119
|
+
"",
|
|
120
|
+
` ${bold}Usual causes, in order:${reset}`,
|
|
121
|
+
` ${dim}1.${reset} The composition assigns an engine-owned global. ${dim}window.__player / window.__hf belong to the engine; assigning either kills frame capture. Run ${reset}vidfarm lint <dir>${dim}.${reset}`,
|
|
122
|
+
` ${dim}2.${reset} No paused timeline is registered. ${dim}window.__timelines["<data-composition-id>"] = gsap.timeline({ paused: true }) — the key must equal the root's data-composition-id exactly.${reset}`,
|
|
123
|
+
` ${dim}3.${reset} The animation library or an asset sits outside the composition root. ${dim}Only <style>/<script> INSIDE the root element execute.${reset}`,
|
|
124
|
+
` ${dim}4.${reset} A watermark/overlay pass over a single-frame PNG without ${reset}-loop 1${dim}.${reset}`,
|
|
125
|
+
"",
|
|
126
|
+
` ${dim}Re-check after fixing: ${reset}vidfarm motion-check ${result.video}${dim}. Ship a deliberately static card with ${reset}--allow-static${dim}.${reset}`,
|
|
127
|
+
""
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=frozen-render.js.map
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// composition vocabulary (data-vf-* proxies, transition holds, caption spans).
|
|
6
6
|
import { parseHTML } from "linkedom";
|
|
7
7
|
import { CAPTION_ANIMATIONS, KEN_BURNS_PRESETS, TRANSITION_OUT_PRESETS, TRANSITION_PRESETS } from "../hyperframes/composition.js";
|
|
8
|
+
import { ENGINE_GLOBALS_FIX, findEngineOwnedGlobalAssignments, usesTimelineLibraryWithoutRegistry } from "../lib/engine-globals.js";
|
|
8
9
|
const OVERLAP_EPSILON = 0.011;
|
|
9
10
|
function parseNumberAttr(value) {
|
|
10
11
|
if (typeof value !== "string" || !value.trim()) {
|
|
@@ -175,6 +176,21 @@ export function lintCompositionHtml(html) {
|
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
178
|
}
|
|
179
|
+
// ── Engine-owned globals ────────────────────────────────────────────────────
|
|
180
|
+
// The highest-value check in this file. A composition that assigns
|
|
181
|
+
// window.__player (or window.__hf) still renders, still exits 0, and still
|
|
182
|
+
// writes a correct-length MP4 — in which nothing moves. Nothing downstream
|
|
183
|
+
// catches it, so it has to be an ERROR here. See lib/engine-globals.ts.
|
|
184
|
+
for (const hit of findEngineOwnedGlobalAssignments(trimmed)) {
|
|
185
|
+
push("error", "engine_owned_global_assigned", `Line ${hit.line} assigns window.${hit.global} — the HyperFrames engine owns that global. ` +
|
|
186
|
+
"Assigning it destroys the frame-capture bridge: the render still succeeds and writes a video in which NOTHING MOVES. " +
|
|
187
|
+
ENGINE_GLOBALS_FIX, hit.snippet);
|
|
188
|
+
}
|
|
189
|
+
if (usesTimelineLibraryWithoutRegistry(trimmed)) {
|
|
190
|
+
push("warning", "missing_timeline_registry", 'Composition builds a GSAP/anime timeline but never registers one on window.__timelines. ' +
|
|
191
|
+
'The engine drives motion by SEEKING a registered paused timeline — without it the render is a still image. ' +
|
|
192
|
+
'Add: window.__timelines = window.__timelines || {}; window.__timelines["<data-composition-id>"] = tl;');
|
|
193
|
+
}
|
|
178
194
|
const scripts = document.querySelectorAll("script");
|
|
179
195
|
if (scripts.length > 0) {
|
|
180
196
|
push("warning", "scripts_stripped_on_save", `Composition contains ${scripts.length} <script> tag(s). Scripts survive local serve/disk renders, but every cloud/web editor save strips them (stored-XSS defense) — script-driven animation will be lost there. Prefer the declarative preset vocabulary (data-kenburns, data-transition, data-caption-animation) for compositions that round-trip through the web editor.`);
|