@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.
- package/.agents/skills/vidfarm/SKILL.md +1 -0
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +12 -0
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +2 -1
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +13 -1
- package/.agents/skills/vidfarm/references/core-workflows.md +62 -5
- package/.agents/skills/vidfarm/references/primitives.md +75 -32
- package/SKILL.director.md +165 -39
- package/SKILL.md +2 -0
- package/dist/src/cli.js +486 -3
- package/dist/src/devcli/dedupe-local.js +209 -0
- package/dist/src/lib/dedupe-recipe.js +420 -0
- package/package.json +3 -1
|
@@ -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.
|
|
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'",
|