@officexapp/vidfarm-devcli 0.21.27 → 0.21.29
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 +52 -0
- package/.agents/skills/vidfarm/SKILL.md +58 -5
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +65 -0
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +78 -7
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +4 -3
- package/.agents/skills/vidfarm/recipes/retheme-template.md +1 -1
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +3 -3
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +92 -1
- package/.agents/skills/vidfarm/references/editor-workflows.md +135 -6
- package/.agents/skills/vidfarm/references/hooks-and-virality.md +237 -0
- package/.agents/skills/vidfarm/references/onboarding.md +5 -5
- package/.agents/skills/vidfarm/references/primitives.md +5 -1
- package/.agents/skills/vidfarm/regimes/README.md +77 -0
- package/.agents/skills/vidfarm/regimes/explainer.QA_REGIME.md +82 -0
- package/.agents/skills/vidfarm/regimes/hooks.QA_REGIME.md +117 -0
- package/.agents/skills/vidfarm/regimes/product-demo.QA_REGIME.md +92 -0
- package/.agents/skills/vidfarm/regimes/short-form.QA_REGIME.md +163 -0
- package/.agents/skills/vidfarm/regimes/ugc-testimonial.QA_REGIME.md +82 -0
- package/SKILL.director.md +685 -32
- package/SKILL.md +22 -3
- package/demo/dist/app.js +103 -103
- package/dist/src/cli.js +987 -11
- package/dist/src/devcli/handoff.js +162 -0
- package/dist/src/devcli/interaction-mode.js +154 -0
- package/dist/src/devcli/qa-check.js +593 -0
- package/dist/src/devcli/qa-regime.js +396 -0
- package/dist/src/devcli/sticker-pack.js +396 -0
- package/dist/src/devcli/storyboard.js +243 -0
- package/package.json +8 -1
- package/public/serve-shells/tools-image.html +378 -265
- package/public/serve-shells/tools-video.html +760 -167
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Sticker-PACK segmentation: turn ONE keyed plate holding MANY items into many
|
|
2
|
+
// individually-cropped transparent stickers.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: `vidfarm cutout` makes ONE sticker per AI generation, and
|
|
5
|
+
// `vidfarm mask --crop x,y,w,h` makes one per hand-measured rectangle. Neither
|
|
6
|
+
// scales to "give me a sticker pack" — the standard, cheapest move, which is:
|
|
7
|
+
// generate a SINGLE image containing every graphic you need, laid out on a flat
|
|
8
|
+
// chroma plate, then cut each item out locally for $0. Measuring a dozen crop
|
|
9
|
+
// rects by hand is the only hard part of that loop, so this module does it
|
|
10
|
+
// automatically: it reads the keyed plate's ALPHA channel and finds the
|
|
11
|
+
// connected islands of opaque pixels — one island per item — and reports their
|
|
12
|
+
// bounding boxes. No AI, no vision call, no network: ffmpeg + a few typed
|
|
13
|
+
// arrays.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately size-agnostic: an item can be a tiny icon or an entire landscape
|
|
16
|
+
// backdrop filling most of the plate. The only filter is `minAreaPct` (drop
|
|
17
|
+
// speckle/JPEG noise), never a maximum — a "sticker" here means "a transparent
|
|
18
|
+
// element you can place and animate", not "a small thing".
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { resolveFfmpeg } from "../services/clip-curation/ffmpeg.js";
|
|
22
|
+
import { probeImageDimensions } from "./greenscreen-local.js";
|
|
23
|
+
/** The plates we'll auto-pick from, best-first, with the color words that rule
|
|
24
|
+
* each one out. Mirrors GREENSCREEN_PRESETS keys so `--preset` still applies. */
|
|
25
|
+
export const PLATE_CANDIDATES = [
|
|
26
|
+
{
|
|
27
|
+
preset: "green",
|
|
28
|
+
keyColor: "#00FF00",
|
|
29
|
+
collides: ["green", "lime", "emerald", "mint", "olive", "forest", "jade", "grass", "leaf", "leaves", "foliage", "plant", "tree", "frog", "cactus", "avocado", "money", "dollar", "eco", "recycle", "matcha", "seaweed", "moss", "teal", "turquoise"]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
preset: "magenta",
|
|
33
|
+
keyColor: "#FF00FF",
|
|
34
|
+
collides: ["magenta", "pink", "fuchsia", "fuscia", "violet", "purple", "lilac", "lavender", "orchid", "rose", "berry", "candy", "barbie", "neon pink"]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
preset: "blue",
|
|
38
|
+
keyColor: "#0047BB",
|
|
39
|
+
collides: ["blue", "navy", "azure", "cobalt", "indigo", "sky", "ocean", "sea", "water", "denim", "cyan", "teal", "turquoise", "police", "sapphire"]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
preset: "black",
|
|
43
|
+
keyColor: "#000000",
|
|
44
|
+
collides: ["black", "dark", "night", "shadow", "charcoal", "noir", "coal", "ink", "silhouette", "outline"]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
preset: "white",
|
|
48
|
+
keyColor: "#FFFFFF",
|
|
49
|
+
collides: ["white", "snow", "cloud", "paper", "ivory", "cream", "milk", "ghost", "bone", "eggshell"]
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Choose a chroma plate that the subject can't collide with. Scans the subject
|
|
54
|
+
* text (theme + item names + style notes) for each candidate's color words and
|
|
55
|
+
* returns the first plate with no hit. Falls back to green with `reason` set
|
|
56
|
+
* when EVERY plate collides, so the caller can warn instead of silently keying
|
|
57
|
+
* holes in the art.
|
|
58
|
+
*/
|
|
59
|
+
export function pickPlateColor(subjectText) {
|
|
60
|
+
const haystack = ` ${subjectText.toLowerCase().replace(/[^a-z0-9]+/g, " ")} `;
|
|
61
|
+
const hits = (c) => c.collides.filter((word) => haystack.includes(` ${word} `));
|
|
62
|
+
const rejections = [];
|
|
63
|
+
for (const candidate of PLATE_CANDIDATES) {
|
|
64
|
+
const collisions = hits(candidate);
|
|
65
|
+
if (!collisions.length) {
|
|
66
|
+
return {
|
|
67
|
+
preset: candidate.preset,
|
|
68
|
+
keyColor: candidate.keyColor,
|
|
69
|
+
moved: candidate.preset !== "green",
|
|
70
|
+
reason: rejections.length ? `the subject mentions ${rejections.join(", ")}` : null
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
rejections.push(`${collisions.join("/")} (rules out ${candidate.preset})`);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
preset: "green",
|
|
77
|
+
keyColor: "#00FF00",
|
|
78
|
+
moved: false,
|
|
79
|
+
reason: `every standard plate collides with the subject (${rejections.join("; ")}) — key by hand with --key-color, or split the pack into two sheets`
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Detect the plate color of an EXISTING sheet by sampling its four corners.
|
|
84
|
+
* A sticker sheet always has plate at the corners (items are spaced inside), so
|
|
85
|
+
* when all four agree within `tolerance` that color IS the plate — which means
|
|
86
|
+
* a user can hand back a red/blue/purple sheet from a web generator and the cut
|
|
87
|
+
* still works without them knowing what a chroma key is. Returns null when the
|
|
88
|
+
* corners disagree (a busy or full-bleed image → let the caller fall back).
|
|
89
|
+
*/
|
|
90
|
+
export async function detectPlateColor(sourcePath, opts = {}) {
|
|
91
|
+
if (!existsSync(sourcePath))
|
|
92
|
+
throw new Error(`No such source file: ${sourcePath}`);
|
|
93
|
+
const dims = await probeImageDimensions(sourcePath);
|
|
94
|
+
if (!dims)
|
|
95
|
+
return null;
|
|
96
|
+
const ffmpeg = await resolveFfmpeg();
|
|
97
|
+
// An 8x8 patch inset 1% from each corner: inside the plate, outside any border
|
|
98
|
+
// artifact or JPEG ringing at the very edge.
|
|
99
|
+
const inset = Math.max(2, Math.round(Math.min(dims.width, dims.height) * 0.01));
|
|
100
|
+
const patch = 8;
|
|
101
|
+
const corners = [
|
|
102
|
+
[inset, inset],
|
|
103
|
+
[Math.max(0, dims.width - inset - patch), inset],
|
|
104
|
+
[inset, Math.max(0, dims.height - inset - patch)],
|
|
105
|
+
[Math.max(0, dims.width - inset - patch), Math.max(0, dims.height - inset - patch)]
|
|
106
|
+
];
|
|
107
|
+
const samples = [];
|
|
108
|
+
for (const [x, y] of corners) {
|
|
109
|
+
const raw = await new Promise((resolve, reject) => {
|
|
110
|
+
const child = spawn(ffmpeg, [
|
|
111
|
+
"-hide_banner", "-v", "error",
|
|
112
|
+
"-i", sourcePath,
|
|
113
|
+
"-frames:v", "1",
|
|
114
|
+
"-vf", `crop=${patch}:${patch}:${x}:${y},scale=1:1:flags=area`,
|
|
115
|
+
"-f", "rawvideo", "-pix_fmt", "rgb24",
|
|
116
|
+
"-"
|
|
117
|
+
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
118
|
+
const chunks = [];
|
|
119
|
+
child.stdout.on("data", (d) => chunks.push(d));
|
|
120
|
+
child.on("error", reject);
|
|
121
|
+
child.on("close", () => resolve(Buffer.concat(chunks)));
|
|
122
|
+
});
|
|
123
|
+
if (raw.length < 3)
|
|
124
|
+
return null;
|
|
125
|
+
samples.push([raw[0], raw[1], raw[2]]);
|
|
126
|
+
}
|
|
127
|
+
const tolerance = opts.tolerance ?? 24;
|
|
128
|
+
const avg = [0, 1, 2].map((i) => Math.round(samples.reduce((s, c) => s + c[i], 0) / samples.length));
|
|
129
|
+
const spread = Math.max(...samples.map((s) => Math.max(Math.abs(s[0] - avg[0]), Math.abs(s[1] - avg[1]), Math.abs(s[2] - avg[2]))));
|
|
130
|
+
if (spread > tolerance)
|
|
131
|
+
return null; // corners disagree → not a uniform plate
|
|
132
|
+
const hex = `#${avg.map((v) => v.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
|
|
133
|
+
return { hex, rgb: avg };
|
|
134
|
+
}
|
|
135
|
+
/** Read a still's alpha plane as raw 8-bit luma at a given size (bundle-safe:
|
|
136
|
+
* ffmpeg's `alphaextract` writes alpha as luma; rawvideo skips any decoding on
|
|
137
|
+
* our side). Returns exactly width*height bytes. */
|
|
138
|
+
async function readAlphaPlane(sourcePath, width, height) {
|
|
139
|
+
const ffmpeg = await resolveFfmpeg();
|
|
140
|
+
const args = [
|
|
141
|
+
"-hide_banner", "-v", "error",
|
|
142
|
+
"-i", sourcePath,
|
|
143
|
+
"-frames:v", "1",
|
|
144
|
+
"-vf", `alphaextract,scale=${width}:${height}:flags=area`,
|
|
145
|
+
"-f", "rawvideo", "-pix_fmt", "gray",
|
|
146
|
+
"-"
|
|
147
|
+
];
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const child = spawn(ffmpeg, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
150
|
+
const chunks = [];
|
|
151
|
+
let stderr = "";
|
|
152
|
+
child.stdout.on("data", (d) => chunks.push(d));
|
|
153
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
154
|
+
child.on("error", reject);
|
|
155
|
+
child.on("close", (code) => {
|
|
156
|
+
const buf = Buffer.concat(chunks);
|
|
157
|
+
if (code !== 0 || buf.length < width * height) {
|
|
158
|
+
reject(new Error(`Couldn't read the plate's alpha channel (ffmpeg exit ${code})${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-2).join(" ")}` : ""}. Is the keyed file really transparent?`));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
resolve(new Uint8Array(buf.buffer, buf.byteOffset, width * height));
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** Separable max-filter (morphological dilation) with a square radius — used
|
|
166
|
+
* only to decide which islands BELONG TOGETHER. Bounding boxes are always
|
|
167
|
+
* measured on the undilated mask, so bridging never inflates a sticker. */
|
|
168
|
+
function dilate(mask, w, h, radius) {
|
|
169
|
+
if (radius <= 0)
|
|
170
|
+
return mask;
|
|
171
|
+
const horiz = new Uint8Array(w * h);
|
|
172
|
+
for (let y = 0; y < h; y++) {
|
|
173
|
+
const row = y * w;
|
|
174
|
+
for (let x = 0; x < w; x++) {
|
|
175
|
+
const from = Math.max(0, x - radius);
|
|
176
|
+
const to = Math.min(w - 1, x + radius);
|
|
177
|
+
let hit = 0;
|
|
178
|
+
for (let i = from; i <= to; i++)
|
|
179
|
+
if (mask[row + i]) {
|
|
180
|
+
hit = 1;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
horiz[row + x] = hit;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const out = new Uint8Array(w * h);
|
|
187
|
+
for (let x = 0; x < w; x++) {
|
|
188
|
+
for (let y = 0; y < h; y++) {
|
|
189
|
+
const from = Math.max(0, y - radius);
|
|
190
|
+
const to = Math.min(h - 1, y + radius);
|
|
191
|
+
let hit = 0;
|
|
192
|
+
for (let i = from; i <= to; i++)
|
|
193
|
+
if (horiz[i * w + x]) {
|
|
194
|
+
hit = 1;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
out[y * w + x] = hit;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Find every item on a keyed plate by segmenting its alpha channel into
|
|
204
|
+
* connected islands of opaque pixels — the automatic replacement for measuring
|
|
205
|
+
* a dozen `--crop x,y,w,h` rects by hand.
|
|
206
|
+
*
|
|
207
|
+
* Two-mask trick: labelling runs on a DILATED mask (so an item's detached
|
|
208
|
+
* pieces — a floating antenna, a dotted arrow, the dot on an "i" — join into
|
|
209
|
+
* one island), while each island's bounding box is accumulated from the
|
|
210
|
+
* ORIGINAL mask, so the reported rect is still the item's true tight box.
|
|
211
|
+
*/
|
|
212
|
+
export async function segmentAlphaComponents(input) {
|
|
213
|
+
if (!existsSync(input.sourcePath))
|
|
214
|
+
throw new Error(`No such source file: ${input.sourcePath}`);
|
|
215
|
+
const dims = await probeImageDimensions(input.sourcePath);
|
|
216
|
+
if (!dims)
|
|
217
|
+
throw new Error(`Couldn't read image dimensions for ${input.sourcePath}.`);
|
|
218
|
+
// Analyse at most ~640px on the long side: segmentation is a layout question,
|
|
219
|
+
// not a detail one, and this keeps a 4K sheet under a few million ops.
|
|
220
|
+
const longSide = Math.max(dims.width, dims.height);
|
|
221
|
+
const scale = longSide > 640 ? 640 / longSide : 1;
|
|
222
|
+
const sw = Math.max(1, Math.round(dims.width * scale));
|
|
223
|
+
const sh = Math.max(1, Math.round(dims.height * scale));
|
|
224
|
+
const alpha = await readAlphaPlane(input.sourcePath, sw, sh);
|
|
225
|
+
const threshold = Math.min(254, Math.max(0, Math.round(input.alphaThreshold ?? 8)));
|
|
226
|
+
const mask = new Uint8Array(sw * sh);
|
|
227
|
+
let opaque = 0;
|
|
228
|
+
for (let i = 0; i < mask.length; i++) {
|
|
229
|
+
if (alpha[i] > threshold) {
|
|
230
|
+
mask[i] = 1;
|
|
231
|
+
opaque++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!opaque) {
|
|
235
|
+
throw new Error("The keyed plate is fully transparent — everything got keyed away. Check --preset/--key-color (does the plate's background really match?) or lower --tolerance.");
|
|
236
|
+
}
|
|
237
|
+
const gapPct = input.gapPct ?? 1.2;
|
|
238
|
+
const radius = Math.max(1, Math.round((gapPct / 100) * Math.min(sw, sh)));
|
|
239
|
+
const bridged = dilate(mask, sw, sh, radius);
|
|
240
|
+
// ---- 8-connected labelling over the bridged mask (explicit stack, no recursion)
|
|
241
|
+
const labels = new Int32Array(sw * sh).fill(0);
|
|
242
|
+
const stack = new Int32Array(sw * sh);
|
|
243
|
+
const boxes = [];
|
|
244
|
+
let next = 0;
|
|
245
|
+
for (let start = 0; start < bridged.length; start++) {
|
|
246
|
+
if (!bridged[start] || labels[start])
|
|
247
|
+
continue;
|
|
248
|
+
next++;
|
|
249
|
+
const box = { minX: sw, minY: sh, maxX: -1, maxY: -1, area: 0 };
|
|
250
|
+
let top = 0;
|
|
251
|
+
stack[top++] = start;
|
|
252
|
+
labels[start] = next;
|
|
253
|
+
while (top > 0) {
|
|
254
|
+
const p = stack[--top];
|
|
255
|
+
const py = (p / sw) | 0;
|
|
256
|
+
const px = p - py * sw;
|
|
257
|
+
// Measure the box on the ORIGINAL mask only — dilation bridges, never grows.
|
|
258
|
+
if (mask[p]) {
|
|
259
|
+
box.area++;
|
|
260
|
+
if (px < box.minX)
|
|
261
|
+
box.minX = px;
|
|
262
|
+
if (px > box.maxX)
|
|
263
|
+
box.maxX = px;
|
|
264
|
+
if (py < box.minY)
|
|
265
|
+
box.minY = py;
|
|
266
|
+
if (py > box.maxY)
|
|
267
|
+
box.maxY = py;
|
|
268
|
+
}
|
|
269
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
270
|
+
const ny = py + dy;
|
|
271
|
+
if (ny < 0 || ny >= sh)
|
|
272
|
+
continue;
|
|
273
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
274
|
+
const nx = px + dx;
|
|
275
|
+
if (nx < 0 || nx >= sw)
|
|
276
|
+
continue;
|
|
277
|
+
const q = ny * sw + nx;
|
|
278
|
+
if (bridged[q] && !labels[q]) {
|
|
279
|
+
labels[q] = next;
|
|
280
|
+
stack[top++] = q;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (box.maxX >= 0)
|
|
286
|
+
boxes.push(box);
|
|
287
|
+
}
|
|
288
|
+
// ---- Filter speckle, cap, then sort into human reading order ---------------
|
|
289
|
+
const total = sw * sh;
|
|
290
|
+
const minAreaPct = input.minAreaPct ?? 0.15;
|
|
291
|
+
const kept = boxes.filter((b) => (b.area / total) * 100 >= minAreaPct);
|
|
292
|
+
const rejected = boxes.length - kept.length;
|
|
293
|
+
const maxItems = Math.max(1, input.maxItems ?? 64);
|
|
294
|
+
kept.sort((a, b) => b.area - a.area);
|
|
295
|
+
const capped = kept.slice(0, maxItems);
|
|
296
|
+
// Reading order: bucket into rows by vertical overlap, then left-to-right.
|
|
297
|
+
capped.sort((a, b) => a.minY - b.minY || a.minX - b.minX);
|
|
298
|
+
const rows = [];
|
|
299
|
+
for (const b of capped) {
|
|
300
|
+
const row = rows.find((r) => {
|
|
301
|
+
const rowTop = Math.min(...r.map((x) => x.minY));
|
|
302
|
+
const rowBottom = Math.max(...r.map((x) => x.maxY));
|
|
303
|
+
const overlap = Math.min(rowBottom, b.maxY) - Math.max(rowTop, b.minY);
|
|
304
|
+
return overlap > 0.4 * Math.min(rowBottom - rowTop, b.maxY - b.minY);
|
|
305
|
+
});
|
|
306
|
+
if (row)
|
|
307
|
+
row.push(b);
|
|
308
|
+
else
|
|
309
|
+
rows.push([b]);
|
|
310
|
+
}
|
|
311
|
+
const ordered = rows.flatMap((r) => r.sort((a, b) => a.minX - b.minX));
|
|
312
|
+
// ---- Map sample-space boxes back to source pixels --------------------------
|
|
313
|
+
// One sample pixel of slack on each side covers the downscale's rounding, so a
|
|
314
|
+
// subject's outermost antialiased edge never gets clipped off.
|
|
315
|
+
const inv = 1 / scale;
|
|
316
|
+
const components = ordered.map((b, i) => {
|
|
317
|
+
const x0 = Math.max(0, Math.floor((b.minX - 1) * inv));
|
|
318
|
+
const y0 = Math.max(0, Math.floor((b.minY - 1) * inv));
|
|
319
|
+
const x1 = Math.min(dims.width, Math.ceil((b.maxX + 2) * inv));
|
|
320
|
+
const y1 = Math.min(dims.height, Math.ceil((b.maxY + 2) * inv));
|
|
321
|
+
return {
|
|
322
|
+
index: i + 1,
|
|
323
|
+
x: x0,
|
|
324
|
+
y: y0,
|
|
325
|
+
width: Math.max(1, x1 - x0),
|
|
326
|
+
height: Math.max(1, y1 - y0),
|
|
327
|
+
area_pct: Math.round((b.area / total) * 1000) / 10
|
|
328
|
+
};
|
|
329
|
+
});
|
|
330
|
+
return { components, sourceWidth: dims.width, sourceHeight: dims.height, sampleWidth: sw, sampleHeight: sh, rejected };
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Re-encode a transparent still as a transparent GIF — the format a lot of
|
|
334
|
+
* sticker surfaces (chat, forums, Notion, old-school web overlays) still want.
|
|
335
|
+
* GIF alpha is 1-bit, so semi-transparent antialiased edges have to snap to
|
|
336
|
+
* either fully-opaque or fully-gone; `alpha_threshold` is where that line falls.
|
|
337
|
+
* Prefer PNG/WebP for compositions — this is for hand-off to GIF-only surfaces.
|
|
338
|
+
*/
|
|
339
|
+
export async function encodeTransparentGif(sourcePath, outputPath, opts = {}) {
|
|
340
|
+
const ffmpeg = await resolveFfmpeg();
|
|
341
|
+
const at = Math.min(255, Math.max(1, Math.round(opts.alphaThreshold ?? 128)));
|
|
342
|
+
const args = [
|
|
343
|
+
"-hide_banner", "-v", "error", "-y",
|
|
344
|
+
"-i", sourcePath,
|
|
345
|
+
"-filter_complex",
|
|
346
|
+
`[0:v]split[a][b];[a]palettegen=reserve_transparent=1:transparency_color=ff00ff[p];[b][p]paletteuse=alpha_threshold=${at}`,
|
|
347
|
+
outputPath
|
|
348
|
+
];
|
|
349
|
+
await new Promise((resolve, reject) => {
|
|
350
|
+
const child = spawn(ffmpeg, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
351
|
+
let stderr = "";
|
|
352
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
353
|
+
child.on("error", reject);
|
|
354
|
+
child.on("close", (code) => {
|
|
355
|
+
if (code !== 0 || !existsSync(outputPath)) {
|
|
356
|
+
reject(new Error(`Transparent GIF encode failed (ffmpeg exit ${code})${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-2).join(" ")}` : ""}.`));
|
|
357
|
+
}
|
|
358
|
+
else
|
|
359
|
+
resolve();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Key-and-encode an ANIMATED transparent GIF from a video clip whose background
|
|
365
|
+
* is already transparent (a keyed WebM/MOV). Same 1-bit-alpha caveat as the
|
|
366
|
+
* still encoder; `fps` and `width` exist because GIF is a heavy container and an
|
|
367
|
+
* animated sticker is usually small and short.
|
|
368
|
+
*/
|
|
369
|
+
export async function encodeTransparentAnimatedGif(sourcePath, outputPath, opts = {}) {
|
|
370
|
+
const ffmpeg = await resolveFfmpeg();
|
|
371
|
+
const fps = Math.min(50, Math.max(1, Math.round(opts.fps ?? 15)));
|
|
372
|
+
const at = Math.min(255, Math.max(1, Math.round(opts.alphaThreshold ?? 128)));
|
|
373
|
+
const scaleClause = opts.width && opts.width > 0 ? `,scale=${Math.round(opts.width)}:-1:flags=lanczos` : "";
|
|
374
|
+
const args = [
|
|
375
|
+
"-hide_banner", "-v", "error", "-y",
|
|
376
|
+
"-i", sourcePath,
|
|
377
|
+
"-filter_complex",
|
|
378
|
+
`[0:v]fps=${fps}${scaleClause},split[a][b];[a]palettegen=reserve_transparent=1:transparency_color=ff00ff[p];[b][p]paletteuse=alpha_threshold=${at}`,
|
|
379
|
+
"-loop", "0",
|
|
380
|
+
outputPath
|
|
381
|
+
];
|
|
382
|
+
await new Promise((resolve, reject) => {
|
|
383
|
+
const child = spawn(ffmpeg, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
384
|
+
let stderr = "";
|
|
385
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
386
|
+
child.on("error", reject);
|
|
387
|
+
child.on("close", (code) => {
|
|
388
|
+
if (code !== 0 || !existsSync(outputPath)) {
|
|
389
|
+
reject(new Error(`Animated transparent GIF encode failed (ffmpeg exit ${code})${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-2).join(" ")}` : ""}.`));
|
|
390
|
+
}
|
|
391
|
+
else
|
|
392
|
+
resolve();
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
//# sourceMappingURL=sticker-pack.js.map
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Storyboarding — Vidfarm's wrapper over the STORYBOARD.md plan format.
|
|
2
|
+
//
|
|
3
|
+
// A storyboard is the PLAN for a video before any animation work happens: an
|
|
4
|
+
// ordered set of frames (key moments) plus their narration. It lives at the
|
|
5
|
+
// project root as one markdown file, `STORYBOARD.md`, with an optional companion
|
|
6
|
+
// `SCRIPT.md` holding the full voiceover. The Vidfarm editor reads it directly —
|
|
7
|
+
// the Storyboard/Preview toggle at the top of the editor renders these frames as
|
|
8
|
+
// a contact sheet, tracks each frame's status (outline → built → animated), and
|
|
9
|
+
// gives the director a per-frame comment box whose feedback comes back to the
|
|
10
|
+
// agent. That makes the storyboard the natural INTERACTIVE checkpoint: draft the
|
|
11
|
+
// plan, let the human approve or annotate it, then build.
|
|
12
|
+
//
|
|
13
|
+
// This module owns the format on Vidfarm's side: parse, validate, and scaffold.
|
|
14
|
+
// It is deliberately backend-free (Node built-ins only) so it ships in the public
|
|
15
|
+
// cloud-only CLI, and deliberately LENIENT — it never throws on a malformed plan,
|
|
16
|
+
// it reports warnings, because a half-written storyboard is still useful to show.
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
/** Canonical filename for the plan at a project root. */
|
|
20
|
+
export const STORYBOARD_FILENAME = "STORYBOARD.md";
|
|
21
|
+
/** Canonical filename for the companion narration script. */
|
|
22
|
+
export const SCRIPT_FILENAME = "SCRIPT.md";
|
|
23
|
+
export const FRAME_STATUSES = ["outline", "built", "animated"];
|
|
24
|
+
export const DEFAULT_FRAME_STATUS = "outline";
|
|
25
|
+
// Frame sections: `## Frame 3 — Title`, `### Beat 1.1`, `## Scene 2`.
|
|
26
|
+
const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
|
|
27
|
+
const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/;
|
|
28
|
+
const HEADING_LEVEL_RE = /^(#{1,6})\s+/;
|
|
29
|
+
/** A metadata list item: `- key: value` / `* key: value`. */
|
|
30
|
+
const META_RE = /^\s*[-*]\s+([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/;
|
|
31
|
+
const LEADING_INT_RE = /^(\d+)/;
|
|
32
|
+
const DURATION_NUM_RE = /(\d+(?:\.\d+)?)/;
|
|
33
|
+
const TRANSITION_KEYS = new Set(["transition_in", "transitionin", "transition"]);
|
|
34
|
+
const SCENE_KEYS = new Set(["scene", "description", "summary", "caption"]);
|
|
35
|
+
const VOICEOVER_KEYS = new Set(["voiceover", "vo", "voice_over", "narration"]);
|
|
36
|
+
function stripQuotes(value) {
|
|
37
|
+
const t = value.trim();
|
|
38
|
+
if (t.length >= 2 && ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))) {
|
|
39
|
+
return t.slice(1, -1);
|
|
40
|
+
}
|
|
41
|
+
return t;
|
|
42
|
+
}
|
|
43
|
+
function isFrameStatus(value) {
|
|
44
|
+
return FRAME_STATUSES.includes(value);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse a `STORYBOARD.md`. Tolerant by design: accepts freeform narrative,
|
|
48
|
+
* Frame/Beat/Scene headings at H2 or H3, unknown keys (kept in `extra`), and
|
|
49
|
+
* records anything surprising as a warning instead of failing.
|
|
50
|
+
*/
|
|
51
|
+
export function parseStoryboard(source) {
|
|
52
|
+
const warnings = [];
|
|
53
|
+
const lines = source.split(/\r?\n/);
|
|
54
|
+
// ---- Frontmatter (optional `---` block of key: value globals) -------------
|
|
55
|
+
const globals = { extra: {} };
|
|
56
|
+
let bodyStart = 0;
|
|
57
|
+
let firstNonEmpty = 0;
|
|
58
|
+
while (firstNonEmpty < lines.length && (lines[firstNonEmpty] ?? "").trim() === "")
|
|
59
|
+
firstNonEmpty++;
|
|
60
|
+
if ((lines[firstNonEmpty] ?? "").trim() === "---") {
|
|
61
|
+
let close = -1;
|
|
62
|
+
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
|
|
63
|
+
if ((lines[i] ?? "").trim() === "---") {
|
|
64
|
+
close = i;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (close === -1) {
|
|
69
|
+
warnings.push({ message: "Frontmatter opening '---' has no closing '---'; treating the whole file as body.", line: firstNonEmpty + 1 });
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
for (let i = firstNonEmpty + 1; i < close; i++) {
|
|
73
|
+
const raw = lines[i] ?? "";
|
|
74
|
+
if (!raw.trim())
|
|
75
|
+
continue;
|
|
76
|
+
const colon = raw.indexOf(":");
|
|
77
|
+
if (colon === -1) {
|
|
78
|
+
warnings.push({ message: `Ignored non key:value frontmatter line: "${raw.trim()}"`, line: i + 1 });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const key = raw.slice(0, colon).trim().toLowerCase();
|
|
82
|
+
const value = stripQuotes(raw.slice(colon + 1).trim());
|
|
83
|
+
if (key === "format")
|
|
84
|
+
globals.format = value;
|
|
85
|
+
else if (key === "message")
|
|
86
|
+
globals.message = value;
|
|
87
|
+
else if (key === "arc")
|
|
88
|
+
globals.arc = value;
|
|
89
|
+
else if (key === "audience")
|
|
90
|
+
globals.audience = value;
|
|
91
|
+
else
|
|
92
|
+
globals.extra[key] = value;
|
|
93
|
+
}
|
|
94
|
+
bodyStart = close + 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const sections = [];
|
|
98
|
+
let current = null;
|
|
99
|
+
for (let i = bodyStart; i < lines.length; i++) {
|
|
100
|
+
const line = lines[i] ?? "";
|
|
101
|
+
const opened = FRAME_HEADING_RE.exec(line);
|
|
102
|
+
if (opened) {
|
|
103
|
+
current = {
|
|
104
|
+
headingText: line.slice(opened[0].length).replace(FRAME_TITLE_SEP_RE, "").trim(),
|
|
105
|
+
headingLine: i + 1,
|
|
106
|
+
level: (opened[1] ?? "##").length,
|
|
107
|
+
lines: []
|
|
108
|
+
};
|
|
109
|
+
sections.push(current);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// A sibling (or shallower) non-frame heading closes the current frame;
|
|
113
|
+
// deeper sub-headings stay part of its narrative.
|
|
114
|
+
const heading = HEADING_LEVEL_RE.exec(line);
|
|
115
|
+
if (current && heading && (heading[1] ?? "").length <= current.level) {
|
|
116
|
+
current = null;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (current)
|
|
120
|
+
current.lines.push(line);
|
|
121
|
+
}
|
|
122
|
+
const frames = sections.map((section, idx) => {
|
|
123
|
+
const index = idx + 1;
|
|
124
|
+
const frame = { index, status: DEFAULT_FRAME_STATUS, narrative: "", extra: {} };
|
|
125
|
+
const intMatch = LEADING_INT_RE.exec(section.headingText);
|
|
126
|
+
if (intMatch) {
|
|
127
|
+
frame.number = Number.parseInt(intMatch[1] ?? "", 10);
|
|
128
|
+
const rest = section.headingText.slice((intMatch[0] ?? "").length).replace(FRAME_TITLE_SEP_RE, "").trim();
|
|
129
|
+
if (rest)
|
|
130
|
+
frame.title = rest;
|
|
131
|
+
}
|
|
132
|
+
else if (section.headingText) {
|
|
133
|
+
frame.title = section.headingText;
|
|
134
|
+
}
|
|
135
|
+
const narrative = [];
|
|
136
|
+
for (const line of section.lines) {
|
|
137
|
+
const meta = META_RE.exec(line);
|
|
138
|
+
if (!meta) {
|
|
139
|
+
narrative.push(line);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const key = (meta[1] ?? "").toLowerCase();
|
|
143
|
+
const value = (meta[2] ?? "").trim();
|
|
144
|
+
if (key === "src")
|
|
145
|
+
frame.src = value;
|
|
146
|
+
else if (key === "duration") {
|
|
147
|
+
frame.duration = value;
|
|
148
|
+
const num = DURATION_NUM_RE.exec(value);
|
|
149
|
+
if (num)
|
|
150
|
+
frame.durationSeconds = Number.parseFloat(num[1] ?? "");
|
|
151
|
+
else
|
|
152
|
+
warnings.push({ message: `Frame ${index}: could not parse duration "${value}".`, line: section.headingLine, frameIndex: index });
|
|
153
|
+
}
|
|
154
|
+
else if (key === "poster") {
|
|
155
|
+
const num = DURATION_NUM_RE.exec(value);
|
|
156
|
+
if (num)
|
|
157
|
+
frame.poster = Number.parseFloat(num[1] ?? "");
|
|
158
|
+
}
|
|
159
|
+
else if (key === "status") {
|
|
160
|
+
const normalized = value.toLowerCase();
|
|
161
|
+
if (isFrameStatus(normalized))
|
|
162
|
+
frame.status = normalized;
|
|
163
|
+
else {
|
|
164
|
+
frame.extra.status = value;
|
|
165
|
+
warnings.push({ message: `Frame ${index}: unknown status "${value}"; defaulting to "${DEFAULT_FRAME_STATUS}".`, line: section.headingLine, frameIndex: index });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else if (TRANSITION_KEYS.has(key))
|
|
169
|
+
frame.transitionIn = value;
|
|
170
|
+
else if (SCENE_KEYS.has(key))
|
|
171
|
+
frame.scene = value;
|
|
172
|
+
else if (VOICEOVER_KEYS.has(key))
|
|
173
|
+
frame.voiceover = stripQuotes(value);
|
|
174
|
+
else
|
|
175
|
+
frame.extra[key] = value;
|
|
176
|
+
}
|
|
177
|
+
frame.narrative = narrative.join("\n").trim();
|
|
178
|
+
return frame;
|
|
179
|
+
});
|
|
180
|
+
return { globals, frames, warnings };
|
|
181
|
+
}
|
|
182
|
+
/** Read + parse a project's storyboard (and its companion script). Missing file
|
|
183
|
+
* is NOT an error — it returns `exists:false` with an empty manifest, so a
|
|
184
|
+
* caller can say "no plan yet" instead of blowing up. */
|
|
185
|
+
export function readStoryboard(projectDir) {
|
|
186
|
+
const absPath = path.join(projectDir, STORYBOARD_FILENAME);
|
|
187
|
+
const scriptAbs = path.join(projectDir, SCRIPT_FILENAME);
|
|
188
|
+
let script = { exists: false, path: SCRIPT_FILENAME, content: "" };
|
|
189
|
+
if (existsSync(scriptAbs)) {
|
|
190
|
+
try {
|
|
191
|
+
script = { exists: true, path: SCRIPT_FILENAME, content: readFileSync(scriptAbs, "utf8") };
|
|
192
|
+
}
|
|
193
|
+
catch { /* unreadable → treat as absent */ }
|
|
194
|
+
}
|
|
195
|
+
if (!existsSync(absPath)) {
|
|
196
|
+
return { exists: false, path: STORYBOARD_FILENAME, absPath, manifest: { globals: { extra: {} }, frames: [], warnings: [] }, script };
|
|
197
|
+
}
|
|
198
|
+
return { exists: true, path: STORYBOARD_FILENAME, absPath, manifest: parseStoryboard(readFileSync(absPath, "utf8")), script };
|
|
199
|
+
}
|
|
200
|
+
/** Which review stage the plan is in, mirroring what the editor's Storyboard
|
|
201
|
+
* view shows the director: plan → visual sketches → animation. */
|
|
202
|
+
export function storyboardStage(frames) {
|
|
203
|
+
if (frames.some((f) => f.status === "animated"))
|
|
204
|
+
return "final";
|
|
205
|
+
if (frames.some((f) => f.status === "built"))
|
|
206
|
+
return "sketch";
|
|
207
|
+
return "storyboard";
|
|
208
|
+
}
|
|
209
|
+
/** Render a starter `STORYBOARD.md`. Keeps every field the editor's Storyboard
|
|
210
|
+
* view reads, so a freshly scaffolded plan renders as a real contact sheet
|
|
211
|
+
* (with empty frames) rather than a blank page. */
|
|
212
|
+
export function renderStoryboardScaffold(input) {
|
|
213
|
+
const frames = (input.frames?.length ? input.frames : Array.from({ length: Math.max(1, input.count ?? 5) }, () => "")).map((raw, i) => {
|
|
214
|
+
const [title, scene] = raw.split("|").map((s) => s.trim());
|
|
215
|
+
const n = i + 1;
|
|
216
|
+
return [
|
|
217
|
+
`## Frame ${n}${title ? ` — ${title}` : ""}`,
|
|
218
|
+
`- duration: 3s`,
|
|
219
|
+
`- status: outline`,
|
|
220
|
+
scene ? `- scene: ${scene}` : `- scene: `,
|
|
221
|
+
`- voiceover: `,
|
|
222
|
+
"",
|
|
223
|
+
scene ? "" : "What happens in this beat, in one or two plain sentences.",
|
|
224
|
+
""
|
|
225
|
+
].filter((line) => line !== undefined).join("\n");
|
|
226
|
+
});
|
|
227
|
+
return [
|
|
228
|
+
"---",
|
|
229
|
+
`format: ${input.format ?? "1080x1920"}`,
|
|
230
|
+
`message: ${input.message ?? ""}`,
|
|
231
|
+
`arc: ${input.arc ?? ""}`,
|
|
232
|
+
`audience: ${input.audience ?? ""}`,
|
|
233
|
+
"---",
|
|
234
|
+
"",
|
|
235
|
+
`# ${input.title ?? "Storyboard"}`,
|
|
236
|
+
"",
|
|
237
|
+
"Each frame below is one key moment. Advance `status` as you build:",
|
|
238
|
+
"`outline` (planned) → `built` (a real sub-composition exists at `src`) → `animated` (motion done).",
|
|
239
|
+
"",
|
|
240
|
+
...frames
|
|
241
|
+
].join("\n").replace(/\n{3,}/g, "\n\n") + "\n";
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=storyboard.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.29",
|
|
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": {
|
|
@@ -17,17 +17,23 @@
|
|
|
17
17
|
"dist/src/devcli/composition-edit.js",
|
|
18
18
|
"dist/src/devcli/cost-mode.js",
|
|
19
19
|
"dist/src/devcli/doctor.js",
|
|
20
|
+
"dist/src/devcli/handoff.js",
|
|
20
21
|
"dist/src/devcli/greenscreen-local.js",
|
|
21
22
|
"dist/src/devcli/hyperframes-cli.js",
|
|
23
|
+
"dist/src/devcli/interaction-mode.js",
|
|
22
24
|
"dist/src/devcli/local-backend.js",
|
|
23
25
|
"dist/src/devcli/local-frontend-server.js",
|
|
24
26
|
"dist/src/devcli/local-render.js",
|
|
25
27
|
"dist/src/devcli/port-utils.js",
|
|
26
28
|
"dist/src/devcli/process-scan.js",
|
|
29
|
+
"dist/src/devcli/qa-check.js",
|
|
30
|
+
"dist/src/devcli/qa-regime.js",
|
|
27
31
|
"dist/src/devcli/sequence.js",
|
|
28
32
|
"dist/src/devcli/skills.js",
|
|
29
33
|
"dist/src/devcli/speech.js",
|
|
34
|
+
"dist/src/devcli/sticker-pack.js",
|
|
30
35
|
"dist/src/devcli/stills.js",
|
|
36
|
+
"dist/src/devcli/storyboard.js",
|
|
31
37
|
"dist/src/devcli/telemetry.js",
|
|
32
38
|
"dist/src/devcli/timeline-edit.js",
|
|
33
39
|
"dist/src/devcli/transitions.js",
|
|
@@ -86,6 +92,7 @@
|
|
|
86
92
|
"start": "node --import ./dist/src/instrument.js --enable-source-maps dist/src/index.js",
|
|
87
93
|
"check": "tsc -p tsconfig.json --noEmit && npm run check:skills",
|
|
88
94
|
"test:clips": "node --import tsx --test test/clip-curation.test.ts",
|
|
95
|
+
"test:qa": "node --import tsx --test test/qa-check.test.ts",
|
|
89
96
|
"check:skills": "node scripts/build-director-skill-rollup.mjs --check && node scripts/check-skill-routes.mjs",
|
|
90
97
|
"benchmark:editor-chat": "node --import tsx scripts/benchmark-editor-chat-harness.mjs",
|
|
91
98
|
"cdk:deploy:prod-serverless": "npm run build && dotenv -e .env.production -- npx aws-cdk deploy --app 'node dist/infra/cdk/bin/vidfarm-prod.js'",
|