@officexapp/vidfarm-devcli 0.21.35 → 0.21.37
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 +1 -1
- package/.agents/skills/vidfarm/SKILL.md +7 -3
- package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +3 -3
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +42 -12
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +4 -4
- package/.agents/skills/vidfarm/references/hooks-and-virality.md +3 -2
- package/SKILL.director.md +56 -21
- package/SKILL.md +1 -1
- package/dist/src/cli.js +429 -53
- package/dist/src/devcli/handoff.js +54 -33
- package/dist/src/devcli/local-frontend-server.js +92 -6
- package/dist/src/devcli/plate-key.js +698 -0
- package/dist/src/devcli/sticker-pack.js +48 -0
- package/package.json +3 -2
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
// Both builders return plain text meant to be shown verbatim to the user, plus a
|
|
23
23
|
// structured form for `--json` so an agent can render it its own way. No network,
|
|
24
24
|
// no backend imports — this module is pure string assembly.
|
|
25
|
-
import { pickPlateColor,
|
|
25
|
+
import { pickPlateColor, connectivitySafeArtInstruction } from "./sticker-pack.js";
|
|
26
|
+
import { planZonedSheet, zonedSheetInstruction } from "./plate-key.js";
|
|
26
27
|
const DEFAULT_STYLE = "simple flat vector illustration, minimal detail, 2-3 flat colors, no shadows, no text";
|
|
27
28
|
/** Pick a sensible grid for N items (roughly square, wider than tall). */
|
|
28
29
|
function gridFor(count) {
|
|
@@ -56,46 +57,66 @@ export function buildImageHandoff(input) {
|
|
|
56
57
|
const pack = input.pack ?? items.length > 1;
|
|
57
58
|
const outDir = input.outDir ?? "./stickers";
|
|
58
59
|
const grid = input.grid ?? gridFor(items.length || 6);
|
|
59
|
-
|
|
60
|
+
// ZONED sheet: hand the user a color-block grid instead of one plate. Worth it
|
|
61
|
+
// when the pack's own colors fight a single plate (a green frog next to a pink
|
|
62
|
+
// flower), because each panel's color is picked against its own item.
|
|
63
|
+
const zonePlan = pack && input.zoned ? planZonedSheet(items, items.length || 6, pickPlateColor, { theme: input.theme }) : null;
|
|
64
|
+
const prompt = zonePlan
|
|
60
65
|
? [
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
`Theme: ${input.theme}.`,
|
|
64
|
-
items.length
|
|
65
|
-
? `Arranged in a ${grid} grid with generous even spacing: ${items.map((it, i) => `(${i + 1}) ${it}`).join(", ")}.`
|
|
66
|
-
: `Arranged in a ${grid} grid with generous even spacing.`,
|
|
67
|
-
`CRITICAL: every object must be fully separated from the others by a clear margin of plain ${keyColor} background —`,
|
|
68
|
-
`nothing touching, overlapping or connected, and nothing touching the image edge.`,
|
|
69
|
-
`One consistent art style, line weight and palette across all objects. Front-facing, centered in its own cell.`,
|
|
70
|
-
// Load-bearing: the user is about to spend their own time on this sheet,
|
|
71
|
-
// and hollow/outline-only art comes back as rims around holes.
|
|
72
|
-
keySafeArtInstruction(keyColor),
|
|
66
|
+
`${input.theme} — ${style}.`,
|
|
67
|
+
zonedSheetInstruction(zonePlan, { theme: input.theme }),
|
|
73
68
|
`Square image, high resolution.`
|
|
74
69
|
].join(" ")
|
|
75
|
-
:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
70
|
+
: pack
|
|
71
|
+
? [
|
|
72
|
+
`A sticker sheet of ${items.length || "several"} separate objects — ${style} — on a solid pure ${keyColor} background`,
|
|
73
|
+
`(flat, evenly lit, no gradient, no shadow, no text, no labels, no frames or dividing lines).`,
|
|
74
|
+
`Theme: ${input.theme}.`,
|
|
75
|
+
items.length
|
|
76
|
+
? `Arranged in a ${grid} grid with generous even spacing: ${items.map((it, i) => `(${i + 1}) ${it}`).join(", ")}.`
|
|
77
|
+
: `Arranged in a ${grid} grid with generous even spacing.`,
|
|
78
|
+
`CRITICAL: every object must be fully separated from the others by a clear margin of plain ${keyColor} background —`,
|
|
79
|
+
`nothing touching, overlapping or connected, and nothing touching the image edge.`,
|
|
80
|
+
`One consistent art style, line weight and palette across all objects. Front-facing, centered in its own cell.`,
|
|
81
|
+
// The local cut uses the smart (connectivity) keyer, so hollow shapes and
|
|
82
|
+
// plate-colored detail sealed inside an object are fine — only the
|
|
83
|
+
// silhouette has to stay distinguishable from the plate.
|
|
84
|
+
connectivitySafeArtInstruction(keyColor),
|
|
85
|
+
`Square image, high resolution.`
|
|
86
|
+
].join(" ")
|
|
87
|
+
: [
|
|
88
|
+
`${input.theme} — ${style} — isolated on a solid pure ${keyColor} background`,
|
|
89
|
+
`(flat, evenly lit, no gradient, no shadow cast on the background, no text).`,
|
|
90
|
+
`Center the subject with generous empty margin on all sides, crisp clean edges, single subject.`,
|
|
91
|
+
connectivitySafeArtInstruction(keyColor),
|
|
92
|
+
`Square image, high resolution.`
|
|
93
|
+
].join(" ");
|
|
94
|
+
const steps = zonePlan
|
|
83
95
|
? [
|
|
84
96
|
"Open a FREE image generator you're already signed into (list below).",
|
|
85
|
-
"Paste the prompt below and generate.
|
|
97
|
+
"Paste the prompt below and generate. One check before you send it back: the image should be a grid of solid COLOR PANELS, one object per panel, no object crossing a panel edge and none touching its panel's edge. If the tool ignored the panels and drew one background instead, send it anyway — I'll cut it the simple way.",
|
|
86
98
|
"Download the image (PNG preferred) and tell me the file path — or drop it in this project folder.",
|
|
87
|
-
`I'll
|
|
99
|
+
`I'll cut each panel with its own background color locally for $0: \`vidfarm sticker-pack <sheet> --zones ${zonePlan.rows}x${zonePlan.cols} --out-dir ${outDir}\`.`
|
|
88
100
|
]
|
|
89
|
-
:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
: pack
|
|
102
|
+
? [
|
|
103
|
+
"Open a FREE image generator you're already signed into (list below).",
|
|
104
|
+
"Paste the prompt below and generate. Two checks before you send it back: (a) if any two objects are touching, re-generate asking for wider spacing — touching objects get cut out as ONE sticker; (b) make sure no object's outer edge blurs, glows or fades into the background — a crisp edge is what the cut needs.",
|
|
105
|
+
"Download the image (PNG preferred) and tell me the file path — or drop it in this project folder.",
|
|
106
|
+
`I'll split it into individual transparent stickers locally for $0: \`vidfarm sticker-pack <sheet> --out-dir ${outDir}\`.`
|
|
107
|
+
]
|
|
108
|
+
: [
|
|
109
|
+
"Open a FREE image generator you're already signed into (list below).",
|
|
110
|
+
"Paste the prompt below and generate.",
|
|
111
|
+
"Download the image (PNG preferred) and tell me the file path.",
|
|
112
|
+
"I'll key the background out and trim it to a snug transparent sticker locally for $0: `vidfarm cutout <file>`."
|
|
113
|
+
];
|
|
95
114
|
const keyFlag = keyColor === "#00FF00" ? "" : ` --key-color "${keyColor}"`;
|
|
96
|
-
const followUp =
|
|
97
|
-
? `vidfarm sticker-pack <downloaded-sheet.png>${items.length ? ` --items "${items.join(",")}"` : ""}${
|
|
98
|
-
:
|
|
115
|
+
const followUp = zonePlan
|
|
116
|
+
? `vidfarm sticker-pack <downloaded-sheet.png>${items.length ? ` --items "${items.join(",")}"` : ""} --zones ${zonePlan.rows}x${zonePlan.cols} --out-dir ${outDir}`
|
|
117
|
+
: pack
|
|
118
|
+
? `vidfarm sticker-pack <downloaded-sheet.png>${items.length ? ` --items "${items.join(",")}"` : ""}${keyFlag} --out-dir ${outDir}`
|
|
119
|
+
: `vidfarm cutout <downloaded.png>${keyFlag}`;
|
|
99
120
|
return {
|
|
100
121
|
prompt,
|
|
101
122
|
steps,
|
|
@@ -235,6 +235,24 @@ function serveAsset(res, pathname) {
|
|
|
235
235
|
return true;
|
|
236
236
|
}
|
|
237
237
|
// ── reverse proxy to the cloud host ─────────────────────────────────────────
|
|
238
|
+
/** How long to wait for upstream RESPONSE HEADERS before giving up (504). */
|
|
239
|
+
const PROXY_HEADERS_TIMEOUT_MS = 30_000;
|
|
240
|
+
function errorText(error) {
|
|
241
|
+
return error instanceof Error ? error.message : String(error);
|
|
242
|
+
}
|
|
243
|
+
/** Stream a finished render file. Same rule as the proxy pipe: a disk read
|
|
244
|
+
* error (file deleted mid-download, bad sector) must fail THIS response, not
|
|
245
|
+
* raise an uncaught 'error' that takes the whole serve process down. */
|
|
246
|
+
function pipeFile(res, filePath) {
|
|
247
|
+
const stream = createReadStream(filePath);
|
|
248
|
+
stream.on("error", (error) => {
|
|
249
|
+
console.warn(`[vidfarm] serve: could not stream ${filePath} (${errorText(error)})`);
|
|
250
|
+
res.destroy();
|
|
251
|
+
});
|
|
252
|
+
res.on("close", () => stream.destroy());
|
|
253
|
+
res.on("error", () => stream.destroy());
|
|
254
|
+
stream.pipe(res);
|
|
255
|
+
}
|
|
238
256
|
// Hop-by-hop headers are stripped so we forward a clean request/response.
|
|
239
257
|
const HOP_BY_HOP = new Set([
|
|
240
258
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
@@ -267,15 +285,40 @@ async function proxyToCloud(req, res, host, pathWithSearch, auth, bodyOverride)
|
|
|
267
285
|
const method = req.method ?? "GET";
|
|
268
286
|
const body = method === "GET" || method === "HEAD" ? undefined : (bodyOverride ?? await readBody(req));
|
|
269
287
|
let upstream;
|
|
288
|
+
// Bound the wait for RESPONSE HEADERS only. A cloud route that never answers
|
|
289
|
+
// used to hang the browser forever with no error — the page shell paints from
|
|
290
|
+
// disk, then a data call spins for good. The BODY stays unbounded on purpose:
|
|
291
|
+
// proxied SSE (/api/v1/editor-chat) and media downloads are long-lived by
|
|
292
|
+
// design, so a body deadline would cut healthy streams.
|
|
293
|
+
const headersAbort = new AbortController();
|
|
294
|
+
const headersTimer = setTimeout(() => headersAbort.abort(), PROXY_HEADERS_TIMEOUT_MS);
|
|
270
295
|
try {
|
|
271
|
-
upstream = await fetch(target, {
|
|
296
|
+
upstream = await fetch(target, {
|
|
297
|
+
method,
|
|
298
|
+
headers,
|
|
299
|
+
body: body,
|
|
300
|
+
redirect: "manual",
|
|
301
|
+
signal: headersAbort.signal
|
|
302
|
+
});
|
|
272
303
|
}
|
|
273
304
|
catch (error) {
|
|
274
|
-
|
|
305
|
+
const timedOut = headersAbort.signal.aborted;
|
|
306
|
+
res.statusCode = timedOut ? 504 : 502;
|
|
275
307
|
res.setHeader("content-type", "application/json");
|
|
276
|
-
res.end(JSON.stringify({
|
|
308
|
+
res.end(JSON.stringify({
|
|
309
|
+
error: timedOut ? "upstream_timeout" : "upstream_unreachable",
|
|
310
|
+
detail: timedOut
|
|
311
|
+
? `${host} sent no response headers within ${PROXY_HEADERS_TIMEOUT_MS / 1000}s`
|
|
312
|
+
: error instanceof Error ? error.message : String(error),
|
|
313
|
+
host
|
|
314
|
+
}));
|
|
277
315
|
return;
|
|
278
316
|
}
|
|
317
|
+
finally {
|
|
318
|
+
// Headers are in (or the request failed) — from here the body streams for
|
|
319
|
+
// as long as it needs to.
|
|
320
|
+
clearTimeout(headersTimer);
|
|
321
|
+
}
|
|
279
322
|
res.statusCode = upstream.status;
|
|
280
323
|
upstream.headers.forEach((value, key) => {
|
|
281
324
|
if (HOP_BY_HOP.has(key.toLowerCase()))
|
|
@@ -287,7 +330,30 @@ async function proxyToCloud(req, res, host, pathWithSearch, auth, bodyOverride)
|
|
|
287
330
|
res.setHeader("x-vidfarm-upstream", host);
|
|
288
331
|
if (upstream.body) {
|
|
289
332
|
// Stream the response (covers SSE from /api/v1/editor-chat too).
|
|
290
|
-
|
|
333
|
+
//
|
|
334
|
+
// An upstream body can break AFTER the headers arrive: a CloudFront/Lambda
|
|
335
|
+
// connection reset, or undici's own body timeout on a proxied stream that
|
|
336
|
+
// idles. That surfaces as an 'error' on this stream — and an UNHANDLED
|
|
337
|
+
// 'error' is an uncaught exception, which killed the entire serve process.
|
|
338
|
+
// The visible symptom was the reported one: the editor page paints, the
|
|
339
|
+
// server dies mid-load, and every remaining request hangs forever. So both
|
|
340
|
+
// ends of the pipe get error handling, and each end tears the other down.
|
|
341
|
+
const stream = Readable.fromWeb(upstream.body);
|
|
342
|
+
stream.on("error", (error) => {
|
|
343
|
+
console.warn(`[vidfarm] serve: upstream stream broke for ${pathWithSearch} (${errorText(error)})`);
|
|
344
|
+
// Headers are already on the wire, so there is no status left to send —
|
|
345
|
+
// cut the response so the browser reports a failed request instead of
|
|
346
|
+
// waiting on a body that will never arrive.
|
|
347
|
+
res.destroy();
|
|
348
|
+
});
|
|
349
|
+
// The browser navigating away or cancelling must not leave us pulling the
|
|
350
|
+
// upstream body forever.
|
|
351
|
+
res.on("close", () => stream.destroy());
|
|
352
|
+
res.on("error", (error) => {
|
|
353
|
+
console.warn(`[vidfarm] serve: client stream broke for ${pathWithSearch} (${errorText(error)})`);
|
|
354
|
+
stream.destroy();
|
|
355
|
+
});
|
|
356
|
+
stream.pipe(res);
|
|
291
357
|
}
|
|
292
358
|
else {
|
|
293
359
|
res.end();
|
|
@@ -709,8 +775,28 @@ function renderLoginRequiredPage(pathname) {
|
|
|
709
775
|
</html>
|
|
710
776
|
`;
|
|
711
777
|
}
|
|
778
|
+
// Last-resort net. Every known stream path now handles its own errors, but a
|
|
779
|
+
// local dev server must never disappear on a stray async throw — the user sees
|
|
780
|
+
// only a page that stopped loading, with no clue the server is gone. Log it
|
|
781
|
+
// loudly and stay up; this process is a stateless proxy plus a static file
|
|
782
|
+
// server, so there is no shared state left corrupt by a failed request.
|
|
783
|
+
let crashGuardInstalled = false;
|
|
784
|
+
function installServeCrashGuard() {
|
|
785
|
+
if (crashGuardInstalled)
|
|
786
|
+
return;
|
|
787
|
+
crashGuardInstalled = true;
|
|
788
|
+
process.on("uncaughtException", (error) => {
|
|
789
|
+
console.error(`[vidfarm] serve: recovered from an uncaught error — ${errorText(error)}`);
|
|
790
|
+
if (error instanceof Error && error.stack)
|
|
791
|
+
console.error(error.stack);
|
|
792
|
+
});
|
|
793
|
+
process.on("unhandledRejection", (reason) => {
|
|
794
|
+
console.error(`[vidfarm] serve: recovered from an unhandled rejection — ${errorText(reason)}`);
|
|
795
|
+
});
|
|
796
|
+
}
|
|
712
797
|
export function startLocalFrontendServer(opts) {
|
|
713
798
|
const host = opts.host.replace(/\/+$/, "");
|
|
799
|
+
installServeCrashGuard();
|
|
714
800
|
const server = createServer((req, res) => {
|
|
715
801
|
void handleRequest(req, res, host, opts.auth).catch((error) => {
|
|
716
802
|
if (!res.headersSent) {
|
|
@@ -816,7 +902,7 @@ async function handleRequest(req, res, host, auth) {
|
|
|
816
902
|
res.setHeader("content-type", "video/mp4");
|
|
817
903
|
res.setHeader("content-length", String(statSync(record.outputPath).size));
|
|
818
904
|
res.setHeader("content-disposition", `inline; filename="vidfarm-${path.basename(record.outputPath)}"`);
|
|
819
|
-
|
|
905
|
+
pipeFile(res, record.outputPath);
|
|
820
906
|
return;
|
|
821
907
|
}
|
|
822
908
|
// 3b. Studio-contract render endpoints (the sealed StudioApp's Export
|
|
@@ -909,7 +995,7 @@ async function handleRequest(req, res, host, auth) {
|
|
|
909
995
|
res.setHeader("content-type", STUDIO_RENDER_MIME[path.extname(job.outputPath)] ?? "video/mp4");
|
|
910
996
|
res.setHeader("content-length", String(statSync(job.outputPath).size));
|
|
911
997
|
res.setHeader("content-disposition", `inline; filename="${filename}"`);
|
|
912
|
-
|
|
998
|
+
pipeFile(res, job.outputPath);
|
|
913
999
|
return;
|
|
914
1000
|
}
|
|
915
1001
|
}
|