@mevdragon/vidfarm-devcli 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.agents/skills/farmville-saas-ux/SKILL.md +156 -0
  2. package/.agents/skills/farmville-saas-ux/assets/starter.html +294 -0
  3. package/.agents/skills/farmville-saas-ux/references/components.md +340 -0
  4. package/.agents/skills/farmville-saas-ux/references/porting-guide.md +121 -0
  5. package/.agents/skills/farmville-saas-ux/references/tokens.md +271 -0
  6. package/.agents/skills/vidfarm-media/SKILL.md +4 -3
  7. package/.agents/skills/vidfarm-media/references/tts.md +20 -1
  8. package/SKILL.director.md +20 -20
  9. package/SKILL.platform.md +4 -4
  10. package/dist/src/account-pages-legacy.js +2 -2
  11. package/dist/src/app.js +721 -101
  12. package/dist/src/cli.js +11 -10
  13. package/dist/src/devcli/clips.js +64 -64
  14. package/dist/src/editor-chat.js +2 -2
  15. package/dist/src/frontend/homepage-client.js +162 -2
  16. package/dist/src/frontend/homepage-store.js +30 -1
  17. package/dist/src/frontend/homepage-view.js +111 -4
  18. package/dist/src/homepage.js +184 -1
  19. package/dist/src/landing-page.js +367 -0
  20. package/dist/src/primitive-registry.js +278 -2
  21. package/dist/src/reskin/agency-page.js +299 -0
  22. package/dist/src/reskin/calendar-page.js +567 -0
  23. package/dist/src/reskin/chat-page.js +607 -0
  24. package/dist/src/reskin/discover-page.js +1096 -0
  25. package/dist/src/reskin/document.js +663 -0
  26. package/dist/src/reskin/help-page.js +356 -0
  27. package/dist/src/reskin/index-page.js +62 -0
  28. package/dist/src/reskin/inpaint-page.js +541 -0
  29. package/dist/src/reskin/job-runs-page.js +477 -0
  30. package/dist/src/reskin/library-page.js +688 -0
  31. package/dist/src/reskin/login-page.js +262 -0
  32. package/dist/src/reskin/pricing-page.js +388 -0
  33. package/dist/src/reskin/settings-page.js +687 -0
  34. package/dist/src/reskin/theme.js +362 -0
  35. package/dist/src/services/serverless-records.js +54 -0
  36. package/dist/src/services/swipe-customize.js +434 -0
  37. package/package.json +1 -1
  38. package/public/assets/homepage-client-app.js +22 -22
package/dist/src/app.js CHANGED
@@ -17,11 +17,24 @@ 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 { renderReskinInpaint } from "./reskin/inpaint-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";
25
38
  import { POPULAR_INSPIRATION_GROUPS } from "./frontend/homepage-shared.js";
26
39
  import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
27
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";
@@ -60,7 +73,7 @@ import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml,
60
73
  import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
61
74
  import { createPrimitiveJobContext } from "./primitive-context.js";
62
75
  import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
