@mevdragon/vidfarm-devcli 0.7.1 → 0.9.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.
Files changed (30) hide show
  1. package/README.md +8 -0
  2. package/demo/dist/app.js +81 -53
  3. package/dist/src/app.js +1010 -8
  4. package/dist/src/cli.js +390 -53
  5. package/dist/src/config.js +6 -0
  6. package/dist/src/devcli/clip-store.js +335 -0
  7. package/dist/src/devcli/clips.js +803 -0
  8. package/dist/src/devcli/telemetry.js +236 -0
  9. package/dist/src/frontend/homepage-view.js +5 -0
  10. package/dist/src/frontend/template-editor-chat.js +212 -0
  11. package/dist/src/services/clip-curation/cost.js +112 -0
  12. package/dist/src/services/clip-curation/ffmpeg.js +258 -0
  13. package/dist/src/services/clip-curation/gemini.js +342 -0
  14. package/dist/src/services/clip-curation/index.js +15 -0
  15. package/dist/src/services/clip-curation/presets.js +20 -0
  16. package/dist/src/services/clip-curation/presets.v1.json +59 -0
  17. package/dist/src/services/clip-curation/query.js +163 -0
  18. package/dist/src/services/clip-curation/refine.js +136 -0
  19. package/dist/src/services/clip-curation/scan.js +137 -0
  20. package/dist/src/services/clip-curation/taxonomy.js +71 -0
  21. package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
  22. package/dist/src/services/clip-curation/types.js +7 -0
  23. package/dist/src/services/clip-records.js +209 -0
  24. package/dist/src/services/clip-search.js +47 -0
  25. package/dist/src/services/clip-vectors.js +125 -0
  26. package/dist/src/services/hyperframes.js +369 -9
  27. package/dist/src/services/scene-annotations.js +32 -0
  28. package/package.json +5 -1
  29. package/public/assets/homepage-client-app.js +17 -17
  30. package/public/assets/page-runtime-client-app.js +31 -31
package/dist/src/app.js CHANGED
@@ -28,7 +28,7 @@ import { primitiveRegistry } from "./primitive-registry.js";
28
28
  import { decryptString, encryptString, hashSecret, safeEqualHash } from "./lib/crypto.js";
29
29
  import { devErrorFields, devLog } from "./lib/dev-log.js";
30
30
  import { displayNameFromEmail } from "./lib/display-name.js";
31
- import { createId } from "./lib/ids.js";
31
+ import { createId, createIdV7 } from "./lib/ids.js";
32
32
  import { resolveRootFileCandidates } from "./lib/package-root.js";
33
33
  import { stripTrackingParams } from "./lib/url-clean.js";
34
34
  import { addSeconds, nowIso } from "./lib/time.js";
@@ -45,7 +45,12 @@ import { ServerlessProviderKeyService } from "./services/serverless-provider-key
45
45
  import { ServerlessTemplateConfigService } from "./services/serverless-template-configs.js";
46
46
  import { parseHTML } from "linkedom";
47
47
  import { joinStorageKey, StorageService } from "./services/storage.js";
48
- import { analyzeCastMembers, buildSmartDecomposedHtml, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
48
+ import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
49
+ import { clipRecords, makeUserPreset } from "./services/clip-records.js";
50
+ import { matchScenesToClips, searchUserClips } from "./services/clip-search.js";
51
+ import { BUILTIN_PRESETS, ClipModelClient, estimateScanCostFromDuration } from "./services/clip-curation/index.js";
52
+ import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
53
+ import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
49
54
  import { createPrimitiveJobContext } from "./primitive-context.js";
50
55
  import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
51
56
  import { MediaDurationExceededError, probeMediaAsset } from "./services/media-processing.js";
@@ -75,6 +80,7 @@ const COMPOSITIONS_PREFIX = `${API_PREFIX}/compositions`;
75
80
  const INSPIRATIONS_PREFIX = `${API_PREFIX}/inspirations`;
76
81
  const VIDEOS_PREFIX = `${API_PREFIX}/videos`;
77
82
  const PRIMITIVES_PREFIX = `${API_PREFIX}/primitives`;
83
+ const CLIPS_PREFIX = "/clips";
78
84
  const APPROVED_POSTS_PREFIX = `${API_PREFIX}/approved/posts`;
79
85
  const APPROVED_POSTS_PUBLIC_PREFIX = "/approved/posts";
80
86
  const ACCOUNT_FRONTEND_PREFIX = "/u/:userId";
@@ -4090,6 +4096,28 @@ function createEditorFrontendActionTool(input) {
4090
4096
  }
4091
4097
  });
4092
4098
  }
4099
+ // Discover-chat only: lets the AI create a BRAND-NEW video for the user, either
4100
+ // from a text prompt (mode=generate) or by replicating a pasted video URL
4101
+ // (mode=replicate). The tool itself is a passthrough — it just structures the
4102
+ // user's intent; the chat frontend runs the ingest→decompose→fork pipeline and
4103
+ // drops an "Open in editor" link into the thread when it's ready.
4104
+ function createVideoCreationTool() {
4105
+ return tool({
4106
+ description: "Create a NEW editable video for the user from scratch. Call this the MOMENT the user asks to \"create/make a video of ...\" (set mode=generate and put the idea in `prompt`) OR to \"replicate/recreate/remix this video\" from a pasted TikTok/YouTube/Instagram/X URL (set mode=replicate, put the URL in `source_url`, and put any requested changes in `instructions`). This kicks off video creation in the browser and posts an 'Open in editor' link to the chat when the new video is ready — you do not need to poll or call any other tool. This is the ONLY way to make a brand-new video; the editor tools only edit an already-open composition.",
4107
+ inputSchema: z.object({
4108
+ mode: z.enum(["generate", "replicate"]).describe("generate = brand-new from a text prompt; replicate = recreate a pasted video URL."),
4109
+ prompt: z.string().optional().describe("For mode=generate: what the video should be about."),
4110
+ source_url: z.string().optional().describe("For mode=replicate: the TikTok/YouTube/Instagram/X video URL to recreate."),
4111
+ instructions: z.string().optional().describe("Optional changes to apply while creating/recreating (e.g. 'make it about my coffee brand', 'change the hook to ...').")
4112
+ }),
4113
+ execute: async ({ mode, prompt, source_url, instructions }) => ({
4114
+ mode,
4115
+ prompt: prompt ?? null,
4116
+ source_url: source_url ?? null,
4117
+ instructions: instructions ?? null
4118
+ })
4119
+ });
4120
+ }
4093
4121
  function createEditorActionTool() {
4094
4122
  return tool({
4095
4123
  description: "Directly mutate the composition timeline in the editor OR start an export render OR generate-and-place AI media. Use this whenever the user asks for a timeline change (add/remove/duplicate/split layers, retime, reposition, resize, restyle text, swap media, group/ungroup), asks to generate an AI video/image and drop it on the timeline (action_type=generate_layer), or asks to export/render/publish/download the final MP4 (action_type=export_composition). The most recent <editor_context> in the user message is the source of truth for current layer_keys, layer indices (called 'track'), durations, timeline_gaps (blank spans), pending_generations (AI gens still rendering), and the most recent exported MP4 (last_export_url). Always provide explanation. For add_layer, also provide layer_key with a stable descriptive id (e.g., 'vf-caption-hook', 'vf-hero-image') so subsequent tool calls in the same turn can reference it — the editor will use that id as the new layer's data-hf-id. PREFER action_type=generate_layer to create NEW AI footage/images on the timeline: it submits the primitive generate job, drops a placeholder clip into the target slot immediately, and auto-swaps in the finished media when the job settles (no manual poll, no separate add_layer). Only use the http_request→add_layer flow for media that already has a URL. Avoid layer/track collisions: pick a track index higher than any existing layer on the requested time range, or set start/duration so the range is free. Never invent layer_keys for remove/set/duplicate/split actions; only use keys present in the most recent editor_context. For action_type=export_composition, no layer_key/geometry fields are needed; the editor kicks off the render and streams status in the UI, and the resulting MP4 URL will appear in the next <editor_context> as last_export_url.",
@@ -4297,7 +4325,12 @@ async function streamEditorChatAgent(input, send) {
4297
4325
  frontend_action: createEditorFrontendActionTool({
4298
4326
  tracerRegistry
4299
4327
  }),
4300
- editor_action: createEditorActionTool()
4328
+ editor_action: createEditorActionTool(),
4329
+ // Discover/brainstorm surface only: "create me a video of…" / "replicate
4330
+ // this URL…". The editor surfaces edit an already-open composition.
4331
+ ...(input.template.templateId === "chat-brainstorm"
4332
+ ? { create_video: createVideoCreationTool() }
4333
+ : {})
4301
4334
  };
4302
4335
  })()
4303
4336
  : undefined;
@@ -5899,6 +5932,42 @@ async function recordCastLambdaUsage(input) {
5899
5932
  console.error("cast billing failed", error instanceof Error ? error.message : error);
5900
5933
  }
5901
5934
  }
