@mevdragon/vidfarm-devcli 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/app.js CHANGED
@@ -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";
@@ -5899,6 +5905,42 @@ async function recordCastLambdaUsage(input) {
5899
5905
  console.error("cast billing failed", error instanceof Error ? error.message : error);
5900
5906
  }
5901
5907
  }
5908
+ // Charges the caller's wallet for the lambda compute of one scene-annotation
5909
+ // poll (the video download + frame sample + annotation vision call). Idempotent
5910
+ // per fork+poll-start so a client re-poll of the same tick never double-charges.
5911
+ // Never throws. Mirrors recordCastLambdaUsage.
5912
+ async function recordSceneAnnotationsLambdaUsage(input) {
5913
+ const durationMs = Date.now() - input.startedAtMs;
5914
+ const memoryMb = Math.max(128, Number.parseInt(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE || "2048", 10) || 2048);
5915
+ const gbSeconds = (memoryMb / 1024) * (durationMs / 1000);
5916
+ const LAMBDA_X86_GB_SECOND_USD = 0.0000166667;
5917
+ const LAMBDA_REQUEST_USD = 0.20 / 1_000_000;
5918
+ const lambdaCostUsd = Number(((gbSeconds * LAMBDA_X86_GB_SECOND_USD) + LAMBDA_REQUEST_USD).toFixed(6));
5919
+ if (lambdaCostUsd <= 0)
5920
+ return;
5921
+ try {
5922
+ await billing.record({
5923
+ customerId: input.customerId,
5924
+ jobId: `scene-annotations:${input.forkId}:${input.startedAtMs}`,
5925
+ tracer: null,
5926
+ templateId: "auto-decompose",
5927
+ type: "cpu_estimate",
5928
+ costUsd: lambdaCostUsd,
5929
+ costCenterSlug: "auto_decompose_lambda",
5930
+ idempotencyKey: `scene_annotations_lambda:${input.forkId}:${input.startedAtMs}`,
5931
+ occurredAtMs: Date.now(),
5932
+ metadata: {
5933
+ fork_id: input.forkId,
5934
+ duration_ms: durationMs,
5935
+ memory_mb: memoryMb,
5936
+ gb_seconds: Number(gbSeconds.toFixed(6))
5937
+ }
5938
+ });
5939
+ }
5940
+ catch (error) {
5941
+ console.error("scene-annotations billing failed", error instanceof Error ? error.message : error);
5942
+ }
5943
+ }
5902
5944
  async function snapshotForkVersion(input) {
5903
5945
  const version = (input.fork.latestVersion ?? 0) + 1;
5904
5946
  // Bound namespace growth for user-initiated snapshots. Internal reasons
@@ -5915,6 +5957,9 @@ async function snapshotForkVersion(input) {
5915
5957
  // Placement surfaces travel with every version too (reusable decompose
5916
5958
  // metadata, same as cast identification).
5917
5959
  const workingPlacement = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "product-placement.json"));
5960
+ // Scene annotations (clip-replacement + recreation DNA) likewise travel with
5961
+ // every version — reusable decompose metadata, same as placement surfaces.
5962
+ const workingAnnotations = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "scene-annotations.json"));
5918
5963
  if (workingHtml) {
5919
5964
  await storage.putText(storage.compositionForkVersionKey(input.fork.id, version, "composition.html"), workingHtml, "text/html; charset=utf-8");
5920
5965
  }
@@ -5927,6 +5972,9 @@ async function snapshotForkVersion(input) {
5927
5972
  if (workingPlacement) {
5928
5973
  await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "product-placement.json"), Buffer.from(workingPlacement, "utf8"), "application/json");
5929
5974
  }
5975
+ if (workingAnnotations) {
5976
+ await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "scene-annotations.json"), Buffer.from(workingAnnotations, "utf8"), "application/json");
5977
+ }
5930
5978
  const versionRecord = await serverlessRecords.createForkVersion({
5931
5979
  forkId: input.fork.id,
5932
5980
  ownerCustomerId: input.fork.customerId,
@@ -6017,6 +6065,7 @@ app.post(COMPOSITIONS_PREFIX, async (c) => {
6017
6065
  // identified people + reference stills as the shared default composition.
6018
6066
  const cast = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "cast.json"));
6019
6067
  const placement = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "product-placement.json"));
6068
+ const annotations = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "scene-annotations.json"));
6020
6069
  if (html) {
6021
6070
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), html, "text/html; charset=utf-8");
6022
6071
  }
