@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.
Files changed (37) 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 +2 -1
  7. package/.agents/skills/vidfarm-media/references/tts.md +20 -1
  8. package/demo/dist/app.css +1 -1
  9. package/demo/dist/app.js +74 -74
  10. package/dist/src/account-pages-legacy.js +2 -2
  11. package/dist/src/app.js +682 -12
  12. package/dist/src/config.js +13 -0
  13. package/dist/src/editor-chat.js +3 -2
  14. package/dist/src/frontend/homepage-client.js +211 -2
  15. package/dist/src/frontend/homepage-shared.js +197 -0
  16. package/dist/src/frontend/homepage-store.js +39 -1
  17. package/dist/src/frontend/homepage-view.js +150 -5
  18. package/dist/src/homepage.js +234 -18
  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 +255 -0
  22. package/dist/src/reskin/calendar-page.js +252 -0
  23. package/dist/src/reskin/chat-page.js +414 -0
  24. package/dist/src/reskin/discover-page.js +380 -0
  25. package/dist/src/reskin/document.js +74 -0
  26. package/dist/src/reskin/help-page.js +318 -0
  27. package/dist/src/reskin/index-page.js +62 -0
  28. package/dist/src/reskin/job-runs-page.js +249 -0
  29. package/dist/src/reskin/library-page.js +515 -0
  30. package/dist/src/reskin/login-page.js +225 -0
  31. package/dist/src/reskin/pricing-page.js +359 -0
  32. package/dist/src/reskin/settings-page.js +353 -0
  33. package/dist/src/reskin/theme.js +265 -0
  34. package/dist/src/services/serverless-records.js +63 -0
  35. package/dist/src/services/swipe-customize.js +434 -0
  36. package/package.json +1 -1
  37. package/public/assets/homepage-client-app.js +23 -23
package/dist/src/app.js CHANGED
@@ -17,11 +17,25 @@ import { config } from "./config.js";
17
17
  import { recordMatchesSearchQuery, serverlessRecords } from "./services/serverless-records.js";
18
18
  import { renderDevApp } from "./dev-app.js";
19
19
  import { CUSTOMER_PLAN_TIERS } from "./domain.js";
20
+ import { SWIPE_STACK_TARGET_DEPTH, customizeOneMore, reconcileSwipeRows, NoProviderKeyError } from "./services/swipe-customize.js";
20
21
  import { sanitizeChatPayload, sanitizeHttpExchange } from "./editor-chat-history.js";
21
22
  import { assertForkAccess, resolveForkAccess, ForkAccessDeniedError, ForkNotFoundError } from "./services/fork-access.js";
22
23
  import { writeForkManifest } from "./services/fork-manifest.js";
23
24
  import { renderHelpPage } from "./help-page.js";
24
25
  import { renderHomepage } from "./homepage.js";
26
+ import { renderLandingPage } from "./landing-page.js";
27
+ import { renderReskinIndex } from "./reskin/index-page.js";
28
+ import { renderReskinSettings } from "./reskin/settings-page.js";
29
+ import { renderReskinLibrary } from "./reskin/library-page.js";
30
+ import { renderReskinDiscover } from "./reskin/discover-page.js";
31
+ import { renderReskinChat } from "./reskin/chat-page.js";
32
+ import { renderReskinCalendar } from "./reskin/calendar-page.js";
33
+ import { renderReskinJobRuns } from "./reskin/job-runs-page.js";
34
+ import { renderReskinAgency } from "./reskin/agency-page.js";
35
+ import { renderReskinPricing } from "./reskin/pricing-page.js";
36
+ import { renderReskinLogin } from "./reskin/login-page.js";
37
+ import { renderReskinHelp } from "./reskin/help-page.js";
38
+ import { POPULAR_INSPIRATION_GROUPS } from "./frontend/homepage-shared.js";
25
39
  import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
26
40
  import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER, upgradeLegacyTransitionCss } from "./hyperframes/composition.js";
27
41
  import { normalizeCompositionZIndexHtml, sanitizeCompositionHtml } from "./services/composition-sanitize.js";