5935
+ // Charges the caller's wallet for the lambda compute of one scene-annotation
5936
+ // poll (the video download + frame sample + annotation vision call). Idempotent
5937
+ // per fork+poll-start so a client re-poll of the same tick never double-charges.
5938
+ // Never throws. Mirrors recordCastLambdaUsage.
5939
+ async function recordSceneAnnotationsLambdaUsage(input) {
5940
+ const durationMs = Date.now() - input.startedAtMs;
5941
+ const memoryMb = Math.max(128, Number.parseInt(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE || "2048", 10) || 2048);
5942
+ const gbSeconds = (memoryMb / 1024) * (durationMs / 1000);
5943
+ const LAMBDA_X86_GB_SECOND_USD = 0.0000166667;
5944
+ const LAMBDA_REQUEST_USD = 0.20 / 1_000_000;
5945
+ const lambdaCostUsd = Number(((gbSeconds * LAMBDA_X86_GB_SECOND_USD) + LAMBDA_REQUEST_USD).toFixed(6));
5946
+ if (lambdaCostUsd <= 0)
5947
+ return;
5948
+ try {
5949
+ await billing.record({
5950
+ customerId: input.customerId,
5951
+ jobId: `scene-annotations:${input.forkId}:${input.startedAtMs}`,
5952
+ tracer: null,
5953
+ templateId: "auto-decompose",
5954
+ type: "cpu_estimate",
5955
+ costUsd: lambdaCostUsd,
5956
+ costCenterSlug: "auto_decompose_lambda",
5957
+ idempotencyKey: `scene_annotations_lambda:${input.forkId}:${input.startedAtMs}`,
5958
+ occurredAtMs: Date.now(),
5959
+ metadata: {
5960
+ fork_id: input.forkId,
5961
+ duration_ms: durationMs,
5962
+ memory_mb: memoryMb,
5963
+ gb_seconds: Number(gbSeconds.toFixed(6))
5964
+ }
5965
+ });
5966
+ }
5967
+ catch (error) {
5968
+ console.error("scene-annotations billing failed", error instanceof Error ? error.message : error);
5969
+ }
5970
+ }
5902
5971
  async function snapshotForkVersion(input) {
5903
5972
  const version = (input.fork.latestVersion ?? 0) + 1;
5904
5973
  // Bound namespace growth for user-initiated snapshots. Internal reasons
@@ -5915,6 +5984,9 @@ async function snapshotForkVersion(input) {
5915
5984
  // Placement surfaces travel with every version too (reusable decompose
5916
5985
  // metadata, same as cast identification).
5917
5986
  const workingPlacement = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "product-placement.json"));
5987
+ // Scene annotations (clip-replacement + recreation DNA) likewise travel with
5988
+ // every version — reusable decompose metadata, same as placement surfaces.
5989
+ const workingAnnotations = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "scene-annotations.json"));
5918
5990
  if (workingHtml) {
5919
5991
  await storage.putText(storage.compositionForkVersionKey(input.fork.id, version, "composition.html"), workingHtml, "text/html; charset=utf-8");
5920
5992
  }
@@ -5927,6 +5999,9 @@ async function snapshotForkVersion(input) {
5927
5999
  if (workingPlacement) {
5928
6000
  await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "product-placement.json"), Buffer.from(workingPlacement, "utf8"), "application/json");
5929
6001
  }
6002
+ if (workingAnnotations) {
6003
+ await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "scene-annotations.json"), Buffer.from(workingAnnotations, "utf8"), "application/json");
6004
+ }
5930
6005
  const versionRecord = await serverlessRecords.createForkVersion({
5931
6006
  forkId: input.fork.id,
5932
6007
  ownerCustomerId: input.fork.customerId,
@@ -6017,6 +6092,7 @@ app.post(COMPOSITIONS_PREFIX, async (c) => {
6017
6092
  // identified people + reference stills as the shared default composition.
6018
6093
  const cast = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "cast.json"));
6019
6094
  const placement = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "product-placement.json"));
6095
+ const annotations = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "scene-annotations.json"));
6020
6096
  if (html) {
6021
6097
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), html, "text/html; charset=utf-8");
6022
6098
  }
@@ -6029,6 +6105,9 @@ app.post(COMPOSITIONS_PREFIX, async (c) => {
6029
6105
  if (placement) {
6030
6106
  await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
6031
6107
  }
6108
+ if (annotations) {
6109
+ await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
6110
+ }
6032
6111
  }
6033
6112
  else if (template.videoUrl) {
6034
6113
  // Template exists but has no canonical fork yet — seed with raw HTML.
@@ -6393,6 +6472,20 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
6393
6472
  catch (placementPersistError) {
6394
6473
  console.error("inspiration product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
6395
6474
  }
6475
+ // Seed the scene-annotation pass "pending" on the canonical fork too,
6476
+ // before the snapshot, so the pending state travels with v1 and every
6477
+ // downstream fork can drive /scene-annotations-poll (or inherit the
6478
+ // completed annotations once the canonical fork's poll finishes).
6479
+ try {
6480
+ await storage.putJson(storage.compositionForkWorkingKey(canonicalFork.id, "scene-annotations.json"), buildPendingSceneAnnotationsState({
6481
+ compositionId: canonicalFork.id,
6482
+ sourceUrl,
6483
+ title
6484
+ }));
6485
+ }
6486
+ catch (annotationSeedError) {
6487
+ console.error("inspiration scene-annotations seed failed", annotationSeedError instanceof Error ? annotationSeedError.message : annotationSeedError);
6488
+ }
6396
6489
  await writeForkManifest(storage, canonicalFork, { kind: "working" });
6397
6490
  await snapshotForkVersion({
6398
6491
  fork: canonicalFork,
@@ -6470,6 +6563,66 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
6470
6563
  }
6471
6564
  });
6472
6565
  });
6566
+ // A "processing" state older than this is treated as abandoned (crashed lambda)
6567
+ // so a forced re-run may start. Must exceed the longest real decompose but stay
6568
+ // under the API lambda's 15-min timeout.
6569
+ const DECOMPOSE_PROCESSING_STALE_MS = 10 * 60 * 1000;
6570
+ async function readDecomposeJobState(forkId) {
6571
+ const text = await storage.readText(storage.compositionForkWorkingKey(forkId, "decompose.json"));
6572
+ if (!text)
6573
+ return null;
6574
+ try {
6575
+ return JSON.parse(text);
6576
+ }
6577
+ catch {
6578
+ return null;
6579
+ }
6580
+ }
6581
+ async function writeDecomposeJobState(forkId, state) {
6582
+ try {
6583
+ await storage.putJson(storage.compositionForkWorkingKey(forkId, "decompose.json"), state);
6584
+ }
6585
+ catch (error) {
6586
+ console.error("decompose.json write failed", error instanceof Error ? error.message : error);
6587
+ }
6588
+ }
6589
+ function decomposeDoneResponse(job, extra = {}) {
6590
+ return {
6591
+ ok: true,
6592
+ status: "done",
6593
+ mode: "smart",
6594
+ provider: job.provider ?? null,
6595
+ model: job.model ?? null,
6596
+ scene_count: job.sceneCount ?? 0,
6597
+ caption_count: job.captionCount ?? 0,
6598
+ duration_seconds: job.durationSeconds ?? null,
6599
+ became_default_fork: job.becameDefaultFork ?? false,
6600
+ ghostcut_pending: job.ghostcutPending ?? false,
6601
+ ...extra
6602
+ };
6603
+ }
6604
+ // Read-only decompose job status — the client's poll target while a decompose
6605
+ // runs (or after a 504). Fast: just reads decompose.json.
6606
+ app.get(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6607
+ const caller = await getBrowserCustomer(c);
6608
+ try {
6609
+ const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: caller?.id ?? null, shareToken: readShareToken(c) }, "view");
6610
+ const job = await readDecomposeJobState(access.fork.id);
6611
+ if (!job)
6612
+ return c.json({ ok: true, status: "none" });
6613
+ if (job.status === "done")
6614
+ return c.json(decomposeDoneResponse(job, { resumed: true }));
6615
+ if (job.status === "failed")
6616
+ return c.json({ ok: true, status: "failed", error: job.failureReason ?? "Decompose failed." });
6617
+ const stale = Date.now() - (job.updatedAtMs ?? job.startedAtMs ?? 0) >= DECOMPOSE_PROCESSING_STALE_MS;
6618
+ return c.json({ ok: true, status: "processing", stale, started_at_ms: job.startedAtMs });
6619
+ }
6620
+ catch (error) {
6621
+ if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
6622
+ return forkAccessErrorResponse(c, error);
6623
+ return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
6624
+ }
6625
+ });
6473
6626
  app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6474
6627
  const caller = await getBrowserCustomer(c);
6475
6628
  if (!caller)
@@ -6486,6 +6639,36 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6486
6639
  const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
6487
6640
  const userPromptRaw = typeof body.user_prompt === "string" ? body.user_prompt.trim() : "";
6488
6641
  const userPrompt = userPromptRaw.length > 0 ? userPromptRaw.slice(0, 4000) : null;
