codeplay-common 4.4.4 → 4.4.6
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/files/generate-ai-promo-video.js +288 -119
- package/package.json +1 -1
|
@@ -31,7 +31,7 @@ const appFilePrefix = `${appUniqueId}. ${sanitizeFileNamePart(appName)}`;
|
|
|
31
31
|
const playStoreDownloadUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageId)}`;
|
|
32
32
|
const configuredAppStoreUrl = String(iosStoreConfig.appStoreUrl || '')
|
|
33
33
|
.match(/https:\/\/apps\.apple\.com\/app\/id\d+/)?.[0] || '';
|
|
34
|
-
|
|
34
|
+
const appStoreDownloadUrl = /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(configuredAppStoreUrl)
|
|
35
35
|
? configuredAppStoreUrl
|
|
36
36
|
: '';
|
|
37
37
|
const promoDesignIndex = createHash('sha1').update(`${appUniqueId}:${appName}`).digest()[0] % 4;
|
|
@@ -51,7 +51,7 @@ const spatialXrVideoPath = path.join(
|
|
|
51
51
|
spatialXrDirectory,
|
|
52
52
|
`${appFilePrefix}-spatial-xr-3d-sbs-lr-3840x1080.mp4`,
|
|
53
53
|
);
|
|
54
|
-
const iconPath = path.join(projectDirectory, '
|
|
54
|
+
const iconPath = path.join(projectDirectory, 'resources', 'icon-only.png');
|
|
55
55
|
const temporaryDirectory = path.join(projectDirectory, 'agent-temp', 'ai-promo-video');
|
|
56
56
|
const captionPath = path.join(temporaryDirectory, `${appUniqueId}-promo-english.ass`);
|
|
57
57
|
const narrationTrackPath = path.join(temporaryDirectory, `${appUniqueId}-promo-narration.wav`);
|
|
@@ -65,14 +65,13 @@ const analysisFrameDirectory = path.join(temporaryDirectory, 'ai-analysis');
|
|
|
65
65
|
const endCardBannerPath = path.join(outputVideoDirectory, `${appFilePrefix}-ai-end-card-banner.png`);
|
|
66
66
|
const promoCopySchemaPath = path.join(temporaryDirectory, 'promo-copy-schema.json');
|
|
67
67
|
const promoCopyResultPath = path.join(temporaryDirectory, 'promo-copy-result.json');
|
|
68
|
-
const ffmpegFallbackDirectory = '
|
|
69
|
-
const
|
|
70
|
-
const minimumSourceDuration = 12;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let
|
|
74
|
-
let
|
|
75
|
-
let sceneWindows = [];
|
|
68
|
+
const ffmpegFallbackDirectory = String(process.env.FFMPEG_BIN || '').trim();
|
|
69
|
+
const minimumSceneCount = 7;
|
|
70
|
+
const minimumSourceDuration = 12;
|
|
71
|
+
let expectedDuration = 34;
|
|
72
|
+
let editSegments = [];
|
|
73
|
+
let sceneWindows = [];
|
|
74
|
+
let sceneCount = minimumSceneCount;
|
|
76
75
|
const thumbnailTime = 2;
|
|
77
76
|
const thumbnailWidth = 3840;
|
|
78
77
|
const thumbnailHeight = 2160;
|
|
@@ -81,7 +80,7 @@ const spatialEyeWidth = 1920;
|
|
|
81
80
|
const spatialEyeHeight = 1080;
|
|
82
81
|
const spatialOutputWidth = spatialEyeWidth * 2;
|
|
83
82
|
const spatialOutputHeight = spatialEyeHeight;
|
|
84
|
-
const promoCopySchemaVersion =
|
|
83
|
+
const promoCopySchemaVersion = 5;
|
|
85
84
|
const promoCopySchema = {
|
|
86
85
|
type: 'object',
|
|
87
86
|
additionalProperties: false,
|
|
@@ -170,39 +169,41 @@ function sanitizeFileNamePart(value) {
|
|
|
170
169
|
.replace(/[. ]+$/g, '') || 'App';
|
|
171
170
|
}
|
|
172
171
|
|
|
173
|
-
async function
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
+ '
|
|
204
|
-
|
|
205
|
-
|
|
172
|
+
async function ensureEndCardBanner() {
|
|
173
|
+
if (fs.existsSync(endCardBannerPath)) return;
|
|
174
|
+
throw new Error(
|
|
175
|
+
`AI end-card banner is missing. Generate it manually and save it here:\n${endCardBannerPath}\n\n`
|
|
176
|
+
+ 'Create a promotional video end-card banner for this application.\n\n'
|
|
177
|
+
+ 'Requirements:\n'
|
|
178
|
+
+ '- Final image size must be exactly 1672 × 941 pixels.\n'
|
|
179
|
+
+ '- Save it as:\n'
|
|
180
|
+
+ ` ${endCardBannerPath}\n`
|
|
181
|
+
+ `- Use this exact app icon file: ${iconPath}\n`
|
|
182
|
+
+ '- Do not generate, redraw, reinterpret, or replace the app icon.\n'
|
|
183
|
+
+ '- Use the complete official black “Download on the App Store” badge.\n'
|
|
184
|
+
+ '- Use the complete official black “Get it on Google Play” badge.\n'
|
|
185
|
+
+ '- Download the official badges from Apple and Google if they are not available in the project.\n'
|
|
186
|
+
+ '- Do not generate or imitate the Apple, Google Play, or application logos.\n'
|
|
187
|
+
+ '- Keep all logos and badge text sharp, correctly proportioned, and completely visible.\n'
|
|
188
|
+
+ '- Match the background colors and visual style to the actual app icon.\n'
|
|
189
|
+
+ '- Create a polished, professional background related to the app’s purpose.\n'
|
|
190
|
+
+ '- Keep the background subtle so it does not compete with the icon or download badges.\n'
|
|
191
|
+
+ '- Use a balanced landscape composition:\n'
|
|
192
|
+
+ ' - Large actual app icon on the left.\n'
|
|
193
|
+
+ ' - App Store badge followed by Google Play badge on the right.\n'
|
|
194
|
+
+ ' - Maintain comfortable margins and clear space around every element.\n'
|
|
195
|
+
+ '- Do not add extra marketing text unless it is specifically requested.\n'
|
|
196
|
+
+ '- Do not add watermarks, fake UI, generated symbols, or unrelated decorations.\n'
|
|
197
|
+
+ '- Rounded corners and a subtle shadow may be applied around the app icon, but its internal artwork must remain unchanged.\n'
|
|
198
|
+
+ '- Use image generation only for the background.\n'
|
|
199
|
+
+ '- Composite the actual app icon and official store badges afterward.\n'
|
|
200
|
+
+ '- Replace the specified output file if it already exists.\n'
|
|
201
|
+
+ '- Verify the final PNG dimensions are exactly 1672 × 941.\n'
|
|
202
|
+
+ '- Visually inspect the completed image before finishing.\n'
|
|
203
|
+
+ '- Report the final saved path.\n\n'
|
|
204
|
+
+ 'Then run the video-generation command again.',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
206
207
|
|
|
207
208
|
function findVideos(directory) {
|
|
208
209
|
if (!fs.existsSync(directory)) return [];
|
|
@@ -225,7 +226,7 @@ function roundTimelineValue(value) {
|
|
|
225
226
|
return Math.round(value * 100) / 100;
|
|
226
227
|
}
|
|
227
228
|
|
|
228
|
-
function mapOutputTimeToSource(outputTime) {
|
|
229
|
+
function mapOutputTimeToSource(outputTime) {
|
|
229
230
|
let remainingTime = outputTime;
|
|
230
231
|
for (const segment of editSegments) {
|
|
231
232
|
const segmentDuration = segment.end - segment.start;
|
|
@@ -234,62 +235,150 @@ function mapOutputTimeToSource(outputTime) {
|
|
|
234
235
|
}
|
|
235
236
|
remainingTime -= segmentDuration;
|
|
236
237
|
}
|
|
237
|
-
return editSegments.at(-1).end - 0.05;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function
|
|
241
|
-
|
|
238
|
+
return editSegments.at(-1).end - 0.05;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function mapSourceTimeToOutput(sourceTime) {
|
|
242
|
+
let outputTime = 0;
|
|
243
|
+
for (const segment of editSegments) {
|
|
244
|
+
if (sourceTime < segment.start) return outputTime;
|
|
245
|
+
if (sourceTime <= segment.end) return outputTime + (sourceTime - segment.start);
|
|
246
|
+
outputTime += segment.end - segment.start;
|
|
247
|
+
}
|
|
248
|
+
return outputTime;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function normalizeSkippedRanges(skippedRanges, sourceDuration) {
|
|
252
|
+
const ranges = skippedRanges
|
|
253
|
+
.map((range) => ({
|
|
254
|
+
start: Math.max(0, Math.min(sourceDuration, range.start)),
|
|
255
|
+
end: Math.max(0, Math.min(sourceDuration, range.end)),
|
|
256
|
+
}))
|
|
257
|
+
.filter((range) => range.end > range.start)
|
|
258
|
+
.sort((left, right) => left.start - right.start);
|
|
259
|
+
|
|
260
|
+
return ranges.reduce((merged, range) => {
|
|
261
|
+
const previous = merged.at(-1);
|
|
262
|
+
if (previous && range.start <= previous.end) {
|
|
263
|
+
previous.end = Math.max(previous.end, range.end);
|
|
264
|
+
} else {
|
|
265
|
+
merged.push({ ...range });
|
|
266
|
+
}
|
|
267
|
+
return merged;
|
|
268
|
+
}, []);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildRetainedSegments(sourceDuration, skippedRanges) {
|
|
272
|
+
const normalizedRanges = normalizeSkippedRanges(skippedRanges, sourceDuration);
|
|
273
|
+
const retainedSegments = [];
|
|
274
|
+
let retainedStart = 0;
|
|
275
|
+
|
|
276
|
+
for (const range of normalizedRanges) {
|
|
277
|
+
if (range.start > retainedStart) {
|
|
278
|
+
retainedSegments.push({ start: retainedStart, end: range.start });
|
|
279
|
+
}
|
|
280
|
+
retainedStart = range.end;
|
|
281
|
+
}
|
|
282
|
+
if (retainedStart < sourceDuration) {
|
|
283
|
+
retainedSegments.push({ start: retainedStart, end: sourceDuration });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
retainedSegments: retainedSegments.map((segment) => ({
|
|
288
|
+
start: roundTimelineValue(segment.start),
|
|
289
|
+
end: roundTimelineValue(segment.end),
|
|
290
|
+
})),
|
|
291
|
+
skippedDuration: normalizedRanges.reduce(
|
|
292
|
+
(total, range) => total + (range.end - range.start),
|
|
293
|
+
0,
|
|
294
|
+
),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function buildSceneWindows(retainedDuration, visualChangeTimes) {
|
|
299
|
+
const minimumWindowDuration = Math.max(2.5, Math.min(4, retainedDuration / 40));
|
|
300
|
+
const maximumWindowDuration = Math.max(6, Math.min(10, retainedDuration / 14));
|
|
301
|
+
const boundaries = [0];
|
|
302
|
+
const mappedChanges = visualChangeTimes
|
|
303
|
+
.map((sourceTime) => mapSourceTimeToOutput(sourceTime))
|
|
304
|
+
.filter((outputTime) => outputTime > 0 && outputTime < retainedDuration)
|
|
305
|
+
.sort((left, right) => left - right);
|
|
306
|
+
|
|
307
|
+
for (const changeTime of mappedChanges) {
|
|
308
|
+
if (changeTime - boundaries.at(-1) >= minimumWindowDuration) {
|
|
309
|
+
boundaries.push(changeTime);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (retainedDuration - boundaries.at(-1) < minimumWindowDuration) boundaries.pop();
|
|
313
|
+
boundaries.push(retainedDuration);
|
|
314
|
+
|
|
315
|
+
const windows = [];
|
|
316
|
+
for (let index = 0; index < boundaries.length - 1; index += 1) {
|
|
317
|
+
const start = boundaries[index];
|
|
318
|
+
const end = boundaries[index + 1];
|
|
319
|
+
const splitCount = Math.max(1, Math.ceil((end - start) / maximumWindowDuration));
|
|
320
|
+
for (let splitIndex = 0; splitIndex < splitCount; splitIndex += 1) {
|
|
321
|
+
windows.push({
|
|
322
|
+
start: roundTimelineValue(start + (((end - start) * splitIndex) / splitCount)),
|
|
323
|
+
end: roundTimelineValue(start + (((end - start) * (splitIndex + 1)) / splitCount)),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (windows.length >= minimumSceneCount) return windows;
|
|
329
|
+
return Array.from({ length: minimumSceneCount }, (_, index) => ({
|
|
330
|
+
start: roundTimelineValue((retainedDuration * index) / minimumSceneCount),
|
|
331
|
+
end: index === minimumSceneCount - 1
|
|
332
|
+
? retainedDuration
|
|
333
|
+
: roundTimelineValue((retainedDuration * (index + 1)) / minimumSceneCount),
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function configurePromoTimeline(sourceDuration, skippedRanges = [], visualChangeTimes = []) {
|
|
338
|
+
if (!Number.isFinite(sourceDuration) || sourceDuration < minimumSourceDuration) {
|
|
242
339
|
throw new Error(
|
|
243
340
|
`The shortest source video must be at least ${minimumSourceDuration} seconds.`,
|
|
244
341
|
);
|
|
245
342
|
}
|
|
246
343
|
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const sceneDuration = end - start;
|
|
278
|
-
return {
|
|
279
|
-
start,
|
|
280
|
-
end,
|
|
281
|
-
sourceTime: roundTimelineValue(mapOutputTimeToSource(start + (sceneDuration / 2))),
|
|
282
|
-
maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
|
|
283
|
-
};
|
|
284
|
-
});
|
|
285
|
-
endCardStart = expectedDuration;
|
|
344
|
+
const { retainedSegments, skippedDuration } = buildRetainedSegments(
|
|
345
|
+
sourceDuration,
|
|
346
|
+
skippedRanges,
|
|
347
|
+
);
|
|
348
|
+
editSegments = retainedSegments.filter((segment) => segment.end > segment.start);
|
|
349
|
+
const retainedDuration = editSegments.reduce(
|
|
350
|
+
(total, segment) => total + (segment.end - segment.start),
|
|
351
|
+
0,
|
|
352
|
+
);
|
|
353
|
+
if (retainedDuration < minimumSourceDuration) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`At least ${minimumSourceDuration} seconds must remain after automatic cleanup.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
expectedDuration = roundTimelineValue(retainedDuration);
|
|
360
|
+
sceneWindows = buildSceneWindows(expectedDuration, visualChangeTimes).map((window) => {
|
|
361
|
+
const sceneDuration = window.end - window.start;
|
|
362
|
+
return {
|
|
363
|
+
...window,
|
|
364
|
+
sourceTime: roundTimelineValue(mapOutputTimeToSource(
|
|
365
|
+
window.start + (sceneDuration / 2),
|
|
366
|
+
)),
|
|
367
|
+
maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
sceneCount = sceneWindows.length;
|
|
371
|
+
promoCopySchema.properties.scenes.minItems = sceneCount;
|
|
372
|
+
promoCopySchema.properties.scenes.maxItems = sceneCount;
|
|
373
|
+
endCardStart = expectedDuration;
|
|
286
374
|
expectedDuration = roundTimelineValue(expectedDuration + endCardDuration);
|
|
287
375
|
|
|
288
376
|
console.log(
|
|
289
|
-
`
|
|
290
|
-
+ `${editSegments.length}
|
|
291
|
-
|
|
292
|
-
|
|
377
|
+
`Feature-complete promo timeline: ${expectedDuration}s from ${sourceDuration.toFixed(2)}s source; `
|
|
378
|
+
+ `${sceneCount} scenes and ${editSegments.length} retained segment${editSegments.length === 1 ? '' : 's'}`
|
|
379
|
+
+ `${skippedDuration > 0 ? ` (${skippedDuration.toFixed(2)}s of redundant static footage removed)` : ''}.`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
293
382
|
|
|
294
383
|
function escapeAssText(value) {
|
|
295
384
|
return String(value)
|
|
@@ -332,14 +421,85 @@ function commandExists(command) {
|
|
|
332
421
|
return !result.error && result.status === 0;
|
|
333
422
|
}
|
|
334
423
|
|
|
335
|
-
function getExecutable(command) {
|
|
336
|
-
if (commandExists(command)) return command;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
424
|
+
function getExecutable(command) {
|
|
425
|
+
if (commandExists(command)) return command;
|
|
426
|
+
|
|
427
|
+
if (!ffmpegFallbackDirectory) return '';
|
|
428
|
+
const executableName = process.platform === 'win32' ? `${command}.exe` : command;
|
|
429
|
+
const fallbackPath = path.join(ffmpegFallbackDirectory, executableName);
|
|
430
|
+
return fs.existsSync(fallbackPath) ? fallbackPath : '';
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function detectRedundantStaticRanges(ffmpegCommand, inputPath, sourceDuration) {
|
|
434
|
+
const minimumStaticDuration = Math.max(2, Math.min(4, sourceDuration / 60));
|
|
435
|
+
const result = spawnSync(ffmpegCommand, [
|
|
436
|
+
'-hide_banner',
|
|
437
|
+
'-nostats',
|
|
438
|
+
'-i', inputPath,
|
|
439
|
+
'-vf', `freezedetect=n=-50dB:d=${minimumStaticDuration}`,
|
|
440
|
+
'-an',
|
|
441
|
+
'-f', 'null',
|
|
442
|
+
process.platform === 'win32' ? 'NUL' : '/dev/null',
|
|
443
|
+
], {
|
|
444
|
+
encoding: 'utf8',
|
|
445
|
+
windowsHide: true,
|
|
446
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
447
|
+
});
|
|
448
|
+
if (result.error || result.status !== 0) {
|
|
449
|
+
console.warn('Static-screen analysis was unavailable; retaining the complete recording.');
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const events = String(result.stderr || '').matchAll(
|
|
454
|
+
/lavfi\.freezedetect\.freeze_(start|end):\s*([\d.]+)/g,
|
|
455
|
+
);
|
|
456
|
+
const staticRanges = [];
|
|
457
|
+
let staticStart = null;
|
|
458
|
+
for (const event of events) {
|
|
459
|
+
const time = Number(event[2]);
|
|
460
|
+
if (event[1] === 'start') {
|
|
461
|
+
staticStart = time;
|
|
462
|
+
} else if (staticStart !== null && time > staticStart) {
|
|
463
|
+
staticRanges.push({ start: staticStart, end: time });
|
|
464
|
+
staticStart = null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (staticStart !== null && sourceDuration > staticStart) {
|
|
468
|
+
staticRanges.push({ start: staticStart, end: sourceDuration });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return staticRanges
|
|
472
|
+
.map((range) => ({
|
|
473
|
+
start: range.start + minimumStaticDuration,
|
|
474
|
+
end: range.end,
|
|
475
|
+
}))
|
|
476
|
+
.filter((range) => range.end - range.start >= 0.75);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function detectVisualChangeTimes(ffmpegCommand, inputPath) {
|
|
480
|
+
const result = spawnSync(ffmpegCommand, [
|
|
481
|
+
'-hide_banner',
|
|
482
|
+
'-nostats',
|
|
483
|
+
'-i', inputPath,
|
|
484
|
+
'-vf', "select='gt(scene,0.03)',showinfo",
|
|
485
|
+
'-an',
|
|
486
|
+
'-f', 'null',
|
|
487
|
+
process.platform === 'win32' ? 'NUL' : '/dev/null',
|
|
488
|
+
], {
|
|
489
|
+
encoding: 'utf8',
|
|
490
|
+
windowsHide: true,
|
|
491
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
492
|
+
});
|
|
493
|
+
if (result.error || result.status !== 0) {
|
|
494
|
+
console.warn('Visual-change analysis was unavailable; using duration-based scene coverage.');
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
return [...String(result.stderr || '').matchAll(/pts_time:([\d.]+)/g)]
|
|
498
|
+
.map((match) => Number(match[1]))
|
|
499
|
+
.filter(Number.isFinite);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function parseArguments() {
|
|
343
503
|
const args = process.argv.slice(2);
|
|
344
504
|
const options = {
|
|
345
505
|
aiModel: '',
|
|
@@ -400,9 +560,10 @@ function parseArguments() {
|
|
|
400
560
|
console.log(' node generate-ai-promo-video.js --list-voices');
|
|
401
561
|
console.log(' node generate-ai-promo-video.js --preview-voices');
|
|
402
562
|
console.log('');
|
|
403
|
-
console.log(
|
|
404
|
-
`Without --input, every MP4 under ${sourceVideoDirectory} except Samsung is processed.`,
|
|
405
|
-
);
|
|
563
|
+
console.log(
|
|
564
|
+
`Without --input, every MP4 under ${sourceVideoDirectory} except Samsung is processed.`,
|
|
565
|
+
);
|
|
566
|
+
console.log('Redundant static pauses are shortened automatically; distinct screens are retained.');
|
|
406
567
|
process.exit(0);
|
|
407
568
|
} else {
|
|
408
569
|
throw new Error(`Unknown argument: ${argument}`);
|
|
@@ -639,9 +800,12 @@ function buildPromoCopyPrompt(appContext, previousError = '') {
|
|
|
639
800
|
|
|
640
801
|
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.
|
|
641
802
|
|
|
642
|
-
Rules:
|
|
643
|
-
- Generate exactly ${sceneWindows.length} scenes in the same order as the images and timings.
|
|
644
|
-
- Use only features supported by the images or project context. Never invent claims.
|
|
803
|
+
Rules:
|
|
804
|
+
- Generate exactly ${sceneWindows.length} scenes in the same order as the images and timings.
|
|
805
|
+
- Use only features supported by the images or project context. Never invent claims.
|
|
806
|
+
- Cover every distinct demonstrated feature across the retained recording; do not omit later features.
|
|
807
|
+
- Do not promote incidental navigation, loading states, advertisements, permission prompts, or repeated screens as features.
|
|
808
|
+
- Keep each scene specific to what is visibly demonstrated in its supplied image whenever possible.
|
|
645
809
|
- The authoritative app name is ${JSON.stringify(appName)}. Mention it naturally in the final narration and final body.
|
|
646
810
|
- APP_UNIQUE_ID is internal and must never appear in audience-facing copy.
|
|
647
811
|
- Each kicker: 2-30 characters. Each title line: 1-22 characters. Body: 8-72 characters.
|
|
@@ -1493,17 +1657,15 @@ async function main() {
|
|
|
1493
1657
|
return;
|
|
1494
1658
|
}
|
|
1495
1659
|
|
|
1496
|
-
|
|
1497
|
-
await ensureEndCardBanner();
|
|
1498
|
-
|
|
1499
|
-
const ffmpegCommand = getExecutable('ffmpeg');
|
|
1660
|
+
const ffmpegCommand = getExecutable('ffmpeg');
|
|
1500
1661
|
const ffprobeCommand = getExecutable('ffprobe');
|
|
1501
1662
|
const voice = resolveVoice(options.voice);
|
|
1502
1663
|
|
|
1503
|
-
if (!ffmpegCommand || !ffprobeCommand) {
|
|
1504
|
-
throw new Error('FFmpeg and FFprobe were not found in PATH or
|
|
1505
|
-
}
|
|
1506
|
-
if (!fs.existsSync(iconPath)) throw new Error(`App icon was not found: ${iconPath}`);
|
|
1664
|
+
if (!ffmpegCommand || !ffprobeCommand) {
|
|
1665
|
+
throw new Error('FFmpeg and FFprobe were not found in PATH or FFMPEG_BIN.');
|
|
1666
|
+
}
|
|
1667
|
+
if (!fs.existsSync(iconPath)) throw new Error(`App icon was not found: ${iconPath}`);
|
|
1668
|
+
await ensureEndCardBanner();
|
|
1507
1669
|
|
|
1508
1670
|
const inputPaths = options.input
|
|
1509
1671
|
? [path.resolve(options.input)]
|
|
@@ -1529,10 +1691,17 @@ async function main() {
|
|
|
1529
1691
|
: getBatchOutputPath(inputPath, target.width, target.height);
|
|
1530
1692
|
return { crop, duration, inputPath, outputPath, processingInputPath, target };
|
|
1531
1693
|
});
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
))
|
|
1694
|
+
const analysisSource = [...sourceVideos].sort((left, right) => (
|
|
1695
|
+
(right.target.width * right.target.height) - (left.target.width * left.target.height)
|
|
1696
|
+
))[0];
|
|
1697
|
+
const sourceDuration = Math.min(...sourceVideos.map((source) => source.duration));
|
|
1698
|
+
const redundantStaticRanges = detectRedundantStaticRanges(
|
|
1699
|
+
ffmpegCommand,
|
|
1700
|
+
analysisSource.inputPath,
|
|
1701
|
+
sourceDuration,
|
|
1702
|
+
);
|
|
1703
|
+
const visualChangeTimes = detectVisualChangeTimes(ffmpegCommand, analysisSource.inputPath);
|
|
1704
|
+
configurePromoTimeline(sourceDuration, redundantStaticRanges, visualChangeTimes);
|
|
1536
1705
|
const promoCopyResult = await generateOrLoadPromoCopy(
|
|
1537
1706
|
options,
|
|
1538
1707
|
ffmpegCommand,
|