@mevdragon/vidfarm-devcli 0.20.6 → 0.20.9

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 (157) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +15 -1
  2. package/.agents/skills/vidfarm-director/SKILL.md +106 -0
  3. package/.agents/skills/vidfarm-director/recipes/find-and-fork-template.md +16 -0
  4. package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +13 -0
  5. package/.agents/skills/vidfarm-director/recipes/onboard-a-new-director.md +13 -0
  6. package/.agents/skills/vidfarm-director/recipes/retheme-template.md +17 -0
  7. package/.agents/skills/vidfarm-director/references/assets-and-sourcing.md +117 -0
  8. package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +245 -0
  9. package/.agents/skills/vidfarm-director/references/core-workflows.md +273 -0
  10. package/.agents/skills/vidfarm-director/references/editor-workflows.md +365 -0
  11. package/.agents/skills/vidfarm-director/references/onboarding.md +28 -0
  12. package/.agents/skills/vidfarm-director/references/primitives.md +308 -0
  13. package/SKILL.director.md +517 -250
  14. package/dist/src/cli.js +563 -99
  15. package/dist/src/devcli/clips.js +29 -12
  16. package/dist/src/devcli/doctor.js +2 -2
  17. package/dist/src/devcli/local-backend.js +46 -2
  18. package/dist/src/services/clip-curation/cost.js +5 -1
  19. package/dist/src/services/clip-curation/gemini.js +14 -2
  20. package/dist/src/services/clip-curation/hunt.js +56 -0
  21. package/dist/src/services/clip-curation/index.js +1 -1
  22. package/dist/src/services/clip-curation/scan.js +5 -3
  23. package/package.json +30 -10
  24. package/SKILL.platform.md +0 -432
  25. package/demo/README.md +0 -28
  26. package/demo/dist/app.css +0 -1
  27. package/demo/dist/app.js +0 -1850
  28. package/demo/dist/chunks/chunk-DXB73IDG.js +0 -1
  29. package/demo/dist/chunks/chunk-S7OWAJDS.js +0 -36
  30. package/demo/dist/chunks/chunk-VTIBZ6AN.js +0 -1
  31. package/demo/dist/chunks/dist-ADSJKBVE.js +0 -332
  32. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +0 -1
  33. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +0 -425
  34. package/demo/dist/chunks/lib-XAQ37YOE.js +0 -1
  35. package/demo/dist/chunks/src-TJ2QYA4U.js +0 -207
  36. package/demo/dist/favicon.ico +0 -0
  37. package/demo/dist/icons/timeline/audio.svg +0 -7
  38. package/demo/dist/icons/timeline/captions.svg +0 -5
  39. package/demo/dist/icons/timeline/composition.svg +0 -12
  40. package/demo/dist/icons/timeline/image.svg +0 -18
  41. package/demo/dist/icons/timeline/music.svg +0 -10
  42. package/demo/dist/icons/timeline/text.svg +0 -3
  43. package/demo/dist/index.html +0 -15
  44. package/dist/src/account-pages-legacy.js +0 -9396
  45. package/dist/src/account-pages.js +0 -61
  46. package/dist/src/app.js +0 -21603
  47. package/dist/src/composition-runtime.js +0 -1053
  48. package/dist/src/config.js +0 -217
  49. package/dist/src/context.js +0 -447
  50. package/dist/src/dev-app-legacy.js +0 -739
  51. package/dist/src/dev-app.js +0 -6
  52. package/dist/src/devcli/migrate-local.js +0 -140
  53. package/dist/src/devcli/sync.js +0 -368
  54. package/dist/src/domain.js +0 -5
  55. package/dist/src/editor-chat-history.js +0 -82
  56. package/dist/src/editor-chat.js +0 -828
  57. package/dist/src/editor-dark-theme.js +0 -1128
  58. package/dist/src/frontend/debug.js +0 -71
  59. package/dist/src/frontend/discover-client.js +0 -130
  60. package/dist/src/frontend/discover-store.js +0 -23
  61. package/dist/src/frontend/file-directory.js +0 -1018
  62. package/dist/src/frontend/flockposter-cache-store.js +0 -124
  63. package/dist/src/frontend/homepage-client.js +0 -446
  64. package/dist/src/frontend/homepage-shared.js +0 -201
  65. package/dist/src/frontend/homepage-store.js +0 -66
  66. package/dist/src/frontend/homepage-view.js +0 -705
  67. package/dist/src/frontend/page-runtime-client.js +0 -132
  68. package/dist/src/frontend/page-runtime-store.js +0 -9
  69. package/dist/src/frontend/sentry.js +0 -42
  70. package/dist/src/frontend/template-editor-chat.js +0 -4181
  71. package/dist/src/help-page.js +0 -346
  72. package/dist/src/homepage.js +0 -1458
  73. package/dist/src/index.js +0 -16
  74. package/dist/src/instrument.js +0 -30
  75. package/dist/src/landing-page.js +0 -384
  76. package/dist/src/page-runtime.js +0 -2
  77. package/dist/src/page-shell.js +0 -1452
  78. package/dist/src/primitive-context.js +0 -416
  79. package/dist/src/primitive-registry.js +0 -3940
  80. package/dist/src/primitive-sdk.js +0 -4
  81. package/dist/src/primitives/hyperframes-media.js +0 -108
  82. package/dist/src/react-page-shell.js +0 -35
  83. package/dist/src/ready-post-schedule-component.js +0 -1540
  84. package/dist/src/registry.js +0 -296
  85. package/dist/src/reskin/agency-page.js +0 -299
  86. package/dist/src/reskin/calendar-page.js +0 -568
  87. package/dist/src/reskin/chat-page.js +0 -942
  88. package/dist/src/reskin/discover-page.js +0 -1788
  89. package/dist/src/reskin/document.js +0 -1587
  90. package/dist/src/reskin/help-page.js +0 -357
  91. package/dist/src/reskin/index-page.js +0 -62
  92. package/dist/src/reskin/inpaint-clipper-page.js +0 -890
  93. package/dist/src/reskin/inpaint-page.js +0 -2554
  94. package/dist/src/reskin/inpaint-video-page.js +0 -1339
  95. package/dist/src/reskin/job-runs-page.js +0 -477
  96. package/dist/src/reskin/library-page.js +0 -1634
  97. package/dist/src/reskin/login-page.js +0 -262
  98. package/dist/src/reskin/portfolio-page.js +0 -687
  99. package/dist/src/reskin/pricing-page.js +0 -390
  100. package/dist/src/reskin/settings-page.js +0 -732
  101. package/dist/src/reskin/theme.js +0 -711
  102. package/dist/src/runtime.js +0 -35
  103. package/dist/src/services/api-call-history.js +0 -249
  104. package/dist/src/services/auth.js +0 -152
  105. package/dist/src/services/billing-pricing.js +0 -39
  106. package/dist/src/services/billing.js +0 -241
  107. package/dist/src/services/cast.js +0 -127
  108. package/dist/src/services/chat-threads.js +0 -92
  109. package/dist/src/services/clip-records.js +0 -250
  110. package/dist/src/services/clip-search.js +0 -77
  111. package/dist/src/services/clip-vectors.js +0 -125
  112. package/dist/src/services/composition-sanitize.js +0 -124
  113. package/dist/src/services/composition-watch.js +0 -79
  114. package/dist/src/services/elevenlabs.js +0 -222
  115. package/dist/src/services/file-directory.js +0 -117
  116. package/dist/src/services/fork-access.js +0 -93
  117. package/dist/src/services/fork-manifest.js +0 -42
  118. package/dist/src/services/ghostcut.js +0 -179
  119. package/dist/src/services/hyperframes.js +0 -3654
  120. package/dist/src/services/job-capacity.js +0 -14
  121. package/dist/src/services/job-logs.js +0 -197
  122. package/dist/src/services/jobs.js +0 -136
  123. package/dist/src/services/local-dynamo.js +0 -0
  124. package/dist/src/services/media-processing.js +0 -766
  125. package/dist/src/services/primitive-media-lambda.js +0 -280
  126. package/dist/src/services/providers.js +0 -2748
  127. package/dist/src/services/rate-limits.js +0 -262
  128. package/dist/src/services/scene-annotations.js +0 -32
  129. package/dist/src/services/serverless-auth.js +0 -382
  130. package/dist/src/services/serverless-jobs.js +0 -1084
  131. package/dist/src/services/serverless-provider-keys.js +0 -409
  132. package/dist/src/services/serverless-records.js +0 -1515
  133. package/dist/src/services/serverless-template-configs.js +0 -75
  134. package/dist/src/services/storage.js +0 -461
  135. package/dist/src/services/swipe-customize.js +0 -437
  136. package/dist/src/services/template-certification.js +0 -413
  137. package/dist/src/services/template-loader.js +0 -99
  138. package/dist/src/services/template-runtime-bundles.js +0 -217
  139. package/dist/src/services/template-sources.js +0 -1017
  140. package/dist/src/services/upstream.js +0 -248
  141. package/dist/src/services/video-normalization.js +0 -2
  142. package/dist/src/services/webhooks.js +0 -62
  143. package/dist/src/template-editor-pages.js +0 -2576
  144. package/dist/src/template-editor-shell.js +0 -2893
  145. package/dist/src/template-sdk.js +0 -4
  146. package/dist/src/worker.js +0 -17
  147. package/public/assets/discover-client-app.js +0 -1
  148. package/public/assets/file-directory-app.js +0 -3
  149. package/public/assets/homepage-app.js +0 -54
  150. package/public/assets/homepage-client-app.js +0 -80
  151. package/public/assets/page-runtime-client-app.js +0 -94
  152. package/public/assets/placeholders/scene-placeholder.png +0 -0
  153. package/src/assets/SELLING_AWARENESS_STAGES.md +0 -579
  154. package/src/assets/SELLING_WITH_HOOKS.md +0 -377
  155. package/src/assets/SELLING_WITH_VSLS.md +0 -606
  156. package/src/assets/favicon.ico +0 -0
  157. package/src/assets/logo-vidfarm.png +0 -0
