@officexapp/vidfarm-devcli 0.21.55 → 0.21.57
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 +87 -1
- 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 +104 -4
- package/SKILL.md +77 -5
- package/clipper.md +36 -4
- package/dist/src/cli.js +168 -4
- package/dist/src/devcli/clipper-run.js +941 -0
- package/dist/src/devcli/doctor.js +23 -1
- package/dist/src/devcli/local-frontend-server.js +10 -2
- package/dist/src/devcli/local-render.js +28 -2
- package/dist/src/devcli/marketplace-gigs.js +545 -39
- package/dist/src/devcli/qa-check.js +27 -1
- package/dist/src/devcli/update-check.js +340 -0
- 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/experimental/flash-harness.md +549 -0
- package/marketplace.md +544 -14
- package/package.json +7 -2
- package/public/serve-shells/editor.html +27 -0
- package/public/serve-shells/library-files.html +28 -1
- package/public/serve-shells/library-raws.html +27 -0
- package/public/serve-shells/tools-clipper.html +27 -0
- package/public/serve-shells/tools-image.html +27 -0
- package/public/serve-shells/tools-video.html +27 -0
- package/update.md +27 -2
|
@@ -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,340 @@
|
|
|
1
|
+
// `vidfarm update-check` — is this install current?
|
|
2
|
+
//
|
|
3
|
+
// Vidfarm ships as TWO moving parts that are versioned TOGETHER: the `vidfarm`
|
|
4
|
+
// agent skill (the workflows on disk) and the `vidfarm-devcli` (the command).
|
|
5
|
+
// Updating one but not the other is the most common cause of "this command
|
|
6
|
+
// doesn't exist" and "the skill says to do X but it fails" — the skill documents
|
|
7
|
+
// routes and flags the installed CLI may not have yet. So this checks both and
|
|
8
|
+
// reports them as one answer.
|
|
9
|
+
//
|
|
10
|
+
// It is built to be run by an AI AGENT on a cadence, not by a human on a whim:
|
|
11
|
+
// - `--quiet` prints ONE line (or nothing at all when current), so it can sit
|
|
12
|
+
// at the top of a session without burning context.
|
|
13
|
+
// - `--if-stale` is a no-op unless the last check is older than the interval,
|
|
14
|
+
// so "check at least once every 24 hours" costs one cheap call a day rather
|
|
15
|
+
// than one per command.
|
|
16
|
+
// - `--skip` records the user's "not now" so the agent stops asking. A prompt
|
|
17
|
+
// that cannot be dismissed gets ignored, and then the real one is ignored too.
|
|
18
|
+
//
|
|
19
|
+
// BACKEND-FREE: Node built-ins plus one unauthenticated fetch of the public npm
|
|
20
|
+
// registry and one of vidfarm.cc/skill.md. No key, no account, no wallet.
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { resolveDevcliHome } from "./auth-store.js";
|
|
25
|
+
const BOLD = "\x1b[1m";
|
|
26
|
+
const DIM = "\x1b[2m";
|
|
27
|
+
const GREEN = "\x1b[32m";
|
|
28
|
+
const YELLOW = "\x1b[33m";
|
|
29
|
+
const RESET = "\x1b[0m";
|
|
30
|
+
const PACKAGE_NAME = "@officexapp/vidfarm-devcli";
|
|
31
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
32
|
+
const DEFAULT_HOST = "https://vidfarm.cc";
|
|
33
|
+
/** "at least once every 24 hours" — the cadence SKILL.md asks agents to keep. */
|
|
34
|
+
export const DEFAULT_STALE_HOURS = 24;
|
|
35
|
+
/** Never let a version probe hold up the real work. */
|
|
36
|
+
const FETCH_TIMEOUT_MS = 6000;
|
|
37
|
+
// ── semver ───────────────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Compare two semvers numerically. A STRING compare is the trap here: "0.9.0"
|
|
40
|
+
* sorts above "0.21.55", which would report a current install as ahead of the
|
|
41
|
+
* registry and silently suppress every update prompt.
|
|
42
|
+
*/
|
|
43
|
+
export function compareSemver(a, b) {
|
|
44
|
+
const parse = (v) => v.trim().replace(/^v/, "").split("-")[0].split(".").map((part) => {
|
|
45
|
+
const n = Number(part);
|
|
46
|
+
return Number.isFinite(n) ? n : 0;
|
|
47
|
+
});
|
|
48
|
+
const left = parse(a);
|
|
49
|
+
const right = parse(b);
|
|
50
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
51
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
52
|
+
if (diff !== 0)
|
|
53
|
+
return diff < 0 ? -1 : 1;
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
/** Which part moved: a major bump is the one that can break a documented flow. */
|
|
58
|
+
export function bumpKind(from, to) {
|
|
59
|
+
const parse = (v) => v.trim().replace(/^v/, "").split("-")[0].split(".").map((p) => Number(p) || 0);
|
|
60
|
+
const [aMajor = 0, aMinor = 0, aPatch = 0] = parse(from);
|
|
61
|
+
const [bMajor = 0, bMinor = 0, bPatch = 0] = parse(to);
|
|
62
|
+
if (bMajor !== aMajor)
|
|
63
|
+
return "major";
|
|
64
|
+
if (bMinor !== aMinor)
|
|
65
|
+
return "minor";
|
|
66
|
+
if (bPatch !== aPatch)
|
|
67
|
+
return "patch";
|
|
68
|
+
return "none";
|
|
69
|
+
}
|
|
70
|
+
// ── what is installed ────────────────────────────────────────────────────────
|
|
71
|
+
/** Walk up from this module to the package root and read its version. */
|
|
72
|
+
export function installedDevcliVersion() {
|
|
73
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
74
|
+
for (let i = 0; i < 6; i += 1) {
|
|
75
|
+
const candidate = path.join(dir, "package.json");
|
|
76
|
+
if (existsSync(candidate)) {
|
|
77
|
+
try {
|
|
78
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf8"));
|
|
79
|
+
if (pkg.version && (pkg.name === PACKAGE_NAME || pkg.name === "vidfarm-devcli" || i > 0)) {
|
|
80
|
+
return String(pkg.version);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// keep walking — a malformed package.json higher up is not fatal
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const parent = path.dirname(dir);
|
|
88
|
+
if (parent === dir)
|
|
89
|
+
break;
|
|
90
|
+
dir = parent;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* `version:` out of a SKILL.md front-matter block. Deliberately tiny — the same
|
|
96
|
+
* stance as the harness front-matter reader: a pack that needs a YAML parser to
|
|
97
|
+
* state its own version has stopped being legible to the human maintaining it.
|
|
98
|
+
*/
|
|
99
|
+
export function readSkillVersion(markdown) {
|
|
100
|
+
const front = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
101
|
+
const body = front ? front[1] : markdown.slice(0, 2000);
|
|
102
|
+
const found = body.match(/^\s*(?:skill_version|version)\s*:\s*["']?v?([0-9]+(?:\.[0-9]+)*)["']?\s*$/m);
|
|
103
|
+
return found ? found[1] : null;
|
|
104
|
+
}
|
|
105
|
+
/** The vidfarm skill pack installed on this machine, if there is one. */
|
|
106
|
+
export function installedSkillPath() {
|
|
107
|
+
const roots = [process.cwd(), path.dirname(fileURLToPath(import.meta.url))];
|
|
108
|
+
for (const root of roots) {
|
|
109
|
+
let dir = root;
|
|
110
|
+
for (let i = 0; i < 6; i += 1) {
|
|
111
|
+
for (const rel of [
|
|
112
|
+
path.join(".agents", "skills", "vidfarm", "SKILL.md"),
|
|
113
|
+
path.join(".claude", "skills", "vidfarm", "SKILL.md")
|
|
114
|
+
]) {
|
|
115
|
+
const candidate = path.join(dir, rel);
|
|
116
|
+
if (existsSync(candidate))
|
|
117
|
+
return candidate;
|
|
118
|
+
}
|
|
119
|
+
const parent = path.dirname(dir);
|
|
120
|
+
if (parent === dir)
|
|
121
|
+
break;
|
|
122
|
+
dir = parent;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
// ── what is published ────────────────────────────────────────────────────────
|
|
128
|
+
async function fetchText(url, headers = {}) {
|
|
129
|
+
try {
|
|
130
|
+
const response = await fetch(url, {
|
|
131
|
+
headers: { accept: "*/*", ...headers },
|
|
132
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
133
|
+
});
|
|
134
|
+
if (!response.ok)
|
|
135
|
+
return null;
|
|
136
|
+
return await response.text();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Offline is a normal state for this CLI — the whole local half works with
|
|
140
|
+
// no network. A failed probe must never look like "you are out of date".
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function latestDevcliVersion() {
|
|
145
|
+
const text = await fetchText(REGISTRY_URL, { accept: "application/json" });
|
|
146
|
+
if (!text)
|
|
147
|
+
return null;
|
|
148
|
+
try {
|
|
149
|
+
const body = JSON.parse(text);
|
|
150
|
+
return body.version ? String(body.version) : null;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function latestSkillVersion(host) {
|
|
157
|
+
const text = await fetchText(`${host.replace(/\/+$/, "")}/SKILL.md`);
|
|
158
|
+
return text ? readSkillVersion(text) : null;
|
|
159
|
+
}
|
|
160
|
+
function statePath(home) {
|
|
161
|
+
return path.join(resolveDevcliHome(home), "update-check.json");
|
|
162
|
+
}
|
|
163
|
+
export function readUpdateState(home) {
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(readFileSync(statePath(home), "utf8"));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export function writeUpdateState(next, home) {
|
|
172
|
+
const file = statePath(home);
|
|
173
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
174
|
+
writeFileSync(file, JSON.stringify(next, null, 2), "utf8");
|
|
175
|
+
}
|
|
176
|
+
/** Hours since the last check, or null if it has never run. */
|
|
177
|
+
export function hoursSinceLastCheck(state, now = Date.now()) {
|
|
178
|
+
if (!state.lastCheckedAt)
|
|
179
|
+
return null;
|
|
180
|
+
const then = Date.parse(state.lastCheckedAt);
|
|
181
|
+
if (!Number.isFinite(then))
|
|
182
|
+
return null;
|
|
183
|
+
return (now - then) / 3_600_000;
|
|
184
|
+
}
|
|
185
|
+
export async function runUpdateCheck(options = {}) {
|
|
186
|
+
const host = options.host || process.env.VIDFARM_HOST || DEFAULT_HOST;
|
|
187
|
+
const now = options.now ?? Date.now();
|
|
188
|
+
const state = readUpdateState(options.home);
|
|
189
|
+
const devcliInstalled = installedDevcliVersion();
|
|
190
|
+
const skillPath = installedSkillPath();
|
|
191
|
+
const skillInstalled = skillPath ? readSkillVersion(readFileSync(skillPath, "utf8")) : null;
|
|
192
|
+
const [devcliLatest, skillLatest] = await Promise.all([latestDevcliVersion(), latestSkillVersion(host)]);
|
|
193
|
+
const offline = devcliLatest === null && skillLatest === null;
|
|
194
|
+
const devcliBehind = Boolean(devcliInstalled && devcliLatest && compareSemver(devcliInstalled, devcliLatest) < 0);
|
|
195
|
+
const skillBehind = Boolean(skillInstalled && skillLatest && compareSemver(skillInstalled, skillLatest) < 0);
|
|
196
|
+
// The version the user would be moving TO — a skip is recorded against it, so
|
|
197
|
+
// a NEWER release always asks again rather than staying silent forever.
|
|
198
|
+
const target = devcliLatest && skillLatest
|
|
199
|
+
? (compareSemver(devcliLatest, skillLatest) >= 0 ? devcliLatest : skillLatest)
|
|
200
|
+
: (devcliLatest ?? skillLatest ?? "");
|
|
201
|
+
const skipped = Boolean(state.skippedVersion && target && compareSemver(target, state.skippedVersion) <= 0);
|
|
202
|
+
const report = {
|
|
203
|
+
devcli: {
|
|
204
|
+
installed: devcliInstalled,
|
|
205
|
+
latest: devcliLatest,
|
|
206
|
+
behind: devcliBehind,
|
|
207
|
+
bump: devcliInstalled && devcliLatest ? bumpKind(devcliInstalled, devcliLatest) : "none"
|
|
208
|
+
},
|
|
209
|
+
skill: { installed: skillInstalled, latest: skillLatest, behind: skillBehind, path: skillPath },
|
|
210
|
+
update_available: (devcliBehind || skillBehind) && !skipped,
|
|
211
|
+
suppressed: (devcliBehind || skillBehind) && skipped,
|
|
212
|
+
skipped,
|
|
213
|
+
checked_at: new Date(now).toISOString(),
|
|
214
|
+
hours_since_last_check: hoursSinceLastCheck(state, now),
|
|
215
|
+
offline
|
|
216
|
+
};
|
|
217
|
+
// Only a check that actually reached the network resets the clock. Stamping it
|
|
218
|
+
// while offline would suppress the next 24 hours of checks on no information.
|
|
219
|
+
if (!offline)
|
|
220
|
+
writeUpdateState({ ...state, lastCheckedAt: report.checked_at }, options.home);
|
|
221
|
+
return report;
|
|
222
|
+
}
|
|
223
|
+
// ── the command ──────────────────────────────────────────────────────────────
|
|
224
|
+
export const UPDATE_CHECK_HELP = `vidfarm update-check — is this install current?
|
|
225
|
+
|
|
226
|
+
update-check Check both halves (skill + devcli) and print the verdict
|
|
227
|
+
--if-stale [--hours 24] No-op unless the last check is older than N hours.
|
|
228
|
+
THIS is the one to put in an agent's session opener.
|
|
229
|
+
--quiet One line, and NOTHING at all when already current
|
|
230
|
+
--json Structured: {devcli, skill, update_available, ...}
|
|
231
|
+
--skip Record "not now" for the current latest version.
|
|
232
|
+
A NEWER release asks again; this one stays quiet.
|
|
233
|
+
--unskip Clear that, and ask again from now on
|
|
234
|
+
--host <url> Where to read the published SKILL.md (default vidfarm.cc)
|
|
235
|
+
|
|
236
|
+
The skill and the devcli are versioned TOGETHER. Updating one without the other
|
|
237
|
+
is the most common cause of "that command doesn't exist" and "the skill says to
|
|
238
|
+
do X but it fails". Update both: https://vidfarm.cc/update.md`;
|
|
239
|
+
export async function runUpdateCheckCommand(argv) {
|
|
240
|
+
const { parseArgs } = await import("node:util");
|
|
241
|
+
const parsed = parseArgs({
|
|
242
|
+
args: argv,
|
|
243
|
+
allowPositionals: true,
|
|
244
|
+
options: {
|
|
245
|
+
json: { type: "boolean" },
|
|
246
|
+
quiet: { type: "boolean" },
|
|
247
|
+
"if-stale": { type: "boolean" },
|
|
248
|
+
hours: { type: "string" },
|
|
249
|
+
skip: { type: "boolean" },
|
|
250
|
+
unskip: { type: "boolean" },
|
|
251
|
+
host: { type: "string" },
|
|
252
|
+
help: { type: "boolean" }
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
const values = parsed.values;
|
|
256
|
+
if (values.help) {
|
|
257
|
+
console.log(UPDATE_CHECK_HELP);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (values.unskip) {
|
|
261
|
+
const state = readUpdateState();
|
|
262
|
+
delete state.skippedVersion;
|
|
263
|
+
delete state.skippedAt;
|
|
264
|
+
writeUpdateState(state);
|
|
265
|
+
console.log(`${GREEN}✓${RESET} Update prompts re-enabled.`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// `--if-stale` is what makes a 24-hour cadence cheap: the agent can call this
|
|
269
|
+
// at the top of every session and it costs one network round-trip a day.
|
|
270
|
+
if (values["if-stale"]) {
|
|
271
|
+
const hours = Number(values.hours ?? DEFAULT_STALE_HOURS);
|
|
272
|
+
const since = hoursSinceLastCheck(readUpdateState());
|
|
273
|
+
if (since !== null && since < (Number.isFinite(hours) ? hours : DEFAULT_STALE_HOURS)) {
|
|
274
|
+
if (values.json)
|
|
275
|
+
console.log(JSON.stringify({ checked: false, hours_since_last_check: since }, null, 2));
|
|
276
|
+
else if (!values.quiet)
|
|
277
|
+
console.log(`${DIM}Checked ${since.toFixed(1)}h ago — skipping (next check after ${hours}h).${RESET}`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const report = await runUpdateCheck({ host: values.host });
|
|
282
|
+
if (values.skip) {
|
|
283
|
+
const target = report.devcli.latest ?? report.skill.latest;
|
|
284
|
+
if (target) {
|
|
285
|
+
writeUpdateState({ ...readUpdateState(), skippedVersion: target, skippedAt: new Date().toISOString() });
|
|
286
|
+
console.log(`${GREEN}✓${RESET} Skipped ${BOLD}${target}${RESET}. A newer release will ask again — ${DIM}vidfarm update-check --unskip${RESET} to undo.`);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
console.log(`${DIM}Nothing to skip — could not read the published versions.${RESET}`);
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (values.json) {
|
|
294
|
+
console.log(JSON.stringify(report, null, 2));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (report.offline) {
|
|
298
|
+
if (!values.quiet)
|
|
299
|
+
console.log(`${DIM}Could not reach the registry or ${values.host ?? DEFAULT_HOST} — skipping the version check. Everything local still works.${RESET}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (!report.update_available) {
|
|
303
|
+
if (values.quiet)
|
|
304
|
+
return; // silence is the point of --quiet when current
|
|
305
|
+
const parts = [
|
|
306
|
+
`devcli ${report.devcli.installed ?? "?"}`,
|
|
307
|
+
`skill ${report.skill.installed ?? "not installed"}`
|
|
308
|
+
];
|
|
309
|
+
console.log(`${GREEN}✓${RESET} Vidfarm is current ${DIM}(${parts.join(" · ")})${RESET}`);
|
|
310
|
+
if (report.suppressed)
|
|
311
|
+
console.log(` ${DIM}An update is available but you skipped it: vidfarm update-check --unskip${RESET}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const lines = [];
|
|
315
|
+
if (report.devcli.behind)
|
|
316
|
+
lines.push(` devcli ${BOLD}${report.devcli.installed}${RESET} → ${BOLD}${GREEN}${report.devcli.latest}${RESET} ${DIM}(${report.devcli.bump})${RESET}`);
|
|
317
|
+
if (report.skill.behind)
|
|
318
|
+
lines.push(` skill ${BOLD}${report.skill.installed}${RESET} → ${BOLD}${GREEN}${report.skill.latest}${RESET}`);
|
|
319
|
+
if (report.skill.installed === null && report.skill.latest) {
|
|
320
|
+
lines.push(` skill ${DIM}not installed${RESET} → ${BOLD}${GREEN}${report.skill.latest}${RESET} ${DIM}(vidfarm skills add vidfarm)${RESET}`);
|
|
321
|
+
}
|
|
322
|
+
if (values.quiet) {
|
|
323
|
+
console.log(`${YELLOW}Vidfarm update available${RESET}${DIM} — ${report.devcli.behind ? `devcli ${report.devcli.installed}→${report.devcli.latest}` : ""}${report.devcli.behind && report.skill.behind ? ", " : ""}${report.skill.behind ? `skill ${report.skill.installed}→${report.skill.latest}` : ""} · vidfarm update-check for detail, --skip to dismiss${RESET}`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
console.log(`${YELLOW}⟳ A Vidfarm update is available.${RESET}`);
|
|
327
|
+
for (const line of lines)
|
|
328
|
+
console.log(line);
|
|
329
|
+
console.log("");
|
|
330
|
+
if (report.devcli.bump === "major") {
|
|
331
|
+
console.log(`${YELLOW}This is a MAJOR bump${RESET}${DIM} — read the breaking-change notes before you assume an old workflow still applies.${RESET}`);
|
|
332
|
+
}
|
|
333
|
+
console.log(`${DIM}Update BOTH halves together — they are versioned together, and updating one alone is the${RESET}`);
|
|
334
|
+
console.log(`${DIM}usual cause of "that command doesn't exist". The runbook: ${RESET}https://vidfarm.cc/update.md`);
|
|
335
|
+
console.log("");
|
|
336
|
+
console.log(` ${BOLD}npm i -g ${PACKAGE_NAME}@latest${RESET} ${DIM}&&${RESET} ${BOLD}vidfarm skills add vidfarm${RESET}`);
|
|
337
|
+
console.log("");
|
|
338
|
+
console.log(`${DIM}Not now? ${RESET}vidfarm update-check --skip${DIM} — this version stays quiet, a newer one asks again.${RESET}`);
|
|
339
|
+
}
|
|
340
|
+
//# sourceMappingURL=update-check.js.map
|
|
@@ -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
|