6642
+ // Idempotency / resumability guard. POST STARTS a decompose (idempotently);
6643
+ // GET /auto-decompose is the poll. An in-flight run always wins so a client's
6644
+ // post-504 retry (or a second caller) never starts a duplicate. A completed
6645
+ // job is returned from cache unless `force` (the editor's decompose click)
6646
+ // asks for a fresh re-run. Absent / failed / abandoned jobs (re)start — so a
6647
+ // plain POST with no `force` still kicks the first decompose (back-compat for
6648
+ // the editor-chat copilot's http_request call).
6649
+ const forceRun = body.force === true;
6650
+ const existingJob = await readDecomposeJobState(fork.id);
6651
+ const jobFresh = existingJob
6652
+ ? Date.now() - (existingJob.updatedAtMs ?? existingJob.startedAtMs ?? 0) < DECOMPOSE_PROCESSING_STALE_MS
6653
+ : false;
6654
+ if (existingJob?.status === "processing" && jobFresh) {
6655
+ return c.json({ ok: true, status: "processing", started_at_ms: existingJob.startedAtMs });
6656
+ }
6657
+ if (existingJob?.status === "done" && !forceRun) {
6658
+ return c.json(decomposeDoneResponse(existingJob, { resumed: true }));
6659
+ }
6660
+ // Claim the job so concurrent polls/kicks back off. A local helper stamps
6661
+ // "failed" on every early-error return so a poller never hangs on processing.
6662
+ await writeDecomposeJobState(fork.id, { status: "processing", startedAtMs, updatedAtMs: Date.now() });
6663
+ const failDecompose = async (payload, statusCode) => {
6664
+ await writeDecomposeJobState(fork.id, {
6665
+ status: "failed",
6666
+ startedAtMs,
6667
+ updatedAtMs: Date.now(),
6668
+ failureReason: typeof payload.error === "string" ? payload.error : "Auto-decompose failed."
6669
+ });
6670
+ return c.json(payload, statusCode);
6671
+ };
6489
6672
  // Parse the current composition HTML with a real DOM (linkedom) so
6490
6673
  // attribute values come back UNESCAPED — `data-source-video="foo?a=1&amp;b=2"`
6491
6674
  // reads as `foo?a=1&b=2`, which is what ghostcut and the vision extractor
@@ -6522,7 +6705,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6522
6705
  }
