codeplay-common 4.4.5 → 4.4.7

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.
@@ -31,7 +31,7 @@ const appFilePrefix = `${appUniqueId}. ${sanitizeFileNamePart(appName)}`;
31
31
  const playStoreDownloadUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageId)}`;
32
32
  const configuredAppStoreUrl = String(iosStoreConfig.appStoreUrl || '')
33
33
  .match(/https:\/\/apps\.apple\.com\/app\/id\d+/)?.[0] || '';
34
- let appStoreDownloadUrl = /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(configuredAppStoreUrl)
34
+ const appStoreDownloadUrl = /^https:\/\/apps\.apple\.com\/app\/id\d+$/.test(configuredAppStoreUrl)
35
35
  ? configuredAppStoreUrl
36
36
  : '';
37
37
  const promoDesignIndex = createHash('sha1').update(`${appUniqueId}:${appName}`).digest()[0] % 4;
@@ -51,7 +51,7 @@ const spatialXrVideoPath = path.join(
51
51
  spatialXrDirectory,
52
52
  `${appFilePrefix}-spatial-xr-3d-sbs-lr-3840x1080.mp4`,
53
53
  );
54
- const iconPath = path.join(projectDirectory, 'src', 'assets', 'icons', 'icon-512.webp');
54
+ const iconPath = path.join(projectDirectory, 'resources', 'icon-only.png');
55
55
  const temporaryDirectory = path.join(projectDirectory, 'agent-temp', 'ai-promo-video');
56
56
  const captionPath = path.join(temporaryDirectory, `${appUniqueId}-promo-english.ass`);
57
57
  const narrationTrackPath = path.join(temporaryDirectory, `${appUniqueId}-promo-narration.wav`);
@@ -65,14 +65,13 @@ const analysisFrameDirectory = path.join(temporaryDirectory, 'ai-analysis');
65
65
  const endCardBannerPath = path.join(outputVideoDirectory, `${appFilePrefix}-ai-end-card-banner.png`);
66
66
  const promoCopySchemaPath = path.join(temporaryDirectory, 'promo-copy-schema.json');
67
67
  const promoCopyResultPath = path.join(temporaryDirectory, 'promo-copy-result.json');
68
- const ffmpegFallbackDirectory = 'W:\\Tools\\ffmpeg\\bin';
69
- const sceneCount = 7;
70
- const minimumSourceDuration = 12;
71
- const minimumPreferredPromoDuration = 24;
72
- const maximumPromoDuration = 40;
73
- let expectedDuration = 34;
74
- let editSegments = [];
75
- let sceneWindows = [];
68
+ const ffmpegFallbackDirectory = String(process.env.FFMPEG_BIN || '').trim();
69
+ const minimumSceneCount = 7;
70
+ const minimumSourceDuration = 12;
71
+ let expectedDuration = 34;
72
+ let editSegments = [];
73
+ let sceneWindows = [];
74
+ let sceneCount = minimumSceneCount;
76
75
  const thumbnailTime = 2;
77
76
  const thumbnailWidth = 3840;
78
77
  const thumbnailHeight = 2160;
@@ -81,7 +80,7 @@ const spatialEyeWidth = 1920;
81
80
  const spatialEyeHeight = 1080;
82
81
  const spatialOutputWidth = spatialEyeWidth * 2;
83
82
  const spatialOutputHeight = spatialEyeHeight;
84
- const promoCopySchemaVersion = 3;
83
+ const promoCopySchemaVersion = 5;
85
84
  const promoCopySchema = {
86
85
  type: 'object',
87
86
  additionalProperties: false,
@@ -170,32 +169,6 @@ function sanitizeFileNamePart(value) {
170
169
  .replace(/[. ]+$/g, '') || 'App';
171
170
  }
172
171
 
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
172
  async function ensureEndCardBanner() {
200
173
  if (fs.existsSync(endCardBannerPath)) return;
201
174
  throw new Error(
@@ -205,7 +178,7 @@ async function ensureEndCardBanner() {
205
178
  + '- Final image size must be exactly 1672 × 941 pixels.\n'
206
179
  + '- Save it as:\n'
207
180
  + ` ${endCardBannerPath}\n`
208
- + '- Inspect the current project and use the application’s actual highest-resolution app icon.\n'
181
+ + `- Use this exact app icon file: ${iconPath}\n`
209
182
  + '- Do not generate, redraw, reinterpret, or replace the app icon.\n'
210
183
  + '- Use the complete official black “Download on the App Store” badge.\n'
211
184
  + '- Use the complete official black “Get it on Google Play” badge.\n'
@@ -253,7 +226,7 @@ function roundTimelineValue(value) {
253
226
  return Math.round(value * 100) / 100;
254
227
  }
255
228
 
256
- function mapOutputTimeToSource(outputTime) {
229
+ function mapOutputTimeToSource(outputTime) {
257
230
  let remainingTime = outputTime;
258
231
  for (const segment of editSegments) {
259
232
  const segmentDuration = segment.end - segment.start;
@@ -262,62 +235,150 @@ function mapOutputTimeToSource(outputTime) {
262
235
  }
263
236
  remainingTime -= segmentDuration;
264
237
  }
265
- return editSegments.at(-1).end - 0.05;
266
- }
267
-
268
- function configurePromoTimeline(sourceDuration) {
269
- if (!Number.isFinite(sourceDuration) || sourceDuration < minimumSourceDuration) {
238
+ return editSegments.at(-1).end - 0.05;
239
+ }
240
+
241
+ function mapSourceTimeToOutput(sourceTime) {
242
+ let outputTime = 0;
243
+ for (const segment of editSegments) {
244
+ if (sourceTime < segment.start) return outputTime;
245
+ if (sourceTime <= segment.end) return outputTime + (sourceTime - segment.start);
246
+ outputTime += segment.end - segment.start;
247
+ }
248
+ return outputTime;
249
+ }
250
+
251
+ function normalizeSkippedRanges(skippedRanges, sourceDuration) {
252
+ const ranges = skippedRanges
253
+ .map((range) => ({
254
+ start: Math.max(0, Math.min(sourceDuration, range.start)),
255
+ end: Math.max(0, Math.min(sourceDuration, range.end)),
256
+ }))
257
+ .filter((range) => range.end > range.start)
258
+ .sort((left, right) => left.start - right.start);
259
+
260
+ return ranges.reduce((merged, range) => {
261
+ const previous = merged.at(-1);
262
+ if (previous && range.start <= previous.end) {
263
+ previous.end = Math.max(previous.end, range.end);
264
+ } else {
265
+ merged.push({ ...range });
266
+ }
267
+ return merged;
268
+ }, []);
269
+ }
270
+
271
+ function buildRetainedSegments(sourceDuration, skippedRanges) {
272
+ const normalizedRanges = normalizeSkippedRanges(skippedRanges, sourceDuration);
273
+ const retainedSegments = [];
274
+ let retainedStart = 0;
275
+
276
+ for (const range of normalizedRanges) {
277
+ if (range.start > retainedStart) {
278
+ retainedSegments.push({ start: retainedStart, end: range.start });
279
+ }
280
+ retainedStart = range.end;
281
+ }
282
+ if (retainedStart < sourceDuration) {
283
+ retainedSegments.push({ start: retainedStart, end: sourceDuration });
284
+ }
285
+
286
+ return {
287
+ retainedSegments: retainedSegments.map((segment) => ({
288
+ start: roundTimelineValue(segment.start),
289
+ end: roundTimelineValue(segment.end),
290
+ })),
291
+ skippedDuration: normalizedRanges.reduce(
292
+ (total, range) => total + (range.end - range.start),
293
+ 0,
294
+ ),
295
+ };
296
+ }
297
+
298
+ function buildSceneWindows(retainedDuration, visualChangeTimes) {
299
+ const minimumWindowDuration = Math.max(2.5, Math.min(4, retainedDuration / 40));
300
+ const maximumWindowDuration = Math.max(6, Math.min(10, retainedDuration / 14));
301
+ const boundaries = [0];
302
+ const mappedChanges = visualChangeTimes
303
+ .map((sourceTime) => mapSourceTimeToOutput(sourceTime))
304
+ .filter((outputTime) => outputTime > 0 && outputTime < retainedDuration)
305
+ .sort((left, right) => left - right);
306
+
307
+ for (const changeTime of mappedChanges) {
308
+ if (changeTime - boundaries.at(-1) >= minimumWindowDuration) {
309
+ boundaries.push(changeTime);
310
+ }
311
+ }
312
+ if (retainedDuration - boundaries.at(-1) < minimumWindowDuration) boundaries.pop();
313
+ boundaries.push(retainedDuration);
314
+
315
+ const windows = [];
316
+ for (let index = 0; index < boundaries.length - 1; index += 1) {
317
+ const start = boundaries[index];
318
+ const end = boundaries[index + 1];
319
+ const splitCount = Math.max(1, Math.ceil((end - start) / maximumWindowDuration));
320
+ for (let splitIndex = 0; splitIndex < splitCount; splitIndex += 1) {
321
+ windows.push({
322
+ start: roundTimelineValue(start + (((end - start) * splitIndex) / splitCount)),
323
+ end: roundTimelineValue(start + (((end - start) * (splitIndex + 1)) / splitCount)),
324
+ });
325
+ }
326
+ }
327
+
328
+ if (windows.length >= minimumSceneCount) return windows;
329
+ return Array.from({ length: minimumSceneCount }, (_, index) => ({
330
+ start: roundTimelineValue((retainedDuration * index) / minimumSceneCount),
331
+ end: index === minimumSceneCount - 1
332
+ ? retainedDuration
333
+ : roundTimelineValue((retainedDuration * (index + 1)) / minimumSceneCount),
334
+ }));
335
+ }
336
+
337
+ function configurePromoTimeline(sourceDuration, skippedRanges = [], visualChangeTimes = []) {
338
+ if (!Number.isFinite(sourceDuration) || sourceDuration < minimumSourceDuration) {
270
339
  throw new Error(
271
340
  `The shortest source video must be at least ${minimumSourceDuration} seconds.`,
272
341
  );
273
342
  }
274
343
 
275
- const introTrim = sourceDuration >= 20 ? Math.min(2, sourceDuration * 0.04) : 0;
276
- const usableDuration = sourceDuration - introTrim;
277
- const preferredDuration = Math.max(
278
- minimumPreferredPromoDuration,
279
- Math.round(sourceDuration * 0.6),
280
- );
281
- expectedDuration = roundTimelineValue(Math.min(maximumPromoDuration, usableDuration, preferredDuration));
282
-
283
- const tailDuration = expectedDuration >= minimumPreferredPromoDuration
284
- ? roundTimelineValue(Math.min(7, expectedDuration * 0.2))
285
- : 0;
286
- const mainDuration = roundTimelineValue(expectedDuration - tailDuration);
287
- const tailStart = roundTimelineValue(sourceDuration - tailDuration);
288
- const mainEnd = roundTimelineValue(introTrim + mainDuration);
289
-
290
- editSegments = tailDuration > 0 && mainEnd + 0.5 < tailStart
291
- ? [
292
- { start: roundTimelineValue(introTrim), end: mainEnd },
293
- { start: tailStart, end: roundTimelineValue(sourceDuration) },
294
- ]
295
- : [{
296
- start: roundTimelineValue(introTrim),
297
- end: roundTimelineValue(introTrim + expectedDuration),
298
- }];
299
-
300
- sceneWindows = Array.from({ length: sceneCount }, (_, index) => {
301
- const start = roundTimelineValue((expectedDuration * index) / sceneCount);
302
- const end = index === sceneCount - 1
303
- ? expectedDuration
304
- : roundTimelineValue((expectedDuration * (index + 1)) / sceneCount);
305
- const sceneDuration = end - start;
306
- return {
307
- start,
308
- end,
309
- sourceTime: roundTimelineValue(mapOutputTimeToSource(start + (sceneDuration / 2))),
310
- maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
311
- };
312
- });
313
- endCardStart = expectedDuration;
344
+ const { retainedSegments, skippedDuration } = buildRetainedSegments(
345
+ sourceDuration,
346
+ skippedRanges,
347
+ );
348
+ editSegments = retainedSegments.filter((segment) => segment.end > segment.start);
349
+ const retainedDuration = editSegments.reduce(
350
+ (total, segment) => total + (segment.end - segment.start),
351
+ 0,
352
+ );
353
+ if (retainedDuration < minimumSourceDuration) {
354
+ throw new Error(
355
+ `At least ${minimumSourceDuration} seconds must remain after automatic cleanup.`,
356
+ );
357
+ }
358
+
359
+ expectedDuration = roundTimelineValue(retainedDuration);
360
+ sceneWindows = buildSceneWindows(expectedDuration, visualChangeTimes).map((window) => {
361
+ const sceneDuration = window.end - window.start;
362
+ return {
363
+ ...window,
364
+ sourceTime: roundTimelineValue(mapOutputTimeToSource(
365
+ window.start + (sceneDuration / 2),
366
+ )),
367
+ maxNarrationWords: Math.max(7, Math.floor(sceneDuration * 2.15)),
368
+ };
369
+ });
370
+ sceneCount = sceneWindows.length;
371
+ promoCopySchema.properties.scenes.minItems = sceneCount;
372
+ promoCopySchema.properties.scenes.maxItems = sceneCount;
373
+ endCardStart = expectedDuration;
314
374
  expectedDuration = roundTimelineValue(expectedDuration + endCardDuration);
315
375
 
316
376
  console.log(
317
- `Adaptive promo timeline: ${expectedDuration}s from ${sourceDuration.toFixed(2)}s source; `
318
- + `${editSegments.length} edit segment${editSegments.length === 1 ? '' : 's'}.`,
319
- );
320
- }
377
+ `Feature-complete promo timeline: ${expectedDuration}s from ${sourceDuration.toFixed(2)}s source; `
378
+ + `${sceneCount} scenes and ${editSegments.length} retained segment${editSegments.length === 1 ? '' : 's'}`
379
+ + `${skippedDuration > 0 ? ` (${skippedDuration.toFixed(2)}s of redundant static footage removed)` : ''}.`,
380
+ );
381
+ }
321
382
 
322
383
  function escapeAssText(value) {
323
384
  return String(value)
@@ -360,14 +421,85 @@ function commandExists(command) {
360
421
  return !result.error && result.status === 0;
361
422
  }
362
423
 
363
- function getExecutable(command) {
364
- if (commandExists(command)) return command;
365
-
366
- const fallbackPath = path.join(ffmpegFallbackDirectory, `${command}.exe`);
367
- return fs.existsSync(fallbackPath) ? fallbackPath : '';
368
- }
369
-
370
- function parseArguments() {
424
+ function getExecutable(command) {
425
+ if (commandExists(command)) return command;
426
+
427
+ if (!ffmpegFallbackDirectory) return '';
428
+ const executableName = process.platform === 'win32' ? `${command}.exe` : command;
429
+ const fallbackPath = path.join(ffmpegFallbackDirectory, executableName);
430
+ return fs.existsSync(fallbackPath) ? fallbackPath : '';
431
+ }
432
+
433
+ function detectRedundantStaticRanges(ffmpegCommand, inputPath, sourceDuration) {
434
+ const minimumStaticDuration = Math.max(2, Math.min(4, sourceDuration / 60));
435
+ const result = spawnSync(ffmpegCommand, [
436
+ '-hide_banner',
437
+ '-nostats',
438
+ '-i', inputPath,
439
+ '-vf', `freezedetect=n=-50dB:d=${minimumStaticDuration}`,
440
+ '-an',
441
+ '-f', 'null',
442
+ process.platform === 'win32' ? 'NUL' : '/dev/null',
443
+ ], {
444
+ encoding: 'utf8',
445
+ windowsHide: true,
446
+ maxBuffer: 10 * 1024 * 1024,
447
+ });
448
+ if (result.error || result.status !== 0) {
449
+ console.warn('Static-screen analysis was unavailable; retaining the complete recording.');
450
+ return [];
451
+ }
452
+
453
+ const events = String(result.stderr || '').matchAll(
454
+ /lavfi\.freezedetect\.freeze_(start|end):\s*([\d.]+)/g,
455
+ );
456
+ const staticRanges = [];
457
+ let staticStart = null;
458
+ for (const event of events) {
459
+ const time = Number(event[2]);
460
+ if (event[1] === 'start') {
461
+ staticStart = time;
462
+ } else if (staticStart !== null && time > staticStart) {
463
+ staticRanges.push({ start: staticStart, end: time });
464
+ staticStart = null;
465
+ }
466
+ }
467
+ if (staticStart !== null && sourceDuration > staticStart) {
468
+ staticRanges.push({ start: staticStart, end: sourceDuration });
469
+ }
470
+
471
+ return staticRanges
472
+ .map((range) => ({
473
+ start: range.start + minimumStaticDuration,
474
+ end: range.end,
475
+ }))
476
+ .filter((range) => range.end - range.start >= 0.75);
477
+ }
478
+
479
+ function detectVisualChangeTimes(ffmpegCommand, inputPath) {
480
+ const result = spawnSync(ffmpegCommand, [
481
+ '-hide_banner',
482
+ '-nostats',
483
+ '-i', inputPath,
484
+ '-vf', "select='gt(scene,0.03)',showinfo",
485
+ '-an',
486
+ '-f', 'null',
487
+ process.platform === 'win32' ? 'NUL' : '/dev/null',
488
+ ], {
489
+ encoding: 'utf8',
490
+ windowsHide: true,
491
+ maxBuffer: 10 * 1024 * 1024,
492
+ });
493
+ if (result.error || result.status !== 0) {
494
+ console.warn('Visual-change analysis was unavailable; using duration-based scene coverage.');
495
+ return [];
496
+ }
497
+ return [...String(result.stderr || '').matchAll(/pts_time:([\d.]+)/g)]
498
+ .map((match) => Number(match[1]))
499
+ .filter(Number.isFinite);
500
+ }
501
+
502
+ function parseArguments() {
371
503
  const args = process.argv.slice(2);
372
504
  const options = {
373
505
  aiModel: '',
@@ -428,9 +560,10 @@ function parseArguments() {
428
560
  console.log(' node generate-ai-promo-video.js --list-voices');
429
561
  console.log(' node generate-ai-promo-video.js --preview-voices');
430
562
  console.log('');
431
- console.log(
432
- `Without --input, every MP4 under ${sourceVideoDirectory} except Samsung is processed.`,
433
- );
563
+ console.log(
564
+ `Without --input, every MP4 under ${sourceVideoDirectory} except Samsung is processed.`,
565
+ );
566
+ console.log('Redundant static pauses are shortened automatically; distinct screens are retained.');
434
567
  process.exit(0);
435
568
  } else {
436
569
  throw new Error(`Unknown argument: ${argument}`);
@@ -667,9 +800,12 @@ function buildPromoCopyPrompt(appContext, previousError = '') {
667
800
 
668
801
  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.
669
802
 
670
- Rules:
671
- - Generate exactly ${sceneWindows.length} scenes in the same order as the images and timings.
672
- - Use only features supported by the images or project context. Never invent claims.
803
+ Rules:
804
+ - Generate exactly ${sceneWindows.length} scenes in the same order as the images and timings.
805
+ - Use only features supported by the images or project context. Never invent claims.
806
+ - Cover every distinct demonstrated feature across the retained recording; do not omit later features.
807
+ - Do not promote incidental navigation, loading states, advertisements, permission prompts, or repeated screens as features.
808
+ - Keep each scene specific to what is visibly demonstrated in its supplied image whenever possible.
673
809
  - The authoritative app name is ${JSON.stringify(appName)}. Mention it naturally in the final narration and final body.
674
810
  - APP_UNIQUE_ID is internal and must never appear in audience-facing copy.
675
811
  - Each kicker: 2-30 characters. Each title line: 1-22 characters. Body: 8-72 characters.
@@ -1521,17 +1657,15 @@ async function main() {
1521
1657
  return;
1522
1658
  }
1523
1659
 
1524
- await askIosStoreDetails();
1525
- await ensureEndCardBanner();
1526
-
1527
- const ffmpegCommand = getExecutable('ffmpeg');
1660
+ const ffmpegCommand = getExecutable('ffmpeg');
1528
1661
  const ffprobeCommand = getExecutable('ffprobe');
1529
1662
  const voice = resolveVoice(options.voice);
1530
1663
 
1531
- if (!ffmpegCommand || !ffprobeCommand) {
1532
- throw new Error('FFmpeg and FFprobe were not found in PATH or W:\\Tools\\ffmpeg\\bin.');
1533
- }
1534
- if (!fs.existsSync(iconPath)) throw new Error(`App icon was not found: ${iconPath}`);
1664
+ if (!ffmpegCommand || !ffprobeCommand) {
1665
+ throw new Error('FFmpeg and FFprobe were not found in PATH or FFMPEG_BIN.');
1666
+ }
1667
+ if (!fs.existsSync(iconPath)) throw new Error(`App icon was not found: ${iconPath}`);
1668
+ await ensureEndCardBanner();
1535
1669
 
1536
1670
  const inputPaths = options.input
1537
1671
  ? [path.resolve(options.input)]
@@ -1557,10 +1691,17 @@ async function main() {
1557
1691
  : getBatchOutputPath(inputPath, target.width, target.height);
1558
1692
  return { crop, duration, inputPath, outputPath, processingInputPath, target };
1559
1693
  });
1560
- configurePromoTimeline(Math.min(...sourceVideos.map((source) => source.duration)));
1561
- const analysisSource = [...sourceVideos].sort((left, right) => (
1562
- (right.target.width * right.target.height) - (left.target.width * left.target.height)
1563
- ))[0];
1694
+ const analysisSource = [...sourceVideos].sort((left, right) => (
1695
+ (right.target.width * right.target.height) - (left.target.width * left.target.height)
1696
+ ))[0];
1697
+ const sourceDuration = Math.min(...sourceVideos.map((source) => source.duration));
1698
+ const redundantStaticRanges = detectRedundantStaticRanges(
1699
+ ffmpegCommand,
1700
+ analysisSource.inputPath,
1701
+ sourceDuration,
1702
+ );
1703
+ const visualChangeTimes = detectVisualChangeTimes(ffmpegCommand, analysisSource.inputPath);
1704
+ configurePromoTimeline(sourceDuration, redundantStaticRanges, visualChangeTimes);
1564
1705
  const promoCopyResult = await generateOrLoadPromoCopy(
1565
1706
  options,
1566
1707
  ffmpegCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeplay-common",
3
- "version": "4.4.5",
3
+ "version": "4.4.7",
4
4
  "description": "Common build scripts and files",
5
5
  "scripts": {
6
6
  "postinstall": "node scripts/sync-files.js",