@@ -1,437 +0,0 @@
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
- // Text generation only — the audio-only elevenlabs provider never appears here.
70
- const TEXT_MODEL_DEFAULTS = {
71
- openai: "gpt-5.4-mini",
72
- gemini: "gemini-2.5-flash",
73
- openrouter: "qwen/qwen3.6-plus",
74
- perplexity: "sonar"
75
- };
76
- const TEXT_PROVIDER_PRIORITY = ["openai", "gemini", "openrouter", "perplexity"];
77
- export class NoProviderKeyError extends Error {
78
- constructor() {
79
- super("Swipe customization needs an AI provider key. Add an OpenAI, Gemini, or OpenRouter key in Settings.");
80
- this.name = "NoProviderKeyError";
81
- }
82
- }
83
- // ---------------------------------------------------------------------------
84
- // Eligibility: decomposed (defaultForkId), GhostCut-clean (composition plays a
85
- // `-no-subs` mirror so there's no baked text to clash with), HAS caption slots
86
- // to re-caption, and not already customized for this customer.
87
- const MIRROR_MARKER = /-no-subs|\/ghostcut\//i;
88
- // Count static caption elements ([data-layer-kind="caption"] that are not the
89
- // animated word-by-word kind) via cheap regex — canonical forks only carry the
90
- // original static captions, so this is the recaption slot count. Style blocks
91
- // are dropped first: caption-animation CSS contains `[data-caption-animation=`
92
- // selectors that would otherwise inflate the animated count.
93
- export function countStaticCaptionsInHtml(html) {
94
- const markup = html.replace(/<style[\s\S]*?<\/style>/gi, "");
95
- const total = (markup.match(/data-layer-kind="caption"/g) ?? []).length;
96
- const animated = (markup.match(/data-caption-animation=/g) ?? []).length;
97
- return Math.max(0, total - animated);
98
- }
99
- async function readCanonicalEligibility(defaultForkId) {
100
- const html = await storage.readText(storage.compositionForkWorkingKey(defaultForkId, "composition.html"));
101
- if (!html)
102
- return { eligible: false, sourceUrl: null };
103
- const isMirror = MIRROR_MARKER.test(html);
104
- const sourceUrl = readCompositionSourceUrl(html);
105
- const captionSlots = countStaticCaptionsInHtml(html);
106
- return { eligible: isMirror && captionSlots >= MIN_CAPTION_SLOTS, sourceUrl };
107
- }
108
- export async function listEligibleTemplates(customerId, limit = SWIPE_STACK_TARGET_DEPTH * 4) {
109
- const feed = await serverlessRecords.listTemplatesFeed({ limit: ELIGIBILITY_SCAN_LIMIT });
110
- const listed = await serverlessRecords.listSwipeCustomizationsForCustomer(customerId);
111
- const existing = await reconcileSwipeRows(customerId, listed);
112
- // Superseded rows do NOT consume their template — the whole point is letting
113
- // the current pipeline re-customize what an older one botched.
114
- const alreadyCustomized = new Set(existing.filter((row) => row.status !== "superseded").map((row) => row.templateId));
115
- const eligible = [];
116
- for (const template of feed) {
117
- if (eligible.length >= limit)
118
- break;
119
- if (!template.defaultForkId)
120
- continue;
121
- if (alreadyCustomized.has(template.id))
122
- continue;
123
- const { eligible: ok, sourceUrl } = await readCanonicalEligibility(template.defaultForkId);
124
- if (!ok)
125
- continue;
126
- eligible.push({ template, sourceUrl });
127
- }
128
- return eligible;
129
- }
130
- function readCompositionSourceUrl(html) {
131
- try {
132
- const { document } = parseHTML(html);
133
- const root = document.querySelector("[data-composition-id]");
134
- const fromAttr = root?.getAttribute("data-source-video")?.trim();
135
- if (fromAttr && /^https?:\/\//i.test(fromAttr))
136
- return fromAttr;
137
- const backing = document.querySelector("[data-vf-playback-source]");
138
- const backingSrc = (backing?.getAttribute("src") ?? backing?.getAttribute("data-src"))?.trim();
139
- if (backingSrc && /^https?:\/\//i.test(backingSrc))
140
- return backingSrc;
141
- const video = Array.from(document.querySelectorAll("[data-layer-kind=\"video\"]"))
142
- .map((n) => n.getAttribute("src")?.trim())
143
- .find((src) => src && /^https?:\/\//i.test(src));
144
- return video ?? null;
145
- }
146
- catch {
147
- return null;
148
- }
149
- }
150
- // ---------------------------------------------------------------------------
151
- // Context (light — tone/theme only; the caption slots are the real input).
152
- function loadContext(smartDecomposeText) {
153
- if (!smartDecomposeText)
154
- return { transcript: "", viralDna: "" };
155
- try {
156
- const result = JSON.parse(smartDecomposeText)?.result ?? {};
157
- const transcript = typeof result?.transcript?.text === "string"
158
- ? result.transcript.text
159
- : Array.isArray(result?.transcript?.segments)
160
- ? result.transcript.segments.map((s) => (typeof s?.text === "string" ? s.text : "")).join(" ").trim()
161
- : "";
162
- const viralDna = typeof result?.viralDna === "string" ? result.viralDna : result?.viralDna ? JSON.stringify(result.viralDna) : "";
163
- return { transcript, viralDna };
164
- }
165
- catch {
166
- return { transcript: "", viralDna: "" };
167
- }
168
- }
169
- // Decompose emits static captions as <div data-layer-kind="caption" ...><span
170
- // data-vf-text-lines><span data-vf-text-inline style="…">words</span></span></div>
171
- // — the inline span carries the outline/stroke treatment, so write the new words
172
- // INTO it rather than flattening the element (`el.textContent =` on the div
173
- // would strip the treatment — the same trap /editor's set_layer_text hit).
174
- // data-label mirrors the words for tooling; keep it in sync.
175
- export function setCaptionSlotText(el, text) {
176
- const inline = typeof el.querySelector === "function" ? el.querySelector("[data-vf-text-inline]") : null;
177
- (inline ?? el).textContent = text;
178
- if (typeof el.getAttribute === "function" && el.getAttribute("data-label") != null) {
179
- el.setAttribute("data-label", text.slice(0, 40));
180
- }
181
- }
182
- // A canonical fork should only carry the original STATIC captions, but if an
183
- // editor session (or an older customize pipeline) left animated word-by-word
184
- // caption layers on it, the clone inherits words that fight the recaption —
185
- // drop them before rewriting. Returns how many were removed.
186
- export function stripAnimatedCaptionLayers(document) {
187
- const root = document.querySelector("[data-composition-id]") ?? document;
188
- const nodes = Array.from(root.querySelectorAll("[data-caption-animation]"));
189
- for (const node of nodes)
190
- node.parentNode?.removeChild(node);
191
- return nodes.length;
192
- }
193
- export function collectCaptionSlots(document) {
194
- const root = document.querySelector("[data-composition-id]") ?? document;
195
- return Array.from(root.querySelectorAll('[data-layer-kind="caption"]:not([data-caption-animation])'))
196
- .map((el) => ({
197
- el,
198
- text: String(el.textContent || "").trim().replace(/\s+/g, " "),
199
- start: Number(el.getAttribute("data-start")) || 0,
200
- duration: Number(el.getAttribute("data-duration")) || 0
201
- }))
202
- .filter((s) => s.text)
203
- .sort((a, b) => a.start - b.start);
204
- }
205
- function buildRecaptionPrompt(input) {
206
- const { productDescription, slots, context, sourceTitle } = input;
207
- const list = slots.map((s, i) => `${i + 1}. [${s.start.toFixed(1)}s-${(s.start + s.duration).toFixed(1)}s] "${s.text}"`).join("\n");
208
- 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.
209
-
210
- CUSTOMER PRODUCT / OFFER:
211
- ${productDescription.trim()}
212
-
213
- THE VIDEO
214
- - Original title: ${sourceTitle}
215
- - Why it went viral: ${context.viralDna || "(unknown)"}
216
- - Spoken/heard: ${(context.transcript || "(none)").slice(0, 800)}
217
-
218
- ORIGINAL ON-SCREEN CAPTIONS (in order, with timing):
219
- ${list}
220
-
221
- TASK
222
- Rewrite EACH caption above so the video becomes a native-feeling ad for the product. Rules:
223
- - Return EXACTLY ${slots.length} captions, in the SAME order, one replacement per original.
224
- - 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).
225
- - Sound like an authentic creator, not a corporate ad. No hashtags inside the on-screen captions.
226
-
227
- Return STRICTLY valid JSON, no markdown fences:
228
- {
229
- "title": "<=60 char scroll-stopping post title",
230
- "socialCaption": "1-2 sentence post caption (may include 1-3 hashtags)",
231
- "pinnedComment": "short first-comment CTA, or null",
232
- "captions": [ ${slots.map(() => '"..."').join(", ")} ]
233
- }`;
234
- }
235
- function parseJsonLoose(text) {
236
- const trimmed = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
237
- try {
238
- return JSON.parse(trimmed);
239
- }
240
- catch {
241
- const start = trimmed.indexOf("{");
242
- const end = trimmed.lastIndexOf("}");
243
- if (start >= 0 && end > start)
244
- return JSON.parse(trimmed.slice(start, end + 1));
245
- throw new Error("LLM did not return parseable JSON.");
246
- }
247
- }
248
- async function generateRecaption(input) {
249
- const available = await providers.listAvailableProvidersAsync(input.customerId);
250
- // Text generation only — never fall back to a non-text provider (e.g. the
251
- // audio-only elevenlabs key), which has no text model.
252
- const provider = TEXT_PROVIDER_PRIORITY.find((p) => available.includes(p));
253
- if (!provider)
254
- throw new NoProviderKeyError();
255
- const generated = await providers.generateText({
256
- customerId: input.customerId,
257
- jobId: input.jobId,
258
- workerId: "swipe-customize",
259
- provider,
260
- model: TEXT_MODEL_DEFAULTS[provider] ?? "gpt-5.4-mini",
261
- prompt: input.prompt,
262
- temperature: 0.7,
263
- responseFormat: "json"
264
- });
265
- const parsed = parseJsonLoose(generated.text);
266
- const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : "");
267
- const captions = Array.isArray(parsed.captions) ? parsed.captions.map((c) => str(c)) : [];
268
- return {
269
- title: str(parsed.title) || input.jobId,
270
- socialCaption: str(parsed.socialCaption) || str(parsed.caption),
271
- pinnedComment: str(parsed.pinnedComment) || null,
272
- captions
273
- };
274
- }
275
- // ---------------------------------------------------------------------------
276
- // Clone the canonical decomposed fork's working state into a private derivative.
277
- async function cloneDecomposedFork(input) {
278
- const canonical = await serverlessRecords.getCompositionFork(input.canonicalForkId);
279
- const fork = await serverlessRecords.createCompositionFork({
280
- customerId: input.customerId,
281
- templateId: input.template.id,
282
- parentForkId: input.canonicalForkId,
283
- parentVersion: canonical?.latestVersion ?? null,
284
- visibility: "private",
285
- title: input.title
286
- });
287
- for (const file of ["composition.html", "composition.json", "cast.json", "product-placement.json", "scene-annotations.json"]) {
288
- const text = await storage.readText(storage.compositionForkWorkingKey(input.canonicalForkId, file));
289
- if (text == null)
290
- continue;
291
- const contentType = file.endsWith(".html") ? "text/html; charset=utf-8" : "application/json";
292
- await storage.putText(storage.compositionForkWorkingKey(fork.id, file), text, contentType);
293
- }
294
- await writeForkManifest(storage, fork, { kind: "working" });
295
- return fork.id;
296
- }
297
- function readCompositionDurationSeconds(html) {
298
- try {
299
- const { document } = parseHTML(html);
300
- const root = document.querySelector("[data-composition-id]") ?? document.querySelector("[data-duration]");
301
- const dur = Number(root?.getAttribute("data-duration"));
302
- return Number.isFinite(dur) && dur > 0 ? dur : null;
303
- }
304
- catch {
305
- return null;
306
- }
307
- }
308
- // Run ONE full recaption: clone → read caption slots → AI rewrites each in place
309
- // → persist. Creates the record up front (status "customizing") and drives it to
310
- // "ready" or "failed".
311
- export async function customizeTemplateForProduct(input) {
312
- const { customerId, template, productDescription } = input;
313
- const sourceTitle = template.title ?? template.id;
314
- const availableProviders = await providers.listAvailableProvidersAsync(customerId);
315
- if (!availableProviders.length)
316
- throw new NoProviderKeyError();
317
- const record = await serverlessRecords.createSwipeCustomization({
318
- customerId,
319
- templateId: template.id,
320
- sourceTitle,
321
- status: "customizing",
322
- pipelineVersion: SWIPE_PIPELINE_VERSION
323
- });
324
- try {
325
- if (!template.defaultForkId)
326
- throw new Error("Template is not decomposed (no default fork).");
327
- const forkId = await cloneDecomposedFork({ customerId, template, canonicalForkId: template.defaultForkId, title: sourceTitle });
328
- const context = loadContext(await storage.readText(storage.compositionForkWorkingKey(template.defaultForkId, "smart-decompose.json")));
329
- const rawHtml = await storage.readText(storage.compositionForkWorkingKey(forkId, "composition.html"));
330
- if (!rawHtml)
331
- throw new Error("Cloned fork has no composition.html.");
332
- // Sanitize the BASE document up front (before any LLM text lands in it):
333
- // sanitizeCompositionHtml is raw-regex hardening, and run after the rewrite
334
- // it can chew visible copy (e.g. a caption containing "hold on tight = ...").
335
- // Text inserted below goes through linkedom textContent/setAttribute, which
336
- // entity-escapes on serialization, so the persisted doc stays inert.
337
- const { html } = sanitizeCompositionHtml(rawHtml);
338
- const durationSeconds = readCompositionDurationSeconds(html) ?? 30;
339
- // documentElement.outerHTML drops the doctype (it's a sibling node) — keep
340
- // the preview iframe out of quirks mode by re-prepending it.
341
- const doctype = /^\s*<!doctype/i.test(html) ? "<!DOCTYPE html>\n" : "";
342
- const { document } = parseHTML(html);
343
- stripAnimatedCaptionLayers(document);
344
- // Only rewrite captions that carry WORDS in any script (\p{L} covers CJK,
345
- // Cyrillic, Arabic, … — viral sources are not all English). Numeric/emoji-only
346
- // captions (e.g. a "6,5,4,3,2,1" countdown overlay) are structural to the
347
- // video's rhythm — the user wants that tempo/choreography kept — so leave
348
- // them untouched.
349
- const slots = collectCaptionSlots(document).filter((s) => /\p{L}/u.test(s.text));
350
- const copy = await generateRecaption({
351
- customerId,
352
- jobId: record.id,
353
- prompt: buildRecaptionPrompt({ productDescription, slots, context, sourceTitle })
354
- });
355
- // The prompt demands EXACTLY slots.length captions. json_object mode only
356
- // guarantees valid JSON, not shape — a miscounted/empty/misshapen response
357
- // must fail the record (retryable) instead of silently deleting caption
358
- // layers below and shipping a caption-stripped card as "ready".
359
- if (slots.length) {
360
- const usable = copy.captions.filter(Boolean).length;
361
- if (copy.captions.length !== slots.length || usable < Math.min(slots.length, MIN_CAPTION_SLOTS)) {
362
- throw new Error(`LLM recaption shape mismatch: expected ${slots.length} captions, got ${usable} usable of ${copy.captions.length} returned.`);
363
- }
364
- }
365
- let styleId = "recaption";
366
- if (slots.length) {
367
- // Rewrite each caption's TEXT in place — timing/position/style untouched.
368
- slots.forEach((slot, i) => {
369
- const next = copy.captions[i];
370
- if (next)
371
- setCaptionSlotText(slot.el, next);
372
- else
373
- slot.el.parentNode?.removeChild(slot.el); // no replacement → drop rather than keep off-product text
374
- });
375
- const serialized = doctype + document.documentElement.outerHTML;
376
- await storage.putText(storage.compositionForkWorkingKey(forkId, "composition.html"), serialized, "text/html; charset=utf-8");
377
- }
378
- else {
379
- // No word-bearing caption slots (post-\p{L} this means genuinely word-free:
380
- // countdown/emoji overlays only) — page the social caption into fresh
381
- // caption layers instead. Serialize from the parsed document so the
382
- // animated-layer strip above is kept; the numeric/emoji statics stay (they
383
- // are rhythm, and carry no words to conflict with the new copy).
384
- const cues = cuesFromText(copy.socialCaption || copy.title, 0, durationSeconds, { maxWordsPerCue: 4 });
385
- const applied = insertCaptionLayers(doctype + document.documentElement.outerHTML, cues, { style: DEFAULT_CAPTION_STYLE });
386
- styleId = applied.styleId;
387
- await storage.putText(storage.compositionForkWorkingKey(forkId, "composition.html"), applied.html, "text/html; charset=utf-8");
388
- }
389
- const updatedFork = await serverlessRecords.updateCompositionFork({ forkId, patch: {} });
390
- if (updatedFork)
391
- await writeForkManifest(storage, updatedFork, { kind: "working" });
392
- const previewSourceUrl = input.sourceUrl ?? readCompositionSourceUrl(html);
393
- return await serverlessRecords.updateSwipeCustomization({
394
- customerId,
395
- id: record.id,
396
- patch: {
397
- forkId,
398
- status: "ready",
399
- tailoredTitle: copy.title,
400
- tailoredCaption: copy.socialCaption,
401
- tailoredPinnedComment: copy.pinnedComment,
402
- captionStyleId: styleId,
403
- previewSourceUrl,
404
- durationSeconds,
405
- jobId: record.id
406
- }
407
- });
408
- }
409
- catch (error) {
410
- const message = error instanceof Error ? error.message : String(error);
411
- const failed = await serverlessRecords.updateSwipeCustomization({
412
- customerId,
413
- id: record.id,
414
- patch: { status: "failed", error: message }
415
- });
416
- if (error instanceof NoProviderKeyError)
417
- throw error;
418
- return failed ?? { ...record, status: "failed", error: message };
419
- }
420
- }
421
- export async function customizeOneMore(input) {
422
- const eligible = await listEligibleTemplates(input.customerId, 1);
423
- const pick = eligible[0];
424
- if (!pick)
425
- return null;
426
- return customizeTemplateForProduct({
427
- customerId: input.customerId,
428
- template: pick.template,
429
- sourceUrl: pick.sourceUrl,
430
- productDescription: input.productDescription
431
- });
432
- }
433
- export async function countReadyCustomizations(customerId) {
434
- const rows = await serverlessRecords.listSwipeCustomizationsForCustomer(customerId);
435
- return rows.filter((row) => row.status === "ready" && !isStaleSwipeRow(row)).length;
436
- }
437
- //# sourceMappingURL=swipe-customize.js.map