@mevdragon/vidfarm-devcli 0.7.0 → 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.
Files changed (37) hide show
  1. package/SKILL.director.md +43 -10
  2. package/SKILL.platform.md +5 -3
  3. package/demo/dist/app.js +107 -57
  4. package/dist/src/app.js +1115 -17
  5. package/dist/src/cli.js +304 -43
  6. package/dist/src/composition-runtime.js +26 -0
  7. package/dist/src/config.js +6 -0
  8. package/dist/src/devcli/clip-store.js +335 -0
  9. package/dist/src/devcli/clips.js +803 -0
  10. package/dist/src/devcli/composition-edit.js +12 -0
  11. package/dist/src/editor-chat.js +1 -0
  12. package/dist/src/frontend/homepage-client.js +61 -6
  13. package/dist/src/frontend/homepage-view.js +22 -4
  14. package/dist/src/homepage.js +46 -0
  15. package/dist/src/hyperframes/composition.js +87 -0
  16. package/dist/src/primitive-registry.js +136 -0
  17. package/dist/src/services/billing.js +1 -0
  18. package/dist/src/services/clip-curation/cost.js +112 -0
  19. package/dist/src/services/clip-curation/ffmpeg.js +258 -0
  20. package/dist/src/services/clip-curation/gemini.js +342 -0
  21. package/dist/src/services/clip-curation/index.js +15 -0
  22. package/dist/src/services/clip-curation/presets.js +20 -0
  23. package/dist/src/services/clip-curation/presets.v1.json +59 -0
  24. package/dist/src/services/clip-curation/query.js +163 -0
  25. package/dist/src/services/clip-curation/refine.js +136 -0
  26. package/dist/src/services/clip-curation/scan.js +137 -0
  27. package/dist/src/services/clip-curation/taxonomy.js +71 -0
  28. package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
  29. package/dist/src/services/clip-curation/types.js +7 -0
  30. package/dist/src/services/clip-records.js +209 -0
  31. package/dist/src/services/clip-search.js +47 -0
  32. package/dist/src/services/clip-vectors.js +125 -0
  33. package/dist/src/services/hyperframes.js +373 -1
  34. package/dist/src/services/scene-annotations.js +32 -0
  35. package/dist/src/services/storage.js +6 -0
  36. package/package.json +5 -1
  37. package/public/assets/homepage-client-app.js +18 -18
package/dist/src/app.js CHANGED
@@ -21,13 +21,14 @@ import { writeForkManifest } from "./services/fork-manifest.js";
21
21
  import { renderHelpPage } from "./help-page.js";
22
22
  import { renderHomepage } from "./homepage.js";
23
23
  import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
24
+ import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER } from "./hyperframes/composition.js";
24
25
  import { normalizeCompositionZIndexHtml, sanitizeCompositionHtml } from "./services/composition-sanitize.js";
25
26
  import { applyMarkupUsd } from "./services/billing-pricing.js";
26
27
  import { primitiveRegistry } from "./primitive-registry.js";
27
28
  import { decryptString, encryptString, hashSecret, safeEqualHash } from "./lib/crypto.js";
28
29
  import { devErrorFields, devLog } from "./lib/dev-log.js";
29
30
  import { displayNameFromEmail } from "./lib/display-name.js";
30
- import { createId } from "./lib/ids.js";
31
+ import { createId, createIdV7 } from "./lib/ids.js";
31
32
  import { resolveRootFileCandidates } from "./lib/package-root.js";
32
33
  import { stripTrackingParams } from "./lib/url-clean.js";
33
34
  import { addSeconds, nowIso } from "./lib/time.js";
@@ -44,7 +45,12 @@ import { ServerlessProviderKeyService } from "./services/serverless-provider-key
44
45
  import { ServerlessTemplateConfigService } from "./services/serverless-template-configs.js";
45
46
  import { parseHTML } from "linkedom";
46
47
  import { joinStorageKey, StorageService } from "./services/storage.js";
47
- 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";
48
54
  import { createPrimitiveJobContext } from "./primitive-context.js";
