@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
@@ -0,0 +1,47 @@
1
+ // Cloud clip search — the two-stage flow (structured filter → semantic re-rank)
2
+ // wired over DynamoDB records + the configured vector index. Same result shape
3
+ // as the devcli store so the API mirrors the CLI (success criterion #4).
4
+ import { matchesCriteria, rankHits, scoreClip, searchClips } from "./clip-curation/index.js";
5
+ import { clipRecords } from "./clip-records.js";
6
+ import { resolveClipVectorIndex } from "./clip-vectors.js";
7
+ const vectorIndex = resolveClipVectorIndex((owner) => clipRecords.listClipsWithEmbeddings(owner));
8
+ export async function searchUserClips(owner, input) {
9
+ const limit = input.limit ?? 20;
10
+ const all = await clipRecords.listClips(owner, { limit: 5000 });
11
+ const structured = all.filter((c) => matchesCriteria(c, input.criteria));
12
+ if (structured.length > 0) {
13
+ // Semantic re-rank the survivors (embeddings already ride on the records).
14
+ const hits = structured.map((c) => scoreClip(c, input.criteria, input.queryEmbedding, false));
15
+ return rankHits(hits, limit);
16
+ }
17
+ // No structured match — relax to a semantic-only pass via the vector index.
18
+ if (input.queryEmbedding) {
19
+ const knn = await vectorIndex.query(owner, input.queryEmbedding, Math.max(limit * 5, 50));
20
+ const byId = new Map(all.map((c) => [c.clip_id, c]));
21
+ const pool = knn.map((h) => byId.get(h.clip_id)).filter((c) => Boolean(c));
22
+ const hits = pool.map((c) => scoreClip(c, input.criteria, input.queryEmbedding, true));
23
+ return rankHits(hits, limit);
24
+ }
25
+ return [];
26
+ }
27
+ export { vectorIndex };
28
+ /**
29
+ * Match many scene annotations against the owner's clip library at once. Loads
30
+ * the library ONCE (embeddings ride on the records) and runs each scene's
31
+ * structured filter + semantic re-rank in memory — so a whole decomposed video
32
+ * costs one library read plus M cheap in-memory ranks.
33
+ */
34
+ export async function matchScenesToClips(owner, queries) {
35
+ const all = await clipRecords.listClips(owner, { limit: 5000 });
36
+ return queries.map((q) => ({
37
+ id: q.id,
38
+ meta: q.meta,
39
+ matches: searchClips(all, {
40
+ criteria: q.criteria,
41
+ queryEmbedding: q.queryEmbedding,
42
+ limit: q.limit ?? 5,
43
+ relaxWhenEmpty: true
44
+ })
45
+ }));
46
+ }
47
+ //# sourceMappingURL=clip-search.js.map
@@ -0,0 +1,125 @@
1
+ // Vector index abstraction for cloud clip search. Two backends behind one
2
+ // interface so the pipeline is portable and cloud infra cost stays deferrable:
3
+ //
4
+ // • "dynamo" (default) — embeddings live on the clip records in DynamoDB;
5
+ // search brute-forces cosine in memory. Zero extra infra, works with the
6
+ // local-dynamo shim in `serve` mode. Fine to thousands of clips per user.
7
+ // • "s3vectors" — Amazon S3 Vectors index (handoff target). Best-effort:
8
+ // dynamically imports @aws-sdk/client-s3vectors and errors clearly if the
9
+ // dep/config is absent, so selecting it is opt-in.
10
+ //
11
+ // Both return the same {clip_id, similarity} shape; the caller applies the
12
+ // structured filter + final scoring with the shared query builder.
13
+ import { cosineSimilarity } from "./clip-curation/index.js";
14
+ import { config } from "../config.js";
15
+ /** Default backend: brute-force cosine over the owner's clip embeddings in DynamoDB. */
16
+ export class DynamoVectorIndex {
17
+ loadEmbeddings;
18
+ backend = "dynamo";
19
+ constructor(loadEmbeddings) {
20
+ this.loadEmbeddings = loadEmbeddings;
21
+ }
22
+ async upsert() {
23
+ /* embedding is stored on the clip record itself */
24
+ }
25
+ async delete() {
26
+ /* removed with the clip record */
27
+ }
28
+ async query(owner, queryEmbedding, k) {
29
+ const clips = await this.loadEmbeddings(owner);
30
+ return clips
31
+ .map((c) => ({ clip_id: c.clip_id, similarity: cosineSimilarity(c.embedding, queryEmbedding) }))
32
+ .sort((a, b) => b.similarity - a.similarity)
33
+ .slice(0, k);
34
+ }
35
+ }
36
+ /**
37
+ * Amazon S3 Vectors backend. Vectors are keyed by clip_id within a per-owner
38
+ * partition; metadata carries source_video_id for filtering. Requires
39
+ * VIDFARM_CLIP_VECTORS_BUCKET + VIDFARM_CLIP_VECTORS_INDEX and the
40
+ * @aws-sdk/client-s3vectors package.
41
+ */
42
+ export class S3VectorsIndex {
43
+ bucket;
44
+ indexName;
45
+ backend = "s3vectors";
46
+ clientPromise = null;
47
+ constructor(bucket, indexName) {
48
+ this.bucket = bucket;
49
+ this.indexName = indexName;
50
+ }
51
+ async client() {
52
+ if (!this.clientPromise) {
53
+ this.clientPromise = import("@aws-sdk/client-s3vectors").catch(() => {
54
+ throw new Error("S3 Vectors backend selected but @aws-sdk/client-s3vectors is not installed. `npm i @aws-sdk/client-s3vectors` or set VIDFARM_CLIP_VECTOR_BACKEND=dynamo.");
55
+ });
56
+ }
57
+ const mod = await this.clientPromise;
58
+ return { mod, client: new mod.S3VectorsClient({}) };
59
+ }
60
+ async upsert(owner, clip) {
61
+ if (!clip.embedding?.length)
62
+ return;
63
+ const { mod, client } = await this.client();
64
+ await client.send(new mod.PutVectorsCommand({
65
+ vectorBucketName: this.bucket,
66
+ indexName: this.indexName,
67
+ vectors: [
68
+ {
69
+ key: vectorKey(owner, clip.clip_id),
70
+ data: { float32: clip.embedding },
71
+ metadata: { owner, source_video_id: clip.source_video_id, clip_id: clip.clip_id }
72
+ }
73
+ ]
74
+ }));
75
+ }
76
+ async delete(owner, clipId) {
77
+ const { mod, client } = await this.client();
78
+ await client.send(new mod.DeleteVectorsCommand({ vectorBucketName: this.bucket, indexName: this.indexName, keys: [vectorKey(owner, clipId)] }));
79
+ }
80
+ async query(owner, queryEmbedding, k) {
81
+ const { mod, client } = await this.client();
82
+ const res = await client.send(new mod.QueryVectorsCommand({
83
+ vectorBucketName: this.bucket,
84
+ indexName: this.indexName,
85
+ topK: k,
86
+ queryVector: { float32: queryEmbedding },
87
+ filter: { owner },
88
+ returnMetadata: true,
89
+ returnDistance: true
90
+ }));
91
+ const vectors = res.vectors ?? [];
92
+ return vectors.map((v) => ({
93
+ clip_id: v.metadata?.clip_id ?? stripVectorKey(v.key),
94
+ // S3 Vectors returns cosine DISTANCE (0..2); convert to similarity.
95
+ similarity: 1 - Number(v.distance ?? 0)
96
+ }));
97
+ }
98
+ }
99
+ function vectorKey(owner, clipId) {
100
+ return `${owner}/${clipId}`;
101
+ }
102
+ function stripVectorKey(key) {
103
+ const idx = key.lastIndexOf("/");
104
+ return idx >= 0 ? key.slice(idx + 1) : key;
105
+ }
106
+ let cached = null;
107
+ /** Resolve the configured vector index (default: dynamo brute-force). */
108
+ export function resolveClipVectorIndex(loadEmbeddings) {
109
+ if (cached)
110
+ return cached;
111
+ const backend = (process.env.VIDFARM_CLIP_VECTOR_BACKEND ?? "dynamo").toLowerCase();
112
+ if (backend === "s3vectors") {
113
+ const bucket = process.env.VIDFARM_CLIP_VECTORS_BUCKET;
114
+ const index = process.env.VIDFARM_CLIP_VECTORS_INDEX;
115
+ if (bucket && index) {
116
+ cached = new S3VectorsIndex(bucket, index);
117
+ return cached;
118
+ }
119
+ // Missing config → fall back to the always-available dynamo backend.
120
+ }
121
+ cached = new DynamoVectorIndex(loadEmbeddings);
122
+ void config; // config imported for parity with sibling services / future use
123
+ return cached;
124
+ }
125
+ //# sourceMappingURL=clip-vectors.js.map
@@ -13,8 +13,14 @@ import ffprobeStatic from "ffprobe-static";
13
13
  import { parseHTML } from "linkedom";
