@mevdragon/vidfarm-devcli 0.19.0 → 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.
@@ -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 metadata = extractCompositionMetadata(compositionHtml, input);
85
- const stack = await resolveHyperframesStack();
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
@@ -2905,6 +3036,44 @@ export function normalizePublishHtml(html) {
2905
3036
  }
2906
3037
  return `<!doctype html>\n${document.documentElement.outerHTML}`;
2907
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
+ }
2908
3077
  async function resolveHyperframesStack() {
2909
3078
  const region = config.HYPERFRAMES_RENDER_REGION || config.AWS_REGION || "us-east-1";
2910
3079
  const lambdaMemoryMb = positiveInteger(config.HYPERFRAMES_RENDER_MEMORY_MB, 3072);
@@ -17,7 +17,7 @@ const dynamodb = config.RECORDS_DRIVER === "local"
17
17
  }
18
18
  });
19
19
  const sfn = new SFNClient({});
20
- const ACTIVE_ROOT_JOB_STATUSES = ["queued", "running", "waiting_for_provider", "waiting_for_child", "waiting_for_human"];
20
+ const ACTIVE_ROOT_JOB_STATUSES = ["queued", "running", "waiting_for_provider", "waiting_for_render", "waiting_for_child", "waiting_for_human"];
21
21
  const ACTIVE_JOB_STATUSES = new Set(ACTIVE_ROOT_JOB_STATUSES);
22
22
  export class ServerlessJobsService {
23
23
  async createRootJob(input) {
@@ -282,6 +282,7 @@ export class ServerlessJobsService {
282
282
  "#status = :queuedStatus",
283
283
  "#status = :runningStatus",
284
284
  "#status = :waitingForProviderStatus",
285
+ "#status = :waitingForRenderStatus",
285
286
  "#status = :waitingForChildStatus",
286
287
  "#status = :waitingForHumanStatus"
287
288
  ].join(" OR ")
@@ -293,6 +294,7 @@ export class ServerlessJobsService {
293
294
  expressionAttributeValues[":queuedStatus"] = "queued";
294
295
  expressionAttributeValues[":runningStatus"] = "running";
295
296
  expressionAttributeValues[":waitingForProviderStatus"] = "waiting_for_provider";
297
+ expressionAttributeValues[":waitingForRenderStatus"] = "waiting_for_render";
296
298
  expressionAttributeValues[":waitingForChildStatus"] = "waiting_for_child";
297
299
  expressionAttributeValues[":waitingForHumanStatus"] = "waiting_for_human";
298
300
  }
@@ -1925,7 +1925,7 @@ export function renderCustomInpaintPage(input) {
1925
1925
  }
1926
1926
 
1927
1927
  function isPendingJobStatus(status) {
1928
- return ["queued", "running", "waiting_for_provider", "waiting_for_child", "waiting_for_human"].includes(String(status || ""));
1928
+ return ["queued", "running", "waiting_for_provider", "waiting_for_render", "waiting_for_child", "waiting_for_human"].includes(String(status || ""));
1929
1929
  }
1930
1930
 
1931
1931
  function pendingHistoryItemKey(jobId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {