@mevdragon/vidfarm-devcli 0.6.0 → 0.7.1

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