14
14
  import { config } from "../config.js";
15
15
  import { devErrorFields, devLog } from "../lib/dev-log.js";
16
- import { buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
16
+ import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
17
17
  import { applyMarkupUsd } from "./billing-pricing.js";
18
+ // The scene-annotation pass reuses the clip library's controlled vocabulary and
19
+ // embedding-text recipe verbatim so a source scene's clip-match query is
20
+ // symmetric with how a candidate clip was tagged/embedded. This is the ONE
21
+ // coupling to the (parallel) clip-curation work — a plain leaf import; the
22
+ // retrieval logic itself (search over these criteria) is merged in later.
23
+ import { buildClipEmbeddingText, sanitizeTagValues, taxonomyForPrompt } from "./clip-curation/index.js";
18
24
  let stackCache = null;
19
25
  const SMART_DECOMPOSE_TEMP_PREFIX = "vidfarm-smart-decompose-";
20
26
  const SMART_DECOMPOSE_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
@@ -477,6 +483,37 @@ if (ffmpegBin && !process.env.HYPERFRAMES_FFMPEG_PATH?.trim()) {
477
483
  if (ffprobeBin && !process.env.HYPERFRAMES_FFPROBE_PATH?.trim()) {
478
484
  process.env.HYPERFRAMES_FFPROBE_PATH = ffprobeBin;
479
485
  }
486
+ // The ARCHETYPE / production style of a source video. Detected once per
487
+ // decompose and used two ways: (a) stamped onto the template metadata so the
488
+ // catalog + clip matcher can filter by format, and (b) to CONDITION the
489
+ // per-scene clip annotations, because what counts as a scene's "literal DNA"
490
+ // differs by format — an AI fruit drama is defined by its stylized character +
491
+ // action, a UGC trend by the creator's face/gesture/authenticity, an
492
+ // educational series by the board/diagram/screen, live action by real-world
493
+ // subjects + cinematography. Controlled + extendable; ambiguous videos fall back
494
+ // to "other".
495
+ export const CONTENT_FORMATS = [
496
+ "ai_fruit_drama", // AI-generated surreal food/object dramas ("fruit drama")
497
+ "ai_generative_skit", // other AI-generated character skits / dramas
498
+ "ugc_trend", // phone-shot creator-to-camera trend/challenge
499
+ "talking_head", // single speaker addressing camera (advice/opinion)
500
+ "educational_series", // explainer / how-to / lecture / listicle
501
+ "live_action", // real-world filmed scenes, actors, cinematography
502
+ "product_demo", // hands-on showcase / unboxing / review
503
+ "street_interview", // vox-pop / man-on-the-street
504
+ "reaction_commentary", // reacting to / narrating other footage
505
+ "cinematic_broll", // moody establishing / atmosphere / montage
506
+ "screen_recording", // app/screen capture, tutorials, gameplay
507
+ "animation_motion_graphics", // 2D/3D animation, kinetic typography
508
+ "other"
509
+ ];
510
+ export const DEFAULT_CONTENT_FORMAT = {
511
+ format: "other",
512
+ label: "Unclassified",
513
+ confidence: 0,
514
+ rationale: "",
515
+ secondary: []
516
+ };
480
517
  const RUN_BINARY_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
481
518
  function runBinary(bin, args) {
482
519
  return new Promise((resolve, reject) => {
@@ -1003,6 +1040,311 @@ async function runPlacementSurfacePass(input) {
1003
1040
  const attempt = await callVisionWithFallback({ prompt, frames: input.frames, keys: input.keys });
1004
1041
  return normalizePlacementSurfaces(attempt.result);
1005
1042
  }
1043
+ // ---------------------------------------------------------------------------
1044
+ // Scene-annotation pass — clip-replacement DNA.
1045
+ //
1046
+ // A dependent vision pass (runs AFTER scenes are finalized, so annotations line
1047
+ // up 1:1 by scene slug) that (1) classifies the whole video's content FORMAT and
1048
+ // (2) for each scene emits the literal + viral DNA needed to find a REPLACEMENT
1049
+ // clip from the clip library. It reuses the sparse frames already sampled for
1050
+ // the main pass (no extra ffmpeg) and the clip library's own controlled tag
1051
+ // vocabulary + embedding recipe, so a scene's clip-match query is symmetric with
1052
+ // how a candidate clip was tagged/embedded. Non-fatal: callers wrap in .catch().
1053
+ // ---------------------------------------------------------------------------
1054
+ // Vocabulary for a scene's role in the retention arc. Constrained in the prompt,
1055
+ // clamped here; free-form-ish (unknown → "context") so it never blocks output.
1056
+ const SCENE_ROLE_VOCAB = [
1057
+ "hook", "build", "reveal", "payoff", "transition", "cta", "proof", "context", "punchline", "loop"
1058
+ ];
1059
+ function asPlainRecord(value) {
1060
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1061
+ }
1062
+ function readAnnotationString(value) {
1063
+ return typeof value === "string" ? value.trim() : "";
1064
+ }
1065
+ function readAnnotationStringList(value, max) {
1066
+ if (!Array.isArray(value))
1067
+ return [];
1068
+ const out = [];
1069
+ for (const v of value) {
1070
+ const s = typeof v === "string" ? v.trim() : "";
1071
+ if (s && !out.includes(s))
1072
+ out.push(s);
1073
+ if (out.length >= max)
1074
+ break;
1075
+ }
1076
+ return out;
1077
+ }
1078
+ function humanizeContentFormat(format) {
1079
+ return format
1080
+ .split("_")
1081
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1082
+ .join(" ");
1083
+ }
1084
+ function normalizeContentFormat(raw) {
1085
+ const rec = asPlainRecord(raw);
1086
+ const slug = normalizeSlug(readAnnotationString(rec.format));
1087
+ const format = CONTENT_FORMATS.includes(slug)
1088
+ ? slug
1089
+ : "other";
1090
+ const secondaryRaw = Array.isArray(rec.secondary) ? rec.secondary : [];
1091
+ const secondary = Array.from(new Set(secondaryRaw
1092
+ .map((s) => normalizeSlug(readAnnotationString(s)))
1093
+ .filter((s) => CONTENT_FORMATS.includes(s) && s !== format))).slice(0, 3);
1094
+ const conf = Number(rec.confidence);
1095
+ const label = readAnnotationString(rec.label);
1096
+ return {
1097
+ format,
1098
+ label: label || humanizeContentFormat(format),
1099
+ confidence: Number.isFinite(conf) ? Math.max(0, Math.min(1, conf)) : 0,
1100
+ rationale: readAnnotationString(rec.rationale),
1101
+ secondary
1102
+ };
1103
+ }
1104
+ function normalizeSceneRole(value) {
1105
+ const slug = normalizeSlug(readAnnotationString(value));
1106
+ return SCENE_ROLE_VOCAB.includes(slug) ? slug : "context";
1107
+ }
1108
+ function normalizeReplaceability(value) {
1109
+ const s = readAnnotationString(value).toLowerCase();
1110
+ return s === "high" || s === "low" ? s : "medium";
1111
+ }
1112
+ // Deterministic per-scene spoken text pulled from the transcript segments that
1113
+ // overlap the scene window — more reliable than asking the vision model to
1114
+ // re-transcribe, and keeps `tags.transcript` grounded in the audio pass.
1115
+ function transcriptForScene(transcript, scene) {
1116
+ if (!transcript || !Array.isArray(transcript.segments))
1117
+ return "";
1118
+ const end = scene.start + scene.duration;
1119
+ return transcript.segments
1120
+ .filter((seg) => seg.end > scene.start && seg.start < end)
1121
+ .map((seg) => seg.text.trim())
1122
+ .filter((t) => t.length > 0)
1123
+ .join(" ")
1124
+ .trim();
1125
+ }
1126
+ // Compose the retrieval query text. Leads with the clip-doc recipe (description +
1127
+ // tags) so it lands near literally-similar clip embeddings, then appends the
1128
+ // viral purpose so the vector also reflects the beat's INTENT — a replacement
1129
+ // must match both axes.
1130
+ function buildSceneEmbeddingText(input) {
1131
+ const literalDoc = buildClipEmbeddingText(input.tags, input.literal);
1132
+ const purpose = [
1133
+ input.scene_role ? `Role: ${input.scene_role}` : "",
1134
+ input.narrative_purpose,
1135
+ input.why_it_works
1136
+ ].filter((p) => p && p.length > 0).join(". ");
1137
+ return purpose ? `${literalDoc}\nPurpose: ${purpose}` : literalDoc;
1138
+ }
1139
+ // Turn a scene's literal tags into a ready-to-run SplicingCriteria for the clip
1140
+ // library. Tag constraints are the high-precision structured filter; the
1141
+ // duration floor lets candidates be trimmed to fit; has_subject/has_dialogue are
1142
+ // derived from the tags. semantic_text carries the embedding query for re-rank.
1143
+ function buildSceneMatchCriteria(tags, scene, semanticText) {
1144
+ const criteria = { semantic_text: semanticText };
1145
+ if (tags.subject.length)
1146
+ criteria.subject = tags.subject;
1147
+ if (tags.action.length)
1148
+ criteria.action = tags.action;
1149
+ if (tags.emotion.length)
1150
+ criteria.emotion = tags.emotion;
1151
+ if (tags.setting.length)
1152
+ criteria.setting = tags.setting;
1153
+ if (tags.composition.length)
1154
+ criteria.composition = tags.composition;
1155
+ if (tags.motion.length)
1156
+ criteria.motion = tags.motion;
1157
+ if (tags.audio_type.length)
1158
+ criteria.audio_type = tags.audio_type;
1159
+ if (tags.energy)
1160
+ criteria.energy = [tags.energy];
1161
+ if (Number.isFinite(scene.duration) && scene.duration > 0) {
1162
+ criteria.min_duration_sec = Number(Math.max(0, scene.duration * 0.6).toFixed(2));
1163
+ }
1164
+ const hasRealSubject = tags.subject.some((s) => s !== "no_subject");
1165
+ if (hasRealSubject)
1166
+ criteria.has_subject = true;
1167
+ else if (tags.subject.includes("no_subject"))
1168
+ criteria.has_subject = false;
1169
+ if (tags.audio_type.includes("dialogue"))
1170
+ criteria.has_dialogue = true;
1171
+ return criteria;
1172
+ }
1173
+ function buildSceneAnnotationPrompt(input) {
1174
+ const frameListing = input.frameTimestamps.length > 0
1175
+ ? `\nFRAMES SUPPLIED (in order, each captioned with its capture timestamp):\n${input.frameTimestamps.map((t, i) => ` frame ${i + 1} @ ${t.toFixed(2)}s`).join("\n")}\n`
1176
+ : "";
1177
+ const sceneListing = input.scenes.map((s) => (` - scene_slug="${s.slug}" @ ${s.start.toFixed(2)}s for ${s.duration.toFixed(2)}s — ${s.label}: ${s.description || "(no description)"}${s.viral_note ? ` [viral note: ${s.viral_note}]` : ""}`)).join("\n");
1178
+ const dna = input.viralDna;
1179
+ const dnaBlock = `Video-level viral DNA (context for each scene's PURPOSE):\n hook: ${dna.hook || "—"}\n retention: ${dna.retention || "—"}\n payoff: ${dna.payoff || "—"}\n preserve: ${(dna.preserve || []).join("; ") || "—"}\n avoid: ${(dna.avoid || []).join("; ") || "—"}`;
1180
+ const userGuidance = input.userPrompt && input.userPrompt.trim().length > 0
1181
+ ? `\nUSER GUIDANCE (highest priority):\n"""\n${input.userPrompt.trim()}\n"""\n`
1182
+ : "";
1183
+ return `You are Vidfarm's scene annotator. For EACH scene of this short-form vertical video, write an annotation rich enough to do TWO things: (A) find a REPLACEMENT clip from a searchable clip library — a different piece of footage that could stand in for this scene without breaking the video; and (B) let an AI agent RE-CREATE this scene from scratch with an image/video generator when no library clip fits.
1184
+
1185
+ Both goals hang on capturing the same TWO axes:
1186
+ 1) LITERAL DNA — what is physically on screen (subject, action, setting, shot, motion, mood). A candidate or generated clip must look and feel like this.
1187
+ 2) VIRAL / NARRATIVE DNA — the ROLE this beat plays in why the video works (the hook, the reveal, the payoff, a transition...). A replacement or regeneration must serve the same purpose in the story, or the swap kills retention.
1188
+ ${userGuidance}
1189
+ FIRST classify the whole video's FORMAT (its production archetype) — what counts as "literal DNA" differs by format. Pick the single best "format" and list any close seconds in "secondary". Allowed formats:
1190
+ ${CONTENT_FORMATS.join(", ")}
1191
+ Format guidance: ai_fruit_drama / ai_generative_skit = AI-generated surreal character dramas (e.g. talking fruit); ugc_trend = phone-shot creator-to-camera trend; talking_head = single speaker giving advice; educational_series = explainer / how-to / listicle; live_action = real filmed scenes / actors; product_demo = hands-on showcase / unboxing; street_interview = man-on-the-street; reaction_commentary = reacting to other footage; cinematic_broll = moody montage / atmosphere; screen_recording = app / screen capture; animation_motion_graphics = animation / kinetic text.
1192
+
1193
+ Video duration: ${input.durationSeconds.toFixed(2)}s. One-line summary: ${input.summary || "(none)"}.
1194
+ ${dnaBlock}
1195
+ ${frameListing}
1196
+ SCENES to annotate (return EXACTLY one annotation per scene_slug below, in this order):
1197
+ ${sceneListing}
1198
+
1199
+ For the LITERAL "tags", use ONLY values from this controlled vocabulary — it is the SAME vocabulary the clip library is tagged with, so your tags must line up with how a candidate clip would be tagged:
1200
+ ${taxonomyForPrompt()}
1201
+
1202
+ Return strictly valid JSON matching this schema:
1203
+ {
1204
+ "content_format": { "format": string, "label": string, "confidence": number, "rationale": string, "secondary": [string, ...] },
1205
+ "scenes": [{
1206
+ "scene_slug": string,
1207
+ "literal": string,
1208
+ "tags": { "subject": [string], "action": [string], "emotion": [string], "setting": [string], "composition": [string], "motion": [string], "energy": "low"|"medium"|"high", "audio_type": [string], "transcript": string },
1209
+ "scene_role": string,
1210
+ "narrative_purpose": string,
1211
+ "why_it_works": string,
1212
+ "must_preserve": [string, ...],
1213
+ "can_vary": [string, ...],
1214
+ "replaceability": "high"|"medium"|"low",
1215
+ "replaceability_reason": string,
1216
+ "recreation_prompt": string,
1217
+ "recreation_notes": [string, ...]
1218
+ }]
1219
+ }
1220
+
1221
+ RULES:
1222
+ - Output one scene object per scene_slug listed above, using the EXACT scene_slug strings. Do not invent, merge, split, or reorder scenes.
1223
+ - "literal": one concrete, factual sentence describing exactly what is on screen in this scene (subject + action + setting + shot). This is the primary match key — be specific and visual, not interpretive.
1224
+ - "tags": pick only vocabulary values you are confident about from the frames; omit a category (empty list) rather than guess. "energy" is a single value. "transcript" = words spoken in this scene verbatim, or "" if none.
1225
+ - "scene_role": one of ${SCENE_ROLE_VOCAB.join(", ")} — the function this beat serves in the retention arc.
1226
+ - "narrative_purpose" / "why_it_works": the viral DNA — WHY this beat exists and how it holds attention. A replacement clip must reproduce this effect, not just the look.
1227
+ - "must_preserve": 1-4 things a replacement MUST keep (e.g. "subject facing camera", "surprised reaction on the beat drop", "product clearly visible"). "can_vary": 1-4 things free to change (specific person, exact background, color grade).
1228
+ - "replaceability": how safely this scene can be swapped from generic library clips. "low" for scenes carrying unique baked-in text / a specific face / a spoken payoff; "high" for generic b-roll / cutaways / establishing shots. Explain in "replaceability_reason".
1229
+ - "recreation_prompt": a self-contained generative brief an AI agent could paste into a text-to-image/video model to REGENERATE this exact beat — describe subject, action, setting, framing, camera move, lighting, mood, art style, and pacing in one rich paragraph. Write it in the idiom of the detected format (e.g. an ai_fruit_drama prompt reads like a stylized 3D-character render brief; a live_action prompt reads like a cinematography direction). Do NOT reference "this video" or timestamps — it must stand alone.
1230
+ - "recreation_notes": 2-5 short discrete production directives that back up the recreation_prompt (e.g. "9:16 vertical", "handheld push-in", "soft key light from left", "on-screen text: 'wait for it'", "cut on the beat at ~1.2s", "dialogue VO: <verbatim line>"). Include any exact on-screen text or spoken line that must be reproduced.
1231
+ - Match your vocabulary and framing to the detected format above.`;
1232
+ }
1233
+ async function runSceneAnnotationPass(input) {
1234
+ if (input.frames.length === 0 || input.scenes.length === 0) {
1235
+ return { contentFormat: DEFAULT_CONTENT_FORMAT, annotations: [], provider: null, model: null };
1236
+ }
1237
+ const prompt = buildSceneAnnotationPrompt({
1238
+ durationSeconds: input.durationSeconds,
1239
+ frameTimestamps: input.frames.map((f) => f.timestamp),
1240
+ scenes: input.scenes,
1241
+ viralDna: input.viralDna,
1242
+ summary: input.summary,
1243
+ userPrompt: input.userPrompt ?? null
1244
+ });
1245
+ const attempt = await callVisionWithFallback({ prompt, frames: input.frames, keys: input.keys });
1246
+ const record = asPlainRecord(attempt.result);
1247
+ const contentFormat = normalizeContentFormat(record.content_format);
1248
+ const rawScenes = Array.isArray(record.scenes) ? record.scenes : [];
1249
+ const bySlug = new Map();
1250
+ for (const r of rawScenes) {
1251
+ const rec = asPlainRecord(r);
1252
+ const slug = normalizeSlug(readAnnotationString(rec.scene_slug));
1253
+ if (slug && !bySlug.has(slug))
1254
+ bySlug.set(slug, rec);
1255
+ }
1256
+ // Iterate the FINALIZED scenes (not the model output) so every scene gets
1257
+ // exactly one annotation aligned by slug, even if the model dropped, renamed,
1258
+ // or duplicated one. Missing fields degrade to the scene's own description.
1259
+ const annotations = input.scenes.map((scene) => {
1260
+ const raw = bySlug.get(scene.slug) ?? {};
1261
+ const tags = {
1262
+ ...sanitizeTagValues(asPlainRecord(raw.tags)),
1263
+ transcript: transcriptForScene(input.transcript, scene) || readAnnotationString(asPlainRecord(raw.tags).transcript)
1264
+ };
1265
+ const literal = readAnnotationString(raw.literal) || scene.description || scene.label;
1266
+ const scene_role = normalizeSceneRole(raw.scene_role);
1267
+ const narrative_purpose = readAnnotationString(raw.narrative_purpose) || scene.viral_note;
1268
+ const why_it_works = readAnnotationString(raw.why_it_works);
1269
+ const embedding_text = buildSceneEmbeddingText({ literal, tags, scene_role, narrative_purpose, why_it_works });
1270
+ return {
1271
+ scene_slug: scene.slug,
1272
+ start: scene.start,
1273
+ duration: scene.duration,
1274
+ content_format: contentFormat.format,
1275
+ literal,
1276
+ tags,
1277
+ scene_role,
1278
+ narrative_purpose,
1279
+ why_it_works,
1280
+ must_preserve: readAnnotationStringList(raw.must_preserve, 4),
1281
+ can_vary: readAnnotationStringList(raw.can_vary, 4),
1282
+ replaceability: normalizeReplaceability(raw.replaceability),
1283
+ replaceability_reason: readAnnotationString(raw.replaceability_reason),
1284
+ embedding_text,
1285
+ match_criteria: buildSceneMatchCriteria(tags, scene, embedding_text),
1286
+ // Fall back to the literal + purpose if the model omitted a recreation
1287
+ // brief, so an agent always has *something* to regenerate from.
1288
+ recreation_prompt: readAnnotationString(raw.recreation_prompt)
1289
+ || [literal, narrative_purpose].filter((p) => p.length > 0).join(" "),
1290
+ recreation_notes: readAnnotationStringList(raw.recreation_notes, 6)
1291
+ };
1292
+ });
1293
+ return { contentFormat, annotations, provider: attempt.provider, model: attempt.model };
1294
+ }
1295
+ // Standalone entry point for the ASYNC scene-annotation pass, invoked once per
1296
+ // /scene-annotations-poll. Mirrors analyzeCastMembers: it re-downloads the
1297
+ // (caption-free) source, re-samples the same sparse frames the main decompose
1298
+ // used, and runs the annotation vision call against the ALREADY-finalized scenes
1299
+ // (passed in from the persisted smart-decompose snapshot) so annotations align
1300
+ // 1:1 by slug. Kept out of the synchronous decompose so that request stays under
1301
+ // the origin timeout.
1302
+ export async function annotateScenesFromSource(input) {
1303
+ if (input.scenes.length === 0) {
1304
+ return { contentFormat: DEFAULT_CONTENT_FORMAT, annotations: [], provider: null, model: null, durationSeconds: 0 };
1305
+ }
1306
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), SMART_DECOMPOSE_TEMP_PREFIX));
1307
+ const sourcePath = path.join(tempDir, "source.mp4");
1308
+ const startedAt = Date.now();
1309
+ devLog("hyperframes.scene_annotations.start", { source_url: input.sourceUrl, scene_count: input.scenes.length });
1310
+ try {
1311
+ await downloadVideoTo(input.sourceUrl, sourcePath);
1312
+ const meta = await ffprobeVideo(sourcePath);
1313
+ if (!Number.isFinite(meta.durationSeconds) || meta.durationSeconds <= 0) {
1314
+ throw new Error("Could not read video duration");
1315
+ }
1316
+ const frames = await sampleFramesAsBase64(sourcePath, meta.durationSeconds, tempDir);
1317
+ const outcome = await runSceneAnnotationPass({
1318
+ frames,
1319
+ keys: input.keys,
1320
+ durationSeconds: meta.durationSeconds,
1321
+ scenes: input.scenes,
1322
+ viralDna: input.viralDna,
1323
+ summary: input.summary,
1324
+ transcript: input.transcript,
1325
+ userPrompt: input.userPrompt ?? null
1326
+ });
1327
+ devLog("hyperframes.scene_annotations.completed", {
1328
+ provider: outcome.provider,
1329
+ model: outcome.model,
1330
+ duration_ms: Date.now() - startedAt,
1331
+ content_format: outcome.contentFormat.format,
1332
+ annotation_count: outcome.annotations.length
1333
+ });
1334
+ return { ...outcome, durationSeconds: meta.durationSeconds };
1335
+ }
1336
+ catch (error) {
1337
+ devLog("hyperframes.scene_annotations.failed", {
1338
+ source_url: input.sourceUrl,
1339
+ duration_ms: Date.now() - startedAt,
1340
+ ...devErrorFields(error)
1341
+ }, "error");
1342
+ throw error;
1343
+ }
1344
+ finally {
1345
+ await rm(tempDir, { recursive: true, force: true });
1346
+ }
1347
+ }
1006
1348
  function buildSmartDecomposePrompt(input) {
1007
1349
  const sceneCutHint = input.sceneCuts && input.sceneCuts.length > 0
1008
1350
  ? `\nDETECTED SCENE CUTS (authoritative — ffmpeg measured hard visual cuts at these timestamps, in seconds):\n${input.sceneCuts.map((t) => ` cut @ ${t.toFixed(2)}s`).join("\n")}\n\nThese are ground truth for where the video visually changes. Align scene boundaries to these cuts: each detected cut should begin a new scene (a scene "start" should equal a detected cut time, within ~0.15s), and do NOT place a scene boundary where there is no detected cut unless the frames clearly show a change the detector missed. If two adjacent cuts are very close and belong to the same beat, you may merge them into one scene, but never exceed 6 scenes.\n`
@@ -1762,6 +2104,11 @@ export async function smartDecomposeVideo(input) {
1762
2104
  if (input.trendTaglineHint && input.trendTaglineHint.trim().length > 0) {
1763
2105
  viralDna.trend_tagline = input.trendTaglineHint.trim();
1764
2106
  }
2107
+ // NOTE: the per-scene clip-annotation + content-format pass is deliberately
2108
+ // NOT run here — it is a separate vision call that would risk the ~60s origin
2109
+ // timeout on longer videos. /auto-decompose seeds scene-annotations.json
2110
+ // "pending" and /scene-annotations-poll drives annotateScenesFromSource
2111
+ // against these finalized scenes (persisted in smart-decompose.json).
1765
2112
  devLog("hyperframes.smart_decompose.completed", {
1766
2113
  provider: attempt.provider,
1767
2114
  model: attempt.model,
@@ -2027,6 +2374,31 @@ export function normalizePublishHtml(html) {
2027
2374
  continue;
2028
2375
  html.style.zIndex = String(trackIndex);
2029
2376
  }
2377
+ // Ken Burns motion must span exactly the clip: timeline edits touch
2378
+ // data-duration only, so re-derive the inline --vf-kb-dur (which drives
2379
+ // animation-duration) from data-duration on every [data-kenburns] layer.
2380
+ // Also make sure the shared keyframes stylesheet travels with compositions
2381
+ // stored before the builder started embedding it.
2382
+ const kenBurnsLayers = Array.from(document.querySelectorAll("[data-kenburns]"));
2383
+ for (const el of kenBurnsLayers) {
2384
+ const duration = Number.parseFloat(el.getAttribute("data-duration") || "");
2385
+ if (!Number.isFinite(duration) || duration <= 0)
2386
+ continue;
2387
+ const styleValue = el.getAttribute("style") || "";
2388
+ const durDecl = `--vf-kb-dur:${Number(duration.toFixed(3))}s`;
2389
+ const nextStyle = /(^|;)\s*--vf-kb-dur\s*:[^;]*/i.test(styleValue)
2390
+ ? styleValue.replace(/(^|;)\s*--vf-kb-dur\s*:[^;]*/i, (_match, lead) => `${lead}${durDecl}`)
2391
+ : styleValue.length === 0 || styleValue.endsWith(";")
2392
+ ? `${styleValue}${durDecl}`
2393
+ : `${styleValue};${durDecl}`;
2394
+ if (nextStyle !== styleValue)
2395
+ el.setAttribute("style", nextStyle);
2396
+ }
2397
+ if (kenBurnsLayers.length > 0 && !html.includes(KEN_BURNS_STYLE_MARKER)) {
2398
+ const styleEl = document.createElement("style");
2399
+ styleEl.textContent = KEN_BURNS_CSS;
2400
+ (document.head ?? document.documentElement)?.appendChild(styleEl);
2401
+ }
2030
2402
  // Editor sessions occasionally leave inline style attributes with double
2031
2403
  // quotes inside CSS values (font-family: "TikTok Sans"). When serialized into
2032
2404
  // an HTML attribute, those quotes become &quot; entities. The render lambda
@@ -0,0 +1,32 @@
1
+ import { DEFAULT_CONTENT_FORMAT } from "./hyperframes.js";
2
+ // ==========================================================================
3
+ // Scene-annotation pass state — persisted per fork under
4
+ // compositions/forks/{forkId}/working/scene-annotations.json
5
+ // and copied on fork/clone/version so the annotations travel with every
6
+ // descendant composition. Drives the async /scene-annotations-poll flow,
7
+ // mirroring the cast.json state machine: /auto-decompose seeds it "pending",
8
+ // and /scene-annotations-poll runs the single annotation vision call (against
9
+ // the finalized scenes persisted in smart-decompose.json) to completion.
10
+ //
11
+ // It's a SINGLE unit of work (one vision call), so the state machine is simple:
12
+ // pending -> processing -> done | failed
13
+ // The `processing` state exists only to keep two concurrent pollers from both
14
+ // paying for the vision call (see the staleness guard in the poll handler).
15
+ // ==========================================================================
16
+ export const SCENE_ANNOTATIONS_STATE_VERSION = 1;
17
+ export function buildPendingSceneAnnotationsState(input) {
18
+ return {
19
+ version: SCENE_ANNOTATIONS_STATE_VERSION,
20
+ status: "pending",
21
+ savedAtMs: Date.now(),
22
+ compositionId: input.compositionId,
23
+ sourceUrl: input.sourceUrl,
24
+ title: input.title,
25
+ provider: null,
26
+ model: null,
27
+ durationSeconds: 0,
28
+ contentFormat: DEFAULT_CONTENT_FORMAT,
29
+ annotations: []
30
+ };
31
+ }
32
+ //# sourceMappingURL=scene-annotations.js.map
@@ -30,6 +30,12 @@ export class StorageService {
30
30
  legacyUserAttachmentKey(customerId, attachmentId, fileName) {
31
31
  return joinStorageKey("users", customerId, attachmentId, fileName);
32
32
  }
33
+ // Add Template → upload a video file: staging area for uploaded inspiration
34
+ // sources. Lives under users/ so the bucket's public-read policy covers it —
35
+ // the video_ingest job and the editor fetch it by plain URL.
36
+ inspirationUploadKey(customerId, uploadId, fileName) {
37
+ return joinStorageKey("users", customerId, "inspiration-uploads", uploadId, fileName);
38
+ }
33
39
  userTemporaryFileKey(customerId, fileId, fileName, folderPath) {
34
40
  return joinStorageKey("users", customerId, "temporary", folderPath || "", fileId, fileName);
35
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/src/**/*.js",
12
+ "dist/src/**/*.json",
12
13
  "demo/dist/**",
13
14
  "public/assets/**",
14
15
  "src/assets/**",
@@ -71,6 +72,7 @@
71
72
  "@sentry/node": "^10.56.0",
72
73
  "@sentry/react": "^10.56.0",
73
74
  "ai": "^6.0.191",
75
+ "better-sqlite3": "^12.11.1",
74
76
  "dotenv": "^16.5.0",
75
77
  "esbuild": "^0.28.1",
76
78
  "ffmpeg-static": "^5.3.0",
@@ -82,9 +84,11 @@
82
84
  "react": "^18.3.1",
83
85
  "react-dom": "^18.3.1",
84
86
  "sharp": "^0.34.5",
87
+ "sqlite-vec": "^0.1.9",
85
88
  "zod": "^3.25.28"
86
89
  },
87
90
  "devDependencies": {
91
+ "@types/better-sqlite3": "^7.6.13",
88
92
  "@types/node": "^24.0.1",
89
93
  "@types/react": "^18.3.23",
90
94
  "@types/react-dom": "^18.3.7",