codeplay-common 4.6.4 → 4.6.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.
@@ -1,2085 +1,2085 @@
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 --layout dynamic
4
- // Optional: node generate-ai-promo-video.js --input path/to/source.mp4 --output path/to/output.mp4
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
-
15
- const fs = require('node:fs');
16
- const path = require('node:path');
17
- const { createHash } = require('node:crypto');
18
- const { spawnSync } = require('node:child_process');
19
- const { createInterface } = require('node:readline/promises');
20
-
21
- const projectDirectory = __dirname;
22
- const capacitorConfigPath = path.join(projectDirectory, 'capacitor.config.json');
23
- const capacitorConfig = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
24
- const appName = String(capacitorConfig.appName || '').trim();
25
- const appUniqueId = String(capacitorConfig.android?.APP_UNIQUE_ID ?? '').trim();
26
- const packageId = String(capacitorConfig.appId || '').trim();
27
- const iosStoreConfigPath = path.join(projectDirectory, 'Auto-AI-Video', 'ios-store-config.json');
28
- let iosStoreConfig = {};
29
- if (fs.existsSync(iosStoreConfigPath)) {
30
- try {
31
- iosStoreConfig = JSON.parse(fs.readFileSync(iosStoreConfigPath, 'utf8'));
32
- } catch {
33
- iosStoreConfig = {};
34
- }
35
- }
36
-
37
- if (!appName) throw new Error('capacitor.config.json is missing appName.');
38
- if (!appUniqueId) throw new Error('capacitor.config.json is missing android.APP_UNIQUE_ID.');
39
- if (!packageId) throw new Error('capacitor.config.json is missing appId.');
40
-
41
- const appFilePrefix = `${appUniqueId}. ${sanitizeFileNamePart(appName)}`;
42
- const playStoreDownloadUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageId)}`;
43
- const configuredAppStoreUrl = String(iosStoreConfig.appStoreUrl || '')
44
- .match(/https:\/\/apps\.apple\.com\/app\/id\d+/)?.[0] || '';
45
- let appStoreDownloadUrl = /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(configuredAppStoreUrl)
46
- ? configuredAppStoreUrl
47
- : '';
48
- const promoDesignIndex = createHash('sha1').update(`${appUniqueId}:${appName}`).digest()[0] % 4;
49
- const promoDesigns = [
50
- { accent: '0x23d5c3', background: '0x07101f', panelX: 105, panelWidth: 500, phoneX: 134 },
51
- { accent: '0xffb020', background: '0x241006', panelX: 1290, panelWidth: 500, phoneX: 64 },
52
- { accent: '0x9b7cff', background: '0x160d2b', panelX: 105, panelWidth: 620, phoneX: 134 },
53
- { accent: '0xff5b8d', background: '0x21101a', panelX: 1240, panelWidth: 550, phoneX: 64 },
54
- ];
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');
66
- let endCardStart = 0;
67
- const endCardDuration = 8;
68
- const sourceVideoDirectory = path.join(projectDirectory, 'Auto-Screenshot', 'Video', 'Output');
69
- const outputVideoDirectory = path.join(projectDirectory, 'Auto-AI-Video');
70
- const spatialXrDirectory = path.join(outputVideoDirectory, 'Google-Play-XR-Spatial');
71
- const spatialXrVideoPath = path.join(
72
- spatialXrDirectory,
73
- `${appFilePrefix}-spatial-xr-3d-sbs-lr-3840x1080.mp4`,
74
- );
75
- const iconPath = path.join(projectDirectory, 'resources', 'icon-only.png');
76
- const temporaryDirectory = path.join(projectDirectory, 'agent-temp', 'ai-promo-video');
77
- const captionPath = path.join(temporaryDirectory, `${appUniqueId}-promo-english.ass`);
78
- const narrationTrackPath = path.join(temporaryDirectory, `${appUniqueId}-promo-narration.wav`);
79
- const voicePreviewDirectory = path.join(projectDirectory, 'Auto-AI-Video', 'Voice-Samples');
80
- const promoCopyCachePath = path.join(
81
- projectDirectory,
82
- 'Auto-AI-Video',
83
- `${appFilePrefix}-promo-copy.json`,
84
- );
85
- const analysisFrameDirectory = path.join(temporaryDirectory, 'ai-analysis');
86
- const endCardBannerPath = path.join(outputVideoDirectory, `${appFilePrefix}-ai-end-card-banner.png`);
87
- const promoCopySchemaPath = path.join(temporaryDirectory, 'promo-copy-schema.json');
88
- const promoCopyResultPath = path.join(temporaryDirectory, 'promo-copy-result.json');
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;
96
- const thumbnailTime = 2;
97
- const thumbnailWidth = 3840;
98
- const thumbnailHeight = 2160;
99
- const maximumThumbnailBytes = 2 * 1024 * 1024;
100
- const spatialEyeWidth = 1920;
101
- const spatialEyeHeight = 1080;
102
- const spatialOutputWidth = spatialEyeWidth * 2;
103
- const spatialOutputHeight = spatialEyeHeight;
104
- const promoCopySchemaVersion = 5;
105
- const promoCopySchema = {
106
- type: 'object',
107
- additionalProperties: false,
108
- required: ['footer_keywords', 'scenes', 'youtube'],
109
- properties: {
110
- footer_keywords: {
111
- type: 'array',
112
- minItems: 3,
113
- maxItems: 3,
114
- items: { type: 'string' },
115
- },
116
- scenes: {
117
- type: 'array',
118
- minItems: sceneCount,
119
- maxItems: sceneCount,
120
- items: {
121
- type: 'object',
122
- additionalProperties: false,
123
- required: ['kicker', 'title_line_1', 'title_line_2', 'body', 'narration'],
124
- properties: {
125
- kicker: { type: 'string' },
126
- title_line_1: { type: 'string' },
127
- title_line_2: { type: 'string' },
128
- body: { type: 'string' },
129
- narration: { type: 'string' },
130
- },
131
- },
132
- },
133
- youtube: {
134
- type: 'object',
135
- additionalProperties: false,
136
- required: ['title', 'description'],
137
- properties: {
138
- title: { type: 'string' },
139
- description: { type: 'string' },
140
- },
141
- },
142
- },
143
- };
144
- const neuralVoiceCatalog = {
145
- aria: {
146
- name: 'en-US-AriaNeural',
147
- label: 'Aria — US English, confident and polished',
148
- },
149
- ava: {
150
- name: 'en-US-AvaMultilingualNeural',
151
- label: 'Ava — US English, expressive and conversational',
152
- },
153
- emma: {
154
- name: 'en-US-EmmaMultilingualNeural',
155
- label: 'Emma — US English, cheerful and clear',
156
- },
157
- jenny: {
158
- name: 'en-US-JennyNeural',
159
- label: 'Jenny — US English, friendly and warm',
160
- },
161
- michelle: {
162
- name: 'en-US-MichelleNeural',
163
- label: 'Michelle — US English, pleasant and composed',
164
- },
165
- sonia: {
166
- name: 'en-GB-SoniaNeural',
167
- label: 'Sonia — UK English, polished and professional',
168
- },
169
- libby: {
170
- name: 'en-GB-LibbyNeural',
171
- label: 'Libby — UK English, friendly and positive',
172
- },
173
- neerja: {
174
- name: 'en-IN-NeerjaExpressiveNeural',
175
- label: 'Neerja — Indian English, expressive and natural',
176
- },
177
- natasha: {
178
- name: 'en-AU-NatashaNeural',
179
- label: 'Natasha — Australian English, friendly and positive',
180
- },
181
- clara: {
182
- name: 'en-CA-ClaraNeural',
183
- label: 'Clara — Canadian English, clear and friendly',
184
- },
185
- };
186
- function sanitizeFileNamePart(value) {
187
- return String(value)
188
- .trim()
189
- .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
190
- .replace(/[. ]+$/g, '') || 'App';
191
- }
192
-
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'
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 --layout dynamic
4
+ // Optional: node generate-ai-promo-video.js --input path/to/source.mp4 --output path/to/output.mp4
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
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const { createHash } = require('node:crypto');
18
+ const { spawnSync } = require('node:child_process');
19
+ const { createInterface } = require('node:readline/promises');
20
+
21
+ const projectDirectory = __dirname;
22
+ const capacitorConfigPath = path.join(projectDirectory, 'capacitor.config.json');
23
+ const capacitorConfig = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
24
+ const appName = String(capacitorConfig.appName || '').trim();
25
+ const appUniqueId = String(capacitorConfig.android?.APP_UNIQUE_ID ?? '').trim();
26
+ const packageId = String(capacitorConfig.appId || '').trim();
27
+ const iosStoreConfigPath = path.join(projectDirectory, 'Auto-AI-Video', 'ios-store-config.json');
28
+ let iosStoreConfig = {};
29
+ if (fs.existsSync(iosStoreConfigPath)) {
30
+ try {
31
+ iosStoreConfig = JSON.parse(fs.readFileSync(iosStoreConfigPath, 'utf8'));
32
+ } catch {
33
+ iosStoreConfig = {};
34
+ }
35
+ }
36
+
37
+ if (!appName) throw new Error('capacitor.config.json is missing appName.');
38
+ if (!appUniqueId) throw new Error('capacitor.config.json is missing android.APP_UNIQUE_ID.');
39
+ if (!packageId) throw new Error('capacitor.config.json is missing appId.');
40
+
41
+ const appFilePrefix = `${appUniqueId}. ${sanitizeFileNamePart(appName)}`;
42
+ const playStoreDownloadUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageId)}`;
43
+ const configuredAppStoreUrl = String(iosStoreConfig.appStoreUrl || '')
44
+ .match(/https:\/\/apps\.apple\.com\/app\/id\d+/)?.[0] || '';
45
+ let appStoreDownloadUrl = /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(configuredAppStoreUrl)
46
+ ? configuredAppStoreUrl
47
+ : '';
48
+ const promoDesignIndex = createHash('sha1').update(`${appUniqueId}:${appName}`).digest()[0] % 4;
49
+ const promoDesigns = [
50
+ { accent: '0x23d5c3', background: '0x07101f', panelX: 105, panelWidth: 500, phoneX: 134 },
51
+ { accent: '0xffb020', background: '0x241006', panelX: 1290, panelWidth: 500, phoneX: 64 },
52
+ { accent: '0x9b7cff', background: '0x160d2b', panelX: 105, panelWidth: 620, phoneX: 134 },
53
+ { accent: '0xff5b8d', background: '0x21101a', panelX: 1240, panelWidth: 550, phoneX: 64 },
54
+ ];
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');
66
+ let endCardStart = 0;
67
+ const endCardDuration = 8;
68
+ const sourceVideoDirectory = path.join(projectDirectory, 'Auto-Screenshot', 'Video', 'Output');
69
+ const outputVideoDirectory = path.join(projectDirectory, 'Auto-AI-Video');
70
+ const spatialXrDirectory = path.join(outputVideoDirectory, 'Google-Play-XR-Spatial');
71
+ const spatialXrVideoPath = path.join(
72
+ spatialXrDirectory,
73
+ `${appFilePrefix}-spatial-xr-3d-sbs-lr-3840x1080.mp4`,
74
+ );
75
+ const iconPath = path.join(projectDirectory, 'resources', 'icon-only.png');
76
+ const temporaryDirectory = path.join(projectDirectory, 'agent-temp', 'ai-promo-video');
77
+ const captionPath = path.join(temporaryDirectory, `${appUniqueId}-promo-english.ass`);
78
+ const narrationTrackPath = path.join(temporaryDirectory, `${appUniqueId}-promo-narration.wav`);
79
+ const voicePreviewDirectory = path.join(projectDirectory, 'Auto-AI-Video', 'Voice-Samples');
80
+ const promoCopyCachePath = path.join(
81
+ projectDirectory,
82
+ 'Auto-AI-Video',
83
+ `${appFilePrefix}-promo-copy.json`,
84
+ );
85
+ const analysisFrameDirectory = path.join(temporaryDirectory, 'ai-analysis');
86
+ const endCardBannerPath = path.join(outputVideoDirectory, `${appFilePrefix}-ai-end-card-banner.png`);
87
+ const promoCopySchemaPath = path.join(temporaryDirectory, 'promo-copy-schema.json');
88
+ const promoCopyResultPath = path.join(temporaryDirectory, 'promo-copy-result.json');
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;
96
+ const thumbnailTime = 2;
97
+ const thumbnailWidth = 3840;
98
+ const thumbnailHeight = 2160;
99
+ const maximumThumbnailBytes = 2 * 1024 * 1024;
100
+ const spatialEyeWidth = 1920;
101
+ const spatialEyeHeight = 1080;
102
+ const spatialOutputWidth = spatialEyeWidth * 2;
103
+ const spatialOutputHeight = spatialEyeHeight;
104
+ const promoCopySchemaVersion = 5;
105
+ const promoCopySchema = {
106
+ type: 'object',
107
+ additionalProperties: false,
108
+ required: ['footer_keywords', 'scenes', 'youtube'],
109
+ properties: {
110
+ footer_keywords: {
111
+ type: 'array',
112
+ minItems: 3,
113
+ maxItems: 3,
114
+ items: { type: 'string' },
115
+ },
116
+ scenes: {
117
+ type: 'array',
118
+ minItems: sceneCount,
119
+ maxItems: sceneCount,
120
+ items: {
121
+ type: 'object',
122
+ additionalProperties: false,
123
+ required: ['kicker', 'title_line_1', 'title_line_2', 'body', 'narration'],
124
+ properties: {
125
+ kicker: { type: 'string' },
126
+ title_line_1: { type: 'string' },
127
+ title_line_2: { type: 'string' },
128
+ body: { type: 'string' },
129
+ narration: { type: 'string' },
130
+ },
131
+ },
132
+ },
133
+ youtube: {
134
+ type: 'object',
135
+ additionalProperties: false,
136
+ required: ['title', 'description'],
137
+ properties: {
138
+ title: { type: 'string' },
139
+ description: { type: 'string' },
140
+ },
141
+ },
142
+ },
143
+ };
144
+ const neuralVoiceCatalog = {
145
+ aria: {
146
+ name: 'en-US-AriaNeural',
147
+ label: 'Aria — US English, confident and polished',
148
+ },
149
+ ava: {
150
+ name: 'en-US-AvaMultilingualNeural',
151
+ label: 'Ava — US English, expressive and conversational',
152
+ },
153
+ emma: {
154
+ name: 'en-US-EmmaMultilingualNeural',
155
+ label: 'Emma — US English, cheerful and clear',
156
+ },
157
+ jenny: {
158
+ name: 'en-US-JennyNeural',
159
+ label: 'Jenny — US English, friendly and warm',
160
+ },
161
+ michelle: {
162
+ name: 'en-US-MichelleNeural',
163
+ label: 'Michelle — US English, pleasant and composed',
164
+ },
165
+ sonia: {
166
+ name: 'en-GB-SoniaNeural',
167
+ label: 'Sonia — UK English, polished and professional',
168
+ },
169
+ libby: {
170
+ name: 'en-GB-LibbyNeural',
171
+ label: 'Libby — UK English, friendly and positive',
172
+ },
173
+ neerja: {
174
+ name: 'en-IN-NeerjaExpressiveNeural',
175
+ label: 'Neerja — Indian English, expressive and natural',
176
+ },
177
+ natasha: {
178
+ name: 'en-AU-NatashaNeural',
179
+ label: 'Natasha — Australian English, friendly and positive',
180
+ },
181
+ clara: {
182
+ name: 'en-CA-ClaraNeural',
183
+ label: 'Clara — Canadian English, clear and friendly',
184
+ },
185
+ };
186
+ function sanitizeFileNamePart(value) {
187
+ return String(value)
188
+ .trim()
189
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
190
+ .replace(/[. ]+$/g, '') || 'App';
191
+ }
192
+
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
211
  + '- Keep the background subtle so it does not compete with the icon, title, feature text, 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'
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
216
  + '- Add the application title prominently.\n'
217
217
  + '- Add concise promotional text describing the application’s actual features, presenting each feature one by one.\n'
218
218
  + '- Keep the title and feature text sharp, readable, accurate, and visually balanced with the icon and download badges.\n'
219
219
  + '- Do not invent unsupported features or add unrelated marketing text.\n'
