@mevdragon/vidfarm-devcli 0.6.0 → 0.7.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/SKILL.director.md +28 -7
- package/SKILL.platform.md +9 -5
- package/demo/dist/app.js +59 -59
- package/dist/src/app.js +332 -47
- package/dist/src/cli.js +59 -96
- package/dist/src/config.js +7 -0
- package/dist/src/editor-chat.js +4 -4
- package/dist/src/primitive-registry.js +125 -0
- package/dist/src/services/upstream.js +248 -0
- package/package.json +1 -1
package/dist/src/app.js
CHANGED
|
@@ -49,6 +49,7 @@ import { createPrimitiveJobContext } from "./primitive-context.js";
|
|
|
49
49
|
import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
|
|
50
50
|
import { MediaDurationExceededError, probeMediaAsset } from "./services/media-processing.js";
|
|
51
51
|
import { estimateGhostcutCostUsd, isGhostcutConfigured, mirrorGhostcutVideoToStorage, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
|
|
52
|
+
import { hasUpstreamKey, isUpstreamEnabled, proxyRequestToUpstream, seedFromUpstream, upstreamHost, upstreamJson } from "./services/upstream.js";
|
|
52
53
|
import { TemplateSourceService } from "./services/template-sources.js";
|
|
53
54
|
import { TemplateSourceAlreadyRegisteredError } from "./services/template-sources.js";
|
|
54
55
|
import { renderCustomInpaintPage } from "./template-editor-pages.js";
|
|
@@ -1576,6 +1577,9 @@ function primitiveOperationPathForJob(job) {
|
|
|
1576
1577
|
if (job.templateId === "primitive:video_trim" && job.operationName === "run") {
|
|
1577
1578
|
return `${PRIMITIVES_PREFIX}/videos/trim`;
|
|
1578
1579
|
}
|
|
1580
|
+
if (job.templateId === "primitive:video_remove_captions" && job.operationName === "run") {
|
|
1581
|
+
return `${PRIMITIVES_PREFIX}/videos/remove-captions`;
|
|
1582
|
+
}
|
|
1579
1583
|
if (job.templateId === "primitive:video_extract_audio" && job.operationName === "run") {
|
|
1580
1584
|
return `${PRIMITIVES_PREFIX}/videos/extract-audio`;
|
|
1581
1585
|
}
|
|
@@ -3061,6 +3065,7 @@ function buildPrimitiveEditorDocsRoutes() {
|
|
|
3061
3065
|
{ 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
3066
|
{ 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
3067
|
{ 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? }." },
|
|
3068
|
+
{ 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
3069
|
{ 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
3070
|
{ 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
3071
|
{ 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 +3490,13 @@ function normalizeEditorChatPath(input) {
|
|
|
3485
3490
|
// /discover/templates (a different prefix) and stays out of reach.
|
|
3486
3491
|
"/discover/feed",
|
|
3487
3492
|
VIDEOS_PREFIX,
|
|
3488
|
-
// Read-only
|
|
3489
|
-
// upload and the
|
|
3490
|
-
// primitive route. Only the GET /
|
|
3491
|
-
//
|
|
3492
|
-
//
|
|
3493
|
-
// its own auth guard, which
|
|
3493
|
+
// Read-only subtitle-removal state per fork so the AI can pick between the
|
|
3494
|
+
// original upload and the caption-free mirror before feeding a video into a
|
|
3495
|
+
// primitive route. Only the GET /remove-video-captions variant (legacy
|
|
3496
|
+
// alias: /ghostcut) is exercised by the AI — POST /remove-video-captions-poll
|
|
3497
|
+
// advances the job so the copilot must be explicit about wanting it, but it
|
|
3498
|
+
// lives under the same prefix and is protected by its own auth guard, which
|
|
3499
|
+
// is fine.
|
|
3494
3500
|
COMPOSITIONS_PREFIX
|
|
3495
3501
|
];
|
|
3496
3502
|
const parsed = trimmed.startsWith("http://") || trimmed.startsWith("https://")
|
|
@@ -3621,7 +3627,7 @@ function normalizeOperationRequestTracer(tracer) {
|
|
|
3621
3627
|
}
|
|
3622
3628
|
function createTemplateHttpTool(input) {
|
|
3623
3629
|
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/
|
|
3630
|
+
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
3631
|
inputSchema: z.object({
|
|
3626
3632
|
method: z.enum(["GET", "POST"]).describe("HTTP method to use."),
|
|
3627
3633
|
path: z.string().min(1).describe("Absolute path or same-origin URL for an allowed Vidfarm REST route."),
|
|
@@ -4527,7 +4533,12 @@ async function renderHyperframesStudioEditorPage(input) {
|
|
|
4527
4533
|
vidfarmApiKey: input.customer ? await getVisibleApiKey(input.customer.id) : null,
|
|
4528
4534
|
// Only `vidfarm serve` (local disk) streams on-disk edits back to the tab;
|
|
4529
4535
|
// deployed environments have nothing to watch, so the client skips the SSE.
|
|
4530
|
-
liveReload: config.STORAGE_DRIVER === "local"
|
|
4536
|
+
liveReload: config.STORAGE_DRIVER === "local",
|
|
4537
|
+
// Cloud passthrough (vidfarm serve): when this box renders in-process AND
|
|
4538
|
+
// an upstream key is configured, the editor's Render button becomes a
|
|
4539
|
+
// popover offering "Render Local (Free)" vs "Render in Cloud".
|
|
4540
|
+
localRender: !config.VIDFARM_JOB_STATE_MACHINE_ARN,
|
|
4541
|
+
cloudRenderAvailable: hasUpstreamKey()
|
|
4531
4542
|
};
|
|
4532
4543
|
return `<!doctype html>
|
|
4533
4544
|
<html lang="en">
|
|
@@ -4589,7 +4600,20 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
4589
4600
|
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
4590
4601
|
if (!templateId.startsWith("template_"))
|
|
4591
4602
|
return c.notFound();
|
|
4592
|
-
|
|
4603
|
+
let template = await serverlessRecords.getTemplate(templateId);
|
|
4604
|
+
// Cloud passthrough (vidfarm serve): first open of an upstream catalog
|
|
4605
|
+
// template (clicked on the proxied /discover feed) — pull its composition
|
|
4606
|
+
// and mint the local template + fork records, then continue as if it had
|
|
4607
|
+
// always been local. Only the editor's working data lands on disk.
|
|
4608
|
+
if (!template && isUpstreamEnabled() && customer) {
|
|
4609
|
+
await seedFromUpstream({
|
|
4610
|
+
customerId: customer.id,
|
|
4611
|
+
templateId,
|
|
4612
|
+
forkId: c.req.query("fork") ?? undefined,
|
|
4613
|
+
shareToken: readShareToken(c) ?? undefined
|
|
4614
|
+
});
|
|
4615
|
+
template = await serverlessRecords.getTemplate(templateId);
|
|
4616
|
+
}
|
|
4593
4617
|
if (!template)
|
|
4594
4618
|
return c.notFound();
|
|
4595
4619
|
// Private templates are only openable by their owner; 404 (not 403) so the
|
|
@@ -4606,7 +4630,19 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
4606
4630
|
// instead of a broken URL.
|
|
4607
4631
|
let effectiveForkParam = forkParam ?? null;
|
|
4608
4632
|
if (effectiveForkParam) {
|
|
4609
|
-
|
|
4633
|
+
let forkRecord = await serverlessRecords.getCompositionFork(effectiveForkParam);
|
|
4634
|
+
// Cloud passthrough: an unknown ?fork= on a known template is an upstream
|
|
4635
|
+
// fork the user opened from the cloud catalog — seed it locally too.
|
|
4636
|
+
if (!forkRecord && isUpstreamEnabled() && customer) {
|
|
4637
|
+
const seeded = await seedFromUpstream({
|
|
4638
|
+
customerId: customer.id,
|
|
4639
|
+
templateId,
|
|
4640
|
+
forkId: effectiveForkParam,
|
|
4641
|
+
shareToken: readShareToken(c) ?? undefined
|
|
4642
|
+
});
|
|
4643
|
+
if (seeded)
|
|
4644
|
+
forkRecord = await serverlessRecords.getCompositionFork(effectiveForkParam);
|
|
4645
|
+
}
|
|
4610
4646
|
if (!forkRecord || forkRecord.deletedAt) {
|
|
4611
4647
|
effectiveForkParam = null;
|
|
4612
4648
|
}
|
|
@@ -4737,6 +4773,24 @@ app.get("/discover/feed", async (c) => {
|
|
|
4737
4773
|
if (entry)
|
|
4738
4774
|
templateEntries.push(entry);
|
|
4739
4775
|
}
|
|
4776
|
+
// Cloud passthrough (vidfarm serve): the local records are just the working
|
|
4777
|
+
// set of seeded templates, so surface the upstream (cloud) catalog behind
|
|
4778
|
+
// them. Local entries win on id conflicts — a seeded template also exists
|
|
4779
|
+
// upstream and the local copy reflects any local edits. Fail-soft: an
|
|
4780
|
+
// unreachable upstream degrades to the local-only feed.
|
|
4781
|
+
if (isUpstreamEnabled()) {
|
|
4782
|
+
const search = new URLSearchParams({ limit: String(limit) });
|
|
4783
|
+
if (query)
|
|
4784
|
+
search.set("q", query);
|
|
4785
|
+
const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
|
|
4786
|
+
const seen = new Set(templateEntries.map((entry) => entry.templateId));
|
|
4787
|
+
for (const entry of upstreamFeed) {
|
|
4788
|
+
if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId)) {
|
|
4789
|
+
templateEntries.push(entry);
|
|
4790
|
+
seen.add(entry.templateId);
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
}
|
|
4740
4794
|
// The public feed is identical for every caller, but a logged-in response
|
|
4741
4795
|
// interleaves the caller's private templates — never let a shared cache
|
|
4742
4796
|
// serve that to someone else.
|
|
@@ -4795,6 +4849,15 @@ app.post("/discover/templates", async (c) => {
|
|
|
4795
4849
|
const customer = await requireBrowserCustomer(c);
|
|
4796
4850
|
if (!customer)
|
|
4797
4851
|
return c.json({ ok: false, error: "Log in to add templates." }, 401);
|
|
4852
|
+
// Cloud passthrough (vidfarm serve): Add Template ingests into the CLOUD
|
|
4853
|
+
// account — the download job, billing, and catalog entry all live upstream,
|
|
4854
|
+
// and the proxied /discover/feed finalizes it. Local ingest only happens
|
|
4855
|
+
// when no upstream key is configured.
|
|
4856
|
+
if (hasUpstreamKey()) {
|
|
4857
|
+
const proxied = await proxyRequestToUpstream(c);
|
|
4858
|
+
if (proxied)
|
|
4859
|
+
return proxied;
|
|
4860
|
+
}
|
|
4798
4861
|
const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
|
|
4799
4862
|
const sourceUrl = readNonEmptyString(body.source_url);
|
|
4800
4863
|
if (!sourceUrl)
|
|
@@ -4866,6 +4929,17 @@ app.delete("/discover/templates/:entryId", async (c) => {
|
|
|
4866
4929
|
if (!customer)
|
|
4867
4930
|
return c.json({ ok: false, error: "Unauthorized" }, 401);
|
|
4868
4931
|
const entryId = c.req.param("entryId");
|
|
4932
|
+
// Cloud passthrough (vidfarm serve): an id that doesn't resolve locally is
|
|
4933
|
+
// an upstream catalog entry (private ones show via the proxied feed).
|
|
4934
|
+
if (hasUpstreamKey()) {
|
|
4935
|
+
const localTemplate = entryId.startsWith("template_") ? await serverlessRecords.getTemplate(entryId) : null;
|
|
4936
|
+
const localInspiration = entryId.startsWith("inspiration_") ? await serverlessRecords.getInspiration(entryId) : null;
|
|
4937
|
+
if (!localTemplate && !localInspiration) {
|
|
4938
|
+
const proxied = await proxyRequestToUpstream(c);
|
|
4939
|
+
if (proxied)
|
|
4940
|
+
return proxied;
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4869
4943
|
let template = null;
|
|
4870
4944
|
let inspiration = null;
|
|
4871
4945
|
if (entryId.startsWith("template_")) {
|
|
@@ -6240,7 +6314,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6240
6314
|
// fast (~1s), but the removal itself can take minutes and exceed the
|
|
6241
6315
|
// CloudFront origin timeout, so we intentionally DO NOT wait here. We
|
|
6242
6316
|
// persist the pending task to ghostcut.json under the fork and let the
|
|
6243
|
-
// client drive completion via POST /
|
|
6317
|
+
// client drive completion via POST /remove-video-captions-poll below. Only runs for
|
|
6244
6318
|
// actual videos — slideshows and images skip it.
|
|
6245
6319
|
let ghostcutTaskId = null;
|
|
6246
6320
|
let ghostcutSkipReason = null;
|
|
@@ -6298,7 +6372,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6298
6372
|
}
|
|
6299
6373
|
// Charges the caller's wallet for the lambda compute cost of this
|
|
6300
6374
|
// decompose run. GhostCut chunk cost is billed separately from the
|
|
6301
|
-
//
|
|
6375
|
+
// remove-video-captions-poll endpoint (only when the task actually completes).
|
|
6302
6376
|
// Wrapped so billing outages never fail the request.
|
|
6303
6377
|
const chargeDecomposeUsage = async (input) => {
|
|
6304
6378
|
const durationMs = Date.now() - startedAtMs;
|
|
@@ -6425,7 +6499,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6425
6499
|
}
|
|
6426
6500
|
const nextHtml = buildSmartDecomposedHtml({ compositionId, sourceUrl, title, result: smart });
|
|
6427
6501
|
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 /
|
|
6502
|
+
// Persist the raw smart-decompose result so the async /remove-video-captions-poll
|
|
6429
6503
|
// handler can rebuild the composition HTML pointing at the cleaned
|
|
6430
6504
|
// (ghostcut-mirrored) video URL — with the ORIGINAL captions, positions,
|
|
6431
6505
|
// and viral_dna intact — instead of doing a fragile source-URL string
|
|
@@ -6454,7 +6528,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6454
6528
|
});
|
|
6455
6529
|
}
|
|
6456
6530
|
catch (persistError) {
|
|
6457
|
-
// Non-fatal: /
|
|
6531
|
+
// Non-fatal: /remove-video-captions-poll will fall back to the URL string-swap path
|
|
6458
6532
|
// if this file is missing.
|
|
6459
6533
|
console.error("smart-decompose persist failed", persistError instanceof Error ? persistError.message : persistError);
|
|
6460
6534
|
}
|
|
@@ -6536,27 +6610,16 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6536
6610
|
return c.json({ ok: false, error: error instanceof Error ? error.message : "Auto-decompose failed." }, 500);
|
|
6537
6611
|
}
|
|
6538
6612
|
});
|
|
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) => {
|
|
6613
|
+
// Read-only subtitle-removal (GhostCut) state for a fork. Lets the editor chat
|
|
6614
|
+
// harness (and the AI copilot via http_request) inspect BOTH the original
|
|
6615
|
+
// source video URL and the mirrored (subtitle-removed) URL without triggering
|
|
6616
|
+
// an extra billable GhostCut API poll. Returns { status: "none" } when the
|
|
6617
|
+
// fork has no subtitle-removal task on file (slideshow forks, ghostcut
|
|
6618
|
+
// disabled, etc.). Chat can call this to pick which video URL to feed into
|
|
6619
|
+
// primitive routes: prefer the mirrored URL when the fork's timeline should be
|
|
6620
|
+
// baked-caption-free, prefer the original when the AI needs the raw source
|
|
6621
|
+
// (thumbnail extraction, re-edits, style references).
|
|
6622
|
+
const readForkSubtitleRemovalState = async (c) => {
|
|
6560
6623
|
// Optional auth, same as the composition.html / manifest.json / video-context
|
|
6561
6624
|
// read routes: the forkId is the view bearer token. A hard cookie requirement
|
|
6562
6625
|
// here silently 401'd the editor-chat http_request tool (which authenticates
|
|
@@ -6594,7 +6657,12 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut`, async (c) => {
|
|
|
6594
6657
|
return forkAccessErrorResponse(c, error);
|
|
6595
6658
|
return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
|
|
6596
6659
|
}
|
|
6597
|
-
}
|
|
6660
|
+
};
|
|
6661
|
+
app.get(`${COMPOSITIONS_PREFIX}/:forkId/remove-video-captions`, readForkSubtitleRemovalState);
|
|
6662
|
+
// Legacy alias: this route shipped as /ghostcut before the vendor-neutral
|
|
6663
|
+
// /remove-video-captions name. Kept for old clients, cached editor bundles,
|
|
6664
|
+
// and published devcli builds.
|
|
6665
|
+
app.get(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut`, readForkSubtitleRemovalState);
|
|
6598
6666
|
// Consolidated "what is this video" context for a fork: the verbatim audio
|
|
6599
6667
|
// transcript (timestamped segments) plus a literal visual description of each
|
|
6600
6668
|
// scene, straight from the smart-decompose.json snapshot written by
|
|
@@ -6690,7 +6758,18 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/video-context.json`, async (c) => {
|
|
|
6690
6758
|
return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
|
|
6691
6759
|
}
|
|
6692
6760
|
});
|
|
6693
|
-
|
|
6761
|
+
// Idempotent status probe for the async GhostCut subtitle-removal task kicked
|
|
6762
|
+
// off by /auto-decompose. The client polls this every few seconds after a
|
|
6763
|
+
// successful decompose. On this endpoint's side:
|
|
6764
|
+
// - "none": no subtitle-removal task was submitted for this fork (slideshow,
|
|
6765
|
+
// or ghostcut disabled / submit failed).
|
|
6766
|
+
// - "pending": task still running. Response includes progress %.
|
|
6767
|
+
// - "done": task finished. We download the cleaned mp4, mirror it to our
|
|
6768
|
+
// own S3, swap the original source URL for the mirror URL throughout the
|
|
6769
|
+
// current composition.html, snapshot a new version, and bill the
|
|
6770
|
+
// per-30s-chunk GhostCut cost. Idempotent — safe to re-poll.
|
|
6771
|
+
// - "failed": task or mirror errored; original video stays in place.
|
|
6772
|
+
const pollForkSubtitleRemoval = async (c) => {
|
|
6694
6773
|
const caller = await getBrowserCustomer(c);
|
|
6695
6774
|
if (!caller)
|
|
6696
6775
|
return c.json({ ok: false, error: "Unauthorized" }, 401);
|
|
@@ -6914,9 +6993,14 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut-poll`, async (c) => {
|
|
|
6914
6993
|
catch (error) {
|
|
6915
6994
|
if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
|
|
6916
6995
|
return forkAccessErrorResponse(c, error);
|
|
6917
|
-
return c.json({ ok: false, error: error instanceof Error ? error.message : "
|
|
6996
|
+
return c.json({ ok: false, error: error instanceof Error ? error.message : "remove-video-captions-poll failed" }, 500);
|
|
6918
6997
|
}
|
|
6919
|
-
}
|
|
6998
|
+
};
|
|
6999
|
+
app.post(`${COMPOSITIONS_PREFIX}/:forkId/remove-video-captions-poll`, pollForkSubtitleRemoval);
|
|
7000
|
+
// Legacy alias: this route shipped as /ghostcut-poll before the vendor-neutral
|
|
7001
|
+
// /remove-video-captions-poll name. Kept for old clients, cached editor
|
|
7002
|
+
// bundles, and published devcli builds.
|
|
7003
|
+
app.post(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut-poll`, pollForkSubtitleRemoval);
|
|
6920
7004
|
// Read-only cast context for a fork: the recurring people/characters
|
|
6921
7005
|
// identified by the decompose 2nd pass, each with a description (for
|
|
6922
7006
|
// consistent regeneration), the scenes they appear in, and reference image
|
|
@@ -7020,7 +7104,7 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/product-placement.json`, async (c) => {
|
|
|
7020
7104
|
});
|
|
7021
7105
|
// Drives the async cast-identification 2nd pass one unit of work per call
|
|
7022
7106
|
// (analyze -> per-member still -> per-member isolation -> done), mirroring
|
|
7023
|
-
// /
|
|
7107
|
+
// /remove-video-captions-poll. Each invocation makes at most one heavy external call so the
|
|
7024
7108
|
// client controls cadence and lambda time stays bounded. Idempotent: terminal
|
|
7025
7109
|
// states short-circuit.
|
|
7026
7110
|
app.post(`${COMPOSITIONS_PREFIX}/:forkId/cast-poll`, async (c) => {
|
|
@@ -7199,7 +7283,7 @@ app.on("POST", [`${COMPOSITIONS_PREFIX}/:forkId/render`, `${COMPOSITIONS_PREFIX}
|
|
|
7199
7283
|
// Block the render while ghostcut is still processing. The composition
|
|
7200
7284
|
// currently points at the ORIGINAL video with baked-in captions; if we
|
|
7201
7285
|
// let the render proceed the MP4 output will double up subtitles (baked +
|
|
7202
|
-
// decomposed overlay layers). Client should poll /
|
|
7286
|
+
// decomposed overlay layers). Client should poll /remove-video-captions-poll until
|
|
7203
7287
|
// it flips to done or failed, then retry /render.
|
|
7204
7288
|
try {
|
|
7205
7289
|
const ghostcutStateText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "ghostcut.json"));
|
|
@@ -7251,6 +7335,27 @@ app.on("POST", [`${COMPOSITIONS_PREFIX}/:forkId/render`, `${COMPOSITIONS_PREFIX}
|
|
|
7251
7335
|
const forkTemplateRecord = await serverlessRecords.getTemplate(fork.templateId);
|
|
7252
7336
|
const projectFiles = [];
|
|
7253
7337
|
const title = readNonEmptyString(requestBody.title) ?? fork.title ?? forkTemplateRecord?.title ?? fork.templateId;
|
|
7338
|
+
// Cloud passthrough (vidfarm serve): render_target="cloud" hands this
|
|
7339
|
+
// render to the upstream (cloud) renderer instead of the free in-process
|
|
7340
|
+
// one. Any submitted html was already saved to the LOCAL working state
|
|
7341
|
+
// above; the upstream render saves + versions its own copy. Identity
|
|
7342
|
+
// stamping and local version snapshots are skipped — they belong to the
|
|
7343
|
+
// upstream fork.
|
|
7344
|
+
const renderTarget = readNonEmptyString(requestBody.render_target) ?? readNonEmptyString(requestBody.target);
|
|
7345
|
+
if (renderTarget === "cloud" && rendersLocally) {
|
|
7346
|
+
if (!hasUpstreamKey()) {
|
|
7347
|
+
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);
|
|
7348
|
+
}
|
|
7349
|
+
const cloudRender = await startUpstreamCloudRender({
|
|
7350
|
+
fork,
|
|
7351
|
+
compositionHtml,
|
|
7352
|
+
title,
|
|
7353
|
+
tracer: readNonEmptyString(requestBody.tracer) ?? `fork:${fork.id}:by:${caller.id}:cloud`
|
|
7354
|
+
});
|
|
7355
|
+
if (!cloudRender.ok)
|
|
7356
|
+
return c.json({ ok: false, error: cloudRender.error }, (cloudRender.status ?? 502));
|
|
7357
|
+
return c.json(cloudRender.payload, 202);
|
|
7358
|
+
}
|
|
7254
7359
|
// Composition identity is fork_id + version. Stamp it before publish so the
|
|
7255
7360
|
// rendered MP4 embeds a stable, sortable composition_id per version.
|
|
7256
7361
|
const nextVersion = requestedVersion && Number.isFinite(requestedVersion)
|
|
@@ -7296,11 +7401,89 @@ app.on("POST", [`${COMPOSITIONS_PREFIX}/:forkId/render`, `${COMPOSITIONS_PREFIX}
|
|
|
7296
7401
|
return c.json({ ok: false, error: error instanceof Error ? error.message : "Publish failed." }, 500);
|
|
7297
7402
|
}
|
|
7298
7403
|
});
|
|
7404
|
+
// Cloud passthrough (vidfarm serve): start a render on the upstream host for
|
|
7405
|
+
// a locally-edited fork. Seeded forks share their id with the cloud source,
|
|
7406
|
+
// but that source is usually another user's shared decomposition the cloud
|
|
7407
|
+
// account cannot publish to — in that case it is cloned into the cloud
|
|
7408
|
+
// account once and the mapping is remembered in the fork's working
|
|
7409
|
+
// upstream-link.json. The upstream render both saves the submitted html and
|
|
7410
|
+
// snapshots a version on the upstream fork; billing happens upstream against
|
|
7411
|
+
// the cloud account's wallet.
|
|
7412
|
+
async function startUpstreamCloudRender(input) {
|
|
7413
|
+
const linkKey = storage.compositionForkWorkingKey(input.fork.id, "upstream-link.json");
|
|
7414
|
+
let upstreamForkId = input.fork.id;
|
|
7415
|
+
try {
|
|
7416
|
+
const linkText = await storage.readText(linkKey);
|
|
7417
|
+
if (linkText) {
|
|
7418
|
+
const link = asRecord(JSON.parse(linkText));
|
|
7419
|
+
if (typeof link?.upstream_fork_id === "string" && link.upstream_fork_id)
|
|
7420
|
+
upstreamForkId = link.upstream_fork_id;
|
|
7421
|
+
}
|
|
7422
|
+
}
|
|
7423
|
+
catch {
|
|
7424
|
+
// Unreadable link file — fall back to the shared fork id.
|
|
7425
|
+
}
|
|
7426
|
+
const renderBody = { title: input.title, html: input.compositionHtml, tracer: input.tracer };
|
|
7427
|
+
// Rendering can take a moment to accept (upstream saves html + snapshots).
|
|
7428
|
+
let render = await upstreamJson(`/api/v1/compositions/${encodeURIComponent(upstreamForkId)}/render`, { method: "POST", body: renderBody, timeoutMs: 60_000 });
|
|
7429
|
+
if (!render.ok && [401, 403, 404].includes(render.status)) {
|
|
7430
|
+
const clone = await upstreamJson(`/api/v1/compositions/${encodeURIComponent(upstreamForkId)}/clone`, { method: "POST", body: { title: input.title } });
|
|
7431
|
+
const clonedForkId = typeof clone.json?.fork_id === "string" ? clone.json.fork_id : null;
|
|
7432
|
+
if (!clone.ok || !clonedForkId) {
|
|
7433
|
+
return {
|
|
7434
|
+
ok: false,
|
|
7435
|
+
error: `Cloud render failed: the cloud account cannot publish to fork ${upstreamForkId} (HTTP ${render.status}) and cloning it failed (HTTP ${clone.status}).`,
|
|
7436
|
+
status: 502
|
|
7437
|
+
};
|
|
7438
|
+
}
|
|
7439
|
+
upstreamForkId = clonedForkId;
|
|
7440
|
+
render = await upstreamJson(`/api/v1/compositions/${encodeURIComponent(upstreamForkId)}/render`, { method: "POST", body: renderBody, timeoutMs: 60_000 });
|
|
7441
|
+
}
|
|
7442
|
+
if (!render.ok || !render.json) {
|
|
7443
|
+
const upstreamError = typeof render.json?.error === "string" ? render.json.error : `HTTP ${render.status}`;
|
|
7444
|
+
return {
|
|
7445
|
+
ok: false,
|
|
7446
|
+
error: `Cloud render failed: ${upstreamError}`,
|
|
7447
|
+
status: render.status >= 400 && render.status < 500 ? render.status : 502
|
|
7448
|
+
};
|
|
7449
|
+
}
|
|
7450
|
+
await storage.putJson(linkKey, { upstream_fork_id: upstreamForkId, updated_at_ms: Date.now() });
|
|
7451
|
+
const renderId = typeof render.json.renderId === "string" ? render.json.renderId : null;
|
|
7452
|
+
if (renderId) {
|
|
7453
|
+
// Marker consumed by GET /renders/:renderId — the editor keeps polling the
|
|
7454
|
+
// LOCAL fork id, and the marker routes that poll to the upstream job.
|
|
7455
|
+
await storage.putJson(storage.compositionForkWorkingKey(input.fork.id, `cloud-render-${renderId}.json`), { upstream_fork_id: upstreamForkId, render_id: renderId, started_at_ms: Date.now() });
|
|
7456
|
+
}
|
|
7457
|
+
return {
|
|
7458
|
+
ok: true,
|
|
7459
|
+
payload: { ...render.json, render_target: "cloud", upstream_fork_id: upstreamForkId, upstream_host: upstreamHost() }
|
|
7460
|
+
};
|
|
7461
|
+
}
|
|
7299
7462
|
app.get(`${COMPOSITIONS_PREFIX}/:forkId/renders/:renderId`, async (c) => {
|
|
7300
7463
|
const customer = await getBrowserCustomer(c);
|
|
7301
7464
|
try {
|
|
7302
7465
|
const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer?.id ?? null, shareToken: readShareToken(c) }, "view");
|
|
7303
7466
|
const renderId = c.req.param("renderId");
|
|
7467
|
+
// Cloud passthrough (vidfarm serve): a marker written by
|
|
7468
|
+
// startUpstreamCloudRender means this render runs on the upstream host —
|
|
7469
|
+
// proxy the status poll there (against the mapped upstream fork).
|
|
7470
|
+
if (isUpstreamEnabled() && /^[A-Za-z0-9_-]+$/.test(renderId)) {
|
|
7471
|
+
const markerText = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, `cloud-render-${renderId}.json`)).catch(() => null);
|
|
7472
|
+
if (markerText) {
|
|
7473
|
+
try {
|
|
7474
|
+
const marker = asRecord(JSON.parse(markerText));
|
|
7475
|
+
const upstreamForkId = typeof marker?.upstream_fork_id === "string" ? marker.upstream_fork_id : access.fork.id;
|
|
7476
|
+
const status = await upstreamJson(`/api/v1/compositions/${encodeURIComponent(upstreamForkId)}/renders/${encodeURIComponent(renderId)}`);
|
|
7477
|
+
if (status.json) {
|
|
7478
|
+
return c.json({ ...status.json, render_target: "cloud" }, status.ok ? 200 : status.status);
|
|
7479
|
+
}
|
|
7480
|
+
return c.json({ ok: false, error: "Cloud render status unavailable (upstream unreachable)." }, 502);
|
|
7481
|
+
}
|
|
7482
|
+
catch {
|
|
7483
|
+
// Corrupt marker — fall through to the local job lookup.
|
|
7484
|
+
}
|
|
7485
|
+
}
|
|
7486
|
+
}
|
|
7304
7487
|
const job = await jobs.getJobAsync(renderId);
|
|
7305
7488
|
if (!job || job.customerId !== access.fork.customerId) {
|
|
7306
7489
|
return c.json({ ok: false, error: "Render job not found" }, 404);
|
|
@@ -7851,21 +8034,50 @@ app.get(VIDEOS_PREFIX, async (c) => {
|
|
|
7851
8034
|
// Optional keyword search over the source-video catalog, matching the same
|
|
7852
8035
|
// decompose-derived metadata as the template feed.
|
|
7853
8036
|
const query = (c.req.query("q") || c.req.query("query") || "").trim() || null;
|
|
8037
|
+
let videos = [];
|
|
7854
8038
|
if (mine) {
|
|
7855
8039
|
if (!customer)
|
|
7856
8040
|
return c.json({ ok: false, error: "Unauthorized" }, 401);
|
|
7857
8041
|
const items = await serverlessRecords.listInspirationsForCustomer(customer.id);
|
|
7858
8042
|
const finalized = await Promise.all(items.map(finalizeInspirationIfReady));
|
|
7859
8043
|
const matched = finalized.filter((inspiration) => recordMatchesSearchQuery(inspiration, query));
|
|
7860
|
-
|
|
8044
|
+
videos = matched.slice(0, limit).map(serializeInspiration);
|
|
8045
|
+
}
|
|
8046
|
+
else {
|
|
8047
|
+
const items = await serverlessRecords.listInspirationsFeed({ limit, query });
|
|
8048
|
+
videos = items.map(serializeInspiration);
|
|
8049
|
+
}
|
|
8050
|
+
// Cloud passthrough (vidfarm serve): surface the upstream source-video
|
|
8051
|
+
// catalog behind the local working set. `mine` is the cloud account's own
|
|
8052
|
+
// list so it needs the upstream key; the public feed proxies keyless.
|
|
8053
|
+
if (isUpstreamEnabled() && (!mine || hasUpstreamKey())) {
|
|
8054
|
+
const search = new URLSearchParams({ limit: String(limit) });
|
|
8055
|
+
if (query)
|
|
8056
|
+
search.set("q", query);
|
|
8057
|
+
if (mine)
|
|
8058
|
+
search.set("mine", "true");
|
|
8059
|
+
const upstreamVideos = (await upstreamJson(`/api/v1/videos?${search.toString()}`)).json?.videos ?? [];
|
|
8060
|
+
const seen = new Set(videos.map((video) => String(video.inspiration_id ?? "")));
|
|
8061
|
+
for (const video of upstreamVideos) {
|
|
8062
|
+
const id = String(video?.inspiration_id ?? "");
|
|
8063
|
+
if (id && !seen.has(id)) {
|
|
8064
|
+
videos.push(video);
|
|
8065
|
+
seen.add(id);
|
|
8066
|
+
}
|
|
8067
|
+
}
|
|
7861
8068
|
}
|
|
7862
|
-
|
|
7863
|
-
return c.json({ videos: items.map(serializeInspiration) });
|
|
8069
|
+
return c.json({ videos });
|
|
7864
8070
|
});
|
|
7865
8071
|
app.get(`${VIDEOS_PREFIX}/:inspirationId`, async (c) => {
|
|
7866
8072
|
const inspiration = await serverlessRecords.getInspiration(c.req.param("inspirationId"));
|
|
7867
|
-
if (!inspiration)
|
|
8073
|
+
if (!inspiration) {
|
|
8074
|
+
// Cloud passthrough (vidfarm serve): unknown ids belong to the upstream
|
|
8075
|
+
// catalog this box mirrors. Keyless is fine — the read is public-feed data.
|
|
8076
|
+
const proxied = await proxyRequestToUpstream(c, { requireKey: false });
|
|
8077
|
+
if (proxied)
|
|
8078
|
+
return proxied;
|
|
7868
8079
|
return c.json({ ok: false, error: "Inspiration not found" }, 404);
|
|
8080
|
+
}
|
|
7869
8081
|
const finalized = await finalizeInspirationIfReady(inspiration);
|
|
7870
8082
|
return c.json(serializeInspiration(finalized));
|
|
7871
8083
|
});
|
|
@@ -8663,7 +8875,7 @@ app.get("/library", async (c) => {
|
|
|
8663
8875
|
avatarUrl: channel.avatarUrl
|
|
8664
8876
|
}))
|
|
8665
8877
|
];
|
|
8666
|
-
const
|
|
8878
|
+
const localPosts = await Promise.all((await serverlessRecords.listReadyPosts(customer.id))
|
|
8667
8879
|
.filter((post) => (post.source ?? "legacy") === "hyperframes")
|
|
8668
8880
|
.filter((post) => post.status === "ready" || post.status === "scheduled")
|
|
8669
8881
|
.map(async (post) => {
|
|
@@ -8673,6 +8885,7 @@ app.get("/library", async (c) => {
|
|
|
8673
8885
|
template_id: post.compositionId ?? null
|
|
8674
8886
|
};
|
|
8675
8887
|
}));
|
|
8888
|
+
const posts = await mergeUpstreamReadyPosts(localPosts);
|
|
8676
8889
|
return c.html(renderLibraryPage({
|
|
8677
8890
|
userId: customer.id,
|
|
8678
8891
|
currentAccountId: customer.id,
|
|
@@ -10292,6 +10505,41 @@ function readyPostSharePath(post, password) {
|
|
|
10292
10505
|
const suffix = params.size ? `?${params.toString()}` : "";
|
|
10293
10506
|
return `${accountApprovedPostPath(post.customerId, post.id)}${suffix}`;
|
|
10294
10507
|
}
|
|
10508
|
+
// Cloud passthrough (vidfarm serve): /library and the approved-posts list
|
|
10509
|
+
// mirror the upstream account's posts next to anything approved locally.
|
|
10510
|
+
// Upstream entries keep their absolute share/download URLs (they open on the
|
|
10511
|
+
// cloud host) and carry no local template_id. Fail-soft: an unreachable
|
|
10512
|
+
// upstream returns the local list unchanged.
|
|
10513
|
+
async function mergeUpstreamReadyPosts(localPosts) {
|
|
10514
|
+
if (!hasUpstreamKey())
|
|
10515
|
+
return localPosts;
|
|
10516
|
+
const upstreamPosts = (await upstreamJson(`${APPROVED_POSTS_PREFIX}?limit=100`)).json?.posts ?? [];
|
|
10517
|
+
const seen = new Set(localPosts.map((post) => post.post_id));
|
|
10518
|
+
const merged = [...localPosts];
|
|
10519
|
+
for (const post of upstreamPosts) {
|
|
10520
|
+
const id = typeof post?.post_id === "string" ? post.post_id : "";
|
|
10521
|
+
if (!id || seen.has(id))
|
|
10522
|
+
continue;
|
|
10523
|
+
merged.push({ template_id: null, ...post });
|
|
10524
|
+
seen.add(id);
|
|
10525
|
+
}
|
|
10526
|
+
return merged.sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")));
|
|
10527
|
+
}
|
|
10528
|
+
// Cloud passthrough (vidfarm serve): a post id that doesn't resolve locally
|
|
10529
|
+
// belongs to the upstream account whose /library this box mirrors — forward
|
|
10530
|
+
// the whole request there. Returns null when the post exists locally (or no
|
|
10531
|
+
// upstream is configured), letting the local handler proceed.
|
|
10532
|
+
async function maybeProxyUnknownReadyPostToUpstream(c) {
|
|
10533
|
+
if (!hasUpstreamKey())
|
|
10534
|
+
return null;
|
|
10535
|
+
const postId = String(c.req.param("postId") ?? "");
|
|
10536
|
+
if (!postId)
|
|
10537
|
+
return null;
|
|
10538
|
+
const post = await serverlessRecords.getReadyPost(postId);
|
|
10539
|
+
if (post)
|
|
10540
|
+
return null;
|
|
10541
|
+
return proxyRequestToUpstream(c);
|
|
10542
|
+
}
|
|
10295
10543
|
function serializeReadyPost(c, post, input) {
|
|
10296
10544
|
const sharePath = readyPostSharePath(post, input?.password);
|
|
10297
10545
|
return {
|
|
@@ -12658,6 +12906,8 @@ function primitiveAliasPathForKind(kind) {
|
|
|
12658
12906
|
return `${PRIMITIVES_PREFIX}/videos/download`;
|
|
12659
12907
|
case "video_trim":
|
|
12660
12908
|
return `${PRIMITIVES_PREFIX}/videos/trim`;
|
|
12909
|
+
case "video_remove_captions":
|
|
12910
|
+
return `${PRIMITIVES_PREFIX}/videos/remove-captions`;
|
|
12661
12911
|
case "video_extract_audio":
|
|
12662
12912
|
return `${PRIMITIVES_PREFIX}/videos/extract-audio`;
|
|
12663
12913
|
case "video_probe":
|
|
@@ -13297,6 +13547,11 @@ app.post(`${PRIMITIVES_PREFIX}/media/dedupe`, async (c) => createPrimitiveJob(c,
|
|
|
13297
13547
|
app.post(`${PRIMITIVES_PREFIX}/timeline/render`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:hyperframes_render", operationName: "run" }));
|
|
13298
13548
|
app.post(`${PRIMITIVES_PREFIX}/videos/normalize`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_normalize", operationName: "run" }));
|
|
13299
13549
|
app.post(`${PRIMITIVES_PREFIX}/videos/trim`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_trim", operationName: "run" }));
|
|
13550
|
+
// Caption removal (GhostCut). Canonical path follows the videos/* convention;
|
|
13551
|
+
// the flat /remove-video-captions alias keeps the primitive reachable under
|
|
13552
|
+
// its plain-English name.
|
|
13553
|
+
app.post(`${PRIMITIVES_PREFIX}/videos/remove-captions`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_remove_captions", operationName: "run" }));
|
|
13554
|
+
app.post(`${PRIMITIVES_PREFIX}/remove-video-captions`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_remove_captions", operationName: "run" }));
|
|
13300
13555
|
app.post(`${PRIMITIVES_PREFIX}/videos/extract-audio`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_extract_audio", operationName: "run" }));
|
|
13301
13556
|
app.post(`${PRIMITIVES_PREFIX}/videos/probe`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_probe", operationName: "run" }));
|
|
13302
13557
|
app.post(`${PRIMITIVES_PREFIX}/videos/extract-frame`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_extract_frame", operationName: "run" }));
|
|
@@ -13324,8 +13579,14 @@ app.get(APPROVED_POSTS_PREFIX, requireAuth, async (c) => {
|
|
|
13324
13579
|
|| (post.createdAt === cursor.createdAt && post.id < cursor.id)))
|
|
13325
13580
|
: filteredPosts;
|
|
13326
13581
|
const page = paginateDescendingByCreatedAt(posts, limit);
|
|
13582
|
+
let serialized = page.items.map((post) => serializeReadyPost(c, post, { includePrivate: true }));
|
|
13583
|
+
// Cloud passthrough (vidfarm serve): only the first (cursorless) page merges
|
|
13584
|
+
// the upstream account's posts — cursors keep paging the local set exactly.
|
|
13585
|
+
if (!cursor && !tracerFilter) {
|
|
13586
|
+
serialized = (await mergeUpstreamReadyPosts(serialized)).slice(0, limit);
|
|
13587
|
+
}
|
|
13327
13588
|
return c.json({
|
|
13328
|
-
posts:
|
|
13589
|
+
posts: serialized,
|
|
13329
13590
|
next_cursor: page.nextCursor
|
|
13330
13591
|
});
|
|
13331
13592
|
});
|
|
@@ -13359,6 +13620,9 @@ app.post(APPROVED_POSTS_PREFIX, requireAuth, async (c) => {
|
|
|
13359
13620
|
}
|
|
13360
13621
|
});
|
|
13361
13622
|
app.get(`${APPROVED_POSTS_PREFIX}/:postId`, async (c) => {
|
|
13623
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13624
|
+
if (proxied)
|
|
13625
|
+
return proxied;
|
|
13362
13626
|
const post = await serverlessRecords.getReadyPost(c.req.param("postId"));
|
|
13363
13627
|
if (!post || post.status === "deleted") {
|
|
13364
13628
|
return c.json({ error: "Approved post not found." }, 404);
|
|
@@ -13370,18 +13634,33 @@ app.get(`${APPROVED_POSTS_PREFIX}/:postId`, async (c) => {
|
|
|
13370
13634
|
return c.json({ post: serializeReadyPost(c, post, { includePrivate: viewer.canManage, password: viewer.hasPasswordAccess ? viewer.password : null }) });
|
|
13371
13635
|
});
|
|
13372
13636
|
app.get(`${APPROVED_POSTS_PREFIX}/:postId/schedules`, requireAuth, async (c) => {
|
|
13637
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13638
|
+
if (proxied)
|
|
13639
|
+
return proxied;
|
|
13373
13640
|
return await listReadyPostSchedulesResponse(c, requireCustomer(c), String(c.req.param("postId")));
|
|
13374
13641
|
});
|
|
13375
13642
|
app.post(`${APPROVED_POSTS_PREFIX}/:postId/schedules`, requireAuth, async (c) => {
|
|
13643
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13644
|
+
if (proxied)
|
|
13645
|
+
return proxied;
|
|
13376
13646
|
return createReadyPostScheduleResponse(c, requireCustomer(c), String(c.req.param("postId")));
|
|
13377
13647
|
});
|
|
13378
13648
|
app.patch(`${APPROVED_POSTS_PREFIX}/:postId/schedules/:scheduleId`, requireAuth, async (c) => {
|
|
13649
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13650
|
+
if (proxied)
|
|
13651
|
+
return proxied;
|
|
13379
13652
|
return updateReadyPostScheduleResponse(c, requireCustomer(c), String(c.req.param("postId")), String(c.req.param("scheduleId")));
|
|
13380
13653
|
});
|
|
13381
13654
|
app.delete(`${APPROVED_POSTS_PREFIX}/:postId/schedules/:scheduleId`, requireAuth, async (c) => {
|
|
13655
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13656
|
+
if (proxied)
|
|
13657
|
+
return proxied;
|
|
13382
13658
|
return deleteReadyPostScheduleResponse(c, requireCustomer(c), String(c.req.param("postId")), String(c.req.param("scheduleId")));
|
|
13383
13659
|
});
|
|
13384
13660
|
app.post(`${APPROVED_POSTS_PREFIX}/:postId/archive`, requireAuth, async (c) => {
|
|
13661
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13662
|
+
if (proxied)
|
|
13663
|
+
return proxied;
|
|
13385
13664
|
const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
|
|
13386
13665
|
if (!post || post.status === "deleted") {
|
|
13387
13666
|
return c.json({ error: "Approved post not found." }, 404);
|
|
@@ -13396,6 +13675,9 @@ app.post(`${APPROVED_POSTS_PREFIX}/:postId/archive`, requireAuth, async (c) => {
|
|
|
13396
13675
|
}
|
|
13397
13676
|
});
|
|
13398
13677
|
app.delete(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
|
|
13678
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13679
|
+
if (proxied)
|
|
13680
|
+
return proxied;
|
|
13399
13681
|
const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
|
|
13400
13682
|
if (!post) {
|
|
13401
13683
|
return c.json({ error: "Approved post not found." }, 404);
|
|
@@ -13410,6 +13692,9 @@ app.delete(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
|
|
|
13410
13692
|
}
|
|
13411
13693
|
});
|
|
13412
13694
|
app.get(`${APPROVED_POSTS_PREFIX}/:postId/download-zip`, async (c) => {
|
|
13695
|
+
const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
|
|
13696
|
+
if (proxied)
|
|
13697
|
+
return proxied;
|
|
13413
13698
|
const post = await serverlessRecords.getReadyPost(c.req.param("postId"));
|
|
13414
13699
|
if (!post || post.status === "deleted") {
|
|
13415
13700
|
return c.json({ error: "Approved post not found." }, 404);
|