49
55
  import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
50
56
  import { MediaDurationExceededError, probeMediaAsset } from "./services/media-processing.js";
@@ -74,6 +80,7 @@ const COMPOSITIONS_PREFIX = `${API_PREFIX}/compositions`;
74
80
  const INSPIRATIONS_PREFIX = `${API_PREFIX}/inspirations`;
75
81
  const VIDEOS_PREFIX = `${API_PREFIX}/videos`;
76
82
  const PRIMITIVES_PREFIX = `${API_PREFIX}/primitives`;
83
+ const CLIPS_PREFIX = "/clips";
77
84
  const APPROVED_POSTS_PREFIX = `${API_PREFIX}/approved/posts`;
78
85
  const APPROVED_POSTS_PUBLIC_PREFIX = "/approved/posts";
79
86
  const ACCOUNT_FRONTEND_PREFIX = "/u/:userId";
@@ -4140,6 +4147,8 @@ function createEditorActionTool() {
4140
4147
  background_style: z.enum(["plain", "outline", "highlight-solid", "highlight-translucent"]).optional().describe("Text background treatment."),
4141
4148
  object_fit: z.string().optional().describe("CSS object-fit for image/video (cover, contain, fill, none, scale-down)."),
4142
4149
  object_position: z.string().optional().describe("CSS object-position for image/video (e.g., center, top, bottom left)."),
4150
+ ken_burns: z.enum(["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right", "none"]).optional().describe("Ken Burns motion for still-image layers: a slow pan/zoom spanning the clip's full duration. Apply with set_layer_media on an existing image, or seed it on add_layer/generate_layer when kind/media_type is image. 'none' removes the effect. When the user says 'animate the images' / 'ken burns', apply it to every still image, varying presets across adjacent clips (zoom-in, pan-left, zoom-out, ...) for a documentary feel."),
4151
+ ken_burns_intensity: z.number().optional().describe("Ken Burns strength: extra zoom / pan travel as a fraction of the frame, 0.04-0.5 (default 0.18; ~0.1 subtle, ~0.3 dramatic). Only meaningful alongside ken_burns."),
4143
4152
  volume: z.number().optional().describe("Volume 0..1 for audio/video."),
4144
4153
  muted: z.boolean().optional().describe("Toggle mute on audio/video."),
4145
4154
  group_label: z.string().optional().describe("Optional label for group_layers."),
@@ -4514,6 +4523,21 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
4514
4523
  rewritten = `${rewritten}\n${runtimeTag}`;
4515
4524
  }
4516
4525
  }
4526
+ // Ken Burns keyframes: always available in the editor iframe so toggling
4527
+ // data-kenburns on a layer animates immediately, even for compositions
4528
+ // stored before the builder embedded the stylesheet.
4529
+ if (!rewritten.includes(KEN_BURNS_STYLE_MARKER)) {
4530
+ const kenBurnsTag = `<style data-vf-kenburns="true">${KEN_BURNS_CSS}</style>`;
4531
+ if (/<\/head>/i.test(rewritten)) {
4532
+ rewritten = rewritten.replace(/<\/head>/i, `${kenBurnsTag}\n</head>`);
4533
+ }
4534
+ else if (/<\/body>/i.test(rewritten)) {
4535
+ rewritten = rewritten.replace(/<\/body>/i, `${kenBurnsTag}\n</body>`);
4536
+ }
4537
+ else {
4538
+ rewritten = `${rewritten}\n${kenBurnsTag}`;
4539
+ }
4540
+ }
4517
4541
  return rewritten;
4518
4542
  }
