codeplay-common 4.4.0 → 4.4.1

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,27 +1,27 @@
1
- {
2
- "plugins":[
3
- {"name":"backbutton","pattern":"backbutton-(\\d+\\.\\d+)\\.js$","minVersion":"2.1","baseDir":"js","mandatoryUpdate":true},
1
+ {
2
+ "plugins":[
3
+ {"name":"backbutton","pattern":"backbutton-(\\d+\\.\\d+)\\.js$","minVersion":"2.1","baseDir":"js","mandatoryUpdate":true},
4
4
  {"name":"common-js","pattern":"common-(\\d+\\.\\d+)(?:-beta-(\\d+))?\\.js$","minVersion":"6.7","baseDir":"js","mandatoryUpdate":true},
5
-
5
+
6
6
  {"name":"admob-emi-nextgen","pattern":"Ads[\\\\/]admob-emi-nextgen-(\\d+\\.\\d+)\\.js$","minVersion":"1.0","baseDir":"js","mandatoryUpdate":true},
7
- {"name":"video-player","pattern":"video-player-(\\d+\\.\\d+)\\.js$","minVersion":"1.6","baseDir":"js","mandatoryUpdate":true},
8
- {"name":"image-cropper","pattern":"image-cropper-(\\d+\\.\\d+)\\.js$","minVersion":"1.1","baseDir":"js","mandatoryUpdate":true},
9
- {"name":"beautify","pattern":"beautify-(\\d+\\.\\d+)\\.js$","minVersion":"1.1","baseDir":"js","mandatoryUpdate":true},
10
-
11
-
12
- {"name":"localization","pattern":"localization-(\\d+\\.\\d+)$","minVersion":"1.8","baseDir":"js/localization","mandatoryUpdate":true,"isFolder":true},
13
- {"name": "localization_settings","pattern": "localization_settings-(\\d+\\.\\d+)\\.js$","minVersion": "1.1","required": true,"baseDir": "js/localization","mandatoryUpdate": true},
14
-
15
-
16
- {"name":"editor","pattern":"editor-(\\d+\\.\\d+)$","minVersion":"2.0","baseDir":"js/editor","mandatoryUpdate":true,"isFolder":true},
17
- {"name":"ffmpeg","pattern":"ffmpeg-(\\d+\\.\\d+)$","minVersion":"1.6","baseDir":"js","mandatoryUpdate":true,"isFolder":true},
18
- {"name":"theme","pattern":"theme-(\\d+\\.\\d+)$","minVersion":"3.5","baseDir":"theme","destDir":"theme","mandatoryUpdate":true,"isFolder":true},
19
- {"name":"certificatejs","pattern":"certificatejs-(\\d+\\.\\d+)$","minVersion":"1.9","baseDir":"certificate","destDir":"certificate","mandatoryUpdate":true,"isFolder":true},
7
+ {"name":"video-player","pattern":"video-player-(\\d+\\.\\d+)\\.js$","minVersion":"1.6","baseDir":"js","mandatoryUpdate":true},
8
+ {"name":"image-cropper","pattern":"image-cropper-(\\d+\\.\\d+)\\.js$","minVersion":"1.1","baseDir":"js","mandatoryUpdate":true},
9
+ {"name":"beautify","pattern":"beautify-(\\d+\\.\\d+)\\.js$","minVersion":"1.1","baseDir":"js","mandatoryUpdate":true},
10
+
11
+
12
+ {"name":"localization","pattern":"localization-(\\d+\\.\\d+)$","minVersion":"1.8","baseDir":"js/localization","mandatoryUpdate":true,"isFolder":true},
13
+ {"name": "localization_settings","pattern": "localization_settings-(\\d+\\.\\d+)\\.js$","minVersion": "1.1","required": true,"baseDir": "js/localization","mandatoryUpdate": true},
14
+
15
+
16
+ {"name":"editor","pattern":"editor-(\\d+\\.\\d+)$","minVersion":"2.0","baseDir":"js/editor","mandatoryUpdate":true,"isFolder":true},
17
+ {"name":"ffmpeg","pattern":"ffmpeg-(\\d+\\.\\d+)$","minVersion":"1.6","baseDir":"js","mandatoryUpdate":true,"isFolder":true},
18
+ {"name":"theme","pattern":"theme-(\\d+\\.\\d+)$","minVersion":"3.5","baseDir":"theme","destDir":"theme","mandatoryUpdate":true,"isFolder":true},
19
+ {"name":"certificatejs","pattern":"certificatejs-(\\d+\\.\\d+)$","minVersion":"1.9","baseDir":"certificate","destDir":"certificate","mandatoryUpdate":true,"isFolder":true},
20
20
  {"name":"IAP","pattern":"IAP-(\\d+\\.\\d+)$","minVersion":"3.6","baseDir":"js/Ads","mandatoryUpdate":true,"isFolder":true},
21
- {"name":"common-less","pattern":"common-(\\d+\\.\\d+)\\.less$","minVersion":"1.8","baseDir":"assets/css","mandatoryUpdate":true},
22
- {"name":"localNotification_AppSettings","pattern":"localNotification_AppSettings-(\\d+\\.\\d+)\\.js$","minVersion":"1.0","baseDir":"js","mandatoryUpdate":true},
23
- {"name":"localNotification","pattern":"localNotification-(\\d+\\.\\d+)\\.js$","minVersion":"2.2","baseDir":"js","mandatoryUpdate":true},
24
- {"name":"onesignal","pattern":"onesignal-(\\d+\\.\\d+)\\.js$","minVersion":"2.4","baseDir":"js","mandatoryUpdate":true},
25
- {"name":"saveToGalleryAndSaveAnyFile","pattern":"saveToGalleryAndSaveAnyFile-(\\d+\\.\\d+)(-ios)?\\.js$","minVersion":"3.1","baseDir":"js","mandatoryUpdate":true}
26
- ]
21
+ {"name":"common-less","pattern":"common-(\\d+\\.\\d+)\\.less$","minVersion":"1.8","baseDir":"assets/css","mandatoryUpdate":true},
22
+ {"name":"localNotification_AppSettings","pattern":"localNotification_AppSettings-(\\d+\\.\\d+)\\.js$","minVersion":"1.0","baseDir":"js","mandatoryUpdate":true},
23
+ {"name":"localNotification","pattern":"localNotification-(\\d+\\.\\d+)\\.js$","minVersion":"2.2","baseDir":"js","mandatoryUpdate":true},
24
+ {"name":"onesignal","pattern":"onesignal-(\\d+\\.\\d+)\\.js$","minVersion":"2.4","baseDir":"js","mandatoryUpdate":true},
25
+ {"name":"saveToGalleryAndSaveAnyFile","pattern":"saveToGalleryAndSaveAnyFile-(\\d+\\.\\d+)(-ios)?\\.js$","minVersion":"3.1","baseDir":"js","mandatoryUpdate":true}
26
+ ]
27
27
  }
@@ -275,14 +275,14 @@ const checkIsAdsDisableByReturnStatement=()=>{
275
275
 
276
276
 
277
277
  const adsFolder = join('src', 'js', 'Ads');
278
- const filePattern = /^admob-emi-nextgen-(\d+\.)+\d+\.js$/;
278
+ const filePattern = /^admob-emi-nextgen-(\d+\.)+\d+\.js$/;
279
279
 
280
280
  // Step 1: Find the admob file
281
281
  const files = readdirSync(adsFolder);
282
282
  const admobFile = files.find(f => filePattern.test(f));
283
283
 
284
284
  if (!admobFile) {
285
- console.log('❌ No Admob nextgen file found.');
285
+ console.log('❌ No Admob nextgen file found.');
286
286
  process.exit(1);
287
287
  }
288
288
 
@@ -527,30 +527,30 @@ if (existsSync(aabOutputDir)) {
527
527
 
528
528
 
529
529
  // Extract version code and version name from build.gradle
530
- const gradleFilePath = join("android", "app", "build.gradle");
531
- const gradleContent = readFileSync(gradleFilePath, 'utf8');
532
-
533
- // Return the character range of a Gradle block, allowing nested blocks inside it.
534
- function findGradleBlock(content, headerPattern) {
535
- const headerMatch = content.match(headerPattern);
536
- if (!headerMatch) return null;
537
- const openingBrace = content.indexOf('{', headerMatch.index + headerMatch[0].length);
538
- if (openingBrace === -1) return null;
539
- let depth = 0;
540
- let quote = null;
541
- for (let index = openingBrace; index < content.length; index += 1) {
542
- const character = content[index];
543
- const previous = content[index - 1];
544
- if (quote) {
545
- if (character === quote && previous !== '\\') quote = null;
546
- continue;
547
- }
548
- if (character === '\"' || character === "'") quote = character;
549
- else if (character === '{') depth += 1;
550
- else if (character === '}' && --depth === 0) return { start: openingBrace + 1, end: index };
551
- }
552
- return null;
553
- }
530
+ const gradleFilePath = join("android", "app", "build.gradle");
531
+ const gradleContent = readFileSync(gradleFilePath, 'utf8');
532
+
533
+ // Return the character range of a Gradle block, allowing nested blocks inside it.
534
+ function findGradleBlock(content, headerPattern) {
535
+ const headerMatch = content.match(headerPattern);
536
+ if (!headerMatch) return null;
537
+ const openingBrace = content.indexOf('{', headerMatch.index + headerMatch[0].length);
538
+ if (openingBrace === -1) return null;
539
+ let depth = 0;
540
+ let quote = null;
541
+ for (let index = openingBrace; index < content.length; index += 1) {
542
+ const character = content[index];
543
+ const previous = content[index - 1];
544
+ if (quote) {
545
+ if (character === quote && previous !== '\\') quote = null;
546
+ continue;
547
+ }
548
+ if (character === '\"' || character === "'") quote = character;
549
+ else if (character === '{') depth += 1;
550
+ else if (character === '}' && --depth === 0) return { start: openingBrace + 1, end: index };
551
+ }
552
+ return null;
553
+ }
554
554
 
555
555
  const versionCodeMatch = gradleContent.match(/versionCode\s+(\d+)/);
556
556
  const versionNameMatch = gradleContent.match(/versionName\s+"([^"]+)"/);
@@ -617,26 +617,26 @@ rl.question('Enter new versionCode (press enter to keep current): ', (newVersion
617
617
  `$1\n minifyEnabled ${desiredMinify}`
618
618
  );
619
619
  console.log(`✅ Inserted minifyEnabled ${desiredMinify} into release block.`);
620
- } else {
621
- console.log('⚠️ Warning: buildTypes > release block not found. minifyEnabled was not added.');
622
- }
623
-
624
- // Add native debug symbols for release AABs, but leave any existing setting untouched.
625
- const releaseBlock = findGradleBlock(updatedGradleContent, /buildTypes\s*\{[\s\S]*?\brelease\s*/);
626
- if (releaseBlock) {
627
- const releaseContent = updatedGradleContent.slice(releaseBlock.start, releaseBlock.end);
628
- if (/\bdebugSymbolLevel\b/.test(releaseContent)) {
629
- console.log('ℹ️ debugSymbolLevel already exists in buildTypes.release; leaving it unchanged.');
630
- } else {
631
- const ndkBlock = `\n ndk {\n debugSymbolLevel 'FULL'\n }`;
632
- updatedGradleContent = updatedGradleContent.slice(0, releaseBlock.end)
633
- + ndkBlock
634
- + updatedGradleContent.slice(releaseBlock.end);
635
- console.log("✅ Added debugSymbolLevel 'FULL' to buildTypes.release.");
636
- }
637
- } else {
638
- console.log('⚠️ Warning: buildTypes.release block not found. debugSymbolLevel was not added.');
639
- }
620
+ } else {
621
+ console.log('⚠️ Warning: buildTypes > release block not found. minifyEnabled was not added.');
622
+ }
623
+
624
+ // Add native debug symbols for release AABs, but leave any existing setting untouched.
625
+ const releaseBlock = findGradleBlock(updatedGradleContent, /buildTypes\s*\{[\s\S]*?\brelease\s*/);
626
+ if (releaseBlock) {
627
+ const releaseContent = updatedGradleContent.slice(releaseBlock.start, releaseBlock.end);
628
+ if (/\bdebugSymbolLevel\b/.test(releaseContent)) {
629
+ console.log('ℹ️ debugSymbolLevel already exists in buildTypes.release; leaving it unchanged.');
630
+ } else {
631
+ const ndkBlock = `\n ndk {\n debugSymbolLevel 'FULL'\n }`;
632
+ updatedGradleContent = updatedGradleContent.slice(0, releaseBlock.end)
633
+ + ndkBlock
634
+ + updatedGradleContent.slice(releaseBlock.end);
635
+ console.log("✅ Added debugSymbolLevel 'FULL' to buildTypes.release.");
636
+ }
637
+ } else {
638
+ console.log('⚠️ Warning: buildTypes.release block not found. debugSymbolLevel was not added.');
639
+ }
640
640
 
641
641
 
642
642
 
@@ -753,11 +753,11 @@ rl.question('Enter new versionCode (press enter to keep current): ', (newVersion
753
753
 
754
754
 
755
755
  // Copy the built files to the appropriate folder
756
- const src = join("www");
757
- const dest = join("android", "app", "src", "main", "assets", "public");
758
-
759
- // Copy the web build with Node so releases work on every platform.
760
- fs.cpSync(src, dest, { recursive: true, force: true });
756
+ const src = join("www");
757
+ const dest = join("android", "app", "src", "main", "assets", "public");
758
+
759
+ // Copy the web build with Node so releases work on every platform.
760
+ fs.cpSync(src, dest, { recursive: true, force: true });
761
761
 
762
762
  // Build Android AAB file
763
763
  //child_process.execSync('cd android && ./gradlew bundleRelease', { stdio: 'inherit' });
@@ -13,6 +13,15 @@ const capacitorConfig = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'))
13
13
  const appName = String(capacitorConfig.appName || '').trim();
14
14
  const appUniqueId = String(capacitorConfig.android?.APP_UNIQUE_ID ?? '').trim();
15
15
  const packageId = String(capacitorConfig.appId || '').trim();
16
+ const iosStoreConfigPath = path.join(projectDirectory, 'Auto-AI-Video', 'ios-store-config.json');
17
+ let iosStoreConfig = {};
18
+ if (fs.existsSync(iosStoreConfigPath)) {
19
+ try {
20
+ iosStoreConfig = JSON.parse(fs.readFileSync(iosStoreConfigPath, 'utf8'));
21
+ } catch {
22
+ iosStoreConfig = {};
23
+ }
24
+ }
16
25
 
17
26
  if (!appName) throw new Error('capacitor.config.json is missing appName.');
18
27
  if (!appUniqueId) throw new Error('capacitor.config.json is missing android.APP_UNIQUE_ID.');
@@ -20,6 +29,21 @@ if (!packageId) throw new Error('capacitor.config.json is missing appId.');
20
29
 
21
30
  const appFilePrefix = `${appUniqueId}. ${sanitizeFileNamePart(appName)}`;
22
31
  const playStoreDownloadUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageId)}`;
32
+ const configuredAppStoreUrl = String(iosStoreConfig.appStoreUrl || '')
33
+ .match(/https:\/\/apps\.apple\.com\/app\/id\d+/)?.[0] || '';
34
+ let appStoreDownloadUrl = /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(configuredAppStoreUrl)
35
+ ? configuredAppStoreUrl
36
+ : '';
37
+ const promoDesignIndex = createHash('sha1').update(`${appUniqueId}:${appName}`).digest()[0] % 4;
38
+ const promoDesigns = [
39
+ { accent: '0x23d5c3', background: '0x07101f', panelX: 105, panelWidth: 500, phoneX: 134 },
40
+ { accent: '0xffb020', background: '0x241006', panelX: 1290, panelWidth: 500, phoneX: 64 },
41
+ { accent: '0x9b7cff', background: '0x160d2b', panelX: 105, panelWidth: 620, phoneX: 134 },
42
+ { accent: '0xff5b8d', background: '0x21101a', panelX: 1240, panelWidth: 550, phoneX: 64 },
43
+ ];
44
+ const promoDesign = promoDesigns[promoDesignIndex];
45
+ let endCardStart = 0;
46
+ const endCardDuration = 8;
23
47
  const sourceVideoDirectory = path.join(projectDirectory, 'Auto-Screenshot', 'Video', 'Output');
24
48
  const outputVideoDirectory = path.join(projectDirectory, 'Auto-AI-Video');
25
49
  const spatialXrDirectory = path.join(outputVideoDirectory, 'Google-Play-XR-Spatial');
@@ -38,6 +62,7 @@ const promoCopyCachePath = path.join(
38
62
  `${appFilePrefix}-promo-copy.json`,
39
63
  );
40
64
  const analysisFrameDirectory = path.join(temporaryDirectory, 'ai-analysis');
65
+ const endCardBannerPath = path.join(outputVideoDirectory, `${appFilePrefix}-ai-end-card-banner.png`);
41
66
  const promoCopySchemaPath = path.join(temporaryDirectory, 'promo-copy-schema.json');
42
67
  const promoCopyResultPath = path.join(temporaryDirectory, 'promo-copy-result.json');
43
68
  const ffmpegFallbackDirectory = 'W:\\Tools\\ffmpeg\\bin';
@@ -145,6 +170,40 @@ function sanitizeFileNamePart(value) {
145
170
  .replace(/[. ]+$/g, '') || 'App';
146
171
  }
147
172
 
173
+ async function askIosStoreDetails() {
174
+ if (iosStoreConfig.configured === true
175
+ && (!iosStoreConfig.available || /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(appStoreDownloadUrl))) {
176
+ return;
177
+ }
178
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
179
+ throw new Error('iOS availability must be answered in an interactive terminal.');
180
+ }
181
+ const readline = require('node:readline/promises');
182
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
183
+ const appStoreId = (await prompt.question('Enter the numeric Apple App Store ID, or press Enter if unavailable: ')).trim();
184
+ prompt.close();
185
+ const available = Boolean(appStoreId);
186
+ if (available) {
187
+ if (!/^\d+$/.test(appStoreId)) throw new Error('Apple App Store ID must contain only numbers.');
188
+ appStoreDownloadUrl = `https://apps.apple.com/app/id${appStoreId}`;
189
+ }
190
+ fs.mkdirSync(path.dirname(iosStoreConfigPath), { recursive: true });
191
+ fs.writeFileSync(iosStoreConfigPath, `${JSON.stringify({
192
+ configured: true,
193
+ available: available === 'yes',
194
+ appStoreId: available === 'yes' ? appStoreDownloadUrl.split('/').pop().replace(/^id/, '') : '',
195
+ appStoreUrl: appStoreDownloadUrl,
196
+ }, null, 2)}\n`, 'utf8');
197
+ }
198
+
199
+ async function ensureEndCardBanner() {
200
+ if (fs.existsSync(endCardBannerPath)) return;
201
+ throw new Error(
202
+ `AI end-card banner is missing. Generate it manually and save it here:\n${endCardBannerPath}\n\n`
203
+ + 'Then run the video-generation command again.',
204
+ );
205
+ }
206
+
148
207
  function findVideos(directory) {
149
208
  if (!fs.existsSync(directory)) return [];
150
209
  return fs.readdirSync(directory, { withFileTypes: true })
@@ -223,6 +282,8 @@ function configurePromoTimeline(sourceDuration) {
223
282
  maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
224
283
  };
225
284
  });