63
- import { MediaDurationExceededError, probeMediaAsset, trimVideoAsset } from "./services/media-processing.js";
76
+ import { MediaDurationExceededError, probeMediaAsset } from "./services/media-processing.js";
64
77
  import { estimateGhostcutCostUsd, isGhostcutConfigured, mirrorGhostcutVideoToStorage, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
65
78
  import { hasUpstreamKey, isUpstreamEnabled, proxyRequestToUpstream, seedFromUpstream, upstreamHost, upstreamJson } from "./services/upstream.js";
66
79
  import { TemplateSourceService } from "./services/template-sources.js";
@@ -88,7 +101,10 @@ const COMPOSITIONS_PREFIX = `${API_PREFIX}/compositions`;
88
101
  const INSPIRATIONS_PREFIX = `${API_PREFIX}/inspirations`;
89
102
  const VIDEOS_PREFIX = `${API_PREFIX}/videos`;
90
103
  const PRIMITIVES_PREFIX = `${API_PREFIX}/primitives`;
91
- const CLIPS_PREFIX = "/clips";
104
+ const RAWS_PREFIX = "/raws";
105
+ // Back-compat: the clip library was renamed "raws". Old /clips callers are
106
+ // transparently redispatched to /raws (see the alias registered below).
107
+ const CLIPS_COMPAT_PREFIX = "/clips";
92
108
  // Temp folder ("My Files → temporary") hard-TTL. Keep in sync with the
93
109
  // tag-scoped S3 lifecycle rule (30 days) in the CDK stacks and the clip-scan
94
110
  // Lambda's TEMP_SOURCE_TTL_DAYS — records, sweeps, and object lifecycle must
@@ -1659,6 +1675,9 @@ function primitiveOperationPathForJob(job) {
1659
1675
  if (job.templateId === "primitive:audio_normalize" && job.operationName === "run") {
1660
1676
  return `${PRIMITIVES_PREFIX}/audio/normalize`;
1661
1677
  }
1678
+ if (job.templateId === "primitive:speech_regenerate" && job.operationName === "run") {
1679
+ return `${PRIMITIVES_PREFIX}/audio/regenerate-speech`;
1680
+ }
1662
1681
  if (job.templateId === "primitive:brainstorm_hooks" && job.operationName === "run") {
1663
1682
  return `${PRIMITIVES_PREFIX}/brainstorm/hooks`;
1664
1683
  }
@@ -3147,6 +3166,7 @@ function buildPrimitiveEditorDocsRoutes() {
3147
3166
  { 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." },
3148
3167
  { 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)." },
3149
3168
  { 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." },
3169
+ { 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." },
3150
3170
  { 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." },
3151
3171
  { 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." },
3152
3172
  { 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." },
@@ -3242,11 +3262,11 @@ async function buildClipsEditorChatBoot(input) {
3242
3262
  const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md") || null;
3243
3263
  const availableProviders = (await normalizeEditorChatProviderRows(input.customer.id)).map((entry) => entry.provider);
3244
3264
  const docsRoutes = [
3245
- { method: "GET", path: `${CLIPS_PREFIX}/feed`, summary: "List the caller's reusable clip library (optional ?q, ?source, ?limit)." },
3246
- { method: "POST", path: `${CLIPS_PREFIX}/search`, summary: "Hybrid structured + semantic clip search. Body is { query } (natural language) or { criteria } (structured filter)." },
3247
- { method: "POST", path: `${CLIPS_PREFIX}/scan`, summary: "Import a source video and mine it into clips. Body accepts source_url (a public/durable video URL), raw_s3_uri, or s3_key, plus an optional prompt describing which scenes to keep (e.g. 'people with food, no on-screen text, vertical, 5-10s')." },
3248
- { method: "GET", path: `${CLIPS_PREFIX}/scan/:scanId`, summary: "Poll clip-scan status for an in-progress import." },
3249
- { method: "GET", path: `${CLIPS_PREFIX}/presets`, summary: "List the caller's saved clip-search presets." }
3265
+ { method: "GET", path: `${RAWS_PREFIX}/feed`, summary: "List the caller's reusable clip library (optional ?q, ?source, ?limit)." },
3266
+ { method: "POST", path: `${RAWS_PREFIX}/search`, summary: "Hybrid structured + semantic clip search. Body is { query } (natural language) or { criteria } (structured filter)." },
3267
+ { method: "POST", path: `${RAWS_PREFIX}/scan`, summary: "Import a source video and mine it into clips. Body accepts source_url (a public/durable video URL), raw_s3_uri, or s3_key, plus an optional prompt describing which scenes to keep (e.g. 'people with food, no on-screen text, vertical, 5-10s')." },
3268
+ { method: "GET", path: `${RAWS_PREFIX}/scan/:scanId`, summary: "Poll clip-scan status for an in-progress import." },
3269
+ { method: "GET", path: `${RAWS_PREFIX}/presets`, summary: "List the caller's saved clip-search presets." }
3250
3270
  ];
3251
3271
  return {
3252
3272
  apiUrl: resolveEditorChatApiUrl(),
@@ -3610,7 +3630,7 @@ function normalizeEditorChatPath(input) {
3610
3630
  // and NEVER runs GhostCut on the long-form source — "no text" is a
3611
3631
  // scene-selection filter; caption removal stays a per-finished-clip
3612
3632
  // primitive (/api/v1/primitives/videos/remove-captions).
3613
- CLIPS_PREFIX,
3633
+ RAWS_PREFIX,
3614
3634
  // Public skill knowledge: the vendored skill packs (load_skill fetches
3615
3635
  // these too) and per-template SKILL.md manifests. Read-only markdown.
3616
3636
  "/skill/",
@@ -3745,7 +3765,7 @@ function normalizeOperationRequestTracer(tracer) {
3745
3765
  }
3746
3766
  function createTemplateHttpTool(input) {
3747
3767
  return tool({
3748
- 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.",
3768
+ 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.",
3749
3769
  inputSchema: z.object({
3750
3770
  method: z.enum(["GET", "POST"]).describe("HTTP method to use."),
3751
3771
  path: z.string().min(1).describe("Absolute path or same-origin URL for an allowed Vidfarm REST route."),
@@ -4884,8 +4904,278 @@ function redirectToPublicPath(c, pathname) {
4884
4904
  return redirect(c, `${pathname}${url.search}`, 303);
4885
4905
  }
4886
4906
  app.get("/", async (c) => {
4887
- return redirect(c, "/discover");
4907
+ // vidfarm.cc landing page — standalone marketing page (own HTML/CSS in
4908
+ // src/landing-page.ts) using the farmville-saas-ux design system. No longer
4909
+ // redirects to /discover; the studio itself still lives at /discover.
4910
+ const customer = await getOptionalPreferredBrowserCustomer(c);
4911
+ return c.html(renderLandingPage({ isLoggedIn: Boolean(customer) }));
4912
+ });
4913
+ // /reskin/* — isolated design-system migration surface (see src/reskin/).
4914
+ // Each page is a standalone document with its own CSS; renders sample data so
4915
+ // it's viewable without auth. Ported pages are wired here one at a time.
4916
+ app.get("/reskin", (c) => c.html(renderReskinIndex()));
4917
+ app.get("/settings", async (c) => {
4918
+ const customer = await requireBrowserCustomer(c);
4919
+ if (!customer) {
4920
+ setLoginReturnPath(c, currentPathWithSearch(c));
4921
+ return redirect(c, "/login");
4922
+ }
4923
+ const flash = consumeSettingsFlash(c);
4924
+ const flockPosterApiKey = customer.flockposterApiKey?.trim() || null;
4925
+ const activeTab = flash?.tab === "wallet" || flash?.tab === "channels" || flash?.tab === "developer"
4926
+ ? flash.tab
4927
+ : c.req.query("tab") === "wallet" || c.req.query("tab") === "channels" || c.req.query("tab") === "developer"
4928
+ ? c.req.query("tab")
4929
+ : "profile";
4930
+ const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md");
4931
+ const emailChannels = await getEmailPseudoChannels(customer.id, c);
4932
+ const [wallet, walletEvents] = await Promise.all([
4933
+ fetchBillingWalletSummary(customer.id).catch((error) => ({
4934
+ balanceUsd: 0, totalFundsAddedUsd: 0, totalChargeUsd: 0, totalBaseCostUsd: 0,
4935
+ totalEventCount: 0, updatedAtMs: 0,
4936
+ unavailableReason: error instanceof Error ? error.message : "Unable to load wallet."
4937
+ })),
4938
+ fetchBillingWalletEvents({ customerId: customer.id, limit: 50 }).catch((error) => ({
4939
+ events: [],
4940
+ nextCursor: null,
4941
+ unavailableReason: error instanceof Error ? error.message : "Unable to load billing history."
4942
+ }))
4943
+ ]);
4944
+ return c.html(renderReskinSettings({
4945
+ userId: customer.id,
4946
+ currentAccountId: customer.id,
4947
+ name: customer.name,
4948
+ notice: flash?.notice ?? null,
4949
+ error: flash?.error ?? null,
4950
+ activeTab,
4951
+ openFlockPosterDialog: c.req.query("connect") === "flockposter" || (activeTab === "channels" && !flockPosterApiKey),
4952
+ email: customer.email,
4953
+ isPaidPlan: customer.isPaidPlan,
4954
+ isDeveloper: customer.isDeveloper,
4955
+ about: customer.about,
4956
+ groupchatUrl: customer.groupchatUrl,
4957
+ flockposterApiKey: flockPosterApiKey,
4958
+ emailChannelIconUrl: await getEmailChannelIconUrl(c),
4959
+ emailChannels,
4960
+ vidfarmApiKey: await getVisibleApiKey(customer.id),
4961
+ billingUrl: config.VIDFARM_BILLING_URL?.trim() || null,
4962
+ directorSkill,
4963
+ wallet,
4964
+ walletEvents,
4965
+ flockposterChannels: [],
4966
+ flockposterChannelsError: null,
4967
+ providerKeys: (await listProviderKeysWithSecretsForCustomer(customer.id)).map((entry) => ({
4968
+ id: String(entry.id),
4969
+ provider: String(entry.provider),
4970
+ label: entry.label ? String(entry.label) : null,
4971
+ secret: readStoredSecret(String(entry.secret)),
4972
+ status: String(entry.status),
4973
+ created_at: String(entry.created_at),
4974
+ last_used_at: entry.last_used_at ? String(entry.last_used_at) : null
4975
+ })),
4976
+ attachments: (await serverlessRecords.listUserAttachments(customer.id)).map((attachment) => serializeUserAttachment(c, attachment))
4977
+ }));
4978
+ });
4979
+ // Library hosts two tabs at distinct URLs (approved projects + mined clips).
4980
+ app.get("/library", (c) => redirect(c, "/library/approved"));
4981
+ const reskinLibraryHandler = async (c, tab) => {
4982
+ const customer = await getPreferredBrowserCustomer(c);
4983
+ if (!customer) {
4984
+ setLoginReturnPath(c, currentPathWithSearch(c));
4985
+ return redirect(c, "/login");
4986
+ }
4987
+ const emailChannels = await getEmailPseudoChannels(customer.id, c);
4988
+ const flockPoster = await getFlockPosterChannels(customer.flockposterApiKey?.trim() || null);
4989
+ const scheduleChannels = [
4990
+ ...emailChannels.map((channel) => ({ id: channel.id, name: channel.name, title: channel.title, handle: channel.handle, platform: channel.platform, status: channel.status, avatarUrl: channel.avatarUrl })),
4991
+ ...flockPoster.channels.map((channel) => ({ id: channel.id, name: channel.title, title: channel.title, handle: channel.handle, platform: channel.platform, status: channel.status, avatarUrl: channel.avatarUrl }))
4992
+ ];
4993
+ const localPosts = await Promise.all((await serverlessRecords.listReadyPosts(customer.id))
4994
+ .filter((post) => (post.source ?? "legacy") === "hyperframes")
4995
+ .filter((post) => post.status === "ready" || post.status === "scheduled")
4996
+ .map(async (post) => ({ ...serializeReadyPost(c, post, { includePrivate: true }), template_id: post.compositionId ?? null })));
4997
+ const posts = await mergeUpstreamReadyPosts(localPosts);
4998
+ return c.html(renderReskinLibrary(tab, {
4999
+ userId: customer.id,
5000
+ currentAccountId: customer.id,
5001
+ name: customer.name,
5002
+ email: customer.email,
5003
+ posts,
5004
+ schedule: { channels: scheduleChannels, connectHref: "/settings?tab=channels&connect=flockposter" },
5005
+ editorChat: null
5006
+ }));
5007
+ };
5008
+ app.get("/library/approved", (c) => reskinLibraryHandler(c, "approved"));
5009
+ app.get("/library/raws", (c) => reskinLibraryHandler(c, "raws"));
5010
+ app.get("/library/logs", (c) => reskinLibraryHandler(c, "logs")); // job-runs, folded in
5011
+ app.get("/library/clips", (c) => redirect(c, "/library/raws")); // renamed Clips → Raws
5012
+ // Discover has three modes selected via ?view= (feed default / swipe / categories).
5013
+ // Path segments are avoided because /discover/feed, /discover/swipe/* are JSON APIs.
5014
+ app.get("/discover", async (c) => {
5015
+ const view = c.req.query("view");
5016
+ const mode = view === "swipe" || view === "categories" ? view : "feed";
5017
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5018
+ return c.html(renderReskinDiscover(mode, { accountId: customer?.id, isLoggedIn: Boolean(customer) }));
5019
+ });
5020
+ app.get("/chat", async (c) => {
5021
+ const customer = await getPreferredBrowserCustomer(c);
5022
+ if (!customer) {
5023
+ setLoginReturnPath(c, currentPathWithSearch(c));
5024
+ return redirect(c, "/login");
5025
+ }
5026
+ return c.html(renderReskinChat({
5027
+ userId: customer.id,
5028
+ currentAccountId: customer.id,
5029
+ name: customer.name,
5030
+ email: customer.email,
5031
+ editorChat: await buildChatBrainstormEditorChatBoot({ customer })
5032
+ }));
5033
+ });
5034
+ // /reskin/inpaint — the "Create Image" masked-image editor (reached from the
5035
+ // chat page's studio-mode select). Auth-gated like the other tools.
5036
+ app.get("/inpaint", async (c) => {
5037
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5038
+ if (!customer) {
5039
+ setLoginReturnPath(c, currentPathWithSearch(c));
5040
+ return redirect(c, "/login");
5041
+ }
5042
+ return c.html(renderReskinInpaint({ accountId: customer.id, isLoggedIn: true }));
5043
+ });
5044
+ app.get("/calendar", async (c) => {
5045
+ const customer = await requireBrowserCustomer(c);
5046
+ if (!customer) {
5047
+ setLoginReturnPath(c, currentPathWithSearch(c));
5048
+ return redirect(c, "/login");
5049
+ }
5050
+ const flockPosterApiKey = customer.flockposterApiKey?.trim() || null;
5051
+ const emailChannels = await getEmailPseudoChannels(customer.id, c);
5052
+ return c.html(renderReskinCalendar({
5053
+ userId: customer.id,
5054
+ currentAccountId: customer.id,
5055
+ name: customer.name,
5056
+ email: customer.email,
5057
+ channels: emailChannels.map((channel) => ({
5058
+ id: channel.id,
5059
+ name: channel.title,
5060
+ handle: channel.handle,
5061
+ platform: channel.platform,
5062
+ avatarUrl: channel.avatarUrl,
5063
+ platformIconUrl: channel.platformIconUrl
5064
+ })),
5065
+ posts: [],
5066
+ loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
5067
+ hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
5068
+ connectChannelsHref: "/settings?tab=channels&connect=flockposter"
5069
+ }));
5070
+ });
5071
+ // Job runs were renamed and folded into the Library "Logs" tab.
5072
+ app.get("/job-runs", (c) => redirect(c, "/library/logs"));
5073
+ app.get("/agency", async (c) => {
5074
+ const customer = await requireBrowserCustomer(c);
5075
+ return c.html(renderReskinAgency({
5076
+ userId: customer?.id ?? null,
5077
+ currentAccountId: customer?.id ?? null,
5078
+ name: customer?.name ?? null,
5079
+ email: customer?.email ?? null,
5080
+ loggedIn: Boolean(customer)
5081
+ }));
5082
+ });
5083
+ app.get("/pricing", async (c) => {
5084
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5085
+ return c.html(renderReskinPricing({ email: customer?.email ?? "" }));
5086
+ });
5087
+ app.get("/login", (c) => c.html(renderReskinLogin({ mode: getLoginMode(c.req.query("mode")), routePrefix: "/login" })));
5088
+ app.post("/login/password", async (c) => {
5089
+ const mode = getLoginMode(c.req.query("mode"));
5090
+ const routePrefix = "/login";
5091
+ const parsed = passwordLoginSchema.safeParse(await parseFormBody(c));
5092
+ if (!parsed.success)
5093
+ return c.html(renderReskinLogin({ mode, routePrefix, error: "Enter a valid email and password." }), 400);
5094
+ try {
5095
+ const result = await auth.authenticateWithPassword(parsed.data.email, parsed.data.password);
5096
+ if (result.status === "pricing")
5097
+ return redirect(c, "/pricing");
5098
+ setBrowserSession(c, result.customer);
5099
+ return redirect(c, resolvePostLoginRedirect(c, result.customer));
5100
+ }
5101
+ catch (error) {
5102
+ return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, error: error instanceof Error ? error.message : "Unable to login." }), 401);
5103
+ }
5104
+ });
5105
+ app.post("/login/otp/request", async (c) => {
5106
+ const mode = getLoginMode(c.req.query("mode"));
5107
+ const routePrefix = "/login";
5108
+ const parsed = otpRequestSchema.safeParse(await parseFormBody(c));
5109
+ if (!parsed.success)
5110
+ return c.html(renderReskinLogin({ mode, routePrefix, error: "Enter a valid email address." }), 400);
5111
+ try {
5112
+ const result = await auth.requestOtp(parsed.data.email);
5113
+ const message = result.delivery === "console" && result.code
5114
+ ? `Local dev mode: OTP is ${result.code}.`
5115
+ : "OTP sent. Enter the code to continue.";
5116
+ return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, otpSent: true, message }));
5117
+ }
5118
+ catch (error) {
5119
+ return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, error: error instanceof Error ? error.message : "Unable to send OTP." }), 502);
5120
+ }
5121
+ });
5122
+ app.post("/login/otp/verify", async (c) => {
5123
+ const mode = getLoginMode(c.req.query("mode"));
5124
+ const routePrefix = "/login";
5125
+ const parsed = otpVerifySchema.safeParse(await parseFormBody(c));
5126
+ if (!parsed.success)
5127
+ return c.html(renderReskinLogin({ mode, routePrefix, error: "Enter the email and 6-digit OTP." }), 400);
5128
+ try {
5129
+ const result = await auth.verifyOtpForBrowserLogin(parsed.data.email, parsed.data.code, parsed.data.name);
5130
+ if (result.status === "pricing") {
5131
+ clearBrowserSession(c, result.customer.id);
5132
+ return redirect(c, "/pricing");
5133
+ }
5134
+ setBrowserSession(c, result.customer);
5135
+ return redirect(c, resolvePostLoginRedirect(c, result.customer));
5136
+ }
5137
+ catch (error) {
5138
+ return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, otpSent: true, error: error instanceof Error ? error.message : "Unable to verify OTP." }), 401);
5139
+ }
5140
+ });
5141
+ app.post("/logout", async (c) => {
5142
+ const formBody = await parseFormBody(c);
5143
+ const customer = await requireBrowserCustomer(c, formBody);
5144
+ if (customer) {
5145
+ clearBrowserSession(c, customer.id);
5146
+ }
5147
+ return redirect(c, "/login");
4888
5148
  });
5149
+ app.get("/help", async (c) => {
5150
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5151
+ return c.html(renderReskinHelp({
5152
+ account: { isLoggedIn: Boolean(customer), userId: customer?.id ?? null, displayName: customer?.name ?? null, email: customer?.email ?? null },
5153
+ expand: c.req.query("expand") ?? null
5154
+ }));
5155
+ });
5156
+ // The reskin IS the app now — every old /reskin/* URL 301/302s to its promoted
5157
+ // canonical route so existing bookmarks/links keep working. The /reskin gallery
5158
+ // (above) is kept as a dev reference; its cards link to the canonical routes.
5159
+ app.get("/reskin/settings", (c) => redirect(c, "/settings"));
5160
+ app.get("/reskin/library", (c) => redirect(c, "/library/approved"));
5161
+ app.get("/reskin/library/approved", (c) => redirect(c, "/library/approved"));
5162
+ app.get("/reskin/library/raws", (c) => redirect(c, "/library/raws"));
5163
+ app.get("/reskin/library/logs", (c) => redirect(c, "/library/logs"));
5164
+ app.get("/reskin/library/clips", (c) => redirect(c, "/library/raws"));
5165
+ app.get("/reskin/clips", (c) => redirect(c, "/library/raws"));
5166
+ app.get("/reskin/discover", (c) => redirect(c, "/discover"));
5167
+ app.get("/reskin/discover/:mode", (c) => {
5168
+ const m = c.req.param("mode");
5169
+ return redirect(c, m === "swipe" || m === "categories" ? `/discover?view=${m}` : "/discover");
5170
+ });
5171
+ app.get("/reskin/chat", (c) => redirect(c, "/chat"));
5172
+ app.get("/reskin/inpaint", (c) => redirect(c, "/inpaint"));
5173
+ app.get("/reskin/calendar", (c) => redirect(c, "/calendar"));
5174
+ app.get("/reskin/job-runs", (c) => redirect(c, "/library/logs"));
5175
+ app.get("/reskin/agency", (c) => redirect(c, "/agency"));
5176
+ app.get("/reskin/pricing", (c) => redirect(c, "/pricing"));
5177
+ app.get("/reskin/help", (c) => redirect(c, "/help"));
5178
+ app.get("/reskin/login", (c) => redirect(c, "/login"));
4889
5179
  app.get("/discover", async (c) => renderApprovedHomepage(c));
4890
5180
  app.get("/editor/:templateId", async (c) => {
4891
5181
  const templateId = c.req.param("templateId");
@@ -5168,6 +5458,314 @@ app.get("/discover/popular", async (c) => {
5168
5458
  next_cursor: null
5169
5459
  });
5170
5460
  });
5461
+ // ---------------------------------------------------------------------------
5462
+ // Swipe mode: a Tinder-style deck of the customer's own product, auto-recut
5463
+ // from decomposed + caption-clean templates. Previews are live HyperFrames
5464
+ // compositions (the card iframe loads /composition/current.html?fork=), never a
5465
+ // baked MP4 — the MP4 render only happens on approve.
5466
+ function serializeSwipeCard(record) {
5467
+ return {
5468
+ id: record.id,
5469
+ template_id: record.templateId,
5470
+ fork_id: record.forkId,
5471
+ source_title: record.sourceTitle,
5472
+ title: record.tailoredTitle ?? record.sourceTitle ?? record.templateId,
5473
+ caption: record.tailoredCaption ?? "",
5474
+ pinned_comment: record.tailoredPinnedComment ?? null,
5475
+ // Self-contained live composition doc that scales itself to the card iframe
5476
+ // and autoplays muted+looping (see /discover/swipe/:id/preview).
5477
+ preview_url: record.forkId ? `/discover/swipe/${encodeURIComponent(record.id)}/preview` : null,
5478
+ duration_seconds: record.durationSeconds ?? null,
5479
+ status: record.status,
5480
+ ready_post_id: record.readyPostId ?? null
5481
+ };
5482
+ }
5483
+ // Current un-swiped deck for the caller (oldest first = top of stack) plus a
5484
+ // count of variants still customizing so the client can show a "preparing"
5485
+ // placeholder and know whether to keep refilling.
5486
+ app.get("/discover/swipe/stack", async (c) => {
5487
+ const customer = await requireBrowserCustomer(c);
5488
+ if (!customer)
5489
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
5490
+ const listed = await serverlessRecords.listSwipeCustomizationsForCustomer(customer.id);
5491
+ // Lazily supersede un-swiped variants built by an older customize pipeline so
5492
+ // stale (e.g. double-captioned) cards never reach the deck; their templates
5493
+ // become eligible again and the client's refill loop rebuilds them properly.
5494
+ const rows = await reconcileSwipeRows(customer.id, listed);
5495
+ const ready = rows
5496
+ .filter((r) => r.status === "ready" && r.forkId)
5497
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
5498
+ return c.json({
5499
+ ok: true,
5500
+ target_depth: SWIPE_STACK_TARGET_DEPTH,
5501
+ customizing: rows.filter((r) => r.status === "customizing").length,
5502
+ buffered: ready.length,
5503
+ product_description_set: Boolean((customer.about ?? "").trim()),
5504
+ stack: ready.map(serializeSwipeCard)
5505
+ });
5506
+ });
5507
+ // Compositions are authored at their design resolution (data-width×data-height,
5508
+ // root = 100vw×100vh, caption font-size in absolute px). In a small card iframe
5509
+ // that renders everything ~2-3× oversized, so this injects a script that sizes
5510
+ // the root to its design px and uniformly transform-scales it to fit the iframe
5511
+ // (fonts scale too). It also mutes every media element and drives __player so
5512
+ // the preview autoplays (muted autoplay is the only kind browsers allow) and
5513
+ // loops. Kept OUT of the shared /composition/current.html so the editor stage is
5514
+ // unaffected.
5515
+ function injectSwipePreviewFit(doc, cardId) {
5516
+ const inject = `<style>
5517
+ html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
5518
+ [data-composition-id] { position: absolute; transform-origin: top left; }
5519
+ </style>
5520
+ <script>(function () {
5521
+ var CARD_ID = ${JSON.stringify(cardId)};
5522
+ function boot() {
5523
+ var root = document.querySelector('[data-composition-id]');
5524
+ if (!root) { return; }
5525
+ var dw = parseFloat(root.getAttribute('data-width')) || 720;
5526
+ var dh = parseFloat(root.getAttribute('data-height')) || 1280;
5527
+ root.style.width = dw + 'px';
5528
+ root.style.height = dh + 'px';
5529
+ function fit() {
5530
+ var vw = window.innerWidth || dw, vh = window.innerHeight || dh;
5531
+ var s = Math.min(vw / dw, vh / dh);
5532
+ root.style.transform = 'scale(' + s + ')';
5533
+ root.style.left = ((vw - dw * s) / 2) + 'px';
5534
+ root.style.top = ((vh - dh * s) / 2) + 'px';
5535
+ }
5536
+ fit();
5537
+ window.addEventListener('resize', fit);
5538
+ // Sound: the deck (same-origin parent) owns the toggle and names which
5539
+ // card may be audible — only ever the TOP one (the 2nd iframe is
5540
+ // preloaded; without the id gate both would play audio at once). Cards
5541
+ // always START muted (the only autoplay browsers permit); the tick below
5542
+ // re-syncs to the parent's flags, so a toggle or a card advancing to the
5543
+ // top picks up sound within ~400ms of the user's gesture.
5544
+ function wantSound() {
5545
+ try {
5546
+ var w = window.parent;
5547
+ return Boolean(w) && w.__VF_SWIPE_SOUND__ === true && w.__VF_SWIPE_SOUND_TOP__ === CARD_ID;
5548
+ } catch (e) { return false; }
5549
+ }
5550
+ // Drive ONLY the player's main mute. The runtime's applyMediaState
5551
+ // computes each element's muted state as previewMuted || authored-muted
5552
+ // and honors per-clip data-volume — force-unmuting every <video>/<audio>
5553
+ // here (the first version of this script) made muted-authored overlapping
5554
+ // clips of the same source all audible at once (the duplicate-audio bug).
5555
+ // Touching elements directly would also pollute the runtime's
5556
+ // initialMuted capture, so hands off; before __player boots nothing plays
5557
+ // (playback only ever starts via __player.play()), so there is no
5558
+ // unmuted-autoplay window to guard.
5559
+ function applySound(on) {
5560
+ try { if (window.__player && window.__player.setMuted) { window.__player.setMuted(!on); } } catch (e) {}
5561
+ }
5562
+ function play() {
5563
+ try { if (window.__player) { window.__player.play(); } } catch (e) {}
5564
+ }
5565
+ applySound(false);
5566
+ play();
5567
+ var lastWant = false;
5568
+ var stalled = 0;
5569
+ var mutedFallback = false;
5570
+ setInterval(function () {
5571
+ try {
5572
+ var want = wantSound();
5573
+ if (want !== lastWant) { lastWant = want; mutedFallback = false; stalled = 0; }
5574
+ applySound(want && !mutedFallback);
5575
+ if (!window.__player || !window.__player.getDuration) { return; }
5576
+ var t = window.__player.getTime(), d = window.__player.getDuration();
5577
+ if (d > 0 && t >= d - 0.08) { window.__player.seek(0, { keepPlaying: true }); }
5578
+ else if (!window.__player.isPlaying()) {
5579
+ stalled += 1;
5580
+ // Unmuted playback needs prior interaction with the page; if the
5581
+ // browser keeps blocking it (e.g. Safari), drop back to muted so
5582
+ // the preview never freezes on a frame.
5583
+ if (stalled >= 3 && want && !mutedFallback) { mutedFallback = true; applySound(false); }
5584
+ play();
5585
+ } else { stalled = 0; }
5586
+ } catch (e) {}
5587
+ }, 400);
5588
+ }
5589
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); }
5590
+ })();</script>`;
5591
+ return /<\/body>/i.test(doc) ? doc.replace(/<\/body>/i, `${inject}\n</body>`) : `${doc}\n${inject}`;
5592
+ }
5593
+ // The self-fitting, muted-autoplay live composition doc for one deck card.
5594
+ app.get("/discover/swipe/:id/preview", async (c) => {
5595
+ const customer = await requireBrowserCustomer(c);
5596
+ if (!customer)
5597
+ return c.text("Unauthorized", 401);
5598
+ const record = await serverlessRecords.getSwipeCustomization(c.req.param("id"));
5599
+ if (!record || record.customerId !== customer.id || !record.forkId)
5600
+ return c.text("Not found", 404);
5601
+ const html = await storage.readText(storage.compositionForkWorkingKey(record.forkId, "composition.html"));
5602
+ if (!html)
5603
+ return c.text("Composition missing", 404);
5604
+ const doc = injectSwipePreviewFit(rewriteHyperframesDraftCompositionHtml(html, `forks/${encodeURIComponent(record.forkId)}`), record.id);
5605
+ return c.html(doc, 200, { "cache-control": "no-store" });
5606
+ });
5607
+ // Progressive top-up: customize exactly ONE more eligible template (awaited, so
5608
+ // it is cloud-safe — the work finishes inside the request). The client calls
5609
+ // this to fill the deck on open and +1 after every swipe. BYOK LLM.
5610
+ app.post("/discover/swipe/refill", async (c) => {
5611
+ const customer = await requireBrowserCustomer(c);
5612
+ if (!customer)
5613
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
5614
+ const productDescription = (customer.about ?? "").trim();
5615
+ if (!productDescription) {
5616
+ return c.json({ ok: false, error: "Add a product / offer description in Settings, then reload Swipe.", need_product_description: true }, 400);
5617
+ }
5618
+ const listed = await serverlessRecords.listSwipeCustomizationsForCustomer(customer.id);
5619
+ const rows = await reconcileSwipeRows(customer.id, listed);
5620
+ const buffered = rows.filter((r) => r.status === "ready" || r.status === "customizing").length;
5621
+ if (buffered >= SWIPE_STACK_TARGET_DEPTH) {
5622
+ return c.json({ ok: true, filled: false, reason: "at_target", buffered });
5623
+ }
5624
+ try {
5625
+ const record = await customizeOneMore({ customerId: customer.id, productDescription });
5626
+ if (!record)
5627
+ return c.json({ ok: true, filled: false, reason: "no_eligible" });
5628
+ return c.json({ ok: true, filled: record.status === "ready", status: record.status, record: serializeSwipeCard(record) });
5629
+ }
5630
+ catch (error) {
5631
+ if (error instanceof NoProviderKeyError)
5632
+ return c.json({ ok: false, error: error.message, need_provider_key: true }, 400);
5633
+ return c.json({ ok: false, error: error instanceof Error ? error.message : "Customization failed." }, 500);
5634
+ }
5635
+ });
5636
+ // Approve: mint the /library post (title/caption now, rendered MP4 backfilled by
5637
+ // finalizeSwipeApproveRender when the render job finishes) and mark the
5638
+ // customization approved. Then the client refills (+1).
5639
+ app.post("/discover/swipe/:id/approve", async (c) => {
5640
+ const customer = await requireBrowserCustomer(c);
5641
+ if (!customer)
5642
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
5643
+ const loaded = await serverlessRecords.getSwipeCustomization(c.req.param("id"));
5644
+ if (!loaded || loaded.customerId !== customer.id)
5645
+ return c.json({ ok: false, error: "Not found" }, 404);
5646
+ // A deck loaded before a pipeline bump can still hold stale cards — supersede
5647
+ // instead of publishing a known-broken variant to /library.
5648
+ const [record] = await reconcileSwipeRows(customer.id, [loaded]);
5649
+ if (record.status !== "ready" || !record.forkId) {
5650
+ return c.json({ ok: false, superseded: record.status === "superseded", error: `Cannot approve a customization in status "${record.status}".` }, 409);
5651
+ }
5652
+ const fork = await serverlessRecords.getCompositionFork(record.forkId);
5653
+ if (!fork)
5654
+ return c.json({ ok: false, error: "Customized composition not found." }, 404);
5655
+ const post = await serverlessRecords.createReadyPost({
5656
+ customerId: customer.id,
5657
+ source: "hyperframes",
5658
+ status: "ready",
5659
+ compositionId: fork.id,
5660
+ tracer: `swipe:${record.id}`,
5661
+ title: record.tailoredTitle,
5662
+ caption: record.tailoredCaption,
5663
+ pinnedComment: record.tailoredPinnedComment,
5664
+ mediaAssets: []
5665
+ });
5666
+ let renderJobId = null;
5667
+ try {
5668
+ const html = await storage.readText(storage.compositionForkWorkingKey(fork.id, "composition.html"));
5669
+ const jsonText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "composition.json"));
5670
+ if (html) {
5671
+ const compositionData = jsonText ? asRecord(JSON.parse(jsonText)) : null;
5672
+ const rendered = await publishCompositionToRenderer({
5673
+ customer,
5674
+ compositionHtml: stampCompositionIdentity(html, { forkId: fork.id, version: (fork.latestVersion ?? 0) + 1 }),
5675
+ compositionData: compositionData ?? null,
5676
+ projectFiles: [],
5677
+ slugId: fork.templateId,
5678
+ title: record.tailoredTitle ?? fork.templateId,
5679
+ tracer: `swipe:${record.id}:render`
5680
+ });
5681
+ if (!("preflight" in rendered)) {
5682
+ renderJobId = rendered.job.id;
5683
+ await serverlessRecords.updateCompositionFork({ forkId: fork.id, patch: { currentRenderJobId: renderJobId } });
5684
+ void finalizeSwipeApproveRender({
5685
+ customerId: customer.id,
5686
+ swipeId: record.id,
5687
+ readyPostId: post.id,
5688
+ renderJobId,
5689
+ title: record.tailoredTitle ?? fork.templateId
5690
+ });
5691
+ }
5692
+ }
5693
+ }
5694
+ catch (error) {
5695
+ console.error("swipe approve render dispatch failed", error instanceof Error ? error.message : error);
5696
+ }
5697
+ const updated = await serverlessRecords.updateSwipeCustomization({
5698
+ customerId: customer.id,
5699
+ id: record.id,
5700
+ patch: { status: "approved", decidedAt: nowIso(), readyPostId: post.id, renderJobId }
5701
+ });
5702
+ return c.json({ ok: true, status: "approved", ready_post_id: post.id, render_job_id: renderJobId, record: serializeSwipeCard(updated) });
5703
+ });
5704
+ // Reject: drop the variant from the deck. It persists (findable in /job-runs).
5705
+ app.post("/discover/swipe/:id/reject", async (c) => {
5706
+ const customer = await requireBrowserCustomer(c);
5707
+ if (!customer)
5708
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
5709
+ const loaded = await serverlessRecords.getSwipeCustomization(c.req.param("id"));
5710
+ if (!loaded || loaded.customerId !== customer.id)
5711
+ return c.json({ ok: false, error: "Not found" }, 404);
5712
+ // Rejecting a stale card would permanently re-consume the template the
5713
+ // supersede migration is trying to free — supersede it instead.
5714
+ const [record] = await reconcileSwipeRows(customer.id, [loaded]);
5715
+ if (record.status !== "ready") {
5716
+ return c.json({ ok: false, superseded: record.status === "superseded", error: `Cannot reject a customization in status "${record.status}".` }, 409);
5717
+ }
5718
+ const updated = await serverlessRecords.updateSwipeCustomization({
5719
+ customerId: customer.id,
5720
+ id: record.id,
5721
+ patch: { status: "rejected", decidedAt: nowIso() }
5722
+ });
5723
+ return c.json({ ok: true, status: "rejected", record: serializeSwipeCard(updated) });
5724
+ });
5725
+ // Poll the approve-time render job to completion and backfill the finished MP4
5726
+ // into the library post's media. In-process (works on a `vidfarm serve` box
5727
+ // where the render also runs in-process); a cloud box's render is driven by its
5728
+ // own worker and this poll simply reads the shared job record until it lands.
5729
+ async function finalizeSwipeApproveRender(input) {
5730
+ const deadline = Date.now() + 5 * 60 * 1000;
5731
+ for (;;) {
5732
+ if (Date.now() > deadline)
5733
+ return;
5734
+ await new Promise((resolve) => setTimeout(resolve, 2500));
5735
+ let job = null;
5736
+ try {
5737
+ job = await jobs.getJobAsync(input.renderJobId);
5738
+ }
5739
+ catch {
5740
+ continue;
5741
+ }
5742
+ const status = String(job?.status ?? "");
5743
+ if (status === "succeeded" || status === "completed") {
5744
+ const url = extractOutputUrlFromJob(job);
5745
+ if (url) {
5746
+ const slug = (input.title || "swipe-ad").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 40) || "swipe-ad";
5747
+ const asset = {
5748
+ id: `swipe-${input.swipeId}`,
5749
+ url,
5750
+ fileName: `${slug}.mp4`,
5751
+ contentType: "video/mp4",
5752
+ kind: "video",
5753
+ role: "primary"
5754
+ };
5755
+ await serverlessRecords.setReadyPostMedia({ customerId: input.customerId, postId: input.readyPostId, mediaAssets: [asset] });
5756
+ }
5757
+ return;
5758
+ }
5759
+ if (status === "failed" || status === "cancelled") {
5760
+ await serverlessRecords.updateSwipeCustomization({
5761
+ customerId: input.customerId,
5762
+ id: input.swipeId,
5763
+ patch: { status: "failed", error: `Approve render ${status}.` }
5764
+ });
5765
+ return;
5766
+ }
5767
+ }
5768
+ }
5171
5769
  function buildTemplateHomepageEntry(template) {
5172
5770
  // Undecomposed templates (no defaultForkId yet) still appear on /discover so
5173
5771
  // users can open them and mint the first fork via the Decompose modal.
@@ -10321,10 +10919,54 @@ app.get("/job-runs/history", async (c) => {
10321
10919
  }
10322
10920
  return true;
10323
10921
  });
10922
+ // Surface Swipe-mode customizations (approved AND rejected, plus in-flight and
10923
+ // failed) in Run History so every auto-recut variant is findable — they don't
10924
+ // pass through the api-call-history middleware, so synthesize them here in the
10925
+ // same entry shape the client already renders. Skipped under a template/tracer
10926
+ // filter since they carry no linked job/template.
10927
+ let entriesWithSwipe = entries;
10928
+ if (!templateFilter && !tracerFilter) {
10929
+ const swipeRows = await serverlessRecords.listSwipeCustomizationsForCustomer(customer.id);
10930
+ // Superseded rows are internal pipeline-migration artifacts, not runs the
10931
+ // customer fired — surfacing them would show phantom in-flight customizes.
10932
+ const swipeEntries = swipeRows.filter((r) => r.status !== "superseded").map((r) => {
10933
+ const action = r.status === "approved" ? "approve" : r.status === "rejected" ? "reject" : "customize";
10934
+ return {
10935
+ id: `swipe:${r.id}`,
10936
+ customerId: customer.id,
10937
+ method: "POST",
10938
+ path: `/discover/swipe/${action}`,
10939
+ routePrefix: "/discover/swipe",
10940
+ tracer: `swipe:${r.templateId}`,
10941
+ jobId: r.renderJobId ?? null,
10942
+ status: r.status === "failed" ? 500 : r.status === "rejected" ? 200 : 202,
10943
+ durationMs: 0,
10944
+ request: { query: {}, body: { template_id: r.templateId, source_title: r.sourceTitle }, bodyOmittedReason: null },
10945
+ response: {
10946
+ body: {
10947
+ swipe_id: r.id,
10948
+ status: r.status,
10949
+ tailored_title: r.tailoredTitle,
10950
+ ready_post_id: r.readyPostId,
10951
+ fork_id: r.forkId,
10952
+ swipe_customization: true
10953
+ },
10954
+ bodyOmittedReason: null
10955
+ },
10956
+ mediaUrls: [],
10957
+ linkedJob: null,
10958
+ outputMediaUrls: [],
10959
+ createdAt: r.updatedAt ?? r.createdAt
10960
+ };
10961
+ });
10962
+ if (swipeEntries.length) {
10963
+ entriesWithSwipe = [...entries, ...swipeEntries].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
10964
+ }
10965
+ }
10324
10966
  const filteredEntries = parseCreatedAtIdCursor(query.cursor)
10325
- ? entries.filter((entry) => (Date.parse(entry.createdAt) < Date.parse(parseCreatedAtIdCursor(query.cursor).createdAt)
10967
+ ? entriesWithSwipe.filter((entry) => (Date.parse(entry.createdAt) < Date.parse(parseCreatedAtIdCursor(query.cursor).createdAt)
10326
10968
  || (entry.createdAt === parseCreatedAtIdCursor(query.cursor).createdAt && entry.id < parseCreatedAtIdCursor(query.cursor).id)))
10327
- : entries;
10969
+ : entriesWithSwipe;
10328
10970
  const page = paginateDescendingByCreatedAt(filteredEntries, limit);
10329
10971
  return c.json({
10330
10972
  entries: page.items,
@@ -11328,7 +11970,7 @@ async function ensureInspirationReadyThenFork(input) {
11328
11970
  try {
11329
11971
  const probe = await probeMediaAsset({
11330
11972
  sourceUrl: inspiration.videoUrl,
11331
- maxDurationSeconds: INSPIRATION_PROBE_MAX_DURATION_SECONDS
11973
+ maxDurationSeconds: input.maxDurationSeconds ?? INSPIRATION_PROBE_MAX_DURATION_SECONDS
11332
11974
  });
11333
11975
  const probedDuration = probe.metadata.durationSeconds;
11334
11976
  if (Number.isFinite(probedDuration) && probedDuration && probedDuration > 0) {
@@ -11408,16 +12050,14 @@ function buildTemplateEditorUrl(publicBaseUrl, templateId) {
11408
12050
  const path = `/editor/${encodeURIComponent(templateId)}`;
11409
12051
  return publicBaseUrl ? `${publicBaseUrl.replace(/\/$/, "")}${path}` : path;
11410
12052
  }
11411
- // Curated Popular picks are short-form: a source longer than the 120s ingest
11412
- // limit is clipped to this many seconds (kept safely under 120 for the probe).
11413
- const POPULAR_MAX_CLIP_SEC = 115;
11414
12053
  // Seed the curated "Popular" catalog from POPULAR_INSPIRATION_GROUPS: ingest
11415
12054
  // each URL once into a real PUBLIC template (same path as admin TikTok
11416
12055
  // submissions) and stamp it with the editorial title + popularGroup +
11417
12056
  // popularRank so it always shows under the /discover Popular toggle and
11418
- // autoplays inline exactly like a Feed card. Over-length sources are clipped to
11419
- // POPULAR_MAX_CLIP_SEC. Idempotent — find-or-create by URL, then (re)stamp.
11420
- // Requires RAPIDAPI_KEY (the download primitive), so run this on staging/prod.
12057
+ // autoplays inline exactly like a Feed card. Curated picks accept ANY length
12058
+ // (the 120s ingest cap is lifted). Idempotent — find-or-create by URL, then
12059
+ // (re)stamp; a legacy 115s clip is re-downloaded in full. Requires RAPIDAPI_KEY
12060
+ // (the download primitive), so run this on staging/prod.
11421
12061
  app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
11422
12062
  try {
11423
12063
  requireSuperagency(c);
@@ -11467,11 +12107,14 @@ app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
11467
12107
  continue;
11468
12108
  }
11469
12109
  let inspiration = await serverlessRecords.findInspirationByOriginalUrl(originalUrl);
11470
- // (Re)start the download only when there's no usable video yet a genuine
11471
- // download failure (no videoUrl) retries, but a pick that DID download and
11472
- // only tripped the duration limit keeps its bytes and gets clipped below.
12110
+ // (Re)start the download when there's no usable video yet (a genuine
12111
+ // download failure), OR the stored video is a legacy 115s clip (videoStorageKey
12112
+ // under popular-clips) long picks are now ingested in full, so a clip must
12113
+ // be re-downloaded from the original source.
12114
+ const isLegacyClip = Boolean(inspiration?.videoStorageKey?.includes("popular-clips"));
11473
12115
  const needsIngest = !inspiration
11474
- || (!inspiration.videoUrl && inspiration.status !== "downloading");
12116
+ || (!inspiration.videoUrl && inspiration.status !== "downloading")
12117
+ || isLegacyClip;
11475
12118
  if (needsIngest) {
11476
12119
  const payload = operation.inputSchema.parse({ source_url: originalUrl, save_manifest: true });
11477
12120
  let job;
@@ -11494,7 +12137,7 @@ app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
11494
12137
  inspiration = inspiration
11495
12138
  ? ((await serverlessRecords.updateInspiration({
11496
12139
  inspirationId: inspiration.id,
11497
- patch: { status: "downloading", downloadJobId: job.id, errorMessage: null }
12140
+ patch: { status: "downloading", downloadJobId: job.id, errorMessage: null, videoUrl: null, videoStorageKey: null, durationSeconds: null }
11498
12141
  })) ?? inspiration)
11499
12142
  : await serverlessRecords.createInspiration({
11500
12143
  customerId: ownerCustomer.id,
@@ -11520,65 +12163,27 @@ app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
11520
12163
  console.error("popular seed download failed", pendingJobId, error instanceof Error ? error.message : error);
11521
12164
  });
11522
12165
  }
11523
- const finalized = await ensureInspirationReadyThenFork({ inspiration, ownerCustomer, publicBaseUrl, note: null });
11524
- let templateId = typeof finalized.template_id === "string" ? finalized.template_id : null;
11525
- let clipped = false;
11526
- let sourceDurationSec = null;
11527
- // Curated Popular picks are short-form. If the source downloaded fine but was
11528
- // rejected ONLY for exceeding the 120s ingest limit, clip it to <=115s and
11529
- // finalize the clip — so a long reference video still becomes a Popular card.
11530
- if (!templateId) {
11531
- const rejected = await serverlessRecords.getInspiration(inspiration.id);
11532
- const tooLong = Boolean(rejected?.videoUrl)
11533
- && ((Number.isFinite(rejected?.durationSeconds) && Number(rejected?.durationSeconds) > POPULAR_MAX_CLIP_SEC)
11534
- || /exceeds/i.test(rejected?.errorMessage ?? ""));
11535
- if (Number.isFinite(rejected?.durationSeconds))
11536
- sourceDurationSec = Number(rejected?.durationSeconds);
11537
- else {
11538
- const m = /([0-9.]+)s exceeds/.exec(rejected?.errorMessage ?? "");
11539
- if (m)
11540
- sourceDurationSec = Number(m[1]);
11541
- }
11542
- if (rejected?.videoUrl && tooLong) {
11543
- try {
11544
- const trimmed = await trimVideoAsset({ sourceUrl: rejected.videoUrl, startMs: 0, durationMs: POPULAR_MAX_CLIP_SEC * 1000 });
11545
- // Store under the public "primitives/" prefix so getPublicUrl can hand
11546
- // back a directly-fetchable S3 URL (other prefixes resolve to null).
11547
- const clipKey = joinStorageKey("primitives", "popular-clips", `${rejected.id}.mp4`);
11548
- const stored = await storage.putBuffer(clipKey, trimmed.bytes, "video/mp4");
11549
- const clipUrl = stored.url ?? storage.getPublicUrl(clipKey);
11550
- if (!clipUrl)
11551
- console.error("popular seed clip: no public URL for", clipKey);
11552
- const clipDurRaw = trimmed.metadata.trimmed?.durationSeconds;
11553
- const clipDur = Number.isFinite(clipDurRaw) && clipDurRaw ? Number(clipDurRaw) : POPULAR_MAX_CLIP_SEC;
11554
- if (clipUrl) {
11555
- const readyInsp = await serverlessRecords.updateInspiration({
11556
- inspirationId: rejected.id,
11557
- patch: {
11558
- videoUrl: clipUrl,
11559
- videoStorageKey: clipKey,
11560
- thumbnailUrl: null,
11561
- durationSeconds: clipDur,
11562
- status: "ready",
11563
- errorMessage: null
11564
- }
11565
- });
11566
- if (readyInsp) {
11567
- const reFinalized = await ensureInspirationReadyThenFork({ inspiration: readyInsp, ownerCustomer, publicBaseUrl, note: null });
11568
- templateId = typeof reFinalized.template_id === "string" ? reFinalized.template_id : null;
11569
- clipped = Boolean(templateId);
11570
- }
11571
- }
11572
- }
11573
- catch (error) {
11574
- console.error("popular seed clip failed", rejected.id, error instanceof Error ? error.message : error);
11575
- }
11576
- }
11577
- }
12166
+ // Curated Popular picks accept ANY length the 120s ingest cap is lifted
12167
+ // here so long reference videos (podcasts, gameplay, etc.) ingest in full.
12168
+ const finalized = await ensureInspirationReadyThenFork({ inspiration, ownerCustomer, publicBaseUrl, note: null, maxDurationSeconds: Number.MAX_SAFE_INTEGER });
12169
+ const templateId = typeof finalized.template_id === "string" ? finalized.template_id : null;
12170
+ let durationSec = null;
11578
12171
  if (templateId) {
12172
+ const done = await serverlessRecords.getInspiration(inspiration.id);
12173
+ durationSec = Number.isFinite(done?.durationSeconds) ? Number(done?.durationSeconds) : null;
12174
+ // Keep the template's video/duration in sync with the (re)ingested source,
12175
+ // so re-seeding a previously-clipped pick swaps in the full-length video.
11579
12176
  await serverlessRecords.updateTemplate({
11580
12177
  templateId,
11581
- patch: { title: item.title, popularGroup: item.group, popularRank: item.rank, visibility: "public" }
12178
+ patch: {
12179
+ title: item.title,
12180
+ popularGroup: item.group,
12181
+ popularRank: item.rank,
12182
+ visibility: "public",
12183
+ ...(done?.videoUrl ? { videoUrl: done.videoUrl } : {}),
12184
+ ...(done?.thumbnailUrl !== undefined ? { thumbnailUrl: done.thumbnailUrl } : {}),
12185
+ durationSeconds: durationSec
12186
+ }
11582
12187
  });
11583
12188
  }
11584
12189
  results.push({
@@ -11588,8 +12193,7 @@ app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
11588
12193
  rank: item.rank,
11589
12194
  template_id: templateId,
11590
12195
  status: templateId ? "ready" : (finalized.status ?? null),
11591
- clipped,
11592
- source_duration_sec: sourceDurationSec,
12196
+ duration_sec: durationSec,
11593
12197
  pending: finalized.pending ?? false
11594
12198
  });
11595
12199
  }
@@ -11774,9 +12378,21 @@ app.use(`${USER_PREFIX}/me/*`, requireAuth);
11774
12378
  // is the public HTML gallery page (like /discover vs /discover/feed). Hono's
11775
12379
  // `/clips/*` also matches the bare `/clips`, so let that one path through
11776
12380
  // unauthenticated.
11777
- const isClipsGalleryPage = (c) => c.req.path === CLIPS_PREFIX || c.req.path === `${CLIPS_PREFIX}/`;
11778
- app.use(`${CLIPS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : requireAuth(c, next)));
11779
- app.use(`${CLIPS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : captureApiCallHistory(c, next)));
12381
+ const isClipsGalleryPage = (c) => c.req.path === RAWS_PREFIX || c.req.path === `${RAWS_PREFIX}/`;
12382
+ app.use(`${RAWS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : requireAuth(c, next)));
12383
+ app.use(`${RAWS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : captureApiCallHistory(c, next)));
12384
+ // Deprecated /clips/* → /raws/* alias: transparently redispatch legacy paths so
12385
+ // existing skills, editor-chat prompts, and the devcli keep working during the
12386
+ // clips→raws rename. Remove once all callers emit /raws.
12387
+ const rawsCompatAlias = (c) => {
12388
+ const url = new URL(c.req.url);
12389
+ url.pathname = RAWS_PREFIX + url.pathname.slice(CLIPS_COMPAT_PREFIX.length);
12390
+ // Redispatch through the app with just the rewritten request — do NOT forward
12391
+ // c.executionCtx (its getter throws in the Node adapter when absent).
12392
+ return app.fetch(new Request(url.toString(), c.req.raw));
12393
+ };
12394
+ app.all(CLIPS_COMPAT_PREFIX, rawsCompatAlias);
12395
+ app.all(`${CLIPS_COMPAT_PREFIX}/*`, rawsCompatAlias);
11780
12396
  app.use(AGENCY_PREFIX, requireAuth);
11781
12397
  app.use(`${AGENCY_PREFIX}/*`, requireAuth);
11782
12398
  app.use(`${API_PREFIX}/rate-limit-status`, requireAuth);
@@ -14604,7 +15220,8 @@ const AI_PRIMITIVE_IDS = new Set([
14604
15220
  "primitive:video_generate",
14605
15221
  "primitive:tts",
14606
15222
  "primitive:stt",
14607
- "primitive:captions"
15223
+ "primitive:captions",
15224
+ "primitive:speech_regenerate"
14608
15225
  ]);
14609
15226
  async function preflightAiProviderKeys(input) {
14610
15227
  if (!operationMayUseAiProvider(input)) {
@@ -15130,6 +15747,9 @@ app.post(`${PRIMITIVES_PREFIX}/stt`, async (c) => createPrimitiveJob(c, { primit
15130
15747
  // Animated-caption cues from narration (STT + word timings + cue paging).
15131
15748
  app.post(`${PRIMITIVES_PREFIX}/audio/captions`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:captions", operationName: "run" }));
15132
15749
  app.post(`${PRIMITIVES_PREFIX}/captions`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:captions", operationName: "run" }));
15750
+ // Voice-matched speech regeneration (profile original voice → reword → TTS).
15751
+ app.post(`${PRIMITIVES_PREFIX}/audio/regenerate-speech`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:speech_regenerate", operationName: "run" }));
15752
+ app.post(`${PRIMITIVES_PREFIX}/regenerate-speech`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:speech_regenerate", operationName: "run" }));
15133
15753
  app.post(`${PRIMITIVES_PREFIX}/brainstorm/hooks`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_hooks", operationName: "run" }));
15134
15754
  app.post(`${PRIMITIVES_PREFIX}/brainstorm/awareness_stages`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_awareness_stages", operationName: "run" }));
15135
15755
  app.post(`${PRIMITIVES_PREFIX}/brainstorm/angles`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_angles", operationName: "run" }));
@@ -16681,7 +17301,7 @@ function clipKeywordMatch(clip, q) {
16681
17301
  // GET /clips — the Clips gallery, rendered in the same shell as the rest of the
16682
17302
  // frontend and reached via the Library page's "Finished / Clips" toggle. The
16683
17303
  // client fetches /clips/feed with the session cookie (mirrors /discover vs /feed).
16684
- app.get(CLIPS_PREFIX, async (c) => {
17304
+ app.get(RAWS_PREFIX, async (c) => {
16685
17305
  const customer = await getPreferredBrowserCustomer(c);
16686
17306
  if (!customer) {
16687
17307
  return redirect(c, "/login");
@@ -16695,7 +17315,7 @@ app.get(CLIPS_PREFIX, async (c) => {
16695
17315
  }));
16696
17316
  });
16697
17317
  // GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q, ?limit).
16698
- app.get(`${CLIPS_PREFIX}/feed`, async (c) => {
17318
+ app.get(`${RAWS_PREFIX}/feed`, async (c) => {
16699
17319
  const customer = requireCustomer(c);
16700
17320
  const limit = clampPageLimit(Number(c.req.query("limit") || "60"), 200);
16701
17321
  const q = c.req.query("q")?.trim();
@@ -16708,7 +17328,7 @@ app.get(`${CLIPS_PREFIX}/feed`, async (c) => {
16708
17328
  return c.json({ clips: await Promise.all(clips.map(serializeClip)) });
16709
17329
  });
16710
17330
  // POST /clips/search — hybrid structured + semantic search.
16711
- app.post(`${CLIPS_PREFIX}/search`, async (c) => {
17331
+ app.post(`${RAWS_PREFIX}/search`, async (c) => {
16712
17332
  const customer = requireCustomer(c);
16713
17333
  try {
16714
17334
  const body = (await c.req.json().catch(() => ({})));
@@ -16756,7 +17376,7 @@ function normalizeMatchScene(s, index) {
16756
17376
  }
16757
17377
  // POST /clips/match — find the top library clips that could REPLACE one scene.
16758
17378
  // Body: a scene annotation, or {criteria, embedding_text|text, limit}.
16759
- app.post(`${CLIPS_PREFIX}/match`, async (c) => {
17379
+ app.post(`${RAWS_PREFIX}/match`, async (c) => {
16760
17380
  const customer = requireCustomer(c);
16761
17381
  try {
16762
17382
  const body = (await c.req.json().catch(() => ({})));
@@ -16779,7 +17399,7 @@ app.post(`${CLIPS_PREFIX}/match`, async (c) => {
16779
17399
  // POST /clips/match-scenes — batch: take a decomposed video's scene annotations
16780
17400
  // and return the top replacement-clip matches per scene (one library read).
16781
17401
  // Body: { scenes | annotations: SceneAnnotation[], limit? }.
16782
- app.post(`${CLIPS_PREFIX}/match-scenes`, async (c) => {
17402
+ app.post(`${RAWS_PREFIX}/match-scenes`, async (c) => {
16783
17403
  const customer = requireCustomer(c);
16784
17404
  try {
16785
17405
  const body = (await c.req.json());
@@ -16820,7 +17440,7 @@ app.post(`${CLIPS_PREFIX}/match-scenes`, async (c) => {
16820
17440
  // pipeline runs the hunt and GET /clips/scan/:scanId polls it. Billing is AWS
16821
17441
  // compute only (clip_scan_lambda + step_functions_standard) — the AI spend is
16822
17442
  // the caller's own provider key (BYOK) and is never wallet-billed.
16823
- app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
17443
+ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
16824
17444
  const customer = requireCustomer(c);
16825
17445
  try {
16826
17446
  const body = (await c.req.json());
@@ -17139,7 +17759,7 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
17139
17759
  // GET /clips/scan-options — what the Import Source modal can run a hunt with:
17140
17760
  // which BYOK providers have saved keys, whether a local agent CLI is available
17141
17761
  // (vidfarm serve boxes only), what "auto" resolves to, and where the hunt runs.
17142
- app.get(`${CLIPS_PREFIX}/scan-options`, async (c) => {
17762
+ app.get(`${RAWS_PREFIX}/scan-options`, async (c) => {
17143
17763
  const customer = requireCustomer(c);
17144
17764
  const keys = await loadCustomerVisionKeys(customer.id);
17145
17765
  const cloudPipeline = Boolean(config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN);
@@ -17384,7 +18004,7 @@ async function mirrorUpstreamClipsToLocal(input) {
17384
18004
  await clipRecords.putScan({ ...(current ?? scan), status: "complete", updated_at: nowIso() });
17385
18005
  }
17386
18006
  // GET /clips/scan/:scanId — poll scan status.
17387
- app.get(`${CLIPS_PREFIX}/scan/:scanId`, async (c) => {
18007
+ app.get(`${RAWS_PREFIX}/scan/:scanId`, async (c) => {
17388
18008
  const customer = requireCustomer(c);
17389
18009
  let scan = await clipRecords.getScan(customer.id, c.req.param("scanId"));
17390
18010
  if (!scan)
@@ -17422,17 +18042,17 @@ app.get(`${CLIPS_PREFIX}/scan/:scanId`, async (c) => {
17422
18042
  return c.json({ scan, source });
17423
18043
  });
17424
18044
  // GET /clips/sources — scanned source videos.
17425
- app.get(`${CLIPS_PREFIX}/sources`, async (c) => {
18045
+ app.get(`${RAWS_PREFIX}/sources`, async (c) => {
17426
18046
  const customer = requireCustomer(c);
17427
18047
  return c.json({ sources: await clipRecords.listSources(customer.id) });
17428
18048
  });
17429
18049
  // GET /clips/presets — built-in + saved presets.
17430
- app.get(`${CLIPS_PREFIX}/presets`, async (c) => {
18050
+ app.get(`${RAWS_PREFIX}/presets`, async (c) => {
17431
18051
  const customer = requireCustomer(c);
17432
18052
  return c.json({ presets: await clipRecords.listPresets(customer.id) });
17433
18053
  });
17434
18054
  // POST /clips/presets — save a custom preset from criteria or a NL query.
17435
- app.post(`${CLIPS_PREFIX}/presets`, async (c) => {
18055
+ app.post(`${RAWS_PREFIX}/presets`, async (c) => {
17436
18056
  const customer = requireCustomer(c);
17437
18057
  try {
17438
18058
  const body = (await c.req.json());
@@ -17460,7 +18080,7 @@ app.post(`${CLIPS_PREFIX}/presets`, async (c) => {
17460
18080
  }
17461
18081
  });
17462
18082
  // DELETE /clips/presets/:presetId — delete a saved (non-builtin) preset.
17463
- app.delete(`${CLIPS_PREFIX}/presets/:presetId`, async (c) => {
18083
+ app.delete(`${RAWS_PREFIX}/presets/:presetId`, async (c) => {
17464
18084
  const customer = requireCustomer(c);
17465
18085
  const id = c.req.param("presetId");
17466
18086
  if (id.startsWith("preset_") && BUILTIN_CLIP_PRESET_IDS.has(id)) {
@@ -17470,7 +18090,7 @@ app.delete(`${CLIPS_PREFIX}/presets/:presetId`, async (c) => {
17470
18090
  return c.json({ ok: true });
17471
18091
  });
17472
18092
  // GET /clips/:clipId/download — presigned redirect to the clip mp4.
17473
- app.get(`${CLIPS_PREFIX}/:clipId/download`, async (c) => {
18093
+ app.get(`${RAWS_PREFIX}/:clipId/download`, async (c) => {
17474
18094
  const customer = requireCustomer(c);
17475
18095
  const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
17476
18096
  if (!clip)
@@ -17481,7 +18101,7 @@ app.get(`${CLIPS_PREFIX}/:clipId/download`, async (c) => {
17481
18101
  return redirect(c, url, 302);
17482
18102
  });
17483
18103
  // GET /clips/:clipId — one clip (+ presigned view/thumbnail urls).
17484
- app.get(`${CLIPS_PREFIX}/:clipId`, async (c) => {
18104
+ app.get(`${RAWS_PREFIX}/:clipId`, async (c) => {
17485
18105
  const customer = requireCustomer(c);
17486
18106
  const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
17487
18107
  if (!clip)
@@ -17489,7 +18109,7 @@ app.get(`${CLIPS_PREFIX}/:clipId`, async (c) => {
17489
18109
  return c.json({ clip: await serializeClip(clip) });
17490
18110
  });
17491
18111
  // DELETE /clips/:clipId — remove a clip record (+ vector).
17492
- app.delete(`${CLIPS_PREFIX}/:clipId`, async (c) => {
18112
+ app.delete(`${RAWS_PREFIX}/:clipId`, async (c) => {
17493
18113
  const customer = requireCustomer(c);
17494
18114
  await clipRecords.deleteClip(customer.id, c.req.param("clipId"));
17495
18115
  return c.json({ ok: true });