@mevdragon/vidfarm-devcli 0.8.0 → 0.10.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 +27 -1
- package/SKILL.director.md +57 -0
- package/SKILL.platform.md +13 -1
- package/demo/dist/app.js +56 -28
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +678 -189
- package/dist/src/cli.js +341 -54
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/devcli/telemetry.js +236 -0
- package/dist/src/editor-chat.js +1 -1
- package/dist/src/frontend/homepage-view.js +0 -5
- package/dist/src/frontend/template-editor-chat.js +212 -0
- package/dist/src/page-shell.js +47 -0
- package/dist/src/primitive-registry.js +7 -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/hyperframes.js +22 -9
- package/dist/src/services/serverless-records.js +9 -2
- package/dist/src/services/storage.js +16 -1
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +17 -17
- package/public/assets/page-runtime-client-app.js +31 -31
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";
|
|
@@ -3174,6 +3180,40 @@ async function buildChatBrainstormEditorChatBoot(input) {
|
|
|
3174
3180
|
}
|
|
3175
3181
|
};
|
|
3176
3182
|
}
|
|
3183
|
+
async function buildClipsEditorChatBoot(input) {
|
|
3184
|
+
const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md") || null;
|
|
3185
|
+
const availableProviders = (await normalizeEditorChatProviderRows(input.customer.id)).map((entry) => entry.provider);
|
|
3186
|
+
const docsRoutes = [
|
|
3187
|
+
{ method: "GET", path: `${CLIPS_PREFIX}/feed`, summary: "List the caller's reusable clip library (optional ?q, ?source, ?limit)." },
|
|
3188
|
+
{ method: "POST", path: `${CLIPS_PREFIX}/search`, summary: "Hybrid structured + semantic clip search. Body is { query } (natural language) or { criteria } (structured filter)." },
|
|
3189
|
+
{ 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')." },
|
|
3190
|
+
{ method: "GET", path: `${CLIPS_PREFIX}/scan/:scanId`, summary: "Poll clip-scan status for an in-progress import." },
|
|
3191
|
+
{ method: "GET", path: `${CLIPS_PREFIX}/presets`, summary: "List the caller's saved clip-search presets." }
|
|
3192
|
+
];
|
|
3193
|
+
return {
|
|
3194
|
+
apiUrl: resolveEditorChatApiUrl(),
|
|
3195
|
+
threadsUrl: `${API_PREFIX}/editor-chat/threads`,
|
|
3196
|
+
vidfarmApiKey: await getVisibleApiKey(input.customer.id),
|
|
3197
|
+
cachedContext: {
|
|
3198
|
+
directorSkill,
|
|
3199
|
+
templateSkill: null,
|
|
3200
|
+
availableProviders,
|
|
3201
|
+
currentTemplateConfig: null
|
|
3202
|
+
},
|
|
3203
|
+
template: {
|
|
3204
|
+
page: "docs",
|
|
3205
|
+
tracerId: null,
|
|
3206
|
+
tracers: [],
|
|
3207
|
+
defaultRequestTracer: null,
|
|
3208
|
+
activeTracer: null,
|
|
3209
|
+
templateId: "chat-brainstorm",
|
|
3210
|
+
templateSlug: "clip-curator",
|
|
3211
|
+
templateTitle: "Clip curator",
|
|
3212
|
+
templateDescription: "Search your reusable clip library and import new source videos to mine into clips.",
|
|
3213
|
+
docsRoutes
|
|
3214
|
+
}
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3177
3217
|
function resolveEditorChatApiUrl() {
|
|
3178
3218
|
return config.PUBLIC_EDITOR_CHAT_URL?.trim() || `${API_PREFIX}/editor-chat`;
|
|
3179
3219
|
}
|
|
@@ -3504,7 +3544,15 @@ function normalizeEditorChatPath(input) {
|
|
|
3504
3544
|
// advances the job so the copilot must be explicit about wanting it, but it
|
|
3505
3545
|
// lives under the same prefix and is protected by its own auth guard, which
|
|
3506
3546
|
// is fine.
|
|
3507
|
-
COMPOSITIONS_PREFIX
|
|
3547
|
+
COMPOSITIONS_PREFIX,
|
|
3548
|
+
// Clip hunting (long-form → short-form): search/feed the caller's clip
|
|
3549
|
+
// library, start a scan (POST /clips/scan with source_url|s3_key|
|
|
3550
|
+
// temp_file_id + prompt/ranges/duration/aspect), and poll its status. The
|
|
3551
|
+
// scan route is wallet-gated + billed for AWS compute only (BYOK AI keys),
|
|
3552
|
+
// and NEVER runs GhostCut on the long-form source — "no text" is a
|
|
3553
|
+
// scene-selection filter; caption removal stays a per-finished-clip
|
|
3554
|
+
// primitive (/api/v1/primitives/videos/remove-captions).
|
|
3555
|
+
CLIPS_PREFIX
|
|
3508
3556
|
];
|
|
3509
3557
|
const parsed = trimmed.startsWith("http://") || trimmed.startsWith("https://")
|
|
3510
3558
|
? new URL(trimmed)
|
|
@@ -3634,7 +3682,7 @@ function normalizeOperationRequestTracer(tracer) {
|
|
|
3634
3682
|
}
|
|
3635
3683
|
function createTemplateHttpTool(input) {
|
|
3636
3684
|
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.",
|
|
3685
|
+
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. 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
3686
|
inputSchema: z.object({
|
|
3639
3687
|
method: z.enum(["GET", "POST"]).describe("HTTP method to use."),
|
|
3640
3688
|
path: z.string().min(1).describe("Absolute path or same-origin URL for an allowed Vidfarm REST route."),
|
|
@@ -4096,6 +4144,28 @@ function createEditorFrontendActionTool(input) {
|
|
|
4096
4144
|
}
|
|
4097
4145
|
});
|
|
4098
4146
|
}
|
|
4147
|
+
// Discover-chat only: lets the AI create a BRAND-NEW video for the user, either
|
|
4148
|
+
// from a text prompt (mode=generate) or by replicating a pasted video URL
|
|
4149
|
+
// (mode=replicate). The tool itself is a passthrough — it just structures the
|
|
4150
|
+
// user's intent; the chat frontend runs the ingest→decompose→fork pipeline and
|
|
4151
|
+
// drops an "Open in editor" link into the thread when it's ready.
|
|
4152
|
+
function createVideoCreationTool() {
|
|
4153
|
+
return tool({
|
|
4154
|
+
description: "Create a NEW editable video for the user from scratch. Call this the MOMENT the user asks to \"create/make a video of ...\" (set mode=generate and put the idea in `prompt`) OR to \"replicate/recreate/remix this video\" from a pasted TikTok/YouTube/Instagram/X URL (set mode=replicate, put the URL in `source_url`, and put any requested changes in `instructions`). This kicks off video creation in the browser and posts an 'Open in editor' link to the chat when the new video is ready — you do not need to poll or call any other tool. This is the ONLY way to make a brand-new video; the editor tools only edit an already-open composition.",
|
|
4155
|
+
inputSchema: z.object({
|
|
4156
|
+
mode: z.enum(["generate", "replicate"]).describe("generate = brand-new from a text prompt; replicate = recreate a pasted video URL."),
|
|
4157
|
+
prompt: z.string().optional().describe("For mode=generate: what the video should be about."),
|
|
4158
|
+
source_url: z.string().optional().describe("For mode=replicate: the TikTok/YouTube/Instagram/X video URL to recreate."),
|
|
4159
|
+
instructions: z.string().optional().describe("Optional changes to apply while creating/recreating (e.g. 'make it about my coffee brand', 'change the hook to ...').")
|
|
4160
|
+
}),
|
|
4161
|
+
execute: async ({ mode, prompt, source_url, instructions }) => ({
|
|
4162
|
+
mode,
|
|
4163
|
+
prompt: prompt ?? null,
|
|
4164
|
+
source_url: source_url ?? null,
|
|
4165
|
+
instructions: instructions ?? null
|
|
4166
|
+
})
|
|
4167
|
+
});
|
|
4168
|
+
}
|
|
4099
4169
|
function createEditorActionTool() {
|
|
4100
4170
|
return tool({
|
|
4101
4171
|
description: "Directly mutate the composition timeline in the editor OR start an export render OR generate-and-place AI media. Use this whenever the user asks for a timeline change (add/remove/duplicate/split layers, retime, reposition, resize, restyle text, swap media, group/ungroup), asks to generate an AI video/image and drop it on the timeline (action_type=generate_layer), or asks to export/render/publish/download the final MP4 (action_type=export_composition). The most recent <editor_context> in the user message is the source of truth for current layer_keys, layer indices (called 'track'), durations, timeline_gaps (blank spans), pending_generations (AI gens still rendering), and the most recent exported MP4 (last_export_url). Always provide explanation. For add_layer, also provide layer_key with a stable descriptive id (e.g., 'vf-caption-hook', 'vf-hero-image') so subsequent tool calls in the same turn can reference it — the editor will use that id as the new layer's data-hf-id. PREFER action_type=generate_layer to create NEW AI footage/images on the timeline: it submits the primitive generate job, drops a placeholder clip into the target slot immediately, and auto-swaps in the finished media when the job settles (no manual poll, no separate add_layer). Only use the http_request→add_layer flow for media that already has a URL. Avoid layer/track collisions: pick a track index higher than any existing layer on the requested time range, or set start/duration so the range is free. Never invent layer_keys for remove/set/duplicate/split actions; only use keys present in the most recent editor_context. For action_type=export_composition, no layer_key/geometry fields are needed; the editor kicks off the render and streams status in the UI, and the resulting MP4 URL will appear in the next <editor_context> as last_export_url.",
|
|
@@ -4303,7 +4373,12 @@ async function streamEditorChatAgent(input, send) {
|
|
|
4303
4373
|
frontend_action: createEditorFrontendActionTool({
|
|
4304
4374
|
tracerRegistry
|
|
4305
4375
|
}),
|
|
4306
|
-
editor_action: createEditorActionTool()
|
|
4376
|
+
editor_action: createEditorActionTool(),
|
|
4377
|
+
// Discover/brainstorm surface only: "create me a video of…" / "replicate
|
|
4378
|
+
// this URL…". The editor surfaces edit an already-open composition.
|
|
4379
|
+
...(input.template.templateId === "chat-brainstorm"
|
|
4380
|
+
? { create_video: createVideoCreationTool() }
|
|
4381
|
+
: {})
|
|
4307
4382
|
};
|
|
4308
4383
|
})()
|
|
4309
4384
|
: undefined;
|
|
@@ -4750,6 +4825,7 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}/discover`, (c) => redirectToPublicPath(c, "/
|
|
|
4750
4825
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/help`, (c) => redirectToPublicPath(c, "/help"));
|
|
4751
4826
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat`, (c) => dispatchScopedGetToLegacyRoute(c, "/chat"));
|
|
4752
4827
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/library`, (c) => dispatchScopedGetToLegacyRoute(c, "/library"));
|
|
4828
|
+
app.get(`${ACCOUNT_FRONTEND_PREFIX}/clips`, (c) => dispatchScopedGetToLegacyRoute(c, "/clips"));
|
|
4753
4829
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/inpaint"));
|
|
4754
4830
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar"));
|
|
4755
4831
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings`, (c) => dispatchScopedGetToLegacyRoute(c, "/settings"));
|
|
@@ -6536,6 +6612,66 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
|
|
|
6536
6612
|
}
|
|
6537
6613
|
});
|
|
6538
6614
|
});
|
|
6615
|
+
// A "processing" state older than this is treated as abandoned (crashed lambda)
|
|
6616
|
+
// so a forced re-run may start. Must exceed the longest real decompose but stay
|
|
6617
|
+
// under the API lambda's 15-min timeout.
|
|
6618
|
+
const DECOMPOSE_PROCESSING_STALE_MS = 10 * 60 * 1000;
|
|
6619
|
+
async function readDecomposeJobState(forkId) {
|
|
6620
|
+
const text = await storage.readText(storage.compositionForkWorkingKey(forkId, "decompose.json"));
|
|
6621
|
+
if (!text)
|
|
6622
|
+
return null;
|
|
6623
|
+
try {
|
|
6624
|
+
return JSON.parse(text);
|
|
6625
|
+
}
|
|
6626
|
+
catch {
|
|
6627
|
+
return null;
|
|
6628
|
+
}
|
|
6629
|
+
}
|
|
6630
|
+
async function writeDecomposeJobState(forkId, state) {
|
|
6631
|
+
try {
|
|
6632
|
+
await storage.putJson(storage.compositionForkWorkingKey(forkId, "decompose.json"), state);
|
|
6633
|
+
}
|
|
6634
|
+
catch (error) {
|
|
6635
|
+
console.error("decompose.json write failed", error instanceof Error ? error.message : error);
|
|
6636
|
+
}
|
|
6637
|
+
}
|
|
6638
|
+
function decomposeDoneResponse(job, extra = {}) {
|
|
6639
|
+
return {
|
|
6640
|
+
ok: true,
|
|
6641
|
+
status: "done",
|
|
6642
|
+
mode: "smart",
|
|
6643
|
+
provider: job.provider ?? null,
|
|
6644
|
+
model: job.model ?? null,
|
|
6645
|
+
scene_count: job.sceneCount ?? 0,
|
|
6646
|
+
caption_count: job.captionCount ?? 0,
|
|
6647
|
+
duration_seconds: job.durationSeconds ?? null,
|
|
6648
|
+
became_default_fork: job.becameDefaultFork ?? false,
|
|
6649
|
+
ghostcut_pending: job.ghostcutPending ?? false,
|
|
6650
|
+
...extra
|
|
6651
|
+
};
|
|
6652
|
+
}
|
|
6653
|
+
// Read-only decompose job status — the client's poll target while a decompose
|
|
6654
|
+
// runs (or after a 504). Fast: just reads decompose.json.
|
|
6655
|
+
app.get(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
6656
|
+
const caller = await getBrowserCustomer(c);
|
|
6657
|
+
try {
|
|
6658
|
+
const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: caller?.id ?? null, shareToken: readShareToken(c) }, "view");
|
|
6659
|
+
const job = await readDecomposeJobState(access.fork.id);
|
|
6660
|
+
if (!job)
|
|
6661
|
+
return c.json({ ok: true, status: "none" });
|
|
6662
|
+
if (job.status === "done")
|
|
6663
|
+
return c.json(decomposeDoneResponse(job, { resumed: true }));
|
|
6664
|
+
if (job.status === "failed")
|
|
6665
|
+
return c.json({ ok: true, status: "failed", error: job.failureReason ?? "Decompose failed." });
|
|
6666
|
+
const stale = Date.now() - (job.updatedAtMs ?? job.startedAtMs ?? 0) >= DECOMPOSE_PROCESSING_STALE_MS;
|
|
6667
|
+
return c.json({ ok: true, status: "processing", stale, started_at_ms: job.startedAtMs });
|
|
6668
|
+
}
|
|
6669
|
+
catch (error) {
|
|
6670
|
+
if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
|
|
6671
|
+
return forkAccessErrorResponse(c, error);
|
|
6672
|
+
return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
|
|
6673
|
+
}
|
|
6674
|
+
});
|
|
6539
6675
|
app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
6540
6676
|
const caller = await getBrowserCustomer(c);
|
|
6541
6677
|
if (!caller)
|
|
@@ -6552,6 +6688,36 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6552
6688
|
const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
|
|
6553
6689
|
const userPromptRaw = typeof body.user_prompt === "string" ? body.user_prompt.trim() : "";
|
|
6554
6690
|
const userPrompt = userPromptRaw.length > 0 ? userPromptRaw.slice(0, 4000) : null;
|
|
6691
|
+
// Idempotency / resumability guard. POST STARTS a decompose (idempotently);
|
|
6692
|
+
// GET /auto-decompose is the poll. An in-flight run always wins so a client's
|
|
6693
|
+
// post-504 retry (or a second caller) never starts a duplicate. A completed
|
|
6694
|
+
// job is returned from cache unless `force` (the editor's decompose click)
|
|
6695
|
+
// asks for a fresh re-run. Absent / failed / abandoned jobs (re)start — so a
|
|
6696
|
+
// plain POST with no `force` still kicks the first decompose (back-compat for
|
|
6697
|
+
// the editor-chat copilot's http_request call).
|
|
6698
|
+
const forceRun = body.force === true;
|
|
6699
|
+
const existingJob = await readDecomposeJobState(fork.id);
|
|
6700
|
+
const jobFresh = existingJob
|
|
6701
|
+
? Date.now() - (existingJob.updatedAtMs ?? existingJob.startedAtMs ?? 0) < DECOMPOSE_PROCESSING_STALE_MS
|
|
6702
|
+
: false;
|
|
6703
|
+
if (existingJob?.status === "processing" && jobFresh) {
|
|
6704
|
+
return c.json({ ok: true, status: "processing", started_at_ms: existingJob.startedAtMs });
|
|
6705
|
+
}
|
|
6706
|
+
if (existingJob?.status === "done" && !forceRun) {
|
|
6707
|
+
return c.json(decomposeDoneResponse(existingJob, { resumed: true }));
|
|
6708
|
+
}
|
|
6709
|
+
// Claim the job so concurrent polls/kicks back off. A local helper stamps
|
|
6710
|
+
// "failed" on every early-error return so a poller never hangs on processing.
|
|
6711
|
+
await writeDecomposeJobState(fork.id, { status: "processing", startedAtMs, updatedAtMs: Date.now() });
|
|
6712
|
+
const failDecompose = async (payload, statusCode) => {
|
|
6713
|
+
await writeDecomposeJobState(fork.id, {
|
|
6714
|
+
status: "failed",
|
|
6715
|
+
startedAtMs,
|
|
6716
|
+
updatedAtMs: Date.now(),
|
|
6717
|
+
failureReason: typeof payload.error === "string" ? payload.error : "Auto-decompose failed."
|
|
6718
|
+
});
|
|
6719
|
+
return c.json(payload, statusCode);
|
|
6720
|
+
};
|
|
6555
6721
|
// Parse the current composition HTML with a real DOM (linkedom) so
|
|
6556
6722
|
// attribute values come back UNESCAPED — `data-source-video="foo?a=1&b=2"`
|
|
6557
6723
|
// reads as `foo?a=1&b=2`, which is what ghostcut and the vision extractor
|
|
@@ -6588,7 +6754,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6588
6754
|
}
|
|
6589
6755
|
catch (error) {
|
|
6590
6756
|
if (error instanceof MediaDurationExceededError) {
|
|
6591
|
-
return
|
|
6757
|
+
return failDecompose({
|
|
6592
6758
|
ok: false,
|
|
6593
6759
|
error: `Source video is ${error.durationSeconds.toFixed(1)}s — Vidfarm caps auto-decompose at ${error.maxDurationSeconds}s. Trim the source before forking.`,
|
|
6594
6760
|
code: "source_too_long",
|
|
@@ -6706,13 +6872,13 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6706
6872
|
const customerKeys = await loadCustomerVisionKeys(caller.id);
|
|
6707
6873
|
const hasAnyKey = Boolean(customerKeys.gemini || customerKeys.openai || customerKeys.openrouter);
|
|
6708
6874
|
if (!hasAnyKey) {
|
|
6709
|
-
return
|
|
6875
|
+
return failDecompose({
|
|
6710
6876
|
ok: false,
|
|
6711
6877
|
error: "Decompose requires an AI provider key. Add a Gemini, OpenAI, or OpenRouter key in Settings and try again."
|
|
6712
6878
|
}, 400);
|
|
6713
6879
|
}
|
|
6714
6880
|
if (!sourceUrl) {
|
|
6715
|
-
return
|
|
6881
|
+
return failDecompose({ ok: false, error: "Composition has no source video URL." }, 400);
|
|
6716
6882
|
}
|
|
6717
6883
|
const inspirationForTagline = forkInspirationId
|
|
6718
6884
|
? await serverlessRecords.getInspiration(forkInspirationId)
|
|
@@ -6742,13 +6908,13 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6742
6908
|
}
|
|
6743
6909
|
catch (error) {
|
|
6744
6910
|
console.error("smart decompose failed", error instanceof Error ? error.message : error);
|
|
6745
|
-
return
|
|
6911
|
+
return failDecompose({
|
|
6746
6912
|
ok: false,
|
|
6747
6913
|
error: error instanceof Error ? error.message : "Smart decompose failed."
|
|
6748
6914
|
}, 502);
|
|
6749
6915
|
}
|
|
6750
6916
|
if (smart.scenes.length === 0 && smart.captions.length === 0) {
|
|
6751
|
-
return
|
|
6917
|
+
return failDecompose({
|
|
6752
6918
|
ok: false,
|
|
6753
6919
|
error: "Smart decompose returned no scenes. Try again with different guidance."
|
|
6754
6920
|
}, 502);
|
|
@@ -6894,8 +7060,23 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6894
7060
|
}
|
|
6895
7061
|
}
|
|
6896
7062
|
await chargeDecomposeUsage({ mode: "smart" });
|
|
7063
|
+
// Stamp the job "done" so a client whose request 504'd mid-run (or any later
|
|
7064
|
+
// poll) resolves to completion and reloads the composition.
|
|
7065
|
+
await writeDecomposeJobState(fork.id, {
|
|
7066
|
+
status: "done",
|
|
7067
|
+
startedAtMs,
|
|
7068
|
+
updatedAtMs: Date.now(),
|
|
7069
|
+
provider: smart.provider,
|
|
7070
|
+
model: smart.model,
|
|
7071
|
+
sceneCount: smart.scenes.length,
|
|
7072
|
+
captionCount: smart.captions.length,
|
|
7073
|
+
durationSeconds: smart.durationSeconds,
|
|
7074
|
+
becameDefaultFork: becameDefault,
|
|
7075
|
+
ghostcutPending: ghostcutTaskId !== null
|
|
7076
|
+
});
|
|
6897
7077
|
return c.json({
|
|
6898
7078
|
ok: true,
|
|
7079
|
+
status: "done",
|
|
6899
7080
|
mode: "smart",
|
|
6900
7081
|
provider: smart.provider,
|
|
6901
7082
|
model: smart.model,
|
|
@@ -6911,6 +7092,15 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6911
7092
|
catch (error) {
|
|
6912
7093
|
if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
|
|
6913
7094
|
return forkAccessErrorResponse(c, error);
|
|
7095
|
+
// Persist a failed job (best-effort) so the client's poll reads a terminal
|
|
7096
|
+
// failure instead of hanging on "processing". Safe: only reached after the
|
|
7097
|
+
// caller passed access control.
|
|
7098
|
+
await writeDecomposeJobState(c.req.param("forkId"), {
|
|
7099
|
+
status: "failed",
|
|
7100
|
+
startedAtMs,
|
|
7101
|
+
updatedAtMs: Date.now(),
|
|
7102
|
+
failureReason: error instanceof Error ? error.message : "Auto-decompose failed."
|
|
7103
|
+
});
|
|
6914
7104
|
return c.json({ ok: false, error: error instanceof Error ? error.message : "Auto-decompose failed." }, 500);
|
|
6915
7105
|
}
|
|
6916
7106
|
});
|
|
@@ -14535,7 +14725,9 @@ app.get(`${USER_PREFIX}/me/temporary-files`, async (c) => {
|
|
|
14535
14725
|
return c.json({
|
|
14536
14726
|
files,
|
|
14537
14727
|
folders: Array.from(new Set(folders)).sort(),
|
|
14538
|
-
|
|
14728
|
+
// Temp folder hard-TTLs at 30 days: DynamoDB record TTL + tag-scoped S3
|
|
14729
|
+
// lifecycle + this route's sweep all converge on the same deadline.
|
|
14730
|
+
ttl_days: TEMP_FILE_TTL_DAYS,
|
|
14539
14731
|
max_size_bytes: 100 * 1024 * 1024
|
|
14540
14732
|
});
|
|
14541
14733
|
});
|
|
@@ -14615,6 +14807,10 @@ app.post(`${USER_PREFIX}/me/temporary-files/upload`, async (c) => {
|
|
|
14615
14807
|
const fileId = randomUUID();
|
|
14616
14808
|
const storageKey = storage.userTemporaryFileKey(customer.id, fileId, fileName, folderPath);
|
|
14617
14809
|
await storage.putBuffer(storageKey, buffer, contentType);
|
|
14810
|
+
// The tag drives the bucket's 30-day lifecycle deletion of the original.
|
|
14811
|
+
await storage.putObjectTags(storageKey, { "vidfarm-temp": "1" }).catch((error) => {
|
|
14812
|
+
console.error(`[vidfarm] putObjectTags failed for temporary file key ${storageKey}:`, error);
|
|
14813
|
+
});
|
|
14618
14814
|
await serverlessRecords.createUserTemporaryFile({
|
|
14619
14815
|
id: fileId,
|
|
14620
14816
|
customerId: customer.id,
|
|
@@ -14623,7 +14819,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/upload`, async (c) => {
|
|
|
14623
14819
|
sizeBytes: buffer.byteLength,
|
|
14624
14820
|
storageKey,
|
|
14625
14821
|
folderPath,
|
|
14626
|
-
expiresAt: addSeconds(new Date(),
|
|
14822
|
+
expiresAt: addSeconds(new Date(), TEMP_FILE_TTL_DAYS * 24 * 60 * 60)
|
|
14627
14823
|
});
|
|
14628
14824
|
const saved = await serverlessRecords.getUserTemporaryFile(customer.id, fileId);
|
|
14629
14825
|
if (!saved) {
|
|
@@ -14649,6 +14845,11 @@ app.post(`${USER_PREFIX}/me/temporary-files`, async (c) => {
|
|
|
14649
14845
|
if (!isAllowedAttachment(fileName, contentType)) {
|
|
14650
14846
|
return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
|
|
14651
14847
|
}
|
|
14848
|
+
// Presigned transport: the bytes went straight to S3, so tag server-side at
|
|
14849
|
+
// finalize — the tag drives the bucket's 30-day lifecycle deletion.
|
|
14850
|
+
await storage.putObjectTags(expectedStorageKey, { "vidfarm-temp": "1" }).catch((error) => {
|
|
14851
|
+
console.error(`[vidfarm] putObjectTags failed for temporary file key ${expectedStorageKey}:`, error);
|
|
14852
|
+
});
|
|
14652
14853
|
await serverlessRecords.createUserTemporaryFile({
|
|
14653
14854
|
id: body.file_id,
|
|
14654
14855
|
customerId: customer.id,
|
|
@@ -14657,7 +14858,7 @@ app.post(`${USER_PREFIX}/me/temporary-files`, async (c) => {
|
|
|
14657
14858
|
sizeBytes: body.size_bytes,
|
|
14658
14859
|
storageKey: expectedStorageKey,
|
|
14659
14860
|
folderPath,
|
|
14660
|
-
expiresAt: addSeconds(new Date(),
|
|
14861
|
+
expiresAt: addSeconds(new Date(), TEMP_FILE_TTL_DAYS * 24 * 60 * 60)
|
|
14661
14862
|
});
|
|
14662
14863
|
const saved = await serverlessRecords.getUserTemporaryFile(customer.id, body.file_id);
|
|
14663
14864
|
if (!saved) {
|
|
@@ -14941,179 +15142,347 @@ app.post(`${USER_PREFIX}/me/provider-keys`, async (c) => {
|
|
|
14941
15142
|
// and natural-language→criteria use the caller's saved gemini provider key.
|
|
14942
15143
|
const clipScanSfn = new SFNClient({});
|
|
14943
15144
|
const BUILTIN_CLIP_PRESET_IDS = new Set(BUILTIN_PRESETS.map((p) => p.preset_id));
|
|
15145
|
+
// Pre-scan estimate of the AWS compute the wallet will be billed for a hunt
|
|
15146
|
+
// (Lambda GB-seconds + Step Functions transitions — mirrors the post-hoc
|
|
15147
|
+
// billing in infra/lambda/clip-scan.ts). The BYOK AI spend is estimated
|
|
15148
|
+
// separately (estimateScanCostFromDuration) and is NOT wallet-billed.
|
|
15149
|
+
function estimateClipScanComputeUsd(effectiveDurationSec, targetSceneSec) {
|
|
15150
|
+
const LAMBDA_X86_GB_SECOND_USD = 0.0000166667;
|
|
15151
|
+
const LAMBDA_REQUEST_USD = 0.20 / 1_000_000;
|
|
15152
|
+
const STEP_FUNCTIONS_STANDARD_TRANSITION_USD = 0.000025;
|
|
15153
|
+
const CLIP_SCAN_MEMORY_GB = 3;
|
|
15154
|
+
const avgSceneSec = Math.max(2, targetSceneSec ?? 4);
|
|
15155
|
+
const scenes = Math.max(1, Math.ceil(effectiveDurationSec / avgSceneSec));
|
|
15156
|
+
// probe decodes the windowed footage once; each scene worker extracts +
|
|
15157
|
+
// uploads a clip (dominated by a fixed overhead + a share of re-encode time).
|
|
15158
|
+
const probeSeconds = 15 + effectiveDurationSec * 0.08;
|
|
15159
|
+
const perSceneSeconds = 8 + avgSceneSec * 0.6;
|
|
15160
|
+
const finalizeSeconds = 3;
|
|
15161
|
+
const lambdaSeconds = probeSeconds + scenes * perSceneSeconds + finalizeSeconds;
|
|
15162
|
+
const gbSeconds = lambdaSeconds * CLIP_SCAN_MEMORY_GB;
|
|
15163
|
+
const lambdaUsd = gbSeconds * LAMBDA_X86_GB_SECOND_USD + (scenes + 2) * LAMBDA_REQUEST_USD;
|
|
15164
|
+
const stepFunctionsUsd = (3 + scenes) * STEP_FUNCTIONS_STANDARD_TRANSITION_USD;
|
|
15165
|
+
const baseUsd = Number((lambdaUsd + stepFunctionsUsd).toFixed(6));
|
|
15166
|
+
return {
|
|
15167
|
+
estimated_scenes: scenes,
|
|
15168
|
+
lambda_gb_seconds: Number(gbSeconds.toFixed(1)),
|
|
15169
|
+
aws_compute_usd: baseUsd,
|
|
15170
|
+
// What actually lands on the wallet (platform markup included).
|
|
15171
|
+
estimated_charge_usd: Number(applyMarkupUsd(baseUsd).toFixed(6))
|
|
15172
|
+
};
|
|
15173
|
+
}
|
|
14944
15174
|
// First-class Clips gallery page (served at /clips). Self-contained HTML; the
|
|
14945
15175
|
// client fetches /clips/feed, /clips/presets and /clips/search with the session
|
|
14946
15176
|
// cookie. Standalone browse/search — no video generation required.
|
|
14947
|
-
function renderClipsPage() {
|
|
14948
|
-
|
|
14949
|
-
|
|
14950
|
-
|
|
14951
|
-
|
|
14952
|
-
|
|
14953
|
-
|
|
14954
|
-
|
|
14955
|
-
|
|
14956
|
-
|
|
14957
|
-
|
|
14958
|
-
|
|
14959
|
-
|
|
14960
|
-
|
|
14961
|
-
|
|
14962
|
-
|
|
14963
|
-
|
|
14964
|
-
|
|
14965
|
-
|
|
14966
|
-
|
|
14967
|
-
|
|
14968
|
-
|
|
14969
|
-
|
|
14970
|
-
|
|
14971
|
-
|
|
14972
|
-
|
|
14973
|
-
|
|
14974
|
-
|
|
14975
|
-
|
|
14976
|
-
|
|
14977
|
-
|
|
14978
|
-
|
|
14979
|
-
|
|
14980
|
-
|
|
14981
|
-
|
|
14982
|
-
|
|
14983
|
-
|
|
14984
|
-
|
|
14985
|
-
|
|
14986
|
-
|
|
14987
|
-
|
|
14988
|
-
|
|
14989
|
-
|
|
14990
|
-
|
|
14991
|
-
|
|
14992
|
-
|
|
14993
|
-
|
|
14994
|
-
|
|
14995
|
-
|
|
14996
|
-
|
|
14997
|
-
|
|
14998
|
-
|
|
14999
|
-
|
|
15000
|
-
|
|
15001
|
-
|
|
15002
|
-
|
|
15003
|
-
|
|
15004
|
-
|
|
15005
|
-
|
|
15006
|
-
|
|
15007
|
-
|
|
15008
|
-
|
|
15009
|
-
|
|
15010
|
-
|
|
15011
|
-
|
|
15012
|
-
|
|
15013
|
-
|
|
15014
|
-
|
|
15015
|
-
|
|
15016
|
-
|
|
15017
|
-
|
|
15018
|
-
|
|
15019
|
-
|
|
15020
|
-
|
|
15177
|
+
function renderClipsPage(input) {
|
|
15178
|
+
const libraryHref = withAccountQuery("/library", input.currentAccountId);
|
|
15179
|
+
const clipsHref = withAccountQuery("/clips", input.currentAccountId);
|
|
15180
|
+
return renderPageShell({
|
|
15181
|
+
title: "Clips — VidFarm",
|
|
15182
|
+
mainClass: "is-wide",
|
|
15183
|
+
runtimeBoot: {
|
|
15184
|
+
pageKind: "legacy",
|
|
15185
|
+
title: "Clips",
|
|
15186
|
+
userId: input.userId,
|
|
15187
|
+
editorChat: input.editorChat ?? null
|
|
15188
|
+
},
|
|
15189
|
+
style: `
|
|
15190
|
+
${TEMPLATE_EDITOR_SHELL_STYLES}
|
|
15191
|
+
|
|
15192
|
+
.clips-frame { padding-left: 432px; }
|
|
15193
|
+
.clips-shell { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 16px; min-height: 0; }
|
|
15194
|
+
.clips-toolbar { display: grid; gap: 12px; }
|
|
15195
|
+
.clips-toolbar-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
|
15196
|
+
.clips-search { display: flex; gap: 10px; flex: 1 1 320px; min-width: 240px; margin: 0; }
|
|
15197
|
+
.clips-search input { flex: 1; }
|
|
15198
|
+
.clips-search button { flex: 0 0 auto; }
|
|
15199
|
+
.clips-status { min-height: 20px; color: var(--muted); font-size: 0.94rem; }
|
|
15200
|
+
.clips-scroll { min-height: 0; overflow: auto; padding-right: 6px; }
|
|
15201
|
+
.clips-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(216px, 1fr)); gap: 16px; padding-bottom: 24px; }
|
|
15202
|
+
.clip-card {
|
|
15203
|
+
display: flex;
|
|
15204
|
+
flex-direction: column;
|
|
15205
|
+
overflow: hidden;
|
|
15206
|
+
border: 1px solid rgba(255, 255, 255, 0.72);
|
|
15207
|
+
border-radius: var(--radius-sm);
|
|
15208
|
+
background: rgba(255, 252, 247, 0.82);
|
|
15209
|
+
box-shadow: var(--shadow-soft);
|
|
15210
|
+
}
|
|
15211
|
+
.clip-thumb { position: relative; aspect-ratio: 9 / 16; overflow: hidden; background: #12161f; }
|
|
15212
|
+
.clip-thumb img, .clip-thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
15213
|
+
.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; }
|
|
15214
|
+
.clip-range {
|
|
15215
|
+
position: absolute; left: 8px; bottom: 8px;
|
|
15216
|
+
padding: 2px 8px; border-radius: 8px;
|
|
15217
|
+
background: rgba(20, 22, 30, 0.72); color: #f4efe6;
|
|
15218
|
+
font-size: 12px; font-variant-numeric: tabular-nums;
|
|
15219
|
+
}
|
|
15220
|
+
.clip-score {
|
|
15221
|
+
position: absolute; right: 8px; top: 8px;
|
|
15222
|
+
padding: 1px 7px; border-radius: 8px;
|
|
15223
|
+
background: rgba(36, 48, 65, 0.92); color: #f3e8cd;
|
|
15224
|
+
font-size: 11px; font-weight: 800;
|
|
15225
|
+
}
|
|
15226
|
+
.clip-body { display: flex; flex-direction: column; gap: 8px; flex: 1; padding: 12px; }
|
|
15227
|
+
.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; }
|
|
15228
|
+
.clip-tags { display: flex; flex-wrap: wrap; gap: 5px; }
|
|
15229
|
+
.clip-tag { padding: 2px 7px; border-radius: 6px; background: rgba(244, 236, 223, 0.86); color: #6f7d95; font-size: 11px; }
|
|
15230
|
+
.clip-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; }
|
|
15231
|
+
.clip-src { max-width: 58%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 11px; }
|
|
15232
|
+
.clip-dl { color: #b4823a; font-size: 12px; font-weight: 800; text-decoration: none; }
|
|
15233
|
+
.clip-dl:hover { text-decoration: underline; }
|
|
15234
|
+
.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; }
|
|
15235
|
+
|
|
15236
|
+
.clips-import-dialog {
|
|
15237
|
+
width: min(30rem, calc(100vw - 1.5rem));
|
|
15238
|
+
padding: 0;
|
|
15239
|
+
border: 1px solid rgba(191, 164, 109, 0.4);
|
|
15240
|
+
border-radius: 26px;
|
|
15241
|
+
background: rgba(255, 252, 247, 0.99);
|
|
15242
|
+
box-shadow: 0 28px 72px rgba(37, 45, 61, 0.22);
|
|
15243
|
+
}
|
|
15244
|
+
.clips-import-dialog::backdrop { background: rgba(31, 38, 53, 0.32); backdrop-filter: blur(6px); }
|
|
15245
|
+
.clips-import-form { display: grid; gap: 14px; margin: 0; padding: 22px 22px 20px; }
|
|
15246
|
+
.clips-import-head { display: grid; gap: 6px; }
|
|
15247
|
+
.clips-import-head h3 { font-size: 1.5rem; letter-spacing: -0.03em; }
|
|
15248
|
+
.clips-import-head p { font-size: 0.9rem; }
|
|
15249
|
+
.clips-import-field { display: grid; gap: 6px; }
|
|
15250
|
+
.clips-import-field textarea { min-height: 96px; border-radius: 18px; }
|
|
15251
|
+
.clips-import-note { min-height: 18px; font-size: 0.85rem; color: var(--muted); }
|
|
15252
|
+
.clips-import-note[data-tone="error"] { color: #9f3d3d; }
|
|
15253
|
+
.clips-import-note[data-tone="success"] { color: #3f7d55; }
|
|
15254
|
+
.clips-import-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
|
15255
|
+
|
|
15256
|
+
@media (max-width: 1180px) {
|
|
15257
|
+
.clips-frame { padding-left: 0; }
|
|
15258
|
+
.editor-right-rail { position: relative; inset: auto; width: 100%; height: 540px; margin-top: 14px; border-radius: 24px; overflow: hidden; }
|
|
15259
|
+
.editor-chat-panel { border-right: 0; border-radius: 24px; }
|
|
15260
|
+
}
|
|
15261
|
+
@media (min-width: 1025px) and (max-width: 1180px) {
|
|
15262
|
+
html, body { height: auto; min-height: 100%; overflow: auto; overflow-x: hidden; }
|
|
15263
|
+
.app-shell { height: auto; min-height: 100svh; }
|
|
15264
|
+
.page-stack, .frame { height: auto; min-height: 0; overflow: visible; }
|
|
15265
|
+
.clips-scroll { overflow: visible; padding-right: 0; }
|
|
15266
|
+
}
|
|
15267
|
+
@media (max-width: 1024px) {
|
|
15268
|
+
.clips-shell { grid-template-rows: auto auto; }
|
|
15269
|
+
.clips-scroll { overflow: visible; padding-right: 0; }
|
|
15270
|
+
}
|
|
15271
|
+
`,
|
|
15272
|
+
body: `
|
|
15273
|
+
<section class="frame clips-frame">
|
|
15274
|
+
<header class="topbar">
|
|
15275
|
+
${renderBrandLockup({ displayName: input.name, email: input.email, accountId: input.currentAccountId })}
|
|
15276
|
+
<div class="topbar-actions">
|
|
15277
|
+
${renderPrimaryNav({ loggedIn: true, current: "library", accountId: input.currentAccountId })}
|
|
15278
|
+
</div>
|
|
15279
|
+
</header>
|
|
15280
|
+
<div class="frame-body">
|
|
15281
|
+
<section class="clips-shell">
|
|
15282
|
+
<div class="clips-toolbar">
|
|
15283
|
+
<div class="clips-toolbar-row">
|
|
15284
|
+
<div class="view-toggle" role="tablist" aria-label="Library view">
|
|
15285
|
+
<a class="view-toggle-tab" role="tab" href="${escapeAttribute(libraryHref)}">Finished</a>
|
|
15286
|
+
<a class="view-toggle-tab is-active" role="tab" aria-current="page" href="${escapeAttribute(clipsHref)}">Clips</a>
|
|
15287
|
+
</div>
|
|
15288
|
+
<form class="clips-search" id="searchForm">
|
|
15289
|
+
<input id="q" type="search" placeholder="Search clips — e.g. "someone looks confused after reading a message"" autocomplete="off" />
|
|
15290
|
+
<button type="submit">Search</button>
|
|
15291
|
+
</form>
|
|
15292
|
+
<button type="button" class="secondary" id="importSourceBtn">Import Source</button>
|
|
15293
|
+
</div>
|
|
15294
|
+
<div class="clips-status" id="status">Loading your clips…</div>
|
|
15295
|
+
</div>
|
|
15296
|
+
<div class="clips-scroll">
|
|
15297
|
+
<div class="clips-grid" id="grid"></div>
|
|
15298
|
+
</div>
|
|
15299
|
+
</section>
|
|
15300
|
+
</div>
|
|
15301
|
+
</section>
|
|
15302
|
+
<aside class="editor-right-rail" aria-label="Clip curator chat"><div class="editor-chat-panel" data-template-editor-chat-root></div></aside>
|
|
15303
|
+
<dialog class="clips-import-dialog" id="importDialog">
|
|
15304
|
+
<form class="clips-import-form" id="importForm" method="dialog">
|
|
15305
|
+
<div class="clips-import-head">
|
|
15306
|
+
<div class="label">Import source</div>
|
|
15307
|
+
<h3>Mine a video into clips</h3>
|
|
15308
|
+
<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>
|
|
15309
|
+
</div>
|
|
15310
|
+
<div class="clips-import-field">
|
|
15311
|
+
<label for="importUrl">Video URL</label>
|
|
15312
|
+
<input id="importUrl" name="url" type="url" placeholder="https://youtube.com/watch?v=… or https://…/video.mp4" autocomplete="off" required />
|
|
15313
|
+
</div>
|
|
15314
|
+
<div class="clips-import-field">
|
|
15315
|
+
<label for="importPrompt">What to clip for</label>
|
|
15316
|
+
<textarea id="importPrompt" name="prompt" placeholder="Clip scenes of people with food, no text on screen, vertical, between 5-10 secs long"></textarea>
|
|
15317
|
+
</div>
|
|
15318
|
+
<div class="clips-import-note" id="importNote"></div>
|
|
15319
|
+
<div class="clips-import-actions">
|
|
15320
|
+
<button type="button" class="secondary" id="importCancel">Cancel</button>
|
|
15321
|
+
<button type="submit" id="importSubmit">Start scan</button>
|
|
15322
|
+
</div>
|
|
15323
|
+
</form>
|
|
15324
|
+
</dialog>
|
|
15325
|
+
<script>
|
|
15326
|
+
(function(){
|
|
15327
|
+
var grid = document.getElementById('grid');
|
|
15328
|
+
var statusEl = document.getElementById('status');
|
|
15329
|
+
var form = document.getElementById('searchForm');
|
|
15330
|
+
var input = document.getElementById('q');
|
|
15021
15331
|
|
|
15022
|
-
|
|
15023
|
-
|
|
15332
|
+
function fmtClock(sec){ var m=Math.floor(sec/60), s=Math.floor(sec%60); return (m<10?'0':'')+m+':'+(s<10?'0':'')+s; }
|
|
15333
|
+
function esc(t){ var d=document.createElement('div'); d.textContent=(t==null?'':String(t)); return d.innerHTML; }
|
|
15024
15334
|
|
|
15025
|
-
|
|
15026
|
-
|
|
15027
|
-
|
|
15028
|
-
|
|
15029
|
-
|
|
15030
|
-
|
|
15031
|
-
|
|
15335
|
+
function topTags(tags){
|
|
15336
|
+
if(!tags) return [];
|
|
15337
|
+
var out=[];
|
|
15338
|
+
['subject','action','emotion','composition'].forEach(function(k){ (tags[k]||[]).slice(0,1).forEach(function(v){ out.push(v); }); });
|
|
15339
|
+
if(tags.energy) out.push('energy:'+tags.energy);
|
|
15340
|
+
return out.slice(0,4);
|
|
15341
|
+
}
|
|
15032
15342
|
|
|
15033
|
-
|
|
15034
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
15037
|
-
|
|
15038
|
-
|
|
15039
|
-
|
|
15040
|
-
|
|
15041
|
-
|
|
15042
|
-
|
|
15043
|
-
|
|
15044
|
-
|
|
15045
|
-
|
|
15046
|
-
|
|
15047
|
-
|
|
15048
|
-
|
|
15049
|
-
|
|
15343
|
+
function card(clip){
|
|
15344
|
+
var range = fmtClock(clip.start_time_sec)+'–'+fmtClock(clip.end_time_sec);
|
|
15345
|
+
var score = (clip.score!=null) ? '<div class="clip-score">'+ (clip.score*100).toFixed(0) +'</div>' : '';
|
|
15346
|
+
var media = clip.thumbnail_url
|
|
15347
|
+
? '<img loading="lazy" src="'+esc(clip.thumbnail_url)+'" onmouseover="var v=this.parentNode.querySelector("video"); if(v){v.style.display="block";v.play();}" />'
|
|
15348
|
+
: '<div class="clip-noimg">no thumb</div>';
|
|
15349
|
+
var vid = clip.view_url ? '<video muted loop playsinline preload="none" style="display:none;position:absolute;inset:0" src="'+esc(clip.view_url)+'"></video>' : '';
|
|
15350
|
+
var tags = topTags(clip.tags).map(function(t){ return '<span class="clip-tag">'+esc(t)+'</span>'; }).join('');
|
|
15351
|
+
return '<div class="clip-card">'
|
|
15352
|
+
+ '<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>'
|
|
15353
|
+
+ '<div class="clip-body">'
|
|
15354
|
+
+ '<div class="clip-desc">'+esc(clip.description||clip.tags&&clip.tags.transcript||'(untagged clip)')+'</div>'
|
|
15355
|
+
+ '<div class="clip-tags">'+tags+'</div>'
|
|
15356
|
+
+ '<div class="clip-row"><span class="clip-src" title="'+esc(clip.source_filename)+'">'+esc(clip.source_filename)+'</span>'
|
|
15357
|
+
+ '<a class="clip-dl" href="/clips/'+encodeURIComponent(clip.clip_id)+'/download">Download</a></div>'
|
|
15358
|
+
+ '</div></div>';
|
|
15359
|
+
}
|
|
15050
15360
|
|
|
15051
|
-
|
|
15052
|
-
|
|
15053
|
-
|
|
15054
|
-
|
|
15055
|
-
|
|
15056
|
-
|
|
15057
|
-
|
|
15058
|
-
|
|
15059
|
-
|
|
15060
|
-
}
|
|
15361
|
+
function render(clips, label){
|
|
15362
|
+
if(!clips || clips.length===0){
|
|
15363
|
+
statusEl.innerHTML = label || '';
|
|
15364
|
+
grid.innerHTML = '<div class="clips-empty">No Clips Yet. Click "Import Source" to begin</div>';
|
|
15365
|
+
return;
|
|
15366
|
+
}
|
|
15367
|
+
statusEl.textContent = (label||'') + clips.length + ' clip' + (clips.length===1?'':'s');
|
|
15368
|
+
grid.innerHTML = clips.map(card).join('');
|
|
15369
|
+
}
|
|
15061
15370
|
|
|
15062
|
-
|
|
15063
|
-
|
|
15064
|
-
|
|
15065
|
-
|
|
15066
|
-
|
|
15067
|
-
|
|
15068
|
-
|
|
15069
|
-
|
|
15371
|
+
function handleAuth(res){
|
|
15372
|
+
if(res.status===401||res.status===403){
|
|
15373
|
+
grid.innerHTML='';
|
|
15374
|
+
statusEl.innerHTML='Sign in to VidFarm to see your clips. Then build a library with <code>vidfarm clips scan</code>.';
|
|
15375
|
+
throw new Error('unauthorized');
|
|
15376
|
+
}
|
|
15377
|
+
return res.json();
|
|
15378
|
+
}
|
|
15070
15379
|
|
|
15071
|
-
|
|
15072
|
-
|
|
15073
|
-
|
|
15074
|
-
|
|
15075
|
-
|
|
15380
|
+
function loadFeed(){
|
|
15381
|
+
statusEl.textContent='Loading your clips…';
|
|
15382
|
+
fetch('/clips/feed?limit=120', {credentials:'same-origin', headers:{Accept:'application/json'}})
|
|
15383
|
+
.then(handleAuth).then(function(d){ render(d.clips, ''); }).catch(function(){});
|
|
15384
|
+
}
|
|
15076
15385
|
|
|
15077
|
-
|
|
15078
|
-
|
|
15079
|
-
|
|
15080
|
-
|
|
15081
|
-
|
|
15082
|
-
|
|
15083
|
-
|
|
15084
|
-
|
|
15386
|
+
function search(query, criteria){
|
|
15387
|
+
statusEl.textContent='Searching…';
|
|
15388
|
+
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})})
|
|
15389
|
+
.then(handleAuth).then(function(d){
|
|
15390
|
+
var label = (d.semantic?'semantic ':'') + 'results for "' + (query||'preset') + '" — ';
|
|
15391
|
+
render(d.results, label);
|
|
15392
|
+
}).catch(function(){});
|
|
15393
|
+
}
|
|
15085
15394
|
|
|
15086
|
-
|
|
15087
|
-
|
|
15088
|
-
|
|
15089
|
-
|
|
15090
|
-
return '<span class="chip" data-id="'+esc(p.preset_id)+'">'+esc(p.name)+'</span>';
|
|
15091
|
-
}).join('');
|
|
15092
|
-
var byId = {}; (d.presets||[]).forEach(function(p){ byId[p.preset_id]=p; });
|
|
15093
|
-
Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(chip){
|
|
15094
|
-
chip.addEventListener('click', function(){
|
|
15095
|
-
Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(c){ c.classList.remove('active'); });
|
|
15096
|
-
chip.classList.add('active');
|
|
15097
|
-
var p = byId[chip.getAttribute('data-id')];
|
|
15098
|
-
if(p){ input.value=''; search(p.name, p.criteria); }
|
|
15395
|
+
form.addEventListener('submit', function(e){
|
|
15396
|
+
e.preventDefault();
|
|
15397
|
+
var q = input.value.trim();
|
|
15398
|
+
if(q) search(q, null); else loadFeed();
|
|
15099
15399
|
});
|
|
15100
|
-
});
|
|
15101
|
-
}).catch(function(){});
|
|
15102
|
-
}
|
|
15103
15400
|
|
|
15104
|
-
|
|
15105
|
-
|
|
15106
|
-
|
|
15107
|
-
|
|
15108
|
-
|
|
15109
|
-
|
|
15401
|
+
// ── Import Source modal ────────────────────────────────────────
|
|
15402
|
+
var importBtn = document.getElementById('importSourceBtn');
|
|
15403
|
+
var importDialog = document.getElementById('importDialog');
|
|
15404
|
+
var importForm = document.getElementById('importForm');
|
|
15405
|
+
var importUrl = document.getElementById('importUrl');
|
|
15406
|
+
var importPrompt = document.getElementById('importPrompt');
|
|
15407
|
+
var importNote = document.getElementById('importNote');
|
|
15408
|
+
var importCancel = document.getElementById('importCancel');
|
|
15409
|
+
var importSubmit = document.getElementById('importSubmit');
|
|
15410
|
+
var scanPoll = null;
|
|
15110
15411
|
|
|
15111
|
-
|
|
15112
|
-
|
|
15113
|
-
|
|
15114
|
-
|
|
15115
|
-
|
|
15116
|
-
|
|
15412
|
+
function setNote(msg, tone){
|
|
15413
|
+
if(!importNote) return;
|
|
15414
|
+
importNote.textContent = msg || '';
|
|
15415
|
+
if(tone) importNote.setAttribute('data-tone', tone); else importNote.removeAttribute('data-tone');
|
|
15416
|
+
}
|
|
15417
|
+
function openImport(){
|
|
15418
|
+
setNote('');
|
|
15419
|
+
if(importDialog && typeof importDialog.showModal === 'function' && !importDialog.open) importDialog.showModal();
|
|
15420
|
+
if(importUrl) importUrl.focus();
|
|
15421
|
+
}
|
|
15422
|
+
function closeImport(){
|
|
15423
|
+
if(importDialog && importDialog.open) importDialog.close();
|
|
15424
|
+
}
|
|
15425
|
+
function pollScan(scanId){
|
|
15426
|
+
if(scanPoll) clearInterval(scanPoll);
|
|
15427
|
+
var tries = 0;
|
|
15428
|
+
scanPoll = setInterval(function(){
|
|
15429
|
+
tries++;
|
|
15430
|
+
if(tries > 200){ clearInterval(scanPoll); scanPoll=null; statusEl.textContent='Still scanning in the background — check back shortly.'; return; }
|
|
15431
|
+
fetch('/clips/scan/'+encodeURIComponent(scanId), {credentials:'same-origin', headers:{Accept:'application/json'}})
|
|
15432
|
+
.then(function(r){ return r.ok ? r.json() : null; })
|
|
15433
|
+
.then(function(d){
|
|
15434
|
+
if(!d) return;
|
|
15435
|
+
var src = d.source || {};
|
|
15436
|
+
var status = src.status || (d.scan && d.scan.status) || 'running';
|
|
15437
|
+
if(status==='complete'){
|
|
15438
|
+
clearInterval(scanPoll); scanPoll=null;
|
|
15439
|
+
statusEl.textContent='Import complete — refreshing your clips…';
|
|
15440
|
+
loadFeed();
|
|
15441
|
+
} else if(status==='failed'||status==='error'){
|
|
15442
|
+
clearInterval(scanPoll); scanPoll=null;
|
|
15443
|
+
statusEl.textContent='Import failed: '+(src.error||(d.scan&&d.scan.error)||'the source could not be scanned.');
|
|
15444
|
+
} else {
|
|
15445
|
+
statusEl.textContent='Scanning source… ('+status+')';
|
|
15446
|
+
}
|
|
15447
|
+
}).catch(function(){});
|
|
15448
|
+
}, 4000);
|
|
15449
|
+
}
|
|
15450
|
+
|
|
15451
|
+
if(importBtn) importBtn.addEventListener('click', openImport);
|
|
15452
|
+
if(importCancel) importCancel.addEventListener('click', closeImport);
|
|
15453
|
+
if(importDialog) importDialog.addEventListener('click', function(e){
|
|
15454
|
+
var rect = importDialog.getBoundingClientRect();
|
|
15455
|
+
if(e.clientX<rect.left||e.clientX>rect.right||e.clientY<rect.top||e.clientY>rect.bottom) closeImport();
|
|
15456
|
+
});
|
|
15457
|
+
if(importForm) importForm.addEventListener('submit', function(e){
|
|
15458
|
+
e.preventDefault();
|
|
15459
|
+
var url = (importUrl && importUrl.value || '').trim();
|
|
15460
|
+
var prompt = (importPrompt && importPrompt.value || '').trim();
|
|
15461
|
+
if(!url){ setNote('Enter a video URL.', 'error'); return; }
|
|
15462
|
+
setNote('Starting scan…');
|
|
15463
|
+
if(importSubmit) importSubmit.disabled = true;
|
|
15464
|
+
fetch('/clips/scan', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json', Accept:'application/json'}, body: JSON.stringify({source_url:url, prompt:prompt})})
|
|
15465
|
+
.then(function(r){ return r.json().then(function(d){ return {ok:r.ok, status:r.status, data:d}; }); })
|
|
15466
|
+
.then(function(res){
|
|
15467
|
+
if(importSubmit) importSubmit.disabled = false;
|
|
15468
|
+
if(!res.ok){ setNote((res.data && res.data.error) || 'Could not start the scan.', 'error'); return; }
|
|
15469
|
+
setNote('Scan started — this can take a few minutes.', 'success');
|
|
15470
|
+
statusEl.textContent='Scanning source…';
|
|
15471
|
+
closeImport();
|
|
15472
|
+
if(importForm.reset) importForm.reset();
|
|
15473
|
+
if(res.data && res.data.scan_id) pollScan(res.data.scan_id);
|
|
15474
|
+
})
|
|
15475
|
+
.catch(function(){
|
|
15476
|
+
if(importSubmit) importSubmit.disabled = false;
|
|
15477
|
+
setNote('Network error — please try again.', 'error');
|
|
15478
|
+
});
|
|
15479
|
+
});
|
|
15480
|
+
|
|
15481
|
+
loadFeed();
|
|
15482
|
+
})();
|
|
15483
|
+
</script>
|
|
15484
|
+
`
|
|
15485
|
+
});
|
|
15117
15486
|
}
|
|
15118
15487
|
async function resolveCustomerProviderKey(customerId, provider) {
|
|
15119
15488
|
const rows = (await listProviderKeysWithSecretsForCustomer(customerId));
|
|
@@ -15209,9 +15578,22 @@ function clipKeywordMatch(clip, q) {
|
|
|
15209
15578
|
.filter(Boolean)
|
|
15210
15579
|
.every((term) => hay.includes(term));
|
|
15211
15580
|
}
|
|
15212
|
-
// GET /clips — the
|
|
15213
|
-
//
|
|
15214
|
-
|
|
15581
|
+
// GET /clips — the Clips gallery, rendered in the same shell as the rest of the
|
|
15582
|
+
// frontend and reached via the Library page's "Finished / Clips" toggle. The
|
|
15583
|
+
// client fetches /clips/feed with the session cookie (mirrors /discover vs /feed).
|
|
15584
|
+
app.get(CLIPS_PREFIX, async (c) => {
|
|
15585
|
+
const customer = await getPreferredBrowserCustomer(c);
|
|
15586
|
+
if (!customer) {
|
|
15587
|
+
return redirect(c, "/login");
|
|
15588
|
+
}
|
|
15589
|
+
return c.html(renderClipsPage({
|
|
15590
|
+
userId: customer.id,
|
|
15591
|
+
currentAccountId: customer.id,
|
|
15592
|
+
name: customer.name,
|
|
15593
|
+
email: customer.email,
|
|
15594
|
+
editorChat: await buildClipsEditorChatBoot({ customer })
|
|
15595
|
+
}));
|
|
15596
|
+
});
|
|
15215
15597
|
// GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q, ?limit).
|
|
15216
15598
|
app.get(`${CLIPS_PREFIX}/feed`, async (c) => {
|
|
15217
15599
|
const customer = requireCustomer(c);
|
|
@@ -15331,14 +15713,79 @@ app.post(`${CLIPS_PREFIX}/match-scenes`, async (c) => {
|
|
|
15331
15713
|
}
|
|
15332
15714
|
});
|
|
15333
15715
|
// POST /clips/scan — kick off a cloud scan of an uploaded/staged source video.
|
|
15716
|
+
// Inherently ASYNC: returns 202 + scan_id immediately; the Step Functions
|
|
15717
|
+
// pipeline runs the hunt and GET /clips/scan/:scanId polls it. Billing is AWS
|
|
15718
|
+
// compute only (clip_scan_lambda + step_functions_standard) — the AI spend is
|
|
15719
|
+
// the caller's own provider key (BYOK) and is never wallet-billed.
|
|
15334
15720
|
app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
15335
15721
|
const customer = requireCustomer(c);
|
|
15336
15722
|
try {
|
|
15337
15723
|
const body = (await c.req.json());
|
|
15724
|
+
const guidancePrompt = body.prompt?.trim() || "";
|
|
15725
|
+
// Build the hunt spec: explicit structured fields win, the free "what to
|
|
15726
|
+
// clip for" prompt fills the gaps (duration band, vertical/aspect, no-text).
|
|
15727
|
+
// A pasted "Import Source" URL rides in hunt_spec.source_url — the clip-scan
|
|
15728
|
+
// Lambda ingests it (RapidAPI, incl. TikTok/YouTube/IG/X) into the user's
|
|
15729
|
+
// 30-day temp folder before scanning.
|
|
15730
|
+
const sourceUrl = readNonEmptyString(body.source_url) ?? readNonEmptyString(body.video_url) ?? readNonEmptyString(body.url);
|
|
15731
|
+
if (sourceUrl && !/^https?:\/\//i.test(sourceUrl)) {
|
|
15732
|
+
return c.json({ error: "That doesn't look like a valid video URL." }, 400);
|
|
15733
|
+
}
|
|
15734
|
+
const huntSpec = normalizeHuntSpec({
|
|
15735
|
+
...(body.hunt_spec && typeof body.hunt_spec === "object" ? body.hunt_spec : {}),
|
|
15736
|
+
...(body.ranges != null ? { ranges: body.ranges } : {}),
|
|
15737
|
+
...(body.target_duration_sec != null ? { target_duration_sec: body.target_duration_sec } : {}),
|
|
15738
|
+
...(body.aspect != null ? { aspect: body.aspect } : {}),
|
|
15739
|
+
...(body.crop_focus != null ? { crop_focus: body.crop_focus } : {}),
|
|
15740
|
+
...(body.avoid_text != null ? { avoid_text: body.avoid_text } : {}),
|
|
15741
|
+
...(body.no_text != null ? { no_text: body.no_text } : {})
|
|
15742
|
+
});
|
|
15743
|
+
const parsedPrompt = parseClipHuntPrompt(guidancePrompt);
|
|
15744
|
+
if (!huntSpec.windows?.length && parsedPrompt.windows.length)
|
|
15745
|
+
huntSpec.windows = parsedPrompt.windows;
|
|
15746
|
+
if (!huntSpec.duration_band && parsedPrompt.target_duration_sec != null) {
|
|
15747
|
+
const band = resolveDurationBand(parsedPrompt.target_duration_sec);
|
|
15748
|
+
if (band)
|
|
15749
|
+
huntSpec.duration_band = band;
|
|
15750
|
+
}
|
|
15751
|
+
if (!huntSpec.aspect && parsedPrompt.aspect)
|
|
15752
|
+
huntSpec.aspect = parsedPrompt.aspect;
|
|
15753
|
+
if (!huntSpec.avoid_text && parsedPrompt.avoid_text)
|
|
15754
|
+
huntSpec.avoid_text = true;
|
|
15755
|
+
if (!huntSpec.source_url && sourceUrl)
|
|
15756
|
+
huntSpec.source_url = sourceUrl;
|
|
15757
|
+
// Uploaded-source hunts: resolve a temp-folder file (the canonical home for
|
|
15758
|
+
// hunt originals — 30-day TTL) or a My Files attachment to its S3 key.
|
|
15759
|
+
let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
|
|
15760
|
+
const tempFileId = readNonEmptyString(body.temp_file_id);
|
|
15761
|
+
const attachmentId = readNonEmptyString(body.attachment_id);
|
|
15762
|
+
if (!body.raw_s3_uri && !resolvedS3Key && tempFileId) {
|
|
15763
|
+
const file = await serverlessRecords.getUserTemporaryFile(customer.id, tempFileId);
|
|
15764
|
+
if (!file)
|
|
15765
|
+
return c.json({ error: "Temporary file not found — upload it first via /api/v1/user/me/temporary-files." }, 404);
|
|
15766
|
+
resolvedS3Key = file.storageKey;
|
|
15767
|
+
}
|
|
15768
|
+
else if (!body.raw_s3_uri && !resolvedS3Key && attachmentId) {
|
|
15769
|
+
const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
|
|
15770
|
+
if (!attachment)
|
|
15771
|
+
return c.json({ error: "Attachment not found in My Files." }, 404);
|
|
15772
|
+
resolvedS3Key = attachment.storageKey;
|
|
15773
|
+
}
|
|
15338
15774
|
const rawS3Uri = body.raw_s3_uri ??
|
|
15339
|
-
(
|
|
15340
|
-
if (!rawS3Uri) {
|
|
15341
|
-
return c.json({ error: "Provide raw_s3_uri (s3://…) or s3_key of the
|
|
15775
|
+
(resolvedS3Key && config.AWS_S3_BUCKET ? `s3://${config.AWS_S3_BUCKET}/${resolvedS3Key}` : undefined);
|
|
15776
|
+
if (!rawS3Uri && !huntSpec.source_url) {
|
|
15777
|
+
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);
|
|
15778
|
+
}
|
|
15779
|
+
// Hunts bill AWS compute post-hoc, so submission is the spend gate (same
|
|
15780
|
+
// policy as job submission).
|
|
15781
|
+
try {
|
|
15782
|
+
await assertWalletCanQueueJobs(customer.id);
|
|
15783
|
+
}
|
|
15784
|
+
catch (error) {
|
|
15785
|
+
if (error instanceof InsufficientWalletFundsError) {
|
|
15786
|
+
return insufficientFundsResponse(c, error);
|
|
15787
|
+
}
|
|
15788
|
+
throw error;
|
|
15342
15789
|
}
|
|
15343
15790
|
const provider = normalizeClipProvider(body.provider);
|
|
15344
15791
|
const built = await buildCustomerClipClient(customer.id, provider);
|
|
@@ -15348,26 +15795,39 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15348
15795
|
const tier = normalizeScanTier(body.tier);
|
|
15349
15796
|
const framesPerScene = Math.max(1, Math.min(3, Number(body.frames_per_scene) || 2));
|
|
15350
15797
|
const includeAudio = body.include_audio !== false && provider === "gemini";
|
|
15351
|
-
const
|
|
15798
|
+
const tracer = readNonEmptyString(body.tracer) ?? `clips_import:${customer.id}`;
|
|
15352
15799
|
const now = nowIso();
|
|
15353
15800
|
const scanId = createIdV7("clipscan");
|
|
15354
15801
|
const sourceVideoId = createIdV7("srcvid");
|
|
15355
|
-
|
|
15356
|
-
const
|
|
15357
|
-
|
|
15802
|
+
// The URL ingest is deferred to the Lambda, so raw_s3_uri may be empty here.
|
|
15803
|
+
const sourcePointer = rawS3Uri ?? huntSpec.source_url ?? "";
|
|
15804
|
+
const filename = body.filename || (sourcePointer.split("/").pop() || "source.mp4").split("?")[0] || "source.mp4";
|
|
15805
|
+
// Time-range hunts only pay for the windows: both estimates use the
|
|
15806
|
+
// EFFECTIVE (windowed) duration, and the duration band drives the expected
|
|
15807
|
+
// scene size. The BYOK estimate is the user's own provider spend; the
|
|
15808
|
+
// compute estimate is what the wallet will be billed (AWS only, marked up).
|
|
15809
|
+
const effectiveDurationSec = body.duration_sec && body.duration_sec > 0
|
|
15810
|
+
? effectiveHuntDurationSec(body.duration_sec, huntSpec.windows)
|
|
15811
|
+
: null;
|
|
15812
|
+
const estimate = effectiveDurationSec
|
|
15813
|
+
? estimateScanCostFromDuration(effectiveDurationSec, {
|
|
15358
15814
|
tier,
|
|
15359
15815
|
provider,
|
|
15360
15816
|
tagModelId: client.tagModelId,
|
|
15361
15817
|
embedModelId: client.embeddingModelId,
|
|
15362
15818
|
framesPerScene,
|
|
15363
|
-
includeAudio
|
|
15819
|
+
includeAudio,
|
|
15820
|
+
avgSceneSec: huntSpec.duration_band?.target_sec
|
|
15364
15821
|
})
|
|
15365
15822
|
: null;
|
|
15823
|
+
const computeEstimate = effectiveDurationSec
|
|
15824
|
+
? estimateClipScanComputeUsd(effectiveDurationSec, huntSpec.duration_band?.target_sec)
|
|
15825
|
+
: null;
|
|
15366
15826
|
const source = {
|
|
15367
15827
|
source_video_id: sourceVideoId,
|
|
15368
15828
|
owner_id: customer.id,
|
|
15369
15829
|
filename,
|
|
15370
|
-
raw_s3_uri:
|
|
15830
|
+
raw_s3_uri: sourcePointer,
|
|
15371
15831
|
duration_sec: body.duration_sec,
|
|
15372
15832
|
clip_count: 0,
|
|
15373
15833
|
tier,
|
|
@@ -15387,7 +15847,7 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15387
15847
|
owner_id: customer.id,
|
|
15388
15848
|
source_video_id: sourceVideoId,
|
|
15389
15849
|
source_filename: filename,
|
|
15390
|
-
raw_s3_uri: rawS3Uri,
|
|
15850
|
+
raw_s3_uri: rawS3Uri ?? "",
|
|
15391
15851
|
tier,
|
|
15392
15852
|
provider,
|
|
15393
15853
|
tag_api_key: tagApiKey,
|
|
@@ -15395,7 +15855,9 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15395
15855
|
embedding_api_key: embeddingKey ?? "",
|
|
15396
15856
|
guidance_prompt: guidancePrompt,
|
|
15397
15857
|
frames_per_scene: framesPerScene,
|
|
15398
|
-
include_audio: includeAudio
|
|
15858
|
+
include_audio: includeAudio,
|
|
15859
|
+
hunt_spec: huntSpec,
|
|
15860
|
+
tracer
|
|
15399
15861
|
})
|
|
15400
15862
|
}));
|
|
15401
15863
|
executionArn = res.executionArn;
|
|
@@ -15406,8 +15868,9 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15406
15868
|
source_video_id: sourceVideoId,
|
|
15407
15869
|
status: executionArn ? "running" : "queued",
|
|
15408
15870
|
tier,
|
|
15409
|
-
raw_s3_uri:
|
|
15871
|
+
raw_s3_uri: sourcePointer,
|
|
15410
15872
|
execution_arn: executionArn,
|
|
15873
|
+
tracer,
|
|
15411
15874
|
created_at: now,
|
|
15412
15875
|
updated_at: now
|
|
15413
15876
|
};
|
|
@@ -15417,7 +15880,10 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15417
15880
|
source_video_id: sourceVideoId,
|
|
15418
15881
|
status: scan.status,
|
|
15419
15882
|
execution_arn: executionArn ?? null,
|
|
15883
|
+
tracer,
|
|
15884
|
+
hunt_spec: huntSpec,
|
|
15420
15885
|
estimate,
|
|
15886
|
+
compute_estimate: computeEstimate,
|
|
15421
15887
|
pipeline_deployed: Boolean(config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN)
|
|
15422
15888
|
}, 202);
|
|
15423
15889
|
}
|
|
@@ -15428,9 +15894,32 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
15428
15894
|
// GET /clips/scan/:scanId — poll scan status.
|
|
15429
15895
|
app.get(`${CLIPS_PREFIX}/scan/:scanId`, async (c) => {
|
|
15430
15896
|
const customer = requireCustomer(c);
|
|
15431
|
-
|
|
15897
|
+
let scan = await clipRecords.getScan(customer.id, c.req.param("scanId"));
|
|
15432
15898
|
if (!scan)
|
|
15433
15899
|
return c.json({ error: "Scan not found" }, 404);
|
|
15900
|
+
// Lazily reconcile with the Step Functions execution: the pipeline has no
|
|
15901
|
+
// failure hook back into the scan record, so a crashed execution would
|
|
15902
|
+
// otherwise leave "running" forever and pollers spin.
|
|
15903
|
+
if ((scan.status === "running" || scan.status === "queued") && scan.execution_arn) {
|
|
15904
|
+
try {
|
|
15905
|
+
const desc = await clipScanSfn.send(new DescribeExecutionCommand({ executionArn: scan.execution_arn }));
|
|
15906
|
+
if (desc.status === "FAILED" || desc.status === "TIMED_OUT" || desc.status === "ABORTED") {
|
|
15907
|
+
scan = { ...scan, status: "failed", error: `Scan pipeline ${desc.status.toLowerCase().replace("_", " ")}.`, updated_at: nowIso() };
|
|
15908
|
+
await clipRecords.putScan(scan);
|
|
15909
|
+
const failedSource = await clipRecords.getSource(customer.id, scan.source_video_id);
|
|
15910
|
+
if (failedSource && failedSource.status !== "complete") {
|
|
15911
|
+
await clipRecords.putSource({ ...failedSource, status: "failed", updated_at: nowIso() });
|
|
15912
|
+
}
|
|
15913
|
+
}
|
|
15914
|
+
else if (desc.status === "SUCCEEDED") {
|
|
15915
|
+
scan = { ...scan, status: "complete", updated_at: nowIso() };
|
|
15916
|
+
await clipRecords.putScan(scan);
|
|
15917
|
+
}
|
|
15918
|
+
}
|
|
15919
|
+
catch (error) {
|
|
15920
|
+
devLog("clip_scan.reconcile_failed", { scan_id: scan.scan_id, ...devErrorFields(error) }, "warn");
|
|
15921
|
+
}
|
|
15922
|
+
}
|
|
15434
15923
|
const source = await clipRecords.getSource(customer.id, scan.source_video_id);
|
|
15435
15924
|
return c.json({ scan, source });
|
|
15436
15925
|
});
|