@mevdragon/vidfarm-devcli 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/farmville-saas-ux/SKILL.md +156 -0
- package/.agents/skills/farmville-saas-ux/assets/starter.html +294 -0
- package/.agents/skills/farmville-saas-ux/references/components.md +340 -0
- package/.agents/skills/farmville-saas-ux/references/porting-guide.md +121 -0
- package/.agents/skills/farmville-saas-ux/references/tokens.md +271 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -1
- package/.agents/skills/vidfarm-media/references/tts.md +20 -1
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +74 -74
- package/dist/src/account-pages-legacy.js +2 -2
- package/dist/src/app.js +682 -12
- package/dist/src/config.js +13 -0
- package/dist/src/editor-chat.js +3 -2
- package/dist/src/frontend/homepage-client.js +211 -2
- package/dist/src/frontend/homepage-shared.js +197 -0
- package/dist/src/frontend/homepage-store.js +39 -1
- package/dist/src/frontend/homepage-view.js +150 -5
- package/dist/src/homepage.js +234 -18
- package/dist/src/landing-page.js +367 -0
- package/dist/src/primitive-registry.js +278 -2
- package/dist/src/reskin/agency-page.js +255 -0
- package/dist/src/reskin/calendar-page.js +252 -0
- package/dist/src/reskin/chat-page.js +414 -0
- package/dist/src/reskin/discover-page.js +380 -0
- package/dist/src/reskin/document.js +74 -0
- package/dist/src/reskin/help-page.js +318 -0
- package/dist/src/reskin/index-page.js +62 -0
- package/dist/src/reskin/job-runs-page.js +249 -0
- package/dist/src/reskin/library-page.js +515 -0
- package/dist/src/reskin/login-page.js +225 -0
- package/dist/src/reskin/pricing-page.js +359 -0
- package/dist/src/reskin/settings-page.js +353 -0
- package/dist/src/reskin/theme.js +265 -0
- package/dist/src/services/serverless-records.js +63 -0
- package/dist/src/services/swipe-customize.js +434 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +23 -23
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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 (
|
|
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
|
-
?
|
|
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
|
-
:
|
|
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({
|
|
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" }));
|