@mevdragon/vidfarm-devcli 0.2.12 → 0.2.13

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.
Files changed (199) hide show
  1. package/.env.example +37 -1
  2. package/README.md +27 -183
  3. package/SKILL.director.md +293 -0
  4. package/SKILL.platform.md +312 -0
  5. package/auto-create-hyperframe-templates/AUTO_CREATE_HYPERFRAME_TEMPLATES.md +275 -0
  6. package/auto-create-hyperframe-templates/extractor-system-prompt.md +128 -0
  7. package/auto-create-hyperframe-templates/input-manifest.schema.json +56 -0
  8. package/auto-create-hyperframe-templates/lambda-memory-estimator.md +118 -0
  9. package/auto-create-hyperframe-templates/production-graph.schema.json +314 -0
  10. package/auto-create-hyperframe-templates/runbook.md +198 -0
  11. package/auto-create-hyperframe-templates/scripts/create-hyperframe-template.mjs +368 -0
  12. package/auto-create-hyperframe-templates/template-plan.schema.json +311 -0
  13. package/auto-create-hyperframe-templates/template-planner-prompt.md +144 -0
  14. package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +116 -0
  15. package/auto-create-templates/extractor-system-prompt.md +6 -1
  16. package/auto-create-templates/template-plan.schema.json +73 -2
  17. package/auto-create-templates/template-planner-prompt.md +62 -1
  18. package/dist/src/account-pages-legacy.js +361 -89
  19. package/dist/src/account-pages-legacy.js.map +1 -0
  20. package/dist/src/account-pages.js +13 -3
  21. package/dist/src/account-pages.js.map +1 -0
  22. package/dist/src/app.js +7060 -946
  23. package/dist/src/app.js.map +1 -0
  24. package/dist/src/cli.js +110 -29
  25. package/dist/src/cli.js.map +1 -0
  26. package/dist/src/composition-runtime.js +581 -0
  27. package/dist/src/composition-runtime.js.map +1 -0
  28. package/dist/src/config.js +69 -11
  29. package/dist/src/config.js.map +1 -0
  30. package/dist/src/context.js +280 -21
  31. package/dist/src/context.js.map +1 -0
  32. package/dist/src/dev-app-legacy.js +18 -17
  33. package/dist/src/dev-app-legacy.js.map +1 -0
  34. package/dist/src/dev-app.js +1 -0
  35. package/dist/src/dev-app.js.map +1 -0
  36. package/dist/src/domain.js +2 -1
  37. package/dist/src/domain.js.map +1 -0
  38. package/dist/src/editor-chat-history.js +10 -0
  39. package/dist/src/editor-chat-history.js.map +1 -0
  40. package/dist/src/editor-chat.js +133 -24
  41. package/dist/src/editor-chat.js.map +1 -0
  42. package/dist/src/frontend/flockposter-cache-store.js +10 -2
  43. package/dist/src/frontend/flockposter-cache-store.js.map +1 -0
  44. package/dist/src/frontend/homepage-client.js +6 -3
  45. package/dist/src/frontend/homepage-client.js.map +1 -0
  46. package/dist/src/frontend/homepage-shared.js +1 -0
  47. package/dist/src/frontend/homepage-shared.js.map +1 -0
  48. package/dist/src/frontend/homepage-store.js +1 -0
  49. package/dist/src/frontend/homepage-store.js.map +1 -0
  50. package/dist/src/frontend/homepage-view.js +131 -18
  51. package/dist/src/frontend/homepage-view.js.map +1 -0
  52. package/dist/src/frontend/page-runtime-client.js +5 -2
  53. package/dist/src/frontend/page-runtime-client.js.map +1 -0
  54. package/dist/src/frontend/page-runtime-store.js +1 -0
  55. package/dist/src/frontend/page-runtime-store.js.map +1 -0
  56. package/dist/src/frontend/sentry.js +42 -0
  57. package/dist/src/frontend/sentry.js.map +1 -0
  58. package/dist/src/frontend/template-editor-chat.js +1719 -213
  59. package/dist/src/frontend/template-editor-chat.js.map +1 -0
  60. package/dist/src/help-page.js +333 -0
  61. package/dist/src/help-page.js.map +1 -0
  62. package/dist/src/homepage.js +169 -47
  63. package/dist/src/homepage.js.map +1 -0
  64. package/dist/src/hyperframes/composition.js +180 -0
  65. package/dist/src/hyperframes/composition.js.map +1 -0
  66. package/dist/src/index.js +1 -0
  67. package/dist/src/index.js.map +1 -0
  68. package/dist/src/instrument.js +30 -0
  69. package/dist/src/instrument.js.map +1 -0
  70. package/dist/src/lib/crypto.js +1 -0
  71. package/dist/src/lib/crypto.js.map +1 -0
  72. package/dist/src/lib/dev-log.js +54 -0
  73. package/dist/src/lib/dev-log.js.map +1 -0
  74. package/dist/src/lib/display-name.js +11 -0
  75. package/dist/src/lib/display-name.js.map +1 -0
  76. package/dist/src/lib/ids.js +21 -1
  77. package/dist/src/lib/ids.js.map +1 -0
  78. package/dist/src/lib/images.js +1 -0
  79. package/dist/src/lib/images.js.map +1 -0
  80. package/dist/src/lib/json.js +1 -0
  81. package/dist/src/lib/json.js.map +1 -0
  82. package/dist/src/lib/template-dna.js +1 -0
  83. package/dist/src/lib/template-dna.js.map +1 -0
  84. package/dist/src/lib/template-paths.js +1 -0
  85. package/dist/src/lib/template-paths.js.map +1 -0
  86. package/dist/src/lib/template-style-options.js +29 -3
  87. package/dist/src/lib/template-style-options.js.map +1 -0
  88. package/dist/src/lib/time.js +1 -0
  89. package/dist/src/lib/time.js.map +1 -0
  90. package/dist/src/lib/video-quality-harness.js +60 -0
  91. package/dist/src/lib/video-quality-harness.js.map +1 -0
  92. package/dist/src/page-runtime.js +1 -0
  93. package/dist/src/page-runtime.js.map +1 -0
  94. package/dist/src/page-shell.js +1501 -49
  95. package/dist/src/page-shell.js.map +1 -0
  96. package/dist/src/primitive-context.js +144 -8
  97. package/dist/src/primitive-context.js.map +1 -0
  98. package/dist/src/primitive-registry.js +1919 -78
  99. package/dist/src/primitive-registry.js.map +1 -0
  100. package/dist/src/primitive-sdk.js +1 -0
  101. package/dist/src/primitive-sdk.js.map +1 -0
  102. package/dist/src/primitives/remotion/html-image.js +3 -1
  103. package/dist/src/primitives/remotion/html-image.js.map +1 -0
  104. package/dist/src/primitives/remotion/media-slideshow.js +60 -0
  105. package/dist/src/primitives/remotion/media-slideshow.js.map +1 -0
  106. package/dist/src/react-page-shell.js +1 -0
  107. package/dist/src/react-page-shell.js.map +1 -0
  108. package/dist/src/ready-post-schedule-component.js +1 -0
  109. package/dist/src/ready-post-schedule-component.js.map +1 -0
  110. package/dist/src/registry.js +237 -24
  111. package/dist/src/registry.js.map +1 -0
  112. package/dist/src/runtime.js +3 -0
  113. package/dist/src/runtime.js.map +1 -0
  114. package/dist/src/services/api-call-history.js +4 -0
  115. package/dist/src/services/api-call-history.js.map +1 -0
  116. package/dist/src/services/auth.js +40 -50
  117. package/dist/src/services/auth.js.map +1 -0
  118. package/dist/src/services/billing.js +20 -44
  119. package/dist/src/services/billing.js.map +1 -0
  120. package/dist/src/services/chat-threads.js +10 -6
  121. package/dist/src/services/chat-threads.js.map +1 -0
  122. package/dist/src/services/fork-access.js +94 -0
  123. package/dist/src/services/fork-access.js.map +1 -0
  124. package/dist/src/services/ghostcut.js +132 -0
  125. package/dist/src/services/ghostcut.js.map +1 -0
  126. package/dist/src/services/hyperframes.js +1014 -0
  127. package/dist/src/services/hyperframes.js.map +1 -0
  128. package/dist/src/services/job-capacity.js +14 -0
  129. package/dist/src/services/job-capacity.js.map +1 -0
  130. package/dist/src/services/job-logs.js +4 -0
  131. package/dist/src/services/job-logs.js.map +1 -0
  132. package/dist/src/services/jobs.js +99 -91
  133. package/dist/src/services/jobs.js.map +1 -0
  134. package/dist/src/services/media-processing.js +743 -0
  135. package/dist/src/services/media-processing.js.map +1 -0
  136. package/dist/src/services/primitive-media-lambda.js +280 -0
  137. package/dist/src/services/primitive-media-lambda.js.map +1 -0
  138. package/dist/src/services/providers.js +1560 -178
  139. package/dist/src/services/providers.js.map +1 -0
  140. package/dist/src/services/rate-limits.js +3 -2
  141. package/dist/src/services/rate-limits.js.map +1 -0
  142. package/dist/src/services/remotion.js +495 -92
  143. package/dist/src/services/remotion.js.map +1 -0
  144. package/dist/src/services/serverless-auth.js +374 -0
  145. package/dist/src/services/serverless-auth.js.map +1 -0
  146. package/dist/src/services/serverless-jobs.js +1074 -0
  147. package/dist/src/services/serverless-jobs.js.map +1 -0
  148. package/dist/src/services/serverless-provider-keys.js +401 -0
  149. package/dist/src/services/serverless-provider-keys.js.map +1 -0
  150. package/dist/src/services/serverless-records.js +1086 -0
  151. package/dist/src/services/serverless-records.js.map +1 -0
  152. package/dist/src/services/serverless-template-configs.js +67 -0
  153. package/dist/src/services/serverless-template-configs.js.map +1 -0
  154. package/dist/src/services/storage.js +170 -35
  155. package/dist/src/services/storage.js.map +1 -0
  156. package/dist/src/services/template-certification.js +295 -3
  157. package/dist/src/services/template-certification.js.map +1 -0
  158. package/dist/src/services/template-loader.js +45 -1
  159. package/dist/src/services/template-loader.js.map +1 -0
  160. package/dist/src/services/template-runtime-bundles.js +217 -0
  161. package/dist/src/services/template-runtime-bundles.js.map +1 -0
  162. package/dist/src/services/template-sources.js +452 -87
  163. package/dist/src/services/template-sources.js.map +1 -0
  164. package/dist/src/services/video-normalization.js +2 -0
  165. package/dist/src/services/video-normalization.js.map +1 -0
  166. package/dist/src/services/webhooks.js +7 -6
  167. package/dist/src/services/webhooks.js.map +1 -0
  168. package/dist/src/template-editor-pages.js +5637 -1322
  169. package/dist/src/template-editor-pages.js.map +1 -0
  170. package/dist/src/template-editor-shell.js +1376 -170
  171. package/dist/src/template-editor-shell.js.map +1 -0
  172. package/dist/src/template-sdk.js +1 -0
  173. package/dist/src/template-sdk.js.map +1 -0
  174. package/dist/src/worker.js +11 -226
  175. package/dist/src/worker.js.map +1 -0
  176. package/package.json +47 -12
  177. package/public/assets/homepage-client-app.js +34 -8
  178. package/public/assets/page-runtime-client-app.js +44 -18
  179. package/readme.secret.md +89 -0
  180. package/templates/vidfarm_template_0000/README.md +106 -0
  181. package/templates/vidfarm_template_0000/SKILL.md +266 -0
  182. package/templates/vidfarm_template_0000/assets/Abel-Regular.ttf +0 -0
  183. package/templates/vidfarm_template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  184. package/templates/vidfarm_template_0000/assets/Montserrat[wght].ttf +0 -0
  185. package/templates/vidfarm_template_0000/assets/SourceCodePro[wght].ttf +0 -0
  186. package/templates/vidfarm_template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  187. package/templates/vidfarm_template_0000/assets/Yesteryear-Regular.ttf +0 -0
  188. package/templates/vidfarm_template_0000/composition.json +11 -0
  189. package/templates/vidfarm_template_0000/src/lib/images.js +221 -0
  190. package/templates/vidfarm_template_0000/src/remotion/Root.js +33 -0
  191. package/templates/vidfarm_template_0000/src/remotion/index.js +3 -0
  192. package/templates/vidfarm_template_0000/src/sdk.js +3 -0
  193. package/templates/vidfarm_template_0000/src/style-options.js +200 -0
  194. package/templates/vidfarm_template_0000/src/template-dna.js +9 -0
  195. package/templates/vidfarm_template_0000/src/template.js +1566 -0
  196. package/templates/vidfarm_template_0000/template.config.json +21 -0
  197. package/GETTING_STARTED.developers.md +0 -87
  198. package/SKILL.developer.md +0 -1186
  199. package/dist/src/db.js +0 -2007
