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