@officexapp/vidfarm-devcli 0.21.31 → 0.21.32

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
@@ -0,0 +1,420 @@
1
+ // Deduplication recipe — the SINGLE source of truth for "make this render
2
+ // visually identical to a human but numerically distinct to a platform's
3
+ // duplicate-content detector".
4
+ //
5
+ // Why this module exists: social platforms (TikTok, Reels, Shorts, X) hash
6
+ // uploads with a perceptual fingerprint. Re-posting the SAME bytes — or the same
7
+ // frames re-encoded — gets the second post suppressed or flagged as duplicate /
8
+ // reused content. Nudging geometry, color, timing and grain by a couple of
9
+ // percent moves the fingerprint far enough to read as a distinct upload while
10
+ // staying invisible to a viewer.
11
+ //
12
+ // This file is PURE (no node/aws imports) so the exact same math drives:
13
+ // • cloud — primitive:media_dedupe → services/media-processing.dedupeMediaAsset
14
+ // • local — `vidfarm dedupe` → devcli/dedupe-local.ts
15
+ // • schemas — primitive-registry.ts REST payload validation
16
+ // Keep it dependency-free: it is inside the published devcli's import closure.
17
+ /** Every knob at its no-op value. */
18
+ export const DEDUPE_NEUTRAL_EFFECTS = {
19
+ zoom: 1,
20
+ tilt: 0,
21
+ rotate: 0,
22
+ skew: 0,
23
+ saturation: 1,
24
+ speed: 1,
25
+ horizontal_flip: false,
26
+ contrast: 1,
27
+ brightness: 1,
28
+ hue_rotate: 0,
29
+ blur: 0,
30
+ noise: 0,
31
+ volume: 1
32
+ };
33
+ /**
34
+ * The standard transformation sets. `standard` is the house default and is the
35
+ * calibrated combination: skew 2%, zoom 3%, rotate 2°, speed +2%, saturation
36
+ * +4% — enough independent axes that a perceptual hash lands well outside the
37
+ * match threshold, small enough that nobody watching can tell.
38
+ *
39
+ * `legacy` reproduces the pre-ffmpeg composition-renderer defaults so old
40
+ * `media_dedupe` callers keep their exact output.
41
+ */
42
+ export const DEDUPE_PRESETS = {
43
+ none: { ...DEDUPE_NEUTRAL_EFFECTS },
44
+ // Barely-there. For footage you have only lightly reused, or where framing is
45
+ // tight and you cannot afford a 3% crop.
46
+ light: {
47
+ ...DEDUPE_NEUTRAL_EFFECTS,
48
+ zoom: 1.02,
49
+ rotate: 0.75,
50
+ skew: 1,
51
+ saturation: 1.02,
52
+ speed: 1.01,
53
+ contrast: 1.01,
54
+ brightness: 1.01,
55
+ hue_rotate: 2,
56
+ noise: 0.5
57
+ },
58
+ // THE DEFAULT — the numbers the house standard is written around.
59
+ standard: {
60
+ ...DEDUPE_NEUTRAL_EFFECTS,
61
+ zoom: 1.03,
62
+ rotate: 2,
63
+ skew: 2,
64
+ saturation: 1.04,
65
+ speed: 1.02,
66
+ contrast: 1.03,
67
+ brightness: 1.02,
68
+ hue_rotate: 4,
69
+ noise: 1.5
70
+ },
71
+ // For a clip you are posting for the Nth time, or onto an account that already
72
+ // posted it. Starts to be noticeable side-by-side with the original.
73
+ strong: {
74
+ ...DEDUPE_NEUTRAL_EFFECTS,
75
+ zoom: 1.06,
76
+ rotate: 3,
77
+ skew: 3.5,
78
+ saturation: 1.08,
79
+ speed: 1.05,
80
+ contrast: 1.05,
81
+ brightness: 1.04,
82
+ hue_rotate: 8,
83
+ noise: 3
84
+ },
85
+ // Pre-ffmpeg composition-renderer defaults (media-dedupe.tsx lineage).
86
+ legacy: {
87
+ ...DEDUPE_NEUTRAL_EFFECTS,
88
+ zoom: 1.04,
89
+ tilt: 3,
90
+ rotate: 3,
91
+ saturation: 1.05,
92
+ speed: 1.05,
93
+ contrast: 1.05,
94
+ brightness: 1.05
95
+ }
96
+ };
97
+ export const DEDUPE_DEFAULT_PRESET = "standard";
98
+ /** Back-compat alias for the composition renderer's old default table. */
99
+ export const DEDUPE_DEFAULT_EFFECTS = DEDUPE_PRESETS.legacy;
100
+ export function isDedupePresetName(value) {
101
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(DEDUPE_PRESETS, value);
102
+ }
103
+ export function resolveDedupeEffects(input = {}) {
104
+ const presetName = isDedupePresetName(input.preset) ? input.preset : DEDUPE_DEFAULT_PRESET;
105
+ const base = { ...DEDUPE_PRESETS[presetName] };
106
+ const variant = Math.max(1, Math.round(Number(input.variant ?? 1)) || 1);
107
+ const shouldJitter = input.jitter ?? variant > 1;
108
+ const jittered = shouldJitter && presetName !== "none"
109
+ ? jitterEffects(base, variant, input.seed ?? "")
110
+ : base;
111
+ // Explicit overrides always win over both preset and jitter — an operator who
112
+ // typed `--rotate 0` means zero, not "zero, jittered".
113
+ const merged = { ...jittered };
114
+ for (const [key, value] of Object.entries(input.effects ?? {})) {
115
+ if (value === undefined || value === null)
116
+ continue;
117
+ merged[key] = value;
118
+ }
119
+ return clampDedupeEffects(merged);
120
+ }
121
+ // Deterministic per-variant perturbation. Each knob's DISTANCE FROM NEUTRAL is
122
+ // scaled by 0.7..1.3, and the signed knobs (rotate/skew/tilt/hue) flip sign on
123
+ // alternating variants — a sign flip moves a perceptual hash much further than
124
+ // a magnitude nudge, so alternating them keeps successive variants apart.
125
+ function jitterEffects(base, variant, seed) {
126
+ const rand = mulberry32(hashSeed(`${seed}:${variant}`));
127
+ const spread = () => 0.7 + rand() * 0.6;
128
+ const flip = (index) => ((variant + index) % 2 === 0 ? -1 : 1);
129
+ return {
130
+ ...base,
131
+ zoom: 1 + (base.zoom - 1) * spread(),
132
+ tilt: base.tilt * spread() * flip(0),
133
+ rotate: base.rotate * spread() * flip(1),
134
+ skew: base.skew * spread() * flip(2),
135
+ saturation: 1 + (base.saturation - 1) * spread(),
136
+ speed: 1 + (base.speed - 1) * spread(),
137
+ horizontal_flip: base.horizontal_flip,
138
+ contrast: 1 + (base.contrast - 1) * spread(),
139
+ brightness: 1 + (base.brightness - 1) * spread(),
140
+ hue_rotate: base.hue_rotate * spread() * flip(3),
141
+ blur: base.blur * spread(),
142
+ noise: base.noise * spread(),
143
+ volume: base.volume
144
+ };
145
+ }
146
+ function hashSeed(value) {
147
+ let hash = 0x811c9dc5;
148
+ for (let index = 0; index < value.length; index += 1) {
149
+ hash ^= value.charCodeAt(index);
150
+ hash = Math.imul(hash, 0x01000193) >>> 0;
151
+ }
152
+ return hash >>> 0;
153
+ }
154
+ function mulberry32(seed) {
155
+ let state = seed >>> 0;
156
+ return () => {
157
+ state = (state + 0x6d2b79f5) >>> 0;
158
+ let t = state;
159
+ t = Math.imul(t ^ (t >>> 15), t | 1);
160
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
161
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
162
+ };
163
+ }
164
+ export function clampDedupeEffects(effects) {
165
+ const merged = { ...DEDUPE_NEUTRAL_EFFECTS, ...effects };
166
+ return {
167
+ zoom: clamp(round4(merged.zoom), 0.1, 10),
168
+ tilt: clamp(round4(merged.tilt), -90, 90),
169
+ rotate: clamp(round4(merged.rotate), -360, 360),
170
+ skew: clamp(round4(merged.skew), -45, 45),
171
+ saturation: clamp(round4(merged.saturation), 0, 10),
172
+ speed: clamp(round4(merged.speed), 0.1, 10),
173
+ horizontal_flip: Boolean(merged.horizontal_flip),
174
+ contrast: clamp(round4(merged.contrast), 0, 10),
175
+ brightness: clamp(round4(merged.brightness), 0, 10),
176
+ hue_rotate: clamp(round4(merged.hue_rotate), -360, 360),
177
+ blur: clamp(round4(merged.blur), 0, 50),
178
+ noise: clamp(round4(merged.noise), 0, 100),
179
+ volume: clamp(round4(merged.volume), 0, 2)
180
+ };
181
+ }
182
+ /** True when the resolved effects would leave the frame byte-identical. */
183
+ export function isDedupeNoop(effects) {
184
+ return !effects.horizontal_flip
185
+ && near(effects.zoom, 1) && near(effects.saturation, 1) && near(effects.speed, 1)
186
+ && near(effects.contrast, 1) && near(effects.brightness, 1) && near(effects.volume, 1)
187
+ && near(effects.rotate, 0) && near(effects.skew, 0) && near(effects.tilt, 0)
188
+ && near(effects.hue_rotate, 0) && near(effects.blur, 0) && near(effects.noise, 0);
189
+ }
190
+ export function buildDedupeFfmpegPlan(input) {
191
+ const e = input.effects;
192
+ const width = Math.max(2, Math.round(input.width) || 2);
193
+ const height = Math.max(2, Math.round(input.height) || 2);
194
+ const outWidth = evenDim(input.outWidth ?? width);
195
+ const outHeight = evenDim(input.outHeight ?? height);
196
+ const notes = [];
197
+ const skewStage = [];
198
+ const mainStage = [];
199
+ if (e.horizontal_flip) {
200
+ mainStage.push("hflip");
201
+ notes.push("mirrored horizontally");
202
+ }
203
+ // --- color -------------------------------------------------------------
204
+ // ffmpeg `eq` takes brightness as an ADDITIVE -1..1 term while our knobs are
205
+ // multipliers around 1, so a 1.02 brightness becomes +0.02.
206
+ const eqParts = [];
207
+ if (!near(e.contrast, 1))
208
+ eqParts.push(`contrast=${fixed(e.contrast)}`);
209
+ if (!near(e.brightness, 1))
210
+ eqParts.push(`brightness=${fixed(clamp(e.brightness - 1, -1, 1))}`);
211
+ if (!near(e.saturation, 1))
212
+ eqParts.push(`saturation=${fixed(e.saturation)}`);
213
+ if (eqParts.length) {
214
+ mainStage.push(`eq=${eqParts.join(":")}`);
215
+ notes.push(`color ${eqParts.map((part) => part.replace("=", " ")).join(", ")}`);
216
+ }
217
+ if (!near(e.hue_rotate, 0)) {
218
+ mainStage.push(`hue=h=${fixed(e.hue_rotate)}`);
219
+ notes.push(`hue ${fixed(e.hue_rotate)}°`);
220
+ }
221
+ // --- geometry ----------------------------------------------------------
222
+ // Horizontal shear. `perspective` in its default sense=source mode maps the
223
+ // four given SOURCE points onto the output corners, so a slightly-sheared
224
+ // quad warps and fills the frame with no black wedges to crop away.
225
+ //
226
+ // `tilt` has no ffmpeg equivalent (it is a 3D rotateX in the composition
227
+ // renderer); fold it into the shear budget so the knob still perturbs the
228
+ // frame rather than silently doing nothing.
229
+ const shearPct = e.skew + e.tilt * 0.25;
230
+ if (!near(shearPct, 0)) {
231
+ const shiftPx = Math.round((Math.abs(shearPct) / 100) * width);
232
+ if (shiftPx >= 1) {
233
+ const [topShift, bottomShift] = shearPct >= 0 ? [shiftPx, -shiftPx] : [-shiftPx, shiftPx];
234
+ // Corner order is TL, TR, BL, BR.
235
+ skewStage.push(`perspective=x0=${topShift}:y0=0`
236
+ + `:x1=${width + topShift}:y1=0`
237
+ + `:x2=${bottomShift}:y2=${height}`
238
+ + `:x3=${width + bottomShift}:y3=${height}`
239
+ + `:interpolation=linear`);
240
+ notes.push(`skew ${fixed(shearPct)}% (${shiftPx}px shear)`);
241
+ }
242
+ }
243
+ if (!near(e.rotate, 0)) {
244
+ mainStage.push(`rotate=${fixed((e.rotate * Math.PI) / 180, 6)}:ow=iw:oh=ih:bilinear=1`);
245
+ notes.push(`rotate ${fixed(e.rotate)}°`);
246
+ }
247
+ // A rotation leaves black wedges in the corners. Compute the smallest zoom
248
+ // that crops them all away and take the max against the requested zoom, so
249
+ // "rotate 2°" never means "rotate 2° and four black triangles".
250
+ const requestedZoom = Math.max(1, e.zoom);
251
+ const coverZoom = rotationCoverZoom(e.rotate, width, height);
252
+ const effectiveZoom = Math.max(requestedZoom, coverZoom);
253
+ if (effectiveZoom > 1.0001) {
254
+ // crop=iw/Z:ih/Z centred, then scale back to the output size.
255
+ mainStage.push(`crop=iw/${fixed(effectiveZoom, 5)}:ih/${fixed(effectiveZoom, 5)}`);
256
+ if (effectiveZoom > requestedZoom + 0.0005) {
257
+ notes.push(`zoom ${pct(effectiveZoom)} (raised from ${pct(requestedZoom)} to hide the rotate corners)`);
258
+ }
259
+ else {
260
+ notes.push(`zoom ${pct(effectiveZoom)}`);
261
+ }
262
+ }
263
+ mainStage.push(`scale=${outWidth}:${outHeight}:flags=bicubic`, "setsar=1");
264
+ // --- surface texture ---------------------------------------------------
265
+ const tintOpacity = clamp(Number(input.tintOpacity ?? 0), 0, 1);
266
+ if (tintOpacity > 0.001) {
267
+ const tint = normalizeFfmpegHex(input.tintColor ?? "#FF8C00");
268
+ mainStage.push(`drawbox=x=0:y=0:w=iw:h=ih:color=${tint}@${fixed(tintOpacity, 3)}:t=fill`);
269
+ notes.push(`tint ${tint} @ ${fixed(tintOpacity, 3)}`);
270
+ }
271
+ if (e.blur > 0.001) {
272
+ mainStage.push(`gblur=sigma=${fixed(e.blur, 3)}`);
273
+ notes.push(`blur σ${fixed(e.blur, 2)}`);
274
+ }
275
+ if (e.noise > 0.001) {
276
+ // `allf=t+u` = temporal + uniform: grain that changes every frame, which
277
+ // defeats frame-averaged fingerprints as well as per-frame ones.
278
+ mainStage.push(`noise=alls=${Math.max(1, Math.round(e.noise))}:allf=t+u`);
279
+ notes.push(`grain ${fixed(e.noise, 1)}`);
280
+ }
281
+ // --- timing ------------------------------------------------------------
282
+ const speed = input.mediaType === "video" ? e.speed : 1;
283
+ const timingStage = [];
284
+ if (!near(speed, 1)) {
285
+ const fps = clamp(Number(input.sourceFps ?? 30) || 30, 1, 240);
286
+ // setpts compresses the timeline; fps= resamples it back to a constant rate
287
+ // so the shorter duration actually survives CFR encoding (see sourceFps).
288
+ timingStage.push(`setpts=PTS/${fixed(speed, 5)}`, `fps=${fixed(fps, 5)}`);
289
+ notes.push(`speed ×${fixed(speed, 3)}`);
290
+ }
291
+ const compose = (parts) => {
292
+ const chain = parts.filter(Boolean);
293
+ return chain.length ? chain.join(",") : "null";
294
+ };
295
+ const videoFilter = compose([...skewStage, ...mainStage, ...timingStage]);
296
+ const videoFilterWithoutSkew = compose([...mainStage, ...timingStage]);
297
+ // --- audio -------------------------------------------------------------
298
+ const audioParts = [];
299
+ if (!near(speed, 1))
300
+ audioParts.push(...atempoChain(speed));
301
+ if (!near(e.volume, 1)) {
302
+ audioParts.push(`volume=${fixed(e.volume, 3)}`);
303
+ notes.push(`volume ×${fixed(e.volume, 2)}`);
304
+ }
305
+ return {
306
+ videoFilter,
307
+ videoFilterWithoutSkew,
308
+ audioFilter: audioParts.length ? audioParts.join(",") : null,
309
+ effectiveZoom: round4(effectiveZoom),
310
+ requestedZoom: round4(requestedZoom),
311
+ speed: round4(speed),
312
+ notes
313
+ };
314
+ }
315
+ /**
316
+ * Smallest uniform scale that keeps a `width`×`height` frame fully covered after
317
+ * rotating by `degrees` — i.e. the zoom needed to crop every black corner away.
318
+ */
319
+ export function rotationCoverZoom(degrees, width, height) {
320
+ if (near(degrees, 0))
321
+ return 1;
322
+ const radians = (Math.abs(degrees) * Math.PI) / 180;
323
+ const cos = Math.abs(Math.cos(radians));
324
+ const sin = Math.abs(Math.sin(radians));
325
+ const coverWidth = (width * cos + height * sin) / width;
326
+ const coverHeight = (width * sin + height * cos) / height;
327
+ // +0.5% safety margin for the bilinear edge pixels.
328
+ return Math.max(coverWidth, coverHeight) * 1.005;
329
+ }
330
+ /** ffmpeg's `atempo` only accepts 0.5..2.0, so a bigger change is a chain. */
331
+ export function atempoChain(speed) {
332
+ const parts = [];
333
+ let remaining = clamp(speed, 0.1, 10);
334
+ let guard = 0;
335
+ while (remaining > 2 && guard < 8) {
336
+ parts.push("atempo=2.0");
337
+ remaining /= 2;
338
+ guard += 1;
339
+ }
340
+ while (remaining < 0.5 && guard < 16) {
341
+ parts.push("atempo=0.5");
342
+ remaining /= 0.5;
343
+ guard += 1;
344
+ }
345
+ if (!near(remaining, 1))
346
+ parts.push(`atempo=${fixed(remaining, 5)}`);
347
+ return parts;
348
+ }
349
+ /**
350
+ * Per-variant CRF jitter. Two encodes of the same frames at the same CRF produce
351
+ * near-identical bitstreams; a ±1 CRF walk changes the coded data as well as the
352
+ * pixels, which matters for the byte-level dupe checks some platforms run first.
353
+ */
354
+ export function dedupeCrfForVariant(baseCrf, variant, seed = "") {
355
+ const rand = mulberry32(hashSeed(`crf:${seed}:${variant}`));
356
+ const offset = Math.round(rand() * 2) - 1;
357
+ return clamp(Math.round(baseCrf) + offset, 14, 34);
358
+ }
359
+ /** One-line human summary, e.g. `standard · zoom 3.0% · rotate 2.0° · speed ×1.020`. */
360
+ export function describeDedupeEffects(effects, presetName) {
361
+ const bits = [];
362
+ if (presetName)
363
+ bits.push(presetName);
364
+ if (!near(effects.zoom, 1))
365
+ bits.push(`zoom ${pct(effects.zoom)}`);
366
+ if (!near(effects.rotate, 0))
367
+ bits.push(`rotate ${fixed(effects.rotate)}°`);
368
+ if (!near(effects.skew, 0))
369
+ bits.push(`skew ${fixed(effects.skew)}%`);
370
+ if (!near(effects.tilt, 0))
371
+ bits.push(`tilt ${fixed(effects.tilt)}°`);
372
+ if (!near(effects.speed, 1))
373
+ bits.push(`speed ×${fixed(effects.speed, 3)}`);
374
+ if (!near(effects.saturation, 1))
375
+ bits.push(`sat ${pct(effects.saturation)}`);
376
+ if (!near(effects.contrast, 1))
377
+ bits.push(`contrast ${pct(effects.contrast)}`);
378
+ if (!near(effects.brightness, 1))
379
+ bits.push(`bright ${pct(effects.brightness)}`);
380
+ if (!near(effects.hue_rotate, 0))
381
+ bits.push(`hue ${fixed(effects.hue_rotate)}°`);
382
+ if (effects.noise > 0.001)
383
+ bits.push(`grain ${fixed(effects.noise, 1)}`);
384
+ if (effects.blur > 0.001)
385
+ bits.push(`blur ${fixed(effects.blur, 2)}`);
386
+ if (effects.horizontal_flip)
387
+ bits.push("mirrored");
388
+ return bits.length ? bits.join(" · ") : "no-op";
389
+ }
390
+ // ---------------------------------------------------------------------------
391
+ function clamp(value, min, max) {
392
+ if (!Number.isFinite(value))
393
+ return min;
394
+ return Math.min(max, Math.max(min, value));
395
+ }
396
+ function round4(value) {
397
+ return Number.isFinite(value) ? Number(value.toFixed(4)) : 0;
398
+ }
399
+ function near(value, target, epsilon = 0.0005) {
400
+ return Number.isFinite(value) && Math.abs(value - target) < epsilon;
401
+ }
402
+ function fixed(value, digits = 4) {
403
+ return (Number.isFinite(value) ? value : 0).toFixed(digits).replace(/0+$/, "").replace(/\.$/, "") || "0";
404
+ }
405
+ function pct(multiplier) {
406
+ return `${((multiplier - 1) * 100).toFixed(1)}%`;
407
+ }
408
+ function evenDim(value) {
409
+ const rounded = Math.max(2, Math.round(Number(value) || 2));
410
+ return rounded % 2 === 0 ? rounded : rounded + 1;
411
+ }
412
+ /** ffmpeg wants `0xRRGGBB` (or a named color); anything else → the orange default. */
413
+ function normalizeFfmpegHex(value) {
414
+ const trimmed = String(value ?? "").trim();
415
+ const hex = /^#?([0-9a-f]{6})$/i.exec(trimmed);
416
+ if (hex)
417
+ return `0x${hex[1]}`;
418
+ return /^[a-z]+$/i.test(trimmed) ? trimmed : "0xFF8C00";
419
+ }
420
+ //# sourceMappingURL=dedupe-recipe.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@officexapp/vidfarm-devcli",
3
- "version": "0.21.31",
3
+ "version": "0.21.32",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "dist/src/devcli/clips.js",
17
17
  "dist/src/devcli/composition-edit.js",
18
18
  "dist/src/devcli/cost-mode.js",
19
+ "dist/src/devcli/dedupe-local.js",
19
20
  "dist/src/devcli/doctor.js",
20
21
  "dist/src/devcli/handoff.js",
21
22
  "dist/src/devcli/greenscreen-local.js",
@@ -97,6 +98,7 @@
97
98
  "test:studio-brand": "node --import tsx --test test/studio-brand.test.ts",
98
99
  "test:stickers": "node --import tsx --test test/sticker-pack.test.ts",
99
100
  "test:social-recycle": "node --import tsx --test test/social-recycle.test.ts",
101
+ "test:dedupe": "node --import tsx --test test/dedupe-recipe.test.ts",
100
102
  "check:skills": "node scripts/build-director-skill-rollup.mjs --check && node scripts/check-skill-routes.mjs",
101
103
  "benchmark:editor-chat": "node --import tsx scripts/benchmark-editor-chat-harness.mjs",
102
104
  "cdk:deploy:prod-serverless": "npm run build && dotenv -e .env.production -- npx aws-cdk deploy --app 'node dist/infra/cdk/bin/vidfarm-prod.js'",