codeplay-common 4.3.5 → 4.3.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 +1525 -0
- package/files/take-screen-image.js +191 -57
- package/files/take-screen-video.js +2 -11
- package/package.json +1 -1
|
@@ -0,0 +1,1525 @@
|
|
|
1
|
+
// Generate an English app promo video from the original Android capture.
|
|
2
|
+
// Usage: node generate-ai-promo-video.js
|
|
3
|
+
// Optional: node generate-ai-promo-video.js --input path/to/source.mp4 --output path/to/output.mp4
|
|
4
|
+
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { createHash } = require('node:crypto');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const projectDirectory = __dirname;
|
|
11
|
+
const capacitorConfigPath = path.join(projectDirectory, 'capacitor.config.json');
|
|
12
|
+
const capacitorConfig = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
|
|
13
|
+
const appName = String(capacitorConfig.appName || '').trim();
|
|
14
|
+
const appUniqueId = String(capacitorConfig.android?.APP_UNIQUE_ID ?? '').trim();
|
|
15
|
+
const packageId = String(capacitorConfig.appId || '').trim();
|
|
16
|
+
|
|
17
|
+
if (!appName) throw new Error('capacitor.config.json is missing appName.');
|
|
18
|
+
if (!appUniqueId) throw new Error('capacitor.config.json is missing android.APP_UNIQUE_ID.');
|
|
19
|
+
if (!packageId) throw new Error('capacitor.config.json is missing appId.');
|
|
20
|
+
|
|
21
|
+
const appFilePrefix = `${appUniqueId}. ${sanitizeFileNamePart(appName)}`;
|
|
22
|
+
const playStoreDownloadUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageId)}`;
|
|
23
|
+
const sourceVideoDirectory = path.join(projectDirectory, 'Auto-Screenshot', 'Video', 'Output');
|
|
24
|
+
const outputVideoDirectory = path.join(projectDirectory, 'Auto-AI-Video');
|
|
25
|
+
const spatialXrDirectory = path.join(outputVideoDirectory, 'Google-Play-XR-Spatial');
|
|
26
|
+
const spatialXrVideoPath = path.join(
|
|
27
|
+
spatialXrDirectory,
|
|
28
|
+
`${appFilePrefix}-spatial-xr-3d-sbs-lr-3840x1080.mp4`,
|
|
29
|
+
);
|
|
30
|
+
const iconPath = path.join(projectDirectory, 'src', 'assets', 'icons', 'icon-512.webp');
|
|
31
|
+
const temporaryDirectory = path.join(projectDirectory, 'agent-temp', 'ai-promo-video');
|
|
32
|
+
const captionPath = path.join(temporaryDirectory, `${appUniqueId}-promo-english.ass`);
|
|
33
|
+
const narrationTrackPath = path.join(temporaryDirectory, `${appUniqueId}-promo-narration.wav`);
|
|
34
|
+
const voicePreviewDirectory = path.join(projectDirectory, 'Auto-AI-Video', 'Voice-Samples');
|
|
35
|
+
const promoCopyCachePath = path.join(
|
|
36
|
+
projectDirectory,
|
|
37
|
+
'Auto-AI-Video',
|
|
38
|
+
`${appFilePrefix}-promo-copy.json`,
|
|
39
|
+
);
|
|
40
|
+
const analysisFrameDirectory = path.join(temporaryDirectory, 'ai-analysis');
|
|
41
|
+
const promoCopySchemaPath = path.join(temporaryDirectory, 'promo-copy-schema.json');
|
|
42
|
+
const promoCopyResultPath = path.join(temporaryDirectory, 'promo-copy-result.json');
|
|
43
|
+
const ffmpegFallbackDirectory = 'W:\\Tools\\ffmpeg\\bin';
|
|
44
|
+
const sceneCount = 7;
|
|
45
|
+
const minimumSourceDuration = 12;
|
|
46
|
+
const minimumPreferredPromoDuration = 24;
|
|
47
|
+
const maximumPromoDuration = 40;
|
|
48
|
+
let expectedDuration = 34;
|
|
49
|
+
let editSegments = [];
|
|
50
|
+
let sceneWindows = [];
|
|
51
|
+
const thumbnailTime = 2;
|
|
52
|
+
const thumbnailWidth = 3840;
|
|
53
|
+
const thumbnailHeight = 2160;
|
|
54
|
+
const maximumThumbnailBytes = 2 * 1024 * 1024;
|
|
55
|
+
const spatialEyeWidth = 1920;
|
|
56
|
+
const spatialEyeHeight = 1080;
|
|
57
|
+
const spatialOutputWidth = spatialEyeWidth * 2;
|
|
58
|
+
const spatialOutputHeight = spatialEyeHeight;
|
|
59
|
+
const promoCopySchemaVersion = 3;
|
|
60
|
+
const promoCopySchema = {
|
|
61
|
+
type: 'object',
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
required: ['footer_keywords', 'scenes', 'youtube'],
|
|
64
|
+
properties: {
|
|
65
|
+
footer_keywords: {
|
|
66
|
+
type: 'array',
|
|
67
|
+
minItems: 3,
|
|
68
|
+
maxItems: 3,
|
|
69
|
+
items: { type: 'string' },
|
|
70
|
+
},
|
|
71
|
+
scenes: {
|
|
72
|
+
type: 'array',
|
|
73
|
+
minItems: sceneCount,
|
|
74
|
+
maxItems: sceneCount,
|
|
75
|
+
items: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
required: ['kicker', 'title_line_1', 'title_line_2', 'body', 'narration'],
|
|
79
|
+
properties: {
|
|
80
|
+
kicker: { type: 'string' },
|
|
81
|
+
title_line_1: { type: 'string' },
|
|
82
|
+
title_line_2: { type: 'string' },
|
|
83
|
+
body: { type: 'string' },
|
|
84
|
+
narration: { type: 'string' },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
youtube: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
required: ['title', 'description'],
|
|
92
|
+
properties: {
|
|
93
|
+
title: { type: 'string' },
|
|
94
|
+
description: { type: 'string' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const neuralVoiceCatalog = {
|
|
100
|
+
aria: {
|
|
101
|
+
name: 'en-US-AriaNeural',
|
|
102
|
+
label: 'Aria — US English, confident and polished',
|
|
103
|
+
},
|
|
104
|
+
ava: {
|
|
105
|
+
name: 'en-US-AvaMultilingualNeural',
|
|
106
|
+
label: 'Ava — US English, expressive and conversational',
|
|
107
|
+
},
|
|
108
|
+
emma: {
|
|
109
|
+
name: 'en-US-EmmaMultilingualNeural',
|
|
110
|
+
label: 'Emma — US English, cheerful and clear',
|
|
111
|
+
},
|
|
112
|
+
jenny: {
|
|
113
|
+
name: 'en-US-JennyNeural',
|
|
114
|
+
label: 'Jenny — US English, friendly and warm',
|
|
115
|
+
},
|
|
116
|
+
michelle: {
|
|
117
|
+
name: 'en-US-MichelleNeural',
|
|
118
|
+
label: 'Michelle — US English, pleasant and composed',
|
|
119
|
+
},
|
|
120
|
+
sonia: {
|
|
121
|
+
name: 'en-GB-SoniaNeural',
|
|
122
|
+
label: 'Sonia — UK English, polished and professional',
|
|
123
|
+
},
|
|
124
|
+
libby: {
|
|
125
|
+
name: 'en-GB-LibbyNeural',
|
|
126
|
+
label: 'Libby — UK English, friendly and positive',
|
|
127
|
+
},
|
|
128
|
+
neerja: {
|
|
129
|
+
name: 'en-IN-NeerjaExpressiveNeural',
|
|
130
|
+
label: 'Neerja — Indian English, expressive and natural',
|
|
131
|
+
},
|
|
132
|
+
natasha: {
|
|
133
|
+
name: 'en-AU-NatashaNeural',
|
|
134
|
+
label: 'Natasha — Australian English, friendly and positive',
|
|
135
|
+
},
|
|
136
|
+
clara: {
|
|
137
|
+
name: 'en-CA-ClaraNeural',
|
|
138
|
+
label: 'Clara — Canadian English, clear and friendly',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
function sanitizeFileNamePart(value) {
|
|
142
|
+
return String(value)
|
|
143
|
+
.trim()
|
|
144
|
+
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
|
|
145
|
+
.replace(/[. ]+$/g, '') || 'App';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function findVideos(directory) {
|
|
149
|
+
if (!fs.existsSync(directory)) return [];
|
|
150
|
+
return fs.readdirSync(directory, { withFileTypes: true })
|
|
151
|
+
.flatMap((entry) => {
|
|
152
|
+
const entryPath = path.join(directory, entry.name);
|
|
153
|
+
if (entry.isDirectory()) return findVideos(entryPath);
|
|
154
|
+
return entry.isFile() && /\.mp4$/i.test(entry.name) ? [entryPath] : [];
|
|
155
|
+
})
|
|
156
|
+
.sort((left, right) => left.localeCompare(right));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isExcludedBatchSource(inputPath) {
|
|
160
|
+
const relativePath = path.relative(sourceVideoDirectory, inputPath);
|
|
161
|
+
const platformDirectory = relativePath.split(path.sep)[0].toLowerCase();
|
|
162
|
+
return platformDirectory === 'samsung';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function roundTimelineValue(value) {
|
|
166
|
+
return Math.round(value * 100) / 100;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function mapOutputTimeToSource(outputTime) {
|
|
170
|
+
let remainingTime = outputTime;
|
|
171
|
+
for (const segment of editSegments) {
|
|
172
|
+
const segmentDuration = segment.end - segment.start;
|
|
173
|
+
if (remainingTime <= segmentDuration) {
|
|
174
|
+
return Math.min(segment.end - 0.05, segment.start + remainingTime);
|
|
175
|
+
}
|
|
176
|
+
remainingTime -= segmentDuration;
|
|
177
|
+
}
|
|
178
|
+
return editSegments.at(-1).end - 0.05;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function configurePromoTimeline(sourceDuration) {
|
|
182
|
+
if (!Number.isFinite(sourceDuration) || sourceDuration < minimumSourceDuration) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`The shortest source video must be at least ${minimumSourceDuration} seconds.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const introTrim = sourceDuration >= 20 ? Math.min(2, sourceDuration * 0.04) : 0;
|
|
189
|
+
const usableDuration = sourceDuration - introTrim;
|
|
190
|
+
const preferredDuration = Math.max(
|
|
191
|
+
minimumPreferredPromoDuration,
|
|
192
|
+
Math.round(sourceDuration * 0.6),
|
|
193
|
+
);
|
|
194
|
+
expectedDuration = roundTimelineValue(Math.min(maximumPromoDuration, usableDuration, preferredDuration));
|
|
195
|
+
|
|
196
|
+
const tailDuration = expectedDuration >= minimumPreferredPromoDuration
|
|
197
|
+
? roundTimelineValue(Math.min(7, expectedDuration * 0.2))
|
|
198
|
+
: 0;
|
|
199
|
+
const mainDuration = roundTimelineValue(expectedDuration - tailDuration);
|
|
200
|
+
const tailStart = roundTimelineValue(sourceDuration - tailDuration);
|
|
201
|
+
const mainEnd = roundTimelineValue(introTrim + mainDuration);
|
|
202
|
+
|
|
203
|
+
editSegments = tailDuration > 0 && mainEnd + 0.5 < tailStart
|
|
204
|
+
? [
|
|
205
|
+
{ start: roundTimelineValue(introTrim), end: mainEnd },
|
|
206
|
+
{ start: tailStart, end: roundTimelineValue(sourceDuration) },
|
|
207
|
+
]
|
|
208
|
+
: [{
|
|
209
|
+
start: roundTimelineValue(introTrim),
|
|
210
|
+
end: roundTimelineValue(introTrim + expectedDuration),
|
|
211
|
+
}];
|
|
212
|
+
|
|
213
|
+
sceneWindows = Array.from({ length: sceneCount }, (_, index) => {
|
|
214
|
+
const start = roundTimelineValue((expectedDuration * index) / sceneCount);
|
|
215
|
+
const end = index === sceneCount - 1
|
|
216
|
+
? expectedDuration
|
|
217
|
+
: roundTimelineValue((expectedDuration * (index + 1)) / sceneCount);
|
|
218
|
+
const sceneDuration = end - start;
|
|
219
|
+
return {
|
|
220
|
+
start,
|
|
221
|
+
end,
|
|
222
|
+
sourceTime: roundTimelineValue(mapOutputTimeToSource(start + (sceneDuration / 2))),
|
|
223
|
+
maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
console.log(
|
|
228
|
+
`Adaptive promo timeline: ${expectedDuration}s from ${sourceDuration.toFixed(2)}s source; `
|
|
229
|
+
+ `${editSegments.length} edit segment${editSegments.length === 1 ? '' : 's'}.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function escapeAssText(value) {
|
|
234
|
+
return String(value)
|
|
235
|
+
.replace(/\\/g, '\\\\')
|
|
236
|
+
.replace(/{/g, '\\{')
|
|
237
|
+
.replace(/}/g, '\\}')
|
|
238
|
+
.replace(/\r?\n/g, '\\N');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function run(command, args, options = {}) {
|
|
242
|
+
const result = spawnSync(command, args, {
|
|
243
|
+
encoding: 'utf8',
|
|
244
|
+
windowsHide: true,
|
|
245
|
+
stdio: options.inherit ? 'inherit' : 'pipe',
|
|
246
|
+
input: options.input,
|
|
247
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (result.error) {
|
|
251
|
+
if (result.error.code === 'ENOENT') {
|
|
252
|
+
throw new Error(`${command} was not found.`);
|
|
253
|
+
}
|
|
254
|
+
throw result.error;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (result.status !== 0) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
String(result.stderr || result.stdout || `${command} exited with code ${result.status}.`).trim(),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return String(result.stdout || '').trim();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function commandExists(command) {
|
|
267
|
+
const result = spawnSync(command, ['-version'], {
|
|
268
|
+
encoding: 'utf8',
|
|
269
|
+
windowsHide: true,
|
|
270
|
+
});
|
|
271
|
+
return !result.error && result.status === 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getExecutable(command) {
|
|
275
|
+
if (commandExists(command)) return command;
|
|
276
|
+
|
|
277
|
+
const fallbackPath = path.join(ffmpegFallbackDirectory, `${command}.exe`);
|
|
278
|
+
return fs.existsSync(fallbackPath) ? fallbackPath : '';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseArguments() {
|
|
282
|
+
const args = process.argv.slice(2);
|
|
283
|
+
const options = {
|
|
284
|
+
aiModel: '',
|
|
285
|
+
aiProvider: 'auto',
|
|
286
|
+
copyOnly: false,
|
|
287
|
+
input: '',
|
|
288
|
+
listVoices: false,
|
|
289
|
+
output: '',
|
|
290
|
+
previewVoices: false,
|
|
291
|
+
refreshAiCopy: false,
|
|
292
|
+
spatialXrOnly: false,
|
|
293
|
+
thumbnailsOnly: false,
|
|
294
|
+
voice: 'sonia',
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
298
|
+
const argument = args[index];
|
|
299
|
+
if (argument === '--input') {
|
|
300
|
+
options.input = path.resolve(args[index + 1] || '');
|
|
301
|
+
index += 1;
|
|
302
|
+
} else if (argument === '--output') {
|
|
303
|
+
options.output = path.resolve(args[index + 1] || '');
|
|
304
|
+
index += 1;
|
|
305
|
+
} else if (argument === '--voice') {
|
|
306
|
+
options.voice = String(args[index + 1] || '').trim();
|
|
307
|
+
index += 1;
|
|
308
|
+
} else if (argument === '--ai-provider') {
|
|
309
|
+
options.aiProvider = String(args[index + 1] || '').trim().toLowerCase();
|
|
310
|
+
index += 1;
|
|
311
|
+
} else if (argument === '--ai-model') {
|
|
312
|
+
options.aiModel = String(args[index + 1] || '').trim();
|
|
313
|
+
index += 1;
|
|
314
|
+
} else if (argument === '--refresh-ai-copy') {
|
|
315
|
+
options.refreshAiCopy = true;
|
|
316
|
+
} else if (argument === '--copy-only') {
|
|
317
|
+
options.copyOnly = true;
|
|
318
|
+
} else if (argument === '--thumbnails-only') {
|
|
319
|
+
options.thumbnailsOnly = true;
|
|
320
|
+
} else if (argument === '--spatial-xr-only') {
|
|
321
|
+
options.spatialXrOnly = true;
|
|
322
|
+
} else if (argument === '--list-voices') {
|
|
323
|
+
options.listVoices = true;
|
|
324
|
+
} else if (argument === '--preview-voices') {
|
|
325
|
+
options.previewVoices = true;
|
|
326
|
+
} else if (argument === '--help' || argument === '-h') {
|
|
327
|
+
console.log(`Generate an adaptive promotional video for ${appName}.`);
|
|
328
|
+
console.log('');
|
|
329
|
+
console.log('Usage:');
|
|
330
|
+
console.log(' node generate-ai-promo-video.js');
|
|
331
|
+
console.log(' node generate-ai-promo-video.js --input source.mp4 --output promo.mp4');
|
|
332
|
+
console.log(' node generate-ai-promo-video.js --voice aria');
|
|
333
|
+
console.log(' node generate-ai-promo-video.js --refresh-ai-copy');
|
|
334
|
+
console.log(' node generate-ai-promo-video.js --copy-only');
|
|
335
|
+
console.log(' node generate-ai-promo-video.js --thumbnails-only');
|
|
336
|
+
console.log(' node generate-ai-promo-video.js --spatial-xr-only');
|
|
337
|
+
console.log(' node generate-ai-promo-video.js --ai-provider auto|codex|openai');
|
|
338
|
+
console.log(' node generate-ai-promo-video.js --ai-model MODEL_NAME');
|
|
339
|
+
console.log(' node generate-ai-promo-video.js --list-voices');
|
|
340
|
+
console.log(' node generate-ai-promo-video.js --preview-voices');
|
|
341
|
+
console.log('');
|
|
342
|
+
console.log(
|
|
343
|
+
`Without --input, every MP4 under ${sourceVideoDirectory} except Samsung is processed.`,
|
|
344
|
+
);
|
|
345
|
+
process.exit(0);
|
|
346
|
+
} else {
|
|
347
|
+
throw new Error(`Unknown argument: ${argument}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!['auto', 'codex', 'openai'].includes(options.aiProvider)) {
|
|
352
|
+
throw new Error('--ai-provider must be auto, codex, or openai.');
|
|
353
|
+
}
|
|
354
|
+
if (options.output && !options.input) {
|
|
355
|
+
throw new Error('--output can only be used together with --input.');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return options;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function getPythonExecutable() {
|
|
362
|
+
for (const candidate of ['py', 'python']) {
|
|
363
|
+
const result = spawnSync(candidate, ['-m', 'edge_tts', '--version'], {
|
|
364
|
+
encoding: 'utf8',
|
|
365
|
+
windowsHide: true,
|
|
366
|
+
});
|
|
367
|
+
if (!result.error && result.status === 0) return candidate;
|
|
368
|
+
}
|
|
369
|
+
return '';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function printVoiceCatalog() {
|
|
373
|
+
console.log('Natural female neural voices:');
|
|
374
|
+
console.log('');
|
|
375
|
+
for (const [alias, voice] of Object.entries(neuralVoiceCatalog)) {
|
|
376
|
+
console.log(` ${alias.padEnd(10)} ${voice.label} (${voice.name})`);
|
|
377
|
+
}
|
|
378
|
+
console.log('');
|
|
379
|
+
console.log('Use an alias above or any full Edge TTS neural voice name with --voice.');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function resolveVoice(input) {
|
|
383
|
+
const alias = String(input || '').trim().toLowerCase();
|
|
384
|
+
return neuralVoiceCatalog[alias] || {
|
|
385
|
+
name: String(input || '').trim(),
|
|
386
|
+
label: String(input || '').trim(),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function escapeFilterPath(filePath) {
|
|
391
|
+
return filePath
|
|
392
|
+
.replace(/\\/g, '/')
|
|
393
|
+
.replace(':', '\\:')
|
|
394
|
+
.replace(/'/g, "\\'");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function readTextIfPresent(filePath, maximumLength = 12000) {
|
|
398
|
+
if (!fs.existsSync(filePath)) return '';
|
|
399
|
+
return fs.readFileSync(filePath, 'utf8').slice(0, maximumLength);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function readJsonIfPresent(filePath) {
|
|
403
|
+
try {
|
|
404
|
+
return JSON.parse(readTextIfPresent(filePath, 50000));
|
|
405
|
+
} catch {
|
|
406
|
+
return {};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function extractHtmlMetadata(html) {
|
|
411
|
+
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '';
|
|
412
|
+
const description = html.match(
|
|
413
|
+
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
|
|
414
|
+
)?.[1] || html.match(
|
|
415
|
+
/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i,
|
|
416
|
+
)?.[1] || '';
|
|
417
|
+
return { title: title.trim(), description: description.trim() };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function stripMarkup(value) {
|
|
421
|
+
return String(value)
|
|
422
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
|
423
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
|
424
|
+
.replace(/<[^>]+>/g, ' ')
|
|
425
|
+
.replace(/\{\{[^}]+\}\}/g, ' ')
|
|
426
|
+
.replace(/&[a-zA-Z#0-9]+;/g, ' ')
|
|
427
|
+
.replace(/\s+/g, ' ')
|
|
428
|
+
.trim();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function collectAppContext() {
|
|
432
|
+
const manifestCandidates = [
|
|
433
|
+
path.join(projectDirectory, 'src', 'manifest.webmanifest'),
|
|
434
|
+
path.join(projectDirectory, 'public', 'manifest.webmanifest'),
|
|
435
|
+
path.join(projectDirectory, 'manifest.webmanifest'),
|
|
436
|
+
];
|
|
437
|
+
const manifest = readJsonIfPresent(manifestCandidates.find((candidate) => fs.existsSync(candidate)) || '');
|
|
438
|
+
const htmlMetadata = extractHtmlMetadata(
|
|
439
|
+
readTextIfPresent(path.join(projectDirectory, 'src', 'index.html'), 30000),
|
|
440
|
+
);
|
|
441
|
+
const pageDirectory = path.join(projectDirectory, 'src', 'pages');
|
|
442
|
+
const pageFiles = fs.existsSync(pageDirectory)
|
|
443
|
+
? fs.readdirSync(pageDirectory)
|
|
444
|
+
.filter((name) => /\.(?:f7|html)$/i.test(name))
|
|
445
|
+
.sort()
|
|
446
|
+
.slice(0, 30)
|
|
447
|
+
: [];
|
|
448
|
+
let remainingPageCharacters = 12000;
|
|
449
|
+
const visiblePageText = [];
|
|
450
|
+
|
|
451
|
+
for (const fileName of pageFiles) {
|
|
452
|
+
if (remainingPageCharacters <= 0) break;
|
|
453
|
+
const text = stripMarkup(
|
|
454
|
+
readTextIfPresent(path.join(pageDirectory, fileName), Math.min(5000, remainingPageCharacters)),
|
|
455
|
+
);
|
|
456
|
+
if (text) {
|
|
457
|
+
const excerpt = text.slice(0, Math.min(2500, remainingPageCharacters));
|
|
458
|
+
visiblePageText.push({ file: fileName, text: excerpt });
|
|
459
|
+
remainingPageCharacters -= excerpt.length;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
identity: {
|
|
465
|
+
appName,
|
|
466
|
+
appUniqueId,
|
|
467
|
+
appId: String(capacitorConfig.appId || '').trim(),
|
|
468
|
+
orientation: String(capacitorConfig.orientation || '').trim(),
|
|
469
|
+
},
|
|
470
|
+
storeMetadata: {
|
|
471
|
+
name: manifest.name || '',
|
|
472
|
+
shortName: manifest.short_name || '',
|
|
473
|
+
description: manifest.description || htmlMetadata.description,
|
|
474
|
+
categories: Array.isArray(manifest.categories) ? manifest.categories : [],
|
|
475
|
+
pageTitle: htmlMetadata.title,
|
|
476
|
+
},
|
|
477
|
+
productNotes: readTextIfPresent(path.join(projectDirectory, 'GROWTH_ASO_PLAN.md'), 12000),
|
|
478
|
+
routeConfiguration: readTextIfPresent(path.join(projectDirectory, 'src', 'js', 'routes.js'), 6000),
|
|
479
|
+
visiblePageText,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function createCopyFingerprint(appContext, inputPaths) {
|
|
484
|
+
const sources = inputPaths.map((inputPath) => {
|
|
485
|
+
const sourceStats = fs.statSync(inputPath);
|
|
486
|
+
return {
|
|
487
|
+
relativePath: path.relative(projectDirectory, inputPath),
|
|
488
|
+
size: sourceStats.size,
|
|
489
|
+
modified: sourceStats.mtimeMs,
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
return createHash('sha256').update(JSON.stringify({
|
|
493
|
+
schemaVersion: promoCopySchemaVersion,
|
|
494
|
+
appContext,
|
|
495
|
+
sources,
|
|
496
|
+
sceneWindows,
|
|
497
|
+
})).digest('hex');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function getCodexExecutable() {
|
|
501
|
+
const result = spawnSync('codex', ['--version'], {
|
|
502
|
+
encoding: 'utf8',
|
|
503
|
+
windowsHide: true,
|
|
504
|
+
});
|
|
505
|
+
return !result.error && result.status === 0 ? 'codex' : '';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function detectActiveVideoCrop(ffmpegCommand, inputPath, videoDetails) {
|
|
509
|
+
const videoStream = videoDetails.streams?.find((stream) => stream.codec_type === 'video') || {};
|
|
510
|
+
const sourceWidth = Number(videoStream.width || 0);
|
|
511
|
+
const sourceHeight = Number(videoStream.height || 0);
|
|
512
|
+
if (sourceWidth <= sourceHeight) return null;
|
|
513
|
+
|
|
514
|
+
const result = spawnSync(ffmpegCommand, [
|
|
515
|
+
'-hide_banner',
|
|
516
|
+
'-ss', '8',
|
|
517
|
+
'-t', '4',
|
|
518
|
+
'-i', inputPath,
|
|
519
|
+
'-vf', 'cropdetect=limit=18:round=2:reset=0',
|
|
520
|
+
'-f', 'null',
|
|
521
|
+
process.platform === 'win32' ? 'NUL' : '/dev/null',
|
|
522
|
+
], {
|
|
523
|
+
encoding: 'utf8',
|
|
524
|
+
windowsHide: true,
|
|
525
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
526
|
+
});
|
|
527
|
+
if (result.error || result.status !== 0) return null;
|
|
528
|
+
const matches = [...String(result.stderr || '').matchAll(/crop=(\d+):(\d+):(\d+):(\d+)/g)];
|
|
529
|
+
const detected = matches.at(-1)?.slice(1).map(Number);
|
|
530
|
+
if (!detected) return null;
|
|
531
|
+
const [width, height, x, y] = detected;
|
|
532
|
+
const isCentralPortrait = width < sourceWidth * 0.8
|
|
533
|
+
&& height >= sourceHeight * 0.9
|
|
534
|
+
&& height > width
|
|
535
|
+
&& Math.abs((x + (width / 2)) - (sourceWidth / 2)) <= sourceWidth * 0.03;
|
|
536
|
+
return isCentralPortrait ? { width, height, x, y } : null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function cropFilter(crop) {
|
|
540
|
+
return crop ? `crop=${crop.width}:${crop.height}:${crop.x}:${crop.y},` : '';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function extractAnalysisFrames(ffmpegCommand, inputPath, crop) {
|
|
544
|
+
fs.mkdirSync(analysisFrameDirectory, { recursive: true });
|
|
545
|
+
return sceneWindows.map((scene, index) => {
|
|
546
|
+
const framePath = path.join(
|
|
547
|
+
analysisFrameDirectory,
|
|
548
|
+
`scene-${String(index + 1).padStart(2, '0')}.jpg`,
|
|
549
|
+
);
|
|
550
|
+
run(ffmpegCommand, [
|
|
551
|
+
'-hide_banner',
|
|
552
|
+
'-loglevel', 'error',
|
|
553
|
+
'-y',
|
|
554
|
+
'-ss', String(scene.sourceTime),
|
|
555
|
+
'-i', inputPath,
|
|
556
|
+
'-frames:v', '1',
|
|
557
|
+
'-vf', `${cropFilter(crop)}setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,scale=768:-2,format=yuvj420p`,
|
|
558
|
+
'-q:v', '3',
|
|
559
|
+
framePath,
|
|
560
|
+
]);
|
|
561
|
+
return framePath;
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function buildPromoCopyPrompt(appContext, previousError = '') {
|
|
566
|
+
const timingRequirements = sceneWindows.map((scene, index) => ({
|
|
567
|
+
scene: index + 1,
|
|
568
|
+
outputStartSeconds: scene.start,
|
|
569
|
+
outputEndSeconds: scene.end,
|
|
570
|
+
sourceFrameSeconds: scene.sourceTime,
|
|
571
|
+
maximumNarrationWords: scene.maxNarrationWords,
|
|
572
|
+
}));
|
|
573
|
+
return `Create factual, high-conversion ASO/SEO copy for a ${expectedDuration}-second mobile-app promo video.
|
|
574
|
+
|
|
575
|
+
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.
|
|
576
|
+
|
|
577
|
+
Rules:
|
|
578
|
+
- Generate exactly ${sceneWindows.length} scenes in the same order as the images and timings.
|
|
579
|
+
- Use only features supported by the images or project context. Never invent claims.
|
|
580
|
+
- The authoritative app name is ${JSON.stringify(appName)}. Mention it naturally in the final narration and final body.
|
|
581
|
+
- APP_UNIQUE_ID is internal and must never appear in audience-facing copy.
|
|
582
|
+
- Each kicker: 2-30 characters. Each title line: 1-22 characters. Body: 8-72 characters.
|
|
583
|
+
- Narration must fit its scene's maximum word count. Use natural spoken English for a warm UK female voice.
|
|
584
|
+
- Keep captions punchy, specific and readable. Prefer user benefits and relevant search intent.
|
|
585
|
+
- footer_keywords must contain exactly 3 distinct, natural app-search phrases, each 2-24 characters.
|
|
586
|
+
- Generate a YouTube upload title and description from the same verified features.
|
|
587
|
+
- YouTube title: 40-100 characters, include ${JSON.stringify(appName)} and one strong natural search phrase, without clickbait.
|
|
588
|
+
- YouTube description: 350-1500 characters with readable paragraphs. Put the clearest app summary and primary search phrase in the opening two lines, explain the demonstrated features, add a simple call to action, and finish with exactly 3 relevant hashtags on one line.
|
|
589
|
+
- Do not add download links or URLs; the script will append the verified Google Play URL built from the configured package ID. Do not add chapter timestamps or unsupported availability claims.
|
|
590
|
+
- Keep YouTube text natural and useful; do not repeat keywords unnaturally.
|
|
591
|
+
- Avoid prices, ratings, awards, competitor names, unsupported superlatives, and words such as best, guaranteed, or free.
|
|
592
|
+
- No emoji, quotation marks, line breaks, markdown, or ending punctuation in kickers/titles/keywords.
|
|
593
|
+
|
|
594
|
+
Scene timing requirements:
|
|
595
|
+
${JSON.stringify(timingRequirements, null, 2)}
|
|
596
|
+
|
|
597
|
+
Sanitized app context (no credentials):
|
|
598
|
+
${JSON.stringify(appContext, null, 2)}
|
|
599
|
+
${previousError ? `\nYour previous output was invalid: ${previousError}\nCorrect that issue in this response.` : ''}`;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function cleanGeneratedString(value) {
|
|
603
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function cleanGeneratedDescription(value) {
|
|
607
|
+
return String(value || '')
|
|
608
|
+
.replace(/\r\n?/g, '\n')
|
|
609
|
+
.split('\n')
|
|
610
|
+
.map((line) => line.trim())
|
|
611
|
+
.join('\n')
|
|
612
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
613
|
+
.trim();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function addPlayStoreDownloadLink(description) {
|
|
617
|
+
const cleanedDescription = cleanGeneratedDescription(description);
|
|
618
|
+
if (cleanedDescription.includes(playStoreDownloadUrl)) return cleanedDescription;
|
|
619
|
+
|
|
620
|
+
const lines = cleanedDescription.split('\n');
|
|
621
|
+
let finalContentIndex = lines.length - 1;
|
|
622
|
+
while (finalContentIndex >= 0 && !lines[finalContentIndex].trim()) finalContentIndex -= 1;
|
|
623
|
+
const finalLine = lines[finalContentIndex]?.trim() || '';
|
|
624
|
+
const hashtags = finalLine.match(/#[\p{L}\p{N}_]+/gu) || [];
|
|
625
|
+
const hasFinalHashtagLine = hashtags.length === 3
|
|
626
|
+
&& !finalLine.replace(/#[\p{L}\p{N}_]+/gu, '').trim();
|
|
627
|
+
const downloadBlock = `Download ${appName} on Google Play:\n${playStoreDownloadUrl}`;
|
|
628
|
+
|
|
629
|
+
if (!hasFinalHashtagLine) {
|
|
630
|
+
return `${cleanedDescription}\n\n${downloadBlock}`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const descriptionBody = lines.slice(0, finalContentIndex).join('\n').trimEnd();
|
|
634
|
+
return `${descriptionBody}\n\n${downloadBlock}\n\n${finalLine}`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function validatePromoCopy(value) {
|
|
638
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
639
|
+
throw new Error('AI copy is not a JSON object.');
|
|
640
|
+
}
|
|
641
|
+
if (!Array.isArray(value.scenes) || value.scenes.length !== sceneWindows.length) {
|
|
642
|
+
throw new Error(`AI copy must contain exactly ${sceneWindows.length} scenes.`);
|
|
643
|
+
}
|
|
644
|
+
if (!Array.isArray(value.footer_keywords) || value.footer_keywords.length !== 3) {
|
|
645
|
+
throw new Error('AI copy must contain exactly 3 footer keywords.');
|
|
646
|
+
}
|
|
647
|
+
if (!value.youtube || typeof value.youtube !== 'object' || Array.isArray(value.youtube)) {
|
|
648
|
+
throw new Error('AI copy must contain YouTube upload metadata.');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const footerKeywords = value.footer_keywords.map((keyword, index) => {
|
|
652
|
+
const cleaned = cleanGeneratedString(keyword);
|
|
653
|
+
if (cleaned.length < 2 || cleaned.length > 24) {
|
|
654
|
+
throw new Error(`Footer keyword ${index + 1} must be 2-24 characters.`);
|
|
655
|
+
}
|
|
656
|
+
return cleaned;
|
|
657
|
+
});
|
|
658
|
+
if (new Set(footerKeywords.map((keyword) => keyword.toLowerCase())).size !== 3) {
|
|
659
|
+
throw new Error('Footer keywords must be distinct.');
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const constraints = {
|
|
663
|
+
kicker: [2, 30],
|
|
664
|
+
title_line_1: [1, 22],
|
|
665
|
+
title_line_2: [1, 22],
|
|
666
|
+
body: [8, 72],
|
|
667
|
+
narration: [8, 120],
|
|
668
|
+
};
|
|
669
|
+
const scenes = value.scenes.map((scene, sceneIndex) => {
|
|
670
|
+
if (!scene || typeof scene !== 'object' || Array.isArray(scene)) {
|
|
671
|
+
throw new Error(`Scene ${sceneIndex + 1} is invalid.`);
|
|
672
|
+
}
|
|
673
|
+
const cleanedScene = {};
|
|
674
|
+
for (const [field, [minimum, maximum]] of Object.entries(constraints)) {
|
|
675
|
+
const cleaned = cleanGeneratedString(scene[field]);
|
|
676
|
+
if (cleaned.length < minimum || cleaned.length > maximum) {
|
|
677
|
+
throw new Error(
|
|
678
|
+
`Scene ${sceneIndex + 1} ${field} must be ${minimum}-${maximum} characters.`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
cleanedScene[field] = cleaned;
|
|
682
|
+
}
|
|
683
|
+
const narrationWords = cleanedScene.narration.split(/\s+/).length;
|
|
684
|
+
if (narrationWords > sceneWindows[sceneIndex].maxNarrationWords) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
`Scene ${sceneIndex + 1} narration has ${narrationWords} words; maximum is ${sceneWindows[sceneIndex].maxNarrationWords}.`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
return cleanedScene;
|
|
690
|
+
});
|
|
691
|
+
const finalCopy = `${scenes.at(-1).body} ${scenes.at(-1).narration}`.toLowerCase();
|
|
692
|
+
if (!finalCopy.includes(appName.toLowerCase())) {
|
|
693
|
+
throw new Error(`The final scene must mention the app name ${JSON.stringify(appName)}.`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const youtubeTitle = cleanGeneratedString(value.youtube.title);
|
|
697
|
+
const youtubeDescription = cleanGeneratedDescription(value.youtube.description);
|
|
698
|
+
if (youtubeTitle.length < 40 || youtubeTitle.length > 100) {
|
|
699
|
+
throw new Error('YouTube title must be 40-100 characters.');
|
|
700
|
+
}
|
|
701
|
+
if (youtubeDescription.length < 350 || youtubeDescription.length > 1500) {
|
|
702
|
+
throw new Error('YouTube description must be 350-1500 characters.');
|
|
703
|
+
}
|
|
704
|
+
if (Buffer.byteLength(youtubeDescription, 'utf8') > 5000) {
|
|
705
|
+
throw new Error('YouTube description exceeds the 5,000-byte API limit.');
|
|
706
|
+
}
|
|
707
|
+
if (/[<>]/.test(`${youtubeTitle}${youtubeDescription}`)) {
|
|
708
|
+
throw new Error('YouTube metadata cannot contain angle brackets.');
|
|
709
|
+
}
|
|
710
|
+
if (!youtubeTitle.toLowerCase().includes(appName.toLowerCase())
|
|
711
|
+
|| !youtubeDescription.toLowerCase().includes(appName.toLowerCase())) {
|
|
712
|
+
throw new Error(`YouTube title and description must mention ${JSON.stringify(appName)}.`);
|
|
713
|
+
}
|
|
714
|
+
const descriptionLines = youtubeDescription.split('\n').filter(Boolean);
|
|
715
|
+
const finalDescriptionLine = descriptionLines.at(-1) || '';
|
|
716
|
+
const hashtags = finalDescriptionLine.match(/#[\p{L}\p{N}_]+/gu) || [];
|
|
717
|
+
if (hashtags.length !== 3 || finalDescriptionLine.replace(/#[\p{L}\p{N}_]+/gu, '').trim()) {
|
|
718
|
+
throw new Error('YouTube description must finish with a line containing exactly 3 hashtags.');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
footer_keywords: footerKeywords,
|
|
723
|
+
scenes,
|
|
724
|
+
youtube: {
|
|
725
|
+
title: youtubeTitle,
|
|
726
|
+
description: youtubeDescription,
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function parseAiJson(rawValue) {
|
|
732
|
+
const text = String(rawValue || '').trim()
|
|
733
|
+
.replace(/^```(?:json)?\s*/i, '')
|
|
734
|
+
.replace(/\s*```$/, '');
|
|
735
|
+
return JSON.parse(text);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function generateCopyWithCodex(prompt, framePaths, modelName) {
|
|
739
|
+
const codexCommand = getCodexExecutable();
|
|
740
|
+
if (!codexCommand) {
|
|
741
|
+
throw new Error('Codex CLI is unavailable or not signed in.');
|
|
742
|
+
}
|
|
743
|
+
fs.mkdirSync(temporaryDirectory, { recursive: true });
|
|
744
|
+
fs.writeFileSync(promoCopySchemaPath, `${JSON.stringify(promoCopySchema, null, 2)}\n`, 'utf8');
|
|
745
|
+
const args = [
|
|
746
|
+
'exec',
|
|
747
|
+
'--ephemeral',
|
|
748
|
+
'--ignore-rules',
|
|
749
|
+
'--sandbox', 'read-only',
|
|
750
|
+
'--skip-git-repo-check',
|
|
751
|
+
'--color', 'never',
|
|
752
|
+
'--output-schema', promoCopySchemaPath,
|
|
753
|
+
'--output-last-message', promoCopyResultPath,
|
|
754
|
+
'-C', projectDirectory,
|
|
755
|
+
];
|
|
756
|
+
if (modelName) args.push('--model', modelName);
|
|
757
|
+
for (const framePath of framePaths) args.push('--image', framePath);
|
|
758
|
+
args.push('-');
|
|
759
|
+
run(codexCommand, args, { input: prompt });
|
|
760
|
+
return {
|
|
761
|
+
copy: parseAiJson(fs.readFileSync(promoCopyResultPath, 'utf8')),
|
|
762
|
+
model: modelName || 'Codex configured model',
|
|
763
|
+
provider: 'codex',
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function generateCopyWithOpenAi(prompt, framePaths, modelName) {
|
|
768
|
+
const apiKey = String(process.env.OPENAI_API_KEY || '').trim();
|
|
769
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY is not configured.');
|
|
770
|
+
const model = modelName || process.env.OPENAI_PROMO_MODEL || 'gpt-4o-mini';
|
|
771
|
+
const content = [{ type: 'input_text', text: prompt }];
|
|
772
|
+
for (const framePath of framePaths) {
|
|
773
|
+
content.push({
|
|
774
|
+
type: 'input_image',
|
|
775
|
+
image_url: `data:image/jpeg;base64,${fs.readFileSync(framePath).toString('base64')}`,
|
|
776
|
+
detail: 'low',
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
const response = await fetch('https://api.openai.com/v1/responses', {
|
|
780
|
+
method: 'POST',
|
|
781
|
+
headers: {
|
|
782
|
+
Authorization: `Bearer ${apiKey}`,
|
|
783
|
+
'Content-Type': 'application/json',
|
|
784
|
+
},
|
|
785
|
+
body: JSON.stringify({
|
|
786
|
+
model,
|
|
787
|
+
store: false,
|
|
788
|
+
max_output_tokens: 2500,
|
|
789
|
+
input: [{ role: 'user', content }],
|
|
790
|
+
text: {
|
|
791
|
+
format: {
|
|
792
|
+
type: 'json_schema',
|
|
793
|
+
name: 'promo_video_copy',
|
|
794
|
+
strict: true,
|
|
795
|
+
schema: promoCopySchema,
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
}),
|
|
799
|
+
});
|
|
800
|
+
const result = await response.json();
|
|
801
|
+
if (!response.ok) {
|
|
802
|
+
throw new Error(`OpenAI Responses API failed: ${result.error?.message || response.statusText}`);
|
|
803
|
+
}
|
|
804
|
+
const outputText = result.output_text || result.output
|
|
805
|
+
?.flatMap((item) => item.content || [])
|
|
806
|
+
.find((item) => item.type === 'output_text')?.text;
|
|
807
|
+
if (!outputText) throw new Error('OpenAI returned no promo copy.');
|
|
808
|
+
return { copy: parseAiJson(outputText), model, provider: 'openai' };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function generateOrLoadPromoCopy(
|
|
812
|
+
options,
|
|
813
|
+
ffmpegCommand,
|
|
814
|
+
analysisInputPath,
|
|
815
|
+
allInputPaths,
|
|
816
|
+
analysisCrop,
|
|
817
|
+
) {
|
|
818
|
+
const appContext = collectAppContext();
|
|
819
|
+
const fingerprint = createCopyFingerprint(appContext, allInputPaths);
|
|
820
|
+
if (!options.refreshAiCopy && fs.existsSync(promoCopyCachePath)) {
|
|
821
|
+
try {
|
|
822
|
+
const cached = JSON.parse(fs.readFileSync(promoCopyCachePath, 'utf8'));
|
|
823
|
+
if (cached.schemaVersion === promoCopySchemaVersion && cached.fingerprint === fingerprint) {
|
|
824
|
+
return {
|
|
825
|
+
copy: validatePromoCopy(cached.copy),
|
|
826
|
+
fromCache: true,
|
|
827
|
+
model: cached.model,
|
|
828
|
+
provider: cached.provider,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
} catch (error) {
|
|
832
|
+
console.warn(`Ignoring invalid AI copy cache: ${error.message}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const provider = options.aiProvider === 'auto'
|
|
837
|
+
? (process.env.OPENAI_API_KEY ? 'openai' : 'codex')
|
|
838
|
+
: options.aiProvider;
|
|
839
|
+
console.log(`Analyzing ${sceneWindows.length} video scenes for app-specific promo copy...`);
|
|
840
|
+
const framePaths = extractAnalysisFrames(ffmpegCommand, analysisInputPath, analysisCrop);
|
|
841
|
+
let previousError = '';
|
|
842
|
+
let generated;
|
|
843
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
844
|
+
const prompt = buildPromoCopyPrompt(appContext, previousError);
|
|
845
|
+
try {
|
|
846
|
+
generated = provider === 'openai'
|
|
847
|
+
? await generateCopyWithOpenAi(prompt, framePaths, options.aiModel)
|
|
848
|
+
: generateCopyWithCodex(prompt, framePaths, options.aiModel);
|
|
849
|
+
generated.copy = validatePromoCopy(generated.copy);
|
|
850
|
+
break;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
previousError = error.message;
|
|
853
|
+
if (attempt === 2 || /unavailable|not configured|not signed in/i.test(previousError)) throw error;
|
|
854
|
+
console.warn(`AI copy attempt ${attempt} was invalid; requesting a corrected result.`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const cache = {
|
|
859
|
+
schemaVersion: promoCopySchemaVersion,
|
|
860
|
+
fingerprint,
|
|
861
|
+
appName,
|
|
862
|
+
appUniqueId,
|
|
863
|
+
provider: generated.provider,
|
|
864
|
+
model: generated.model,
|
|
865
|
+
generatedAt: new Date().toISOString(),
|
|
866
|
+
copy: generated.copy,
|
|
867
|
+
};
|
|
868
|
+
fs.mkdirSync(path.dirname(promoCopyCachePath), { recursive: true });
|
|
869
|
+
fs.writeFileSync(promoCopyCachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf8');
|
|
870
|
+
return { ...generated, fromCache: false };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function createSpatialMetadataSource(promoCopy) {
|
|
874
|
+
const suffix = ' | Spatial 3D XR Preview';
|
|
875
|
+
const maximumBaseLength = 100 - suffix.length;
|
|
876
|
+
let titleBase = promoCopy.youtube.title.slice(0, maximumBaseLength).trim();
|
|
877
|
+
if (titleBase.length < promoCopy.youtube.title.length && titleBase.includes(' ')) {
|
|
878
|
+
titleBase = titleBase.slice(0, titleBase.lastIndexOf(' ')).trim();
|
|
879
|
+
}
|
|
880
|
+
const spatialIntroduction = [
|
|
881
|
+
`Experience ${appName} as a stereoscopic 3D floating-screen preview for Android XR.`,
|
|
882
|
+
'The mobile app interface remains a 2D experience, presented with depth for headset viewing.',
|
|
883
|
+
].join(' ');
|
|
884
|
+
return {
|
|
885
|
+
description: `${spatialIntroduction}\n\n${promoCopy.youtube.description}`,
|
|
886
|
+
inputPath: spatialXrVideoPath,
|
|
887
|
+
outputPath: spatialXrVideoPath,
|
|
888
|
+
platform: 'Google-Play-XR-Spatial',
|
|
889
|
+
recommendedVisibility: 'Unlisted',
|
|
890
|
+
spatialStereo: true,
|
|
891
|
+
title: `${titleBase}${suffix}`,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function writeYoutubeUploadMetadata(promoCopy, sourceVideos) {
|
|
896
|
+
return sourceVideos.map((source) => {
|
|
897
|
+
const metadataDirectory = path.dirname(source.outputPath);
|
|
898
|
+
const relativeInputPath = path.relative(sourceVideoDirectory, source.inputPath);
|
|
899
|
+
const platform = source.platform || (relativeInputPath.startsWith('..')
|
|
900
|
+
? path.basename(metadataDirectory)
|
|
901
|
+
: relativeInputPath.split(path.sep)[0]);
|
|
902
|
+
const metadataJsonPath = path.join(
|
|
903
|
+
metadataDirectory,
|
|
904
|
+
`${appFilePrefix}-youtube-upload-metadata.json`,
|
|
905
|
+
);
|
|
906
|
+
const metadataTextPath = path.join(
|
|
907
|
+
metadataDirectory,
|
|
908
|
+
`${appFilePrefix}-youtube-upload-metadata.txt`,
|
|
909
|
+
);
|
|
910
|
+
const metadata = {
|
|
911
|
+
appName,
|
|
912
|
+
appUniqueId,
|
|
913
|
+
packageId,
|
|
914
|
+
playStoreDownloadUrl,
|
|
915
|
+
platform,
|
|
916
|
+
videoFile: path.basename(source.outputPath),
|
|
917
|
+
thumbnailFile: path.basename(getThumbnailPath(source.outputPath)),
|
|
918
|
+
recommendedVisibility: source.recommendedVisibility || '',
|
|
919
|
+
title: source.title || promoCopy.youtube.title,
|
|
920
|
+
description: addPlayStoreDownloadLink(
|
|
921
|
+
source.description || promoCopy.youtube.description,
|
|
922
|
+
),
|
|
923
|
+
};
|
|
924
|
+
const visibilityText = metadata.recommendedVisibility
|
|
925
|
+
? `\n\nRECOMMENDED VISIBILITY\n${metadata.recommendedVisibility}`
|
|
926
|
+
: '';
|
|
927
|
+
const textContent = `TITLE\n${metadata.title}\n\nDESCRIPTION\n${metadata.description}${visibilityText}\n`;
|
|
928
|
+
fs.mkdirSync(metadataDirectory, { recursive: true });
|
|
929
|
+
fs.writeFileSync(metadataJsonPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
|
|
930
|
+
fs.writeFileSync(metadataTextPath, textContent, 'utf8');
|
|
931
|
+
return { metadataJsonPath, metadataTextPath, platform };
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function getThumbnailPath(videoPath) {
|
|
936
|
+
return path.join(
|
|
937
|
+
path.dirname(videoPath),
|
|
938
|
+
`${appFilePrefix}-youtube-thumbnail-${thumbnailWidth}x${thumbnailHeight}.jpg`,
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function createVideoThumbnail(ffmpegCommand, ffprobeCommand, videoPath, spatialStereo = false) {
|
|
943
|
+
const thumbnailPath = getThumbnailPath(videoPath);
|
|
944
|
+
fs.mkdirSync(path.dirname(thumbnailPath), { recursive: true });
|
|
945
|
+
let thumbnailSize = Infinity;
|
|
946
|
+
|
|
947
|
+
for (const quality of [2, 4, 6, 8, 10]) {
|
|
948
|
+
const stereoCrop = spatialStereo ? 'crop=iw/2:ih:0:0,' : '';
|
|
949
|
+
run(ffmpegCommand, [
|
|
950
|
+
'-hide_banner',
|
|
951
|
+
'-loglevel', 'error',
|
|
952
|
+
'-y',
|
|
953
|
+
'-ss', String(thumbnailTime),
|
|
954
|
+
'-i', videoPath,
|
|
955
|
+
'-frames:v', '1',
|
|
956
|
+
'-vf', `${stereoCrop}scale=${thumbnailWidth}:${thumbnailHeight}:flags=lanczos,setsar=1`,
|
|
957
|
+
'-q:v', String(quality),
|
|
958
|
+
thumbnailPath,
|
|
959
|
+
]);
|
|
960
|
+
thumbnailSize = fs.statSync(thumbnailPath).size;
|
|
961
|
+
if (thumbnailSize <= maximumThumbnailBytes) break;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const details = getVideoDetails(ffprobeCommand, thumbnailPath);
|
|
965
|
+
const imageStream = details.streams?.find((stream) => stream.codec_type === 'video') || {};
|
|
966
|
+
if (Number(imageStream.width) !== thumbnailWidth
|
|
967
|
+
|| Number(imageStream.height) !== thumbnailHeight) {
|
|
968
|
+
throw new Error(`Thumbnail resolution is not ${thumbnailWidth}x${thumbnailHeight}.`);
|
|
969
|
+
}
|
|
970
|
+
if (thumbnailSize > maximumThumbnailBytes) {
|
|
971
|
+
throw new Error(
|
|
972
|
+
`Thumbnail is ${(thumbnailSize / 1024 / 1024).toFixed(2)}MB; maximum is 2MB.`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
console.log(
|
|
976
|
+
`Created thumbnail: ${thumbnailPath} (${Math.round(thumbnailSize / 1024)}KB)`,
|
|
977
|
+
);
|
|
978
|
+
return thumbnailPath;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function formatAssTime(seconds) {
|
|
982
|
+
const centiseconds = Math.round(seconds * 100);
|
|
983
|
+
const hours = Math.floor(centiseconds / 360000);
|
|
984
|
+
const minutes = Math.floor((centiseconds % 360000) / 6000);
|
|
985
|
+
const wholeSeconds = Math.floor((centiseconds % 6000) / 100);
|
|
986
|
+
const remainder = centiseconds % 100;
|
|
987
|
+
return `${hours}:${String(minutes).padStart(2, '0')}:${String(wholeSeconds).padStart(2, '0')}.${String(remainder).padStart(2, '0')}`;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function createCaptionFile(promoCopy) {
|
|
991
|
+
const captionAppName = escapeAssText(appName.toUpperCase());
|
|
992
|
+
const events = sceneWindows.flatMap((window, index) => {
|
|
993
|
+
const scene = promoCopy.scenes[index];
|
|
994
|
+
const start = formatAssTime(window.start);
|
|
995
|
+
const end = formatAssTime(window.end);
|
|
996
|
+
const x = index === sceneWindows.length - 1 ? 895 : 720;
|
|
997
|
+
const fadeIn = window.end - window.start <= 3 ? 180 : 220;
|
|
998
|
+
const fadeOut = index === sceneWindows.length - 1 ? 350 : fadeIn;
|
|
999
|
+
const fade = `\\fad(${fadeIn},${fadeOut})`;
|
|
1000
|
+
return [
|
|
1001
|
+
`Dialogue: 0,${start},${end},Kicker,,0,0,0,,{\\pos(${x},225)${fade}}${escapeAssText(scene.kicker.toUpperCase())}`,
|
|
1002
|
+
`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())}`,
|
|
1003
|
+
`Dialogue: 0,${start},${end},Body,,0,0,0,,{\\pos(${x},470)${fade}}${escapeAssText(scene.body)}`,
|
|
1004
|
+
];
|
|
1005
|
+
});
|
|
1006
|
+
const footer = promoCopy.footer_keywords
|
|
1007
|
+
.map((keyword) => escapeAssText(keyword.toUpperCase()))
|
|
1008
|
+
.join(' / ');
|
|
1009
|
+
const captionContent = `[Script Info]
|
|
1010
|
+
ScriptType: v4.00+
|
|
1011
|
+
PlayResX: 1920
|
|
1012
|
+
PlayResY: 1080
|
|
1013
|
+
ScaledBorderAndShadow: yes
|
|
1014
|
+
WrapStyle: 2
|
|
1015
|
+
|
|
1016
|
+
[V4+ Styles]
|
|
1017
|
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
1018
|
+
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
|
|
1019
|
+
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
|
|
1020
|
+
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
|
|
1021
|
+
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
|
|
1022
|
+
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
|
|
1023
|
+
|
|
1024
|
+
[Events]
|
|
1025
|
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
1026
|
+
Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},Brand,,0,0,0,,{\\pos(720,78)\\fad(350,350)}${captionAppName}
|
|
1027
|
+
${events.join('\n')}
|
|
1028
|
+
Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},Footer,,0,0,0,,{\\pos(720,1008)\\fad(350,350)}${footer}
|
|
1029
|
+
`;
|
|
1030
|
+
|
|
1031
|
+
fs.mkdirSync(temporaryDirectory, { recursive: true });
|
|
1032
|
+
fs.writeFileSync(captionPath, captionContent, 'utf8');
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function synthesizeNeuralSpeech(pythonCommand, voiceName, text, destinationPath) {
|
|
1036
|
+
run(pythonCommand, [
|
|
1037
|
+
'-m', 'edge_tts',
|
|
1038
|
+
'--voice', voiceName,
|
|
1039
|
+
'--rate', '+4%',
|
|
1040
|
+
'--pitch', '+0Hz',
|
|
1041
|
+
'--text', text,
|
|
1042
|
+
'--write-media', destinationPath,
|
|
1043
|
+
]);
|
|
1044
|
+
|
|
1045
|
+
if (!fs.existsSync(destinationPath) || fs.statSync(destinationPath).size < 1000) {
|
|
1046
|
+
throw new Error(`Neural narration synthesis failed: ${destinationPath}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function createNarrationSegments(pythonCommand, voiceName, promoCopy) {
|
|
1051
|
+
const segmentDirectory = path.join(temporaryDirectory, 'narration-segments');
|
|
1052
|
+
fs.mkdirSync(segmentDirectory, { recursive: true });
|
|
1053
|
+
|
|
1054
|
+
const segmentPaths = promoCopy.scenes.map((scene, index) => (
|
|
1055
|
+
path.join(segmentDirectory, `segment-${String(index + 1).padStart(2, '0')}.mp3`)
|
|
1056
|
+
));
|
|
1057
|
+
|
|
1058
|
+
promoCopy.scenes.forEach((scene, index) => {
|
|
1059
|
+
synthesizeNeuralSpeech(
|
|
1060
|
+
pythonCommand,
|
|
1061
|
+
voiceName,
|
|
1062
|
+
scene.narration,
|
|
1063
|
+
segmentPaths[index],
|
|
1064
|
+
);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
return segmentPaths;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function createVoicePreviews(pythonCommand) {
|
|
1071
|
+
const previewText = `Discover what ${appName} can do. Explore the highlights and get started today.`;
|
|
1072
|
+
fs.mkdirSync(voicePreviewDirectory, { recursive: true });
|
|
1073
|
+
|
|
1074
|
+
for (const [alias, voice] of Object.entries(neuralVoiceCatalog)) {
|
|
1075
|
+
const previewPath = path.join(voicePreviewDirectory, `${alias}-${voice.name}.mp3`);
|
|
1076
|
+
console.log(`Creating ${alias} preview: ${voice.label}`);
|
|
1077
|
+
synthesizeNeuralSpeech(pythonCommand, voice.name, previewText, previewPath);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
console.log(`Created voice previews: ${voicePreviewDirectory}`);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function createNarrationTrack(ffmpegCommand, segmentPaths) {
|
|
1084
|
+
const inputArguments = segmentPaths.flatMap((segmentPath) => ['-i', segmentPath]);
|
|
1085
|
+
const segmentFilters = sceneWindows.map((scene, index) => {
|
|
1086
|
+
const delay = Math.round((scene.start + (index === 0 ? 0.25 : 0.2)) * 1000);
|
|
1087
|
+
return `[${index}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${delay}|${delay}[n${index}]`;
|
|
1088
|
+
});
|
|
1089
|
+
const inputs = sceneWindows.map((scene, index) => `[n${index}]`).join('');
|
|
1090
|
+
const filterGraph = [
|
|
1091
|
+
...segmentFilters,
|
|
1092
|
+
`${inputs}amix=inputs=${sceneWindows.length}:normalize=0:duration=longest,highpass=f=80,lowpass=f=12000,loudnorm=I=-16:TP=-2:LRA=7,apad=whole_dur=${expectedDuration}[narration]`,
|
|
1093
|
+
].join(';');
|
|
1094
|
+
|
|
1095
|
+
run(ffmpegCommand, [
|
|
1096
|
+
'-hide_banner',
|
|
1097
|
+
'-y',
|
|
1098
|
+
...inputArguments,
|
|
1099
|
+
'-filter_complex', filterGraph,
|
|
1100
|
+
'-map', '[narration]',
|
|
1101
|
+
'-t', String(expectedDuration),
|
|
1102
|
+
'-c:a', 'pcm_s16le',
|
|
1103
|
+
'-ar', '48000',
|
|
1104
|
+
'-ac', '2',
|
|
1105
|
+
narrationTrackPath,
|
|
1106
|
+
]);
|
|
1107
|
+
|
|
1108
|
+
if (!fs.existsSync(narrationTrackPath) || fs.statSync(narrationTrackPath).size < 1000) {
|
|
1109
|
+
throw new Error('The narration track was not created.');
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function getVideoDetails(ffprobeCommand, videoPath) {
|
|
1114
|
+
return JSON.parse(run(ffprobeCommand, [
|
|
1115
|
+
'-v', 'error',
|
|
1116
|
+
'-show_entries', 'stream=codec_name,codec_type,width,height,r_frame_rate,sample_aspect_ratio,sample_rate,channels',
|
|
1117
|
+
'-show_entries', 'format=duration,size,bit_rate',
|
|
1118
|
+
'-of', 'json',
|
|
1119
|
+
videoPath,
|
|
1120
|
+
]));
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function validateInput(ffprobeCommand, inputPath) {
|
|
1124
|
+
const details = getVideoDetails(ffprobeCommand, inputPath);
|
|
1125
|
+
const videoStream = details.streams?.find((stream) => stream.codec_type === 'video');
|
|
1126
|
+
const audioStream = details.streams?.find((stream) => stream.codec_type === 'audio');
|
|
1127
|
+
const duration = Number(details.format?.duration || 0);
|
|
1128
|
+
|
|
1129
|
+
if (!videoStream || !audioStream) {
|
|
1130
|
+
throw new Error('The source must contain both video and audio streams.');
|
|
1131
|
+
}
|
|
1132
|
+
if (Number(videoStream.width) < 480 || Number(videoStream.height) < 1080) {
|
|
1133
|
+
throw new Error('The source resolution is too small for the promotional layout.');
|
|
1134
|
+
}
|
|
1135
|
+
if (duration < minimumSourceDuration) {
|
|
1136
|
+
throw new Error(`The source must be at least ${minimumSourceDuration} seconds.`);
|
|
1137
|
+
}
|
|
1138
|
+
return details;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function validateOutput(ffprobeCommand, outputPath, targetWidth, targetHeight) {
|
|
1142
|
+
const details = getVideoDetails(ffprobeCommand, outputPath);
|
|
1143
|
+
const videoStream = details.streams?.find((stream) => stream.codec_type === 'video') || {};
|
|
1144
|
+
const audioStream = details.streams?.find((stream) => stream.codec_type === 'audio') || {};
|
|
1145
|
+
const duration = Number(details.format?.duration || 0);
|
|
1146
|
+
|
|
1147
|
+
if (videoStream.codec_name !== 'h264') throw new Error('Output video is not H.264.');
|
|
1148
|
+
if (Number(videoStream.width) !== targetWidth || Number(videoStream.height) !== targetHeight) {
|
|
1149
|
+
throw new Error(`Output resolution is not ${targetWidth}x${targetHeight}.`);
|
|
1150
|
+
}
|
|
1151
|
+
if (videoStream.sample_aspect_ratio !== '1:1') {
|
|
1152
|
+
throw new Error(`Output sample aspect ratio is ${videoStream.sample_aspect_ratio}; expected 1:1.`);
|
|
1153
|
+
}
|
|
1154
|
+
if (audioStream.codec_name !== 'aac') throw new Error('Output audio is not AAC.');
|
|
1155
|
+
if (Math.abs(duration - expectedDuration) > 0.2) {
|
|
1156
|
+
throw new Error(`Output duration is ${duration.toFixed(2)}s; expected ${expectedDuration}s.`);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
console.log(
|
|
1160
|
+
`Validated: ${targetWidth}x${targetHeight}, H.264/AAC, ${duration.toFixed(2)}s, `
|
|
1161
|
+
+ `${Math.round(Number(details.format?.bit_rate || 0) / 1000)} kbps.`,
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function generatePromo(ffmpegCommand, inputPath, outputPath, crop, targetWidth, targetHeight) {
|
|
1166
|
+
const subtitleFilterPath = escapeFilterPath(captionPath);
|
|
1167
|
+
const is4K = targetWidth >= 3840 || targetHeight >= 2160;
|
|
1168
|
+
const videoBitrate = is4K ? '24M' : '9M';
|
|
1169
|
+
const maximumBitrate = is4K ? '32M' : '12M';
|
|
1170
|
+
const bufferSize = is4K ? '48M' : '18M';
|
|
1171
|
+
const h264Level = is4K ? '5.1' : '4.2';
|
|
1172
|
+
const segmentCount = editSegments.length;
|
|
1173
|
+
const videoSourceLabels = editSegments.map((_, index) => `[videosource${index}]`).join('');
|
|
1174
|
+
const audioSourceLabels = editSegments.map((_, index) => `[audiosource${index}]`).join('');
|
|
1175
|
+
const videoSourceFilter = segmentCount === 1
|
|
1176
|
+
? `[0:v]${cropFilter(crop)}format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[videosource0]`
|
|
1177
|
+
: `[0:v]${cropFilter(crop)}format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,split=${segmentCount}${videoSourceLabels}`;
|
|
1178
|
+
const audioSourceFilter = segmentCount === 1
|
|
1179
|
+
? '[0:a]aresample=48000:async=1000:first_pts=0[audiosource0]'
|
|
1180
|
+
: `[0:a]aresample=48000:async=1000:first_pts=0,asplit=${segmentCount}${audioSourceLabels}`;
|
|
1181
|
+
const videoTrimFilters = editSegments.map((segment, index) => (
|
|
1182
|
+
`[videosource${index}]trim=start=${segment.start}:end=${segment.end},setpts=PTS-STARTPTS[v${index}]`
|
|
1183
|
+
));
|
|
1184
|
+
const audioTrimFilters = editSegments.map((segment, index) => (
|
|
1185
|
+
`[audiosource${index}]atrim=start=${segment.start}:end=${segment.end},asetpts=PTS-STARTPTS[a${index}]`
|
|
1186
|
+
));
|
|
1187
|
+
const videoSequenceFilter = segmentCount === 1
|
|
1188
|
+
? '[v0]fps=30,setpts=PTS-STARTPTS,split=2[bgsrc][phonesrc]'
|
|
1189
|
+
: `${editSegments.map((_, index) => `[v${index}]`).join('')}concat=n=${segmentCount}:v=1:a=0,fps=30,setpts=PTS-STARTPTS,split=2[bgsrc][phonesrc]`;
|
|
1190
|
+
const audioSequenceFilter = segmentCount === 1
|
|
1191
|
+
? '[a0]loudnorm=I=-20:TP=-2:LRA=11[appaudio-pre]'
|
|
1192
|
+
: `${editSegments.map((_, index) => `[a${index}]`).join('')}concat=n=${segmentCount}:v=0:a=1,loudnorm=I=-20:TP=-2:LRA=11[appaudio-pre]`;
|
|
1193
|
+
const finalSceneStart = sceneWindows.at(-1).start;
|
|
1194
|
+
const audioFadeOutStart = Math.max(0, expectedDuration - 0.65);
|
|
1195
|
+
const filterGraph = [
|
|
1196
|
+
videoSourceFilter,
|
|
1197
|
+
...videoTrimFilters,
|
|
1198
|
+
videoSequenceFilter,
|
|
1199
|
+
'[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=0x07101f@0.72:t=fill[background]',
|
|
1200
|
+
'[background]drawbox=x=105:y=30:w=500:h=1020:color=black@0.58:t=fill,drawbox=x=112:y=37:w=486:h=1006:color=0x23d5c3@0.24:t=3,drawbox=x=680:y=176:w=1050:h=2:color=0x23d5c3@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]',
|
|
1201
|
+
'[phonesrc]scale=-2:980,setsar=1[phone]',
|
|
1202
|
+
'[stage][phone]overlay=x=134:y=50:shortest=1[layout]',
|
|
1203
|
+
'[1:v]scale=145:145,format=rgba,colorchannelmixer=aa=0.96[icon]',
|
|
1204
|
+
`[layout][icon]overlay=x=720:y=650:enable=between(t\\,${finalSceneStart}\\,${expectedDuration}):shortest=1[branded]`,
|
|
1205
|
+
`[branded]ass=filename='${subtitleFilterPath}',scale=${targetWidth}:${targetHeight}:flags=lanczos,setsar=1,format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[video]`,
|
|
1206
|
+
audioSourceFilter,
|
|
1207
|
+
...audioTrimFilters,
|
|
1208
|
+
audioSequenceFilter,
|
|
1209
|
+
`[appaudio-pre]afade=t=in:st=0:d=0.12,afade=t=out:st=${audioFadeOutStart}:d=0.65[appaudio]`,
|
|
1210
|
+
'[2:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,asplit=2[voicekey][voice]',
|
|
1211
|
+
'[appaudio][voicekey]sidechaincompress=threshold=0.015:ratio=10:attack=15:release=350:makeup=1[ducked]',
|
|
1212
|
+
'[ducked][voice]amix=inputs=2:weights=0.72 1.12:normalize=0,alimiter=limit=0.84:level=false[audio]',
|
|
1213
|
+
].join(';');
|
|
1214
|
+
|
|
1215
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
1216
|
+
|
|
1217
|
+
run(ffmpegCommand, [
|
|
1218
|
+
'-hide_banner',
|
|
1219
|
+
'-y',
|
|
1220
|
+
'-i', inputPath,
|
|
1221
|
+
'-loop', '1',
|
|
1222
|
+
'-framerate', '30',
|
|
1223
|
+
'-i', iconPath,
|
|
1224
|
+
'-i', narrationTrackPath,
|
|
1225
|
+
'-filter_complex', filterGraph,
|
|
1226
|
+
'-map', '[video]',
|
|
1227
|
+
'-map', '[audio]',
|
|
1228
|
+
'-t', String(expectedDuration),
|
|
1229
|
+
'-c:v', 'libx264',
|
|
1230
|
+
'-preset', 'medium',
|
|
1231
|
+
'-profile:v', 'high',
|
|
1232
|
+
'-level', h264Level,
|
|
1233
|
+
'-pix_fmt', 'yuv420p',
|
|
1234
|
+
'-r', '30',
|
|
1235
|
+
'-b:v', videoBitrate,
|
|
1236
|
+
'-maxrate', maximumBitrate,
|
|
1237
|
+
'-bufsize', bufferSize,
|
|
1238
|
+
'-c:a', 'aac',
|
|
1239
|
+
'-b:a', '192k',
|
|
1240
|
+
'-ar', '48000',
|
|
1241
|
+
'-movflags', '+faststart',
|
|
1242
|
+
outputPath,
|
|
1243
|
+
], { inherit: true });
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function generateSpatialXrPromo(ffmpegCommand, inputPath, outputPath) {
|
|
1247
|
+
const foregroundWidth = 1760;
|
|
1248
|
+
const foregroundHeight = 990;
|
|
1249
|
+
const leftForegroundX = 96;
|
|
1250
|
+
const rightForegroundX = 64;
|
|
1251
|
+
const foregroundY = 45;
|
|
1252
|
+
const filterGraph = [
|
|
1253
|
+
`[0:v]scale=${spatialEyeWidth}:${spatialEyeHeight}:flags=lanczos,setsar=1,split=3[base][leftsource][rightsource]`,
|
|
1254
|
+
'[base]gblur=sigma=48,eq=brightness=-0.34:saturation=0.82,split=2[leftbg][rightbg]',
|
|
1255
|
+
`[leftsource]scale=${foregroundWidth}:${foregroundHeight}:flags=lanczos[leftforeground]`,
|
|
1256
|
+
`[rightsource]scale=${foregroundWidth}:${foregroundHeight}:flags=lanczos[rightforeground]`,
|
|
1257
|
+
`[leftbg]drawbox=x=${leftForegroundX - 7}:y=${foregroundY - 7}:w=${foregroundWidth + 14}:h=${foregroundHeight + 14}:color=0x23d5c3@0.55:t=4[leftstage]`,
|
|
1258
|
+
`[rightbg]drawbox=x=${rightForegroundX - 7}:y=${foregroundY - 7}:w=${foregroundWidth + 14}:h=${foregroundHeight + 14}:color=0x23d5c3@0.55:t=4[rightstage]`,
|
|
1259
|
+
`[leftstage][leftforeground]overlay=x=${leftForegroundX}:y=${foregroundY}:shortest=1[left]`,
|
|
1260
|
+
`[rightstage][rightforeground]overlay=x=${rightForegroundX}:y=${foregroundY}:shortest=1[right]`,
|
|
1261
|
+
`[left][right]hstack=inputs=2,setsar=1,format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[video]`,
|
|
1262
|
+
].join(';');
|
|
1263
|
+
|
|
1264
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
1265
|
+
run(ffmpegCommand, [
|
|
1266
|
+
'-hide_banner',
|
|
1267
|
+
'-y',
|
|
1268
|
+
'-i', inputPath,
|
|
1269
|
+
'-filter_complex', filterGraph,
|
|
1270
|
+
'-map', '[video]',
|
|
1271
|
+
'-map', '0:a:0',
|
|
1272
|
+
'-t', String(expectedDuration),
|
|
1273
|
+
'-c:v', 'libx264',
|
|
1274
|
+
'-preset', 'medium',
|
|
1275
|
+
'-profile:v', 'high',
|
|
1276
|
+
'-level', '5.1',
|
|
1277
|
+
'-pix_fmt', 'yuv420p',
|
|
1278
|
+
'-r', '30',
|
|
1279
|
+
'-b:v', '18M',
|
|
1280
|
+
'-maxrate', '24M',
|
|
1281
|
+
'-bufsize', '36M',
|
|
1282
|
+
'-x264opts', 'frame-packing=3',
|
|
1283
|
+
'-metadata:s:v:0', 'stereo_mode=left_right',
|
|
1284
|
+
'-c:a', 'aac',
|
|
1285
|
+
'-b:a', '192k',
|
|
1286
|
+
'-ar', '48000',
|
|
1287
|
+
'-movflags', '+faststart',
|
|
1288
|
+
outputPath,
|
|
1289
|
+
], { inherit: true });
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function validateSpatialXrOutput(ffmpegCommand, ffprobeCommand, outputPath) {
|
|
1293
|
+
validateOutput(
|
|
1294
|
+
ffprobeCommand,
|
|
1295
|
+
outputPath,
|
|
1296
|
+
spatialOutputWidth,
|
|
1297
|
+
spatialOutputHeight,
|
|
1298
|
+
);
|
|
1299
|
+
const result = spawnSync(ffmpegCommand, [
|
|
1300
|
+
'-hide_banner',
|
|
1301
|
+
'-loglevel', 'trace',
|
|
1302
|
+
'-i', outputPath,
|
|
1303
|
+
'-map', '0:v:0',
|
|
1304
|
+
'-c:v', 'copy',
|
|
1305
|
+
'-bsf:v', 'trace_headers',
|
|
1306
|
+
'-frames:v', '1',
|
|
1307
|
+
'-f', 'null',
|
|
1308
|
+
process.platform === 'win32' ? 'NUL' : '/dev/null',
|
|
1309
|
+
], {
|
|
1310
|
+
encoding: 'utf8',
|
|
1311
|
+
windowsHide: true,
|
|
1312
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
1313
|
+
});
|
|
1314
|
+
const trace = `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
1315
|
+
if (result.error || result.status !== 0
|
|
1316
|
+
|| !/frame_packing_arrangement_type[^\n]*=\s*3/i.test(trace)) {
|
|
1317
|
+
throw new Error('Spatial XR output is missing side-by-side 3D frame-packing metadata.');
|
|
1318
|
+
}
|
|
1319
|
+
console.log('Validated spatial metadata: stereoscopic side-by-side left-right (type 3).');
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function createSpatialXrAssets(ffmpegCommand, ffprobeCommand, inputPath) {
|
|
1323
|
+
console.log(`Generating stereoscopic Spatial XR promo: ${spatialXrVideoPath}`);
|
|
1324
|
+
generateSpatialXrPromo(ffmpegCommand, inputPath, spatialXrVideoPath);
|
|
1325
|
+
validateSpatialXrOutput(ffmpegCommand, ffprobeCommand, spatialXrVideoPath);
|
|
1326
|
+
createVideoThumbnail(ffmpegCommand, ffprobeCommand, spatialXrVideoPath, true);
|
|
1327
|
+
console.log(`Created Spatial XR promo video: ${spatialXrVideoPath}`);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function getTargetDimensions(videoDetails) {
|
|
1331
|
+
const videoStream = videoDetails.streams?.find((stream) => stream.codec_type === 'video') || {};
|
|
1332
|
+
const sourceWidth = Number(videoStream.width || 0);
|
|
1333
|
+
const sourceHeight = Number(videoStream.height || 0);
|
|
1334
|
+
return sourceWidth > sourceHeight
|
|
1335
|
+
? { width: sourceWidth, height: sourceHeight }
|
|
1336
|
+
: { width: 1920, height: 1080 };
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function getBatchOutputPath(inputPath, targetWidth, targetHeight) {
|
|
1340
|
+
const relativeDirectory = path.relative(sourceVideoDirectory, path.dirname(inputPath));
|
|
1341
|
+
const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
|
|
1342
|
+
return path.join(
|
|
1343
|
+
outputVideoDirectory,
|
|
1344
|
+
relativeDirectory,
|
|
1345
|
+
`${appFilePrefix}-${sourceName}-promo-english-voiceover-${targetWidth}x${targetHeight}.mp4`,
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function getSingleOutputPath(inputPath, targetWidth, targetHeight) {
|
|
1350
|
+
const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
|
|
1351
|
+
return path.join(
|
|
1352
|
+
outputVideoDirectory,
|
|
1353
|
+
`${appFilePrefix}-${sourceName}-promo-english-voiceover-${targetWidth}x${targetHeight}.mp4`,
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async function main() {
|
|
1358
|
+
const options = parseArguments();
|
|
1359
|
+
if (options.listVoices) {
|
|
1360
|
+
printVoiceCatalog();
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if (options.previewVoices) {
|
|
1365
|
+
const previewPythonCommand = getPythonExecutable();
|
|
1366
|
+
if (!previewPythonCommand) {
|
|
1367
|
+
throw new Error(
|
|
1368
|
+
'edge-tts is required for natural neural voices. Install it with: py -m pip install edge-tts',
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
createVoicePreviews(previewPythonCommand);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const ffmpegCommand = getExecutable('ffmpeg');
|
|
1376
|
+
const ffprobeCommand = getExecutable('ffprobe');
|
|
1377
|
+
const voice = resolveVoice(options.voice);
|
|
1378
|
+
|
|
1379
|
+
if (!ffmpegCommand || !ffprobeCommand) {
|
|
1380
|
+
throw new Error('FFmpeg and FFprobe were not found in PATH or W:\\Tools\\ffmpeg\\bin.');
|
|
1381
|
+
}
|
|
1382
|
+
if (!fs.existsSync(iconPath)) throw new Error(`App icon was not found: ${iconPath}`);
|
|
1383
|
+
|
|
1384
|
+
const inputPaths = options.input
|
|
1385
|
+
? [path.resolve(options.input)]
|
|
1386
|
+
: findVideos(sourceVideoDirectory).filter((inputPath) => !isExcludedBatchSource(inputPath));
|
|
1387
|
+
if (inputPaths.length === 0) {
|
|
1388
|
+
throw new Error(`No MP4 videos were found under: ${sourceVideoDirectory}`);
|
|
1389
|
+
}
|
|
1390
|
+
for (const inputPath of inputPaths) {
|
|
1391
|
+
if (!fs.existsSync(inputPath)) throw new Error(`Source video was not found: ${inputPath}`);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
console.log(`Preparing ${inputPaths.length} source video${inputPaths.length === 1 ? '' : 's'}...`);
|
|
1395
|
+
const sourceVideos = inputPaths.map((inputPath) => {
|
|
1396
|
+
const details = validateInput(ffprobeCommand, inputPath);
|
|
1397
|
+
const duration = Number(details.format?.duration || 0);
|
|
1398
|
+
const crop = detectActiveVideoCrop(ffmpegCommand, inputPath, details);
|
|
1399
|
+
const target = getTargetDimensions(details);
|
|
1400
|
+
const outputPath = options.output
|
|
1401
|
+
? path.resolve(options.output)
|
|
1402
|
+
: options.input
|
|
1403
|
+
? getSingleOutputPath(inputPath, target.width, target.height)
|
|
1404
|
+
: getBatchOutputPath(inputPath, target.width, target.height);
|
|
1405
|
+
return { crop, duration, inputPath, outputPath, target };
|
|
1406
|
+
});
|
|
1407
|
+
configurePromoTimeline(Math.min(...sourceVideos.map((source) => source.duration)));
|
|
1408
|
+
const analysisSource = [...sourceVideos].sort((left, right) => (
|
|
1409
|
+
(right.target.width * right.target.height) - (left.target.width * left.target.height)
|
|
1410
|
+
))[0];
|
|
1411
|
+
const promoCopyResult = await generateOrLoadPromoCopy(
|
|
1412
|
+
options,
|
|
1413
|
+
ffmpegCommand,
|
|
1414
|
+
analysisSource.inputPath,
|
|
1415
|
+
inputPaths,
|
|
1416
|
+
analysisSource.crop,
|
|
1417
|
+
);
|
|
1418
|
+
console.log(
|
|
1419
|
+
`${promoCopyResult.fromCache ? 'Using cached' : 'Generated'} AI promo copy `
|
|
1420
|
+
+ `(${promoCopyResult.provider}, ${promoCopyResult.model}).`,
|
|
1421
|
+
);
|
|
1422
|
+
console.log(`AI promo copy: ${promoCopyCachePath}`);
|
|
1423
|
+
const nonSpatialXrSource = sourceVideos.find((source) => (
|
|
1424
|
+
/(^|[\\/])google-play-xr-non-spatial([\\/]|$)/i.test(source.inputPath)
|
|
1425
|
+
));
|
|
1426
|
+
const spatialMetadataSource = nonSpatialXrSource
|
|
1427
|
+
? createSpatialMetadataSource(promoCopyResult.copy)
|
|
1428
|
+
: null;
|
|
1429
|
+
const youtubeMetadataFiles = writeYoutubeUploadMetadata(
|
|
1430
|
+
promoCopyResult.copy,
|
|
1431
|
+
spatialMetadataSource ? [...sourceVideos, spatialMetadataSource] : sourceVideos,
|
|
1432
|
+
);
|
|
1433
|
+
for (const metadataFile of youtubeMetadataFiles) {
|
|
1434
|
+
console.log(
|
|
1435
|
+
`${metadataFile.platform} YouTube metadata: ${metadataFile.metadataTextPath}`,
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
if (options.copyOnly) return;
|
|
1439
|
+
if (options.spatialXrOnly) {
|
|
1440
|
+
if (!nonSpatialXrSource || !spatialMetadataSource) {
|
|
1441
|
+
throw new Error('A Google-Play-XR-Non-Spatial source video is required.');
|
|
1442
|
+
}
|
|
1443
|
+
if (!fs.existsSync(nonSpatialXrSource.outputPath)) {
|
|
1444
|
+
throw new Error(
|
|
1445
|
+
`Generate the non-spatial XR promo first: ${nonSpatialXrSource.outputPath}`,
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
createSpatialXrAssets(
|
|
1449
|
+
ffmpegCommand,
|
|
1450
|
+
ffprobeCommand,
|
|
1451
|
+
nonSpatialXrSource.outputPath,
|
|
1452
|
+
);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
if (options.thumbnailsOnly) {
|
|
1456
|
+
const thumbnailSources = spatialMetadataSource
|
|
1457
|
+
? [...sourceVideos, spatialMetadataSource]
|
|
1458
|
+
: sourceVideos;
|
|
1459
|
+
for (const source of thumbnailSources) {
|
|
1460
|
+
if (!fs.existsSync(source.outputPath)) {
|
|
1461
|
+
throw new Error(`Generated promo video was not found: ${source.outputPath}`);
|
|
1462
|
+
}
|
|
1463
|
+
createVideoThumbnail(
|
|
1464
|
+
ffmpegCommand,
|
|
1465
|
+
ffprobeCommand,
|
|
1466
|
+
source.outputPath,
|
|
1467
|
+
Boolean(source.spatialStereo),
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
console.log(`Completed ${thumbnailSources.length} promotional thumbnails.`);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const pythonCommand = getPythonExecutable();
|
|
1475
|
+
if (!pythonCommand) {
|
|
1476
|
+
throw new Error(
|
|
1477
|
+
'edge-tts is required for natural neural voices. Install it with: py -m pip install edge-tts',
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
createCaptionFile(promoCopyResult.copy);
|
|
1481
|
+
console.log(`Creating natural neural narration with: ${voice.label}`);
|
|
1482
|
+
const narrationSegmentPaths = createNarrationSegments(
|
|
1483
|
+
pythonCommand,
|
|
1484
|
+
voice.name,
|
|
1485
|
+
promoCopyResult.copy,
|
|
1486
|
+
);
|
|
1487
|
+
createNarrationTrack(ffmpegCommand, narrationSegmentPaths);
|
|
1488
|
+
for (const [index, source] of sourceVideos.entries()) {
|
|
1489
|
+
console.log(
|
|
1490
|
+
`Generating promo ${index + 1}/${sourceVideos.length}: ${source.inputPath} `
|
|
1491
|
+
+ `-> ${source.target.width}x${source.target.height}`,
|
|
1492
|
+
);
|
|
1493
|
+
generatePromo(
|
|
1494
|
+
ffmpegCommand,
|
|
1495
|
+
source.inputPath,
|
|
1496
|
+
source.outputPath,
|
|
1497
|
+
source.crop,
|
|
1498
|
+
source.target.width,
|
|
1499
|
+
source.target.height,
|
|
1500
|
+
);
|
|
1501
|
+
validateOutput(
|
|
1502
|
+
ffprobeCommand,
|
|
1503
|
+
source.outputPath,
|
|
1504
|
+
source.target.width,
|
|
1505
|
+
source.target.height,
|
|
1506
|
+
);
|
|
1507
|
+
console.log(`Created promo video: ${source.outputPath}`);
|
|
1508
|
+
createVideoThumbnail(ffmpegCommand, ffprobeCommand, source.outputPath);
|
|
1509
|
+
}
|
|
1510
|
+
if (nonSpatialXrSource && spatialMetadataSource) {
|
|
1511
|
+
createSpatialXrAssets(
|
|
1512
|
+
ffmpegCommand,
|
|
1513
|
+
ffprobeCommand,
|
|
1514
|
+
nonSpatialXrSource.outputPath,
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
console.log(
|
|
1518
|
+
`Completed ${sourceVideos.length + (spatialMetadataSource ? 1 : 0)} promotional videos.`,
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
main().catch((error) => {
|
|
1523
|
+
console.error(`Promo generation failed: ${error.message}`);
|
|
1524
|
+
process.exitCode = 1;
|
|
1525
|
+
});
|