@mevdragon/vidfarm-devcli 0.16.0 → 0.18.0

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.
Files changed (38) hide show
  1. package/.agents/skills/farmville-saas-ux/SKILL.md +156 -0
  2. package/.agents/skills/farmville-saas-ux/assets/starter.html +294 -0
  3. package/.agents/skills/farmville-saas-ux/references/components.md +340 -0
  4. package/.agents/skills/farmville-saas-ux/references/porting-guide.md +121 -0
  5. package/.agents/skills/farmville-saas-ux/references/tokens.md +271 -0
  6. package/.agents/skills/vidfarm-media/SKILL.md +4 -3
  7. package/.agents/skills/vidfarm-media/references/tts.md +20 -1
  8. package/SKILL.director.md +20 -20
  9. package/SKILL.platform.md +4 -4
  10. package/dist/src/account-pages-legacy.js +2 -2
  11. package/dist/src/app.js +721 -101
  12. package/dist/src/cli.js +11 -10
  13. package/dist/src/devcli/clips.js +64 -64
  14. package/dist/src/editor-chat.js +2 -2
  15. package/dist/src/frontend/homepage-client.js +162 -2
  16. package/dist/src/frontend/homepage-store.js +30 -1
  17. package/dist/src/frontend/homepage-view.js +111 -4
  18. package/dist/src/homepage.js +184 -1
  19. package/dist/src/landing-page.js +367 -0
  20. package/dist/src/primitive-registry.js +278 -2
  21. package/dist/src/reskin/agency-page.js +299 -0
  22. package/dist/src/reskin/calendar-page.js +567 -0
  23. package/dist/src/reskin/chat-page.js +607 -0
  24. package/dist/src/reskin/discover-page.js +1096 -0
  25. package/dist/src/reskin/document.js +663 -0
  26. package/dist/src/reskin/help-page.js +356 -0
  27. package/dist/src/reskin/index-page.js +62 -0
  28. package/dist/src/reskin/inpaint-page.js +541 -0
  29. package/dist/src/reskin/job-runs-page.js +477 -0
  30. package/dist/src/reskin/library-page.js +688 -0
  31. package/dist/src/reskin/login-page.js +262 -0
  32. package/dist/src/reskin/pricing-page.js +388 -0
  33. package/dist/src/reskin/settings-page.js +687 -0
  34. package/dist/src/reskin/theme.js +362 -0
  35. package/dist/src/services/serverless-records.js +54 -0
  36. package/dist/src/services/swipe-customize.js +434 -0
  37. package/package.json +1 -1
  38. package/public/assets/homepage-client-app.js +22 -22
