@mevdragon/vidfarm-devcli 0.9.0 → 0.11.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/README.md +37 -1
- package/SKILL.director.md +123 -0
- package/SKILL.platform.md +15 -2
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +550 -184
- package/dist/src/cli.js +397 -14
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/devcli/speech.js +175 -0
- package/dist/src/editor-chat.js +3 -2
- package/dist/src/frontend/homepage-view.js +0 -5
- package/dist/src/page-shell.js +47 -0
- package/dist/src/primitive-context.js +16 -0
- package/dist/src/primitive-registry.js +239 -0
- package/dist/src/services/billing.js +3 -0
- package/dist/src/services/clip-curation/ffmpeg.js +59 -17
- package/dist/src/services/clip-curation/gemini.js +72 -23
- package/dist/src/services/clip-curation/hunt.js +332 -0
- package/dist/src/services/clip-curation/index.js +3 -1
- package/dist/src/services/clip-curation/local-agent.js +247 -0
- package/dist/src/services/clip-curation/refine.js +7 -29
- package/dist/src/services/clip-curation/scan.js +37 -10
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/provider-errors.js +74 -0
- package/dist/src/services/providers.js +30 -429
- package/dist/src/services/serverless-records.js +9 -2
- package/dist/src/services/speech.js +467 -0
- package/dist/src/services/storage.js +16 -1
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +17 -17
package/dist/src/app.js
CHANGED
|
@@ -45,10 +45,10 @@ import { ServerlessProviderKeyService } from "./services/serverless-provider-key
|
|
|
45
45
|
import { ServerlessTemplateConfigService } from "./services/serverless-template-configs.js";
|
|
46
46
|
import { parseHTML } from "linkedom";
|
|
47
47
|
import { joinStorageKey, StorageService } from "./services/storage.js";
|
|
48
|
-
import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
|
|
48
|
+
import { DescribeExecutionCommand, SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
|
|
49
49
|
import { clipRecords, makeUserPreset } from "./services/clip-records.js";
|
|
50
50
|
import { matchScenesToClips, searchUserClips } from "./services/clip-search.js";
|
|
51
|
-
import { BUILTIN_PRESETS, ClipModelClient, estimateScanCostFromDuration } from "./services/clip-curation/index.js";
|
|
51
|
+
import { BUILTIN_PRESETS, ClipModelClient, effectiveHuntDurationSec, estimateScanCostFromDuration, normalizeHuntSpec, parseClipHuntPrompt, resolveDurationBand } from "./services/clip-curation/index.js";
|
|
52
52
|
import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
|
|
53
53
|
import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
|
|
54
54
|
import { createPrimitiveJobContext } from "./primitive-context.js";
|
|
@@ -59,7 +59,8 @@ import { hasUpstreamKey, isUpstreamEnabled, proxyRequestToUpstream, seedFromUpst
|
|
|
59
59
|
import { TemplateSourceService } from "./services/template-sources.js";
|
|
60
60
|
import { TemplateSourceAlreadyRegisteredError } from "./services/template-sources.js";
|
|
61
61
|
import { renderCustomInpaintPage } from "./template-editor-pages.js";
|
|
62
|
-
import {
|
|
62
|
+
import { TEMPLATE_EDITOR_SHELL_STYLES } from "./template-editor-shell.js";
|
|
63
|
+
import { escapeAttribute, escapeHtml, escapeJsonForHtml, renderBrandLockup, renderPageShell, renderPrimaryNav } from "./page-shell.js";
|
|
63
64
|
import { renderReadyPostScheduleData, renderReadyPostScheduleModal, renderReadyPostScheduleScript, renderReadyPostScheduleStyles } from "./ready-post-schedule-component.js";
|
|
64
65
|
const auth = new AuthService();
|
|
65
66
|
const billing = new BillingService();
|
|
@@ -81,6 +82,11 @@ const INSPIRATIONS_PREFIX = `${API_PREFIX}/inspirations`;
|
|
|
81
82
|
const VIDEOS_PREFIX = `${API_PREFIX}/videos`;
|
|
82
83
|
const PRIMITIVES_PREFIX = `${API_PREFIX}/primitives`;
|
|
83
84
|
const CLIPS_PREFIX = "/clips";
|
|
85
|
+
// Temp folder ("My Files → temporary") hard-TTL. Keep in sync with the
|
|
86
|
+
// tag-scoped S3 lifecycle rule (30 days) in the CDK stacks and the clip-scan
|
|
87
|
+
// Lambda's TEMP_SOURCE_TTL_DAYS — records, sweeps, and object lifecycle must
|
|
88
|
+
// agree or users see phantom files (record without object) or orphans.
|
|
89
|
+
const TEMP_FILE_TTL_DAYS = 30;
|
|
84
90
|
const APPROVED_POSTS_PREFIX = `${API_PREFIX}/approved/posts`;
|
|
85
91
|
const APPROVED_POSTS_PUBLIC_PREFIX = "/approved/posts";
|
|
86
92
|
const ACCOUNT_FRONTEND_PREFIX = "/u/:userId";
|
|
@@ -3083,6 +3089,8 @@ function buildPrimitiveEditorDocsRoutes() {
|
|
|
3083
3089
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/audio/probe`, summary: "Primitive audio probe job. Body must be { tracer, payload: { source_audio_url }, webhook_url? }. Returns durable JSON metadata." },
|
|
3084
3090
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/audio/concat`, summary: "Primitive audio concat job. Body must be { tracer, payload: { source_audio_urls, output_format?, audio_bitrate_kbps? }, webhook_url? }." },
|
|
3085
3091
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/audio/normalize`, summary: "Primitive audio normalization job. Body must be { tracer, payload: { source_audio_url, output_format?, audio_bitrate_kbps?, sample_rate_hz? }, webhook_url? }." },
|
|
3092
|
+
{ 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\"). Returns a durable platform audio URL (mp3 or wav). Alias: POST /api/v1/primitives/tts." },
|
|
3093
|
+
{ 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." },
|
|
3086
3094
|
{ 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." },
|
|
3087
3095
|
{ 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." },
|
|
3088
3096
|
{ 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." },
|
|
@@ -3174,6 +3182,40 @@ async function buildChatBrainstormEditorChatBoot(input) {
|
|
|
3174
3182
|
}
|
|
3175
3183
|
};
|
|
3176
3184
|
}
|
|
3185
|
+
async function buildClipsEditorChatBoot(input) {
|
|
3186
|
+
const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md") || null;
|
|
3187
|
+
const availableProviders = (await normalizeEditorChatProviderRows(input.customer.id)).map((entry) => entry.provider);
|
|
3188
|
+
const docsRoutes = [
|
|
3189
|
+
{ method: "GET", path: `${CLIPS_PREFIX}/feed`, summary: "List the caller's reusable clip library (optional ?q, ?source, ?limit)." },
|
|
3190
|
+
{ method: "POST", path: `${CLIPS_PREFIX}/search`, summary: "Hybrid structured + semantic clip search. Body is { query } (natural language) or { criteria } (structured filter)." },
|
|
3191
|
+
{ method: "POST", path: `${CLIPS_PREFIX}/scan`, summary: "Import a source video and mine it into clips. Body accepts source_url (a public/durable video URL), raw_s3_uri, or s3_key, plus an optional prompt describing which scenes to keep (e.g. 'people with food, no on-screen text, vertical, 5-10s')." },
|
|
3192
|
+
{ method: "GET", path: `${CLIPS_PREFIX}/scan/:scanId`, summary: "Poll clip-scan status for an in-progress import." },
|
|
3193
|
+
{ method: "GET", path: `${CLIPS_PREFIX}/presets`, summary: "List the caller's saved clip-search presets." }
|
|
3194
|
+
];
|
|
3195
|
+
return {
|
|
3196
|
+
apiUrl: resolveEditorChatApiUrl(),
|
|
3197
|
+
threadsUrl: `${API_PREFIX}/editor-chat/threads`,
|
|
3198
|
+
vidfarmApiKey: await getVisibleApiKey(input.customer.id),
|
|
3199
|
+
cachedContext: {
|
|
3200
|
+
directorSkill,
|
|
3201
|
+
templateSkill: null,
|
|
3202
|
+
availableProviders,
|
|
3203
|
+
currentTemplateConfig: null
|
|
3204
|
+
},
|
|
3205
|
+
template: {
|
|
3206
|
+
page: "docs",
|
|
3207
|
+
tracerId: null,
|
|
3208
|
+
tracers: [],
|
|
3209
|
+
defaultRequestTracer: null,
|
|
3210
|
+
activeTracer: null,
|
|
3211
|
+
templateId: "chat-brainstorm",
|
|
3212
|
+
templateSlug: "clip-curator",
|
|
3213
|
+
templateTitle: "Clip curator",
|
|
3214
|
+
templateDescription: "Search your reusable clip library and import new source videos to mine into clips.",
|
|
3215
|
+
docsRoutes
|
|
3216
|
+
}
|
|
3217
|
+
};
|
|
3218
|
+
}
|
|
3177
3219
|
function resolveEditorChatApiUrl() {
|
|
3178
3220
|
return config.PUBLIC_EDITOR_CHAT_URL?.trim() || `${API_PREFIX}/editor-chat`;
|
|
3179
3221
|
}
|
|
@@ -3504,7 +3546,15 @@ function normalizeEditorChatPath(input) {
|
|
|
3504
3546
|
// advances the job so the copilot must be explicit about wanting it, but it
|
|
3505
3547
|
// lives under the same prefix and is protected by its own auth guard, which
|
|
3506
3548
|
// is fine.
|
|
3507
|
-
COMPOSITIONS_PREFIX
|
|
3549
|
+
COMPOSITIONS_PREFIX,
|
|
3550
|
+
// Clip hunting (long-form → short-form): search/feed the caller's clip
|
|
3551
|
+
// library, start a scan (POST /clips/scan with source_url|s3_key|
|
|
3552
|
+
// temp_file_id + prompt/ranges/duration/aspect), and poll its status. The
|
|
3553
|
+
// scan route is wallet-gated + billed for AWS compute only (BYOK AI keys),
|
|
3554
|
+
// and NEVER runs GhostCut on the long-form source — "no text" is a
|
|
3555
|
+
// scene-selection filter; caption removal stays a per-finished-clip
|
|
3556
|
+
// primitive (/api/v1/primitives/videos/remove-captions).
|
|
3557
|
+
CLIPS_PREFIX
|
|
3508
3558
|
];
|
|
3509
3559
|
const parsed = trimmed.startsWith("http://") || trimmed.startsWith("https://")
|
|
3510
3560
|
? new URL(trimmed)
|
|
@@ -3634,7 +3684,7 @@ function normalizeOperationRequestTracer(tracer) {
|
|
|
3634
3684
|
}
|
|
3635
3685
|
function createTemplateHttpTool(input) {
|
|
3636
3686
|
return tool({
|
|
3637
|
-
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, 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, 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 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. 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.",
|
|
3687
|
+
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. 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?, 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. 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.",
|
|
3638
3688
|
inputSchema: z.object({
|
|
3639
3689
|
method: z.enum(["GET", "POST"]).describe("HTTP method to use."),
|
|
3640
3690
|
path: z.string().min(1).describe("Absolute path or same-origin URL for an allowed Vidfarm REST route."),
|
|
@@ -4777,6 +4827,7 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}/discover`, (c) => redirectToPublicPath(c, "/
|
|
|
4777
4827
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/help`, (c) => redirectToPublicPath(c, "/help"));
|
|
4778
4828
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat`, (c) => dispatchScopedGetToLegacyRoute(c, "/chat"));
|
|
4779
4829
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/library`, (c) => dispatchScopedGetToLegacyRoute(c, "/library"));
|
|
4830
|
+
app.get(`${ACCOUNT_FRONTEND_PREFIX}/clips`, (c) => dispatchScopedGetToLegacyRoute(c, "/clips"));
|
|
4780
4831
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/inpaint"));
|
|
4781
4832
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar"));
|
|
4782
4833
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings`, (c) => dispatchScopedGetToLegacyRoute(c, "/settings"));
|
|
@@ -13724,7 +13775,9 @@ const AI_PRIMITIVE_IDS = new Set([
|
|
|
13724
13775
|
"primitive:image_generate",
|
|
13725
13776
|
"primitive:image_edit",
|
|
13726
13777
|
"primitive:image_inpaint",
|
|
13727
|
-
"primitive:video_generate"
|
|
13778
|
+
"primitive:video_generate",
|
|
13779
|
+
"primitive:tts",
|
|
13780
|
+
"primitive:stt"
|
|
13728
13781
|
]);
|
|
13729
13782
|
async function preflightAiProviderKeys(input) {
|
|
13730
13783
|
if (!operationMayUseAiProvider(input)) {
|
|
@@ -13929,6 +13982,14 @@ async function createPrimitiveJob(c, input) {
|
|
|
13929
13982
|
}
|
|
13930
13983
|
throw error;
|
|
13931
13984
|
}
|
|
13985
|
+
if (!config.VIDFARM_JOB_STATE_MACHINE_ARN) {
|
|
13986
|
+
// `vidfarm serve` boxes have no Step Functions worker — run the job
|
|
13987
|
+
// in-process (detached, client keeps polling) instead of letting it sit
|
|
13988
|
+
// queued forever, mirroring the local render path.
|
|
13989
|
+
void runPrimitiveJobInProcess(job.id).catch((error) => {
|
|
13990
|
+
console.error("local primitive job failed", job.id, error instanceof Error ? error.message : error);
|
|
13991
|
+
});
|
|
13992
|
+
}
|
|
13932
13993
|
return c.json({
|
|
13933
13994
|
job_id: job.id,
|
|
13934
13995
|
tracer: job.tracer,
|
|
@@ -14233,6 +14294,12 @@ app.post(`${PRIMITIVES_PREFIX}/audio/trim`, async (c) => createPrimitiveJob(c, {
|
|
|
14233
14294
|
app.post(`${PRIMITIVES_PREFIX}/audio/probe`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:audio_probe", operationName: "run" }));
|
|
14234
14295
|
app.post(`${PRIMITIVES_PREFIX}/audio/concat`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:audio_concat", operationName: "run" }));
|
|
14235
14296
|
app.post(`${PRIMITIVES_PREFIX}/audio/normalize`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:audio_normalize", operationName: "run" }));
|
|
14297
|
+
// Speech primitives (TTS/STT). Canonical paths follow the audio/* convention;
|
|
14298
|
+
// the flat /tts and /stt aliases keep them reachable under their plain names.
|
|
14299
|
+
app.post(`${PRIMITIVES_PREFIX}/audio/speech`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:tts", operationName: "run" }));
|
|
14300
|
+
app.post(`${PRIMITIVES_PREFIX}/tts`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:tts", operationName: "run" }));
|
|
14301
|
+
app.post(`${PRIMITIVES_PREFIX}/audio/transcribe`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:stt", operationName: "run" }));
|
|
14302
|
+
app.post(`${PRIMITIVES_PREFIX}/stt`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:stt", operationName: "run" }));
|
|
14236
14303
|
app.post(`${PRIMITIVES_PREFIX}/brainstorm/hooks`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_hooks", operationName: "run" }));
|
|
14237
14304
|
app.post(`${PRIMITIVES_PREFIX}/brainstorm/awareness_stages`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_awareness_stages", operationName: "run" }));
|
|
14238
14305
|
app.post(`${PRIMITIVES_PREFIX}/brainstorm/angles`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:brainstorm_angles", operationName: "run" }));
|
|
@@ -14676,7 +14743,9 @@ app.get(`${USER_PREFIX}/me/temporary-files`, async (c) => {
|
|
|
14676
14743
|
return c.json({
|
|
14677
14744
|
files,
|
|
14678
14745
|
folders: Array.from(new Set(folders)).sort(),
|
|
14679
|
-
|
|
14746
|
+
// Temp folder hard-TTLs at 30 days: DynamoDB record TTL + tag-scoped S3
|
|
14747
|
+
// lifecycle + this route's sweep all converge on the same deadline.
|
|
14748
|
+
ttl_days: TEMP_FILE_TTL_DAYS,
|
|
14680
14749
|
max_size_bytes: 100 * 1024 * 1024
|
|
14681
14750
|
});
|
|
14682
14751
|
});
|
|
@@ -14756,6 +14825,10 @@ app.post(`${USER_PREFIX}/me/temporary-files/upload`, async (c) => {
|
|
|
14756
14825
|
const fileId = randomUUID();
|
|
14757
14826
|
const storageKey = storage.userTemporaryFileKey(customer.id, fileId, fileName, folderPath);
|
|
14758
14827
|
await storage.putBuffer(storageKey, buffer, contentType);
|
|
14828
|
+
// The tag drives the bucket's 30-day lifecycle deletion of the original.
|
|
14829
|
+
await storage.putObjectTags(storageKey, { "vidfarm-temp": "1" }).catch((error) => {
|
|
14830
|
+
console.error(`[vidfarm] putObjectTags failed for temporary file key ${storageKey}:`, error);
|
|
14831
|
+
});
|
|
14759
14832
|
await serverlessRecords.createUserTemporaryFile({
|
|
14760
14833
|
id: fileId,
|
|
14761
14834
|
customerId: customer.id,
|
|
@@ -14764,7 +14837,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/upload`, async (c) => {
|
|
|
14764
14837
|
sizeBytes: buffer.byteLength,
|
|
14765
14838
|
storageKey,
|
|
14766
14839
|
folderPath,
|
|
14767
|
-
expiresAt: addSeconds(new Date(),
|
|
14840
|
+
expiresAt: addSeconds(new Date(), TEMP_FILE_TTL_DAYS * 24 * 60 * 60)
|
|
14768
14841
|
});
|
|
14769
14842
|
const saved = await serverlessRecords.getUserTemporaryFile(customer.id, fileId);
|
|
14770
14843
|
if (!saved) {
|
|
@@ -14790,6 +14863,11 @@ app.post(`${USER_PREFIX}/me/temporary-files`, async (c) => {
|
|
|
14790
14863
|
if (!isAllowedAttachment(fileName, contentType)) {
|
|
14791
14864
|
return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
|
|
14792
14865
|
}
|
|
14866
|
+
// Presigned transport: the bytes went straight to S3, so tag server-side at
|
|
14867
|
+
// finalize — the tag drives the bucket's 30-day lifecycle deletion.
|
|
14868
|
+
await storage.putObjectTags(expectedStorageKey, { "vidfarm-temp": "1" }).catch((error) => {
|
|
14869
|
+
console.error(`[vidfarm] putObjectTags failed for temporary file key ${expectedStorageKey}:`, error);
|
|
14870
|
+
});
|
|
14793
14871
|
await serverlessRecords.createUserTemporaryFile({
|
|
14794
14872
|
id: body.file_id,
|
|
14795
14873
|
customerId: customer.id,
|
|
@@ -14798,7 +14876,7 @@ app.post(`${USER_PREFIX}/me/temporary-files`, async (c) => {
|
|
|
14798
14876
|
sizeBytes: body.size_bytes,
|
|
14799
14877
|
storageKey: expectedStorageKey,
|
|
14800
14878
|
folderPath,
|
|
14801
|
-
expiresAt: addSeconds(new Date(),
|
|
14879
|
+
expiresAt: addSeconds(new Date(), TEMP_FILE_TTL_DAYS * 24 * 60 * 60)
|
|
14802
14880
|
});
|
|
14803
14881
|
const saved = await serverlessRecords.getUserTemporaryFile(customer.id, body.file_id);
|
|
14804
14882
|
if (!saved) {
|
|
@@ -15082,179 +15160,347 @@ app.post(`${USER_PREFIX}/me/provider-keys`, async (c) => {
|
|
|
15082
15160
|
// and natural-language→criteria use the caller's saved gemini provider key.
|
|
15083
15161
|
const clipScanSfn = new SFNClient({});
|
|
15084
15162
|
const BUILTIN_CLIP_PRESET_IDS = new Set(BUILTIN_PRESETS.map((p) => p.preset_id));
|
|
15163
|
+
// Pre-scan estimate of the AWS compute the wallet will be billed for a hunt
|
|
15164
|
+
// (Lambda GB-seconds + Step Functions transitions — mirrors the post-hoc
|
|
15165
|
+
// billing in infra/lambda/clip-scan.ts). The BYOK AI spend is estimated
|
|
15166
|
+
// separately (estimateScanCostFromDuration) and is NOT wallet-billed.
|
|
15167
|
+
function estimateClipScanComputeUsd(effectiveDurationSec, targetSceneSec) {
|
|
15168
|
+
const LAMBDA_X86_GB_SECOND_USD = 0.0000166667;
|
|
15169
|
+
const LAMBDA_REQUEST_USD = 0.20 / 1_000_000;
|
|
15170
|
+
const STEP_FUNCTIONS_STANDARD_TRANSITION_USD = 0.000025;
|
|
15171
|
+
const CLIP_SCAN_MEMORY_GB = 3;
|
|
15172
|
+
const avgSceneSec = Math.max(2, targetSceneSec ?? 4);
|
|
15173
|
+
const scenes = Math.max(1, Math.ceil(effectiveDurationSec / avgSceneSec));
|
|
15174
|
+
// probe decodes the windowed footage once; each scene worker extracts +
|
|
15175
|
+
// uploads a clip (dominated by a fixed overhead + a share of re-encode time).
|
|
15176
|
+
const probeSeconds = 15 + effectiveDurationSec * 0.08;
|
|
15177
|
+
const perSceneSeconds = 8 + avgSceneSec * 0.6;
|
|
15178
|
+
const finalizeSeconds = 3;
|
|
15179
|
+
const lambdaSeconds = probeSeconds + scenes * perSceneSeconds + finalizeSeconds;
|
|
15180
|
+
const gbSeconds = lambdaSeconds * CLIP_SCAN_MEMORY_GB;
|
|
15181
|
+
const lambdaUsd = gbSeconds * LAMBDA_X86_GB_SECOND_USD + (scenes + 2) * LAMBDA_REQUEST_USD;
|
|
15182
|
+
const stepFunctionsUsd = (3 + scenes) * STEP_FUNCTIONS_STANDARD_TRANSITION_USD;
|
|
15183
|
+
const baseUsd = Number((lambdaUsd + stepFunctionsUsd).toFixed(6));
|
|
15184
|
+
return {
|
|
15185
|
+
estimated_scenes: scenes,
|
|
15186
|
+
lambda_gb_seconds: Number(gbSeconds.toFixed(1)),
|
|
15187
|
+
aws_compute_usd: baseUsd,
|
|
15188
|
+
// What actually lands on the wallet (platform markup included).
|
|
15189
|
+
estimated_charge_usd: Number(applyMarkupUsd(baseUsd).toFixed(6))
|
|
15190
|
+
};
|
|
15191
|
+
}
|
|
15085
15192
|
// First-class Clips gallery page (served at /clips). Self-contained HTML; the
|
|
15086
15193
|
// client fetches /clips/feed, /clips/presets and /clips/search with the session
|
|
15087
15194
|
// cookie. Standalone browse/search — no video generation required.
|
|
15088
|
-
function renderClipsPage() {
|
|
15089
|
-
|
|
15090
|
-
|
|
15091
|
-
|
|
15092
|
-
|
|
15093
|
-
|
|
15094
|
-
|
|
15095
|
-
|
|
15096
|
-
|
|
15097
|
-
|
|
15098
|
-
|
|
15099
|
-
|
|
15100
|
-
|
|
15101
|
-
|
|
15102
|
-
header.top nav { display:flex; gap:6px; }
|
|
15103
|
-
header.top nav a { padding:6px 12px; border-radius:8px; color:var(--dim); font-weight:600; }
|
|
15104
|
-
header.top nav a.active, header.top nav a:hover { color:var(--text); background:var(--panel2); }
|
|
15105
|
-
.wrap { max-width:1200px; margin:0 auto; padding:28px 24px 80px; }
|
|
15106
|
-
.hero h1 { font-size:26px; margin:0 0 4px; }
|
|
15107
|
-
.hero p { color:var(--dim); margin:0 0 20px; }
|
|
15108
|
-
.searchbar { display:flex; gap:10px; margin-bottom:14px; }
|
|
15109
|
-
.searchbar input { flex:1; background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:10px; padding:12px 14px; font-size:15px; outline:none; }
|
|
15110
|
-
.searchbar input:focus { border-color:var(--accent); }
|
|
15111
|
-
.searchbar button { background:var(--accent); color:#fff; border:0; border-radius:10px; padding:0 18px; font-weight:700; cursor:pointer; }
|
|
15112
|
-
.presets { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:22px; }
|
|
15113
|
-
.chip { background:var(--panel); border:1px solid var(--line); color:var(--dim); border-radius:999px; padding:6px 13px; font-size:13px; font-weight:600; cursor:pointer; }
|
|
15114
|
-
.chip:hover, .chip.active { color:var(--text); border-color:var(--accent); }
|
|
15115
|
-
.status { color:var(--dim); font-size:14px; margin-bottom:16px; min-height:20px; }
|
|
15116
|
-
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:18px; }
|
|
15117
|
-
.card { background:var(--panel); border:1px solid var(--line); border-radius:14px; overflow:hidden; display:flex; flex-direction:column; }
|
|
15118
|
-
.card .thumb { position:relative; aspect-ratio:9/16; background:#000; overflow:hidden; }
|
|
15119
|
-
.card .thumb img, .card .thumb video { width:100%; height:100%; object-fit:cover; display:block; }
|
|
15120
|
-
.card .thumb .range { position:absolute; left:8px; bottom:8px; background:rgba(0,0,0,.7); border-radius:6px; padding:2px 7px; font-size:12px; font-variant-numeric:tabular-nums; }
|
|
15121
|
-
.card .thumb .score { position:absolute; right:8px; top:8px; background:rgba(58,214,160,.15); color:var(--accent2); border:1px solid rgba(58,214,160,.4); border-radius:6px; padding:1px 6px; font-size:11px; font-weight:700; }
|
|
15122
|
-
.card .body { padding:11px 12px 12px; display:flex; flex-direction:column; gap:8px; flex:1; }
|
|
15123
|
-
.card .desc { font-size:13px; color:var(--text); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
|
15124
|
-
.card .tags { display:flex; flex-wrap:wrap; gap:5px; }
|
|
15125
|
-
.card .tag { font-size:11px; color:var(--dim); background:var(--panel2); border-radius:5px; padding:1px 6px; }
|
|
15126
|
-
.card .row { display:flex; align-items:center; justify-content:space-between; margin-top:auto; }
|
|
15127
|
-
.card .src { font-size:11px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:60%; }
|
|
15128
|
-
.card a.dl { font-size:12px; font-weight:700; color:var(--accent); }
|
|
15129
|
-
.empty { text-align:center; color:var(--dim); padding:60px 20px; }
|
|
15130
|
-
.empty code { background:var(--panel2); padding:2px 8px; border-radius:6px; color:var(--accent2); }
|
|
15131
|
-
</style>
|
|
15132
|
-
</head>
|
|
15133
|
-
<body>
|
|
15134
|
-
<header class="top">
|
|
15135
|
-
<div class="brand">Vidfarm</div>
|
|
15136
|
-
<nav>
|
|
15137
|
-
<a href="/discover">Discover</a>
|
|
15138
|
-
<a href="/library">Library</a>
|
|
15139
|
-
<a href="/clips" class="active">Clips</a>
|
|
15140
|
-
</nav>
|
|
15141
|
-
</header>
|
|
15142
|
-
<div class="wrap">
|
|
15143
|
-
<div class="hero">
|
|
15144
|
-
<h1>Clips</h1>
|
|
15145
|
-
<p>Your reusable clip library — mined from long-form video, searchable by tag or natural language.</p>
|
|
15146
|
-
</div>
|
|
15147
|
-
<form class="searchbar" id="searchForm">
|
|
15148
|
-
<input id="q" type="search" placeholder="Search clips — e.g. "someone looks confused after reading a message"" autocomplete="off" />
|
|
15149
|
-
<button type="submit">Search</button>
|
|
15150
|
-
</form>
|
|
15151
|
-
<div class="presets" id="presets"></div>
|
|
15152
|
-
<div class="status" id="status">Loading your clips…</div>
|
|
15153
|
-
<div class="grid" id="grid"></div>
|
|
15154
|
-
</div>
|
|
15155
|
-
<script>
|
|
15156
|
-
(function(){
|
|
15157
|
-
var grid = document.getElementById('grid');
|
|
15158
|
-
var statusEl = document.getElementById('status');
|
|
15159
|
-
var presetsEl = document.getElementById('presets');
|
|
15160
|
-
var form = document.getElementById('searchForm');
|
|
15161
|
-
var input = document.getElementById('q');
|
|
15195
|
+
function renderClipsPage(input) {
|
|
15196
|
+
const libraryHref = withAccountQuery("/library", input.currentAccountId);
|
|
15197
|
+
const clipsHref = withAccountQuery("/clips", input.currentAccountId);
|
|
15198
|
+
return renderPageShell({
|
|
15199
|
+
title: "Clips — VidFarm",
|
|
15200
|
+
mainClass: "is-wide",
|
|
15201
|
+
runtimeBoot: {
|
|
15202
|
+
pageKind: "legacy",
|
|
15203
|
+
title: "Clips",
|
|
15204
|
+
userId: input.userId,
|
|
15205
|
+
editorChat: input.editorChat ?? null
|
|
15206
|
+
},
|
|
15207
|
+
style: `
|
|
15208
|
+
${TEMPLATE_EDITOR_SHELL_STYLES}
|
|
15162
15209
|
|
|
15163
|
-
|
|
15164
|
-
|
|
15210
|
+
.clips-frame { padding-left: 432px; }
|
|
15211
|
+
.clips-shell { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 16px; min-height: 0; }
|
|
15212
|
+
.clips-toolbar { display: grid; gap: 12px; }
|
|
15213
|
+
.clips-toolbar-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
|
15214
|
+
.clips-search { display: flex; gap: 10px; flex: 1 1 320px; min-width: 240px; margin: 0; }
|
|
15215
|
+
.clips-search input { flex: 1; }
|
|
15216
|
+
.clips-search button { flex: 0 0 auto; }
|
|
15217
|
+
.clips-status { min-height: 20px; color: var(--muted); font-size: 0.94rem; }
|
|
15218
|
+
.clips-scroll { min-height: 0; overflow: auto; padding-right: 6px; }
|
|
15219
|
+
.clips-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(216px, 1fr)); gap: 16px; padding-bottom: 24px; }
|
|
15220
|
+
.clip-card {
|
|
15221
|
+
display: flex;
|
|
15222
|
+
flex-direction: column;
|
|
15223
|
+
overflow: hidden;
|
|
15224
|
+
border: 1px solid rgba(255, 255, 255, 0.72);
|
|
15225
|
+
border-radius: var(--radius-sm);
|
|
15226
|
+
background: rgba(255, 252, 247, 0.82);
|
|
15227
|
+
box-shadow: var(--shadow-soft);
|
|
15228
|
+
}
|
|
15229
|
+
.clip-thumb { position: relative; aspect-ratio: 9 / 16; overflow: hidden; background: #12161f; }
|
|
15230
|
+
.clip-thumb img, .clip-thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
15231
|
+
.clip-thumb .clip-noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; }
|
|
15232
|
+
.clip-range {
|
|
15233
|
+
position: absolute; left: 8px; bottom: 8px;
|
|
15234
|
+
padding: 2px 8px; border-radius: 8px;
|
|
15235
|
+
background: rgba(20, 22, 30, 0.72); color: #f4efe6;
|
|
15236
|
+
font-size: 12px; font-variant-numeric: tabular-nums;
|
|
15237
|
+
}
|
|
15238
|
+
.clip-score {
|
|
15239
|
+
position: absolute; right: 8px; top: 8px;
|
|
15240
|
+
padding: 1px 7px; border-radius: 8px;
|
|
15241
|
+
background: rgba(36, 48, 65, 0.92); color: #f3e8cd;
|
|
15242
|
+
font-size: 11px; font-weight: 800;
|
|
15243
|
+
}
|
|
15244
|
+
.clip-body { display: flex; flex-direction: column; gap: 8px; flex: 1; padding: 12px; }
|
|
15245
|
+
.clip-desc { color: var(--ink); font-size: 0.9rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
15246
|
+
.clip-tags { display: flex; flex-wrap: wrap; gap: 5px; }
|
|
15247
|
+
.clip-tag { padding: 2px 7px; border-radius: 6px; background: rgba(244, 236, 223, 0.86); color: #6f7d95; font-size: 11px; }
|
|
15248
|
+
.clip-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; }
|
|
15249
|
+
.clip-src { max-width: 58%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 11px; }
|
|
15250
|
+
.clip-dl { color: #b4823a; font-size: 12px; font-weight: 800; text-decoration: none; }
|
|
15251
|
+
.clip-dl:hover { text-decoration: underline; }
|
|
15252
|
+
.clips-empty { grid-column: 1 / -1; display: grid; place-items: center; min-height: 320px; padding: 48px 20px; text-align: center; color: var(--muted); font-size: 1.05rem; line-height: 1.7; }
|
|
15165
15253
|
|
|
15166
|
-
|
|
15167
|
-
|
|
15168
|
-
|
|
15169
|
-
|
|
15170
|
-
|
|
15171
|
-
|
|
15172
|
-
|
|
15254
|
+
.clips-import-dialog {
|
|
15255
|
+
width: min(30rem, calc(100vw - 1.5rem));
|
|
15256
|
+
padding: 0;
|
|
15257
|
+
border: 1px solid rgba(191, 164, 109, 0.4);
|
|
15258
|
+
border-radius: 26px;
|
|
15259
|
+
background: rgba(255, 252, 247, 0.99);
|
|
15260
|
+
box-shadow: 0 28px 72px rgba(37, 45, 61, 0.22);
|
|
15261
|
+
}
|
|
15262
|
+
.clips-import-dialog::backdrop { background: rgba(31, 38, 53, 0.32); backdrop-filter: blur(6px); }
|
|
15263
|
+
.clips-import-form { display: grid; gap: 14px; margin: 0; padding: 22px 22px 20px; }
|
|
15264
|
+
.clips-import-head { display: grid; gap: 6px; }
|
|
15265
|
+
.clips-import-head h3 { font-size: 1.5rem; letter-spacing: -0.03em; }
|
|
15266
|
+
.clips-import-head p { font-size: 0.9rem; }
|
|
15267
|
+
.clips-import-field { display: grid; gap: 6px; }
|
|
15268
|
+
.clips-import-field textarea { min-height: 96px; border-radius: 18px; }
|
|
15269
|
+
.clips-import-note { min-height: 18px; font-size: 0.85rem; color: var(--muted); }
|
|
15270
|
+
.clips-import-note[data-tone="error"] { color: #9f3d3d; }
|
|
15271
|
+
.clips-import-note[data-tone="success"] { color: #3f7d55; }
|
|
15272
|
+
.clips-import-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
|
15173
15273
|
|
|
15174
|
-
|
|
15175
|
-
|
|
15176
|
-
|
|
15177
|
-
|
|
15178
|
-
|
|
15179
|
-
:
|
|
15180
|
-
|
|
15181
|
-
|
|
15182
|
-
|
|
15183
|
-
|
|
15184
|
-
|
|
15185
|
-
|
|
15186
|
-
|
|
15187
|
-
|
|
15188
|
-
|
|
15189
|
-
|
|
15190
|
-
|
|
15274
|
+
@media (max-width: 1180px) {
|
|
15275
|
+
.clips-frame { padding-left: 0; }
|
|
15276
|
+
.editor-right-rail { position: relative; inset: auto; width: 100%; height: 540px; margin-top: 14px; border-radius: 24px; overflow: hidden; }
|
|
15277
|
+
.editor-chat-panel { border-right: 0; border-radius: 24px; }
|
|
15278
|
+
}
|
|
15279
|
+
@media (min-width: 1025px) and (max-width: 1180px) {
|
|
15280
|
+
html, body { height: auto; min-height: 100%; overflow: auto; overflow-x: hidden; }
|
|
15281
|
+
.app-shell { height: auto; min-height: 100svh; }
|
|
15282
|
+
.page-stack, .frame { height: auto; min-height: 0; overflow: visible; }
|
|
15283
|
+
.clips-scroll { overflow: visible; padding-right: 0; }
|
|
15284
|
+
}
|
|
15285
|
+
@media (max-width: 1024px) {
|
|
15286
|
+
.clips-shell { grid-template-rows: auto auto; }
|
|
15287
|
+
.clips-scroll { overflow: visible; padding-right: 0; }
|
|
15288
|
+
}
|
|
15289
|
+
`,
|
|
15290
|
+
body: `
|
|
15291
|
+
<section class="frame clips-frame">
|
|
15292
|
+
<header class="topbar">
|
|
15293
|
+
${renderBrandLockup({ displayName: input.name, email: input.email, accountId: input.currentAccountId })}
|
|
15294
|
+
<div class="topbar-actions">
|
|
15295
|
+
${renderPrimaryNav({ loggedIn: true, current: "library", accountId: input.currentAccountId })}
|
|
15296
|
+
</div>
|
|
15297
|
+
</header>
|
|
15298
|
+
<div class="frame-body">
|
|
15299
|
+
<section class="clips-shell">
|
|
15300
|
+
<div class="clips-toolbar">
|
|
15301
|
+
<div class="clips-toolbar-row">
|
|
15302
|
+
<div class="view-toggle" role="tablist" aria-label="Library view">
|
|
15303
|
+
<a class="view-toggle-tab" role="tab" href="${escapeAttribute(libraryHref)}">Finished</a>
|
|
15304
|
+
<a class="view-toggle-tab is-active" role="tab" aria-current="page" href="${escapeAttribute(clipsHref)}">Clips</a>
|
|
15305
|
+
</div>
|
|
15306
|
+
<form class="clips-search" id="searchForm">
|
|
15307
|
+
<input id="q" type="search" placeholder="Search clips — e.g. "someone looks confused after reading a message"" autocomplete="off" />
|
|
15308
|
+
<button type="submit">Search</button>
|
|
15309
|
+
</form>
|
|
15310
|
+
<button type="button" class="secondary" id="importSourceBtn">Import Source</button>
|
|
15311
|
+
</div>
|
|
15312
|
+
<div class="clips-status" id="status">Loading your clips…</div>
|
|
15313
|
+
</div>
|
|
15314
|
+
<div class="clips-scroll">
|
|
15315
|
+
<div class="clips-grid" id="grid"></div>
|
|
15316
|
+
</div>
|
|
15317
|
+
</section>
|
|
15318
|
+
</div>
|
|
15319
|
+
</section>
|
|
15320
|
+
<aside class="editor-right-rail" aria-label="Clip curator chat"><div class="editor-chat-panel" data-template-editor-chat-root></div></aside>
|
|
15321
|
+
<dialog class="clips-import-dialog" id="importDialog">
|
|
15322
|
+
<form class="clips-import-form" id="importForm" method="dialog">
|
|
15323
|
+
<div class="clips-import-head">
|
|
15324
|
+
<div class="label">Import source</div>
|
|
15325
|
+
<h3>Mine a video into clips</h3>
|
|
15326
|
+
<p>Paste a video URL — TikTok, YouTube, Instagram, X, or a direct link. We'll scan it and add the matching moments to your clip library.</p>
|
|
15327
|
+
</div>
|
|
15328
|
+
<div class="clips-import-field">
|
|
15329
|
+
<label for="importUrl">Video URL</label>
|
|
15330
|
+
<input id="importUrl" name="url" type="url" placeholder="https://youtube.com/watch?v=… or https://…/video.mp4" autocomplete="off" required />
|
|
15331
|
+
</div>
|
|
15332
|
+
<div class="clips-import-field">
|
|
15333
|
+
<label for="importPrompt">What to clip for</label>
|
|
15334
|
+
<textarea id="importPrompt" name="prompt" placeholder="Clip scenes of people with food, no text on screen, vertical, between 5-10 secs long"></textarea>
|
|
15335
|
+
</div>
|
|
15336
|
+
<div class="clips-import-note" id="importNote"></div>
|
|
15337
|
+
<div class="clips-import-actions">
|
|
15338
|
+
<button type="button" class="secondary" id="importCancel">Cancel</button>
|
|
15339
|
+
<button type="submit" id="importSubmit">Start scan</button>
|
|
15340
|
+
</div>
|
|
15341
|
+
</form>
|
|
15342
|
+
</dialog>
|
|
15343
|
+
<script>
|
|
15344
|
+
(function(){
|
|
15345
|
+
var grid = document.getElementById('grid');
|
|
15346
|
+
var statusEl = document.getElementById('status');
|
|
15347
|
+
var form = document.getElementById('searchForm');
|
|
15348
|
+
var input = document.getElementById('q');
|
|
15349
|
+
|
|
15350
|
+
function fmtClock(sec){ var m=Math.floor(sec/60), s=Math.floor(sec%60); return (m<10?'0':'')+m+':'+(s<10?'0':'')+s; }
|
|
15351
|
+
function esc(t){ var d=document.createElement('div'); d.textContent=(t==null?'':String(t)); return d.innerHTML; }
|
|
15352
|
+
|
|
15353
|
+
function topTags(tags){
|
|
15354
|
+
if(!tags) return [];
|
|
15355
|
+
var out=[];
|
|
15356
|
+
['subject','action','emotion','composition'].forEach(function(k){ (tags[k]||[]).slice(0,1).forEach(function(v){ out.push(v); }); });
|
|
15357
|
+
if(tags.energy) out.push('energy:'+tags.energy);
|
|
15358
|
+
return out.slice(0,4);
|
|
15359
|
+
}
|
|
15360
|
+
|
|
15361
|
+
function card(clip){
|
|
15362
|
+
var range = fmtClock(clip.start_time_sec)+'–'+fmtClock(clip.end_time_sec);
|
|
15363
|
+
var score = (clip.score!=null) ? '<div class="clip-score">'+ (clip.score*100).toFixed(0) +'</div>' : '';
|
|
15364
|
+
var media = clip.thumbnail_url
|
|
15365
|
+
? '<img loading="lazy" src="'+esc(clip.thumbnail_url)+'" onmouseover="var v=this.parentNode.querySelector("video"); if(v){v.style.display="block";v.play();}" />'
|
|
15366
|
+
: '<div class="clip-noimg">no thumb</div>';
|
|
15367
|
+
var vid = clip.view_url ? '<video muted loop playsinline preload="none" style="display:none;position:absolute;inset:0" src="'+esc(clip.view_url)+'"></video>' : '';
|
|
15368
|
+
var tags = topTags(clip.tags).map(function(t){ return '<span class="clip-tag">'+esc(t)+'</span>'; }).join('');
|
|
15369
|
+
return '<div class="clip-card">'
|
|
15370
|
+
+ '<div class="clip-thumb" onmouseleave="var v=this.querySelector("video"); if(v){v.pause();v.style.display="none";}">'+media+vid+'<div class="clip-range">'+range+' · '+clip.duration_sec.toFixed(1)+'s</div>'+score+'</div>'
|
|
15371
|
+
+ '<div class="clip-body">'
|
|
15372
|
+
+ '<div class="clip-desc">'+esc(clip.description||clip.tags&&clip.tags.transcript||'(untagged clip)')+'</div>'
|
|
15373
|
+
+ '<div class="clip-tags">'+tags+'</div>'
|
|
15374
|
+
+ '<div class="clip-row"><span class="clip-src" title="'+esc(clip.source_filename)+'">'+esc(clip.source_filename)+'</span>'
|
|
15375
|
+
+ '<a class="clip-dl" href="/clips/'+encodeURIComponent(clip.clip_id)+'/download">Download</a></div>'
|
|
15376
|
+
+ '</div></div>';
|
|
15377
|
+
}
|
|
15191
15378
|
|
|
15192
|
-
|
|
15193
|
-
|
|
15194
|
-
|
|
15195
|
-
|
|
15196
|
-
|
|
15197
|
-
|
|
15198
|
-
|
|
15199
|
-
|
|
15200
|
-
|
|
15201
|
-
}
|
|
15379
|
+
function render(clips, label){
|
|
15380
|
+
if(!clips || clips.length===0){
|
|
15381
|
+
statusEl.innerHTML = label || '';
|
|
15382
|
+
grid.innerHTML = '<div class="clips-empty">No Clips Yet. Click "Import Source" to begin</div>';
|
|
15383
|
+
return;
|
|
15384
|
+
}
|
|
15385
|
+
statusEl.textContent = (label||'') + clips.length + ' clip' + (clips.length===1?'':'s');
|
|
15386
|
+
grid.innerHTML = clips.map(card).join('');
|
|
15387
|
+
}
|
|
15202
15388
|
|
|
15203
|
-
|
|
15204
|
-
|
|
15205
|
-
|
|
15206
|
-
|
|
15207
|
-
|
|
15208
|
-
|
|
15209
|
-
|
|
15210
|
-
|
|
15389
|
+
function handleAuth(res){
|
|
15390
|
+
if(res.status===401||res.status===403){
|
|
15391
|
+
grid.innerHTML='';
|
|
15392
|
+
statusEl.innerHTML='Sign in to VidFarm to see your clips. Then build a library with <code>vidfarm clips scan</code>.';
|
|
15393
|
+
throw new Error('unauthorized');
|
|
15394
|
+
}
|
|
15395
|
+
return res.json();
|
|
15396
|
+
}
|
|
15211
15397
|
|
|
15212
|
-
|
|
15213
|
-
|
|
15214
|
-
|
|
15215
|
-
|
|
15216
|
-
|
|
15398
|
+
function loadFeed(){
|
|
15399
|
+
statusEl.textContent='Loading your clips…';
|
|
15400
|
+
fetch('/clips/feed?limit=120', {credentials:'same-origin', headers:{Accept:'application/json'}})
|
|
15401
|
+
.then(handleAuth).then(function(d){ render(d.clips, ''); }).catch(function(){});
|
|
15402
|
+
}
|
|
15217
15403
|
|
|
15218
|
-
|
|
15219
|
-
|
|
15220
|
-
|
|
15221
|
-
|
|
15222
|
-
|
|
15223
|
-
|
|
15224
|
-
|
|
15225
|
-
|
|
15404
|
+
function search(query, criteria){
|
|
15405
|
+
statusEl.textContent='Searching…';
|
|
15406
|
+
fetch('/clips/search', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json', Accept:'application/json'}, body: JSON.stringify(criteria?{criteria:criteria, limit:60}:{query:query, limit:60})})
|
|
15407
|
+
.then(handleAuth).then(function(d){
|
|
15408
|
+
var label = (d.semantic?'semantic ':'') + 'results for "' + (query||'preset') + '" — ';
|
|
15409
|
+
render(d.results, label);
|
|
15410
|
+
}).catch(function(){});
|
|
15411
|
+
}
|
|
15226
15412
|
|
|
15227
|
-
|
|
15228
|
-
|
|
15229
|
-
|
|
15230
|
-
|
|
15231
|
-
return '<span class="chip" data-id="'+esc(p.preset_id)+'">'+esc(p.name)+'</span>';
|
|
15232
|
-
}).join('');
|
|
15233
|
-
var byId = {}; (d.presets||[]).forEach(function(p){ byId[p.preset_id]=p; });
|
|
15234
|
-
Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(chip){
|
|
15235
|
-
chip.addEventListener('click', function(){
|
|
15236
|
-
Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(c){ c.classList.remove('active'); });
|
|
15237
|
-
chip.classList.add('active');
|
|
15238
|
-
var p = byId[chip.getAttribute('data-id')];
|
|
15239
|
-
if(p){ input.value=''; search(p.name, p.criteria); }
|
|
15413
|
+
form.addEventListener('submit', function(e){
|
|
15414
|
+
e.preventDefault();
|
|
15415
|
+
var q = input.value.trim();
|
|
15416
|
+
if(q) search(q, null); else loadFeed();
|
|
15240
15417
|
});
|
|
15241
|
-
});
|
|
15242
|
-
}).catch(function(){});
|
|
15243
|
-
}
|
|
15244
15418
|
|
|
15245
|
-
|
|
15246
|
-
|
|
15247
|
-
|
|
15248
|
-
|
|
15249
|
-
|
|
15250
|
-
|
|
15419
|
+
// ── Import Source modal ────────────────────────────────────────
|
|
15420
|
+
var importBtn = document.getElementById('importSourceBtn');
|
|
15421
|
+
var importDialog = document.getElementById('importDialog');
|
|
15422
|
+
var importForm = document.getElementById('importForm');
|
|
15423
|
+
var importUrl = document.getElementById('importUrl');
|
|
15424
|
+
var importPrompt = document.getElementById('importPrompt');
|
|
15425
|
+
var importNote = document.getElementById('importNote');
|
|
15426
|
+
var importCancel = document.getElementById('importCancel');
|
|
15427
|
+
var importSubmit = document.getElementById('importSubmit');
|
|
15428
|
+
var scanPoll = null;
|
|
15251
15429
|
|
|
15252
|
-
|
|
15253
|
-
|
|
15254
|
-
|
|
15255
|
-
|
|
15256
|
-
|
|
15257
|
-
|
|
15430
|
+
function setNote(msg, tone){
|
|
15431
|
+
if(!importNote) return;
|
|
15432
|
+
importNote.textContent = msg || '';
|
|
15433
|
+
if(tone) importNote.setAttribute('data-tone', tone); else importNote.removeAttribute('data-tone');
|
|
15434
|
+
}
|
|
15435
|
+
function openImport(){
|
|
15436
|
+
setNote('');
|
|
15437
|
+
if(importDialog && typeof importDialog.showModal === 'function' && !importDialog.open) importDialog.showModal();
|
|
15438
|
+
if(importUrl) importUrl.focus();
|
|
15439
|
+
}
|
|
15440
|
+
function closeImport(){
|
|
15441
|
+
if(importDialog && importDialog.open) importDialog.close();
|
|
15442
|
+
}
|
|
15443
|
+
function pollScan(scanId){
|
|
15444
|
+
if(scanPoll) clearInterval(scanPoll);
|
|
15445
|
+
var tries = 0;
|
|
15446
|
+
scanPoll = setInterval(function(){
|
|
15447
|
+
tries++;
|
|
15448
|
+
if(tries > 200){ clearInterval(scanPoll); scanPoll=null; statusEl.textContent='Still scanning in the background — check back shortly.'; return; }
|
|
15449
|
+
fetch('/clips/scan/'+encodeURIComponent(scanId), {credentials:'same-origin', headers:{Accept:'application/json'}})
|
|
15450
|
+
.then(function(r){ return r.ok ? r.json() : null; })
|
|
15451
|
+
.then(function(d){
|
|
15452
|
+
if(!d) return;
|
|
15453
|
+
var src = d.source || {};
|
|
15454
|
+
var status = src.status || (d.scan && d.scan.status) || 'running';
|
|
15455
|
+
if(status==='complete'){
|
|
15456
|
+
clearInterval(scanPoll); scanPoll=null;
|
|
15457
|
+
statusEl.textContent='Import complete — refreshing your clips…';
|
|
15458
|
+
loadFeed();
|
|
15459
|
+
} else if(status==='failed'||status==='error'){
|
|
15460
|
+
clearInterval(scanPoll); scanPoll=null;
|
|
15461
|
+
statusEl.textContent='Import failed: '+(src.error||(d.scan&&d.scan.error)||'the source could not be scanned.');
|
|
15462
|
+
} else {
|
|
15463
|
+
statusEl.textContent='Scanning source… ('+status+')';
|
|
15464
|
+
}
|
|
15465
|
+
}).catch(function(){});
|
|
15466
|
+
}, 4000);
|
|
15467
|
+
}
|
|
15468
|
+
|
|
15469
|
+
if(importBtn) importBtn.addEventListener('click', openImport);
|
|
15470
|
+
if(importCancel) importCancel.addEventListener('click', closeImport);
|
|
15471
|
+
if(importDialog) importDialog.addEventListener('click', function(e){
|
|
15472
|
+
var rect = importDialog.getBoundingClientRect();
|
|
15473
|
+
if(e.clientX<rect.left||e.clientX>rect.right||e.clientY<rect.top||e.clientY>rect.bottom) closeImport();
|
|
15474
|
+
});
|
|
15475
|
+
if(importForm) importForm.addEventListener('submit', function(e){
|
|
15476
|
+
e.preventDefault();
|
|
15477
|
+
var url = (importUrl && importUrl.value || '').trim();
|
|
15478
|
+
var prompt = (importPrompt && importPrompt.value || '').trim();
|
|
15479
|
+
if(!url){ setNote('Enter a video URL.', 'error'); return; }
|
|
15480
|
+
setNote('Starting scan…');
|
|
15481
|
+
if(importSubmit) importSubmit.disabled = true;
|
|
15482
|
+
fetch('/clips/scan', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json', Accept:'application/json'}, body: JSON.stringify({source_url:url, prompt:prompt})})
|
|
15483
|
+
.then(function(r){ return r.json().then(function(d){ return {ok:r.ok, status:r.status, data:d}; }); })
|
|
15484
|
+
.then(function(res){
|
|
15485
|
+
if(importSubmit) importSubmit.disabled = false;
|
|
15486
|
+
if(!res.ok){ setNote((res.data && res.data.error) || 'Could not start the scan.', 'error'); return; }
|
|
15487
|
+
setNote('Scan started — this can take a few minutes.', 'success');
|
|
15488
|
+
statusEl.textContent='Scanning source…';
|
|
15489
|
+
closeImport();
|
|
15490
|
+
if(importForm.reset) importForm.reset();
|
|
15491
|
+
if(res.data && res.data.scan_id) pollScan(res.data.scan_id);
|
|
15492
|
+
})
|
|
15493
|
+
.catch(function(){
|
|
15494
|
+
if(importSubmit) importSubmit.disabled = false;
|
|
15495
|
+
setNote('Network error — please try again.', 'error');
|
|
15496
|
+
});
|
|
15497
|
+
});
|
|
15498
|
+
|
|
15499
|
+
loadFeed();
|
|
15500
|
+
})();
|
|
15501
|
+
</script>
|
|
15502
|
+
`
|
|
15503
|
+
});
|
|
15258
15504
|
}
|
|
15259
15505
|
async function resolveCustomerProviderKey(customerId, provider) {
|
|
15260
15506
|
const rows = (await listProviderKeysWithSecretsForCustomer(customerId));
|
|
@@ -15350,9 +15596,22 @@ function clipKeywordMatch(clip, q) {
|
|
|
15350
15596
|
.filter(Boolean)
|
|
15351
15597
|
.every((term) => hay.includes(term));
|
|
15352
15598
|
}
|
|
15353
|
-
// GET /clips — the
|
|
15354
|
-
//
|
|
15355
|
-
|
|
15599
|
+
// GET /clips — the Clips gallery, rendered in the same shell as the rest of the
|
|
15600
|
+
// frontend and reached via the Library page's "Finished / Clips" toggle. The
|
|
15601
|
+
// client fetches /clips/feed with the session cookie (mirrors /discover vs /feed).
|
|
15602
|
+
app.get(CLIPS_PREFIX, async (c) => {
|
|
15603
|
+
const customer = await getPreferredBrowserCustomer(c);
|
|
15604
|
+
if (!customer) {
|
|
15605
|
+
return redirect(c, "/login");
|
|
15606
|
+
}
|
|
15607
|
+
return c.html(renderClipsPage({
|
|
15608
|
+
userId: customer.id,
|
|
15609
|
+
currentAccountId: customer.id,
|
|
15610
|
+
name: customer.name,
|
|
15611
|
+
email: customer.email,
|
|
15612
|
+
editorChat: await buildClipsEditorChatBoot({ customer })
|
|
15613
|
+
}));
|
|
15614
|
+
});
|
|
15356
15615
|
// GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q, ?limit).
|
|
15357
15616
|
app.get(`${CLIPS_PREFIX}/feed`, async (c) => {
|
|
15358
15617
|
const customer = requireCustomer(c);
|
|
@@ -15472,14 +15731,79 @@ app.post(`${CLIPS_PREFIX}/match-scenes`, async (c) => {
|
|
|
15472
15731
|
}
|
|
15473
15732
|
});
|
|
15474
15733
|
// POST /clips/scan — kick off a cloud scan of an uploaded/staged source video.
|
|
15734
|
+
// Inherently ASYNC: returns 202 + scan_id immediately; the Step Functions
|
|
15735
|
+
// pipeline runs the hunt and GET /clips/scan/:scanId polls it. Billing is AWS
|
|
15736
|
+
// compute only (clip_scan_lambda + step_functions_standard) — the AI spend is
|
|
15737
|
+
// the caller's own provider key (BYOK) and is never wallet-billed.
|
|
15475
15738
|
app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
15476
15739
|
const customer = requireCustomer(c);
|
|
15477
15740
|
try {
|
|
15478
15741
|
const body = (await c.req.json());
|
|
15742
|
+
const guidancePrompt = body.prompt?.trim() || "";
|
|
15743
|
+
// Build the hunt spec: explicit structured fields win, the free "what to
|
|
15744
|
+
// clip for" prompt fills the gaps (duration band, vertical/aspect, no-text).
|
|
15745
|
+
// A pasted "Import Source" URL rides in hunt_spec.source_url — the clip-scan
|
|
15746
|
+
// Lambda ingests it (RapidAPI, incl. TikTok/YouTube/IG/X) into the user's
|
|
15747
|
+
// 30-day temp folder before scanning.
|
|
15748
|
+
const sourceUrl = readNonEmptyString(body.source_url) ?? readNonEmptyString(body.video_url) ?? readNonEmptyString(body.url);
|
|
15749
|
+
if (sourceUrl && !/^https?:\/\//i.test(sourceUrl)) {
|
|
15750
|
+
return c.json({ error: "That doesn't look like a valid video URL." }, 400);
|
|
15751
|
+
}
|
|
15752
|
+
const huntSpec = normalizeHuntSpec({
|
|
15753
|
+
...(body.hunt_spec && typeof body.hunt_spec === "object" ? body.hunt_spec : {}),
|
|
15754
|
+
...(body.ranges != null ? { ranges: body.ranges } : {}),
|
|
15755
|
+
...(body.target_duration_sec != null ? { target_duration_sec: body.target_duration_sec } : {}),
|
|
15756
|
+
...(body.aspect != null ? { aspect: body.aspect } : {}),
|
|
15757
|
+
...(body.crop_focus != null ? { crop_focus: body.crop_focus } : {}),
|
|
15758
|
+
...(body.avoid_text != null ? { avoid_text: body.avoid_text } : {}),
|
|
15759
|
+
...(body.no_text != null ? { no_text: body.no_text } : {})
|
|
15760
|
+
});
|
|
15761
|
+
const parsedPrompt = parseClipHuntPrompt(guidancePrompt);
|
|
15762
|
+
if (!huntSpec.windows?.length && parsedPrompt.windows.length)
|
|
15763
|
+
huntSpec.windows = parsedPrompt.windows;
|
|
15764
|
+
if (!huntSpec.duration_band && parsedPrompt.target_duration_sec != null) {
|
|
15765
|
+
const band = resolveDurationBand(parsedPrompt.target_duration_sec);
|
|
15766
|
+
if (band)
|
|
15767
|
+
huntSpec.duration_band = band;
|
|
15768
|
+
}
|
|
15769
|
+
if (!huntSpec.aspect && parsedPrompt.aspect)
|
|
15770
|
+
huntSpec.aspect = parsedPrompt.aspect;
|
|
15771
|
+
if (!huntSpec.avoid_text && parsedPrompt.avoid_text)
|
|
15772
|
+
huntSpec.avoid_text = true;
|
|
15773
|
+
if (!huntSpec.source_url && sourceUrl)
|
|
15774
|
+
huntSpec.source_url = sourceUrl;
|
|
15775
|
+
// Uploaded-source hunts: resolve a temp-folder file (the canonical home for
|
|
15776
|
+
// hunt originals — 30-day TTL) or a My Files attachment to its S3 key.
|
|
15777
|
+
let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
|
|
15778
|
+
const tempFileId = readNonEmptyString(body.temp_file_id);
|
|
15779
|
+
const attachmentId = readNonEmptyString(body.attachment_id);
|
|
15780
|
+
if (!body.raw_s3_uri && !resolvedS3Key && tempFileId) {
|
|
15781
|
+
const file = await serverlessRecords.getUserTemporaryFile(customer.id, tempFileId);
|
|
15782
|
+
if (!file)
|
|
15783
|
+
return c.json({ error: "Temporary file not found — upload it first via /api/v1/user/me/temporary-files." }, 404);
|
|
15784
|
+
resolvedS3Key = file.storageKey;
|
|
15785
|
+
}
|
|
15786
|
+
else if (!body.raw_s3_uri && !resolvedS3Key && attachmentId) {
|
|
15787
|
+
const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
|
|
15788
|
+
if (!attachment)
|
|
15789
|
+
return c.json({ error: "Attachment not found in My Files." }, 404);
|
|
15790
|
+
resolvedS3Key = attachment.storageKey;
|
|
15791
|
+
}
|
|
15479
15792
|
const rawS3Uri = body.raw_s3_uri ??
|
|
15480
|
-
(
|
|
15481
|
-
if (!rawS3Uri) {
|
|
15482
|
-
return c.json({ error: "Provide raw_s3_uri (s3://…) or s3_key of the
|
|
15793
|
+
(resolvedS3Key && config.AWS_S3_BUCKET ? `s3://${config.AWS_S3_BUCKET}/${resolvedS3Key}` : undefined);
|
|
15794
|
+
if (!rawS3Uri && !huntSpec.source_url) {
|
|
15795
|
+
return c.json({ error: "Provide source_url (a video URL), temp_file_id/attachment_id of an upload, raw_s3_uri (s3://…), or s3_key of the staged source video." }, 400);
|
|
15796
|
+
}
|
|
15797
|
+
// Hunts bill AWS compute post-hoc, so submission is the spend gate (same
|
|
15798
|
+
// policy as job submission).
|
|
15799
|
+
try {
|
|
15800
|
+
await assertWalletCanQueueJobs(customer.id);
|
|
15801
|
+
}
|
|
15802
|
+
catch (error) {
|
|
15803
|
+
if (error instanceof InsufficientWalletFundsError) {
|
|
15804
|
+
return insufficientFundsResponse(c, error);
|
|
15805
|
+
}
|
|
15806
|
+
throw error;
|
|
15483
15807
|
}
|
|
15484
15808
|
const provider = normalizeClipProvider(body.provider);
|
|
15485
15809
|
const built = await buildCustomerClipClient(customer.id, provider);
|
|
@@ -15489,26 +15813,39 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15489
15813
|
const tier = normalizeScanTier(body.tier);
|
|
15490
15814
|
const framesPerScene = Math.max(1, Math.min(3, Number(body.frames_per_scene) || 2));
|
|
15491
15815
|
const includeAudio = body.include_audio !== false && provider === "gemini";
|
|
15492
|
-
const
|
|
15816
|
+
const tracer = readNonEmptyString(body.tracer) ?? `clips_import:${customer.id}`;
|
|
15493
15817
|
const now = nowIso();
|
|
15494
15818
|
const scanId = createIdV7("clipscan");
|
|
15495
15819
|
const sourceVideoId = createIdV7("srcvid");
|
|
15496
|
-
|
|
15497
|
-
const
|
|
15498
|
-
|
|
15820
|
+
// The URL ingest is deferred to the Lambda, so raw_s3_uri may be empty here.
|
|
15821
|
+
const sourcePointer = rawS3Uri ?? huntSpec.source_url ?? "";
|
|
15822
|
+
const filename = body.filename || (sourcePointer.split("/").pop() || "source.mp4").split("?")[0] || "source.mp4";
|
|
15823
|
+
// Time-range hunts only pay for the windows: both estimates use the
|
|
15824
|
+
// EFFECTIVE (windowed) duration, and the duration band drives the expected
|
|
15825
|
+
// scene size. The BYOK estimate is the user's own provider spend; the
|
|
15826
|
+
// compute estimate is what the wallet will be billed (AWS only, marked up).
|
|
15827
|
+
const effectiveDurationSec = body.duration_sec && body.duration_sec > 0
|
|
15828
|
+
? effectiveHuntDurationSec(body.duration_sec, huntSpec.windows)
|
|
15829
|
+
: null;
|
|
15830
|
+
const estimate = effectiveDurationSec
|
|
15831
|
+
? estimateScanCostFromDuration(effectiveDurationSec, {
|
|
15499
15832
|
tier,
|
|
15500
15833
|
provider,
|
|
15501
15834
|
tagModelId: client.tagModelId,
|
|
15502
15835
|
embedModelId: client.embeddingModelId,
|
|
15503
15836
|
framesPerScene,
|
|
15504
|
-
includeAudio
|
|
15837
|
+
includeAudio,
|
|
15838
|
+
avgSceneSec: huntSpec.duration_band?.target_sec
|
|
15505
15839
|
})
|
|
15506
15840
|
: null;
|
|
15841
|
+
const computeEstimate = effectiveDurationSec
|
|
15842
|
+
? estimateClipScanComputeUsd(effectiveDurationSec, huntSpec.duration_band?.target_sec)
|
|
15843
|
+
: null;
|
|
15507
15844
|
const source = {
|
|
15508
15845
|
source_video_id: sourceVideoId,
|
|
15509
15846
|
owner_id: customer.id,
|
|
15510
15847
|
filename,
|
|
15511
|
-
raw_s3_uri:
|
|
15848
|
+
raw_s3_uri: sourcePointer,
|
|
15512
15849
|
duration_sec: body.duration_sec,
|
|
15513
15850
|
clip_count: 0,
|
|
15514
15851
|
tier,
|
|
@@ -15528,7 +15865,7 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15528
15865
|
owner_id: customer.id,
|
|
15529
15866
|
source_video_id: sourceVideoId,
|
|
15530
15867
|
source_filename: filename,
|
|
15531
|
-
raw_s3_uri: rawS3Uri,
|
|
15868
|
+
raw_s3_uri: rawS3Uri ?? "",
|
|
15532
15869
|
tier,
|
|
15533
15870
|
provider,
|
|
15534
15871
|
tag_api_key: tagApiKey,
|
|
@@ -15536,7 +15873,9 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15536
15873
|
embedding_api_key: embeddingKey ?? "",
|
|
15537
15874
|
guidance_prompt: guidancePrompt,
|
|
15538
15875
|
frames_per_scene: framesPerScene,
|
|
15539
|
-
include_audio: includeAudio
|
|
15876
|
+
include_audio: includeAudio,
|
|
15877
|
+
hunt_spec: huntSpec,
|
|
15878
|
+
tracer
|
|
15540
15879
|
})
|
|
15541
15880
|
}));
|
|
15542
15881
|
executionArn = res.executionArn;
|
|
@@ -15547,8 +15886,9 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15547
15886
|
source_video_id: sourceVideoId,
|
|
15548
15887
|
status: executionArn ? "running" : "queued",
|
|
15549
15888
|
tier,
|
|
15550
|
-
raw_s3_uri:
|
|
15889
|
+
raw_s3_uri: sourcePointer,
|
|
15551
15890
|
execution_arn: executionArn,
|
|
15891
|
+
tracer,
|
|
15552
15892
|
created_at: now,
|
|
15553
15893
|
updated_at: now
|
|
15554
15894
|
};
|
|
@@ -15558,7 +15898,10 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15558
15898
|
source_video_id: sourceVideoId,
|
|
15559
15899
|
status: scan.status,
|
|
15560
15900
|
execution_arn: executionArn ?? null,
|
|
15901
|
+
tracer,
|
|
15902
|
+
hunt_spec: huntSpec,
|
|
15561
15903
|
estimate,
|
|
15904
|
+
compute_estimate: computeEstimate,
|
|
15562
15905
|
pipeline_deployed: Boolean(config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN)
|
|
15563
15906
|
}, 202);
|
|
15564
15907
|
}
|
|
@@ -15569,9 +15912,32 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15569
15912
|
// GET /clips/scan/:scanId — poll scan status.
|
|
15570
15913
|
app.get(`${CLIPS_PREFIX}/scan/:scanId`, async (c) => {
|
|
15571
15914
|
const customer = requireCustomer(c);
|
|
15572
|
-
|
|
15915
|
+
let scan = await clipRecords.getScan(customer.id, c.req.param("scanId"));
|
|
15573
15916
|
if (!scan)
|
|
15574
15917
|
return c.json({ error: "Scan not found" }, 404);
|
|
15918
|
+
// Lazily reconcile with the Step Functions execution: the pipeline has no
|
|
15919
|
+
// failure hook back into the scan record, so a crashed execution would
|
|
15920
|
+
// otherwise leave "running" forever and pollers spin.
|
|
15921
|
+
if ((scan.status === "running" || scan.status === "queued") && scan.execution_arn) {
|
|
15922
|
+
try {
|
|
15923
|
+
const desc = await clipScanSfn.send(new DescribeExecutionCommand({ executionArn: scan.execution_arn }));
|
|
15924
|
+
if (desc.status === "FAILED" || desc.status === "TIMED_OUT" || desc.status === "ABORTED") {
|
|
15925
|
+
scan = { ...scan, status: "failed", error: `Scan pipeline ${desc.status.toLowerCase().replace("_", " ")}.`, updated_at: nowIso() };
|
|
15926
|
+
await clipRecords.putScan(scan);
|
|
15927
|
+
const failedSource = await clipRecords.getSource(customer.id, scan.source_video_id);
|
|
15928
|
+
if (failedSource && failedSource.status !== "complete") {
|
|
15929
|
+
await clipRecords.putSource({ ...failedSource, status: "failed", updated_at: nowIso() });
|
|
15930
|
+
}
|
|
15931
|
+
}
|
|
15932
|
+
else if (desc.status === "SUCCEEDED") {
|
|
15933
|
+
scan = { ...scan, status: "complete", updated_at: nowIso() };
|
|
15934
|
+
await clipRecords.putScan(scan);
|
|
15935
|
+
}
|
|
15936
|
+
}
|
|
15937
|
+
catch (error) {
|
|
15938
|
+
devLog("clip_scan.reconcile_failed", { scan_id: scan.scan_id, ...devErrorFields(error) }, "warn");
|
|
15939
|
+
}
|
|
15940
|
+
}
|
|
15575
15941
|
const source = await clipRecords.getSource(customer.id, scan.source_video_id);
|
|
15576
15942
|
return c.json({ scan, source });
|
|
15577
15943
|
});
|