@officexapp/vidfarm-devcli 0.21.31 → 0.21.33
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/editor-capabilities/SKILL.md +4 -3
- package/.agents/skills/vidfarm/SKILL.md +3 -2
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +12 -0
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +3 -2
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +14 -1
- package/.agents/skills/vidfarm/references/core-workflows.md +62 -5
- package/.agents/skills/vidfarm/references/editor-workflows.md +5 -3
- package/.agents/skills/vidfarm/references/primitives.md +75 -32
- package/SKILL.director.md +174 -45
- package/SKILL.md +3 -1
- package/dist/src/cli.js +487 -4
- package/dist/src/devcli/dedupe-local.js +209 -0
- package/dist/src/devcli/qa-check.js +68 -0
- package/dist/src/lib/dedupe-recipe.js +420 -0
- package/package.json +3 -1
- package/public/assets/homepage-client-app.js +14 -14
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Bundle-safe LOCAL deduplication for the published devcli.
|
|
2
|
+
//
|
|
3
|
+
// Deduplication = nudging a finished render's geometry, color, timing and grain
|
|
4
|
+
// by a couple of percent so a platform's duplicate-content fingerprint reads it
|
|
5
|
+
// as a NEW upload, while a human sees no difference. It is what lets one good
|
|
6
|
+
// video be posted across several accounts/slots without the second and third
|
|
7
|
+
// posts being suppressed as reused content.
|
|
8
|
+
//
|
|
9
|
+
// This is a pure ffmpeg op with zero server dependency (exactly like
|
|
10
|
+
// greenscreen-local.ts), so it runs FREE and offline in the shipped CLI. Every
|
|
11
|
+
// transform comes from lib/dedupe-recipe.ts — the SAME module the cloud
|
|
12
|
+
// primitive (services/media-processing.dedupeMediaAsset) uses — so local output
|
|
13
|
+
// matches cloud output knob for knob. No backend module is imported here, which
|
|
14
|
+
// keeps the pack guard happy.
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { existsSync, statSync } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { resolveFfmpeg, resolveFfprobe, hasFfmpeg } from "../services/clip-curation/ffmpeg.js";
|
|
19
|
+
import { buildDedupeFfmpegPlan, dedupeCrfForVariant, describeDedupeEffects, resolveDedupeEffects, DEDUPE_DEFAULT_PRESET } from "../lib/dedupe-recipe.js";
|
|
20
|
+
export { DEDUPE_PRESETS, DEDUPE_DEFAULT_PRESET, describeDedupeEffects, isDedupePresetName, resolveDedupeEffects } from "../lib/dedupe-recipe.js";
|
|
21
|
+
/** True when a usable ffmpeg is available (bundled ffmpeg-static or on PATH). */
|
|
22
|
+
export async function localDedupeAvailable() {
|
|
23
|
+
return hasFfmpeg();
|
|
24
|
+
}
|
|
25
|
+
async function runFfmpeg(bin, args) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
28
|
+
let stderr = "";
|
|
29
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
30
|
+
child.on("error", reject);
|
|
31
|
+
child.on("close", (code) => resolve({ code: code ?? 0, stderr }));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function probe(filePath) {
|
|
35
|
+
const ffprobe = await resolveFfprobe();
|
|
36
|
+
const args = ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", filePath];
|
|
37
|
+
const raw = await new Promise((resolve) => {
|
|
38
|
+
const child = spawn(ffprobe, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
39
|
+
let out = "";
|
|
40
|
+
child.stdout.on("data", (d) => (out += d.toString()));
|
|
41
|
+
child.on("error", () => resolve(""));
|
|
42
|
+
child.on("close", () => resolve(out));
|
|
43
|
+
});
|
|
44
|
+
let parsed = {};
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(raw || "{}");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
parsed = {};
|
|
50
|
+
}
|
|
51
|
+
const streams = (Array.isArray(parsed.streams) ? parsed.streams : []);
|
|
52
|
+
const video = streams.find((stream) => stream.codec_type === "video");
|
|
53
|
+
const format = (parsed.format ?? {});
|
|
54
|
+
let fps = null;
|
|
55
|
+
if (typeof video?.avg_frame_rate === "string") {
|
|
56
|
+
const [numerator, denominator] = video.avg_frame_rate.split("/").map(Number);
|
|
57
|
+
if (numerator && denominator)
|
|
58
|
+
fps = numerator / denominator;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
width: typeof video?.width === "number" ? video.width : null,
|
|
62
|
+
height: typeof video?.height === "number" ? video.height : null,
|
|
63
|
+
durationSec: Number(format.duration ?? video?.duration ?? 0) || 0,
|
|
64
|
+
fps,
|
|
65
|
+
hasAudio: streams.some((stream) => stream.codec_type === "audio"),
|
|
66
|
+
hasVideo: Boolean(video)
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Deduplicate an image or video LOCALLY with ffmpeg — free, offline, no wallet.
|
|
71
|
+
* Cloud-parity: identical filter graph to `primitive:media_dedupe` (engine
|
|
72
|
+
* `ffmpeg`).
|
|
73
|
+
*
|
|
74
|
+
* Two things happen beyond the visible transforms:
|
|
75
|
+
* • `-map_metadata -1` strips creation time / encoder / source handler, which
|
|
76
|
+
* some platforms compare BEFORE they compare pixels.
|
|
77
|
+
* • The CRF is walked ±1 per variant, so the coded bitstream differs even
|
|
78
|
+
* where the decoded frames are near-identical.
|
|
79
|
+
*
|
|
80
|
+
* Retries without the `perspective` (skew) stage on ffmpeg builds compiled
|
|
81
|
+
* without vf_perspective rather than failing the whole pass.
|
|
82
|
+
*/
|
|
83
|
+
export async function dedupeMediaLocal(input) {
|
|
84
|
+
if (!existsSync(input.sourcePath))
|
|
85
|
+
throw new Error(`No such source file: ${input.sourcePath}`);
|
|
86
|
+
const info = await probe(input.sourcePath);
|
|
87
|
+
const mediaType = input.mediaType
|
|
88
|
+
?? (info.hasVideo && info.durationSec > 0.25 ? "video" : "image");
|
|
89
|
+
const variant = Math.max(1, Math.round(input.variant ?? 1) || 1);
|
|
90
|
+
const preset = input.preset ?? DEDUPE_DEFAULT_PRESET;
|
|
91
|
+
const effects = resolveDedupeEffects({
|
|
92
|
+
preset,
|
|
93
|
+
effects: input.effects,
|
|
94
|
+
variant,
|
|
95
|
+
seed: input.seed,
|
|
96
|
+
jitter: input.jitter
|
|
97
|
+
});
|
|
98
|
+
const plan = buildDedupeFfmpegPlan({
|
|
99
|
+
effects,
|
|
100
|
+
mediaType,
|
|
101
|
+
width: info.width ?? input.width ?? 1080,
|
|
102
|
+
height: info.height ?? input.height ?? 1920,
|
|
103
|
+
outWidth: input.width ?? info.width ?? null,
|
|
104
|
+
outHeight: input.height ?? info.height ?? null,
|
|
105
|
+
sourceFps: info.fps,
|
|
106
|
+
tintColor: input.tintColor ?? null,
|
|
107
|
+
tintOpacity: input.tintOpacity ?? null
|
|
108
|
+
});
|
|
109
|
+
const crf = dedupeCrfForVariant(input.crf ?? 21, variant, input.seed ?? "");
|
|
110
|
+
const stripMetadata = input.stripMetadata ?? true;
|
|
111
|
+
const isWebp = /\.webp$/i.test(input.outputPath);
|
|
112
|
+
const isJpeg = /\.jpe?g$/i.test(input.outputPath);
|
|
113
|
+
const ffmpeg = await resolveFfmpeg();
|
|
114
|
+
const buildArgs = (videoFilter) => mediaType === "video"
|
|
115
|
+
? [
|
|
116
|
+
"-hide_banner", "-y",
|
|
117
|
+
"-i", input.sourcePath,
|
|
118
|
+
"-map", "0:v:0",
|
|
119
|
+
"-map", "0:a:0?",
|
|
120
|
+
"-vf", videoFilter,
|
|
121
|
+
...(plan.audioFilter && info.hasAudio ? ["-af", plan.audioFilter] : []),
|
|
122
|
+
...(stripMetadata ? ["-map_metadata", "-1"] : []),
|
|
123
|
+
"-c:v", "libx264",
|
|
124
|
+
"-preset", input.x264Preset ?? "veryfast",
|
|
125
|
+
"-crf", String(crf),
|
|
126
|
+
"-pix_fmt", "yuv420p",
|
|
127
|
+
"-movflags", "+faststart",
|
|
128
|
+
"-c:a", "aac",
|
|
129
|
+
"-b:a", `${input.audioBitrateKbps ?? 128}k`,
|
|
130
|
+
"-ar", "48000",
|
|
131
|
+
input.outputPath
|
|
132
|
+
]
|
|
133
|
+
: [
|
|
134
|
+
"-hide_banner", "-y",
|
|
135
|
+
"-i", input.sourcePath,
|
|
136
|
+
"-frames:v", "1",
|
|
137
|
+
"-vf", videoFilter,
|
|
138
|
+
...(stripMetadata ? ["-map_metadata", "-1"] : []),
|
|
139
|
+
...(isWebp ? ["-c:v", "libwebp", "-quality", "92"] : []),
|
|
140
|
+
...(isJpeg ? ["-q:v", "3"] : []),
|
|
141
|
+
input.outputPath
|
|
142
|
+
];
|
|
143
|
+
// Distinguish "this recipe has no shear" from "the shear was dropped": only
|
|
144
|
+
// the latter is worth warning about.
|
|
145
|
+
const skewRequested = plan.videoFilter !== plan.videoFilterWithoutSkew;
|
|
146
|
+
let skewApplied = skewRequested;
|
|
147
|
+
let run = await runFfmpeg(ffmpeg, buildArgs(plan.videoFilter));
|
|
148
|
+
if ((run.code !== 0 || !existsSync(input.outputPath)) && skewRequested) {
|
|
149
|
+
// Minimal ffmpeg builds ship without vf_perspective. Losing the shear beats
|
|
150
|
+
// losing the whole dedupe pass.
|
|
151
|
+
skewApplied = false;
|
|
152
|
+
run = await runFfmpeg(ffmpeg, buildArgs(plan.videoFilterWithoutSkew));
|
|
153
|
+
}
|
|
154
|
+
if (run.code !== 0 || !existsSync(input.outputPath)) {
|
|
155
|
+
throw new Error(`Local dedupe failed (ffmpeg exit ${run.code})${tail(run.stderr)}.`);
|
|
156
|
+
}
|
|
157
|
+
const skewDropped = skewRequested && !skewApplied;
|
|
158
|
+
return {
|
|
159
|
+
outputPath: input.outputPath,
|
|
160
|
+
mediaType,
|
|
161
|
+
effects,
|
|
162
|
+
preset,
|
|
163
|
+
variant,
|
|
164
|
+
notes: skewDropped ? [...plan.notes, "skew skipped — this ffmpeg has no vf_perspective"] : plan.notes,
|
|
165
|
+
effectiveZoom: plan.effectiveZoom,
|
|
166
|
+
speed: plan.speed,
|
|
167
|
+
crf,
|
|
168
|
+
skewApplied,
|
|
169
|
+
skewDropped,
|
|
170
|
+
sourceWidth: info.width,
|
|
171
|
+
sourceHeight: info.height,
|
|
172
|
+
bytes: safeSize(input.outputPath)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** One-line summary of what a resolved recipe does, for CLI output. */
|
|
176
|
+
export function describeLocalDedupe(result) {
|
|
177
|
+
const summary = describeDedupeEffects(result.effects, result.preset);
|
|
178
|
+
// A rotate forces a bigger centre-crop than asked for (the zoom that hides the
|
|
179
|
+
// black corners). Say so — on a tall frame it is a real framing change.
|
|
180
|
+
if (result.effectiveZoom > result.effects.zoom + 0.0005) {
|
|
181
|
+
const applied = `${((result.effectiveZoom - 1) * 100).toFixed(1)}%`;
|
|
182
|
+
return `${summary} → crops ${applied} to hide the rotate corners (--rotate 0 keeps your framing)`;
|
|
183
|
+
}
|
|
184
|
+
return summary;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Default output path next to a source: `<stem>.dedupe.<ext>` for a one-off,
|
|
188
|
+
* `<stem>.dedupe-02.<ext>` and up for batch variants (variant 1 keeps the plain
|
|
189
|
+
* name so a single-copy run reads cleanly).
|
|
190
|
+
*/
|
|
191
|
+
export function defaultDedupeOutPath(sourcePath, variant = 1) {
|
|
192
|
+
const ext = path.extname(sourcePath) || ".mp4";
|
|
193
|
+
const stem = path.basename(sourcePath, ext);
|
|
194
|
+
const suffix = variant > 1 ? `.dedupe-${String(variant).padStart(2, "0")}` : ".dedupe";
|
|
195
|
+
return path.resolve(path.dirname(path.resolve(sourcePath)), `${stem}${suffix}${ext}`);
|
|
196
|
+
}
|
|
197
|
+
function safeSize(filePath) {
|
|
198
|
+
try {
|
|
199
|
+
return statSync(filePath).size;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function tail(stderr) {
|
|
206
|
+
const trimmed = stderr.trim().split("\n").slice(-3).join("\n");
|
|
207
|
+
return trimmed ? `:\n${trimmed}` : "";
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=dedupe-local.js.map
|
|
@@ -103,6 +103,38 @@ function radiusPx(style) {
|
|
|
103
103
|
const num = Number((raw.match(/(-?[\d.]+)px/) ?? [])[1] ?? 0);
|
|
104
104
|
return Number.isFinite(num) ? num : 0;
|
|
105
105
|
}
|
|
106
|
+
/** Walk self + ancestors up to the composition root. */
|
|
107
|
+
function selfAndAncestors(node) {
|
|
108
|
+
const chain = [];
|
|
109
|
+
let cursor = node;
|
|
110
|
+
while (cursor && typeof cursor.getAttribute === "function") {
|
|
111
|
+
chain.push(cursor);
|
|
112
|
+
if (cursor.getAttribute("data-composition-id") != null)
|
|
113
|
+
break;
|
|
114
|
+
cursor = cursor.parentNode;
|
|
115
|
+
}
|
|
116
|
+
return chain;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Is this node part of an animated caption run? The active-word highlight IS a
|
|
120
|
+
* pill, and it is the one legitimate pill in a video — it tracks the spoken
|
|
121
|
+
* word instead of sitting there like a badge.
|
|
122
|
+
*/
|
|
123
|
+
function isAnimatedCaptionPart(node) {
|
|
124
|
+
return selfAndAncestors(node).some((n) => n.getAttribute("data-caption-animation") != null || n.getAttribute("data-cap-word") != null);
|
|
125
|
+
}
|
|
126
|
+
// Native platform artifacts that legitimately use rounded, filled bubbles:
|
|
127
|
+
// iMessage/DM threads, TikTok comment cards, fake chat UI. These are social
|
|
128
|
+
// furniture, not web furniture, so the badge rule must never fire on them.
|
|
129
|
+
const MOCK_UI_HINT = /(imessage|messenger|whatsapp|bubble|chat|sms|dm-|comment|reply|tweet|notification|caption-pill)/i;
|
|
130
|
+
function isMockSocialUi(node) {
|
|
131
|
+
return selfAndAncestors(node).some((n) => {
|
|
132
|
+
if (n.getAttribute("data-vf-mock-ui") != null)
|
|
133
|
+
return true;
|
|
134
|
+
const hay = `${n.getAttribute("class") ?? ""} ${n.getAttribute("data-label") ?? ""} ${n.getAttribute("data-slug") ?? ""} ${n.getAttribute("id") ?? ""}`;
|
|
135
|
+
return MOCK_UI_HINT.test(hay);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
106
138
|
function primaryFamily(raw) {
|
|
107
139
|
return String(raw).split(",")[0].replace(/['"]/g, "").trim().toLowerCase();
|
|
108
140
|
}
|
|
@@ -264,6 +296,42 @@ export function qaCompositionHtml(html) {
|
|
|
264
296
|
fix: "Say each benefit as its OWN timed caption line on the footage, one at a time, in the font regime. One idea per beat reads far better than a chip strip."
|
|
265
297
|
});
|
|
266
298
|
}
|
|
299
|
+
// ── Rule: standalone badge pill ────────────────────────────────────────────
|
|
300
|
+
// The lonely capsule: ONE rounded, padded, filled tag holding a static stat or
|
|
301
|
+
// label — "10 hrs / week", "STEP 2", "EP.01", "+40%". badge-chip-row needs two
|
|
302
|
+
// siblings and cta-button needs action copy, so a single stat pill used to slip
|
|
303
|
+
// through both — and it is the most common surviving web tell in practice.
|
|
304
|
+
// Two signals: a capsule shape (radius well past a caption band) AND a fill.
|
|
305
|
+
// Excluded on purpose: active-word caption highlights (they MOVE with the
|
|
306
|
+
// spoken word — the one legitimate pill) and mock social UI, which is native.
|
|
307
|
+
for (const node of all) {
|
|
308
|
+
const text = textOf(node);
|
|
309
|
+
if (!text || text.length > 45)
|
|
310
|
+
continue;
|
|
311
|
+
const style = styleString(node);
|
|
312
|
+
const radius = radiusPx(style);
|
|
313
|
+
const filled = /background(?:-color|-image)?\s*:/.test(style) && !/background[^;]*:\s*(none|transparent)/.test(style);
|
|
314
|
+
// A caption band tops out around 8px; 20px+ on a text-sized box is a capsule.
|
|
315
|
+
if (!filled || radius < 20)
|
|
316
|
+
continue;
|
|
317
|
+
// Padding is what turns a hugging band into a tag. Either axis counts.
|
|
318
|
+
const padded = hasDecl(style, "padding") || hasDecl(style, "padding-left") || hasDecl(style, "padding-inline") ||
|
|
319
|
+
hasDecl(style, "padding-top") || hasDecl(style, "padding-block");
|
|
320
|
+
if (!padded)
|
|
321
|
+
continue;
|
|
322
|
+
if (isAnimatedCaptionPart(node) || isMockSocialUi(node))
|
|
323
|
+
continue;
|
|
324
|
+
// A capsule wrapping several elements is a card — card-panel's job, not ours.
|
|
325
|
+
if (Array.from(node.children ?? []).filter((c) => textOf(c)).length >= 2)
|
|
326
|
+
continue;
|
|
327
|
+
push({
|
|
328
|
+
rule: "static-pill",
|
|
329
|
+
severity: "error",
|
|
330
|
+
message: `Badge pill ("${text.slice(0, 30)}") — a filled ${radius >= 9999 ? "fully-rounded" : `${Math.round(radius)}px`} capsule with padding around static text. One is still a badge.`,
|
|
331
|
+
where: label(node, "pill"),
|
|
332
|
+
fix: "Drop the capsule and set the words themselves: bigger, heavier, ALL-CAPS, or an accent color — or circle/underline them. The only legitimate pill tracks the spoken word (set_captions spotlight/karaoke). Mock social UI is exempt: mark it data-vf-mock-ui."
|
|
333
|
+
});
|
|
334
|
+
}
|
|
267
335
|
// ── Rule: card / panel / glassmorphism ─────────────────────────────────────
|
|
268
336
|
// A rounded box with a border, shadow, or frosted blur, holding more than one
|
|
269
337
|
// piece of content. A tight caption BAND is legal (small radius, one run) —
|