@playdrop/playdrop-cli 0.8.8 → 0.9.0

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.
@@ -0,0 +1,1102 @@
1
+ "use strict";
2
+ /* eslint-disable max-lines */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.MARKETING_AUDIO_POLICIES = exports.MARKETING_SURFACES = void 0;
5
+ exports.parseMarketingCaptureOptions = parseMarketingCaptureOptions;
6
+ exports.assertSupportedMarketingCapturePlatform = assertSupportedMarketingCapturePlatform;
7
+ exports.resolveMarketingOutputPaths = resolveMarketingOutputPaths;
8
+ exports.validateMarketingPreviewConfig = validateMarketingPreviewConfig;
9
+ exports.buildMarketingFfmpegArgs = buildMarketingFfmpegArgs;
10
+ exports.buildMarketingCaptureManifest = buildMarketingCaptureManifest;
11
+ exports.createMarketingReport = createMarketingReport;
12
+ exports.marketingDoctor = marketingDoctor;
13
+ exports.marketingCapture = marketingCapture;
14
+ const node_child_process_1 = require("node:child_process");
15
+ const promises_1 = require("node:fs/promises");
16
+ const node_fs_1 = require("node:fs");
17
+ const node_path_1 = require("node:path");
18
+ const node_os_1 = require("node:os");
19
+ const types_1 = require("@playdrop/types");
20
+ const catalogue_utils_1 = require("../catalogue-utils");
21
+ const catalogue_1 = require("../catalogue");
22
+ const commandContext_1 = require("../commandContext");
23
+ const http_1 = require("../http");
24
+ const appUrls_1 = require("../appUrls");
25
+ const messages_1 = require("../messages");
26
+ const playwright_1 = require("../playwright");
27
+ const devServer_1 = require("./devServer");
28
+ const devRuntimeAssets_1 = require("./devRuntimeAssets");
29
+ const dev_1 = require("./dev");
30
+ const devShared_1 = require("./devShared");
31
+ exports.MARKETING_SURFACES = ['desktop', 'mobile-landscape', 'mobile-portrait'];
32
+ exports.MARKETING_AUDIO_POLICIES = ['music-and-sfx', 'sfx-only', 'silent'];
33
+ const MOBILE_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1';
34
+ const SURFACE_PRESETS = {
35
+ desktop: {
36
+ width: 1920,
37
+ height: 1080,
38
+ sceneId: 'listing-landscape',
39
+ viewport: {
40
+ width: 1280,
41
+ height: 720,
42
+ deviceScaleFactor: 1,
43
+ isMobile: false,
44
+ hasTouch: false,
45
+ },
46
+ },
47
+ 'mobile-landscape': {
48
+ width: 1920,
49
+ height: 1080,
50
+ sceneId: 'listing-landscape',
51
+ viewport: {
52
+ width: 896,
53
+ height: 414,
54
+ deviceScaleFactor: 2,
55
+ isMobile: true,
56
+ hasTouch: true,
57
+ userAgent: MOBILE_USER_AGENT,
58
+ },
59
+ },
60
+ 'mobile-portrait': {
61
+ width: 1080,
62
+ height: 1920,
63
+ sceneId: 'listing-portrait',
64
+ viewport: {
65
+ width: 414,
66
+ height: 896,
67
+ deviceScaleFactor: 2,
68
+ isMobile: true,
69
+ hasTouch: true,
70
+ userAgent: MOBILE_USER_AGENT,
71
+ },
72
+ },
73
+ };
74
+ const CAPTURE_FRAME_SELECTOR = 'iframe[title="Game"]';
75
+ const DEFAULT_DURATION_SECONDS = 12;
76
+ const DEFAULT_FPS = 60;
77
+ const DEFAULT_AUDIO_POLICY = 'music-and-sfx';
78
+ const MIN_DURATION_SECONDS = 1;
79
+ const MAX_DURATION_SECONDS = 300;
80
+ const MIN_FPS = 1;
81
+ const MAX_FPS = 120;
82
+ const MIN_MARKETING_AUDIO_INTEGRATED_LUFS = -28;
83
+ const MIN_MARKETING_AUDIO_PEAK_DB = -12;
84
+ const MIN_MARKETING_PREVIEW_FRAME_SIMILARITY = 0.25;
85
+ function parsePositiveNumber(raw, defaultValue, fieldName, minimum, maximum) {
86
+ if (raw === undefined) {
87
+ return defaultValue;
88
+ }
89
+ const parsed = typeof raw === 'number' ? raw : Number.parseFloat(String(raw));
90
+ if (!Number.isFinite(parsed)) {
91
+ throw new Error(`${fieldName}_invalid`);
92
+ }
93
+ if (parsed < minimum || parsed > maximum) {
94
+ throw new Error(`${fieldName}_out_of_range`);
95
+ }
96
+ return parsed;
97
+ }
98
+ function normalizeSurface(raw) {
99
+ const normalized = raw.trim().toLowerCase().replace(/[_\s]/g, '-');
100
+ if (exports.MARKETING_SURFACES.includes(normalized)) {
101
+ return normalized;
102
+ }
103
+ throw new Error(`surface_invalid:${raw}`);
104
+ }
105
+ function parseSurfaces(raw) {
106
+ if (!raw?.trim()) {
107
+ return [...exports.MARKETING_SURFACES];
108
+ }
109
+ const surfaces = raw.split(',').map(normalizeSurface);
110
+ const seen = new Set();
111
+ const unique = [];
112
+ for (const surface of surfaces) {
113
+ if (!seen.has(surface)) {
114
+ seen.add(surface);
115
+ unique.push(surface);
116
+ }
117
+ }
118
+ return unique;
119
+ }
120
+ function parseAudioPolicy(raw) {
121
+ const normalized = raw?.trim().toLowerCase() || DEFAULT_AUDIO_POLICY;
122
+ if (exports.MARKETING_AUDIO_POLICIES.includes(normalized)) {
123
+ return normalized;
124
+ }
125
+ throw new Error('audio_policy_invalid');
126
+ }
127
+ function parseMarketingCaptureOptions(targetArg, options = {}) {
128
+ const durationSeconds = parsePositiveNumber(options.duration, DEFAULT_DURATION_SECONDS, 'duration', MIN_DURATION_SECONDS, MAX_DURATION_SECONDS);
129
+ const fps = parsePositiveNumber(options.fps, DEFAULT_FPS, 'fps', MIN_FPS, MAX_FPS);
130
+ const outputDir = options.outputDir?.trim() ? (0, node_path_1.resolve)(process.cwd(), options.outputDir.trim()) : null;
131
+ const seed = options.seed?.trim() || `marketing-${new Date().toISOString().slice(0, 10)}`;
132
+ return {
133
+ targetArg,
134
+ appName: options.app?.trim() || undefined,
135
+ surfaces: parseSurfaces(options.surfaces),
136
+ durationSeconds,
137
+ fps,
138
+ audioPolicy: parseAudioPolicy(options.audioPolicy),
139
+ seed,
140
+ outputDir,
141
+ screenDevice: options.screenDevice?.trim() || null,
142
+ keepRaw: Boolean(options.keepRaw),
143
+ };
144
+ }
145
+ function assertSupportedMarketingCapturePlatform(platform = process.platform) {
146
+ if (platform !== 'darwin' && platform !== 'win32') {
147
+ throw new Error(`unsupported_marketing_capture_platform:${platform}`);
148
+ }
149
+ }
150
+ function resolveMarketingOutputPaths(projectRoot, _appName, explicitOutputDir) {
151
+ const rootDir = explicitOutputDir ?? (0, node_path_1.join)(projectRoot, 'assets', 'marketing');
152
+ const marketingRoot = (0, node_path_1.join)(projectRoot, 'assets', 'marketing');
153
+ const rootRelativeToMarketing = (0, node_path_1.relative)(marketingRoot, rootDir);
154
+ if (rootRelativeToMarketing === '..' || rootRelativeToMarketing.startsWith(`..${node_path_1.sep}`) || (0, node_path_1.isAbsolute)(rootRelativeToMarketing)) {
155
+ throw new Error(`marketing_output_outside_assets:${rootDir}`);
156
+ }
157
+ return {
158
+ rootDir,
159
+ capturesDir: (0, node_path_1.join)(rootDir, 'captures'),
160
+ playdropDir: (0, node_path_1.join)(rootDir, 'playdrop'),
161
+ socialDir: (0, node_path_1.join)(rootDir, 'social'),
162
+ thumbnailsDir: (0, node_path_1.join)(rootDir, 'thumbnails'),
163
+ screenshotsDir: (0, node_path_1.join)(rootDir, 'screenshots'),
164
+ manifestPath: (0, node_path_1.join)(rootDir, 'capture-manifest.json'),
165
+ reportPath: (0, node_path_1.join)(rootDir, 'marketing-report.json'),
166
+ };
167
+ }
168
+ function validateMarketingPreviewConfig(rawApp) {
169
+ const errors = [];
170
+ const record = rawApp && typeof rawApp === 'object' ? rawApp : {};
171
+ if (record.previewable !== true) {
172
+ errors.push('catalogue_previewable_missing');
173
+ }
174
+ const preview = record.preview && typeof record.preview === 'object'
175
+ ? record.preview
176
+ : null;
177
+ const audioPolicy = typeof preview?.audioPolicy === 'string' ? preview.audioPolicy.trim() : '';
178
+ if (!audioPolicy) {
179
+ errors.push('catalogue_preview_audio_policy_missing');
180
+ }
181
+ else if (!exports.MARKETING_AUDIO_POLICIES.includes(audioPolicy)) {
182
+ errors.push(`catalogue_preview_audio_policy_invalid:${audioPolicy}`);
183
+ }
184
+ return errors;
185
+ }
186
+ function buildMarketingFfmpegArgs(input) {
187
+ const videoFilter = [
188
+ `crop=${input.crop.width}:${input.crop.height}:${input.crop.x}:${input.crop.y}`,
189
+ `scale=${input.output.width}:${input.output.height}:flags=lanczos`,
190
+ `fps=${input.fps}`,
191
+ ].join(',');
192
+ const commonTail = [
193
+ '-t',
194
+ String(input.durationSeconds),
195
+ '-vf',
196
+ videoFilter,
197
+ '-c:v',
198
+ 'libx264',
199
+ '-preset',
200
+ 'slow',
201
+ '-crf',
202
+ '16',
203
+ '-pix_fmt',
204
+ 'yuv420p',
205
+ '-movflags',
206
+ '+faststart',
207
+ input.output.path,
208
+ ];
209
+ if (input.platform === 'darwin') {
210
+ const device = input.inputDevice?.trim() || process.env.PLAYDROP_MARKETING_CAPTURE_AVFOUNDATION_DEVICE?.trim() || '1';
211
+ return [
212
+ '-y',
213
+ '-f',
214
+ 'avfoundation',
215
+ '-framerate',
216
+ String(input.fps),
217
+ '-capture_cursor',
218
+ '0',
219
+ '-i',
220
+ `${device}:none`,
221
+ ...commonTail,
222
+ ];
223
+ }
224
+ if (input.platform === 'win32') {
225
+ return [
226
+ '-y',
227
+ '-f',
228
+ 'gdigrab',
229
+ '-framerate',
230
+ String(input.fps),
231
+ '-offset_x',
232
+ String(input.crop.x),
233
+ '-offset_y',
234
+ String(input.crop.y),
235
+ '-video_size',
236
+ `${input.crop.width}x${input.crop.height}`,
237
+ '-i',
238
+ 'desktop',
239
+ '-t',
240
+ String(input.durationSeconds),
241
+ '-vf',
242
+ `scale=${input.output.width}:${input.output.height}:flags=lanczos,fps=${input.fps}`,
243
+ '-c:v',
244
+ 'libx264',
245
+ '-preset',
246
+ 'slow',
247
+ '-crf',
248
+ '16',
249
+ '-pix_fmt',
250
+ 'yuv420p',
251
+ '-movflags',
252
+ '+faststart',
253
+ input.output.path,
254
+ ];
255
+ }
256
+ throw new Error(`unsupported_marketing_capture_platform:${input.platform}`);
257
+ }
258
+ function buildMarketingCaptureManifest(input) {
259
+ return {
260
+ appName: input.appName,
261
+ createdAt: input.createdAt,
262
+ captureSource: 'playdrop-cli-local-screen',
263
+ captureEngine: input.captureEngine,
264
+ audioPolicy: input.audioPolicy,
265
+ seed: input.seed,
266
+ captures: input.captures,
267
+ };
268
+ }
269
+ function getMarketingCaptureEngine(platform) {
270
+ if (platform === 'darwin') {
271
+ return 'avfoundation';
272
+ }
273
+ if (platform === 'win32') {
274
+ return 'gdigrab';
275
+ }
276
+ throw new Error(`unsupported_marketing_capture_platform:${platform}`);
277
+ }
278
+ function commandExists(command) {
279
+ const result = (0, node_child_process_1.spawnSync)(command, ['-version'], {
280
+ encoding: 'utf8',
281
+ maxBuffer: 4 * 1024 * 1024,
282
+ });
283
+ return result.status === 0;
284
+ }
285
+ function runTool(command, args, failureCode) {
286
+ const result = (0, node_child_process_1.spawnSync)(command, args, {
287
+ encoding: 'utf8',
288
+ maxBuffer: 20 * 1024 * 1024,
289
+ });
290
+ if (result.status !== 0) {
291
+ const stdout = result.stdout?.trim() || '';
292
+ const stderr = result.stderr?.trim() || '';
293
+ const combined = [stdout, stderr].filter(Boolean).join('\n');
294
+ throw new Error(`${failureCode}:${combined}`);
295
+ }
296
+ return {
297
+ stdout: result.stdout || '',
298
+ stderr: result.stderr || '',
299
+ };
300
+ }
301
+ function readFfmpegCapabilities() {
302
+ const encoders = runTool('ffmpeg', ['-hide_banner', '-encoders'], 'ffmpeg_probe_failed');
303
+ const formats = runTool('ffmpeg', ['-hide_banner', '-formats'], 'ffmpeg_probe_failed');
304
+ return `${encoders.stdout}\n${encoders.stderr}\n${formats.stdout}\n${formats.stderr}`;
305
+ }
306
+ function validateFfmpegCapabilities(platform) {
307
+ const capabilities = readFfmpegCapabilities();
308
+ const errors = [];
309
+ if (!/\blibx264\b/.test(capabilities)) {
310
+ errors.push('ffmpeg_libx264_missing');
311
+ }
312
+ if (platform === 'darwin' && !/\bavfoundation\b/.test(capabilities)) {
313
+ errors.push('ffmpeg_avfoundation_missing');
314
+ }
315
+ if (platform === 'win32' && !/\bgdigrab\b/.test(capabilities)) {
316
+ errors.push('ffmpeg_gdigrab_missing');
317
+ }
318
+ return errors;
319
+ }
320
+ async function ensureOutputDirectories(paths) {
321
+ await (0, promises_1.mkdir)(paths.capturesDir, { recursive: true });
322
+ await (0, promises_1.mkdir)(paths.playdropDir, { recursive: true });
323
+ await (0, promises_1.mkdir)(paths.socialDir, { recursive: true });
324
+ await (0, promises_1.mkdir)(paths.thumbnailsDir, { recursive: true });
325
+ await (0, promises_1.mkdir)(paths.screenshotsDir, { recursive: true });
326
+ await (0, promises_1.access)(paths.rootDir);
327
+ }
328
+ function loadCatalogueApp(cataloguePath, appName) {
329
+ if (!cataloguePath || !(0, node_fs_1.existsSync)(cataloguePath)) {
330
+ return null;
331
+ }
332
+ const raw = JSON.parse(require('node:fs').readFileSync(cataloguePath, 'utf8'));
333
+ const apps = raw && typeof raw === 'object' && Array.isArray(raw.apps)
334
+ ? raw.apps
335
+ : Array.isArray(raw)
336
+ ? raw
337
+ : [];
338
+ return apps.find(entry => entry && typeof entry === 'object' && entry.name === appName) ?? null;
339
+ }
340
+ function formatMarketingError(error) {
341
+ if (error.message.startsWith('unsupported_marketing_capture_platform:')) {
342
+ const platform = error.message.split(':')[1] || process.platform;
343
+ return {
344
+ message: `Marketing capture only supports macOS and Windows in v1. Current platform: ${platform}.`,
345
+ suggestions: [],
346
+ };
347
+ }
348
+ if (error.message === 'ffmpeg_missing') {
349
+ return {
350
+ message: 'ffmpeg is required for local marketing capture.',
351
+ suggestions: ['Install ffmpeg and make sure it is available on PATH.'],
352
+ };
353
+ }
354
+ if (error.message === 'ffprobe_missing') {
355
+ return {
356
+ message: 'ffprobe is required for local marketing capture.',
357
+ suggestions: ['Install ffmpeg and make sure ffprobe is available on PATH.'],
358
+ };
359
+ }
360
+ if (error.message === 'ffmpeg_libx264_missing') {
361
+ return {
362
+ message: 'ffmpeg must support the libx264 encoder for marketing capture.',
363
+ suggestions: ['Install a full ffmpeg build with libx264 enabled.'],
364
+ };
365
+ }
366
+ if (error.message === 'ffmpeg_avfoundation_missing') {
367
+ return {
368
+ message: 'ffmpeg must support avfoundation screen capture on macOS.',
369
+ suggestions: ['Install a macOS ffmpeg build with avfoundation enabled.'],
370
+ };
371
+ }
372
+ if (error.message === 'ffmpeg_gdigrab_missing') {
373
+ return {
374
+ message: 'ffmpeg must support gdigrab screen capture on Windows.',
375
+ suggestions: ['Install a Windows ffmpeg build with gdigrab enabled.'],
376
+ };
377
+ }
378
+ if (error.message.startsWith('marketing_output_outside_assets:')) {
379
+ return {
380
+ message: 'Marketing capture output must stay under assets/marketing/.',
381
+ suggestions: ['Remove --output-dir or set it to assets/marketing or a subdirectory inside assets/marketing.'],
382
+ };
383
+ }
384
+ if (error.message === 'catalogue_previewable_missing') {
385
+ return {
386
+ message: 'catalogue.json must mark the app as previewable before marketing capture.',
387
+ suggestions: ['Set previewable: true on the app entry.'],
388
+ };
389
+ }
390
+ if (error.message === 'catalogue_preview_audio_policy_missing') {
391
+ return {
392
+ message: 'catalogue.json must declare preview.audioPolicy before marketing capture.',
393
+ suggestions: ['Set preview.audioPolicy to music-and-sfx, sfx-only, or silent.'],
394
+ };
395
+ }
396
+ if (error.message.startsWith('catalogue_preview_audio_policy_invalid:')) {
397
+ return {
398
+ message: 'catalogue.json preview.audioPolicy must be music-and-sfx, sfx-only, or silent.',
399
+ suggestions: [],
400
+ };
401
+ }
402
+ if (error.message.startsWith('ffmpeg_failed:') || error.message.startsWith('ffprobe_failed:')) {
403
+ return {
404
+ message: error.message.slice(error.message.indexOf(':') + 1).trim() || 'ffmpeg failed.',
405
+ suggestions: [],
406
+ };
407
+ }
408
+ if (error.message === 'marketing_preview_hook_missing') {
409
+ return {
410
+ message: 'The game did not expose window.__listingCapture.prepare for marketing preview capture.',
411
+ suggestions: ['Implement the PlayDrop preview hook before running marketing capture.'],
412
+ };
413
+ }
414
+ if (error.message === 'marketing_audio_capture_hook_missing') {
415
+ return {
416
+ message: 'The game did not expose preview audio capture hooks.',
417
+ suggestions: ['Expose startAudioCapture and stopAudioCapture when preview audioPolicy is not silent.'],
418
+ };
419
+ }
420
+ if (error.message.startsWith('marketing_capture_wrong_window:')) {
421
+ const [, surface, similarity] = error.message.split(':');
422
+ return {
423
+ message: `Marketing capture for ${surface || 'the surface'} does not match the PlayDrop preview frame. Similarity: ${similarity || 'unknown'}.`,
424
+ suggestions: [
425
+ 'Make sure the visible Chromium window is on the selected capture display.',
426
+ 'On macOS, rerun with the correct --screen-device value for the display that contains the PlayDrop preview.',
427
+ 'Do not continue with manual or browser-frame capture output.',
428
+ ],
429
+ };
430
+ }
431
+ if (error.message.startsWith('marketing_capture_audio_too_quiet:')) {
432
+ const [, surface, integratedLufs, peakDb] = error.message.split(':');
433
+ return {
434
+ message: `Marketing capture audio for ${surface || 'the surface'} is too quiet (${integratedLufs || '?'} LUFS, peak ${peakDb || '?'} dB).`,
435
+ suggestions: [
436
+ 'Add or raise preview background music and SFX through PlayDrop catalogue audio or PlayDrop AI generation.',
437
+ 'Rerun marketing capture after the preview audio is clearly audible.',
438
+ ],
439
+ };
440
+ }
441
+ if (error.message === 'preview_game_frame_missing') {
442
+ return {
443
+ message: 'The local dev preview did not expose a playable game iframe for capture.',
444
+ suggestions: ['Run playdrop project validate . and confirm the app opens on /dev-preview.'],
445
+ };
446
+ }
447
+ if (error.message === 'preview_game_frame_hidden') {
448
+ return {
449
+ message: 'The local dev preview game frame is hidden or has no measurable size.',
450
+ suggestions: ['Make sure the preview scene renders the game immediately without a menu or loading screen.'],
451
+ };
452
+ }
453
+ return {
454
+ message: error.message,
455
+ suggestions: [],
456
+ };
457
+ }
458
+ function ensureToolingAvailable(platform) {
459
+ assertSupportedMarketingCapturePlatform(platform);
460
+ if (!commandExists('ffmpeg')) {
461
+ throw new Error('ffmpeg_missing');
462
+ }
463
+ if (!commandExists('ffprobe')) {
464
+ throw new Error('ffprobe_missing');
465
+ }
466
+ const capabilityErrors = validateFfmpegCapabilities(platform);
467
+ if (capabilityErrors.length > 0) {
468
+ throw new Error(capabilityErrors[0]);
469
+ }
470
+ }
471
+ function resolveProjectRoot(resolvedTarget) {
472
+ if (resolvedTarget.cataloguePath) {
473
+ return (0, node_path_1.dirname)(resolvedTarget.cataloguePath);
474
+ }
475
+ const projectInfo = (0, devShared_1.findProjectInfo)(resolvedTarget.htmlPath);
476
+ return projectInfo.projectDir ?? (0, node_path_1.dirname)(resolvedTarget.htmlPath);
477
+ }
478
+ async function waitForPreviewGameFrame(page, timeoutMs) {
479
+ const frameHandle = await page.waitForSelector(CAPTURE_FRAME_SELECTOR, {
480
+ state: 'attached',
481
+ timeout: timeoutMs,
482
+ });
483
+ const startedAt = Date.now();
484
+ while (Date.now() - startedAt < timeoutMs) {
485
+ const frame = await frameHandle.contentFrame();
486
+ if (frame) {
487
+ return frame;
488
+ }
489
+ await page.waitForTimeout(200);
490
+ }
491
+ throw new Error('preview_game_frame_missing');
492
+ }
493
+ async function prepareMarketingPreview(page, surface, audioPolicy, seed) {
494
+ const frame = await waitForPreviewGameFrame(page, 60000);
495
+ const preset = SURFACE_PRESETS[surface];
496
+ await frame.waitForFunction(() => {
497
+ const captureWindow = window;
498
+ return typeof captureWindow.__listingCapture?.prepare === 'function';
499
+ }, undefined, { timeout: 60000 });
500
+ await frame.evaluate(async (payload) => {
501
+ const captureWindow = window;
502
+ const hook = captureWindow.__listingCapture;
503
+ if (!hook || typeof hook.prepare !== 'function') {
504
+ throw new Error('marketing_preview_hook_missing');
505
+ }
506
+ await hook.prepare(payload);
507
+ }, {
508
+ active: true,
509
+ sceneId: preset.sceneId,
510
+ surface,
511
+ seed,
512
+ audioPolicy,
513
+ });
514
+ }
515
+ async function startMarketingAudioCapture(page) {
516
+ const frame = await waitForPreviewGameFrame(page, 20000);
517
+ await frame.evaluate(async () => {
518
+ const captureWindow = window;
519
+ const hook = captureWindow.__listingCapture;
520
+ if (!hook || typeof hook.startAudioCapture !== 'function') {
521
+ throw new Error('marketing_audio_capture_hook_missing');
522
+ }
523
+ await hook.startAudioCapture();
524
+ });
525
+ }
526
+ function resolveExportedAudioFileName(mimeType) {
527
+ if (mimeType.includes('webm')) {
528
+ return 'preview-audio.webm';
529
+ }
530
+ if (mimeType.includes('mp4') || mimeType.includes('aac') || mimeType.includes('m4a')) {
531
+ return 'preview-audio.m4a';
532
+ }
533
+ throw new Error(`unsupported_marketing_audio_mime_type:${mimeType}`);
534
+ }
535
+ async function stopMarketingAudioCapture(page, outputDir) {
536
+ const frame = await waitForPreviewGameFrame(page, 20000);
537
+ const exportedAudio = await frame.evaluate(async () => {
538
+ const captureWindow = window;
539
+ const hook = captureWindow.__listingCapture;
540
+ if (!hook || typeof hook.stopAudioCapture !== 'function') {
541
+ throw new Error('marketing_audio_capture_hook_missing');
542
+ }
543
+ return hook.stopAudioCapture();
544
+ });
545
+ const filePath = (0, node_path_1.join)(outputDir, resolveExportedAudioFileName(exportedAudio.mimeType));
546
+ await (0, promises_1.writeFile)(filePath, Buffer.from(exportedAudio.base64, 'base64'));
547
+ return filePath;
548
+ }
549
+ async function measurePreviewGameFrame(page) {
550
+ const cdpSession = await page.context().newCDPSession(page);
551
+ const windowState = await cdpSession.send('Browser.getWindowForTarget');
552
+ const bounds = windowState.bounds ?? {};
553
+ return await page.evaluate((input) => {
554
+ const frame = document.querySelector(input.selector);
555
+ if (!(frame instanceof HTMLIFrameElement)) {
556
+ throw new Error('preview_game_frame_missing');
557
+ }
558
+ const rect = frame.getBoundingClientRect();
559
+ if (rect.width <= 0 || rect.height <= 0) {
560
+ throw new Error('preview_game_frame_hidden');
561
+ }
562
+ return {
563
+ outerWidth: window.outerWidth,
564
+ outerHeight: window.outerHeight,
565
+ innerWidth: window.innerWidth,
566
+ innerHeight: window.innerHeight,
567
+ windowX: Math.round(input.windowBounds.left ?? 0),
568
+ windowY: Math.round(input.windowBounds.top ?? 0),
569
+ devicePixelRatio: window.devicePixelRatio,
570
+ iframeRect: {
571
+ x: rect.x,
572
+ y: rect.y,
573
+ width: rect.width,
574
+ height: rect.height,
575
+ },
576
+ };
577
+ }, {
578
+ selector: CAPTURE_FRAME_SELECTOR,
579
+ windowBounds: {
580
+ left: bounds.left,
581
+ top: bounds.top,
582
+ width: bounds.width,
583
+ height: bounds.height,
584
+ },
585
+ });
586
+ }
587
+ async function fitWindowToSurface(page, surface) {
588
+ const cdpSession = await page.context().newCDPSession(page);
589
+ const preset = SURFACE_PRESETS[surface];
590
+ let measurement = await measurePreviewGameFrame(page);
591
+ for (let attempt = 0; attempt < 5; attempt += 1) {
592
+ const deltaWidth = preset.viewport.width - Math.round(measurement.iframeRect.width);
593
+ const deltaHeight = preset.viewport.height - Math.round(measurement.iframeRect.height);
594
+ if (Math.abs(deltaWidth) <= 1 && Math.abs(deltaHeight) <= 1) {
595
+ return measurement;
596
+ }
597
+ const windowState = await cdpSession.send('Browser.getWindowForTarget');
598
+ const currentWidth = typeof windowState.bounds.width === 'number' ? windowState.bounds.width : Math.round(measurement.outerWidth);
599
+ const currentHeight = typeof windowState.bounds.height === 'number' ? windowState.bounds.height : Math.round(measurement.outerHeight);
600
+ await cdpSession.send('Browser.setWindowBounds', {
601
+ windowId: windowState.windowId,
602
+ bounds: {
603
+ width: Math.max(400, currentWidth + deltaWidth),
604
+ height: Math.max(300, currentHeight + deltaHeight),
605
+ },
606
+ });
607
+ await page.waitForTimeout(800);
608
+ measurement = await measurePreviewGameFrame(page);
609
+ }
610
+ return measurement;
611
+ }
612
+ function computeScreenCrop(measurement, platform) {
613
+ const scale = platform === 'darwin' ? measurement.devicePixelRatio || 1 : 1;
614
+ const x = Math.round((measurement.windowX + measurement.iframeRect.x) * scale);
615
+ const y = Math.round((measurement.windowY + measurement.iframeRect.y) * scale);
616
+ const width = Math.round(measurement.iframeRect.width * scale);
617
+ const height = Math.round(measurement.iframeRect.height * scale);
618
+ if (width <= 0 || height <= 0) {
619
+ throw new Error('invalid_marketing_capture_crop');
620
+ }
621
+ return { x, y, width, height };
622
+ }
623
+ function probeMediaFile(filePath) {
624
+ const { stdout } = runTool('ffprobe', [
625
+ '-v',
626
+ 'error',
627
+ '-show_streams',
628
+ '-show_format',
629
+ '-of',
630
+ 'json',
631
+ filePath,
632
+ ], 'ffprobe_failed');
633
+ const probe = JSON.parse(stdout);
634
+ const video = probe.streams?.find(stream => stream.codec_type === 'video');
635
+ if (!video || typeof video.width !== 'number' || typeof video.height !== 'number') {
636
+ throw new Error('marketing_video_stream_missing');
637
+ }
638
+ const durationSeconds = probe.format?.duration ? Number.parseFloat(probe.format.duration) : Number.NaN;
639
+ if (!Number.isFinite(durationSeconds)) {
640
+ throw new Error('marketing_duration_missing');
641
+ }
642
+ return {
643
+ width: video.width,
644
+ height: video.height,
645
+ durationSeconds,
646
+ audioTracks: probe.streams?.filter(stream => stream.codec_type === 'audio').length ?? 0,
647
+ };
648
+ }
649
+ function muxMarketingAudio(videoPath, audioPath) {
650
+ const muxedPath = `${videoPath}.muxed.mp4`;
651
+ runTool('ffmpeg', [
652
+ '-y',
653
+ '-i',
654
+ videoPath,
655
+ '-i',
656
+ audioPath,
657
+ '-c:v',
658
+ 'copy',
659
+ '-c:a',
660
+ 'aac',
661
+ '-shortest',
662
+ muxedPath,
663
+ ], 'ffmpeg_failed');
664
+ require('node:fs').renameSync(muxedPath, videoPath);
665
+ }
666
+ function extractPoster(videoPath, posterPath) {
667
+ runTool('ffmpeg', [
668
+ '-y',
669
+ '-ss',
670
+ '2',
671
+ '-i',
672
+ videoPath,
673
+ '-frames:v',
674
+ '1',
675
+ posterPath,
676
+ ], 'ffmpeg_failed');
677
+ }
678
+ async function capturePreviewReferenceFrame(page, outputPath) {
679
+ await page.locator(CAPTURE_FRAME_SELECTOR).screenshot({ path: outputPath });
680
+ }
681
+ function calculatePreviewFrameSimilarity(referencePath, posterPath) {
682
+ const { stderr } = runTool('ffmpeg', [
683
+ '-hide_banner',
684
+ '-i',
685
+ referencePath,
686
+ '-i',
687
+ posterPath,
688
+ '-filter_complex',
689
+ '[0:v]scale=320:320:force_original_aspect_ratio=decrease,pad=320:320:(ow-iw)/2:(oh-ih)/2,format=yuv420p[ref];[1:v]scale=320:320:force_original_aspect_ratio=decrease,pad=320:320:(ow-iw)/2:(oh-ih)/2,format=yuv420p[cap];[ref][cap]ssim',
690
+ '-f',
691
+ 'null',
692
+ '-',
693
+ ], 'ffmpeg_failed');
694
+ const match = stderr.match(/All:([0-9.]+)/);
695
+ if (!match) {
696
+ throw new Error('marketing_capture_similarity_missing');
697
+ }
698
+ return Number.parseFloat(match[1]);
699
+ }
700
+ function validatePreviewFrameSimilarity(surface, similarity) {
701
+ if (!Number.isFinite(similarity) || similarity < MIN_MARKETING_PREVIEW_FRAME_SIMILARITY) {
702
+ throw new Error(`marketing_capture_wrong_window:${surface}:${Number.isFinite(similarity) ? similarity.toFixed(3) : 'unknown'}`);
703
+ }
704
+ }
705
+ function measureMarketingAudio(videoPath) {
706
+ const ebur = runTool('ffmpeg', [
707
+ '-hide_banner',
708
+ '-nostats',
709
+ '-i',
710
+ videoPath,
711
+ '-filter_complex',
712
+ 'ebur128',
713
+ '-f',
714
+ 'null',
715
+ '-',
716
+ ], 'ffmpeg_failed');
717
+ const eburText = `${ebur.stdout}\n${ebur.stderr}`;
718
+ const integratedMatches = [...eburText.matchAll(/I:\s*(-?\d+(?:\.\d+)?) LUFS/g)];
719
+ const integratedLufs = integratedMatches.length
720
+ ? Number.parseFloat(integratedMatches[integratedMatches.length - 1]?.[1] ?? '')
721
+ : Number.NaN;
722
+ const volume = runTool('ffmpeg', [
723
+ '-hide_banner',
724
+ '-nostats',
725
+ '-i',
726
+ videoPath,
727
+ '-af',
728
+ 'volumedetect',
729
+ '-f',
730
+ 'null',
731
+ '-',
732
+ ], 'ffmpeg_failed');
733
+ const volumeText = `${volume.stdout}\n${volume.stderr}`;
734
+ const peakMatch = volumeText.match(/max_volume:\s*(-?\d+(?:\.\d+)?) dB/);
735
+ const peakDb = peakMatch ? Number.parseFloat(peakMatch[1] ?? '') : Number.NaN;
736
+ if (!Number.isFinite(integratedLufs) || !Number.isFinite(peakDb)) {
737
+ throw new Error('marketing_audio_metrics_missing');
738
+ }
739
+ return { integratedLufs, peakDb };
740
+ }
741
+ function validateMarketingAudio(surface, metrics) {
742
+ if (metrics.integratedLufs < MIN_MARKETING_AUDIO_INTEGRATED_LUFS || metrics.peakDb < MIN_MARKETING_AUDIO_PEAK_DB) {
743
+ throw new Error(`marketing_capture_audio_too_quiet:${surface}:${metrics.integratedLufs.toFixed(1)}:${metrics.peakDb.toFixed(1)}`);
744
+ }
745
+ }
746
+ function createMarketingReport(input) {
747
+ const warnings = ['Exciting moment validation requires visual review from the marketing-pack workflow.'];
748
+ if (!input.audioValidated) {
749
+ warnings.push('Required audio was not validated.');
750
+ }
751
+ return {
752
+ appName: input.appName,
753
+ createdAt: input.createdAt,
754
+ status: 'passed',
755
+ durationSeconds: Math.round((Date.now() - input.startedAt) / 1000),
756
+ gates: [
757
+ {
758
+ id: 'marketing-capture',
759
+ status: 'passed',
760
+ summary: 'Local marketing capture completed.',
761
+ },
762
+ ],
763
+ assetCounts: {
764
+ captures: input.surfaces.length,
765
+ videos: 0,
766
+ images: 0,
767
+ listingArt: 0,
768
+ },
769
+ captureValidation: {
770
+ surfaces: input.surfaces,
771
+ fps: input.fps,
772
+ audioValidated: input.audioValidated,
773
+ excitingMomentValidated: false,
774
+ },
775
+ timings: {
776
+ captureSeconds: Math.round((Date.now() - input.startedAt) / 1000),
777
+ assetRenderSeconds: 0,
778
+ documentationSeconds: 0,
779
+ },
780
+ warnings,
781
+ };
782
+ }
783
+ async function resolveMarketingTarget(parsedOptions) {
784
+ try {
785
+ return (0, devShared_1.resolveDevTarget)(parsedOptions.targetArg, parsedOptions.appName);
786
+ }
787
+ catch (error) {
788
+ (0, messages_1.printErrorWithHelp)(error?.message || 'Unable to resolve marketing capture target.', [
789
+ 'Run the command inside a PlayDrop workspace or pass an app target.',
790
+ ], { command: 'project marketing capture' });
791
+ process.exitCode = 1;
792
+ return null;
793
+ }
794
+ }
795
+ async function marketingDoctor(targetArg, options = {}) {
796
+ let parsedOptions;
797
+ try {
798
+ parsedOptions = parseMarketingCaptureOptions(targetArg, options);
799
+ ensureToolingAvailable(process.platform);
800
+ }
801
+ catch (error) {
802
+ const detail = formatMarketingError(error instanceof Error ? error : new Error(String(error)));
803
+ (0, messages_1.printErrorWithHelp)(detail.message, detail.suggestions, { command: 'project marketing doctor' });
804
+ process.exitCode = 1;
805
+ return;
806
+ }
807
+ const resolvedTarget = await resolveMarketingTarget(parsedOptions);
808
+ if (!resolvedTarget) {
809
+ return;
810
+ }
811
+ const projectRoot = resolveProjectRoot(resolvedTarget);
812
+ const paths = resolveMarketingOutputPaths(projectRoot, resolvedTarget.appName, parsedOptions.outputDir);
813
+ try {
814
+ await ensureOutputDirectories(paths);
815
+ const app = loadCatalogueApp(resolvedTarget.cataloguePath, resolvedTarget.appName);
816
+ const previewErrors = validateMarketingPreviewConfig(app);
817
+ if (previewErrors.length > 0) {
818
+ throw new Error(previewErrors[0]);
819
+ }
820
+ const userDataDir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'playdrop-marketing-doctor-'));
821
+ const context = await (0, playwright_1.launchPersistentChromiumContext)(userDataDir, { viewport: SURFACE_PRESETS.desktop.viewport });
822
+ await context.close();
823
+ await (0, promises_1.rm)(userDataDir, { recursive: true, force: true });
824
+ }
825
+ catch (error) {
826
+ const detail = formatMarketingError(error instanceof Error ? error : new Error(String(error)));
827
+ (0, messages_1.printErrorWithHelp)(detail.message, detail.suggestions, { command: 'project marketing doctor' });
828
+ process.exitCode = 1;
829
+ return;
830
+ }
831
+ console.log('[marketing] Doctor passed.');
832
+ }
833
+ async function marketingCapture(targetArg, options = {}) {
834
+ const startedAt = Date.now();
835
+ let parsedOptions;
836
+ try {
837
+ parsedOptions = parseMarketingCaptureOptions(targetArg, options);
838
+ ensureToolingAvailable(process.platform);
839
+ }
840
+ catch (error) {
841
+ const detail = formatMarketingError(error instanceof Error ? error : new Error(String(error)));
842
+ (0, messages_1.printErrorWithHelp)(detail.message, detail.suggestions, { command: 'project marketing capture' });
843
+ process.exitCode = 1;
844
+ return;
845
+ }
846
+ const resolvedTarget = await resolveMarketingTarget(parsedOptions);
847
+ if (!resolvedTarget) {
848
+ return;
849
+ }
850
+ let appName = resolvedTarget.appName;
851
+ let appTypeSlug = (0, appUrls_1.getAppTypeSlug)(null);
852
+ try {
853
+ const match = (0, catalogue_utils_1.findAppDefinition)(resolvedTarget.htmlPath);
854
+ appName = match.name;
855
+ appTypeSlug = (0, appUrls_1.getAppTypeSlug)(match.type);
856
+ }
857
+ catch {
858
+ // Keep resolved target defaults for direct-file targets.
859
+ }
860
+ const projectRoot = resolveProjectRoot(resolvedTarget);
861
+ const outputPaths = resolveMarketingOutputPaths(projectRoot, appName, parsedOptions.outputDir);
862
+ const projectInfo = (0, devShared_1.findProjectInfo)(resolvedTarget.htmlPath);
863
+ const devScriptAvailable = Boolean(projectInfo.projectDir && projectInfo.packageJson && typeof projectInfo.packageJson.scripts?.dev === 'string');
864
+ await (0, commandContext_1.withEnvironment)('project marketing capture', 'Capturing marketing media', async ({ client, env, envConfig }) => {
865
+ let currentUsername = '';
866
+ try {
867
+ const currentUser = await (0, devShared_1.fetchDevUser)(client);
868
+ currentUsername = currentUser.username.trim();
869
+ }
870
+ catch (error) {
871
+ if (error instanceof http_1.CLIUnsupportedClientError) {
872
+ return;
873
+ }
874
+ if (error instanceof types_1.UnsupportedClientError) {
875
+ (0, http_1.handleUnsupportedError)(error, 'Authentication');
876
+ process.exitCode = 1;
877
+ return;
878
+ }
879
+ if (error instanceof types_1.ApiError) {
880
+ (0, messages_1.printErrorWithHelp)(`Could not fetch your account (status ${error.status}).`, [
881
+ 'Run "playdrop auth login" to refresh your session.',
882
+ 'Use "playdrop auth whoami" afterwards to verify the current account.',
883
+ ], { command: 'project marketing capture' });
884
+ process.exitCode = 1;
885
+ return;
886
+ }
887
+ if ((0, devShared_1.isNetworkError)(error)) {
888
+ (0, messages_1.printNetworkIssue)('Could not reach the Playdrop API to resolve your account.', 'project marketing capture');
889
+ process.exitCode = 1;
890
+ return;
891
+ }
892
+ throw error;
893
+ }
894
+ const taskLookup = (0, catalogue_1.findAppTaskByFile)(resolvedTarget.htmlPath);
895
+ if (taskLookup.errors.length > 0) {
896
+ (0, messages_1.printErrorWithHelp)(taskLookup.errors[0] || 'Failed to resolve the app task from catalogue.json.', taskLookup.errors.slice(1), {
897
+ command: 'project marketing capture',
898
+ });
899
+ process.exitCode = 1;
900
+ return;
901
+ }
902
+ let runtimeAssetManifest = (0, devRuntimeAssets_1.createEmptyDevRuntimeAssetManifest)();
903
+ if (taskLookup.task) {
904
+ try {
905
+ runtimeAssetManifest = await (0, devRuntimeAssets_1.buildDevRuntimeAssetManifest)({
906
+ client,
907
+ apiBase: envConfig.apiBase,
908
+ task: taskLookup.task,
909
+ creatorUsername: currentUsername,
910
+ appBaseUrl: new URL('.', (0, devServer_1.buildLocalDevAppUrl)({
911
+ creatorUsername: currentUsername,
912
+ appType: appTypeSlug,
913
+ appName,
914
+ port: devServer_1.DEV_ROUTER_PORT,
915
+ })).toString(),
916
+ });
917
+ }
918
+ catch (error) {
919
+ const formatted = (0, dev_1.formatDevRuntimeAssetManifestFailure)(error);
920
+ (0, messages_1.printErrorWithHelp)(formatted.message, formatted.suggestions, { command: 'project marketing capture' });
921
+ process.exitCode = 1;
922
+ return;
923
+ }
924
+ }
925
+ await ensureOutputDirectories(outputPaths);
926
+ const app = loadCatalogueApp(resolvedTarget.cataloguePath, appName);
927
+ const previewErrors = validateMarketingPreviewConfig(app);
928
+ if (previewErrors.length > 0) {
929
+ const detail = formatMarketingError(new Error(previewErrors[0] || 'Marketing preview configuration is invalid.'));
930
+ (0, messages_1.printErrorWithHelp)(detail.message, detail.suggestions, { command: 'project marketing capture' });
931
+ process.exitCode = 1;
932
+ return;
933
+ }
934
+ const serverAlreadyRunning = await (0, devServer_1.isDevServerAvailable)({
935
+ creatorUsername: currentUsername,
936
+ appType: appTypeSlug,
937
+ appName,
938
+ port: devServer_1.DEV_ROUTER_PORT,
939
+ }, 750);
940
+ let serverHandle = null;
941
+ let userDataDir = null;
942
+ let context = null;
943
+ const cleanup = async () => {
944
+ if (context) {
945
+ await context.close().catch(() => { });
946
+ }
947
+ if (userDataDir) {
948
+ await (0, promises_1.rm)(userDataDir, { recursive: true, force: true }).catch(() => { });
949
+ }
950
+ if (serverHandle) {
951
+ await serverHandle.close().catch(() => { });
952
+ }
953
+ };
954
+ try {
955
+ if (serverAlreadyRunning) {
956
+ await (0, devServer_1.updateMountedDevRuntimeAssetManifest)({
957
+ creatorUsername: currentUsername,
958
+ appName,
959
+ runtimeAssetManifest,
960
+ port: devServer_1.DEV_ROUTER_PORT,
961
+ });
962
+ }
963
+ else {
964
+ serverHandle = await (0, devServer_1.startDevServer)({
965
+ appName,
966
+ appType: appTypeSlug,
967
+ creatorUsername: currentUsername,
968
+ htmlPath: resolvedTarget.htmlPath,
969
+ port: devServer_1.DEV_ROUTER_PORT,
970
+ projectInfo,
971
+ runtimeAssetManifest,
972
+ });
973
+ }
974
+ if (!serverAlreadyRunning && projectInfo.projectDir && !devScriptAvailable && projectInfo.packageJsonPath) {
975
+ const projectLabel = (0, devShared_1.formatProjectLabel)(projectInfo);
976
+ if (projectLabel) {
977
+ console.log(`[marketing] package.json detected at ${projectLabel}, but no "dev" script was found. Run your app build manually if needed.`);
978
+ }
979
+ }
980
+ if (!serverAlreadyRunning) {
981
+ await new Promise(resolve => setTimeout(resolve, 1000));
982
+ }
983
+ const webBase = envConfig.webBase ?? 'https://www.playdrop.ai';
984
+ const frameUrlObject = new URL(`${(0, appUrls_1.normalizePlaydropWebBase)(webBase)}/creators/${encodeURIComponent(currentUsername)}/apps/${appTypeSlug}/${encodeURIComponent(appName)}/dev-preview`);
985
+ frameUrlObject.searchParams.set('launchCheck', '1');
986
+ const frameUrl = frameUrlObject.toString();
987
+ userDataDir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'playdrop-marketing-capture-'));
988
+ const manifestCaptures = [];
989
+ let audioValidated = parsedOptions.audioPolicy === 'silent';
990
+ for (const surface of parsedOptions.surfaces) {
991
+ const preset = SURFACE_PRESETS[surface];
992
+ context = await (0, playwright_1.launchPersistentChromiumContext)(userDataDir, {
993
+ viewport: preset.viewport,
994
+ deviceScaleFactor: preset.viewport.deviceScaleFactor,
995
+ isMobile: preset.viewport.isMobile,
996
+ hasTouch: preset.viewport.hasTouch,
997
+ userAgent: preset.viewport.userAgent,
998
+ automationOrigin: frameUrlObject.origin,
999
+ });
1000
+ const page = context.pages()[0] ?? await context.newPage();
1001
+ await page.goto(frameUrl, { waitUntil: 'domcontentloaded' });
1002
+ await prepareMarketingPreview(page, surface, parsedOptions.audioPolicy, parsedOptions.seed);
1003
+ await page.waitForTimeout(1000);
1004
+ const measurement = await fitWindowToSurface(page, surface);
1005
+ const crop = computeScreenCrop(measurement, process.platform);
1006
+ await page.bringToFront();
1007
+ await page.waitForTimeout(250);
1008
+ const videoPath = (0, node_path_1.join)(outputPaths.capturesDir, `${surface}.mp4`);
1009
+ const posterPath = (0, node_path_1.join)(outputPaths.capturesDir, `${surface}-poster.png`);
1010
+ const referencePath = (0, node_path_1.join)(outputPaths.capturesDir, `${surface}-reference.png`);
1011
+ await capturePreviewReferenceFrame(page, referencePath);
1012
+ const args = buildMarketingFfmpegArgs({
1013
+ platform: process.platform,
1014
+ fps: parsedOptions.fps,
1015
+ durationSeconds: parsedOptions.durationSeconds,
1016
+ inputDevice: parsedOptions.screenDevice,
1017
+ crop,
1018
+ output: {
1019
+ width: preset.width,
1020
+ height: preset.height,
1021
+ path: videoPath,
1022
+ },
1023
+ });
1024
+ let audioMetrics = null;
1025
+ if (parsedOptions.audioPolicy !== 'silent') {
1026
+ await startMarketingAudioCapture(page);
1027
+ }
1028
+ runTool('ffmpeg', args, 'ffmpeg_failed');
1029
+ if (parsedOptions.audioPolicy !== 'silent') {
1030
+ const audioPath = await stopMarketingAudioCapture(page, outputPaths.capturesDir);
1031
+ muxMarketingAudio(videoPath, audioPath);
1032
+ audioMetrics = measureMarketingAudio(videoPath);
1033
+ validateMarketingAudio(surface, audioMetrics);
1034
+ audioValidated = true;
1035
+ }
1036
+ extractPoster(videoPath, posterPath);
1037
+ const previewFrameSimilarity = calculatePreviewFrameSimilarity(referencePath, posterPath);
1038
+ validatePreviewFrameSimilarity(surface, previewFrameSimilarity);
1039
+ if (!parsedOptions.keepRaw) {
1040
+ await (0, promises_1.rm)(referencePath, { force: true }).catch(() => { });
1041
+ }
1042
+ const probe = probeMediaFile(videoPath);
1043
+ if (probe.width !== preset.width || probe.height !== preset.height) {
1044
+ throw new Error(`marketing_capture_dimension_mismatch:${surface}:${probe.width}x${probe.height}`);
1045
+ }
1046
+ if (parsedOptions.audioPolicy !== 'silent' && probe.audioTracks === 0) {
1047
+ throw new Error(`marketing_capture_audio_missing:${surface}`);
1048
+ }
1049
+ manifestCaptures.push({
1050
+ surface,
1051
+ path: (0, node_path_1.relative)(projectRoot, videoPath),
1052
+ width: preset.width,
1053
+ height: preset.height,
1054
+ fps: parsedOptions.fps,
1055
+ durationSeconds: probe.durationSeconds,
1056
+ hasAudio: parsedOptions.audioPolicy !== 'silent',
1057
+ audio: {
1058
+ policy: parsedOptions.audioPolicy,
1059
+ backgroundMusic: parsedOptions.audioPolicy === 'music-and-sfx',
1060
+ sfx: parsedOptions.audioPolicy !== 'silent',
1061
+ peakDb: audioMetrics?.peakDb ?? null,
1062
+ integratedLufs: audioMetrics?.integratedLufs ?? null,
1063
+ },
1064
+ validation: {
1065
+ previewFrameSimilarity,
1066
+ },
1067
+ });
1068
+ await context.close();
1069
+ context = null;
1070
+ }
1071
+ const createdAt = new Date().toISOString();
1072
+ const manifest = buildMarketingCaptureManifest({
1073
+ appName,
1074
+ createdAt,
1075
+ audioPolicy: parsedOptions.audioPolicy,
1076
+ seed: parsedOptions.seed,
1077
+ captureEngine: getMarketingCaptureEngine(process.platform),
1078
+ captures: manifestCaptures,
1079
+ });
1080
+ const report = createMarketingReport({
1081
+ appName,
1082
+ startedAt,
1083
+ createdAt,
1084
+ surfaces: parsedOptions.surfaces,
1085
+ fps: parsedOptions.fps,
1086
+ audioValidated,
1087
+ });
1088
+ await (0, promises_1.writeFile)(outputPaths.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
1089
+ await (0, promises_1.writeFile)(outputPaths.reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
1090
+ console.log(`[marketing] Saved capture manifest to ${(0, node_path_1.relative)(process.cwd(), outputPaths.manifestPath) || outputPaths.manifestPath}`);
1091
+ console.log(`[marketing] Saved report to ${(0, node_path_1.relative)(process.cwd(), outputPaths.reportPath) || outputPaths.reportPath}`);
1092
+ }
1093
+ catch (error) {
1094
+ const detail = formatMarketingError(error instanceof Error ? error : new Error(String(error)));
1095
+ (0, messages_1.printErrorWithHelp)(detail.message, detail.suggestions, { command: 'project marketing capture' });
1096
+ process.exitCode = 1;
1097
+ }
1098
+ finally {
1099
+ await cleanup();
1100
+ }
1101
+ }, { workspacePath: resolvedTarget.cataloguePath ?? (0, node_path_1.dirname)(resolvedTarget.htmlPath) });
1102
+ }