@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/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 ghostcut state per fork so the AI can pick between the original
3489
- // upload and the subtitle-removed mirror before feeding a video into a
3490
- // primitive route. Only the GET /ghostcut variant is exercised by the AI —
3491
- // POST /ghostcut-poll advances the job so the copilot must be explicit
3492
- // about wanting it, but it lives under the same prefix and is protected by
3493
- // its own auth guard, which is fine.
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/ghostcut 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 /ghostcut-poll (which advances the 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.",
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
- const template = await serverlessRecords.getTemplate(templateId);
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
- const forkRecord = await serverlessRecords.getCompositionFork(effectiveForkParam);
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 /ghostcut-poll below. Only runs for
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
- // ghostcut-poll endpoint (only when the task actually completes).
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 /ghostcut-poll
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: /ghostcut-poll will fall back to the URL string-swap path
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
- // Idempotent status probe for the async GhostCut subtitle-removal task kicked
6540
- // off by /auto-decompose. The client polls this every few seconds after a
6541
- // successful decompose. On this endpoint's side:
6542
- // - "none": no ghostcut task was submitted for this fork (slideshow, or
6543
- // ghostcut disabled / submit failed).
6544
- // - "pending": task still running. Response includes progress %.
6545
- // - "done": task finished. We download the cleaned mp4, mirror it to our
6546
- // own S3, swap the original source URL for the mirror URL throughout the
6547
- // current composition.html, snapshot a new version, and bill the
6548
- // per-30s-chunk GhostCut cost. Idempotent safe to re-poll.
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
- app.post(`${COMPOSITIONS_PREFIX}/:forkId/ghostcut-poll`, async (c) => {
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 : "ghostcut-poll failed" }, 500);
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
- // /ghostcut-poll. Each invocation makes at most one heavy external call so the
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 /ghostcut-poll until
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
- return c.json({ videos: matched.slice(0, limit).map(serializeInspiration) });
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
- const items = await serverlessRecords.listInspirationsFeed({ limit, query });
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 posts = await Promise.all((await serverlessRecords.listReadyPosts(customer.id))
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: page.items.map((post) => serializeReadyPost(c, post, { includePrivate: true })),
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);