4519
4543
  async function renderHyperframesStudioEditorPage(input) {
@@ -4806,7 +4830,10 @@ function buildTemplateHomepageEntry(template) {
4806
4830
  if (!template.videoUrl)
4807
4831
  return null;
4808
4832
  const isPrivate = template.visibility === "private";
4809
- const title = isPrivate ? (template.title || template.id) : template.id;
4833
+ // Older records stored the source host ("youtube.com") as a title fallback —
4834
+ // never show that as a card title; the template id is the canonical default.
4835
+ const storedTitle = template.title && template.title !== template.sourceHost ? template.title : null;
4836
+ const title = isPrivate ? (storedTitle || template.id) : template.id;
4810
4837
  const previewUrl = template.thumbnailUrl || template.videoUrl || null;
4811
4838
  return {
4812
4839
  title,
@@ -4842,26 +4869,213 @@ const DISCOVER_SUBMISSION_HOSTS = [
4842
4869
  function isSupportedDiscoverSubmissionHost(host) {
4843
4870
  return DISCOVER_SUBMISSION_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
4844
4871
  }
4845
- // Discover "Add Template": ingest a social video URL as a PRIVATE inspiration
4846
- // owned by the caller. The download job runs async; /discover/feed finalizes
4847
- // it and mints the private template once the video lands.
4848
- app.post("/discover/templates", async (c) => {
4872
+ // Upload cap for "Add Template upload a video". Generous relative to the
4873
+ // 50 MB My Files cap because template sources are full short-form videos; the
4874
+ // bytes go straight to S3 via presigned PUT, never through a Lambda body.
4875
+ const INSPIRATION_UPLOAD_MAX_BYTES = 200 * 1024 * 1024;
4876
+ function isUploadableInspirationVideo(fileName, contentType) {
4877
+ if (contentType.startsWith("video/"))
4878
+ return true;
4879
+ return [".mp4", ".m4v", ".mov", ".webm"].includes(path.extname(fileName).toLowerCase());
4880
+ }
4881
+ function isOwnInspirationUploadKey(storageKey, customerId) {
4882
+ return storageKey.startsWith(`users/${customerId}/inspiration-uploads/`);
4883
+ }
4884
+ // Discover "Add Template" (upload, step 1 of 3): mint a write target for a
4885
+ // local video file. Contract shared by web + devcli: presign here (JSON), PUT
4886
+ // (or multipart-fallback POST) the bytes, then POST /discover/templates with
4887
+ // { upload: { storage_key, file_name } } to queue the ingest. In `vidfarm
4888
+ // serve` cloud-passthrough mode this proxies upstream so the client PUTs
4889
+ // straight to cloud S3 and the ingest lands in the cloud account — multipart
4890
+ // bodies can't proxy (text-only proxy), so proxying the presign avoids ever
4891
+ // needing to.
4892
+ app.post("/discover/templates/upload/presign", async (c) => {
4849
4893
  const customer = await requireBrowserCustomer(c);
4850
4894
  if (!customer)
4851
4895
  return c.json({ ok: false, error: "Log in to add templates." }, 401);
4852
- // Cloud passthrough (vidfarm serve): Add Template ingests into the CLOUD
4853
- // account — the download job, billing, and catalog entry all live upstream,
4854
- // and the proxied /discover/feed finalizes it. Local ingest only happens
4855
- // when no upstream key is configured.
4856
4896
  if (hasUpstreamKey()) {
4857
4897
  const proxied = await proxyRequestToUpstream(c);
4858
4898
  if (proxied)
4859
4899
  return proxied;
4860
4900
  }
4861
4901
  const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
4902
+ const fileName = sanitizeFileName(readNonEmptyString(body.file_name) ?? "upload.mp4");
4903
+ const contentType = readNonEmptyString(body.content_type) ?? inferAttachmentContentType(fileName);
4904
+ if (!isUploadableInspirationVideo(fileName, contentType)) {
4905
+ return c.json({ ok: false, error: "Upload a video file (MP4, MOV, or WebM)." }, 400);
4906
+ }
4907
+ const sizeBytes = Number(body.size_bytes);
4908
+ if (Number.isFinite(sizeBytes) && sizeBytes > INSPIRATION_UPLOAD_MAX_BYTES) {
4909
+ return c.json({ ok: false, error: "Video uploads can be at most 200 MB." }, 400);
4910
+ }
4911
+ const uploadId = randomUUID();
4912
+ const storageKey = storage.inspirationUploadKey(customer.id, uploadId, fileName);
4913
+ if (config.STORAGE_DRIVER !== "s3") {
4914
+ return c.json({
4915
+ transport: "server",
4916
+ upload_id: uploadId,
4917
+ file_name: fileName,
4918
+ content_type: contentType,
4919
+ storage_key: storageKey,
4920
+ upload: {
4921
+ method: "POST",
4922
+ url: "/discover/templates/upload",
4923
+ form_field: "file"
4924
+ }
4925
+ });
4926
+ }
4927
+ const upload = await storage.createWriteUrl(storageKey, { contentType, expiresIn: 3600 });
4928
+ return c.json({
4929
+ transport: "presigned",
4930
+ upload_id: uploadId,
4931
+ file_name: fileName,
4932
+ content_type: contentType,
4933
+ storage_key: storageKey,
4934
+ upload: {
4935
+ method: upload.method,
4936
+ url: upload.url,
4937
+ headers: upload.headers,
4938
+ expires_in_seconds: upload.expiresIn
4939
+ }
4940
+ });
4941
+ });
4942
+ // Upload step 2 (multipart fallback): only used when presign returned
4943
+ // transport "server" — i.e. the local storage driver (vidfarm serve without
4944
+ // S3). Deliberately not proxied upstream: the upstream proxy is text-only,
4945
+ // and in passthrough mode the presign already handed the client a direct
4946
+ // cloud target.
4947
+ app.post("/discover/templates/upload", async (c) => {
4948
+ const customer = await requireBrowserCustomer(c);
4949
+ if (!customer)
4950
+ return c.json({ ok: false, error: "Log in to add templates." }, 401);
4951
+ const formData = await c.req.formData().catch(() => null);
4952
+ const file = formData?.get("file");
4953
+ if (!isFormFile(file))
4954
+ return c.json({ ok: false, error: "Select a video file to upload." }, 400);
4955
+ const fileName = sanitizeFileName(file.name || "upload.mp4");
4956
+ const contentType = file.type || inferAttachmentContentType(fileName);
4957
+ if (!isUploadableInspirationVideo(fileName, contentType)) {
4958
+ return c.json({ ok: false, error: "Upload a video file (MP4, MOV, or WebM)." }, 400);
4959
+ }
4960
+ const buffer = Buffer.from(await file.arrayBuffer());
4961
+ if (buffer.byteLength > INSPIRATION_UPLOAD_MAX_BYTES) {
4962
+ return c.json({ ok: false, error: "Video uploads can be at most 200 MB." }, 400);
4963
+ }
4964
+ const uploadId = randomUUID();
4965
+ const storageKey = storage.inspirationUploadKey(customer.id, uploadId, fileName);
4966
+ const stored = await storage.putBuffer(storageKey, buffer, contentType);
4967
+ return c.json({
4968
+ ok: true,
4969
+ storage_key: storageKey,
4970
+ file_name: fileName,
4971
+ content_type: contentType,
4972
+ size_bytes: buffer.byteLength,
4973
+ url: stored.url
4974
+ }, 201);
4975
+ });
4976
+ // Discover "Add Template": ingest a social video URL — or a video the caller
4977
+ // just uploaded via the presign/upload pair above — as a PRIVATE inspiration
4978
+ // owned by the caller. The download/ingest job runs async; /discover/feed
4979
+ // finalizes it and mints the private template once the video lands.
4980
+ app.post("/discover/templates", async (c) => {
4981
+ const customer = await requireBrowserCustomer(c);
4982
+ if (!customer)
4983
+ return c.json({ ok: false, error: "Log in to add templates." }, 401);
4984
+ const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
4985
+ const uploadRef = asRecord(body.upload);
4986
+ const uploadStorageKey = readNonEmptyString(uploadRef?.storage_key);
4987
+ // Cloud passthrough (vidfarm serve): Add Template ingests into the CLOUD
4988
+ // account — the download/ingest job, billing, and catalog entry all live
4989
+ // upstream, and the proxied /discover/feed finalizes it. The one exception:
4990
+ // an upload key minted by THIS box (local presign fallback while upstream
4991
+ // was unreachable) — its bytes only exist locally, so ingest locally.
4992
+ if (hasUpstreamKey() && !(uploadStorageKey && isOwnInspirationUploadKey(uploadStorageKey, customer.id))) {
4993
+ const proxied = await proxyRequestToUpstream(c);
4994
+ if (proxied)
4995
+ return proxied;
4996
+ }
4997
+ if (uploadStorageKey) {
4998
+ if (!isOwnInspirationUploadKey(uploadStorageKey, customer.id)) {
4999
+ return c.json({ ok: false, error: "That upload doesn't belong to this account." }, 403);
5000
+ }
5001
+ const uploadUrl = storage.getPublicUrl(uploadStorageKey);
5002
+ if (!uploadUrl)
5003
+ return c.json({ ok: false, error: "Uploaded video could not be resolved." }, 400);
5004
+ const explicitTitle = readNonEmptyString(body.title) ?? null;
5005
+ const tagline = readNonEmptyString(body.tagline) ?? readNonEmptyString(body.trend_tagline) ?? null;
5006
+ const notes = readNonEmptyString(body.notes) ?? null;
5007
+ const uploadFileName = readNonEmptyString(uploadRef?.file_name) ?? null;
5008
+ const primitive = primitiveRegistry.get("primitive:video_ingest");
5009
+ if (!primitive)
5010
+ return c.json({ ok: false, error: "Video ingest primitive not available" }, 500);
5011
+ const operation = primitive.operations["run"];
5012
+ if (!operation)
5013
+ return c.json({ ok: false, error: "Video ingest operation not available" }, 500);
5014
+ // Only a caller-chosen title flows into the manifest — never the file
5015
+ // name. An untitled upload finalizes with title null so its /discover
5016
+ // card displays the minted template id.
5017
+ const payload = operation.inputSchema.parse({
5018
+ source_video_url: uploadUrl,
5019
+ title: explicitTitle ?? tagline ?? undefined,
5020
+ file_name: uploadFileName ?? undefined,
5021
+ save_manifest: true
5022
+ });
5023
+ let job;
5024
+ try {
5025
+ await assertWalletCanQueueJobs(customer.id);
5026
+ job = await jobs.createRootJob({
5027
+ templateId: primitive.id,
5028
+ operationName: "run",
5029
+ workflowName: operation.workflow,
5030
+ tracer: `discover_template_upload:${customer.id}`,
5031
+ payload,
5032
+ webhookUrl: null,
5033
+ customer,
5034
+ providerHint: undefined
5035
+ });
5036
+ }
5037
+ catch (error) {
5038
+ if (error instanceof InsufficientWalletFundsError)
5039
+ return insufficientFundsResponse(c, error);
5040
+ if (error instanceof ActiveJobLimitExceededError)
5041
+ return activeJobLimitExceededResponse(c, error);
5042
+ throw error;
5043
+ }
5044
+ // No Step Functions worker on a local (vidfarm serve) box — run the ingest
5045
+ // in-process, detached, exactly like local renders. /discover/feed polls
5046
+ // finalize the inspiration when the job lands.
5047
+ if (!config.VIDFARM_JOB_STATE_MACHINE_ARN) {
5048
+ void runPrimitiveJobInProcess(job.id).catch((error) => {
5049
+ console.error("local video ingest failed", job.id, error instanceof Error ? error.message : error);
5050
+ });
5051
+ }
5052
+ // Every upload key is unique (uuid segment), so no dedupe pass: two
5053
+ // uploads of the same file are two intentional inspirations.
5054
+ let inspiration = await serverlessRecords.createInspiration({
5055
+ customerId: customer.id,
5056
+ sourceUrl: uploadUrl,
5057
+ originalUrl: uploadUrl,
5058
+ sourceHost: "upload",
5059
+ downloadJobId: job.id,
5060
+ trendTagline: tagline,
5061
+ visibility: "private",
5062
+ notes
5063
+ });
5064
+ // Pending-card label while the ingest runs: explicit title, else the file
5065
+ // name. Finalize overwrites this from the manifest, so an untitled upload
5066
+ // ends at title null → the card shows the minted template id.
5067
+ const pendingTitle = explicitTitle ?? uploadFileName;
5068
+ if (pendingTitle) {
5069
+ inspiration = (await serverlessRecords.updateInspiration({
5070
+ inspirationId: inspiration.id,
5071
+ patch: { title: pendingTitle }
5072
+ })) ?? inspiration;
5073
+ }
5074
+ return c.json(serializeInspiration(inspiration), 202);
5075
+ }
4862
5076
  const sourceUrl = readNonEmptyString(body.source_url);
4863
5077
  if (!sourceUrl)
4864
- return c.json({ ok: false, error: "Enter a video URL." }, 400);
5078
+ return c.json({ ok: false, error: "Enter a video URL or upload a video file." }, 400);
4865
5079
  const originalUrl = stripTrackingParams(sourceUrl);
4866
5080
  const sourceHost = safeUrlHostname(originalUrl);
4867
5081
  if (!sourceHost)
@@ -4908,6 +5122,13 @@ app.post("/discover/templates", async (c) => {
4908
5122
  return activeJobLimitExceededResponse(c, error);
4909
5123
  throw error;
4910
5124
  }
5125
+ // Same local-box treatment as the upload branch: no Step Functions worker
5126
+ // means the download job must run in-process or it queues forever.
5127
+ if (!config.VIDFARM_JOB_STATE_MACHINE_ARN) {
5128
+ void runPrimitiveJobInProcess(job.id).catch((error) => {
5129
+ console.error("local video download failed", job.id, error instanceof Error ? error.message : error);
5130
+ });
5131
+ }
4911
5132
  const inspiration = await serverlessRecords.createInspiration({
4912
5133
  customerId: customer.id,
4913
5134
  sourceUrl,
@@ -4975,13 +5196,21 @@ app.delete("/discover/templates/:entryId", async (c) => {
4975
5196
  // client-side via status.
4976
5197
  function buildPendingInspirationHomepageEntry(inspiration) {
4977
5198
  return {
4978
- title: inspiration.trendTagline || inspiration.title || inspiration.sourceHost || "New template",
5199
+ // Uploads: explicit title (or file name, stored at submit time) outranks
5200
+ // the tagline; URL ingests keep tagline-first.
5201
+ title: (inspiration.sourceHost === "upload"
5202
+ ? (inspiration.title || inspiration.trendTagline)
5203
+ : (inspiration.trendTagline || inspiration.title)) || inspiration.sourceHost || "New template",
4979
5204
  templateId: inspiration.id,
4980
5205
  slugId: inspiration.id,
4981
5206
  difficulty: "easy",
4982
5207
  viralDna: inspiration.status === "failed"
4983
- ? (inspiration.errorMessage || "Video download failed. Check the URL and try adding it again.")
4984
- : (inspiration.notes || "Downloading the source video. This card unlocks automatically once it's ready."),
5208
+ ? (inspiration.errorMessage || (inspiration.sourceHost === "upload"
5209
+ ? "Video ingest failed. Try uploading the file again."
5210
+ : "Video download failed. Check the URL and try adding it again."))
5211
+ : (inspiration.notes || (inspiration.sourceHost === "upload"
5212
+ ? "Processing your uploaded video. This card unlocks automatically once it's ready."
5213
+ : "Downloading the source video. This card unlocks automatically once it's ready.")),
4985
5214
  previewUrl: inspiration.thumbnailUrl || "",
4986
5215
  approvedAt: inspiration.createdAt,
4987
5216
  sourceType: inspiration.sourceHost || "social",
@@ -5676,6 +5905,42 @@ async function recordCastLambdaUsage(input) {
5676
5905
  console.error("cast billing failed", error instanceof Error ? error.message : error);
5677
5906
  }
5678
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
+ }
5679
5944
  async function snapshotForkVersion(input) {
5680
5945
  const version = (input.fork.latestVersion ?? 0) + 1;
5681
5946
  // Bound namespace growth for user-initiated snapshots. Internal reasons
@@ -5692,6 +5957,9 @@ async function snapshotForkVersion(input) {
5692
5957
  // Placement surfaces travel with every version too (reusable decompose
5693
5958
  // metadata, same as cast identification).
5694
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"));
5695
5963
  if (workingHtml) {
5696
5964
  await storage.putText(storage.compositionForkVersionKey(input.fork.id, version, "composition.html"), workingHtml, "text/html; charset=utf-8");
5697
5965
  }
@@ -5704,6 +5972,9 @@ async function snapshotForkVersion(input) {
5704
5972
  if (workingPlacement) {
5705
5973
  await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "product-placement.json"), Buffer.from(workingPlacement, "utf8"), "application/json");
5706
5974
  }
5975
+ if (workingAnnotations) {
5976
+ await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "scene-annotations.json"), Buffer.from(workingAnnotations, "utf8"), "application/json");
5977
+ }
5707
5978
  const versionRecord = await serverlessRecords.createForkVersion({
5708
5979
  forkId: input.fork.id,
5709
5980
  ownerCustomerId: input.fork.customerId,
@@ -5794,6 +6065,7 @@ app.post(COMPOSITIONS_PREFIX, async (c) => {
5794
6065
  // identified people + reference stills as the shared default composition.
5795
6066
  const cast = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "cast.json"));
5796
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"));
5797
6069
  if (html) {
5798
6070
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), html, "text/html; charset=utf-8");
5799
6071
  }
@@ -5806,6 +6078,9 @@ app.post(COMPOSITIONS_PREFIX, async (c) => {
5806
6078
  if (placement) {
5807
6079
  await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
5808
6080
  }
6081
+ if (annotations) {
6082
+ await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
6083
+ }
5809
6084
  }
5810
6085
  else if (template.videoUrl) {
5811
6086
  // Template exists but has no canonical fork yet — seed with raw HTML.
@@ -6170,6 +6445,20 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
6170
6445
  catch (placementPersistError) {
6171
6446
  console.error("inspiration product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
6172
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
+ }
6173
6462
  await writeForkManifest(storage, canonicalFork, { kind: "working" });
6174
6463
  await snapshotForkVersion({
6175
6464
  fork: canonicalFork,
@@ -6552,6 +6841,21 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6552
6841
  catch (placementPersistError) {
6553
6842
  console.error("product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
6554
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
+ }
6555
6859
  // Seed the cast-identification 2nd pass as "pending" and let the client
6556
6860
  // drive it via POST /cast-poll (same async model as ghostcut). We identify
6557
6861
  // cast from the same caption-free analysis source the vision pass used, so
@@ -7102,6 +7406,204 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/product-placement.json`, async (c) => {
7102
7406
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
7103
7407
  }
7104
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
+ });
7105
7607
  // Drives the async cast-identification 2nd pass one unit of work per call
7106
7608
  // (analyze -> per-member still -> per-member isolation -> done), mirroring
7107
7609
  // /remove-video-captions-poll. Each invocation makes at most one heavy external call so the
@@ -7747,6 +8249,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
7747
8249
  const json = await readVersionFile("composition.json");
7748
8250
  const cast = await readVersionFile("cast.json");
7749
8251
  const placement = await readVersionFile("product-placement.json");
8252
+ const annotations = await readVersionFile("scene-annotations.json");
7750
8253
  if (html) {
7751
8254
  await storage.putText(storage.compositionForkWorkingKey(cloned.id, "composition.html"), html, "text/html; charset=utf-8");
7752
8255
  }
@@ -7759,6 +8262,9 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
7759
8262
  if (placement) {
7760
8263
  await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
7761
8264
  }
8265
+ if (annotations) {
8266
+ await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
8267
+ }
7762
8268
  await snapshotForkVersion({
7763
8269
  fork: cloned,
7764
8270
  callerCustomerId: customer.id,
@@ -7781,8 +8287,15 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/versions`, async (c) => {
7781
8287
  const limit = Number.parseInt(c.req.query("limit") ?? "25", 10);
7782
8288
  const cursor = c.req.query("cursor") ?? null;
7783
8289
  const page = await serverlessRecords.listForkVersions({ forkId: access.fork.id, limit, cursor });
8290
+ // Fork content is world-viewable, but the snapshot trail is personal.
8291
+ // /editor parks users without their own fork on the template's shared
8292
+ // default fork, so without this scoping the History modal would list the
8293
+ // fork owner's (i.e. another user's) publish/decompose history.
8294
+ const visible = access.capabilities.isOwner
8295
+ ? page.items
8296
+ : page.items.filter((version) => Boolean(customer && version.createdBy === customer.id));
7784
8297
  return c.json({
7785
- versions: page.items.map(serializeVersion),
8298
+ versions: visible.map(serializeVersion),
7786
8299
  next_cursor: page.nextCursor
7787
8300
  });
7788
8301
  }
@@ -7945,7 +8458,14 @@ async function finalizeInspirationIfReady(inspiration) {
7945
8458
  customerId: ready.customerId,
7946
8459
  originalUrl: ready.originalUrl,
7947
8460
  sourceHost: ready.sourceHost,
7948
- title: ready.trendTagline || ready.title || ready.sourceHost || "Untitled",
8461
+ // No host/"Untitled" fallback: a template without an explicit title or
8462
+ // tagline displays as its template id on /discover. For uploads the
8463
+ // manifest title IS the caller's explicit title, so it outranks the
8464
+ // tagline; URL ingests keep tagline-first (manifest title is just the
8465
+ // social post's own caption).
8466
+ title: (ready.sourceHost === "upload"
8467
+ ? (ready.title || ready.trendTagline)
8468
+ : (ready.trendTagline || ready.title)) || null,
7949
8469
  thumbnailUrl: ready.thumbnailUrl,
7950
8470
  videoUrl: ready.videoUrl,
7951
8471
  durationSeconds: ready.durationSeconds,
@@ -8138,6 +8658,7 @@ app.post(`${VIDEOS_PREFIX}/:inspirationId/decompose`, async (c) => {
8138
8658
  const json = await readSource("composition.json");
8139
8659
  const cast = await readSource("cast.json");
8140
8660
  const placement = await readSource("product-placement.json");
8661
+ const annotations = await readSource("scene-annotations.json");
8141
8662
  if (html)
8142
8663
  await storage.putText(storage.compositionForkWorkingKey(cloned.id, "composition.html"), html, "text/html; charset=utf-8");
8143
8664
  if (json)
@@ -8146,6 +8667,8 @@ app.post(`${VIDEOS_PREFIX}/:inspirationId/decompose`, async (c) => {
8146
8667
  await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
8147
8668
  if (placement)
8148
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");
8149
8672
  await snapshotForkVersion({
8150
8673
  fork: cloned,
8151
8674
  callerCustomerId: customer.id,
@@ -10229,6 +10752,13 @@ app.use(`${API_PREFIX}/*`, async (c, next) => {
10229
10752
  });
10230
10753
  app.use(`${USER_PREFIX}/me`, requireAuth);
10231
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)));
10232
10762
  app.use(AGENCY_PREFIX, requireAuth);
10233
10763
  app.use(`${AGENCY_PREFIX}/*`, requireAuth);
10234
10764
  app.use(`${API_PREFIX}/rate-limit-status`, requireAuth);
@@ -14405,6 +14935,574 @@ app.post(`${USER_PREFIX}/me/provider-keys`, async (c) => {
14405
14935
  });
14406
14936
  return c.json({ ok: true }, 201);
14407
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
+ });
14408
15506
  app.get(`${TEMPLATES_PREFIX}/sources`, async (c) => {
14409
15507
  try {
14410
15508
  requireAdmin(c);