285
+ endCardStart = expectedDuration;
286
+ expectedDuration = roundTimelineValue(expectedDuration + endCardDuration);
226
287
 
227
288
  console.log(
228
289
  `Adaptive promo timeline: ${expectedDuration}s from ${sourceDuration.toFixed(2)}s source; `
@@ -394,6 +455,10 @@ function escapeFilterPath(filePath) {
394
455
  .replace(/'/g, "\\'");
395
456
  }
396
457
 
458
+ function escapeFilterText(value) {
459
+ return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\\:');
460
+ }
461
+
397
462
  function readTextIfPresent(filePath, maximumLength = 12000) {
398
463
  if (!fs.existsSync(filePath)) return '';
399
464
  return fs.readFileSync(filePath, 'utf8').slice(0, maximumLength);
@@ -624,7 +689,10 @@ function addPlayStoreDownloadLink(description) {
624
689
  const hashtags = finalLine.match(/#[\p{L}\p{N}_]+/gu) || [];
625
690
  const hasFinalHashtagLine = hashtags.length === 3
626
691
  && !finalLine.replace(/#[\p{L}\p{N}_]+/gu, '').trim();
627
- const downloadBlock = `Download ${appName} on Google Play:\n${playStoreDownloadUrl}`;
692
+ const iosBlock = appStoreDownloadUrl
693
+ ? `\n\nDownload ${appName} on the App Store:\n${appStoreDownloadUrl}`
694
+ : '';
695
+ const downloadBlock = `Download ${appName} on Google Play:\n${playStoreDownloadUrl}${iosBlock}`;
628
696
 
629
697
  if (!hasFinalHashtagLine) {
630
698
  return `${cleanedDescription}\n\n${downloadBlock}`;
@@ -912,6 +980,7 @@ function writeYoutubeUploadMetadata(promoCopy, sourceVideos) {
912
980
  appUniqueId,
913
981
  packageId,
914
982
  playStoreDownloadUrl,
983
+ appStoreDownloadUrl,
915
984
  platform,
916
985
  videoFile: path.basename(source.outputPath),
917
986
  thumbnailFile: path.basename(getThumbnailPath(source.outputPath)),
@@ -1064,6 +1133,17 @@ function createNarrationSegments(pythonCommand, voiceName, promoCopy) {
1064
1133
  );
1065
1134
  });
1066
1135
 
1136
+ const endCardPath = path.join(segmentDirectory, 'end-card.mp3');
1137
+ synthesizeNeuralSpeech(
1138
+ pythonCommand,
1139
+ voiceName,
1140
+ appStoreDownloadUrl
1141
+ ? 'Now available on Google Play and the App Store. Download links are available in the description.'
1142
+ : 'Now available on Google Play. The download link is available in the description.',
1143
+ endCardPath,
1144
+ );
1145
+ segmentPaths.push(endCardPath);
1146
+
1067
1147
  return segmentPaths;
1068
1148
  }
1069
1149
 
@@ -1086,18 +1166,23 @@ function createNarrationTrack(ffmpegCommand, segmentPaths) {
1086
1166
  const delay = Math.round((scene.start + (index === 0 ? 0.25 : 0.2)) * 1000);
1087
1167
  return `[${index}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${delay}|${delay}[n${index}]`;
1088
1168
  });
1169
+ const endCardFilter = segmentPaths.length > sceneWindows.length
1170
+ ? `[${sceneWindows.length}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${endCardStart * 1000}|${endCardStart * 1000},apad=whole_dur=${expectedDuration}[endcard]`
1171
+ : '';
1089
1172
  const inputs = sceneWindows.map((scene, index) => `[n${index}]`).join('');
1090
1173
  const filterGraph = [
1091
1174
  ...segmentFilters,
1092
1175
  `${inputs}amix=inputs=${sceneWindows.length}:normalize=0:duration=longest,highpass=f=80,lowpass=f=12000,loudnorm=I=-16:TP=-2:LRA=7,apad=whole_dur=${expectedDuration}[narration]`,
1093
- ].join(';');
1176
+ endCardFilter,
1177
+ endCardFilter ? '[narration][endcard]amix=inputs=2:normalize=0:duration=longest,loudnorm=I=-16:TP=-2:LRA=7[narration-final]' : '',
1178
+ ].filter(Boolean).join(';');
1094
1179
 
1095
1180
  run(ffmpegCommand, [
1096
1181
  '-hide_banner',
1097
1182
  '-y',
1098
1183
  ...inputArguments,
1099
1184
  '-filter_complex', filterGraph,
1100
- '-map', '[narration]',
1185
+ '-map', endCardFilter ? '[narration-final]' : '[narration]',
1101
1186
  '-t', String(expectedDuration),
1102
1187
  '-c:a', 'pcm_s16le',
1103
1188
  '-ar', '48000',
@@ -1123,12 +1208,9 @@ function getVideoDetails(ffprobeCommand, videoPath) {
1123
1208
  function validateInput(ffprobeCommand, inputPath) {
1124
1209
  const details = getVideoDetails(ffprobeCommand, inputPath);
1125
1210
  const videoStream = details.streams?.find((stream) => stream.codec_type === 'video');
1126
- const audioStream = details.streams?.find((stream) => stream.codec_type === 'audio');
1127
1211
  const duration = Number(details.format?.duration || 0);
1128
1212
 
1129
- if (!videoStream || !audioStream) {
1130
- throw new Error('The source must contain both video and audio streams.');
1131
- }
1213
+ if (!videoStream) throw new Error('The source must contain a video stream.');
1132
1214
  if (Number(videoStream.width) < 480 || Number(videoStream.height) < 1080) {
1133
1215
  throw new Error('The source resolution is too small for the promotional layout.');
1134
1216
  }
@@ -1138,6 +1220,40 @@ function validateInput(ffprobeCommand, inputPath) {
1138
1220
  return details;
1139
1221
  }
1140
1222
 
1223
+ function ensureAudioStream(ffmpegCommand, inputPath, details) {
1224
+ const hasAudioStream = details.streams?.some((stream) => stream.codec_type === 'audio');
1225
+ if (hasAudioStream) return inputPath;
1226
+
1227
+ const sourceHash = createHash('sha1').update(inputPath).digest('hex').slice(0, 12);
1228
+ const audioFallbackPath = path.join(
1229
+ temporaryDirectory,
1230
+ `${sanitizeFileNamePart(path.basename(inputPath, path.extname(inputPath)))}-${sourceHash}-with-silent-audio.mp4`,
1231
+ );
1232
+ if (!fs.existsSync(audioFallbackPath)) {
1233
+ const duration = Number(details.format?.duration || 0);
1234
+ console.log(`Adding a silent audio track to video-only source: ${inputPath}`);
1235
+ fs.mkdirSync(temporaryDirectory, { recursive: true });
1236
+ run(ffmpegCommand, [
1237
+ '-hide_banner',
1238
+ '-y',
1239
+ '-i', inputPath,
1240
+ '-f', 'lavfi',
1241
+ '-t', String(duration),
1242
+ '-i', 'anullsrc=channel_layout=stereo:sample_rate=48000',
1243
+ '-map', '0:v:0',
1244
+ '-map', '1:a:0',
1245
+ '-c:v', 'copy',
1246
+ '-c:a', 'aac',
1247
+ '-b:a', '192k',
1248
+ '-ar', '48000',
1249
+ '-t', String(duration),
1250
+ '-movflags', '+faststart',
1251
+ audioFallbackPath,
1252
+ ]);
1253
+ }
1254
+ return audioFallbackPath;
1255
+ }
1256
+
1141
1257
  function validateOutput(ffprobeCommand, outputPath, targetWidth, targetHeight) {
1142
1258
  const details = getVideoDetails(ffprobeCommand, outputPath);
1143
1259
  const videoStream = details.streams?.find((stream) => stream.codec_type === 'video') || {};
@@ -1185,8 +1301,8 @@ function generatePromo(ffmpegCommand, inputPath, outputPath, crop, targetWidth,
1185
1301
  `[audiosource${index}]atrim=start=${segment.start}:end=${segment.end},asetpts=PTS-STARTPTS[a${index}]`
1186
1302
  ));
1187
1303
  const videoSequenceFilter = segmentCount === 1
1188
- ? '[v0]fps=30,setpts=PTS-STARTPTS,split=2[bgsrc][phonesrc]'
1189
- : `${editSegments.map((_, index) => `[v${index}]`).join('')}concat=n=${segmentCount}:v=1:a=0,fps=30,setpts=PTS-STARTPTS,split=2[bgsrc][phonesrc]`;
1304
+ ? `[v0]fps=30,setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=${endCardDuration},split=2[bgsrc][phonesrc]`
1305
+ : `${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]`;
1190
1306
  const audioSequenceFilter = segmentCount === 1
1191
1307
  ? '[a0]loudnorm=I=-20:TP=-2:LRA=11[appaudio-pre]'
1192
1308
  : `${editSegments.map((_, index) => `[a${index}]`).join('')}concat=n=${segmentCount}:v=0:a=1,loudnorm=I=-20:TP=-2:LRA=11[appaudio-pre]`;
@@ -1196,13 +1312,16 @@ function generatePromo(ffmpegCommand, inputPath, outputPath, crop, targetWidth,
1196
1312
  videoSourceFilter,
1197
1313
  ...videoTrimFilters,
1198
1314
  videoSequenceFilter,
1199
- '[bgsrc]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,gblur=sigma=34,eq=brightness=-0.40:saturation=1.30,drawbox=x=0:y=0:w=iw:h=ih:color=0x07101f@0.72:t=fill[background]',
1200
- '[background]drawbox=x=105:y=30:w=500:h=1020:color=black@0.58:t=fill,drawbox=x=112:y=37:w=486:h=1006:color=0x23d5c3@0.24:t=3,drawbox=x=680:y=176:w=1050:h=2:color=0x23d5c3@0.55:t=fill,drawbox=x=680:y=960:w=1050:h=2:color=white@0.16:t=fill,drawbox=x=680:y=176:w=10:h=786:color=0xffb020@0.90:t=fill[stage]',
1315
+ `[bgsrc]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,gblur=sigma=34,eq=brightness=-0.40:saturation=1.30,drawbox=x=0:y=0:w=iw:h=ih:color=${promoDesign.background}@0.72:t=fill[background]`,
1316
+ `[background]drawbox=x=${promoDesign.panelX}:y=30:w=${promoDesign.panelWidth}:h=1020:color=black@0.58:t=fill,drawbox=x=${promoDesign.panelX + 7}:y=37:w=${promoDesign.panelWidth - 14}:h=1006:color=${promoDesign.accent}@0.24:t=3,drawbox=x=680:y=176:w=1050:h=2:color=${promoDesign.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=0xffb020@0.90:t=fill[stage]`,
1201
1317
  '[phonesrc]scale=-2:980,setsar=1[phone]',
1202
- '[stage][phone]overlay=x=134:y=50:shortest=1[layout]',
1318
+ `[stage][phone]overlay=x=${promoDesign.phoneX}:y=50:shortest=1[layout]`,
1203
1319
  '[1:v]scale=145:145,format=rgba,colorchannelmixer=aa=0.96[icon]',
1204
1320
  `[layout][icon]overlay=x=720:y=650:enable=between(t\\,${finalSceneStart}\\,${expectedDuration}):shortest=1[branded]`,
1205
- `[branded]ass=filename='${subtitleFilterPath}',scale=${targetWidth}:${targetHeight}:flags=lanczos,setsar=1,format=yuv420p,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[video]`,
1321
+ `[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]`,
1322
+ `[3:v]scale=1920:1080,format=yuv420p[cardbanner]`,
1323
+ `[captioned][cardbanner]overlay=eof_action=repeat:enable=between(t\\,${endCardStart}\\,${expectedDuration})[cardonly]`,
1324
+ `[cardonly]scale=${targetWidth}:${targetHeight}:flags=lanczos,setsar=1,format=yuv420p[video]`,
1206
1325
  audioSourceFilter,
1207
1326
  ...audioTrimFilters,
1208
1327
  audioSequenceFilter,
@@ -1222,6 +1341,8 @@ function generatePromo(ffmpegCommand, inputPath, outputPath, crop, targetWidth,
1222
1341
  '-framerate', '30',
1223
1342
  '-i', iconPath,
1224
1343
  '-i', narrationTrackPath,
1344
+ '-loop', '1',
1345
+ '-i', endCardBannerPath,
1225
1346
  '-filter_complex', filterGraph,
1226
1347
  '-map', '[video]',
1227
1348
  '-map', '[audio]',
@@ -1372,6 +1493,9 @@ async function main() {
1372
1493
  return;
1373
1494
  }
1374
1495
 
1496
+ await askIosStoreDetails();
1497
+ await ensureEndCardBanner();
1498
+
1375
1499
  const ffmpegCommand = getExecutable('ffmpeg');
1376
1500
  const ffprobeCommand = getExecutable('ffprobe');
1377
1501
  const voice = resolveVoice(options.voice);
@@ -1394,6 +1518,7 @@ async function main() {
1394
1518
  console.log(`Preparing ${inputPaths.length} source video${inputPaths.length === 1 ? '' : 's'}...`);
1395
1519
  const sourceVideos = inputPaths.map((inputPath) => {
1396
1520
  const details = validateInput(ffprobeCommand, inputPath);
1521
+ const processingInputPath = ensureAudioStream(ffmpegCommand, inputPath, details);
1397
1522
  const duration = Number(details.format?.duration || 0);
1398
1523
  const crop = detectActiveVideoCrop(ffmpegCommand, inputPath, details);
1399
1524
  const target = getTargetDimensions(details);
@@ -1402,7 +1527,7 @@ async function main() {
1402
1527
  : options.input
1403
1528
  ? getSingleOutputPath(inputPath, target.width, target.height)
1404
1529
  : getBatchOutputPath(inputPath, target.width, target.height);
1405
- return { crop, duration, inputPath, outputPath, target };
1530
+ return { crop, duration, inputPath, outputPath, processingInputPath, target };
1406
1531
  });
1407
1532
  configurePromoTimeline(Math.min(...sourceVideos.map((source) => source.duration)));
1408
1533
  const analysisSource = [...sourceVideos].sort((left, right) => (
@@ -1492,7 +1617,7 @@ async function main() {
1492
1617
  );
1493
1618
  generatePromo(
1494
1619
  ffmpegCommand,
1495
- source.inputPath,
1620
+ source.processingInputPath,
1496
1621
  source.outputPath,
1497
1622
  source.crop,
1498
1623
  source.target.width,