@adforge-adgeniq/render 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,489 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ assignStartTimes: () => assignStartTimes,
34
+ buildAssSubtitles: () => buildAssSubtitles,
35
+ estimateRenderCredits: () => estimateRenderCredits,
36
+ flattenCaptions: () => flattenCaptions,
37
+ renderVideo: () => renderVideo,
38
+ storyboardDurationS: () => storyboardDurationS
39
+ });
40
+ module.exports = __toCommonJS(index_exports);
41
+
42
+ // src/ffmpeg/renderer.ts
43
+ var import_child_process = require("child_process");
44
+ var import_util = require("util");
45
+ var import_promises = __toESM(require("fs/promises"));
46
+ var import_os = __toESM(require("os"));
47
+ var import_path = __toESM(require("path"));
48
+
49
+ // src/ffmpeg/dimensions.ts
50
+ var DIMS = {
51
+ "9:16": { "720p": { w: 720, h: 1280 }, "1080p": { w: 1080, h: 1920 } },
52
+ "16:9": { "720p": { w: 1280, h: 720 }, "1080p": { w: 1920, h: 1080 } },
53
+ "1:1": { "720p": { w: 720, h: 720 }, "1080p": { w: 1080, h: 1080 } }
54
+ };
55
+ var SAFE_AREA_PCT = { top: 0.08, bottom: 0.12, side: 0.05 };
56
+ function getDimensions(aspect, resolution) {
57
+ const { w, h } = DIMS[aspect][resolution];
58
+ return {
59
+ width: w,
60
+ height: h,
61
+ safeAreaTop: Math.round(h * SAFE_AREA_PCT.top),
62
+ safeAreaBottom: Math.round(h * SAFE_AREA_PCT.bottom),
63
+ safeAreaSide: Math.round(w * SAFE_AREA_PCT.side)
64
+ };
65
+ }
66
+ function fontScale(dims) {
67
+ return dims.width / 1080;
68
+ }
69
+
70
+ // src/ffmpeg/captions.ts
71
+ function toAssTime(s) {
72
+ const h = Math.floor(s / 3600);
73
+ const m = Math.floor(s % 3600 / 60);
74
+ const sec = (s % 60).toFixed(2);
75
+ return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(5, "0")}`;
76
+ }
77
+ function escapeAss(text) {
78
+ return text.replace(/\\/g, "\\\\").replace(/{/g, "\\{").replace(/}/g, "\\}");
79
+ }
80
+ function buildAssSubtitles(scenes, style, dims) {
81
+ if (style === "NONE") return "";
82
+ const scale = fontScale(dims);
83
+ const fontSize = Math.round(48 * scale);
84
+ const marginV = dims.safeAreaBottom + Math.round(40 * scale);
85
+ const marginH = dims.safeAreaSide + Math.round(20 * scale);
86
+ const header = `[Script Info]
87
+ ScriptType: v4.00+
88
+ PlayResX: ${dims.width}
89
+ PlayResY: ${dims.height}
90
+ ScaledBorderAndShadow: yes
91
+
92
+ [V4+ Styles]
93
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
94
+ Style: Default,Arial,${fontSize},&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,0,2,${marginH},${marginH},${marginV},1
95
+
96
+ [Events]
97
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`;
98
+ const events = [];
99
+ for (const scene of scenes) {
100
+ if (!scene.captions?.length) continue;
101
+ const offset = scene.start_s;
102
+ if (style === "KARAOKE") {
103
+ for (const cap of scene.captions) {
104
+ const text = escapeAss(cap.text);
105
+ events.push(
106
+ `Dialogue: 0,${toAssTime(offset + cap.start_s)},${toAssTime(offset + cap.end_s)},Default,,0,0,0,,{\\an8\\fad(80,80)}${text}`
107
+ );
108
+ }
109
+ } else {
110
+ const merged = scene.captions.map((c) => c.text).join(" ");
111
+ const startS = offset + (scene.captions[0]?.start_s ?? 0);
112
+ const endS = offset + (scene.captions[scene.captions.length - 1]?.end_s ?? scene.duration_s);
113
+ events.push(
114
+ `Dialogue: 0,${toAssTime(startS)},${toAssTime(endS)},Default,,0,0,0,,{\\an2\\fad(100,100)}${escapeAss(merged)}`
115
+ );
116
+ }
117
+ }
118
+ return [header, ...events].join("\n");
119
+ }
120
+ function flattenCaptions(scenes) {
121
+ const out = [];
122
+ for (const s of scenes) {
123
+ for (const c of s.captions) {
124
+ out.push({ text: c.text, startS: c.start_s, endS: c.end_s });
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ // src/ffmpeg/renderer.ts
131
+ var exec = (0, import_util.promisify)(import_child_process.execFile);
132
+ var FFMPEG = process.env.FFMPEG_PATH ?? "ffmpeg";
133
+ var FPS = 25;
134
+ async function runFfmpeg(args) {
135
+ try {
136
+ await exec(FFMPEG, ["-y", "-loglevel", "error", ...args], {
137
+ maxBuffer: 100 * 1024 * 1024
138
+ // 100 MB
139
+ });
140
+ } catch (err) {
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ throw new Error(`FFmpeg failed: ${message}`);
143
+ }
144
+ }
145
+ function ffDur(s) {
146
+ return String(s.toFixed(3));
147
+ }
148
+ async function renderAvatarClip(scene, videoPath, dims, outPath) {
149
+ const { width, height } = dims;
150
+ await runFfmpeg([
151
+ "-ss",
152
+ "0",
153
+ "-i",
154
+ videoPath,
155
+ "-t",
156
+ ffDur(scene.duration_s),
157
+ "-vf",
158
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1`,
159
+ "-r",
160
+ String(FPS),
161
+ "-c:v",
162
+ "libx264",
163
+ "-preset",
164
+ "fast",
165
+ "-crf",
166
+ "22",
167
+ "-c:a",
168
+ "aac",
169
+ "-b:a",
170
+ "128k",
171
+ "-pix_fmt",
172
+ "yuv420p",
173
+ outPath
174
+ ]);
175
+ }
176
+ async function renderBrollClip(scene, assetPath, dims, outPath, isImage) {
177
+ const { width, height } = dims;
178
+ const durationFrames = Math.ceil(scene.duration_s * FPS);
179
+ if (isImage) {
180
+ const motion = scene.motion ?? "kenburns_in";
181
+ const zoomStart = motion === "kenburns_out" ? 1.4 : 1;
182
+ const zoomEnd = motion === "kenburns_out" ? 1 : 1.4;
183
+ const zoomStep = (zoomEnd - zoomStart) / Math.max(durationFrames - 1, 1);
184
+ const zoompan = [
185
+ `zoompan=z='${zoomStart}+${zoomStep.toFixed(6)}*on'`,
186
+ `d=${durationFrames}`,
187
+ `x='iw/2-(iw/zoom/2)'`,
188
+ `y='ih/2-(ih/zoom/2)'`,
189
+ `s=${width}x${height}`,
190
+ `fps=${FPS}`
191
+ ].join(":");
192
+ await runFfmpeg([
193
+ "-loop",
194
+ "1",
195
+ "-i",
196
+ assetPath,
197
+ "-t",
198
+ ffDur(scene.duration_s),
199
+ "-vf",
200
+ `${zoompan},setsar=1`,
201
+ "-c:v",
202
+ "libx264",
203
+ "-preset",
204
+ "fast",
205
+ "-crf",
206
+ "22",
207
+ "-pix_fmt",
208
+ "yuv420p",
209
+ "-an",
210
+ outPath
211
+ ]);
212
+ } else {
213
+ await runFfmpeg([
214
+ "-ss",
215
+ "0",
216
+ "-i",
217
+ assetPath,
218
+ "-t",
219
+ ffDur(scene.duration_s),
220
+ "-vf",
221
+ `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},setsar=1`,
222
+ "-r",
223
+ String(FPS),
224
+ "-c:v",
225
+ "libx264",
226
+ "-preset",
227
+ "fast",
228
+ "-crf",
229
+ "22",
230
+ "-pix_fmt",
231
+ "yuv420p",
232
+ "-an",
233
+ outPath
234
+ ]);
235
+ }
236
+ }
237
+ async function renderTextCardClip(scene, dims, outPath, scale) {
238
+ const { width, height } = dims;
239
+ const text = scene.captions.map((c) => c.text).join(" ").replace(/'/g, "\\'").replace(/:/g, "\\:");
240
+ const fontSize = Math.round(52 * scale);
241
+ await runFfmpeg([
242
+ "-f",
243
+ "lavfi",
244
+ "-i",
245
+ `color=c=0x111118:s=${width}x${height}:r=${FPS}:d=${ffDur(scene.duration_s)}`,
246
+ "-vf",
247
+ [
248
+ `drawtext=text='${text}'`,
249
+ `fontsize=${fontSize}`,
250
+ `fontcolor=white`,
251
+ `x=(w-text_w)/2`,
252
+ `y=(h-text_h)/2`,
253
+ `box=1:boxcolor=black@0.4:boxborderw=${Math.round(16 * scale)}`
254
+ ].join(":"),
255
+ "-c:v",
256
+ "libx264",
257
+ "-preset",
258
+ "fast",
259
+ "-crf",
260
+ "22",
261
+ "-pix_fmt",
262
+ "yuv420p",
263
+ "-an",
264
+ outPath
265
+ ]);
266
+ }
267
+ async function concatClips(clipPaths, outPath) {
268
+ const listPath = outPath.replace(/\.\w+$/, "-list.txt");
269
+ const listContent = clipPaths.map((p) => `file '${p}'`).join("\n");
270
+ await import_promises.default.writeFile(listPath, listContent, "utf8");
271
+ await runFfmpeg([
272
+ "-f",
273
+ "concat",
274
+ "-safe",
275
+ "0",
276
+ "-i",
277
+ listPath,
278
+ "-c:v",
279
+ "libx264",
280
+ "-preset",
281
+ "fast",
282
+ "-crf",
283
+ "22",
284
+ "-c:a",
285
+ "aac",
286
+ "-b:a",
287
+ "128k",
288
+ "-pix_fmt",
289
+ "yuv420p",
290
+ outPath
291
+ ]);
292
+ await import_promises.default.unlink(listPath).catch(() => {
293
+ });
294
+ }
295
+ async function burnSubtitles(inputPath, assPath, outputPath) {
296
+ await runFfmpeg([
297
+ "-i",
298
+ inputPath,
299
+ "-vf",
300
+ `ass='${assPath.replace(/'/g, "\\'")}'`,
301
+ "-c:v",
302
+ "libx264",
303
+ "-preset",
304
+ "fast",
305
+ "-crf",
306
+ "22",
307
+ "-c:a",
308
+ "copy",
309
+ "-pix_fmt",
310
+ "yuv420p",
311
+ outputPath
312
+ ]);
313
+ }
314
+ async function mixMusic(videoPath, musicPath, volumeDb, outputPath) {
315
+ const vol = Math.pow(10, volumeDb / 20);
316
+ await runFfmpeg([
317
+ "-i",
318
+ videoPath,
319
+ "-stream_loop",
320
+ "-1",
321
+ "-i",
322
+ musicPath,
323
+ "-filter_complex",
324
+ `[1:a]aformat=sample_fmts=fltp:sample_rates=44100,volume=${vol.toFixed(4)},atrim=0[mus];[0:a][mus]amix=inputs=2:duration=shortest[aout]`,
325
+ "-map",
326
+ "0:v",
327
+ "-map",
328
+ "[aout]",
329
+ "-c:v",
330
+ "copy",
331
+ "-c:a",
332
+ "aac",
333
+ "-b:a",
334
+ "128k",
335
+ "-shortest",
336
+ outputPath
337
+ ]);
338
+ }
339
+ async function applyWatermark(inputPath, outputPath, dims, scale) {
340
+ const fontSize = Math.round(28 * scale);
341
+ await runFfmpeg([
342
+ "-i",
343
+ inputPath,
344
+ "-vf",
345
+ [
346
+ `drawtext=text='Made with AdForge'`,
347
+ `fontsize=${fontSize}`,
348
+ `fontcolor=white@0.65`,
349
+ `x=(w-text_w)/2`,
350
+ `y=${Math.round(dims.height * 0.04)}`,
351
+ `box=1:boxcolor=black@0.3:boxborderw=${Math.round(8 * scale)}`
352
+ ].join(":"),
353
+ "-c:v",
354
+ "libx264",
355
+ "-preset",
356
+ "fast",
357
+ "-crf",
358
+ "22",
359
+ "-c:a",
360
+ "copy",
361
+ "-pix_fmt",
362
+ "yuv420p",
363
+ outputPath
364
+ ]);
365
+ }
366
+ var IMAGE_EXTS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp", ".gif"]);
367
+ function isImagePath(p) {
368
+ return IMAGE_EXTS.has(import_path.default.extname(p).toLowerCase());
369
+ }
370
+ async function resolveSceneAsset(scene, assetPaths, avatarRenderPaths) {
371
+ if (scene.type === "avatar" && scene.avatar_render_id) {
372
+ return avatarRenderPaths[scene.avatar_render_id] ?? null;
373
+ }
374
+ if (scene.asset_id) {
375
+ return assetPaths[scene.asset_id] ?? null;
376
+ }
377
+ return null;
378
+ }
379
+ async function renderVideo(job) {
380
+ const { storyboard, aspect, resolution, captionStyle, watermark, outputPath } = job;
381
+ const dims = getDimensions(aspect, resolution);
382
+ const scale = fontScale(dims);
383
+ const tmpDir = await import_promises.default.mkdtemp(import_path.default.join(import_os.default.tmpdir(), "adforge-render-"));
384
+ const clipPaths = [];
385
+ try {
386
+ for (let i = 0; i < storyboard.scenes.length; i++) {
387
+ const scene = storyboard.scenes[i];
388
+ const clipOut = import_path.default.join(tmpDir, `scene-${i}.mp4`);
389
+ if (scene.type === "text_card") {
390
+ await renderTextCardClip(scene, dims, clipOut, scale);
391
+ } else {
392
+ const assetPath = await resolveSceneAsset(scene, job.assetPaths, job.avatarRenderPaths);
393
+ if (!assetPath) {
394
+ const fallback = {
395
+ ...scene,
396
+ type: "text_card",
397
+ captions: [{ text: scene.type.replace(/_/g, " ").toUpperCase(), start_s: scene.start_s, end_s: scene.start_s + scene.duration_s }]
398
+ };
399
+ await renderTextCardClip(fallback, dims, clipOut, scale);
400
+ } else if (scene.type === "avatar") {
401
+ await renderAvatarClip(scene, assetPath, dims, clipOut);
402
+ } else {
403
+ await renderBrollClip(scene, assetPath, dims, clipOut, isImagePath(assetPath));
404
+ }
405
+ }
406
+ clipPaths.push(clipOut);
407
+ }
408
+ const concatOut = import_path.default.join(tmpDir, "concat.mp4");
409
+ if (clipPaths.length === 1) {
410
+ await import_promises.default.copyFile(clipPaths[0], concatOut);
411
+ } else {
412
+ await concatClips(clipPaths, concatOut);
413
+ }
414
+ let currentPath = concatOut;
415
+ if (captionStyle !== "NONE") {
416
+ const assContent = buildAssSubtitles(storyboard.scenes, captionStyle, dims);
417
+ if (assContent) {
418
+ const assPath = import_path.default.join(tmpDir, "captions.ass");
419
+ await import_promises.default.writeFile(assPath, assContent, "utf8");
420
+ const subOut = import_path.default.join(tmpDir, "subtitled.mp4");
421
+ await burnSubtitles(currentPath, assPath, subOut);
422
+ currentPath = subOut;
423
+ }
424
+ }
425
+ const skippedAssets = [];
426
+ if (storyboard.music?.asset_id) {
427
+ const musicPath = job.assetPaths[storyboard.music.asset_id];
428
+ if (musicPath) {
429
+ const musicOut = import_path.default.join(tmpDir, "music.mp4");
430
+ await mixMusic(currentPath, musicPath, storyboard.music.volume_db, musicOut);
431
+ currentPath = musicOut;
432
+ } else {
433
+ console.warn(
434
+ `[renderer] Music asset "${storyboard.music.asset_id}" is set on the storyboard but its file path is missing from job.assetPaths \u2014 music step skipped.`
435
+ );
436
+ skippedAssets.push(storyboard.music.asset_id);
437
+ }
438
+ }
439
+ if (watermark) {
440
+ const wmOut = import_path.default.join(tmpDir, "watermarked.mp4");
441
+ await applyWatermark(currentPath, wmOut, dims, scale);
442
+ currentPath = wmOut;
443
+ }
444
+ await import_promises.default.mkdir(import_path.default.dirname(outputPath), { recursive: true });
445
+ await import_promises.default.copyFile(currentPath, outputPath);
446
+ const totalDurationMs = storyboard.scenes.reduce((sum, s) => sum + s.duration_s * 1e3, 0);
447
+ const totalS = totalDurationMs / 1e3;
448
+ const costUsd = Math.ceil(totalS / 30) * 5 * 2e-3;
449
+ return {
450
+ r2Key: outputPath,
451
+ url: outputPath,
452
+ durationMs: Math.round(totalDurationMs),
453
+ costUsd,
454
+ ...skippedAssets.length > 0 ? { skippedAssets } : {}
455
+ };
456
+ } finally {
457
+ for (const p of clipPaths) {
458
+ await import_promises.default.unlink(p).catch(() => {
459
+ });
460
+ }
461
+ }
462
+ }
463
+
464
+ // src/storyboard/utils.ts
465
+ function storyboardDurationS(sb) {
466
+ return sb.scenes.reduce((sum, s) => sum + s.duration_s, 0);
467
+ }
468
+ function estimateRenderCredits(sb, aspects) {
469
+ const durationS = storyboardDurationS(sb);
470
+ return Math.ceil(durationS / 30) * 5 * aspects;
471
+ }
472
+ function assignStartTimes(sb) {
473
+ let cursor = 0;
474
+ const scenes = sb.scenes.map((s) => {
475
+ const scene = { ...s, start_s: cursor };
476
+ cursor += s.duration_s;
477
+ return scene;
478
+ });
479
+ return { ...sb, scenes };
480
+ }
481
+ // Annotate the CommonJS export names for ESM import in node:
482
+ 0 && (module.exports = {
483
+ assignStartTimes,
484
+ buildAssSubtitles,
485
+ estimateRenderCredits,
486
+ flattenCaptions,
487
+ renderVideo,
488
+ storyboardDurationS
489
+ });