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