codeplay-common 4.5.1 → 4.5.2

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.
@@ -1,12 +1,22 @@
1
1
  // Generate an English app promo video from the original Android capture.
2
2
  // Usage: node generate-ai-promo-video.js
3
+ // Optional: node generate-ai-promo-video.js --layout dynamic
3
4
  // Optional: node generate-ai-promo-video.js --input path/to/source.mp4 --output path/to/output.mp4
4
5
 
6
+ // node generate-ai-promo-video.js --layout dynamic
7
+ // node generate-ai-promo-video.js --layout split
8
+ // node generate-ai-promo-video.js --layout centered
9
+ // node generate-ai-promo-video.js --layout floating
10
+ // node generate-ai-promo-video.js --layout cinematic
11
+ // node generate-ai-promo-video.js --layout mixed
12
+ // node generate-ai-promo-video.js --layout random
13
+
14
+
5
15
  const fs = require('node:fs');
6
16
  const path = require('node:path');
7
- const { createHash } = require('node:crypto');
8
- const { spawnSync } = require('node:child_process');
9
- const { createInterface } = require('node:readline/promises');
17
+ const { createHash } = require('node:crypto');
18
+ const { spawnSync } = require('node:child_process');
19
+ const { createInterface } = require('node:readline/promises');
10
20
 
11
21
  const projectDirectory = __dirname;
12
22
  const capacitorConfigPath = path.join(projectDirectory, 'capacitor.config.json');
@@ -32,9 +42,9 @@ const appFilePrefix = `${appUniqueId}. ${sanitizeFileNamePart(appName)}`;
32
42
  const playStoreDownloadUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageId)}`;
33
43
  const configuredAppStoreUrl = String(iosStoreConfig.appStoreUrl || '')
34
44
  .match(/https:\/\/apps\.apple\.com\/app\/id\d+/)?.[0] || '';
35
- let appStoreDownloadUrl = /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(configuredAppStoreUrl)
36
- ? configuredAppStoreUrl
37
- : '';
45
+ let appStoreDownloadUrl = /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(configuredAppStoreUrl)
46
+ ? configuredAppStoreUrl
47
+ : '';
38
48
  const promoDesignIndex = createHash('sha1').update(`${appUniqueId}:${appName}`).digest()[0] % 4;
39
49
  const promoDesigns = [
40
50
  { accent: '0x23d5c3', background: '0x07101f', panelX: 105, panelWidth: 500, phoneX: 134 },
@@ -43,6 +53,16 @@ const promoDesigns = [
43
53
  { accent: '0xff5b8d', background: '0x21101a', panelX: 1240, panelWidth: 550, phoneX: 64 },
44
54
  ];
45
55
  const promoDesign = promoDesigns[promoDesignIndex];
56
+ const promoLayoutNames = [
57
+ 'dynamic',
58
+ 'split',
59
+ 'centered',
60
+ 'floating',
61
+ 'cinematic',
62
+ 'mixed',
63
+ 'random',
64
+ ];
65
+ const selectablePromoLayoutNames = promoLayoutNames.filter((layoutName) => layoutName !== 'random');
46
66
  let endCardStart = 0;
47
67
  const endCardDuration = 8;
48
68
  const sourceVideoDirectory = path.join(projectDirectory, 'Auto-Screenshot', 'Video', 'Output');
@@ -52,7 +72,7 @@ const spatialXrVideoPath = path.join(
52
72
  spatialXrDirectory,
53
73
  `${appFilePrefix}-spatial-xr-3d-sbs-lr-3840x1080.mp4`,
54
74
  );
55
- const iconPath = path.join(projectDirectory, 'resources', 'icon-only.png');
75
+ const iconPath = path.join(projectDirectory, 'resources', 'icon-only.png');
56
76
  const temporaryDirectory = path.join(projectDirectory, 'agent-temp', 'ai-promo-video');
57
77
  const captionPath = path.join(temporaryDirectory, `${appUniqueId}-promo-english.ass`);
58
78
  const narrationTrackPath = path.join(temporaryDirectory, `${appUniqueId}-promo-narration.wav`);
@@ -66,13 +86,13 @@ const analysisFrameDirectory = path.join(temporaryDirectory, 'ai-analysis');
66
86
  const endCardBannerPath = path.join(outputVideoDirectory, `${appFilePrefix}-ai-end-card-banner.png`);
67
87
  const promoCopySchemaPath = path.join(temporaryDirectory, 'promo-copy-schema.json');
68
88
  const promoCopyResultPath = path.join(temporaryDirectory, 'promo-copy-result.json');
69
- const ffmpegFallbackDirectory = String(process.env.FFMPEG_BIN || '').trim();
70
- const minimumSceneCount = 7;
71
- const minimumSourceDuration = 12;
72
- let expectedDuration = 34;
73
- let editSegments = [];
74
- let sceneWindows = [];
75
- let sceneCount = minimumSceneCount;
89
+ const ffmpegFallbackDirectory = String(process.env.FFMPEG_BIN || '').trim();
90
+ const minimumSceneCount = 7;
91
+ const minimumSourceDuration = 12;
92
+ let expectedDuration = 34;
93
+ let editSegments = [];
94
+ let sceneWindows = [];
95
+ let sceneCount = minimumSceneCount;
76
96
  const thumbnailTime = 2;
77
97
  const thumbnailWidth = 3840;
78
98
  const thumbnailHeight = 2160;
@@ -81,7 +101,7 @@ const spatialEyeWidth = 1920;
81
101
  const spatialEyeHeight = 1080;
82
102
  const spatialOutputWidth = spatialEyeWidth * 2;
83
103
  const spatialOutputHeight = spatialEyeHeight;
84
- const promoCopySchemaVersion = 5;
104
+ const promoCopySchemaVersion = 5;
85
105
  const promoCopySchema = {
86
106
  type: 'object',
87
107
  additionalProperties: false,
@@ -163,69 +183,69 @@ const neuralVoiceCatalog = {
163
183
  label: 'Clara — Canadian English, clear and friendly',
164
184
  },
165
185
  };
166
- function sanitizeFileNamePart(value) {
186
+ function sanitizeFileNamePart(value) {
167
187
  return String(value)
168
188
  .trim()
169
189
  .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
170
190
  .replace(/[. ]+$/g, '') || 'App';
171
191
  }
172
192
 
173
- async function ensureEndCardBanner() {
174
- if (fs.existsSync(endCardBannerPath)) return;
175
- throw new Error(
176
- `AI end-card banner is missing. Generate it manually and save it here:\n${endCardBannerPath}\n\n`
177
- + 'Create a promotional video end-card banner for this application.\n\n'
178
- + 'Requirements:\n'
179
- + '- Final image size must be exactly 1672 × 941 pixels.\n'
180
- + '- Save it as:\n'
181
- + ` ${endCardBannerPath}\n`
182
- + `- Use this exact app icon file: ${iconPath}\n`
183
- + '- Do not generate, redraw, reinterpret, or replace the app icon.\n'
184
- + '- Use the complete official black “Download on the App Store” badge.\n'
185
- + '- Use the complete official black “Get it on Google Play” badge.\n'
186
- + '- Download the official badges from Apple and Google if they are not available in the project.\n'
187
- + '- Do not generate or imitate the Apple, Google Play, or application logos.\n'
188
- + '- Keep all logos and badge text sharp, correctly proportioned, and completely visible.\n'
189
- + '- Match the background colors and visual style to the actual app icon.\n'
190
- + '- Create a polished, professional background related to the app’s purpose.\n'
191
- + '- Keep the background subtle so it does not compete with the icon or download badges.\n'
192
- + '- Use a balanced landscape composition:\n'
193
- + ' - Large actual app icon on the left.\n'
194
- + ' - App Store badge followed by Google Play badge on the right.\n'
195
- + ' - Maintain comfortable margins and clear space around every element.\n'
196
- + '- Do not add extra marketing text unless it is specifically requested.\n'
197
- + '- Do not add watermarks, fake UI, generated symbols, or unrelated decorations.\n'
198
- + '- Rounded corners and a subtle shadow may be applied around the app icon, but its internal artwork must remain unchanged.\n'
199
- + '- Use image generation only for the background.\n'
200
- + '- Composite the actual app icon and official store badges afterward.\n'
201
- + '- Replace the specified output file if it already exists.\n'
202
- + '- Verify the final PNG dimensions are exactly 1672 × 941.\n'
203
- + '- Visually inspect the completed image before finishing.\n'
204
- + '- Report the final saved path.\n\n'
205
- + 'Then run the video-generation command again.',
206
- );
207
- }
208
-
209
- async function ensureAppStoreDownloadUrl() {
210
- if (appStoreDownloadUrl) return;
211
- const readline = createInterface({ input: process.stdin, output: process.stdout });
212
- try {
213
- const appStoreId = (await readline.question(
214
- 'Enter the iOS App Store ID (press Enter to skip): ',
215
- )).trim();
216
- if (!appStoreId) return;
217
- if (!/^\d+$/.test(appStoreId)) {
218
- throw new Error('iOS App Store ID must contain digits only.');
219
- }
220
- appStoreDownloadUrl = `https://apps.apple.com/app/id${appStoreId}`;
221
- iosStoreConfig = { ...iosStoreConfig, appStoreUrl: appStoreDownloadUrl };
222
- fs.mkdirSync(path.dirname(iosStoreConfigPath), { recursive: true });
223
- fs.writeFileSync(iosStoreConfigPath, `${JSON.stringify(iosStoreConfig, null, 2)}\n`, 'utf8');
224
- console.log(`Saved iOS App Store URL to ${iosStoreConfigPath}`);
225
- } finally {
226
- readline.close();
227
- }
228
- }
193
+ async function ensureEndCardBanner() {
194
+ if (fs.existsSync(endCardBannerPath)) return;
195
+ throw new Error(
196
+ `AI end-card banner is missing. Generate it manually and save it here:\n${endCardBannerPath}\n\n`
197
+ + 'Create a promotional video end-card banner for this application.\n\n'
198
+ + 'Requirements:\n'
199
+ + '- Final image size must be exactly 1672 × 941 pixels.\n'
200
+ + '- Save it as:\n'
201
+ + ` ${endCardBannerPath}\n`
202
+ + `- Use this exact app icon file: ${iconPath}\n`
203
+ + '- Do not generate, redraw, reinterpret, or replace the app icon.\n'
204
+ + '- Use the complete official black “Download on the App Store” badge.\n'
205
+ + '- Use the complete official black “Get it on Google Play” badge.\n'
206
+ + '- Download the official badges from Apple and Google if they are not available in the project.\n'
207
+ + '- Do not generate or imitate the Apple, Google Play, or application logos.\n'
208
+ + '- Keep all logos and badge text sharp, correctly proportioned, and completely visible.\n'
209
+ + '- Match the background colors and visual style to the actual app icon.\n'
210
+ + '- Create a polished, professional background related to the app’s purpose.\n'
211
+ + '- Keep the background subtle so it does not compete with the icon or download badges.\n'
212
+ + '- Use a balanced landscape composition:\n'
213
+ + ' - Large actual app icon on the left.\n'
214
+ + ' - App Store badge followed by Google Play badge on the right.\n'
215
+ + ' - Maintain comfortable margins and clear space around every element.\n'
216
+ + '- Do not add extra marketing text unless it is specifically requested.\n'
217
+ + '- Do not add watermarks, fake UI, generated symbols, or unrelated decorations.\n'
218
+ + '- Rounded corners and a subtle shadow may be applied around the app icon, but its internal artwork must remain unchanged.\n'
219
+ + '- Use image generation only for the background.\n'
220
+ + '- Composite the actual app icon and official store badges afterward.\n'
221
+ + '- Replace the specified output file if it already exists.\n'
222
+ + '- Verify the final PNG dimensions are exactly 1672 × 941.\n'
223
+ + '- Visually inspect the completed image before finishing.\n'
224
+ + '- Report the final saved path.\n\n'
225
+ + 'Then run the video-generation command again.',
226
+ );
227
+ }
228
+
229
+ async function ensureAppStoreDownloadUrl() {
230
+ if (appStoreDownloadUrl) return;
231
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
232
+ try {
233
+ const appStoreId = (await readline.question(
234
+ 'Enter the iOS App Store ID (press Enter to skip): ',
235
+ )).trim();
236
+ if (!appStoreId) return;
237
+ if (!/^\d+$/.test(appStoreId)) {
238
+ throw new Error('iOS App Store ID must contain digits only.');
239
+ }
240
+ appStoreDownloadUrl = `https://apps.apple.com/app/id${appStoreId}`;
241
+ iosStoreConfig = { ...iosStoreConfig, appStoreUrl: appStoreDownloadUrl };
242
+ fs.mkdirSync(path.dirname(iosStoreConfigPath), { recursive: true });
243
+ fs.writeFileSync(iosStoreConfigPath, `${JSON.stringify(iosStoreConfig, null, 2)}\n`, 'utf8');
244
+ console.log(`Saved iOS App Store URL to ${iosStoreConfigPath}`);
245
+ } finally {
246
+ readline.close();
247
+ }
248
+ }
229
249
 