@@ -1658,6 +1672,9 @@ function primitiveOperationPathForJob(job) {
1658
1672
  if (job.templateId === "primitive:audio_normalize" && job.operationName === "run") {
1659
1673
  return `${PRIMITIVES_PREFIX}/audio/normalize`;
1660
1674
  }
1675
+ if (job.templateId === "primitive:speech_regenerate" && job.operationName === "run") {
1676
+ return `${PRIMITIVES_PREFIX}/audio/regenerate-speech`;
1677
+ }
1661
1678
  if (job.templateId === "primitive:brainstorm_hooks" && job.operationName === "run") {
1662
1679
  return `${PRIMITIVES_PREFIX}/brainstorm/hooks`;
1663
1680
  }
@@ -3146,6 +3163,7 @@ function buildPrimitiveEditorDocsRoutes() {
3146
3163
  { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/speech`, summary: "Primitive text-to-speech (TTS) job using saved OpenAI, Gemini, or OpenRouter provider keys. Body must be { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? }, webhook_url? }. instructions is a free-form voice-style prompt (tone, pacing, accent, emotion, persona — e.g. \"calm, warm bedtime narrator with a slight British accent\"). voice is a provider preset (OpenAI: alloy/ash/coral/…; Gemini: Kore/Puck/Charon/…); provider is inferred from the voice when omitted, and a voice paired with the wrong provider is rejected with a 400. Returns a durable platform audio URL (mp3 or wav). Alias: POST /api/v1/primitives/tts." },
3147
3164
  { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/transcribe`, summary: "Primitive speech-to-text (STT) job using saved provider keys. Accepts VIDEO or AUDIO sources; video audio is demuxed automatically. Body must be { tracer, payload: { source_url (aliases: source_video_url, source_audio_url, url), diarize?, language?, prompt?, provider?, model? }, webhook_url? }. Returns TWO formats in one job: a simple subtitle transcript (plain text + timed SRT cues, no speakers) and an advanced multi-speaker version (speaker-attributed timed segments for multi-narration), plus transcript.json/.srt/.txt artifacts. diarize defaults to true; speaker attribution needs a Gemini key (openai/openrouter degrade to a plain single-speaker transcript). Sources over ~40 minutes of speech must be trimmed first. Alias: POST /api/v1/primitives/stt. Pass word_timestamps: true for word-level timings on each segment (real timestamps on an OpenAI key via whisper-1; other providers stay segment-level)." },
3148
3165
  { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/captions`, summary: "ANIMATED CAPTIONS from speech: one job transcribes narration WITH word-level timings and pages it into word-by-word caption cues ready for the editor's set_captions action. Body must be { tracer, payload: { source_url (video or audio; video audio is demuxed) OR text (page a script instead of transcribing), style? (spotlight|karaoke|word-pop|stack-up|neon|bounce), animation?, active_color?, highlight_color?, uppercase?, color?, background?, background_style?, font_family?, font_size?, max_words_per_cue?, offset_sec?, language?, provider?, window_start?/window_duration? (text mode) }, webhook_url? }. The finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments — apply verbatim). Real word timestamps need an OpenAI key (whisper-1, tried first); gemini/openrouter fall back to estimated word windows. Alias: POST /api/v1/primitives/captions." },
3166
+ { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/regenerate-speech`, summary: "REGENERATE SPEECH IN THE ORIGINAL SPEAKER'S VOICE (approximate match, not a clone): one job listens to a video/audio source, profiles the speaker's voice (needs a saved Gemini key; other keys degrade to transcript-only with no voice match), rewords the transcript per your instruction (or takes an exact replacement script), and returns new TTS audio using the closest preset voice + a style prompt matched to the speaker. Body must be { tracer, payload: { source_url (video or audio; video audio is demuxed; aliases source_video_url/source_audio_url/url), rewrite_instruction? (how to reword, e.g. \"mention the summer sale instead of the discount code\") OR text? (exact new script — one of the two is required), voice? (explicit preset override), instructions? (extra style guidance), provider?, model?, language?, output_format? }, webhook_url? }. Result carries audioUrl (durable, place with add_layer), script, original_transcript, voice_profile, and voice_match: \"approximate\"|\"none\" — OpenAI/Gemini APIs cannot clone voice identity, so always tell the user the voice is a matched approximation. Alias: POST /api/v1/primitives/regenerate-speech." },
3149
3167
  { method: "POST", path: `${PRIMITIVES_PREFIX}/brainstorm/coldstart`, summary: "Primitive cold-start strategy questionnaire. Body should usually be { tracer, payload: { user_message }, webhook_url? }. user_message can be rough, like \"I don't know where to start\" or a messy description. count is optional only when the user explicitly wants more or fewer questions. offer_description remains accepted as a backward-compatible alias." },
3150
3168
  { method: "POST", path: `${PRIMITIVES_PREFIX}/brainstorm/awareness_stages`, summary: "Primitive awareness-stage recommendation. Body must be { tracer, payload: { offer_description }, webhook_url? }. Use this when the customer is unsure what type of ads to produce." },
3151
3169
  { method: "POST", path: `${PRIMITIVES_PREFIX}/brainstorm/angles`, summary: "Primitive ad-angle brainstorm. Body must be { tracer, payload: { offer_description, problem_awareness, solution_awareness }, webhook_url? }. Allowed values: problem_awareness is problem_unaware|problem_aware, solution_awareness is solution_unaware|solution_aware. count is optional only when the user explicitly wants more or fewer angles. Returns diverse persuasive TikTok angles with explanations." },
@@ -3744,7 +3762,7 @@ function normalizeOperationRequestTracer(tracer) {
3744
3762
  }
3745
3763
  function createTemplateHttpTool(input) {
3746
3764
  return tool({
3747
- description: "Call exactly one Vidfarm REST API route and inspect the response before deciding the next call. Use template routes for metadata, skill, config, operations, jobs, logs, and cancel routes. Use primitive routes under /api/v1/primitives for reusable image generation, image editing, inpainting, still rendering, social/media video download, AI video generation, video slide rendering, burned-in caption removal, video trim/extract/probe/mute/concat operations, audio trim/probe/concat/normalize operations, text-to-speech narration (audio/speech) and speech-to-text transcription (audio/transcribe) routes, brainstorm strategy routes, and primitive job inspection; these primitive routes are allowed directly from editor chat even when they are not specific to the current template. Primitive POST bodies must use { tracer, payload: { ...primitive fields... }, webhook_url? }; do not put prompt, source_image_url, instruction, slides, html, or other primitive fields at the top level. Never say primitive image generation, image edit, inpaint, HTML render, video download, AI video generation, caption removal, trim, probe, extract-audio, concat, mute, video render, text-to-speech, speech transcription, or brainstorm routes are unavailable here when the route exists. If the user asks to generate a new image similar to an attachment, call POST /api/v1/primitives/images/generate with body { tracer, payload: { prompt, prompt_attachments: [exact attachment URL] } }. If the user asks to revise the attached image itself, call POST /api/v1/primitives/images/edit with body { tracer, payload: { source_image_url, instruction } }. If the user asks to download a social/media post into a durable MP4, call POST /api/v1/primitives/videos/download with body { tracer, payload: { source_url|video_url|url, quality?: \"best\"|\"hd\"|\"full_hd\" } }. If the user asks to generate a new AI video, call POST /api/v1/primitives/videos/generate with body { tracer, payload: { prompt, provider?: \"openai\"|\"gemini\"|\"openrouter\", duration?: seconds, input_references?: [direct image asset URL], frame_images?: [{ image_url, frame_type }] } }; never send webpage or social post URLs in input_references. duration is seconds, not milliseconds, and duration_ms belongs only to /videos/render-slides slides. Default video models are openai/sora-2, gemini/veo-3.0-generate-001, and openrouter/bytedance/seedance-2.0. Use POST /api/v1/primitives/videos/render-slides only to assemble existing media URLs into an MP4 slideshow. Use POST /api/v1/primitives/media/dedupe to apply subtle dedupe transforms (zoom/tilt/rotate/saturation/speed/horizontal_flip/contrast/brightness/hue_rotate/blur/tint) to any image or video URL, with body { tracer, payload: { source_media_url, media_type?, effects?, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Use POST /api/v1/primitives/videos/trim for clipping, /videos/extract-audio for demuxing, /videos/probe or /audio/probe for metadata-only JSON outputs, /videos/extract-frame for stills, /videos/mute to remove audio, /videos/replace-audio to swap tracks, /videos/concat for sequential muted MP4 joins, /audio/trim for clipped audio, /audio/concat for stitched audio, and /audio/normalize for leveled/re-encoded audio. If the user asks for AI voiceover, narration, or dub audio from text (a script, hook, or caption read aloud), call POST /api/v1/primitives/audio/speech with body { tracer, payload: { text, voice?, instructions? } } — instructions is a free-form voice-style prompt like \"excited sports announcer, fast paced\" and the job returns a durable audio URL you can place with add_layer. If the user asks to transcribe a video or audio, get subtitles/captions from speech, or identify who says what, call POST /api/v1/primitives/audio/transcribe with body { tracer, payload: { source_url, diarize?, language? } } — it accepts video or audio URLs (video audio is demuxed automatically) and one job returns BOTH a simple subtitle transcript (plain text + timed SRT cues) and an advanced multi-speaker version (speaker-attributed timed segments for multi-narration); speaker attribution needs a Gemini key, and word_timestamps: true adds word-level timings per segment (real on an OpenAI key via whisper-1). If the user wants ANIMATED word-by-word captions generated from narration, call POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url (narration audio or video URL) OR text (a script to page), style?: \"spotlight\"|\"karaoke\"|\"word-pop\"|\"stack-up\"|\"neon\"|\"bounce\", max_words_per_cue?, offset_sec?, language?, provider? } } — the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments); apply them with the editor_action tool (action_type=set_captions) so the change flows through the editor state. Real word timestamps need an OpenAI key (whisper-1, tried first); gemini/openrouter estimate word windows. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video. Caption removal is capped at ~15 minutes of source and must NEVER be used on long-form video: when the user wants short clips out of a long video (a podcast, stream VOD, webinar, or any YouTube/TikTok/IG/X URL), call POST /clips/scan instead with body { source_url | temp_file_id | s3_key, prompt, ranges?: [\"MM:SS-MM:SS\"], target_duration_sec?, aspect?: \"9:16\"|\"16:9\"|\"4:3\"|\"1:1\", avoid_text?, provider?: \"gemini\"|\"openai\"|\"openrouter\", tracer? } — target_duration_sec is a SOFT range (30 → 20-40s), ranges restrict the hunt to those source windows (big cost saver on long videos), aspect crops every clip, and avoid_text prefers scenes WITHOUT on-screen text via scene SELECTION (never caption removal on the source). The hunt is async: it returns scan_id immediately; poll GET /clips/scan/:scanId, then browse results via GET /clips/feed?source=<source_video_id> or POST /clips/search. Hunts bill AWS compute only — the AI tagging runs on the user's own saved provider keys (auto-picked gemini → openai → openrouter unless body.provider names one). To remove captions from a finished HUNTED clip, pass that clip's short mp4 to /videos/remove-captions afterwards. If the user explicitly asks for hooks, angles, awareness-stage advice, or a cold-start strategy questionnaire, call the matching brainstorm route immediately instead of brainstorming in chat from memory. Use POST /api/v1/primitives/brainstorm/coldstart when the customer is starting from zero, /brainstorm/awareness_stages when they do not know what ad types to make, /brainstorm/angles when they want persuasive angles, and /brainstorm/hooks when they want many openings. Product placement is a first-class brainstorm primitive like angles and hooks: when the user wants to identify product-placement opportunities in a video or repurpose a video into a native ad for their product, call POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? } — it watches the video and returns timestamped native placement opportunities and prefers a Gemini key. You may also analyze an attached video directly to answer product-placement questions conversationally instead of the primitive when the user just wants a quick take. For brainstorm routes, do not send count by default; only include count when the user explicitly asks for a specific number of hooks, angles, questions, or opportunities. When brainstorm jobs finish and the response includes a JSON artifact, tell the user to open and review that JSON in the chat UI because it contains the structured output. When the user asks for content ideas or says they do not know where to start, prefer the sequence coldstart -> awareness_stages -> angles -> hooks, then use the output to choose templates. For template operation POST bodies, use top-level template_preview_media=\"auto\" by default, \"include\" when the user wants the template to use catalog preview media as references where supported, and \"exclude\" when the user asks not to use preview media as references. Templates decide which generation steps honor that policy. If a POST returns a queued job, do not poll it in this same response; continue with any remaining requested POST calls, then summarize the queued job ids and tracers. If you know a job_id but not the template or primitive, use GET /api/v1/user/me/jobs/:jobId or its /logs and /artifacts subroutes instead of paging through job lists. Use GET /api/v1/compositions/:forkId/remove-video-captions to look up both the original source video URL and the subtitle-removed mirrored URL for the current fork before feeding a video URL into a primitive route; this is a read-only, non-billing status check — never confuse it with POST /remove-video-captions-poll (which advances the fork's decompose-time subtitle-removal job and can bill). Use POST /api/v1/approved/posts with title, caption, pinned_comment, media URLs, and tracer to approve a finished output into a preview/share page. Use POST /api/v1/approved/posts/:postId/schedules with destination_type, destination_id, scheduled_at, timezone, and optional settings/additional_notes to schedule an approved post. When a rendered video was built from slideshow frame images, include both versions in one media array: final MP4 first with kind=\"video\" and role=\"primary\", then every finished captioned slide frame image in order with kind=\"image\" and role=\"slide\". Prefer create-slideshow result.renderVideoInput.slides[].imageUrl or result.slides[].frameImageUrl for carousel media; do not use backgroundImageUrl, manifests, thumbnails, or a partial files array as the full slide set. If the user supplies exact media file URLs for approval, use those URLs as the backup source of truth, preserve their order, infer kind when needed, and assign primary/video and slide/image roles correctly. If the user asks for N distinct generated outputs, make N sequential POST calls rather than one combined POST and do not stop after the first queued job.",
3765
+ description: "Call exactly one Vidfarm REST API route and inspect the response before deciding the next call. Use template routes for metadata, skill, config, operations, jobs, logs, and cancel routes. Use primitive routes under /api/v1/primitives for reusable image generation, image editing, inpainting, still rendering, social/media video download, AI video generation, video slide rendering, burned-in caption removal, video trim/extract/probe/mute/concat operations, audio trim/probe/concat/normalize operations, text-to-speech narration (audio/speech) and speech-to-text transcription (audio/transcribe) routes, brainstorm strategy routes, and primitive job inspection; these primitive routes are allowed directly from editor chat even when they are not specific to the current template. Primitive POST bodies must use { tracer, payload: { ...primitive fields... }, webhook_url? }; do not put prompt, source_image_url, instruction, slides, html, or other primitive fields at the top level. Never say primitive image generation, image edit, inpaint, HTML render, video download, AI video generation, caption removal, trim, probe, extract-audio, concat, mute, video render, text-to-speech, speech transcription, or brainstorm routes are unavailable here when the route exists. If the user asks to generate a new image similar to an attachment, call POST /api/v1/primitives/images/generate with body { tracer, payload: { prompt, prompt_attachments: [exact attachment URL] } }. If the user asks to revise the attached image itself, call POST /api/v1/primitives/images/edit with body { tracer, payload: { source_image_url, instruction } }. If the user asks to download a social/media post into a durable MP4, call POST /api/v1/primitives/videos/download with body { tracer, payload: { source_url|video_url|url, quality?: \"best\"|\"hd\"|\"full_hd\" } }. If the user asks to generate a new AI video, call POST /api/v1/primitives/videos/generate with body { tracer, payload: { prompt, provider?: \"openai\"|\"gemini\"|\"openrouter\", duration?: seconds, input_references?: [direct image asset URL], frame_images?: [{ image_url, frame_type }] } }; never send webpage or social post URLs in input_references. duration is seconds, not milliseconds, and duration_ms belongs only to /videos/render-slides slides. Default video models are openai/sora-2, gemini/veo-3.0-generate-001, and openrouter/bytedance/seedance-2.0. Use POST /api/v1/primitives/videos/render-slides only to assemble existing media URLs into an MP4 slideshow. Use POST /api/v1/primitives/media/dedupe to apply subtle dedupe transforms (zoom/tilt/rotate/saturation/speed/horizontal_flip/contrast/brightness/hue_rotate/blur/tint) to any image or video URL, with body { tracer, payload: { source_media_url, media_type?, effects?, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Use POST /api/v1/primitives/videos/trim for clipping, /videos/extract-audio for demuxing, /videos/probe or /audio/probe for metadata-only JSON outputs, /videos/extract-frame for stills, /videos/mute to remove audio, /videos/replace-audio to swap tracks, /videos/concat for sequential muted MP4 joins, /audio/trim for clipped audio, /audio/concat for stitched audio, and /audio/normalize for leveled/re-encoded audio. If the user asks for AI voiceover, narration, or dub audio from text (a script, hook, or caption read aloud), call POST /api/v1/primitives/audio/speech with body { tracer, payload: { text, voice?, instructions? } } — instructions is a free-form voice-style prompt like \"excited sports announcer, fast paced\" and the job returns a durable audio URL you can place with add_layer. If the user asks to transcribe a video or audio, get subtitles/captions from speech, or identify who says what, call POST /api/v1/primitives/audio/transcribe with body { tracer, payload: { source_url, diarize?, language? } } — it accepts video or audio URLs (video audio is demuxed automatically) and one job returns BOTH a simple subtitle transcript (plain text + timed SRT cues) and an advanced multi-speaker version (speaker-attributed timed segments for multi-narration); speaker attribution needs a Gemini key, and word_timestamps: true adds word-level timings per segment (real on an OpenAI key via whisper-1). If the user wants ANIMATED word-by-word captions generated from narration, call POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url (narration audio or video URL) OR text (a script to page), style?: \"spotlight\"|\"karaoke\"|\"word-pop\"|\"stack-up\"|\"neon\"|\"bounce\", max_words_per_cue?, offset_sec?, language?, provider? } } — the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments); apply them with the editor_action tool (action_type=set_captions) so the change flows through the editor state. Real word timestamps need an OpenAI key (whisper-1, tried first); gemini/openrouter estimate word windows. If the user wants the SAME speaker to say DIFFERENT words — reword, revoice, redub, or regenerate existing narration so it sounds like the original voice — call POST /api/v1/primitives/audio/regenerate-speech with body { tracer, payload: { source_url (video or audio; video audio is demuxed), rewrite_instruction (how to reword the original transcript) OR text (the exact new script), voice?, instructions?, provider?, language?, output_format? } } — the job listens to the source, profiles the speaker's voice (needs a saved Gemini key; other keys degrade to transcript-only with no voice match), rewords the script, and returns a durable audio URL spoken by the closest PRESET voice with a style prompt matched to the speaker. OpenAI/Gemini APIs cannot clone voice identity, so always present regenerate-speech output as an approximation of the original voice, never a clone; place the finished audio with editor_action add_layer, typically after muting the original span with /videos/mute or swapping tracks with /videos/replace-audio. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video. Caption removal is capped at ~15 minutes of source and must NEVER be used on long-form video: when the user wants short clips out of a long video (a podcast, stream VOD, webinar, or any YouTube/TikTok/IG/X URL), call POST /clips/scan instead with body { source_url | temp_file_id | s3_key, prompt, ranges?: [\"MM:SS-MM:SS\"], target_duration_sec?, aspect?: \"9:16\"|\"16:9\"|\"4:3\"|\"1:1\", avoid_text?, provider?: \"gemini\"|\"openai\"|\"openrouter\", tracer? } — target_duration_sec is a SOFT range (30 → 20-40s), ranges restrict the hunt to those source windows (big cost saver on long videos), aspect crops every clip, and avoid_text prefers scenes WITHOUT on-screen text via scene SELECTION (never caption removal on the source). The hunt is async: it returns scan_id immediately; poll GET /clips/scan/:scanId, then browse results via GET /clips/feed?source=<source_video_id> or POST /clips/search. Hunts bill AWS compute only — the AI tagging runs on the user's own saved provider keys (auto-picked gemini → openai → openrouter unless body.provider names one). To remove captions from a finished HUNTED clip, pass that clip's short mp4 to /videos/remove-captions afterwards. If the user explicitly asks for hooks, angles, awareness-stage advice, or a cold-start strategy questionnaire, call the matching brainstorm route immediately instead of brainstorming in chat from memory. Use POST /api/v1/primitives/brainstorm/coldstart when the customer is starting from zero, /brainstorm/awareness_stages when they do not know what ad types to make, /brainstorm/angles when they want persuasive angles, and /brainstorm/hooks when they want many openings. Product placement is a first-class brainstorm primitive like angles and hooks: when the user wants to identify product-placement opportunities in a video or repurpose a video into a native ad for their product, call POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? } — it watches the video and returns timestamped native placement opportunities and prefers a Gemini key. You may also analyze an attached video directly to answer product-placement questions conversationally instead of the primitive when the user just wants a quick take. For brainstorm routes, do not send count by default; only include count when the user explicitly asks for a specific number of hooks, angles, questions, or opportunities. When brainstorm jobs finish and the response includes a JSON artifact, tell the user to open and review that JSON in the chat UI because it contains the structured output. When the user asks for content ideas or says they do not know where to start, prefer the sequence coldstart -> awareness_stages -> angles -> hooks, then use the output to choose templates. For template operation POST bodies, use top-level template_preview_media=\"auto\" by default, \"include\" when the user wants the template to use catalog preview media as references where supported, and \"exclude\" when the user asks not to use preview media as references. Templates decide which generation steps honor that policy. If a POST returns a queued job, do not poll it in this same response; continue with any remaining requested POST calls, then summarize the queued job ids and tracers. If you know a job_id but not the template or primitive, use GET /api/v1/user/me/jobs/:jobId or its /logs and /artifacts subroutes instead of paging through job lists. Use GET /api/v1/compositions/:forkId/remove-video-captions to look up both the original source video URL and the subtitle-removed mirrored URL for the current fork before feeding a video URL into a primitive route; this is a read-only, non-billing status check — never confuse it with POST /remove-video-captions-poll (which advances the fork's decompose-time subtitle-removal job and can bill). Use POST /api/v1/approved/posts with title, caption, pinned_comment, media URLs, and tracer to approve a finished output into a preview/share page. Use POST /api/v1/approved/posts/:postId/schedules with destination_type, destination_id, scheduled_at, timezone, and optional settings/additional_notes to schedule an approved post. When a rendered video was built from slideshow frame images, include both versions in one media array: final MP4 first with kind=\"video\" and role=\"primary\", then every finished captioned slide frame image in order with kind=\"image\" and role=\"slide\". Prefer create-slideshow result.renderVideoInput.slides[].imageUrl or result.slides[].frameImageUrl for carousel media; do not use backgroundImageUrl, manifests, thumbnails, or a partial files array as the full slide set. If the user supplies exact media file URLs for approval, use those URLs as the backup source of truth, preserve their order, infer kind when needed, and assign primary/video and slide/image roles correctly. If the user asks for N distinct generated outputs, make N sequential POST calls rather than one combined POST and do not stop after the first queued job.",
3748
3766
  inputSchema: z.object({
3749
3767
  method: z.enum(["GET", "POST"]).describe("HTTP method to use."),
3750
3768
  path: z.string().min(1).describe("Absolute path or same-origin URL for an allowed Vidfarm REST route."),
@@ -4883,8 +4901,37 @@ function redirectToPublicPath(c, pathname) {
4883
4901
  return redirect(c, `${pathname}${url.search}`, 303);
4884
4902
  }
4885
4903
  app.get("/", async (c) => {
4886
- return redirect(c, "/discover");
4887
- });
4904
+ // vidfarm.cc landing page — standalone marketing page (own HTML/CSS in
4905
+ // src/landing-page.ts) using the farmville-saas-ux design system. No longer
4906
+ // redirects to /discover; the studio itself still lives at /discover.
4907
+ const customer = await getOptionalPreferredBrowserCustomer(c);
4908
+ return c.html(renderLandingPage({ isLoggedIn: Boolean(customer) }));
4909
+ });
4910
+ // /reskin/* — isolated design-system migration surface (see src/reskin/).
4911
+ // Each page is a standalone document with its own CSS; renders sample data so
4912
+ // it's viewable without auth. Ported pages are wired here one at a time.
4913
+ app.get("/reskin", (c) => c.html(renderReskinIndex()));
4914
+ app.get("/reskin/settings", (c) => c.html(renderReskinSettings()));
4915
+ // Library hosts two tabs at distinct URLs (approved projects + mined clips).
4916
+ app.get("/reskin/library", (c) => redirect(c, "/reskin/library/approved"));
4917
+ app.get("/reskin/library/approved", (c) => c.html(renderReskinLibrary("approved")));
4918
+ app.get("/reskin/library/clips", (c) => c.html(renderReskinLibrary("clips")));
4919
+ app.get("/reskin/clips", (c) => redirect(c, "/reskin/library/clips")); // merged into Library
4920
+ // Discover has three modes at distinct URLs (feed / swipe / categories).
4921
+ app.get("/reskin/discover", (c) => redirect(c, "/reskin/discover/feed"));
4922
+ app.get("/reskin/discover/:mode", (c) => {
4923
+ const mode = c.req.param("mode");
4924
+ if (mode !== "feed" && mode !== "swipe" && mode !== "categories")
4925
+ return c.notFound();
4926
+ return c.html(renderReskinDiscover(mode));
4927
+ });
4928
+ app.get("/reskin/chat", (c) => c.html(renderReskinChat()));
4929
+ app.get("/reskin/calendar", (c) => c.html(renderReskinCalendar()));
4930
+ app.get("/reskin/job-runs", (c) => c.html(renderReskinJobRuns()));
4931
+ app.get("/reskin/agency", (c) => c.html(renderReskinAgency()));
4932
+ app.get("/reskin/pricing", (c) => c.html(renderReskinPricing()));
4933
+ app.get("/reskin/login", (c) => c.html(renderReskinLogin()));
4934
+ app.get("/reskin/help", (c) => c.html(renderReskinHelp()));
4888
4935
  app.get("/discover", async (c) => renderApprovedHomepage(c));
4889
4936
  app.get("/editor/:templateId", async (c) => {
4890
4937
  const templateId = c.req.param("templateId");
@@ -4937,6 +4984,28 @@ app.get("/editor/:templateId", async (c) => {
4937
4984
  if (!forkRecord || forkRecord.deletedAt) {
4938
4985
  effectiveForkParam = null;
4939
4986
  }
4987
+ else if (customer) {
4988
+ // A caller who can VIEW but not EDIT this fork (e.g. a public template's
4989
+ // default fork opened via a shared deep link) must not be pinned onto it:
4990
+ // the editor would happily let them "edit", but every composition.html PUT
4991
+ // is 403'd and the change silently reverts on reload — the exact "the AI
4992
+ // said it changed the text but it didn't" failure. Drop the fork and fall
4993
+ // through to per-user resolution so they get (or mint) their own editable
4994
+ // copy instead.
4995
+ try {
4996
+ const access = await resolveForkAccess({
4997
+ forkId: effectiveForkParam,
4998
+ customerId: customer.id,
4999
+ shareToken: readShareToken(c) ?? undefined
5000
+ });
5001
+ if (!access.capabilities.canEdit) {
5002
+ effectiveForkParam = null;
5003
+ }
5004
+ }
5005
+ catch {
5006
+ effectiveForkParam = null;
5007
+ }
5008
+ }
4940
5009
  }
4941
5010
  if (!effectiveForkParam) {
4942
5011
  // Per-user default fork resolution: prefer caller's most-recently-updated
@@ -4953,8 +5022,14 @@ app.get("/editor/:templateId", async (c) => {
4953
5022
  if (mostRecent)
4954
5023
  targetForkId = mostRecent.id;
4955
5024
  }
4956
- if (!targetForkId)
5025
+ // Only fall back to the template's canonical default fork when the caller can
5026
+ // actually edit it — i.e. they own the template. A non-owner sent to the
5027
+ // default fork would land back on a read-only fork (and, since it's also the
5028
+ // page we just dropped, loop). Leaving targetForkId null routes them to the
5029
+ // bare editor, where the client mints their own editable fork on first save.
5030
+ if (!targetForkId && template.customerId === customer?.id) {
4957
5031
  targetForkId = template.defaultForkId;
5032
+ }
4958
5033
  if (targetForkId) {
4959
5034
  // Do NOT auto-attach a thread_id here: the caller's browser is the
4960
5035
  // source of truth for "which chat is active" via localStorage. Injecting
@@ -5062,6 +5137,11 @@ app.get("/discover/feed", async (c) => {
5062
5137
  }
5063
5138
  const templates = await serverlessRecords.listTemplatesFeed({ limit, query });
5064
5139
  for (const template of templates) {
5140
+ // Curated "Popular" picks live only under the Popular toggle
5141
+ // (GET /discover/popular) — keep them out of the continuously-grown Feed
5142
+ // so the two views stay distinct.
5143
+ if (typeof template.popularGroup === "string" && template.popularGroup.trim().length > 0)
5144
+ continue;
5065
5145
  const entry = buildTemplateHomepageEntry(template);
5066
5146
  if (entry)
5067
5147
  templateEntries.push(entry);
@@ -5078,7 +5158,7 @@ app.get("/discover/feed", async (c) => {
5078
5158
  const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
5079
5159
  const seen = new Set(templateEntries.map((entry) => entry.templateId));
5080
5160
  for (const entry of upstreamFeed) {
5081
- if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId)) {
5161
+ if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId) && !entry.popularGroup) {
5082
5162
  templateEntries.push(entry);
5083
5163
  seen.add(entry.templateId);
5084
5164
  }
@@ -5093,16 +5173,368 @@ app.get("/discover/feed", async (c) => {
5093
5173
  next_cursor: null
5094
5174
  });
5095
5175
  });
5176
+ // Curated "Popular" feed: the editorial list surfaced under the /discover
5177
+ // Popular toggle. Same template records as Feed, just the ones flagged with a
5178
+ // popularGroup — rendered client-side as autoplay TemplateCards grouped by that
5179
+ // label. Public + identical for everyone, so it caches shared. Mirrors upstream
5180
+ // (vidfarm serve) exactly like /discover/feed so a locally-empty box still sees
5181
+ // the cloud-seeded picks. Seed with POST /api/v1/admin/popular/seed.
5182
+ app.get("/discover/popular", async (c) => {
5183
+ const templateEntries = [];
5184
+ const seen = new Set();
5185
+ const popular = await serverlessRecords.listPopularTemplates();
5186
+ for (const template of popular) {
5187
+ const entry = buildTemplateHomepageEntry(template);
5188
+ if (entry) {
5189
+ templateEntries.push(entry);
5190
+ seen.add(entry.templateId);
5191
+ }
5192
+ }
5193
+ if (isUpstreamEnabled()) {
5194
+ const upstream = (await upstreamJson(`/discover/popular`)).json?.templates ?? [];
5195
+ for (const entry of upstream) {
5196
+ if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId) && entry.popularGroup) {
5197
+ templateEntries.push(entry);
5198
+ seen.add(entry.templateId);
5199
+ }
5200
+ }
5201
+ }
5202
+ // Stable editorial order: popularRank is a global sequence assigned by the
5203
+ // seed (group index * 1000 + item index), so a plain rank sort preserves both
5204
+ // group order and within-group order. Group label is only a tiebreak.
5205
+ templateEntries.sort((a, b) => {
5206
+ const rankCompare = (a.popularRank ?? 0) - (b.popularRank ?? 0);
5207
+ if (rankCompare !== 0)
5208
+ return rankCompare;
5209
+ return String(a.popularGroup ?? "").localeCompare(String(b.popularGroup ?? ""));
5210
+ });
5211
+ c.header("cache-control", "public, max-age=60");
5212
+ return c.json({
5213
+ templates: templateEntries,
5214
+ next_cursor: null
5215
+ });
5216
+ });
5217
+ // ---------------------------------------------------------------------------
5218
+ // Swipe mode: a Tinder-style deck of the customer's own product, auto-recut
5219
+ // from decomposed + caption-clean templates. Previews are live HyperFrames
5220
+ // compositions (the card iframe loads /composition/current.html?fork=), never a
5221
+ // baked MP4 — the MP4 render only happens on approve.
5222
+ function serializeSwipeCard(record) {
5223
+ return {
5224
+ id: record.id,
5225
+ template_id: record.templateId,
5226
+ fork_id: record.forkId,
5227
+ source_title: record.sourceTitle,
5228
+ title: record.tailoredTitle ?? record.sourceTitle ?? record.templateId,
5229
+ caption: record.tailoredCaption ?? "",
5230
+ pinned_comment: record.tailoredPinnedComment ?? null,
5231
+ // Self-contained live composition doc that scales itself to the card iframe
5232
+ // and autoplays muted+looping (see /discover/swipe/:id/preview).
5233
+ preview_url: record.forkId ? `/discover/swipe/${encodeURIComponent(record.id)}/preview` : null,
5234
+ duration_seconds: record.durationSeconds ?? null,
5235
+ status: record.status,
5236
+ ready_post_id: record.readyPostId ?? null
5237
+ };
5238
+ }
5239
+ // Current un-swiped deck for the caller (oldest first = top of stack) plus a
5240
+ // count of variants still customizing so the client can show a "preparing"
5241
+ // placeholder and know whether to keep refilling.
5242
+ app.get("/discover/swipe/stack", async (c) => {
5243
+ const customer = await requireBrowserCustomer(c);
5244
+ if (!customer)
5245
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
5246
+ const listed = await serverlessRecords.listSwipeCustomizationsForCustomer(customer.id);
5247
+ // Lazily supersede un-swiped variants built by an older customize pipeline so
5248
+ // stale (e.g. double-captioned) cards never reach the deck; their templates
5249
+ // become eligible again and the client's refill loop rebuilds them properly.
5250
+ const rows = await reconcileSwipeRows(customer.id, listed);
5251
+ const ready = rows
5252
+ .filter((r) => r.status === "ready" && r.forkId)
5253
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
5254
+ return c.json({
5255
+ ok: true,
5256
+ target_depth: SWIPE_STACK_TARGET_DEPTH,
5257
+ customizing: rows.filter((r) => r.status === "customizing").length,
5258
+ buffered: ready.length,
5259
+ product_description_set: Boolean((customer.about ?? "").trim()),
5260
+ stack: ready.map(serializeSwipeCard)
5261
+ });
5262
+ });
5263
+ // Compositions are authored at their design resolution (data-width×data-height,
5264
+ // root = 100vw×100vh, caption font-size in absolute px). In a small card iframe
5265
+ // that renders everything ~2-3× oversized, so this injects a script that sizes
5266
+ // the root to its design px and uniformly transform-scales it to fit the iframe
5267
+ // (fonts scale too). It also mutes every media element and drives __player so
5268
+ // the preview autoplays (muted autoplay is the only kind browsers allow) and
5269
+ // loops. Kept OUT of the shared /composition/current.html so the editor stage is
5270
+ // unaffected.
5271
+ function injectSwipePreviewFit(doc, cardId) {
5272
+ const inject = `<style>
5273
+ html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
5274
+ [data-composition-id] { position: absolute; transform-origin: top left; }
5275
+ </style>
5276
+ <script>(function () {
5277
+ var CARD_ID = ${JSON.stringify(cardId)};
5278
+ function boot() {
5279
+ var root = document.querySelector('[data-composition-id]');
5280
+ if (!root) { return; }
5281
+ var dw = parseFloat(root.getAttribute('data-width')) || 720;
5282
+ var dh = parseFloat(root.getAttribute('data-height')) || 1280;
5283
+ root.style.width = dw + 'px';
5284
+ root.style.height = dh + 'px';
5285
+ function fit() {
5286
+ var vw = window.innerWidth || dw, vh = window.innerHeight || dh;
5287
+ var s = Math.min(vw / dw, vh / dh);
5288
+ root.style.transform = 'scale(' + s + ')';
5289
+ root.style.left = ((vw - dw * s) / 2) + 'px';
5290
+ root.style.top = ((vh - dh * s) / 2) + 'px';
5291
+ }
5292
+ fit();
5293
+ window.addEventListener('resize', fit);
5294
+ // Sound: the deck (same-origin parent) owns the toggle and names which
5295
+ // card may be audible — only ever the TOP one (the 2nd iframe is
5296
+ // preloaded; without the id gate both would play audio at once). Cards
5297
+ // always START muted (the only autoplay browsers permit); the tick below
5298
+ // re-syncs to the parent's flags, so a toggle or a card advancing to the
5299
+ // top picks up sound within ~400ms of the user's gesture.
5300
+ function wantSound() {
5301
+ try {
5302
+ var w = window.parent;
5303
+ return Boolean(w) && w.__VF_SWIPE_SOUND__ === true && w.__VF_SWIPE_SOUND_TOP__ === CARD_ID;
5304
+ } catch (e) { return false; }
5305
+ }
5306
+ // Drive ONLY the player's main mute. The runtime's applyMediaState
5307
+ // computes each element's muted state as previewMuted || authored-muted
5308
+ // and honors per-clip data-volume — force-unmuting every <video>/<audio>
5309
+ // here (the first version of this script) made muted-authored overlapping
5310
+ // clips of the same source all audible at once (the duplicate-audio bug).
5311
+ // Touching elements directly would also pollute the runtime's
5312
+ // initialMuted capture, so hands off; before __player boots nothing plays
5313
+ // (playback only ever starts via __player.play()), so there is no
5314
+ // unmuted-autoplay window to guard.
5315
+ function applySound(on) {
5316
+ try { if (window.__player && window.__player.setMuted) { window.__player.setMuted(!on); } } catch (e) {}
5317
+ }
5318
+ function play() {
5319
+ try { if (window.__player) { window.__player.play(); } } catch (e) {}
5320
+ }
5321
+ applySound(false);
5322
+ play();
5323
+ var lastWant = false;
5324
+ var stalled = 0;
5325
+ var mutedFallback = false;
5326
+ setInterval(function () {
5327
+ try {
5328
+ var want = wantSound();
5329
+ if (want !== lastWant) { lastWant = want; mutedFallback = false; stalled = 0; }
5330
+ applySound(want && !mutedFallback);
5331
+ if (!window.__player || !window.__player.getDuration) { return; }
5332
+ var t = window.__player.getTime(), d = window.__player.getDuration();
5333
+ if (d > 0 && t >= d - 0.08) { window.__player.seek(0, { keepPlaying: true }); }
5334
+ else if (!window.__player.isPlaying()) {
5335
+ stalled += 1;
5336
+ // Unmuted playback needs prior interaction with the page; if the
5337
+ // browser keeps blocking it (e.g. Safari), drop back to muted so
5338
+ // the preview never freezes on a frame.
5339
+ if (stalled >= 3 && want && !mutedFallback) { mutedFallback = true; applySound(false); }
5340
+ play();
5341
+ } else { stalled = 0; }
5342
+ } catch (e) {}
5343
+ }, 400);
5344
+ }
5345
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); }
5346
+ })();</script>`;
5347
+ return /<\/body>/i.test(doc) ? doc.replace(/<\/body>/i, `${inject}\n</body>`) : `${doc}\n${inject}`;
5348
+ }
5349
+ // The self-fitting, muted-autoplay live composition doc for one deck card.
5350
+ app.get("/discover/swipe/:id/preview", async (c) => {
5351
+ const customer = await requireBrowserCustomer(c);
5352
+ if (!customer)
5353
+ return c.text("Unauthorized", 401);
5354
+ const record = await serverlessRecords.getSwipeCustomization(c.req.param("id"));
5355
+ if (!record || record.customerId !== customer.id || !record.forkId)
5356
+ return c.text("Not found", 404);
5357
+ const html = await storage.readText(storage.compositionForkWorkingKey(record.forkId, "composition.html"));
5358
+ if (!html)
5359
+ return c.text("Composition missing", 404);
5360
+ const doc = injectSwipePreviewFit(rewriteHyperframesDraftCompositionHtml(html, `forks/${encodeURIComponent(record.forkId)}`), record.id);
5361
+ return c.html(doc, 200, { "cache-control": "no-store" });
5362
+ });
5363
+ // Progressive top-up: customize exactly ONE more eligible template (awaited, so
5364
+ // it is cloud-safe — the work finishes inside the request). The client calls
5365
+ // this to fill the deck on open and +1 after every swipe. BYOK LLM.
5366
+ app.post("/discover/swipe/refill", async (c) => {
5367
+ const customer = await requireBrowserCustomer(c);
5368
+ if (!customer)
5369
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
5370
+ const productDescription = (customer.about ?? "").trim();
5371
+ if (!productDescription) {
5372
+ return c.json({ ok: false, error: "Add a product / offer description in Settings, then reload Swipe.", need_product_description: true }, 400);
5373
+ }
5374
+ const listed = await serverlessRecords.listSwipeCustomizationsForCustomer(customer.id);
5375
+ const rows = await reconcileSwipeRows(customer.id, listed);
5376
+ const buffered = rows.filter((r) => r.status === "ready" || r.status === "customizing").length;
5377
+ if (buffered >= SWIPE_STACK_TARGET_DEPTH) {
5378
+ return c.json({ ok: true, filled: false, reason: "at_target", buffered });
5379
+ }
5380
+ try {
5381
+ const record = await customizeOneMore({ customerId: customer.id, productDescription });
5382
+ if (!record)
5383
+ return c.json({ ok: true, filled: false, reason: "no_eligible" });
5384
+ return c.json({ ok: true, filled: record.status === "ready", status: record.status, record: serializeSwipeCard(record) });
5385
+ }
5386
+ catch (error) {
5387
+ if (error instanceof NoProviderKeyError)
5388
+ return c.json({ ok: false, error: error.message, need_provider_key: true }, 400);
5389
+ return c.json({ ok: false, error: error instanceof Error ? error.message : "Customization failed." }, 500);
5390
+ }
5391
+ });
5392
+ // Approve: mint the /library post (title/caption now, rendered MP4 backfilled by
5393
+ // finalizeSwipeApproveRender when the render job finishes) and mark the
5394
+ // customization approved. Then the client refills (+1).
5395
+ app.post("/discover/swipe/:id/approve", async (c) => {
5396
+ const customer = await requireBrowserCustomer(c);
5397
+ if (!customer)
5398
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
5399
+ const loaded = await serverlessRecords.getSwipeCustomization(c.req.param("id"));
5400
+ if (!loaded || loaded.customerId !== customer.id)
5401
+ return c.json({ ok: false, error: "Not found" }, 404);
5402
+ // A deck loaded before a pipeline bump can still hold stale cards — supersede
5403
+ // instead of publishing a known-broken variant to /library.
5404
+ const [record] = await reconcileSwipeRows(customer.id, [loaded]);
5405
+ if (record.status !== "ready" || !record.forkId) {
5406
+ return c.json({ ok: false, superseded: record.status === "superseded", error: `Cannot approve a customization in status "${record.status}".` }, 409);
5407
+ }
5408
+ const fork = await serverlessRecords.getCompositionFork(record.forkId);
5409
+ if (!fork)
5410
+ return c.json({ ok: false, error: "Customized composition not found." }, 404);
5411
+ const post = await serverlessRecords.createReadyPost({
5412
+ customerId: customer.id,
5413
+ source: "hyperframes",
5414
+ status: "ready",
5415
+ compositionId: fork.id,
5416
+ tracer: `swipe:${record.id}`,
5417
+ title: record.tailoredTitle,
5418
+ caption: record.tailoredCaption,
5419
+ pinnedComment: record.tailoredPinnedComment,
5420
+ mediaAssets: []
5421
+ });
5422
+ let renderJobId = null;
5423
+ try {
5424
+ const html = await storage.readText(storage.compositionForkWorkingKey(fork.id, "composition.html"));
5425
+ const jsonText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "composition.json"));
5426
+ if (html) {
5427
+ const compositionData = jsonText ? asRecord(JSON.parse(jsonText)) : null;
5428
+ const rendered = await publishCompositionToRenderer({
5429
+ customer,
5430
+ compositionHtml: stampCompositionIdentity(html, { forkId: fork.id, version: (fork.latestVersion ?? 0) + 1 }),
5431
+ compositionData: compositionData ?? null,
5432
+ projectFiles: [],
5433
+ slugId: fork.templateId,
5434
+ title: record.tailoredTitle ?? fork.templateId,
5435
+ tracer: `swipe:${record.id}:render`
5436
+ });
5437
+ if (!("preflight" in rendered)) {
5438
+ renderJobId = rendered.job.id;
5439
+ await serverlessRecords.updateCompositionFork({ forkId: fork.id, patch: { currentRenderJobId: renderJobId } });
5440
+ void finalizeSwipeApproveRender({
5441
+ customerId: customer.id,
5442
+ swipeId: record.id,
5443
+ readyPostId: post.id,
5444
+ renderJobId,
5445
+ title: record.tailoredTitle ?? fork.templateId
5446
+ });
5447
+ }
5448
+ }
5449
+ }
5450
+ catch (error) {
5451
+ console.error("swipe approve render dispatch failed", error instanceof Error ? error.message : error);
5452
+ }
5453
+ const updated = await serverlessRecords.updateSwipeCustomization({
5454
+ customerId: customer.id,
5455
+ id: record.id,
5456
+ patch: { status: "approved", decidedAt: nowIso(), readyPostId: post.id, renderJobId }
5457
+ });
5458
+ return c.json({ ok: true, status: "approved", ready_post_id: post.id, render_job_id: renderJobId, record: serializeSwipeCard(updated) });
5459
+ });
5460
+ // Reject: drop the variant from the deck. It persists (findable in /job-runs).
5461
+ app.post("/discover/swipe/:id/reject", async (c) => {
5462
+ const customer = await requireBrowserCustomer(c);
5463
+ if (!customer)
5464
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
5465
+ const loaded = await serverlessRecords.getSwipeCustomization(c.req.param("id"));
5466
+ if (!loaded || loaded.customerId !== customer.id)
5467
+ return c.json({ ok: false, error: "Not found" }, 404);
5468
+ // Rejecting a stale card would permanently re-consume the template the
5469
+ // supersede migration is trying to free — supersede it instead.
5470
+ const [record] = await reconcileSwipeRows(customer.id, [loaded]);
5471
+ if (record.status !== "ready") {
5472
+ return c.json({ ok: false, superseded: record.status === "superseded", error: `Cannot reject a customization in status "${record.status}".` }, 409);
5473
+ }
5474
+ const updated = await serverlessRecords.updateSwipeCustomization({
5475
+ customerId: customer.id,
5476
+ id: record.id,
5477
+ patch: { status: "rejected", decidedAt: nowIso() }
5478
+ });
5479
+ return c.json({ ok: true, status: "rejected", record: serializeSwipeCard(updated) });
5480
+ });
5481
+ // Poll the approve-time render job to completion and backfill the finished MP4
5482
+ // into the library post's media. In-process (works on a `vidfarm serve` box
5483
+ // where the render also runs in-process); a cloud box's render is driven by its
5484
+ // own worker and this poll simply reads the shared job record until it lands.
5485
+ async function finalizeSwipeApproveRender(input) {
5486
+ const deadline = Date.now() + 5 * 60 * 1000;
5487
+ for (;;) {
5488
+ if (Date.now() > deadline)
5489
+ return;
5490
+ await new Promise((resolve) => setTimeout(resolve, 2500));
5491
+ let job = null;
5492
+ try {
5493
+ job = await jobs.getJobAsync(input.renderJobId);
5494
+ }
5495
+ catch {
5496
+ continue;
5497
+ }
5498
+ const status = String(job?.status ?? "");
5499
+ if (status === "succeeded" || status === "completed") {
5500
+ const url = extractOutputUrlFromJob(job);
5501
+ if (url) {
5502
+ const slug = (input.title || "swipe-ad").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 40) || "swipe-ad";
5503
+ const asset = {
5504
+ id: `swipe-${input.swipeId}`,
5505
+ url,
5506
+ fileName: `${slug}.mp4`,
5507
+ contentType: "video/mp4",
5508
+ kind: "video",
5509
+ role: "primary"
5510
+ };
5511
+ await serverlessRecords.setReadyPostMedia({ customerId: input.customerId, postId: input.readyPostId, mediaAssets: [asset] });
5512
+ }
5513
+ return;
5514
+ }
5515
+ if (status === "failed" || status === "cancelled") {
5516
+ await serverlessRecords.updateSwipeCustomization({
5517
+ customerId: input.customerId,
5518
+ id: input.swipeId,
5519
+ patch: { status: "failed", error: `Approve render ${status}.` }
5520
+ });
5521
+ return;
5522
+ }
5523
+ }
5524
+ }
5096
5525
  function buildTemplateHomepageEntry(template) {
5097
5526
  // Undecomposed templates (no defaultForkId yet) still appear on /discover so
5098
5527
  // users can open them and mint the first fork via the Decompose modal.
5099
5528
  if (!template.videoUrl)
5100
5529
  return null;
5101
5530
  const isPrivate = template.visibility === "private";
5531
+ const isPopular = typeof template.popularGroup === "string" && template.popularGroup.trim().length > 0;
5102
5532
  // Older records stored the source host ("youtube.com") as a title fallback —
5103
5533
  // never show that as a card title; the template id is the canonical default.
5104
5534
  const storedTitle = template.title && template.title !== template.sourceHost ? template.title : null;
5105
- const title = isPrivate ? (storedTitle || template.id) : template.id;
5535
+ // Public feed cards default to the template id, but curated Popular picks show
5536
+ // their editorial title (the whole point of the Popular list).
5537
+ const title = (isPrivate || isPopular) ? (storedTitle || template.id) : template.id;
5106
5538
  const previewUrl = template.thumbnailUrl || template.videoUrl || null;
5107
5539
  return {
5108
5540
  title,
@@ -5122,6 +5554,8 @@ function buildTemplateHomepageEntry(template) {
5122
5554
  summary: template.searchSummary ?? null,
5123
5555
  visibility: template.visibility,
5124
5556
  origin: template.origin ?? "inspiration",
5557
+ popularGroup: template.popularGroup ?? null,
5558
+ popularRank: template.popularRank ?? null,
5125
5559
  notes: template.notes,
5126
5560
  status: "ready"
5127
5561
  };
@@ -5489,6 +5923,8 @@ function buildPendingInspirationHomepageEntry(inspiration) {
5489
5923
  durationSeconds: inspiration.durationSeconds ?? null,
5490
5924
  visibility: "private",
5491
5925
  origin: inspiration.origin ?? "inspiration",
5926
+ popularGroup: inspiration.popularGroup ?? null,
5927
+ popularRank: inspiration.popularRank ?? null,
5492
5928
  notes: inspiration.notes,
5493
5929
  status: inspiration.status === "failed" ? "failed" : "processing"
5494
5930
  };
@@ -7131,6 +7567,13 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
7131
7567
  // so a forced re-run may start. Must exceed the longest real decompose but stay
7132
7568
  // under the API lambda's 15-min timeout.
7133
7569
  const DECOMPOSE_PROCESSING_STALE_MS = 10 * 60 * 1000;
7570
+ // Inspiration ingest + auto-decompose no longer hard-cap the source at 120s.
7571
+ // Users can bring longer inspiration; an operator can still re-impose a ceiling
7572
+ // via INSPIRATION_MAX_DURATION_SEC (>0). 0 → unlimited, translated to Infinity so
7573
+ // probeMediaAsset never rejects a source purely on length.
7574
+ const INSPIRATION_PROBE_MAX_DURATION_SECONDS = config.INSPIRATION_MAX_DURATION_SEC > 0
7575
+ ? config.INSPIRATION_MAX_DURATION_SEC
7576
+ : Number.POSITIVE_INFINITY;
7134
7577
  async function readDecomposeJobState(forkId) {
7135
7578
  const text = await storage.readText(storage.compositionForkWorkingKey(forkId, "decompose.json"));
7136
7579
  if (!text)
@@ -7250,7 +7693,10 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
7250
7693
  let probedDurationSeconds = null;
7251
7694
  if (sourceUrl) {
7252
7695
  try {
7253
- const probe = await probeMediaAsset({ sourceUrl });
7696
+ const probe = await probeMediaAsset({
7697
+ sourceUrl,
7698
+ maxDurationSeconds: INSPIRATION_PROBE_MAX_DURATION_SECONDS
7699
+ });
7254
7700
  const probed = probe.metadata.durationSeconds;
7255
7701
  if (Number.isFinite(probed) && probed && probed > 0) {
7256
7702
  probedDurationSeconds = probed;
@@ -7298,7 +7744,17 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
7298
7744
  ghostcutSkipReason = "ghostcut_not_configured";
7299
7745
  console.warn(`ghostcut: skipping subtitle removal for fork ${fork.id} — GHOSTCUT_KEY/GHOSTCUT_SECRET not configured. Composition will keep the original video with baked-in text.`);
7300
7746
  }
7301
- if (sourceUrl && sourceIsVideo && isGhostcutConfigured()) {
7747
+ else if (config.GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC > 0 &&
7748
+ probedDurationSeconds !== null &&
7749
+ probedDurationSeconds > config.GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC) {
7750
+ // GhostCut is per-30s-chunk and meant for short clips. Long inspiration is
7751
+ // now allowed through ingest/decompose, but laundering a multi-minute video
7752
+ // through GhostCut is disproportionately expensive — skip it and keep the
7753
+ // original burned-in captions instead.
7754
+ ghostcutSkipReason = `source_too_long:${probedDurationSeconds.toFixed(1)}s>${config.GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC}s`;
7755
+ console.warn(`ghostcut: skipping subtitle removal for fork ${fork.id} — source is ${probedDurationSeconds.toFixed(1)}s, over the ${config.GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC}s decompose ceiling. Composition keeps the original burned-in captions.`);
7756
+ }
7757
+ if (sourceUrl && sourceIsVideo && isGhostcutConfigured() && !ghostcutSkipReason) {
7302
7758
  try {
7303
7759
  const submission = await submitSubtitleRemoval(sourceUrl);
7304
7760
  ghostcutTaskId = submission.taskId;
@@ -9118,6 +9574,8 @@ function serializeInspiration(inspiration) {
9118
9574
  summary: inspiration.searchSummary ?? null,
9119
9575
  visibility: inspiration.visibility ?? "public",
9120
9576
  origin: inspiration.origin ?? "inspiration",
9577
+ popular_group: inspiration.popularGroup ?? null,
9578
+ popular_rank: inspiration.popularRank ?? null,
9121
9579
  notes: inspiration.notes ?? null,
9122
9580
  ingester_customer_id: inspiration.customerId,
9123
9581
  error: inspiration.errorMessage,
@@ -10217,10 +10675,54 @@ app.get("/job-runs/history", async (c) => {
10217
10675
  }
10218
10676
  return true;
10219
10677
  });
10678
+ // Surface Swipe-mode customizations (approved AND rejected, plus in-flight and
10679
+ // failed) in Run History so every auto-recut variant is findable — they don't
10680
+ // pass through the api-call-history middleware, so synthesize them here in the
10681
+ // same entry shape the client already renders. Skipped under a template/tracer
10682
+ // filter since they carry no linked job/template.
10683
+ let entriesWithSwipe = entries;
10684
+ if (!templateFilter && !tracerFilter) {
10685
+ const swipeRows = await serverlessRecords.listSwipeCustomizationsForCustomer(customer.id);
10686
+ // Superseded rows are internal pipeline-migration artifacts, not runs the
10687
+ // customer fired — surfacing them would show phantom in-flight customizes.
10688
+ const swipeEntries = swipeRows.filter((r) => r.status !== "superseded").map((r) => {
10689
+ const action = r.status === "approved" ? "approve" : r.status === "rejected" ? "reject" : "customize";
10690
+ return {
10691
+ id: `swipe:${r.id}`,
10692
+ customerId: customer.id,
10693
+ method: "POST",
10694
+ path: `/discover/swipe/${action}`,
10695
+ routePrefix: "/discover/swipe",
10696
+ tracer: `swipe:${r.templateId}`,
10697
+ jobId: r.renderJobId ?? null,
10698
+ status: r.status === "failed" ? 500 : r.status === "rejected" ? 200 : 202,
10699
+ durationMs: 0,
10700
+ request: { query: {}, body: { template_id: r.templateId, source_title: r.sourceTitle }, bodyOmittedReason: null },
10701
+ response: {
10702
+ body: {
10703
+ swipe_id: r.id,
10704
+ status: r.status,
10705
+ tailored_title: r.tailoredTitle,
10706
+ ready_post_id: r.readyPostId,
10707
+ fork_id: r.forkId,
10708
+ swipe_customization: true
10709
+ },
10710
+ bodyOmittedReason: null
10711
+ },
10712
+ mediaUrls: [],
10713
+ linkedJob: null,
10714
+ outputMediaUrls: [],
10715
+ createdAt: r.updatedAt ?? r.createdAt
10716
+ };
10717
+ });
10718
+ if (swipeEntries.length) {
10719
+ entriesWithSwipe = [...entries, ...swipeEntries].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
10720
+ }
10721
+ }
10220
10722
  const filteredEntries = parseCreatedAtIdCursor(query.cursor)
10221
- ? entries.filter((entry) => (Date.parse(entry.createdAt) < Date.parse(parseCreatedAtIdCursor(query.cursor).createdAt)
10723
+ ? entriesWithSwipe.filter((entry) => (Date.parse(entry.createdAt) < Date.parse(parseCreatedAtIdCursor(query.cursor).createdAt)
10222
10724
  || (entry.createdAt === parseCreatedAtIdCursor(query.cursor).createdAt && entry.id < parseCreatedAtIdCursor(query.cursor).id)))
10223
- : entries;
10725
+ : entriesWithSwipe;
10224
10726
  const page = paginateDescendingByCreatedAt(filteredEntries, limit);
10225
10727
  return c.json({
10226
10728
  entries: page.items,
@@ -11222,7 +11724,10 @@ async function ensureInspirationReadyThenFork(input) {
11222
11724
  // the true length instead of a placeholder.
11223
11725
  if (!inspiration.durationSeconds) {
11224
11726
  try {
11225
- const probe = await probeMediaAsset({ sourceUrl: inspiration.videoUrl });
11727
+ const probe = await probeMediaAsset({
11728
+ sourceUrl: inspiration.videoUrl,
11729
+ maxDurationSeconds: input.maxDurationSeconds ?? INSPIRATION_PROBE_MAX_DURATION_SECONDS
11730
+ });
11226
11731
  const probedDuration = probe.metadata.durationSeconds;
11227
11732
  if (Number.isFinite(probedDuration) && probedDuration && probedDuration > 0) {
11228
11733
  inspiration = (await serverlessRecords.updateInspiration({
@@ -11301,6 +11806,167 @@ function buildTemplateEditorUrl(publicBaseUrl, templateId) {
11301
11806
  const path = `/editor/${encodeURIComponent(templateId)}`;
11302
11807
  return publicBaseUrl ? `${publicBaseUrl.replace(/\/$/, "")}${path}` : path;
11303
11808
  }
11809
+ // Seed the curated "Popular" catalog from POPULAR_INSPIRATION_GROUPS: ingest
11810
+ // each URL once into a real PUBLIC template (same path as admin TikTok
11811
+ // submissions) and stamp it with the editorial title + popularGroup +
11812
+ // popularRank so it always shows under the /discover Popular toggle and
11813
+ // autoplays inline exactly like a Feed card. Curated picks accept ANY length
11814
+ // (the 120s ingest cap is lifted). Idempotent — find-or-create by URL, then
11815
+ // (re)stamp; a legacy 115s clip is re-downloaded in full. Requires RAPIDAPI_KEY
11816
+ // (the download primitive), so run this on staging/prod.
11817
+ app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
11818
+ try {
11819
+ requireSuperagency(c);
11820
+ }
11821
+ catch (error) {
11822
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
11823
+ }
11824
+ const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
11825
+ const requestedOwnerEmail = readNonEmptyString(body.owner_email)?.trim().toLowerCase();
11826
+ const ownerEmail = requestedOwnerEmail ?? config.adminEmails[0]?.trim().toLowerCase();
11827
+ if (!ownerEmail)
11828
+ return c.json({ error: "No owner_email supplied and no admin email configured." }, 400);
11829
+ const ownerCustomer = await serverlessRecords.getCustomerByEmail(ownerEmail);
11830
+ if (!ownerCustomer)
11831
+ return c.json({ error: `Owner not found: ${ownerEmail}` }, 404);
11832
+ const primitive = primitiveRegistry.get("primitive:video_download");
11833
+ if (!primitive)
11834
+ return c.json({ error: "video_download primitive missing" }, 500);
11835
+ const operation = primitive.operations["run"];
11836
+ if (!operation)
11837
+ return c.json({ error: "video_download.run missing" }, 500);
11838
+ const publicBaseUrl = config.PUBLIC_BASE_URL?.trim() || "";
11839
+ try {
11840
+ await assertWalletCanQueueJobs(ownerCustomer.id);
11841
+ }
11842
+ catch (error) {
11843
+ if (error instanceof InsufficientWalletFundsError)
11844
+ return insufficientFundsResponse(c, error);
11845
+ throw error;
11846
+ }
11847
+ // Flatten the manifest into one ranked list. popularRank is a global sequence
11848
+ // (group index * 1000 + item index) so a plain rank sort restores both the
11849
+ // group order and the order within each group.
11850
+ const allItems = POPULAR_INSPIRATION_GROUPS.flatMap((group, gi) => group.items.map((item, ri) => ({ title: item.title, url: item.url, group: group.label, rank: gi * 1000 + ri })));
11851
+ // Optional { groups: [...] } filter seeds one category at a time so a large
11852
+ // catalog stays well under the Lambda timeout (each URL downloads inline).
11853
+ // Reconcile below still runs against the FULL manifest, so a partial seed
11854
+ // never retires the groups it skipped.
11855
+ const onlyGroups = Array.isArray(body.groups) ? new Set(body.groups.map((g) => String(g))) : null;
11856
+ const items = onlyGroups ? allItems.filter((it) => onlyGroups.has(it.group)) : allItems;
11857
+ const results = [];
11858
+ for (const item of items) {
11859
+ const originalUrl = stripTrackingParams(item.url);
11860
+ const sourceHost = safeUrlHostname(originalUrl);
11861
+ if (!sourceHost) {
11862
+ results.push({ source_url: item.url, title: item.title, error: "invalid URL" });
11863
+ continue;
11864
+ }
11865
+ let inspiration = await serverlessRecords.findInspirationByOriginalUrl(originalUrl);
11866
+ // (Re)start the download when there's no usable video yet (a genuine
11867
+ // download failure), OR the stored video is a legacy 115s clip (videoStorageKey
11868
+ // under popular-clips) — long picks are now ingested in full, so a clip must
11869
+ // be re-downloaded from the original source.
11870
+ const isLegacyClip = Boolean(inspiration?.videoStorageKey?.includes("popular-clips"));
11871
+ const needsIngest = !inspiration
11872
+ || (!inspiration.videoUrl && inspiration.status !== "downloading")
11873
+ || isLegacyClip;
11874
+ if (needsIngest) {
11875
+ const payload = operation.inputSchema.parse({ source_url: originalUrl, save_manifest: true });
11876
+ let job;
11877
+ try {
11878
+ job = await jobs.createRootJob({
11879
+ templateId: primitive.id,
11880
+ operationName: "run",
11881
+ workflowName: operation.workflow,
11882
+ tracer: `popular_seed_ingest:${ownerCustomer.id}`,
11883
+ payload,
11884
+ webhookUrl: null,
11885
+ customer: ownerCustomer,
11886
+ providerHint: undefined
11887
+ });
11888
+ }
11889
+ catch (error) {
11890
+ results.push({ source_url: item.url, title: item.title, error: error instanceof Error ? error.message : String(error) });
11891
+ continue;
11892
+ }
11893
+ inspiration = inspiration
11894
+ ? ((await serverlessRecords.updateInspiration({
11895
+ inspirationId: inspiration.id,
11896
+ patch: { status: "downloading", downloadJobId: job.id, errorMessage: null, videoUrl: null, videoStorageKey: null, durationSeconds: null }
11897
+ })) ?? inspiration)
11898
+ : await serverlessRecords.createInspiration({
11899
+ customerId: ownerCustomer.id,
11900
+ sourceUrl: item.url,
11901
+ originalUrl,
11902
+ sourceHost,
11903
+ downloadJobId: job.id,
11904
+ trendTagline: item.title
11905
+ });
11906
+ }
11907
+ // Stamp the editorial marker + curated title up front so a still-downloading
11908
+ // pick already surfaces under Popular with the right label (pending cards
11909
+ // read trendTagline, which finalize never clobbers).
11910
+ inspiration = (await serverlessRecords.updateInspiration({
11911
+ inspirationId: inspiration.id,
11912
+ patch: { title: item.title, trendTagline: item.title, popularGroup: item.group, popularRank: item.rank, visibility: "public" }
11913
+ })) ?? inspiration;
11914
+ // On a local box (no Step Functions) the download job would queue forever —
11915
+ // run it in-process so ensureInspirationReadyThenFork can finalize.
11916
+ if (!config.VIDFARM_JOB_STATE_MACHINE_ARN && inspiration.status === "downloading" && inspiration.downloadJobId) {
11917
+ const pendingJobId = inspiration.downloadJobId;
11918
+ void runPrimitiveJobInProcess(pendingJobId).catch((error) => {
11919
+ console.error("popular seed download failed", pendingJobId, error instanceof Error ? error.message : error);
11920
+ });
11921
+ }
11922
+ // Curated Popular picks accept ANY length — the 120s ingest cap is lifted
11923
+ // here so long reference videos (podcasts, gameplay, etc.) ingest in full.
11924
+ const finalized = await ensureInspirationReadyThenFork({ inspiration, ownerCustomer, publicBaseUrl, note: null, maxDurationSeconds: Number.MAX_SAFE_INTEGER });
11925
+ const templateId = typeof finalized.template_id === "string" ? finalized.template_id : null;
11926
+ let durationSec = null;
11927
+ if (templateId) {
11928
+ const done = await serverlessRecords.getInspiration(inspiration.id);
11929
+ durationSec = Number.isFinite(done?.durationSeconds) ? Number(done?.durationSeconds) : null;
11930
+ // Keep the template's video/duration in sync with the (re)ingested source,
11931
+ // so re-seeding a previously-clipped pick swaps in the full-length video.
11932
+ await serverlessRecords.updateTemplate({
11933
+ templateId,
11934
+ patch: {
11935
+ title: item.title,
11936
+ popularGroup: item.group,
11937
+ popularRank: item.rank,
11938
+ visibility: "public",
11939
+ ...(done?.videoUrl ? { videoUrl: done.videoUrl } : {}),
11940
+ ...(done?.thumbnailUrl !== undefined ? { thumbnailUrl: done.thumbnailUrl } : {}),
11941
+ durationSeconds: durationSec
11942
+ }
11943
+ });
11944
+ }
11945
+ results.push({
11946
+ source_url: item.url,
11947
+ title: item.title,
11948
+ group: item.group,
11949
+ rank: item.rank,
11950
+ template_id: templateId,
11951
+ status: templateId ? "ready" : (finalized.status ?? null),
11952
+ duration_sec: durationSec,
11953
+ pending: finalized.pending ?? false
11954
+ });
11955
+ }
11956
+ // Reconcile: any previously-seeded pick whose URL is no longer in the manifest
11957
+ // (a replaced or removed entry) gets un-flagged so it drops out of the Popular
11958
+ // view — the manifest is the single source of truth. Compared after the same
11959
+ // tracking-param stripping the stored originalUrl went through.
11960
+ const manifestUrls = new Set(allItems.map((it) => stripTrackingParams(it.url)));
11961
+ const retired = [];
11962
+ for (const tmpl of await serverlessRecords.listPopularTemplates()) {
11963
+ if (!manifestUrls.has(tmpl.originalUrl)) {
11964
+ await serverlessRecords.updateTemplate({ templateId: tmpl.id, patch: { popularGroup: null, popularRank: null } });
11965
+ retired.push({ template_id: tmpl.id, title: tmpl.title, original_url: tmpl.originalUrl });
11966
+ }
11967
+ }
11968
+ return c.json({ ok: true, owner_customer_id: ownerCustomer.id, count: results.length, items: results, retired }, 202);
11969
+ });
11304
11970
  function buildRawTikTokCompositionHtml(input) {
11305
11971
  const duration = Number.isFinite(input.durationSeconds) && input.durationSeconds > 0
11306
11972
  ? Number(input.durationSeconds.toFixed(3))
@@ -14298,7 +14964,8 @@ const AI_PRIMITIVE_IDS = new Set([
14298
14964
  "primitive:video_generate",
14299
14965
  "primitive:tts",
14300
14966
  "primitive:stt",
14301
- "primitive:captions"
14967
+ "primitive:captions",
14968
+ "primitive:speech_regenerate"
14302
14969
  ]);
14303
14970
  async function preflightAiProviderKeys(input) {
14304
14971
  if (!operationMayUseAiProvider(input)) {
@@ -14824,6 +15491,9 @@ app.post(`${PRIMITIVES_PREFIX}/stt`, async (c) => createPrimitiveJob(c, { primit
14824
15491
  // Animated-caption cues from narration (STT + word timings + cue paging).
14825
15492
  app.post(`${PRIMITIVES_PREFIX}/audio/captions`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:captions", operationName: "run" }));
14826
15493
  app.post(`${PRIMITIVES_PREFIX}/captions`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:captions", operationName: "run" }));
15494
+ // Voice-matched speech regeneration (profile original voice → reword → TTS).
15495
+ app.post(`${PRIMITIVES_PREFIX}/audio/regenerate-speech`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:speech_regenerate", operationName: "run" }));
15496
+ app.post(`${PRIMITIVES_PREFIX}/regenerate-speech`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:speech_regenerate", operationName: "run" }));
14827
15497
  app.post(`${PRIMITIVES_PREFIX}/brainstorm/hooks`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_hooks", operationName: "run" }));
14828
15498
  app.post(`${PRIMITIVES_PREFIX}/brainstorm/awareness_stages`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_awareness_stages", operationName: "run" }));
14829
15499
  app.post(`${PRIMITIVES_PREFIX}/brainstorm/angles`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_angles", operationName: "run" }));