@mevdragon/vidfarm-devcli 0.15.0 → 0.17.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.
- package/.agents/skills/farmville-saas-ux/SKILL.md +156 -0
- package/.agents/skills/farmville-saas-ux/assets/starter.html +294 -0
- package/.agents/skills/farmville-saas-ux/references/components.md +340 -0
- package/.agents/skills/farmville-saas-ux/references/porting-guide.md +121 -0
- package/.agents/skills/farmville-saas-ux/references/tokens.md +271 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -1
- package/.agents/skills/vidfarm-media/references/tts.md +20 -1
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +74 -74
- package/dist/src/account-pages-legacy.js +2 -2
- package/dist/src/app.js +682 -12
- package/dist/src/config.js +13 -0
- package/dist/src/editor-chat.js +3 -2
- package/dist/src/frontend/homepage-client.js +211 -2
- package/dist/src/frontend/homepage-shared.js +197 -0
- package/dist/src/frontend/homepage-store.js +39 -1
- package/dist/src/frontend/homepage-view.js +150 -5
- package/dist/src/homepage.js +234 -18
- package/dist/src/landing-page.js +367 -0
- package/dist/src/primitive-registry.js +278 -2
- package/dist/src/reskin/agency-page.js +255 -0
- package/dist/src/reskin/calendar-page.js +252 -0
- package/dist/src/reskin/chat-page.js +414 -0
- package/dist/src/reskin/discover-page.js +380 -0
- package/dist/src/reskin/document.js +74 -0
- package/dist/src/reskin/help-page.js +318 -0
- package/dist/src/reskin/index-page.js +62 -0
- package/dist/src/reskin/job-runs-page.js +249 -0
- package/dist/src/reskin/library-page.js +515 -0
- package/dist/src/reskin/login-page.js +225 -0
- package/dist/src/reskin/pricing-page.js +359 -0
- package/dist/src/reskin/settings-page.js +353 -0
- package/dist/src/reskin/theme.js +265 -0
- package/dist/src/services/serverless-records.js +63 -0
- package/dist/src/services/swipe-customize.js +434 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +23 -23
|
@@ -591,6 +591,15 @@ class ServerlessRecordsServiceImpl {
|
|
|
591
591
|
// so the coarse keyword filter never fights the TTL cache.
|
|
592
592
|
return filterAndRankBySearch(this.templatesFeedCache.records, input.query).slice(0, limit);
|
|
593
593
|
}
|
|
594
|
+
// Curated "Popular" picks: the handful of public templates flagged with a
|
|
595
|
+
// popularGroup. Drawn from the same cached feed as listTemplatesFeed (the set
|
|
596
|
+
// is tiny), ordered by popularRank so the editorial sequence is preserved.
|
|
597
|
+
async listPopularTemplates() {
|
|
598
|
+
const feed = await this.listTemplatesFeed({ limit: ServerlessRecordsServiceImpl.TEMPLATES_FEED_MAX });
|
|
599
|
+
return feed
|
|
600
|
+
.filter((record) => typeof record.popularGroup === "string" && record.popularGroup.trim().length > 0)
|
|
601
|
+
.sort((a, b) => (a.popularRank ?? 0) - (b.popularRank ?? 0));
|
|
602
|
+
}
|
|
594
603
|
async queryTemplatesFeed() {
|
|
595
604
|
const response = await dynamodb.send(new QueryCommand({
|
|
596
605
|
TableName: requireMainTableName(),
|
|
@@ -645,6 +654,16 @@ class ServerlessRecordsServiceImpl {
|
|
|
645
654
|
const updated = await this.putRecord("ready_post", { ...post, status: "deleted", deletedAt: nowIso(), updatedAt: nowIso() });
|
|
646
655
|
return updated;
|
|
647
656
|
}
|
|
657
|
+
async setReadyPostMedia(input) {
|
|
658
|
+
const post = await this.getReadyPost(input.postId);
|
|
659
|
+
if (!post || post.customerId !== input.customerId)
|
|
660
|
+
return null;
|
|
661
|
+
return this.putRecord("ready_post", {
|
|
662
|
+
...post,
|
|
663
|
+
mediaAssets: input.mediaAssets,
|
|
664
|
+
updatedAt: nowIso()
|
|
665
|
+
});
|
|
666
|
+
}
|
|
648
667
|
async setReadyPostZip(input) {
|
|
649
668
|
const post = await this.getReadyPost(input.postId);
|
|
650
669
|
if (!post || post.customerId !== input.customerId)
|
|
@@ -656,6 +675,50 @@ class ServerlessRecordsServiceImpl {
|
|
|
656
675
|
updatedAt: nowIso()
|
|
657
676
|
});
|
|
658
677
|
}
|
|
678
|
+
async createSwipeCustomization(input) {
|
|
679
|
+
const timestamp = nowIso();
|
|
680
|
+
return this.putRecord("swipe_customization", {
|
|
681
|
+
id: input.id ?? createIdV7("swipe"),
|
|
682
|
+
customerId: input.customerId,
|
|
683
|
+
templateId: input.templateId,
|
|
684
|
+
forkId: input.forkId ?? null,
|
|
685
|
+
sourceTitle: input.sourceTitle ?? null,
|
|
686
|
+
status: input.status ?? "customizing",
|
|
687
|
+
pipelineVersion: input.pipelineVersion ?? null,
|
|
688
|
+
tailoredTitle: input.tailoredTitle ?? null,
|
|
689
|
+
tailoredCaption: input.tailoredCaption ?? "",
|
|
690
|
+
tailoredPinnedComment: input.tailoredPinnedComment ?? null,
|
|
691
|
+
captionStyleId: input.captionStyleId ?? null,
|
|
692
|
+
previewSourceUrl: input.previewSourceUrl ?? null,
|
|
693
|
+
durationSeconds: input.durationSeconds ?? null,
|
|
694
|
+
aspect: input.aspect ?? null,
|
|
695
|
+
jobId: input.jobId ?? null,
|
|
696
|
+
renderJobId: input.renderJobId ?? null,
|
|
697
|
+
readyPostId: input.readyPostId ?? null,
|
|
698
|
+
error: input.error ?? null,
|
|
699
|
+
decidedAt: input.decidedAt ?? null,
|
|
700
|
+
createdAt: input.createdAt ?? timestamp,
|
|
701
|
+
updatedAt: timestamp
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
async getSwipeCustomization(id) {
|
|
705
|
+
return this.getById("swipe_customization", id);
|
|
706
|
+
}
|
|
707
|
+
async listSwipeCustomizationsForCustomer(customerId) {
|
|
708
|
+
return this.listByCustomer("swipe_customization", customerId);
|
|
709
|
+
}
|
|
710
|
+
async updateSwipeCustomization(input) {
|
|
711
|
+
const existing = await this.getSwipeCustomization(input.id);
|
|
712
|
+
if (!existing || existing.customerId !== input.customerId)
|
|
713
|
+
return null;
|
|
714
|
+
return this.putRecord("swipe_customization", {
|
|
715
|
+
...existing,
|
|
716
|
+
...input.patch,
|
|
717
|
+
id: existing.id,
|
|
718
|
+
customerId: existing.customerId,
|
|
719
|
+
updatedAt: nowIso()
|
|
720
|
+
});
|
|
721
|
+
}
|
|
659
722
|
async createPostSchedule(input) {
|
|
660
723
|
const timestamp = nowIso();
|
|
661
724
|
return this.putRecord("post_schedule", {
|
|
@@ -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.
|
|
3
|
+
"version": "0.17.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": {
|