@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.
- package/SKILL.director.md +43 -10
- package/SKILL.platform.md +5 -3
- package/demo/dist/app.js +107 -57
- package/dist/src/app.js +1115 -17
- package/dist/src/cli.js +304 -43
- package/dist/src/composition-runtime.js +26 -0
- package/dist/src/config.js +6 -0
- package/dist/src/devcli/clip-store.js +335 -0
- package/dist/src/devcli/clips.js +803 -0
- package/dist/src/devcli/composition-edit.js +12 -0
- package/dist/src/editor-chat.js +1 -0
- package/dist/src/frontend/homepage-client.js +61 -6
- package/dist/src/frontend/homepage-view.js +22 -4
- package/dist/src/homepage.js +46 -0
- package/dist/src/hyperframes/composition.js +87 -0
- package/dist/src/primitive-registry.js +136 -0
- package/dist/src/services/billing.js +1 -0
- package/dist/src/services/clip-curation/cost.js +112 -0
- package/dist/src/services/clip-curation/ffmpeg.js +258 -0
- package/dist/src/services/clip-curation/gemini.js +342 -0
- package/dist/src/services/clip-curation/index.js +15 -0
- package/dist/src/services/clip-curation/presets.js +20 -0
- package/dist/src/services/clip-curation/presets.v1.json +59 -0
- package/dist/src/services/clip-curation/query.js +163 -0
- package/dist/src/services/clip-curation/refine.js +136 -0
- package/dist/src/services/clip-curation/scan.js +137 -0
- package/dist/src/services/clip-curation/taxonomy.js +71 -0
- package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
- package/dist/src/services/clip-curation/types.js +7 -0
- package/dist/src/services/clip-records.js +209 -0
- package/dist/src/services/clip-search.js +47 -0
- package/dist/src/services/clip-vectors.js +125 -0
- package/dist/src/services/hyperframes.js +373 -1
- package/dist/src/services/scene-annotations.js +32 -0
- package/dist/src/services/storage.js +6 -0
- package/package.json +5 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
4846
|
-
//
|
|
4847
|
-
//
|
|
4848
|
-
|
|
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
|
-
|
|
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 ||
|
|
4984
|
-
|
|
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:
|
|
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
|
-
|
|
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. "someone looks confused after reading a message"" 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("video"); if(v){v.style.display="block";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("video"); if(v){v.pause();v.style.display="none";}">'+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);
|