@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.
- package/README.md +8 -0
- package/demo/dist/app.js +81 -53
- package/dist/src/app.js +1010 -8
- package/dist/src/cli.js +390 -53
- 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/telemetry.js +236 -0
- package/dist/src/frontend/homepage-view.js +5 -0
- package/dist/src/frontend/template-editor-chat.js +212 -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 +369 -9
- package/dist/src/services/scene-annotations.js +32 -0
- package/package.json +5 -1
- package/public/assets/homepage-client-app.js +17 -17
- 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.
|
|
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",
|