@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.
@@ -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) —