@mevdragon/vidfarm-devcli 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +8 -0
  2. package/demo/dist/app.js +81 -53
  3. package/dist/src/app.js +1010 -8
  4. package/dist/src/cli.js +390 -53
  5. package/dist/src/config.js +6 -0
  6. package/dist/src/devcli/clip-store.js +335 -0
  7. package/dist/src/devcli/clips.js +803 -0
  8. package/dist/src/devcli/telemetry.js +236 -0
  9. package/dist/src/frontend/homepage-view.js +5 -0
  10. package/dist/src/frontend/template-editor-chat.js +212 -0
  11. package/dist/src/services/clip-curation/cost.js +112 -0
  12. package/dist/src/services/clip-curation/ffmpeg.js +258 -0
  13. package/dist/src/services/clip-curation/gemini.js +342 -0
  14. package/dist/src/services/clip-curation/index.js +15 -0
  15. package/dist/src/services/clip-curation/presets.js +20 -0
  16. package/dist/src/services/clip-curation/presets.v1.json +59 -0
  17. package/dist/src/services/clip-curation/query.js +163 -0
  18. package/dist/src/services/clip-curation/refine.js +136 -0
  19. package/dist/src/services/clip-curation/scan.js +137 -0
  20. package/dist/src/services/clip-curation/taxonomy.js +71 -0
  21. package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
  22. package/dist/src/services/clip-curation/types.js +7 -0
  23. package/dist/src/services/clip-records.js +209 -0
  24. package/dist/src/services/clip-search.js +47 -0
  25. package/dist/src/services/clip-vectors.js +125 -0
  26. package/dist/src/services/hyperframes.js +369 -9
  27. package/dist/src/services/scene-annotations.js +32 -0
  28. package/package.json +5 -1
  29. package/public/assets/homepage-client-app.js +17 -17
  30. package/public/assets/page-runtime-client-app.js +31 -31
@@ -15,6 +15,12 @@ import { config } from "../config.js";
15
15
  import { devErrorFields, devLog } from "../lib/dev-log.js";
16
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`
@@ -1329,6 +1671,27 @@ async function callOpenAIVision(input) {
1329
1671
  image_url: { url: `data:image/jpeg;base64,${frame.data}` }
1330
1672
  });
1331
1673
  }
1674
+ // OpenRouter proxies many models and still accepts the classic Chat
1675
+ // Completions params. Native OpenAI's current models (gpt-5.x / o-series)
1676
+ // rejected `max_tokens` (they require `max_completion_tokens`) and only allow
1677
+ // the default temperature, so sending `max_tokens`/`temperature:0.15` 400s
1678
+ // every call. Branch the body by endpoint.
1679
+ const isOpenRouter = Boolean(input.endpoint && input.endpoint.includes("openrouter"));
1680
+ // Give the JSON response room to breathe so a video with 12+ text overlays
1681
+ // doesn't get truncated (same rationale as Gemini's maxOutputTokens).
1682
+ const OUTPUT_TOKEN_BUDGET = 16384;
1683
+ const body = {
1684
+ model: input.model,
1685
+ response_format: { type: "json_object" },
1686
+ messages: [{ role: "user", content }]
1687
+ };
1688
+ if (isOpenRouter) {
1689
+ body.temperature = 0.15;
1690
+ body.max_tokens = OUTPUT_TOKEN_BUDGET;
1691
+ }
1692
+ else {
1693
+ body.max_completion_tokens = OUTPUT_TOKEN_BUDGET;
1694
+ }
1332
1695
  const response = await fetch(input.endpoint ?? "https://api.openai.com/v1/chat/completions", {
1333
1696
  method: "POST",
1334
1697
  headers: {
@@ -1336,15 +1699,7 @@ async function callOpenAIVision(input) {
1336
1699
  "authorization": `Bearer ${input.apiKey}`,
1337
1700
  ...(input.extraHeaders ?? {})
1338
1701
  },
1339
- body: JSON.stringify({
1340
- model: input.model,
1341
- temperature: 0.15,
1342
- // Same rationale as Gemini's maxOutputTokens: give the JSON response room
1343
- // to breathe so a video with 12+ text overlays doesn't get truncated.
1344
- max_tokens: 16384,
1345
- response_format: { type: "json_object" },
1346
- messages: [{ role: "user", content }]
1347
- })
1702
+ body: JSON.stringify(body)
1348
1703
  });
1349
1704
  const raw = await response.text();
1350
1705
  if (!response.ok) {
@@ -1762,6 +2117,11 @@ export async function smartDecomposeVideo(input) {
1762
2117
  if (input.trendTaglineHint && input.trendTaglineHint.trim().length > 0) {
1763
2118
  viralDna.trend_tagline = input.trendTaglineHint.trim();
1764
2119
  }
2120
+ // NOTE: the per-scene clip-annotation + content-format pass is deliberately
2121
+ // NOT run here — it is a separate vision call that would risk the ~60s origin
2122
+ // timeout on longer videos. /auto-decompose seeds scene-annotations.json
2123
+ // "pending" and /scene-annotations-poll drives annotateScenesFromSource
2124
+ // against these finalized scenes (persisted in smart-decompose.json).
1765
2125
  devLog("hyperframes.smart_decompose.completed", {
1766
2126
  provider: attempt.provider,
1767
2127
  model: attempt.model,
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.7.1",
3
+ "version": "0.9.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",