@officexapp/vidfarm-devcli 0.21.30 → 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,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.30",
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",
@@ -95,6 +96,9 @@
95
96
  "test:clips": "node --import tsx --test test/clip-curation.test.ts",
96
97
  "test:qa": "node --import tsx --test test/qa-check.test.ts",
97
98
  "test:studio-brand": "node --import tsx --test test/studio-brand.test.ts",
99
+ "test:stickers": "node --import tsx --test test/sticker-pack.test.ts",
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",
98
102
  "check:skills": "node scripts/build-director-skill-rollup.mjs --check && node scripts/check-skill-routes.mjs",
99
103
  "benchmark:editor-chat": "node --import tsx scripts/benchmark-editor-chat-harness.mjs",
100
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'",