@mevdragon/vidfarm-devcli 0.5.3 → 0.6.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 +3 -3
- package/demo/README.md +28 -0
- package/demo/dist/app.css +1 -0
- package/demo/dist/app.js +1184 -0
- package/demo/dist/chunks/chunk-DXB73IDG.js +1 -0
- package/demo/dist/chunks/chunk-S7OWAJDS.js +36 -0
- package/demo/dist/chunks/chunk-VTIBZ6AN.js +1 -0
- package/demo/dist/chunks/dist-ADSJKBVE.js +332 -0
- package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +1 -0
- package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +425 -0
- package/demo/dist/chunks/lib-XAQ37YOE.js +1 -0
- package/demo/dist/chunks/src-TJ2QYA4U.js +207 -0
- package/demo/dist/favicon.ico +0 -0
- package/demo/dist/icons/timeline/audio.svg +7 -0
- package/demo/dist/icons/timeline/captions.svg +5 -0
- package/demo/dist/icons/timeline/composition.svg +12 -0
- package/demo/dist/icons/timeline/image.svg +18 -0
- package/demo/dist/icons/timeline/music.svg +10 -0
- package/demo/dist/icons/timeline/text.svg +3 -0
- package/demo/dist/index.html +15 -0
- package/dist/src/account-pages-legacy.js +9396 -0
- package/dist/src/account-pages.js +61 -0
- package/dist/src/app.js +14378 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/composition-runtime.js +613 -0
- package/dist/src/config.js +166 -0
- package/dist/src/context.js +447 -0
- package/dist/src/dev-app-legacy.js +739 -0
- package/dist/src/dev-app.js +6 -0
- package/dist/src/domain.js +2 -0
- package/dist/src/editor-chat-history.js +82 -0
- package/dist/src/editor-chat.js +449 -0
- package/dist/src/editor-dark-theme.js +1128 -0
- package/dist/src/frontend/debug.js +71 -0
- package/dist/src/frontend/flockposter-cache-store.js +124 -0
- package/dist/src/frontend/homepage-client.js +182 -0
- package/dist/src/frontend/homepage-shared.js +4 -0
- package/dist/src/frontend/homepage-store.js +28 -0
- package/dist/src/frontend/homepage-view.js +547 -0
- package/dist/src/frontend/page-runtime-client.js +132 -0
- package/dist/src/frontend/page-runtime-store.js +9 -0
- package/dist/src/frontend/sentry.js +42 -0
- package/dist/src/frontend/template-editor-chat.js +3960 -0
- package/dist/src/help-page.js +346 -0
- package/dist/src/homepage.js +1235 -0
- package/dist/src/hyperframes/composition.js +180 -0
- package/dist/src/index.js +16 -0
- package/dist/src/instrument.js +30 -0
- package/dist/src/lib/crypto.js +45 -0
- package/dist/src/lib/dev-log.js +54 -0
- package/dist/src/lib/display-name.js +11 -0
- package/dist/src/lib/ids.js +24 -0
- package/dist/src/lib/images.js +19 -0
- package/dist/src/lib/json.js +15 -0
- package/dist/src/lib/package-root.js +47 -0
- package/dist/src/lib/template-paths.js +28 -0
- package/dist/src/lib/time.js +7 -0
- package/dist/src/lib/url-clean.js +85 -0
- package/dist/src/page-runtime.js +2 -0
- package/dist/src/page-shell.js +1381 -0
- package/dist/src/primitive-context.js +357 -0
- package/dist/src/primitive-registry.js +2436 -0
- package/dist/src/primitive-sdk.js +4 -0
- package/dist/src/primitives/hyperframes-media.js +108 -0
- package/dist/src/react-page-shell.js +35 -0
- package/dist/src/ready-post-schedule-component.js +1540 -0
- package/dist/src/registry.js +296 -0
- package/dist/src/runtime.js +35 -0
- package/dist/src/services/api-call-history.js +249 -0
- package/dist/src/services/auth.js +152 -0
- package/dist/src/services/billing-pricing.js +39 -0
- package/dist/src/services/billing.js +228 -0
- package/dist/src/services/cast.js +127 -0
- package/dist/src/services/chat-threads.js +92 -0
- package/dist/src/services/composition-sanitize.js +124 -0
- package/dist/src/services/composition-watch.js +79 -0
- package/dist/src/services/fork-access.js +93 -0
- package/dist/src/services/fork-manifest.js +42 -0
- package/dist/src/services/ghostcut.js +179 -0
- package/dist/src/services/hyperframes.js +2307 -0
- package/dist/src/services/job-capacity.js +14 -0
- package/dist/src/services/job-logs.js +197 -0
- package/dist/src/services/jobs.js +136 -0
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/media-processing.js +766 -0
- package/dist/src/services/primitive-media-lambda.js +280 -0
- package/dist/src/services/providers.js +2926 -0
- package/dist/src/services/rate-limits.js +262 -0
- package/dist/src/services/serverless-auth.js +382 -0
- package/dist/src/services/serverless-jobs.js +1082 -0
- package/dist/src/services/serverless-provider-keys.js +409 -0
- package/dist/src/services/serverless-records.js +1385 -0
- package/dist/src/services/serverless-template-configs.js +75 -0
- package/dist/src/services/storage.js +383 -0
- package/dist/src/services/template-certification.js +413 -0
- package/dist/src/services/template-loader.js +99 -0
- package/dist/src/services/template-runtime-bundles.js +217 -0
- package/dist/src/services/template-sources.js +1017 -0
- package/dist/src/services/video-normalization.js +2 -0
- package/dist/src/services/webhooks.js +62 -0
- package/dist/src/template-editor-pages.js +2576 -0
- package/dist/src/template-editor-shell.js +2840 -0
- package/dist/src/template-sdk.js +4 -0
- package/dist/src/worker.js +17 -0
- package/package.json +6 -4
- package/public/assets/homepage-app.js +54 -0
- package/public/assets/homepage-client-app.js +80 -0
- package/public/assets/page-runtime-client-app.js +94 -0
- package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
- package/src/assets/SELLING_WITH_HOOKS.md +377 -0
- package/src/assets/SELLING_WITH_VSLS.md +606 -0
- package/src/assets/favicon.ico +0 -0
- package/src/assets/logo-vidfarm.png +0 -0
|
@@ -0,0 +1,2307 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { CloudFormationClient, DescribeStacksCommand } from "@aws-sdk/client-cloudformation";
|
|
9
|
+
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
10
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
11
|
+
import ffmpegStatic from "ffmpeg-static";
|
|
12
|
+
import ffprobeStatic from "ffprobe-static";
|
|
13
|
+
import { parseHTML } from "linkedom";
|
|
14
|
+
import { config } from "../config.js";
|
|
15
|
+
import { devErrorFields, devLog } from "../lib/dev-log.js";
|
|
16
|
+
import { buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
|
|
17
|
+
import { applyMarkupUsd } from "./billing-pricing.js";
|
|
18
|
+
let stackCache = null;
|
|
19
|
+
const SMART_DECOMPOSE_TEMP_PREFIX = "vidfarm-smart-decompose-";
|
|
20
|
+
const SMART_DECOMPOSE_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
async function sweepStaleSmartDecomposeTempDirs() {
|
|
22
|
+
const tmpRoot = os.tmpdir();
|
|
23
|
+
let entries;
|
|
24
|
+
try {
|
|
25
|
+
entries = await readdir(tmpRoot);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
await Promise.all(entries.map(async (name) => {
|
|
32
|
+
if (!name.startsWith(SMART_DECOMPOSE_TEMP_PREFIX))
|
|
33
|
+
return;
|
|
34
|
+
const full = path.join(tmpRoot, name);
|
|
35
|
+
try {
|
|
36
|
+
const info = await stat(full);
|
|
37
|
+
if (!info.isDirectory())
|
|
38
|
+
return;
|
|
39
|
+
if (now - info.mtimeMs < SMART_DECOMPOSE_TEMP_MAX_AGE_MS)
|
|
40
|
+
return;
|
|
41
|
+
await rm(full, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* ignore */
|
|
45
|
+
}
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
sweepStaleSmartDecomposeTempDirs().catch((err) => {
|
|
49
|
+
console.error("smart decompose temp sweep failed", err);
|
|
50
|
+
});
|
|
51
|
+
// True when no cloud HyperFrames render stack is wired (env or explicit
|
|
52
|
+
// CloudFormation outputs). On a `vidfarm serve` box this is the normal state,
|
|
53
|
+
// so render should run in-process rather than fail. `VIDFARM_LOCAL_RENDER`
|
|
54
|
+
// forces local even when a cloud stack IS configured (useful for offline dev).
|
|
55
|
+
function shouldRenderLocally() {
|
|
56
|
+
if (config.VIDFARM_LOCAL_RENDER)
|
|
57
|
+
return true;
|
|
58
|
+
const hasCloudStack = Boolean((config.HYPERFRAMES_RENDER_STATE_MACHINE_ARN || config.HYPERFRAMES_STATE_MACHINE_ARN) &&
|
|
59
|
+
(config.HYPERFRAMES_RENDER_BUCKET_NAME || config.HYPERFRAMES_LAMBDA_BUCKET_NAME));
|
|
60
|
+
return !hasCloudStack;
|
|
61
|
+
}
|
|
62
|
+
export class HyperframesService {
|
|
63
|
+
async render(input) {
|
|
64
|
+
const compositionHtml = normalizePublishHtml(input.compositionHtml || (input.composition ? buildHyperframeCompositionHtml(input.composition) : ""));
|
|
65
|
+
if (!compositionHtml.includes("data-composition-id=")) {
|
|
66
|
+
throw new Error("HyperFrames render requires composition HTML with data-composition-id.");
|
|
67
|
+
}
|
|
68
|
+
if (shouldRenderLocally()) {
|
|
69
|
+
return this.renderLocal({ ...input, compositionHtml });
|
|
70
|
+
}
|
|
71
|
+
const metadata = extractCompositionMetadata(compositionHtml, input);
|
|
72
|
+
const stack = await resolveHyperframesStack();
|
|
73
|
+
devLog("hyperframes.render.staging", {
|
|
74
|
+
composition_id: metadata.compositionId,
|
|
75
|
+
width: metadata.width,
|
|
76
|
+
height: metadata.height,
|
|
77
|
+
fps: metadata.fps,
|
|
78
|
+
stack_name: stack.stackName,
|
|
79
|
+
region: stack.region
|
|
80
|
+
});
|
|
81
|
+
const projectDir = await stageProject({
|
|
82
|
+
compositionHtml,
|
|
83
|
+
compositionId: metadata.compositionId,
|
|
84
|
+
projectFiles: input.projectFiles,
|
|
85
|
+
copyPaths: input.copyPaths
|
|
86
|
+
});
|
|
87
|
+
const timestamp = Date.now().toString(36);
|
|
88
|
+
const renderId = input.executionName || `vf-${metadata.compositionId.slice(0, 40)}-${timestamp}-${randomUUID().slice(0, 8)}`;
|
|
89
|
+
const outputKey = input.outputKey || `renders/vidfarm/${metadata.compositionId}/${renderId}/output.mp4`;
|
|
90
|
+
devLog("hyperframes.render.dispatch", {
|
|
91
|
+
composition_id: metadata.compositionId,
|
|
92
|
+
render_id: renderId,
|
|
93
|
+
output_key: outputKey,
|
|
94
|
+
chunk_size: input.chunkSize ?? 240,
|
|
95
|
+
max_parallel_chunks: input.maxParallelChunks ?? 8,
|
|
96
|
+
wait: input.wait !== false
|
|
97
|
+
});
|
|
98
|
+
const { renderToLambda, getRenderProgress } = await import("@hyperframes/aws-lambda/sdk");
|
|
99
|
+
const handle = await renderToLambda({
|
|
100
|
+
projectDir,
|
|
101
|
+
bucketName: stack.bucketName,
|
|
102
|
+
stateMachineArn: stack.stateMachineArn,
|
|
103
|
+
region: stack.region,
|
|
104
|
+
executionName: renderId,
|
|
105
|
+
outputKey,
|
|
106
|
+
config: {
|
|
107
|
+
fps: normalizeRenderFps(metadata.fps),
|
|
108
|
+
width: metadata.width,
|
|
109
|
+
height: metadata.height,
|
|
110
|
+
format: input.format || "mp4",
|
|
111
|
+
codec: "h264",
|
|
112
|
+
quality: "standard",
|
|
113
|
+
chunkSize: input.chunkSize ?? 240,
|
|
114
|
+
maxParallelChunks: input.maxParallelChunks ?? 8,
|
|
115
|
+
runtimeCap: "lambda"
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
if (input.wait === false) {
|
|
119
|
+
devLog("hyperframes.render.dispatched_async", {
|
|
120
|
+
composition_id: metadata.compositionId,
|
|
121
|
+
render_id: renderId,
|
|
122
|
+
execution_arn: handle.executionArn
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
...handle,
|
|
126
|
+
outputUrl: null,
|
|
127
|
+
outputS3Uri: handle.outputS3Uri,
|
|
128
|
+
endedAt: null,
|
|
129
|
+
metadata: {
|
|
130
|
+
mode: "hyperframes_lambda",
|
|
131
|
+
status: "RUNNING",
|
|
132
|
+
stack,
|
|
133
|
+
composition: metadata
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const startedAtMs = Date.now();
|
|
138
|
+
const timeoutMs = input.timeoutMs ?? 12 * 60 * 1000;
|
|
139
|
+
const pollIntervalMs = input.pollIntervalMs ?? 5000;
|
|
140
|
+
let pollCount = 0;
|
|
141
|
+
let lastLoggedStatus = null;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const progress = await getRenderProgress({
|
|
144
|
+
executionArn: handle.executionArn,
|
|
145
|
+
region: stack.region,
|
|
146
|
+
defaultMemorySizeMb: stack.lambdaMemoryMb
|
|
147
|
+
});
|
|
148
|
+
pollCount += 1;
|
|
149
|
+
if (progress.status !== lastLoggedStatus) {
|
|
150
|
+
devLog("hyperframes.render.progress", {
|
|
151
|
+
composition_id: metadata.compositionId,
|
|
152
|
+
render_id: renderId,
|
|
153
|
+
status: progress.status,
|
|
154
|
+
poll_count: pollCount,
|
|
155
|
+
elapsed_ms: Date.now() - startedAtMs,
|
|
156
|
+
frames_rendered: progress.framesRendered,
|
|
157
|
+
total_frames: progress.totalFrames
|
|
158
|
+
});
|
|
159
|
+
lastLoggedStatus = progress.status;
|
|
160
|
+
}
|
|
161
|
+
if (progress.status === "SUCCEEDED") {
|
|
162
|
+
const outputS3Uri = progress.outputFile?.s3Uri || handle.outputS3Uri;
|
|
163
|
+
devLog("hyperframes.render.succeeded", {
|
|
164
|
+
composition_id: metadata.compositionId,
|
|
165
|
+
render_id: renderId,
|
|
166
|
+
duration_ms: Date.now() - startedAtMs,
|
|
167
|
+
output_s3_uri: outputS3Uri,
|
|
168
|
+
frames_rendered: progress.framesRendered,
|
|
169
|
+
total_frames: progress.totalFrames,
|
|
170
|
+
lambdas_invoked: progress.lambdasInvoked,
|
|
171
|
+
cost_usd: progress.costs?.accruedSoFarUsd
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
...handle,
|
|
175
|
+
outputS3Uri,
|
|
176
|
+
outputUrl: await signedS3Url(outputS3Uri, stack.region),
|
|
177
|
+
endedAt: progress.endedAt,
|
|
178
|
+
metadata: {
|
|
179
|
+
mode: "hyperframes_lambda",
|
|
180
|
+
status: progress.status,
|
|
181
|
+
stack,
|
|
182
|
+
composition: metadata,
|
|
183
|
+
progress,
|
|
184
|
+
costs: progress.costs,
|
|
185
|
+
estimatedCostUsd: progress.costs.accruedSoFarUsd,
|
|
186
|
+
chargeUsd: applyMarkupUsd(progress.costs.accruedSoFarUsd),
|
|
187
|
+
costDisplay: progress.costs.displayCost,
|
|
188
|
+
lambdasInvoked: progress.lambdasInvoked,
|
|
189
|
+
framesRendered: progress.framesRendered,
|
|
190
|
+
totalFrames: progress.totalFrames
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (progress.fatalErrorEncountered || ["FAILED", "TIMED_OUT", "ABORTED"].includes(progress.status)) {
|
|
195
|
+
devLog("hyperframes.render.failed", {
|
|
196
|
+
composition_id: metadata.compositionId,
|
|
197
|
+
render_id: renderId,
|
|
198
|
+
status: progress.status,
|
|
199
|
+
duration_ms: Date.now() - startedAtMs,
|
|
200
|
+
errors: Array.isArray(progress.errors) ? progress.errors.slice(0, 3) : progress.errors
|
|
201
|
+
}, "error");
|
|
202
|
+
throw new Error(`HyperFrames render ${handle.renderId} failed: ${JSON.stringify(progress.errors)}`);
|
|
203
|
+
}
|
|
204
|
+
if (Date.now() - startedAtMs > timeoutMs) {
|
|
205
|
+
devLog("hyperframes.render.timeout", {
|
|
206
|
+
composition_id: metadata.compositionId,
|
|
207
|
+
render_id: renderId,
|
|
208
|
+
timeout_ms: timeoutMs,
|
|
209
|
+
last_status: progress.status
|
|
210
|
+
}, "error");
|
|
211
|
+
throw new Error(`Timed out waiting for HyperFrames render ${handle.renderId}.`);
|
|
212
|
+
}
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// In-process render: stage the project, then drive HyperFrames' single-machine
|
|
217
|
+
// orchestrator (headless Chromium → per-frame capture → ffmpeg encode) to write
|
|
218
|
+
// the MP4 straight to local disk storage. No AWS, no Lambda fan-out. The result
|
|
219
|
+
// is shaped like the cloud render result so the `hyperframes_render` primitive
|
|
220
|
+
// and the publish/polling flow consume it unchanged.
|
|
221
|
+
async renderLocal(input) {
|
|
222
|
+
const rawHtml = normalizePublishHtml(input.compositionHtml || (input.composition ? buildHyperframeCompositionHtml(input.composition) : ""));
|
|
223
|
+
if (!rawHtml.includes("data-composition-id=")) {
|
|
224
|
+
throw new Error("HyperFrames render requires composition HTML with data-composition-id.");
|
|
225
|
+
}
|
|
226
|
+
// libx264 (yuv420p) requires even width/height. The in-process renderer sizes
|
|
227
|
+
// the canvas from the composition's data-width/data-height, so round any odd
|
|
228
|
+
// dimension down to even here — the cloud path sidesteps this by rendering at
|
|
229
|
+
// explicit even asset dimensions.
|
|
230
|
+
const compositionHtml = forceEvenCompositionDimensions(rawHtml);
|
|
231
|
+
const metadata = extractCompositionMetadata(compositionHtml, input);
|
|
232
|
+
const projectDir = await stageProject({
|
|
233
|
+
compositionHtml,
|
|
234
|
+
compositionId: metadata.compositionId,
|
|
235
|
+
projectFiles: input.projectFiles,
|
|
236
|
+
copyPaths: input.copyPaths
|
|
237
|
+
});
|
|
238
|
+
const timestamp = Date.now().toString(36);
|
|
239
|
+
const renderId = input.executionName || `vf-${metadata.compositionId.slice(0, 40)}-${timestamp}-${randomUUID().slice(0, 8)}`;
|
|
240
|
+
const outputKey = input.outputKey || `renders/vidfarm/${metadata.compositionId}/${renderId}/output.mp4`;
|
|
241
|
+
const outputLocation = path.join(config.VIDFARM_DATA_DIR, "storage", outputKey);
|
|
242
|
+
await mkdir(path.dirname(outputLocation), { recursive: true });
|
|
243
|
+
const startedAt = new Date().toISOString();
|
|
244
|
+
const startedAtMs = Date.now();
|
|
245
|
+
devLog("hyperframes.render_local.start", {
|
|
246
|
+
composition_id: metadata.compositionId,
|
|
247
|
+
render_id: renderId,
|
|
248
|
+
width: metadata.width,
|
|
249
|
+
height: metadata.height,
|
|
250
|
+
fps: metadata.fps,
|
|
251
|
+
output_key: outputKey
|
|
252
|
+
});
|
|
253
|
+
const { createRenderJob, executeRenderJob } = await import("@hyperframes/producer");
|
|
254
|
+
const job = createRenderJob({
|
|
255
|
+
fps: normalizeRenderFps(metadata.fps),
|
|
256
|
+
quality: "standard",
|
|
257
|
+
format: input.format || "mp4"
|
|
258
|
+
});
|
|
259
|
+
let lastStatus = null;
|
|
260
|
+
await executeRenderJob(job, projectDir, outputLocation, (progressJob, message) => {
|
|
261
|
+
if (progressJob.status !== lastStatus) {
|
|
262
|
+
devLog("hyperframes.render_local.progress", {
|
|
263
|
+
render_id: renderId,
|
|
264
|
+
status: progressJob.status,
|
|
265
|
+
message
|
|
266
|
+
});
|
|
267
|
+
lastStatus = progressJob.status;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
// Single-segment encoded key — matches the `/storage/:key` route which
|
|
271
|
+
// decodeURIComponent's one param (slashes ride through as %2F).
|
|
272
|
+
const outputUrl = `${config.PUBLIC_BASE_URL}/storage/${encodeURIComponent(outputKey)}`;
|
|
273
|
+
const durationMs = Date.now() - startedAtMs;
|
|
274
|
+
devLog("hyperframes.render_local.succeeded", {
|
|
275
|
+
composition_id: metadata.compositionId,
|
|
276
|
+
render_id: renderId,
|
|
277
|
+
duration_ms: durationMs,
|
|
278
|
+
output_location: outputLocation,
|
|
279
|
+
output_url: outputUrl
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
renderId,
|
|
283
|
+
outputUrl,
|
|
284
|
+
outputS3Uri: null,
|
|
285
|
+
executionArn: `local:${renderId}`,
|
|
286
|
+
bucketName: "",
|
|
287
|
+
stateMachineArn: "",
|
|
288
|
+
projectS3Uri: `file://${projectDir}`,
|
|
289
|
+
siteId: null,
|
|
290
|
+
startedAt,
|
|
291
|
+
endedAt: new Date().toISOString(),
|
|
292
|
+
metadata: {
|
|
293
|
+
mode: "hyperframes_local",
|
|
294
|
+
status: "SUCCEEDED",
|
|
295
|
+
composition: metadata,
|
|
296
|
+
outputLocation,
|
|
297
|
+
durationMs,
|
|
298
|
+
// Local render burns the operator's own CPU — no per-frame Lambda cost.
|
|
299
|
+
chargeUsd: 0,
|
|
300
|
+
estimatedCostUsd: 0,
|
|
301
|
+
costDisplay: "$0.00 (local render)"
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// In-process STILL render: render one frame of a composition to a PNG/JPEG/WEBP
|
|
306
|
+
// image on local disk. Uses the same headless-Chromium capture as renderLocal
|
|
307
|
+
// via HyperFrames' png-sequence format, then transcodes to the requested still
|
|
308
|
+
// format with ffmpeg. Replaces the Remotion `renderStill` path.
|
|
309
|
+
async renderStill(input) {
|
|
310
|
+
const compositionHtml = forceEvenCompositionDimensions(normalizePublishHtml(input.compositionHtml));
|
|
311
|
+
if (!compositionHtml.includes("data-composition-id=")) {
|
|
312
|
+
throw new Error("HyperFrames still render requires composition HTML with data-composition-id.");
|
|
313
|
+
}
|
|
314
|
+
const metadata = extractCompositionMetadata(compositionHtml, { compositionId: input.compositionId });
|
|
315
|
+
const projectDir = await stageProject({
|
|
316
|
+
compositionHtml,
|
|
317
|
+
compositionId: metadata.compositionId,
|
|
318
|
+
projectFiles: input.projectFiles,
|
|
319
|
+
copyPaths: input.copyPaths
|
|
320
|
+
});
|
|
321
|
+
const timestamp = Date.now().toString(36);
|
|
322
|
+
const renderId = `vfimg-${metadata.compositionId.slice(0, 40)}-${timestamp}-${randomUUID().slice(0, 8)}`;
|
|
323
|
+
const framesDir = path.join(config.VIDFARM_DATA_DIR, "hyperframes-projects", `${renderId}-frames`);
|
|
324
|
+
await mkdir(framesDir, { recursive: true });
|
|
325
|
+
devLog("hyperframes.render_still.start", {
|
|
326
|
+
composition_id: metadata.compositionId,
|
|
327
|
+
render_id: renderId,
|
|
328
|
+
width: metadata.width,
|
|
329
|
+
height: metadata.height
|
|
330
|
+
});
|
|
331
|
+
const { createRenderJob, executeRenderJob } = await import("@hyperframes/producer");
|
|
332
|
+
// png-sequence writes outputPath as a DIRECTORY of frame_000001.png … files.
|
|
333
|
+
const job = createRenderJob({ fps: 30, quality: "high", format: "png-sequence" });
|
|
334
|
+
try {
|
|
335
|
+
await executeRenderJob(job, projectDir, framesDir);
|
|
336
|
+
const frameFiles = (await readdir(framesDir))
|
|
337
|
+
.filter((name) => name.toLowerCase().endsWith(".png"))
|
|
338
|
+
.sort();
|
|
339
|
+
if (!frameFiles.length) {
|
|
340
|
+
throw new Error("HyperFrames still render produced no frames.");
|
|
341
|
+
}
|
|
342
|
+
const firstFramePath = path.join(framesDir, frameFiles[0]);
|
|
343
|
+
const format = input.imageFormat ?? "png";
|
|
344
|
+
const extension = format === "jpeg" ? "jpg" : format;
|
|
345
|
+
const outputKey = input.outputKey
|
|
346
|
+
|| `renders/vidfarm/stills/${metadata.compositionId}/${renderId}/output.${extension}`;
|
|
347
|
+
const outputLocation = path.join(config.VIDFARM_DATA_DIR, "storage", outputKey);
|
|
348
|
+
await mkdir(path.dirname(outputLocation), { recursive: true });
|
|
349
|
+
if (format === "png") {
|
|
350
|
+
await cp(firstFramePath, outputLocation, { force: true });
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// Transcode the captured PNG frame to the requested still format.
|
|
354
|
+
await runBinary(ffmpegBin, [
|
|
355
|
+
"-y",
|
|
356
|
+
"-i",
|
|
357
|
+
firstFramePath,
|
|
358
|
+
...(format === "jpeg" ? ["-q:v", "3"] : []),
|
|
359
|
+
outputLocation
|
|
360
|
+
]);
|
|
361
|
+
}
|
|
362
|
+
const outputUrl = `${config.PUBLIC_BASE_URL}/storage/${encodeURIComponent(outputKey)}`;
|
|
363
|
+
devLog("hyperframes.render_still.succeeded", {
|
|
364
|
+
composition_id: metadata.compositionId,
|
|
365
|
+
render_id: renderId,
|
|
366
|
+
image_format: format,
|
|
367
|
+
output_location: outputLocation
|
|
368
|
+
});
|
|
369
|
+
return {
|
|
370
|
+
renderId,
|
|
371
|
+
outputUrl,
|
|
372
|
+
outputLocation,
|
|
373
|
+
metadata: {
|
|
374
|
+
mode: "hyperframes_local_still",
|
|
375
|
+
compositionId: metadata.compositionId,
|
|
376
|
+
width: metadata.width,
|
|
377
|
+
height: metadata.height,
|
|
378
|
+
imageFormat: format,
|
|
379
|
+
outputKey
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
await rm(framesDir, { recursive: true, force: true });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async readRenderOutput(outputS3Uri, region) {
|
|
388
|
+
const parsed = parseS3Uri(outputS3Uri);
|
|
389
|
+
if (!parsed) {
|
|
390
|
+
throw new Error(`Invalid HyperFrames output S3 URI: ${outputS3Uri}`);
|
|
391
|
+
}
|
|
392
|
+
const s3 = new S3Client({ region: region || config.HYPERFRAMES_RENDER_REGION || config.AWS_REGION });
|
|
393
|
+
const response = await s3.send(new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key }));
|
|
394
|
+
if (!response.Body) {
|
|
395
|
+
throw new Error(`HyperFrames output is empty: ${outputS3Uri}`);
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
bytes: Buffer.from(await response.Body.transformToByteArray()),
|
|
399
|
+
contentType: response.ContentType || "video/mp4"
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Convert a "raw" composition (source video only, no timeline scenes) into a
|
|
404
|
+
// scene-scoped composition by chopping the source video into ~target-second
|
|
405
|
+
// chunks and inserting timeline-proxy divs the editor can drag. This is a
|
|
406
|
+
// deterministic time-based split; smarter AI-frame-based decomposition can
|
|
407
|
+
// replace it later without changing the shape of the produced HTML.
|
|
408
|
+
export function decomposeUndecomposedComposition(html, options) {
|
|
409
|
+
const { document } = parseHTML(html);
|
|
410
|
+
const root = document.querySelector("[data-composition-id]");
|
|
411
|
+
if (!root)
|
|
412
|
+
return { ok: false, error: "Composition root not found." };
|
|
413
|
+
const existingProxies = document.querySelectorAll('[data-vf-timeline-proxy="video"]');
|
|
414
|
+
if (existingProxies.length > 0) {
|
|
415
|
+
return { ok: false, error: "Composition already has timeline scenes." };
|
|
416
|
+
}
|
|
417
|
+
const backingVideo = document.querySelector("[data-vf-playback-source]") ?? document.querySelector("video");
|
|
418
|
+
const sourceUrl = (backingVideo?.getAttribute("data-src") || backingVideo?.getAttribute("src") || root.getAttribute("data-source-video") || "").trim();
|
|
419
|
+
if (!sourceUrl)
|
|
420
|
+
return { ok: false, error: "No source video URL found on the composition." };
|
|
421
|
+
const durationSeconds = Number.parseFloat(root.getAttribute("data-duration") ?? "");
|
|
422
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
423
|
+
return { ok: false, error: "Composition data-duration is missing — cannot compute scenes." };
|
|
424
|
+
}
|
|
425
|
+
const targetSeconds = options.targetSceneSeconds;
|
|
426
|
+
const preferredCount = options.requestedScenes ?? Math.max(1, Math.round(durationSeconds / targetSeconds));
|
|
427
|
+
const sceneCount = Math.min(24, Math.max(1, preferredCount));
|
|
428
|
+
const rawScene = durationSeconds / sceneCount;
|
|
429
|
+
for (let index = 0; index < sceneCount; index += 1) {
|
|
430
|
+
const start = index === 0 ? 0 : Number((index * rawScene).toFixed(3));
|
|
431
|
+
const end = index === sceneCount - 1 ? durationSeconds : Number(((index + 1) * rawScene).toFixed(3));
|
|
432
|
+
const duration = Number(Math.max(0.1, end - start).toFixed(3));
|
|
433
|
+
const proxy = document.createElement("div");
|
|
434
|
+
const proxyId = shortElementId();
|
|
435
|
+
proxy.id = proxyId;
|
|
436
|
+
proxy.setAttribute("data-hf-id", proxyId);
|
|
437
|
+
proxy.setAttribute("data-layer-mode", "both");
|
|
438
|
+
proxy.setAttribute("data-layer-kind", "video");
|
|
439
|
+
proxy.setAttribute("data-viral-note", `Auto-decomposed scene ${index + 1} of ${sceneCount}.`);
|
|
440
|
+
proxy.setAttribute("data-start", String(start));
|
|
441
|
+
proxy.setAttribute("data-duration", String(duration));
|
|
442
|
+
proxy.setAttribute("data-end", String(Number((start + duration).toFixed(3))));
|
|
443
|
+
proxy.setAttribute("data-track-index", "0");
|
|
444
|
+
proxy.setAttribute("data-label", `Scene ${index + 1}`);
|
|
445
|
+
proxy.setAttribute("data-media-start", String(start));
|
|
446
|
+
proxy.setAttribute("data-playback-start", String(start));
|
|
447
|
+
proxy.setAttribute("data-vf-timeline-proxy", "video");
|
|
448
|
+
proxy.setAttribute("data-src", sourceUrl);
|
|
449
|
+
proxy.setAttribute("style", "position:absolute;left:0%;top:0%;width:1%;height:1%;z-index:0;display:none;pointer-events:none;");
|
|
450
|
+
proxy.className = "clip";
|
|
451
|
+
root.append(proxy);
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
ok: true,
|
|
455
|
+
html: `<!doctype html>\n${document.documentElement.outerHTML}`,
|
|
456
|
+
sceneCount,
|
|
457
|
+
durationSeconds
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
// ==========================================================================
|
|
461
|
+
// Smart video decomposition — samples frames from the source, ships them
|
|
462
|
+
// to Gemini vision alongside a strict JSON schema request, and returns the
|
|
463
|
+
// scene bounds + text captions found in the footage.
|
|
464
|
+
// ==========================================================================
|
|
465
|
+
const ffmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : ffmpegStatic.path;
|
|
466
|
+
const ffprobeBin = typeof ffprobeStatic.path === "string"
|
|
467
|
+
? ffprobeStatic.path
|
|
468
|
+
: ffprobeStatic;
|
|
469
|
+
// The in-process HyperFrames producer (renderLocal / renderStill) shells out to
|
|
470
|
+
// ffmpeg + ffprobe, resolving them from HYPERFRAMES_FFMPEG_PATH / HYPERFRAMES_FFPROBE_PATH,
|
|
471
|
+
// then bare `ffmpeg`/`ffprobe` on PATH. The api Lambda image ships no ffmpeg on PATH, so
|
|
472
|
+
// point the producer at the bundled ffmpeg-static / ffprobe-static binaries (already
|
|
473
|
+
// resolved above) — otherwise render fails with "ffprobe not found. Please install FFmpeg."
|
|
474
|
+
if (ffmpegBin && !process.env.HYPERFRAMES_FFMPEG_PATH?.trim()) {
|
|
475
|
+
process.env.HYPERFRAMES_FFMPEG_PATH = ffmpegBin;
|
|
476
|
+
}
|
|
477
|
+
if (ffprobeBin && !process.env.HYPERFRAMES_FFPROBE_PATH?.trim()) {
|
|
478
|
+
process.env.HYPERFRAMES_FFPROBE_PATH = ffprobeBin;
|
|
479
|
+
}
|
|
480
|
+
const RUN_BINARY_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
481
|
+
function runBinary(bin, args) {
|
|
482
|
+
return new Promise((resolve, reject) => {
|
|
483
|
+
const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
484
|
+
let stdout = "";
|
|
485
|
+
let stderr = "";
|
|
486
|
+
let stdoutBytes = 0;
|
|
487
|
+
let stderrBytes = 0;
|
|
488
|
+
let settled = false;
|
|
489
|
+
let killedForOverflow = null;
|
|
490
|
+
const settle = (fn) => {
|
|
491
|
+
if (settled)
|
|
492
|
+
return;
|
|
493
|
+
settled = true;
|
|
494
|
+
fn();
|
|
495
|
+
};
|
|
496
|
+
const killForOverflow = (stream) => {
|
|
497
|
+
if (killedForOverflow || settled)
|
|
498
|
+
return;
|
|
499
|
+
killedForOverflow = stream;
|
|
500
|
+
try {
|
|
501
|
+
proc.kill("SIGKILL");
|
|
502
|
+
}
|
|
503
|
+
catch { /* ignore */ }
|
|
504
|
+
settle(() => reject(new Error(`${path.basename(bin)} ${stream} exceeded ${RUN_BINARY_MAX_BUFFER_BYTES} bytes; killed with SIGKILL.`)));
|
|
505
|
+
};
|
|
506
|
+
proc.stdout.on("data", (chunk) => {
|
|
507
|
+
stdoutBytes += chunk.length;
|
|
508
|
+
if (stdoutBytes > RUN_BINARY_MAX_BUFFER_BYTES) {
|
|
509
|
+
killForOverflow("stdout");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
stdout += chunk.toString();
|
|
513
|
+
});
|
|
514
|
+
proc.stderr.on("data", (chunk) => {
|
|
515
|
+
stderrBytes += chunk.length;
|
|
516
|
+
if (stderrBytes > RUN_BINARY_MAX_BUFFER_BYTES) {
|
|
517
|
+
killForOverflow("stderr");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
stderr += chunk.toString();
|
|
521
|
+
});
|
|
522
|
+
proc.on("error", (err) => {
|
|
523
|
+
settle(() => reject(err));
|
|
524
|
+
});
|
|
525
|
+
proc.on("close", (code) => {
|
|
526
|
+
if (killedForOverflow)
|
|
527
|
+
return;
|
|
528
|
+
settle(() => {
|
|
529
|
+
if (code === 0)
|
|
530
|
+
resolve(stdout);
|
|
531
|
+
else
|
|
532
|
+
reject(new Error(`${path.basename(bin)} exited ${code}: ${stderr || stdout}`));
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
async function downloadVideoTo(url, dest) {
|
|
538
|
+
const response = await fetch(url);
|
|
539
|
+
if (!response.ok || !response.body)
|
|
540
|
+
throw new Error(`Failed to download ${url}: HTTP ${response.status}`);
|
|
541
|
+
await pipeline(response.body, createWriteStream(dest));
|
|
542
|
+
}
|
|
543
|
+
async function ffprobeVideo(filePath) {
|
|
544
|
+
const output = await runBinary(ffprobeBin, [
|
|
545
|
+
"-v", "error",
|
|
546
|
+
"-show_entries", "format=duration:stream=width,height",
|
|
547
|
+
"-of", "json",
|
|
548
|
+
filePath
|
|
549
|
+
]);
|
|
550
|
+
const parsed = JSON.parse(output || "{}");
|
|
551
|
+
const stream = (parsed.streams || []).find((s) => s.width && s.height) || {};
|
|
552
|
+
return {
|
|
553
|
+
width: Number(stream.width || 0),
|
|
554
|
+
height: Number(stream.height || 0),
|
|
555
|
+
durationSeconds: Number(parsed.format?.duration || 0)
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
// Detect hard scene cuts deterministically with ffmpeg's frame-difference
|
|
559
|
+
// scene score, so scene-layer boundaries are grounded in real pixel-level cuts
|
|
560
|
+
// instead of the vision model eyeballing sparse frames and guessing. This is
|
|
561
|
+
// the scene-layer analogue of the OCR pass that grounds caption timing.
|
|
562
|
+
//
|
|
563
|
+
// We run `select='gt(scene,THRESH)'` (score 0..1; higher = bigger visual jump)
|
|
564
|
+
// and dump the selected frames' metadata to a temp file via `metadata=print`.
|
|
565
|
+
// `-f null -` discards the decoded video so stdout stays tiny. Tunable via
|
|
566
|
+
// SCENE_CUT_THRESHOLD (default 0.30). Never throws — cuts are an optional hint,
|
|
567
|
+
// so any failure returns [] and decompose falls back to model-only boundaries.
|
|
568
|
+
async function detectSceneCuts(videoPath, durationSeconds, tempDir) {
|
|
569
|
+
const thresholdRaw = Number(process.env.SCENE_CUT_THRESHOLD ?? "0.30");
|
|
570
|
+
const threshold = Number.isFinite(thresholdRaw) && thresholdRaw > 0 && thresholdRaw < 1 ? thresholdRaw : 0.30;
|
|
571
|
+
const metaPath = path.join(tempDir, "scene-cuts.txt");
|
|
572
|
+
try {
|
|
573
|
+
await runBinary(ffmpegBin, [
|
|
574
|
+
"-y",
|
|
575
|
+
"-i", videoPath,
|
|
576
|
+
"-vf", `select='gt(scene,${threshold})',metadata=print:file=${metaPath}`,
|
|
577
|
+
"-an",
|
|
578
|
+
"-f", "null",
|
|
579
|
+
"-"
|
|
580
|
+
]);
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
console.error("scene-cut detection ffmpeg failed", err instanceof Error ? err.message : err);
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
let raw;
|
|
587
|
+
try {
|
|
588
|
+
raw = await readFile(metaPath, "utf8");
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
// No selected frames (e.g. single continuous take) → file may be absent.
|
|
592
|
+
return [];
|
|
593
|
+
}
|
|
594
|
+
const safeDuration = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : Infinity;
|
|
595
|
+
const cuts = [];
|
|
596
|
+
const re = /pts_time:([0-9]+\.?[0-9]*)/g;
|
|
597
|
+
let match;
|
|
598
|
+
while ((match = re.exec(raw)) !== null) {
|
|
599
|
+
const t = Number(match[1]);
|
|
600
|
+
// Ignore a cut at t≈0 (that's just the first frame, not a boundary) and
|
|
601
|
+
// anything past the probed duration. Collapse cuts within 0.4s of an
|
|
602
|
+
// already-recorded one so micro-flicker doesn't over-segment the timeline.
|
|
603
|
+
if (!Number.isFinite(t) || t < 0.4 || t >= safeDuration - 0.1)
|
|
604
|
+
continue;
|
|
605
|
+
if (cuts.length > 0 && t - cuts[cuts.length - 1] < 0.4)
|
|
606
|
+
continue;
|
|
607
|
+
cuts.push(Number(t.toFixed(3)));
|
|
608
|
+
}
|
|
609
|
+
return cuts;
|
|
610
|
+
}
|
|
611
|
+
// Sample frames for the vision pass. Vision no longer owns caption timing —
|
|
612
|
+
// that's handled deterministically by the dense OCR pass (extractOcrFrames +
|
|
613
|
+
// snapCaptionTimingsToOcr). Vision only needs enough coverage to identify
|
|
614
|
+
// which captions exist, their text, style, and rough scene boundaries. Keep
|
|
615
|
+
// the cadence around 1 fps with a low ceiling to control token cost. Frames
|
|
616
|
+
// stay at 960px wide so small overlay text is legible for the LLM.
|
|
617
|
+
//
|
|
618
|
+
// Uses a single ffmpeg invocation with `-vf fps=…` — the previous per-frame
|
|
619
|
+
// spawn loop occasionally left individual output files unwritten when the
|
|
620
|
+
// seek raced a demux edge case, producing ENOENT reads. Batched extraction
|
|
621
|
+
// is also ~10× faster.
|
|
622
|
+
async function sampleFramesAsBase64(videoPath, durationSeconds, tempDir) {
|
|
623
|
+
const safeDuration = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 5;
|
|
624
|
+
const MIN_FRAMES = 8;
|
|
625
|
+
// Denser + sharper than before (was 14 / 960px / q3 / 1.0fps). More frames
|
|
626
|
+
// catch more scene transitions and short-lived captions; higher resolution +
|
|
627
|
+
// lower JPEG quantization keep thin overlay glyphs legible for the model.
|
|
628
|
+
const MAX_FRAMES = 18;
|
|
629
|
+
const targetPerSecond = 1.2;
|
|
630
|
+
const desired = Math.round(safeDuration * targetPerSecond);
|
|
631
|
+
const frameCount = Math.min(MAX_FRAMES, Math.max(MIN_FRAMES, desired));
|
|
632
|
+
if (frameCount <= 0)
|
|
633
|
+
return [];
|
|
634
|
+
const effectiveInterval = safeDuration / frameCount;
|
|
635
|
+
const outDir = path.join(tempDir, "vision-frames");
|
|
636
|
+
await mkdir(outDir, { recursive: true });
|
|
637
|
+
const outPattern = path.join(outDir, "frame-%04d.jpg");
|
|
638
|
+
const fps = 1 / effectiveInterval;
|
|
639
|
+
await runBinary(ffmpegBin, [
|
|
640
|
+
"-y",
|
|
641
|
+
"-i", videoPath,
|
|
642
|
+
"-vf", `fps=${fps.toFixed(6)},scale=1120:-2`,
|
|
643
|
+
"-q:v", "2",
|
|
644
|
+
"-vframes", String(frameCount),
|
|
645
|
+
outPattern
|
|
646
|
+
]);
|
|
647
|
+
const results = [];
|
|
648
|
+
for (let i = 0; i < frameCount; i += 1) {
|
|
649
|
+
const framePath = path.join(outDir, `frame-${String(i + 1).padStart(4, "0")}.jpg`);
|
|
650
|
+
let buf;
|
|
651
|
+
try {
|
|
652
|
+
buf = await readFile(framePath);
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
// ffmpeg may emit fewer frames than requested for very short/edge-case
|
|
656
|
+
// videos; skip missing frames instead of throwing so the vision pass
|
|
657
|
+
// still runs with whatever we got.
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const timestamp = Math.min(safeDuration - 0.01, Math.max(0.01, i * effectiveInterval + effectiveInterval / 2));
|
|
661
|
+
results.push({ timestamp: Number(timestamp.toFixed(3)), data: buf.toString("base64") });
|
|
662
|
+
}
|
|
663
|
+
return results;
|
|
664
|
+
}
|
|
665
|
+
// Extract frames at a dense fixed interval for OCR — this is what actually
|
|
666
|
+
// grounds caption start/end timing. Interval defaults to 0.5s; tunable via
|
|
667
|
+
// OCR_FRAME_INTERVAL_SECONDS. Cap total frames so a 5-minute video doesn't
|
|
668
|
+
// fan out into hundreds of OCR calls.
|
|
669
|
+
//
|
|
670
|
+
// IMPORTANT: uses a SINGLE ffmpeg invocation with `-vf fps=…` to write all
|
|
671
|
+
// frames in one pass. The previous per-frame ffmpeg spawn loop was ~1s per
|
|
672
|
+
// frame and pushed 30s videos past the 60s CloudFront timeout on their own.
|
|
673
|
+
async function extractDenseOcrFrames(videoPath, durationSeconds, tempDir) {
|
|
674
|
+
const intervalRaw = Number(process.env.OCR_FRAME_INTERVAL_SECONDS ?? "0.5");
|
|
675
|
+
const interval = Number.isFinite(intervalRaw) && intervalRaw > 0 ? Math.max(0.1, intervalRaw) : 0.5;
|
|
676
|
+
const maxFramesRaw = Number(process.env.OCR_MAX_FRAMES ?? "60");
|
|
677
|
+
const maxFrames = Number.isFinite(maxFramesRaw) && maxFramesRaw > 0 ? Math.floor(maxFramesRaw) : 60;
|
|
678
|
+
const safeDuration = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 5;
|
|
679
|
+
const totalDesired = Math.max(1, Math.floor(safeDuration / interval));
|
|
680
|
+
const totalFrames = Math.min(maxFrames, totalDesired);
|
|
681
|
+
if (totalFrames <= 0)
|
|
682
|
+
return [];
|
|
683
|
+
const effectiveInterval = safeDuration / totalFrames;
|
|
684
|
+
const outDir = path.join(tempDir, "ocr-frames");
|
|
685
|
+
await mkdir(outDir, { recursive: true });
|
|
686
|
+
const outPattern = path.join(outDir, "ocr-%04d.jpg");
|
|
687
|
+
const fps = 1 / effectiveInterval;
|
|
688
|
+
await runBinary(ffmpegBin, [
|
|
689
|
+
"-y",
|
|
690
|
+
"-i", videoPath,
|
|
691
|
+
// fps filter samples at the target rate; scale keeps the frame small
|
|
692
|
+
// enough that token cost stays down while text stays readable.
|
|
693
|
+
"-vf", `fps=${fps.toFixed(6)},scale=640:-2`,
|
|
694
|
+
"-q:v", "5",
|
|
695
|
+
"-vframes", String(totalFrames),
|
|
696
|
+
outPattern
|
|
697
|
+
]);
|
|
698
|
+
const results = [];
|
|
699
|
+
for (let i = 0; i < totalFrames; i += 1) {
|
|
700
|
+
const framePath = path.join(outDir, `ocr-${String(i + 1).padStart(4, "0")}.jpg`);
|
|
701
|
+
let buf;
|
|
702
|
+
try {
|
|
703
|
+
buf = await readFile(framePath);
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
// ffmpeg may emit fewer frames than requested for very short videos
|
|
707
|
+
// (rounding). Stop cleanly instead of throwing.
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
// Frame N (1-indexed) captures roughly at the midpoint of its interval.
|
|
711
|
+
const timestamp = Math.min(safeDuration - 0.01, Math.max(0.01, i * effectiveInterval + effectiveInterval / 2));
|
|
712
|
+
results.push({ timestamp: Number(timestamp.toFixed(3)), data: buf.toString("base64") });
|
|
713
|
+
}
|
|
714
|
+
return results;
|
|
715
|
+
}
|
|
716
|
+
// Prompt the vision model to return ONLY the overlay text visible in each
|
|
717
|
+
// frame, verbatim, no interpretation. This is intentionally narrow so the
|
|
718
|
+
// model behaves as an OCR engine rather than doing scene analysis (which
|
|
719
|
+
// the sparse vision pass already handles).
|
|
720
|
+
function buildOcrBatchPrompt(frames) {
|
|
721
|
+
const frameList = frames
|
|
722
|
+
.map((f, i) => ` ${i + 1}. frame @ ${f.timestamp.toFixed(2)}s`)
|
|
723
|
+
.join("\n");
|
|
724
|
+
return `You are an OCR engine. For each of the following ${frames.length} video frames, return every piece of overlay/caption text that appears in the image. Ignore any text that is part of the background scenery (signs, packaging, phone UIs, watermarks). Only report captions/subtitles/CTAs that were added on top of the video.
|
|
725
|
+
|
|
726
|
+
Return ONLY strictly valid JSON with this schema:
|
|
727
|
+
{
|
|
728
|
+
"frames": [
|
|
729
|
+
{ "index": 1, "timestamp": <seconds>, "texts": ["exact text line 1", "exact text line 2"] },
|
|
730
|
+
...
|
|
731
|
+
]
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
Rules:
|
|
735
|
+
- One entry per frame, in the same order given below.
|
|
736
|
+
- "texts" is an array of text lines exactly as they appear (preserve case, punctuation, emoji). Split multi-line captions into one string per visible line.
|
|
737
|
+
- If a frame has no overlay text, "texts" MUST be [].
|
|
738
|
+
- Do NOT paraphrase, translate, or "clean up" text. Verbatim only.
|
|
739
|
+
- Do NOT hallucinate text that isn't clearly visible.
|
|
740
|
+
|
|
741
|
+
FRAMES (in order):
|
|
742
|
+
${frameList}`;
|
|
743
|
+
}
|
|
744
|
+
// Run the dense frames through the same vision fallback chain that
|
|
745
|
+
// smartDecomposeVideo uses. Batches ~8 frames per call to bound token cost
|
|
746
|
+
// per request while keeping request count low. Any batch that fails is
|
|
747
|
+
// dropped — downstream code falls back to vision-provided timing for
|
|
748
|
+
// captions that don't get an OCR match, so partial OCR is still useful.
|
|
749
|
+
async function runOcrPass(input) {
|
|
750
|
+
// Larger batches + higher concurrency keep the total OCR wall-time under
|
|
751
|
+
// the 60s CloudFront read timeout for typical 28s vertical videos (56
|
|
752
|
+
// dense frames). Gemini handles 12 image inputs per call fine and the
|
|
753
|
+
// extra parallelism means 56 frames finish in ~1 round of 5 concurrent
|
|
754
|
+
// batches instead of 2 rounds of 3.
|
|
755
|
+
const BATCH_SIZE = 12;
|
|
756
|
+
const CONCURRENCY = 5;
|
|
757
|
+
const batches = [];
|
|
758
|
+
for (let i = 0; i < input.frames.length; i += BATCH_SIZE) {
|
|
759
|
+
batches.push(input.frames.slice(i, i + BATCH_SIZE));
|
|
760
|
+
}
|
|
761
|
+
const results = [];
|
|
762
|
+
for (let i = 0; i < batches.length; i += CONCURRENCY) {
|
|
763
|
+
const slice = batches.slice(i, i + CONCURRENCY);
|
|
764
|
+
const settled = await Promise.allSettled(slice.map(async (batch) => {
|
|
765
|
+
const prompt = buildOcrBatchPrompt(batch);
|
|
766
|
+
const attempt = await callVisionWithFallback({ prompt, frames: batch, keys: input.keys });
|
|
767
|
+
const record = (attempt.result && typeof attempt.result === "object") ? attempt.result : {};
|
|
768
|
+
const framesRaw = Array.isArray(record.frames) ? record.frames : [];
|
|
769
|
+
const parsed = [];
|
|
770
|
+
for (let idx = 0; idx < batch.length; idx += 1) {
|
|
771
|
+
const entry = framesRaw[idx];
|
|
772
|
+
const timestamp = batch[idx].timestamp;
|
|
773
|
+
const textsRaw = entry && Array.isArray(entry.texts) ? entry.texts : [];
|
|
774
|
+
const texts = textsRaw
|
|
775
|
+
.map((t) => (typeof t === "string" ? t : ""))
|
|
776
|
+
.filter((t) => t.trim().length > 0);
|
|
777
|
+
parsed.push({ timestamp, texts });
|
|
778
|
+
}
|
|
779
|
+
return parsed;
|
|
780
|
+
}));
|
|
781
|
+
for (const s of settled) {
|
|
782
|
+
if (s.status === "fulfilled") {
|
|
783
|
+
results.push(...s.value);
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
console.error("smart decompose OCR batch failed", s.reason instanceof Error ? s.reason.message : s.reason);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
results.sort((a, b) => a.timestamp - b.timestamp);
|
|
791
|
+
return results;
|
|
792
|
+
}
|
|
793
|
+
function normalizeForOcrMatch(text) {
|
|
794
|
+
return text
|
|
795
|
+
.toLowerCase()
|
|
796
|
+
.replace(/[\u2018\u2019\u201C\u201D]/g, "'")
|
|
797
|
+
.replace(/[^\p{Letter}\p{Number}\s]/gu, " ")
|
|
798
|
+
.replace(/\s+/g, " ")
|
|
799
|
+
.trim();
|
|
800
|
+
}
|
|
801
|
+
// Overlap ratio: what fraction of the caption's significant words appear in
|
|
802
|
+
// the frame's concatenated OCR text? Stopwords-free would be nicer but for
|
|
803
|
+
// short-form video captions ("single", "have kids", "buy my course") the
|
|
804
|
+
// full word list is fine and simpler.
|
|
805
|
+
function captionOverlapWithFrame(captionText, frame) {
|
|
806
|
+
const captionWords = normalizeForOcrMatch(captionText).split(" ").filter((w) => w.length >= 2);
|
|
807
|
+
if (captionWords.length === 0)
|
|
808
|
+
return 0;
|
|
809
|
+
const frameText = " " + normalizeForOcrMatch(frame.texts.join(" ")) + " ";
|
|
810
|
+
if (frameText.trim().length === 0)
|
|
811
|
+
return 0;
|
|
812
|
+
let hits = 0;
|
|
813
|
+
for (const word of captionWords) {
|
|
814
|
+
if (frameText.includes(" " + word + " "))
|
|
815
|
+
hits += 1;
|
|
816
|
+
}
|
|
817
|
+
return hits / captionWords.length;
|
|
818
|
+
}
|
|
819
|
+
// For each vision-detected caption, find the contiguous run of OCR frames
|
|
820
|
+
// where the caption's text is present, and snap start/duration to that run.
|
|
821
|
+
// If no OCR match is confident enough, keep the vision-provided timing
|
|
822
|
+
// (graceful degradation).
|
|
823
|
+
function snapCaptionTimingsToOcr(input) {
|
|
824
|
+
if (input.ocrFrames.length === 0) {
|
|
825
|
+
return { captions: input.captions, snappedCount: 0 };
|
|
826
|
+
}
|
|
827
|
+
const MATCH_THRESHOLD = 0.6;
|
|
828
|
+
// Half the median inter-frame gap — used to extend duration past the last
|
|
829
|
+
// matching frame since the caption presumably stayed on-screen partway
|
|
830
|
+
// into the next (non-matching) frame.
|
|
831
|
+
const gaps = [];
|
|
832
|
+
for (let i = 1; i < input.ocrFrames.length; i += 1) {
|
|
833
|
+
gaps.push(input.ocrFrames[i].timestamp - input.ocrFrames[i - 1].timestamp);
|
|
834
|
+
}
|
|
835
|
+
const sortedGaps = gaps.slice().sort((a, b) => a - b);
|
|
836
|
+
const medianGap = sortedGaps.length > 0 ? sortedGaps[Math.floor(sortedGaps.length / 2)] : 0.5;
|
|
837
|
+
const halfGap = Math.max(0.05, medianGap / 2);
|
|
838
|
+
let snappedCount = 0;
|
|
839
|
+
const snapped = input.captions.map((caption) => {
|
|
840
|
+
const scores = input.ocrFrames.map((frame) => captionOverlapWithFrame(caption.text, frame));
|
|
841
|
+
const matchIndexes = scores
|
|
842
|
+
.map((s, i) => ({ s, i }))
|
|
843
|
+
.filter((x) => x.s >= MATCH_THRESHOLD)
|
|
844
|
+
.map((x) => x.i);
|
|
845
|
+
if (matchIndexes.length === 0) {
|
|
846
|
+
return caption;
|
|
847
|
+
}
|
|
848
|
+
// Pick the contiguous run whose midpoint is closest to the vision-
|
|
849
|
+
// provided midpoint. This handles the case of a repeated caption
|
|
850
|
+
// (same text shown twice with a gap in between) by choosing the
|
|
851
|
+
// occurrence the vision pass was pointing at.
|
|
852
|
+
const runs = [];
|
|
853
|
+
let runStart = matchIndexes[0];
|
|
854
|
+
let prev = matchIndexes[0];
|
|
855
|
+
for (let i = 1; i < matchIndexes.length; i += 1) {
|
|
856
|
+
const idx = matchIndexes[i];
|
|
857
|
+
if (idx - prev <= 2) {
|
|
858
|
+
prev = idx;
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
runs.push({ start: runStart, end: prev });
|
|
862
|
+
runStart = idx;
|
|
863
|
+
prev = idx;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
runs.push({ start: runStart, end: prev });
|
|
867
|
+
const visionMid = caption.start + caption.duration / 2;
|
|
868
|
+
const bestRun = runs.reduce((best, r) => {
|
|
869
|
+
const runMid = (input.ocrFrames[r.start].timestamp + input.ocrFrames[r.end].timestamp) / 2;
|
|
870
|
+
const bestMid = (input.ocrFrames[best.start].timestamp + input.ocrFrames[best.end].timestamp) / 2;
|
|
871
|
+
return Math.abs(runMid - visionMid) < Math.abs(bestMid - visionMid) ? r : best;
|
|
872
|
+
}, runs[0]);
|
|
873
|
+
const firstTs = input.ocrFrames[bestRun.start].timestamp;
|
|
874
|
+
const lastTs = input.ocrFrames[bestRun.end].timestamp;
|
|
875
|
+
const newStart = Math.max(0, firstTs - halfGap);
|
|
876
|
+
const newEnd = Math.min(input.durationSeconds, lastTs + halfGap);
|
|
877
|
+
const newDuration = Math.max(0.1, newEnd - newStart);
|
|
878
|
+
snappedCount += 1;
|
|
879
|
+
return {
|
|
880
|
+
...caption,
|
|
881
|
+
start: Number(newStart.toFixed(3)),
|
|
882
|
+
duration: Number(newDuration.toFixed(3))
|
|
883
|
+
};
|
|
884
|
+
});
|
|
885
|
+
return { captions: snapped, snappedCount };
|
|
886
|
+
}
|
|
887
|
+
// Snap vision-provided scene boundaries onto ffmpeg-detected hard cuts, the
|
|
888
|
+
// scene-layer analogue of snapCaptionTimingsToOcr. Each scene "start" that
|
|
889
|
+
// falls within TOLERANCE of a detected cut is pulled exactly onto that cut;
|
|
890
|
+
// durations are then recomputed so scenes stay contiguous and still cover the
|
|
891
|
+
// full duration (start[0] forced to 0, last scene extended to the end). If no
|
|
892
|
+
// cuts were detected the scenes are returned unchanged.
|
|
893
|
+
function snapScenesToCuts(input) {
|
|
894
|
+
if (input.cuts.length === 0 || input.scenes.length === 0) {
|
|
895
|
+
return { scenes: input.scenes, snappedCount: 0 };
|
|
896
|
+
}
|
|
897
|
+
const TOLERANCE = 0.6;
|
|
898
|
+
const sorted = [...input.scenes].sort((a, b) => a.start - b.start);
|
|
899
|
+
let snappedCount = 0;
|
|
900
|
+
const snappedStarts = sorted.map((scene, index) => {
|
|
901
|
+
if (index === 0)
|
|
902
|
+
return 0; // first scene always starts at 0
|
|
903
|
+
let nearest = scene.start;
|
|
904
|
+
let bestDelta = TOLERANCE;
|
|
905
|
+
for (const cut of input.cuts) {
|
|
906
|
+
const delta = Math.abs(cut - scene.start);
|
|
907
|
+
if (delta < bestDelta) {
|
|
908
|
+
bestDelta = delta;
|
|
909
|
+
nearest = cut;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (nearest !== scene.start)
|
|
913
|
+
snappedCount += 1;
|
|
914
|
+
return nearest;
|
|
915
|
+
});
|
|
916
|
+
const rebuilt = sorted.map((scene, index) => {
|
|
917
|
+
const start = Number(snappedStarts[index].toFixed(3));
|
|
918
|
+
const nextStart = index + 1 < snappedStarts.length ? snappedStarts[index + 1] : input.durationSeconds;
|
|
919
|
+
const duration = Math.max(0.1, Number((nextStart - start).toFixed(3)));
|
|
920
|
+
return { ...scene, start, duration };
|
|
921
|
+
}).filter((s) => s.duration > 0.05);
|
|
922
|
+
return { scenes: rebuilt, snappedCount };
|
|
923
|
+
}
|
|
924
|
+
// Product-AGNOSTIC placement-surface prompt. Deliberately does NOT know the
|
|
925
|
+
// user's product — it enumerates reusable slots where any product could later
|
|
926
|
+
// be inserted. The product-SPECIFIC pitch stays the on-demand
|
|
927
|
+
// brainstorm_product_placement primitive, which these surfaces can seed.
|
|
928
|
+
function buildPlacementSurfacePrompt(input) {
|
|
929
|
+
const frameListing = input.frameTimestamps.length > 0
|
|
930
|
+
? `\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`
|
|
931
|
+
: "";
|
|
932
|
+
const sceneSlugList = input.sceneSlugs.length > 0
|
|
933
|
+
? `\nSCENE SLUGS (link each surface to the closest one via "scene_slug"): ${input.sceneSlugs.join(", ")}\n`
|
|
934
|
+
: "";
|
|
935
|
+
return `You are Vidfarm's native-advertising product-placement scout. You are studying a short-form vertical video (TikTok/Reel) to find natural PLACEMENT SURFACES — moments and spots where a product could later be inserted so it feels native, not bolted on.
|
|
936
|
+
|
|
937
|
+
CRITICAL: You do NOT know the product yet. Do NOT invent a specific product. Describe the SLOT itself (what is physically/attention-wise available), and list the KINDS of products that would fit. Think of this as reusable metadata that a later, product-specific pass will draw from.
|
|
938
|
+
|
|
939
|
+
Video duration: ${input.durationSeconds.toFixed(2)} seconds.
|
|
940
|
+
${frameListing}${sceneSlugList}
|
|
941
|
+
Return strictly valid JSON matching this schema:
|
|
942
|
+
{
|
|
943
|
+
"surfaces": [{
|
|
944
|
+
"slug": string,
|
|
945
|
+
"timestamp": string,
|
|
946
|
+
"start": number,
|
|
947
|
+
"scene_slug": string,
|
|
948
|
+
"surface_type": string,
|
|
949
|
+
"surface_note": string,
|
|
950
|
+
"best_for": [string, ...],
|
|
951
|
+
"why_it_works": string
|
|
952
|
+
}]
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
RULES:
|
|
956
|
+
- Identify 3-8 distinct surfaces, ordered strongest to weakest. Each must reference a real moment you actually see in the frames.
|
|
957
|
+
- "slug": short snake_case role label, e.g. "desk_prop_slot", "phone_screen_swap", "hand_held_reveal", "b_roll_cutaway", "end_card_cta". Unique.
|
|
958
|
+
- "timestamp": human range like "0:04" or "0:12-0:18". "start": the numeric seconds (float) where the surface begins — use it for editor targeting.
|
|
959
|
+
- "scene_slug": the closest scene slug from the list above (or "" if none given).
|
|
960
|
+
- "surface_type": one of prop | set_dressing | screen_ui | verbal_mention | caption_overlay | sticker_lower_third | b_roll_cutaway | apparel | before_after_demo | end_card.
|
|
961
|
+
- "surface_note": describe the SLOT product-agnostically — what is physically there and how long it holds (e.g. "empty desk surface faces camera, unobstructed for ~2s", "subject holds phone screen toward camera", "blank wall behind subject at chest height").
|
|
962
|
+
- "best_for": 2-4 product/offer CATEGORIES that would fit this slot naturally (e.g. "beverage/snack brand", "SaaS dashboard", "skincare bottle", "mobile app UI"). Categories, never a specific brand.
|
|
963
|
+
- "why_it_works": the attention/emotion/story logic that makes a placement here land.
|
|
964
|
+
- Bias toward surfaces that ride existing attention or story beats. If the video genuinely has no natural surface, return "surfaces": []. Never fabricate.`;
|
|
965
|
+
}
|
|
966
|
+
function normalizePlacementSurfaces(raw) {
|
|
967
|
+
const record = (raw && typeof raw === "object") ? raw : {};
|
|
968
|
+
const surfacesRaw = Array.isArray(record.surfaces) ? record.surfaces : [];
|
|
969
|
+
const usedSlugs = new Set();
|
|
970
|
+
return surfacesRaw
|
|
971
|
+
.map((s, index) => {
|
|
972
|
+
const slug = dedupeSlug(normalizeSlug(typeof s.slug === "string" ? s.slug : `surface_${index + 1}`) || `surface_${index + 1}`, usedSlugs);
|
|
973
|
+
const bestForRaw = Array.isArray(s.best_for) ? s.best_for : [];
|
|
974
|
+
const best_for = bestForRaw
|
|
975
|
+
.map((b) => (typeof b === "string" ? b.trim() : ""))
|
|
976
|
+
.filter((b) => b.length > 0)
|
|
977
|
+
.slice(0, 6);
|
|
978
|
+
return {
|
|
979
|
+
slug,
|
|
980
|
+
timestamp: typeof s.timestamp === "string" ? s.timestamp : "",
|
|
981
|
+
start: Number.isFinite(Number(s.start)) ? Number(Number(s.start).toFixed(3)) : 0,
|
|
982
|
+
scene_slug: typeof s.scene_slug === "string" ? normalizeSlug(s.scene_slug) : "",
|
|
983
|
+
surface_type: typeof s.surface_type === "string" ? s.surface_type : "prop",
|
|
984
|
+
surface_note: typeof s.surface_note === "string" ? s.surface_note : "",
|
|
985
|
+
best_for,
|
|
986
|
+
why_it_works: typeof s.why_it_works === "string" ? s.why_it_works : ""
|
|
987
|
+
};
|
|
988
|
+
})
|
|
989
|
+
.filter((s) => s.surface_note.trim().length > 0);
|
|
990
|
+
}
|
|
991
|
+
// Isolated placement-surface vision call. Reuses the SAME sparse frames already
|
|
992
|
+
// sampled for the main pass — no extra ffmpeg — and runs as its own request so
|
|
993
|
+
// it can never dilute the caption/scene call's attention budget. Non-fatal:
|
|
994
|
+
// callers wrap in .catch(() => []).
|
|
995
|
+
async function runPlacementSurfacePass(input) {
|
|
996
|
+
if (input.frames.length === 0)
|
|
997
|
+
return [];
|
|
998
|
+
const prompt = buildPlacementSurfacePrompt({
|
|
999
|
+
durationSeconds: input.durationSeconds,
|
|
1000
|
+
frameTimestamps: input.frames.map((f) => f.timestamp),
|
|
1001
|
+
sceneSlugs: input.sceneSlugs
|
|
1002
|
+
});
|
|
1003
|
+
const attempt = await callVisionWithFallback({ prompt, frames: input.frames, keys: input.keys });
|
|
1004
|
+
return normalizePlacementSurfaces(attempt.result);
|
|
1005
|
+
}
|
|
1006
|
+
function buildSmartDecomposePrompt(input) {
|
|
1007
|
+
const sceneCutHint = input.sceneCuts && input.sceneCuts.length > 0
|
|
1008
|
+
? `\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`
|
|
1009
|
+
: "";
|
|
1010
|
+
const userGuidance = input.userPrompt && input.userPrompt.trim().length > 0
|
|
1011
|
+
? `\nUSER GUIDANCE (highest priority — pay special attention to this when deciding scenes, captions, and viral_dna):\n"""\n${input.userPrompt.trim()}\n"""\n`
|
|
1012
|
+
: "";
|
|
1013
|
+
const trendTaglineHint = input.trendTaglineHint && input.trendTaglineHint.trim().length > 0
|
|
1014
|
+
? `\nTREND TAGLINE (caller-supplied, use verbatim as viral_dna.trend_tagline — do NOT rephrase):\n"""\n${input.trendTaglineHint.trim()}\n"""\n`
|
|
1015
|
+
: "";
|
|
1016
|
+
const frameListing = input.frameTimestamps.length > 0
|
|
1017
|
+
? `\nFRAMES SUPPLIED (in order — each image is captioned with its capture timestamp):\n${input.frameTimestamps.map((t, i) => ` frame ${i + 1} @ ${t.toFixed(2)}s`).join("\n")}\n\nUse the frame timestamps to determine caption "start" and "duration". Cross-reference consecutive frames: a caption whose text is identical across frames N and M was on-screen from at least the timestamp of frame N through the timestamp of frame M (extend "duration" accordingly). A caption present in only one frame is short-lived — set duration to roughly (next_frame_timestamp - this_frame_timestamp) or 0.8s, whichever is smaller.\n`
|
|
1018
|
+
: "";
|
|
1019
|
+
return `You are decomposing a short-form vertical video (TikTok/Reel) into editable HyperFrames timeline layers.
|
|
1020
|
+
${userGuidance}${trendTaglineHint}
|
|
1021
|
+
Video metadata:
|
|
1022
|
+
- duration: ${input.durationSeconds.toFixed(3)} seconds
|
|
1023
|
+
- dimensions: ${input.width} x ${input.height} (9:16 vertical, canvas is 100% x 100%)
|
|
1024
|
+
${frameListing}${sceneCutHint}
|
|
1025
|
+
|
|
1026
|
+
Return strictly valid JSON matching this schema:
|
|
1027
|
+
{
|
|
1028
|
+
"summary": string,
|
|
1029
|
+
"viral_dna": {
|
|
1030
|
+
"trend_tagline": string,
|
|
1031
|
+
"hook": string,
|
|
1032
|
+
"retention": string,
|
|
1033
|
+
"payoff": string,
|
|
1034
|
+
"preserve": [string, ...],
|
|
1035
|
+
"avoid": [string, ...],
|
|
1036
|
+
"promotions": [string, ...],
|
|
1037
|
+
"keywords": [string, ...]
|
|
1038
|
+
},
|
|
1039
|
+
"scenes": [{
|
|
1040
|
+
"slug": string,
|
|
1041
|
+
"start": number,
|
|
1042
|
+
"duration": number,
|
|
1043
|
+
"label": string,
|
|
1044
|
+
"description": string,
|
|
1045
|
+
"viral_note": string
|
|
1046
|
+
}],
|
|
1047
|
+
"captions": [{
|
|
1048
|
+
"slug": string,
|
|
1049
|
+
"text": string,
|
|
1050
|
+
"start": number,
|
|
1051
|
+
"duration": number,
|
|
1052
|
+
"x": number,
|
|
1053
|
+
"y": number,
|
|
1054
|
+
"width": number,
|
|
1055
|
+
"height": number,
|
|
1056
|
+
"font_size_percent": number,
|
|
1057
|
+
"font_size": number,
|
|
1058
|
+
"color": string,
|
|
1059
|
+
"background": string,
|
|
1060
|
+
"background_style": "plain" | "outline" | "highlight-solid" | "highlight-translucent",
|
|
1061
|
+
"font_family": string,
|
|
1062
|
+
"font_weight": number,
|
|
1063
|
+
"viral_note": string
|
|
1064
|
+
}]
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
VIRAL_DNA RULES:
|
|
1068
|
+
- "trend_tagline": a short (3-8 word) semantic identifier for the underlying trend/format itself, NOT this specific video's topic. Think of it as the label a scroller would use to categorize the format (e.g., "green-screen news debunk", "day-in-the-life sped-up montage", "best-at-what-cost destination reveal", "pinned-comment dance dare"). If the caller pre-supplied a trend_tagline (see USER GUIDANCE), preserve it verbatim; otherwise generate one.
|
|
1069
|
+
- "hook": the 1-sentence tactic used in the first 1-2 seconds to stop the scroll (e.g., "Pinned comment screenshot dares the viewer to keep watching").
|
|
1070
|
+
- "retention": how the middle of the video keeps attention (visual cuts, pattern loops, escalating stakes).
|
|
1071
|
+
- "payoff": what the viewer earns at the end (reveal, punchline, transformation).
|
|
1072
|
+
- "preserve": 3-5 concrete elements a remixer MUST keep to preserve the format (e.g., "Pinned comment stays on-screen the whole video", "Wedding dance payoff shot").
|
|
1073
|
+
- "avoid": 3-5 things that would break the format if changed (e.g., "Do not remove the on-screen text overlay", "Do not slow the cuts below 1.5s").
|
|
1074
|
+
- "promotions": 3-5 categories of product / service / offer this exact format could be reused to promote, WITHOUT changing the beat structure or hook mechanic. Each item is a concrete pitchable use-case, not a genre. Examples: "SaaS free-trial signup (before/after workflow)", "Fitness challenge (7-day transformation)", "Local restaurant grand-opening (line-out-the-door proof)", "Ebook / course launch (result-first payoff)", "Real-estate open-house (walk-through reveal)". Anchor each promotion to the same viral tactic captured in "hook" — do not invent unrelated verticals.
|
|
1075
|
+
- "keywords": 6-12 lowercase search terms that make this format discoverable when a marketer describes their product/offer in their own words. Include the niches, target audiences, product categories, outcomes, and emotions this format naturally sells to — plus obvious synonyms a user might type. Think about what a customer would search: e.g. for a before/after fitness format include "weight loss", "fitness", "gym", "transformation", "before after", "health app", "diet", "body goals", "wellness". Single words or 2-3 word phrases, no punctuation, no duplicates. These are for matching only, so bias toward the words a customer would use, not jargon.
|
|
1076
|
+
- Use short imperative fragments in preserve/avoid arrays. Never leave empty — infer from the frames if unstated.
|
|
1077
|
+
|
|
1078
|
+
SCENE RULES:
|
|
1079
|
+
- 2 to 6 scenes covering the FULL duration; contiguous (scene[i].start = scene[i-1].start + scene[i-1].duration); first scene starts at 0.0; last scene ends at ${input.durationSeconds.toFixed(3)}.
|
|
1080
|
+
- Every scene MUST include a "description": 1-3 plain sentences literally describing what is happening on screen during that scene — setting, subjects, their actions, camera movement/framing, and any notable visual changes (cuts, zooms, transitions, product shots). Describe only what a viewer literally sees in the frames; do NOT repeat viral_note commentary, marketing analysis, or audio content here.
|
|
1081
|
+
|
|
1082
|
+
SLUG RULES:
|
|
1083
|
+
- Every scene AND every caption MUST have a "slug": short snake_case (2-4 words) describing its ROLE in the viral structure. Unique within scenes; unique within captions. Examples: "hook_reveal", "problem_setup", "payoff_context", "cta_follow", "caption_hook_dare".
|
|
1084
|
+
|
|
1085
|
+
CAPTION RULES (CRITICAL — this is the primary use of vision on this task):
|
|
1086
|
+
- Inspect EVERY supplied frame. Enumerate EVERY distinct visible text overlay across all frames — hooks, dialog subtitles, hashtags, usernames, handles, brand tags, CTAs, sticker text, arrow labels, price tags, meme text, comment-screenshot text, product names — anything rendered as text on the pixels.
|
|
1087
|
+
- One caption = one visually distinct text block. If two lines share the same visual treatment and appear together as one overlay, keep them as ONE caption with "\\n" between the lines. If two blocks look different (different font, color, position, or independent timing), they are SEPARATE captions.
|
|
1088
|
+
- Preserve text VERBATIM. Copy spelling, punctuation, casing, emojis, and line breaks exactly as they appear. Do NOT paraphrase, normalize casing, translate, or "clean up" spacing. If a caption contains a typo, keep the typo.
|
|
1089
|
+
- The SAME text overlay that persists across multiple sampled frames is ONE caption with a longer duration — do NOT emit one caption per frame. Merge by (identical text + roughly same on-screen position + same visual style).
|
|
1090
|
+
- If a caption changes text mid-clip (e.g. rolling subtitle updates), emit each version as a separate caption with its own tight start/duration.
|
|
1091
|
+
- Position numbers (x, y, width, height) must reflect where the text ACTUALLY sits in the frame — measure the bounding box, don't guess. Vertical (9:16) captions almost never span x=0..100; hooks near y=8-18, dialog near y=60-72, CTAs near y=80-88.
|
|
1092
|
+
- If no visible text overlays exist across ANY frame, return "captions": []. NEVER invent text. NEVER hallucinate captions from audio or context.
|
|
1093
|
+
- Prioritize completeness. Missing an on-screen caption is a worse failure than emitting an extra minor one.
|
|
1094
|
+
|
|
1095
|
+
FONT_FAMILY (choose ONE, match visual appearance):
|
|
1096
|
+
- "TikTok Sans" — clean sans-serif (DEFAULT if unsure). Rounded, modern.
|
|
1097
|
+
- "Montserrat" — geometric bold sans. Common on IG/TikTok bold captions.
|
|
1098
|
+
- "Source Code Pro" — monospaced. For code/terminal aesthetic.
|
|
1099
|
+
- "Yesteryear" — cursive script. For quotes / romantic vibes.
|
|
1100
|
+
- "Georgia" — serif. For editorial / news look.
|
|
1101
|
+
- "Abel" — condensed sans. Newsletter/headline vibe.
|
|
1102
|
+
- Return exactly one of the above six strings. If unsure, return "TikTok Sans".
|
|
1103
|
+
|
|
1104
|
+
FONT_WEIGHT (integer):
|
|
1105
|
+
- 400 for normal weight
|
|
1106
|
+
- 600 for semibold (typical TikTok caption look)
|
|
1107
|
+
- 700 for bold (heavy captions)
|
|
1108
|
+
- 800 or 900 for ultra-black display text
|
|
1109
|
+
|
|
1110
|
+
FONT_SIZE — return TWO numbers, both required. The renderer uses font_size_percent to survive canvas resizes.
|
|
1111
|
+
- "font_size_percent" (float 1.0–15.0): the caption glyph height as a percent of canvas height (${input.height || 1280}px). This is the SOURCE OF TRUTH. Measure a single-line uppercase-height letter (like an "M" or "H") in the actual frame and divide by canvas height, times 100.
|
|
1112
|
+
- Tiny UI / hashtag row: 1.2–2.0
|
|
1113
|
+
- Standard TikTok caption (dialog line): 2.0–3.2
|
|
1114
|
+
- Standard hook caption (readable but not screaming): 3.2–5.0
|
|
1115
|
+
- Bold hook overlay filling a wide band: 5.0–8.0
|
|
1116
|
+
- Giant kinetic display / meme text: 8.0–12.0
|
|
1117
|
+
- "font_size" (integer pixels): compute as round(font_size_percent / 100 * ${input.height || 1280}). Include it for redundancy.
|
|
1118
|
+
- Do NOT default to 24. Measure the actual on-screen glyph height. When unsure between two ranges, pick the one closest to what you actually measure — do not over-inflate.
|
|
1119
|
+
- Give the SAME caption the SAME font_size — do not vary between instances.
|
|
1120
|
+
|
|
1121
|
+
COLOR (hex "#rrggbb"):
|
|
1122
|
+
- Pure white: "#ffffff" (most common TikTok captions)
|
|
1123
|
+
- Pure black: "#000000"
|
|
1124
|
+
- Bright yellow (highlight text): "#ffe600"
|
|
1125
|
+
- Match the actual on-screen color as closely as you can. Never return names like "white"; always hex.
|
|
1126
|
+
|
|
1127
|
+
BACKGROUND_STYLE (choose ONE):
|
|
1128
|
+
- "plain" — no visible pill/outline. Just letters over the video. (Most common.)
|
|
1129
|
+
- "outline" — thin dark outline around each glyph (for legibility over busy video).
|
|
1130
|
+
- "highlight-solid" — a solid colored pill / block behind the text (typical TikTok white-on-black caption card).
|
|
1131
|
+
- "highlight-translucent" — semi-transparent pill.
|
|
1132
|
+
- BACKGROUND ("#rrggbb") is the pill / outline color. For "plain" this is unused but still return a valid hex like "#000000".
|
|
1133
|
+
|
|
1134
|
+
POSITION (percent 0-100, floats OK):
|
|
1135
|
+
- x, y = top-left corner of the caption's bounding box relative to the canvas.
|
|
1136
|
+
- width, height = its bounding box size.
|
|
1137
|
+
- TikTok baked captions are usually centered horizontally: x ≈ 5-10, width ≈ 80-90.
|
|
1138
|
+
- Vertical placement: hook captions near top (y ≈ 8-18); dialog/subtitle mid-lower (y ≈ 60-72); brand tag / cta bottom (y ≈ 80-88).
|
|
1139
|
+
- Height should roughly match font_size and line count.
|
|
1140
|
+
|
|
1141
|
+
TIMING RULES:
|
|
1142
|
+
- start, duration are seconds (floats). start >= 0. start + duration <= ${input.durationSeconds.toFixed(3)}.
|
|
1143
|
+
|
|
1144
|
+
OUTPUT: pure JSON, no prose, no markdown fences.`;
|
|
1145
|
+
}
|
|
1146
|
+
const ALLOWED_FONTS = [
|
|
1147
|
+
"TikTok Sans", "Montserrat", "Source Code Pro", "Yesteryear", "Georgia", "Abel"
|
|
1148
|
+
];
|
|
1149
|
+
const ALLOWED_BG_STYLES = [
|
|
1150
|
+
"plain", "outline", "highlight-solid", "highlight-translucent"
|
|
1151
|
+
];
|
|
1152
|
+
function normalizeFontFamily(value) {
|
|
1153
|
+
if (typeof value !== "string")
|
|
1154
|
+
return "TikTok Sans";
|
|
1155
|
+
const trimmed = value.trim();
|
|
1156
|
+
const match = ALLOWED_FONTS.find((font) => font.toLowerCase() === trimmed.toLowerCase());
|
|
1157
|
+
return match ?? "TikTok Sans";
|
|
1158
|
+
}
|
|
1159
|
+
function normalizeHexColor(value, fallback) {
|
|
1160
|
+
if (typeof value !== "string")
|
|
1161
|
+
return fallback;
|
|
1162
|
+
const trimmed = value.trim();
|
|
1163
|
+
if (/^#[0-9a-fA-F]{6}$/.test(trimmed))
|
|
1164
|
+
return trimmed.toLowerCase();
|
|
1165
|
+
if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
|
|
1166
|
+
const [, r, g, b] = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(trimmed) ?? [];
|
|
1167
|
+
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
|
1168
|
+
}
|
|
1169
|
+
return fallback;
|
|
1170
|
+
}
|
|
1171
|
+
function normalizeBackgroundStyle(value) {
|
|
1172
|
+
if (typeof value !== "string")
|
|
1173
|
+
return "plain";
|
|
1174
|
+
const lowered = value.trim().toLowerCase();
|
|
1175
|
+
const match = ALLOWED_BG_STYLES.find((style) => style === lowered);
|
|
1176
|
+
return match ?? "plain";
|
|
1177
|
+
}
|
|
1178
|
+
// Convert a #rrggbb hex to an rgba(...) string with the given alpha. Falls back
|
|
1179
|
+
// to a solid dark backing if the hex is malformed so the caption still stays
|
|
1180
|
+
// legible.
|
|
1181
|
+
function hexToRgba(hex, alpha) {
|
|
1182
|
+
const match = /^#([0-9a-f]{6})$/i.exec(String(hex ?? "").trim());
|
|
1183
|
+
if (!match)
|
|
1184
|
+
return `rgba(0,0,0,${alpha})`;
|
|
1185
|
+
const value = match[1];
|
|
1186
|
+
const r = parseInt(value.slice(0, 2), 16);
|
|
1187
|
+
const g = parseInt(value.slice(2, 4), 16);
|
|
1188
|
+
const b = parseInt(value.slice(4, 6), 16);
|
|
1189
|
+
return `rgba(${r},${g},${b},${alpha})`;
|
|
1190
|
+
}
|
|
1191
|
+
// Builds the inline-span CSS for a caption based on its background_style so
|
|
1192
|
+
// captions actually render with the visual treatment the vision LLM extracted
|
|
1193
|
+
// ("outline" gets a real text stroke; "highlight-*" gets a colored pill).
|
|
1194
|
+
// Without this the span just had `color:#fff` and everything looked washed
|
|
1195
|
+
// out over the video.
|
|
1196
|
+
function captionInlineTextStyle(caption) {
|
|
1197
|
+
const parts = [`color:${caption.color}`];
|
|
1198
|
+
const strokeColor = caption.background && /^#[0-9a-f]{6}$/i.test(caption.background)
|
|
1199
|
+
? caption.background
|
|
1200
|
+
: "#000000";
|
|
1201
|
+
// Outline width scales with font size so small captions still get a
|
|
1202
|
+
// visible edge. Clamp so it never disappears (1px) and never eats the
|
|
1203
|
+
// glyph interior (5px).
|
|
1204
|
+
const strokePx = Math.max(1, Math.min(5, Math.round(caption.font_size * 0.06)));
|
|
1205
|
+
switch (caption.background_style) {
|
|
1206
|
+
case "outline":
|
|
1207
|
+
parts.push(`-webkit-text-stroke:${strokePx}px ${strokeColor}`, `paint-order:stroke fill`,
|
|
1208
|
+
// text-shadow fallback for renderers that ignore paint-order.
|
|
1209
|
+
`text-shadow:0 0 ${strokePx}px ${strokeColor}, ${strokePx}px ${strokePx}px 0 ${strokeColor}, -${strokePx}px ${strokePx}px 0 ${strokeColor}, ${strokePx}px -${strokePx}px 0 ${strokeColor}, -${strokePx}px -${strokePx}px 0 ${strokeColor}`);
|
|
1210
|
+
break;
|
|
1211
|
+
case "highlight-solid":
|
|
1212
|
+
parts.push(`background:${strokeColor}`, `padding:0.18em 0.5em`, `border-radius:0.35em`, `box-decoration-break:clone`, `-webkit-box-decoration-break:clone`);
|
|
1213
|
+
break;
|
|
1214
|
+
case "highlight-translucent":
|
|
1215
|
+
parts.push(`background:${hexToRgba(strokeColor, 0.55)}`, `padding:0.18em 0.5em`, `border-radius:0.35em`, `box-decoration-break:clone`, `-webkit-box-decoration-break:clone`);
|
|
1216
|
+
break;
|
|
1217
|
+
case "plain":
|
|
1218
|
+
default:
|
|
1219
|
+
// Even "plain" captions need SOME shadow over busy TikTok footage or
|
|
1220
|
+
// white-on-white becomes invisible. Use a subtle drop shadow that
|
|
1221
|
+
// doesn't read as an intentional outline.
|
|
1222
|
+
parts.push(`text-shadow:0 1px 2px rgba(0,0,0,0.55), 0 0 3px rgba(0,0,0,0.35)`);
|
|
1223
|
+
break;
|
|
1224
|
+
}
|
|
1225
|
+
return parts.join(";");
|
|
1226
|
+
}
|
|
1227
|
+
function normalizeFontWeight(value) {
|
|
1228
|
+
const parsed = Number(value);
|
|
1229
|
+
if (!Number.isFinite(parsed))
|
|
1230
|
+
return 600;
|
|
1231
|
+
const clamped = Math.max(100, Math.min(900, Math.round(parsed / 100) * 100));
|
|
1232
|
+
return clamped;
|
|
1233
|
+
}
|
|
1234
|
+
function normalizeFontSize(value, canvasHeight) {
|
|
1235
|
+
const parsed = Number(value);
|
|
1236
|
+
const heightRef = canvasHeight > 0 ? canvasHeight : 1280;
|
|
1237
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
1238
|
+
return Math.round(heightRef * 0.028);
|
|
1239
|
+
return Math.max(10, Math.min(160, Math.round(parsed)));
|
|
1240
|
+
}
|
|
1241
|
+
function resolveFontSize(input) {
|
|
1242
|
+
const heightRef = input.canvasHeight > 0 ? input.canvasHeight : 1280;
|
|
1243
|
+
const percent = Number(input.fontSizePercent);
|
|
1244
|
+
if (Number.isFinite(percent) && percent > 0) {
|
|
1245
|
+
const clampedPct = Math.max(0.8, Math.min(15, percent));
|
|
1246
|
+
return Math.max(10, Math.min(160, Math.round((clampedPct / 100) * heightRef)));
|
|
1247
|
+
}
|
|
1248
|
+
return normalizeFontSize(input.fontSize, input.canvasHeight);
|
|
1249
|
+
}
|
|
1250
|
+
const DEFAULT_VIRAL_DNA = {
|
|
1251
|
+
trend_tagline: "", hook: "", retention: "", payoff: "", preserve: [], avoid: [], promotions: [], keywords: []
|
|
1252
|
+
};
|
|
1253
|
+
function normalizeViralDna(value) {
|
|
1254
|
+
if (!value || typeof value !== "object")
|
|
1255
|
+
return DEFAULT_VIRAL_DNA;
|
|
1256
|
+
const record = value;
|
|
1257
|
+
const asString = (v) => typeof v === "string" ? v.trim() : "";
|
|
1258
|
+
const asStringArray = (v) => Array.isArray(v)
|
|
1259
|
+
? v.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0)
|
|
1260
|
+
: [];
|
|
1261
|
+
return {
|
|
1262
|
+
trend_tagline: asString(record.trend_tagline ?? record.trendTagline),
|
|
1263
|
+
hook: asString(record.hook),
|
|
1264
|
+
retention: asString(record.retention),
|
|
1265
|
+
payoff: asString(record.payoff),
|
|
1266
|
+
preserve: asStringArray(record.preserve),
|
|
1267
|
+
avoid: asStringArray(record.avoid),
|
|
1268
|
+
promotions: asStringArray(record.promotions),
|
|
1269
|
+
keywords: asStringArray(record.keywords).map((item) => item.toLowerCase())
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
function shortElementId() {
|
|
1273
|
+
return `element_${randomUUID().slice(0, 8)}`;
|
|
1274
|
+
}
|
|
1275
|
+
function extractJson(text) {
|
|
1276
|
+
const trimmed = text.trim();
|
|
1277
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
|
|
1278
|
+
const candidate = fenced?.[1] ?? trimmed;
|
|
1279
|
+
try {
|
|
1280
|
+
return JSON.parse(candidate);
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
const objectMatch = /\{[\s\S]*\}/.exec(candidate);
|
|
1284
|
+
if (objectMatch)
|
|
1285
|
+
return JSON.parse(objectMatch[0]);
|
|
1286
|
+
throw new Error("Gemini did not return valid JSON");
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
async function callGeminiVision(input) {
|
|
1290
|
+
const parts = [{ text: input.prompt }];
|
|
1291
|
+
for (const frame of input.frames) {
|
|
1292
|
+
parts.push({ text: `Frame captured at ${frame.timestamp.toFixed(2)}s` });
|
|
1293
|
+
parts.push({ inlineData: { mimeType: "image/jpeg", data: frame.data } });
|
|
1294
|
+
}
|
|
1295
|
+
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(input.model)}:generateContent?key=${encodeURIComponent(input.apiKey)}`, {
|
|
1296
|
+
method: "POST",
|
|
1297
|
+
headers: { "content-type": "application/json" },
|
|
1298
|
+
body: JSON.stringify({
|
|
1299
|
+
contents: [{ role: "user", parts }],
|
|
1300
|
+
generationConfig: {
|
|
1301
|
+
responseMimeType: "application/json",
|
|
1302
|
+
temperature: 0.15,
|
|
1303
|
+
// Bumped from Gemini's 8k default so long caption arrays (10+ text
|
|
1304
|
+
// overlays × per-caption position/style metadata) don't truncate
|
|
1305
|
+
// mid-JSON and blow up the parser.
|
|
1306
|
+
maxOutputTokens: 16384
|
|
1307
|
+
}
|
|
1308
|
+
})
|
|
1309
|
+
});
|
|
1310
|
+
const raw = await response.text();
|
|
1311
|
+
if (!response.ok) {
|
|
1312
|
+
let message = raw;
|
|
1313
|
+
try {
|
|
1314
|
+
message = JSON.parse(raw).error?.message ?? raw;
|
|
1315
|
+
}
|
|
1316
|
+
catch { }
|
|
1317
|
+
throw new Error(`Gemini responded ${response.status}: ${message}`);
|
|
1318
|
+
}
|
|
1319
|
+
const parsed = JSON.parse(raw);
|
|
1320
|
+
const text = parsed.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
|
|
1321
|
+
return extractJson(text);
|
|
1322
|
+
}
|
|
1323
|
+
async function callOpenAIVision(input) {
|
|
1324
|
+
const content = [{ type: "text", text: input.prompt }];
|
|
1325
|
+
for (const frame of input.frames) {
|
|
1326
|
+
content.push({ type: "text", text: `Frame captured at ${frame.timestamp.toFixed(2)}s` });
|
|
1327
|
+
content.push({
|
|
1328
|
+
type: "image_url",
|
|
1329
|
+
image_url: { url: `data:image/jpeg;base64,${frame.data}` }
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
const response = await fetch(input.endpoint ?? "https://api.openai.com/v1/chat/completions", {
|
|
1333
|
+
method: "POST",
|
|
1334
|
+
headers: {
|
|
1335
|
+
"content-type": "application/json",
|
|
1336
|
+
"authorization": `Bearer ${input.apiKey}`,
|
|
1337
|
+
...(input.extraHeaders ?? {})
|
|
1338
|
+
},
|
|
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
|
+
})
|
|
1348
|
+
});
|
|
1349
|
+
const raw = await response.text();
|
|
1350
|
+
if (!response.ok) {
|
|
1351
|
+
let message = raw;
|
|
1352
|
+
try {
|
|
1353
|
+
message = JSON.parse(raw).error?.message ?? raw;
|
|
1354
|
+
}
|
|
1355
|
+
catch { }
|
|
1356
|
+
throw new Error(`${input.endpoint?.includes("openrouter") ? "OpenRouter" : "OpenAI"} responded ${response.status}: ${message}`);
|
|
1357
|
+
}
|
|
1358
|
+
const parsed = JSON.parse(raw);
|
|
1359
|
+
const text = parsed.choices?.[0]?.message?.content ?? "";
|
|
1360
|
+
return extractJson(text);
|
|
1361
|
+
}
|
|
1362
|
+
async function callVisionWithFallback(input) {
|
|
1363
|
+
const attempts = [];
|
|
1364
|
+
if (input.keys.gemini) {
|
|
1365
|
+
const model = process.env.GEMINI_MODEL ?? "gemini-3.5-flash";
|
|
1366
|
+
attempts.push({
|
|
1367
|
+
provider: "gemini",
|
|
1368
|
+
model,
|
|
1369
|
+
run: () => callGeminiVision({ apiKey: input.keys.gemini, model, prompt: input.prompt, frames: input.frames })
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
if (input.keys.openai) {
|
|
1373
|
+
const model = process.env.OPENAI_VISION_MODEL ?? "gpt-5.5-2026-04-23";
|
|
1374
|
+
attempts.push({
|
|
1375
|
+
provider: "openai",
|
|
1376
|
+
model,
|
|
1377
|
+
run: () => callOpenAIVision({ apiKey: input.keys.openai, model, prompt: input.prompt, frames: input.frames })
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
if (input.keys.openrouter) {
|
|
1381
|
+
const model = process.env.OPENROUTER_VISION_MODEL ?? "google/gemini-3.5-flash";
|
|
1382
|
+
attempts.push({
|
|
1383
|
+
provider: "openrouter",
|
|
1384
|
+
model,
|
|
1385
|
+
run: () => callOpenAIVision({
|
|
1386
|
+
apiKey: input.keys.openrouter,
|
|
1387
|
+
model,
|
|
1388
|
+
prompt: input.prompt,
|
|
1389
|
+
frames: input.frames,
|
|
1390
|
+
endpoint: "https://openrouter.ai/api/v1/chat/completions",
|
|
1391
|
+
extraHeaders: {
|
|
1392
|
+
"HTTP-Referer": "https://staging.vidfarm.cc",
|
|
1393
|
+
"X-Title": "Vidfarm smart decompose"
|
|
1394
|
+
}
|
|
1395
|
+
})
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
if (attempts.length === 0) {
|
|
1399
|
+
throw new Error("No vision provider key available (GEMINI_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY).");
|
|
1400
|
+
}
|
|
1401
|
+
const errors = [];
|
|
1402
|
+
for (const attempt of attempts) {
|
|
1403
|
+
try {
|
|
1404
|
+
const result = await attempt.run();
|
|
1405
|
+
return { result, provider: attempt.provider, model: attempt.model };
|
|
1406
|
+
}
|
|
1407
|
+
catch (error) {
|
|
1408
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1409
|
+
errors.push(`${attempt.provider}(${attempt.model}): ${message}`);
|
|
1410
|
+
console.error(`smart decompose ${attempt.provider} failed`, message);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
throw new Error(`All vision providers failed. ${errors.join(" | ")}`);
|
|
1414
|
+
}
|
|
1415
|
+
// ==========================================================================
|
|
1416
|
+
// Audio transcript pass — demux the source's audio track to mono 16kHz WAV
|
|
1417
|
+
// (codec-free, accepted by every STT provider) and transcribe it verbatim
|
|
1418
|
+
// with timestamped segments. Runs concurrently with the vision + OCR passes
|
|
1419
|
+
// and is non-fatal: a video with no audio track, or a transcription failure,
|
|
1420
|
+
// yields transcript: null instead of failing the decompose.
|
|
1421
|
+
// ==========================================================================
|
|
1422
|
+
async function extractAudioAsWav(videoPath, tempDir) {
|
|
1423
|
+
const probeOutput = await runBinary(ffprobeBin, [
|
|
1424
|
+
"-v", "error",
|
|
1425
|
+
"-select_streams", "a",
|
|
1426
|
+
"-show_entries", "stream=codec_type",
|
|
1427
|
+
"-of", "json",
|
|
1428
|
+
videoPath
|
|
1429
|
+
]);
|
|
1430
|
+
const parsed = JSON.parse(probeOutput || "{}");
|
|
1431
|
+
if (!Array.isArray(parsed.streams) || parsed.streams.length === 0)
|
|
1432
|
+
return null;
|
|
1433
|
+
const audioPath = path.join(tempDir, "audio.wav");
|
|
1434
|
+
await runBinary(ffmpegBin, [
|
|
1435
|
+
"-y",
|
|
1436
|
+
"-i", videoPath,
|
|
1437
|
+
"-vn",
|
|
1438
|
+
"-ac", "1",
|
|
1439
|
+
"-ar", "16000",
|
|
1440
|
+
"-c:a", "pcm_s16le",
|
|
1441
|
+
audioPath
|
|
1442
|
+
]);
|
|
1443
|
+
return audioPath;
|
|
1444
|
+
}
|
|
1445
|
+
function buildTranscriptionPrompt(durationSeconds) {
|
|
1446
|
+
return `Transcribe the spoken audio in this ${durationSeconds.toFixed(1)}-second clip VERBATIM.
|
|
1447
|
+
|
|
1448
|
+
Return strictly valid JSON matching this schema:
|
|
1449
|
+
{
|
|
1450
|
+
"language": string,
|
|
1451
|
+
"segments": [{ "start": number, "end": number, "text": string }]
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
RULES:
|
|
1455
|
+
- Transcribe every spoken word exactly as heard — keep filler words, slang, and repetitions. Do not paraphrase, translate, or clean up grammar.
|
|
1456
|
+
- "language" is the BCP-47 code of the dominant spoken language (e.g. "en", "es").
|
|
1457
|
+
- Split into short segments at natural phrase boundaries; "start"/"end" are seconds within the clip (start >= 0, end <= ${durationSeconds.toFixed(3)}, end > start).
|
|
1458
|
+
- Only transcribe speech and sung lyrics. Ignore background music without lyrics and sound effects.
|
|
1459
|
+
- If there is no speech at all, return { "language": null, "segments": [] }.
|
|
1460
|
+
|
|
1461
|
+
OUTPUT: pure JSON, no prose, no markdown fences.`;
|
|
1462
|
+
}
|
|
1463
|
+
function normalizeTranscriptSegments(raw, durationSeconds) {
|
|
1464
|
+
if (!Array.isArray(raw))
|
|
1465
|
+
return [];
|
|
1466
|
+
return raw
|
|
1467
|
+
.map((entry) => {
|
|
1468
|
+
const record = (entry && typeof entry === "object") ? entry : {};
|
|
1469
|
+
const start = Number(record.start ?? NaN);
|
|
1470
|
+
const end = Number(record.end ?? NaN);
|
|
1471
|
+
const text = typeof record.text === "string" ? record.text.trim() : "";
|
|
1472
|
+
return { start, end, text };
|
|
1473
|
+
})
|
|
1474
|
+
.filter((s) => s.text.length > 0 && Number.isFinite(s.start) && Number.isFinite(s.end) && s.end > s.start)
|
|
1475
|
+
.map((s) => ({
|
|
1476
|
+
start: Number(Math.max(0, s.start).toFixed(3)),
|
|
1477
|
+
end: Number(Math.min(durationSeconds > 0 ? durationSeconds : s.end, s.end).toFixed(3)),
|
|
1478
|
+
text: s.text
|
|
1479
|
+
}))
|
|
1480
|
+
.sort((a, b) => a.start - b.start);
|
|
1481
|
+
}
|
|
1482
|
+
async function callGeminiTranscription(input) {
|
|
1483
|
+
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(input.model)}:generateContent?key=${encodeURIComponent(input.apiKey)}`, {
|
|
1484
|
+
method: "POST",
|
|
1485
|
+
headers: { "content-type": "application/json" },
|
|
1486
|
+
body: JSON.stringify({
|
|
1487
|
+
contents: [{
|
|
1488
|
+
role: "user",
|
|
1489
|
+
parts: [
|
|
1490
|
+
{ text: buildTranscriptionPrompt(input.durationSeconds) },
|
|
1491
|
+
{ inlineData: { mimeType: "audio/wav", data: input.audioBase64 } }
|
|
1492
|
+
]
|
|
1493
|
+
}],
|
|
1494
|
+
generationConfig: {
|
|
1495
|
+
responseMimeType: "application/json",
|
|
1496
|
+
temperature: 0,
|
|
1497
|
+
maxOutputTokens: 16384
|
|
1498
|
+
}
|
|
1499
|
+
})
|
|
1500
|
+
});
|
|
1501
|
+
const raw = await response.text();
|
|
1502
|
+
if (!response.ok) {
|
|
1503
|
+
let message = raw;
|
|
1504
|
+
try {
|
|
1505
|
+
message = JSON.parse(raw).error?.message ?? raw;
|
|
1506
|
+
}
|
|
1507
|
+
catch { }
|
|
1508
|
+
throw new Error(`Gemini responded ${response.status}: ${message}`);
|
|
1509
|
+
}
|
|
1510
|
+
const parsed = JSON.parse(raw);
|
|
1511
|
+
const text = parsed.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
|
|
1512
|
+
const record = (extractJson(text) ?? {});
|
|
1513
|
+
return {
|
|
1514
|
+
language: typeof record.language === "string" && record.language.trim() ? record.language.trim() : null,
|
|
1515
|
+
segments: normalizeTranscriptSegments(record.segments, input.durationSeconds)
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
async function callOpenAITranscription(input) {
|
|
1519
|
+
const audioBuffer = await readFile(input.audioPath);
|
|
1520
|
+
// Only whisper-1 supports verbose_json (per-segment timestamps). Newer
|
|
1521
|
+
// gpt-4o-*-transcribe models return plain json; collapse to one segment.
|
|
1522
|
+
const wantsSegments = input.model === "whisper-1";
|
|
1523
|
+
const form = new FormData();
|
|
1524
|
+
form.append("file", new Blob([new Uint8Array(audioBuffer)], { type: "audio/wav" }), "audio.wav");
|
|
1525
|
+
form.append("model", input.model);
|
|
1526
|
+
form.append("response_format", wantsSegments ? "verbose_json" : "json");
|
|
1527
|
+
const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
1528
|
+
method: "POST",
|
|
1529
|
+
headers: { "authorization": `Bearer ${input.apiKey}` },
|
|
1530
|
+
body: form
|
|
1531
|
+
});
|
|
1532
|
+
const raw = await response.text();
|
|
1533
|
+
if (!response.ok) {
|
|
1534
|
+
let message = raw;
|
|
1535
|
+
try {
|
|
1536
|
+
message = JSON.parse(raw).error?.message ?? raw;
|
|
1537
|
+
}
|
|
1538
|
+
catch { }
|
|
1539
|
+
throw new Error(`OpenAI responded ${response.status}: ${message}`);
|
|
1540
|
+
}
|
|
1541
|
+
const parsed = JSON.parse(raw);
|
|
1542
|
+
const segments = wantsSegments && Array.isArray(parsed.segments)
|
|
1543
|
+
? normalizeTranscriptSegments(parsed.segments, input.durationSeconds)
|
|
1544
|
+
: [];
|
|
1545
|
+
if (segments.length === 0 && typeof parsed.text === "string" && parsed.text.trim().length > 0) {
|
|
1546
|
+
segments.push({ start: 0, end: Number(input.durationSeconds.toFixed(3)), text: parsed.text.trim() });
|
|
1547
|
+
}
|
|
1548
|
+
return {
|
|
1549
|
+
language: typeof parsed.language === "string" && parsed.language.trim() ? parsed.language.trim() : null,
|
|
1550
|
+
segments
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
async function transcribeAudioWithFallback(input) {
|
|
1554
|
+
const attempts = [];
|
|
1555
|
+
if (input.keys.gemini) {
|
|
1556
|
+
const model = process.env.GEMINI_TRANSCRIBE_MODEL ?? process.env.GEMINI_MODEL ?? "gemini-3.5-flash";
|
|
1557
|
+
attempts.push({
|
|
1558
|
+
provider: "gemini",
|
|
1559
|
+
model,
|
|
1560
|
+
run: async () => {
|
|
1561
|
+
const audioBase64 = (await readFile(input.audioPath)).toString("base64");
|
|
1562
|
+
return callGeminiTranscription({ apiKey: input.keys.gemini, model, audioBase64, durationSeconds: input.durationSeconds });
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
if (input.keys.openai) {
|
|
1567
|
+
const model = process.env.OPENAI_TRANSCRIBE_MODEL ?? "whisper-1";
|
|
1568
|
+
attempts.push({
|
|
1569
|
+
provider: "openai",
|
|
1570
|
+
model,
|
|
1571
|
+
run: () => callOpenAITranscription({ apiKey: input.keys.openai, model, audioPath: input.audioPath, durationSeconds: input.durationSeconds })
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
if (attempts.length === 0)
|
|
1575
|
+
return null;
|
|
1576
|
+
const errors = [];
|
|
1577
|
+
for (const attempt of attempts) {
|
|
1578
|
+
try {
|
|
1579
|
+
const result = await attempt.run();
|
|
1580
|
+
return {
|
|
1581
|
+
text: result.segments.map((s) => s.text).join(" ").trim(),
|
|
1582
|
+
language: result.language,
|
|
1583
|
+
segments: result.segments,
|
|
1584
|
+
provider: attempt.provider,
|
|
1585
|
+
model: attempt.model
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
catch (error) {
|
|
1589
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1590
|
+
errors.push(`${attempt.provider}(${attempt.model}): ${message}`);
|
|
1591
|
+
console.error(`smart decompose transcription ${attempt.provider} failed`, message);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
throw new Error(`All transcription providers failed. ${errors.join(" | ")}`);
|
|
1595
|
+
}
|
|
1596
|
+
export async function smartDecomposeVideo(input) {
|
|
1597
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), SMART_DECOMPOSE_TEMP_PREFIX));
|
|
1598
|
+
const sourcePath = path.join(tempDir, "source.mp4");
|
|
1599
|
+
const startedAt = Date.now();
|
|
1600
|
+
devLog("hyperframes.smart_decompose.start", {
|
|
1601
|
+
source_url: input.sourceUrl,
|
|
1602
|
+
has_user_prompt: Boolean(input.userPrompt),
|
|
1603
|
+
providers_available: {
|
|
1604
|
+
gemini: Boolean(input.keys.gemini),
|
|
1605
|
+
openai: Boolean(input.keys.openai),
|
|
1606
|
+
openrouter: Boolean(input.keys.openrouter)
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1609
|
+
try {
|
|
1610
|
+
await downloadVideoTo(input.sourceUrl, sourcePath);
|
|
1611
|
+
const meta = await ffprobeVideo(sourcePath);
|
|
1612
|
+
if (!Number.isFinite(meta.durationSeconds) || meta.durationSeconds <= 0) {
|
|
1613
|
+
throw new Error("Could not read video duration");
|
|
1614
|
+
}
|
|
1615
|
+
devLog("hyperframes.smart_decompose.probed", {
|
|
1616
|
+
duration_seconds: meta.durationSeconds,
|
|
1617
|
+
width: meta.width,
|
|
1618
|
+
height: meta.height
|
|
1619
|
+
});
|
|
1620
|
+
// Sparse frames for the vision pass (scenes, captions, style, viral_dna)
|
|
1621
|
+
// and dense frames for the OCR pass (deterministic caption timing).
|
|
1622
|
+
// Extract both up front so the OCR API calls can run in parallel with
|
|
1623
|
+
// the main vision call.
|
|
1624
|
+
const [frames, ocrRawFrames, audioPath, sceneCuts] = await Promise.all([
|
|
1625
|
+
sampleFramesAsBase64(sourcePath, meta.durationSeconds, tempDir),
|
|
1626
|
+
extractDenseOcrFrames(sourcePath, meta.durationSeconds, tempDir),
|
|
1627
|
+
extractAudioAsWav(sourcePath, tempDir).catch((err) => {
|
|
1628
|
+
console.error("smart decompose audio extract failed", err instanceof Error ? err.message : err);
|
|
1629
|
+
return null;
|
|
1630
|
+
}),
|
|
1631
|
+
detectSceneCuts(sourcePath, meta.durationSeconds, tempDir).catch((err) => {
|
|
1632
|
+
console.error("smart decompose scene-cut detection failed", err instanceof Error ? err.message : err);
|
|
1633
|
+
return [];
|
|
1634
|
+
})
|
|
1635
|
+
]);
|
|
1636
|
+
devLog("hyperframes.smart_decompose.frames_sampled", {
|
|
1637
|
+
frame_count: frames.length,
|
|
1638
|
+
ocr_frame_count: ocrRawFrames.length,
|
|
1639
|
+
has_audio_track: Boolean(audioPath),
|
|
1640
|
+
scene_cut_count: sceneCuts.length
|
|
1641
|
+
});
|
|
1642
|
+
const prompt = buildSmartDecomposePrompt({
|
|
1643
|
+
durationSeconds: meta.durationSeconds,
|
|
1644
|
+
width: meta.width,
|
|
1645
|
+
height: meta.height,
|
|
1646
|
+
userPrompt: input.userPrompt ?? null,
|
|
1647
|
+
trendTaglineHint: input.trendTaglineHint ?? null,
|
|
1648
|
+
frameTimestamps: frames.map((f) => f.timestamp),
|
|
1649
|
+
sceneCuts
|
|
1650
|
+
});
|
|
1651
|
+
// Vision, OCR, transcription, and the product-placement scout run
|
|
1652
|
+
// concurrently. OCR, transcription, and placement failures are all
|
|
1653
|
+
// non-fatal. The placement call is DELIBERATELY a separate request from the
|
|
1654
|
+
// caption/scene vision call so it can never dilute timeline-layer quality —
|
|
1655
|
+
// it just reuses the same already-sampled sparse frames (no extra ffmpeg).
|
|
1656
|
+
const [attempt, ocrFrames, transcript, placementSurfaces] = await Promise.all([
|
|
1657
|
+
callVisionWithFallback({
|
|
1658
|
+
prompt,
|
|
1659
|
+
frames,
|
|
1660
|
+
keys: input.keys
|
|
1661
|
+
}),
|
|
1662
|
+
runOcrPass({ frames: ocrRawFrames, keys: input.keys }).catch((err) => {
|
|
1663
|
+
console.error("smart decompose OCR pass failed", err instanceof Error ? err.message : err);
|
|
1664
|
+
return [];
|
|
1665
|
+
}),
|
|
1666
|
+
audioPath
|
|
1667
|
+
? transcribeAudioWithFallback({ audioPath, durationSeconds: meta.durationSeconds, keys: input.keys }).catch((err) => {
|
|
1668
|
+
console.error("smart decompose transcription failed", err instanceof Error ? err.message : err);
|
|
1669
|
+
return null;
|
|
1670
|
+
})
|
|
1671
|
+
: Promise.resolve(null),
|
|
1672
|
+
runPlacementSurfacePass({
|
|
1673
|
+
frames,
|
|
1674
|
+
keys: input.keys,
|
|
1675
|
+
durationSeconds: meta.durationSeconds,
|
|
1676
|
+
sceneSlugs: []
|
|
1677
|
+
}).catch((err) => {
|
|
1678
|
+
console.error("smart decompose placement pass failed", err instanceof Error ? err.message : err);
|
|
1679
|
+
return [];
|
|
1680
|
+
})
|
|
1681
|
+
]);
|
|
1682
|
+
devLog("hyperframes.smart_decompose.ocr_completed", {
|
|
1683
|
+
frames_with_text: ocrFrames.filter((f) => f.texts.length > 0).length,
|
|
1684
|
+
total_ocr_frames: ocrFrames.length
|
|
1685
|
+
});
|
|
1686
|
+
devLog("hyperframes.smart_decompose.transcript_completed", {
|
|
1687
|
+
has_transcript: Boolean(transcript),
|
|
1688
|
+
segment_count: transcript?.segments.length ?? 0,
|
|
1689
|
+
language: transcript?.language ?? null,
|
|
1690
|
+
provider: transcript?.provider ?? null
|
|
1691
|
+
});
|
|
1692
|
+
const raw = attempt.result;
|
|
1693
|
+
const record = (raw && typeof raw === "object") ? raw : {};
|
|
1694
|
+
const scenesRaw = Array.isArray(record.scenes) ? record.scenes : [];
|
|
1695
|
+
const captionsRaw = Array.isArray(record.captions) ? record.captions : [];
|
|
1696
|
+
const usedSceneSlugs = new Set();
|
|
1697
|
+
const scenes = scenesRaw
|
|
1698
|
+
.map((scene, index) => {
|
|
1699
|
+
const slug = dedupeSlug(normalizeSlug(typeof scene.slug === "string" ? scene.slug : `scene_${index + 1}`) || `scene_${index + 1}`, usedSceneSlugs);
|
|
1700
|
+
return {
|
|
1701
|
+
slug,
|
|
1702
|
+
start: Number(scene.start ?? 0),
|
|
1703
|
+
duration: Number(scene.duration ?? 0),
|
|
1704
|
+
label: typeof scene.label === "string" ? scene.label : `Scene ${index + 1}`,
|
|
1705
|
+
description: typeof scene.description === "string" ? scene.description : "",
|
|
1706
|
+
viral_note: typeof scene.viral_note === "string" ? scene.viral_note : ""
|
|
1707
|
+
};
|
|
1708
|
+
})
|
|
1709
|
+
.filter((s) => Number.isFinite(s.start) && Number.isFinite(s.duration) && s.duration > 0);
|
|
1710
|
+
const usedCaptionSlugs = new Set();
|
|
1711
|
+
const captions = captionsRaw
|
|
1712
|
+
.map((c, index) => {
|
|
1713
|
+
const slug = dedupeSlug(normalizeSlug(typeof c.slug === "string" ? c.slug : `caption_${index + 1}`) || `caption_${index + 1}`, usedCaptionSlugs);
|
|
1714
|
+
return {
|
|
1715
|
+
id: `caption_${index + 1}`,
|
|
1716
|
+
slug,
|
|
1717
|
+
text: typeof c.text === "string" ? c.text : "",
|
|
1718
|
+
start: Number(c.start ?? 0),
|
|
1719
|
+
duration: Number(c.duration ?? 0),
|
|
1720
|
+
x: Number(c.x ?? 8),
|
|
1721
|
+
y: Number(c.y ?? 68),
|
|
1722
|
+
width: Number(c.width ?? 84),
|
|
1723
|
+
height: Number(c.height ?? 12),
|
|
1724
|
+
font_size: resolveFontSize({ fontSize: c.font_size, fontSizePercent: c.font_size_percent, canvasHeight: meta.height }),
|
|
1725
|
+
color: normalizeHexColor(c.color, "#ffffff"),
|
|
1726
|
+
background: normalizeHexColor(c.background, "#000000"),
|
|
1727
|
+
background_style: normalizeBackgroundStyle(c.background_style),
|
|
1728
|
+
font_family: normalizeFontFamily(c.font_family),
|
|
1729
|
+
font_weight: normalizeFontWeight(c.font_weight),
|
|
1730
|
+
viral_note: typeof c.viral_note === "string" ? c.viral_note : undefined
|
|
1731
|
+
};
|
|
1732
|
+
})
|
|
1733
|
+
.filter((c) => c.text.trim().length > 0 && Number.isFinite(c.start) && Number.isFinite(c.duration) && c.duration > 0);
|
|
1734
|
+
const snapped = snapCaptionTimingsToOcr({
|
|
1735
|
+
captions,
|
|
1736
|
+
ocrFrames,
|
|
1737
|
+
durationSeconds: meta.durationSeconds
|
|
1738
|
+
});
|
|
1739
|
+
const finalCaptions = snapped.captions;
|
|
1740
|
+
devLog("hyperframes.smart_decompose.captions_snapped", {
|
|
1741
|
+
total_captions: captions.length,
|
|
1742
|
+
snapped_count: snapped.snappedCount
|
|
1743
|
+
});
|
|
1744
|
+
// Ground scene boundaries on the ffmpeg-detected hard cuts (no-op if none).
|
|
1745
|
+
const snappedScenes = snapScenesToCuts({ scenes, cuts: sceneCuts, durationSeconds: meta.durationSeconds });
|
|
1746
|
+
const finalScenes = snappedScenes.scenes;
|
|
1747
|
+
devLog("hyperframes.smart_decompose.scenes_snapped", {
|
|
1748
|
+
total_scenes: scenes.length,
|
|
1749
|
+
snapped_count: snappedScenes.snappedCount,
|
|
1750
|
+
scene_cut_count: sceneCuts.length
|
|
1751
|
+
});
|
|
1752
|
+
// Link each product-placement surface to the scene its start falls within,
|
|
1753
|
+
// deterministically from the finalized scene ranges (the placement call ran
|
|
1754
|
+
// before scenes existed, so it couldn't know the slugs).
|
|
1755
|
+
const finalPlacementSurfaces = placementSurfaces.map((surface) => {
|
|
1756
|
+
const owning = finalScenes.find((s) => surface.start >= s.start && surface.start < s.start + s.duration);
|
|
1757
|
+
return owning ? { ...surface, scene_slug: owning.slug } : surface;
|
|
1758
|
+
});
|
|
1759
|
+
const viralDna = normalizeViralDna(record.viral_dna);
|
|
1760
|
+
// Caller-supplied trend_tagline always wins over whatever the LLM emits so
|
|
1761
|
+
// the user's chosen tagline round-trips even if the model tries to rephrase.
|
|
1762
|
+
if (input.trendTaglineHint && input.trendTaglineHint.trim().length > 0) {
|
|
1763
|
+
viralDna.trend_tagline = input.trendTaglineHint.trim();
|
|
1764
|
+
}
|
|
1765
|
+
devLog("hyperframes.smart_decompose.completed", {
|
|
1766
|
+
provider: attempt.provider,
|
|
1767
|
+
model: attempt.model,
|
|
1768
|
+
duration_ms: Date.now() - startedAt,
|
|
1769
|
+
scene_count: finalScenes.length,
|
|
1770
|
+
caption_count: finalCaptions.length,
|
|
1771
|
+
captions_snapped: snapped.snappedCount,
|
|
1772
|
+
scenes_snapped: snappedScenes.snappedCount,
|
|
1773
|
+
placement_surface_count: finalPlacementSurfaces.length
|
|
1774
|
+
});
|
|
1775
|
+
return {
|
|
1776
|
+
summary: typeof record.summary === "string" ? record.summary : "",
|
|
1777
|
+
viralDna,
|
|
1778
|
+
placementSurfaces: finalPlacementSurfaces,
|
|
1779
|
+
transcript,
|
|
1780
|
+
durationSeconds: meta.durationSeconds,
|
|
1781
|
+
width: meta.width,
|
|
1782
|
+
height: meta.height,
|
|
1783
|
+
scenes: finalScenes,
|
|
1784
|
+
captions: finalCaptions,
|
|
1785
|
+
provider: attempt.provider,
|
|
1786
|
+
model: attempt.model
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
catch (error) {
|
|
1790
|
+
devLog("hyperframes.smart_decompose.failed", {
|
|
1791
|
+
source_url: input.sourceUrl,
|
|
1792
|
+
duration_ms: Date.now() - startedAt,
|
|
1793
|
+
...devErrorFields(error)
|
|
1794
|
+
}, "error");
|
|
1795
|
+
throw error;
|
|
1796
|
+
}
|
|
1797
|
+
finally {
|
|
1798
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
export function buildSmartDecomposedHtml(input) {
|
|
1802
|
+
const { compositionId, sourceUrl, title, result } = input;
|
|
1803
|
+
const totalDuration = Number(result.durationSeconds.toFixed(3));
|
|
1804
|
+
const scenes = result.scenes.length > 0
|
|
1805
|
+
? result.scenes
|
|
1806
|
+
: [{ slug: "scene_1", start: 0, duration: totalDuration, label: "Scene 1", description: "", viral_note: "" }];
|
|
1807
|
+
// Assign each caption to the lowest track index (starting at 2 — track 0
|
|
1808
|
+
// is video, track 1 is audio) where it doesn't temporally overlap any
|
|
1809
|
+
// caption already on that track. Enforces the "no two clips on the same
|
|
1810
|
+
// track can overlap in time" rule. Multiple stacked labels (like a
|
|
1811
|
+
// staircase of "single/talking/dating/marriage/have kids" all shown for
|
|
1812
|
+
// the full duration) end up on tracks 2,3,4,5,6 respectively.
|
|
1813
|
+
const CAPTION_TRACK_START = 2;
|
|
1814
|
+
const captionTrackClipRanges = [];
|
|
1815
|
+
const assignCaptionTrackIndex = (start, end) => {
|
|
1816
|
+
for (let i = 0; i < captionTrackClipRanges.length; i += 1) {
|
|
1817
|
+
if (!captionTrackClipRanges[i].some((r) => start < r.end && r.start < end)) {
|
|
1818
|
+
captionTrackClipRanges[i].push({ start, end });
|
|
1819
|
+
return CAPTION_TRACK_START + i;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
captionTrackClipRanges.push([{ start, end }]);
|
|
1823
|
+
return CAPTION_TRACK_START + captionTrackClipRanges.length - 1;
|
|
1824
|
+
};
|
|
1825
|
+
const sceneEls = scenes.map((scene, index) => {
|
|
1826
|
+
const start = Math.max(0, Math.min(totalDuration - 0.1, Number(scene.start.toFixed(3))));
|
|
1827
|
+
const duration = Math.max(0.1, Number(scene.duration.toFixed(3)));
|
|
1828
|
+
const end = Math.min(totalDuration, start + duration);
|
|
1829
|
+
const id = shortElementId();
|
|
1830
|
+
const sceneSlug = scene.slug || `scene_${index + 1}`;
|
|
1831
|
+
return `<div class="clip" id="${id}" data-hf-id="${id}" data-hf-slug="${escapeAttr(sceneSlug)}" data-layer-mode="both" data-layer-kind="video" data-viral-note="${escapeAttr(scene.viral_note ?? "")}" data-start="${start}" data-duration="${Number(duration.toFixed(3))}" data-end="${Number(end.toFixed(3))}" data-track-index="0" data-label="${escapeAttr(scene.label)}" data-media-start="${start}" data-playback-start="${start}" data-vf-timeline-proxy="video" data-src="${escapeAttr(sourceUrl)}" style="position:absolute;left:0%;top:0%;width:1%;height:1%;z-index:0;display:none;pointer-events:none;"></div>`;
|
|
1832
|
+
}).join("\n ");
|
|
1833
|
+
// Sort captions by start time so the track-assignment greedy fills lower
|
|
1834
|
+
// tracks first with earlier captions.
|
|
1835
|
+
const sortedCaptions = [...result.captions].sort((a, b) => (a.start - b.start) || (a.duration - b.duration));
|
|
1836
|
+
const captionEls = sortedCaptions.map((caption, index) => {
|
|
1837
|
+
const start = Math.max(0, Math.min(totalDuration - 0.1, Number(caption.start.toFixed(3))));
|
|
1838
|
+
const duration = Math.max(0.1, Math.min(totalDuration - start, Number(caption.duration.toFixed(3))));
|
|
1839
|
+
const end = start + duration;
|
|
1840
|
+
const trackIndex = assignCaptionTrackIndex(start, end);
|
|
1841
|
+
const id = shortElementId();
|
|
1842
|
+
const captionSlug = caption.slug || `caption_${index + 1}`;
|
|
1843
|
+
const clampedX = clampPercent(caption.x, 0, 95);
|
|
1844
|
+
const clampedY = clampPercent(caption.y, 0, 95);
|
|
1845
|
+
const clampedW = clampPercent(caption.width, 5, 100 - clampedX);
|
|
1846
|
+
const clampedH = clampPercent(caption.height, 3, 100 - clampedY);
|
|
1847
|
+
const escapedText = escapeHtml(caption.text);
|
|
1848
|
+
const innerStyle = captionInlineTextStyle(caption);
|
|
1849
|
+
return `<div class="clip" id="${id}" data-hf-id="${id}" data-hf-slug="${escapeAttr(captionSlug)}" data-layer-mode="publish" data-layer-kind="caption" data-viral-note="${escapeAttr(caption.viral_note ?? "")}" data-start="${start}" data-duration="${Number(duration.toFixed(3))}" data-end="${Number(end.toFixed(3))}" data-track-index="${trackIndex}" data-label="${escapeAttr(caption.text.slice(0, 40))}" data-text-background-style="${caption.background_style}" data-text-background-color="${escapeAttr(caption.background)}" data-font-family="${escapeAttr(caption.font_family)}" style="position:absolute;left:${clampedX}%;top:${clampedY}%;width:${clampedW}%;height:${clampedH}%;z-index:${trackIndex};display:flex;align-items:center;justify-content:center;padding:3px;box-sizing:border-box;text-align:center;color:${caption.color};background:transparent;font-family:'${caption.font_family}', 'TikTok Sans', sans-serif;font-weight:${caption.font_weight};font-size:${caption.font_size}px;line-height:1.2;overflow:visible;"><span data-vf-text-lines="true"><span data-vf-text-inline="true" style="${innerStyle}">${escapedText}</span></span></div>`;
|
|
1850
|
+
}).join("\n ");
|
|
1851
|
+
const audioId = shortElementId();
|
|
1852
|
+
const audioEl = `<audio class="clip" id="${audioId}" data-hf-id="${audioId}" data-hf-slug="source_audio" data-layer-mode="both" data-layer-kind="audio" data-viral-note="Original TikTok audio track." data-start="0" data-duration="${totalDuration}" data-end="${totalDuration}" data-track-index="1" data-label="Original audio" data-media-start="0" data-playback-start="0" data-timeline-role="music" data-volume="1" src="${escapeAttr(sourceUrl)}" preload="metadata"></audio>`;
|
|
1853
|
+
const viralDnaJson = escapeAttr(JSON.stringify(result.viralDna ?? DEFAULT_VIRAL_DNA));
|
|
1854
|
+
return `<!doctype html>
|
|
1855
|
+
<html lang="en">
|
|
1856
|
+
<head>
|
|
1857
|
+
<meta charset="UTF-8" />
|
|
1858
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1859
|
+
<style>
|
|
1860
|
+
@import url("https://fonts.googleapis.com/css2?family=Abel&family=Montserrat:wght@600;700&family=Source+Code+Pro:wght@700&family=TikTok+Sans:wght@400;600;700;800;900&family=Yesteryear&display=swap");
|
|
1861
|
+
html, body { width:100%; height:100%; margin:0; overflow:hidden; background:#050604; }
|
|
1862
|
+
[data-composition-id] { position:relative; width:100vw; height:100vh; overflow:hidden; background:#050604; }
|
|
1863
|
+
audio { display:none; }
|
|
1864
|
+
</style>
|
|
1865
|
+
</head>
|
|
1866
|
+
<body>
|
|
1867
|
+
<div data-composition-id="${escapeAttr(compositionId)}" data-source-video="${escapeAttr(sourceUrl)}" data-width="${result.width || 720}" data-height="${result.height || 1280}" data-duration="${totalDuration}" data-source-title="${escapeAttr(title)}" data-viral-dna="${viralDnaJson}">
|
|
1868
|
+
<video data-vf-playback-source="true" src="${escapeAttr(sourceUrl)}" muted playsinline preload="auto" style="position:absolute;left:0%;top:0%;width:100%;height:100%;z-index:0;object-fit:cover;background:#050604"></video>
|
|
1869
|
+
${sceneEls}
|
|
1870
|
+
${audioEl}
|
|
1871
|
+
${captionEls}
|
|
1872
|
+
</div>
|
|
1873
|
+
</body>
|
|
1874
|
+
</html>`;
|
|
1875
|
+
}
|
|
1876
|
+
function normalizeSlug(value) {
|
|
1877
|
+
return String(value ?? "")
|
|
1878
|
+
.toLowerCase()
|
|
1879
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
1880
|
+
.replace(/^_+|_+$/g, "")
|
|
1881
|
+
.slice(0, 64);
|
|
1882
|
+
}
|
|
1883
|
+
function dedupeSlug(base, used) {
|
|
1884
|
+
const seed = base || "element";
|
|
1885
|
+
if (!used.has(seed)) {
|
|
1886
|
+
used.add(seed);
|
|
1887
|
+
return seed;
|
|
1888
|
+
}
|
|
1889
|
+
for (let index = 2; index < 999; index += 1) {
|
|
1890
|
+
const candidate = `${seed}_${index}`;
|
|
1891
|
+
if (!used.has(candidate)) {
|
|
1892
|
+
used.add(candidate);
|
|
1893
|
+
return candidate;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
const fallback = `${seed}_${Date.now().toString(36)}`;
|
|
1897
|
+
used.add(fallback);
|
|
1898
|
+
return fallback;
|
|
1899
|
+
}
|
|
1900
|
+
function escapeAttr(value) {
|
|
1901
|
+
return String(value ?? "").replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
1902
|
+
}
|
|
1903
|
+
function escapeHtml(value) {
|
|
1904
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1905
|
+
}
|
|
1906
|
+
function clampPercent(value, min, max) {
|
|
1907
|
+
if (!Number.isFinite(value))
|
|
1908
|
+
return min;
|
|
1909
|
+
return Math.max(min, Math.min(max, value));
|
|
1910
|
+
}
|
|
1911
|
+
export function stampCompositionIdentity(html, input) {
|
|
1912
|
+
if (!input.forkId)
|
|
1913
|
+
return html;
|
|
1914
|
+
const composed = `${input.forkId}_v${input.version}`;
|
|
1915
|
+
const { document } = parseHTML(html);
|
|
1916
|
+
const root = document.querySelector("[data-composition-id]");
|
|
1917
|
+
if (!root)
|
|
1918
|
+
return html;
|
|
1919
|
+
root.setAttribute("data-composition-id", composed);
|
|
1920
|
+
root.setAttribute("data-fork-id", input.forkId);
|
|
1921
|
+
root.setAttribute("data-fork-version", String(input.version));
|
|
1922
|
+
return `<!doctype html>\n${document.documentElement.outerHTML}`;
|
|
1923
|
+
}
|
|
1924
|
+
// True when the union of canvas-filling intervals leaves any hole ≥ minGap within
|
|
1925
|
+
// [0, total] — a leading gap, a hole between clips, or a trailing gap. Mirrors the
|
|
1926
|
+
// editor-side computeCompositionGaps intent (see src/devcli/composition-edit.ts).
|
|
1927
|
+
function compositionHasCoverageGap(intervals, total, minGap = 0.2) {
|
|
1928
|
+
if (!Number.isFinite(total) || total <= 0)
|
|
1929
|
+
return false;
|
|
1930
|
+
const sorted = intervals.filter(([start, end]) => end > start).sort((a, b) => a[0] - b[0]);
|
|
1931
|
+
let cursor = 0;
|
|
1932
|
+
for (const [start, end] of sorted) {
|
|
1933
|
+
if (start > cursor + minGap)
|
|
1934
|
+
return true;
|
|
1935
|
+
cursor = Math.max(cursor, end);
|
|
1936
|
+
}
|
|
1937
|
+
return cursor < total - minGap;
|
|
1938
|
+
}
|
|
1939
|
+
export function normalizePublishHtml(html) {
|
|
1940
|
+
const { document } = parseHTML(html);
|
|
1941
|
+
// Coverage gaps: any interval of the timeline with no active canvas-filling
|
|
1942
|
+
// layer renders as the page background (#050604 — near-black), which the user
|
|
1943
|
+
// experiences as the video "going black" mid-composition (e.g. a scene that
|
|
1944
|
+
// was shortened/deleted or a replacement clip shorter than its slot). The
|
|
1945
|
+
// full-length backing source video (data-vf-playback-source) is the natural
|
|
1946
|
+
// fallback — every scene proxy is cut from that same source — but it is
|
|
1947
|
+
// normally dropped at publish. When a gap exists we instead retain it as a
|
|
1948
|
+
// timed bottom base layer so uncovered intervals show the original footage
|
|
1949
|
+
// rather than black; with no gap we drop it (avoids decoding a redundant
|
|
1950
|
+
// full-length video on every render).
|
|
1951
|
+
const compositionRoot = document.querySelector("[data-composition-id]");
|
|
1952
|
+
const totalDuration = compositionRoot
|
|
1953
|
+
? Number.parseFloat(compositionRoot.getAttribute("data-duration") || "")
|
|
1954
|
+
: NaN;
|
|
1955
|
+
const coverageIntervals = [];
|
|
1956
|
+
for (const el of Array.from(document.querySelectorAll('[data-vf-timeline-proxy="video"], video[data-start], img[data-start]'))) {
|
|
1957
|
+
const start = Number.parseFloat(el.getAttribute("data-start") || "");
|
|
1958
|
+
const duration = Number.parseFloat(el.getAttribute("data-duration") || "");
|
|
1959
|
+
if (Number.isFinite(start) && Number.isFinite(duration) && duration > 0) {
|
|
1960
|
+
coverageIntervals.push([start, start + duration]);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
const retainBackingVideo = Number.isFinite(totalDuration) &&
|
|
1964
|
+
totalDuration > 0 &&
|
|
1965
|
+
compositionHasCoverageGap(coverageIntervals, totalDuration);
|
|
1966
|
+
for (const backingVideo of Array.from(document.querySelectorAll("[data-vf-playback-source]"))) {
|
|
1967
|
+
if (!retainBackingVideo) {
|
|
1968
|
+
backingVideo.remove();
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
// Retime the backing video into a full-timeline base layer the render engine
|
|
1972
|
+
// seeks per frame (video[data-start]), pinned below every proxy (z-index:-1).
|
|
1973
|
+
backingVideo.removeAttribute("data-vf-playback-source");
|
|
1974
|
+
backingVideo.setAttribute("data-start", "0");
|
|
1975
|
+
backingVideo.setAttribute("data-duration", String(totalDuration));
|
|
1976
|
+
backingVideo.setAttribute("data-media-start", "0");
|
|
1977
|
+
backingVideo.setAttribute("muted", "");
|
|
1978
|
+
backingVideo.setAttribute("playsinline", "");
|
|
1979
|
+
backingVideo.setAttribute("preload", "auto");
|
|
1980
|
+
backingVideo.setAttribute("style", [
|
|
1981
|
+
"position:absolute",
|
|
1982
|
+
"left:0%",
|
|
1983
|
+
"top:0%",
|
|
1984
|
+
"width:100%",
|
|
1985
|
+
"height:100%",
|
|
1986
|
+
"z-index:-1",
|
|
1987
|
+
"object-fit:cover",
|
|
1988
|
+
"background:#050604"
|
|
1989
|
+
].join(";"));
|
|
1990
|
+
}
|
|
1991
|
+
for (const proxy of Array.from(document.querySelectorAll('[data-vf-timeline-proxy="video"]'))) {
|
|
1992
|
+
const video = document.createElement("video");
|
|
1993
|
+
const source = proxy.getAttribute("data-src") || proxy.getAttribute("src") || "";
|
|
1994
|
+
for (const attr of Array.from(proxy.attributes)) {
|
|
1995
|
+
if (attr.name === "data-vf-timeline-proxy" || attr.name === "data-src")
|
|
1996
|
+
continue;
|
|
1997
|
+
video.setAttribute(attr.name, attr.value);
|
|
1998
|
+
}
|
|
1999
|
+
video.setAttribute("src", source);
|
|
2000
|
+
video.setAttribute("muted", "");
|
|
2001
|
+
video.setAttribute("playsinline", "");
|
|
2002
|
+
video.setAttribute("preload", "auto");
|
|
2003
|
+
const trackIndex = Number.parseInt(proxy.getAttribute("data-track-index") ?? "0", 10) || 0;
|
|
2004
|
+
video.setAttribute("style", [
|
|
2005
|
+
"position:absolute",
|
|
2006
|
+
"left:0%",
|
|
2007
|
+
"top:0%",
|
|
2008
|
+
"width:100%",
|
|
2009
|
+
"height:100%",
|
|
2010
|
+
`z-index:${trackIndex}`,
|
|
2011
|
+
"object-fit:cover",
|
|
2012
|
+
"background:#050604"
|
|
2013
|
+
].join(";"));
|
|
2014
|
+
proxy.replaceWith(video);
|
|
2015
|
+
}
|
|
2016
|
+
// Enforce z-index = data-track-index for every timed layer so visual stacking
|
|
2017
|
+
// strictly follows the timeline layer index. No arbitrary inline z-index survives.
|
|
2018
|
+
for (const el of Array.from(document.querySelectorAll("[data-track-index]"))) {
|
|
2019
|
+
const trackAttr = el.getAttribute("data-track-index");
|
|
2020
|
+
if (!trackAttr)
|
|
2021
|
+
continue;
|
|
2022
|
+
const trackIndex = Number.parseInt(trackAttr, 10);
|
|
2023
|
+
if (!Number.isFinite(trackIndex))
|
|
2024
|
+
continue;
|
|
2025
|
+
const html = el;
|
|
2026
|
+
if (typeof html.style === "undefined")
|
|
2027
|
+
continue;
|
|
2028
|
+
html.style.zIndex = String(trackIndex);
|
|
2029
|
+
}
|
|
2030
|
+
// Editor sessions occasionally leave inline style attributes with double
|
|
2031
|
+
// quotes inside CSS values (font-family: "TikTok Sans"). When serialized into
|
|
2032
|
+
// an HTML attribute, those quotes become " entities. The render lambda
|
|
2033
|
+
// resolves font-family with a regex over raw HTML and misreads " as a
|
|
2034
|
+
// literal font name, exploding with FontFetchError. Normalize to single
|
|
2035
|
+
// quotes so serialization stays entity-free.
|
|
2036
|
+
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
|
2037
|
+
const styleValue = el.getAttribute("style");
|
|
2038
|
+
if (!styleValue || styleValue.indexOf('"') === -1)
|
|
2039
|
+
continue;
|
|
2040
|
+
el.setAttribute("style", styleValue.replace(/"/g, "'"));
|
|
2041
|
+
}
|
|
2042
|
+
return `<!doctype html>\n${document.documentElement.outerHTML}`;
|
|
2043
|
+
}
|
|
2044
|
+
async function resolveHyperframesStack() {
|
|
2045
|
+
const region = config.HYPERFRAMES_RENDER_REGION || config.AWS_REGION || "us-east-1";
|
|
2046
|
+
const lambdaMemoryMb = positiveInteger(config.HYPERFRAMES_RENDER_MEMORY_MB, 3072);
|
|
2047
|
+
const envStack = {
|
|
2048
|
+
stackName: config.HYPERFRAMES_LAMBDA_STACK_NAME || "hyperframes-vidfarm-prod",
|
|
2049
|
+
bucketName: config.HYPERFRAMES_RENDER_BUCKET_NAME || config.HYPERFRAMES_LAMBDA_BUCKET_NAME || "",
|
|
2050
|
+
stateMachineArn: config.HYPERFRAMES_RENDER_STATE_MACHINE_ARN || config.HYPERFRAMES_STATE_MACHINE_ARN || "",
|
|
2051
|
+
region,
|
|
2052
|
+
lambdaMemoryMb
|
|
2053
|
+
};
|
|
2054
|
+
if (envStack.bucketName && envStack.stateMachineArn) {
|
|
2055
|
+
return envStack;
|
|
2056
|
+
}
|
|
2057
|
+
if (stackCache && stackCache.expiresAt > Date.now()) {
|
|
2058
|
+
return stackCache.value;
|
|
2059
|
+
}
|
|
2060
|
+
const stackName = envStack.stackName;
|
|
2061
|
+
const cloudFormation = new CloudFormationClient({ region });
|
|
2062
|
+
const result = await cloudFormation.send(new DescribeStacksCommand({ StackName: stackName }));
|
|
2063
|
+
const stack = result.Stacks?.[0];
|
|
2064
|
+
if (!stack) {
|
|
2065
|
+
throw new Error(`HyperFrames Lambda stack not found: ${stackName}`);
|
|
2066
|
+
}
|
|
2067
|
+
const outputs = stack.Outputs || [];
|
|
2068
|
+
const value = {
|
|
2069
|
+
stackName,
|
|
2070
|
+
bucketName: stackOutput(outputs, "RenderBucketName"),
|
|
2071
|
+
stateMachineArn: stackOutput(outputs, "RenderStateMachineArn"),
|
|
2072
|
+
functionArn: stackOutput(outputs, "RenderFunctionArn"),
|
|
2073
|
+
region: stackOutput(outputs, "RenderRegion") || region,
|
|
2074
|
+
lambdaMemoryMb: positiveInteger(stackOutput(outputs, "RenderMemoryMb"), lambdaMemoryMb)
|
|
2075
|
+
};
|
|
2076
|
+
if (!value.bucketName || !value.stateMachineArn) {
|
|
2077
|
+
throw new Error(`HyperFrames Lambda stack ${stackName} is missing render outputs.`);
|
|
2078
|
+
}
|
|
2079
|
+
stackCache = { value, expiresAt: Date.now() + 60_000 };
|
|
2080
|
+
return value;
|
|
2081
|
+
}
|
|
2082
|
+
async function stageProject(input) {
|
|
2083
|
+
const root = path.join(config.VIDFARM_DATA_DIR, "hyperframes-projects");
|
|
2084
|
+
const projectDir = path.join(root, `${Date.now().toString(36)}-${safeIdentifier(input.compositionId)}-${randomUUID().slice(0, 8)}`);
|
|
2085
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
2086
|
+
await mkdir(projectDir, { recursive: true });
|
|
2087
|
+
await writeFile(path.join(projectDir, "index.html"), input.compositionHtml, "utf8");
|
|
2088
|
+
for (const file of input.projectFiles || []) {
|
|
2089
|
+
const target = safeProjectPath(projectDir, file.path);
|
|
2090
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
2091
|
+
await writeFile(target, file.contents);
|
|
2092
|
+
}
|
|
2093
|
+
for (const copyPath of input.copyPaths || []) {
|
|
2094
|
+
const target = safeProjectPath(projectDir, copyPath.to);
|
|
2095
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
2096
|
+
await cp(copyPath.from, target, { recursive: true, force: true });
|
|
2097
|
+
}
|
|
2098
|
+
return projectDir;
|
|
2099
|
+
}
|
|
2100
|
+
function extractCompositionMetadata(html, input) {
|
|
2101
|
+
const compositionId = safeIdentifier(input.compositionId || htmlAttribute(html, "data-composition-id") || input.composition?.compositionId || "composition");
|
|
2102
|
+
const width = positiveInteger(input.width || htmlAttribute(html, "data-width") || input.composition?.width, 720);
|
|
2103
|
+
const height = positiveInteger(input.height || htmlAttribute(html, "data-height") || input.composition?.height, 1280);
|
|
2104
|
+
const fps = positiveInteger(input.fps || 30, 30);
|
|
2105
|
+
const duration = Number.parseFloat(htmlAttribute(html, "data-duration") || String(input.composition?.duration || ""));
|
|
2106
|
+
return {
|
|
2107
|
+
compositionId,
|
|
2108
|
+
title: input.title || htmlAttribute(html, "data-source-video") || compositionId,
|
|
2109
|
+
width,
|
|
2110
|
+
height,
|
|
2111
|
+
fps,
|
|
2112
|
+
duration: Number.isFinite(duration) && duration > 0 ? duration : null
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
function safeProjectPath(root, relativePath) {
|
|
2116
|
+
const target = path.resolve(root, relativePath);
|
|
2117
|
+
const resolvedRoot = path.resolve(root);
|
|
2118
|
+
if (target !== resolvedRoot && !target.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
2119
|
+
throw new Error(`Invalid HyperFrames project path: ${relativePath}`);
|
|
2120
|
+
}
|
|
2121
|
+
return target;
|
|
2122
|
+
}
|
|
2123
|
+
// Round the composition's data-width / data-height down to the nearest even
|
|
2124
|
+
// integer so libx264 (yuv420p) accepts the frame size. Only rewrites values
|
|
2125
|
+
// that are actually odd, leaving well-formed even compositions untouched.
|
|
2126
|
+
function forceEvenCompositionDimensions(html) {
|
|
2127
|
+
return html.replace(/\bdata-(width|height)=(["'])(\d+)\2/gi, (match, dim, quote, value) => {
|
|
2128
|
+
const n = Number.parseInt(value, 10);
|
|
2129
|
+
if (!Number.isFinite(n) || n % 2 === 0)
|
|
2130
|
+
return match;
|
|
2131
|
+
return `data-${dim}=${quote}${n - 1}${quote}`;
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
function htmlAttribute(html, name) {
|
|
2135
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2136
|
+
const match = html.match(new RegExp(`${escaped}=(["'])(.*?)\\1`, "i"));
|
|
2137
|
+
return match?.[2] || "";
|
|
2138
|
+
}
|
|
2139
|
+
function stackOutput(outputs, key) {
|
|
2140
|
+
return outputs.find((output) => output.OutputKey === key)?.OutputValue
|
|
2141
|
+
|| outputs.find((output) => String(output.OutputKey || "").endsWith(key))?.OutputValue
|
|
2142
|
+
|| "";
|
|
2143
|
+
}
|
|
2144
|
+
function parseS3Uri(uri) {
|
|
2145
|
+
const match = String(uri || "").match(/^s3:\/\/([^/]+)\/(.+)$/);
|
|
2146
|
+
return match ? { bucket: match[1], key: match[2] } : null;
|
|
2147
|
+
}
|
|
2148
|
+
async function signedS3Url(s3Uri, region) {
|
|
2149
|
+
const parsed = parseS3Uri(s3Uri);
|
|
2150
|
+
if (!parsed)
|
|
2151
|
+
return null;
|
|
2152
|
+
const client = new S3Client({ region });
|
|
2153
|
+
return getSignedUrl(client, new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key }), { expiresIn: 3600 });
|
|
2154
|
+
}
|
|
2155
|
+
function safeIdentifier(value) {
|
|
2156
|
+
return String(value || "composition")
|
|
2157
|
+
.toLowerCase()
|
|
2158
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
2159
|
+
.replace(/^-+|-+$/g, "")
|
|
2160
|
+
.slice(0, 64) || "composition";
|
|
2161
|
+
}
|
|
2162
|
+
function positiveInteger(value, fallback) {
|
|
2163
|
+
const next = Math.round(Number(value));
|
|
2164
|
+
return Number.isFinite(next) && next > 0 ? next : fallback;
|
|
2165
|
+
}
|
|
2166
|
+
function normalizeRenderFps(value) {
|
|
2167
|
+
if (value <= 24)
|
|
2168
|
+
return 24;
|
|
2169
|
+
if (value > 45)
|
|
2170
|
+
return 60;
|
|
2171
|
+
return 30;
|
|
2172
|
+
}
|
|
2173
|
+
function buildCastAnalysisPrompt(input) {
|
|
2174
|
+
const summaryBlock = input.summary && input.summary.trim()
|
|
2175
|
+
? `\nVIDEO SUMMARY (from the first decompose pass):\n"""\n${input.summary.trim()}\n"""\n`
|
|
2176
|
+
: "";
|
|
2177
|
+
const sceneBlock = input.scenes && input.scenes.length > 0
|
|
2178
|
+
? `\nSCENES (slug — description, from the first pass; use these exact slugs in "appears_in"):\n${input.scenes
|
|
2179
|
+
.map((s) => ` ${s.slug}: ${(s.description ?? "").slice(0, 240)}`)
|
|
2180
|
+
.join("\n")}\n`
|
|
2181
|
+
: "";
|
|
2182
|
+
const frameBlock = input.frameTimestamps.length > 0
|
|
2183
|
+
? `\nFRAMES SUPPLIED (in order, each captioned with its capture timestamp): ${input.frameTimestamps
|
|
2184
|
+
.map((t) => `${t.toFixed(2)}s`)
|
|
2185
|
+
.join(", ")}\nPick "best_frame_seconds" from one of these exact timestamps — the frame where that person is largest, most frontal, and least occluded (ideally alone).\n`
|
|
2186
|
+
: "";
|
|
2187
|
+
return `You are identifying the recurring CAST (the people/characters) of a short-form vertical video so they can be reused and regenerated consistently in later edits.
|
|
2188
|
+
${summaryBlock}${sceneBlock}
|
|
2189
|
+
Video metadata:
|
|
2190
|
+
- duration: ${input.durationSeconds.toFixed(3)} seconds
|
|
2191
|
+
- dimensions: ${input.width} x ${input.height} (canvas is 100% x 100%)
|
|
2192
|
+
${frameBlock}
|
|
2193
|
+
Identify each DISTINCT person who appears. Merge the same person across scenes into ONE entry — do not create a new entry per scene. Ignore incidental crowd/background extras who never recur unless they are visually significant. If the video has no people (product-only, screen-recording, etc.), return an empty "cast" array.
|
|
2194
|
+
|
|
2195
|
+
For each person, describe them in enough concrete detail (face, hair, skin tone, build, age range, distinguishing features, wardrobe) that an image model could regenerate the SAME person from the description alone. Give a stable snake_case "slug" (e.g. "hero_girl", "male_narrator"), a short "name"/label, a "role" of "main" | "secondary" | "background", the list of scene slugs they "appears_in", the single "best_frame_seconds" timestamp where they're cleanest, a normalized bounding box "bbox" {x,y,width,height} in 0..1 coordinates locating that person within that frame (null if you cannot localize), and "is_primary": true for the single most central recurring subject (false for the rest).
|
|
2196
|
+
|
|
2197
|
+
Return strictly valid JSON:
|
|
2198
|
+
{
|
|
2199
|
+
"cast": [{
|
|
2200
|
+
"slug": string,
|
|
2201
|
+
"name": string,
|
|
2202
|
+
"role": "main" | "secondary" | "background",
|
|
2203
|
+
"description": string,
|
|
2204
|
+
"wardrobe": string,
|
|
2205
|
+
"appears_in": [string, ...],
|
|
2206
|
+
"best_frame_seconds": number,
|
|
2207
|
+
"bbox": { "x": number, "y": number, "width": number, "height": number } | null,
|
|
2208
|
+
"is_primary": boolean
|
|
2209
|
+
}]
|
|
2210
|
+
}`;
|
|
2211
|
+
}
|
|
2212
|
+
function clamp01(value) {
|
|
2213
|
+
const n = Number(value);
|
|
2214
|
+
if (!Number.isFinite(n))
|
|
2215
|
+
return 0;
|
|
2216
|
+
return Math.min(1, Math.max(0, n));
|
|
2217
|
+
}
|
|
2218
|
+
function normalizeCastMembers(raw, durationSeconds) {
|
|
2219
|
+
const root = raw && typeof raw === "object" ? raw : {};
|
|
2220
|
+
const list = Array.isArray(root.cast)
|
|
2221
|
+
? root.cast
|
|
2222
|
+
: Array.isArray(root.members)
|
|
2223
|
+
? root.members
|
|
2224
|
+
: [];
|
|
2225
|
+
const usedSlugs = new Set();
|
|
2226
|
+
const members = [];
|
|
2227
|
+
for (const entry of list) {
|
|
2228
|
+
if (!entry || typeof entry !== "object")
|
|
2229
|
+
continue;
|
|
2230
|
+
const e = entry;
|
|
2231
|
+
const description = typeof e.description === "string" ? e.description.trim() : "";
|
|
2232
|
+
if (!description)
|
|
2233
|
+
continue;
|
|
2234
|
+
let slug = safeIdentifier(typeof e.slug === "string" && e.slug.trim() ? e.slug : (typeof e.name === "string" ? e.name : `cast-${members.length + 1}`));
|
|
2235
|
+
while (usedSlugs.has(slug))
|
|
2236
|
+
slug = `${slug}-${members.length + 1}`;
|
|
2237
|
+
usedSlugs.add(slug);
|
|
2238
|
+
const bboxRaw = e.bbox && typeof e.bbox === "object" ? e.bbox : null;
|
|
2239
|
+
const bbox = bboxRaw
|
|
2240
|
+
? {
|
|
2241
|
+
x: clamp01(bboxRaw.x),
|
|
2242
|
+
y: clamp01(bboxRaw.y),
|
|
2243
|
+
width: clamp01(bboxRaw.width ?? bboxRaw.w),
|
|
2244
|
+
height: clamp01(bboxRaw.height ?? bboxRaw.h)
|
|
2245
|
+
}
|
|
2246
|
+
: null;
|
|
2247
|
+
const best = Number(e.best_frame_seconds ?? e.best_frame ?? 0);
|
|
2248
|
+
members.push({
|
|
2249
|
+
slug,
|
|
2250
|
+
name: typeof e.name === "string" && e.name.trim() ? e.name.trim().slice(0, 120) : slug,
|
|
2251
|
+
role: typeof e.role === "string" && e.role.trim() ? e.role.trim().toLowerCase().slice(0, 40) : "secondary",
|
|
2252
|
+
description: description.slice(0, 2000),
|
|
2253
|
+
wardrobe: typeof e.wardrobe === "string" ? e.wardrobe.trim().slice(0, 600) : "",
|
|
2254
|
+
appears_in: Array.isArray(e.appears_in)
|
|
2255
|
+
? e.appears_in.map((s) => (typeof s === "string" ? s.trim() : "")).filter(Boolean).slice(0, 40)
|
|
2256
|
+
: [],
|
|
2257
|
+
best_frame_seconds: Number.isFinite(best) && best > 0
|
|
2258
|
+
? Math.min(Math.max(0, best), Math.max(0, durationSeconds - 0.05))
|
|
2259
|
+
: 0,
|
|
2260
|
+
bbox: bbox && (bbox.width > 0 || bbox.height > 0) ? bbox : null,
|
|
2261
|
+
is_primary: e.is_primary === true
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
// Guarantee exactly one primary when there's at least one member.
|
|
2265
|
+
if (members.length > 0 && !members.some((m) => m.is_primary)) {
|
|
2266
|
+
members[0].is_primary = true;
|
|
2267
|
+
}
|
|
2268
|
+
return members;
|
|
2269
|
+
}
|
|
2270
|
+
export async function analyzeCastMembers(input) {
|
|
2271
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), SMART_DECOMPOSE_TEMP_PREFIX));
|
|
2272
|
+
const sourcePath = path.join(tempDir, "source.mp4");
|
|
2273
|
+
try {
|
|
2274
|
+
await downloadVideoTo(input.sourceUrl, sourcePath);
|
|
2275
|
+
const meta = await ffprobeVideo(sourcePath);
|
|
2276
|
+
if (!Number.isFinite(meta.durationSeconds) || meta.durationSeconds <= 0) {
|
|
2277
|
+
throw new Error("Could not read video duration for cast analysis");
|
|
2278
|
+
}
|
|
2279
|
+
const frames = await sampleFramesAsBase64(sourcePath, meta.durationSeconds, tempDir);
|
|
2280
|
+
const prompt = buildCastAnalysisPrompt({
|
|
2281
|
+
durationSeconds: meta.durationSeconds,
|
|
2282
|
+
width: meta.width,
|
|
2283
|
+
height: meta.height,
|
|
2284
|
+
summary: input.summary ?? null,
|
|
2285
|
+
scenes: input.scenes ?? [],
|
|
2286
|
+
frameTimestamps: frames.map((f) => f.timestamp)
|
|
2287
|
+
});
|
|
2288
|
+
const attempt = await callVisionWithFallback({ prompt, frames, keys: input.keys });
|
|
2289
|
+
devLog("hyperframes.cast_analyze.done", {
|
|
2290
|
+
source_url: input.sourceUrl,
|
|
2291
|
+
provider: attempt.provider,
|
|
2292
|
+
model: attempt.model
|
|
2293
|
+
});
|
|
2294
|
+
return {
|
|
2295
|
+
members: normalizeCastMembers(attempt.result, meta.durationSeconds),
|
|
2296
|
+
provider: attempt.provider,
|
|
2297
|
+
model: attempt.model,
|
|
2298
|
+
durationSeconds: meta.durationSeconds,
|
|
2299
|
+
width: meta.width,
|
|
2300
|
+
height: meta.height
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
finally {
|
|
2304
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
//# sourceMappingURL=hyperframes.js.map
|