230
250
  function findVideos(directory) {
231
251
  if (!fs.existsSync(directory)) return [];
@@ -248,7 +268,7 @@ function roundTimelineValue(value) {
248
268
  return Math.round(value * 100) / 100;
249
269
  }
250
270
 
251
- function mapOutputTimeToSource(outputTime) {
271
+ function mapOutputTimeToSource(outputTime) {
252
272
  let remainingTime = outputTime;
253
273
  for (const segment of editSegments) {
254
274
  const segmentDuration = segment.end - segment.start;
@@ -257,150 +277,150 @@ function mapOutputTimeToSource(outputTime) {
257
277
  }
258
278
  remainingTime -= segmentDuration;
259
279
  }
260
- return editSegments.at(-1).end - 0.05;
261
- }
262
-
263
- function mapSourceTimeToOutput(sourceTime) {
264
- let outputTime = 0;
265
- for (const segment of editSegments) {
266
- if (sourceTime < segment.start) return outputTime;
267
- if (sourceTime <= segment.end) return outputTime + (sourceTime - segment.start);
268
- outputTime += segment.end - segment.start;
269
- }
270
- return outputTime;
271
- }
272
-
273
- function normalizeSkippedRanges(skippedRanges, sourceDuration) {
274
- const ranges = skippedRanges
275
- .map((range) => ({
276
- start: Math.max(0, Math.min(sourceDuration, range.start)),
277
- end: Math.max(0, Math.min(sourceDuration, range.end)),
278
- }))
279
- .filter((range) => range.end > range.start)
280
- .sort((left, right) => left.start - right.start);
281
-
282
- return ranges.reduce((merged, range) => {
283
- const previous = merged.at(-1);
284
- if (previous && range.start <= previous.end) {
285
- previous.end = Math.max(previous.end, range.end);
286
- } else {
287
- merged.push({ ...range });
288
- }
289
- return merged;
290
- }, []);
291
- }
292
-
293
- function buildRetainedSegments(sourceDuration, skippedRanges) {
294
- const normalizedRanges = normalizeSkippedRanges(skippedRanges, sourceDuration);
295
- const retainedSegments = [];
296
- let retainedStart = 0;
297
-
298
- for (const range of normalizedRanges) {
299
- if (range.start > retainedStart) {
300
- retainedSegments.push({ start: retainedStart, end: range.start });
301
- }
302
- retainedStart = range.end;
303
- }
304
- if (retainedStart < sourceDuration) {
305
- retainedSegments.push({ start: retainedStart, end: sourceDuration });
306
- }
307
-
308
- return {
309
- retainedSegments: retainedSegments.map((segment) => ({
310
- start: roundTimelineValue(segment.start),
311
- end: roundTimelineValue(segment.end),
312
- })),
313
- skippedDuration: normalizedRanges.reduce(
314
- (total, range) => total + (range.end - range.start),
315
- 0,
316
- ),
317
- };
318
- }
319
-
320
- function buildSceneWindows(retainedDuration, visualChangeTimes) {
321
- const minimumWindowDuration = Math.max(2.5, Math.min(4, retainedDuration / 40));
322
- const maximumWindowDuration = Math.max(6, Math.min(10, retainedDuration / 14));
323
- const boundaries = [0];
324
- const mappedChanges = visualChangeTimes
325
- .map((sourceTime) => mapSourceTimeToOutput(sourceTime))
326
- .filter((outputTime) => outputTime > 0 && outputTime < retainedDuration)
327
- .sort((left, right) => left - right);
328
-
329
- for (const changeTime of mappedChanges) {
330
- if (changeTime - boundaries.at(-1) >= minimumWindowDuration) {
331
- boundaries.push(changeTime);
332
- }
333
- }
334
- if (retainedDuration - boundaries.at(-1) < minimumWindowDuration) boundaries.pop();
335
- boundaries.push(retainedDuration);
336
-
337
- const windows = [];
338
- for (let index = 0; index < boundaries.length - 1; index += 1) {
339
- const start = boundaries[index];
340
- const end = boundaries[index + 1];
341
- const splitCount = Math.max(1, Math.ceil((end - start) / maximumWindowDuration));
342
- for (let splitIndex = 0; splitIndex < splitCount; splitIndex += 1) {
343
- windows.push({
344
- start: roundTimelineValue(start + (((end - start) * splitIndex) / splitCount)),
345
- end: roundTimelineValue(start + (((end - start) * (splitIndex + 1)) / splitCount)),
346
- });
347
- }
348
- }
349
-
350
- if (windows.length >= minimumSceneCount) return windows;
351
- return Array.from({ length: minimumSceneCount }, (_, index) => ({
352
- start: roundTimelineValue((retainedDuration * index) / minimumSceneCount),
353
- end: index === minimumSceneCount - 1
354
- ? retainedDuration
355
- : roundTimelineValue((retainedDuration * (index + 1)) / minimumSceneCount),
356
- }));
357
- }
358
-
359
- function configurePromoTimeline(sourceDuration, skippedRanges = [], visualChangeTimes = []) {
360
- if (!Number.isFinite(sourceDuration) || sourceDuration < minimumSourceDuration) {
280
+ return editSegments.at(-1).end - 0.05;
281
+ }
282
+
283
+ function mapSourceTimeToOutput(sourceTime) {
284
+ let outputTime = 0;
285
+ for (const segment of editSegments) {
286
+ if (sourceTime < segment.start) return outputTime;
287
+ if (sourceTime <= segment.end) return outputTime + (sourceTime - segment.start);
288
+ outputTime += segment.end - segment.start;
289
+ }
290
+ return outputTime;
291
+ }
292
+
293
+ function normalizeSkippedRanges(skippedRanges, sourceDuration) {
294
+ const ranges = skippedRanges
295
+ .map((range) => ({
296
+ start: Math.max(0, Math.min(sourceDuration, range.start)),
297
+ end: Math.max(0, Math.min(sourceDuration, range.end)),
298
+ }))
299
+ .filter((range) => range.end > range.start)
300
+ .sort((left, right) => left.start - right.start);
301
+
302
+ return ranges.reduce((merged, range) => {
303
+ const previous = merged.at(-1);
304
+ if (previous && range.start <= previous.end) {
305
+ previous.end = Math.max(previous.end, range.end);
306
+ } else {
307
+ merged.push({ ...range });
308
+ }
309
+ return merged;
310
+ }, []);
311
+ }
312
+
313
+ function buildRetainedSegments(sourceDuration, skippedRanges) {
314
+ const normalizedRanges = normalizeSkippedRanges(skippedRanges, sourceDuration);
315
+ const retainedSegments = [];
316
+ let retainedStart = 0;
317
+
318
+ for (const range of normalizedRanges) {
319
+ if (range.start > retainedStart) {
320
+ retainedSegments.push({ start: retainedStart, end: range.start });
321
+ }
322
+ retainedStart = range.end;
323
+ }
324
+ if (retainedStart < sourceDuration) {
325
+ retainedSegments.push({ start: retainedStart, end: sourceDuration });
326
+ }
327
+
328
+ return {
329
+ retainedSegments: retainedSegments.map((segment) => ({
330
+ start: roundTimelineValue(segment.start),
331
+ end: roundTimelineValue(segment.end),
332
+ })),
333
+ skippedDuration: normalizedRanges.reduce(
334
+ (total, range) => total + (range.end - range.start),
335
+ 0,
336
+ ),
337
+ };
338
+ }
339
+
340
+ function buildSceneWindows(retainedDuration, visualChangeTimes) {
341
+ const minimumWindowDuration = Math.max(2.5, Math.min(4, retainedDuration / 40));
342
+ const maximumWindowDuration = Math.max(6, Math.min(10, retainedDuration / 14));
343
+ const boundaries = [0];
344
+ const mappedChanges = visualChangeTimes
345
+ .map((sourceTime) => mapSourceTimeToOutput(sourceTime))
346
+ .filter((outputTime) => outputTime > 0 && outputTime < retainedDuration)
347
+ .sort((left, right) => left - right);
348
+
349
+ for (const changeTime of mappedChanges) {
350
+ if (changeTime - boundaries.at(-1) >= minimumWindowDuration) {
351
+ boundaries.push(changeTime);
352
+ }
353
+ }
354
+ if (retainedDuration - boundaries.at(-1) < minimumWindowDuration) boundaries.pop();
355
+ boundaries.push(retainedDuration);
356
+
357
+ const windows = [];
358
+ for (let index = 0; index < boundaries.length - 1; index += 1) {
359
+ const start = boundaries[index];
360
+ const end = boundaries[index + 1];
361
+ const splitCount = Math.max(1, Math.ceil((end - start) / maximumWindowDuration));
362
+ for (let splitIndex = 0; splitIndex < splitCount; splitIndex += 1) {
363
+ windows.push({
364
+ start: roundTimelineValue(start + (((end - start) * splitIndex) / splitCount)),
365
+ end: roundTimelineValue(start + (((end - start) * (splitIndex + 1)) / splitCount)),
366
+ });
367
+ }
368
+ }
369
+
370
+ if (windows.length >= minimumSceneCount) return windows;
371
+ return Array.from({ length: minimumSceneCount }, (_, index) => ({
372
+ start: roundTimelineValue((retainedDuration * index) / minimumSceneCount),
373
+ end: index === minimumSceneCount - 1
374
+ ? retainedDuration
375
+ : roundTimelineValue((retainedDuration * (index + 1)) / minimumSceneCount),
376
+ }));
377
+ }
378
+
379
+ function configurePromoTimeline(sourceDuration, skippedRanges = [], visualChangeTimes = []) {
380
+ if (!Number.isFinite(sourceDuration) || sourceDuration < minimumSourceDuration) {
361
381
  throw new Error(
362
382
  `The shortest source video must be at least ${minimumSourceDuration} seconds.`,
363
383
  );
364
384
  }
365
385
 
366
- const { retainedSegments, skippedDuration } = buildRetainedSegments(
367
- sourceDuration,
368
- skippedRanges,
369
- );
370
- editSegments = retainedSegments.filter((segment) => segment.end > segment.start);
371
- const retainedDuration = editSegments.reduce(
372
- (total, segment) => total + (segment.end - segment.start),
373
- 0,
374
- );
375
- if (retainedDuration < minimumSourceDuration) {
376
- throw new Error(
377
- `At least ${minimumSourceDuration} seconds must remain after automatic cleanup.`,
378
- );
379
- }
380
-
381
- expectedDuration = roundTimelineValue(retainedDuration);
382
- sceneWindows = buildSceneWindows(expectedDuration, visualChangeTimes).map((window) => {
383
- const sceneDuration = window.end - window.start;
384
- return {
385
- ...window,
386
- sourceTime: roundTimelineValue(mapOutputTimeToSource(
387
- window.start + (sceneDuration / 2),
388
- )),
389
- maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
390
- };
391
- });
392
- sceneCount = sceneWindows.length;
393
- promoCopySchema.properties.scenes.minItems = sceneCount;
394
- promoCopySchema.properties.scenes.maxItems = sceneCount;
395
- endCardStart = expectedDuration;
386
+ const { retainedSegments, skippedDuration } = buildRetainedSegments(
387
+ sourceDuration,
388
+ skippedRanges,
389
+ );
390
+ editSegments = retainedSegments.filter((segment) => segment.end > segment.start);
391
+ const retainedDuration = editSegments.reduce(
392
+ (total, segment) => total + (segment.end - segment.start),
393
+ 0,
394
+ );
395
+ if (retainedDuration < minimumSourceDuration) {
396
+ throw new Error(
397
+ `At least ${minimumSourceDuration} seconds must remain after automatic cleanup.`,
398
+ );
399
+ }
400
+
401
+ expectedDuration = roundTimelineValue(retainedDuration);
402
+ sceneWindows = buildSceneWindows(expectedDuration, visualChangeTimes).map((window) => {
403
+ const sceneDuration = window.end - window.start;
404
+ return {
405
+ ...window,
406
+ sourceTime: roundTimelineValue(mapOutputTimeToSource(
407
+ window.start + (sceneDuration / 2),
408
+ )),
409
+ maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
410
+ };
411
+ });
412
+ sceneCount = sceneWindows.length;
413
+ promoCopySchema.properties.scenes.minItems = sceneCount;
414
+ promoCopySchema.properties.scenes.maxItems = sceneCount;
415
+ endCardStart = expectedDuration;
396
416
  expectedDuration = roundTimelineValue(expectedDuration + endCardDuration);
397
417
 
398
418
  console.log(
399
- `Feature-complete promo timeline: ${expectedDuration}s from ${sourceDuration.toFixed(2)}s source; `
400
- + `${sceneCount} scenes and ${editSegments.length} retained segment${editSegments.length === 1 ? '' : 's'}`
401
- + `${skippedDuration > 0 ? ` (${skippedDuration.toFixed(2)}s of redundant static footage removed)` : ''}.`,
402
- );
403
- }
419
+ `Feature-complete promo timeline: ${expectedDuration}s from ${sourceDuration.toFixed(2)}s source; `
420
+ + `${sceneCount} scenes and ${editSegments.length} retained segment${editSegments.length === 1 ? '' : 's'}`
421
+ + `${skippedDuration > 0 ? ` (${skippedDuration.toFixed(2)}s of redundant static footage removed)` : ''}.`,
422
+ );
423
+ }
404
424
 
405
425
  function escapeAssText(value) {
406
426
  return String(value)
@@ -443,91 +463,93 @@ function commandExists(command) {
443
463
  return !result.error && result.status === 0;
444
464
  }
445
465
 
446
- function getExecutable(command) {
447
- if (commandExists(command)) return command;
448
-
449
- if (!ffmpegFallbackDirectory) return '';
450
- const executableName = process.platform === 'win32' ? `${command}.exe` : command;
451
- const fallbackPath = path.join(ffmpegFallbackDirectory, executableName);
452
- return fs.existsSync(fallbackPath) ? fallbackPath : '';
453
- }
454
-
455
- function detectRedundantStaticRanges(ffmpegCommand, inputPath, sourceDuration) {
456
- const minimumStaticDuration = Math.max(2, Math.min(4, sourceDuration / 60));
457
- const result = spawnSync(ffmpegCommand, [
458
- '-hide_banner',
459
- '-nostats',
460
- '-i', inputPath,
461
- '-vf', `freezedetect=n=-50dB:d=${minimumStaticDuration}`,
462
- '-an',
463
- '-f', 'null',
464
- process.platform === 'win32' ? 'NUL' : '/dev/null',
465
- ], {
466
- encoding: 'utf8',
467
- windowsHide: true,
468
- maxBuffer: 10 * 1024 * 1024,
469
- });
470
- if (result.error || result.status !== 0) {
471
- console.warn('Static-screen analysis was unavailable; retaining the complete recording.');
472
- return [];
473
- }
474
-
475
- const events = String(result.stderr || '').matchAll(
476
- /lavfi\.freezedetect\.freeze_(start|end):\s*([\d.]+)/g,
477
- );
478
- const staticRanges = [];
479
- let staticStart = null;
480
- for (const event of events) {
481
- const time = Number(event[2]);
482
- if (event[1] === 'start') {
483
- staticStart = time;
484
- } else if (staticStart !== null && time > staticStart) {
485
- staticRanges.push({ start: staticStart, end: time });
486
- staticStart = null;
487
- }
488
- }
489
- if (staticStart !== null && sourceDuration > staticStart) {
490
- staticRanges.push({ start: staticStart, end: sourceDuration });
491
- }
492
-
493
- return staticRanges
494
- .map((range) => ({
495
- start: range.start + minimumStaticDuration,
496
- end: range.end,
497
- }))
498
- .filter((range) => range.end - range.start >= 0.75);
499
- }
500
-
501
- function detectVisualChangeTimes(ffmpegCommand, inputPath) {
502
- const result = spawnSync(ffmpegCommand, [
503
- '-hide_banner',
504
- '-nostats',
505
- '-i', inputPath,
506
- '-vf', "select='gt(scene,0.03)',showinfo",
507
- '-an',
508
- '-f', 'null',
509
- process.platform === 'win32' ? 'NUL' : '/dev/null',
510
- ], {
511
- encoding: 'utf8',
512
- windowsHide: true,
513
- maxBuffer: 10 * 1024 * 1024,
514
- });
515
- if (result.error || result.status !== 0) {
516
- console.warn('Visual-change analysis was unavailable; using duration-based scene coverage.');
517
- return [];
518
- }
519
- return [...String(result.stderr || '').matchAll(/pts_time:([\d.]+)/g)]
520
- .map((match) => Number(match[1]))
521
- .filter(Number.isFinite);
522
- }
523
-
524
- function parseArguments() {
466
+ function getExecutable(command) {
467
+ if (commandExists(command)) return command;
468
+
469
+ if (!ffmpegFallbackDirectory) return '';
470
+ const executableName = process.platform === 'win32' ? `${command}.exe` : command;
471
+ const fallbackPath = path.join(ffmpegFallbackDirectory, executableName);
472
+ return fs.existsSync(fallbackPath) ? fallbackPath : '';
473
+ }
474
+
475
+ function detectRedundantStaticRanges(ffmpegCommand, inputPath, sourceDuration) {
476
+ const minimumStaticDuration = Math.max(2, Math.min(4, sourceDuration / 60));
477
+ const result = spawnSync(ffmpegCommand, [
478
+ '-hide_banner',
479
+ '-nostats',
480
+ '-i', inputPath,
481
+ '-vf', `freezedetect=n=-50dB:d=${minimumStaticDuration}`,
482
+ '-an',
483
+ '-f', 'null',
484
+ process.platform === 'win32' ? 'NUL' : '/dev/null',
485
+ ], {
486
+ encoding: 'utf8',
487
+ windowsHide: true,
488
+ maxBuffer: 10 * 1024 * 1024,
489
+ });
490
+ if (result.error || result.status !== 0) {
491
+ console.warn('Static-screen analysis was unavailable; retaining the complete recording.');
492
+ return [];
493
+ }
494
+
495
+ const events = String(result.stderr || '').matchAll(
496
+ /lavfi\.freezedetect\.freeze_(start|end):\s*([\d.]+)/g,
497
+ );
498
+ const staticRanges = [];
499
+ let staticStart = null;
500
+ for (const event of events) {
501
+ const time = Number(event[2]);
502
+ if (event[1] === 'start') {
503
+ staticStart = time;
504
+ } else if (staticStart !== null && time > staticStart) {
505
+ staticRanges.push({ start: staticStart, end: time });
506
+ staticStart = null;
507
+ }
508
+ }
509
+ if (staticStart !== null && sourceDuration > staticStart) {
510
+ staticRanges.push({ start: staticStart, end: sourceDuration });
511
+ }
512
+
513
+ return staticRanges
514
+ .map((range) => ({
515
+ start: range.start + minimumStaticDuration,
516
+ end: range.end,
517
+ }))
518
+ .filter((range) => range.end - range.start >= 0.75);
519
+ }
520
+
521
+ function detectVisualChangeTimes(ffmpegCommand, inputPath) {
522
+ const result = spawnSync(ffmpegCommand, [
523
+ '-hide_banner',
524
+ '-nostats',
525
+ '-i', inputPath,
526
+ '-vf', "select='gt(scene,0.03)',showinfo",
527
+ '-an',
528
+ '-f', 'null',
529
+ process.platform === 'win32' ? 'NUL' : '/dev/null',
530
+ ], {
531
+ encoding: 'utf8',
532
+ windowsHide: true,
533
+ maxBuffer: 10 * 1024 * 1024,
534
+ });
535
+ if (result.error || result.status !== 0) {
536
+ console.warn('Visual-change analysis was unavailable; using duration-based scene coverage.');
537
+ return [];
538
+ }
539
+ return [...String(result.stderr || '').matchAll(/pts_time:([\d.]+)/g)]
540
+ .map((match) => Number(match[1]))
541
+ .filter(Number.isFinite);
542
+ }
543
+
544
+ function parseArguments() {
525
545
  const args = process.argv.slice(2);
526
546
  const options = {
527
547
  aiModel: '',
528
548
  aiProvider: 'auto',
529
549
  copyOnly: false,
530
550
  input: '',
551
+ layout: 'split',
552
+ layoutExplicit: false,
531
553
  listVoices: false,
532
554
  output: '',
533
555
  previewVoices: false,
@@ -548,6 +570,10 @@ function parseArguments() {
548
570
  } else if (argument === '--voice') {
549
571
  options.voice = String(args[index + 1] || '').trim();
550
572
  index += 1;
573
+ } else if (argument === '--layout') {
574
+ options.layout = String(args[index + 1] || '').trim().toLowerCase();
575
+ options.layoutExplicit = true;
576
+ index += 1;
551
577
  } else if (argument === '--ai-provider') {
552
578
  options.aiProvider = String(args[index + 1] || '').trim().toLowerCase();
553
579
  index += 1;
@@ -572,6 +598,7 @@ function parseArguments() {
572
598
  console.log('Usage:');
573
599
  console.log(' node generate-ai-promo-video.js');
574
600
  console.log(' node generate-ai-promo-video.js --input source.mp4 --output promo.mp4');
601
+ console.log(' node generate-ai-promo-video.js --layout dynamic|split|centered|floating|cinematic|mixed|random');
575
602
  console.log(' node generate-ai-promo-video.js --voice aria');
576
603
  console.log(' node generate-ai-promo-video.js --refresh-ai-copy');
577
604
  console.log(' node generate-ai-promo-video.js --copy-only');
@@ -582,10 +609,10 @@ function parseArguments() {
582
609
  console.log(' node generate-ai-promo-video.js --list-voices');
583
610
  console.log(' node generate-ai-promo-video.js --preview-voices');
584
611
  console.log('');
585
- console.log(
586
- `Without --input, every MP4 under ${sourceVideoDirectory} except Samsung is processed.`,
587
- );
588
- console.log('Redundant static pauses are shortened automatically; distinct screens are retained.');
612
+ console.log(
613
+ `Without --input, every MP4 under ${sourceVideoDirectory} except Samsung is processed.`,
614
+ );
615
+ console.log('Redundant static pauses are shortened automatically; distinct screens are retained.');
589
616
  process.exit(0);
590
617
  } else {
591
618
  throw new Error(`Unknown argument: ${argument}`);
@@ -595,6 +622,9 @@ function parseArguments() {
595
622
  if (!['auto', 'codex', 'openai'].includes(options.aiProvider)) {
596
623
  throw new Error('--ai-provider must be auto, codex, or openai.');
597
624
  }
625
+ if (!promoLayoutNames.includes(options.layout)) {
626
+ throw new Error(`--layout must be one of: ${promoLayoutNames.join(', ')}.`);
627
+ }
598
628
  if (options.output && !options.input) {
599
629
  throw new Error('--output can only be used together with --input.');
600
630
  }
@@ -602,6 +632,11 @@ function parseArguments() {
602
632
  return options;
603
633
  }
604
634
 
635
+ function resolvePromoLayout(requestedLayout) {
636
+ if (requestedLayout !== 'random') return requestedLayout;
637
+ return selectablePromoLayoutNames[Math.floor(Math.random() * selectablePromoLayoutNames.length)];
638
+ }
639
+
605
640
  function getPythonExecutable() {
606
641
  for (const candidate of ['py', 'python']) {
607
642
  const result = spawnSync(candidate, ['-m', 'edge_tts', '--version'], {
@@ -745,55 +780,55 @@ function createCopyFingerprint(appContext, inputPaths) {
745
780
  })).digest('hex');
746
781
  }
747
782
 
748
- function getCodexCandidates() {
749
- const candidates = ['codex'];
750
- const configuredCodexPath = String(process.env.CODEX_BIN || '').trim();
751
- if (configuredCodexPath) candidates.unshift(configuredCodexPath);
752
-
753
- if (process.platform === 'win32') {
754
- const userProfile = process.env.USERPROFILE || process.env.HOME;
755
- const extensionRoots = [
756
- path.join(userProfile || '', '.vscode', 'extensions'),
757
- path.join(userProfile || '', '.vscode-insiders', 'extensions'),
758
- path.join(userProfile || '', '.vscode-server', 'extensions'),
759
- path.join(userProfile || '', '.vscode-server-insiders', 'extensions'),
760
- ];
761
- const executableNames = ['codex.exe', 'codex'];
762
-
763
- for (const extensionRoot of extensionRoots) {
764
- if (!fs.existsSync(extensionRoot)) continue;
765
- for (const extensionName of fs.readdirSync(extensionRoot)) {
766
- if (!/^openai\.chatgpt-/i.test(extensionName)) continue;
767
- const extensionPath = path.join(extensionRoot, extensionName, 'bin');
768
- if (!fs.existsSync(extensionPath)) continue;
769
- for (const platformDirectory of fs.readdirSync(extensionPath)) {
770
- for (const executableName of executableNames) {
771
- candidates.push(path.join(extensionPath, platformDirectory, executableName));
772
- }
773
- }
774
- }
775
- }
776
- }
777
-
778
- return [...new Set(candidates)];
779
- }
780
-
781
- function getCodexExecutable() {
782
- for (const candidate of getCodexCandidates()) {
783
- const versionResult = spawnSync(candidate, ['--version'], {
784
- encoding: 'utf8',
785
- windowsHide: true,
786
- });
787
- if (versionResult.error || versionResult.status !== 0) continue;
788
-
789
- const loginResult = spawnSync(candidate, ['login', 'status'], {
790
- encoding: 'utf8',
791
- windowsHide: true,
792
- });
793
- if (!loginResult.error && loginResult.status === 0) return candidate;
794
- }
795
- return '';
796
- }
783
+ function getCodexCandidates() {
784
+ const candidates = ['codex'];
785
+ const configuredCodexPath = String(process.env.CODEX_BIN || '').trim();
786
+ if (configuredCodexPath) candidates.unshift(configuredCodexPath);
787
+
788
+ if (process.platform === 'win32') {
789
+ const userProfile = process.env.USERPROFILE || process.env.HOME;
790
+ const extensionRoots = [
791
+ path.join(userProfile || '', '.vscode', 'extensions'),
792
+ path.join(userProfile || '', '.vscode-insiders', 'extensions'),
793
+ path.join(userProfile || '', '.vscode-server', 'extensions'),
794
+ path.join(userProfile || '', '.vscode-server-insiders', 'extensions'),
795
+ ];
796
+ const executableNames = ['codex.exe', 'codex'];
797
+
798
+ for (const extensionRoot of extensionRoots) {
799
+ if (!fs.existsSync(extensionRoot)) continue;
800
+ for (const extensionName of fs.readdirSync(extensionRoot)) {
801
+ if (!/^openai\.chatgpt-/i.test(extensionName)) continue;
802
+ const extensionPath = path.join(extensionRoot, extensionName, 'bin');
803
+ if (!fs.existsSync(extensionPath)) continue;
804
+ for (const platformDirectory of fs.readdirSync(extensionPath)) {
805
+ for (const executableName of executableNames) {
806
+ candidates.push(path.join(extensionPath, platformDirectory, executableName));
807
+ }
808
+ }
809
+ }
810
+ }
811
+ }
812
+
813
+ return [...new Set(candidates)];
814
+ }
815
+
816
+ function getCodexExecutable() {
817
+ for (const candidate of getCodexCandidates()) {
818
+ const versionResult = spawnSync(candidate, ['--version'], {
819
+ encoding: 'utf8',
820
+ windowsHide: true,
821
+ });
822
+ if (versionResult.error || versionResult.status !== 0) continue;
823
+
824
+ const loginResult = spawnSync(candidate, ['login', 'status'], {
825
+ encoding: 'utf8',
826
+ windowsHide: true,
827
+ });
828
+ if (!loginResult.error && loginResult.status === 0) return candidate;
829
+ }
830
+ return '';
831
+ }
797
832
 
798
833
  function detectActiveVideoCrop(ffmpegCommand, inputPath, videoDetails) {
799
834
  const videoStream = videoDetails.streams?.find((stream) => stream.codec_type === 'video') || {};
@@ -864,12 +899,12 @@ function buildPromoCopyPrompt(appContext, previousError = '') {
864
899
 
865
900
  The ${sceneWindows.length} supplied images are ordered scene 1 through scene ${sceneWindows.length}. Use both those images and the sanitized project context below. Do not inspect files, run commands, or edit anything. Return only JSON matching the supplied schema.
866
901
 
867
- Rules:
868
- - Generate exactly ${sceneWindows.length} scenes in the same order as the images and timings.
869
- - Use only features supported by the images or project context. Never invent claims.
870
- - Cover every distinct demonstrated feature across the retained recording; do not omit later features.
871
- - Do not promote incidental navigation, loading states, advertisements, permission prompts, or repeated screens as features.
872
- - Keep each scene specific to what is visibly demonstrated in its supplied image whenever possible.
902
+ Rules:
903
+ - Generate exactly ${sceneWindows.length} scenes in the same order as the images and timings.
904
+ - Use only features supported by the images or project context. Never invent claims.
905
+ - Cover every distinct demonstrated feature across the retained recording; do not omit later features.
906
+ - Do not promote incidental navigation, loading states, advertisements, permission prompts, or repeated screens as features.
907
+ - Keep each scene specific to what is visibly demonstrated in its supplied image whenever possible.
873
908
  - The authoritative app name is ${JSON.stringify(appName)}. Mention it naturally in the final narration and final body.
874
909
  - APP_UNIQUE_ID is internal and must never appear in audience-facing copy.
875
910
  - Each kicker: 2-30 characters. Each title line: 1-22 characters. Body: 8-72 characters.
@@ -976,11 +1011,11 @@ function validatePromoCopy(value) {
976
1011
  }
977
1012
  cleanedScene[field] = cleaned;
978
1013
  }
979
- const maximumNarrationWords = sceneWindows[sceneIndex].maxNarrationWords;
980
- const narrationWords = cleanedScene.narration.split(/\s+/);
981
- if (narrationWords.length > maximumNarrationWords) {
982
- cleanedScene.narration = narrationWords.slice(0, maximumNarrationWords).join(' ');
983
- }
1014
+ const maximumNarrationWords = sceneWindows[sceneIndex].maxNarrationWords;
1015
+ const narrationWords = cleanedScene.narration.split(/\s+/);
1016
+ if (narrationWords.length > maximumNarrationWords) {
1017
+ cleanedScene.narration = narrationWords.slice(0, maximumNarrationWords).join(' ');
1018
+ }
984
1019
  return cleanedScene;
985
1020
  });
986
1021
  const finalCopy = `${scenes.at(-1).body} ${scenes.at(-1).narration}`.toLowerCase();
@@ -1135,7 +1170,7 @@ async function generateOrLoadPromoCopy(
1135
1170
  const framePaths = extractAnalysisFrames(ffmpegCommand, analysisInputPath, analysisCrop);
1136
1171
  let previousError = '';
1137
1172
  let generated;
1138
- for (let attempt = 1; attempt <= 3; attempt += 1) {
1173
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1139
1174
  const prompt = buildPromoCopyPrompt(appContext, previousError);
1140
1175
  try {
1141
1176
  generated = provider === 'openai'
@@ -1145,7 +1180,7 @@ async function generateOrLoadPromoCopy(
1145
1180
  break;
1146
1181
  } catch (error) {
1147
1182
  previousError = error.message;
1148
- if (attempt === 3 || /unavailable|not configured|not signed in/i.test(previousError)) throw error;
1183
+ if (attempt === 3 || /unavailable|not configured|not signed in/i.test(previousError)) throw error;
1149
1184
  console.warn(`AI copy attempt ${attempt} was invalid; requesting a corrected result.`);
1150
1185
  }
1151
1186
  }
@@ -1283,22 +1318,99 @@ function formatAssTime(seconds) {
1283
1318
  return `${hours}:${String(minutes).padStart(2, '0')}:${String(wholeSeconds).padStart(2, '0')}.${String(remainder).padStart(2, '0')}`;
1284
1319
  }
1285
1320
 
1286
- function createCaptionFile(promoCopy) {
1321
+ function getSceneComposition(layoutName, sceneIndex) {
1322
+ const left = {
1323
+ bodyY: 470,
1324
+ phoneX: '90',
1325
+ phoneY: '50',
1326
+ styleSuffix: 'Left',
1327
+ textX: 720,
1328
+ titleY: 270,
1329
+ kickerY: 225,
1330
+ };
1331
+ const right = {
1332
+ ...left,
1333
+ phoneX: 'W-w-90',
1334
+ textX: 110,
1335
+ };
1336
+ const centered = {
1337
+ bodyY: 925,
1338
+ phoneX: '(W-w)/2',
1339
+ phoneY: '220',
1340
+ styleSuffix: 'Center',
1341
+ textX: 960,
1342
+ titleY: 102,
1343
+ kickerY: 66,
1344
+ };
1345
+ const mixedCentered = {
1346
+ ...left,
1347
+ phoneX: '(W-w)/2',
1348
+ phoneY: '110',
1349
+ textX: 80,
1350
+ };
1351
+ const floating = {
1352
+ ...left,
1353
+ phoneX: '125+24*sin(t*0.8)',
1354
+ phoneY: '110+14*cos(t*0.65)',
1355
+ textX: 1050,
1356
+ };
1357
+ const cinematic = {
1358
+ bodyY: 925,
1359
+ phoneX: '(W-w)/2',
1360
+ phoneY: '0',
1361
+ styleSuffix: 'Center',
1362
+ textX: 960,
1363
+ titleY: 745,
1364
+ kickerY: 705,
1365
+ };
1366
+
1367
+ if (layoutName === 'dynamic') return sceneIndex % 2 === 0 ? left : right;
1368
+ if (layoutName === 'centered') return centered;
1369
+ if (layoutName === 'floating') return floating;
1370
+ if (layoutName === 'cinematic') return cinematic;
1371
+ if (layoutName === 'mixed') {
1372
+ return [left, right, mixedCentered, floating, cinematic][sceneIndex % 5];
1373
+ }
1374
+ return left;
1375
+ }
1376
+
1377
+ function getGlobalCaptionComposition(layoutName) {
1378
+ if (layoutName === 'split') {
1379
+ return {
1380
+ brandStyle: 'BrandLeft', brandX: 720, brandY: 78,
1381
+ footerStyle: 'FooterLeft', footerX: 720, footerY: 1008,
1382
+ };
1383
+ }
1384
+ if (layoutName === 'floating') {
1385
+ return {
1386
+ brandStyle: 'BrandLeft', brandX: 1050, brandY: 78,
1387
+ footerStyle: 'FooterLeft', footerX: 1050, footerY: 1008,
1388
+ };
1389
+ }
1390
+ return {
1391
+ brandStyle: 'BrandCenter', brandX: 960,
1392
+ brandY: ['centered', 'mixed'].includes(layoutName) ? 18 : 78,
1393
+ footerStyle: 'FooterCenter', footerX: 960, footerY: 1008,
1394
+ };
1395
+ }
1396
+
1397
+ function createCaptionFile(promoCopy, layoutName) {
1287
1398
  const captionAppName = escapeAssText(appName.toUpperCase());
1288
1399
  const events = sceneWindows.flatMap((window, index) => {
1289
1400
  const scene = promoCopy.scenes[index];
1401
+ const composition = getSceneComposition(layoutName, index);
1290
1402
  const start = formatAssTime(window.start);
1291
1403
  const end = formatAssTime(window.end);
1292
- const x = index === sceneWindows.length - 1 ? 895 : 720;
1293
1404
  const fadeIn = window.end - window.start <= 3 ? 180 : 220;
1294
1405
  const fadeOut = index === sceneWindows.length - 1 ? 350 : fadeIn;
1295
1406
  const fade = `\\fad(${fadeIn},${fadeOut})`;
1296
1407
  return [
1297
- `Dialogue: 0,${start},${end},Kicker,,0,0,0,,{\\pos(${x},225)${fade}}${escapeAssText(scene.kicker.toUpperCase())}`,
1298
- `Dialogue: 0,${start},${end},Title,,0,0,0,,{\\pos(${x},270)${fade}}${escapeAssText(scene.title_line_1.toUpperCase())}\\N${escapeAssText(scene.title_line_2.toUpperCase())}`,
1299
- `Dialogue: 0,${start},${end},Body,,0,0,0,,{\\pos(${x},470)${fade}}${escapeAssText(scene.body)}`,
1408
+ `Dialogue: 0,${start},${end},Kicker${composition.styleSuffix},,0,0,0,,{\\pos(${composition.textX},${composition.kickerY})${fade}}${escapeAssText(scene.kicker.toUpperCase())}`,
1409
+ `Dialogue: 0,${start},${end},Title${composition.styleSuffix},,0,0,0,,{\\pos(${composition.textX},${composition.titleY})${fade}}${escapeAssText(scene.title_line_1.toUpperCase())}\\N${escapeAssText(scene.title_line_2.toUpperCase())}`,
1410
+ `Dialogue: 0,${start},${end},Body${composition.styleSuffix},,0,0,0,,{\\pos(${composition.textX},${composition.bodyY})${fade}}${escapeAssText(scene.body)}`,
1300
1411
  ];
1301
1412
  });
1413
+ const globalComposition = getGlobalCaptionComposition(layoutName);
1302
1414
  const footer = promoCopy.footer_keywords
1303
1415
  .map((keyword) => escapeAssText(keyword.toUpperCase()))
1304
1416
  .join(' / ');
@@ -1311,17 +1423,22 @@ WrapStyle: 2
1311
1423
 
1312
1424
  [V4+ Styles]
1313
1425
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
1314
- Style: Brand,Segoe UI,30,&H00FFFFFF,&H00FFFFFF,&H50000000,&H00000000,1,0,0,0,100,100,2,0,1,1,0,7,0,0,0,1
1315
- Style: Kicker,Segoe UI Semibold,25,&H00C3D523,&H00C3D523,&H50000000,&H00000000,1,0,0,0,100,100,2,0,1,1,0,7,0,0,0,1
1316
- Style: Title,Segoe UI Semibold,76,&H00FFFFFF,&H00FFFFFF,&H80000000,&H00000000,1,0,0,0,100,100,0,0,1,2,0,7,0,0,0,1
1317
- Style: Body,Segoe UI,35,&H00D8E3F0,&H00D8E3F0,&H70000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,7,0,0,0,1
1318
- Style: Footer,Segoe UI Semibold,22,&H00FFFFFF,&H00FFFFFF,&H50000000,&H00000000,1,0,0,0,100,100,2,0,1,1,0,7,0,0,0,1
1426
+ Style: BrandLeft,Segoe UI,30,&H00FFFFFF,&H00FFFFFF,&H50000000,&H00000000,1,0,0,0,100,100,2,0,1,1,0,7,0,0,0,1
1427
+ Style: BrandCenter,Segoe UI,30,&H00FFFFFF,&H00FFFFFF,&H50000000,&H00000000,1,0,0,0,100,100,2,0,1,1,0,8,0,0,0,1
1428
+ Style: KickerLeft,Segoe UI Semibold,25,&H00C3D523,&H00C3D523,&H50000000,&H00000000,1,0,0,0,100,100,2,0,1,1,0,7,0,0,0,1
1429
+ Style: KickerCenter,Segoe UI Semibold,23,&H00C3D523,&H00C3D523,&H50000000,&H00000000,1,0,0,0,100,100,2,0,1,1,0,8,0,0,0,1
1430
+ Style: TitleLeft,Segoe UI Semibold,76,&H00FFFFFF,&H00FFFFFF,&H80000000,&H00000000,1,0,0,0,100,100,0,0,1,2,0,7,0,0,0,1
1431
+ Style: TitleCenter,Segoe UI Semibold,58,&H00FFFFFF,&H00FFFFFF,&H80000000,&H00000000,1,0,0,0,100,100,0,0,1,2,0,8,0,0,0,1
1432
+ Style: BodyLeft,Segoe UI,35,&H00D8E3F0,&H00D8E3F0,&H70000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,7,0,0,0,1
1433
+ Style: BodyCenter,Segoe UI,29,&H00D8E3F0,&H00D8E3F0,&H70000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,8,0,0,0,1
1434
+ Style: FooterLeft,Segoe UI Semibold,22,&H00FFFFFF,&H00FFFFFF,&H50000000,&H00000000,1,0,0,0,100,100,2,0,1,1,0,7,0,0,0,1
1435
+ Style: FooterCenter,Segoe UI Semibold,22,&H00FFFFFF,&H00FFFFFF,&H50000000,&H00000000,1,0,0,0,100,100,2,0,1,1,0,8,0,0,0,1
1319
1436
 
1320
1437
  [Events]
1321
1438
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
1322
- Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},Brand,,0,0,0,,{\\pos(720,78)\\fad(350,350)}${captionAppName}
1439
+ Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},${globalComposition.brandStyle},,0,0,0,,{\\pos(${globalComposition.brandX},${globalComposition.brandY})\\fad(350,350)}${captionAppName}
1323
1440
  ${events.join('\n')}
1324
- Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},Footer,,0,0,0,,{\\pos(720,1008)\\fad(350,350)}${footer}
1441
+ Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},${globalComposition.footerStyle},,0,0,0,,{\\pos(${globalComposition.footerX},${globalComposition.footerY})\\fad(350,350)}${footer}
1325
1442
  `;
1326
1443
 
1327
1444
  fs.mkdirSync(temporaryDirectory, { recursive: true });
@@ -1505,8 +1622,75 @@ function validateOutput(ffprobeCommand, outputPath, targetWidth, targetHeight) {
1505
1622
  );
1506
1623
  }
1507
1624
 
1508
- function generatePromo(ffmpegCommand, inputPath, outputPath, crop, targetWidth, targetHeight) {
1625
+ function getPromoLayoutSettings(layoutName) {
1626
+ const settings = {
1627
+ cinematic: { backgroundBrightness: '-0.28', backgroundSaturation: '1.05', phoneHeight: 1080 },
1628
+ centered: { backgroundBrightness: '-0.42', backgroundSaturation: '1.18', phoneHeight: 650 },
1629
+ dynamic: { backgroundBrightness: '-0.44', backgroundSaturation: '1.35', phoneHeight: 920 },
1630
+ floating: { backgroundBrightness: '-0.38', backgroundSaturation: '1.25', phoneHeight: 860 },
1631
+ mixed: { backgroundBrightness: '-0.42', backgroundSaturation: '1.30', phoneHeight: 860 },
1632
+ split: { backgroundBrightness: '-0.40', backgroundSaturation: '1.30', phoneHeight: 980 },
1633
+ };
1634
+ return settings[layoutName] || settings.split;
1635
+ }
1636
+
1637
+ function buildScenePositionExpression(layoutName, propertyName) {
1638
+ const values = sceneWindows.map((_, index) => getSceneComposition(layoutName, index)[propertyName]);
1639
+ if (values.every((value) => value === values[0])) return values[0];
1640
+
1641
+ let expression = values.at(-1);
1642
+ for (let index = values.length - 2; index >= 0; index -= 1) {
1643
+ const window = sceneWindows[index];
1644
+ expression = `if(between(t\\,${window.start}\\,${window.end})\\,${values[index]}\\,${expression})`;
1645
+ }
1646
+ return expression;
1647
+ }
1648
+
1649
+ function buildPromoStageFilter(layoutName) {
1650
+ const accent = promoDesign.accent;
1651
+ if (layoutName === 'dynamic') {
1652
+ return `[background]drawbox=x=45:y=35:w=610:h=1010:color=black@0.38:t=fill,drawbox=x=1265:y=35:w=610:h=1010:color=black@0.38:t=fill,drawbox=x=52:y=42:w=596:h=996:color=${accent}@0.38:t=3,drawbox=x=1272:y=42:w=596:h=996:color=${accent}@0.38:t=3,drawbox=x=690:y=105:w=540:h=3:color=${accent}@0.72:t=fill[stage]`;
1653
+ }
1654
+ if (layoutName === 'centered') {
1655
+ return `[background]drawbox=x=0:y=18:w=1920:h=170:color=black@0.62:t=fill,drawbox=x=0:y=875:w=1920:h=180:color=black@0.68:t=fill,drawbox=x=612:y=210:w=696:h=670:color=${accent}@0.46:t=4,drawbox=x=660:y=200:w=600:h=3:color=${accent}@0.78:t=fill[stage]`;
1656
+ }
1657
+ if (layoutName === 'floating') {
1658
+ return `[background]drawbox=x=55:y=55:w=680:h=970:color=black@0.52:t=fill,drawbox=x=63:y=63:w=664:h=954:color=${accent}@0.52:t=4,drawbox=x=960:y=175:w=900:h=650:color=black@0.46:t=fill,drawbox=x=978:y=195:w=6:h=610:color=${accent}@0.85:t=fill[stage]`;
1659
+ }
1660
+ if (layoutName === 'cinematic') {
1661
+ return '[background]null[stage]';
1662
+ }
1663
+ if (layoutName === 'mixed') {
1664
+ return `[background]drawbox=x=35:y=28:w=1850:h=1024:color=black@0.32:t=fill,drawbox=x=43:y=36:w=1834:h=1008:color=${accent}@0.30:t=3,drawbox=x=640:y=80:w=3:h=920:color=white@0.10:t=fill,drawbox=x=1277:y=80:w=3:h=920:color=white@0.10:t=fill,drawbox=x=90:y=690:w=1740:h=3:color=${accent}@0.62:t=fill[stage]`;
1665
+ }
1666
+ return `[background]drawbox=x=45:y=30:w=570:h=1020:color=black@0.58:t=fill,drawbox=x=52:y=37:w=556:h=1006:color=${accent}@0.42:t=3,drawbox=x=680:y=176:w=1050:h=2:color=${accent}@0.55:t=fill,drawbox=x=680:y=960:w=1050:h=2:color=white@0.16:t=fill,drawbox=x=680:y=176:w=10:h=786:color=${accent}@0.90:t=fill[stage]`;
1667
+ }
1668
+
1669
+ function buildPromoForegroundFilter(layoutName) {
1670
+ if (layoutName !== 'cinematic') return '[layout]null[layoutfinished]';
1671
+ return `[layout]drawbox=x=0:y=0:w=1920:h=145:color=black@0.52:t=fill,drawbox=x=0:y=665:w=1920:h=415:color=black@0.72:t=fill,drawbox=x=90:y=690:w=1740:h=3:color=${promoDesign.accent}@0.80:t=fill[layoutfinished]`;
1672
+ }
1673
+
1674
+ function getFinalSceneIconPosition(layoutName) {
1675
+ const composition = getSceneComposition(layoutName, sceneWindows.length - 1);
1676
+ if (composition.styleSuffix === 'Center') return { x: 80, y: 850 };
1677
+ return { x: composition.textX, y: 650 };
1678
+ }
1679
+
1680
+ function generatePromo(
1681
+ ffmpegCommand,
1682
+ inputPath,
1683
+ outputPath,
1684
+ crop,
1685
+ targetWidth,
1686
+ targetHeight,
1687
+ layoutName,
1688
+ ) {
1509
1689
  const subtitleFilterPath = escapeFilterPath(captionPath);
1690
+ const layoutSettings = getPromoLayoutSettings(layoutName);
1691
+ const phoneX = buildScenePositionExpression(layoutName, 'phoneX');
1692
+ const phoneY = buildScenePositionExpression(layoutName, 'phoneY');
1693
+ const iconPosition = getFinalSceneIconPosition(layoutName);
1510
1694
  const is4K = targetWidth >= 3840 || targetHeight >= 2160;
1511
1695
  const videoBitrate = is4K ? '24M' : '9M';
1512
1696
  const maximumBitrate = is4K ? '32M' : '12M';
@@ -1539,12 +1723,13 @@ function generatePromo(ffmpegCommand, inputPath, outputPath, crop, targetWidth,
1539
1723
  videoSourceFilter,
1540
1724
  ...videoTrimFilters,
1541
1725
  videoSequenceFilter,
1542
- `[bgsrc]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,gblur=sigma=34,eq=brightness=-0.40:saturation=1.30,drawbox=x=0:y=0:w=iw:h=ih:color=${promoDesign.background}@0.72:t=fill[background]`,
1543
- `[background]drawbox=x=${promoDesign.panelX}:y=30:w=${promoDesign.panelWidth}:h=1020:color=black@0.58:t=fill,drawbox=x=${promoDesign.panelX + 7}:y=37:w=${promoDesign.panelWidth - 14}:h=1006:color=${promoDesign.accent}@0.24:t=3,drawbox=x=680:y=176:w=1050:h=2:color=${promoDesign.accent}@0.55:t=fill,drawbox=x=680:y=960:w=1050:h=2:color=white@0.16:t=fill,drawbox=x=680:y=176:w=10:h=786:color=0xffb020@0.90:t=fill[stage]`,
1544
- '[phonesrc]scale=-2:980,setsar=1[phone]',
1545
- `[stage][phone]overlay=x=${promoDesign.phoneX}:y=50:shortest=1[layout]`,
1726
+ `[bgsrc]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,gblur=sigma=34,eq=brightness=${layoutSettings.backgroundBrightness}:saturation=${layoutSettings.backgroundSaturation},drawbox=x=0:y=0:w=iw:h=ih:color=${promoDesign.background}@0.72:t=fill[background]`,
1727
+ buildPromoStageFilter(layoutName),
1728
+ `[phonesrc]scale=-2:${layoutSettings.phoneHeight},setsar=1[phone]`,
1729
+ `[stage][phone]overlay=x='${phoneX}':y='${phoneY}':eval=frame:shortest=1[layout]`,
1730
+ buildPromoForegroundFilter(layoutName),
1546
1731
  '[1:v]scale=145:145,format=rgba,colorchannelmixer=aa=0.96[icon]',
1547
- `[layout][icon]overlay=x=720:y=650:enable=between(t\\,${finalSceneStart}\\,${expectedDuration}):shortest=1[branded]`,
1732
+ `[layoutfinished][icon]overlay=x=${iconPosition.x}:y=${iconPosition.y}:enable=between(t\\,${finalSceneStart}\\,${expectedDuration}):shortest=1[branded]`,
1548
1733
  `[branded]ass=filename='${subtitleFilterPath}',scale=${targetWidth}:${targetHeight}:flags=lanczos,setsar=1,format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[captioned]`,
1549
1734
  `[3:v]scale=1920:1080,format=yuv420p[cardbanner]`,
1550
1735
  `[captioned][cardbanner]overlay=eof_action=repeat:enable=between(t\\,${endCardStart}\\,${expectedDuration})[cardonly]`,
@@ -1684,27 +1869,34 @@ function getTargetDimensions(videoDetails) {
1684
1869
  : { width: 1920, height: 1080 };
1685
1870
  }
1686
1871
 
1687
- function getBatchOutputPath(inputPath, targetWidth, targetHeight) {
1872
+ function getLayoutOutputSuffix(options, layoutName) {
1873
+ if (!options.layoutExplicit) return '';
1874
+ return options.layout === 'random'
1875
+ ? `-layout-random-${layoutName}`
1876
+ : `-layout-${layoutName}`;
1877
+ }
1878
+
1879
+ function getBatchOutputPath(inputPath, targetWidth, targetHeight, layoutSuffix = '') {
1688
1880
  const relativeDirectory = path.relative(sourceVideoDirectory, path.dirname(inputPath));
1689
1881
  const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
1690
1882
  return path.join(
1691
1883
  outputVideoDirectory,
1692
1884
  relativeDirectory,
1693
- `${appFilePrefix}-${sourceName}-promo-english-voiceover-${targetWidth}x${targetHeight}.mp4`,
1885
+ `${appFilePrefix}-${sourceName}-promo-english-voiceover${layoutSuffix}-${targetWidth}x${targetHeight}.mp4`,
1694
1886
  );
1695
1887
  }
1696
1888
 
1697
- function getSingleOutputPath(inputPath, targetWidth, targetHeight) {
1889
+ function getSingleOutputPath(inputPath, targetWidth, targetHeight, layoutSuffix = '') {
1698
1890
  const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
1699
1891
  return path.join(
1700
1892
  outputVideoDirectory,
1701
- `${appFilePrefix}-${sourceName}-promo-english-voiceover-${targetWidth}x${targetHeight}.mp4`,
1893
+ `${appFilePrefix}-${sourceName}-promo-english-voiceover${layoutSuffix}-${targetWidth}x${targetHeight}.mp4`,
1702
1894
  );
1703
1895
  }
1704
1896
 
1705
- async function main() {
1706
- const options = parseArguments();
1707
- await ensureAppStoreDownloadUrl();
1897
+ async function main() {
1898
+ const options = parseArguments();
1899
+ await ensureAppStoreDownloadUrl();
1708
1900
  if (options.listVoices) {
1709
1901
  printVoiceCatalog();
1710
1902
  return;
@@ -1721,15 +1913,21 @@ async function main() {
1721
1913
  return;
1722
1914
  }
1723
1915
 
1724
- const ffmpegCommand = getExecutable('ffmpeg');
1916
+ const layoutName = resolvePromoLayout(options.layout);
1917
+ const layoutSuffix = getLayoutOutputSuffix(options, layoutName);
1918
+ console.log(
1919
+ `Promo layout: ${layoutName}${options.layout === 'random' ? ' (randomly selected)' : ''}.`,
1920
+ );
1921
+
1922
+ const ffmpegCommand = getExecutable('ffmpeg');
1725
1923
  const ffprobeCommand = getExecutable('ffprobe');
1726
1924
  const voice = resolveVoice(options.voice);
1727
1925
 
1728
- if (!ffmpegCommand || !ffprobeCommand) {
1729
- throw new Error('FFmpeg and FFprobe were not found in PATH or FFMPEG_BIN.');
1730
- }
1731
- if (!fs.existsSync(iconPath)) throw new Error(`App icon was not found: ${iconPath}`);
1732
- await ensureEndCardBanner();
1926
+ if (!ffmpegCommand || !ffprobeCommand) {
1927
+ throw new Error('FFmpeg and FFprobe were not found in PATH or FFMPEG_BIN.');
1928
+ }
1929
+ if (!fs.existsSync(iconPath)) throw new Error(`App icon was not found: ${iconPath}`);
1930
+ await ensureEndCardBanner();
1733
1931
 
1734
1932
  const inputPaths = options.input
1735
1933
  ? [path.resolve(options.input)]
@@ -1751,21 +1949,21 @@ async function main() {
1751
1949
  const outputPath = options.output
1752
1950
  ? path.resolve(options.output)
1753
1951
  : options.input
1754
- ? getSingleOutputPath(inputPath, target.width, target.height)
1755
- : getBatchOutputPath(inputPath, target.width, target.height);
1952
+ ? getSingleOutputPath(inputPath, target.width, target.height, layoutSuffix)
1953
+ : getBatchOutputPath(inputPath, target.width, target.height, layoutSuffix);
1756
1954
  return { crop, duration, inputPath, outputPath, processingInputPath, target };
1757
1955
  });
1758
- const analysisSource = [...sourceVideos].sort((left, right) => (
1759
- (right.target.width * right.target.height) - (left.target.width * left.target.height)
1760
- ))[0];
1761
- const sourceDuration = Math.min(...sourceVideos.map((source) => source.duration));
1762
- const redundantStaticRanges = detectRedundantStaticRanges(
1763
- ffmpegCommand,
1764
- analysisSource.inputPath,
1765
- sourceDuration,
1766
- );
1767
- const visualChangeTimes = detectVisualChangeTimes(ffmpegCommand, analysisSource.inputPath);
1768
- configurePromoTimeline(sourceDuration, redundantStaticRanges, visualChangeTimes);
1956
+ const analysisSource = [...sourceVideos].sort((left, right) => (
1957
+ (right.target.width * right.target.height) - (left.target.width * left.target.height)
1958
+ ))[0];
1959
+ const sourceDuration = Math.min(...sourceVideos.map((source) => source.duration));
1960
+ const redundantStaticRanges = detectRedundantStaticRanges(
1961
+ ffmpegCommand,
1962
+ analysisSource.inputPath,
1963
+ sourceDuration,
1964
+ );
1965
+ const visualChangeTimes = detectVisualChangeTimes(ffmpegCommand, analysisSource.inputPath);
1966
+ configurePromoTimeline(sourceDuration, redundantStaticRanges, visualChangeTimes);
1769
1967
  const promoCopyResult = await generateOrLoadPromoCopy(
1770
1968
  options,
1771
1969
  ffmpegCommand,
@@ -1835,7 +2033,7 @@ async function main() {
1835
2033
  'edge-tts is required for natural neural voices. Install it with: py -m pip install edge-tts',
1836
2034
  );
1837
2035
  }
1838
- createCaptionFile(promoCopyResult.copy);
2036
+ createCaptionFile(promoCopyResult.copy, layoutName);
1839
2037
  console.log(`Creating natural neural narration with: ${voice.label}`);
1840
2038
  const narrationSegmentPaths = createNarrationSegments(
1841
2039
  pythonCommand,
@@ -1855,6 +2053,7 @@ async function main() {
1855
2053
  source.crop,
1856
2054
  source.target.width,
1857
2055
  source.target.height,
2056
+ layoutName,
1858
2057
  );
1859
2058
  validateOutput(
1860
2059
  ffprobeCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeplay-common",
3
- "version": "4.5.1",
3
+ "version": "4.5.2",
4
4
  "description": "Common build scripts and files",
5
5
  "scripts": {
6
6
  "postinstall": "node scripts/sync-files.js",