@@ -0,0 +1,434 @@
1
+ // Swipe-mode customization brain + progressive queue.
2
+ //
3
+ // Takes a decomposed template whose footage is GhostCut-clean (plays a `-no-subs`
4
+ // mirror) and that ALREADY has on-screen caption elements, clones its canonical
5
+ // fork into a private derivative, then RE-CAPTIONS it: each original caption's
6
+ // TEXT is rewritten to sell the customer's product while its timing (data-start/
7
+ // duration), position (left/top) and styling stay exactly as the original — so
8
+ // the new ad keeps the source video's caption tempo and choreography. Only the
9
+ // words change. The LLM runs on the customer's own saved keys (BYOK), never billed.
10
+ import { parseHTML } from "linkedom";
11
+ import { serverlessRecords } from "./serverless-records.js";
12
+ import { StorageService } from "./storage.js";
13
+ import { ProviderService } from "./providers.js";
14
+ import { cuesFromText } from "./captions.js";
15
+ import { sanitizeCompositionHtml } from "./composition-sanitize.js";
16
+ import { writeForkManifest } from "./fork-manifest.js";
17
+ import { insertCaptionLayers } from "../devcli/composition-edit.js";
18
+ const storage = new StorageService();
19
+ const providers = new ProviderService();
20
+ export const SWIPE_STACK_TARGET_DEPTH = 5;
21
+ const ELIGIBILITY_SCAN_LIMIT = 80;
22
+ const DEFAULT_CAPTION_STYLE = "word-pop";
23
+ // Minimum original captions a template must have to be worth re-captioning.
24
+ const MIN_CAPTION_SLOTS = 2;
25
+ // Bump when the customize pipeline changes shape enough that variants built by
26
+ // the previous pipeline should be pulled from the deck. v1 = the tack-on
27
+ // pipeline (insertCaptionLayers word-pop over the original captions — the
28
+ // double-caption artifact); v2 = in-place recaption. Un-swiped rows from older
29
+ // versions are lazily marked "superseded" (see reconcileSwipeRows) which drops
30
+ // them from the deck AND frees their template to be customized again properly.
31
+ // Rows the customer already decided on (approved/rejected) are never touched.
32
+ export const SWIPE_PIPELINE_VERSION = 2;
33
+ // A "customizing" row whose lambda died mid-flight would otherwise occupy a
34
+ // refill buffer slot forever; past the api lambda's max runtime it can't still
35
+ // be live, so supersede it. Failed rows get a cooldown then are superseded too,
36
+ // freeing their template for a retry (bounds a deterministic failure to one
37
+ // LLM call per cooldown, and lets transient LLM flakes self-heal).
38
+ const CUSTOMIZING_WEDGE_MS = 20 * 60 * 1000;
39
+ const FAILED_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
40
+ function isStaleSwipeRow(row) {
41
+ const ageMs = Date.now() - Date.parse(String(row.updatedAt ?? row.createdAt ?? ""));
42
+ if (row.status === "customizing" && Number.isFinite(ageMs) && ageMs > CUSTOMIZING_WEDGE_MS)
43
+ return true;
44
+ if (row.status === "failed" && Number.isFinite(ageMs) && ageMs > FAILED_RETRY_COOLDOWN_MS)
45
+ return true;
46
+ if ((row.pipelineVersion ?? 1) >= SWIPE_PIPELINE_VERSION)
47
+ return false;
48
+ return row.status === "ready" || row.status === "customizing" || row.status === "failed";
49
+ }
50
+ // Lazily migrate a customer's rows: supersede every un-swiped variant built by
51
+ // an older pipeline. Idempotent (superseded rows are no longer stale) and cheap
52
+ // after the first pass. Returns the rows with the patches applied.
53
+ export async function reconcileSwipeRows(customerId, rows) {
54
+ const out = [];
55
+ for (const row of rows) {
56
+ if (!isStaleSwipeRow(row)) {
57
+ out.push(row);
58
+ continue;
59
+ }
60
+ const updated = await serverlessRecords.updateSwipeCustomization({
61
+ customerId,
62
+ id: row.id,
63
+ patch: { status: "superseded" }
64
+ });
65
+ out.push(updated ?? { ...row, status: "superseded" });
66
+ }
67
+ return out;
68
+ }
69
+ const TEXT_MODEL_DEFAULTS = {
70
+ openai: "gpt-5.4-mini",
71
+ gemini: "gemini-2.5-flash",
72
+ openrouter: "qwen/qwen3.6-plus",
73
+ perplexity: "sonar"
74
+ };
75
+ const TEXT_PROVIDER_PRIORITY = ["openai", "gemini", "openrouter", "perplexity"];
76
+ export class NoProviderKeyError extends Error {
77
+ constructor() {
78
+ super("Swipe customization needs an AI provider key. Add an OpenAI, Gemini, or OpenRouter key in Settings.");
79
+ this.name = "NoProviderKeyError";
80
+ }
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // Eligibility: decomposed (defaultForkId), GhostCut-clean (composition plays a
84
+ // `-no-subs` mirror so there's no baked text to clash with), HAS caption slots
85
+ // to re-caption, and not already customized for this customer.
86
+ const MIRROR_MARKER = /-no-subs|\/ghostcut\//i;
87
+ // Count static caption elements ([data-layer-kind="caption"] that are not the
88
+ // animated word-by-word kind) via cheap regex — canonical forks only carry the
89
+ // original static captions, so this is the recaption slot count. Style blocks
90
+ // are dropped first: caption-animation CSS contains `[data-caption-animation=`
91
+ // selectors that would otherwise inflate the animated count.
92
+ export function countStaticCaptionsInHtml(html) {
93
+ const markup = html.replace(/<style[\s\S]*?<\/style>/gi, "");
94
+ const total = (markup.match(/data-layer-kind="caption"/g) ?? []).length;
95
+ const animated = (markup.match(/data-caption-animation=/g) ?? []).length;
96
+ return Math.max(0, total - animated);
97
+ }
98
+ async function readCanonicalEligibility(defaultForkId) {
99
+ const html = await storage.readText(storage.compositionForkWorkingKey(defaultForkId, "composition.html"));
100
+ if (!html)
101
+ return { eligible: false, sourceUrl: null };
102
+ const isMirror = MIRROR_MARKER.test(html);
103
+ const sourceUrl = readCompositionSourceUrl(html);
104
+ const captionSlots = countStaticCaptionsInHtml(html);
105
+ return { eligible: isMirror && captionSlots >= MIN_CAPTION_SLOTS, sourceUrl };
106
+ }
107
+ export async function listEligibleTemplates(customerId, limit = SWIPE_STACK_TARGET_DEPTH * 4) {
108
+ const feed = await serverlessRecords.listTemplatesFeed({ limit: ELIGIBILITY_SCAN_LIMIT });
109
+ const listed = await serverlessRecords.listSwipeCustomizationsForCustomer(customerId);
110
+ const existing = await reconcileSwipeRows(customerId, listed);
111
+ // Superseded rows do NOT consume their template — the whole point is letting
112
+ // the current pipeline re-customize what an older one botched.
113
+ const alreadyCustomized = new Set(existing.filter((row) => row.status !== "superseded").map((row) => row.templateId));
114
+ const eligible = [];
115
+ for (const template of feed) {
116
+ if (eligible.length >= limit)
117
+ break;
118
+ if (!template.defaultForkId)
119
+ continue;
120
+ if (alreadyCustomized.has(template.id))
121
+ continue;
122
+ const { eligible: ok, sourceUrl } = await readCanonicalEligibility(template.defaultForkId);
123
+ if (!ok)
124
+ continue;
125
+ eligible.push({ template, sourceUrl });
126
+ }
127
+ return eligible;
128
+ }
129
+ function readCompositionSourceUrl(html) {
130
+ try {
131
+ const { document } = parseHTML(html);
132
+ const root = document.querySelector("[data-composition-id]");
133
+ const fromAttr = root?.getAttribute("data-source-video")?.trim();
134
+ if (fromAttr && /^https?:\/\//i.test(fromAttr))
135
+ return fromAttr;
136
+ const backing = document.querySelector("[data-vf-playback-source]");
137
+ const backingSrc = (backing?.getAttribute("src") ?? backing?.getAttribute("data-src"))?.trim();
138
+ if (backingSrc && /^https?:\/\//i.test(backingSrc))
139
+ return backingSrc;
140
+ const video = Array.from(document.querySelectorAll("[data-layer-kind=\"video\"]"))
141
+ .map((n) => n.getAttribute("src")?.trim())
142
+ .find((src) => src && /^https?:\/\//i.test(src));
143
+ return video ?? null;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // Context (light — tone/theme only; the caption slots are the real input).
151
+ function loadContext(smartDecomposeText) {
152
+ if (!smartDecomposeText)
153
+ return { transcript: "", viralDna: "" };
154
+ try {
155
+ const result = JSON.parse(smartDecomposeText)?.result ?? {};
156
+ const transcript = typeof result?.transcript?.text === "string"
157
+ ? result.transcript.text
158
+ : Array.isArray(result?.transcript?.segments)
159
+ ? result.transcript.segments.map((s) => (typeof s?.text === "string" ? s.text : "")).join(" ").trim()
160
+ : "";
161
+ const viralDna = typeof result?.viralDna === "string" ? result.viralDna : result?.viralDna ? JSON.stringify(result.viralDna) : "";
162
+ return { transcript, viralDna };
163
+ }
164
+ catch {
165
+ return { transcript: "", viralDna: "" };
166
+ }
167
+ }
168
+ // Decompose emits static captions as <div data-layer-kind="caption" ...><span
169
+ // data-vf-text-lines><span data-vf-text-inline style="…">words</span></span></div>
170
+ // — the inline span carries the outline/stroke treatment, so write the new words
171
+ // INTO it rather than flattening the element (`el.textContent =` on the div
172
+ // would strip the treatment — the same trap /editor's set_layer_text hit).
173
+ // data-label mirrors the words for tooling; keep it in sync.
174
+ export function setCaptionSlotText(el, text) {
175
+ const inline = typeof el.querySelector === "function" ? el.querySelector("[data-vf-text-inline]") : null;
176
+ (inline ?? el).textContent = text;
177
+ if (typeof el.getAttribute === "function" && el.getAttribute("data-label") != null) {
178
+ el.setAttribute("data-label", text.slice(0, 40));
179
+ }
180
+ }
181
+ // A canonical fork should only carry the original STATIC captions, but if an
182
+ // editor session (or an older customize pipeline) left animated word-by-word
183
+ // caption layers on it, the clone inherits words that fight the recaption —
184
+ // drop them before rewriting. Returns how many were removed.
185
+ export function stripAnimatedCaptionLayers(document) {
186
+ const root = document.querySelector("[data-composition-id]") ?? document;
187
+ const nodes = Array.from(root.querySelectorAll("[data-caption-animation]"));
188
+ for (const node of nodes)
189
+ node.parentNode?.removeChild(node);
190
+ return nodes.length;
191
+ }
192
+ export function collectCaptionSlots(document) {
193
+ const root = document.querySelector("[data-composition-id]") ?? document;
194
+ return Array.from(root.querySelectorAll('[data-layer-kind="caption"]:not([data-caption-animation])'))
195
+ .map((el) => ({
196
+ el,
197
+ text: String(el.textContent || "").trim().replace(/\s+/g, " "),
198
+ start: Number(el.getAttribute("data-start")) || 0,
199
+ duration: Number(el.getAttribute("data-duration")) || 0
200
+ }))
201
+ .filter((s) => s.text)
202
+ .sort((a, b) => a.start - b.start);
203
+ }
204
+ function buildRecaptionPrompt(input) {
205
+ const { productDescription, slots, context, sourceTitle } = input;
206
+ const list = slots.map((s, i) => `${i + 1}. [${s.start.toFixed(1)}s-${(s.start + s.duration).toFixed(1)}s] "${s.text}"`).join("\n");
207
+ return `You are re-captioning a viral video so it now advertises the customer's product. The video's footage, cuts, timing and caption placement all stay the same — you ONLY rewrite the words of each on-screen caption so they sell the product.
208
+
209
+ CUSTOMER PRODUCT / OFFER:
210
+ ${productDescription.trim()}
211
+
212
+ THE VIDEO
213
+ - Original title: ${sourceTitle}
214
+ - Why it went viral: ${context.viralDna || "(unknown)"}
215
+ - Spoken/heard: ${(context.transcript || "(none)").slice(0, 800)}
216
+
217
+ ORIGINAL ON-SCREEN CAPTIONS (in order, with timing):
218
+ ${list}
219
+
220
+ TASK
221
+ Rewrite EACH caption above so the video becomes a native-feeling ad for the product. Rules:
222
+ - Return EXACTLY ${slots.length} captions, in the SAME order, one replacement per original.
223
+ - Keep each replacement roughly the SAME LENGTH as its original (it must fit the same on-screen slot) and the SAME BEAT/ROLE (a hook stays a hook, a reveal stays a reveal, a CTA stays a CTA).
224
+ - Sound like an authentic creator, not a corporate ad. No hashtags inside the on-screen captions.
225
+
226
+ Return STRICTLY valid JSON, no markdown fences:
227
+ {
228
+ "title": "<=60 char scroll-stopping post title",
229
+ "socialCaption": "1-2 sentence post caption (may include 1-3 hashtags)",
230
+ "pinnedComment": "short first-comment CTA, or null",
231
+ "captions": [ ${slots.map(() => '"..."').join(", ")} ]
232
+ }`;
233
+ }
234
+ function parseJsonLoose(text) {
235
+ const trimmed = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
236
+ try {
237
+ return JSON.parse(trimmed);
238
+ }
239
+ catch {
240
+ const start = trimmed.indexOf("{");
241
+ const end = trimmed.lastIndexOf("}");
242
+ if (start >= 0 && end > start)
243
+ return JSON.parse(trimmed.slice(start, end + 1));
244
+ throw new Error("LLM did not return parseable JSON.");
245
+ }
246
+ }
247
+ async function generateRecaption(input) {
248
+ const available = await providers.listAvailableProvidersAsync(input.customerId);
249
+ const provider = TEXT_PROVIDER_PRIORITY.find((p) => available.includes(p)) ?? available[0];
250
+ if (!provider)
251
+ throw new NoProviderKeyError();
252
+ const generated = await providers.generateText({
253
+ customerId: input.customerId,
254
+ jobId: input.jobId,
255
+ workerId: "swipe-customize",
256
+ provider,
257
+ model: TEXT_MODEL_DEFAULTS[provider],
258
+ prompt: input.prompt,
259
+ temperature: 0.7,
260
+ responseFormat: "json"
261
+ });
262
+ const parsed = parseJsonLoose(generated.text);
263
+ const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : "");
264
+ const captions = Array.isArray(parsed.captions) ? parsed.captions.map((c) => str(c)) : [];
265
+ return {
266
+ title: str(parsed.title) || input.jobId,
267
+ socialCaption: str(parsed.socialCaption) || str(parsed.caption),
268
+ pinnedComment: str(parsed.pinnedComment) || null,
269
+ captions
270
+ };
271
+ }
272
+ // ---------------------------------------------------------------------------
273
+ // Clone the canonical decomposed fork's working state into a private derivative.
274
+ async function cloneDecomposedFork(input) {
275
+ const canonical = await serverlessRecords.getCompositionFork(input.canonicalForkId);
276
+ const fork = await serverlessRecords.createCompositionFork({
277
+ customerId: input.customerId,
278
+ templateId: input.template.id,
279
+ parentForkId: input.canonicalForkId,
280
+ parentVersion: canonical?.latestVersion ?? null,
281
+ visibility: "private",
282
+ title: input.title
283
+ });
284
+ for (const file of ["composition.html", "composition.json", "cast.json", "product-placement.json", "scene-annotations.json"]) {
285
+ const text = await storage.readText(storage.compositionForkWorkingKey(input.canonicalForkId, file));
286
+ if (text == null)
287
+ continue;
288
+ const contentType = file.endsWith(".html") ? "text/html; charset=utf-8" : "application/json";
289
+ await storage.putText(storage.compositionForkWorkingKey(fork.id, file), text, contentType);
290
+ }
291
+ await writeForkManifest(storage, fork, { kind: "working" });
292
+ return fork.id;
293
+ }
294
+ function readCompositionDurationSeconds(html) {
295
+ try {
296
+ const { document } = parseHTML(html);
297
+ const root = document.querySelector("[data-composition-id]") ?? document.querySelector("[data-duration]");
298
+ const dur = Number(root?.getAttribute("data-duration"));
299
+ return Number.isFinite(dur) && dur > 0 ? dur : null;
300
+ }
301
+ catch {
302
+ return null;
303
+ }
304
+ }
305
+ // Run ONE full recaption: clone → read caption slots → AI rewrites each in place
306
+ // → persist. Creates the record up front (status "customizing") and drives it to
307
+ // "ready" or "failed".
308
+ export async function customizeTemplateForProduct(input) {
309
+ const { customerId, template, productDescription } = input;
310
+ const sourceTitle = template.title ?? template.id;
311
+ const availableProviders = await providers.listAvailableProvidersAsync(customerId);
312
+ if (!availableProviders.length)
313
+ throw new NoProviderKeyError();
314
+ const record = await serverlessRecords.createSwipeCustomization({
315
+ customerId,
316
+ templateId: template.id,
317
+ sourceTitle,
318
+ status: "customizing",
319
+ pipelineVersion: SWIPE_PIPELINE_VERSION
320
+ });
321
+ try {
322
+ if (!template.defaultForkId)
323
+ throw new Error("Template is not decomposed (no default fork).");
324
+ const forkId = await cloneDecomposedFork({ customerId, template, canonicalForkId: template.defaultForkId, title: sourceTitle });
325
+ const context = loadContext(await storage.readText(storage.compositionForkWorkingKey(template.defaultForkId, "smart-decompose.json")));
326
+ const rawHtml = await storage.readText(storage.compositionForkWorkingKey(forkId, "composition.html"));
327
+ if (!rawHtml)
328
+ throw new Error("Cloned fork has no composition.html.");
329
+ // Sanitize the BASE document up front (before any LLM text lands in it):
330
+ // sanitizeCompositionHtml is raw-regex hardening, and run after the rewrite
331
+ // it can chew visible copy (e.g. a caption containing "hold on tight = ...").
332
+ // Text inserted below goes through linkedom textContent/setAttribute, which
333
+ // entity-escapes on serialization, so the persisted doc stays inert.
334
+ const { html } = sanitizeCompositionHtml(rawHtml);
335
+ const durationSeconds = readCompositionDurationSeconds(html) ?? 30;
336
+ // documentElement.outerHTML drops the doctype (it's a sibling node) — keep
337
+ // the preview iframe out of quirks mode by re-prepending it.
338
+ const doctype = /^\s*<!doctype/i.test(html) ? "<!DOCTYPE html>\n" : "";
339
+ const { document } = parseHTML(html);
340
+ stripAnimatedCaptionLayers(document);
341
+ // Only rewrite captions that carry WORDS in any script (\p{L} covers CJK,
342
+ // Cyrillic, Arabic, … — viral sources are not all English). Numeric/emoji-only
343
+ // captions (e.g. a "6,5,4,3,2,1" countdown overlay) are structural to the
344
+ // video's rhythm — the user wants that tempo/choreography kept — so leave
345
+ // them untouched.
346
+ const slots = collectCaptionSlots(document).filter((s) => /\p{L}/u.test(s.text));
347
+ const copy = await generateRecaption({
348
+ customerId,
349
+ jobId: record.id,
350
+ prompt: buildRecaptionPrompt({ productDescription, slots, context, sourceTitle })
351
+ });
352
+ // The prompt demands EXACTLY slots.length captions. json_object mode only
353
+ // guarantees valid JSON, not shape — a miscounted/empty/misshapen response
354
+ // must fail the record (retryable) instead of silently deleting caption
355
+ // layers below and shipping a caption-stripped card as "ready".
356
+ if (slots.length) {
357
+ const usable = copy.captions.filter(Boolean).length;
358
+ if (copy.captions.length !== slots.length || usable < Math.min(slots.length, MIN_CAPTION_SLOTS)) {
359
+ throw new Error(`LLM recaption shape mismatch: expected ${slots.length} captions, got ${usable} usable of ${copy.captions.length} returned.`);
360
+ }
361
+ }
362
+ let styleId = "recaption";
363
+ if (slots.length) {
364
+ // Rewrite each caption's TEXT in place — timing/position/style untouched.
365
+ slots.forEach((slot, i) => {
366
+ const next = copy.captions[i];
367
+ if (next)
368
+ setCaptionSlotText(slot.el, next);
369
+ else
370
+ slot.el.parentNode?.removeChild(slot.el); // no replacement → drop rather than keep off-product text
371
+ });
372
+ const serialized = doctype + document.documentElement.outerHTML;
373
+ await storage.putText(storage.compositionForkWorkingKey(forkId, "composition.html"), serialized, "text/html; charset=utf-8");
374
+ }
375
+ else {
376
+ // No word-bearing caption slots (post-\p{L} this means genuinely word-free:
377
+ // countdown/emoji overlays only) — page the social caption into fresh
378
+ // caption layers instead. Serialize from the parsed document so the
379
+ // animated-layer strip above is kept; the numeric/emoji statics stay (they
380
+ // are rhythm, and carry no words to conflict with the new copy).
381
+ const cues = cuesFromText(copy.socialCaption || copy.title, 0, durationSeconds, { maxWordsPerCue: 4 });
382
+ const applied = insertCaptionLayers(doctype + document.documentElement.outerHTML, cues, { style: DEFAULT_CAPTION_STYLE });
383
+ styleId = applied.styleId;
384
+ await storage.putText(storage.compositionForkWorkingKey(forkId, "composition.html"), applied.html, "text/html; charset=utf-8");
385
+ }
386
+ const updatedFork = await serverlessRecords.updateCompositionFork({ forkId, patch: {} });
387
+ if (updatedFork)
388
+ await writeForkManifest(storage, updatedFork, { kind: "working" });
389
+ const previewSourceUrl = input.sourceUrl ?? readCompositionSourceUrl(html);
390
+ return await serverlessRecords.updateSwipeCustomization({
391
+ customerId,
392
+ id: record.id,
393
+ patch: {
394
+ forkId,
395
+ status: "ready",
396
+ tailoredTitle: copy.title,
397
+ tailoredCaption: copy.socialCaption,
398
+ tailoredPinnedComment: copy.pinnedComment,
399
+ captionStyleId: styleId,
400
+ previewSourceUrl,
401
+ durationSeconds,
402
+ jobId: record.id
403
+ }
404
+ });
405
+ }
406
+ catch (error) {
407
+ const message = error instanceof Error ? error.message : String(error);
408
+ const failed = await serverlessRecords.updateSwipeCustomization({
409
+ customerId,
410
+ id: record.id,
411
+ patch: { status: "failed", error: message }
412
+ });
413
+ if (error instanceof NoProviderKeyError)
414
+ throw error;
415
+ return failed ?? { ...record, status: "failed", error: message };
416
+ }
417
+ }
418
+ export async function customizeOneMore(input) {
419
+ const eligible = await listEligibleTemplates(input.customerId, 1);
420
+ const pick = eligible[0];
421
+ if (!pick)
422
+ return null;
423
+ return customizeTemplateForProduct({
424
+ customerId: input.customerId,
425
+ template: pick.template,
426
+ sourceUrl: pick.sourceUrl,
427
+ productDescription: input.productDescription
428
+ });
429
+ }
430
+ export async function countReadyCustomizations(customerId) {
431
+ const rows = await serverlessRecords.listSwipeCustomizationsForCustomer(customerId);
432
+ return rows.filter((row) => row.status === "ready" && !isStaleSwipeRow(row)).length;
433
+ }
434
+ //# sourceMappingURL=swipe-customize.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
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": {