@mevdragon/vidfarm-devcli 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/demo/dist/app.js +49 -49
- package/dist/src/app.js +863 -2
- package/dist/src/cli.js +59 -3
- 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/frontend/homepage-view.js +5 -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 +347 -0
- package/dist/src/services/scene-annotations.js +32 -0
- package/package.json +5 -1
- package/public/assets/homepage-client-app.js +17 -17
|
@@ -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
|
|
@@ -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`
|
|
@@ -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,
|
|
@@ -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.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",
|