@mevdragon/vidfarm-devcli 0.6.0 → 0.7.1
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/SKILL.director.md +70 -16
- package/SKILL.platform.md +13 -7
- package/demo/dist/app.js +110 -60
- package/dist/src/app.js +579 -57
- package/dist/src/cli.js +304 -136
- package/dist/src/composition-runtime.js +26 -0
- package/dist/src/config.js +7 -0
- package/dist/src/devcli/composition-edit.js +12 -0
- package/dist/src/editor-chat.js +5 -4
- package/dist/src/frontend/homepage-client.js +61 -6
- package/dist/src/frontend/homepage-view.js +17 -4
- package/dist/src/homepage.js +46 -0
- package/dist/src/hyperframes/composition.js +87 -0
- package/dist/src/primitive-registry.js +261 -0
- package/dist/src/services/billing.js +1 -0
- package/dist/src/services/hyperframes.js +26 -1
- package/dist/src/services/storage.js +6 -0
- package/dist/src/services/upstream.js +248 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +18 -18
package/dist/src/app.js
CHANGED
|
@@ -21,6 +21,7 @@ import { writeForkManifest } from "./services/fork-manifest.js";
|
|
|
21
21
|
import { renderHelpPage } from "./help-page.js";
|
|
22
22
|
import { renderHomepage } from "./homepage.js";
|
|
23
23
|
import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
|
|
24
|
+
import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER } from "./hyperframes/composition.js";
|
|
24
25
|
import { normalizeCompositionZIndexHtml, sanitizeCompositionHtml } from "./services/composition-sanitize.js";
|
|
25
26
|
import { applyMarkupUsd } from "./services/billing-pricing.js";
|
|
26
27
|
import { primitiveRegistry } from "./primitive-registry.js";
|
|
@@ -49,6 +50,7 @@ import { createPrimitiveJobContext } from "./primitive-context.js";
|
|
|
49
50
|
import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
|
|
50
51
|
import { MediaDurationExceededError, probeMediaAsset } from "./services/media-processing.js";
|
|
51
52
|
import { estimateGhostcutCostUsd, isGhostcutConfigured, mirrorGhostcutVideoToStorage, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
|
|
53
|
+
import { hasUpstreamKey, isUpstreamEnabled, proxyRequestToUpstream, seedFromUpstream, upstreamHost, upstreamJson } from "./services/upstream.js";
|
|
52
54
|
import { TemplateSourceService } from "./services/template-sources.js";
|
|
53
55
|
import { TemplateSourceAlreadyRegisteredError } from "./services/template-sources.js";
|
|
54
56
|
import { renderCustomInpaintPage } from "./template-editor-pages.js";
|
|
@@ -1576,6 +1578,9 @@ function primitiveOperationPathForJob(job) {
|
|
|
1576
1578
|
if (job.templateId === "primitive:video_trim" && job.operationName === "run") {
|
|
1577
1579
|
return `${PRIMITIVES_PREFIX}/videos/trim`;
|
|
1578
1580
|
}
|
|
1581
|
+
if (job.templateId === "primitive:video_remove_captions" && job.operationName === "run") {
|
|
1582
|
+
return `${PRIMITIVES_PREFIX}/videos/remove-captions`;
|
|
1583
|
+
}
|
|
1579
1584
|
if (job.templateId === "primitive:video_extract_audio" && job.operationName === "run") {
|
|
1580
1585
|
return `${PRIMITIVES_PREFIX}/videos/extract-audio`;
|
|
1581
1586
|
}
|
|
@@ -3061,6 +3066,7 @@ function buildPrimitiveEditorDocsRoutes() {
|
|
|
3061
3066
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/media/dedupe`, summary: "Primitive dedupe transform for any image or video. Body must be { tracer, payload: { source_media_url, media_type?: \"image\"|\"video\", effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, fallback_duration_ms?, object_fit?, background_color?, muted?, volume?, output_format? }, webhook_url? }. Use to camouflage reused media by applying small transforms (zoom/tilt/rotate/filter/tint) before republishing." },
|
|
3062
3067
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/timeline/render`, summary: "Primitive timeline/layer render job. Body must be { tracer, payload: { composition_html | composition: { composition_id, width?, height?, fps?, layers: [{ id, kind, start, duration, src?, text?, html?, x?, y?, width?, height?, track?, z? }] }, chunk_size?, max_parallel_chunks?, output_key? }, webhook_url? }. Use this instead of creating new per-template REST APIs." },
|
|
3063
3068
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/videos/trim`, summary: "Primitive video trim job. Body must be { tracer, payload: { source_video_url, start_ms, duration_ms|end_ms, max_width?, max_height?, crf?, preset?, audio_bitrate_kbps? }, webhook_url? }." },
|
|
3069
|
+
{ method: "POST", path: `${PRIMITIVES_PREFIX}/videos/remove-captions`, summary: "Primitive caption-removal job. Removes burned-in captions/subtitles/on-screen text from a source video (GhostCut) and returns a durable caption-free platform MP4. Body must be { tracer, payload: { source_video_url }, webhook_url? }. Async job that typically takes a few minutes; bills ~$0.10 per 30 seconds of source video. Also reachable at POST /api/v1/primitives/remove-video-captions." },
|
|
3064
3070
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/videos/extract-audio`, summary: "Primitive audio extraction job from a source video. Body must be { tracer, payload: { source_video_url, output_format?, audio_bitrate_kbps? }, webhook_url? }." },
|
|
3065
3071
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/videos/probe`, summary: "Primitive video probe job. Body must be { tracer, payload: { source_video_url }, webhook_url? }. Returns durable JSON metadata." },
|
|
3066
3072
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/videos/extract-frame`, summary: "Primitive video frame extraction job. Body must be { tracer, payload: { source_video_url, at_ms?, output_format? }, webhook_url? }." },
|
|
@@ -3485,12 +3491,13 @@ function normalizeEditorChatPath(input) {
|
|
|
3485
3491
|
// /discover/templates (a different prefix) and stays out of reach.
|
|
3486
3492
|
"/discover/feed",
|
|
3487
3493
|
VIDEOS_PREFIX,
|
|
3488
|
-
// Read-only
|
|
3489
|
-
// upload and the
|
|
3490
|
-
// primitive route. Only the GET /
|
|
3491
|
-
//
|
|
3492
|
-
//
|
|
3493
|
-
// its own auth guard, which
|
|
3494
|
+
// Read-only subtitle-removal state per fork so the AI can pick between the
|
|
3495
|
+
// original upload and the caption-free mirror before feeding a video into a
|
|
3496
|
+
// primitive route. Only the GET /remove-video-captions variant (legacy
|
|
3497
|
+
// alias: /ghostcut) is exercised by the AI — POST /remove-video-captions-poll
|
|
3498
|
+
// advances the job so the copilot must be explicit about wanting it, but it
|
|
3499
|
+
// lives under the same prefix and is protected by its own auth guard, which
|
|
3500
|
+
// is fine.
|
|
3494
3501
|
COMPOSITIONS_PREFIX
|
|
3495
3502
|
];
|
|
3496
3503
|
const parsed = trimmed.startsWith("http://") || trimmed.startsWith("https://")
|
|
@@ -3621,7 +3628,7 @@ function normalizeOperationRequestTracer(tracer) {
|
|
|
3621
3628
|
}
|
|
3622
3629
|
function createTemplateHttpTool(input) {
|
|
3623
3630
|
return tool({
|
|
3624
|
-
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, 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, 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 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/
|
|
3631
|
+
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.",
|
|
3625
3632
|
inputSchema: z.object({
|
|
3626
3633
|
method: z.enum(["GET", "POST"]).describe("HTTP method to use."),
|
|
3627
3634
|
path: z.string().min(1).describe("Absolute path or same-origin URL for an allowed Vidfarm REST route."),
|
|
@@ -4134,6 +4141,8 @@ function createEditorActionTool() {
|
|
|
4134
4141
|
background_style: z.enum(["plain", "outline", "highlight-solid", "highlight-translucent"]).optional().describe("Text background treatment."),
|
|
4135
4142
|
object_fit: z.string().optional().describe("CSS object-fit for image/video (cover, contain, fill, none, scale-down)."),
|
|
4136
4143
|
object_position: z.string().optional().describe("CSS object-position for image/video (e.g., center, top, bottom left)."),
|
|
4144
|
+
ken_burns: z.enum(["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right", "none"]).optional().describe("Ken Burns motion for still-image layers: a slow pan/zoom spanning the clip's full duration. Apply with set_layer_media on an existing image, or seed it on add_layer/generate_layer when kind/media_type is image. 'none' removes the effect. When the user says 'animate the images' / 'ken burns', apply it to every still image, varying presets across adjacent clips (zoom-in, pan-left, zoom-out, ...) for a documentary feel."),
|
|
4145
|
+
ken_burns_intensity: z.number().optional().describe("Ken Burns strength: extra zoom / pan travel as a fraction of the frame, 0.04-0.5 (default 0.18; ~0.1 subtle, ~0.3 dramatic). Only meaningful alongside ken_burns."),
|
|
4137
4146
|
volume: z.number().optional().describe("Volume 0..1 for audio/video."),
|
|
4138
4147
|
muted: z.boolean().optional().describe("Toggle mute on audio/video."),
|
|
4139
4148
|
group_label: z.string().optional().describe("Optional label for group_layers."),
|
|
@@ -4508,6 +4517,21 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
|
|
|
4508
4517
|
rewritten = `${rewritten}\n${runtimeTag}`;
|
|
4509
4518
|
}
|
|
4510
4519
|
}
|
|
4520
|
+
// Ken Burns keyframes: always available in the editor iframe so toggling
|
|
4521
|
+
// data-kenburns on a layer animates immediately, even for compositions
|
|
4522
|
+
// stored before the builder embedded the stylesheet.
|
|
4523
|
+
if (!rewritten.includes(KEN_BURNS_STYLE_MARKER)) {
|
|
4524
|
+
const kenBurnsTag = `<style data-vf-kenburns="true">${KEN_BURNS_CSS}</style>`;
|
|
4525
|
+
if (/<\/head>/i.test(rewritten)) {
|
|
4526
|
+
rewritten = rewritten.replace(/<\/head>/i, `${kenBurnsTag}\n</head>`);
|
|
4527
|
+
}
|
|
4528
|
+
else if (/<\/body>/i.test(rewritten)) {
|
|
4529
|
+
rewritten = rewritten.replace(/<\/body>/i, `${kenBurnsTag}\n</body>`);
|
|
4530
|
+
}
|
|
4531
|
+
else {
|
|
4532
|
+
rewritten = `${rewritten}\n${kenBurnsTag}`;
|
|
4533
|
+
}
|
|
4534
|
+
}
|
|
4511
4535
|
return rewritten;
|
|
4512
4536
|
}
|
|
4513
4537
|
async function renderHyperframesStudioEditorPage(input) {
|
|
@@ -4527,7 +4551,12 @@ async function renderHyperframesStudioEditorPage(input) {
|
|
|
4527
4551
|
vidfarmApiKey: input.customer ? await getVisibleApiKey(input.customer.id) : null,
|
|
4528
4552
|
// Only `vidfarm serve` (local disk) streams on-disk edits back to the tab;
|
|
4529
4553
|
// deployed environments have nothing to watch, so the client skips the SSE.
|
|
4530
|
-
liveReload: config.STORAGE_DRIVER === "local"
|
|
4554
|
+
liveReload: config.STORAGE_DRIVER === "local",
|
|
4555
|
+
// Cloud passthrough (vidfarm serve): when this box renders in-process AND
|
|
4556
|
+
// an upstream key is configured, the editor's Render button becomes a
|
|
4557
|
+
// popover offering "Render Local (Free)" vs "Render in Cloud".
|
|
4558
|
+
localRender: !config.VIDFARM_JOB_STATE_MACHINE_ARN,
|
|
4559
|
+
cloudRenderAvailable: hasUpstreamKey()
|
|
4531
4560
|
};
|
|
4532
4561
|
return `<!doctype html>
|
|
4533
4562
|
<html lang="en">
|
|
@@ -4589,7 +4618,20 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
4589
4618
|
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
4590
4619
|
if (!templateId.startsWith("template_"))
|
|
4591
4620
|
return c.notFound();
|
|
4592
|
-
|
|
4621
|
+
let template = await serverlessRecords.getTemplate(templateId);
|
|
4622
|
+
// Cloud passthrough (vidfarm serve): first open of an upstream catalog
|
|
4623
|
+
// template (clicked on the proxied /discover feed) — pull its composition
|
|
4624
|
+
// and mint the local template + fork records, then continue as if it had
|
|
4625
|
+
// always been local. Only the editor's working data lands on disk.
|
|
4626
|
+
if (!template && isUpstreamEnabled() && customer) {
|
|
4627
|
+
await seedFromUpstream({
|
|
4628
|
+
customerId: customer.id,
|
|
4629
|
+
templateId,
|
|
4630
|
+
forkId: c.req.query("fork") ?? undefined,
|
|
4631
|
+
shareToken: readShareToken(c) ?? undefined
|
|
4632
|
+
});
|
|
4633
|
+
template = await serverlessRecords.getTemplate(templateId);
|
|
4634
|
+
}
|
|
4593
4635
|
if (!template)
|
|
4594
4636
|
return c.notFound();
|
|
4595
4637
|
// Private templates are only openable by their owner; 404 (not 403) so the
|
|
@@ -4606,7 +4648,19 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
4606
4648
|
// instead of a broken URL.
|
|
4607
4649
|
let effectiveForkParam = forkParam ?? null;
|
|
4608
4650
|
if (effectiveForkParam) {
|
|
4609
|
-
|
|
4651
|
+
let forkRecord = await serverlessRecords.getCompositionFork(effectiveForkParam);
|
|
4652
|
+
// Cloud passthrough: an unknown ?fork= on a known template is an upstream
|
|
4653
|
+
// fork the user opened from the cloud catalog — seed it locally too.
|
|
4654
|
+
if (!forkRecord && isUpstreamEnabled() && customer) {
|
|
4655
|
+
const seeded = await seedFromUpstream({
|
|
4656
|
+
customerId: customer.id,
|
|
4657
|
+
templateId,
|
|
4658
|
+
forkId: effectiveForkParam,
|
|
4659
|
+
shareToken: readShareToken(c) ?? undefined
|
|
4660
|
+
});
|
|
4661
|
+
if (seeded)
|
|
4662
|
+
forkRecord = await serverlessRecords.getCompositionFork(effectiveForkParam);
|
|
4663
|
+
}
|
|
4610
4664
|
if (!forkRecord || forkRecord.deletedAt) {
|
|
4611
4665
|
effectiveForkParam = null;
|
|
4612
4666
|
}
|
|
@@ -4737,6 +4791,24 @@ app.get("/discover/feed", async (c) => {
|
|
|
4737
4791
|
if (entry)
|
|
4738
4792
|
templateEntries.push(entry);
|
|
4739
4793
|
}
|
|
4794
|
+
// Cloud passthrough (vidfarm serve): the local records are just the working
|
|
4795
|
+
// set of seeded templates, so surface the upstream (cloud) catalog behind
|
|
4796
|
+
// them. Local entries win on id conflicts — a seeded template also exists
|
|
4797
|
+
// upstream and the local copy reflects any local edits. Fail-soft: an
|
|
4798
|
+
// unreachable upstream degrades to the local-only feed.
|
|
4799
|
+
if (isUpstreamEnabled()) {
|
|
4800
|
+
const search = new URLSearchParams({ limit: String(limit) });
|
|
4801
|
+
if (query)
|
|
4802
|
+
search.set("q", query);
|
|
4803
|
+
const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
|
|
4804
|
+
const seen = new Set(templateEntries.map((entry) => entry.templateId));
|
|
4805
|
+
for (const entry of upstreamFeed) {
|
|
4806
|
+
if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId)) {
|
|
4807
|
+
templateEntries.push(entry);
|
|
4808
|
+
seen.add(entry.templateId);
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4740
4812
|
// The public feed is identical for every caller, but a logged-in response
|
|
4741
4813
|
// interleaves the caller's private templates — never let a shared cache
|
|
4742
4814
|
// serve that to someone else.
|
|
@@ -4752,7 +4824,10 @@ function buildTemplateHomepageEntry(template) {
|
|
|
4752
4824
|
if (!template.videoUrl)
|
|
4753
4825
|
return null;
|
|
4754
4826
|
const isPrivate = template.visibility === "private";
|
|
4755
|
-
|
|
4827
|
+
// Older records stored the source host ("youtube.com") as a title fallback —
|
|
4828
|
+
// never show that as a card title; the template id is the canonical default.
|
|
4829
|
+
const storedTitle = template.title && template.title !== template.sourceHost ? template.title : null;
|
|
4830
|
+
const title = isPrivate ? (storedTitle || template.id) : template.id;
|
|
4756
4831
|
const previewUrl = template.thumbnailUrl || template.videoUrl || null;
|
|
4757
4832
|
return {
|
|
4758
4833
|
title,
|
|
@@ -4788,17 +4863,213 @@ const DISCOVER_SUBMISSION_HOSTS = [
|
|
|
4788
4863
|
function isSupportedDiscoverSubmissionHost(host) {
|
|
4789
4864
|
return DISCOVER_SUBMISSION_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
|
|
4790
4865
|
}
|
|
4791
|
-
//
|
|
4792
|
-
//
|
|
4793
|
-
//
|
|
4866
|
+
// Upload cap for "Add Template → upload a video". Generous relative to the
|
|
4867
|
+
// 50 MB My Files cap because template sources are full short-form videos; the
|
|
4868
|
+
// bytes go straight to S3 via presigned PUT, never through a Lambda body.
|
|
4869
|
+
const INSPIRATION_UPLOAD_MAX_BYTES = 200 * 1024 * 1024;
|
|
4870
|
+
function isUploadableInspirationVideo(fileName, contentType) {
|
|
4871
|
+
if (contentType.startsWith("video/"))
|
|
4872
|
+
return true;
|
|
4873
|
+
return [".mp4", ".m4v", ".mov", ".webm"].includes(path.extname(fileName).toLowerCase());
|
|
4874
|
+
}
|
|
4875
|
+
function isOwnInspirationUploadKey(storageKey, customerId) {
|
|
4876
|
+
return storageKey.startsWith(`users/${customerId}/inspiration-uploads/`);
|
|
4877
|
+
}
|
|
4878
|
+
// Discover "Add Template" (upload, step 1 of 3): mint a write target for a
|
|
4879
|
+
// local video file. Contract shared by web + devcli: presign here (JSON), PUT
|
|
4880
|
+
// (or multipart-fallback POST) the bytes, then POST /discover/templates with
|
|
4881
|
+
// { upload: { storage_key, file_name } } to queue the ingest. In `vidfarm
|
|
4882
|
+
// serve` cloud-passthrough mode this proxies upstream so the client PUTs
|
|
4883
|
+
// straight to cloud S3 and the ingest lands in the cloud account — multipart
|
|
4884
|
+
// bodies can't proxy (text-only proxy), so proxying the presign avoids ever
|
|
4885
|
+
// needing to.
|
|
4886
|
+
app.post("/discover/templates/upload/presign", async (c) => {
|
|
4887
|
+
const customer = await requireBrowserCustomer(c);
|
|
4888
|
+
if (!customer)
|
|
4889
|
+
return c.json({ ok: false, error: "Log in to add templates." }, 401);
|
|
4890
|
+
if (hasUpstreamKey()) {
|
|
4891
|
+
const proxied = await proxyRequestToUpstream(c);
|
|
4892
|
+
if (proxied)
|
|
4893
|
+
return proxied;
|
|
4894
|
+
}
|
|
4895
|
+
const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
|
|
4896
|
+
const fileName = sanitizeFileName(readNonEmptyString(body.file_name) ?? "upload.mp4");
|
|
4897
|
+
const contentType = readNonEmptyString(body.content_type) ?? inferAttachmentContentType(fileName);
|
|
4898
|
+
if (!isUploadableInspirationVideo(fileName, contentType)) {
|
|
4899
|
+
return c.json({ ok: false, error: "Upload a video file (MP4, MOV, or WebM)." }, 400);
|
|
4900
|
+
}
|
|
4901
|
+
const sizeBytes = Number(body.size_bytes);
|
|
4902
|
+
if (Number.isFinite(sizeBytes) && sizeBytes > INSPIRATION_UPLOAD_MAX_BYTES) {
|
|
4903
|
+
return c.json({ ok: false, error: "Video uploads can be at most 200 MB." }, 400);
|
|
4904
|
+
}
|
|
4905
|
+
const uploadId = randomUUID();
|
|
4906
|
+
const storageKey = storage.inspirationUploadKey(customer.id, uploadId, fileName);
|
|
4907
|
+
if (config.STORAGE_DRIVER !== "s3") {
|
|
4908
|
+
return c.json({
|
|
4909
|
+
transport: "server",
|
|
4910
|
+
upload_id: uploadId,
|
|
4911
|
+
file_name: fileName,
|
|
4912
|
+
content_type: contentType,
|
|
4913
|
+
storage_key: storageKey,
|
|
4914
|
+
upload: {
|
|
4915
|
+
method: "POST",
|
|
4916
|
+
url: "/discover/templates/upload",
|
|
4917
|
+
form_field: "file"
|
|
4918
|
+
}
|
|
4919
|
+
});
|
|
4920
|
+
}
|
|
4921
|
+
const upload = await storage.createWriteUrl(storageKey, { contentType, expiresIn: 3600 });
|
|
4922
|
+
return c.json({
|
|
4923
|
+
transport: "presigned",
|
|
4924
|
+
upload_id: uploadId,
|
|
4925
|
+
file_name: fileName,
|
|
4926
|
+
content_type: contentType,
|
|
4927
|
+
storage_key: storageKey,
|
|
4928
|
+
upload: {
|
|
4929
|
+
method: upload.method,
|
|
4930
|
+
url: upload.url,
|
|
4931
|
+
headers: upload.headers,
|
|
4932
|
+
expires_in_seconds: upload.expiresIn
|
|
4933
|
+
}
|
|
4934
|
+
});
|
|
4935
|
+
});
|
|
4936
|
+
// Upload step 2 (multipart fallback): only used when presign returned
|
|
4937
|
+
// transport "server" — i.e. the local storage driver (vidfarm serve without
|
|
4938
|
+
// S3). Deliberately not proxied upstream: the upstream proxy is text-only,
|
|
4939
|
+
// and in passthrough mode the presign already handed the client a direct
|
|
4940
|
+
// cloud target.
|
|
4941
|
+
app.post("/discover/templates/upload", async (c) => {
|
|
4942
|
+
const customer = await requireBrowserCustomer(c);
|
|
4943
|
+
if (!customer)
|
|
4944
|
+
return c.json({ ok: false, error: "Log in to add templates." }, 401);
|
|
4945
|
+
const formData = await c.req.formData().catch(() => null);
|
|
4946
|
+
const file = formData?.get("file");
|
|
4947
|
+
if (!isFormFile(file))
|
|
4948
|
+
return c.json({ ok: false, error: "Select a video file to upload." }, 400);
|
|
4949
|
+
const fileName = sanitizeFileName(file.name || "upload.mp4");
|
|
4950
|
+
const contentType = file.type || inferAttachmentContentType(fileName);
|
|
4951
|
+
if (!isUploadableInspirationVideo(fileName, contentType)) {
|
|
4952
|
+
return c.json({ ok: false, error: "Upload a video file (MP4, MOV, or WebM)." }, 400);
|
|
4953
|
+
}
|
|
4954
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
4955
|
+
if (buffer.byteLength > INSPIRATION_UPLOAD_MAX_BYTES) {
|
|
4956
|
+
return c.json({ ok: false, error: "Video uploads can be at most 200 MB." }, 400);
|
|
4957
|
+
}
|
|
4958
|
+
const uploadId = randomUUID();
|
|
4959
|
+
const storageKey = storage.inspirationUploadKey(customer.id, uploadId, fileName);
|
|
4960
|
+
const stored = await storage.putBuffer(storageKey, buffer, contentType);
|
|
4961
|
+
return c.json({
|
|
4962
|
+
ok: true,
|
|
4963
|
+
storage_key: storageKey,
|
|
4964
|
+
file_name: fileName,
|
|
4965
|
+
content_type: contentType,
|
|
4966
|
+
size_bytes: buffer.byteLength,
|
|
4967
|
+
url: stored.url
|
|
4968
|
+
}, 201);
|
|
4969
|
+
});
|
|
4970
|
+
// Discover "Add Template": ingest a social video URL — or a video the caller
|
|
4971
|
+
// just uploaded via the presign/upload pair above — as a PRIVATE inspiration
|
|
4972
|
+
// owned by the caller. The download/ingest job runs async; /discover/feed
|
|
4973
|
+
// finalizes it and mints the private template once the video lands.
|
|
4794
4974
|
app.post("/discover/templates", async (c) => {
|
|
4795
4975
|
const customer = await requireBrowserCustomer(c);
|
|
4796
4976
|
if (!customer)
|
|
4797
4977
|
return c.json({ ok: false, error: "Log in to add templates." }, 401);
|
|
4798
4978
|
const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
|
|
4979
|
+
const uploadRef = asRecord(body.upload);
|
|
4980
|
+
const uploadStorageKey = readNonEmptyString(uploadRef?.storage_key);
|
|
4981
|
+
// Cloud passthrough (vidfarm serve): Add Template ingests into the CLOUD
|
|
4982
|
+
// account — the download/ingest job, billing, and catalog entry all live
|
|
4983
|
+
// upstream, and the proxied /discover/feed finalizes it. The one exception:
|
|
4984
|
+
// an upload key minted by THIS box (local presign fallback while upstream
|
|
4985
|
+
// was unreachable) — its bytes only exist locally, so ingest locally.
|
|
4986
|
+
if (hasUpstreamKey() && !(uploadStorageKey && isOwnInspirationUploadKey(uploadStorageKey, customer.id))) {
|
|
4987
|
+
const proxied = await proxyRequestToUpstream(c);
|
|
4988
|
+
if (proxied)
|
|
4989
|
+
return proxied;
|
|
4990
|
+
}
|
|
4991
|
+
if (uploadStorageKey) {
|
|
4992
|
+
if (!isOwnInspirationUploadKey(uploadStorageKey, customer.id)) {
|
|
4993
|
+
return c.json({ ok: false, error: "That upload doesn't belong to this account." }, 403);
|
|
4994
|
+
}
|
|
4995
|
+
const uploadUrl = storage.getPublicUrl(uploadStorageKey);
|
|
4996
|
+
if (!uploadUrl)
|
|
4997
|
+
return c.json({ ok: false, error: "Uploaded video could not be resolved." }, 400);
|
|
4998
|
+
const explicitTitle = readNonEmptyString(body.title) ?? null;
|
|
4999
|
+
const tagline = readNonEmptyString(body.tagline) ?? readNonEmptyString(body.trend_tagline) ?? null;
|
|
5000
|
+
const notes = readNonEmptyString(body.notes) ?? null;
|
|
5001
|
+
const uploadFileName = readNonEmptyString(uploadRef?.file_name) ?? null;
|
|
5002
|
+
const primitive = primitiveRegistry.get("primitive:video_ingest");
|
|
5003
|
+
if (!primitive)
|
|
5004
|
+
return c.json({ ok: false, error: "Video ingest primitive not available" }, 500);
|
|
5005
|
+
const operation = primitive.operations["run"];
|
|
5006
|
+
if (!operation)
|
|
5007
|
+
return c.json({ ok: false, error: "Video ingest operation not available" }, 500);
|
|
5008
|
+
// Only a caller-chosen title flows into the manifest — never the file
|
|
5009
|
+
// name. An untitled upload finalizes with title null so its /discover
|
|
5010
|
+
// card displays the minted template id.
|
|
5011
|
+
const payload = operation.inputSchema.parse({
|
|
5012
|
+
source_video_url: uploadUrl,
|
|
5013
|
+
title: explicitTitle ?? tagline ?? undefined,
|
|
5014
|
+
file_name: uploadFileName ?? undefined,
|
|
5015
|
+
save_manifest: true
|
|
5016
|
+
});
|
|
5017
|
+
let job;
|
|
5018
|
+
try {
|
|
5019
|
+
await assertWalletCanQueueJobs(customer.id);
|
|
5020
|
+
job = await jobs.createRootJob({
|
|
5021
|
+
templateId: primitive.id,
|
|
5022
|
+
operationName: "run",
|
|
5023
|
+
workflowName: operation.workflow,
|
|
5024
|
+
tracer: `discover_template_upload:${customer.id}`,
|
|
5025
|
+
payload,
|
|
5026
|
+
webhookUrl: null,
|
|
5027
|
+
customer,
|
|
5028
|
+
providerHint: undefined
|
|
5029
|
+
});
|
|
5030
|
+
}
|
|
5031
|
+
catch (error) {
|
|
5032
|
+
if (error instanceof InsufficientWalletFundsError)
|
|
5033
|
+
return insufficientFundsResponse(c, error);
|
|
5034
|
+
if (error instanceof ActiveJobLimitExceededError)
|
|
5035
|
+
return activeJobLimitExceededResponse(c, error);
|
|
5036
|
+
throw error;
|
|
5037
|
+
}
|
|
5038
|
+
// No Step Functions worker on a local (vidfarm serve) box — run the ingest
|
|
5039
|
+
// in-process, detached, exactly like local renders. /discover/feed polls
|
|
5040
|
+
// finalize the inspiration when the job lands.
|
|
5041
|
+
if (!config.VIDFARM_JOB_STATE_MACHINE_ARN) {
|
|
5042
|
+
void runPrimitiveJobInProcess(job.id).catch((error) => {
|
|
5043
|
+
console.error("local video ingest failed", job.id, error instanceof Error ? error.message : error);
|
|
5044
|
+
});
|
|
5045
|
+
}
|
|
5046
|
+
// Every upload key is unique (uuid segment), so no dedupe pass: two
|
|
5047
|
+
// uploads of the same file are two intentional inspirations.
|
|
5048
|
+
let inspiration = await serverlessRecords.createInspiration({
|
|
5049
|
+
customerId: customer.id,
|
|
5050
|
+
sourceUrl: uploadUrl,
|
|
5051
|
+
originalUrl: uploadUrl,
|
|
5052
|
+
sourceHost: "upload",
|
|
5053
|
+
downloadJobId: job.id,
|
|
5054
|
+
trendTagline: tagline,
|
|
5055
|
+
visibility: "private",
|
|
5056
|
+
notes
|
|
5057
|
+
});
|
|
5058
|
+
// Pending-card label while the ingest runs: explicit title, else the file
|
|
5059
|
+
// name. Finalize overwrites this from the manifest, so an untitled upload
|
|
5060
|
+
// ends at title null → the card shows the minted template id.
|
|
5061
|
+
const pendingTitle = explicitTitle ?? uploadFileName;
|
|
5062
|
+
if (pendingTitle) {
|
|
5063
|
+
inspiration = (await serverlessRecords.updateInspiration({
|
|
5064
|
+
inspirationId: inspiration.id,
|
|
5065
|
+
patch: { title: pendingTitle }
|
|
5066
|
+
})) ?? inspiration;
|
|
5067
|
+
}
|
|
5068
|
+
return c.json(serializeInspiration(inspiration), 202);
|
|
5069
|
+
}
|
|
4799
5070
|
const sourceUrl = readNonEmptyString(body.source_url);
|
|
4800
5071
|
if (!sourceUrl)
|
|
4801
|
-
return c.json({ ok: false, error: "Enter a video URL." }, 400);
|
|
5072
|
+
return c.json({ ok: false, error: "Enter a video URL or upload a video file." }, 400);
|
|
4802
5073
|
const originalUrl = stripTrackingParams(sourceUrl);
|
|
4803
5074
|
const sourceHost = safeUrlHostname(originalUrl);
|
|
4804
5075
|
if (!sourceHost)
|
|
@@ -4845,6 +5116,13 @@ app.post("/discover/templates", async (c) => {
|
|
|
4845
5116
|
return activeJobLimitExceededResponse(c, error);
|
|
4846
5117
|
throw error;
|
|
4847
5118
|
}
|
|
5119
|
+
// Same local-box treatment as the upload branch: no Step Functions worker
|
|
5120
|
+
// means the download job must run in-process or it queues forever.
|
|
5121
|
+
if (!config.VIDFARM_JOB_STATE_MACHINE_ARN) {
|
|
5122
|
+
void runPrimitiveJobInProcess(job.id).catch((error) => {
|
|
5123
|
+
console.error("local video download failed", job.id, error instanceof Error ? error.message : error);
|
|
5124
|
+
});
|
|
5125
|
+
}
|
|
4848
5126
|
const inspiration = await serverlessRecords.createInspiration({
|
|
4849
5127
|
customerId: customer.id,
|
|
4850
5128
|
sourceUrl,
|
|
@@ -4866,6 +5144,17 @@ app.delete("/discover/templates/:entryId", async (c) => {
|
|
|
4866
5144
|
if (!customer)
|
|
4867
5145
|
return c.json({ ok: false, error: "Unauthorized" }, 401);
|
|
4868
5146
|
const entryId = c.req.param("entryId");
|
|
5147
|
+
// Cloud passthrough (vidfarm serve): an id that doesn't resolve locally is
|
|
5148
|
+
// an upstream catalog entry (private ones show via the proxied feed).
|
|
5149
|
+
if (hasUpstreamKey()) {
|
|
5150
|
+
const localTemplate = entryId.startsWith("template_") ? await serverlessRecords.getTemplate(entryId) : null;
|
|
5151
|
+
const localInspiration = entryId.startsWith("inspiration_") ? await serverlessRecords.getInspiration(entryId) : null;
|
|
5152
|
+
if (!localTemplate && !localInspiration) {
|
|
5153
|
+
const proxied = await proxyRequestToUpstream(c);
|
|
5154
|
+
if (proxied)
|
|
5155
|
+
return proxied;
|
|
5156
|
+
}
|
|
5157
|
+
}
|
|
4869
5158
|
let template = null;
|
|
4870
5159
|
let inspiration = null;
|
|
4871
5160
|
if (entryId.startsWith("template_")) {
|
|
@@ -4901,13 +5190,21 @@ app.delete("/discover/templates/:entryId", async (c) => {
|
|
|
4901
5190
|
// client-side via status.
|
|
4902
5191
|
function buildPendingInspirationHomepageEntry(inspiration) {
|
|
4903
5192
|
return {
|
|
4904
|
-
|
|
5193
|
+
// Uploads: explicit title (or file name, stored at submit time) outranks
|
|
5194
|
+
// the tagline; URL ingests keep tagline-first.
|
|
5195
|
+
title: (inspiration.sourceHost === "upload"
|
|
5196
|
+
? (inspiration.title || inspiration.trendTagline)
|
|
5197
|
+
: (inspiration.trendTagline || inspiration.title)) || inspiration.sourceHost || "New template",
|
|
4905
5198
|
templateId: inspiration.id,
|
|
4906
5199
|
slugId: inspiration.id,
|
|
4907
5200
|
difficulty: "easy",
|
|
4908
5201
|
viralDna: inspiration.status === "failed"
|
|
4909
|
-
? (inspiration.errorMessage ||
|
|
4910
|
-
|
|
5202
|
+
? (inspiration.errorMessage || (inspiration.sourceHost === "upload"
|
|
5203
|
+
? "Video ingest failed. Try uploading the file again."
|
|
5204
|
+
: "Video download failed. Check the URL and try adding it again."))
|
|
5205
|
+
: (inspiration.notes || (inspiration.sourceHost === "upload"
|
|
5206
|
+
? "Processing your uploaded video. This card unlocks automatically once it's ready."
|
|
5207
|
+
: "Downloading the source video. This card unlocks automatically once it's ready.")),
|
|
4911
5208
|
previewUrl: inspiration.thumbnailUrl || "",
|
|
4912
5209
|
approvedAt: inspiration.createdAt,
|
|
4913
5210
|
sourceType: inspiration.sourceHost || "social",
|
|
@@ -6240,7 +6537,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6240
6537
|
// fast (~1s), but the removal itself can take minutes and exceed the
|
|
6241
6538
|
// CloudFront origin timeout, so we intentionally DO NOT wait here. We
|
|
6242
6539
|
// persist the pending task to ghostcut.json under the fork and let the
|
|
6243
|
-
// client drive completion via POST /
|
|
6540
|
+
// client drive completion via POST /remove-video-captions-poll below. Only runs for
|
|
6244
6541
|
// actual videos — slideshows and images skip it.
|
|
6245
6542
|
let ghostcutTaskId = null;
|
|
6246
6543
|
let ghostcutSkipReason = null;
|
|
@@ -6298,7 +6595,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6298
6595
|
}
|
|
6299
6596
|
// Charges the caller's wallet for the lambda compute cost of this
|
|
6300
6597
|
// decompose run. GhostCut chunk cost is billed separately from the
|
|
6301
|
-
//
|
|
6598
|
+
// remove-video-captions-poll endpoint (only when the task actually completes).
|
|
6302
6599
|
// Wrapped so billing outages never fail the request.
|
|
6303
6600
|
const chargeDecomposeUsage = async (input) => {
|
|
6304
6601
|
const durationMs = Date.now() - startedAtMs;
|
|
@@ -6425,7 +6722,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6425
6722
|
}
|
|
6426
6723
|
const nextHtml = buildSmartDecomposedHtml({ compositionId, sourceUrl, title, result: smart });
|
|
6427
6724
|
await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), nextHtml, "text/html; charset=utf-8");
|
|
6428
|
-
// Persist the raw smart-decompose result so the async /
|
|
6725
|
+
// Persist the raw smart-decompose result so the async /remove-video-captions-poll
|
|
6429
6726
|
// handler can rebuild the composition HTML pointing at the cleaned
|
|
6430
6727
|
// (ghostcut-mirrored) video URL — with the ORIGINAL captions, positions,
|
|
6431
6728
|
// and viral_dna intact — instead of doing a fragile source-URL string
|
|
@@ -6454,7 +6751,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6454
6751
|
});
|
|
6455
6752
|
}
|
|
6456
6753
|
catch (persistError) {
|
|
6457
|
-
// Non-fatal: /
|
|
6754
|
+
// Non-fatal: /remove-video-captions-poll will fall back to the URL string-swap path
|
|
6458
6755
|
// if this file is missing.
|
|
6459
6756
|
console.error("smart-decompose persist failed", persistError instanceof Error ? persistError.message : persistError);
|
|
6460
6757
|
}
|
|
@@ -6536,27 +6833,16 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6536
6833
|
return c.json({ ok: false, error: error instanceof Error ? error.message : "Auto-decompose failed." }, 500);
|
|
6537
6834
|
}
|
|
6538
6835
|
});
|
|
6539
|
-
//
|
|
6540
|
-
//
|
|
6541
|
-
//
|
|
6542
|
-
//
|
|
6543
|
-
//
|
|
6544
|
-
//
|
|
6545
|
-
//
|
|
6546
|
-
//
|
|
6547
|
-
//
|
|
6548
|
-
|
|
6549
|
-
// - "failed": task or mirror errored; original video stays in place.
|
|
6550
|
-
// Read-only ghostcut state for a fork. Lets the editor chat harness (and the
|
|
6551
|
-
// AI copilot via http_request) inspect BOTH the original source video URL and
|
|
6552
|
-
// the ghostcut-mirrored (subtitle-removed) URL without triggering an extra
|
|
6553
|
-
// billable GhostCut API poll. Returns { status: "none" } when the fork has no
|
|
6554
|
-
// ghostcut task on file (slideshow forks, ghostcut disabled, etc.). Chat can
|
|
6555
|
-
// call this to pick which video URL to feed into primitive routes: prefer the
|
|
6556
|
-
// mirrored URL when the fork's timeline should be baked-caption-free, prefer
|
|
6557
|
-
// the original when the AI needs the raw source (thumbnail extraction,
|
|
6558
|
-
// re-edits, style references).
|
|
6559
|
-
app.get(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut`, async (c) => {
|
|
6836
|
+
// Read-only subtitle-removal (GhostCut) state for a fork. Lets the editor chat
|
|
6837
|
+
// harness (and the AI copilot via http_request) inspect BOTH the original
|
|
6838
|
+
// source video URL and the mirrored (subtitle-removed) URL without triggering
|
|
6839
|
+
// an extra billable GhostCut API poll. Returns { status: "none" } when the
|
|
6840
|
+
// fork has no subtitle-removal task on file (slideshow forks, ghostcut
|
|
6841
|
+
// disabled, etc.). Chat can call this to pick which video URL to feed into
|
|
6842
|
+
// primitive routes: prefer the mirrored URL when the fork's timeline should be
|
|
6843
|
+
// baked-caption-free, prefer the original when the AI needs the raw source
|
|
6844
|
+
// (thumbnail extraction, re-edits, style references).
|
|
6845
|
+
const readForkSubtitleRemovalState = async (c) => {
|
|
6560
6846
|
// Optional auth, same as the composition.html / manifest.json / video-context
|
|
6561
6847
|
// read routes: the forkId is the view bearer token. A hard cookie requirement
|
|
6562
6848
|
// here silently 401'd the editor-chat http_request tool (which authenticates
|
|
@@ -6594,7 +6880,12 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut`, async (c) => {
|
|
|
6594
6880
|
return forkAccessErrorResponse(c, error);
|
|
6595
6881
|
return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
|
|
6596
6882
|
}
|
|
6597
|
-
}
|
|
6883
|
+
};
|
|
6884
|
+
app.get(`${COMPOSITIONS_PREFIX}/:forkId/remove-video-captions`, readForkSubtitleRemovalState);
|
|
6885
|
+
// Legacy alias: this route shipped as /ghostcut before the vendor-neutral
|
|
6886
|
+
// /remove-video-captions name. Kept for old clients, cached editor bundles,
|
|
6887
|
+
// and published devcli builds.
|
|
6888
|
+
app.get(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut`, readForkSubtitleRemovalState);
|
|
6598
6889
|
// Consolidated "what is this video" context for a fork: the verbatim audio
|
|
6599
6890
|
// transcript (timestamped segments) plus a literal visual description of each
|
|
6600
6891
|
// scene, straight from the smart-decompose.json snapshot written by
|
|
@@ -6690,7 +6981,18 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/video-context.json`, async (c) => {
|
|
|
6690
6981
|
return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
|
|
6691
6982
|
}
|
|
6692
6983
|
});
|
|
6693
|
-
|
|
6984
|
+
// Idempotent status probe for the async GhostCut subtitle-removal task kicked
|
|
6985
|
+
// off by /auto-decompose. The client polls this every few seconds after a
|
|
6986
|
+
// successful decompose. On this endpoint's side:
|
|
6987
|
+
// - "none": no subtitle-removal task was submitted for this fork (slideshow,
|
|
6988
|
+
// or ghostcut disabled / submit failed).
|
|
6989
|
+
// - "pending": task still running. Response includes progress %.
|
|
6990
|
+
// - "done": task finished. We download the cleaned mp4, mirror it to our
|
|
6991
|
+
// own S3, swap the original source URL for the mirror URL throughout the
|
|
6992
|
+
// current composition.html, snapshot a new version, and bill the
|
|
6993
|
+
// per-30s-chunk GhostCut cost. Idempotent — safe to re-poll.
|
|
6994
|
+
// - "failed": task or mirror errored; original video stays in place.
|
|
6995
|
+
const pollForkSubtitleRemoval = async (c) => {
|
|
6694
6996
|
const caller = await getBrowserCustomer(c);
|
|
6695
6997
|
if (!caller)
|
|
6696
6998
|
return c.json({ ok: false, error: "Unauthorized" }, 401);
|
|
@@ -6914,9 +7216,14 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut-poll`, async (c) => {
|
|
|
6914
7216
|
catch (error) {
|
|
6915
7217
|
if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
|
|
6916
7218
|
return forkAccessErrorResponse(c, error);
|
|
6917
|
-
return c.json({ ok: false, error: error instanceof Error ? error.message : "
|
|
7219
|
+
return c.json({ ok: false, error: error instanceof Error ? error.message : "remove-video-captions-poll failed" }, 500);
|
|
6918
7220
|
}
|
|
6919
|
-
}
|
|
7221
|
+
};
|
|
7222
|
+
app.post(`${COMPOSITIONS_PREFIX}/:forkId/remove-video-captions-poll`, pollForkSubtitleRemoval);
|
|
7223
|
+
// Legacy alias: this route shipped as /ghostcut-poll before the vendor-neutral
|
|
7224
|
+
// /remove-video-captions-poll name. Kept for old clients, cached editor
|
|
7225
|
+
// bundles, and published devcli builds.
|
|
7226
|
+
app.post(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut-poll`, pollForkSubtitleRemoval);
|
|
6920
7227
|
// Read-only cast context for a fork: the recurring people/characters
|
|
6921
7228
|
// identified by the decompose 2nd pass, each with a description (for
|
|
6922
7229
|
// consistent regeneration), the scenes they appear in, and reference image
|
|
@@ -7020,7 +7327,7 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/product-placement.json`, async (c) => {
|
|
|
7020
7327
|
});
|
|
7021
7328
|
// Drives the async cast-identification 2nd pass one unit of work per call
|
|
7022
7329
|
// (analyze -> per-member still -> per-member isolation -> done), mirroring
|
|
7023
|
-
// /
|
|
7330
|
+
// /remove-video-captions-poll. Each invocation makes at most one heavy external call so the
|
|
7024
7331
|
// client controls cadence and lambda time stays bounded. Idempotent: terminal
|
|
7025
7332
|
// states short-circuit.
|
|
7026
7333
|
app.post(`${COMPOSITIONS_PREFIX}/:forkId/cast-poll`, async (c) => {
|
|
@@ -7199,7 +7506,7 @@ app.on("POST", [`${COMPOSITIONS_PREFIX}/:forkId/render`, `${COMPOSITIONS_PREFIX}
|
|
|
7199
7506
|
// Block the render while ghostcut is still processing. The composition
|
|
7200
7507
|
// currently points at the ORIGINAL video with baked-in captions; if we
|
|
7201
7508
|
// let the render proceed the MP4 output will double up subtitles (baked +
|
|
7202
|
-
// decomposed overlay layers). Client should poll /
|
|
7509
|
+
// decomposed overlay layers). Client should poll /remove-video-captions-poll until
|
|
7203
7510
|
// it flips to done or failed, then retry /render.
|
|
7204
7511
|
try {
|
|
7205
7512
|
const ghostcutStateText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "ghostcut.json"));
|
|
@@ -7251,6 +7558,27 @@ app.on("POST", [`${COMPOSITIONS_PREFIX}/:forkId/render`, `${COMPOSITIONS_PREFIX}
|
|
|
7251
7558
|
const forkTemplateRecord = await serverlessRecords.getTemplate(fork.templateId);
|
|
7252
7559
|
const projectFiles = [];
|
|
7253
7560
|
const title = readNonEmptyString(requestBody.title) ?? fork.title ?? forkTemplateRecord?.title ?? fork.templateId;
|
|
7561
|
+
// Cloud passthrough (vidfarm serve): render_target="cloud" hands this
|
|
7562
|
+
// render to the upstream (cloud) renderer instead of the free in-process
|
|
7563
|
+
// one. Any submitted html was already saved to the LOCAL working state
|
|
7564
|
+
// above; the upstream render saves + versions its own copy. Identity
|
|
7565
|
+
// stamping and local version snapshots are skipped — they belong to the
|
|
7566
|
+
// upstream fork.
|
|
7567
|
+
const renderTarget = readNonEmptyString(requestBody.render_target) ?? readNonEmptyString(requestBody.target);
|
|
7568
|
+
if (renderTarget === "cloud" && rendersLocally) {
|
|
7569
|
+
if (!hasUpstreamKey()) {
|
|
7570
|
+
return c.json({ ok: false, error: "Cloud render needs a cloud API key — start `vidfarm serve` with VIDFARM_API_KEY (or --api-key) set." }, 400);
|
|
7571
|
+
}
|
|
7572
|
+
const cloudRender = await startUpstreamCloudRender({
|
|
7573
|
+
fork,
|
|
7574
|
+
compositionHtml,
|
|
7575
|
+
title,
|
|
7576
|
+
tracer: readNonEmptyString(requestBody.tracer) ?? `fork:${fork.id}:by:${caller.id}:cloud`
|
|
7577
|
+
});
|
|
7578
|
+
if (!cloudRender.ok)
|
|
7579
|
+
return c.json({ ok: false, error: cloudRender.error }, (cloudRender.status ?? 502));
|
|
7580
|
+
return c.json(cloudRender.payload, 202);
|
|
7581
|
+
}
|
|
7254
7582
|
// Composition identity is fork_id + version. Stamp it before publish so the
|
|
7255
7583
|
// rendered MP4 embeds a stable, sortable composition_id per version.
|
|
7256
7584
|
const nextVersion = requestedVersion && Number.isFinite(requestedVersion)
|
|
@@ -7296,11 +7624,89 @@ app.on("POST", [`${COMPOSITIONS_PREFIX}/:forkId/render`, `${COMPOSITIONS_PREFIX}
|
|
|
7296
7624
|
return c.json({ ok: false, error: error instanceof Error ? error.message : "Publish failed." }, 500);
|
|
7297
7625
|
}
|
|
7298
7626
|
});
|
|
7627
|
+
// Cloud passthrough (vidfarm serve): start a render on the upstream host for
|
|
7628
|
+
// a locally-edited fork. Seeded forks share their id with the cloud source,
|
|
7629
|
+
// but that source is usually another user's shared decomposition the cloud
|
|
7630
|
+
// account cannot publish to — in that case it is cloned into the cloud
|
|
7631
|
+
// account once and the mapping is remembered in the fork's working
|
|
7632
|
+
// upstream-link.json. The upstream render both saves the submitted html and
|
|
7633
|
+
// snapshots a version on the upstream fork; billing happens upstream against
|
|
7634
|
+
// the cloud account's wallet.
|
|
7635
|
+
async function startUpstreamCloudRender(input) {
|
|
7636
|
+
const linkKey = storage.compositionForkWorkingKey(input.fork.id, "upstream-link.json");
|
|
7637
|
+
let upstreamForkId = input.fork.id;
|
|
7638
|
+
try {
|
|
7639
|
+
const linkText = await storage.readText(linkKey);
|
|
7640
|
+
if (linkText) {
|
|
7641
|
+
const link = asRecord(JSON.parse(linkText));
|
|
7642
|
+
if (typeof link?.upstream_fork_id === "string" && link.upstream_fork_id)
|
|
7643
|
+
upstreamForkId = link.upstream_fork_id;
|
|
7644
|
+
}
|
|
7645
|
+
}
|
|
7646
|
+
catch {
|
|
7647
|
+
// Unreadable link file — fall back to the shared fork id.
|
|
7648
|
+
}
|
|
7649
|
+
const renderBody = { title: input.title, html: input.compositionHtml, tracer: input.tracer };
|
|
7650
|
+
// Rendering can take a moment to accept (upstream saves html + snapshots).
|
|
7651
|
+
let render = await upstreamJson(`/api/v1/compositions/${encodeURIComponent(upstreamForkId)}/render`, { method: "POST", body: renderBody, timeoutMs: 60_000 });
|
|
7652
|
+
if (!render.ok && [401, 403, 404].includes(render.status)) {
|
|
7653
|
+
const clone = await upstreamJson(`/api/v1/compositions/${encodeURIComponent(upstreamForkId)}/clone`, { method: "POST", body: { title: input.title } });
|
|
7654
|
+
const clonedForkId = typeof clone.json?.fork_id === "string" ? clone.json.fork_id : null;
|
|
7655
|
+
if (!clone.ok || !clonedForkId) {
|
|
7656
|
+
return {
|
|
7657
|
+
ok: false,
|
|
7658
|
+
error: `Cloud render failed: the cloud account cannot publish to fork ${upstreamForkId} (HTTP ${render.status}) and cloning it failed (HTTP ${clone.status}).`,
|
|
7659
|
+
status: 502
|
|
7660
|
+
};
|
|
7661
|
+
}
|
|
7662
|
+
upstreamForkId = clonedForkId;
|
|
7663
|
+
render = await upstreamJson(`/api/v1/compositions/${encodeURIComponent(upstreamForkId)}/render`, { method: "POST", body: renderBody, timeoutMs: 60_000 });
|
|
7664
|
+
}
|
|
7665
|
+
if (!render.ok || !render.json) {
|
|
7666
|
+
const upstreamError = typeof render.json?.error === "string" ? render.json.error : `HTTP ${render.status}`;
|
|
7667
|
+
return {
|
|
7668
|
+
ok: false,
|
|
7669
|
+
error: `Cloud render failed: ${upstreamError}`,
|
|
7670
|
+
status: render.status >= 400 && render.status < 500 ? render.status : 502
|
|
7671
|
+
};
|
|
7672
|
+
}
|
|
7673
|
+
await storage.putJson(linkKey, { upstream_fork_id: upstreamForkId, updated_at_ms: Date.now() });
|
|
7674
|
+
const renderId = typeof render.json.renderId === "string" ? render.json.renderId : null;
|
|
7675
|
+
if (renderId) {
|
|
7676
|
+
// Marker consumed by GET /renders/:renderId — the editor keeps polling the
|
|
7677
|
+
// LOCAL fork id, and the marker routes that poll to the upstream job.
|
|
7678
|
+
await storage.putJson(storage.compositionForkWorkingKey(input.fork.id, `cloud-render-${renderId}.json`), { upstream_fork_id: upstreamForkId, render_id: renderId, started_at_ms: Date.now() });
|
|
7679
|
+
}
|
|
7680
|
+
return {
|
|
7681
|
+
ok: true,
|
|
7682
|
+
payload: { ...render.json, render_target: "cloud", upstream_fork_id: upstreamForkId, upstream_host: upstreamHost() }
|
|
7683
|
+
};
|
|
7684
|
+
}
|
|
7299
7685
|
app.get(`${COMPOSITIONS_PREFIX}/:forkId/renders/:renderId`, async (c) => {
|
|
7300
7686
|
const customer = await getBrowserCustomer(c);
|
|
7301
7687
|
try {
|
|
7302
7688
|
const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer?.id ?? null, shareToken: readShareToken(c) }, "view");
|
|
7303
7689
|
const renderId = c.req.param("renderId");
|
|
7690
|
+
// Cloud passthrough (vidfarm serve): a marker written by
|
|
7691
|
+
// startUpstreamCloudRender means this render runs on the upstream host —
|
|
7692
|
+
// proxy the status poll there (against the mapped upstream fork).
|
|
7693
|
+
if (isUpstreamEnabled() && /^[A-Za-z0-9_-]+$/.test(renderId)) {
|
|
7694
|
+
const markerText = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, `cloud-render-${renderId}.json`)).catch(() => null);
|
|
7695
|
+
if (markerText) {
|
|
7696
|
+
try {
|
|
7697
|
+
const marker = asRecord(JSON.parse(markerText));
|
|
7698
|
+
const upstreamForkId = typeof marker?.upstream_fork_id === "string" ? marker.upstream_fork_id : access.fork.id;
|
|
7699
|
+
const status = await upstreamJson(`/api/v1/compositions/${encodeURIComponent(upstreamForkId)}/renders/${encodeURIComponent(renderId)}`);
|
|
7700
|
+
if (status.json) {
|
|
7701
|
+
return c.json({ ...status.json, render_target: "cloud" }, status.ok ? 200 : status.status);
|
|
7702
|
+
}
|
|
7703
|
+
return c.json({ ok: false, error: "Cloud render status unavailable (upstream unreachable)." }, 502);
|
|
7704
|
+
}
|
|
7705
|
+
catch {
|
|
7706
|
+
// Corrupt marker — fall through to the local job lookup.
|
|
7707
|
+
}
|
|
7708
|
+
}
|
|
7709
|
+
}
|
|
7304
7710
|
const job = await jobs.getJobAsync(renderId);
|
|
7305
7711
|
if (!job || job.customerId !== access.fork.customerId) {
|
|
7306
7712
|
return c.json({ ok: false, error: "Render job not found" }, 404);
|
|
@@ -7598,8 +8004,15 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/versions`, async (c) => {
|
|
|
7598
8004
|
const limit = Number.parseInt(c.req.query("limit") ?? "25", 10);
|
|
7599
8005
|
const cursor = c.req.query("cursor") ?? null;
|
|
7600
8006
|
const page = await serverlessRecords.listForkVersions({ forkId: access.fork.id, limit, cursor });
|
|
8007
|
+
// Fork content is world-viewable, but the snapshot trail is personal.
|
|
8008
|
+
// /editor parks users without their own fork on the template's shared
|
|
8009
|
+
// default fork, so without this scoping the History modal would list the
|
|
8010
|
+
// fork owner's (i.e. another user's) publish/decompose history.
|
|
8011
|
+
const visible = access.capabilities.isOwner
|
|
8012
|
+
? page.items
|
|
8013
|
+
: page.items.filter((version) => Boolean(customer && version.createdBy === customer.id));
|
|
7601
8014
|
return c.json({
|
|
7602
|
-
versions:
|
|
8015
|
+
versions: visible.map(serializeVersion),
|
|
7603
8016
|
next_cursor: page.nextCursor
|
|
7604
8017
|
});
|
|
7605
8018
|
}
|
|
@@ -7762,7 +8175,14 @@ async function finalizeInspirationIfReady(inspiration) {
|
|
|
7762
8175
|
customerId: ready.customerId,
|
|
7763
8176
|
originalUrl: ready.originalUrl,
|
|
7764
8177
|
sourceHost: ready.sourceHost,
|
|
7765
|
-
|
|
8178
|
+
// No host/"Untitled" fallback: a template without an explicit title or
|
|
8179
|
+
// tagline displays as its template id on /discover. For uploads the
|
|
8180
|
+
// manifest title IS the caller's explicit title, so it outranks the
|
|
8181
|
+
// tagline; URL ingests keep tagline-first (manifest title is just the
|
|
8182
|
+
// social post's own caption).
|
|
8183
|
+
title: (ready.sourceHost === "upload"
|
|
8184
|
+
? (ready.title || ready.trendTagline)
|
|
8185
|
+
: (ready.trendTagline || ready.title)) || null,
|
|
7766
8186
|
thumbnailUrl: ready.thumbnailUrl,
|
|
7767
8187
|
videoUrl: ready.videoUrl,
|
|
7768
8188
|
durationSeconds: ready.durationSeconds,
|
|
@@ -7851,21 +8271,50 @@ app.get(VIDEOS_PREFIX, async (c) => {
|
|
|
7851
8271
|
// Optional keyword search over the source-video catalog, matching the same
|
|
7852
8272
|
// decompose-derived metadata as the template feed.
|
|
7853
8273
|
const query = (c.req.query("q") || c.req.query("query") || "").trim() || null;
|
|
8274
|
+
let videos = [];
|
|
7854
8275
|
if (mine) {
|
|
7855
8276
|
if (!customer)
|
|
7856
8277
|
return c.json({ ok: false, error: "Unauthorized" }, 401);
|
|
7857
8278
|
const items = await serverlessRecords.listInspirationsForCustomer(customer.id);
|
|
7858
8279
|
const finalized = await Promise.all(items.map(finalizeInspirationIfReady));
|
|
7859
8280
|
const matched = finalized.filter((inspiration) => recordMatchesSearchQuery(inspiration, query));
|
|
7860
|
-
|
|
8281
|
+
videos = matched.slice(0, limit).map(serializeInspiration);
|
|
8282
|
+
}
|
|
8283
|
+
else {
|
|
8284
|
+
const items = await serverlessRecords.listInspirationsFeed({ limit, query });
|
|
8285
|
+
videos = items.map(serializeInspiration);
|
|
8286
|
+
}
|
|
8287
|
+
// Cloud passthrough (vidfarm serve): surface the upstream source-video
|
|
8288
|
+
// catalog behind the local working set. `mine` is the cloud account's own
|
|
8289
|
+
// list so it needs the upstream key; the public feed proxies keyless.
|
|
8290
|
+
if (isUpstreamEnabled() && (!mine || hasUpstreamKey())) {
|
|
8291
|
+
const search = new URLSearchParams({ limit: String(limit) });
|
|
8292
|
+
if (query)
|
|
8293
|
+
search.set("q", query);
|
|
8294
|
+
if (mine)
|
|
8295
|
+
search.set("mine", "true");
|
|
8296
|
+
const upstreamVideos = (await upstreamJson(`/api/v1/videos?${search.toString()}`)).json?.videos ?? [];
|
|
8297
|
+
const seen = new Set(videos.map((video) => String(video.inspiration_id ?? "")));
|
|
8298
|
+
for (const video of upstreamVideos) {
|
|
8299
|
+
const id = String(video?.inspiration_id ?? "");
|
|
8300
|
+
if (id && !seen.has(id)) {
|
|
8301
|
+
videos.push(video);
|
|
8302
|
+
seen.add(id);
|
|
8303
|
+
}
|
|
8304
|
+
}
|
|
7861
8305
|
}
|
|
7862
|
-
|
|
7863
|
-
return c.json({ videos: items.map(serializeInspiration) });
|
|
8306
|
+
return c.json({ videos });
|
|
7864
8307
|
});
|
|
7865
8308
|
app.get(`${VIDEOS_PREFIX}/:inspirationId`, async (c) => {
|
|
7866
8309
|
const inspiration = await serverlessRecords.getInspiration(c.req.param("inspirationId"));
|
|
7867
|
-
if (!inspiration)
|
|
8310
|
+
if (!inspiration) {
|
|
8311
|
+
// Cloud passthrough (vidfarm serve): unknown ids belong to the upstream
|
|
8312
|
+
// catalog this box mirrors. Keyless is fine — the read is public-feed data.
|
|
8313
|
+
const proxied = await proxyRequestToUpstream(c, { requireKey: false });
|
|
8314
|
+
if (proxied)
|
|
8315
|
+
return proxied;
|
|
7868
8316
|
return c.json({ ok: false, error: "Inspiration not found" }, 404);
|
|
8317
|
+
}
|
|
7869
8318
|
const finalized = await finalizeInspirationIfReady(inspiration);
|
|
7870
8319
|
return c.json(serializeInspiration(finalized));
|
|
7871
8320
|
});
|
|
@@ -8663,7 +9112,7 @@ app.get("/library", async (c) => {
|
|
|
8663
9112
|
avatarUrl: channel.avatarUrl
|
|
8664
9113
|
}))
|
|
8665
9114
|
];
|
|
8666
|
-
const
|
|
9115
|
+
const localPosts = await Promise.all((await serverlessRecords.listReadyPosts(customer.id))
|
|
8667
9116
|
.filter((post) => (post.source ?? "legacy") === "hyperframes")
|
|
8668
9117
|
.filter((post) => post.status === "ready" || post.status === "scheduled")
|
|
8669
9118
|
.map(async (post) => {
|
|
@@ -8673,6 +9122,7 @@ app.get("/library", async (c) => {
|
|
|
8673
9122
|
template_id: post.compositionId ?? null
|
|
8674
9123
|
};
|
|
8675
9124
|
}));
|
|
9125
|
+
const posts = await mergeUpstreamReadyPosts(localPosts);
|
|
8676
9126
|
return c.html(renderLibraryPage({
|
|
8677
9127
|
userId: customer.id,
|
|
8678
9128
|
currentAccountId: customer.id,
|
|
@@ -10292,6 +10742,41 @@ function readyPostSharePath(post, password) {
|
|
|
10292
10742
|
const suffix = params.size ? `?${params.toString()}` : "";
|
|
10293
10743
|
return `${accountApprovedPostPath(post.customerId, post.id)}${suffix}`;
|
|
10294
10744
|
}
|
|
10745
|
+
// Cloud passthrough (vidfarm serve): /library and the approved-posts list
|
|
10746
|
+
// mirror the upstream account's posts next to anything approved locally.
|
|
10747
|
+
// Upstream entries keep their absolute share/download URLs (they open on the
|
|
10748
|
+
// cloud host) and carry no local template_id. Fail-soft: an unreachable
|
|
10749
|
+
// upstream returns the local list unchanged.
|
|
10750
|
+
async function mergeUpstreamReadyPosts(localPosts) {
|
|
10751
|
+
if (!hasUpstreamKey())
|
|
10752
|
+
return localPosts;
|
|
10753
|
+
const upstreamPosts = (await upstreamJson(`${APPROVED_POSTS_PREFIX}?limit=100`)).json?.posts ?? [];
|
|
10754
|
+
const seen = new Set(localPosts.map((post) => post.post_id));
|
|
10755
|
+
const merged = [...localPosts];
|
|
10756
|
+
for (const post of upstreamPosts) {
|
|
10757
|
+
const id = typeof post?.post_id === "string" ? post.post_id : "";
|
|
10758
|
+
if (!id || seen.has(id))
|
|
10759
|
+
continue;
|
|
10760
|
+
merged.push({ template_id: null, ...post });
|
|
10761
|
+
seen.add(id);
|
|
10762
|
+
}
|
|
10763
|
+
return merged.sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")));
|
|
10764
|
+
}
|
|
10765
|
+
// Cloud passthrough (vidfarm serve): a post id that doesn't resolve locally
|
|
10766
|
+
// belongs to the upstream account whose /library this box mirrors — forward
|
|
10767
|
+
// the whole request there. Returns null when the post exists locally (or no
|
|
10768
|
+
// upstream is configured), letting the local handler proceed.
|
|
10769
|
+
async function maybeProxyUnknownReadyPostToUpstream(c) {
|
|
10770
|
+
if (!hasUpstreamKey())
|
|
10771
|
+
return null;
|
|
10772
|
+
const postId = String(c.req.param("postId") ?? "");
|
|
10773
|
+
if (!postId)
|
|
10774
|
+
return null;
|
|
10775
|
+
const post = await serverlessRecords.getReadyPost(postId);
|
|
10776
|
+
if (post)
|
|
10777
|
+
return null;
|
|
10778
|
+
return proxyRequestToUpstream(c);
|
|
10779
|
+
}
|
|
10295
10780
|
function serializeReadyPost(c, post, input) {
|
|
10296
10781
|
const sharePath = readyPostSharePath(post, input?.password);
|
|
10297
10782
|
return {
|
|
@@ -12658,6 +13143,8 @@ function primitiveAliasPathForKind(kind) {
|
|
|
12658
13143
|
return `${PRIMITIVES_PREFIX}/videos/download`;
|
|
12659
13144
|
case "video_trim":
|
|
12660
13145
|
return `${PRIMITIVES_PREFIX}/videos/trim`;
|
|
13146
|
+
case "video_remove_captions":
|
|
13147
|
+
return `${PRIMITIVES_PREFIX}/videos/remove-captions`;
|
|
12661
13148
|
case "video_extract_audio":
|
|
12662
13149
|
return `${PRIMITIVES_PREFIX}/videos/extract-audio`;
|
|
12663
13150
|
case "video_probe":
|
|
@@ -13297,6 +13784,11 @@ app.post(`${PRIMITIVES_PREFIX}/media/dedupe`, async (c) => createPrimitiveJob(c,
|
|
|
13297
13784
|
app.post(`${PRIMITIVES_PREFIX}/timeline/render`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:hyperframes_render", operationName: "run" }));
|
|
13298
13785
|
app.post(`${PRIMITIVES_PREFIX}/videos/normalize`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_normalize", operationName: "run" }));
|
|
13299
13786
|
app.post(`${PRIMITIVES_PREFIX}/videos/trim`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_trim", operationName: "run" }));
|
|
13787
|
+
// Caption removal (GhostCut). Canonical path follows the videos/* convention;
|
|
13788
|
+
// the flat /remove-video-captions alias keeps the primitive reachable under
|
|
13789
|
+
// its plain-English name.
|
|
13790
|
+
app.post(`${PRIMITIVES_PREFIX}/videos/remove-captions`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_remove_captions", operationName: "run" }));
|
|
13791
|
+
app.post(`${PRIMITIVES_PREFIX}/remove-video-captions`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_remove_captions", operationName: "run" }));
|
|
13300
13792
|
app.post(`${PRIMITIVES_PREFIX}/videos/extract-audio`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_extract_audio", operationName: "run" }));
|
|
13301
13793
|
app.post(`${PRIMITIVES_PREFIX}/videos/probe`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_probe", operationName: "run" }));
|
|
13302
13794
|
app.post(`${PRIMITIVES_PREFIX}/videos/extract-frame`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_extract_frame", operationName: "run" }));
|
|
@@ -13324,8 +13816,14 @@ app.get(APPROVED_POSTS_PREFIX, requireAuth, async (c) => {
|
|
|
13324
13816
|
|| (post.createdAt === cursor.createdAt && post.id < cursor.id)))
|
|
13325
13817
|
: filteredPosts;
|
|
13326
13818
|
const page = paginateDescendingByCreatedAt(posts, limit);
|
|
13819
|
+
let serialized = page.items.map((post) => serializeReadyPost(c, post, { includePrivate: true }));
|
|
13820
|
+
// Cloud passthrough (vidfarm serve): only the first (cursorless) page merges
|
|
13821
|
+
// the upstream account's posts — cursors keep paging the local set exactly.
|
|
13822
|
+
if (!cursor && !tracerFilter) {
|
|
13823
|
+
serialized = (await mergeUpstreamReadyPosts(serialized)).slice(0, limit);
|
|
13824
|
+
}
|
|
13327
13825
|
return c.json({
|
|
13328
|
-
posts:
|
|
13826
|
+
posts: serialized,
|
|
13329
13827
|
next_cursor: page.nextCursor
|
|
13330
13828
|
});
|
|
13331
13829
|
});
|
|
@@ -13359,6 +13857,9 @@ app.post(APPROVED_POSTS_PREFIX, requireAuth, async (c) => {
|
|
|
13359
13857
|
}
|
|
13360
13858
|
});
|
|
13361
13859
|
app.get(`${APPROVED_POSTS_PREFIX}/:postId`, async (c) => {
|
|
13860
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13861
|
+
if (proxied)
|
|
13862
|
+
return proxied;
|
|
13362
13863
|
const post = await serverlessRecords.getReadyPost(c.req.param("postId"));
|
|
13363
13864
|
if (!post || post.status === "deleted") {
|
|
13364
13865
|
return c.json({ error: "Approved post not found." }, 404);
|
|
@@ -13370,18 +13871,33 @@ app.get(`${APPROVED_POSTS_PREFIX}/:postId`, async (c) => {
|
|
|
13370
13871
|
return c.json({ post: serializeReadyPost(c, post, { includePrivate: viewer.canManage, password: viewer.hasPasswordAccess ? viewer.password : null }) });
|
|
13371
13872
|
});
|
|
13372
13873
|
app.get(`${APPROVED_POSTS_PREFIX}/:postId/schedules`, requireAuth, async (c) => {
|
|
13874
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13875
|
+
if (proxied)
|
|
13876
|
+
return proxied;
|
|
13373
13877
|
return await listReadyPostSchedulesResponse(c, requireCustomer(c), String(c.req.param("postId")));
|
|
13374
13878
|
});
|
|
13375
13879
|
app.post(`${APPROVED_POSTS_PREFIX}/:postId/schedules`, requireAuth, async (c) => {
|
|
13880
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13881
|
+
if (proxied)
|
|
13882
|
+
return proxied;
|
|
13376
13883
|
return createReadyPostScheduleResponse(c, requireCustomer(c), String(c.req.param("postId")));
|
|
13377
13884
|
});
|
|
13378
13885
|
app.patch(`${APPROVED_POSTS_PREFIX}/:postId/schedules/:scheduleId`, requireAuth, async (c) => {
|
|
13886
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13887
|
+
if (proxied)
|
|
13888
|
+
return proxied;
|
|
13379
13889
|
return updateReadyPostScheduleResponse(c, requireCustomer(c), String(c.req.param("postId")), String(c.req.param("scheduleId")));
|
|
13380
13890
|
});
|
|
13381
13891
|
app.delete(`${APPROVED_POSTS_PREFIX}/:postId/schedules/:scheduleId`, requireAuth, async (c) => {
|
|
13892
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13893
|
+
if (proxied)
|
|
13894
|
+
return proxied;
|
|
13382
13895
|
return deleteReadyPostScheduleResponse(c, requireCustomer(c), String(c.req.param("postId")), String(c.req.param("scheduleId")));
|
|
13383
13896
|
});
|
|
13384
13897
|
app.post(`${APPROVED_POSTS_PREFIX}/:postId/archive`, requireAuth, async (c) => {
|
|
13898
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13899
|
+
if (proxied)
|
|
13900
|
+
return proxied;
|
|
13385
13901
|
const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
|
|
13386
13902
|
if (!post || post.status === "deleted") {
|
|
13387
13903
|
return c.json({ error: "Approved post not found." }, 404);
|
|
@@ -13396,6 +13912,9 @@ app.post(`${APPROVED_POSTS_PREFIX}/:postId/archive`, requireAuth, async (c) => {
|
|
|
13396
13912
|
}
|
|
13397
13913
|
});
|
|
13398
13914
|
app.delete(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
|
|
13915
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13916
|
+
if (proxied)
|
|
13917
|
+
return proxied;
|
|
13399
13918
|
const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
|
|
13400
13919
|
if (!post) {
|
|
13401
13920
|
return c.json({ error: "Approved post not found." }, 404);
|
|
@@ -13410,6 +13929,9 @@ app.delete(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
|
|
|
13410
13929
|
}
|
|
13411
13930
|
});
|
|
13412
13931
|
app.get(`${APPROVED_POSTS_PREFIX}/:postId/download-zip`, async (c) => {
|
|
13932
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13933
|
+
if (proxied)
|
|
13934
|
+
return proxied;
|
|
13413
13935
|
const post = await serverlessRecords.getReadyPost(c.req.param("postId"));
|
|
13414
13936
|
if (!post || post.status === "deleted") {
|
|
13415
13937
|
return c.json({ error: "Approved post not found." }, 404);
|