@@ -0,0 +1,1014 @@
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, rm, 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 { buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
16
+ let stackCache = null;
17
+ export class HyperframesService {
18
+ async render(input) {
19
+ const compositionHtml = normalizePublishHtml(input.compositionHtml || (input.composition ? buildHyperframeCompositionHtml(input.composition) : ""));
20
+ if (!compositionHtml.includes("data-composition-id=")) {
21
+ throw new Error("HyperFrames render requires composition HTML with data-composition-id.");
22
+ }
23
+ const metadata = extractCompositionMetadata(compositionHtml, input);
24
+ const stack = await resolveHyperframesStack();
25
+ const projectDir = await stageProject({
26
+ compositionHtml,
27
+ compositionId: metadata.compositionId,
28
+ projectFiles: input.projectFiles,
29
+ copyPaths: input.copyPaths
30
+ });
31
+ const timestamp = Date.now().toString(36);
32
+ const renderId = input.executionName || `vf-${metadata.compositionId.slice(0, 40)}-${timestamp}-${randomUUID().slice(0, 8)}`;
33
+ const outputKey = input.outputKey || `renders/vidfarm/${metadata.compositionId}/${renderId}/output.mp4`;
34
+ const { renderToLambda, getRenderProgress } = await import("@hyperframes/aws-lambda/sdk");
35
+ const handle = await renderToLambda({
36
+ projectDir,
37
+ bucketName: stack.bucketName,
38
+ stateMachineArn: stack.stateMachineArn,
39
+ region: stack.region,
40
+ executionName: renderId,
41
+ outputKey,
42
+ config: {
43
+ fps: normalizeRenderFps(metadata.fps),
44
+ width: metadata.width,
45
+ height: metadata.height,
46
+ format: input.format || "mp4",
47
+ codec: "h264",
48
+ quality: "standard",
49
+ chunkSize: input.chunkSize ?? 240,
50
+ maxParallelChunks: input.maxParallelChunks ?? 8,
51
+ runtimeCap: "lambda"
52
+ }
53
+ });
54
+ if (input.wait === false) {
55
+ return {
56
+ ...handle,
57
+ outputUrl: null,
58
+ outputS3Uri: handle.outputS3Uri,
59
+ endedAt: null,
60
+ metadata: {
61
+ mode: "hyperframes_lambda",
62
+ status: "RUNNING",
63
+ stack,
64
+ composition: metadata
65
+ }
66
+ };
67
+ }
68
+ const startedAtMs = Date.now();
69
+ const timeoutMs = input.timeoutMs ?? 12 * 60 * 1000;
70
+ const pollIntervalMs = input.pollIntervalMs ?? 5000;
71
+ for (;;) {
72
+ const progress = await getRenderProgress({
73
+ executionArn: handle.executionArn,
74
+ region: stack.region,
75
+ defaultMemorySizeMb: stack.lambdaMemoryMb
76
+ });
77
+ if (progress.status === "SUCCEEDED") {
78
+ const outputS3Uri = progress.outputFile?.s3Uri || handle.outputS3Uri;
79
+ return {
80
+ ...handle,
81
+ outputS3Uri,
82
+ outputUrl: await signedS3Url(outputS3Uri, stack.region),
83
+ endedAt: progress.endedAt,
84
+ metadata: {
85
+ mode: "hyperframes_lambda",
86
+ status: progress.status,
87
+ stack,
88
+ composition: metadata,
89
+ progress,
90
+ costs: progress.costs,
91
+ estimatedCostUsd: progress.costs.accruedSoFarUsd,
92
+ chargeUsd: roundUsd(progress.costs.accruedSoFarUsd * 1.2),
93
+ costDisplay: progress.costs.displayCost,
94
+ lambdasInvoked: progress.lambdasInvoked,
95
+ framesRendered: progress.framesRendered,
96
+ totalFrames: progress.totalFrames
97
+ }
98
+ };
99
+ }
100
+ if (progress.fatalErrorEncountered || ["FAILED", "TIMED_OUT", "ABORTED"].includes(progress.status)) {
101
+ throw new Error(`HyperFrames render ${handle.renderId} failed: ${JSON.stringify(progress.errors)}`);
102
+ }
103
+ if (Date.now() - startedAtMs > timeoutMs) {
104
+ throw new Error(`Timed out waiting for HyperFrames render ${handle.renderId}.`);
105
+ }
106
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
107
+ }
108
+ }
109
+ async readRenderOutput(outputS3Uri, region) {
110
+ const parsed = parseS3Uri(outputS3Uri);
111
+ if (!parsed) {
112
+ throw new Error(`Invalid HyperFrames output S3 URI: ${outputS3Uri}`);
113
+ }
114
+ const s3 = new S3Client({ region: region || config.HYPERFRAMES_RENDER_REGION || config.AWS_REGION });
115
+ const response = await s3.send(new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key }));
116
+ if (!response.Body) {
117
+ throw new Error(`HyperFrames output is empty: ${outputS3Uri}`);
118
+ }
119
+ return {
120
+ bytes: Buffer.from(await response.Body.transformToByteArray()),
121
+ contentType: response.ContentType || "video/mp4"
122
+ };
123
+ }
124
+ }
125
+ // Convert a "raw" composition (source video only, no timeline scenes) into a
126
+ // scene-scoped composition by chopping the source video into ~target-second
127
+ // chunks and inserting timeline-proxy divs the editor can drag. This is a
128
+ // deterministic time-based split; smarter AI-frame-based decomposition can
129
+ // replace it later without changing the shape of the produced HTML.
130
+ export function decomposeUndecomposedComposition(html, options) {
131
+ const { document } = parseHTML(html);
132
+ const root = document.querySelector("[data-composition-id]");
133
+ if (!root)
134
+ return { ok: false, error: "Composition root not found." };
135
+ const existingProxies = document.querySelectorAll('[data-vf-timeline-proxy="video"]');
136
+ if (existingProxies.length > 0) {
137
+ return { ok: false, error: "Composition already has timeline scenes." };
138
+ }
139
+ const backingVideo = document.querySelector("[data-vf-playback-source]") ?? document.querySelector("video");
140
+ const sourceUrl = (backingVideo?.getAttribute("data-src") || backingVideo?.getAttribute("src") || root.getAttribute("data-source-video") || "").trim();
141
+ if (!sourceUrl)
142
+ return { ok: false, error: "No source video URL found on the composition." };
143
+ const durationSeconds = Number.parseFloat(root.getAttribute("data-duration") ?? "");
144
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
145
+ return { ok: false, error: "Composition data-duration is missing — cannot compute scenes." };
146
+ }
147
+ const targetSeconds = options.targetSceneSeconds;
148
+ const preferredCount = options.requestedScenes ?? Math.max(1, Math.round(durationSeconds / targetSeconds));
149
+ const sceneCount = Math.min(24, Math.max(1, preferredCount));
150
+ const rawScene = durationSeconds / sceneCount;
151
+ for (let index = 0; index < sceneCount; index += 1) {
152
+ const start = index === 0 ? 0 : Number((index * rawScene).toFixed(3));
153
+ const end = index === sceneCount - 1 ? durationSeconds : Number(((index + 1) * rawScene).toFixed(3));
154
+ const duration = Number(Math.max(0.1, end - start).toFixed(3));
155
+ const proxy = document.createElement("div");
156
+ const proxyId = shortElementId();
157
+ proxy.id = proxyId;
158
+ proxy.setAttribute("data-hf-id", proxyId);
159
+ proxy.setAttribute("data-layer-mode", "both");
160
+ proxy.setAttribute("data-layer-kind", "video");
161
+ proxy.setAttribute("data-viral-note", `Auto-decomposed scene ${index + 1} of ${sceneCount}.`);
162
+ proxy.setAttribute("data-start", String(start));
163
+ proxy.setAttribute("data-duration", String(duration));
164
+ proxy.setAttribute("data-end", String(Number((start + duration).toFixed(3))));
165
+ proxy.setAttribute("data-track-index", "0");
166
+ proxy.setAttribute("data-label", `Scene ${index + 1}`);
167
+ proxy.setAttribute("data-media-start", String(start));
168
+ proxy.setAttribute("data-playback-start", String(start));
169
+ proxy.setAttribute("data-vf-timeline-proxy", "video");
170
+ proxy.setAttribute("data-src", sourceUrl);
171
+ proxy.setAttribute("style", "position:absolute;left:0%;top:0%;width:1%;height:1%;z-index:0;display:none;pointer-events:none;");
172
+ proxy.className = "clip";
173
+ root.append(proxy);
174
+ }
175
+ return {
176
+ ok: true,
177
+ html: `<!doctype html>\n${document.documentElement.outerHTML}`,
178
+ sceneCount,
179
+ durationSeconds
180
+ };
181
+ }
182
+ // ==========================================================================
183
+ // Smart video decomposition — samples frames from the source, ships them
184
+ // to Gemini vision alongside a strict JSON schema request, and returns the
185
+ // scene bounds + text captions found in the footage.
186
+ // ==========================================================================
187
+ const ffmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : ffmpegStatic.path;
188
+ const ffprobeBin = typeof ffprobeStatic.path === "string"
189
+ ? ffprobeStatic.path
190
+ : ffprobeStatic;
191
+ function runBinary(bin, args) {
192
+ return new Promise((resolve, reject) => {
193
+ const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
194
+ let stdout = "";
195
+ let stderr = "";
196
+ proc.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
197
+ proc.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
198
+ proc.on("error", reject);
199
+ proc.on("close", (code) => {
200
+ if (code === 0)
201
+ resolve(stdout);
202
+ else
203
+ reject(new Error(`${path.basename(bin)} exited ${code}: ${stderr || stdout}`));
204
+ });
205
+ });
206
+ }
207
+ async function downloadVideoTo(url, dest) {
208
+ const response = await fetch(url);
209
+ if (!response.ok || !response.body)
210
+ throw new Error(`Failed to download ${url}: HTTP ${response.status}`);
211
+ await pipeline(response.body, createWriteStream(dest));
212
+ }
213
+ async function ffprobeVideo(filePath) {
214
+ const output = await runBinary(ffprobeBin, [
215
+ "-v", "error",
216
+ "-show_entries", "format=duration:stream=width,height",
217
+ "-of", "json",
218
+ filePath
219
+ ]);
220
+ const parsed = JSON.parse(output || "{}");
221
+ const stream = (parsed.streams || []).find((s) => s.width && s.height) || {};
222
+ return {
223
+ width: Number(stream.width || 0),
224
+ height: Number(stream.height || 0),
225
+ durationSeconds: Number(parsed.format?.duration || 0)
226
+ };
227
+ }
228
+ async function sampleFramesAsBase64(videoPath, durationSeconds, tempDir) {
229
+ const timestamps = [
230
+ Math.max(0.1, durationSeconds * 0.05),
231
+ durationSeconds * 0.2,
232
+ durationSeconds * 0.35,
233
+ durationSeconds * 0.5,
234
+ durationSeconds * 0.65,
235
+ durationSeconds * 0.8,
236
+ Math.max(0.1, durationSeconds * 0.95)
237
+ ]
238
+ .filter((value, index, list) => Number.isFinite(value) && value >= 0 && list.indexOf(value) === index)
239
+ .slice(0, 6);
240
+ const results = [];
241
+ for (const [index, timestamp] of timestamps.entries()) {
242
+ const framePath = path.join(tempDir, `frame-${index}.jpg`);
243
+ await runBinary(ffmpegBin, [
244
+ "-y",
245
+ "-ss", String(timestamp),
246
+ "-i", videoPath,
247
+ "-frames:v", "1",
248
+ "-vf", "scale=576:-1",
249
+ framePath
250
+ ]);
251
+ const data = (await readFile(framePath)).toString("base64");
252
+ results.push({ timestamp, data });
253
+ }
254
+ return results;
255
+ }
256
+ function buildSmartDecomposePrompt(input) {
257
+ const userGuidance = input.userPrompt && input.userPrompt.trim().length > 0
258
+ ? `\nUSER GUIDANCE (highest priority — pay special attention to this when deciding scenes, captions, and viral_dna):\n"""\n${input.userPrompt.trim()}\n"""\n`
259
+ : "";
260
+ return `You are decomposing a short-form vertical video (TikTok/Reel) into editable HyperFrames timeline layers.
261
+ ${userGuidance}
262
+ Video metadata:
263
+ - duration: ${input.durationSeconds.toFixed(3)} seconds
264
+ - dimensions: ${input.width} x ${input.height} (9:16 vertical, canvas is 100% x 100%)
265
+
266
+ Return strictly valid JSON matching this schema:
267
+ {
268
+ "summary": string,
269
+ "viral_dna": {
270
+ "hook": string,
271
+ "retention": string,
272
+ "payoff": string,
273
+ "preserve": [string, ...],
274
+ "avoid": [string, ...],
275
+ "promotions": [string, ...]
276
+ },
277
+ "scenes": [{
278
+ "slug": string,
279
+ "start": number,
280
+ "duration": number,
281
+ "label": string,
282
+ "viral_note": string
283
+ }],
284
+ "captions": [{
285
+ "slug": string,
286
+ "text": string,
287
+ "start": number,
288
+ "duration": number,
289
+ "x": number,
290
+ "y": number,
291
+ "width": number,
292
+ "height": number,
293
+ "font_size_percent": number,
294
+ "font_size": number,
295
+ "color": string,
296
+ "background": string,
297
+ "background_style": "plain" | "outline" | "highlight-solid" | "highlight-translucent",
298
+ "font_family": string,
299
+ "font_weight": number,
300
+ "viral_note": string
301
+ }]
302
+ }
303
+
304
+ VIRAL_DNA RULES:
305
+ - "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").
306
+ - "retention": how the middle of the video keeps attention (visual cuts, pattern loops, escalating stakes).
307
+ - "payoff": what the viewer earns at the end (reveal, punchline, transformation).
308
+ - "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").
309
+ - "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").
310
+ - "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.
311
+ - Use short imperative fragments in preserve/avoid arrays. Never leave empty — infer from the frames if unstated.
312
+
313
+ SCENE RULES:
314
+ - 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)}.
315
+
316
+ SLUG RULES:
317
+ - 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".
318
+
319
+ CAPTION RULES:
320
+ - Extract EVERY visible on-screen text overlay as its own caption. Preserve exact spelling/casing/line breaks (use \\n for line breaks).
321
+ - If no visible text, return "captions": []. NEVER invent text.
322
+
323
+ FONT_FAMILY (choose ONE, match visual appearance):
324
+ - "TikTok Sans" — clean sans-serif (DEFAULT if unsure). Rounded, modern.
325
+ - "Montserrat" — geometric bold sans. Common on IG/TikTok bold captions.
326
+ - "Source Code Pro" — monospaced. For code/terminal aesthetic.
327
+ - "Yesteryear" — cursive script. For quotes / romantic vibes.
328
+ - "Georgia" — serif. For editorial / news look.
329
+ - "Abel" — condensed sans. Newsletter/headline vibe.
330
+ - Return exactly one of the above six strings. If unsure, return "TikTok Sans".
331
+
332
+ FONT_WEIGHT (integer):
333
+ - 400 for normal weight
334
+ - 600 for semibold (typical TikTok caption look)
335
+ - 700 for bold (heavy captions)
336
+ - 800 or 900 for ultra-black display text
337
+
338
+ FONT_SIZE — return TWO numbers, both required. The renderer uses font_size_percent to survive canvas resizes.
339
+ - "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.
340
+ - Tiny UI / hashtag row: 1.2–2.0
341
+ - Standard TikTok caption (dialog line): 2.0–3.2
342
+ - Standard hook caption (readable but not screaming): 3.2–5.0
343
+ - Bold hook overlay filling a wide band: 5.0–8.0
344
+ - Giant kinetic display / meme text: 8.0–12.0
345
+ - "font_size" (integer pixels): compute as round(font_size_percent / 100 * ${input.height || 1280}). Include it for redundancy.
346
+ - 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.
347
+ - Give the SAME caption the SAME font_size — do not vary between instances.
348
+
349
+ COLOR (hex "#rrggbb"):
350
+ - Pure white: "#ffffff" (most common TikTok captions)
351
+ - Pure black: "#000000"
352
+ - Bright yellow (highlight text): "#ffe600"
353
+ - Match the actual on-screen color as closely as you can. Never return names like "white"; always hex.
354
+
355
+ BACKGROUND_STYLE (choose ONE):
356
+ - "plain" — no visible pill/outline. Just letters over the video. (Most common.)
357
+ - "outline" — thin dark outline around each glyph (for legibility over busy video).
358
+ - "highlight-solid" — a solid colored pill / block behind the text (typical TikTok white-on-black caption card).
359
+ - "highlight-translucent" — semi-transparent pill.
360
+ - BACKGROUND ("#rrggbb") is the pill / outline color. For "plain" this is unused but still return a valid hex like "#000000".
361
+
362
+ POSITION (percent 0-100, floats OK):
363
+ - x, y = top-left corner of the caption's bounding box relative to the canvas.
364
+ - width, height = its bounding box size.
365
+ - TikTok baked captions are usually centered horizontally: x ≈ 5-10, width ≈ 80-90.
366
+ - Vertical placement: hook captions near top (y ≈ 8-18); dialog/subtitle mid-lower (y ≈ 60-72); brand tag / cta bottom (y ≈ 80-88).
367
+ - Height should roughly match font_size and line count.
368
+
369
+ TIMING RULES:
370
+ - start, duration are seconds (floats). start >= 0. start + duration <= ${input.durationSeconds.toFixed(3)}.
371
+
372
+ OUTPUT: pure JSON, no prose, no markdown fences.`;
373
+ }
374
+ const ALLOWED_FONTS = [
375
+ "TikTok Sans", "Montserrat", "Source Code Pro", "Yesteryear", "Georgia", "Abel"
376
+ ];
377
+ const ALLOWED_BG_STYLES = [
378
+ "plain", "outline", "highlight-solid", "highlight-translucent"
379
+ ];
380
+ function normalizeFontFamily(value) {
381
+ if (typeof value !== "string")
382
+ return "TikTok Sans";
383
+ const trimmed = value.trim();
384
+ const match = ALLOWED_FONTS.find((font) => font.toLowerCase() === trimmed.toLowerCase());
385
+ return match ?? "TikTok Sans";
386
+ }
387
+ function normalizeHexColor(value, fallback) {
388
+ if (typeof value !== "string")
389
+ return fallback;
390
+ const trimmed = value.trim();
391
+ if (/^#[0-9a-fA-F]{6}$/.test(trimmed))
392
+ return trimmed.toLowerCase();
393
+ if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
394
+ const [, r, g, b] = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(trimmed) ?? [];
395
+ return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
396
+ }
397
+ return fallback;
398
+ }
399
+ function normalizeBackgroundStyle(value) {
400
+ if (typeof value !== "string")
401
+ return "plain";
402
+ const lowered = value.trim().toLowerCase();
403
+ const match = ALLOWED_BG_STYLES.find((style) => style === lowered);
404
+ return match ?? "plain";
405
+ }
406
+ // Convert a #rrggbb hex to an rgba(...) string with the given alpha. Falls back
407
+ // to a solid dark backing if the hex is malformed so the caption still stays
408
+ // legible.
409
+ function hexToRgba(hex, alpha) {
410
+ const match = /^#([0-9a-f]{6})$/i.exec(String(hex ?? "").trim());
411
+ if (!match)
412
+ return `rgba(0,0,0,${alpha})`;
413
+ const value = match[1];
414
+ const r = parseInt(value.slice(0, 2), 16);
415
+ const g = parseInt(value.slice(2, 4), 16);
416
+ const b = parseInt(value.slice(4, 6), 16);
417
+ return `rgba(${r},${g},${b},${alpha})`;
418
+ }
419
+ // Builds the inline-span CSS for a caption based on its background_style so
420
+ // captions actually render with the visual treatment the vision LLM extracted
421
+ // ("outline" gets a real text stroke; "highlight-*" gets a colored pill).
422
+ // Without this the span just had `color:#fff` and everything looked washed
423
+ // out over the video.
424
+ function captionInlineTextStyle(caption) {
425
+ const parts = [`color:${caption.color}`];
426
+ const strokeColor = caption.background && /^#[0-9a-f]{6}$/i.test(caption.background)
427
+ ? caption.background
428
+ : "#000000";
429
+ // Outline width scales with font size so small captions still get a
430
+ // visible edge. Clamp so it never disappears (1px) and never eats the
431
+ // glyph interior (5px).
432
+ const strokePx = Math.max(1, Math.min(5, Math.round(caption.font_size * 0.06)));
433
+ switch (caption.background_style) {
434
+ case "outline":
435
+ parts.push(`-webkit-text-stroke:${strokePx}px ${strokeColor}`, `paint-order:stroke fill`,
436
+ // text-shadow fallback for renderers that ignore paint-order.
437
+ `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}`);
438
+ break;
439
+ case "highlight-solid":
440
+ parts.push(`background:${strokeColor}`, `padding:0.18em 0.5em`, `border-radius:0.35em`, `box-decoration-break:clone`, `-webkit-box-decoration-break:clone`);
441
+ break;
442
+ case "highlight-translucent":
443
+ 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`);
444
+ break;
445
+ case "plain":
446
+ default:
447
+ // Even "plain" captions need SOME shadow over busy TikTok footage or
448
+ // white-on-white becomes invisible. Use a subtle drop shadow that
449
+ // doesn't read as an intentional outline.
450
+ parts.push(`text-shadow:0 1px 2px rgba(0,0,0,0.55), 0 0 3px rgba(0,0,0,0.35)`);
451
+ break;
452
+ }
453
+ return parts.join(";");
454
+ }
455
+ function normalizeFontWeight(value) {
456
+ const parsed = Number(value);
457
+ if (!Number.isFinite(parsed))
458
+ return 600;
459
+ const clamped = Math.max(100, Math.min(900, Math.round(parsed / 100) * 100));
460
+ return clamped;
461
+ }
462
+ function normalizeFontSize(value, canvasHeight) {
463
+ const parsed = Number(value);
464
+ const heightRef = canvasHeight > 0 ? canvasHeight : 1280;
465
+ if (!Number.isFinite(parsed) || parsed <= 0)
466
+ return Math.round(heightRef * 0.028);
467
+ return Math.max(10, Math.min(160, Math.round(parsed)));
468
+ }
469
+ function resolveFontSize(input) {
470
+ const heightRef = input.canvasHeight > 0 ? input.canvasHeight : 1280;
471
+ const percent = Number(input.fontSizePercent);
472
+ if (Number.isFinite(percent) && percent > 0) {
473
+ const clampedPct = Math.max(0.8, Math.min(15, percent));
474
+ return Math.max(10, Math.min(160, Math.round((clampedPct / 100) * heightRef)));
475
+ }
476
+ return normalizeFontSize(input.fontSize, input.canvasHeight);
477
+ }
478
+ const DEFAULT_VIRAL_DNA = {
479
+ hook: "", retention: "", payoff: "", preserve: [], avoid: [], promotions: []
480
+ };
481
+ function normalizeViralDna(value) {
482
+ if (!value || typeof value !== "object")
483
+ return DEFAULT_VIRAL_DNA;
484
+ const record = value;
485
+ const asString = (v) => typeof v === "string" ? v.trim() : "";
486
+ const asStringArray = (v) => Array.isArray(v)
487
+ ? v.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0)
488
+ : [];
489
+ return {
490
+ hook: asString(record.hook),
491
+ retention: asString(record.retention),
492
+ payoff: asString(record.payoff),
493
+ preserve: asStringArray(record.preserve),
494
+ avoid: asStringArray(record.avoid),
495
+ promotions: asStringArray(record.promotions)
496
+ };
497
+ }
498
+ function shortElementId() {
499
+ return `element_${randomUUID().slice(0, 8)}`;
500
+ }
501
+ function extractJson(text) {
502
+ const trimmed = text.trim();
503
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
504
+ const candidate = fenced?.[1] ?? trimmed;
505
+ try {
506
+ return JSON.parse(candidate);
507
+ }
508
+ catch {
509
+ const objectMatch = /\{[\s\S]*\}/.exec(candidate);
510
+ if (objectMatch)
511
+ return JSON.parse(objectMatch[0]);
512
+ throw new Error("Gemini did not return valid JSON");
513
+ }
514
+ }
515
+ async function callGeminiVision(input) {
516
+ const parts = [{ text: input.prompt }];
517
+ for (const frame of input.frames) {
518
+ parts.push({ text: `Frame captured at ${frame.timestamp.toFixed(2)}s` });
519
+ parts.push({ inlineData: { mimeType: "image/jpeg", data: frame.data } });
520
+ }
521
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(input.model)}:generateContent?key=${encodeURIComponent(input.apiKey)}`, {
522
+ method: "POST",
523
+ headers: { "content-type": "application/json" },
524
+ body: JSON.stringify({
525
+ contents: [{ role: "user", parts }],
526
+ generationConfig: {
527
+ responseMimeType: "application/json",
528
+ temperature: 0.15
529
+ }
530
+ })
531
+ });
532
+ const raw = await response.text();
533
+ if (!response.ok) {
534
+ let message = raw;
535
+ try {
536
+ message = JSON.parse(raw).error?.message ?? raw;
537
+ }
538
+ catch { }
539
+ throw new Error(`Gemini responded ${response.status}: ${message}`);
540
+ }
541
+ const parsed = JSON.parse(raw);
542
+ const text = parsed.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
543
+ return extractJson(text);
544
+ }
545
+ async function callOpenAIVision(input) {
546
+ const content = [{ type: "text", text: input.prompt }];
547
+ for (const frame of input.frames) {
548
+ content.push({ type: "text", text: `Frame captured at ${frame.timestamp.toFixed(2)}s` });
549
+ content.push({
550
+ type: "image_url",
551
+ image_url: { url: `data:image/jpeg;base64,${frame.data}` }
552
+ });
553
+ }
554
+ const response = await fetch(input.endpoint ?? "https://api.openai.com/v1/chat/completions", {
555
+ method: "POST",
556
+ headers: {
557
+ "content-type": "application/json",
558
+ "authorization": `Bearer ${input.apiKey}`,
559
+ ...(input.extraHeaders ?? {})
560
+ },
561
+ body: JSON.stringify({
562
+ model: input.model,
563
+ temperature: 0.15,
564
+ response_format: { type: "json_object" },
565
+ messages: [{ role: "user", content }]
566
+ })
567
+ });
568
+ const raw = await response.text();
569
+ if (!response.ok) {
570
+ let message = raw;
571
+ try {
572
+ message = JSON.parse(raw).error?.message ?? raw;
573
+ }
574
+ catch { }
575
+ throw new Error(`${input.endpoint?.includes("openrouter") ? "OpenRouter" : "OpenAI"} responded ${response.status}: ${message}`);
576
+ }
577
+ const parsed = JSON.parse(raw);
578
+ const text = parsed.choices?.[0]?.message?.content ?? "";
579
+ return extractJson(text);
580
+ }
581
+ async function callVisionWithFallback(input) {
582
+ const attempts = [];
583
+ if (input.keys.gemini) {
584
+ const model = process.env.GEMINI_MODEL ?? "gemini-3.5-flash";
585
+ attempts.push({
586
+ provider: "gemini",
587
+ model,
588
+ run: () => callGeminiVision({ apiKey: input.keys.gemini, model, prompt: input.prompt, frames: input.frames })
589
+ });
590
+ }
591
+ if (input.keys.openai) {
592
+ const model = process.env.OPENAI_VISION_MODEL ?? "gpt-5.5-2026-04-23";
593
+ attempts.push({
594
+ provider: "openai",
595
+ model,
596
+ run: () => callOpenAIVision({ apiKey: input.keys.openai, model, prompt: input.prompt, frames: input.frames })
597
+ });
598
+ }
599
+ if (input.keys.openrouter) {
600
+ const model = process.env.OPENROUTER_VISION_MODEL ?? "google/gemini-3.5-flash";
601
+ attempts.push({
602
+ provider: "openrouter",
603
+ model,
604
+ run: () => callOpenAIVision({
605
+ apiKey: input.keys.openrouter,
606
+ model,
607
+ prompt: input.prompt,
608
+ frames: input.frames,
609
+ endpoint: "https://openrouter.ai/api/v1/chat/completions",
610
+ extraHeaders: {
611
+ "HTTP-Referer": "https://staging.vidfarm.cc",
612
+ "X-Title": "Vidfarm smart decompose"
613
+ }
614
+ })
615
+ });
616
+ }
617
+ if (attempts.length === 0) {
618
+ throw new Error("No vision provider key available (GEMINI_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY).");
619
+ }
620
+ const errors = [];
621
+ for (const attempt of attempts) {
622
+ try {
623
+ const result = await attempt.run();
624
+ return { result, provider: attempt.provider, model: attempt.model };
625
+ }
626
+ catch (error) {
627
+ const message = error instanceof Error ? error.message : String(error);
628
+ errors.push(`${attempt.provider}(${attempt.model}): ${message}`);
629
+ console.error(`smart decompose ${attempt.provider} failed`, message);
630
+ }
631
+ }
632
+ throw new Error(`All vision providers failed. ${errors.join(" | ")}`);
633
+ }
634
+ export async function smartDecomposeVideo(input) {
635
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "vidfarm-smart-decompose-"));
636
+ const sourcePath = path.join(tempDir, "source.mp4");
637
+ try {
638
+ await downloadVideoTo(input.sourceUrl, sourcePath);
639
+ const meta = await ffprobeVideo(sourcePath);
640
+ if (!Number.isFinite(meta.durationSeconds) || meta.durationSeconds <= 0) {
641
+ throw new Error("Could not read video duration");
642
+ }
643
+ const frames = await sampleFramesAsBase64(sourcePath, meta.durationSeconds, tempDir);
644
+ const prompt = buildSmartDecomposePrompt({
645
+ durationSeconds: meta.durationSeconds,
646
+ width: meta.width,
647
+ height: meta.height,
648
+ userPrompt: input.userPrompt ?? null
649
+ });
650
+ const attempt = await callVisionWithFallback({
651
+ prompt,
652
+ frames,
653
+ keys: input.keys
654
+ });
655
+ const raw = attempt.result;
656
+ const record = (raw && typeof raw === "object") ? raw : {};
657
+ const scenesRaw = Array.isArray(record.scenes) ? record.scenes : [];
658
+ const captionsRaw = Array.isArray(record.captions) ? record.captions : [];
659
+ const usedSceneSlugs = new Set();
660
+ const scenes = scenesRaw
661
+ .map((scene, index) => {
662
+ const slug = dedupeSlug(normalizeSlug(typeof scene.slug === "string" ? scene.slug : `scene_${index + 1}`) || `scene_${index + 1}`, usedSceneSlugs);
663
+ return {
664
+ slug,
665
+ start: Number(scene.start ?? 0),
666
+ duration: Number(scene.duration ?? 0),
667
+ label: typeof scene.label === "string" ? scene.label : `Scene ${index + 1}`,
668
+ viral_note: typeof scene.viral_note === "string" ? scene.viral_note : ""
669
+ };
670
+ })
671
+ .filter((s) => Number.isFinite(s.start) && Number.isFinite(s.duration) && s.duration > 0);
672
+ const usedCaptionSlugs = new Set();
673
+ const captions = captionsRaw
674
+ .map((c, index) => {
675
+ const slug = dedupeSlug(normalizeSlug(typeof c.slug === "string" ? c.slug : `caption_${index + 1}`) || `caption_${index + 1}`, usedCaptionSlugs);
676
+ return {
677
+ id: `caption_${index + 1}`,
678
+ slug,
679
+ text: typeof c.text === "string" ? c.text : "",
680
+ start: Number(c.start ?? 0),
681
+ duration: Number(c.duration ?? 0),
682
+ x: Number(c.x ?? 8),
683
+ y: Number(c.y ?? 68),
684
+ width: Number(c.width ?? 84),
685
+ height: Number(c.height ?? 12),
686
+ font_size: resolveFontSize({ fontSize: c.font_size, fontSizePercent: c.font_size_percent, canvasHeight: meta.height }),
687
+ color: normalizeHexColor(c.color, "#ffffff"),
688
+ background: normalizeHexColor(c.background, "#000000"),
689
+ background_style: normalizeBackgroundStyle(c.background_style),
690
+ font_family: normalizeFontFamily(c.font_family),
691
+ font_weight: normalizeFontWeight(c.font_weight),
692
+ viral_note: typeof c.viral_note === "string" ? c.viral_note : undefined
693
+ };
694
+ })
695
+ .filter((c) => c.text.trim().length > 0 && Number.isFinite(c.start) && Number.isFinite(c.duration) && c.duration > 0);
696
+ return {
697
+ summary: typeof record.summary === "string" ? record.summary : "",
698
+ viralDna: normalizeViralDna(record.viral_dna),
699
+ durationSeconds: meta.durationSeconds,
700
+ width: meta.width,
701
+ height: meta.height,
702
+ scenes,
703
+ captions,
704
+ provider: attempt.provider,
705
+ model: attempt.model
706
+ };
707
+ }
708
+ finally {
709
+ await rm(tempDir, { recursive: true, force: true });
710
+ }
711
+ }
712
+ export function buildSmartDecomposedHtml(input) {
713
+ const { compositionId, sourceUrl, title, result } = input;
714
+ const totalDuration = Number(result.durationSeconds.toFixed(3));
715
+ const scenes = result.scenes.length > 0
716
+ ? result.scenes
717
+ : [{ slug: "scene_1", start: 0, duration: totalDuration, label: "Scene 1", viral_note: "" }];
718
+ // Assign each caption to the lowest track index (starting at 2 — track 0
719
+ // is video, track 1 is audio) where it doesn't temporally overlap any
720
+ // caption already on that track. Enforces the "no two clips on the same
721
+ // track can overlap in time" rule. Multiple stacked labels (like a
722
+ // staircase of "single/talking/dating/marriage/have kids" all shown for
723
+ // the full duration) end up on tracks 2,3,4,5,6 respectively.
724
+ const CAPTION_TRACK_START = 2;
725
+ const captionTrackClipRanges = [];
726
+ const assignCaptionTrackIndex = (start, end) => {
727
+ for (let i = 0; i < captionTrackClipRanges.length; i += 1) {
728
+ if (!captionTrackClipRanges[i].some((r) => start < r.end && r.start < end)) {
729
+ captionTrackClipRanges[i].push({ start, end });
730
+ return CAPTION_TRACK_START + i;
731
+ }
732
+ }
733
+ captionTrackClipRanges.push([{ start, end }]);
734
+ return CAPTION_TRACK_START + captionTrackClipRanges.length - 1;
735
+ };
736
+ const sceneEls = scenes.map((scene, index) => {
737
+ const start = Math.max(0, Math.min(totalDuration - 0.1, Number(scene.start.toFixed(3))));
738
+ const duration = Math.max(0.1, Number(scene.duration.toFixed(3)));
739
+ const end = Math.min(totalDuration, start + duration);
740
+ const id = shortElementId();
741
+ const sceneSlug = scene.slug || `scene_${index + 1}`;
742
+ 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>`;
743
+ }).join("\n ");
744
+ // Sort captions by start time so the track-assignment greedy fills lower
745
+ // tracks first with earlier captions.
746
+ const sortedCaptions = [...result.captions].sort((a, b) => (a.start - b.start) || (a.duration - b.duration));
747
+ const captionEls = sortedCaptions.map((caption, index) => {
748
+ const start = Math.max(0, Math.min(totalDuration - 0.1, Number(caption.start.toFixed(3))));
749
+ const duration = Math.max(0.1, Math.min(totalDuration - start, Number(caption.duration.toFixed(3))));
750
+ const end = start + duration;
751
+ const trackIndex = assignCaptionTrackIndex(start, end);
752
+ const id = shortElementId();
753
+ const captionSlug = caption.slug || `caption_${index + 1}`;
754
+ const clampedX = clampPercent(caption.x, 0, 95);
755
+ const clampedY = clampPercent(caption.y, 0, 95);
756
+ const clampedW = clampPercent(caption.width, 5, 100 - clampedX);
757
+ const clampedH = clampPercent(caption.height, 3, 100 - clampedY);
758
+ const escapedText = escapeHtml(caption.text);
759
+ const innerStyle = captionInlineTextStyle(caption);
760
+ 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>`;
761
+ }).join("\n ");
762
+ const audioId = shortElementId();
763
+ 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>`;
764
+ const viralDnaJson = escapeAttr(JSON.stringify(result.viralDna ?? DEFAULT_VIRAL_DNA));
765
+ return `<!doctype html>
766
+ <html lang="en">
767
+ <head>
768
+ <meta charset="UTF-8" />
769
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
770
+ <style>
771
+ @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");
772
+ html, body { width:100%; height:100%; margin:0; overflow:hidden; background:#050604; }
773
+ [data-composition-id] { position:relative; width:100vw; height:100vh; overflow:hidden; background:#050604; }
774
+ audio { display:none; }
775
+ </style>
776
+ </head>
777
+ <body>
778
+ <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}">
779
+ <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>
780
+ ${sceneEls}
781
+ ${audioEl}
782
+ ${captionEls}
783
+ </div>
784
+ </body>
785
+ </html>`;
786
+ }
787
+ function normalizeSlug(value) {
788
+ return String(value ?? "")
789
+ .toLowerCase()
790
+ .replace(/[^a-z0-9]+/g, "_")
791
+ .replace(/^_+|_+$/g, "")
792
+ .slice(0, 64);
793
+ }
794
+ function dedupeSlug(base, used) {
795
+ const seed = base || "element";
796
+ if (!used.has(seed)) {
797
+ used.add(seed);
798
+ return seed;
799
+ }
800
+ for (let index = 2; index < 999; index += 1) {
801
+ const candidate = `${seed}_${index}`;
802
+ if (!used.has(candidate)) {
803
+ used.add(candidate);
804
+ return candidate;
805
+ }
806
+ }
807
+ const fallback = `${seed}_${Date.now().toString(36)}`;
808
+ used.add(fallback);
809
+ return fallback;
810
+ }
811
+ function escapeAttr(value) {
812
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
813
+ }
814
+ function escapeHtml(value) {
815
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
816
+ }
817
+ function clampPercent(value, min, max) {
818
+ if (!Number.isFinite(value))
819
+ return min;
820
+ return Math.max(min, Math.min(max, value));
821
+ }
822
+ export function stampCompositionIdentity(html, input) {
823
+ if (!input.forkId)
824
+ return html;
825
+ const composed = `${input.forkId}_v${input.version}`;
826
+ const { document } = parseHTML(html);
827
+ const root = document.querySelector("[data-composition-id]");
828
+ if (!root)
829
+ return html;
830
+ root.setAttribute("data-composition-id", composed);
831
+ root.setAttribute("data-fork-id", input.forkId);
832
+ root.setAttribute("data-fork-version", String(input.version));
833
+ return `<!doctype html>\n${document.documentElement.outerHTML}`;
834
+ }
835
+ export function normalizePublishHtml(html) {
836
+ const { document } = parseHTML(html);
837
+ for (const backingVideo of Array.from(document.querySelectorAll("[data-vf-playback-source]"))) {
838
+ backingVideo.remove();
839
+ }
840
+ for (const proxy of Array.from(document.querySelectorAll('[data-vf-timeline-proxy="video"]'))) {
841
+ const video = document.createElement("video");
842
+ const source = proxy.getAttribute("data-src") || proxy.getAttribute("src") || "";
843
+ for (const attr of Array.from(proxy.attributes)) {
844
+ if (attr.name === "data-vf-timeline-proxy" || attr.name === "data-src")
845
+ continue;
846
+ video.setAttribute(attr.name, attr.value);
847
+ }
848
+ video.setAttribute("src", source);
849
+ video.setAttribute("muted", "");
850
+ video.setAttribute("playsinline", "");
851
+ video.setAttribute("preload", "auto");
852
+ const trackIndex = Number.parseInt(proxy.getAttribute("data-track-index") ?? "0", 10) || 0;
853
+ video.setAttribute("style", [
854
+ "position:absolute",
855
+ "left:0%",
856
+ "top:0%",
857
+ "width:100%",
858
+ "height:100%",
859
+ `z-index:${trackIndex}`,
860
+ "object-fit:cover",
861
+ "background:#050604"
862
+ ].join(";"));
863
+ proxy.replaceWith(video);
864
+ }
865
+ // Enforce z-index = data-track-index for every timed layer so visual stacking
866
+ // strictly follows the timeline layer index. No arbitrary inline z-index survives.
867
+ for (const el of Array.from(document.querySelectorAll("[data-track-index]"))) {
868
+ const trackAttr = el.getAttribute("data-track-index");
869
+ if (!trackAttr)
870
+ continue;
871
+ const trackIndex = Number.parseInt(trackAttr, 10);
872
+ if (!Number.isFinite(trackIndex))
873
+ continue;
874
+ const html = el;
875
+ if (typeof html.style === "undefined")
876
+ continue;
877
+ html.style.zIndex = String(trackIndex);
878
+ }
879
+ // Editor sessions occasionally leave inline style attributes with double
880
+ // quotes inside CSS values (font-family: "TikTok Sans"). When serialized into
881
+ // an HTML attribute, those quotes become &quot; entities. The render lambda
882
+ // resolves font-family with a regex over raw HTML and misreads &quot; as a
883
+ // literal font name, exploding with FontFetchError. Normalize to single
884
+ // quotes so serialization stays entity-free.
885
+ for (const el of Array.from(document.querySelectorAll("[style]"))) {
886
+ const styleValue = el.getAttribute("style");
887
+ if (!styleValue || styleValue.indexOf('"') === -1)
888
+ continue;
889
+ el.setAttribute("style", styleValue.replace(/"/g, "'"));
890
+ }
891
+ return `<!doctype html>\n${document.documentElement.outerHTML}`;
892
+ }
893
+ async function resolveHyperframesStack() {
894
+ const region = config.HYPERFRAMES_RENDER_REGION || config.AWS_REGION || "us-east-1";
895
+ const lambdaMemoryMb = positiveInteger(config.HYPERFRAMES_RENDER_MEMORY_MB, 3072);
896
+ const envStack = {
897
+ stackName: config.HYPERFRAMES_LAMBDA_STACK_NAME || "hyperframes-vidfarm-prod",
898
+ bucketName: config.HYPERFRAMES_RENDER_BUCKET_NAME || config.HYPERFRAMES_LAMBDA_BUCKET_NAME || "",
899
+ stateMachineArn: config.HYPERFRAMES_RENDER_STATE_MACHINE_ARN || config.HYPERFRAMES_STATE_MACHINE_ARN || "",
900
+ region,
901
+ lambdaMemoryMb
902
+ };
903
+ if (envStack.bucketName && envStack.stateMachineArn) {
904
+ return envStack;
905
+ }
906
+ if (stackCache && stackCache.expiresAt > Date.now()) {
907
+ return stackCache.value;
908
+ }
909
+ const stackName = envStack.stackName;
910
+ const cloudFormation = new CloudFormationClient({ region });
911
+ const result = await cloudFormation.send(new DescribeStacksCommand({ StackName: stackName }));
912
+ const stack = result.Stacks?.[0];
913
+ if (!stack) {
914
+ throw new Error(`HyperFrames Lambda stack not found: ${stackName}`);
915
+ }
916
+ const outputs = stack.Outputs || [];
917
+ const value = {
918
+ stackName,
919
+ bucketName: stackOutput(outputs, "RenderBucketName"),
920
+ stateMachineArn: stackOutput(outputs, "RenderStateMachineArn"),
921
+ functionArn: stackOutput(outputs, "RenderFunctionArn"),
922
+ region: stackOutput(outputs, "RenderRegion") || region,
923
+ lambdaMemoryMb: positiveInteger(stackOutput(outputs, "RenderMemoryMb"), lambdaMemoryMb)
924
+ };
925
+ if (!value.bucketName || !value.stateMachineArn) {
926
+ throw new Error(`HyperFrames Lambda stack ${stackName} is missing render outputs.`);
927
+ }
928
+ stackCache = { value, expiresAt: Date.now() + 60_000 };
929
+ return value;
930
+ }
931
+ async function stageProject(input) {
932
+ const root = path.join(config.VIDFARM_DATA_DIR, "hyperframes-projects");
933
+ const projectDir = path.join(root, `${Date.now().toString(36)}-${safeIdentifier(input.compositionId)}-${randomUUID().slice(0, 8)}`);
934
+ await rm(projectDir, { recursive: true, force: true });
935
+ await mkdir(projectDir, { recursive: true });
936
+ await writeFile(path.join(projectDir, "index.html"), input.compositionHtml, "utf8");
937
+ for (const file of input.projectFiles || []) {
938
+ const target = safeProjectPath(projectDir, file.path);
939
+ await mkdir(path.dirname(target), { recursive: true });
940
+ await writeFile(target, file.contents);
941
+ }
942
+ for (const copyPath of input.copyPaths || []) {
943
+ const target = safeProjectPath(projectDir, copyPath.to);
944
+ await mkdir(path.dirname(target), { recursive: true });
945
+ await cp(copyPath.from, target, { recursive: true, force: true });
946
+ }
947
+ return projectDir;
948
+ }
949
+ function extractCompositionMetadata(html, input) {
950
+ const compositionId = safeIdentifier(input.compositionId || htmlAttribute(html, "data-composition-id") || input.composition?.compositionId || "composition");
951
+ const width = positiveInteger(input.width || htmlAttribute(html, "data-width") || input.composition?.width, 720);
952
+ const height = positiveInteger(input.height || htmlAttribute(html, "data-height") || input.composition?.height, 1280);
953
+ const fps = positiveInteger(input.fps || 30, 30);
954
+ const duration = Number.parseFloat(htmlAttribute(html, "data-duration") || String(input.composition?.duration || ""));
955
+ return {
956
+ compositionId,
957
+ title: input.title || htmlAttribute(html, "data-source-video") || compositionId,
958
+ width,
959
+ height,
960
+ fps,
961
+ duration: Number.isFinite(duration) && duration > 0 ? duration : null
962
+ };
963
+ }
964
+ function safeProjectPath(root, relativePath) {
965
+ const target = path.resolve(root, relativePath);
966
+ const resolvedRoot = path.resolve(root);
967
+ if (target !== resolvedRoot && !target.startsWith(`${resolvedRoot}${path.sep}`)) {
968
+ throw new Error(`Invalid HyperFrames project path: ${relativePath}`);
969
+ }
970
+ return target;
971
+ }
972
+ function htmlAttribute(html, name) {
973
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
974
+ const match = html.match(new RegExp(`${escaped}=(["'])(.*?)\\1`, "i"));
975
+ return match?.[2] || "";
976
+ }
977
+ function stackOutput(outputs, key) {
978
+ return outputs.find((output) => output.OutputKey === key)?.OutputValue
979
+ || outputs.find((output) => String(output.OutputKey || "").endsWith(key))?.OutputValue
980
+ || "";
981
+ }
982
+ function parseS3Uri(uri) {
983
+ const match = String(uri || "").match(/^s3:\/\/([^/]+)\/(.+)$/);
984
+ return match ? { bucket: match[1], key: match[2] } : null;
985
+ }
986
+ async function signedS3Url(s3Uri, region) {
987
+ const parsed = parseS3Uri(s3Uri);
988
+ if (!parsed)
989
+ return null;
990
+ const client = new S3Client({ region });
991
+ return getSignedUrl(client, new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key }), { expiresIn: 3600 });
992
+ }
993
+ function safeIdentifier(value) {
994
+ return String(value || "composition")
995
+ .toLowerCase()
996
+ .replace(/[^a-z0-9_-]+/g, "-")
997
+ .replace(/^-+|-+$/g, "")
998
+ .slice(0, 64) || "composition";
999
+ }
1000
+ function positiveInteger(value, fallback) {
1001
+ const next = Math.round(Number(value));
1002
+ return Number.isFinite(next) && next > 0 ? next : fallback;
1003
+ }
1004
+ function normalizeRenderFps(value) {
1005
+ if (value <= 24)
1006
+ return 24;
1007
+ if (value > 45)
1008
+ return 60;
1009
+ return 30;
1010
+ }
1011
+ function roundUsd(value) {
1012
+ return Number(value.toFixed(4));
1013
+ }
1014
+ //# sourceMappingURL=hyperframes.js.map