@mevdragon/vidfarm-devcli 0.16.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/dist/src/account-pages-legacy.js +2 -2
- package/dist/src/app.js +441 -77
- package/dist/src/editor-chat.js +2 -2
- package/dist/src/frontend/homepage-client.js +162 -2
- package/dist/src/frontend/homepage-store.js +30 -1
- package/dist/src/frontend/homepage-view.js +111 -4
- package/dist/src/homepage.js +184 -1
- 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 +54 -0
- package/dist/src/services/swipe-customize.js +434 -0
- package/package.json +1 -1
- 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 { 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";
|
|
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
|
|
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";
|
|
@@ -1659,6 +1672,9 @@ function primitiveOperationPathForJob(job) {
|
|
|
1659
1672
|
if (job.templateId === "primitive:audio_normalize" && job.operationName === "run") {
|
|
1660
1673
|
return `${PRIMITIVES_PREFIX}/audio/normalize`;
|
|
1661
1674
|
}
|
|
1675
|
+
if (job.templateId === "primitive:speech_regenerate" && job.operationName === "run") {
|
|
1676
|
+
return `${PRIMITIVES_PREFIX}/audio/regenerate-speech`;
|
|
1677
|
+
}
|
|
1662
1678
|
if (job.templateId === "primitive:brainstorm_hooks" && job.operationName === "run") {
|
|
1663
1679
|
return `${PRIMITIVES_PREFIX}/brainstorm/hooks`;
|
|
1664
1680
|
}
|
|
@@ -3147,6 +3163,7 @@ function buildPrimitiveEditorDocsRoutes() {
|
|
|
3147
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." },
|
|
3148
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)." },
|
|
3149
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." },
|
|
3150
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." },
|
|
3151
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." },
|
|
3152
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." },
|
|
@@ -3745,7 +3762,7 @@ function normalizeOperationRequestTracer(tracer) {
|
|
|
3745
3762
|
}
|
|
3746
3763
|
function createTemplateHttpTool(input) {
|
|
3747
3764
|
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.",
|
|
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.",
|
|
3749
3766
|
inputSchema: z.object({
|
|
3750
3767
|
method: z.enum(["GET", "POST"]).describe("HTTP method to use."),
|
|
3751
3768
|
path: z.string().min(1).describe("Absolute path or same-origin URL for an allowed Vidfarm REST route."),
|
|
@@ -4884,8 +4901,37 @@ function redirectToPublicPath(c, pathname) {
|
|
|
4884
4901
|
return redirect(c, `${pathname}${url.search}`, 303);
|
|
4885
4902
|
}
|
|
4886
4903
|
app.get("/", async (c) => {
|
|
4887
|
-
|
|
4888
|
-
|
|
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()));
|
|
4889
4935
|
app.get("/discover", async (c) => renderApprovedHomepage(c));
|
|
4890
4936
|
app.get("/editor/:templateId", async (c) => {
|
|
4891
4937
|
const templateId = c.req.param("templateId");
|
|
@@ -5168,6 +5214,314 @@ app.get("/discover/popular", async (c) => {
|
|
|
5168
5214
|
next_cursor: null
|
|
5169
5215
|
});
|
|
5170
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
|
+
}
|
|
5171
5525
|
function buildTemplateHomepageEntry(template) {
|
|
5172
5526
|
// Undecomposed templates (no defaultForkId yet) still appear on /discover so
|
|
5173
5527
|
// users can open them and mint the first fork via the Decompose modal.
|
|
@@ -10321,10 +10675,54 @@ app.get("/job-runs/history", async (c) => {
|
|
|
10321
10675
|
}
|
|
10322
10676
|
return true;
|
|
10323
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
|
+
}
|
|
10324
10722
|
const filteredEntries = parseCreatedAtIdCursor(query.cursor)
|
|
10325
|
-
?
|
|
10723
|
+
? entriesWithSwipe.filter((entry) => (Date.parse(entry.createdAt) < Date.parse(parseCreatedAtIdCursor(query.cursor).createdAt)
|
|
10326
10724
|
|| (entry.createdAt === parseCreatedAtIdCursor(query.cursor).createdAt && entry.id < parseCreatedAtIdCursor(query.cursor).id)))
|
|
10327
|
-
:
|
|
10725
|
+
: entriesWithSwipe;
|
|
10328
10726
|
const page = paginateDescendingByCreatedAt(filteredEntries, limit);
|
|
10329
10727
|
return c.json({
|
|
10330
10728
|
entries: page.items,
|
|
@@ -11328,7 +11726,7 @@ async function ensureInspirationReadyThenFork(input) {
|
|
|
11328
11726
|
try {
|
|
11329
11727
|
const probe = await probeMediaAsset({
|
|
11330
11728
|
sourceUrl: inspiration.videoUrl,
|
|
11331
|
-
maxDurationSeconds: INSPIRATION_PROBE_MAX_DURATION_SECONDS
|
|
11729
|
+
maxDurationSeconds: input.maxDurationSeconds ?? INSPIRATION_PROBE_MAX_DURATION_SECONDS
|
|
11332
11730
|
});
|
|
11333
11731
|
const probedDuration = probe.metadata.durationSeconds;
|
|
11334
11732
|
if (Number.isFinite(probedDuration) && probedDuration && probedDuration > 0) {
|
|
@@ -11408,16 +11806,14 @@ function buildTemplateEditorUrl(publicBaseUrl, templateId) {
|
|
|
11408
11806
|
const path = `/editor/${encodeURIComponent(templateId)}`;
|
|
11409
11807
|
return publicBaseUrl ? `${publicBaseUrl.replace(/\/$/, "")}${path}` : path;
|
|
11410
11808
|
}
|
|
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
11809
|
// Seed the curated "Popular" catalog from POPULAR_INSPIRATION_GROUPS: ingest
|
|
11415
11810
|
// each URL once into a real PUBLIC template (same path as admin TikTok
|
|
11416
11811
|
// submissions) and stamp it with the editorial title + popularGroup +
|
|
11417
11812
|
// popularRank so it always shows under the /discover Popular toggle and
|
|
11418
|
-
// autoplays inline exactly like a Feed card.
|
|
11419
|
-
//
|
|
11420
|
-
//
|
|
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.
|
|
11421
11817
|
app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
|
|
11422
11818
|
try {
|
|
11423
11819
|
requireSuperagency(c);
|
|
@@ -11467,11 +11863,14 @@ app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
|
|
|
11467
11863
|
continue;
|
|
11468
11864
|
}
|
|
11469
11865
|
let inspiration = await serverlessRecords.findInspirationByOriginalUrl(originalUrl);
|
|
11470
|
-
// (Re)start the download
|
|
11471
|
-
// download failure
|
|
11472
|
-
//
|
|
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"));
|
|
11473
11871
|
const needsIngest = !inspiration
|
|
11474
|
-
|| (!inspiration.videoUrl && inspiration.status !== "downloading")
|
|
11872
|
+
|| (!inspiration.videoUrl && inspiration.status !== "downloading")
|
|
11873
|
+
|| isLegacyClip;
|
|
11475
11874
|
if (needsIngest) {
|
|
11476
11875
|
const payload = operation.inputSchema.parse({ source_url: originalUrl, save_manifest: true });
|
|
11477
11876
|
let job;
|
|
@@ -11494,7 +11893,7 @@ app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
|
|
|
11494
11893
|
inspiration = inspiration
|
|
11495
11894
|
? ((await serverlessRecords.updateInspiration({
|
|
11496
11895
|
inspirationId: inspiration.id,
|
|
11497
|
-
patch: { status: "downloading", downloadJobId: job.id, errorMessage: null }
|
|
11896
|
+
patch: { status: "downloading", downloadJobId: job.id, errorMessage: null, videoUrl: null, videoStorageKey: null, durationSeconds: null }
|
|
11498
11897
|
})) ?? inspiration)
|
|
11499
11898
|
: await serverlessRecords.createInspiration({
|
|
11500
11899
|
customerId: ownerCustomer.id,
|
|
@@ -11520,65 +11919,27 @@ app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
|
|
|
11520
11919
|
console.error("popular seed download failed", pendingJobId, error instanceof Error ? error.message : error);
|
|
11521
11920
|
});
|
|
11522
11921
|
}
|
|
11523
|
-
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
11527
|
-
|
|
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
|
-
}
|
|
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;
|
|
11578
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.
|
|
11579
11932
|
await serverlessRecords.updateTemplate({
|
|
11580
11933
|
templateId,
|
|
11581
|
-
patch: {
|
|
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
|
+
}
|
|
11582
11943
|
});
|
|
11583
11944
|
}
|
|
11584
11945
|
results.push({
|
|
@@ -11588,8 +11949,7 @@ app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
|
|
|
11588
11949
|
rank: item.rank,
|
|
11589
11950
|
template_id: templateId,
|
|
11590
11951
|
status: templateId ? "ready" : (finalized.status ?? null),
|
|
11591
|
-
|
|
11592
|
-
source_duration_sec: sourceDurationSec,
|
|
11952
|
+
duration_sec: durationSec,
|
|
11593
11953
|
pending: finalized.pending ?? false
|
|
11594
11954
|
});
|
|
11595
11955
|
}
|
|
@@ -14604,7 +14964,8 @@ const AI_PRIMITIVE_IDS = new Set([
|
|
|
14604
14964
|
"primitive:video_generate",
|
|
14605
14965
|
"primitive:tts",
|
|
14606
14966
|
"primitive:stt",
|
|
14607
|
-
"primitive:captions"
|
|
14967
|
+
"primitive:captions",
|
|
14968
|
+
"primitive:speech_regenerate"
|
|
14608
14969
|
]);
|
|
14609
14970
|
async function preflightAiProviderKeys(input) {
|
|
14610
14971
|
if (!operationMayUseAiProvider(input)) {
|
|
@@ -15130,6 +15491,9 @@ app.post(`${PRIMITIVES_PREFIX}/stt`, async (c) => createPrimitiveJob(c, { primit
|
|
|
15130
15491
|
// Animated-caption cues from narration (STT + word timings + cue paging).
|
|
15131
15492
|
app.post(`${PRIMITIVES_PREFIX}/audio/captions`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:captions", operationName: "run" }));
|
|
15132
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" }));
|
|
15133
15497
|
app.post(`${PRIMITIVES_PREFIX}/brainstorm/hooks`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_hooks", operationName: "run" }));
|
|
15134
15498
|
app.post(`${PRIMITIVES_PREFIX}/brainstorm/awareness_stages`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_awareness_stages", operationName: "run" }));
|
|
15135
15499
|
app.post(`${PRIMITIVES_PREFIX}/brainstorm/angles`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_angles", operationName: "run" }));
|