@mevdragon/vidfarm-devcli 0.18.1 → 0.19.1
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/.agents/skills/editor-capabilities/SKILL.md +166 -0
- package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -0
- package/SKILL.director.md +174 -16
- package/SKILL.platform.md +7 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +79 -75
- package/dist/src/account-pages-legacy.js +1 -1
- package/dist/src/app.js +1694 -217
- package/dist/src/cli.js +1365 -105
- package/dist/src/config.js +8 -0
- package/dist/src/devcli/clips.js +3 -0
- package/dist/src/devcli/composition-edit.js +401 -10
- package/dist/src/devcli/local-backend.js +123 -0
- package/dist/src/devcli/migrate-local.js +140 -0
- package/dist/src/devcli/sync.js +311 -0
- package/dist/src/devcli/timeline-edit.js +208 -1
- package/dist/src/editor-chat.js +37 -18
- package/dist/src/frontend/file-directory.js +271 -20
- package/dist/src/frontend/homepage-view.js +3 -3
- package/dist/src/frontend/template-editor-chat.js +6 -3
- package/dist/src/page-shell.js +1 -1
- package/dist/src/primitive-context.js +31 -2
- package/dist/src/primitive-registry.js +409 -4
- package/dist/src/reskin/chat-page.js +103 -15
- package/dist/src/reskin/discover-page.js +247 -10
- package/dist/src/reskin/document.js +147 -10
- package/dist/src/reskin/inpaint-clipper-page.js +649 -0
- package/dist/src/reskin/inpaint-page.js +2459 -452
- package/dist/src/reskin/inpaint-video-page.js +1339 -0
- package/dist/src/reskin/library-page.js +324 -82
- package/dist/src/reskin/portfolio-page.js +687 -0
- package/dist/src/reskin/theme.js +36 -11
- package/dist/src/services/billing.js +4 -0
- package/dist/src/services/clip-curation/hunt.js +2 -0
- package/dist/src/services/clip-records.js +28 -0
- package/dist/src/services/file-directory.js +6 -3
- package/dist/src/services/hyperframes.js +535 -86
- package/dist/src/services/serverless-jobs.js +3 -1
- package/dist/src/services/serverless-records.js +43 -0
- package/dist/src/services/storage.js +24 -2
- package/dist/src/template-editor-pages.js +1 -1
- package/package.json +1 -1
- package/public/assets/file-directory-app.js +2 -2
- package/public/assets/homepage-client-app.js +1 -1
- package/public/assets/page-runtime-client-app.js +2 -2
- package/public/assets/placeholders/scene-placeholder.png +0 -0
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { pipeline } from "node:stream/promises";
|
|
8
8
|
import { CloudFormationClient, DescribeStacksCommand } from "@aws-sdk/client-cloudformation";
|
|
9
9
|
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
10
|
+
import { DescribeExecutionCommand, ExecutionDoesNotExist, SFNClient } from "@aws-sdk/client-sfn";
|
|
10
11
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
11
12
|
import ffmpegStatic from "ffmpeg-static";
|
|
12
13
|
import ffprobeStatic from "ffprobe-static";
|
|
@@ -21,6 +22,24 @@ import { applyMarkupUsd } from "./billing-pricing.js";
|
|
|
21
22
|
// coupling to the (parallel) clip-curation work — a plain leaf import; the
|
|
22
23
|
// retrieval logic itself (search over these criteria) is merged in later.
|
|
23
24
|
import { buildClipEmbeddingText, sanitizeTagValues, taxonomyForPrompt } from "./clip-curation/index.js";
|
|
25
|
+
/** Thrown by the primitive render wrapper when a distributed render is still in
|
|
26
|
+
* flight. The job runner catches it and parks the job as `waiting_for_render`
|
|
27
|
+
* so Step Functions waits + re-invokes — no single Lambda holds the render open
|
|
28
|
+
* for its 15-minute lifetime. */
|
|
29
|
+
export class RenderInProgressError extends Error {
|
|
30
|
+
executionArn;
|
|
31
|
+
renderId;
|
|
32
|
+
waitSeconds;
|
|
33
|
+
progressFraction;
|
|
34
|
+
constructor(input) {
|
|
35
|
+
super(`HyperFrames render ${input.renderId} still in progress`);
|
|
36
|
+
this.name = "RenderInProgressError";
|
|
37
|
+
this.executionArn = input.executionArn;
|
|
38
|
+
this.renderId = input.renderId;
|
|
39
|
+
this.waitSeconds = input.waitSeconds;
|
|
40
|
+
this.progressFraction = input.progressFraction;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
24
43
|
let stackCache = null;
|
|
25
44
|
const SMART_DECOMPOSE_TEMP_PREFIX = "vidfarm-smart-decompose-";
|
|
26
45
|
const SMART_DECOMPOSE_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
@@ -81,57 +100,12 @@ export class HyperframesService {
|
|
|
81
100
|
if (shouldRenderLocally()) {
|
|
82
101
|
return this.renderLocal({ ...input, compositionHtml });
|
|
83
102
|
}
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
devLog("hyperframes.render.staging", {
|
|
87
|
-
composition_id: metadata.compositionId,
|
|
88
|
-
width: metadata.width,
|
|
89
|
-
height: metadata.height,
|
|
90
|
-
fps: metadata.fps,
|
|
91
|
-
stack_name: stack.stackName,
|
|
92
|
-
region: stack.region
|
|
93
|
-
});
|
|
94
|
-
const projectDir = await stageProject({
|
|
95
|
-
compositionHtml,
|
|
96
|
-
compositionId: metadata.compositionId,
|
|
97
|
-
projectFiles: input.projectFiles,
|
|
98
|
-
copyPaths: input.copyPaths
|
|
99
|
-
});
|
|
100
|
-
const timestamp = Date.now().toString(36);
|
|
101
|
-
const renderId = input.executionName || `vf-${metadata.compositionId.slice(0, 40)}-${timestamp}-${randomUUID().slice(0, 8)}`;
|
|
102
|
-
const outputKey = input.outputKey || `renders/vidfarm/${metadata.compositionId}/${renderId}/output.mp4`;
|
|
103
|
-
devLog("hyperframes.render.dispatch", {
|
|
104
|
-
composition_id: metadata.compositionId,
|
|
105
|
-
render_id: renderId,
|
|
106
|
-
output_key: outputKey,
|
|
107
|
-
chunk_size: input.chunkSize ?? 240,
|
|
108
|
-
max_parallel_chunks: input.maxParallelChunks ?? 8,
|
|
109
|
-
wait: input.wait !== false
|
|
110
|
-
});
|
|
111
|
-
const { renderToLambda, getRenderProgress } = await import("@hyperframes/aws-lambda/sdk");
|
|
112
|
-
const handle = await renderToLambda({
|
|
113
|
-
projectDir,
|
|
114
|
-
bucketName: stack.bucketName,
|
|
115
|
-
stateMachineArn: stack.stateMachineArn,
|
|
116
|
-
region: stack.region,
|
|
117
|
-
executionName: renderId,
|
|
118
|
-
outputKey,
|
|
119
|
-
config: {
|
|
120
|
-
fps: normalizeRenderFps(metadata.fps),
|
|
121
|
-
width: metadata.width,
|
|
122
|
-
height: metadata.height,
|
|
123
|
-
format: input.format || "mp4",
|
|
124
|
-
codec: "h264",
|
|
125
|
-
quality: "standard",
|
|
126
|
-
chunkSize: input.chunkSize ?? 240,
|
|
127
|
-
maxParallelChunks: input.maxParallelChunks ?? 8,
|
|
128
|
-
runtimeCap: "lambda"
|
|
129
|
-
}
|
|
130
|
-
});
|
|
103
|
+
const prep = await this.prepareCloudRender(input, compositionHtml);
|
|
104
|
+
const handle = await this.dispatchCloudRender(prep, input);
|
|
131
105
|
if (input.wait === false) {
|
|
132
106
|
devLog("hyperframes.render.dispatched_async", {
|
|
133
|
-
composition_id: metadata.compositionId,
|
|
134
|
-
render_id: renderId,
|
|
107
|
+
composition_id: prep.metadata.compositionId,
|
|
108
|
+
render_id: prep.renderId,
|
|
135
109
|
execution_arn: handle.executionArn
|
|
136
110
|
});
|
|
137
111
|
return {
|
|
@@ -142,11 +116,12 @@ export class HyperframesService {
|
|
|
142
116
|
metadata: {
|
|
143
117
|
mode: "hyperframes_lambda",
|
|
144
118
|
status: "RUNNING",
|
|
145
|
-
stack,
|
|
146
|
-
composition: metadata
|
|
119
|
+
stack: prep.stack,
|
|
120
|
+
composition: prep.metadata
|
|
147
121
|
}
|
|
148
122
|
};
|
|
149
123
|
}
|
|
124
|
+
const { getRenderProgress } = await import("@hyperframes/aws-lambda/sdk");
|
|
150
125
|
const startedAtMs = Date.now();
|
|
151
126
|
const timeoutMs = input.timeoutMs ?? 12 * 60 * 1000;
|
|
152
127
|
const pollIntervalMs = input.pollIntervalMs ?? 5000;
|
|
@@ -155,14 +130,14 @@ export class HyperframesService {
|
|
|
155
130
|
for (;;) {
|
|
156
131
|
const progress = await getRenderProgress({
|
|
157
132
|
executionArn: handle.executionArn,
|
|
158
|
-
region: stack.region,
|
|
159
|
-
defaultMemorySizeMb: stack.lambdaMemoryMb
|
|
133
|
+
region: prep.stack.region,
|
|
134
|
+
defaultMemorySizeMb: prep.stack.lambdaMemoryMb
|
|
160
135
|
});
|
|
161
136
|
pollCount += 1;
|
|
162
137
|
if (progress.status !== lastLoggedStatus) {
|
|
163
138
|
devLog("hyperframes.render.progress", {
|
|
164
|
-
composition_id: metadata.compositionId,
|
|
165
|
-
render_id: renderId,
|
|
139
|
+
composition_id: prep.metadata.compositionId,
|
|
140
|
+
render_id: prep.renderId,
|
|
166
141
|
status: progress.status,
|
|
167
142
|
poll_count: pollCount,
|
|
168
143
|
elapsed_ms: Date.now() - startedAtMs,
|
|
@@ -172,42 +147,22 @@ export class HyperframesService {
|
|
|
172
147
|
lastLoggedStatus = progress.status;
|
|
173
148
|
}
|
|
174
149
|
if (progress.status === "SUCCEEDED") {
|
|
175
|
-
const outputS3Uri = progress.outputFile?.s3Uri || handle.outputS3Uri;
|
|
176
150
|
devLog("hyperframes.render.succeeded", {
|
|
177
|
-
composition_id: metadata.compositionId,
|
|
178
|
-
render_id: renderId,
|
|
151
|
+
composition_id: prep.metadata.compositionId,
|
|
152
|
+
render_id: prep.renderId,
|
|
179
153
|
duration_ms: Date.now() - startedAtMs,
|
|
180
|
-
output_s3_uri: outputS3Uri,
|
|
154
|
+
output_s3_uri: progress.outputFile?.s3Uri || handle.outputS3Uri,
|
|
181
155
|
frames_rendered: progress.framesRendered,
|
|
182
156
|
total_frames: progress.totalFrames,
|
|
183
157
|
lambdas_invoked: progress.lambdasInvoked,
|
|
184
158
|
cost_usd: progress.costs?.accruedSoFarUsd
|
|
185
159
|
});
|
|
186
|
-
return
|
|
187
|
-
...handle,
|
|
188
|
-
outputS3Uri,
|
|
189
|
-
outputUrl: await signedS3Url(outputS3Uri, stack.region),
|
|
190
|
-
endedAt: progress.endedAt,
|
|
191
|
-
metadata: {
|
|
192
|
-
mode: "hyperframes_lambda",
|
|
193
|
-
status: progress.status,
|
|
194
|
-
stack,
|
|
195
|
-
composition: metadata,
|
|
196
|
-
progress,
|
|
197
|
-
costs: progress.costs,
|
|
198
|
-
estimatedCostUsd: progress.costs.accruedSoFarUsd,
|
|
199
|
-
chargeUsd: applyMarkupUsd(progress.costs.accruedSoFarUsd),
|
|
200
|
-
costDisplay: progress.costs.displayCost,
|
|
201
|
-
lambdasInvoked: progress.lambdasInvoked,
|
|
202
|
-
framesRendered: progress.framesRendered,
|
|
203
|
-
totalFrames: progress.totalFrames
|
|
204
|
-
}
|
|
205
|
-
};
|
|
160
|
+
return this.buildCloudRenderResult(handle, progress, prep.metadata, prep.stack);
|
|
206
161
|
}
|
|
207
162
|
if (progress.fatalErrorEncountered || ["FAILED", "TIMED_OUT", "ABORTED"].includes(progress.status)) {
|
|
208
163
|
devLog("hyperframes.render.failed", {
|
|
209
|
-
composition_id: metadata.compositionId,
|
|
210
|
-
render_id: renderId,
|
|
164
|
+
composition_id: prep.metadata.compositionId,
|
|
165
|
+
render_id: prep.renderId,
|
|
211
166
|
status: progress.status,
|
|
212
167
|
duration_ms: Date.now() - startedAtMs,
|
|
213
168
|
errors: Array.isArray(progress.errors) ? progress.errors.slice(0, 3) : progress.errors
|
|
@@ -216,8 +171,8 @@ export class HyperframesService {
|
|
|
216
171
|
}
|
|
217
172
|
if (Date.now() - startedAtMs > timeoutMs) {
|
|
218
173
|
devLog("hyperframes.render.timeout", {
|
|
219
|
-
composition_id: metadata.compositionId,
|
|
220
|
-
render_id: renderId,
|
|
174
|
+
composition_id: prep.metadata.compositionId,
|
|
175
|
+
render_id: prep.renderId,
|
|
221
176
|
timeout_ms: timeoutMs,
|
|
222
177
|
last_status: progress.status
|
|
223
178
|
}, "error");
|
|
@@ -226,6 +181,182 @@ export class HyperframesService {
|
|
|
226
181
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
227
182
|
}
|
|
228
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* One non-blocking step of a distributed render, keyed by a stable
|
|
186
|
+
* `executionName`. The first call stages + dispatches the render to the
|
|
187
|
+
* HyperFrames render state machine and returns `running` immediately; later
|
|
188
|
+
* calls (from a re-invoked job runner) DescribeExecution the SAME execution
|
|
189
|
+
* and either keep waiting, finalize on SUCCEEDED, or report failure. This is
|
|
190
|
+
* what keeps a 40-minute render off any single Lambda's 15-minute clock.
|
|
191
|
+
*/
|
|
192
|
+
async renderStep(input) {
|
|
193
|
+
const compositionHtml = normalizePublishHtml(input.compositionHtml || (input.composition ? buildHyperframeCompositionHtml(input.composition) : ""));
|
|
194
|
+
if (!compositionHtml.includes("data-composition-id=")) {
|
|
195
|
+
throw new Error("HyperFrames render requires composition HTML with data-composition-id.");
|
|
196
|
+
}
|
|
197
|
+
// Local/dev has no Step Functions — render in-process, blocking, and finalize
|
|
198
|
+
// in one step so the devcli and local backend behave exactly as before.
|
|
199
|
+
if (shouldRenderLocally()) {
|
|
200
|
+
try {
|
|
201
|
+
const result = await this.renderLocal({ ...input, compositionHtml });
|
|
202
|
+
return { state: "succeeded", ...result };
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
return {
|
|
206
|
+
state: "failed",
|
|
207
|
+
renderId: input.executionName,
|
|
208
|
+
executionArn: null,
|
|
209
|
+
message: error instanceof Error ? error.message : String(error)
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const stack = await resolveHyperframesStack();
|
|
214
|
+
const executionArn = executionArnFromName(stack.stateMachineArn, input.executionName);
|
|
215
|
+
const existingStatus = await describeRenderExecutionStatus(executionArn, stack.region);
|
|
216
|
+
if (existingStatus === null) {
|
|
217
|
+
// Never dispatched — stage + kick off the render, then park.
|
|
218
|
+
const prep = await this.prepareCloudRender(input, compositionHtml, input.executionName);
|
|
219
|
+
const handle = await this.dispatchCloudRender(prep, input);
|
|
220
|
+
devLog("hyperframes.render.dispatched_async", {
|
|
221
|
+
composition_id: prep.metadata.compositionId,
|
|
222
|
+
render_id: prep.renderId,
|
|
223
|
+
execution_arn: handle.executionArn
|
|
224
|
+
});
|
|
225
|
+
return { state: "running", executionArn: handle.executionArn, renderId: handle.renderId, progressFraction: 0.1 };
|
|
226
|
+
}
|
|
227
|
+
if (existingStatus === "RUNNING" || existingStatus === "PENDING_REDRIVE") {
|
|
228
|
+
// Cheap path: DescribeExecution already told us it's still going. Avoid the
|
|
229
|
+
// heavier GetExecutionHistory pagination that getRenderProgress performs.
|
|
230
|
+
return { state: "running", executionArn, renderId: input.executionName, progressFraction: 0.5 };
|
|
231
|
+
}
|
|
232
|
+
// Terminal (SUCCEEDED / FAILED / TIMED_OUT / ABORTED) — read the full snapshot
|
|
233
|
+
// for output location, frames, and cost metadata.
|
|
234
|
+
const { getRenderProgress } = await import("@hyperframes/aws-lambda/sdk");
|
|
235
|
+
const progress = await getRenderProgress({
|
|
236
|
+
executionArn,
|
|
237
|
+
region: stack.region,
|
|
238
|
+
defaultMemorySizeMb: stack.lambdaMemoryMb
|
|
239
|
+
});
|
|
240
|
+
if (progress.status === "SUCCEEDED") {
|
|
241
|
+
const outputS3Uri = progress.outputFile?.s3Uri || null;
|
|
242
|
+
if (!outputS3Uri) {
|
|
243
|
+
return {
|
|
244
|
+
state: "failed",
|
|
245
|
+
renderId: input.executionName,
|
|
246
|
+
executionArn,
|
|
247
|
+
message: `HyperFrames render ${input.executionName} reported SUCCEEDED without an output file.`
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const metadata = extractCompositionMetadata(compositionHtml, input);
|
|
251
|
+
const handleShape = {
|
|
252
|
+
renderId: input.executionName,
|
|
253
|
+
executionArn,
|
|
254
|
+
bucketName: stack.bucketName,
|
|
255
|
+
stateMachineArn: stack.stateMachineArn,
|
|
256
|
+
outputS3Uri,
|
|
257
|
+
// Not carried across re-invocations; unused by downstream consumers,
|
|
258
|
+
// which read outputS3Uri / outputUrl / renderId / metadata only.
|
|
259
|
+
projectS3Uri: "",
|
|
260
|
+
startedAt: progress.startedAt
|
|
261
|
+
};
|
|
262
|
+
const result = await this.buildCloudRenderResult(handleShape, progress, metadata, stack);
|
|
263
|
+
devLog("hyperframes.render.succeeded", {
|
|
264
|
+
composition_id: metadata.compositionId,
|
|
265
|
+
render_id: input.executionName,
|
|
266
|
+
output_s3_uri: outputS3Uri,
|
|
267
|
+
frames_rendered: progress.framesRendered,
|
|
268
|
+
total_frames: progress.totalFrames,
|
|
269
|
+
lambdas_invoked: progress.lambdasInvoked,
|
|
270
|
+
cost_usd: progress.costs?.accruedSoFarUsd
|
|
271
|
+
});
|
|
272
|
+
return { state: "succeeded", ...result };
|
|
273
|
+
}
|
|
274
|
+
devLog("hyperframes.render.failed", {
|
|
275
|
+
render_id: input.executionName,
|
|
276
|
+
status: progress.status,
|
|
277
|
+
errors: Array.isArray(progress.errors) ? progress.errors.slice(0, 3) : progress.errors
|
|
278
|
+
}, "error");
|
|
279
|
+
return {
|
|
280
|
+
state: "failed",
|
|
281
|
+
renderId: input.executionName,
|
|
282
|
+
executionArn,
|
|
283
|
+
message: `HyperFrames render ${input.executionName} ${progress.status}: ${scrubVendorDocLinks(JSON.stringify(progress.errors))}`
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
async prepareCloudRender(input, compositionHtml, executionName) {
|
|
287
|
+
const metadata = extractCompositionMetadata(compositionHtml, input);
|
|
288
|
+
const stack = await resolveHyperframesStack();
|
|
289
|
+
devLog("hyperframes.render.staging", {
|
|
290
|
+
composition_id: metadata.compositionId,
|
|
291
|
+
width: metadata.width,
|
|
292
|
+
height: metadata.height,
|
|
293
|
+
fps: metadata.fps,
|
|
294
|
+
stack_name: stack.stackName,
|
|
295
|
+
region: stack.region
|
|
296
|
+
});
|
|
297
|
+
const projectDir = await stageProject({
|
|
298
|
+
compositionHtml,
|
|
299
|
+
compositionId: metadata.compositionId,
|
|
300
|
+
projectFiles: input.projectFiles,
|
|
301
|
+
copyPaths: input.copyPaths
|
|
302
|
+
});
|
|
303
|
+
const timestamp = Date.now().toString(36);
|
|
304
|
+
const renderId = executionName || input.executionName || `vf-${metadata.compositionId.slice(0, 40)}-${timestamp}-${randomUUID().slice(0, 8)}`;
|
|
305
|
+
const outputKey = input.outputKey || `renders/vidfarm/${metadata.compositionId}/${renderId}/output.mp4`;
|
|
306
|
+
return { metadata, stack, projectDir, renderId, outputKey };
|
|
307
|
+
}
|
|
308
|
+
async dispatchCloudRender(prep, input) {
|
|
309
|
+
devLog("hyperframes.render.dispatch", {
|
|
310
|
+
composition_id: prep.metadata.compositionId,
|
|
311
|
+
render_id: prep.renderId,
|
|
312
|
+
output_key: prep.outputKey,
|
|
313
|
+
chunk_size: input.chunkSize ?? 240,
|
|
314
|
+
max_parallel_chunks: input.maxParallelChunks ?? 8
|
|
315
|
+
});
|
|
316
|
+
const { renderToLambda } = await import("@hyperframes/aws-lambda/sdk");
|
|
317
|
+
return renderToLambda({
|
|
318
|
+
projectDir: prep.projectDir,
|
|
319
|
+
bucketName: prep.stack.bucketName,
|
|
320
|
+
stateMachineArn: prep.stack.stateMachineArn,
|
|
321
|
+
region: prep.stack.region,
|
|
322
|
+
executionName: prep.renderId,
|
|
323
|
+
outputKey: prep.outputKey,
|
|
324
|
+
config: {
|
|
325
|
+
fps: normalizeRenderFps(prep.metadata.fps),
|
|
326
|
+
width: prep.metadata.width,
|
|
327
|
+
height: prep.metadata.height,
|
|
328
|
+
format: input.format || "mp4",
|
|
329
|
+
codec: "h264",
|
|
330
|
+
quality: "standard",
|
|
331
|
+
chunkSize: input.chunkSize ?? 240,
|
|
332
|
+
maxParallelChunks: input.maxParallelChunks ?? 8,
|
|
333
|
+
runtimeCap: "lambda"
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
async buildCloudRenderResult(handle, progress, metadata, stack) {
|
|
338
|
+
const outputS3Uri = progress.outputFile?.s3Uri || handle.outputS3Uri;
|
|
339
|
+
return {
|
|
340
|
+
...handle,
|
|
341
|
+
outputS3Uri,
|
|
342
|
+
outputUrl: outputS3Uri ? await signedS3Url(outputS3Uri, stack.region) : null,
|
|
343
|
+
endedAt: progress.endedAt,
|
|
344
|
+
metadata: {
|
|
345
|
+
mode: "hyperframes_lambda",
|
|
346
|
+
status: progress.status,
|
|
347
|
+
stack,
|
|
348
|
+
composition: metadata,
|
|
349
|
+
progress,
|
|
350
|
+
costs: progress.costs,
|
|
351
|
+
estimatedCostUsd: progress.costs.accruedSoFarUsd,
|
|
352
|
+
chargeUsd: applyMarkupUsd(progress.costs.accruedSoFarUsd),
|
|
353
|
+
costDisplay: progress.costs.displayCost,
|
|
354
|
+
lambdasInvoked: progress.lambdasInvoked,
|
|
355
|
+
framesRendered: progress.framesRendered,
|
|
356
|
+
totalFrames: progress.totalFrames
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
229
360
|
// In-process render: stage the project, then drive HyperFrames' single-machine
|
|
230
361
|
// orchestrator (headless Chromium → per-frame capture → ffmpeg encode) to write
|
|
231
362
|
// the MP4 straight to local disk storage. No AWS, no Lambda fan-out. The result
|
|
@@ -1628,6 +1759,197 @@ function normalizeViralDna(value) {
|
|
|
1628
1759
|
keywords: asStringArray(record.keywords).map((item) => item.toLowerCase())
|
|
1629
1760
|
};
|
|
1630
1761
|
}
|
|
1762
|
+
// ---------------------------------------------------------------------------
|
|
1763
|
+
// Editor-harness pass — technical editing direction (the "how to edit like
|
|
1764
|
+
// this" companion to viral DNA's "why it works"). Isolated vision call in the
|
|
1765
|
+
// decompose burst (never the caption/scene call) so it can't dilute
|
|
1766
|
+
// timeline-layer quality; reuses the SAME sparse frames already sampled + the
|
|
1767
|
+
// ffmpeg cut timings for grounded pacing. Non-fatal: callers wrap in .catch().
|
|
1768
|
+
// ---------------------------------------------------------------------------
|
|
1769
|
+
export const DEFAULT_EDITOR_HARNESS = {
|
|
1770
|
+
format: "other",
|
|
1771
|
+
one_liner: "",
|
|
1772
|
+
aspect_ratio: "",
|
|
1773
|
+
pacing: { cut_rhythm: "medium", avg_scene_seconds: 0, cuts_per_10s: 0, energy_curve: "" },
|
|
1774
|
+
typography: { caption_style: "", placement: "", font_character: "", emphasis: "", text_density: "none" },
|
|
1775
|
+
broll: { reliance: "none", sourcing: "", shot_kinds: [], cadence: "" },
|
|
1776
|
+
transitions: { default: "cut", intro: "", outro: "", usage: "" },
|
|
1777
|
+
audio: { voiceover: "", music: "", sfx: "", captions_from: "" },
|
|
1778
|
+
scenes: [],
|
|
1779
|
+
important_scenes: [],
|
|
1780
|
+
editing_bias: [],
|
|
1781
|
+
do: [],
|
|
1782
|
+
dont: []
|
|
1783
|
+
};
|
|
1784
|
+
const HARNESS_CUT_RHYTHMS = ["fast", "medium", "slow", "varied"];
|
|
1785
|
+
const HARNESS_TEXT_DENSITY = ["heavy", "moderate", "minimal", "none"];
|
|
1786
|
+
const HARNESS_BROLL_RELIANCE = ["heavy", "moderate", "light", "none"];
|
|
1787
|
+
const HARNESS_SCENE_IMPORTANCE = ["critical", "high", "normal"];
|
|
1788
|
+
function buildEditorHarnessPrompt(input) {
|
|
1789
|
+
const frameListing = input.frameTimestamps.length > 0
|
|
1790
|
+
? `\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`
|
|
1791
|
+
: "";
|
|
1792
|
+
const cutListing = input.sceneCuts.length > 0
|
|
1793
|
+
? `\nDETECTED HARD CUTS (seconds, from the editing timeline — use these to ground pacing/cut_rhythm/cuts_per_10s/avg_scene_seconds): ${input.sceneCuts.map((c) => c.toFixed(2)).join(", ")}\n`
|
|
1794
|
+
: "\n(No hard cuts detected — likely a single continuous take or slow crossfades. Judge pacing from the frames.)\n";
|
|
1795
|
+
const promptHint = input.userPrompt && input.userPrompt.trim().length > 0
|
|
1796
|
+
? `\nUSER GOAL (bias the harness toward recreating THIS intent): ${input.userPrompt.trim()}\n`
|
|
1797
|
+
: "";
|
|
1798
|
+
const roleVocab = SCENE_ROLE_VOCAB.join(" | ");
|
|
1799
|
+
const formatVocab = CONTENT_FORMATS.join(" | ");
|
|
1800
|
+
return `You are Vidfarm's EDITING DIRECTOR. You are studying a short-form video to write an EDITOR HARNESS — a compact, technical brief that lets another AI editor recreate this video's STYLE (not its exact content) in one clean pass on a timeline editor (scenes, captions, audio, transitions, keyframes).
|
|
1801
|
+
|
|
1802
|
+
This is DIFFERENT from "viral DNA" (why the video works / what to preserve). The harness is HOW to edit: pace, typography, b-roll usage, transitions, audio bed, and the role each scene plays. Be concrete and directive — every field should be something an editor can act on, not vibes.
|
|
1803
|
+
|
|
1804
|
+
Video duration: ${input.durationSeconds.toFixed(2)} seconds.
|
|
1805
|
+
${frameListing}${cutListing}${promptHint}
|
|
1806
|
+
Return strictly valid JSON matching this schema:
|
|
1807
|
+
{
|
|
1808
|
+
"format": string, // one of: ${formatVocab}
|
|
1809
|
+
"one_liner": string, // ONE sentence: how to edit to match this style
|
|
1810
|
+
"aspect_ratio": string, // "9:16" | "1:1" | "16:9" (infer from the frames)
|
|
1811
|
+
"pacing": {
|
|
1812
|
+
"cut_rhythm": "fast" | "medium" | "slow" | "varied",
|
|
1813
|
+
"avg_scene_seconds": number, // typical shot length in seconds
|
|
1814
|
+
"cuts_per_10s": number, // ground on the detected cuts + duration
|
|
1815
|
+
"energy_curve": string // e.g. "front-loaded hook then steady"
|
|
1816
|
+
},
|
|
1817
|
+
"typography": {
|
|
1818
|
+
"caption_style": string, // karaoke | word-pop | spotlight | stack-up | neon | bounce | none | free-form label
|
|
1819
|
+
"placement": string, // e.g. "center-lower" | "top" | "full-bleed" | "none"
|
|
1820
|
+
"font_character": string, // e.g. "bold condensed all-caps sans"
|
|
1821
|
+
"emphasis": string, // e.g. "active-word highlight in yellow" | "none"
|
|
1822
|
+
"text_density": "heavy" | "moderate" | "minimal" | "none"
|
|
1823
|
+
},
|
|
1824
|
+
"broll": {
|
|
1825
|
+
"reliance": "heavy" | "moderate" | "light" | "none",
|
|
1826
|
+
"sourcing": string, // generated | stock | screen-recording | talking-head | mixed
|
|
1827
|
+
"shot_kinds": [string, ...], // e.g. b_roll, product_shot, screen_recording, reaction, establishing, text_graphic
|
|
1828
|
+
"cadence": string // e.g. "new visual ~every 2s under VO"
|
|
1829
|
+
},
|
|
1830
|
+
"transitions": {
|
|
1831
|
+
"default": string, // junction at most cuts: cut | crossfade | whip | slide | zoom | dissolve | ...
|
|
1832
|
+
"intro": string, // first-clip entrance
|
|
1833
|
+
"outro": string, // last-clip exit
|
|
1834
|
+
"usage": string // one line on how transitions are used
|
|
1835
|
+
},
|
|
1836
|
+
"audio": {
|
|
1837
|
+
"voiceover": string, // e.g. "first-person narration" | "none"
|
|
1838
|
+
"music": string, // e.g. "upbeat lofi bed, low volume" | "none"
|
|
1839
|
+
"sfx": string, // e.g. "whoosh on cuts" | "none"
|
|
1840
|
+
"captions_from": string // "voiceover" | "on-screen text" | "none"
|
|
1841
|
+
},
|
|
1842
|
+
"scenes": [{
|
|
1843
|
+
"role": string, // ${roleVocab}
|
|
1844
|
+
"timestamp": string, // human range, e.g. "0:00-0:03"
|
|
1845
|
+
"start": number, // numeric seconds where the beat begins
|
|
1846
|
+
"importance": "critical" | "high" | "normal",
|
|
1847
|
+
"must_keep": boolean, // true if swapping/removing this beat kills the style
|
|
1848
|
+
"note": string, // what happens in this beat
|
|
1849
|
+
"edit_bias": string // how to edit this beat while staying on-style
|
|
1850
|
+
}],
|
|
1851
|
+
"important_scenes": [string, ...],// roles or timestamps that carry the virality — protect when editing
|
|
1852
|
+
"editing_bias": [string, ...], // 3-6 top directives an editor MUST follow to stay on-style
|
|
1853
|
+
"do": [string, ...], // 2-5 concrete DOs
|
|
1854
|
+
"dont": [string, ...] // 2-5 concrete DON'Ts
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
RULES:
|
|
1858
|
+
- Ground pacing numbers on the detected cuts + duration when cuts are supplied; otherwise estimate from the frames. Round avg_scene_seconds and cuts_per_10s to at most 2 decimals.
|
|
1859
|
+
- "scenes": 3-8 entries covering the whole runtime in order, each tied to a real moment you see. Reserve importance "critical" for the 1-3 beats that actually carry the style/retention (usually the hook + payoff).
|
|
1860
|
+
- Prefer the caption_style / transition / shot_kind vocabulary above so the editor can map straight to presets; a free-form label is fine when nothing fits.
|
|
1861
|
+
- Keep every string tight and directive. Never invent audio you cannot infer — say "none"/"unknown" instead.
|
|
1862
|
+
- Output ONLY the JSON object.`;
|
|
1863
|
+
}
|
|
1864
|
+
function normalizeEditorHarness(raw) {
|
|
1865
|
+
const rec = asPlainRecord(raw);
|
|
1866
|
+
const oneOf = (value, allowed, fallback) => {
|
|
1867
|
+
const s = normalizeSlug(readAnnotationString(value));
|
|
1868
|
+
return allowed.includes(s) ? s : fallback;
|
|
1869
|
+
};
|
|
1870
|
+
const num = (value) => {
|
|
1871
|
+
const n = Number(value);
|
|
1872
|
+
return Number.isFinite(n) && n >= 0 ? Number(n.toFixed(2)) : 0;
|
|
1873
|
+
};
|
|
1874
|
+
const formatSlug = normalizeSlug(readAnnotationString(rec.format));
|
|
1875
|
+
const format = CONTENT_FORMATS.includes(formatSlug)
|
|
1876
|
+
? formatSlug
|
|
1877
|
+
: "other";
|
|
1878
|
+
const pacing = asPlainRecord(rec.pacing);
|
|
1879
|
+
const typography = asPlainRecord(rec.typography);
|
|
1880
|
+
const broll = asPlainRecord(rec.broll);
|
|
1881
|
+
const transitions = asPlainRecord(rec.transitions);
|
|
1882
|
+
const audio = asPlainRecord(rec.audio);
|
|
1883
|
+
const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : [];
|
|
1884
|
+
const scenes = scenesRaw
|
|
1885
|
+
.map((s) => {
|
|
1886
|
+
const sr = asPlainRecord(s);
|
|
1887
|
+
return {
|
|
1888
|
+
role: normalizeSlug(readAnnotationString(sr.role)) || "context",
|
|
1889
|
+
timestamp: readAnnotationString(sr.timestamp),
|
|
1890
|
+
start: num(sr.start),
|
|
1891
|
+
importance: oneOf(sr.importance, HARNESS_SCENE_IMPORTANCE, "normal"),
|
|
1892
|
+
must_keep: sr.must_keep === true,
|
|
1893
|
+
note: readAnnotationString(sr.note),
|
|
1894
|
+
edit_bias: readAnnotationString(sr.edit_bias)
|
|
1895
|
+
};
|
|
1896
|
+
})
|
|
1897
|
+
.filter((s) => s.note.length > 0 || s.edit_bias.length > 0)
|
|
1898
|
+
.slice(0, 12);
|
|
1899
|
+
return {
|
|
1900
|
+
format,
|
|
1901
|
+
one_liner: readAnnotationString(rec.one_liner),
|
|
1902
|
+
aspect_ratio: readAnnotationString(rec.aspect_ratio),
|
|
1903
|
+
pacing: {
|
|
1904
|
+
cut_rhythm: oneOf(pacing.cut_rhythm, HARNESS_CUT_RHYTHMS, "medium"),
|
|
1905
|
+
avg_scene_seconds: num(pacing.avg_scene_seconds),
|
|
1906
|
+
cuts_per_10s: num(pacing.cuts_per_10s),
|
|
1907
|
+
energy_curve: readAnnotationString(pacing.energy_curve)
|
|
1908
|
+
},
|
|
1909
|
+
typography: {
|
|
1910
|
+
caption_style: readAnnotationString(typography.caption_style),
|
|
1911
|
+
placement: readAnnotationString(typography.placement),
|
|
1912
|
+
font_character: readAnnotationString(typography.font_character),
|
|
1913
|
+
emphasis: readAnnotationString(typography.emphasis),
|
|
1914
|
+
text_density: oneOf(typography.text_density, HARNESS_TEXT_DENSITY, "none")
|
|
1915
|
+
},
|
|
1916
|
+
broll: {
|
|
1917
|
+
reliance: oneOf(broll.reliance, HARNESS_BROLL_RELIANCE, "none"),
|
|
1918
|
+
sourcing: readAnnotationString(broll.sourcing),
|
|
1919
|
+
shot_kinds: readAnnotationStringList(broll.shot_kinds, 8),
|
|
1920
|
+
cadence: readAnnotationString(broll.cadence)
|
|
1921
|
+
},
|
|
1922
|
+
transitions: {
|
|
1923
|
+
default: readAnnotationString(transitions.default) || "cut",
|
|
1924
|
+
intro: readAnnotationString(transitions.intro),
|
|
1925
|
+
outro: readAnnotationString(transitions.outro),
|
|
1926
|
+
usage: readAnnotationString(transitions.usage)
|
|
1927
|
+
},
|
|
1928
|
+
audio: {
|
|
1929
|
+
voiceover: readAnnotationString(audio.voiceover),
|
|
1930
|
+
music: readAnnotationString(audio.music),
|
|
1931
|
+
sfx: readAnnotationString(audio.sfx),
|
|
1932
|
+
captions_from: readAnnotationString(audio.captions_from)
|
|
1933
|
+
},
|
|
1934
|
+
scenes,
|
|
1935
|
+
important_scenes: readAnnotationStringList(rec.important_scenes, 8),
|
|
1936
|
+
editing_bias: readAnnotationStringList(rec.editing_bias, 8),
|
|
1937
|
+
do: readAnnotationStringList(rec.do, 6),
|
|
1938
|
+
dont: readAnnotationStringList(rec.dont, 6)
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
async function runEditorHarnessPass(input) {
|
|
1942
|
+
if (input.frames.length === 0)
|
|
1943
|
+
return DEFAULT_EDITOR_HARNESS;
|
|
1944
|
+
const prompt = buildEditorHarnessPrompt({
|
|
1945
|
+
durationSeconds: input.durationSeconds,
|
|
1946
|
+
frameTimestamps: input.frames.map((f) => f.timestamp),
|
|
1947
|
+
sceneCuts: input.sceneCuts,
|
|
1948
|
+
userPrompt: input.userPrompt ?? null
|
|
1949
|
+
});
|
|
1950
|
+
const attempt = await callVisionWithFallback({ prompt, frames: input.frames, keys: input.keys });
|
|
1951
|
+
return normalizeEditorHarness(attempt.result);
|
|
1952
|
+
}
|
|
1631
1953
|
function shortElementId() {
|
|
1632
1954
|
return `element_${randomUUID().slice(0, 8)}`;
|
|
1633
1955
|
}
|
|
@@ -2025,7 +2347,7 @@ export async function smartDecomposeVideo(input) {
|
|
|
2025
2347
|
// non-fatal. The placement call is DELIBERATELY a separate request from the
|
|
2026
2348
|
// caption/scene vision call so it can never dilute timeline-layer quality —
|
|
2027
2349
|
// it just reuses the same already-sampled sparse frames (no extra ffmpeg).
|
|
2028
|
-
const [attempt, ocrFrames, transcript, placementSurfaces] = await Promise.all([
|
|
2350
|
+
const [attempt, ocrFrames, transcript, placementSurfaces, editorHarness] = await Promise.all([
|
|
2029
2351
|
callVisionWithFallback({
|
|
2030
2352
|
prompt,
|
|
2031
2353
|
frames,
|
|
@@ -2049,6 +2371,18 @@ export async function smartDecomposeVideo(input) {
|
|
|
2049
2371
|
}).catch((err) => {
|
|
2050
2372
|
console.error("smart decompose placement pass failed", err instanceof Error ? err.message : err);
|
|
2051
2373
|
return [];
|
|
2374
|
+
}),
|
|
2375
|
+
// Editor harness — technical editing direction. Isolated call in the same
|
|
2376
|
+
// burst (reuses these frames + the detected cuts); non-fatal.
|
|
2377
|
+
runEditorHarnessPass({
|
|
2378
|
+
frames,
|
|
2379
|
+
keys: input.keys,
|
|
2380
|
+
durationSeconds: meta.durationSeconds,
|
|
2381
|
+
sceneCuts,
|
|
2382
|
+
userPrompt: input.userPrompt ?? null
|
|
2383
|
+
}).catch((err) => {
|
|
2384
|
+
console.error("smart decompose editor-harness pass failed", err instanceof Error ? err.message : err);
|
|
2385
|
+
return DEFAULT_EDITOR_HARNESS;
|
|
2052
2386
|
})
|
|
2053
2387
|
]);
|
|
2054
2388
|
devLog("hyperframes.smart_decompose.ocr_completed", {
|
|
@@ -2147,12 +2481,15 @@ export async function smartDecomposeVideo(input) {
|
|
|
2147
2481
|
caption_count: finalCaptions.length,
|
|
2148
2482
|
captions_snapped: snapped.snappedCount,
|
|
2149
2483
|
scenes_snapped: snappedScenes.snappedCount,
|
|
2150
|
-
placement_surface_count: finalPlacementSurfaces.length
|
|
2484
|
+
placement_surface_count: finalPlacementSurfaces.length,
|
|
2485
|
+
editor_harness_format: editorHarness.format,
|
|
2486
|
+
editor_harness_scene_count: editorHarness.scenes.length
|
|
2151
2487
|
});
|
|
2152
2488
|
return {
|
|
2153
2489
|
summary: typeof record.summary === "string" ? record.summary : "",
|
|
2154
2490
|
viralDna,
|
|
2155
2491
|
placementSurfaces: finalPlacementSurfaces,
|
|
2492
|
+
editorHarness,
|
|
2156
2493
|
transcript,
|
|
2157
2494
|
durationSeconds: meta.durationSeconds,
|
|
2158
2495
|
width: meta.width,
|
|
@@ -2228,6 +2565,7 @@ export function buildSmartDecomposedHtml(input) {
|
|
|
2228
2565
|
const audioId = shortElementId();
|
|
2229
2566
|
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>`;
|
|
2230
2567
|
const viralDnaJson = escapeAttr(JSON.stringify(result.viralDna ?? DEFAULT_VIRAL_DNA));
|
|
2568
|
+
const editorHarnessJson = escapeAttr(JSON.stringify(result.editorHarness ?? DEFAULT_EDITOR_HARNESS));
|
|
2231
2569
|
return `<!doctype html>
|
|
2232
2570
|
<html lang="en">
|
|
2233
2571
|
<head>
|
|
@@ -2241,7 +2579,7 @@ export function buildSmartDecomposedHtml(input) {
|
|
|
2241
2579
|
</style>
|
|
2242
2580
|
</head>
|
|
2243
2581
|
<body>
|
|
2244
|
-
<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}">
|
|
2582
|
+
<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}" data-editor-harness="${editorHarnessJson}">
|
|
2245
2583
|
<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>
|
|
2246
2584
|
${sceneEls}
|
|
2247
2585
|
${audioEl}
|
|
@@ -2250,6 +2588,79 @@ export function buildSmartDecomposedHtml(input) {
|
|
|
2250
2588
|
</body>
|
|
2251
2589
|
</html>`;
|
|
2252
2590
|
}
|
|
2591
|
+
// The public placeholder tile every blank scene starts on — a gray diagonal-
|
|
2592
|
+
// striped 720×1280 image with a dashed text box (uploaded once by
|
|
2593
|
+
// scripts/upload-scene-placeholder.ts). Hardcoded to the prod bucket URL (a
|
|
2594
|
+
// public object that resolves from any stage) so the blank composition renders
|
|
2595
|
+
// identically everywhere. Mirrors SCENE_PLACEHOLDER_IMAGE_URL in the SPA editor.
|
|
2596
|
+
export const SCENE_PLACEHOLDER_IMAGE_URL = "https://vidfarmprodstack-vidfarmbucket335ee12f-0vsvtd5earqy.s3.us-east-1.amazonaws.com/primitives/placeholders/scene-placeholder.png";
|
|
2597
|
+
const BLANK_PROJECT_SCENE_COUNT = 3;
|
|
2598
|
+
const BLANK_PROJECT_SCENE_DURATION = 4;
|
|
2599
|
+
// The canonical, reusable "new blank project" composition. This is the single
|
|
2600
|
+
// composition.html every fresh /editor/original/fork/new opens on, and the base
|
|
2601
|
+
// every blank project forks from (POST /api/v1/compositions seeds a new fork by
|
|
2602
|
+
// copying this). It mirrors blankProjectSemanticLayers() in the SPA editor: a
|
|
2603
|
+
// continuous placeholder image track (3 butt-cut 4s scenes on track 0) plus a
|
|
2604
|
+
// "Scene 1/2/3" caption on track 3, giving the user an editable 12s scaffold
|
|
2605
|
+
// instead of an empty canvas. Like the other builders it emits clips only — the
|
|
2606
|
+
// runtime is injected on serve by rewriteHyperframesDraftCompositionHtml, and z
|
|
2607
|
+
// is normalized to track index on the way out.
|
|
2608
|
+
export function buildBlankCompositionHtml(compositionId) {
|
|
2609
|
+
const totalDuration = BLANK_PROJECT_SCENE_COUNT * BLANK_PROJECT_SCENE_DURATION;
|
|
2610
|
+
const sceneEls = [];
|
|
2611
|
+
const captionEls = [];
|
|
2612
|
+
for (let index = 0; index < BLANK_PROJECT_SCENE_COUNT; index += 1) {
|
|
2613
|
+
const sceneNumber = index + 1;
|
|
2614
|
+
const start = index * BLANK_PROJECT_SCENE_DURATION;
|
|
2615
|
+
const end = start + BLANK_PROJECT_SCENE_DURATION;
|
|
2616
|
+
// Placeholder image scene — full canvas, track 0. Together the three form
|
|
2617
|
+
// one continuous 12s media track the user replaces with real footage.
|
|
2618
|
+
const imageId = `scene-${sceneNumber}-image`;
|
|
2619
|
+
sceneEls.push(`<img class="clip" id="${imageId}" data-hf-id="${imageId}" data-hf-slug="scene_${sceneNumber}" data-layer-mode="both" data-layer-kind="image" data-viral-note="Placeholder scene backdrop — replace with your own media." data-start="${start}" data-duration="${BLANK_PROJECT_SCENE_DURATION}" data-end="${end}" data-track-index="0" data-label="Scene ${sceneNumber}" src="${escapeAttr(SCENE_PLACEHOLDER_IMAGE_URL)}" alt="" style="position:absolute;left:0%;top:0%;width:100%;height:100%;z-index:0;opacity:1;border-radius:0px;overflow:hidden;object-fit:cover;" />`);
|
|
2620
|
+
// "Scene N" label sitting in the tile's blank text box — white on a solid
|
|
2621
|
+
// black highlight so it reads over both the placeholder and swapped-in media.
|
|
2622
|
+
const caption = {
|
|
2623
|
+
slug: `scene_${sceneNumber}_label`,
|
|
2624
|
+
text: `Scene ${sceneNumber}`,
|
|
2625
|
+
start,
|
|
2626
|
+
duration: BLANK_PROJECT_SCENE_DURATION,
|
|
2627
|
+
x: 13.5,
|
|
2628
|
+
y: 44,
|
|
2629
|
+
width: 73,
|
|
2630
|
+
height: 12,
|
|
2631
|
+
font_size: 34,
|
|
2632
|
+
color: "#ffffff",
|
|
2633
|
+
background: "#000000",
|
|
2634
|
+
background_style: "highlight-solid",
|
|
2635
|
+
font_family: "TikTok Sans",
|
|
2636
|
+
font_weight: 700,
|
|
2637
|
+
viral_note: "Placeholder scene label — edit or delete once you add real media."
|
|
2638
|
+
};
|
|
2639
|
+
const captionId = `scene-${sceneNumber}-caption`;
|
|
2640
|
+
const innerStyle = captionInlineTextStyle(caption);
|
|
2641
|
+
captionEls.push(`<div class="clip" id="${captionId}" data-hf-id="${captionId}" data-hf-slug="${escapeAttr(caption.slug)}" data-layer-mode="publish" data-layer-kind="caption" data-viral-note="${escapeAttr(caption.viral_note ?? "")}" data-start="${start}" data-duration="${BLANK_PROJECT_SCENE_DURATION}" data-end="${end}" data-track-index="3" data-label="${escapeAttr(caption.text)}" 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:${caption.x}%;top:${caption.y}%;width:${caption.width}%;height:${caption.height}%;z-index:3;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}">${escapeHtml(caption.text)}</span></span></div>`);
|
|
2642
|
+
}
|
|
2643
|
+
// data-vf-origin="raw": a brand-new project, so the editor never auto-prompts
|
|
2644
|
+
// the Decompose modal (there is no source video to decompose).
|
|
2645
|
+
return `<!doctype html>
|
|
2646
|
+
<html lang="en">
|
|
2647
|
+
<head>
|
|
2648
|
+
<meta charset="UTF-8" />
|
|
2649
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2650
|
+
<style>
|
|
2651
|
+
@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");
|
|
2652
|
+
html, body { width:100%; height:100%; margin:0; overflow:hidden; background:#050604; }
|
|
2653
|
+
[data-composition-id] { position:relative; width:100vw; height:100vh; overflow:hidden; background:#050604; }
|
|
2654
|
+
</style>
|
|
2655
|
+
</head>
|
|
2656
|
+
<body>
|
|
2657
|
+
<div data-composition-id="${escapeAttr(compositionId)}" data-vf-origin="raw" data-width="720" data-height="1280" data-duration="${totalDuration}" data-source-title="Untitled project">
|
|
2658
|
+
${sceneEls.join("\n ")}
|
|
2659
|
+
${captionEls.join("\n ")}
|
|
2660
|
+
</div>
|
|
2661
|
+
</body>
|
|
2662
|
+
</html>`;
|
|
2663
|
+
}
|
|
2253
2664
|
function normalizeSlug(value) {
|
|
2254
2665
|
return String(value ?? "")
|
|
2255
2666
|
.toLowerCase()
|
|
@@ -2625,6 +3036,44 @@ export function normalizePublishHtml(html) {
|
|
|
2625
3036
|
}
|
|
2626
3037
|
return `<!doctype html>\n${document.documentElement.outerHTML}`;
|
|
2627
3038
|
}
|
|
3039
|
+
/**
|
|
3040
|
+
* Reconstruct a Step Functions execution ARN from the render state machine ARN
|
|
3041
|
+
* plus a deterministic execution name, so a re-invoked job runner can poll the
|
|
3042
|
+
* SAME render without persisting the ARN handed back at dispatch time.
|
|
3043
|
+
* arn:...:stateMachine:NAME -> arn:...:execution:NAME:EXEC_NAME
|
|
3044
|
+
*/
|
|
3045
|
+
function executionArnFromName(stateMachineArn, executionName) {
|
|
3046
|
+
const marker = ":stateMachine:";
|
|
3047
|
+
const idx = stateMachineArn.indexOf(marker);
|
|
3048
|
+
if (idx === -1) {
|
|
3049
|
+
throw new Error(`Unexpected HyperFrames render state machine ARN: ${stateMachineArn}`);
|
|
3050
|
+
}
|
|
3051
|
+
const prefix = stateMachineArn.slice(0, idx);
|
|
3052
|
+
const machineName = stateMachineArn.slice(idx + marker.length);
|
|
3053
|
+
return `${prefix}:execution:${machineName}:${executionName}`;
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* DescribeExecution for a render execution. Returns the Step Functions status,
|
|
3057
|
+
* or `null` when the execution does not exist yet (never dispatched) — the
|
|
3058
|
+
* signal renderStep() uses to decide between dispatch and resume.
|
|
3059
|
+
*/
|
|
3060
|
+
async function describeRenderExecutionStatus(executionArn, region) {
|
|
3061
|
+
const client = new SFNClient({ region });
|
|
3062
|
+
try {
|
|
3063
|
+
const response = await client.send(new DescribeExecutionCommand({ executionArn }));
|
|
3064
|
+
return response.status ?? "RUNNING";
|
|
3065
|
+
}
|
|
3066
|
+
catch (error) {
|
|
3067
|
+
if (error instanceof ExecutionDoesNotExist) {
|
|
3068
|
+
return null;
|
|
3069
|
+
}
|
|
3070
|
+
const name = error instanceof Error ? error.name : "";
|
|
3071
|
+
if (name === "ExecutionDoesNotExist") {
|
|
3072
|
+
return null;
|
|
3073
|
+
}
|
|
3074
|
+
throw error;
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
2628
3077
|
async function resolveHyperframesStack() {
|
|
2629
3078
|
const region = config.HYPERFRAMES_RENDER_REGION || config.AWS_REGION || "us-east-1";
|
|
2630
3079
|
const lambdaMemoryMb = positiveInteger(config.HYPERFRAMES_RENDER_MEMORY_MB, 3072);
|