@@ -6029,6 +6078,9 @@ app.post(COMPOSITIONS_PREFIX, async (c) => {
6029
6078
  if (placement) {
6030
6079
  await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
6031
6080
  }
6081
+ if (annotations) {
6082
+ await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
6083
+ }
6032
6084
  }
6033
6085
  else if (template.videoUrl) {
6034
6086
  // Template exists but has no canonical fork yet — seed with raw HTML.
@@ -6393,6 +6445,20 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
6393
6445
  catch (placementPersistError) {
6394
6446
  console.error("inspiration product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
6395
6447
  }
6448
+ // Seed the scene-annotation pass "pending" on the canonical fork too,
6449
+ // before the snapshot, so the pending state travels with v1 and every
6450
+ // downstream fork can drive /scene-annotations-poll (or inherit the
6451
+ // completed annotations once the canonical fork's poll finishes).
6452
+ try {
6453
+ await storage.putJson(storage.compositionForkWorkingKey(canonicalFork.id, "scene-annotations.json"), buildPendingSceneAnnotationsState({
6454
+ compositionId: canonicalFork.id,
6455
+ sourceUrl,
6456
+ title
6457
+ }));
6458
+ }
6459
+ catch (annotationSeedError) {
6460
+ console.error("inspiration scene-annotations seed failed", annotationSeedError instanceof Error ? annotationSeedError.message : annotationSeedError);
6461
+ }
6396
6462
  await writeForkManifest(storage, canonicalFork, { kind: "working" });
6397
6463
  await snapshotForkVersion({
6398
6464
  fork: canonicalFork,
@@ -6775,6 +6841,21 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6775
6841
  catch (placementPersistError) {
6776
6842
  console.error("product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
6777
6843
  }
6844
+ // Seed the per-scene clip-annotation pass as "pending" and let the client
6845
+ // drive it via POST /scene-annotations-poll (same async model as cast /
6846
+ // ghostcut). The annotation is its own vision call — running it inline would
6847
+ // risk the ~60s origin timeout on longer videos — and it reads the finalized
6848
+ // scenes back from smart-decompose.json at poll time. Non-fatal.
6849
+ try {
6850
+ await storage.putJson(storage.compositionForkWorkingKey(fork.id, "scene-annotations.json"), buildPendingSceneAnnotationsState({
6851
+ compositionId,
6852
+ sourceUrl: analysisSourceUrl,
6853
+ title: typeof title === "string" ? title : fork.id
6854
+ }));
6855
+ }
6856
+ catch (annotationSeedError) {
6857
+ console.error("scene-annotations seed failed", annotationSeedError instanceof Error ? annotationSeedError.message : annotationSeedError);
6858
+ }
6778
6859
  // Seed the cast-identification 2nd pass as "pending" and let the client
6779
6860
  // drive it via POST /cast-poll (same async model as ghostcut). We identify
6780
6861
  // cast from the same caption-free analysis source the vision pass used, so
@@ -7325,6 +7406,204 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/product-placement.json`, async (c) => {
7325
7406
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
7326
7407
  }
7327
7408
  });
7409
+ // Read-only scene-annotation context for a fork: the detected content FORMAT
7410
+ // plus one annotation per scene carrying the literal + viral DNA needed to
7411
+ // (a) match a REPLACEMENT clip from the library (each annotation ships a
7412
+ // ready-to-run match_criteria + embedding_text) and (b) let an AI agent
7413
+ // RE-CREATE the beat from scratch (recreation_prompt + recreation_notes).
7414
+ // Reusable decompose metadata — the editor-chat harness and desktop agents read
7415
+ // it to plan clip swaps or regeneration. Optional-auth like /product-placement.json.
7416
+ // Returns { status: "none" } when the fork was never decomposed (or predates
7417
+ // this pass).
7418
+ app.get(`${COMPOSITIONS_PREFIX}/:forkId/scene-annotations.json`, async (c) => {
7419
+ const customer = await getBrowserCustomer(c);
7420
+ try {
7421
+ const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer?.id ?? null, shareToken: readShareToken(c) }, "view");
7422
+ const text = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "scene-annotations.json"));
7423
+ if (!text) {
7424
+ return c.json({
7425
+ ok: true,
7426
+ status: "none",
7427
+ hint: "This fork has no scene-annotation pass yet. Run POST /api/v1/compositions/:forkId/auto-decompose to generate scene annotations."
7428
+ });
7429
+ }
7430
+ let state;
7431
+ try {
7432
+ state = JSON.parse(text);
7433
+ }
7434
+ catch {
7435
+ return c.json({ ok: true, status: "none" });
7436
+ }
7437
+ // Surface the pass's own state-machine status (pending/processing/done/
7438
+ // failed) so pollers and agents can tell "not done yet" from "no annotations
7439
+ // found". Annotations/contentFormat are only meaningful once status=="done".
7440
+ return c.json({
7441
+ ok: true,
7442
+ status: state.status,
7443
+ reason: state.failureReason ?? null,
7444
+ provider: state.provider ?? null,
7445
+ model: state.model ?? null,
7446
+ source_url: state.sourceUrl ?? null,
7447
+ duration_seconds: state.durationSeconds ?? null,
7448
+ content_format: state.status === "done" ? state.contentFormat : null,
7449
+ annotation_count: Array.isArray(state.annotations) ? state.annotations.length : 0,
7450
+ annotations: state.status === "done" && Array.isArray(state.annotations) ? state.annotations : []
7451
+ });
7452
+ }
7453
+ catch (error) {
7454
+ if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
7455
+ return forkAccessErrorResponse(c, error);
7456
+ return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
7457
+ }
7458
+ });
7459
+ // Drives the async scene-annotation pass to completion — one vision call over
7460
+ // the finalized scenes (read back from smart-decompose.json) that classifies
7461
+ // the content format and annotates every scene with its clip-match + recreation
7462
+ // DNA. Mirrors /cast-poll but single-stage: pending -> processing -> done|failed.
7463
+ // Terminal states short-circuit (idempotent). On success it also backfills the
7464
+ // detected content format onto the template + inspiration metadata.
7465
+ app.post(`${COMPOSITIONS_PREFIX}/:forkId/scene-annotations-poll`, async (c) => {
7466
+ const caller = await getBrowserCustomer(c);
7467
+ if (!caller)
7468
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
7469
+ const startedAtMs = Date.now();
7470
+ try {
7471
+ const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: caller.id, shareToken: readShareToken(c) }, "edit");
7472
+ const fork = access.fork;
7473
+ const stateKey = storage.compositionForkWorkingKey(fork.id, "scene-annotations.json");
7474
+ const stateText = await storage.readText(stateKey);
7475
+ if (!stateText)
7476
+ return c.json({ ok: true, status: "none" });
7477
+ let state;
7478
+ try {
7479
+ state = JSON.parse(stateText);
7480
+ }
7481
+ catch {
7482
+ return c.json({ ok: true, status: "none" });
7483
+ }
7484
+ // Terminal states — no re-work.
7485
+ if (state.status === "done" || state.status === "failed") {
7486
+ return c.json({
7487
+ ok: true,
7488
+ status: state.status,
7489
+ reason: state.failureReason ?? null,
7490
+ content_format: state.status === "done" ? state.contentFormat : null,
7491
+ annotation_count: Array.isArray(state.annotations) ? state.annotations.length : 0
7492
+ });
7493
+ }
7494
+ // Another poll is already running this fork's (single) vision call — don't
7495
+ // pay for a duplicate. Re-enter only if the in-flight attempt looks stalled.
7496
+ const STALE_PROCESSING_MS = 3 * 60 * 1000;
7497
+ if (state.status === "processing" && Date.now() - (state.savedAtMs ?? 0) < STALE_PROCESSING_MS) {
7498
+ return c.json({ ok: true, status: "pending", note: "in_progress" });
7499
+ }
7500
+ const keys = await loadCustomerVisionKeys(caller.id);
7501
+ if (!(keys.gemini || keys.openai || keys.openrouter)) {
7502
+ const next = { ...state, status: "failed", failureReason: "no_provider_key", savedAtMs: Date.now() };
7503
+ await storage.putJson(stateKey, next);
7504
+ return c.json({ ok: true, status: "failed", reason: "no_provider_key" });
7505
+ }
7506
+ // Read the finalized scenes + viral DNA + summary + transcript from the
7507
+ // decompose snapshot (same source /cast-poll grounds on). Without it there
7508
+ // is nothing to annotate.
7509
+ const smartText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "smart-decompose.json"));
7510
+ if (!smartText) {
7511
+ const next = { ...state, status: "failed", failureReason: "missing_decompose_snapshot", savedAtMs: Date.now() };
7512
+ await storage.putJson(stateKey, next);
7513
+ return c.json({ ok: true, status: "failed", reason: "missing_decompose_snapshot" });
7514
+ }
7515
+ let scenes = [];
7516
+ let viralDna = null;
7517
+ let summary = "";
7518
+ let transcript = null;
7519
+ let userPrompt = null;
7520
+ try {
7521
+ const snapshot = JSON.parse(smartText);
7522
+ scenes = Array.isArray(snapshot.result?.scenes) ? snapshot.result.scenes : [];
7523
+ viralDna = snapshot.result?.viralDna ?? null;
7524
+ summary = typeof snapshot.result?.summary === "string" ? snapshot.result.summary : "";
7525
+ transcript = snapshot.result?.transcript ?? null;
7526
+ userPrompt = typeof snapshot.userPrompt === "string" ? snapshot.userPrompt : null;
7527
+ }
7528
+ catch {
7529
+ const next = { ...state, status: "failed", failureReason: "unreadable_decompose_snapshot", savedAtMs: Date.now() };
7530
+ await storage.putJson(stateKey, next);
7531
+ return c.json({ ok: true, status: "failed", reason: "unreadable_decompose_snapshot" });
7532
+ }
7533
+ if (scenes.length === 0) {
7534
+ // A slideshow/time-slice fork with no real scenes — nothing to annotate.
7535
+ const next = { ...state, status: "done", savedAtMs: Date.now() };
7536
+ await storage.putJson(stateKey, next);
7537
+ return c.json({ ok: true, status: "done", annotation_count: 0 });
7538
+ }
7539
+ // Claim the work so concurrent pollers back off (see the staleness guard).
7540
+ await storage.putJson(stateKey, { ...state, status: "processing", savedAtMs: Date.now() });
7541
+ let outcome;
7542
+ try {
7543
+ outcome = await annotateScenesFromSource({
7544
+ sourceUrl: state.sourceUrl,
7545
+ keys,
7546
+ scenes,
7547
+ viralDna: viralDna ?? { trend_tagline: "", hook: "", retention: "", payoff: "", preserve: [], avoid: [], promotions: [], keywords: [] },
7548
+ summary,
7549
+ transcript,
7550
+ userPrompt
7551
+ });
7552
+ }
7553
+ catch (error) {
7554
+ const reason = error instanceof Error ? error.message : String(error);
7555
+ const next = { ...state, status: "failed", failureReason: reason, savedAtMs: Date.now() };
7556
+ await storage.putJson(stateKey, next);
7557
+ return c.json({ ok: true, status: "failed", reason });
7558
+ }
7559
+ const next = {
7560
+ ...state,
7561
+ status: "done",
7562
+ provider: outcome.provider,
7563
+ model: outcome.model,
7564
+ durationSeconds: outcome.durationSeconds,
7565
+ contentFormat: outcome.contentFormat,
7566
+ annotations: outcome.annotations,
7567
+ savedAtMs: Date.now()
7568
+ };
7569
+ await storage.putJson(stateKey, next);
7570
+ // Backfill the detected content format onto the template + inspiration
7571
+ // metadata now that we know it (the synchronous decompose couldn't). This is
7572
+ // what the catalog + clip matcher filter on. Best-effort.
7573
+ try {
7574
+ const forkTemplate = fork.templateId ? await serverlessRecords.getTemplate(fork.templateId) : null;
7575
+ if (forkTemplate) {
7576
+ await serverlessRecords.updateTemplate({
7577
+ templateId: forkTemplate.id,
7578
+ patch: { contentFormat: outcome.contentFormat.format }
7579
+ });
7580
+ if (forkTemplate.inspirationId) {
7581
+ await serverlessRecords.updateInspiration({
7582
+ inspirationId: forkTemplate.inspirationId,
7583
+ patch: { contentFormat: outcome.contentFormat.format }
7584
+ });
7585
+ }
7586
+ }
7587
+ }
7588
+ catch (backfillError) {
7589
+ console.error("scene-annotations content-format backfill failed", backfillError instanceof Error ? backfillError.message : backfillError);
7590
+ }
7591
+ await recordSceneAnnotationsLambdaUsage({ customerId: caller.id, forkId: fork.id, startedAtMs });
7592
+ return c.json({
7593
+ ok: true,
7594
+ status: "done",
7595
+ provider: outcome.provider,
7596
+ model: outcome.model,
7597
+ content_format: outcome.contentFormat,
7598
+ annotation_count: outcome.annotations.length
7599
+ });
7600
+ }
7601
+ catch (error) {
7602
+ if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
7603
+ return forkAccessErrorResponse(c, error);
7604
+ return c.json({ ok: false, error: error instanceof Error ? error.message : "scene-annotations-poll failed" }, 500);
7605
+ }
7606
+ });
7328
7607
  // Drives the async cast-identification 2nd pass one unit of work per call
7329
7608
  // (analyze -> per-member still -> per-member isolation -> done), mirroring
7330
7609
  // /remove-video-captions-poll. Each invocation makes at most one heavy external call so the
@@ -7970,6 +8249,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
7970
8249
  const json = await readVersionFile("composition.json");
7971
8250
  const cast = await readVersionFile("cast.json");
7972
8251
  const placement = await readVersionFile("product-placement.json");
8252
+ const annotations = await readVersionFile("scene-annotations.json");
7973
8253
  if (html) {
7974
8254
  await storage.putText(storage.compositionForkWorkingKey(cloned.id, "composition.html"), html, "text/html; charset=utf-8");
7975
8255
  }
@@ -7982,6 +8262,9 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
7982
8262
  if (placement) {
7983
8263
  await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
7984
8264
  }
8265
+ if (annotations) {
8266
+ await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
8267
+ }
7985
8268
  await snapshotForkVersion({
7986
8269
  fork: cloned,
7987
8270
  callerCustomerId: customer.id,
@@ -8375,6 +8658,7 @@ app.post(`${VIDEOS_PREFIX}/:inspirationId/decompose`, async (c) => {
8375
8658
  const json = await readSource("composition.json");
8376
8659
  const cast = await readSource("cast.json");
8377
8660
  const placement = await readSource("product-placement.json");
8661
+ const annotations = await readSource("scene-annotations.json");
8378
8662
  if (html)
8379
8663
  await storage.putText(storage.compositionForkWorkingKey(cloned.id, "composition.html"), html, "text/html; charset=utf-8");
8380
8664
  if (json)
@@ -8383,6 +8667,8 @@ app.post(`${VIDEOS_PREFIX}/:inspirationId/decompose`, async (c) => {
8383
8667
  await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
8384
8668
  if (placement)
8385
8669
  await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
8670
+ if (annotations)
8671
+ await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
8386
8672
  await snapshotForkVersion({
8387
8673
  fork: cloned,
8388
8674
  callerCustomerId: customer.id,
@@ -10466,6 +10752,13 @@ app.use(`${API_PREFIX}/*`, async (c, next) => {
10466
10752
  });
10467
10753
  app.use(`${USER_PREFIX}/me`, requireAuth);
10468
10754
  app.use(`${USER_PREFIX}/me/*`, requireAuth);
10755
+ // The /clips API (feed/search/scan/presets) is auth-gated; the bare /clips path
10756
+ // is the public HTML gallery page (like /discover vs /discover/feed). Hono's
10757
+ // `/clips/*` also matches the bare `/clips`, so let that one path through
10758
+ // unauthenticated.
10759
+ const isClipsGalleryPage = (c) => c.req.path === CLIPS_PREFIX || c.req.path === `${CLIPS_PREFIX}/`;
10760
+ app.use(`${CLIPS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : requireAuth(c, next)));
10761
+ app.use(`${CLIPS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : captureApiCallHistory(c, next)));
10469
10762
  app.use(AGENCY_PREFIX, requireAuth);
10470
10763
  app.use(`${AGENCY_PREFIX}/*`, requireAuth);
10471
10764
  app.use(`${API_PREFIX}/rate-limit-status`, requireAuth);
@@ -14642,6 +14935,574 @@ app.post(`${USER_PREFIX}/me/provider-keys`, async (c) => {
14642
14935
  });
14643
14936
  return c.json({ ok: true }, 201);
14644
14937
  });
14938
+ // ── Clip curation (the third library: viral DNA + formats + CLIPS) ───────────
14939
+ // First-class clips API mirroring the devcli `clips` commands. /clips/* is gated
14940
+ // by requireAuth + captureApiCallHistory (middleware above). BYOK Gemini: search
14941
+ // and natural-language→criteria use the caller's saved gemini provider key.
14942
+ const clipScanSfn = new SFNClient({});
14943
+ const BUILTIN_CLIP_PRESET_IDS = new Set(BUILTIN_PRESETS.map((p) => p.preset_id));
14944
+ // First-class Clips gallery page (served at /clips). Self-contained HTML; the
14945
+ // client fetches /clips/feed, /clips/presets and /clips/search with the session
14946
+ // cookie. Standalone browse/search — no video generation required.
14947
+ function renderClipsPage() {
14948
+ return `<!doctype html>
14949
+ <html lang="en">
14950
+ <head>
14951
+ <meta charset="utf-8" />
14952
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
14953
+ <title>Clips — Vidfarm</title>
14954
+ <style>
14955
+ :root { --bg:#0b0d12; --panel:#141821; --panel2:#1b2130; --line:#242c3a; --text:#e7ecf5; --dim:#8b96ab; --accent:#5b8cff; --accent2:#3ad6a0; }
14956
+ * { box-sizing: border-box; }
14957
+ body { margin:0; background:var(--bg); color:var(--text); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
14958
+ a { color:inherit; text-decoration:none; }
14959
+ 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; }
14960
+ header.top .brand { font-weight:700; letter-spacing:.3px; }
14961
+ header.top nav { display:flex; gap:6px; }
14962
+ header.top nav a { padding:6px 12px; border-radius:8px; color:var(--dim); font-weight:600; }
14963
+ header.top nav a.active, header.top nav a:hover { color:var(--text); background:var(--panel2); }
14964
+ .wrap { max-width:1200px; margin:0 auto; padding:28px 24px 80px; }
14965
+ .hero h1 { font-size:26px; margin:0 0 4px; }
14966
+ .hero p { color:var(--dim); margin:0 0 20px; }
14967
+ .searchbar { display:flex; gap:10px; margin-bottom:14px; }
14968
+ .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; }
14969
+ .searchbar input:focus { border-color:var(--accent); }
14970
+ .searchbar button { background:var(--accent); color:#fff; border:0; border-radius:10px; padding:0 18px; font-weight:700; cursor:pointer; }
14971
+ .presets { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:22px; }
14972
+ .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; }
14973
+ .chip:hover, .chip.active { color:var(--text); border-color:var(--accent); }
14974
+ .status { color:var(--dim); font-size:14px; margin-bottom:16px; min-height:20px; }
14975
+ .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:18px; }
14976
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:14px; overflow:hidden; display:flex; flex-direction:column; }
14977
+ .card .thumb { position:relative; aspect-ratio:9/16; background:#000; overflow:hidden; }
14978
+ .card .thumb img, .card .thumb video { width:100%; height:100%; object-fit:cover; display:block; }
14979
+ .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; }
14980
+ .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; }
14981
+ .card .body { padding:11px 12px 12px; display:flex; flex-direction:column; gap:8px; flex:1; }
14982
+ .card .desc { font-size:13px; color:var(--text); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
14983
+ .card .tags { display:flex; flex-wrap:wrap; gap:5px; }
14984
+ .card .tag { font-size:11px; color:var(--dim); background:var(--panel2); border-radius:5px; padding:1px 6px; }
14985
+ .card .row { display:flex; align-items:center; justify-content:space-between; margin-top:auto; }
14986
+ .card .src { font-size:11px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:60%; }
14987
+ .card a.dl { font-size:12px; font-weight:700; color:var(--accent); }
14988
+ .empty { text-align:center; color:var(--dim); padding:60px 20px; }
14989
+ .empty code { background:var(--panel2); padding:2px 8px; border-radius:6px; color:var(--accent2); }
14990
+ </style>
14991
+ </head>
14992
+ <body>
14993
+ <header class="top">
14994
+ <div class="brand">Vidfarm</div>
14995
+ <nav>
14996
+ <a href="/discover">Discover</a>
14997
+ <a href="/library">Library</a>
14998
+ <a href="/clips" class="active">Clips</a>
14999
+ </nav>
15000
+ </header>
15001
+ <div class="wrap">
15002
+ <div class="hero">
15003
+ <h1>Clips</h1>
15004
+ <p>Your reusable clip library — mined from long-form video, searchable by tag or natural language.</p>
15005
+ </div>
15006
+ <form class="searchbar" id="searchForm">
15007
+ <input id="q" type="search" placeholder="Search clips — e.g. &quot;someone looks confused after reading a message&quot;" autocomplete="off" />
15008
+ <button type="submit">Search</button>
15009
+ </form>
15010
+ <div class="presets" id="presets"></div>
15011
+ <div class="status" id="status">Loading your clips…</div>
15012
+ <div class="grid" id="grid"></div>
15013
+ </div>
15014
+ <script>
15015
+ (function(){
15016
+ var grid = document.getElementById('grid');
15017
+ var statusEl = document.getElementById('status');
15018
+ var presetsEl = document.getElementById('presets');
15019
+ var form = document.getElementById('searchForm');
15020
+ var input = document.getElementById('q');
15021
+
15022
+ function fmtClock(sec){ var m=Math.floor(sec/60), s=Math.floor(sec%60); return (m<10?'0':'')+m+':'+(s<10?'0':'')+s; }
15023
+ function esc(t){ var d=document.createElement('div'); d.textContent=(t==null?'':String(t)); return d.innerHTML; }
15024
+
15025
+ function topTags(tags){
15026
+ if(!tags) return [];
15027
+ var out=[];
15028
+ ['subject','action','emotion','composition'].forEach(function(k){ (tags[k]||[]).slice(0,1).forEach(function(v){ out.push(v); }); });
15029
+ if(tags.energy) out.push('energy:'+tags.energy);
15030
+ return out.slice(0,4);
15031
+ }
15032
+
15033
+ function card(clip){
15034
+ var range = fmtClock(clip.start_time_sec)+'–'+fmtClock(clip.end_time_sec);
15035
+ var score = (clip.score!=null) ? '<div class="score">'+ (clip.score*100).toFixed(0) +'</div>' : '';
15036
+ var media = clip.thumbnail_url
15037
+ ? '<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();}" />'
15038
+ : '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#556">no thumb</div>';
15039
+ var vid = clip.view_url ? '<video muted loop playsinline preload="none" style="display:none;position:absolute;inset:0" src="'+esc(clip.view_url)+'"></video>' : '';
15040
+ var tags = topTags(clip.tags).map(function(t){ return '<span class="tag">'+esc(t)+'</span>'; }).join('');
15041
+ return '<div class="card">'
15042
+ + '<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>'
15043
+ + '<div class="body">'
15044
+ + '<div class="desc">'+esc(clip.description||clip.tags&&clip.tags.transcript||'(untagged clip)')+'</div>'
15045
+ + '<div class="tags">'+tags+'</div>'
15046
+ + '<div class="row"><span class="src" title="'+esc(clip.source_filename)+'">'+esc(clip.source_filename)+'</span>'
15047
+ + '<a class="dl" href="/clips/'+encodeURIComponent(clip.clip_id)+'/download">Download</a></div>'
15048
+ + '</div></div>';
15049
+ }
15050
+
15051
+ function render(clips, label){
15052
+ if(!clips || clips.length===0){
15053
+ grid.innerHTML='';
15054
+ statusEl.innerHTML = label || '';
15055
+ 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>';
15056
+ return;
15057
+ }
15058
+ statusEl.textContent = (label||'') + clips.length + ' clip' + (clips.length===1?'':'s');
15059
+ grid.innerHTML = clips.map(card).join('');
15060
+ }
15061
+
15062
+ function handleAuth(res){
15063
+ if(res.status===401||res.status===403){
15064
+ grid.innerHTML='';
15065
+ 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>.';
15066
+ throw new Error('unauthorized');
15067
+ }
15068
+ return res.json();
15069
+ }
15070
+
15071
+ function loadFeed(){
15072
+ statusEl.textContent='Loading your clips…';
15073
+ fetch('/clips/feed?limit=120', {credentials:'same-origin', headers:{Accept:'application/json'}})
15074
+ .then(handleAuth).then(function(d){ render(d.clips, ''); }).catch(function(){});
15075
+ }
15076
+
15077
+ function search(query, criteria){
15078
+ statusEl.textContent='Searching…';
15079
+ 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})})
15080
+ .then(handleAuth).then(function(d){
15081
+ var label = (d.semantic?'semantic ':'') + 'results for "' + (query||'preset') + '" — ';
15082
+ render(d.results, label);
15083
+ }).catch(function(){});
15084
+ }
15085
+
15086
+ function loadPresets(){
15087
+ fetch('/clips/presets', {credentials:'same-origin', headers:{Accept:'application/json'}})
15088
+ .then(handleAuth).then(function(d){
15089
+ presetsEl.innerHTML = (d.presets||[]).map(function(p){
15090
+ return '<span class="chip" data-id="'+esc(p.preset_id)+'">'+esc(p.name)+'</span>';
15091
+ }).join('');
15092
+ var byId = {}; (d.presets||[]).forEach(function(p){ byId[p.preset_id]=p; });
15093
+ Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(chip){
15094
+ chip.addEventListener('click', function(){
15095
+ Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(c){ c.classList.remove('active'); });
15096
+ chip.classList.add('active');
15097
+ var p = byId[chip.getAttribute('data-id')];
15098
+ if(p){ input.value=''; search(p.name, p.criteria); }
15099
+ });
15100
+ });
15101
+ }).catch(function(){});
15102
+ }
15103
+
15104
+ form.addEventListener('submit', function(e){
15105
+ e.preventDefault();
15106
+ Array.prototype.forEach.call(presetsEl.querySelectorAll('.chip'), function(c){ c.classList.remove('active'); });
15107
+ var q = input.value.trim();
15108
+ if(q) search(q, null); else loadFeed();
15109
+ });
15110
+
15111
+ loadPresets();
15112
+ loadFeed();
15113
+ })();
15114
+ </script>
15115
+ </body>
15116
+ </html>`;
15117
+ }
15118
+ async function resolveCustomerProviderKey(customerId, provider) {
15119
+ const rows = (await listProviderKeysWithSecretsForCustomer(customerId));
15120
+ for (const row of rows) {
15121
+ if (row.provider === provider && (row.status ?? "active") === "active") {
15122
+ const secret = readStoredSecret(String(row.secret ?? "")).trim();
15123
+ if (secret)
15124
+ return secret;
15125
+ }
15126
+ }
15127
+ return null;
15128
+ }
15129
+ // Normalize a request-supplied provider to the supported set (default gemini).
15130
+ function normalizeClipProvider(value) {
15131
+ return value === "openai" || value === "openrouter" ? value : "gemini";
15132
+ }
15133
+ /**
15134
+ * A client for embedding MATCH queries in the SAME vector space the library was
15135
+ * built with (derived from the newest clip's embedding_model), so scene-annotation
15136
+ * queries are cosine-comparable to the stored clips. Returns null when embeddings
15137
+ * aren't available (no key) — matching then falls back to the structured filter.
15138
+ */
15139
+ async function buildMatchEmbedder(customerId) {
15140
+ const libModel = await clipRecords.getLibraryEmbeddingModel(customerId);
15141
+ const provider = libModel && libModel.startsWith("text-embedding") ? "openai" : "gemini";
15142
+ const key = await resolveCustomerProviderKey(customerId, provider);
15143
+ if (!key)
15144
+ return null;
15145
+ const client = new ClipModelClient({ provider, apiKey: key });
15146
+ return client.canEmbed ? client : null;
15147
+ }
15148
+ // Build a ClipModelClient for a customer + provider, resolving the tagging key
15149
+ // and (for OpenRouter, which can't embed) an embedding key from their saved keys.
15150
+ async function buildCustomerClipClient(customerId, provider) {
15151
+ const apiKey = await resolveCustomerProviderKey(customerId, provider);
15152
+ if (!apiKey) {
15153
+ return { error: `Add a ${provider} provider key first (BYOK) — POST /api/v1/user/me/provider-keys.` };
15154
+ }
15155
+ let embedding = null;
15156
+ let embeddingKey = null;
15157
+ if (provider === "gemini" || provider === "openai") {
15158
+ embedding = { provider, apiKey };
15159
+ embeddingKey = apiKey;
15160
+ }
15161
+ else {
15162
+ // OpenRouter: pair with a gemini/openai key for embeddings if the user has one.
15163
+ const g = await resolveCustomerProviderKey(customerId, "gemini");
15164
+ const o = g ? null : await resolveCustomerProviderKey(customerId, "openai");
15165
+ if (g) {
15166
+ embedding = { provider: "gemini", apiKey: g };
15167
+ embeddingKey = g;
15168
+ }
15169
+ else if (o) {
15170
+ embedding = { provider: "openai", apiKey: o };
15171
+ embeddingKey = o;
15172
+ }
15173
+ }
15174
+ return {
15175
+ client: new ClipModelClient({ provider, apiKey, embedding, publicBaseUrl: config.PUBLIC_BASE_URL }),
15176
+ apiKey,
15177
+ embeddingKey
15178
+ };
15179
+ }
15180
+ function parseClipStorageKey(pathOrUri) {
15181
+ const m = /^s3:\/\/[^/]+\/(.+)$/.exec(pathOrUri || "");
15182
+ return m ? m[1] : null;
15183
+ }
15184
+ async function clipMediaUrl(pathOrUri) {
15185
+ if (!pathOrUri)
15186
+ return null;
15187
+ const key = parseClipStorageKey(pathOrUri);
15188
+ if (key)
15189
+ return (await storage.getReadUrl(key)) ?? storage.getPublicUrl(key);
15190
+ return pathOrUri; // non-s3 value (e.g. a local devcli-origin path) — pass through
15191
+ }
15192
+ async function serializeClip(clip) {
15193
+ const [view_url, thumbnail_url] = await Promise.all([
15194
+ clipMediaUrl(clip.file_path),
15195
+ clipMediaUrl(clip.thumbnail_path)
15196
+ ]);
15197
+ const { embedding, ...rest } = clip;
15198
+ void embedding; // never ship the raw vector to the client
15199
+ return { ...rest, view_url, thumbnail_url };
15200
+ }
15201
+ function normalizeScanTier(value) {
15202
+ return value === "flash" ? "flash" : "flash-lite";
15203
+ }
15204
+ function clipKeywordMatch(clip, q) {
15205
+ const hay = `${clip.description} ${clip.tags.transcript} ${clip.source_filename} ${Object.values(clip.tags).flat().join(" ")}`.toLowerCase();
15206
+ return q
15207
+ .toLowerCase()
15208
+ .split(/\s+/)
15209
+ .filter(Boolean)
15210
+ .every((term) => hay.includes(term));
15211
+ }
15212
+ // GET /clips — the first-class Clips gallery page (public HTML; the client
15213
+ // fetches /clips/feed with the session cookie). Mirrors /discover vs /discover/feed.
15214
+ app.get(CLIPS_PREFIX, (c) => c.html(renderClipsPage()));
15215
+ // GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q, ?limit).
15216
+ app.get(`${CLIPS_PREFIX}/feed`, async (c) => {
15217
+ const customer = requireCustomer(c);
15218
+ const limit = clampPageLimit(Number(c.req.query("limit") || "60"), 200);
15219
+ const q = c.req.query("q")?.trim();
15220
+ let clips = await clipRecords.listClips(customer.id, {
15221
+ sourceVideoId: c.req.query("source") || undefined,
15222
+ limit: q ? 5000 : limit
15223
+ });
15224
+ if (q)
15225
+ clips = clips.filter((clip) => clipKeywordMatch(clip, q)).slice(0, limit);
15226
+ return c.json({ clips: await Promise.all(clips.map(serializeClip)) });
15227
+ });
15228
+ // POST /clips/search — hybrid structured + semantic search.
15229
+ app.post(`${CLIPS_PREFIX}/search`, async (c) => {
15230
+ const customer = requireCustomer(c);
15231
+ try {
15232
+ const body = (await c.req.json().catch(() => ({})));
15233
+ const limit = Math.max(1, Math.min(200, Number(body.limit) || 20));
15234
+ const provider = normalizeClipProvider(body.provider);
15235
+ let criteria = body.criteria ?? (body.query ? { semantic_text: body.query } : {});
15236
+ let queryEmbedding;
15237
+ if (body.query || criteria.semantic_text) {
15238
+ const built = await buildCustomerClipClient(customer.id, provider);
15239
+ if ("client" in built) {
15240
+ const client = built.client;
15241
+ if (body.query && !body.criteria)
15242
+ criteria = await client.naturalLanguageToCriteria(body.query);
15243
+ if (client.canEmbed)
15244
+ queryEmbedding = await client.embedQuery(criteria.semantic_text ?? body.query ?? "");
15245
+ }
15246
+ }
15247
+ const hits = await searchUserClips(customer.id, { criteria, queryEmbedding, limit });
15248
+ const results = await Promise.all(hits.map(async (h) => ({ ...(await serializeClip(h.clip)), score: h.score, similarity: h.similarity })));
15249
+ return c.json({ results, criteria, semantic: Boolean(queryEmbedding) });
15250
+ }
15251
+ catch (error) {
15252
+ return c.json({ error: error instanceof Error ? error.message : "Clip search failed." }, 400);
15253
+ }
15254
+ });
15255
+ function normalizeMatchScene(s, index) {
15256
+ const criteria = s.match_criteria ?? s.criteria ?? (s.embedding_text || s.text ? { semantic_text: s.embedding_text || s.text } : {});
15257
+ const text = s.embedding_text || s.text || criteria.semantic_text || "";
15258
+ return {
15259
+ id: s.scene_slug || s.id || `scene_${index}`,
15260
+ criteria,
15261
+ text,
15262
+ limit: s.limit,
15263
+ meta: {
15264
+ scene_slug: s.scene_slug || s.id,
15265
+ start: s.start,
15266
+ duration: s.duration,
15267
+ replaceability: s.replaceability,
15268
+ scene_role: s.scene_role
15269
+ }
15270
+ };
15271
+ }
15272
+ // POST /clips/match — find the top library clips that could REPLACE one scene.
15273
+ // Body: a scene annotation, or {criteria, embedding_text|text, limit}.
15274
+ app.post(`${CLIPS_PREFIX}/match`, async (c) => {
15275
+ const customer = requireCustomer(c);
15276
+ try {
15277
+ const body = (await c.req.json().catch(() => ({})));
15278
+ const scene = normalizeMatchScene(body, 0);
15279
+ const limit = Math.max(1, Math.min(50, Number(body.limit) || scene.limit || 8));
15280
+ let queryEmbedding;
15281
+ if (scene.text) {
15282
+ const embedder = await buildMatchEmbedder(customer.id);
15283
+ if (embedder)
15284
+ [queryEmbedding] = await embedder.embedQueries([scene.text]);
15285
+ }
15286
+ const hits = await searchUserClips(customer.id, { criteria: scene.criteria, queryEmbedding, limit });
15287
+ const matches = await Promise.all(hits.map(async (h) => ({ ...(await serializeClip(h.clip)), score: h.score, similarity: h.similarity })));
15288
+ return c.json({ matches, criteria: scene.criteria, semantic: Boolean(queryEmbedding) });
15289
+ }
15290
+ catch (error) {
15291
+ return c.json({ error: error instanceof Error ? error.message : "Clip match failed." }, 400);
15292
+ }
15293
+ });
15294
+ // POST /clips/match-scenes — batch: take a decomposed video's scene annotations
15295
+ // and return the top replacement-clip matches per scene (one library read).
15296
+ // Body: { scenes | annotations: SceneAnnotation[], limit? }.
15297
+ app.post(`${CLIPS_PREFIX}/match-scenes`, async (c) => {
15298
+ const customer = requireCustomer(c);
15299
+ try {
15300
+ const body = (await c.req.json());
15301
+ const raw = body.scenes ?? body.annotations ?? [];
15302
+ if (!Array.isArray(raw) || raw.length === 0) {
15303
+ return c.json({ error: "Provide scenes[] (or annotations[]) of scene annotations to match." }, 400);
15304
+ }
15305
+ if (raw.length > 200)
15306
+ return c.json({ error: "Too many scenes (max 200 per request)." }, 400);
15307
+ const defaultLimit = Math.max(1, Math.min(50, Number(body.limit) || 5));
15308
+ const scenes = raw.map(normalizeMatchScene);
15309
+ // One batch embedding call for every scene's query text (in the library's space).
15310
+ const embedder = await buildMatchEmbedder(customer.id);
15311
+ let embeddings = [];
15312
+ if (embedder)
15313
+ embeddings = await embedder.embedQueries(scenes.map((s) => s.text || " "));
15314
+ const queries = scenes.map((s, i) => ({
15315
+ id: s.id,
15316
+ criteria: s.criteria,
15317
+ queryEmbedding: embedder ? embeddings[i] : undefined,
15318
+ limit: s.limit ?? defaultLimit,
15319
+ meta: s.meta
15320
+ }));
15321
+ const results = await matchScenesToClips(customer.id, queries);
15322
+ const serialized = await Promise.all(results.map(async (r) => ({
15323
+ scene_slug: r.id,
15324
+ ...(r.meta ?? {}),
15325
+ matches: await Promise.all(r.matches.map(async (h) => ({ ...(await serializeClip(h.clip)), score: h.score, similarity: h.similarity })))
15326
+ })));
15327
+ return c.json({ results: serialized, semantic: Boolean(embedder) });
15328
+ }
15329
+ catch (error) {
15330
+ return c.json({ error: error instanceof Error ? error.message : "Clip match-scenes failed." }, 400);
15331
+ }
15332
+ });
15333
+ // POST /clips/scan — kick off a cloud scan of an uploaded/staged source video.
15334
+ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
15335
+ const customer = requireCustomer(c);
15336
+ try {
15337
+ const body = (await c.req.json());
15338
+ const rawS3Uri = body.raw_s3_uri ??
15339
+ (body.s3_key && config.AWS_S3_BUCKET ? `s3://${config.AWS_S3_BUCKET}/${body.s3_key}` : undefined);
15340
+ if (!rawS3Uri) {
15341
+ return c.json({ error: "Provide raw_s3_uri (s3://…) or s3_key of the uploaded source video." }, 400);
15342
+ }
15343
+ const provider = normalizeClipProvider(body.provider);
15344
+ const built = await buildCustomerClipClient(customer.id, provider);
15345
+ if ("error" in built)
15346
+ return c.json({ error: built.error }, 400);
15347
+ const { client, apiKey: tagApiKey, embeddingKey } = built;
15348
+ const tier = normalizeScanTier(body.tier);
15349
+ const framesPerScene = Math.max(1, Math.min(3, Number(body.frames_per_scene) || 2));
15350
+ const includeAudio = body.include_audio !== false && provider === "gemini";
15351
+ const guidancePrompt = body.prompt?.trim() || "";
15352
+ const now = nowIso();
15353
+ const scanId = createIdV7("clipscan");
15354
+ const sourceVideoId = createIdV7("srcvid");
15355
+ const filename = body.filename || rawS3Uri.split("/").pop() || "source.mp4";
15356
+ const estimate = body.duration_sec && body.duration_sec > 0
15357
+ ? estimateScanCostFromDuration(body.duration_sec, {
15358
+ tier,
15359
+ provider,
15360
+ tagModelId: client.tagModelId,
15361
+ embedModelId: client.embeddingModelId,
15362
+ framesPerScene,
15363
+ includeAudio
15364
+ })
15365
+ : null;
15366
+ const source = {
15367
+ source_video_id: sourceVideoId,
15368
+ owner_id: customer.id,
15369
+ filename,
15370
+ raw_s3_uri: rawS3Uri,
15371
+ duration_sec: body.duration_sec,
15372
+ clip_count: 0,
15373
+ tier,
15374
+ status: "pending",
15375
+ scan_id: scanId,
15376
+ created_at: now,
15377
+ updated_at: now
15378
+ };
15379
+ await clipRecords.putSource(source);
15380
+ let executionArn;
15381
+ if (config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN) {
15382
+ const res = await clipScanSfn.send(new StartExecutionCommand({
15383
+ stateMachineArn: config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN,
15384
+ name: scanId.replace(/[^A-Za-z0-9-_]/g, "-").slice(0, 80),
15385
+ input: JSON.stringify({
15386
+ scan_id: scanId,
15387
+ owner_id: customer.id,
15388
+ source_video_id: sourceVideoId,
15389
+ source_filename: filename,
15390
+ raw_s3_uri: rawS3Uri,
15391
+ tier,
15392
+ provider,
15393
+ tag_api_key: tagApiKey,
15394
+ embedding_provider: client.embeddingProvider ?? "",
15395
+ embedding_api_key: embeddingKey ?? "",
15396
+ guidance_prompt: guidancePrompt,
15397
+ frames_per_scene: framesPerScene,
15398
+ include_audio: includeAudio
15399
+ })
15400
+ }));
15401
+ executionArn = res.executionArn;
15402
+ }
15403
+ const scan = {
15404
+ scan_id: scanId,
15405
+ owner_id: customer.id,
15406
+ source_video_id: sourceVideoId,
15407
+ status: executionArn ? "running" : "queued",
15408
+ tier,
15409
+ raw_s3_uri: rawS3Uri,
15410
+ execution_arn: executionArn,
15411
+ created_at: now,
15412
+ updated_at: now
15413
+ };
15414
+ await clipRecords.putScan(scan);
15415
+ return c.json({
15416
+ scan_id: scanId,
15417
+ source_video_id: sourceVideoId,
15418
+ status: scan.status,
15419
+ execution_arn: executionArn ?? null,
15420
+ estimate,
15421
+ pipeline_deployed: Boolean(config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN)
15422
+ }, 202);
15423
+ }
15424
+ catch (error) {
15425
+ return c.json({ error: error instanceof Error ? error.message : "Failed to start clip scan." }, 400);
15426
+ }
15427
+ });
15428
+ // GET /clips/scan/:scanId — poll scan status.
15429
+ app.get(`${CLIPS_PREFIX}/scan/:scanId`, async (c) => {
15430
+ const customer = requireCustomer(c);
15431
+ const scan = await clipRecords.getScan(customer.id, c.req.param("scanId"));
15432
+ if (!scan)
15433
+ return c.json({ error: "Scan not found" }, 404);
15434
+ const source = await clipRecords.getSource(customer.id, scan.source_video_id);
15435
+ return c.json({ scan, source });
15436
+ });
15437
+ // GET /clips/sources — scanned source videos.
15438
+ app.get(`${CLIPS_PREFIX}/sources`, async (c) => {
15439
+ const customer = requireCustomer(c);
15440
+ return c.json({ sources: await clipRecords.listSources(customer.id) });
15441
+ });
15442
+ // GET /clips/presets — built-in + saved presets.
15443
+ app.get(`${CLIPS_PREFIX}/presets`, async (c) => {
15444
+ const customer = requireCustomer(c);
15445
+ return c.json({ presets: await clipRecords.listPresets(customer.id) });
15446
+ });
15447
+ // POST /clips/presets — save a custom preset from criteria or a NL query.
15448
+ app.post(`${CLIPS_PREFIX}/presets`, async (c) => {
15449
+ const customer = requireCustomer(c);
15450
+ try {
15451
+ const body = (await c.req.json());
15452
+ if (!body.name)
15453
+ return c.json({ error: "name is required." }, 400);
15454
+ let criteria = body.criteria;
15455
+ if (!criteria && body.query) {
15456
+ const built = await buildCustomerClipClient(customer.id, normalizeClipProvider(body.provider));
15457
+ if ("error" in built)
15458
+ return c.json({ error: built.error }, 400);
15459
+ criteria = await built.client.naturalLanguageToCriteria(body.query);
15460
+ }
15461
+ if (!criteria)
15462
+ return c.json({ error: "Provide criteria or query." }, 400);
15463
+ const preset = makeUserPreset({ name: body.name, criteria, description: body.description, owner: customer.id });
15464
+ await clipRecords.putPreset(customer.id, preset);
15465
+ return c.json({ preset }, 201);
15466
+ }
15467
+ catch (error) {
15468
+ return c.json({ error: error instanceof Error ? error.message : "Failed to save preset." }, 400);
15469
+ }
15470
+ });
15471
+ // DELETE /clips/presets/:presetId — delete a saved (non-builtin) preset.
15472
+ app.delete(`${CLIPS_PREFIX}/presets/:presetId`, async (c) => {
15473
+ const customer = requireCustomer(c);
15474
+ const id = c.req.param("presetId");
15475
+ if (id.startsWith("preset_") && BUILTIN_CLIP_PRESET_IDS.has(id)) {
15476
+ return c.json({ error: "Built-in presets cannot be deleted." }, 400);
15477
+ }
15478
+ await clipRecords.deletePreset(customer.id, id);
15479
+ return c.json({ ok: true });
15480
+ });
15481
+ // GET /clips/:clipId/download — presigned redirect to the clip mp4.
15482
+ app.get(`${CLIPS_PREFIX}/:clipId/download`, async (c) => {
15483
+ const customer = requireCustomer(c);
15484
+ const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
15485
+ if (!clip)
15486
+ return c.json({ error: "Clip not found" }, 404);
15487
+ const url = await clipMediaUrl(clip.file_path);
15488
+ if (!url)
15489
+ return c.json({ error: "Clip file unavailable" }, 404);
15490
+ return redirect(c, url, 302);
15491
+ });
15492
+ // GET /clips/:clipId — one clip (+ presigned view/thumbnail urls).
15493
+ app.get(`${CLIPS_PREFIX}/:clipId`, async (c) => {
15494
+ const customer = requireCustomer(c);
15495
+ const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
15496
+ if (!clip)
15497
+ return c.json({ error: "Clip not found" }, 404);
15498
+ return c.json({ clip: await serializeClip(clip) });
15499
+ });
15500
+ // DELETE /clips/:clipId — remove a clip record (+ vector).
15501
+ app.delete(`${CLIPS_PREFIX}/:clipId`, async (c) => {
15502
+ const customer = requireCustomer(c);
15503
+ await clipRecords.deleteClip(customer.id, c.req.param("clipId"));
15504
+ return c.json({ ok: true });
15505
+ });
14645
15506
  app.get(`${TEMPLATES_PREFIX}/sources`, async (c) => {
14646
15507
  try {
14647
15508
  requireAdmin(c);