@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,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.
|
|
3
|
+
"version": "0.21.33",
|
|
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'",
|