6523
6706
  catch (error) {
6524
6707
  if (error instanceof MediaDurationExceededError) {
6525
- return c.json({
6708
+ return failDecompose({
6526
6709
  ok: false,
6527
6710
  error: `Source video is ${error.durationSeconds.toFixed(1)}s — Vidfarm caps auto-decompose at ${error.maxDurationSeconds}s. Trim the source before forking.`,
6528
6711
  code: "source_too_long",
@@ -6640,13 +6823,13 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6640
6823
  const customerKeys = await loadCustomerVisionKeys(caller.id);
6641
6824
  const hasAnyKey = Boolean(customerKeys.gemini || customerKeys.openai || customerKeys.openrouter);
6642
6825
  if (!hasAnyKey) {
6643
- return c.json({
6826
+ return failDecompose({
6644
6827
  ok: false,
6645
6828
  error: "Decompose requires an AI provider key. Add a Gemini, OpenAI, or OpenRouter key in Settings and try again."
6646
6829
  }, 400);
6647
6830
  }
6648
6831
  if (!sourceUrl) {
6649
- return c.json({ ok: false, error: "Composition has no source video URL." }, 400);
6832
+ return failDecompose({ ok: false, error: "Composition has no source video URL." }, 400);
6650
6833
  }
6651
6834
  const inspirationForTagline = forkInspirationId
6652
6835
  ? await serverlessRecords.getInspiration(forkInspirationId)
@@ -6676,13 +6859,13 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6676
6859
  }
6677
6860
  catch (error) {
6678
6861
  console.error("smart decompose failed", error instanceof Error ? error.message : error);
6679
- return c.json({
6862
+ return failDecompose({
6680
6863
  ok: false,
6681
6864
  error: error instanceof Error ? error.message : "Smart decompose failed."
6682
6865
  }, 502);
6683
6866
  }
6684
6867
  if (smart.scenes.length === 0 && smart.captions.length === 0) {
6685
- return c.json({
6868
+ return failDecompose({
6686
6869
  ok: false,
6687
6870
  error: "Smart decompose returned no scenes. Try again with different guidance."
6688
6871
  }, 502);
@@ -6775,6 +6958,21 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6775
6958
  catch (placementPersistError) {
6776
6959
  console.error("product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
6777
6960
  }
6961
+ // Seed the per-scene clip-annotation pass as "pending" and let the client
6962
+ // drive it via POST /scene-annotations-poll (same async model as cast /
6963
+ // ghostcut). The annotation is its own vision call — running it inline would
6964
+ // risk the ~60s origin timeout on longer videos — and it reads the finalized
6965
+ // scenes back from smart-decompose.json at poll time. Non-fatal.
6966
+ try {
6967
+ await storage.putJson(storage.compositionForkWorkingKey(fork.id, "scene-annotations.json"), buildPendingSceneAnnotationsState({
6968
+ compositionId,
6969
+ sourceUrl: analysisSourceUrl,
6970
+ title: typeof title === "string" ? title : fork.id
6971
+ }));
6972
+ }
6973
+ catch (annotationSeedError) {
6974
+ console.error("scene-annotations seed failed", annotationSeedError instanceof Error ? annotationSeedError.message : annotationSeedError);
6975
+ }
6778
6976
  // Seed the cast-identification 2nd pass as "pending" and let the client
6779
6977
  // drive it via POST /cast-poll (same async model as ghostcut). We identify
6780
6978
  // cast from the same caption-free analysis source the vision pass used, so
@@ -6813,8 +7011,23 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6813
7011
  }
6814
7012
  }
6815
7013
  await chargeDecomposeUsage({ mode: "smart" });
7014
+ // Stamp the job "done" so a client whose request 504'd mid-run (or any later
7015
+ // poll) resolves to completion and reloads the composition.
7016
+ await writeDecomposeJobState(fork.id, {
7017
+ status: "done",
7018
+ startedAtMs,
7019
+ updatedAtMs: Date.now(),
7020
+ provider: smart.provider,
7021
+ model: smart.model,
7022
+ sceneCount: smart.scenes.length,
7023
+ captionCount: smart.captions.length,
7024
+ durationSeconds: smart.durationSeconds,
7025
+ becameDefaultFork: becameDefault,
7026
+ ghostcutPending: ghostcutTaskId !== null
7027
+ });
6816
7028
  return c.json({
6817
7029
  ok: true,
7030
+ status: "done",
6818
7031
  mode: "smart",
6819
7032
  provider: smart.provider,
6820
7033
  model: smart.model,
@@ -6830,6 +7043,15 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6830
7043
  catch (error) {
6831
7044
  if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
6832
7045
  return forkAccessErrorResponse(c, error);
7046
+ // Persist a failed job (best-effort) so the client's poll reads a terminal
7047
+ // failure instead of hanging on "processing". Safe: only reached after the
7048
+ // caller passed access control.
7049
+ await writeDecomposeJobState(c.req.param("forkId"), {
7050
+ status: "failed",
7051
+ startedAtMs,
7052
+ updatedAtMs: Date.now(),
7053
+ failureReason: error instanceof Error ? error.message : "Auto-decompose failed."
7054
+ });
6833
7055
  return c.json({ ok: false, error: error instanceof Error ? error.message : "Auto-decompose failed." }, 500);
6834
7056
  }
6835
7057
  });
@@ -7325,6 +7547,204 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/product-placement.json`, async (c) => {
7325
7547
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
7326
7548
  }
7327
7549
  });
7550
+ // Read-only scene-annotation context for a fork: the detected content FORMAT
7551
+ // plus one annotation per scene carrying the literal + viral DNA needed to
7552
+ // (a) match a REPLACEMENT clip from the library (each annotation ships a
7553
+ // ready-to-run match_criteria + embedding_text) and (b) let an AI agent
7554
+ // RE-CREATE the beat from scratch (recreation_prompt + recreation_notes).
7555
+ // Reusable decompose metadata — the editor-chat harness and desktop agents read
7556
+ // it to plan clip swaps or regeneration. Optional-auth like /product-placement.json.
7557
+ // Returns { status: "none" } when the fork was never decomposed (or predates
7558
+ // this pass).
7559
+ app.get(`${COMPOSITIONS_PREFIX}/:forkId/scene-annotations.json`, async (c) => {
7560
+ const customer = await getBrowserCustomer(c);
7561
+ try {
7562
+ const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer?.id ?? null, shareToken: readShareToken(c) }, "view");
7563
+ const text = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "scene-annotations.json"));
7564
+ if (!text) {
7565
+ return c.json({
7566
+ ok: true,
7567
+ status: "none",
7568
+ hint: "This fork has no scene-annotation pass yet. Run POST /api/v1/compositions/:forkId/auto-decompose to generate scene annotations."
7569
+ });
7570
+ }
7571
+ let state;
7572
+ try {
7573
+ state = JSON.parse(text);
7574
+ }
7575
+ catch {
7576
+ return c.json({ ok: true, status: "none" });
7577
+ }
7578
+ // Surface the pass's own state-machine status (pending/processing/done/
7579
+ // failed) so pollers and agents can tell "not done yet" from "no annotations
7580
+ // found". Annotations/contentFormat are only meaningful once status=="done".
7581
+ return c.json({
7582
+ ok: true,
7583
+ status: state.status,
7584
+ reason: state.failureReason ?? null,
7585
+ provider: state.provider ?? null,
7586
+ model: state.model ?? null,
7587
+ source_url: state.sourceUrl ?? null,
7588
+ duration_seconds: state.durationSeconds ?? null,
7589
+ content_format: state.status === "done" ? state.contentFormat : null,
7590
+ annotation_count: Array.isArray(state.annotations) ? state.annotations.length : 0,
7591
+ annotations: state.status === "done" && Array.isArray(state.annotations) ? state.annotations : []
7592
+ });
7593
+ }
7594
+ catch (error) {
7595
+ if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
7596
+ return forkAccessErrorResponse(c, error);
7597
+ return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
7598
+ }
7599
+ });
7600
+ // Drives the async scene-annotation pass to completion — one vision call over
7601
+ // the finalized scenes (read back from smart-decompose.json) that classifies
7602
+ // the content format and annotates every scene with its clip-match + recreation
7603
+ // DNA. Mirrors /cast-poll but single-stage: pending -> processing -> done|failed.
7604
+ // Terminal states short-circuit (idempotent). On success it also backfills the
7605
+ // detected content format onto the template + inspiration metadata.
7606
+ app.post(`${COMPOSITIONS_PREFIX}/:forkId/scene-annotations-poll`, async (c) => {
7607
+ const caller = await getBrowserCustomer(c);
7608
+ if (!caller)
7609
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
7610
+ const startedAtMs = Date.now();
7611
+ try {
7612
+ const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: caller.id, shareToken: readShareToken(c) }, "edit");
7613
+ const fork = access.fork;
7614
+ const stateKey = storage.compositionForkWorkingKey(fork.id, "scene-annotations.json");
7615
+ const stateText = await storage.readText(stateKey);
7616
+ if (!stateText)
7617
+ return c.json({ ok: true, status: "none" });
7618
+ let state;
7619
+ try {
7620
+ state = JSON.parse(stateText);
7621
+ }
7622
+ catch {
7623
+ return c.json({ ok: true, status: "none" });
7624
+ }
7625
+ // Terminal states — no re-work.
7626
+ if (state.status === "done" || state.status === "failed") {
7627
+ return c.json({
7628
+ ok: true,
7629
+ status: state.status,
7630
+ reason: state.failureReason ?? null,
7631
+ content_format: state.status === "done" ? state.contentFormat : null,
7632
+ annotation_count: Array.isArray(state.annotations) ? state.annotations.length : 0
7633
+ });
7634
+ }
7635
+ // Another poll is already running this fork's (single) vision call — don't
7636
+ // pay for a duplicate. Re-enter only if the in-flight attempt looks stalled.
7637
+ const STALE_PROCESSING_MS = 3 * 60 * 1000;
7638
+ if (state.status === "processing" && Date.now() - (state.savedAtMs ?? 0) < STALE_PROCESSING_MS) {
7639
+ return c.json({ ok: true, status: "pending", note: "in_progress" });
7640
+ }
7641
+ const keys = await loadCustomerVisionKeys(caller.id);
7642
+ if (!(keys.gemini || keys.openai || keys.openrouter)) {
7643
+ const next = { ...state, status: "failed", failureReason: "no_provider_key", savedAtMs: Date.now() };
7644
+ await storage.putJson(stateKey, next);
7645
+ return c.json({ ok: true, status: "failed", reason: "no_provider_key" });
7646
+ }
7647
+ // Read the finalized scenes + viral DNA + summary + transcript from the
7648
+ // decompose snapshot (same source /cast-poll grounds on). Without it there
7649
+ // is nothing to annotate.
7650
+ const smartText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "smart-decompose.json"));
7651
+ if (!smartText) {
7652
+ const next = { ...state, status: "failed", failureReason: "missing_decompose_snapshot", savedAtMs: Date.now() };
7653
+ await storage.putJson(stateKey, next);
7654
+ return c.json({ ok: true, status: "failed", reason: "missing_decompose_snapshot" });
7655
+ }
7656
+ let scenes = [];
7657
+ let viralDna = null;
7658
+ let summary = "";
7659
+ let transcript = null;
7660
+ let userPrompt = null;
7661
+ try {
7662
+ const snapshot = JSON.parse(smartText);
7663
+ scenes = Array.isArray(snapshot.result?.scenes) ? snapshot.result.scenes : [];
7664
+ viralDna = snapshot.result?.viralDna ?? null;
7665
+ summary = typeof snapshot.result?.summary === "string" ? snapshot.result.summary : "";
7666
+ transcript = snapshot.result?.transcript ?? null;
7667
+ userPrompt = typeof snapshot.userPrompt === "string" ? snapshot.userPrompt : null;
7668
+ }
7669
+ catch {
7670
+ const next = { ...state, status: "failed", failureReason: "unreadable_decompose_snapshot", savedAtMs: Date.now() };
7671
+ await storage.putJson(stateKey, next);
7672
+ return c.json({ ok: true, status: "failed", reason: "unreadable_decompose_snapshot" });
7673
+ }
7674
+ if (scenes.length === 0) {
7675
+ // A slideshow/time-slice fork with no real scenes — nothing to annotate.
7676
+ const next = { ...state, status: "done", savedAtMs: Date.now() };
7677
+ await storage.putJson(stateKey, next);
7678
+ return c.json({ ok: true, status: "done", annotation_count: 0 });
7679
+ }
7680
+ // Claim the work so concurrent pollers back off (see the staleness guard).
7681
+ await storage.putJson(stateKey, { ...state, status: "processing", savedAtMs: Date.now() });
7682
+ let outcome;
7683
+ try {
7684
+ outcome = await annotateScenesFromSource({
7685
+ sourceUrl: state.sourceUrl,
7686
+ keys,
7687
+ scenes,
7688
+ viralDna: viralDna ?? { trend_tagline: "", hook: "", retention: "", payoff: "", preserve: [], avoid: [], promotions: [], keywords: [] },
7689
+ summary,
7690
+ transcript,
7691
+ userPrompt
7692
+ });
7693
+ }
7694
+ catch (error) {
7695
+ const reason = error instanceof Error ? error.message : String(error);
7696
+ const next = { ...state, status: "failed", failureReason: reason, savedAtMs: Date.now() };
7697
+ await storage.putJson(stateKey, next);
7698
+ return c.json({ ok: true, status: "failed", reason });
7699
+ }
7700
+ const next = {
7701
+ ...state,
7702
+ status: "done",
7703
+ provider: outcome.provider,
7704
+ model: outcome.model,
7705
+ durationSeconds: outcome.durationSeconds,
7706
+ contentFormat: outcome.contentFormat,
7707
+ annotations: outcome.annotations,
7708
+ savedAtMs: Date.now()
7709
+ };
7710
+ await storage.putJson(stateKey, next);
7711
+ // Backfill the detected content format onto the template + inspiration
7712
+ // metadata now that we know it (the synchronous decompose couldn't). This is
7713
+ // what the catalog + clip matcher filter on. Best-effort.
7714
+ try {
7715
+ const forkTemplate = fork.templateId ? await serverlessRecords.getTemplate(fork.templateId) : null;
7716
+ if (forkTemplate) {
7717
+ await serverlessRecords.updateTemplate({
7718
+ templateId: forkTemplate.id,
7719
+ patch: { contentFormat: outcome.contentFormat.format }
7720
+ });
7721
+ if (forkTemplate.inspirationId) {
7722
+ await serverlessRecords.updateInspiration({
7723
+ inspirationId: forkTemplate.inspirationId,
7724
+ patch: { contentFormat: outcome.contentFormat.format }
7725
+ });
7726
+ }
7727
+ }
7728
+ }
7729
+ catch (backfillError) {
7730
+ console.error("scene-annotations content-format backfill failed", backfillError instanceof Error ? backfillError.message : backfillError);
7731
+ }
7732
+ await recordSceneAnnotationsLambdaUsage({ customerId: caller.id, forkId: fork.id, startedAtMs });
7733
+ return c.json({
7734
+ ok: true,
7735
+ status: "done",
7736
+ provider: outcome.provider,
7737
+ model: outcome.model,
7738
+ content_format: outcome.contentFormat,
7739
+ annotation_count: outcome.annotations.length
7740
+ });
7741
+ }
7742
+ catch (error) {
7743
+ if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
7744
+ return forkAccessErrorResponse(c, error);
7745
+ return c.json({ ok: false, error: error instanceof Error ? error.message : "scene-annotations-poll failed" }, 500);
7746
+ }
7747
+ });
7328
7748
  // Drives the async cast-identification 2nd pass one unit of work per call
7329
7749
  // (analyze -> per-member still -> per-member isolation -> done), mirroring
7330
7750
  // /remove-video-captions-poll. Each invocation makes at most one heavy external call so the
@@ -7970,6 +8390,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
7970
8390
  const json = await readVersionFile("composition.json");
7971
8391
  const cast = await readVersionFile("cast.json");
7972
8392
  const placement = await readVersionFile("product-placement.json");
8393
+ const annotations = await readVersionFile("scene-annotations.json");
7973
8394
  if (html) {
7974
8395
  await storage.putText(storage.compositionForkWorkingKey(cloned.id, "composition.html"), html, "text/html; charset=utf-8");
7975
8396
  }
@@ -7982,6 +8403,9 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
7982
8403
  if (placement) {
7983
8404
  await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
7984
8405
  }
8406
+ if (annotations) {
8407
+ await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
8408
+ }
7985
8409
  await snapshotForkVersion({
7986
8410
  fork: cloned,
7987
8411
  callerCustomerId: customer.id,
@@ -8375,6 +8799,7 @@ app.post(`${VIDEOS_PREFIX}/:inspirationId/decompose`, async (c) => {
8375
8799
  const json = await readSource("composition.json");
8376
8800
  const cast = await readSource("cast.json");
8377
8801
  const placement = await readSource("product-placement.json");
8802
+ const annotations = await readSource("scene-annotations.json");
8378
8803
  if (html)
8379
8804
  await storage.putText(storage.compositionForkWorkingKey(cloned.id, "composition.html"), html, "text/html; charset=utf-8");
8380
8805
  if (json)
@@ -8383,6 +8808,8 @@ app.post(`${VIDEOS_PREFIX}/:inspirationId/decompose`, async (c) => {
8383
8808
  await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
8384
8809
  if (placement)
8385
8810
  await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
8811
+ if (annotations)
8812
+ await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
8386
8813
  await snapshotForkVersion({
8387
8814
  fork: cloned,
8388
8815
  callerCustomerId: customer.id,
@@ -10466,6 +10893,13 @@ app.use(`${API_PREFIX}/*`, async (c, next) => {
10466
10893
  });
10467
10894
  app.use(`${USER_PREFIX}/me`, requireAuth);
10468
10895
  app.use(`${USER_PREFIX}/me/*`, requireAuth);
10896
+ // The /clips API (feed/search/scan/presets) is auth-gated; the bare /clips path
10897
+ // is the public HTML gallery page (like /discover vs /discover/feed). Hono's
10898
+ // `/clips/*` also matches the bare `/clips`, so let that one path through
10899
+ // unauthenticated.
10900
+ const isClipsGalleryPage = (c) => c.req.path === CLIPS_PREFIX || c.req.path === `${CLIPS_PREFIX}/`;
10901
+ app.use(`${CLIPS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : requireAuth(c, next)));
10902
+ app.use(`${CLIPS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : captureApiCallHistory(c, next)));
10469
10903
  app.use(AGENCY_PREFIX, requireAuth);
10470
10904
  app.use(`${AGENCY_PREFIX}/*`, requireAuth);
10471
10905
  app.use(`${API_PREFIX}/rate-limit-status`, requireAuth);
@@ -14642,6 +15076,574 @@ app.post(`${USER_PREFIX}/me/provider-keys`, async (c) => {
14642
15076
  });
14643
15077
  return c.json({ ok: true }, 201);
14644
15078
  });
15079
+ // ── Clip curation (the third library: viral DNA + formats + CLIPS) ───────────
15080
+ // First-class clips API mirroring the devcli `clips` commands. /clips/* is gated
15081
+ // by requireAuth + captureApiCallHistory (middleware above). BYOK Gemini: search
15082
+ // and natural-language→criteria use the caller's saved gemini provider key.
15083
+ const clipScanSfn = new SFNClient({});
15084
+ const BUILTIN_CLIP_PRESET_IDS = new Set(BUILTIN_PRESETS.map((p) => p.preset_id));
15085
+ // First-class Clips gallery page (served at /clips). Self-contained HTML; the
15086
+ // client fetches /clips/feed, /clips/presets and /clips/search with the session
15087
+ // cookie. Standalone browse/search — no video generation required.
15088
+ function renderClipsPage() {
15089
+ return `<!doctype html>
15090
+ <html lang="en">
15091
+ <head>
15092
+ <meta charset="utf-8" />
15093
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
15094
+ <title>Clips — Vidfarm</title>
15095
+ <style>
15096
+ :root { --bg:#0b0d12; --panel:#141821; --panel2:#1b2130; --line:#242c3a; --text:#e7ecf5; --dim:#8b96ab; --accent:#5b8cff; --accent2:#3ad6a0; }
15097
+ * { box-sizing: border-box; }
15098
+ body { margin:0; background:var(--bg); color:var(--text); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
15099
+ a { color:inherit; text-decoration:none; }
15100
+ header.top { display:flex; align-items:center; gap:22px; padding:14px 24px; border-bottom:1px solid var(--line); position:sticky; top:0; background:rgba(11,13,18,.9); backdrop-filter:blur(8px); z-index:5; }
15101
+ header.top .brand { font-weight:700; letter-spacing:.3px; }
15102
+ header.top nav { display:flex; gap:6px; }
15103
+ header.top nav a { padding:6px 12px; border-radius:8px; color:var(--dim); font-weight:600; }
15104
+ header.top nav a.active, header.top nav a:hover { color:var(--text); background:var(--panel2); }
15105
+ .wrap { max-width:1200px; margin:0 auto; padding:28px 24px 80px; }
15106
+ .hero h1 { font-size:26px; margin:0 0 4px; }
15107
+ .hero p { color:var(--dim); margin:0 0 20px; }
15108
+ .searchbar { display:flex; gap:10px; margin-bottom:14px; }
15109
+ .searchbar input { flex:1; background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:10px; padding:12px 14px; font-size:15px; outline:none; }
15110
+ .searchbar input:focus { border-color:var(--accent); }
15111
+ .searchbar button { background:var(--accent); color:#fff; border:0; border-radius:10px; padding:0 18px; font-weight:700; cursor:pointer; }
15112
+ .presets { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:22px; }
15113
+ .chip { background:var(--panel); border:1px solid var(--line); color:var(--dim); border-radius:999px; padding:6px 13px; font-size:13px; font-weight:600; cursor:pointer; }
15114
+ .chip:hover, .chip.active { color:var(--text); border-color:var(--accent); }
15115
+ .status { color:var(--dim); font-size:14px; margin-bottom:16px; min-height:20px; }
15116
+ .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:18px; }
15117
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:14px; overflow:hidden; display:flex; flex-direction:column; }
15118
+ .card .thumb { position:relative; aspect-ratio:9/16; background:#000; overflow:hidden; }
15119
+ .card .thumb img, .card .thumb video { width:100%; height:100%; object-fit:cover; display:block; }
15120
+ .card .thumb .range { position:absolute; left:8px; bottom:8px; background:rgba(0,0,0,.7); border-radius:6px; padding:2px 7px; font-size:12px; font-variant-numeric:tabular-nums; }
15121
+ .card .thumb .score { position:absolute; right:8px; top:8px; background:rgba(58,214,160,.15); color:var(--accent2); border:1px solid rgba(58,214,160,.4); border-radius:6px; padding:1px 6px; font-size:11px; font-weight:700; }
15122
+ .card .body { padding:11px 12px 12px; display:flex; flex-direction:column; gap:8px; flex:1; }
15123
+ .card .desc { font-size:13px; color:var(--text); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
15124
+ .card .tags { display:flex; flex-wrap:wrap; gap:5px; }
15125
+ .card .tag { font-size:11px; color:var(--dim); background:var(--panel2); border-radius:5px; padding:1px 6px; }
15126
+ .card .row { display:flex; align-items:center; justify-content:space-between; margin-top:auto; }
15127
+ .card .src { font-size:11px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:60%; }
15128
+ .card a.dl { font-size:12px; font-weight:700; color:var(--accent); }
15129
+ .empty { text-align:center; color:var(--dim); padding:60px 20px; }
15130
+ .empty code { background:var(--panel2); padding:2px 8px; border-radius:6px; color:var(--accent2); }
15131
+ </style>
15132
+ </head>
15133
+ <body>
15134
+ <header class="top">
15135
+ <div class="brand">Vidfarm</div>
15136
+ <nav>
15137
+ <a href="/discover">Discover</a>
15138
+ <a href="/library">Library</a>
15139
+ <a href="/clips" class="active">Clips</a>
15140
+ </nav>
15141
+ </header>
15142
+ <div class="wrap">
15143
+ <div class="hero">
15144
+ <h1>Clips</h1>
15145
+ <p>Your reusable clip library — mined from long-form video, searchable by tag or natural language.</p>
15146
+ </div>
15147
+ <form class="searchbar" id="searchForm">
15148
+ <input id="q" type="search" placeholder="Search clips — e.g. &quot;someone looks confused after reading a message&quot;" autocomplete="off" />
15149
+ <button type="submit">Search</button>
15150
+ </form>
15151
+ <div class="presets" id="presets"></div>
15152
+ <div class="status" id="status">Loading your clips…</div>
15153
+ <div class="grid" id="grid"></div>
15154
+ </div>
15155
+ <script>
15156
+ (function(){
15157
+ var grid = document.getElementById('grid');
15158
+ var statusEl = document.getElementById('status');
15159
+ var presetsEl = document.getElementById('presets');
15160
+ var form = document.getElementById('searchForm');
15161
+ var input = document.getElementById('q');
15162
+
15163
+ function fmtClock(sec){ var m=Math.floor(sec/60), s=Math.floor(sec%60); return (m<10?'0':'')+m+':'+(s<10?'0':'')+s; }
15164
+ function esc(t){ var d=document.createElement('div'); d.textContent=(t==null?'':String(t)); return d.innerHTML; }
15165
+
15166
+ function topTags(tags){
15167
+ if(!tags) return [];
15168
+ var out=[];
15169
+ ['subject','action','emotion','composition'].forEach(function(k){ (tags[k]||[]).slice(0,1).forEach(function(v){ out.push(v); }); });
15170
+ if(tags.energy) out.push('energy:'+tags.energy);
15171
+ return out.slice(0,4);
15172
+ }
15173
+
15174
+ function card(clip){
15175
+ var range = fmtClock(clip.start_time_sec)+'–'+fmtClock(clip.end_time_sec);
15176
+ var score = (clip.score!=null) ? '<div class="score">'+ (clip.score*100).toFixed(0) +'</div>' : '';
15177
+ var media = clip.thumbnail_url
15178
+ ? '<img loading="lazy" src="'+esc(clip.thumbnail_url)+'" onmouseover="var v=this.parentNode.querySelector(&quot;video&quot;); if(v){v.style.display=&quot;block&quot;;v.play();}" />'
15179
+ : '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#556">no thumb</div>';
15180
+ var vid = clip.view_url ? '<video muted loop playsinline preload="none" style="display:none;position:absolute;inset:0" src="'+esc(clip.view_url)+'"></video>' : '';
15181
+ var tags = topTags(clip.tags).map(function(t){ return '<span class="tag">'+esc(t)+'</span>'; }).join('');
15182
+ return '<div class="card">'
15183
+ + '<div class="thumb" onmouseleave="var v=this.querySelector(&quot;video&quot;); if(v){v.pause();v.style.display=&quot;none&quot;;}">'+media+vid+'<div class="range">'+range+' · '+clip.duration_sec.toFixed(1)+'s</div>'+score+'</div>'
15184
+ + '<div class="body">'
15185
+ + '<div class="desc">'+esc(clip.description||clip.tags&&clip.tags.transcript||'(untagged clip)')+'</div>'
15186
+ + '<div class="tags">'+tags+'</div>'
15187
+ + '<div class="row"><span class="src" title="'+esc(clip.source_filename)+'">'+esc(clip.source_filename)+'</span>'
15188
+ + '<a class="dl" href="/clips/'+encodeURIComponent(clip.clip_id)+'/download">Download</a></div>'
15189
+ + '</div></div>';
15190
+ }
15191
+
15192
+ function render(clips, label){
15193
+ if(!clips || clips.length===0){
15194
+ grid.innerHTML='';
15195
+ statusEl.innerHTML = label || '';
15196
+ grid.innerHTML = '<div class="empty">No clips yet. Build your library from the CLI:<br><br><code>vidfarm clips scan your-video.mp4</code><br><br>or POST a source to <code>/clips/scan</code>.</div>';
15197
+ return;
15198
+ }
15199
+ statusEl.textContent = (label||'') + clips.length + ' clip' + (clips.length===1?'':'s');
15200
+ grid.innerHTML = clips.map(card).join('');
15201
+ }
15202
+
15203
+ function handleAuth(res){
15204
+ if(res.status===401||res.status===403){
15205
+ grid.innerHTML='';
15206
+ statusEl.innerHTML='Sign in to Vidfarm to see your clips. Then build a library with <code style="background:#1b2130;padding:2px 6px;border-radius:6px">vidfarm clips scan</code>.';
15207
+ throw new Error('unauthorized');
15208
+ }
15209
+ return res.json();
15210
+ }
15211
+
15212
+ function loadFeed(){
15213
+ statusEl.textContent='Loading your clips…';
15214
+ fetch('/clips/feed?limit=120', {credentials:'same-origin', headers:{Accept:'application/json'}})
15215
+ .then(handleAuth).then(function(d){ render(d.clips, ''); }).catch(function(){});
15216
+ }
15217
+
15218
+ function search(query, criteria){
15219
+ statusEl.textContent='Searching…';
15220
+ fetch('/clips/search', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json', Accept:'application/json'}, body: JSON.stringify(criteria?{criteria:criteria, limit:60}:{query:query, limit:60})})
15221
+ .then(handleAuth).then(function(d){
15222
+ var label = (d.semantic?'semantic ':'') + 'results for "' + (query||'preset') + '" — ';
15223
+ render(d.results, label);
15224
+ }).catch(function(){});
15225
+ }
15226
+
15227
+ function loadPresets(){
15228
+ fetch('/clips/presets', {credentials:'same-origin', headers:{Accept:'application/json'}})
15229
+ .then(handleAuth).then(function(d){
15230
+ presetsEl.innerHTML = (d.presets||[]).map(function(p){
15231
+ return '<span class="chip" data-id="'+esc(p.preset_id)+'">'+esc(p.name)+'</span>';
15232
+ }).join('');
15233
+ var byId = {}; (d.presets||[]).forEach(function(p){ byId[p.preset_id]=p; });
15234
+ Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(chip){
15235
+ chip.addEventListener('click', function(){
15236
+ Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(c){ c.classList.remove('active'); });
15237
+ chip.classList.add('active');
15238
+ var p = byId[chip.getAttribute('data-id')];
15239
+ if(p){ input.value=''; search(p.name, p.criteria); }
15240
+ });
15241
+ });
15242
+ }).catch(function(){});
15243
+ }
15244
+
15245
+ form.addEventListener('submit', function(e){
15246
+ e.preventDefault();
15247
+ Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(c){ c.classList.remove('active'); });
15248
+ var q = input.value.trim();
15249
+ if(q) search(q, null); else loadFeed();
15250
+ });
15251
+
15252
+ loadPresets();
15253
+ loadFeed();
15254
+ })();
15255
+ </script>
15256
+ </body>
15257
+ </html>`;
15258
+ }
15259
+ async function resolveCustomerProviderKey(customerId, provider) {
15260
+ const rows = (await listProviderKeysWithSecretsForCustomer(customerId));
15261
+ for (const row of rows) {
15262
+ if (row.provider === provider && (row.status ?? "active") === "active") {
15263
+ const secret = readStoredSecret(String(row.secret ?? "")).trim();
15264
+ if (secret)
15265
+ return secret;
15266
+ }
15267
+ }
15268
+ return null;
15269
+ }
15270
+ // Normalize a request-supplied provider to the supported set (default gemini).
15271
+ function normalizeClipProvider(value) {
15272
+ return value === "openai" || value === "openrouter" ? value : "gemini";
15273
+ }
15274
+ /**
15275
+ * A client for embedding MATCH queries in the SAME vector space the library was
15276
+ * built with (derived from the newest clip's embedding_model), so scene-annotation
15277
+ * queries are cosine-comparable to the stored clips. Returns null when embeddings
15278
+ * aren't available (no key) — matching then falls back to the structured filter.
15279
+ */
15280
+ async function buildMatchEmbedder(customerId) {
15281
+ const libModel = await clipRecords.getLibraryEmbeddingModel(customerId);
15282
+ const provider = libModel && libModel.startsWith("text-embedding") ? "openai" : "gemini";
15283
+ const key = await resolveCustomerProviderKey(customerId, provider);
15284
+ if (!key)
15285
+ return null;
15286
+ const client = new ClipModelClient({ provider, apiKey: key });
15287
+ return client.canEmbed ? client : null;
15288
+ }
15289
+ // Build a ClipModelClient for a customer + provider, resolving the tagging key
15290
+ // and (for OpenRouter, which can't embed) an embedding key from their saved keys.
15291
+ async function buildCustomerClipClient(customerId, provider) {
15292
+ const apiKey = await resolveCustomerProviderKey(customerId, provider);
15293
+ if (!apiKey) {
15294
+ return { error: `Add a ${provider} provider key first (BYOK) — POST /api/v1/user/me/provider-keys.` };
15295
+ }
15296
+ let embedding = null;
15297
+ let embeddingKey = null;
15298
+ if (provider === "gemini" || provider === "openai") {
15299
+ embedding = { provider, apiKey };
15300
+ embeddingKey = apiKey;
15301
+ }
15302
+ else {
15303
+ // OpenRouter: pair with a gemini/openai key for embeddings if the user has one.
15304
+ const g = await resolveCustomerProviderKey(customerId, "gemini");
15305
+ const o = g ? null : await resolveCustomerProviderKey(customerId, "openai");
15306
+ if (g) {
15307
+ embedding = { provider: "gemini", apiKey: g };
15308
+ embeddingKey = g;
15309
+ }
15310
+ else if (o) {
15311
+ embedding = { provider: "openai", apiKey: o };
15312
+ embeddingKey = o;
15313
+ }
15314
+ }
15315
+ return {
15316
+ client: new ClipModelClient({ provider, apiKey, embedding, publicBaseUrl: config.PUBLIC_BASE_URL }),
15317
+ apiKey,
15318
+ embeddingKey
15319
+ };
15320
+ }
15321
+ function parseClipStorageKey(pathOrUri) {
15322
+ const m = /^s3:\/\/[^/]+\/(.+)$/.exec(pathOrUri || "");
15323
+ return m ? m[1] : null;
15324
+ }
15325
+ async function clipMediaUrl(pathOrUri) {
15326
+ if (!pathOrUri)
15327
+ return null;
15328
+ const key = parseClipStorageKey(pathOrUri);
15329
+ if (key)
15330
+ return (await storage.getReadUrl(key)) ?? storage.getPublicUrl(key);
15331
+ return pathOrUri; // non-s3 value (e.g. a local devcli-origin path) — pass through
15332
+ }
15333
+ async function serializeClip(clip) {
15334
+ const [view_url, thumbnail_url] = await Promise.all([
15335
+ clipMediaUrl(clip.file_path),
15336
+ clipMediaUrl(clip.thumbnail_path)
15337
+ ]);
15338
+ const { embedding, ...rest } = clip;
15339
+ void embedding; // never ship the raw vector to the client
15340
+ return { ...rest, view_url, thumbnail_url };
15341
+ }
15342
+ function normalizeScanTier(value) {
15343
+ return value === "flash" ? "flash" : "flash-lite";
15344
+ }
15345
+ function clipKeywordMatch(clip, q) {
15346
+ const hay = `${clip.description} ${clip.tags.transcript} ${clip.source_filename} ${Object.values(clip.tags).flat().join(" ")}`.toLowerCase();
15347
+ return q
15348
+ .toLowerCase()
15349
+ .split(/\s+/)
15350
+ .filter(Boolean)
15351
+ .every((term) => hay.includes(term));
15352
+ }
15353
+ // GET /clips — the first-class Clips gallery page (public HTML; the client
15354
+ // fetches /clips/feed with the session cookie). Mirrors /discover vs /discover/feed.
15355
+ app.get(CLIPS_PREFIX, (c) => c.html(renderClipsPage()));
15356
+ // GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q, ?limit).
15357
+ app.get(`${CLIPS_PREFIX}/feed`, async (c) => {
15358
+ const customer = requireCustomer(c);
15359
+ const limit = clampPageLimit(Number(c.req.query("limit") || "60"), 200);
15360
+ const q = c.req.query("q")?.trim();
15361
+ let clips = await clipRecords.listClips(customer.id, {
15362
+ sourceVideoId: c.req.query("source") || undefined,
15363
+ limit: q ? 5000 : limit
15364
+ });
15365
+ if (q)
15366
+ clips = clips.filter((clip) => clipKeywordMatch(clip, q)).slice(0, limit);
15367
+ return c.json({ clips: await Promise.all(clips.map(serializeClip)) });
15368
+ });
15369
+ // POST /clips/search — hybrid structured + semantic search.
15370
+ app.post(`${CLIPS_PREFIX}/search`, async (c) => {
15371
+ const customer = requireCustomer(c);
15372
+ try {
15373
+ const body = (await c.req.json().catch(() => ({})));
15374
+ const limit = Math.max(1, Math.min(200, Number(body.limit) || 20));
15375
+ const provider = normalizeClipProvider(body.provider);
15376
+ let criteria = body.criteria ?? (body.query ? { semantic_text: body.query } : {});
15377
+ let queryEmbedding;
15378
+ if (body.query || criteria.semantic_text) {
15379
+ const built = await buildCustomerClipClient(customer.id, provider);
15380
+ if ("client" in built) {
15381
+ const client = built.client;
15382
+ if (body.query && !body.criteria)
15383
+ criteria = await client.naturalLanguageToCriteria(body.query);
15384
+ if (client.canEmbed)
15385
+ queryEmbedding = await client.embedQuery(criteria.semantic_text ?? body.query ?? "");
15386
+ }
15387
+ }
15388
+ const hits = await searchUserClips(customer.id, { criteria, queryEmbedding, limit });
15389
+ const results = await Promise.all(hits.map(async (h) => ({ ...(await serializeClip(h.clip)), score: h.score, similarity: h.similarity })));
15390
+ return c.json({ results, criteria, semantic: Boolean(queryEmbedding) });
15391
+ }
15392
+ catch (error) {
15393
+ return c.json({ error: error instanceof Error ? error.message : "Clip search failed." }, 400);
15394
+ }
15395
+ });
15396
+ function normalizeMatchScene(s, index) {
15397
+ const criteria = s.match_criteria ?? s.criteria ?? (s.embedding_text || s.text ? { semantic_text: s.embedding_text || s.text } : {});
15398
+ const text = s.embedding_text || s.text || criteria.semantic_text || "";
15399
+ return {
15400
+ id: s.scene_slug || s.id || `scene_${index}`,
15401
+ criteria,
15402
+ text,
15403
+ limit: s.limit,
15404
+ meta: {
15405
+ scene_slug: s.scene_slug || s.id,
15406
+ start: s.start,
15407
+ duration: s.duration,
15408
+ replaceability: s.replaceability,
15409
+ scene_role: s.scene_role
15410
+ }
15411
+ };
15412
+ }
15413
+ // POST /clips/match — find the top library clips that could REPLACE one scene.
15414
+ // Body: a scene annotation, or {criteria, embedding_text|text, limit}.
15415
+ app.post(`${CLIPS_PREFIX}/match`, async (c) => {
15416
+ const customer = requireCustomer(c);
15417
+ try {
15418
+ const body = (await c.req.json().catch(() => ({})));
15419
+ const scene = normalizeMatchScene(body, 0);
15420
+ const limit = Math.max(1, Math.min(50, Number(body.limit) || scene.limit || 8));
15421
+ let queryEmbedding;
15422
+ if (scene.text) {
15423
+ const embedder = await buildMatchEmbedder(customer.id);
15424
+ if (embedder)
15425
+ [queryEmbedding] = await embedder.embedQueries([scene.text]);
15426
+ }
15427
+ const hits = await searchUserClips(customer.id, { criteria: scene.criteria, queryEmbedding, limit });
15428
+ const matches = await Promise.all(hits.map(async (h) => ({ ...(await serializeClip(h.clip)), score: h.score, similarity: h.similarity })));
15429
+ return c.json({ matches, criteria: scene.criteria, semantic: Boolean(queryEmbedding) });
15430
+ }
15431
+ catch (error) {
15432
+ return c.json({ error: error instanceof Error ? error.message : "Clip match failed." }, 400);
15433
+ }
15434
+ });
15435
+ // POST /clips/match-scenes — batch: take a decomposed video's scene annotations
15436
+ // and return the top replacement-clip matches per scene (one library read).
15437
+ // Body: { scenes | annotations: SceneAnnotation[], limit? }.
15438
+ app.post(`${CLIPS_PREFIX}/match-scenes`, async (c) => {
15439
+ const customer = requireCustomer(c);
15440
+ try {
15441
+ const body = (await c.req.json());
15442
+ const raw = body.scenes ?? body.annotations ?? [];
15443
+ if (!Array.isArray(raw) || raw.length === 0) {
15444
+ return c.json({ error: "Provide scenes[] (or annotations[]) of scene annotations to match." }, 400);
15445
+ }
15446
+ if (raw.length > 200)
15447
+ return c.json({ error: "Too many scenes (max 200 per request)." }, 400);
15448
+ const defaultLimit = Math.max(1, Math.min(50, Number(body.limit) || 5));
15449
+ const scenes = raw.map(normalizeMatchScene);
15450
+ // One batch embedding call for every scene's query text (in the library's space).
15451
+ const embedder = await buildMatchEmbedder(customer.id);
15452
+ let embeddings = [];
15453
+ if (embedder)
15454
+ embeddings = await embedder.embedQueries(scenes.map((s) => s.text || " "));
15455
+ const queries = scenes.map((s, i) => ({
15456
+ id: s.id,
15457
+ criteria: s.criteria,
15458
+ queryEmbedding: embedder ? embeddings[i] : undefined,
15459
+ limit: s.limit ?? defaultLimit,
15460
+ meta: s.meta
15461
+ }));
15462
+ const results = await matchScenesToClips(customer.id, queries);
15463
+ const serialized = await Promise.all(results.map(async (r) => ({
15464
+ scene_slug: r.id,
15465
+ ...(r.meta ?? {}),
15466
+ matches: await Promise.all(r.matches.map(async (h) => ({ ...(await serializeClip(h.clip)), score: h.score, similarity: h.similarity })))
15467
+ })));
15468
+ return c.json({ results: serialized, semantic: Boolean(embedder) });
15469
+ }
15470
+ catch (error) {
15471
+ return c.json({ error: error instanceof Error ? error.message : "Clip match-scenes failed." }, 400);
15472
+ }
15473
+ });
15474
+ // POST /clips/scan — kick off a cloud scan of an uploaded/staged source video.
15475
+ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
15476
+ const customer = requireCustomer(c);
15477
+ try {
15478
+ const body = (await c.req.json());
15479
+ const rawS3Uri = body.raw_s3_uri ??
15480
+ (body.s3_key && config.AWS_S3_BUCKET ? `s3://${config.AWS_S3_BUCKET}/${body.s3_key}` : undefined);
15481
+ if (!rawS3Uri) {
15482
+ return c.json({ error: "Provide raw_s3_uri (s3://…) or s3_key of the uploaded source video." }, 400);
15483
+ }
15484
+ const provider = normalizeClipProvider(body.provider);
15485
+ const built = await buildCustomerClipClient(customer.id, provider);
15486
+ if ("error" in built)
15487
+ return c.json({ error: built.error }, 400);
15488
+ const { client, apiKey: tagApiKey, embeddingKey } = built;
15489
+ const tier = normalizeScanTier(body.tier);
15490
+ const framesPerScene = Math.max(1, Math.min(3, Number(body.frames_per_scene) || 2));
15491
+ const includeAudio = body.include_audio !== false && provider === "gemini";
15492
+ const guidancePrompt = body.prompt?.trim() || "";
15493
+ const now = nowIso();
15494
+ const scanId = createIdV7("clipscan");
15495
+ const sourceVideoId = createIdV7("srcvid");
15496
+ const filename = body.filename || rawS3Uri.split("/").pop() || "source.mp4";
15497
+ const estimate = body.duration_sec && body.duration_sec > 0
15498
+ ? estimateScanCostFromDuration(body.duration_sec, {
15499
+ tier,
15500
+ provider,
15501
+ tagModelId: client.tagModelId,
15502
+ embedModelId: client.embeddingModelId,
15503
+ framesPerScene,
15504
+ includeAudio
15505
+ })
15506
+ : null;
15507
+ const source = {
15508
+ source_video_id: sourceVideoId,
15509
+ owner_id: customer.id,
15510
+ filename,
15511
+ raw_s3_uri: rawS3Uri,
15512
+ duration_sec: body.duration_sec,
15513
+ clip_count: 0,
15514
+ tier,
15515
+ status: "pending",
15516
+ scan_id: scanId,
15517
+ created_at: now,
15518
+ updated_at: now
15519
+ };
15520
+ await clipRecords.putSource(source);
15521
+ let executionArn;
15522
+ if (config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN) {
15523
+ const res = await clipScanSfn.send(new StartExecutionCommand({
15524
+ stateMachineArn: config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN,
15525
+ name: scanId.replace(/[^A-Za-z0-9-_]/g, "-").slice(0, 80),
15526
+ input: JSON.stringify({
15527
+ scan_id: scanId,
15528
+ owner_id: customer.id,
15529
+ source_video_id: sourceVideoId,
15530
+ source_filename: filename,
15531
+ raw_s3_uri: rawS3Uri,
15532
+ tier,
15533
+ provider,
15534
+ tag_api_key: tagApiKey,
15535
+ embedding_provider: client.embeddingProvider ?? "",
15536
+ embedding_api_key: embeddingKey ?? "",
15537
+ guidance_prompt: guidancePrompt,
15538
+ frames_per_scene: framesPerScene,
15539
+ include_audio: includeAudio
15540
+ })
15541
+ }));
15542
+ executionArn = res.executionArn;
15543
+ }
15544
+ const scan = {
15545
+ scan_id: scanId,
15546
+ owner_id: customer.id,
15547
+ source_video_id: sourceVideoId,
15548
+ status: executionArn ? "running" : "queued",
15549
+ tier,
15550
+ raw_s3_uri: rawS3Uri,
15551
+ execution_arn: executionArn,
15552
+ created_at: now,
15553
+ updated_at: now
15554
+ };
15555
+ await clipRecords.putScan(scan);
15556
+ return c.json({
15557
+ scan_id: scanId,
15558
+ source_video_id: sourceVideoId,
15559
+ status: scan.status,
15560
+ execution_arn: executionArn ?? null,
15561
+ estimate,
15562
+ pipeline_deployed: Boolean(config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN)
15563
+ }, 202);
15564
+ }
15565
+ catch (error) {
15566
+ return c.json({ error: error instanceof Error ? error.message : "Failed to start clip scan." }, 400);
15567
+ }
15568
+ });
15569
+ // GET /clips/scan/:scanId — poll scan status.
15570
+ app.get(`${CLIPS_PREFIX}/scan/:scanId`, async (c) => {
15571
+ const customer = requireCustomer(c);
15572
+ const scan = await clipRecords.getScan(customer.id, c.req.param("scanId"));
15573
+ if (!scan)
15574
+ return c.json({ error: "Scan not found" }, 404);
15575
+ const source = await clipRecords.getSource(customer.id, scan.source_video_id);
15576
+ return c.json({ scan, source });
15577
+ });
15578
+ // GET /clips/sources — scanned source videos.
15579
+ app.get(`${CLIPS_PREFIX}/sources`, async (c) => {
15580
+ const customer = requireCustomer(c);
15581
+ return c.json({ sources: await clipRecords.listSources(customer.id) });
15582
+ });
15583
+ // GET /clips/presets — built-in + saved presets.
15584
+ app.get(`${CLIPS_PREFIX}/presets`, async (c) => {
15585
+ const customer = requireCustomer(c);
15586
+ return c.json({ presets: await clipRecords.listPresets(customer.id) });
15587
+ });
15588
+ // POST /clips/presets — save a custom preset from criteria or a NL query.
15589
+ app.post(`${CLIPS_PREFIX}/presets`, async (c) => {
15590
+ const customer = requireCustomer(c);
15591
+ try {
15592
+ const body = (await c.req.json());
15593
+ if (!body.name)
15594
+ return c.json({ error: "name is required." }, 400);
15595
+ let criteria = body.criteria;
15596
+ if (!criteria && body.query) {
15597
+ const built = await buildCustomerClipClient(customer.id, normalizeClipProvider(body.provider));
15598
+ if ("error" in built)
15599
+ return c.json({ error: built.error }, 400);
15600
+ criteria = await built.client.naturalLanguageToCriteria(body.query);
15601
+ }
15602
+ if (!criteria)
15603
+ return c.json({ error: "Provide criteria or query." }, 400);
15604
+ const preset = makeUserPreset({ name: body.name, criteria, description: body.description, owner: customer.id });
15605
+ await clipRecords.putPreset(customer.id, preset);
15606
+ return c.json({ preset }, 201);
15607
+ }
15608
+ catch (error) {
15609
+ return c.json({ error: error instanceof Error ? error.message : "Failed to save preset." }, 400);
15610
+ }
15611
+ });
15612
+ // DELETE /clips/presets/:presetId — delete a saved (non-builtin) preset.
15613
+ app.delete(`${CLIPS_PREFIX}/presets/:presetId`, async (c) => {
15614
+ const customer = requireCustomer(c);
15615
+ const id = c.req.param("presetId");
15616
+ if (id.startsWith("preset_") && BUILTIN_CLIP_PRESET_IDS.has(id)) {
15617
+ return c.json({ error: "Built-in presets cannot be deleted." }, 400);
15618
+ }
15619
+ await clipRecords.deletePreset(customer.id, id);
15620
+ return c.json({ ok: true });
15621
+ });
15622
+ // GET /clips/:clipId/download — presigned redirect to the clip mp4.
15623
+ app.get(`${CLIPS_PREFIX}/:clipId/download`, async (c) => {
15624
+ const customer = requireCustomer(c);
15625
+ const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
15626
+ if (!clip)
15627
+ return c.json({ error: "Clip not found" }, 404);
15628
+ const url = await clipMediaUrl(clip.file_path);
15629
+ if (!url)
15630
+ return c.json({ error: "Clip file unavailable" }, 404);
15631
+ return redirect(c, url, 302);
15632
+ });
15633
+ // GET /clips/:clipId — one clip (+ presigned view/thumbnail urls).
15634
+ app.get(`${CLIPS_PREFIX}/:clipId`, async (c) => {
15635
+ const customer = requireCustomer(c);
15636
+ const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
15637
+ if (!clip)
15638
+ return c.json({ error: "Clip not found" }, 404);
15639
+ return c.json({ clip: await serializeClip(clip) });
15640
+ });
15641
+ // DELETE /clips/:clipId — remove a clip record (+ vector).
15642
+ app.delete(`${CLIPS_PREFIX}/:clipId`, async (c) => {
15643
+ const customer = requireCustomer(c);
15644
+ await clipRecords.deleteClip(customer.id, c.req.param("clipId"));
15645
+ return c.json({ ok: true });
15646
+ });
14645
15647
  app.get(`${TEMPLATES_PREFIX}/sources`, async (c) => {
14646
15648
  try {
14647
15649
  requireAdmin(c);