@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.
@@ -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, keySafeArtInstruction } from "./sticker-pack.js";
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
- const prompt = pack
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
- `A sticker sheet of ${items.length || "several"} separate objects — ${style} — on a solid pure ${keyColor} background`,
62
- `(flat, evenly lit, no gradient, no shadow, no text, no labels, no frames or dividing lines).`,
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
- `${input.theme} — ${style} — isolated on a solid pure ${keyColor} background`,
77
- `(flat, evenly lit, no gradient, no shadow cast on the background, no text).`,
78
- `Center the subject with generous empty margin on all sides, crisp clean edges, single subject.`,
79
- keySafeArtInstruction(keyColor),
80
- `Square image, high resolution.`
81
- ].join(" ");
82
- const steps = pack
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. 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) if any object is a hollow outline with the background showing through its middle, re-generate asking for solid fills that interior gets deleted with the background and the sticker ends up as a rim around a hole.",
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 split it into individual transparent stickers locally for $0: \`vidfarm sticker-pack <sheet> --out-dir ${outDir}\`.`
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
- "Open a FREE image generator you're already signed into (list below).",
91
- "Paste the prompt below and generate.",
92
- "Download the image (PNG preferred) and tell me the file path.",
93
- "I'll key the background out and trim it to a snug transparent sticker locally for $0: `vidfarm cutout <file>`."
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 = pack
97
- ? `vidfarm sticker-pack <downloaded-sheet.png>${items.length ? ` --items "${items.join(",")}"` : ""}${keyFlag} --out-dir ${outDir}`
98
- : `vidfarm cutout <downloaded.png>${keyFlag}`;
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, { method, headers, body: body, redirect: "manual" });
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
- res.statusCode = 502;
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({ error: "upstream_unreachable", detail: error instanceof Error ? error.message : String(error), host }));
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
- Readable.fromWeb(upstream.body).pipe(res);
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
- createReadStream(record.outputPath).pipe(res);
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
- createReadStream(job.outputPath).pipe(res);
998
+ pipeFile(res, job.outputPath);
913
999
  return;
914
1000
  }
915
1001
  }