codeplay-common 4.5.0 → 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.
- package/files/generate-ai-promo-video.js +588 -325
- package/package.json +1 -1
|
@@ -1,11 +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
17
|
const { createHash } = require('node:crypto');
|
|
8
18
|
const { spawnSync } = require('node:child_process');
|
|
19
|
+
const { createInterface } = require('node:readline/promises');
|
|
9
20
|
|
|
10
21
|
const projectDirectory = __dirname;
|
|
11
22
|
const capacitorConfigPath = path.join(projectDirectory, 'capacitor.config.json');
|
|
@@ -31,7 +42,7 @@ const appFilePrefix = `${appUniqueId}. ${sanitizeFileNamePart(appName)}`;
|
|
|
31
42
|
const playStoreDownloadUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageId)}`;
|
|
32
43
|
const configuredAppStoreUrl = String(iosStoreConfig.appStoreUrl || '')
|
|
33
44
|
.match(/https:\/\/apps\.apple\.com\/app\/id\d+/)?.[0] || '';
|
|
34
|
-
|
|
45
|
+
let appStoreDownloadUrl = /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(configuredAppStoreUrl)
|
|
35
46
|
? configuredAppStoreUrl
|
|
36
47
|
: '';
|
|
37
48
|
const promoDesignIndex = createHash('sha1').update(`${appUniqueId}:${appName}`).digest()[0] % 4;
|
|
@@ -42,6 +53,16 @@ const promoDesigns = [
|
|
|
42
53
|
{ accent: '0xff5b8d', background: '0x21101a', panelX: 1240, panelWidth: 550, phoneX: 64 },
|
|
43
54
|
];
|
|
44
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');
|
|
45
66
|
let endCardStart = 0;
|
|
46
67
|
const endCardDuration = 8;
|
|
47
68
|
const sourceVideoDirectory = path.join(projectDirectory, 'Auto-Screenshot', 'Video', 'Output');
|
|
@@ -51,7 +72,7 @@ const spatialXrVideoPath = path.join(
|
|
|
51
72
|
spatialXrDirectory,
|
|
52
73
|
`${appFilePrefix}-spatial-xr-3d-sbs-lr-3840x1080.mp4`,
|
|
53
74
|
);
|
|
54
|
-
const iconPath = path.join(projectDirectory, 'resources', 'icon-only.png');
|
|
75
|
+
const iconPath = path.join(projectDirectory, 'resources', 'icon-only.png');
|
|
55
76
|
const temporaryDirectory = path.join(projectDirectory, 'agent-temp', 'ai-promo-video');
|
|
56
77
|
const captionPath = path.join(temporaryDirectory, `${appUniqueId}-promo-english.ass`);
|
|
57
78
|
const narrationTrackPath = path.join(temporaryDirectory, `${appUniqueId}-promo-narration.wav`);
|
|
@@ -65,13 +86,13 @@ const analysisFrameDirectory = path.join(temporaryDirectory, 'ai-analysis');
|
|
|
65
86
|
const endCardBannerPath = path.join(outputVideoDirectory, `${appFilePrefix}-ai-end-card-banner.png`);
|
|
66
87
|
const promoCopySchemaPath = path.join(temporaryDirectory, 'promo-copy-schema.json');
|
|
67
88
|
const promoCopyResultPath = path.join(temporaryDirectory, 'promo-copy-result.json');
|
|
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;
|
|
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;
|
|
75
96
|
const thumbnailTime = 2;
|
|
76
97
|
const thumbnailWidth = 3840;
|
|
77
98
|
const thumbnailHeight = 2160;
|
|
@@ -80,7 +101,7 @@ const spatialEyeWidth = 1920;
|
|
|
80
101
|
const spatialEyeHeight = 1080;
|
|
81
102
|
const spatialOutputWidth = spatialEyeWidth * 2;
|
|
82
103
|
const spatialOutputHeight = spatialEyeHeight;
|
|
83
|
-
const promoCopySchemaVersion = 5;
|
|
104
|
+
const promoCopySchemaVersion = 5;
|
|
84
105
|
const promoCopySchema = {
|
|
85
106
|
type: 'object',
|
|
86
107
|
additionalProperties: false,
|
|
@@ -169,41 +190,62 @@ function sanitizeFileNamePart(value) {
|
|
|
169
190
|
.replace(/[. ]+$/g, '') || 'App';
|
|
170
191
|
}
|
|
171
192
|
|
|
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
|
-
}
|
|
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
|
+
}
|
|
207
249
|
|
|
208
250
|
function findVideos(directory) {
|
|
209
251
|
if (!fs.existsSync(directory)) return [];
|
|
@@ -226,7 +268,7 @@ function roundTimelineValue(value) {
|
|
|
226
268
|
return Math.round(value * 100) / 100;
|
|
227
269
|
}
|
|
228
270
|
|
|
229
|
-
function mapOutputTimeToSource(outputTime) {
|
|
271
|
+
function mapOutputTimeToSource(outputTime) {
|
|
230
272
|
let remainingTime = outputTime;
|
|
231
273
|
for (const segment of editSegments) {
|
|
232
274
|
const segmentDuration = segment.end - segment.start;
|
|
@@ -235,150 +277,150 @@ function mapOutputTimeToSource(outputTime) {
|
|
|
235
277
|
}
|
|
236
278
|
remainingTime -= segmentDuration;
|
|
237
279
|
}
|
|
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) {
|
|
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) {
|
|
339
381
|
throw new Error(
|
|
340
382
|
`The shortest source video must be at least ${minimumSourceDuration} seconds.`,
|
|
341
383
|
);
|
|
342
384
|
}
|
|
343
385
|
|
|
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;
|
|
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;
|
|
374
416
|
expectedDuration = roundTimelineValue(expectedDuration + endCardDuration);
|
|
375
417
|
|
|
376
418
|
console.log(
|
|
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
|
-
}
|
|
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
|
+
}
|
|
382
424
|
|
|
383
425
|
function escapeAssText(value) {
|
|
384
426
|
return String(value)
|
|
@@ -421,91 +463,93 @@ function commandExists(command) {
|
|
|
421
463
|
return !result.error && result.status === 0;
|
|
422
464
|
}
|
|
423
465
|
|
|
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() {
|
|
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() {
|
|
503
545
|
const args = process.argv.slice(2);
|
|
504
546
|
const options = {
|
|
505
547
|
aiModel: '',
|
|
506
548
|
aiProvider: 'auto',
|
|
507
549
|
copyOnly: false,
|
|
508
550
|
input: '',
|
|
551
|
+
layout: 'split',
|
|
552
|
+
layoutExplicit: false,
|
|
509
553
|
listVoices: false,
|
|
510
554
|
output: '',
|
|
511
555
|
previewVoices: false,
|
|
@@ -526,6 +570,10 @@ function parseArguments() {
|
|
|
526
570
|
} else if (argument === '--voice') {
|
|
527
571
|
options.voice = String(args[index + 1] || '').trim();
|
|
528
572
|
index += 1;
|
|
573
|
+
} else if (argument === '--layout') {
|
|
574
|
+
options.layout = String(args[index + 1] || '').trim().toLowerCase();
|
|
575
|
+
options.layoutExplicit = true;
|
|
576
|
+
index += 1;
|
|
529
577
|
} else if (argument === '--ai-provider') {
|
|
530
578
|
options.aiProvider = String(args[index + 1] || '').trim().toLowerCase();
|
|
531
579
|
index += 1;
|
|
@@ -550,6 +598,7 @@ function parseArguments() {
|
|
|
550
598
|
console.log('Usage:');
|
|
551
599
|
console.log(' node generate-ai-promo-video.js');
|
|
552
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');
|
|
553
602
|
console.log(' node generate-ai-promo-video.js --voice aria');
|
|
554
603
|
console.log(' node generate-ai-promo-video.js --refresh-ai-copy');
|
|
555
604
|
console.log(' node generate-ai-promo-video.js --copy-only');
|
|
@@ -560,10 +609,10 @@ function parseArguments() {
|
|
|
560
609
|
console.log(' node generate-ai-promo-video.js --list-voices');
|
|
561
610
|
console.log(' node generate-ai-promo-video.js --preview-voices');
|
|
562
611
|
console.log('');
|
|
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.');
|
|
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.');
|
|
567
616
|
process.exit(0);
|
|
568
617
|
} else {
|
|
569
618
|
throw new Error(`Unknown argument: ${argument}`);
|
|
@@ -573,6 +622,9 @@ function parseArguments() {
|
|
|
573
622
|
if (!['auto', 'codex', 'openai'].includes(options.aiProvider)) {
|
|
574
623
|
throw new Error('--ai-provider must be auto, codex, or openai.');
|
|
575
624
|
}
|
|
625
|
+
if (!promoLayoutNames.includes(options.layout)) {
|
|
626
|
+
throw new Error(`--layout must be one of: ${promoLayoutNames.join(', ')}.`);
|
|
627
|
+
}
|
|
576
628
|
if (options.output && !options.input) {
|
|
577
629
|
throw new Error('--output can only be used together with --input.');
|
|
578
630
|
}
|
|
@@ -580,6 +632,11 @@ function parseArguments() {
|
|
|
580
632
|
return options;
|
|
581
633
|
}
|
|
582
634
|
|
|
635
|
+
function resolvePromoLayout(requestedLayout) {
|
|
636
|
+
if (requestedLayout !== 'random') return requestedLayout;
|
|
637
|
+
return selectablePromoLayoutNames[Math.floor(Math.random() * selectablePromoLayoutNames.length)];
|
|
638
|
+
}
|
|
639
|
+
|
|
583
640
|
function getPythonExecutable() {
|
|
584
641
|
for (const candidate of ['py', 'python']) {
|
|
585
642
|
const result = spawnSync(candidate, ['-m', 'edge_tts', '--version'], {
|
|
@@ -723,12 +780,54 @@ function createCopyFingerprint(appContext, inputPaths) {
|
|
|
723
780
|
})).digest('hex');
|
|
724
781
|
}
|
|
725
782
|
|
|
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
|
+
|
|
726
816
|
function getCodexExecutable() {
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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 '';
|
|
732
831
|
}
|
|
733
832
|
|
|
734
833
|
function detectActiveVideoCrop(ffmpegCommand, inputPath, videoDetails) {
|
|
@@ -800,12 +899,12 @@ function buildPromoCopyPrompt(appContext, previousError = '') {
|
|
|
800
899
|
|
|
801
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.
|
|
802
901
|
|
|
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.
|
|
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.
|
|
809
908
|
- The authoritative app name is ${JSON.stringify(appName)}. Mention it naturally in the final narration and final body.
|
|
810
909
|
- APP_UNIQUE_ID is internal and must never appear in audience-facing copy.
|
|
811
910
|
- Each kicker: 2-30 characters. Each title line: 1-22 characters. Body: 8-72 characters.
|
|
@@ -912,11 +1011,10 @@ function validatePromoCopy(value) {
|
|
|
912
1011
|
}
|
|
913
1012
|
cleanedScene[field] = cleaned;
|
|
914
1013
|
}
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
);
|
|
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(' ');
|
|
920
1018
|
}
|
|
921
1019
|
return cleanedScene;
|
|
922
1020
|
});
|
|
@@ -1072,7 +1170,7 @@ async function generateOrLoadPromoCopy(
|
|
|
1072
1170
|
const framePaths = extractAnalysisFrames(ffmpegCommand, analysisInputPath, analysisCrop);
|
|
1073
1171
|
let previousError = '';
|
|
1074
1172
|
let generated;
|
|
1075
|
-
for (let attempt = 1; attempt <=
|
|
1173
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
1076
1174
|
const prompt = buildPromoCopyPrompt(appContext, previousError);
|
|
1077
1175
|
try {
|
|
1078
1176
|
generated = provider === 'openai'
|
|
@@ -1082,7 +1180,7 @@ async function generateOrLoadPromoCopy(
|
|
|
1082
1180
|
break;
|
|
1083
1181
|
} catch (error) {
|
|
1084
1182
|
previousError = error.message;
|
|
1085
|
-
if (attempt ===
|
|
1183
|
+
if (attempt === 3 || /unavailable|not configured|not signed in/i.test(previousError)) throw error;
|
|
1086
1184
|
console.warn(`AI copy attempt ${attempt} was invalid; requesting a corrected result.`);
|
|
1087
1185
|
}
|
|
1088
1186
|
}
|
|
@@ -1220,22 +1318,99 @@ function formatAssTime(seconds) {
|
|
|
1220
1318
|
return `${hours}:${String(minutes).padStart(2, '0')}:${String(wholeSeconds).padStart(2, '0')}.${String(remainder).padStart(2, '0')}`;
|
|
1221
1319
|
}
|
|
1222
1320
|
|
|
1223
|
-
function
|
|
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) {
|
|
1224
1398
|
const captionAppName = escapeAssText(appName.toUpperCase());
|
|
1225
1399
|
const events = sceneWindows.flatMap((window, index) => {
|
|
1226
1400
|
const scene = promoCopy.scenes[index];
|
|
1401
|
+
const composition = getSceneComposition(layoutName, index);
|
|
1227
1402
|
const start = formatAssTime(window.start);
|
|
1228
1403
|
const end = formatAssTime(window.end);
|
|
1229
|
-
const x = index === sceneWindows.length - 1 ? 895 : 720;
|
|
1230
1404
|
const fadeIn = window.end - window.start <= 3 ? 180 : 220;
|
|
1231
1405
|
const fadeOut = index === sceneWindows.length - 1 ? 350 : fadeIn;
|
|
1232
1406
|
const fade = `\\fad(${fadeIn},${fadeOut})`;
|
|
1233
1407
|
return [
|
|
1234
|
-
`Dialogue: 0,${start},${end},Kicker,,0,0,0,,{\\pos(${
|
|
1235
|
-
`Dialogue: 0,${start},${end},Title,,0,0,0,,{\\pos(${
|
|
1236
|
-
`Dialogue: 0,${start},${end},Body,,0,0,0,,{\\pos(${
|
|
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)}`,
|
|
1237
1411
|
];
|
|
1238
1412
|
});
|
|
1413
|
+
const globalComposition = getGlobalCaptionComposition(layoutName);
|
|
1239
1414
|
const footer = promoCopy.footer_keywords
|
|
1240
1415
|
.map((keyword) => escapeAssText(keyword.toUpperCase()))
|
|
1241
1416
|
.join(' / ');
|
|
@@ -1248,17 +1423,22 @@ WrapStyle: 2
|
|
|
1248
1423
|
|
|
1249
1424
|
[V4+ Styles]
|
|
1250
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
|
|
1251
|
-
Style:
|
|
1252
|
-
Style:
|
|
1253
|
-
Style:
|
|
1254
|
-
Style:
|
|
1255
|
-
Style:
|
|
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
|
|
1256
1436
|
|
|
1257
1437
|
[Events]
|
|
1258
1438
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
1259
|
-
Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)}
|
|
1439
|
+
Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},${globalComposition.brandStyle},,0,0,0,,{\\pos(${globalComposition.brandX},${globalComposition.brandY})\\fad(350,350)}${captionAppName}
|
|
1260
1440
|
${events.join('\n')}
|
|
1261
|
-
Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)}
|
|
1441
|
+
Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},${globalComposition.footerStyle},,0,0,0,,{\\pos(${globalComposition.footerX},${globalComposition.footerY})\\fad(350,350)}${footer}
|
|
1262
1442
|
`;
|
|
1263
1443
|
|
|
1264
1444
|
fs.mkdirSync(temporaryDirectory, { recursive: true });
|
|
@@ -1442,8 +1622,75 @@ function validateOutput(ffprobeCommand, outputPath, targetWidth, targetHeight) {
|
|
|
1442
1622
|
);
|
|
1443
1623
|
}
|
|
1444
1624
|
|
|
1445
|
-
function
|
|
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
|
+
) {
|
|
1446
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);
|
|
1447
1694
|
const is4K = targetWidth >= 3840 || targetHeight >= 2160;
|
|
1448
1695
|
const videoBitrate = is4K ? '24M' : '9M';
|
|
1449
1696
|
const maximumBitrate = is4K ? '32M' : '12M';
|
|
@@ -1476,12 +1723,13 @@ function generatePromo(ffmpegCommand, inputPath, outputPath, crop, targetWidth,
|
|
|
1476
1723
|
videoSourceFilter,
|
|
1477
1724
|
...videoTrimFilters,
|
|
1478
1725
|
videoSequenceFilter,
|
|
1479
|
-
`[bgsrc]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,gblur=sigma=34,eq=brightness
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
`[stage][phone]overlay=x
|
|
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),
|
|
1483
1731
|
'[1:v]scale=145:145,format=rgba,colorchannelmixer=aa=0.96[icon]',
|
|
1484
|
-
`[
|
|
1732
|
+
`[layoutfinished][icon]overlay=x=${iconPosition.x}:y=${iconPosition.y}:enable=between(t\\,${finalSceneStart}\\,${expectedDuration}):shortest=1[branded]`,
|
|
1485
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]`,
|
|
1486
1734
|
`[3:v]scale=1920:1080,format=yuv420p[cardbanner]`,
|
|
1487
1735
|
`[captioned][cardbanner]overlay=eof_action=repeat:enable=between(t\\,${endCardStart}\\,${expectedDuration})[cardonly]`,
|
|
@@ -1621,26 +1869,34 @@ function getTargetDimensions(videoDetails) {
|
|
|
1621
1869
|
: { width: 1920, height: 1080 };
|
|
1622
1870
|
}
|
|
1623
1871
|
|
|
1624
|
-
function
|
|
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 = '') {
|
|
1625
1880
|
const relativeDirectory = path.relative(sourceVideoDirectory, path.dirname(inputPath));
|
|
1626
1881
|
const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
|
|
1627
1882
|
return path.join(
|
|
1628
1883
|
outputVideoDirectory,
|
|
1629
1884
|
relativeDirectory,
|
|
1630
|
-
`${appFilePrefix}-${sourceName}-promo-english-voiceover-${targetWidth}x${targetHeight}.mp4`,
|
|
1885
|
+
`${appFilePrefix}-${sourceName}-promo-english-voiceover${layoutSuffix}-${targetWidth}x${targetHeight}.mp4`,
|
|
1631
1886
|
);
|
|
1632
1887
|
}
|
|
1633
1888
|
|
|
1634
|
-
function getSingleOutputPath(inputPath, targetWidth, targetHeight) {
|
|
1889
|
+
function getSingleOutputPath(inputPath, targetWidth, targetHeight, layoutSuffix = '') {
|
|
1635
1890
|
const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
|
|
1636
1891
|
return path.join(
|
|
1637
1892
|
outputVideoDirectory,
|
|
1638
|
-
`${appFilePrefix}-${sourceName}-promo-english-voiceover-${targetWidth}x${targetHeight}.mp4`,
|
|
1893
|
+
`${appFilePrefix}-${sourceName}-promo-english-voiceover${layoutSuffix}-${targetWidth}x${targetHeight}.mp4`,
|
|
1639
1894
|
);
|
|
1640
1895
|
}
|
|
1641
1896
|
|
|
1642
1897
|
async function main() {
|
|
1643
1898
|
const options = parseArguments();
|
|
1899
|
+
await ensureAppStoreDownloadUrl();
|
|
1644
1900
|
if (options.listVoices) {
|
|
1645
1901
|
printVoiceCatalog();
|
|
1646
1902
|
return;
|
|
@@ -1657,15 +1913,21 @@ async function main() {
|
|
|
1657
1913
|
return;
|
|
1658
1914
|
}
|
|
1659
1915
|
|
|
1660
|
-
const
|
|
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');
|
|
1661
1923
|
const ffprobeCommand = getExecutable('ffprobe');
|
|
1662
1924
|
const voice = resolveVoice(options.voice);
|
|
1663
1925
|
|
|
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();
|
|
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();
|
|
1669
1931
|
|
|
1670
1932
|
const inputPaths = options.input
|
|
1671
1933
|
? [path.resolve(options.input)]
|
|
@@ -1687,21 +1949,21 @@ async function main() {
|
|
|
1687
1949
|
const outputPath = options.output
|
|
1688
1950
|
? path.resolve(options.output)
|
|
1689
1951
|
: options.input
|
|
1690
|
-
? getSingleOutputPath(inputPath, target.width, target.height)
|
|
1691
|
-
: getBatchOutputPath(inputPath, target.width, target.height);
|
|
1952
|
+
? getSingleOutputPath(inputPath, target.width, target.height, layoutSuffix)
|
|
1953
|
+
: getBatchOutputPath(inputPath, target.width, target.height, layoutSuffix);
|
|
1692
1954
|
return { crop, duration, inputPath, outputPath, processingInputPath, target };
|
|
1693
1955
|
});
|
|
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);
|
|
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);
|
|
1705
1967
|
const promoCopyResult = await generateOrLoadPromoCopy(
|
|
1706
1968
|
options,
|
|
1707
1969
|
ffmpegCommand,
|
|
@@ -1771,7 +2033,7 @@ async function main() {
|
|
|
1771
2033
|
'edge-tts is required for natural neural voices. Install it with: py -m pip install edge-tts',
|
|
1772
2034
|
);
|
|
1773
2035
|
}
|
|
1774
|
-
createCaptionFile(promoCopyResult.copy);
|
|
2036
|
+
createCaptionFile(promoCopyResult.copy, layoutName);
|
|
1775
2037
|
console.log(`Creating natural neural narration with: ${voice.label}`);
|
|
1776
2038
|
const narrationSegmentPaths = createNarrationSegments(
|
|
1777
2039
|
pythonCommand,
|
|
@@ -1791,6 +2053,7 @@ async function main() {
|
|
|
1791
2053
|
source.crop,
|
|
1792
2054
|
source.target.width,
|
|
1793
2055
|
source.target.height,
|
|
2056
|
+
layoutName,
|
|
1794
2057
|
);
|
|
1795
2058
|
validateOutput(
|
|
1796
2059
|
ffprobeCommand,
|