@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.mjs ADDED
@@ -0,0 +1,360 @@
1
+ import {
2
+ buildAssSubtitles,
3
+ flattenCaptions
4
+ } from "./chunk-NQY73BAM.mjs";
5
+ import {
6
+ fontScale,
7
+ getDimensions
8
+ } from "./chunk-PNJ7IKUC.mjs";
9
+ import {
10
+ assignStartTimes,
11
+ estimateRenderCredits,
12
+ storyboardDurationS
13
+ } from "./chunk-IU6GCMDG.mjs";
14
+
15
+ // src/ffmpeg/renderer.ts
16
+ import { execFile } from "child_process";
17
+ import { promisify } from "util";
18
+ import fs from "fs/promises";
19
+ import os from "os";
20
+ import path from "path";
21
+ var exec = promisify(execFile);
22
+ var FFMPEG = process.env.FFMPEG_PATH ?? "ffmpeg";
23
+ var FPS = 25;
24
+ async function runFfmpeg(args) {
25
+ try {
26
+ await exec(FFMPEG, ["-y", "-loglevel", "error", ...args], {
27
+ maxBuffer: 100 * 1024 * 1024
28
+ // 100 MB
29
+ });
30
+ } catch (err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ throw new Error(`FFmpeg failed: ${message}`);
33
+ }
34
+ }
35
+ function ffDur(s) {
36
+ return String(s.toFixed(3));
37
+ }
38
+ async function renderAvatarClip(scene, videoPath, dims, outPath) {
39
+ const { width, height } = dims;
40
+ await runFfmpeg([
41
+ "-ss",
42
+ "0",
43
+ "-i",
44
+ videoPath,
45
+ "-t",
46
+ ffDur(scene.duration_s),
47
+ "-vf",
48
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1`,
49
+ "-r",
50
+ String(FPS),
51
+ "-c:v",
52
+ "libx264",
53
+ "-preset",
54
+ "fast",
55
+ "-crf",
56
+ "22",
57
+ "-c:a",
58
+ "aac",
59
+ "-b:a",
60
+ "128k",
61
+ "-pix_fmt",
62
+ "yuv420p",
63
+ outPath
64
+ ]);
65
+ }
66
+ async function renderBrollClip(scene, assetPath, dims, outPath, isImage) {
67
+ const { width, height } = dims;
68
+ const durationFrames = Math.ceil(scene.duration_s * FPS);
69
+ if (isImage) {
70
+ const motion = scene.motion ?? "kenburns_in";
71
+ const zoomStart = motion === "kenburns_out" ? 1.4 : 1;
72
+ const zoomEnd = motion === "kenburns_out" ? 1 : 1.4;
73
+ const zoomStep = (zoomEnd - zoomStart) / Math.max(durationFrames - 1, 1);
74
+ const zoompan = [
75
+ `zoompan=z='${zoomStart}+${zoomStep.toFixed(6)}*on'`,
76
+ `d=${durationFrames}`,
77
+ `x='iw/2-(iw/zoom/2)'`,
78
+ `y='ih/2-(ih/zoom/2)'`,
79
+ `s=${width}x${height}`,
80
+ `fps=${FPS}`
81
+ ].join(":");
82
+ await runFfmpeg([
83
+ "-loop",
84
+ "1",
85
+ "-i",
86
+ assetPath,
87
+ "-t",
88
+ ffDur(scene.duration_s),
89
+ "-vf",
90
+ `${zoompan},setsar=1`,
91
+ "-c:v",
92
+ "libx264",
93
+ "-preset",
94
+ "fast",
95
+ "-crf",
96
+ "22",
97
+ "-pix_fmt",
98
+ "yuv420p",
99
+ "-an",
100
+ outPath
101
+ ]);
102
+ } else {
103
+ await runFfmpeg([
104
+ "-ss",
105
+ "0",
106
+ "-i",
107
+ assetPath,
108
+ "-t",
109
+ ffDur(scene.duration_s),
110
+ "-vf",
111
+ `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},setsar=1`,
112
+ "-r",
113
+ String(FPS),
114
+ "-c:v",
115
+ "libx264",
116
+ "-preset",
117
+ "fast",
118
+ "-crf",
119
+ "22",
120
+ "-pix_fmt",
121
+ "yuv420p",
122
+ "-an",
123
+ outPath
124
+ ]);
125
+ }
126
+ }
127
+ async function renderTextCardClip(scene, dims, outPath, scale) {
128
+ const { width, height } = dims;
129
+ const text = scene.captions.map((c) => c.text).join(" ").replace(/'/g, "\\'").replace(/:/g, "\\:");
130
+ const fontSize = Math.round(52 * scale);
131
+ await runFfmpeg([
132
+ "-f",
133
+ "lavfi",
134
+ "-i",
135
+ `color=c=0x111118:s=${width}x${height}:r=${FPS}:d=${ffDur(scene.duration_s)}`,
136
+ "-vf",
137
+ [
138
+ `drawtext=text='${text}'`,
139
+ `fontsize=${fontSize}`,
140
+ `fontcolor=white`,
141
+ `x=(w-text_w)/2`,
142
+ `y=(h-text_h)/2`,
143
+ `box=1:boxcolor=black@0.4:boxborderw=${Math.round(16 * scale)}`
144
+ ].join(":"),
145
+ "-c:v",
146
+ "libx264",
147
+ "-preset",
148
+ "fast",
149
+ "-crf",
150
+ "22",
151
+ "-pix_fmt",
152
+ "yuv420p",
153
+ "-an",
154
+ outPath
155
+ ]);
156
+ }
157
+ async function concatClips(clipPaths, outPath) {
158
+ const listPath = outPath.replace(/\.\w+$/, "-list.txt");
159
+ const listContent = clipPaths.map((p) => `file '${p}'`).join("\n");
160
+ await fs.writeFile(listPath, listContent, "utf8");
161
+ await runFfmpeg([
162
+ "-f",
163
+ "concat",
164
+ "-safe",
165
+ "0",
166
+ "-i",
167
+ listPath,
168
+ "-c:v",
169
+ "libx264",
170
+ "-preset",
171
+ "fast",
172
+ "-crf",
173
+ "22",
174
+ "-c:a",
175
+ "aac",
176
+ "-b:a",
177
+ "128k",
178
+ "-pix_fmt",
179
+ "yuv420p",
180
+ outPath
181
+ ]);
182
+ await fs.unlink(listPath).catch(() => {
183
+ });
184
+ }
185
+ async function burnSubtitles(inputPath, assPath, outputPath) {
186
+ await runFfmpeg([
187
+ "-i",
188
+ inputPath,
189
+ "-vf",
190
+ `ass='${assPath.replace(/'/g, "\\'")}'`,
191
+ "-c:v",
192
+ "libx264",
193
+ "-preset",
194
+ "fast",
195
+ "-crf",
196
+ "22",
197
+ "-c:a",
198
+ "copy",
199
+ "-pix_fmt",
200
+ "yuv420p",
201
+ outputPath
202
+ ]);
203
+ }
204
+ async function mixMusic(videoPath, musicPath, volumeDb, outputPath) {
205
+ const vol = Math.pow(10, volumeDb / 20);
206
+ await runFfmpeg([
207
+ "-i",
208
+ videoPath,
209
+ "-stream_loop",
210
+ "-1",
211
+ "-i",
212
+ musicPath,
213
+ "-filter_complex",
214
+ `[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]`,
215
+ "-map",
216
+ "0:v",
217
+ "-map",
218
+ "[aout]",
219
+ "-c:v",
220
+ "copy",
221
+ "-c:a",
222
+ "aac",
223
+ "-b:a",
224
+ "128k",
225
+ "-shortest",
226
+ outputPath
227
+ ]);
228
+ }
229
+ async function applyWatermark(inputPath, outputPath, dims, scale) {
230
+ const fontSize = Math.round(28 * scale);
231
+ await runFfmpeg([
232
+ "-i",
233
+ inputPath,
234
+ "-vf",
235
+ [
236
+ `drawtext=text='Made with AdForge'`,
237
+ `fontsize=${fontSize}`,
238
+ `fontcolor=white@0.65`,
239
+ `x=(w-text_w)/2`,
240
+ `y=${Math.round(dims.height * 0.04)}`,
241
+ `box=1:boxcolor=black@0.3:boxborderw=${Math.round(8 * scale)}`
242
+ ].join(":"),
243
+ "-c:v",
244
+ "libx264",
245
+ "-preset",
246
+ "fast",
247
+ "-crf",
248
+ "22",
249
+ "-c:a",
250
+ "copy",
251
+ "-pix_fmt",
252
+ "yuv420p",
253
+ outputPath
254
+ ]);
255
+ }
256
+ var IMAGE_EXTS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp", ".gif"]);
257
+ function isImagePath(p) {
258
+ return IMAGE_EXTS.has(path.extname(p).toLowerCase());
259
+ }
260
+ async function resolveSceneAsset(scene, assetPaths, avatarRenderPaths) {
261
+ if (scene.type === "avatar" && scene.avatar_render_id) {
262
+ return avatarRenderPaths[scene.avatar_render_id] ?? null;
263
+ }
264
+ if (scene.asset_id) {
265
+ return assetPaths[scene.asset_id] ?? null;
266
+ }
267
+ return null;
268
+ }
269
+ async function renderVideo(job) {
270
+ const { storyboard, aspect, resolution, captionStyle, watermark, outputPath } = job;
271
+ const dims = getDimensions(aspect, resolution);
272
+ const scale = fontScale(dims);
273
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "adforge-render-"));
274
+ const clipPaths = [];
275
+ try {
276
+ for (let i = 0; i < storyboard.scenes.length; i++) {
277
+ const scene = storyboard.scenes[i];
278
+ const clipOut = path.join(tmpDir, `scene-${i}.mp4`);
279
+ if (scene.type === "text_card") {
280
+ await renderTextCardClip(scene, dims, clipOut, scale);
281
+ } else {
282
+ const assetPath = await resolveSceneAsset(scene, job.assetPaths, job.avatarRenderPaths);
283
+ if (!assetPath) {
284
+ const fallback = {
285
+ ...scene,
286
+ type: "text_card",
287
+ captions: [{ text: scene.type.replace(/_/g, " ").toUpperCase(), start_s: scene.start_s, end_s: scene.start_s + scene.duration_s }]
288
+ };
289
+ await renderTextCardClip(fallback, dims, clipOut, scale);
290
+ } else if (scene.type === "avatar") {
291
+ await renderAvatarClip(scene, assetPath, dims, clipOut);
292
+ } else {
293
+ await renderBrollClip(scene, assetPath, dims, clipOut, isImagePath(assetPath));
294
+ }
295
+ }
296
+ clipPaths.push(clipOut);
297
+ }
298
+ const concatOut = path.join(tmpDir, "concat.mp4");
299
+ if (clipPaths.length === 1) {
300
+ await fs.copyFile(clipPaths[0], concatOut);
301
+ } else {
302
+ await concatClips(clipPaths, concatOut);
303
+ }
304
+ let currentPath = concatOut;
305
+ if (captionStyle !== "NONE") {
306
+ const assContent = buildAssSubtitles(storyboard.scenes, captionStyle, dims);
307
+ if (assContent) {
308
+ const assPath = path.join(tmpDir, "captions.ass");
309
+ await fs.writeFile(assPath, assContent, "utf8");
310
+ const subOut = path.join(tmpDir, "subtitled.mp4");
311
+ await burnSubtitles(currentPath, assPath, subOut);
312
+ currentPath = subOut;
313
+ }
314
+ }
315
+ const skippedAssets = [];
316
+ if (storyboard.music?.asset_id) {
317
+ const musicPath = job.assetPaths[storyboard.music.asset_id];
318
+ if (musicPath) {
319
+ const musicOut = path.join(tmpDir, "music.mp4");
320
+ await mixMusic(currentPath, musicPath, storyboard.music.volume_db, musicOut);
321
+ currentPath = musicOut;
322
+ } else {
323
+ console.warn(
324
+ `[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.`
325
+ );
326
+ skippedAssets.push(storyboard.music.asset_id);
327
+ }
328
+ }
329
+ if (watermark) {
330
+ const wmOut = path.join(tmpDir, "watermarked.mp4");
331
+ await applyWatermark(currentPath, wmOut, dims, scale);
332
+ currentPath = wmOut;
333
+ }
334
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
335
+ await fs.copyFile(currentPath, outputPath);
336
+ const totalDurationMs = storyboard.scenes.reduce((sum, s) => sum + s.duration_s * 1e3, 0);
337
+ const totalS = totalDurationMs / 1e3;
338
+ const costUsd = Math.ceil(totalS / 30) * 5 * 2e-3;
339
+ return {
340
+ r2Key: outputPath,
341
+ url: outputPath,
342
+ durationMs: Math.round(totalDurationMs),
343
+ costUsd,
344
+ ...skippedAssets.length > 0 ? { skippedAssets } : {}
345
+ };
346
+ } finally {
347
+ for (const p of clipPaths) {
348
+ await fs.unlink(p).catch(() => {
349
+ });
350
+ }
351
+ }
352
+ }
353
+ export {
354
+ assignStartTimes,
355
+ buildAssSubtitles,
356
+ estimateRenderCredits,
357
+ flattenCaptions,
358
+ renderVideo,
359
+ storyboardDurationS
360
+ };
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { StoryboardJson } from '@adforge-adgeniq/shared';
3
+ import { Aspect, CaptionStyle } from '../types.mjs';
4
+
5
+ declare const COMPOSITION_ID = "AdForge";
6
+ interface AdForgeCompositionProps {
7
+ storyboard: StoryboardJson;
8
+ aspect: Aspect;
9
+ captionStyle: CaptionStyle;
10
+ watermark?: boolean;
11
+ /** Asset URLs keyed by asset_id. Defaults to {} so a missing/deleted asset
12
+ * renders a placeholder rather than crashing the whole player. */
13
+ assetUrls?: Record<string, string>;
14
+ /** Avatar render URLs keyed by avatar_render_id. Same fallback behaviour. */
15
+ avatarUrls?: Record<string, string>;
16
+ }
17
+ declare function AdForgeComposition({ storyboard, aspect, captionStyle, watermark, assetUrls, avatarUrls, }: AdForgeCompositionProps): React.JSX.Element;
18
+
19
+ /**
20
+ * Per-aspect layout config for the Remotion composition.
21
+ * Drives avatar position, caption margins, safe-area insets.
22
+ */
23
+
24
+ interface LayoutConfig {
25
+ width: number;
26
+ height: number;
27
+ /** PiP avatar position (% from top-left) */
28
+ pipAvatarLeft: number;
29
+ pipAvatarTop: number;
30
+ pipAvatarWidth: number;
31
+ /** Caption block: bottom margin (px at 1080p scale) */
32
+ captionMarginBottom: number;
33
+ /** Caption block: side margin */
34
+ captionMarginSide: number;
35
+ /** Caption font size base (px) */
36
+ captionFontSize: number;
37
+ }
38
+ declare function getLayout(aspect: Aspect): LayoutConfig;
39
+
40
+ export { AdForgeComposition, type AdForgeCompositionProps, COMPOSITION_ID, getLayout };
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { StoryboardJson } from '@adforge-adgeniq/shared';
3
+ import { Aspect, CaptionStyle } from '../types.js';
4
+
5
+ declare const COMPOSITION_ID = "AdForge";
6
+ interface AdForgeCompositionProps {
7
+ storyboard: StoryboardJson;
8
+ aspect: Aspect;
9
+ captionStyle: CaptionStyle;
10
+ watermark?: boolean;
11
+ /** Asset URLs keyed by asset_id. Defaults to {} so a missing/deleted asset
12
+ * renders a placeholder rather than crashing the whole player. */
13
+ assetUrls?: Record<string, string>;
14
+ /** Avatar render URLs keyed by avatar_render_id. Same fallback behaviour. */
15
+ avatarUrls?: Record<string, string>;
16
+ }
17
+ declare function AdForgeComposition({ storyboard, aspect, captionStyle, watermark, assetUrls, avatarUrls, }: AdForgeCompositionProps): React.JSX.Element;
18
+
19
+ /**
20
+ * Per-aspect layout config for the Remotion composition.
21
+ * Drives avatar position, caption margins, safe-area insets.
22
+ */
23
+
24
+ interface LayoutConfig {
25
+ width: number;
26
+ height: number;
27
+ /** PiP avatar position (% from top-left) */
28
+ pipAvatarLeft: number;
29
+ pipAvatarTop: number;
30
+ pipAvatarWidth: number;
31
+ /** Caption block: bottom margin (px at 1080p scale) */
32
+ captionMarginBottom: number;
33
+ /** Caption block: side margin */
34
+ captionMarginSide: number;
35
+ /** Caption font size base (px) */
36
+ captionFontSize: number;
37
+ }
38
+ declare function getLayout(aspect: Aspect): LayoutConfig;
39
+
40
+ export { AdForgeComposition, type AdForgeCompositionProps, COMPOSITION_ID, getLayout };