220
- + '- Do not add watermarks, fake UI, generated symbols, or unrelated decorations.\n'
221
- + '- Rounded corners and a subtle shadow may be applied around the app icon, but its internal artwork must remain unchanged.\n'
222
- + '- Use image generation only for the background.\n'
223
- + '- Composite the actual app icon and official store badges afterward.\n'
224
- + '- Replace the specified output file if it already exists.\n'
225
- + '- Verify the final PNG dimensions are exactly 1672 × 941.\n'
226
- + '- Visually inspect the completed image before finishing.\n'
227
- + '- Report the final saved path.\n\n'
228
- + 'Then run the video-generation command again.',
229
- );
230
- }
231
-
232
- async function ensureAppStoreDownloadUrl() {
233
- if (appStoreDownloadUrl) return;
234
- const readline = createInterface({ input: process.stdin, output: process.stdout });
235
- try {
236
- const appStoreId = (await readline.question(
237
- 'Enter the iOS App Store ID (press Enter to skip): ',
238
- )).trim();
239
- if (!appStoreId) return;
240
- if (!/^\d+$/.test(appStoreId)) {
241
- throw new Error('iOS App Store ID must contain digits only.');
242
- }
243
- appStoreDownloadUrl = `https://apps.apple.com/app/id${appStoreId}`;
244
- iosStoreConfig = { ...iosStoreConfig, appStoreUrl: appStoreDownloadUrl };
245
- fs.mkdirSync(path.dirname(iosStoreConfigPath), { recursive: true });
246
- fs.writeFileSync(iosStoreConfigPath, `${JSON.stringify(iosStoreConfig, null, 2)}\n`, 'utf8');
247
- console.log(`Saved iOS App Store URL to ${iosStoreConfigPath}`);
248
- } finally {
249
- readline.close();
250
- }
251
- }
252
-
253
- function findVideos(directory) {
254
- if (!fs.existsSync(directory)) return [];
255
- return fs.readdirSync(directory, { withFileTypes: true })
256
- .flatMap((entry) => {
257
- const entryPath = path.join(directory, entry.name);
258
- if (entry.isDirectory()) return findVideos(entryPath);
259
- return entry.isFile() && /\.mp4$/i.test(entry.name) ? [entryPath] : [];
260
- })
261
- .sort((left, right) => left.localeCompare(right));
262
- }
263
-
264
- function isExcludedBatchSource(inputPath) {
265
- const relativePath = path.relative(sourceVideoDirectory, inputPath);
266
- const platformDirectory = relativePath.split(path.sep)[0].toLowerCase();
267
- return platformDirectory === 'samsung';
268
- }
269
-
270
- function roundTimelineValue(value) {
271
- return Math.round(value * 100) / 100;
272
- }
273
-
274
- function mapOutputTimeToSource(outputTime) {
275
- let remainingTime = outputTime;
276
- for (const segment of editSegments) {
277
- const segmentDuration = segment.end - segment.start;
278
- if (remainingTime <= segmentDuration) {
279
- return Math.min(segment.end - 0.05, segment.start + remainingTime);
280
- }
281
- remainingTime -= segmentDuration;
282
- }
283
- return editSegments.at(-1).end - 0.05;
284
- }
285
-
286
- function mapSourceTimeToOutput(sourceTime) {
287
- let outputTime = 0;
288
- for (const segment of editSegments) {
289
- if (sourceTime < segment.start) return outputTime;
290
- if (sourceTime <= segment.end) return outputTime + (sourceTime - segment.start);
291
- outputTime += segment.end - segment.start;
292
- }
293
- return outputTime;
294
- }
295
-
296
- function normalizeSkippedRanges(skippedRanges, sourceDuration) {
297
- const ranges = skippedRanges
298
- .map((range) => ({
299
- start: Math.max(0, Math.min(sourceDuration, range.start)),
300
- end: Math.max(0, Math.min(sourceDuration, range.end)),
301
- }))
302
- .filter((range) => range.end > range.start)
303
- .sort((left, right) => left.start - right.start);
304
-
305
- return ranges.reduce((merged, range) => {
306
- const previous = merged.at(-1);
307
- if (previous && range.start <= previous.end) {
308
- previous.end = Math.max(previous.end, range.end);
309
- } else {
310
- merged.push({ ...range });
311
- }
312
- return merged;
313
- }, []);
314
- }
315
-
316
- function buildRetainedSegments(sourceDuration, skippedRanges) {
317
- const normalizedRanges = normalizeSkippedRanges(skippedRanges, sourceDuration);
318
- const retainedSegments = [];
319
- let retainedStart = 0;
320
-
321
- for (const range of normalizedRanges) {
322
- if (range.start > retainedStart) {
323
- retainedSegments.push({ start: retainedStart, end: range.start });
324
- }
325
- retainedStart = range.end;
326
- }
327
- if (retainedStart < sourceDuration) {
328
- retainedSegments.push({ start: retainedStart, end: sourceDuration });
329
- }
330
-
331
- return {
332
- retainedSegments: retainedSegments.map((segment) => ({
333
- start: roundTimelineValue(segment.start),
334
- end: roundTimelineValue(segment.end),
335
- })),
336
- skippedDuration: normalizedRanges.reduce(
337
- (total, range) => total + (range.end - range.start),
338
- 0,
339
- ),
340
- };
341
- }
342
-
343
- function buildSceneWindows(retainedDuration, visualChangeTimes) {
344
- const minimumWindowDuration = Math.max(2.5, Math.min(4, retainedDuration / 40));
345
- const maximumWindowDuration = Math.max(6, Math.min(10, retainedDuration / 14));
346
- const boundaries = [0];
347
- const mappedChanges = visualChangeTimes
348
- .map((sourceTime) => mapSourceTimeToOutput(sourceTime))
349
- .filter((outputTime) => outputTime > 0 && outputTime < retainedDuration)
350
- .sort((left, right) => left - right);
351
-
352
- for (const changeTime of mappedChanges) {
353
- if (changeTime - boundaries.at(-1) >= minimumWindowDuration) {
354
- boundaries.push(changeTime);
355
- }
356
- }
357
- if (retainedDuration - boundaries.at(-1) < minimumWindowDuration) boundaries.pop();
358
- boundaries.push(retainedDuration);
359
-
360
- const windows = [];
361
- for (let index = 0; index < boundaries.length - 1; index += 1) {
362
- const start = boundaries[index];
363
- const end = boundaries[index + 1];
364
- const splitCount = Math.max(1, Math.ceil((end - start) / maximumWindowDuration));
365
- for (let splitIndex = 0; splitIndex < splitCount; splitIndex += 1) {
366
- windows.push({
367
- start: roundTimelineValue(start + (((end - start) * splitIndex) / splitCount)),
368
- end: roundTimelineValue(start + (((end - start) * (splitIndex + 1)) / splitCount)),
369
- });
370
- }
371
- }
372
-
373
- if (windows.length >= minimumSceneCount) return windows;
374
- return Array.from({ length: minimumSceneCount }, (_, index) => ({
375
- start: roundTimelineValue((retainedDuration * index) / minimumSceneCount),
376
- end: index === minimumSceneCount - 1
377
- ? retainedDuration
378
- : roundTimelineValue((retainedDuration * (index + 1)) / minimumSceneCount),
379
- }));
380
- }
381
-
382
- function configurePromoTimeline(sourceDuration, skippedRanges = [], visualChangeTimes = []) {
383
- if (!Number.isFinite(sourceDuration) || sourceDuration < minimumSourceDuration) {
384
- throw new Error(
385
- `The shortest source video must be at least ${minimumSourceDuration} seconds.`,
386
- );
387
- }
388
-
389
- const { retainedSegments, skippedDuration } = buildRetainedSegments(
390
- sourceDuration,
391
- skippedRanges,
392
- );
393
- editSegments = retainedSegments.filter((segment) => segment.end > segment.start);
394
- const retainedDuration = editSegments.reduce(
395
- (total, segment) => total + (segment.end - segment.start),
396
- 0,
397
- );
398
- if (retainedDuration < minimumSourceDuration) {
399
- throw new Error(
400
- `At least ${minimumSourceDuration} seconds must remain after automatic cleanup.`,
401
- );
402
- }
403
-
404
- expectedDuration = roundTimelineValue(retainedDuration);
405
- sceneWindows = buildSceneWindows(expectedDuration, visualChangeTimes).map((window) => {
406
- const sceneDuration = window.end - window.start;
407
- return {
408
- ...window,
409
- sourceTime: roundTimelineValue(mapOutputTimeToSource(
410
- window.start + (sceneDuration / 2),
411
- )),
412
- maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
413
- };
414
- });
415
- sceneCount = sceneWindows.length;
416
- promoCopySchema.properties.scenes.minItems = sceneCount;
417
- promoCopySchema.properties.scenes.maxItems = sceneCount;
418
- endCardStart = expectedDuration;
419
- expectedDuration = roundTimelineValue(expectedDuration + endCardDuration);
420
-
421
- console.log(
422
- `Feature-complete promo timeline: ${expectedDuration}s from ${sourceDuration.toFixed(2)}s source; `
423
- + `${sceneCount} scenes and ${editSegments.length} retained segment${editSegments.length === 1 ? '' : 's'}`
424
- + `${skippedDuration > 0 ? ` (${skippedDuration.toFixed(2)}s of redundant static footage removed)` : ''}.`,
425
- );
426
- }
427
-
428
- function escapeAssText(value) {
429
- return String(value)
430
- .replace(/\\/g, '\\\\')
431
- .replace(/{/g, '\\{')
432
- .replace(/}/g, '\\}')
433
- .replace(/\r?\n/g, '\\N');
434
- }
435
-
436
- function run(command, args, options = {}) {
437
- const result = spawnSync(command, args, {
438
- encoding: 'utf8',
439
- windowsHide: true,
440
- stdio: options.inherit ? 'inherit' : 'pipe',
441
- input: options.input,
442
- maxBuffer: 10 * 1024 * 1024,
443
- });
444
-
445
- if (result.error) {
446
- if (result.error.code === 'ENOENT') {
447
- throw new Error(`${command} was not found.`);
448
- }
449
- throw result.error;
450
- }
451
-
452
- if (result.status !== 0) {
453
- throw new Error(
454
- String(result.stderr || result.stdout || `${command} exited with code ${result.status}.`).trim(),
455
- );
456
- }
457
-
458
- return String(result.stdout || '').trim();
459
- }
460
-
461
- function commandExists(command) {
462
- const result = spawnSync(command, ['-version'], {
463
- encoding: 'utf8',
464
- windowsHide: true,
465
- });
466
- return !result.error && result.status === 0;
467
- }
468
-
469
- function getExecutable(command) {
470
- if (commandExists(command)) return command;
471
-
472
- if (!ffmpegFallbackDirectory) return '';
473
- const executableName = process.platform === 'win32' ? `${command}.exe` : command;
474
- const fallbackPath = path.join(ffmpegFallbackDirectory, executableName);
475
- return fs.existsSync(fallbackPath) ? fallbackPath : '';
476
- }
477
-
478
- function detectRedundantStaticRanges(ffmpegCommand, inputPath, sourceDuration) {
479
- const minimumStaticDuration = Math.max(2, Math.min(4, sourceDuration / 60));
480
- const result = spawnSync(ffmpegCommand, [
481
- '-hide_banner',
482
- '-nostats',
483
- '-i', inputPath,
484
- '-vf', `freezedetect=n=-50dB:d=${minimumStaticDuration}`,
485
- '-an',
486
- '-f', 'null',
487
- process.platform === 'win32' ? 'NUL' : '/dev/null',
488
- ], {
489
- encoding: 'utf8',
490
- windowsHide: true,
491
- maxBuffer: 10 * 1024 * 1024,
492
- });
493
- if (result.error || result.status !== 0) {
494
- console.warn('Static-screen analysis was unavailable; retaining the complete recording.');
495
- return [];
496
- }
497
-
498
- const events = String(result.stderr || '').matchAll(
499
- /lavfi\.freezedetect\.freeze_(start|end):\s*([\d.]+)/g,
500
- );
501
- const staticRanges = [];
502
- let staticStart = null;
503
- for (const event of events) {
504
- const time = Number(event[2]);
505
- if (event[1] === 'start') {
506
- staticStart = time;
507
- } else if (staticStart !== null && time > staticStart) {
508
- staticRanges.push({ start: staticStart, end: time });
509
- staticStart = null;
510
- }
511
- }
512
- if (staticStart !== null && sourceDuration > staticStart) {
513
- staticRanges.push({ start: staticStart, end: sourceDuration });
514
- }
515
-
516
- return staticRanges
517
- .map((range) => ({
518
- start: range.start + minimumStaticDuration,
519
- end: range.end,
520
- }))
521
- .filter((range) => range.end - range.start >= 0.75);
522
- }
523
-
524
- function detectVisualChangeTimes(ffmpegCommand, inputPath) {
525
- const result = spawnSync(ffmpegCommand, [
526
- '-hide_banner',
527
- '-nostats',
528
- '-i', inputPath,
529
- '-vf', "select='gt(scene,0.03)',showinfo",
530
- '-an',
531
- '-f', 'null',
532
- process.platform === 'win32' ? 'NUL' : '/dev/null',
533
- ], {
534
- encoding: 'utf8',
535
- windowsHide: true,
536
- maxBuffer: 10 * 1024 * 1024,
537
- });
538
- if (result.error || result.status !== 0) {
539
- console.warn('Visual-change analysis was unavailable; using duration-based scene coverage.');
540
- return [];
541
- }
542
- return [...String(result.stderr || '').matchAll(/pts_time:([\d.]+)/g)]
543
- .map((match) => Number(match[1]))
544
- .filter(Number.isFinite);
545
- }
546
-
547
- function parseArguments() {
548
- const args = process.argv.slice(2);
549
- const options = {
550
- aiModel: '',
551
- aiProvider: 'auto',
552
- copyOnly: false,
553
- input: '',
554
- layout: 'split',
555
- layoutExplicit: false,
556
- listVoices: false,
557
- output: '',
558
- previewVoices: false,
559
- refreshAiCopy: false,
560
- spatialXrOnly: false,
561
- thumbnailsOnly: false,
562
- voice: 'sonia',
563
- };
564
-
565
- for (let index = 0; index < args.length; index += 1) {
566
- const argument = args[index];
567
- if (argument === '--input') {
568
- options.input = path.resolve(args[index + 1] || '');
569
- index += 1;
570
- } else if (argument === '--output') {
571
- options.output = path.resolve(args[index + 1] || '');
572
- index += 1;
573
- } else if (argument === '--voice') {
574
- options.voice = String(args[index + 1] || '').trim();
575
- index += 1;
576
- } else if (argument === '--layout') {
577
- options.layout = String(args[index + 1] || '').trim().toLowerCase();
578
- options.layoutExplicit = true;
579
- index += 1;
580
- } else if (argument === '--ai-provider') {
581
- options.aiProvider = String(args[index + 1] || '').trim().toLowerCase();
582
- index += 1;
583
- } else if (argument === '--ai-model') {
584
- options.aiModel = String(args[index + 1] || '').trim();
585
- index += 1;
586
- } else if (argument === '--refresh-ai-copy') {
587
- options.refreshAiCopy = true;
588
- } else if (argument === '--copy-only') {
589
- options.copyOnly = true;
590
- } else if (argument === '--thumbnails-only') {
591
- options.thumbnailsOnly = true;
592
- } else if (argument === '--spatial-xr-only') {
593
- options.spatialXrOnly = true;
594
- } else if (argument === '--list-voices') {
595
- options.listVoices = true;
596
- } else if (argument === '--preview-voices') {
597
- options.previewVoices = true;
598
- } else if (argument === '--help' || argument === '-h') {
599
- console.log(`Generate an adaptive promotional video for ${appName}.`);
600
- console.log('');
601
- console.log('Usage:');
602
- console.log(' node generate-ai-promo-video.js');
603
- console.log(' node generate-ai-promo-video.js --input source.mp4 --output promo.mp4');
604
- console.log(' node generate-ai-promo-video.js --layout dynamic|split|centered|floating|cinematic|mixed|random');
605
- console.log(' node generate-ai-promo-video.js --voice aria');
606
- console.log(' node generate-ai-promo-video.js --refresh-ai-copy');
607
- console.log(' node generate-ai-promo-video.js --copy-only');
608
- console.log(' node generate-ai-promo-video.js --thumbnails-only');
609
- console.log(' node generate-ai-promo-video.js --spatial-xr-only');
610
- console.log(' node generate-ai-promo-video.js --ai-provider auto|codex|openai');
611
- console.log(' node generate-ai-promo-video.js --ai-model MODEL_NAME');
612
- console.log(' node generate-ai-promo-video.js --list-voices');
613
- console.log(' node generate-ai-promo-video.js --preview-voices');
614
- console.log('');
615
- console.log(
616
- `Without --input, every MP4 under ${sourceVideoDirectory} except Samsung is processed.`,
617
- );
618
- console.log('Redundant static pauses are shortened automatically; distinct screens are retained.');
619
- process.exit(0);
620
- } else {
621
- throw new Error(`Unknown argument: ${argument}`);
622
- }
623
- }
624
-
625
- if (!['auto', 'codex', 'openai'].includes(options.aiProvider)) {
626
- throw new Error('--ai-provider must be auto, codex, or openai.');
627
- }
628
- if (!promoLayoutNames.includes(options.layout)) {
629
- throw new Error(`--layout must be one of: ${promoLayoutNames.join(', ')}.`);
630
- }
631
- if (options.output && !options.input) {
632
- throw new Error('--output can only be used together with --input.');
633
- }
634
-
635
- return options;
636
- }
637
-
638
- function resolvePromoLayout(requestedLayout) {
639
- if (requestedLayout !== 'random') return requestedLayout;
640
- return selectablePromoLayoutNames[Math.floor(Math.random() * selectablePromoLayoutNames.length)];
641
- }
642
-
643
- function getPythonExecutable() {
644
- for (const candidate of ['py', 'python']) {
645
- const result = spawnSync(candidate, ['-m', 'edge_tts', '--version'], {
646
- encoding: 'utf8',
647
- windowsHide: true,
648
- });
649
- if (!result.error && result.status === 0) return candidate;
650
- }
651
- return '';
652
- }
653
-
654
- function printVoiceCatalog() {
655
- console.log('Natural female neural voices:');
656
- console.log('');
657
- for (const [alias, voice] of Object.entries(neuralVoiceCatalog)) {
658
- console.log(` ${alias.padEnd(10)} ${voice.label} (${voice.name})`);
659
- }
660
- console.log('');
661
- console.log('Use an alias above or any full Edge TTS neural voice name with --voice.');
662
- }
663
-
664
- function resolveVoice(input) {
665
- const alias = String(input || '').trim().toLowerCase();
666
- return neuralVoiceCatalog[alias] || {
667
- name: String(input || '').trim(),
668
- label: String(input || '').trim(),
669
- };
670
- }
671
-
672
- function escapeFilterPath(filePath) {
673
- return filePath
674
- .replace(/\\/g, '/')
675
- .replace(':', '\\:')
676
- .replace(/'/g, "\\'");
677
- }
678
-
679
- function escapeFilterText(value) {
680
- return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\\:');
681
- }
682
-
683
- function readTextIfPresent(filePath, maximumLength = 12000) {
684
- if (!fs.existsSync(filePath)) return '';
685
- return fs.readFileSync(filePath, 'utf8').slice(0, maximumLength);
686
- }
687
-
688
- function readJsonIfPresent(filePath) {
689
- try {
690
- return JSON.parse(readTextIfPresent(filePath, 50000));
691
- } catch {
692
- return {};
693
- }
694
- }
695
-
696
- function extractHtmlMetadata(html) {
697
- const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '';
698
- const description = html.match(
699
- /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
700
- )?.[1] || html.match(
701
- /<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i,
702
- )?.[1] || '';
703
- return { title: title.trim(), description: description.trim() };
704
- }
705
-
706
- function stripMarkup(value) {
707
- return String(value)
708
- .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
709
- .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
710
- .replace(/<[^>]+>/g, ' ')
711
- .replace(/\{\{[^}]+\}\}/g, ' ')
712
- .replace(/&[a-zA-Z#0-9]+;/g, ' ')
713
- .replace(/\s+/g, ' ')
714
- .trim();
715
- }
716
-
717
- function collectAppContext() {
718
- const manifestCandidates = [
719
- path.join(projectDirectory, 'src', 'manifest.webmanifest'),
720
- path.join(projectDirectory, 'public', 'manifest.webmanifest'),
721
- path.join(projectDirectory, 'manifest.webmanifest'),
722
- ];
723
- const manifest = readJsonIfPresent(manifestCandidates.find((candidate) => fs.existsSync(candidate)) || '');
724
- const htmlMetadata = extractHtmlMetadata(
725
- readTextIfPresent(path.join(projectDirectory, 'src', 'index.html'), 30000),
726
- );
727
- const pageDirectory = path.join(projectDirectory, 'src', 'pages');
728
- const pageFiles = fs.existsSync(pageDirectory)
729
- ? fs.readdirSync(pageDirectory)
730
- .filter((name) => /\.(?:f7|html)$/i.test(name))
731
- .sort()
732
- .slice(0, 30)
733
- : [];
734
- let remainingPageCharacters = 12000;
735
- const visiblePageText = [];
736
-
737
- for (const fileName of pageFiles) {
738
- if (remainingPageCharacters <= 0) break;
739
- const text = stripMarkup(
740
- readTextIfPresent(path.join(pageDirectory, fileName), Math.min(5000, remainingPageCharacters)),
741
- );
742
- if (text) {
743
- const excerpt = text.slice(0, Math.min(2500, remainingPageCharacters));
744
- visiblePageText.push({ file: fileName, text: excerpt });
745
- remainingPageCharacters -= excerpt.length;
746
- }
747
- }
748
-
749
- return {
750
- identity: {
751
- appName,
752
- appUniqueId,
753
- appId: String(capacitorConfig.appId || '').trim(),
754
- orientation: String(capacitorConfig.orientation || '').trim(),
755
- },
756
- storeMetadata: {
757
- name: manifest.name || '',
758
- shortName: manifest.short_name || '',
759
- description: manifest.description || htmlMetadata.description,
760
- categories: Array.isArray(manifest.categories) ? manifest.categories : [],
761
- pageTitle: htmlMetadata.title,
762
- },
763
- productNotes: readTextIfPresent(path.join(projectDirectory, 'GROWTH_ASO_PLAN.md'), 12000),
764
- routeConfiguration: readTextIfPresent(path.join(projectDirectory, 'src', 'js', 'routes.js'), 6000),
765
- visiblePageText,
766
- };
767
- }
768
-
769
- function createCopyFingerprint(appContext, inputPaths) {
770
- const sources = inputPaths.map((inputPath) => {
771
- const sourceStats = fs.statSync(inputPath);
772
- return {
773
- relativePath: path.relative(projectDirectory, inputPath),
774
- size: sourceStats.size,
775
- modified: sourceStats.mtimeMs,
776
- };
777
- });
778
- return createHash('sha256').update(JSON.stringify({
779
- schemaVersion: promoCopySchemaVersion,
780
- appContext,
781
- sources,
782
- sceneWindows,
783
- })).digest('hex');
784
- }
785
-
786
- function getCodexCandidates() {
787
- const candidates = ['codex'];
788
- const configuredCodexPath = String(process.env.CODEX_BIN || '').trim();
789
- if (configuredCodexPath) candidates.unshift(configuredCodexPath);
790
-
791
- if (process.platform === 'win32') {
792
- const userProfile = process.env.USERPROFILE || process.env.HOME;
793
- const extensionRoots = [
794
- path.join(userProfile || '', '.vscode', 'extensions'),
795
- path.join(userProfile || '', '.vscode-insiders', 'extensions'),
796
- path.join(userProfile || '', '.vscode-server', 'extensions'),
797
- path.join(userProfile || '', '.vscode-server-insiders', 'extensions'),
798
- ];
799
- const executableNames = ['codex.exe', 'codex'];
800
-
801
- for (const extensionRoot of extensionRoots) {
802
- if (!fs.existsSync(extensionRoot)) continue;
803
- for (const extensionName of fs.readdirSync(extensionRoot)) {
804
- if (!/^openai\.chatgpt-/i.test(extensionName)) continue;
805
- const extensionPath = path.join(extensionRoot, extensionName, 'bin');
806
- if (!fs.existsSync(extensionPath)) continue;
807
- for (const platformDirectory of fs.readdirSync(extensionPath)) {
808
- for (const executableName of executableNames) {
809
- candidates.push(path.join(extensionPath, platformDirectory, executableName));
810
- }
811
- }
812
- }
813
- }
814
- }
815
-
816
- return [...new Set(candidates)];
817
- }
818
-
819
- function getCodexExecutable() {
820
- for (const candidate of getCodexCandidates()) {
821
- const versionResult = spawnSync(candidate, ['--version'], {
822
- encoding: 'utf8',
823
- windowsHide: true,
824
- });
825
- if (versionResult.error || versionResult.status !== 0) continue;
826
-
827
- const loginResult = spawnSync(candidate, ['login', 'status'], {
828
- encoding: 'utf8',
829
- windowsHide: true,
830
- });
831
- if (!loginResult.error && loginResult.status === 0) return candidate;
832
- }
833
- return '';
834
- }
835
-
836
- function detectActiveVideoCrop(ffmpegCommand, inputPath, videoDetails) {
837
- const videoStream = videoDetails.streams?.find((stream) => stream.codec_type === 'video') || {};
838
- const sourceWidth = Number(videoStream.width || 0);
839
- const sourceHeight = Number(videoStream.height || 0);
840
- if (sourceWidth <= sourceHeight) return null;
841
-
842
- const result = spawnSync(ffmpegCommand, [
843
- '-hide_banner',
844
- '-ss', '8',
845
- '-t', '4',
846
- '-i', inputPath,
847
- '-vf', 'cropdetect=limit=18:round=2:reset=0',
848
- '-f', 'null',
849
- process.platform === 'win32' ? 'NUL' : '/dev/null',
850
- ], {
851
- encoding: 'utf8',
852
- windowsHide: true,
853
- maxBuffer: 10 * 1024 * 1024,
854
- });
855
- if (result.error || result.status !== 0) return null;
856
- const matches = [...String(result.stderr || '').matchAll(/crop=(\d+):(\d+):(\d+):(\d+)/g)];
857
- const detected = matches.at(-1)?.slice(1).map(Number);
858
- if (!detected) return null;
859
- const [width, height, x, y] = detected;
860
- const isCentralPortrait = width < sourceWidth * 0.8
861
- && height >= sourceHeight * 0.9
862
- && height > width
863
- && Math.abs((x + (width / 2)) - (sourceWidth / 2)) <= sourceWidth * 0.03;
864
- return isCentralPortrait ? { width, height, x, y } : null;
865
- }
866
-
867
- function cropFilter(crop) {
868
- return crop ? `crop=${crop.width}:${crop.height}:${crop.x}:${crop.y},` : '';
869
- }
870
-
871
- function extractAnalysisFrames(ffmpegCommand, inputPath, crop) {
872
- fs.mkdirSync(analysisFrameDirectory, { recursive: true });
873
- return sceneWindows.map((scene, index) => {
874
- const framePath = path.join(
875
- analysisFrameDirectory,
876
- `scene-${String(index + 1).padStart(2, '0')}.jpg`,
877
- );
878
- run(ffmpegCommand, [
879
- '-hide_banner',
880
- '-loglevel', 'error',
881
- '-y',
882
- '-ss', String(scene.sourceTime),
883
- '-i', inputPath,
884
- '-frames:v', '1',
885
- '-vf', `${cropFilter(crop)}setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,scale=768:-2,format=yuvj420p`,
886
- '-q:v', '3',
887
- framePath,
888
- ]);
889
- return framePath;
890
- });
891
- }
892
-
893
- function buildPromoCopyPrompt(appContext, previousError = '') {
894
- const timingRequirements = sceneWindows.map((scene, index) => ({
895
- scene: index + 1,
896
- outputStartSeconds: scene.start,
897
- outputEndSeconds: scene.end,
898
- sourceFrameSeconds: scene.sourceTime,
899
- maximumNarrationWords: scene.maxNarrationWords,
900
- }));
901
- return `Create factual, high-conversion ASO/SEO copy for a ${expectedDuration}-second mobile-app promo video.
902
-
903
- 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.
904
-
905
- Rules:
906
- - Generate exactly ${sceneWindows.length} scenes in the same order as the images and timings.
907
- - Use only features supported by the images or project context. Never invent claims.
908
- - Cover every distinct demonstrated feature across the retained recording; do not omit later features.
909
- - Do not promote incidental navigation, loading states, advertisements, permission prompts, or repeated screens as features.
910
- - Keep each scene specific to what is visibly demonstrated in its supplied image whenever possible.
911
- - The authoritative app name is ${JSON.stringify(appName)}. Mention it naturally in the final narration and final body.
912
- - APP_UNIQUE_ID is internal and must never appear in audience-facing copy.
913
- - Each kicker: 2-30 characters. Each title line: 1-22 characters. Body: 8-72 characters.
914
- - Narration must fit its scene's maximum word count. Use natural spoken English for a warm UK female voice.
915
- - Keep captions punchy, specific and readable. Prefer user benefits and relevant search intent.
916
- - footer_keywords must contain exactly 3 distinct, natural app-search phrases, each 2-24 characters.
917
- - Generate a YouTube upload title and description from the same verified features.
918
- - YouTube title: 40-100 characters, include ${JSON.stringify(appName)} and one strong natural search phrase, without clickbait.
919
- - 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.
920
- - 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.
921
- - Keep YouTube text natural and useful; do not repeat keywords unnaturally.
922
- - Avoid prices, ratings, awards, competitor names, unsupported superlatives, and words such as best, guaranteed, or free.
923
- - No emoji, quotation marks, line breaks, markdown, or ending punctuation in kickers/titles/keywords.
924
-
925
- Scene timing requirements:
926
- ${JSON.stringify(timingRequirements, null, 2)}
927
-
928
- Sanitized app context (no credentials):
929
- ${JSON.stringify(appContext, null, 2)}
930
- ${previousError ? `\nYour previous output was invalid: ${previousError}\nCorrect that issue in this response.` : ''}`;
931
- }
932
-
933
- function cleanGeneratedString(value) {
934
- return String(value || '').replace(/\s+/g, ' ').trim();
935
- }
936
-
937
- function cleanGeneratedDescription(value) {
938
- return String(value || '')
939
- .replace(/\r\n?/g, '\n')
940
- .split('\n')
941
- .map((line) => line.trim())
942
- .join('\n')
943
- .replace(/\n{3,}/g, '\n\n')
944
- .trim();
945
- }
946
-
947
- function addPlayStoreDownloadLink(description) {
948
- const cleanedDescription = cleanGeneratedDescription(description);
949
- if (cleanedDescription.includes(playStoreDownloadUrl)) return cleanedDescription;
950
-
951
- const lines = cleanedDescription.split('\n');
952
- let finalContentIndex = lines.length - 1;
953
- while (finalContentIndex >= 0 && !lines[finalContentIndex].trim()) finalContentIndex -= 1;
954
- const finalLine = lines[finalContentIndex]?.trim() || '';
955
- const hashtags = finalLine.match(/#[\p{L}\p{N}_]+/gu) || [];
956
- const hasFinalHashtagLine = hashtags.length === 3
957
- && !finalLine.replace(/#[\p{L}\p{N}_]+/gu, '').trim();
958
- const iosBlock = appStoreDownloadUrl
959
- ? `\n\nDownload ${appName} on the App Store:\n${appStoreDownloadUrl}`
960
- : '';
961
- const downloadBlock = `Download ${appName} on Google Play:\n${playStoreDownloadUrl}${iosBlock}`;
962
-
963
- if (!hasFinalHashtagLine) {
964
- return `${cleanedDescription}\n\n${downloadBlock}`;
965
- }
966
-
967
- const descriptionBody = lines.slice(0, finalContentIndex).join('\n').trimEnd();
968
- return `${descriptionBody}\n\n${downloadBlock}\n\n${finalLine}`;
969
- }
970
-
971
- function validatePromoCopy(value) {
972
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
973
- throw new Error('AI copy is not a JSON object.');
974
- }
975
- if (!Array.isArray(value.scenes) || value.scenes.length !== sceneWindows.length) {
976
- throw new Error(`AI copy must contain exactly ${sceneWindows.length} scenes.`);
977
- }
978
- if (!Array.isArray(value.footer_keywords) || value.footer_keywords.length !== 3) {
979
- throw new Error('AI copy must contain exactly 3 footer keywords.');
980
- }
981
- if (!value.youtube || typeof value.youtube !== 'object' || Array.isArray(value.youtube)) {
982
- throw new Error('AI copy must contain YouTube upload metadata.');
983
- }
984
-
985
- const footerKeywords = value.footer_keywords.map((keyword, index) => {
986
- const cleaned = cleanGeneratedString(keyword);
987
- if (cleaned.length < 2 || cleaned.length > 24) {
988
- throw new Error(`Footer keyword ${index + 1} must be 2-24 characters.`);
989
- }
990
- return cleaned;
991
- });
992
- if (new Set(footerKeywords.map((keyword) => keyword.toLowerCase())).size !== 3) {
993
- throw new Error('Footer keywords must be distinct.');
994
- }
995
-
996
- const constraints = {
997
- kicker: [2, 30],
998
- title_line_1: [1, 22],
999
- title_line_2: [1, 22],
1000
- body: [8, 72],
1001
- narration: [8, 120],
1002
- };
1003
- const scenes = value.scenes.map((scene, sceneIndex) => {
1004
- if (!scene || typeof scene !== 'object' || Array.isArray(scene)) {
1005
- throw new Error(`Scene ${sceneIndex + 1} is invalid.`);
1006
- }
1007
- const cleanedScene = {};
1008
- for (const [field, [minimum, maximum]] of Object.entries(constraints)) {
1009
- const cleaned = cleanGeneratedString(scene[field]);
1010
- if (cleaned.length < minimum || cleaned.length > maximum) {
1011
- throw new Error(
1012
- `Scene ${sceneIndex + 1} ${field} must be ${minimum}-${maximum} characters.`,
1013
- );
1014
- }
1015
- cleanedScene[field] = cleaned;
1016
- }
1017
- const maximumNarrationWords = sceneWindows[sceneIndex].maxNarrationWords;
1018
- const narrationWords = cleanedScene.narration.split(/\s+/);
1019
- if (narrationWords.length > maximumNarrationWords) {
1020
- cleanedScene.narration = narrationWords.slice(0, maximumNarrationWords).join(' ');
1021
- }
1022
- return cleanedScene;
1023
- });
1024
- const finalCopy = `${scenes.at(-1).body} ${scenes.at(-1).narration}`.toLowerCase();
1025
- if (!finalCopy.includes(appName.toLowerCase())) {
1026
- throw new Error(`The final scene must mention the app name ${JSON.stringify(appName)}.`);
1027
- }
1028
-
1029
- const youtubeTitle = cleanGeneratedString(value.youtube.title);
1030
- const youtubeDescription = cleanGeneratedDescription(value.youtube.description);
1031
- if (youtubeTitle.length < 40 || youtubeTitle.length > 100) {
1032
- throw new Error('YouTube title must be 40-100 characters.');
1033
- }
1034
- if (youtubeDescription.length < 350 || youtubeDescription.length > 1500) {
1035
- throw new Error('YouTube description must be 350-1500 characters.');
1036
- }
1037
- if (Buffer.byteLength(youtubeDescription, 'utf8') > 5000) {
1038
- throw new Error('YouTube description exceeds the 5,000-byte API limit.');
1039
- }
1040
- if (/[<>]/.test(`${youtubeTitle}${youtubeDescription}`)) {
1041
- throw new Error('YouTube metadata cannot contain angle brackets.');
1042
- }
1043
- if (!youtubeTitle.toLowerCase().includes(appName.toLowerCase())
1044
- || !youtubeDescription.toLowerCase().includes(appName.toLowerCase())) {
1045
- throw new Error(`YouTube title and description must mention ${JSON.stringify(appName)}.`);
1046
- }
1047
- const descriptionLines = youtubeDescription.split('\n').filter(Boolean);
1048
- const finalDescriptionLine = descriptionLines.at(-1) || '';
1049
- const hashtags = finalDescriptionLine.match(/#[\p{L}\p{N}_]+/gu) || [];
1050
- if (hashtags.length !== 3 || finalDescriptionLine.replace(/#[\p{L}\p{N}_]+/gu, '').trim()) {
1051
- throw new Error('YouTube description must finish with a line containing exactly 3 hashtags.');
1052
- }
1053
-
1054
- return {
1055
- footer_keywords: footerKeywords,
1056
- scenes,
1057
- youtube: {
1058
- title: youtubeTitle,
1059
- description: youtubeDescription,
1060
- },
1061
- };
1062
- }
1063
-
1064
- function parseAiJson(rawValue) {
1065
- const text = String(rawValue || '').trim()
1066
- .replace(/^```(?:json)?\s*/i, '')
1067
- .replace(/\s*```$/, '');
1068
- return JSON.parse(text);
1069
- }
1070
-
1071
- function generateCopyWithCodex(prompt, framePaths, modelName) {
1072
- const codexCommand = getCodexExecutable();
1073
- if (!codexCommand) {
1074
- throw new Error('Codex CLI is unavailable or not signed in.');
1075
- }
1076
- fs.mkdirSync(temporaryDirectory, { recursive: true });
1077
- fs.writeFileSync(promoCopySchemaPath, `${JSON.stringify(promoCopySchema, null, 2)}\n`, 'utf8');
1078
- const args = [
1079
- 'exec',
1080
- '--ephemeral',
1081
- '--ignore-rules',
1082
- '--sandbox', 'read-only',
1083
- '--skip-git-repo-check',
1084
- '--color', 'never',
1085
- '--output-schema', promoCopySchemaPath,
1086
- '--output-last-message', promoCopyResultPath,
1087
- '-C', projectDirectory,
1088
- ];
1089
- if (modelName) args.push('--model', modelName);
1090
- for (const framePath of framePaths) args.push('--image', framePath);
1091
- args.push('-');
1092
- run(codexCommand, args, { input: prompt });
1093
- return {
1094
- copy: parseAiJson(fs.readFileSync(promoCopyResultPath, 'utf8')),
1095
- model: modelName || 'Codex configured model',
1096
- provider: 'codex',
1097
- };
1098
- }
1099
-
1100
- async function generateCopyWithOpenAi(prompt, framePaths, modelName) {
1101
- const apiKey = String(process.env.OPENAI_API_KEY || '').trim();
1102
- if (!apiKey) throw new Error('OPENAI_API_KEY is not configured.');
1103
- const model = modelName || process.env.OPENAI_PROMO_MODEL || 'gpt-4o-mini';
1104
- const content = [{ type: 'input_text', text: prompt }];
1105
- for (const framePath of framePaths) {
1106
- content.push({
1107
- type: 'input_image',
1108
- image_url: `data:image/jpeg;base64,${fs.readFileSync(framePath).toString('base64')}`,
1109
- detail: 'low',
1110
- });
1111
- }
1112
- const response = await fetch('https://api.openai.com/v1/responses', {
1113
- method: 'POST',
1114
- headers: {
1115
- Authorization: `Bearer ${apiKey}`,
1116
- 'Content-Type': 'application/json',
1117
- },
1118
- body: JSON.stringify({
1119
- model,
1120
- store: false,
1121
- max_output_tokens: 2500,
1122
- input: [{ role: 'user', content }],
1123
- text: {
1124
- format: {
1125
- type: 'json_schema',
1126
- name: 'promo_video_copy',
1127
- strict: true,
1128
- schema: promoCopySchema,
1129
- },
1130
- },
1131
- }),
1132
- });
1133
- const result = await response.json();
1134
- if (!response.ok) {
1135
- throw new Error(`OpenAI Responses API failed: ${result.error?.message || response.statusText}`);
1136
- }
1137
- const outputText = result.output_text || result.output
1138
- ?.flatMap((item) => item.content || [])
1139
- .find((item) => item.type === 'output_text')?.text;
1140
- if (!outputText) throw new Error('OpenAI returned no promo copy.');
1141
- return { copy: parseAiJson(outputText), model, provider: 'openai' };
1142
- }
1143
-
1144
- async function generateOrLoadPromoCopy(
1145
- options,
1146
- ffmpegCommand,
1147
- analysisInputPath,
1148
- allInputPaths,
1149
- analysisCrop,
1150
- ) {
1151
- const appContext = collectAppContext();
1152
- const fingerprint = createCopyFingerprint(appContext, allInputPaths);
1153
- if (!options.refreshAiCopy && fs.existsSync(promoCopyCachePath)) {
1154
- try {
1155
- const cached = JSON.parse(fs.readFileSync(promoCopyCachePath, 'utf8'));
1156
- if (cached.schemaVersion === promoCopySchemaVersion && cached.fingerprint === fingerprint) {
1157
- return {
1158
- copy: validatePromoCopy(cached.copy),
1159
- fromCache: true,
1160
- model: cached.model,
1161
- provider: cached.provider,
1162
- };
1163
- }
1164
- } catch (error) {
1165
- console.warn(`Ignoring invalid AI copy cache: ${error.message}`);
1166
- }
1167
- }
1168
-
1169
- const provider = options.aiProvider === 'auto'
1170
- ? (process.env.OPENAI_API_KEY ? 'openai' : 'codex')
1171
- : options.aiProvider;
1172
- console.log(`Analyzing ${sceneWindows.length} video scenes for app-specific promo copy...`);
1173
- const framePaths = extractAnalysisFrames(ffmpegCommand, analysisInputPath, analysisCrop);
1174
- let previousError = '';
1175
- let generated;
1176
- for (let attempt = 1; attempt <= 3; attempt += 1) {
1177
- const prompt = buildPromoCopyPrompt(appContext, previousError);
1178
- try {
1179
- generated = provider === 'openai'
1180
- ? await generateCopyWithOpenAi(prompt, framePaths, options.aiModel)
1181
- : generateCopyWithCodex(prompt, framePaths, options.aiModel);
1182
- generated.copy = validatePromoCopy(generated.copy);
1183
- break;
1184
- } catch (error) {
1185
- previousError = error.message;
1186
- if (attempt === 3 || /unavailable|not configured|not signed in/i.test(previousError)) throw error;
1187
- console.warn(`AI copy attempt ${attempt} was invalid; requesting a corrected result.`);
1188
- }
1189
- }
1190
-
1191
- const cache = {
1192
- schemaVersion: promoCopySchemaVersion,
1193
- fingerprint,
1194
- appName,
1195
- appUniqueId,
1196
- provider: generated.provider,
1197
- model: generated.model,
1198
- generatedAt: new Date().toISOString(),
1199
- copy: generated.copy,
1200
- };
1201
- fs.mkdirSync(path.dirname(promoCopyCachePath), { recursive: true });
1202
- fs.writeFileSync(promoCopyCachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf8');
1203
- return { ...generated, fromCache: false };
1204
- }
1205
-
1206
- function createSpatialMetadataSource(promoCopy) {
1207
- const suffix = ' | Spatial 3D XR Preview';
1208
- const maximumBaseLength = 100 - suffix.length;
1209
- let titleBase = promoCopy.youtube.title.slice(0, maximumBaseLength).trim();
1210
- if (titleBase.length < promoCopy.youtube.title.length && titleBase.includes(' ')) {
1211
- titleBase = titleBase.slice(0, titleBase.lastIndexOf(' ')).trim();
1212
- }
1213
- const spatialIntroduction = [
1214
- `Experience ${appName} as a stereoscopic 3D floating-screen preview for Android XR.`,
1215
- 'The mobile app interface remains a 2D experience, presented with depth for headset viewing.',
1216
- ].join(' ');
1217
- return {
1218
- description: `${spatialIntroduction}\n\n${promoCopy.youtube.description}`,
1219
- inputPath: spatialXrVideoPath,
1220
- outputPath: spatialXrVideoPath,
1221
- platform: 'Google-Play-XR-Spatial',
1222
- recommendedVisibility: 'Unlisted',
1223
- spatialStereo: true,
1224
- title: `${titleBase}${suffix}`,
1225
- };
1226
- }
1227
-
1228
- function writeYoutubeUploadMetadata(promoCopy, sourceVideos) {
1229
- return sourceVideos.map((source) => {
1230
- const metadataDirectory = path.dirname(source.outputPath);
1231
- const relativeInputPath = path.relative(sourceVideoDirectory, source.inputPath);
1232
- const platform = source.platform || (relativeInputPath.startsWith('..')
1233
- ? path.basename(metadataDirectory)
1234
- : relativeInputPath.split(path.sep)[0]);
1235
- const metadataJsonPath = path.join(
1236
- metadataDirectory,
1237
- `${appFilePrefix}-youtube-upload-metadata.json`,
1238
- );
1239
- const metadataTextPath = path.join(
1240
- metadataDirectory,
1241
- `${appFilePrefix}-youtube-upload-metadata.txt`,
1242
- );
1243
- const metadata = {
1244
- appName,
1245
- appUniqueId,
1246
- packageId,
1247
- playStoreDownloadUrl,
1248
- appStoreDownloadUrl,
1249
- platform,
1250
- videoFile: path.basename(source.outputPath),
1251
- thumbnailFile: path.basename(getThumbnailPath(source.outputPath)),
1252
- recommendedVisibility: source.recommendedVisibility || '',
1253
- title: source.title || promoCopy.youtube.title,
1254
- description: addPlayStoreDownloadLink(
1255
- source.description || promoCopy.youtube.description,
1256
- ),
1257
- };
1258
- const visibilityText = metadata.recommendedVisibility
1259
- ? `\n\nRECOMMENDED VISIBILITY\n${metadata.recommendedVisibility}`
1260
- : '';
1261
- const textContent = `TITLE\n${metadata.title}\n\nDESCRIPTION\n${metadata.description}${visibilityText}\n`;
1262
- fs.mkdirSync(metadataDirectory, { recursive: true });
1263
- fs.writeFileSync(metadataJsonPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
1264
- fs.writeFileSync(metadataTextPath, textContent, 'utf8');
1265
- return { metadataJsonPath, metadataTextPath, platform };
1266
- });
1267
- }
1268
-
1269
- function getThumbnailPath(videoPath) {
1270
- return path.join(
1271
- path.dirname(videoPath),
1272
- `${appFilePrefix}-youtube-thumbnail-${thumbnailWidth}x${thumbnailHeight}.jpg`,
1273
- );
1274
- }
1275
-
1276
- function createVideoThumbnail(ffmpegCommand, ffprobeCommand, videoPath, spatialStereo = false) {
1277
- const thumbnailPath = getThumbnailPath(videoPath);
1278
- fs.mkdirSync(path.dirname(thumbnailPath), { recursive: true });
1279
- let thumbnailSize = Infinity;
1280
-
1281
- for (const quality of [2, 4, 6, 8, 10]) {
1282
- const stereoCrop = spatialStereo ? 'crop=iw/2:ih:0:0,' : '';
1283
- run(ffmpegCommand, [
1284
- '-hide_banner',
1285
- '-loglevel', 'error',
1286
- '-y',
1287
- '-ss', String(thumbnailTime),
1288
- '-i', videoPath,
1289
- '-frames:v', '1',
1290
- '-vf', `${stereoCrop}scale=${thumbnailWidth}:${thumbnailHeight}:flags=lanczos,setsar=1`,
1291
- '-q:v', String(quality),
1292
- thumbnailPath,
1293
- ]);
1294
- thumbnailSize = fs.statSync(thumbnailPath).size;
1295
- if (thumbnailSize <= maximumThumbnailBytes) break;
1296
- }
1297
-
1298
- const details = getVideoDetails(ffprobeCommand, thumbnailPath);
1299
- const imageStream = details.streams?.find((stream) => stream.codec_type === 'video') || {};
1300
- if (Number(imageStream.width) !== thumbnailWidth
1301
- || Number(imageStream.height) !== thumbnailHeight) {
1302
- throw new Error(`Thumbnail resolution is not ${thumbnailWidth}x${thumbnailHeight}.`);
1303
- }
1304
- if (thumbnailSize > maximumThumbnailBytes) {
1305
- throw new Error(
1306
- `Thumbnail is ${(thumbnailSize / 1024 / 1024).toFixed(2)}MB; maximum is 2MB.`,
1307
- );
1308
- }
1309
- console.log(
1310
- `Created thumbnail: ${thumbnailPath} (${Math.round(thumbnailSize / 1024)}KB)`,
1311
- );
1312
- return thumbnailPath;
1313
- }
1314
-
1315
- function formatAssTime(seconds) {
1316
- const centiseconds = Math.round(seconds * 100);
1317
- const hours = Math.floor(centiseconds / 360000);
1318
- const minutes = Math.floor((centiseconds % 360000) / 6000);
1319
- const wholeSeconds = Math.floor((centiseconds % 6000) / 100);
1320
- const remainder = centiseconds % 100;
1321
- return `${hours}:${String(minutes).padStart(2, '0')}:${String(wholeSeconds).padStart(2, '0')}.${String(remainder).padStart(2, '0')}`;
1322
- }
1323
-
1324
- function getSceneComposition(layoutName, sceneIndex) {
1325
- const left = {
1326
- bodyY: 470,
1327
- phoneX: '90',
1328
- phoneY: '50',
1329
- styleSuffix: 'Left',
1330
- textX: 720,
1331
- titleY: 270,
1332
- kickerY: 225,
1333
- };
1334
- const right = {
1335
- ...left,
1336
- phoneX: 'W-w-90',
1337
- textX: 110,
1338
- };
1339
- const centered = {
1340
- bodyY: 925,
1341
- phoneX: '(W-w)/2',
1342
- phoneY: '220',
1343
- styleSuffix: 'Center',
1344
- textX: 960,
1345
- titleY: 102,
1346
- kickerY: 66,
1347
- };
1348
- const mixedCentered = {
1349
- ...left,
1350
- phoneX: '(W-w)/2',
1351
- phoneY: '110',
1352
- textX: 80,
1353
- };
1354
- const floating = {
1355
- ...left,
1356
- phoneX: '125+24*sin(t*0.8)',
1357
- phoneY: '110+14*cos(t*0.65)',
1358
- textX: 1050,
1359
- };
1360
- const cinematic = {
1361
- bodyY: 925,
1362
- phoneX: '(W-w)/2',
1363
- phoneY: '0',
1364
- styleSuffix: 'Center',
1365
- textX: 960,
1366
- titleY: 745,
1367
- kickerY: 705,
1368
- };
1369
-
1370
- if (layoutName === 'dynamic') return sceneIndex % 2 === 0 ? left : right;
1371
- if (layoutName === 'centered') return centered;
1372
- if (layoutName === 'floating') return floating;
1373
- if (layoutName === 'cinematic') return cinematic;
1374
- if (layoutName === 'mixed') {
1375
- return [left, right, mixedCentered, floating, cinematic][sceneIndex % 5];
1376
- }
1377
- return left;
1378
- }
1379
-
1380
- function getGlobalCaptionComposition(layoutName) {
1381
- if (layoutName === 'split') {
1382
- return {
1383
- brandStyle: 'BrandLeft', brandX: 720, brandY: 78,
1384
- footerStyle: 'FooterLeft', footerX: 720, footerY: 1008,
1385
- };
1386
- }
1387
- if (layoutName === 'floating') {
1388
- return {
1389
- brandStyle: 'BrandLeft', brandX: 1050, brandY: 78,
1390
- footerStyle: 'FooterLeft', footerX: 1050, footerY: 1008,
1391
- };
1392
- }
1393
- return {
1394
- brandStyle: 'BrandCenter', brandX: 960,
1395
- brandY: ['centered', 'mixed'].includes(layoutName) ? 18 : 78,
1396
- footerStyle: 'FooterCenter', footerX: 960, footerY: 1008,
1397
- };
1398
- }
1399
-
1400
- function createCaptionFile(promoCopy, layoutName) {
1401
- const captionAppName = escapeAssText(appName.toUpperCase());
1402
- const events = sceneWindows.flatMap((window, index) => {
1403
- const scene = promoCopy.scenes[index];
1404
- const composition = getSceneComposition(layoutName, index);
1405
- const start = formatAssTime(window.start);
1406
- const end = formatAssTime(window.end);
1407
- const fadeIn = window.end - window.start <= 3 ? 180 : 220;
1408
- const fadeOut = index === sceneWindows.length - 1 ? 350 : fadeIn;
1409
- const fade = `\\fad(${fadeIn},${fadeOut})`;
1410
- return [
1411
- `Dialogue: 0,${start},${end},Kicker${composition.styleSuffix},,0,0,0,,{\\pos(${composition.textX},${composition.kickerY})${fade}}${escapeAssText(scene.kicker.toUpperCase())}`,
1412
- `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())}`,
1413
- `Dialogue: 0,${start},${end},Body${composition.styleSuffix},,0,0,0,,{\\pos(${composition.textX},${composition.bodyY})${fade}}${escapeAssText(scene.body)}`,
1414
- ];
1415
- });
1416
- const globalComposition = getGlobalCaptionComposition(layoutName);
1417
- const footer = promoCopy.footer_keywords
1418
- .map((keyword) => escapeAssText(keyword.toUpperCase()))
1419
- .join(' / ');
1420
- const captionContent = `[Script Info]
1421
- ScriptType: v4.00+
1422
- PlayResX: 1920
1423
- PlayResY: 1080
1424
- ScaledBorderAndShadow: yes
1425
- WrapStyle: 2
1426
-
1427
- [V4+ Styles]
1428
- Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
1429
- 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
1430
- 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
1431
- 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
1432
- 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
1433
- 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
1434
- 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
1435
- 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
1436
- 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
1437
- 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
1438
- 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
1439
-
1440
- [Events]
1441
- Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
1442
- Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},${globalComposition.brandStyle},,0,0,0,,{\\pos(${globalComposition.brandX},${globalComposition.brandY})\\fad(350,350)}${captionAppName}
1443
- ${events.join('\n')}
1444
- Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},${globalComposition.footerStyle},,0,0,0,,{\\pos(${globalComposition.footerX},${globalComposition.footerY})\\fad(350,350)}${footer}
1445
- `;
1446
-
1447
- fs.mkdirSync(temporaryDirectory, { recursive: true });
1448
- fs.writeFileSync(captionPath, captionContent, 'utf8');
1449
- }
1450
-
1451
- function synthesizeNeuralSpeech(pythonCommand, voiceName, text, destinationPath) {
1452
- run(pythonCommand, [
1453
- '-m', 'edge_tts',
1454
- '--voice', voiceName,
1455
- '--rate', '+4%',
1456
- '--pitch', '+0Hz',
1457
- '--text', text,
1458
- '--write-media', destinationPath,
1459
- ]);
1460
-
1461
- if (!fs.existsSync(destinationPath) || fs.statSync(destinationPath).size < 1000) {
1462
- throw new Error(`Neural narration synthesis failed: ${destinationPath}`);
1463
- }
1464
- }
1465
-
1466
- function createNarrationSegments(pythonCommand, voiceName, promoCopy) {
1467
- const segmentDirectory = path.join(temporaryDirectory, 'narration-segments');
1468
- fs.mkdirSync(segmentDirectory, { recursive: true });
1469
-
1470
- const segmentPaths = promoCopy.scenes.map((scene, index) => (
1471
- path.join(segmentDirectory, `segment-${String(index + 1).padStart(2, '0')}.mp3`)
1472
- ));
1473
-
1474
- promoCopy.scenes.forEach((scene, index) => {
1475
- synthesizeNeuralSpeech(
1476
- pythonCommand,
1477
- voiceName,
1478
- scene.narration,
1479
- segmentPaths[index],
1480
- );
1481
- });
1482
-
1483
- const endCardPath = path.join(segmentDirectory, 'end-card.mp3');
1484
- synthesizeNeuralSpeech(
1485
- pythonCommand,
1486
- voiceName,
1487
- appStoreDownloadUrl
1488
- ? 'Now available on Google Play and the App Store. Download links are available in the description.'
1489
- : 'Now available on Google Play. The download link is available in the description.',
1490
- endCardPath,
1491
- );
1492
- segmentPaths.push(endCardPath);
1493
-
1494
- return segmentPaths;
1495
- }
1496
-
1497
- function createVoicePreviews(pythonCommand) {
1498
- const previewText = `Discover what ${appName} can do. Explore the highlights and get started today.`;
1499
- fs.mkdirSync(voicePreviewDirectory, { recursive: true });
1500
-
1501
- for (const [alias, voice] of Object.entries(neuralVoiceCatalog)) {
1502
- const previewPath = path.join(voicePreviewDirectory, `${alias}-${voice.name}.mp3`);
1503
- console.log(`Creating ${alias} preview: ${voice.label}`);
1504
- synthesizeNeuralSpeech(pythonCommand, voice.name, previewText, previewPath);
1505
- }
1506
-
1507
- console.log(`Created voice previews: ${voicePreviewDirectory}`);
1508
- }
1509
-
1510
- function createNarrationTrack(ffmpegCommand, segmentPaths) {
1511
- const inputArguments = segmentPaths.flatMap((segmentPath) => ['-i', segmentPath]);
1512
- const segmentFilters = sceneWindows.map((scene, index) => {
1513
- const delay = Math.round((scene.start + (index === 0 ? 0.25 : 0.2)) * 1000);
1514
- return `[${index}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${delay}|${delay}[n${index}]`;
1515
- });
1516
- const endCardFilter = segmentPaths.length > sceneWindows.length
1517
- ? `[${sceneWindows.length}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${endCardStart * 1000}|${endCardStart * 1000},apad=whole_dur=${expectedDuration}[endcard]`
1518
- : '';
1519
- const inputs = sceneWindows.map((scene, index) => `[n${index}]`).join('');
1520
- const filterGraph = [
1521
- ...segmentFilters,
1522
- `${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]`,
1523
- endCardFilter,
1524
- endCardFilter ? '[narration][endcard]amix=inputs=2:normalize=0:duration=longest,loudnorm=I=-16:TP=-2:LRA=7[narration-final]' : '',
1525
- ].filter(Boolean).join(';');
1526
-
1527
- run(ffmpegCommand, [
1528
- '-hide_banner',
1529
- '-y',
1530
- ...inputArguments,
1531
- '-filter_complex', filterGraph,
1532
- '-map', endCardFilter ? '[narration-final]' : '[narration]',
1533
- '-t', String(expectedDuration),
1534
- '-c:a', 'pcm_s16le',
1535
- '-ar', '48000',
1536
- '-ac', '2',
1537
- narrationTrackPath,
1538
- ]);
1539
-
1540
- if (!fs.existsSync(narrationTrackPath) || fs.statSync(narrationTrackPath).size < 1000) {
1541
- throw new Error('The narration track was not created.');
1542
- }
1543
- }
1544
-
1545
- function getVideoDetails(ffprobeCommand, videoPath) {
1546
- return JSON.parse(run(ffprobeCommand, [
1547
- '-v', 'error',
1548
- '-show_entries', 'stream=codec_name,codec_type,width,height,r_frame_rate,sample_aspect_ratio,sample_rate,channels',
1549
- '-show_entries', 'format=duration,size,bit_rate',
1550
- '-of', 'json',
1551
- videoPath,
1552
- ]));
1553
- }
1554
-
1555
- function validateInput(ffprobeCommand, inputPath) {
1556
- const details = getVideoDetails(ffprobeCommand, inputPath);
1557
- const videoStream = details.streams?.find((stream) => stream.codec_type === 'video');
1558
- const duration = Number(details.format?.duration || 0);
1559
-
1560
- if (!videoStream) throw new Error('The source must contain a video stream.');
1561
- if (Number(videoStream.width) < 480 || Number(videoStream.height) < 1080) {
1562
- throw new Error('The source resolution is too small for the promotional layout.');
1563
- }
1564
- if (duration < minimumSourceDuration) {
1565
- throw new Error(`The source must be at least ${minimumSourceDuration} seconds.`);
1566
- }
1567
- return details;
1568
- }
1569
-
1570
- function ensureAudioStream(ffmpegCommand, inputPath, details) {
1571
- const hasAudioStream = details.streams?.some((stream) => stream.codec_type === 'audio');
1572
- if (hasAudioStream) return inputPath;
1573
-
1574
- const sourceHash = createHash('sha1').update(inputPath).digest('hex').slice(0, 12);
1575
- const audioFallbackPath = path.join(
1576
- temporaryDirectory,
1577
- `${sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)))}-${sourceHash}-with-silent-audio.mp4`,
1578
- );
1579
- if (!fs.existsSync(audioFallbackPath)) {
1580
- const duration = Number(details.format?.duration || 0);
1581
- console.log(`Adding a silent audio track to video-only source: ${inputPath}`);
1582
- fs.mkdirSync(temporaryDirectory, { recursive: true });
1583
- run(ffmpegCommand, [
1584
- '-hide_banner',
1585
- '-y',
1586
- '-i', inputPath,
1587
- '-f', 'lavfi',
1588
- '-t', String(duration),
1589
- '-i', 'anullsrc=channel_layout=stereo:sample_rate=48000',
1590
- '-map', '0:v:0',
1591
- '-map', '1:a:0',
1592
- '-c:v', 'copy',
1593
- '-c:a', 'aac',
1594
- '-b:a', '192k',
1595
- '-ar', '48000',
1596
- '-t', String(duration),
1597
- '-movflags', '+faststart',
1598
- audioFallbackPath,
1599
- ]);
1600
- }
1601
- return audioFallbackPath;
1602
- }
1603
-
1604
- function validateOutput(ffprobeCommand, outputPath, targetWidth, targetHeight) {
1605
- const details = getVideoDetails(ffprobeCommand, outputPath);
1606
- const videoStream = details.streams?.find((stream) => stream.codec_type === 'video') || {};
1607
- const audioStream = details.streams?.find((stream) => stream.codec_type === 'audio') || {};
1608
- const duration = Number(details.format?.duration || 0);
1609
-
1610
- if (videoStream.codec_name !== 'h264') throw new Error('Output video is not H.264.');
1611
- if (Number(videoStream.width) !== targetWidth || Number(videoStream.height) !== targetHeight) {
1612
- throw new Error(`Output resolution is not ${targetWidth}x${targetHeight}.`);
1613
- }
1614
- if (videoStream.sample_aspect_ratio !== '1:1') {
1615
- throw new Error(`Output sample aspect ratio is ${videoStream.sample_aspect_ratio}; expected 1:1.`);
1616
- }
1617
- if (audioStream.codec_name !== 'aac') throw new Error('Output audio is not AAC.');
1618
- if (Math.abs(duration - expectedDuration) > 0.2) {
1619
- throw new Error(`Output duration is ${duration.toFixed(2)}s; expected ${expectedDuration}s.`);
1620
- }
1621
-
1622
- console.log(
1623
- `Validated: ${targetWidth}x${targetHeight}, H.264/AAC, ${duration.toFixed(2)}s, `
1624
- + `${Math.round(Number(details.format?.bit_rate || 0) / 1000)} kbps.`,
1625
- );
1626
- }
1627
-
1628
- function getPromoLayoutSettings(layoutName) {
1629
- const settings = {
1630
- cinematic: { backgroundBrightness: '-0.28', backgroundSaturation: '1.05', phoneHeight: 1080 },
1631
- centered: { backgroundBrightness: '-0.42', backgroundSaturation: '1.18', phoneHeight: 650 },
1632
- dynamic: { backgroundBrightness: '-0.44', backgroundSaturation: '1.35', phoneHeight: 920 },
1633
- floating: { backgroundBrightness: '-0.38', backgroundSaturation: '1.25', phoneHeight: 860 },
1634
- mixed: { backgroundBrightness: '-0.42', backgroundSaturation: '1.30', phoneHeight: 860 },
1635
- split: { backgroundBrightness: '-0.40', backgroundSaturation: '1.30', phoneHeight: 980 },
1636
- };
1637
- return settings[layoutName] || settings.split;
1638
- }
1639
-
1640
- function buildScenePositionExpression(layoutName, propertyName) {
1641
- const values = sceneWindows.map((_, index) => getSceneComposition(layoutName, index)[propertyName]);
1642
- if (values.every((value) => value === values[0])) return values[0];
1643
-
1644
- let expression = values.at(-1);
1645
- for (let index = values.length - 2; index >= 0; index -= 1) {
1646
- const window = sceneWindows[index];
1647
- expression = `if(between(t\\,${window.start}\\,${window.end})\\,${values[index]}\\,${expression})`;
1648
- }
1649
- return expression;
1650
- }
1651
-
1652
- function buildPromoStageFilter(layoutName) {
1653
- const accent = promoDesign.accent;
1654
- if (layoutName === 'dynamic') {
1655
- 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]`;
1656
- }
1657
- if (layoutName === 'centered') {
1658
- 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]`;
1659
- }
1660
- if (layoutName === 'floating') {
1661
- 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]`;
1662
- }
1663
- if (layoutName === 'cinematic') {
1664
- return '[background]null[stage]';
1665
- }
1666
- if (layoutName === 'mixed') {
1667
- 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]`;
1668
- }
1669
- 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]`;
1670
- }
1671
-
1672
- function buildPromoForegroundFilter(layoutName) {
1673
- if (layoutName !== 'cinematic') return '[layout]null[layoutfinished]';
1674
- 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]`;
1675
- }
1676
-
1677
- function getFinalSceneIconPosition(layoutName) {
1678
- const composition = getSceneComposition(layoutName, sceneWindows.length - 1);
1679
- if (composition.styleSuffix === 'Center') return { x: 80, y: 850 };
1680
- return { x: composition.textX, y: 650 };
1681
- }
1682
-
1683
- function generatePromo(
1684
- ffmpegCommand,
1685
- inputPath,
1686
- outputPath,
1687
- crop,
1688
- targetWidth,
1689
- targetHeight,
1690
- layoutName,
1691
- ) {
1692
- const subtitleFilterPath = escapeFilterPath(captionPath);
1693
- const layoutSettings = getPromoLayoutSettings(layoutName);
1694
- const phoneX = buildScenePositionExpression(layoutName, 'phoneX');
1695
- const phoneY = buildScenePositionExpression(layoutName, 'phoneY');
1696
- const iconPosition = getFinalSceneIconPosition(layoutName);
1697
- const is4K = targetWidth >= 3840 || targetHeight >= 2160;
1698
- const videoBitrate = is4K ? '24M' : '9M';
1699
- const maximumBitrate = is4K ? '32M' : '12M';
1700
- const bufferSize = is4K ? '48M' : '18M';
1701
- const h264Level = is4K ? '5.1' : '4.2';
1702
- const segmentCount = editSegments.length;
1703
- const videoSourceLabels = editSegments.map((_, index) => `[videosource${index}]`).join('');
1704
- const audioSourceLabels = editSegments.map((_, index) => `[audiosource${index}]`).join('');
1705
- const videoSourceFilter = segmentCount === 1
1706
- ? `[0:v]${cropFilter(crop)}format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[videosource0]`
1707
- : `[0:v]${cropFilter(crop)}format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,split=${segmentCount}${videoSourceLabels}`;
1708
- const audioSourceFilter = segmentCount === 1
1709
- ? '[0:a]aresample=48000:async=1000:first_pts=0[audiosource0]'
1710
- : `[0:a]aresample=48000:async=1000:first_pts=0,asplit=${segmentCount}${audioSourceLabels}`;
1711
- const videoTrimFilters = editSegments.map((segment, index) => (
1712
- `[videosource${index}]trim=start=${segment.start}:end=${segment.end},setpts=PTS-STARTPTS[v${index}]`
1713
- ));
1714
- const audioTrimFilters = editSegments.map((segment, index) => (
1715
- `[audiosource${index}]atrim=start=${segment.start}:end=${segment.end},asetpts=PTS-STARTPTS[a${index}]`
1716
- ));
1717
- const videoSequenceFilter = segmentCount === 1
1718
- ? `[v0]fps=30,setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=${endCardDuration},split=2[bgsrc][phonesrc]`
1719
- : `${editSegments.map((_, index) => `[v${index}]`).join('')}concat=n=${segmentCount}:v=1:a=0,fps=30,setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=${endCardDuration},split=2[bgsrc][phonesrc]`;
1720
- const audioSequenceFilter = segmentCount === 1
1721
- ? '[a0]loudnorm=I=-20:TP=-2:LRA=11[appaudio-pre]'
1722
- : `${editSegments.map((_, index) => `[a${index}]`).join('')}concat=n=${segmentCount}:v=0:a=1,loudnorm=I=-20:TP=-2:LRA=11[appaudio-pre]`;
1723
- const finalSceneStart = sceneWindows.at(-1).start;
1724
- const audioFadeOutStart = Math.max(0, expectedDuration - 0.65);
1725
- const filterGraph = [
1726
- videoSourceFilter,
1727
- ...videoTrimFilters,
1728
- videoSequenceFilter,
1729
- `[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]`,
1730
- buildPromoStageFilter(layoutName),
1731
- `[phonesrc]scale=-2:${layoutSettings.phoneHeight},setsar=1[phone]`,
1732
- `[stage][phone]overlay=x='${phoneX}':y='${phoneY}':eval=frame:shortest=1[layout]`,
1733
- buildPromoForegroundFilter(layoutName),
1734
- '[1:v]scale=145:145,format=rgba,colorchannelmixer=aa=0.96[icon]',
1735
- `[layoutfinished][icon]overlay=x=${iconPosition.x}:y=${iconPosition.y}:enable=between(t\\,${finalSceneStart}\\,${expectedDuration}):shortest=1[branded]`,
1736
- `[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]`,
1737
- `[3:v]scale=1920:1080,format=yuv420p[cardbanner]`,
1738
- `[captioned][cardbanner]overlay=eof_action=repeat:enable=between(t\\,${endCardStart}\\,${expectedDuration})[cardonly]`,
1739
- `[cardonly]scale=${targetWidth}:${targetHeight}:flags=lanczos,setsar=1,format=yuv420p[video]`,
1740
- audioSourceFilter,
1741
- ...audioTrimFilters,
1742
- audioSequenceFilter,
1743
- `[appaudio-pre]afade=t=in:st=0:d=0.12,afade=t=out:st=${audioFadeOutStart}:d=0.65[appaudio]`,
1744
- '[2:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,asplit=2[voicekey][voice]',
1745
- '[appaudio][voicekey]sidechaincompress=threshold=0.015:ratio=10:attack=15:release=350:makeup=1[ducked]',
1746
- '[ducked][voice]amix=inputs=2:weights=0.72 1.12:normalize=0,alimiter=limit=0.84:level=false[audio]',
1747
- ].join(';');
1748
-
1749
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1750
-
1751
- run(ffmpegCommand, [
1752
- '-hide_banner',
1753
- '-y',
1754
- '-i', inputPath,
1755
- '-loop', '1',
1756
- '-framerate', '30',
1757
- '-i', iconPath,
1758
- '-i', narrationTrackPath,
1759
- '-loop', '1',
1760
- '-i', endCardBannerPath,
1761
- '-filter_complex', filterGraph,
1762
- '-map', '[video]',
1763
- '-map', '[audio]',
1764
- '-t', String(expectedDuration),
1765
- '-c:v', 'libx264',
1766
- '-preset', 'medium',
1767
- '-profile:v', 'high',
1768
- '-level', h264Level,
1769
- '-pix_fmt', 'yuv420p',
1770
- '-r', '30',
1771
- '-b:v', videoBitrate,
1772
- '-maxrate', maximumBitrate,
1773
- '-bufsize', bufferSize,
1774
- '-c:a', 'aac',
1775
- '-b:a', '192k',
1776
- '-ar', '48000',
1777
- '-movflags', '+faststart',
1778
- outputPath,
1779
- ], { inherit: true });
1780
- }
1781
-
1782
- function generateSpatialXrPromo(ffmpegCommand, inputPath, outputPath) {
1783
- const foregroundWidth = 1760;
1784
- const foregroundHeight = 990;
1785
- const leftForegroundX = 96;
1786
- const rightForegroundX = 64;
1787
- const foregroundY = 45;
1788
- const filterGraph = [
1789
- `[0:v]scale=${spatialEyeWidth}:${spatialEyeHeight}:flags=lanczos,setsar=1,split=3[base][leftsource][rightsource]`,
1790
- '[base]gblur=sigma=48,eq=brightness=-0.34:saturation=0.82,split=2[leftbg][rightbg]',
1791
- `[leftsource]scale=${foregroundWidth}:${foregroundHeight}:flags=lanczos[leftforeground]`,
1792
- `[rightsource]scale=${foregroundWidth}:${foregroundHeight}:flags=lanczos[rightforeground]`,
1793
- `[leftbg]drawbox=x=${leftForegroundX - 7}:y=${foregroundY - 7}:w=${foregroundWidth + 14}:h=${foregroundHeight + 14}:color=0x23d5c3@0.55:t=4[leftstage]`,
1794
- `[rightbg]drawbox=x=${rightForegroundX - 7}:y=${foregroundY - 7}:w=${foregroundWidth + 14}:h=${foregroundHeight + 14}:color=0x23d5c3@0.55:t=4[rightstage]`,
1795
- `[leftstage][leftforeground]overlay=x=${leftForegroundX}:y=${foregroundY}:shortest=1[left]`,
1796
- `[rightstage][rightforeground]overlay=x=${rightForegroundX}:y=${foregroundY}:shortest=1[right]`,
1797
- `[left][right]hstack=inputs=2,setsar=1,format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[video]`,
1798
- ].join(';');
1799
-
1800
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1801
- run(ffmpegCommand, [
1802
- '-hide_banner',
1803
- '-y',
1804
- '-i', inputPath,
1805
- '-filter_complex', filterGraph,
1806
- '-map', '[video]',
1807
- '-map', '0:a:0',
1808
- '-t', String(expectedDuration),
1809
- '-c:v', 'libx264',
1810
- '-preset', 'medium',
1811
- '-profile:v', 'high',
1812
- '-level', '5.1',
1813
- '-pix_fmt', 'yuv420p',
1814
- '-r', '30',
1815
- '-b:v', '18M',
1816
- '-maxrate', '24M',
1817
- '-bufsize', '36M',
1818
- '-x264opts', 'frame-packing=3',
1819
- '-metadata:s:v:0', 'stereo_mode=left_right',
1820
- '-c:a', 'aac',
1821
- '-b:a', '192k',
1822
- '-ar', '48000',
1823
- '-movflags', '+faststart',
1824
- outputPath,
1825
- ], { inherit: true });
1826
- }
1827
-
1828
- function validateSpatialXrOutput(ffmpegCommand, ffprobeCommand, outputPath) {
1829
- validateOutput(
1830
- ffprobeCommand,
1831
- outputPath,
1832
- spatialOutputWidth,
1833
- spatialOutputHeight,
1834
- );
1835
- const result = spawnSync(ffmpegCommand, [
1836
- '-hide_banner',
1837
- '-loglevel', 'trace',
1838
- '-i', outputPath,
1839
- '-map', '0:v:0',
1840
- '-c:v', 'copy',
1841
- '-bsf:v', 'trace_headers',
1842
- '-frames:v', '1',
1843
- '-f', 'null',
1844
- process.platform === 'win32' ? 'NUL' : '/dev/null',
1845
- ], {
1846
- encoding: 'utf8',
1847
- windowsHide: true,
1848
- maxBuffer: 10 * 1024 * 1024,
1849
- });
1850
- const trace = `${result.stdout || ''}\n${result.stderr || ''}`;
1851
- if (result.error || result.status !== 0
1852
- || !/frame_packing_arrangement_type[^\n]*=\s*3/i.test(trace)) {
1853
- throw new Error('Spatial XR output is missing side-by-side 3D frame-packing metadata.');
1854
- }
1855
- console.log('Validated spatial metadata: stereoscopic side-by-side left-right (type 3).');
1856
- }
1857
-
1858
- function createSpatialXrAssets(ffmpegCommand, ffprobeCommand, inputPath) {
1859
- console.log(`Generating stereoscopic Spatial XR promo: ${spatialXrVideoPath}`);
1860
- generateSpatialXrPromo(ffmpegCommand, inputPath, spatialXrVideoPath);
1861
- validateSpatialXrOutput(ffmpegCommand, ffprobeCommand, spatialXrVideoPath);
1862
- createVideoThumbnail(ffmpegCommand, ffprobeCommand, spatialXrVideoPath, true);
1863
- console.log(`Created Spatial XR promo video: ${spatialXrVideoPath}`);
1864
- }
1865
-
1866
- function getTargetDimensions(videoDetails) {
1867
- const videoStream = videoDetails.streams?.find((stream) => stream.codec_type === 'video') || {};
1868
- const sourceWidth = Number(videoStream.width || 0);
1869
- const sourceHeight = Number(videoStream.height || 0);
1870
- return sourceWidth > sourceHeight
1871
- ? { width: sourceWidth, height: sourceHeight }
1872
- : { width: 1920, height: 1080 };
1873
- }
1874
-
1875
- function getLayoutOutputSuffix(options, layoutName) {
1876
- if (!options.layoutExplicit) return '';
1877
- return options.layout === 'random'
1878
- ? `-layout-random-${layoutName}`
1879
- : `-layout-${layoutName}`;
1880
- }
1881
-
1882
- function getBatchOutputPath(inputPath, targetWidth, targetHeight, layoutSuffix = '') {
1883
- const relativeDirectory = path.relative(sourceVideoDirectory, path.dirname(inputPath));
1884
- const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
1885
- return path.join(
1886
- outputVideoDirectory,
1887
- relativeDirectory,
1888
- `${appFilePrefix}-${sourceName}-promo-english-voiceover${layoutSuffix}-${targetWidth}x${targetHeight}.mp4`,
1889
- );
1890
- }
1891
-
1892
- function getSingleOutputPath(inputPath, targetWidth, targetHeight, layoutSuffix = '') {
1893
- const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
1894
- return path.join(
1895
- outputVideoDirectory,
1896
- `${appFilePrefix}-${sourceName}-promo-english-voiceover${layoutSuffix}-${targetWidth}x${targetHeight}.mp4`,
1897
- );
1898
- }
1899
-
1900
- async function main() {
1901
- const options = parseArguments();
1902
- await ensureAppStoreDownloadUrl();
1903
- if (options.listVoices) {
1904
- printVoiceCatalog();
1905
- return;
1906
- }
1907
-
1908
- if (options.previewVoices) {
1909
- const previewPythonCommand = getPythonExecutable();
1910
- if (!previewPythonCommand) {
1911
- throw new Error(
1912
- 'edge-tts is required for natural neural voices. Install it with: py -m pip install edge-tts',
1913
- );
1914
- }
1915
- createVoicePreviews(previewPythonCommand);
1916
- return;
1917
- }
1918
-
1919
- const layoutName = resolvePromoLayout(options.layout);
1920
- const layoutSuffix = getLayoutOutputSuffix(options, layoutName);
1921
- console.log(
1922
- `Promo layout: ${layoutName}${options.layout === 'random' ? ' (randomly selected)' : ''}.`,
1923
- );
1924
-
1925
- const ffmpegCommand = getExecutable('ffmpeg');
1926
- const ffprobeCommand = getExecutable('ffprobe');
1927
- const voice = resolveVoice(options.voice);
1928
-
1929
- if (!ffmpegCommand || !ffprobeCommand) {
1930
- throw new Error('FFmpeg and FFprobe were not found in PATH or FFMPEG_BIN.');
1931
- }
1932
- if (!fs.existsSync(iconPath)) throw new Error(`App icon was not found: ${iconPath}`);
1933
- await ensureEndCardBanner();
1934
-
1935
- const inputPaths = options.input
1936
- ? [path.resolve(options.input)]
1937
- : findVideos(sourceVideoDirectory).filter((inputPath) => !isExcludedBatchSource(inputPath));
1938
- if (inputPaths.length === 0) {
1939
- throw new Error(`No MP4 videos were found under: ${sourceVideoDirectory}`);
1940
- }
1941
- for (const inputPath of inputPaths) {
1942
- if (!fs.existsSync(inputPath)) throw new Error(`Source video was not found: ${inputPath}`);
1943
- }
1944
-
1945
- console.log(`Preparing ${inputPaths.length} source video${inputPaths.length === 1 ? '' : 's'}...`);
1946
- const sourceVideos = inputPaths.map((inputPath) => {
1947
- const details = validateInput(ffprobeCommand, inputPath);
1948
- const processingInputPath = ensureAudioStream(ffmpegCommand, inputPath, details);
1949
- const duration = Number(details.format?.duration || 0);
1950
- const crop = detectActiveVideoCrop(ffmpegCommand, inputPath, details);
1951
- const target = getTargetDimensions(details);
1952
- const outputPath = options.output
1953
- ? path.resolve(options.output)
1954
- : options.input
1955
- ? getSingleOutputPath(inputPath, target.width, target.height, layoutSuffix)
1956
- : getBatchOutputPath(inputPath, target.width, target.height, layoutSuffix);
1957
- return { crop, duration, inputPath, outputPath, processingInputPath, target };
1958
- });
1959
- const analysisSource = [...sourceVideos].sort((left, right) => (
1960
- (right.target.width * right.target.height) - (left.target.width * left.target.height)
1961
- ))[0];
1962
- const sourceDuration = Math.min(...sourceVideos.map((source) => source.duration));
1963
- const redundantStaticRanges = detectRedundantStaticRanges(
1964
- ffmpegCommand,
1965
- analysisSource.inputPath,
1966
- sourceDuration,
1967
- );
1968
- const visualChangeTimes = detectVisualChangeTimes(ffmpegCommand, analysisSource.inputPath);
1969
- configurePromoTimeline(sourceDuration, redundantStaticRanges, visualChangeTimes);
1970
- const promoCopyResult = await generateOrLoadPromoCopy(
1971
- options,
1972
- ffmpegCommand,
1973
- analysisSource.inputPath,
1974
- inputPaths,
1975
- analysisSource.crop,
1976
- );
1977
- console.log(
1978
- `${promoCopyResult.fromCache ? 'Using cached' : 'Generated'} AI promo copy `
1979
- + `(${promoCopyResult.provider}, ${promoCopyResult.model}).`,
1980
- );
1981
- console.log(`AI promo copy: ${promoCopyCachePath}`);
1982
- const nonSpatialXrSource = sourceVideos.find((source) => (
1983
- /(^|[\\/])google-play-xr-non-spatial([\\/]|$)/i.test(source.inputPath)
1984
- ));
1985
- const spatialMetadataSource = nonSpatialXrSource
1986
- ? createSpatialMetadataSource(promoCopyResult.copy)
1987
- : null;
1988
- const youtubeMetadataFiles = writeYoutubeUploadMetadata(
1989
- promoCopyResult.copy,
1990
- spatialMetadataSource ? [...sourceVideos, spatialMetadataSource] : sourceVideos,
1991
- );
1992
- for (const metadataFile of youtubeMetadataFiles) {
1993
- console.log(
1994
- `${metadataFile.platform} YouTube metadata: ${metadataFile.metadataTextPath}`,
1995
- );
1996
- }
1997
- if (options.copyOnly) return;
1998
- if (options.spatialXrOnly) {
1999
- if (!nonSpatialXrSource || !spatialMetadataSource) {
2000
- throw new Error('A Google-Play-XR-Non-Spatial source video is required.');
2001
- }
2002
- if (!fs.existsSync(nonSpatialXrSource.outputPath)) {
2003
- throw new Error(
2004
- `Generate the non-spatial XR promo first: ${nonSpatialXrSource.outputPath}`,
2005
- );
2006
- }
2007
- createSpatialXrAssets(
2008
- ffmpegCommand,
2009
- ffprobeCommand,
2010
- nonSpatialXrSource.outputPath,
2011
- );
2012
- return;
2013
- }
2014
- if (options.thumbnailsOnly) {
2015
- const thumbnailSources = spatialMetadataSource
2016
- ? [...sourceVideos, spatialMetadataSource]
2017
- : sourceVideos;
2018
- for (const source of thumbnailSources) {
2019
- if (!fs.existsSync(source.outputPath)) {
2020
- throw new Error(`Generated promo video was not found: ${source.outputPath}`);
2021
- }
2022
- createVideoThumbnail(
2023
- ffmpegCommand,
2024
- ffprobeCommand,
2025
- source.outputPath,
2026
- Boolean(source.spatialStereo),
2027
- );
2028
- }
2029
- console.log(`Completed ${thumbnailSources.length} promotional thumbnails.`);
2030
- return;
2031
- }
2032
-
2033
- const pythonCommand = getPythonExecutable();
2034
- if (!pythonCommand) {
2035
- throw new Error(
2036
- 'edge-tts is required for natural neural voices. Install it with: py -m pip install edge-tts',
2037
- );
2038
- }
2039
- createCaptionFile(promoCopyResult.copy, layoutName);
2040
- console.log(`Creating natural neural narration with: ${voice.label}`);
2041
- const narrationSegmentPaths = createNarrationSegments(
2042
- pythonCommand,
2043
- voice.name,
2044
- promoCopyResult.copy,
2045
- );
2046
- createNarrationTrack(ffmpegCommand, narrationSegmentPaths);
2047
- for (const [index, source] of sourceVideos.entries()) {
2048
- console.log(
2049
- `Generating promo ${index + 1}/${sourceVideos.length}: ${source.inputPath} `
2050
- + `-> ${source.target.width}x${source.target.height}`,
2051
- );
2052
- generatePromo(
2053
- ffmpegCommand,
2054
- source.processingInputPath,
2055
- source.outputPath,
2056
- source.crop,
2057
- source.target.width,
2058
- source.target.height,
2059
- layoutName,
2060
- );
2061
- validateOutput(
2062
- ffprobeCommand,
2063
- source.outputPath,
2064
- source.target.width,
2065
- source.target.height,
2066
- );
2067
- console.log(`Created promo video: ${source.outputPath}`);
2068
- createVideoThumbnail(ffmpegCommand, ffprobeCommand, source.outputPath);
2069
- }
2070
- if (nonSpatialXrSource && spatialMetadataSource) {
2071
- createSpatialXrAssets(
2072
- ffmpegCommand,
2073
- ffprobeCommand,
2074
- nonSpatialXrSource.outputPath,
2075
- );
2076
- }
2077
- console.log(
2078
- `Completed ${sourceVideos.length + (spatialMetadataSource ? 1 : 0)} promotional videos.`,
2079
- );
2080
- }
2081
-
2082
- main().catch((error) => {
2083
- console.error(`Promo generation failed: ${error.message}`);
2084
- process.exitCode = 1;
2085
- });
220
+ + '- Do not add watermarks, fake UI, generated symbols, or unrelated decorations.\n'
221
+ + '- Rounded corners and a subtle shadow may be applied around the app icon, but its internal artwork must remain unchanged.\n'
222
+ + '- Use image generation only for the background.\n'
223
+ + '- Composite the actual app icon and official store badges afterward.\n'
224
+ + '- Replace the specified output file if it already exists.\n'
225
+ + '- Verify the final PNG dimensions are exactly 1672 × 941.\n'
226
+ + '- Visually inspect the completed image before finishing.\n'
227
+ + '- Report the final saved path.\n\n'
228
+ + 'Then run the video-generation command again.',
229
+ );
230
+ }
231
+
232
+ async function ensureAppStoreDownloadUrl() {
233
+ if (appStoreDownloadUrl) return;
234
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
235
+ try {
236
+ const appStoreId = (await readline.question(
237
+ 'Enter the iOS App Store ID (press Enter to skip): ',
238
+ )).trim();
239
+ if (!appStoreId) return;
240
+ if (!/^\d+$/.test(appStoreId)) {
241
+ throw new Error('iOS App Store ID must contain digits only.');
242
+ }
243
+ appStoreDownloadUrl = `https://apps.apple.com/app/id${appStoreId}`;
244
+ iosStoreConfig = { ...iosStoreConfig, appStoreUrl: appStoreDownloadUrl };
245
+ fs.mkdirSync(path.dirname(iosStoreConfigPath), { recursive: true });
246
+ fs.writeFileSync(iosStoreConfigPath, `${JSON.stringify(iosStoreConfig, null, 2)}\n`, 'utf8');
247
+ console.log(`Saved iOS App Store URL to ${iosStoreConfigPath}`);
248
+ } finally {
249
+ readline.close();
250
+ }
251
+ }
252
+
253
+ function findVideos(directory) {
254
+ if (!fs.existsSync(directory)) return [];
255
+ return fs.readdirSync(directory, { withFileTypes: true })
256
+ .flatMap((entry) => {
257
+ const entryPath = path.join(directory, entry.name);
258
+ if (entry.isDirectory()) return findVideos(entryPath);
259
+ return entry.isFile() && /\.mp4$/i.test(entry.name) ? [entryPath] : [];
260
+ })
261
+ .sort((left, right) => left.localeCompare(right));
262
+ }
263
+
264
+ function isExcludedBatchSource(inputPath) {
265
+ const relativePath = path.relative(sourceVideoDirectory, inputPath);
266
+ const platformDirectory = relativePath.split(path.sep)[0].toLowerCase();
267
+ return platformDirectory === 'samsung';
268
+ }
269
+
270
+ function roundTimelineValue(value) {
271
+ return Math.round(value * 100) / 100;
272
+ }
273
+
274
+ function mapOutputTimeToSource(outputTime) {
275
+ let remainingTime = outputTime;
276
+ for (const segment of editSegments) {
277
+ const segmentDuration = segment.end - segment.start;
278
+ if (remainingTime <= segmentDuration) {
279
+ return Math.min(segment.end - 0.05, segment.start + remainingTime);
280
+ }
281
+ remainingTime -= segmentDuration;
282
+ }
283
+ return editSegments.at(-1).end - 0.05;
284
+ }
285
+
286
+ function mapSourceTimeToOutput(sourceTime) {
287
+ let outputTime = 0;
288
+ for (const segment of editSegments) {
289
+ if (sourceTime < segment.start) return outputTime;
290
+ if (sourceTime <= segment.end) return outputTime + (sourceTime - segment.start);
291
+ outputTime += segment.end - segment.start;
292
+ }
293
+ return outputTime;
294
+ }
295
+
296
+ function normalizeSkippedRanges(skippedRanges, sourceDuration) {
297
+ const ranges = skippedRanges
298
+ .map((range) => ({
299
+ start: Math.max(0, Math.min(sourceDuration, range.start)),
300
+ end: Math.max(0, Math.min(sourceDuration, range.end)),
301
+ }))
302
+ .filter((range) => range.end > range.start)
303
+ .sort((left, right) => left.start - right.start);
304
+
305
+ return ranges.reduce((merged, range) => {
306
+ const previous = merged.at(-1);
307
+ if (previous && range.start <= previous.end) {
308
+ previous.end = Math.max(previous.end, range.end);
309
+ } else {
310
+ merged.push({ ...range });
311
+ }
312
+ return merged;
313
+ }, []);
314
+ }
315
+
316
+ function buildRetainedSegments(sourceDuration, skippedRanges) {
317
+ const normalizedRanges = normalizeSkippedRanges(skippedRanges, sourceDuration);
318
+ const retainedSegments = [];
319
+ let retainedStart = 0;
320
+
321
+ for (const range of normalizedRanges) {
322
+ if (range.start > retainedStart) {
323
+ retainedSegments.push({ start: retainedStart, end: range.start });
324
+ }
325
+ retainedStart = range.end;
326
+ }
327
+ if (retainedStart < sourceDuration) {
328
+ retainedSegments.push({ start: retainedStart, end: sourceDuration });
329
+ }
330
+
331
+ return {
332
+ retainedSegments: retainedSegments.map((segment) => ({
333
+ start: roundTimelineValue(segment.start),
334
+ end: roundTimelineValue(segment.end),
335
+ })),
336
+ skippedDuration: normalizedRanges.reduce(
337
+ (total, range) => total + (range.end - range.start),
338
+ 0,
339
+ ),
340
+ };
341
+ }
342
+
343
+ function buildSceneWindows(retainedDuration, visualChangeTimes) {
344
+ const minimumWindowDuration = Math.max(2.5, Math.min(4, retainedDuration / 40));
345
+ const maximumWindowDuration = Math.max(6, Math.min(10, retainedDuration / 14));
346
+ const boundaries = [0];
347
+ const mappedChanges = visualChangeTimes
348
+ .map((sourceTime) => mapSourceTimeToOutput(sourceTime))
349
+ .filter((outputTime) => outputTime > 0 && outputTime < retainedDuration)
350
+ .sort((left, right) => left - right);
351
+
352
+ for (const changeTime of mappedChanges) {
353
+ if (changeTime - boundaries.at(-1) >= minimumWindowDuration) {
354
+ boundaries.push(changeTime);
355
+ }
356
+ }
357
+ if (retainedDuration - boundaries.at(-1) < minimumWindowDuration) boundaries.pop();
358
+ boundaries.push(retainedDuration);
359
+
360
+ const windows = [];
361
+ for (let index = 0; index < boundaries.length - 1; index += 1) {
362
+ const start = boundaries[index];
363
+ const end = boundaries[index + 1];
364
+ const splitCount = Math.max(1, Math.ceil((end - start) / maximumWindowDuration));
365
+ for (let splitIndex = 0; splitIndex < splitCount; splitIndex += 1) {
366
+ windows.push({
367
+ start: roundTimelineValue(start + (((end - start) * splitIndex) / splitCount)),
368
+ end: roundTimelineValue(start + (((end - start) * (splitIndex + 1)) / splitCount)),
369
+ });
370
+ }
371
+ }
372
+
373
+ if (windows.length >= minimumSceneCount) return windows;
374
+ return Array.from({ length: minimumSceneCount }, (_, index) => ({
375
+ start: roundTimelineValue((retainedDuration * index) / minimumSceneCount),
376
+ end: index === minimumSceneCount - 1
377
+ ? retainedDuration
378
+ : roundTimelineValue((retainedDuration * (index + 1)) / minimumSceneCount),
379
+ }));
380
+ }
381
+
382
+ function configurePromoTimeline(sourceDuration, skippedRanges = [], visualChangeTimes = []) {
383
+ if (!Number.isFinite(sourceDuration) || sourceDuration < minimumSourceDuration) {
384
+ throw new Error(
385
+ `The shortest source video must be at least ${minimumSourceDuration} seconds.`,
386
+ );
387
+ }
388
+
389
+ const { retainedSegments, skippedDuration } = buildRetainedSegments(
390
+ sourceDuration,
391
+ skippedRanges,
392
+ );
393
+ editSegments = retainedSegments.filter((segment) => segment.end > segment.start);
394
+ const retainedDuration = editSegments.reduce(
395
+ (total, segment) => total + (segment.end - segment.start),
396
+ 0,
397
+ );
398
+ if (retainedDuration < minimumSourceDuration) {
399
+ throw new Error(
400
+ `At least ${minimumSourceDuration} seconds must remain after automatic cleanup.`,
401
+ );
402
+ }
403
+
404
+ expectedDuration = roundTimelineValue(retainedDuration);
405
+ sceneWindows = buildSceneWindows(expectedDuration, visualChangeTimes).map((window) => {
406
+ const sceneDuration = window.end - window.start;
407
+ return {
408
+ ...window,
409
+ sourceTime: roundTimelineValue(mapOutputTimeToSource(
410
+ window.start + (sceneDuration / 2),
411
+ )),
412
+ maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
413
+ };
414
+ });
415
+ sceneCount = sceneWindows.length;
416
+ promoCopySchema.properties.scenes.minItems = sceneCount;
417
+ promoCopySchema.properties.scenes.maxItems = sceneCount;
418
+ endCardStart = expectedDuration;
419
+ expectedDuration = roundTimelineValue(expectedDuration + endCardDuration);
420
+
421
+ console.log(
422
+ `Feature-complete promo timeline: ${expectedDuration}s from ${sourceDuration.toFixed(2)}s source; `
423
+ + `${sceneCount} scenes and ${editSegments.length} retained segment${editSegments.length === 1 ? '' : 's'}`
424
+ + `${skippedDuration > 0 ? ` (${skippedDuration.toFixed(2)}s of redundant static footage removed)` : ''}.`,
425
+ );
426
+ }
427
+
428
+ function escapeAssText(value) {
429
+ return String(value)
430
+ .replace(/\\/g, '\\\\')
431
+ .replace(/{/g, '\\{')
432
+ .replace(/}/g, '\\}')
433
+ .replace(/\r?\n/g, '\\N');
434
+ }
435
+
436
+ function run(command, args, options = {}) {
437
+ const result = spawnSync(command, args, {
438
+ encoding: 'utf8',
439
+ windowsHide: true,
440
+ stdio: options.inherit ? 'inherit' : 'pipe',
441
+ input: options.input,
442
+ maxBuffer: 10 * 1024 * 1024,
443
+ });
444
+
445
+ if (result.error) {
446
+ if (result.error.code === 'ENOENT') {
447
+ throw new Error(`${command} was not found.`);
448
+ }
449
+ throw result.error;
450
+ }
451
+
452
+ if (result.status !== 0) {
453
+ throw new Error(
454
+ String(result.stderr || result.stdout || `${command} exited with code ${result.status}.`).trim(),
455
+ );
456
+ }
457
+
458
+ return String(result.stdout || '').trim();
459
+ }
460
+
461
+ function commandExists(command) {
462
+ const result = spawnSync(command, ['-version'], {
463
+ encoding: 'utf8',
464
+ windowsHide: true,
465
+ });
466
+ return !result.error && result.status === 0;
467
+ }
468
+
469
+ function getExecutable(command) {
470
+ if (commandExists(command)) return command;
471
+
472
+ if (!ffmpegFallbackDirectory) return '';
473
+ const executableName = process.platform === 'win32' ? `${command}.exe` : command;
474
+ const fallbackPath = path.join(ffmpegFallbackDirectory, executableName);
475
+ return fs.existsSync(fallbackPath) ? fallbackPath : '';
476
+ }
477
+
478
+ function detectRedundantStaticRanges(ffmpegCommand, inputPath, sourceDuration) {
479
+ const minimumStaticDuration = Math.max(2, Math.min(4, sourceDuration / 60));
480
+ const result = spawnSync(ffmpegCommand, [
481
+ '-hide_banner',
482
+ '-nostats',
483
+ '-i', inputPath,
484
+ '-vf', `freezedetect=n=-50dB:d=${minimumStaticDuration}`,
485
+ '-an',
486
+ '-f', 'null',
487
+ process.platform === 'win32' ? 'NUL' : '/dev/null',
488
+ ], {
489
+ encoding: 'utf8',
490
+ windowsHide: true,
491
+ maxBuffer: 10 * 1024 * 1024,
492
+ });
493
+ if (result.error || result.status !== 0) {
494
+ console.warn('Static-screen analysis was unavailable; retaining the complete recording.');
495
+ return [];
496
+ }
497
+
498
+ const events = String(result.stderr || '').matchAll(
499
+ /lavfi\.freezedetect\.freeze_(start|end):\s*([\d.]+)/g,
500
+ );
501
+ const staticRanges = [];
502
+ let staticStart = null;
503
+ for (const event of events) {
504
+ const time = Number(event[2]);
505
+ if (event[1] === 'start') {
506
+ staticStart = time;
507
+ } else if (staticStart !== null && time > staticStart) {
508
+ staticRanges.push({ start: staticStart, end: time });
509
+ staticStart = null;
510
+ }
511
+ }
512
+ if (staticStart !== null && sourceDuration > staticStart) {
513
+ staticRanges.push({ start: staticStart, end: sourceDuration });
514
+ }
515
+
516
+ return staticRanges
517
+ .map((range) => ({
518
+ start: range.start + minimumStaticDuration,
519
+ end: range.end,
520
+ }))
521
+ .filter((range) => range.end - range.start >= 0.75);
522
+ }
523
+
524
+ function detectVisualChangeTimes(ffmpegCommand, inputPath) {
525
+ const result = spawnSync(ffmpegCommand, [
526
+ '-hide_banner',
527
+ '-nostats',
528
+ '-i', inputPath,
529
+ '-vf', "select='gt(scene,0.03)',showinfo",
530
+ '-an',
531
+ '-f', 'null',
532
+ process.platform === 'win32' ? 'NUL' : '/dev/null',
533
+ ], {
534
+ encoding: 'utf8',
535
+ windowsHide: true,
536
+ maxBuffer: 10 * 1024 * 1024,
537
+ });
538
+ if (result.error || result.status !== 0) {
539
+ console.warn('Visual-change analysis was unavailable; using duration-based scene coverage.');
540
+ return [];
541
+ }
542
+ return [...String(result.stderr || '').matchAll(/pts_time:([\d.]+)/g)]
543
+ .map((match) => Number(match[1]))
544
+ .filter(Number.isFinite);
545
+ }
546
+
547
+ function parseArguments() {
548
+ const args = process.argv.slice(2);
549
+ const options = {
550
+ aiModel: '',
551
+ aiProvider: 'auto',
552
+ copyOnly: false,
553
+ input: '',
554
+ layout: 'split',
555
+ layoutExplicit: false,
556
+ listVoices: false,
557
+ output: '',
558
+ previewVoices: false,
559
+ refreshAiCopy: false,
560
+ spatialXrOnly: false,
561
+ thumbnailsOnly: false,
562
+ voice: 'sonia',
563
+ };
564
+
565
+ for (let index = 0; index < args.length; index += 1) {
566
+ const argument = args[index];
567
+ if (argument === '--input') {
568
+ options.input = path.resolve(args[index + 1] || '');
569
+ index += 1;
570
+ } else if (argument === '--output') {
571
+ options.output = path.resolve(args[index + 1] || '');
572
+ index += 1;
573
+ } else if (argument === '--voice') {
574
+ options.voice = String(args[index + 1] || '').trim();
575
+ index += 1;
576
+ } else if (argument === '--layout') {
577
+ options.layout = String(args[index + 1] || '').trim().toLowerCase();
578
+ options.layoutExplicit = true;
579
+ index += 1;
580
+ } else if (argument === '--ai-provider') {
581
+ options.aiProvider = String(args[index + 1] || '').trim().toLowerCase();
582
+ index += 1;
583
+ } else if (argument === '--ai-model') {
584
+ options.aiModel = String(args[index + 1] || '').trim();
585
+ index += 1;
586
+ } else if (argument === '--refresh-ai-copy') {
587
+ options.refreshAiCopy = true;
588
+ } else if (argument === '--copy-only') {
589
+ options.copyOnly = true;
590
+ } else if (argument === '--thumbnails-only') {
591
+ options.thumbnailsOnly = true;
592
+ } else if (argument === '--spatial-xr-only') {
593
+ options.spatialXrOnly = true;
594
+ } else if (argument === '--list-voices') {
595
+ options.listVoices = true;
596
+ } else if (argument === '--preview-voices') {
597
+ options.previewVoices = true;
598
+ } else if (argument === '--help' || argument === '-h') {
599
+ console.log(`Generate an adaptive promotional video for ${appName}.`);
600
+ console.log('');
601
+ console.log('Usage:');
602
+ console.log(' node generate-ai-promo-video.js');
603
+ console.log(' node generate-ai-promo-video.js --input source.mp4 --output promo.mp4');
604
+ console.log(' node generate-ai-promo-video.js --layout dynamic|split|centered|floating|cinematic|mixed|random');
605
+ console.log(' node generate-ai-promo-video.js --voice aria');
606
+ console.log(' node generate-ai-promo-video.js --refresh-ai-copy');
607
+ console.log(' node generate-ai-promo-video.js --copy-only');
608
+ console.log(' node generate-ai-promo-video.js --thumbnails-only');
609
+ console.log(' node generate-ai-promo-video.js --spatial-xr-only');
610
+ console.log(' node generate-ai-promo-video.js --ai-provider auto|codex|openai');
611
+ console.log(' node generate-ai-promo-video.js --ai-model MODEL_NAME');
612
+ console.log(' node generate-ai-promo-video.js --list-voices');
613
+ console.log(' node generate-ai-promo-video.js --preview-voices');
614
+ console.log('');
615
+ console.log(
616
+ `Without --input, every MP4 under ${sourceVideoDirectory} except Samsung is processed.`,
617
+ );
618
+ console.log('Redundant static pauses are shortened automatically; distinct screens are retained.');
619
+ process.exit(0);
620
+ } else {
621
+ throw new Error(`Unknown argument: ${argument}`);
622
+ }
623
+ }
624
+
625
+ if (!['auto', 'codex', 'openai'].includes(options.aiProvider)) {
626
+ throw new Error('--ai-provider must be auto, codex, or openai.');
627
+ }
628
+ if (!promoLayoutNames.includes(options.layout)) {
629
+ throw new Error(`--layout must be one of: ${promoLayoutNames.join(', ')}.`);
630
+ }
631
+ if (options.output && !options.input) {
632
+ throw new Error('--output can only be used together with --input.');
633
+ }
634
+
635
+ return options;
636
+ }
637
+
638
+ function resolvePromoLayout(requestedLayout) {
639
+ if (requestedLayout !== 'random') return requestedLayout;
640
+ return selectablePromoLayoutNames[Math.floor(Math.random() * selectablePromoLayoutNames.length)];
641
+ }
642
+
643
+ function getPythonExecutable() {
644
+ for (const candidate of ['py', 'python']) {
645
+ const result = spawnSync(candidate, ['-m', 'edge_tts', '--version'], {
646
+ encoding: 'utf8',
647
+ windowsHide: true,
648
+ });
649
+ if (!result.error && result.status === 0) return candidate;
650
+ }
651
+ return '';
652
+ }
653
+
654
+ function printVoiceCatalog() {
655
+ console.log('Natural female neural voices:');
656
+ console.log('');
657
+ for (const [alias, voice] of Object.entries(neuralVoiceCatalog)) {
658
+ console.log(` ${alias.padEnd(10)} ${voice.label} (${voice.name})`);
659
+ }
660
+ console.log('');
661
+ console.log('Use an alias above or any full Edge TTS neural voice name with --voice.');
662
+ }
663
+
664
+ function resolveVoice(input) {
665
+ const alias = String(input || '').trim().toLowerCase();
666
+ return neuralVoiceCatalog[alias] || {
667
+ name: String(input || '').trim(),
668
+ label: String(input || '').trim(),
669
+ };
670
+ }
671
+
672
+ function escapeFilterPath(filePath) {
673
+ return filePath
674
+ .replace(/\\/g, '/')
675
+ .replace(':', '\\:')
676
+ .replace(/'/g, "\\'");
677
+ }
678
+
679
+ function escapeFilterText(value) {
680
+ return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\\:');
681
+ }
682
+
683
+ function readTextIfPresent(filePath, maximumLength = 12000) {
684
+ if (!fs.existsSync(filePath)) return '';
685
+ return fs.readFileSync(filePath, 'utf8').slice(0, maximumLength);
686
+ }
687
+
688
+ function readJsonIfPresent(filePath) {
689
+ try {
690
+ return JSON.parse(readTextIfPresent(filePath, 50000));
691
+ } catch {
692
+ return {};
693
+ }
694
+ }
695
+
696
+ function extractHtmlMetadata(html) {
697
+ const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '';
698
+ const description = html.match(
699
+ /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
700
+ )?.[1] || html.match(
701
+ /<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i,
702
+ )?.[1] || '';
703
+ return { title: title.trim(), description: description.trim() };
704
+ }
705
+
706
+ function stripMarkup(value) {
707
+ return String(value)
708
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
709
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
710
+ .replace(/<[^>]+>/g, ' ')
711
+ .replace(/\{\{[^}]+\}\}/g, ' ')
712
+ .replace(/&[a-zA-Z#0-9]+;/g, ' ')
713
+ .replace(/\s+/g, ' ')
714
+ .trim();
715
+ }
716
+
717
+ function collectAppContext() {
718
+ const manifestCandidates = [
719
+ path.join(projectDirectory, 'src', 'manifest.webmanifest'),
720
+ path.join(projectDirectory, 'public', 'manifest.webmanifest'),
721
+ path.join(projectDirectory, 'manifest.webmanifest'),
722
+ ];
723
+ const manifest = readJsonIfPresent(manifestCandidates.find((candidate) => fs.existsSync(candidate)) || '');
724
+ const htmlMetadata = extractHtmlMetadata(
725
+ readTextIfPresent(path.join(projectDirectory, 'src', 'index.html'), 30000),
726
+ );
727
+ const pageDirectory = path.join(projectDirectory, 'src', 'pages');
728
+ const pageFiles = fs.existsSync(pageDirectory)
729
+ ? fs.readdirSync(pageDirectory)
730
+ .filter((name) => /\.(?:f7|html)$/i.test(name))
731
+ .sort()
732
+ .slice(0, 30)
733
+ : [];
734
+ let remainingPageCharacters = 12000;
735
+ const visiblePageText = [];
736
+
737
+ for (const fileName of pageFiles) {
738
+ if (remainingPageCharacters <= 0) break;
739
+ const text = stripMarkup(
740
+ readTextIfPresent(path.join(pageDirectory, fileName), Math.min(5000, remainingPageCharacters)),
741
+ );
742
+ if (text) {
743
+ const excerpt = text.slice(0, Math.min(2500, remainingPageCharacters));
744
+ visiblePageText.push({ file: fileName, text: excerpt });
745
+ remainingPageCharacters -= excerpt.length;
746
+ }
747
+ }
748
+
749
+ return {
750
+ identity: {
751
+ appName,
752
+ appUniqueId,
753
+ appId: String(capacitorConfig.appId || '').trim(),
754
+ orientation: String(capacitorConfig.orientation || '').trim(),
755
+ },
756
+ storeMetadata: {
757
+ name: manifest.name || '',
758
+ shortName: manifest.short_name || '',
759
+ description: manifest.description || htmlMetadata.description,
760
+ categories: Array.isArray(manifest.categories) ? manifest.categories : [],
761
+ pageTitle: htmlMetadata.title,
762
+ },
763
+ productNotes: readTextIfPresent(path.join(projectDirectory, 'GROWTH_ASO_PLAN.md'), 12000),
764
+ routeConfiguration: readTextIfPresent(path.join(projectDirectory, 'src', 'js', 'routes.js'), 6000),
765
+ visiblePageText,
766
+ };
767
+ }
768
+
769
+ function createCopyFingerprint(appContext, inputPaths) {
770
+ const sources = inputPaths.map((inputPath) => {
771
+ const sourceStats = fs.statSync(inputPath);
772
+ return {
773
+ relativePath: path.relative(projectDirectory, inputPath),
774
+ size: sourceStats.size,
775
+ modified: sourceStats.mtimeMs,
776
+ };
777
+ });
778
+ return createHash('sha256').update(JSON.stringify({
779
+ schemaVersion: promoCopySchemaVersion,
780
+ appContext,
781
+ sources,
782
+ sceneWindows,
783
+ })).digest('hex');
784
+ }
785
+
786
+ function getCodexCandidates() {
787
+ const candidates = ['codex'];
788
+ const configuredCodexPath = String(process.env.CODEX_BIN || '').trim();
789
+ if (configuredCodexPath) candidates.unshift(configuredCodexPath);
790
+
791
+ if (process.platform === 'win32') {
792
+ const userProfile = process.env.USERPROFILE || process.env.HOME;
793
+ const extensionRoots = [
794
+ path.join(userProfile || '', '.vscode', 'extensions'),
795
+ path.join(userProfile || '', '.vscode-insiders', 'extensions'),
796
+ path.join(userProfile || '', '.vscode-server', 'extensions'),
797
+ path.join(userProfile || '', '.vscode-server-insiders', 'extensions'),
798
+ ];
799
+ const executableNames = ['codex.exe', 'codex'];
800
+
801
+ for (const extensionRoot of extensionRoots) {
802
+ if (!fs.existsSync(extensionRoot)) continue;
803
+ for (const extensionName of fs.readdirSync(extensionRoot)) {
804
+ if (!/^openai\.chatgpt-/i.test(extensionName)) continue;
805
+ const extensionPath = path.join(extensionRoot, extensionName, 'bin');
806
+ if (!fs.existsSync(extensionPath)) continue;
807
+ for (const platformDirectory of fs.readdirSync(extensionPath)) {
808
+ for (const executableName of executableNames) {
809
+ candidates.push(path.join(extensionPath, platformDirectory, executableName));
810
+ }
811
+ }
812
+ }
813
+ }
814
+ }
815
+
816
+ return [...new Set(candidates)];
817
+ }
818
+
819
+ function getCodexExecutable() {
820
+ for (const candidate of getCodexCandidates()) {
821
+ const versionResult = spawnSync(candidate, ['--version'], {
822
+ encoding: 'utf8',
823
+ windowsHide: true,
824
+ });
825
+ if (versionResult.error || versionResult.status !== 0) continue;
826
+
827
+ const loginResult = spawnSync(candidate, ['login', 'status'], {
828
+ encoding: 'utf8',
829
+ windowsHide: true,
830
+ });
831
+ if (!loginResult.error && loginResult.status === 0) return candidate;
832
+ }
833
+ return '';
834
+ }
835
+
836
+ function detectActiveVideoCrop(ffmpegCommand, inputPath, videoDetails) {
837
+ const videoStream = videoDetails.streams?.find((stream) => stream.codec_type === 'video') || {};
838
+ const sourceWidth = Number(videoStream.width || 0);
839
+ const sourceHeight = Number(videoStream.height || 0);
840
+ if (sourceWidth <= sourceHeight) return null;
841
+
842
+ const result = spawnSync(ffmpegCommand, [
843
+ '-hide_banner',
844
+ '-ss', '8',
845
+ '-t', '4',
846
+ '-i', inputPath,
847
+ '-vf', 'cropdetect=limit=18:round=2:reset=0',
848
+ '-f', 'null',
849
+ process.platform === 'win32' ? 'NUL' : '/dev/null',
850
+ ], {
851
+ encoding: 'utf8',
852
+ windowsHide: true,
853
+ maxBuffer: 10 * 1024 * 1024,
854
+ });
855
+ if (result.error || result.status !== 0) return null;
856
+ const matches = [...String(result.stderr || '').matchAll(/crop=(\d+):(\d+):(\d+):(\d+)/g)];
857
+ const detected = matches.at(-1)?.slice(1).map(Number);
858
+ if (!detected) return null;
859
+ const [width, height, x, y] = detected;
860
+ const isCentralPortrait = width < sourceWidth * 0.8
861
+ && height >= sourceHeight * 0.9
862
+ && height > width
863
+ && Math.abs((x + (width / 2)) - (sourceWidth / 2)) <= sourceWidth * 0.03;
864
+ return isCentralPortrait ? { width, height, x, y } : null;
865
+ }
866
+
867
+ function cropFilter(crop) {
868
+ return crop ? `crop=${crop.width}:${crop.height}:${crop.x}:${crop.y},` : '';
869
+ }
870
+
871
+ function extractAnalysisFrames(ffmpegCommand, inputPath, crop) {
872
+ fs.mkdirSync(analysisFrameDirectory, { recursive: true });
873
+ return sceneWindows.map((scene, index) => {
874
+ const framePath = path.join(
875
+ analysisFrameDirectory,
876
+ `scene-${String(index + 1).padStart(2, '0')}.jpg`,
877
+ );
878
+ run(ffmpegCommand, [
879
+ '-hide_banner',
880
+ '-loglevel', 'error',
881
+ '-y',
882
+ '-ss', String(scene.sourceTime),
883
+ '-i', inputPath,
884
+ '-frames:v', '1',
885
+ '-vf', `${cropFilter(crop)}setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,scale=768:-2,format=yuvj420p`,
886
+ '-q:v', '3',
887
+ framePath,
888
+ ]);
889
+ return framePath;
890
+ });
891
+ }
892
+
893
+ function buildPromoCopyPrompt(appContext, previousError = '') {
894
+ const timingRequirements = sceneWindows.map((scene, index) => ({
895
+ scene: index + 1,
896
+ outputStartSeconds: scene.start,
897
+ outputEndSeconds: scene.end,
898
+ sourceFrameSeconds: scene.sourceTime,
899
+ maximumNarrationWords: scene.maxNarrationWords,
900
+ }));
901
+ return `Create factual, high-conversion ASO/SEO copy for a ${expectedDuration}-second mobile-app promo video.
902
+
903
+ 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.
904
+
905
+ Rules:
906
+ - Generate exactly ${sceneWindows.length} scenes in the same order as the images and timings.
907
+ - Use only features supported by the images or project context. Never invent claims.
908
+ - Cover every distinct demonstrated feature across the retained recording; do not omit later features.
909
+ - Do not promote incidental navigation, loading states, advertisements, permission prompts, or repeated screens as features.
910
+ - Keep each scene specific to what is visibly demonstrated in its supplied image whenever possible.
911
+ - The authoritative app name is ${JSON.stringify(appName)}. Mention it naturally in the final narration and final body.
912
+ - APP_UNIQUE_ID is internal and must never appear in audience-facing copy.
913
+ - Each kicker: 2-30 characters. Each title line: 1-22 characters. Body: 8-72 characters.
914
+ - Narration must fit its scene's maximum word count. Use natural spoken English for a warm UK female voice.
915
+ - Keep captions punchy, specific and readable. Prefer user benefits and relevant search intent.
916
+ - footer_keywords must contain exactly 3 distinct, natural app-search phrases, each 2-24 characters.
917
+ - Generate a YouTube upload title and description from the same verified features.
918
+ - YouTube title: 40-100 characters, include ${JSON.stringify(appName)} and one strong natural search phrase, without clickbait.
919
+ - 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.
920
+ - 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.
921
+ - Keep YouTube text natural and useful; do not repeat keywords unnaturally.
922
+ - Avoid prices, ratings, awards, competitor names, unsupported superlatives, and words such as best, guaranteed, or free.
923
+ - No emoji, quotation marks, line breaks, markdown, or ending punctuation in kickers/titles/keywords.
924
+
925
+ Scene timing requirements:
926
+ ${JSON.stringify(timingRequirements, null, 2)}
927
+
928
+ Sanitized app context (no credentials):
929
+ ${JSON.stringify(appContext, null, 2)}
930
+ ${previousError ? `\nYour previous output was invalid: ${previousError}\nCorrect that issue in this response.` : ''}`;
931
+ }
932
+
933
+ function cleanGeneratedString(value) {
934
+ return String(value || '').replace(/\s+/g, ' ').trim();
935
+ }
936
+
937
+ function cleanGeneratedDescription(value) {
938
+ return String(value || '')
939
+ .replace(/\r\n?/g, '\n')
940
+ .split('\n')
941
+ .map((line) => line.trim())
942
+ .join('\n')
943
+ .replace(/\n{3,}/g, '\n\n')
944
+ .trim();
945
+ }
946
+
947
+ function addPlayStoreDownloadLink(description) {
948
+ const cleanedDescription = cleanGeneratedDescription(description);
949
+ if (cleanedDescription.includes(playStoreDownloadUrl)) return cleanedDescription;
950
+
951
+ const lines = cleanedDescription.split('\n');
952
+ let finalContentIndex = lines.length - 1;
953
+ while (finalContentIndex >= 0 && !lines[finalContentIndex].trim()) finalContentIndex -= 1;
954
+ const finalLine = lines[finalContentIndex]?.trim() || '';
955
+ const hashtags = finalLine.match(/#[\p{L}\p{N}_]+/gu) || [];
956
+ const hasFinalHashtagLine = hashtags.length === 3
957
+ && !finalLine.replace(/#[\p{L}\p{N}_]+/gu, '').trim();
958
+ const iosBlock = appStoreDownloadUrl
959
+ ? `\n\nDownload ${appName} on the App Store:\n${appStoreDownloadUrl}`
960
+ : '';
961
+ const downloadBlock = `Download ${appName} on Google Play:\n${playStoreDownloadUrl}${iosBlock}`;
962
+
963
+ if (!hasFinalHashtagLine) {
964
+ return `${cleanedDescription}\n\n${downloadBlock}`;
965
+ }
966
+
967
+ const descriptionBody = lines.slice(0, finalContentIndex).join('\n').trimEnd();
968
+ return `${descriptionBody}\n\n${downloadBlock}\n\n${finalLine}`;
969
+ }
970
+
971
+ function validatePromoCopy(value) {
972
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
973
+ throw new Error('AI copy is not a JSON object.');
974
+ }
975
+ if (!Array.isArray(value.scenes) || value.scenes.length !== sceneWindows.length) {
976
+ throw new Error(`AI copy must contain exactly ${sceneWindows.length} scenes.`);
977
+ }
978
+ if (!Array.isArray(value.footer_keywords) || value.footer_keywords.length !== 3) {
979
+ throw new Error('AI copy must contain exactly 3 footer keywords.');
980
+ }
981
+ if (!value.youtube || typeof value.youtube !== 'object' || Array.isArray(value.youtube)) {
982
+ throw new Error('AI copy must contain YouTube upload metadata.');
983
+ }
984
+
985
+ const footerKeywords = value.footer_keywords.map((keyword, index) => {
986
+ const cleaned = cleanGeneratedString(keyword);
987
+ if (cleaned.length < 2 || cleaned.length > 24) {
988
+ throw new Error(`Footer keyword ${index + 1} must be 2-24 characters.`);
989
+ }
990
+ return cleaned;
991
+ });
992
+ if (new Set(footerKeywords.map((keyword) => keyword.toLowerCase())).size !== 3) {
993
+ throw new Error('Footer keywords must be distinct.');
994
+ }
995
+
996
+ const constraints = {
997
+ kicker: [2, 30],
998
+ title_line_1: [1, 22],
999
+ title_line_2: [1, 22],
1000
+ body: [8, 72],
1001
+ narration: [8, 120],
1002
+ };
1003
+ const scenes = value.scenes.map((scene, sceneIndex) => {
1004
+ if (!scene || typeof scene !== 'object' || Array.isArray(scene)) {
1005
+ throw new Error(`Scene ${sceneIndex + 1} is invalid.`);
1006
+ }
1007
+ const cleanedScene = {};
1008
+ for (const [field, [minimum, maximum]] of Object.entries(constraints)) {
1009
+ const cleaned = cleanGeneratedString(scene[field]);
1010
+ if (cleaned.length < minimum || cleaned.length > maximum) {
1011
+ throw new Error(
1012
+ `Scene ${sceneIndex + 1} ${field} must be ${minimum}-${maximum} characters.`,
1013
+ );
1014
+ }
1015
+ cleanedScene[field] = cleaned;
1016
+ }
1017
+ const maximumNarrationWords = sceneWindows[sceneIndex].maxNarrationWords;
1018
+ const narrationWords = cleanedScene.narration.split(/\s+/);
1019
+ if (narrationWords.length > maximumNarrationWords) {
1020
+ cleanedScene.narration = narrationWords.slice(0, maximumNarrationWords).join(' ');
1021
+ }
1022
+ return cleanedScene;
1023
+ });
1024
+ const finalCopy = `${scenes.at(-1).body} ${scenes.at(-1).narration}`.toLowerCase();
1025
+ if (!finalCopy.includes(appName.toLowerCase())) {
1026
+ throw new Error(`The final scene must mention the app name ${JSON.stringify(appName)}.`);
1027
+ }
1028
+
1029
+ const youtubeTitle = cleanGeneratedString(value.youtube.title);
1030
+ const youtubeDescription = cleanGeneratedDescription(value.youtube.description);
1031
+ if (youtubeTitle.length < 40 || youtubeTitle.length > 100) {
1032
+ throw new Error('YouTube title must be 40-100 characters.');
1033
+ }
1034
+ if (youtubeDescription.length < 350 || youtubeDescription.length > 1500) {
1035
+ throw new Error('YouTube description must be 350-1500 characters.');
1036
+ }
1037
+ if (Buffer.byteLength(youtubeDescription, 'utf8') > 5000) {
1038
+ throw new Error('YouTube description exceeds the 5,000-byte API limit.');
1039
+ }
1040
+ if (/[<>]/.test(`${youtubeTitle}${youtubeDescription}`)) {
1041
+ throw new Error('YouTube metadata cannot contain angle brackets.');
1042
+ }
1043
+ if (!youtubeTitle.toLowerCase().includes(appName.toLowerCase())
1044
+ || !youtubeDescription.toLowerCase().includes(appName.toLowerCase())) {
1045
+ throw new Error(`YouTube title and description must mention ${JSON.stringify(appName)}.`);
1046
+ }
1047
+ const descriptionLines = youtubeDescription.split('\n').filter(Boolean);
1048
+ const finalDescriptionLine = descriptionLines.at(-1) || '';
1049
+ const hashtags = finalDescriptionLine.match(/#[\p{L}\p{N}_]+/gu) || [];
1050
+ if (hashtags.length !== 3 || finalDescriptionLine.replace(/#[\p{L}\p{N}_]+/gu, '').trim()) {
1051
+ throw new Error('YouTube description must finish with a line containing exactly 3 hashtags.');
1052
+ }
1053
+
1054
+ return {
1055
+ footer_keywords: footerKeywords,
1056
+ scenes,
1057
+ youtube: {
1058
+ title: youtubeTitle,
1059
+ description: youtubeDescription,
1060
+ },
1061
+ };
1062
+ }
1063
+
1064
+ function parseAiJson(rawValue) {
1065
+ const text = String(rawValue || '').trim()
1066
+ .replace(/^```(?:json)?\s*/i, '')
1067
+ .replace(/\s*```$/, '');
1068
+ return JSON.parse(text);
1069
+ }
1070
+
1071
+ function generateCopyWithCodex(prompt, framePaths, modelName) {
1072
+ const codexCommand = getCodexExecutable();
1073
+ if (!codexCommand) {
1074
+ throw new Error('Codex CLI is unavailable or not signed in.');
1075
+ }
1076
+ fs.mkdirSync(temporaryDirectory, { recursive: true });
1077
+ fs.writeFileSync(promoCopySchemaPath, `${JSON.stringify(promoCopySchema, null, 2)}\n`, 'utf8');
1078
+ const args = [
1079
+ 'exec',
1080
+ '--ephemeral',
1081
+ '--ignore-rules',
1082
+ '--sandbox', 'read-only',
1083
+ '--skip-git-repo-check',
1084
+ '--color', 'never',
1085
+ '--output-schema', promoCopySchemaPath,
1086
+ '--output-last-message', promoCopyResultPath,
1087
+ '-C', projectDirectory,
1088
+ ];
1089
+ if (modelName) args.push('--model', modelName);
1090
+ for (const framePath of framePaths) args.push('--image', framePath);
1091
+ args.push('-');
1092
+ run(codexCommand, args, { input: prompt });
1093
+ return {
1094
+ copy: parseAiJson(fs.readFileSync(promoCopyResultPath, 'utf8')),
1095
+ model: modelName || 'Codex configured model',
1096
+ provider: 'codex',
1097
+ };
1098
+ }
1099
+
1100
+ async function generateCopyWithOpenAi(prompt, framePaths, modelName) {
1101
+ const apiKey = String(process.env.OPENAI_API_KEY || '').trim();
1102
+ if (!apiKey) throw new Error('OPENAI_API_KEY is not configured.');
1103
+ const model = modelName || process.env.OPENAI_PROMO_MODEL || 'gpt-4o-mini';
1104
+ const content = [{ type: 'input_text', text: prompt }];
1105
+ for (const framePath of framePaths) {
1106
+ content.push({
1107
+ type: 'input_image',
1108
+ image_url: `data:image/jpeg;base64,${fs.readFileSync(framePath).toString('base64')}`,
1109
+ detail: 'low',
1110
+ });
1111
+ }
1112
+ const response = await fetch('https://api.openai.com/v1/responses', {
1113
+ method: 'POST',
1114
+ headers: {
1115
+ Authorization: `Bearer ${apiKey}`,
1116
+ 'Content-Type': 'application/json',
1117
+ },
1118
+ body: JSON.stringify({
1119
+ model,
1120
+ store: false,
1121
+ max_output_tokens: 2500,
1122
+ input: [{ role: 'user', content }],
1123
+ text: {
1124
+ format: {
1125
+ type: 'json_schema',
1126
+ name: 'promo_video_copy',
1127
+ strict: true,
1128
+ schema: promoCopySchema,
1129
+ },
1130
+ },
1131
+ }),
1132
+ });
1133
+ const result = await response.json();
1134
+ if (!response.ok) {
1135
+ throw new Error(`OpenAI Responses API failed: ${result.error?.message || response.statusText}`);
1136
+ }
1137
+ const outputText = result.output_text || result.output
1138
+ ?.flatMap((item) => item.content || [])
1139
+ .find((item) => item.type === 'output_text')?.text;
1140
+ if (!outputText) throw new Error('OpenAI returned no promo copy.');
1141
+ return { copy: parseAiJson(outputText), model, provider: 'openai' };
1142
+ }
1143
+
1144
+ async function generateOrLoadPromoCopy(
1145
+ options,
1146
+ ffmpegCommand,
1147
+ analysisInputPath,
1148
+ allInputPaths,
1149
+ analysisCrop,
1150
+ ) {
1151
+ const appContext = collectAppContext();
1152
+ const fingerprint = createCopyFingerprint(appContext, allInputPaths);
1153
+ if (!options.refreshAiCopy && fs.existsSync(promoCopyCachePath)) {
1154
+ try {
1155
+ const cached = JSON.parse(fs.readFileSync(promoCopyCachePath, 'utf8'));
1156
+ if (cached.schemaVersion === promoCopySchemaVersion && cached.fingerprint === fingerprint) {
1157
+ return {
1158
+ copy: validatePromoCopy(cached.copy),
1159
+ fromCache: true,
1160
+ model: cached.model,
1161
+ provider: cached.provider,
1162
+ };
1163
+ }
1164
+ } catch (error) {
1165
+ console.warn(`Ignoring invalid AI copy cache: ${error.message}`);
1166
+ }
1167
+ }
1168
+
1169
+ const provider = options.aiProvider === 'auto'
1170
+ ? (process.env.OPENAI_API_KEY ? 'openai' : 'codex')
1171
+ : options.aiProvider;
1172
+ console.log(`Analyzing ${sceneWindows.length} video scenes for app-specific promo copy...`);
1173
+ const framePaths = extractAnalysisFrames(ffmpegCommand, analysisInputPath, analysisCrop);
1174
+ let previousError = '';
1175
+ let generated;
1176
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1177
+ const prompt = buildPromoCopyPrompt(appContext, previousError);
1178
+ try {
1179
+ generated = provider === 'openai'
1180
+ ? await generateCopyWithOpenAi(prompt, framePaths, options.aiModel)
1181
+ : generateCopyWithCodex(prompt, framePaths, options.aiModel);
1182
+ generated.copy = validatePromoCopy(generated.copy);
1183
+ break;
1184
+ } catch (error) {
1185
+ previousError = error.message;
1186
+ if (attempt === 3 || /unavailable|not configured|not signed in/i.test(previousError)) throw error;
1187
+ console.warn(`AI copy attempt ${attempt} was invalid; requesting a corrected result.`);
1188
+ }
1189
+ }
1190
+
1191
+ const cache = {
1192
+ schemaVersion: promoCopySchemaVersion,
1193
+ fingerprint,
1194
+ appName,
1195
+ appUniqueId,
1196
+ provider: generated.provider,
1197
+ model: generated.model,
1198
+ generatedAt: new Date().toISOString(),
1199
+ copy: generated.copy,
1200
+ };
1201
+ fs.mkdirSync(path.dirname(promoCopyCachePath), { recursive: true });
1202
+ fs.writeFileSync(promoCopyCachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf8');
1203
+ return { ...generated, fromCache: false };
1204
+ }
1205
+
1206
+ function createSpatialMetadataSource(promoCopy) {
1207
+ const suffix = ' | Spatial 3D XR Preview';
1208
+ const maximumBaseLength = 100 - suffix.length;
1209
+ let titleBase = promoCopy.youtube.title.slice(0, maximumBaseLength).trim();
1210
+ if (titleBase.length < promoCopy.youtube.title.length && titleBase.includes(' ')) {
1211
+ titleBase = titleBase.slice(0, titleBase.lastIndexOf(' ')).trim();
1212
+ }
1213
+ const spatialIntroduction = [
1214
+ `Experience ${appName} as a stereoscopic 3D floating-screen preview for Android XR.`,
1215
+ 'The mobile app interface remains a 2D experience, presented with depth for headset viewing.',
1216
+ ].join(' ');
1217
+ return {
1218
+ description: `${spatialIntroduction}\n\n${promoCopy.youtube.description}`,
1219
+ inputPath: spatialXrVideoPath,
1220
+ outputPath: spatialXrVideoPath,
1221
+ platform: 'Google-Play-XR-Spatial',
1222
+ recommendedVisibility: 'Unlisted',
1223
+ spatialStereo: true,
1224
+ title: `${titleBase}${suffix}`,
1225
+ };
1226
+ }
1227
+
1228
+ function writeYoutubeUploadMetadata(promoCopy, sourceVideos) {
1229
+ return sourceVideos.map((source) => {
1230
+ const metadataDirectory = path.dirname(source.outputPath);
1231
+ const relativeInputPath = path.relative(sourceVideoDirectory, source.inputPath);
1232
+ const platform = source.platform || (relativeInputPath.startsWith('..')
1233
+ ? path.basename(metadataDirectory)
1234
+ : relativeInputPath.split(path.sep)[0]);
1235
+ const metadataJsonPath = path.join(
1236
+ metadataDirectory,
1237
+ `${appFilePrefix}-youtube-upload-metadata.json`,
1238
+ );
1239
+ const metadataTextPath = path.join(
1240
+ metadataDirectory,
1241
+ `${appFilePrefix}-youtube-upload-metadata.txt`,
1242
+ );
1243
+ const metadata = {
1244
+ appName,
1245
+ appUniqueId,
1246
+ packageId,
1247
+ playStoreDownloadUrl,
1248
+ appStoreDownloadUrl,
1249
+ platform,
1250
+ videoFile: path.basename(source.outputPath),
1251
+ thumbnailFile: path.basename(getThumbnailPath(source.outputPath)),
1252
+ recommendedVisibility: source.recommendedVisibility || '',
1253
+ title: source.title || promoCopy.youtube.title,
1254
+ description: addPlayStoreDownloadLink(
1255
+ source.description || promoCopy.youtube.description,
1256
+ ),
1257
+ };
1258
+ const visibilityText = metadata.recommendedVisibility
1259
+ ? `\n\nRECOMMENDED VISIBILITY\n${metadata.recommendedVisibility}`
1260
+ : '';
1261
+ const textContent = `TITLE\n${metadata.title}\n\nDESCRIPTION\n${metadata.description}${visibilityText}\n`;
1262
+ fs.mkdirSync(metadataDirectory, { recursive: true });
1263
+ fs.writeFileSync(metadataJsonPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
1264
+ fs.writeFileSync(metadataTextPath, textContent, 'utf8');
1265
+ return { metadataJsonPath, metadataTextPath, platform };
1266
+ });
1267
+ }
1268
+
1269
+ function getThumbnailPath(videoPath) {
1270
+ return path.join(
1271
+ path.dirname(videoPath),
1272
+ `${appFilePrefix}-youtube-thumbnail-${thumbnailWidth}x${thumbnailHeight}.jpg`,
1273
+ );
1274
+ }
1275
+
1276
+ function createVideoThumbnail(ffmpegCommand, ffprobeCommand, videoPath, spatialStereo = false) {
1277
+ const thumbnailPath = getThumbnailPath(videoPath);
1278
+ fs.mkdirSync(path.dirname(thumbnailPath), { recursive: true });
1279
+ let thumbnailSize = Infinity;
1280
+
1281
+ for (const quality of [2, 4, 6, 8, 10]) {
1282
+ const stereoCrop = spatialStereo ? 'crop=iw/2:ih:0:0,' : '';
1283
+ run(ffmpegCommand, [
1284
+ '-hide_banner',
1285
+ '-loglevel', 'error',
1286
+ '-y',
1287
+ '-ss', String(thumbnailTime),
1288
+ '-i', videoPath,
1289
+ '-frames:v', '1',
1290
+ '-vf', `${stereoCrop}scale=${thumbnailWidth}:${thumbnailHeight}:flags=lanczos,setsar=1`,
1291
+ '-q:v', String(quality),
1292
+ thumbnailPath,
1293
+ ]);
1294
+ thumbnailSize = fs.statSync(thumbnailPath).size;
1295
+ if (thumbnailSize <= maximumThumbnailBytes) break;
1296
+ }
1297
+
1298
+ const details = getVideoDetails(ffprobeCommand, thumbnailPath);
1299
+ const imageStream = details.streams?.find((stream) => stream.codec_type === 'video') || {};
1300
+ if (Number(imageStream.width) !== thumbnailWidth
1301
+ || Number(imageStream.height) !== thumbnailHeight) {
1302
+ throw new Error(`Thumbnail resolution is not ${thumbnailWidth}x${thumbnailHeight}.`);
1303
+ }
1304
+ if (thumbnailSize > maximumThumbnailBytes) {
1305
+ throw new Error(
1306
+ `Thumbnail is ${(thumbnailSize / 1024 / 1024).toFixed(2)}MB; maximum is 2MB.`,
1307
+ );
1308
+ }
1309
+ console.log(
1310
+ `Created thumbnail: ${thumbnailPath} (${Math.round(thumbnailSize / 1024)}KB)`,
1311
+ );
1312
+ return thumbnailPath;
1313
+ }
1314
+
1315
+ function formatAssTime(seconds) {
1316
+ const centiseconds = Math.round(seconds * 100);
1317
+ const hours = Math.floor(centiseconds / 360000);
1318
+ const minutes = Math.floor((centiseconds % 360000) / 6000);
1319
+ const wholeSeconds = Math.floor((centiseconds % 6000) / 100);
1320
+ const remainder = centiseconds % 100;
1321
+ return `${hours}:${String(minutes).padStart(2, '0')}:${String(wholeSeconds).padStart(2, '0')}.${String(remainder).padStart(2, '0')}`;
1322
+ }
1323
+
1324
+ function getSceneComposition(layoutName, sceneIndex) {
1325
+ const left = {
1326
+ bodyY: 470,
1327
+ phoneX: '90',
1328
+ phoneY: '50',
1329
+ styleSuffix: 'Left',
1330
+ textX: 720,
1331
+ titleY: 270,
1332
+ kickerY: 225,
1333
+ };
1334
+ const right = {
1335
+ ...left,
1336
+ phoneX: 'W-w-90',
1337
+ textX: 110,
1338
+ };
1339
+ const centered = {
1340
+ bodyY: 925,
1341
+ phoneX: '(W-w)/2',
1342
+ phoneY: '220',
1343
+ styleSuffix: 'Center',
1344
+ textX: 960,
1345
+ titleY: 102,
1346
+ kickerY: 66,
1347
+ };
1348
+ const mixedCentered = {
1349
+ ...left,
1350
+ phoneX: '(W-w)/2',
1351
+ phoneY: '110',
1352
+ textX: 80,
1353
+ };
1354
+ const floating = {
1355
+ ...left,
1356
+ phoneX: '125+24*sin(t*0.8)',
1357
+ phoneY: '110+14*cos(t*0.65)',
1358
+ textX: 1050,
1359
+ };
1360
+ const cinematic = {
1361
+ bodyY: 925,
1362
+ phoneX: '(W-w)/2',
1363
+ phoneY: '0',
1364
+ styleSuffix: 'Center',
1365
+ textX: 960,
1366
+ titleY: 745,
1367
+ kickerY: 705,
1368
+ };
1369
+
1370
+ if (layoutName === 'dynamic') return sceneIndex % 2 === 0 ? left : right;
1371
+ if (layoutName === 'centered') return centered;
1372
+ if (layoutName === 'floating') return floating;
1373
+ if (layoutName === 'cinematic') return cinematic;
1374
+ if (layoutName === 'mixed') {
1375
+ return [left, right, mixedCentered, floating, cinematic][sceneIndex % 5];
1376
+ }
1377
+ return left;
1378
+ }
1379
+
1380
+ function getGlobalCaptionComposition(layoutName) {
1381
+ if (layoutName === 'split') {
1382
+ return {
1383
+ brandStyle: 'BrandLeft', brandX: 720, brandY: 78,
1384
+ footerStyle: 'FooterLeft', footerX: 720, footerY: 1008,
1385
+ };
1386
+ }
1387
+ if (layoutName === 'floating') {
1388
+ return {
1389
+ brandStyle: 'BrandLeft', brandX: 1050, brandY: 78,
1390
+ footerStyle: 'FooterLeft', footerX: 1050, footerY: 1008,
1391
+ };
1392
+ }
1393
+ return {
1394
+ brandStyle: 'BrandCenter', brandX: 960,
1395
+ brandY: ['centered', 'mixed'].includes(layoutName) ? 18 : 78,
1396
+ footerStyle: 'FooterCenter', footerX: 960, footerY: 1008,
1397
+ };
1398
+ }
1399
+
1400
+ function createCaptionFile(promoCopy, layoutName) {
1401
+ const captionAppName = escapeAssText(appName.toUpperCase());
1402
+ const events = sceneWindows.flatMap((window, index) => {
1403
+ const scene = promoCopy.scenes[index];
1404
+ const composition = getSceneComposition(layoutName, index);
1405
+ const start = formatAssTime(window.start);
1406
+ const end = formatAssTime(window.end);
1407
+ const fadeIn = window.end - window.start <= 3 ? 180 : 220;
1408
+ const fadeOut = index === sceneWindows.length - 1 ? 350 : fadeIn;
1409
+ const fade = `\\fad(${fadeIn},${fadeOut})`;
1410
+ return [
1411
+ `Dialogue: 0,${start},${end},Kicker${composition.styleSuffix},,0,0,0,,{\\pos(${composition.textX},${composition.kickerY})${fade}}${escapeAssText(scene.kicker.toUpperCase())}`,
1412
+ `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())}`,
1413
+ `Dialogue: 0,${start},${end},Body${composition.styleSuffix},,0,0,0,,{\\pos(${composition.textX},${composition.bodyY})${fade}}${escapeAssText(scene.body)}`,
1414
+ ];
1415
+ });
1416
+ const globalComposition = getGlobalCaptionComposition(layoutName);
1417
+ const footer = promoCopy.footer_keywords
1418
+ .map((keyword) => escapeAssText(keyword.toUpperCase()))
1419
+ .join(' / ');
1420
+ const captionContent = `[Script Info]
1421
+ ScriptType: v4.00+
1422
+ PlayResX: 1920
1423
+ PlayResY: 1080
1424
+ ScaledBorderAndShadow: yes
1425
+ WrapStyle: 2
1426
+
1427
+ [V4+ Styles]
1428
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
1429
+ 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
1430
+ 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
1431
+ 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
1432
+ 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
1433
+ 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
1434
+ 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
1435
+ 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
1436
+ 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
1437
+ 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
1438
+ 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
1439
+
1440
+ [Events]
1441
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
1442
+ Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},${globalComposition.brandStyle},,0,0,0,,{\\pos(${globalComposition.brandX},${globalComposition.brandY})\\fad(350,350)}${captionAppName}
1443
+ ${events.join('\n')}
1444
+ Dialogue: 0,0:00:00.00,${formatAssTime(expectedDuration)},${globalComposition.footerStyle},,0,0,0,,{\\pos(${globalComposition.footerX},${globalComposition.footerY})\\fad(350,350)}${footer}
1445
+ `;
1446
+
1447
+ fs.mkdirSync(temporaryDirectory, { recursive: true });
1448
+ fs.writeFileSync(captionPath, captionContent, 'utf8');
1449
+ }
1450
+
1451
+ function synthesizeNeuralSpeech(pythonCommand, voiceName, text, destinationPath) {
1452
+ run(pythonCommand, [
1453
+ '-m', 'edge_tts',
1454
+ '--voice', voiceName,
1455
+ '--rate', '+4%',
1456
+ '--pitch', '+0Hz',
1457
+ '--text', text,
1458
+ '--write-media', destinationPath,
1459
+ ]);
1460
+
1461
+ if (!fs.existsSync(destinationPath) || fs.statSync(destinationPath).size < 1000) {
1462
+ throw new Error(`Neural narration synthesis failed: ${destinationPath}`);
1463
+ }
1464
+ }
1465
+
1466
+ function createNarrationSegments(pythonCommand, voiceName, promoCopy) {
1467
+ const segmentDirectory = path.join(temporaryDirectory, 'narration-segments');
1468
+ fs.mkdirSync(segmentDirectory, { recursive: true });
1469
+
1470
+ const segmentPaths = promoCopy.scenes.map((scene, index) => (
1471
+ path.join(segmentDirectory, `segment-${String(index + 1).padStart(2, '0')}.mp3`)
1472
+ ));
1473
+
1474
+ promoCopy.scenes.forEach((scene, index) => {
1475
+ synthesizeNeuralSpeech(
1476
+ pythonCommand,
1477
+ voiceName,
1478
+ scene.narration,
1479
+ segmentPaths[index],
1480
+ );
1481
+ });
1482
+
1483
+ const endCardPath = path.join(segmentDirectory, 'end-card.mp3');
1484
+ synthesizeNeuralSpeech(
1485
+ pythonCommand,
1486
+ voiceName,
1487
+ appStoreDownloadUrl
1488
+ ? 'Now available on Google Play and the App Store. Download links are available in the description.'
1489
+ : 'Now available on Google Play. The download link is available in the description.',
1490
+ endCardPath,
1491
+ );
1492
+ segmentPaths.push(endCardPath);
1493
+
1494
+ return segmentPaths;
1495
+ }
1496
+
1497
+ function createVoicePreviews(pythonCommand) {
1498
+ const previewText = `Discover what ${appName} can do. Explore the highlights and get started today.`;
1499
+ fs.mkdirSync(voicePreviewDirectory, { recursive: true });
1500
+
1501
+ for (const [alias, voice] of Object.entries(neuralVoiceCatalog)) {
1502
+ const previewPath = path.join(voicePreviewDirectory, `${alias}-${voice.name}.mp3`);
1503
+ console.log(`Creating ${alias} preview: ${voice.label}`);
1504
+ synthesizeNeuralSpeech(pythonCommand, voice.name, previewText, previewPath);
1505
+ }
1506
+
1507
+ console.log(`Created voice previews: ${voicePreviewDirectory}`);
1508
+ }
1509
+
1510
+ function createNarrationTrack(ffmpegCommand, segmentPaths) {
1511
+ const inputArguments = segmentPaths.flatMap((segmentPath) => ['-i', segmentPath]);
1512
+ const segmentFilters = sceneWindows.map((scene, index) => {
1513
+ const delay = Math.round((scene.start + (index === 0 ? 0.25 : 0.2)) * 1000);
1514
+ return `[${index}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${delay}|${delay}[n${index}]`;
1515
+ });
1516
+ const endCardFilter = segmentPaths.length > sceneWindows.length
1517
+ ? `[${sceneWindows.length}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${endCardStart * 1000}|${endCardStart * 1000},apad=whole_dur=${expectedDuration}[endcard]`
1518
+ : '';
1519
+ const inputs = sceneWindows.map((scene, index) => `[n${index}]`).join('');
1520
+ const filterGraph = [
1521
+ ...segmentFilters,
1522
+ `${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]`,
1523
+ endCardFilter,
1524
+ endCardFilter ? '[narration][endcard]amix=inputs=2:normalize=0:duration=longest,loudnorm=I=-16:TP=-2:LRA=7[narration-final]' : '',
1525
+ ].filter(Boolean).join(';');
1526
+
1527
+ run(ffmpegCommand, [
1528
+ '-hide_banner',
1529
+ '-y',
1530
+ ...inputArguments,
1531
+ '-filter_complex', filterGraph,
1532
+ '-map', endCardFilter ? '[narration-final]' : '[narration]',
1533
+ '-t', String(expectedDuration),
1534
+ '-c:a', 'pcm_s16le',
1535
+ '-ar', '48000',
1536
+ '-ac', '2',
1537
+ narrationTrackPath,
1538
+ ]);
1539
+
1540
+ if (!fs.existsSync(narrationTrackPath) || fs.statSync(narrationTrackPath).size < 1000) {
1541
+ throw new Error('The narration track was not created.');
1542
+ }
1543
+ }
1544
+
1545
+ function getVideoDetails(ffprobeCommand, videoPath) {
1546
+ return JSON.parse(run(ffprobeCommand, [
1547
+ '-v', 'error',
1548
+ '-show_entries', 'stream=codec_name,codec_type,width,height,r_frame_rate,sample_aspect_ratio,sample_rate,channels',
1549
+ '-show_entries', 'format=duration,size,bit_rate',
1550
+ '-of', 'json',
1551
+ videoPath,
1552
+ ]));
1553
+ }
1554
+
1555
+ function validateInput(ffprobeCommand, inputPath) {
1556
+ const details = getVideoDetails(ffprobeCommand, inputPath);
1557
+ const videoStream = details.streams?.find((stream) => stream.codec_type === 'video');
1558
+ const duration = Number(details.format?.duration || 0);
1559
+
1560
+ if (!videoStream) throw new Error('The source must contain a video stream.');
1561
+ if (Number(videoStream.width) < 480 || Number(videoStream.height) < 1080) {
1562
+ throw new Error('The source resolution is too small for the promotional layout.');
1563
+ }
1564
+ if (duration < minimumSourceDuration) {
1565
+ throw new Error(`The source must be at least ${minimumSourceDuration} seconds.`);
1566
+ }
1567
+ return details;
1568
+ }
1569
+
1570
+ function ensureAudioStream(ffmpegCommand, inputPath, details) {
1571
+ const hasAudioStream = details.streams?.some((stream) => stream.codec_type === 'audio');
1572
+ if (hasAudioStream) return inputPath;
1573
+
1574
+ const sourceHash = createHash('sha1').update(inputPath).digest('hex').slice(0, 12);
1575
+ const audioFallbackPath = path.join(
1576
+ temporaryDirectory,
1577
+ `${sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)))}-${sourceHash}-with-silent-audio.mp4`,
1578
+ );
1579
+ if (!fs.existsSync(audioFallbackPath)) {
1580
+ const duration = Number(details.format?.duration || 0);
1581
+ console.log(`Adding a silent audio track to video-only source: ${inputPath}`);
1582
+ fs.mkdirSync(temporaryDirectory, { recursive: true });
1583
+ run(ffmpegCommand, [
1584
+ '-hide_banner',
1585
+ '-y',
1586
+ '-i', inputPath,
1587
+ '-f', 'lavfi',
1588
+ '-t', String(duration),
1589
+ '-i', 'anullsrc=channel_layout=stereo:sample_rate=48000',
1590
+ '-map', '0:v:0',
1591
+ '-map', '1:a:0',
1592
+ '-c:v', 'copy',
1593
+ '-c:a', 'aac',
1594
+ '-b:a', '192k',
1595
+ '-ar', '48000',
1596
+ '-t', String(duration),
1597
+ '-movflags', '+faststart',
1598
+ audioFallbackPath,
1599
+ ]);
1600
+ }
1601
+ return audioFallbackPath;
1602
+ }
1603
+
1604
+ function validateOutput(ffprobeCommand, outputPath, targetWidth, targetHeight) {
1605
+ const details = getVideoDetails(ffprobeCommand, outputPath);
1606
+ const videoStream = details.streams?.find((stream) => stream.codec_type === 'video') || {};
1607
+ const audioStream = details.streams?.find((stream) => stream.codec_type === 'audio') || {};
1608
+ const duration = Number(details.format?.duration || 0);
1609
+
1610
+ if (videoStream.codec_name !== 'h264') throw new Error('Output video is not H.264.');
1611
+ if (Number(videoStream.width) !== targetWidth || Number(videoStream.height) !== targetHeight) {
1612
+ throw new Error(`Output resolution is not ${targetWidth}x${targetHeight}.`);
1613
+ }
1614
+ if (videoStream.sample_aspect_ratio !== '1:1') {
1615
+ throw new Error(`Output sample aspect ratio is ${videoStream.sample_aspect_ratio}; expected 1:1.`);
1616
+ }
1617
+ if (audioStream.codec_name !== 'aac') throw new Error('Output audio is not AAC.');
1618
+ if (Math.abs(duration - expectedDuration) > 0.2) {
1619
+ throw new Error(`Output duration is ${duration.toFixed(2)}s; expected ${expectedDuration}s.`);
1620
+ }
1621
+
1622
+ console.log(
1623
+ `Validated: ${targetWidth}x${targetHeight}, H.264/AAC, ${duration.toFixed(2)}s, `
1624
+ + `${Math.round(Number(details.format?.bit_rate || 0) / 1000)} kbps.`,
1625
+ );
1626
+ }
1627
+
1628
+ function getPromoLayoutSettings(layoutName) {
1629
+ const settings = {
1630
+ cinematic: { backgroundBrightness: '-0.28', backgroundSaturation: '1.05', phoneHeight: 1080 },
1631
+ centered: { backgroundBrightness: '-0.42', backgroundSaturation: '1.18', phoneHeight: 650 },
1632
+ dynamic: { backgroundBrightness: '-0.44', backgroundSaturation: '1.35', phoneHeight: 920 },
1633
+ floating: { backgroundBrightness: '-0.38', backgroundSaturation: '1.25', phoneHeight: 860 },
1634
+ mixed: { backgroundBrightness: '-0.42', backgroundSaturation: '1.30', phoneHeight: 860 },
1635
+ split: { backgroundBrightness: '-0.40', backgroundSaturation: '1.30', phoneHeight: 980 },
1636
+ };
1637
+ return settings[layoutName] || settings.split;
1638
+ }
1639
+
1640
+ function buildScenePositionExpression(layoutName, propertyName) {
1641
+ const values = sceneWindows.map((_, index) => getSceneComposition(layoutName, index)[propertyName]);
1642
+ if (values.every((value) => value === values[0])) return values[0];
1643
+
1644
+ let expression = values.at(-1);
1645
+ for (let index = values.length - 2; index >= 0; index -= 1) {
1646
+ const window = sceneWindows[index];
1647
+ expression = `if(between(t\\,${window.start}\\,${window.end})\\,${values[index]}\\,${expression})`;
1648
+ }
1649
+ return expression;
1650
+ }
1651
+
1652
+ function buildPromoStageFilter(layoutName) {
1653
+ const accent = promoDesign.accent;
1654
+ if (layoutName === 'dynamic') {
1655
+ 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]`;
1656
+ }
1657
+ if (layoutName === 'centered') {
1658
+ 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]`;
1659
+ }
1660
+ if (layoutName === 'floating') {
1661
+ 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]`;
1662
+ }
1663
+ if (layoutName === 'cinematic') {
1664
+ return '[background]null[stage]';
1665
+ }
1666
+ if (layoutName === 'mixed') {
1667
+ 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]`;
1668
+ }
1669
+ 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]`;
1670
+ }
1671
+
1672
+ function buildPromoForegroundFilter(layoutName) {
1673
+ if (layoutName !== 'cinematic') return '[layout]null[layoutfinished]';
1674
+ 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]`;
1675
+ }
1676
+
1677
+ function getFinalSceneIconPosition(layoutName) {
1678
+ const composition = getSceneComposition(layoutName, sceneWindows.length - 1);
1679
+ if (composition.styleSuffix === 'Center') return { x: 80, y: 850 };
1680
+ return { x: composition.textX, y: 650 };
1681
+ }
1682
+
1683
+ function generatePromo(
1684
+ ffmpegCommand,
1685
+ inputPath,
1686
+ outputPath,
1687
+ crop,
1688
+ targetWidth,
1689
+ targetHeight,
1690
+ layoutName,
1691
+ ) {
1692
+ const subtitleFilterPath = escapeFilterPath(captionPath);
1693
+ const layoutSettings = getPromoLayoutSettings(layoutName);
1694
+ const phoneX = buildScenePositionExpression(layoutName, 'phoneX');
1695
+ const phoneY = buildScenePositionExpression(layoutName, 'phoneY');
1696
+ const iconPosition = getFinalSceneIconPosition(layoutName);
1697
+ const is4K = targetWidth >= 3840 || targetHeight >= 2160;
1698
+ const videoBitrate = is4K ? '24M' : '9M';
1699
+ const maximumBitrate = is4K ? '32M' : '12M';
1700
+ const bufferSize = is4K ? '48M' : '18M';
1701
+ const h264Level = is4K ? '5.1' : '4.2';
1702
+ const segmentCount = editSegments.length;
1703
+ const videoSourceLabels = editSegments.map((_, index) => `[videosource${index}]`).join('');
1704
+ const audioSourceLabels = editSegments.map((_, index) => `[audiosource${index}]`).join('');
1705
+ const videoSourceFilter = segmentCount === 1
1706
+ ? `[0:v]${cropFilter(crop)}format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[videosource0]`
1707
+ : `[0:v]${cropFilter(crop)}format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,split=${segmentCount}${videoSourceLabels}`;
1708
+ const audioSourceFilter = segmentCount === 1
1709
+ ? '[0:a]aresample=48000:async=1000:first_pts=0[audiosource0]'
1710
+ : `[0:a]aresample=48000:async=1000:first_pts=0,asplit=${segmentCount}${audioSourceLabels}`;
1711
+ const videoTrimFilters = editSegments.map((segment, index) => (
1712
+ `[videosource${index}]trim=start=${segment.start}:end=${segment.end},setpts=PTS-STARTPTS[v${index}]`
1713
+ ));
1714
+ const audioTrimFilters = editSegments.map((segment, index) => (
1715
+ `[audiosource${index}]atrim=start=${segment.start}:end=${segment.end},asetpts=PTS-STARTPTS[a${index}]`
1716
+ ));
1717
+ const videoSequenceFilter = segmentCount === 1
1718
+ ? `[v0]fps=30,setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=${endCardDuration},split=2[bgsrc][phonesrc]`
1719
+ : `${editSegments.map((_, index) => `[v${index}]`).join('')}concat=n=${segmentCount}:v=1:a=0,fps=30,setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=${endCardDuration},split=2[bgsrc][phonesrc]`;
1720
+ const audioSequenceFilter = segmentCount === 1
1721
+ ? '[a0]loudnorm=I=-20:TP=-2:LRA=11[appaudio-pre]'
1722
+ : `${editSegments.map((_, index) => `[a${index}]`).join('')}concat=n=${segmentCount}:v=0:a=1,loudnorm=I=-20:TP=-2:LRA=11[appaudio-pre]`;
1723
+ const finalSceneStart = sceneWindows.at(-1).start;
1724
+ const audioFadeOutStart = Math.max(0, expectedDuration - 0.65);
1725
+ const filterGraph = [
1726
+ videoSourceFilter,
1727
+ ...videoTrimFilters,
1728
+ videoSequenceFilter,
1729
+ `[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]`,
1730
+ buildPromoStageFilter(layoutName),
1731
+ `[phonesrc]scale=-2:${layoutSettings.phoneHeight},setsar=1[phone]`,
1732
+ `[stage][phone]overlay=x='${phoneX}':y='${phoneY}':eval=frame:shortest=1[layout]`,
1733
+ buildPromoForegroundFilter(layoutName),
1734
+ '[1:v]scale=145:145,format=rgba,colorchannelmixer=aa=0.96[icon]',
1735
+ `[layoutfinished][icon]overlay=x=${iconPosition.x}:y=${iconPosition.y}:enable=between(t\\,${finalSceneStart}\\,${expectedDuration}):shortest=1[branded]`,
1736
+ `[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]`,
1737
+ `[3:v]scale=1920:1080,format=yuv420p[cardbanner]`,
1738
+ `[captioned][cardbanner]overlay=eof_action=repeat:enable=between(t\\,${endCardStart}\\,${expectedDuration})[cardonly]`,
1739
+ `[cardonly]scale=${targetWidth}:${targetHeight}:flags=lanczos,setsar=1,format=yuv420p[video]`,
1740
+ audioSourceFilter,
1741
+ ...audioTrimFilters,
1742
+ audioSequenceFilter,
1743
+ `[appaudio-pre]afade=t=in:st=0:d=0.12,afade=t=out:st=${audioFadeOutStart}:d=0.65[appaudio]`,
1744
+ '[2:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,asplit=2[voicekey][voice]',
1745
+ '[appaudio][voicekey]sidechaincompress=threshold=0.015:ratio=10:attack=15:release=350:makeup=1[ducked]',
1746
+ '[ducked][voice]amix=inputs=2:weights=0.72 1.12:normalize=0,alimiter=limit=0.84:level=false[audio]',
1747
+ ].join(';');
1748
+
1749
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1750
+
1751
+ run(ffmpegCommand, [
1752
+ '-hide_banner',
1753
+ '-y',
1754
+ '-i', inputPath,
1755
+ '-loop', '1',
1756
+ '-framerate', '30',
1757
+ '-i', iconPath,
1758
+ '-i', narrationTrackPath,
1759
+ '-loop', '1',
1760
+ '-i', endCardBannerPath,
1761
+ '-filter_complex', filterGraph,
1762
+ '-map', '[video]',
1763
+ '-map', '[audio]',
1764
+ '-t', String(expectedDuration),
1765
+ '-c:v', 'libx264',
1766
+ '-preset', 'medium',
1767
+ '-profile:v', 'high',
1768
+ '-level', h264Level,
1769
+ '-pix_fmt', 'yuv420p',
1770
+ '-r', '30',
1771
+ '-b:v', videoBitrate,
1772
+ '-maxrate', maximumBitrate,
1773
+ '-bufsize', bufferSize,
1774
+ '-c:a', 'aac',
1775
+ '-b:a', '192k',
1776
+ '-ar', '48000',
1777
+ '-movflags', '+faststart',
1778
+ outputPath,
1779
+ ], { inherit: true });
1780
+ }
1781
+
1782
+ function generateSpatialXrPromo(ffmpegCommand, inputPath, outputPath) {
1783
+ const foregroundWidth = 1760;
1784
+ const foregroundHeight = 990;
1785
+ const leftForegroundX = 96;
1786
+ const rightForegroundX = 64;
1787
+ const foregroundY = 45;
1788
+ const filterGraph = [
1789
+ `[0:v]scale=${spatialEyeWidth}:${spatialEyeHeight}:flags=lanczos,setsar=1,split=3[base][leftsource][rightsource]`,
1790
+ '[base]gblur=sigma=48,eq=brightness=-0.34:saturation=0.82,split=2[leftbg][rightbg]',
1791
+ `[leftsource]scale=${foregroundWidth}:${foregroundHeight}:flags=lanczos[leftforeground]`,
1792
+ `[rightsource]scale=${foregroundWidth}:${foregroundHeight}:flags=lanczos[rightforeground]`,
1793
+ `[leftbg]drawbox=x=${leftForegroundX - 7}:y=${foregroundY - 7}:w=${foregroundWidth + 14}:h=${foregroundHeight + 14}:color=0x23d5c3@0.55:t=4[leftstage]`,
1794
+ `[rightbg]drawbox=x=${rightForegroundX - 7}:y=${foregroundY - 7}:w=${foregroundWidth + 14}:h=${foregroundHeight + 14}:color=0x23d5c3@0.55:t=4[rightstage]`,
1795
+ `[leftstage][leftforeground]overlay=x=${leftForegroundX}:y=${foregroundY}:shortest=1[left]`,
1796
+ `[rightstage][rightforeground]overlay=x=${rightForegroundX}:y=${foregroundY}:shortest=1[right]`,
1797
+ `[left][right]hstack=inputs=2,setsar=1,format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[video]`,
1798
+ ].join(';');
1799
+
1800
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1801
+ run(ffmpegCommand, [
1802
+ '-hide_banner',
1803
+ '-y',
1804
+ '-i', inputPath,
1805
+ '-filter_complex', filterGraph,
1806
+ '-map', '[video]',
1807
+ '-map', '0:a:0',
1808
+ '-t', String(expectedDuration),
1809
+ '-c:v', 'libx264',
1810
+ '-preset', 'medium',
1811
+ '-profile:v', 'high',
1812
+ '-level', '5.1',
1813
+ '-pix_fmt', 'yuv420p',
1814
+ '-r', '30',
1815
+ '-b:v', '18M',
1816
+ '-maxrate', '24M',
1817
+ '-bufsize', '36M',
1818
+ '-x264opts', 'frame-packing=3',
1819
+ '-metadata:s:v:0', 'stereo_mode=left_right',
1820
+ '-c:a', 'aac',
1821
+ '-b:a', '192k',
1822
+ '-ar', '48000',
1823
+ '-movflags', '+faststart',
1824
+ outputPath,
1825
+ ], { inherit: true });
1826
+ }
1827
+
1828
+ function validateSpatialXrOutput(ffmpegCommand, ffprobeCommand, outputPath) {
1829
+ validateOutput(
1830
+ ffprobeCommand,
1831
+ outputPath,
1832
+ spatialOutputWidth,
1833
+ spatialOutputHeight,
1834
+ );
1835
+ const result = spawnSync(ffmpegCommand, [
1836
+ '-hide_banner',
1837
+ '-loglevel', 'trace',
1838
+ '-i', outputPath,
1839
+ '-map', '0:v:0',
1840
+ '-c:v', 'copy',
1841
+ '-bsf:v', 'trace_headers',
1842
+ '-frames:v', '1',
1843
+ '-f', 'null',
1844
+ process.platform === 'win32' ? 'NUL' : '/dev/null',
1845
+ ], {
1846
+ encoding: 'utf8',
1847
+ windowsHide: true,
1848
+ maxBuffer: 10 * 1024 * 1024,
1849
+ });
1850
+ const trace = `${result.stdout || ''}\n${result.stderr || ''}`;
1851
+ if (result.error || result.status !== 0
1852
+ || !/frame_packing_arrangement_type[^\n]*=\s*3/i.test(trace)) {
1853
+ throw new Error('Spatial XR output is missing side-by-side 3D frame-packing metadata.');
1854
+ }
1855
+ console.log('Validated spatial metadata: stereoscopic side-by-side left-right (type 3).');
1856
+ }
1857
+
1858
+ function createSpatialXrAssets(ffmpegCommand, ffprobeCommand, inputPath) {
1859
+ console.log(`Generating stereoscopic Spatial XR promo: ${spatialXrVideoPath}`);
1860
+ generateSpatialXrPromo(ffmpegCommand, inputPath, spatialXrVideoPath);
1861
+ validateSpatialXrOutput(ffmpegCommand, ffprobeCommand, spatialXrVideoPath);
1862
+ createVideoThumbnail(ffmpegCommand, ffprobeCommand, spatialXrVideoPath, true);
1863
+ console.log(`Created Spatial XR promo video: ${spatialXrVideoPath}`);
1864
+ }
1865
+
1866
+ function getTargetDimensions(videoDetails) {
1867
+ const videoStream = videoDetails.streams?.find((stream) => stream.codec_type === 'video') || {};
1868
+ const sourceWidth = Number(videoStream.width || 0);
1869
+ const sourceHeight = Number(videoStream.height || 0);
1870
+ return sourceWidth > sourceHeight
1871
+ ? { width: sourceWidth, height: sourceHeight }
1872
+ : { width: 1920, height: 1080 };
1873
+ }
1874
+
1875
+ function getLayoutOutputSuffix(options, layoutName) {
1876
+ if (!options.layoutExplicit) return '';
1877
+ return options.layout === 'random'
1878
+ ? `-layout-random-${layoutName}`
1879
+ : `-layout-${layoutName}`;
1880
+ }
1881
+
1882
+ function getBatchOutputPath(inputPath, targetWidth, targetHeight, layoutSuffix = '') {
1883
+ const relativeDirectory = path.relative(sourceVideoDirectory, path.dirname(inputPath));
1884
+ const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
1885
+ return path.join(
1886
+ outputVideoDirectory,
1887
+ relativeDirectory,
1888
+ `${appFilePrefix}-${sourceName}-promo-english-voiceover${layoutSuffix}-${targetWidth}x${targetHeight}.mp4`,
1889
+ );
1890
+ }
1891
+
1892
+ function getSingleOutputPath(inputPath, targetWidth, targetHeight, layoutSuffix = '') {
1893
+ const sourceName = sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)));
1894
+ return path.join(
1895
+ outputVideoDirectory,
1896
+ `${appFilePrefix}-${sourceName}-promo-english-voiceover${layoutSuffix}-${targetWidth}x${targetHeight}.mp4`,
1897
+ );
1898
+ }
1899
+
1900
+ async function main() {
1901
+ const options = parseArguments();
1902
+ await ensureAppStoreDownloadUrl();
1903
+ if (options.listVoices) {
1904
+ printVoiceCatalog();
1905
+ return;
1906
+ }
1907
+
1908
+ if (options.previewVoices) {
1909
+ const previewPythonCommand = getPythonExecutable();
1910
+ if (!previewPythonCommand) {
1911
+ throw new Error(
1912
+ 'edge-tts is required for natural neural voices. Install it with: py -m pip install edge-tts',
1913
+ );
1914
+ }
1915
+ createVoicePreviews(previewPythonCommand);
1916
+ return;
1917
+ }
1918
+
1919
+ const layoutName = resolvePromoLayout(options.layout);
1920
+ const layoutSuffix = getLayoutOutputSuffix(options, layoutName);
1921
+ console.log(
1922
+ `Promo layout: ${layoutName}${options.layout === 'random' ? ' (randomly selected)' : ''}.`,
1923
+ );
1924
+
1925
+ const ffmpegCommand = getExecutable('ffmpeg');
1926
+ const ffprobeCommand = getExecutable('ffprobe');
1927
+ const voice = resolveVoice(options.voice);
1928
+
1929
+ if (!ffmpegCommand || !ffprobeCommand) {
1930
+ throw new Error('FFmpeg and FFprobe were not found in PATH or FFMPEG_BIN.');
1931
+ }
1932
+ if (!fs.existsSync(iconPath)) throw new Error(`App icon was not found: ${iconPath}`);
1933
+ await ensureEndCardBanner();
1934
+
1935
+ const inputPaths = options.input
1936
+ ? [path.resolve(options.input)]
1937
+ : findVideos(sourceVideoDirectory).filter((inputPath) => !isExcludedBatchSource(inputPath));
1938
+ if (inputPaths.length === 0) {
1939
+ throw new Error(`No MP4 videos were found under: ${sourceVideoDirectory}`);
1940
+ }
1941
+ for (const inputPath of inputPaths) {
1942
+ if (!fs.existsSync(inputPath)) throw new Error(`Source video was not found: ${inputPath}`);
1943
+ }
1944
+
1945
+ console.log(`Preparing ${inputPaths.length} source video${inputPaths.length === 1 ? '' : 's'}...`);
1946
+ const sourceVideos = inputPaths.map((inputPath) => {
1947
+ const details = validateInput(ffprobeCommand, inputPath);
1948
+ const processingInputPath = ensureAudioStream(ffmpegCommand, inputPath, details);
1949
+ const duration = Number(details.format?.duration || 0);
1950
+ const crop = detectActiveVideoCrop(ffmpegCommand, inputPath, details);
1951
+ const target = getTargetDimensions(details);
1952
+ const outputPath = options.output
1953
+ ? path.resolve(options.output)
1954
+ : options.input
1955
+ ? getSingleOutputPath(inputPath, target.width, target.height, layoutSuffix)
1956
+ : getBatchOutputPath(inputPath, target.width, target.height, layoutSuffix);
1957
+ return { crop, duration, inputPath, outputPath, processingInputPath, target };
1958
+ });
1959
+ const analysisSource = [...sourceVideos].sort((left, right) => (
1960
+ (right.target.width * right.target.height) - (left.target.width * left.target.height)
1961
+ ))[0];
1962
+ const sourceDuration = Math.min(...sourceVideos.map((source) => source.duration));
1963
+ const redundantStaticRanges = detectRedundantStaticRanges(
1964
+ ffmpegCommand,
1965
+ analysisSource.inputPath,
1966
+ sourceDuration,
1967
+ );
1968
+ const visualChangeTimes = detectVisualChangeTimes(ffmpegCommand, analysisSource.inputPath);
1969
+ configurePromoTimeline(sourceDuration, redundantStaticRanges, visualChangeTimes);
1970
+ const promoCopyResult = await generateOrLoadPromoCopy(
1971
+ options,
1972
+ ffmpegCommand,
1973
+ analysisSource.inputPath,
1974
+ inputPaths,
1975
+ analysisSource.crop,
1976
+ );
1977
+ console.log(
1978
+ `${promoCopyResult.fromCache ? 'Using cached' : 'Generated'} AI promo copy `
1979
+ + `(${promoCopyResult.provider}, ${promoCopyResult.model}).`,
1980
+ );
1981
+ console.log(`AI promo copy: ${promoCopyCachePath}`);
1982
+ const nonSpatialXrSource = sourceVideos.find((source) => (
1983
+ /(^|[\\/])google-play-xr-non-spatial([\\/]|$)/i.test(source.inputPath)
1984
+ ));
1985
+ const spatialMetadataSource = nonSpatialXrSource
1986
+ ? createSpatialMetadataSource(promoCopyResult.copy)
1987
+ : null;
1988
+ const youtubeMetadataFiles = writeYoutubeUploadMetadata(
1989
+ promoCopyResult.copy,
1990
+ spatialMetadataSource ? [...sourceVideos, spatialMetadataSource] : sourceVideos,
1991
+ );
1992
+ for (const metadataFile of youtubeMetadataFiles) {
1993
+ console.log(
1994
+ `${metadataFile.platform} YouTube metadata: ${metadataFile.metadataTextPath}`,
1995
+ );
1996
+ }
1997
+ if (options.copyOnly) return;
1998
+ if (options.spatialXrOnly) {
1999
+ if (!nonSpatialXrSource || !spatialMetadataSource) {
2000
+ throw new Error('A Google-Play-XR-Non-Spatial source video is required.');
2001
+ }
2002
+ if (!fs.existsSync(nonSpatialXrSource.outputPath)) {
2003
+ throw new Error(
2004
+ `Generate the non-spatial XR promo first: ${nonSpatialXrSource.outputPath}`,
2005
+ );
2006
+ }
2007
+ createSpatialXrAssets(
2008
+ ffmpegCommand,
2009
+ ffprobeCommand,
2010
+ nonSpatialXrSource.outputPath,
2011
+ );
2012
+ return;
2013
+ }
2014
+ if (options.thumbnailsOnly) {
2015
+ const thumbnailSources = spatialMetadataSource
2016
+ ? [...sourceVideos, spatialMetadataSource]
2017
+ : sourceVideos;
2018
+ for (const source of thumbnailSources) {
2019
+ if (!fs.existsSync(source.outputPath)) {
2020
+ throw new Error(`Generated promo video was not found: ${source.outputPath}`);
2021
+ }
2022
+ createVideoThumbnail(
2023
+ ffmpegCommand,
2024
+ ffprobeCommand,
2025
+ source.outputPath,
2026
+ Boolean(source.spatialStereo),
2027
+ );
2028
+ }
2029
+ console.log(`Completed ${thumbnailSources.length} promotional thumbnails.`);
2030
+ return;
2031
+ }
2032
+
2033
+ const pythonCommand = getPythonExecutable();
2034
+ if (!pythonCommand) {
2035
+ throw new Error(
2036
+ 'edge-tts is required for natural neural voices. Install it with: py -m pip install edge-tts',
2037
+ );
2038
+ }
2039
+ createCaptionFile(promoCopyResult.copy, layoutName);
2040
+ console.log(`Creating natural neural narration with: ${voice.label}`);
2041
+ const narrationSegmentPaths = createNarrationSegments(
2042
+ pythonCommand,
2043
+ voice.name,
2044
+ promoCopyResult.copy,
2045
+ );
2046
+ createNarrationTrack(ffmpegCommand, narrationSegmentPaths);
2047
+ for (const [index, source] of sourceVideos.entries()) {
2048
+ console.log(
2049
+ `Generating promo ${index + 1}/${sourceVideos.length}: ${source.inputPath} `
2050
+ + `-> ${source.target.width}x${source.target.height}`,
2051
+ );
2052
+ generatePromo(
2053
+ ffmpegCommand,
2054
+ source.processingInputPath,
2055
+ source.outputPath,
2056
+ source.crop,
2057
+ source.target.width,
2058
+ source.target.height,
2059
+ layoutName,
2060
+ );
2061
+ validateOutput(
2062
+ ffprobeCommand,
2063
+ source.outputPath,
2064
+ source.target.width,
2065
+ source.target.height,
2066
+ );
2067
+ console.log(`Created promo video: ${source.outputPath}`);
2068
+ createVideoThumbnail(ffmpegCommand, ffprobeCommand, source.outputPath);
2069
+ }
2070
+ if (nonSpatialXrSource && spatialMetadataSource) {
2071
+ createSpatialXrAssets(
2072
+ ffmpegCommand,
2073
+ ffprobeCommand,
2074
+ nonSpatialXrSource.outputPath,
2075
+ );
2076
+ }
2077
+ console.log(
2078
+ `Completed ${sourceVideos.length + (spatialMetadataSource ? 1 : 0)} promotional videos.`,
2079
+ );
2080
+ }
2081
+
2082
+ main().catch((error) => {
2083
+ console.error(`Promo generation failed: ${error.message}`);
2084
+ process.exitCode = 1;
2085
+ });