@playdrop/playdrop-cli 0.13.16 → 0.14.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.
Files changed (29) hide show
  1. package/config/client-meta.json +2 -2
  2. package/dist/apps/staticHtml.js +1 -0
  3. package/dist/commandContext.js +1 -1
  4. package/dist/commands/captureListing.d.ts +52 -0
  5. package/dist/commands/captureListing.js +213 -12
  6. package/dist/commands/upload.d.ts +12 -1
  7. package/dist/commands/upload.js +97 -88
  8. package/dist/commands/whoami.js +10 -1
  9. package/dist/commands/worker/runtime.js +2 -0
  10. package/dist/commands/worker.d.ts +28 -1
  11. package/dist/commands/worker.js +381 -29
  12. package/dist/index.js +17 -2
  13. package/dist/listingPreflight.d.ts +2 -1
  14. package/dist/listingPreflight.js +63 -35
  15. package/dist/workerAppProject.d.ts +2 -0
  16. package/dist/workerAppProject.js +33 -0
  17. package/node_modules/@playdrop/api-client/dist/client.d.ts +5 -2
  18. package/node_modules/@playdrop/api-client/dist/client.d.ts.map +1 -1
  19. package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.d.ts +27 -1
  20. package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.d.ts.map +1 -1
  21. package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.js +103 -0
  22. package/node_modules/@playdrop/api-client/dist/index.d.ts +4 -2
  23. package/node_modules/@playdrop/api-client/dist/index.d.ts.map +1 -1
  24. package/node_modules/@playdrop/api-client/dist/index.js +10 -0
  25. package/node_modules/@playdrop/config/client-meta.json +2 -2
  26. package/node_modules/@playdrop/types/dist/api.d.ts +150 -2
  27. package/node_modules/@playdrop/types/dist/api.d.ts.map +1 -1
  28. package/node_modules/@playdrop/types/dist/api.js +3 -1
  29. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "0.13.16",
2
+ "version": "0.14.0",
3
3
  "build": 1,
4
- "runtimeSdkVersion": "0.13.16",
4
+ "runtimeSdkVersion": "0.14.0",
5
5
  "runtimeSdkBuild": 1,
6
6
  "clients": {
7
7
  "all": {
@@ -240,6 +240,7 @@ function buildStaticAppTask(input) {
240
240
  hasValidateScript: false,
241
241
  hasFormatScript: false,
242
242
  displayName: input.metadata.title,
243
+ ...(input.metadata.description ? { subtitle: input.metadata.description } : {}),
243
244
  ...(input.metadata.description ? { description: input.metadata.description } : {}),
244
245
  type: 'GAME',
245
246
  surfaceTargets: [...STATIC_GAME_SURFACES],
@@ -154,7 +154,7 @@ function resolveWorkspaceSelectedAccount(cfg, currentAccount, workspaceAuth, req
154
154
  matchingSessions,
155
155
  };
156
156
  }
157
- if (workspaceAuth.config.taskToken && currentAccount) {
157
+ if (currentAccount) {
158
158
  if (currentAccount.env === preferredEnv) {
159
159
  return {
160
160
  account: currentAccount,
@@ -22,6 +22,7 @@ type ParsedCaptureListingOptions = {
22
22
  posterAtSeconds: number;
23
23
  audio: boolean;
24
24
  outputDir: string | null;
25
+ outputDirInput: string | null;
25
26
  keepRaw: boolean;
26
27
  explicitDimensions: boolean;
27
28
  };
@@ -43,6 +44,10 @@ type HostedGameMeasurement = {
43
44
  devicePixelRatio: number;
44
45
  iframeRect: CaptureRect;
45
46
  };
47
+ type BrowserWindowBounds = {
48
+ width: number;
49
+ height: number;
50
+ };
46
51
  type CropRect = {
47
52
  x: number;
48
53
  y: number;
@@ -70,6 +75,43 @@ type ListingRecorderMetadata = {
70
75
  recordedDurationSeconds: number;
71
76
  recordedFileSizeBytes: number;
72
77
  };
78
+ type ListingCaptureSurfaceReport = {
79
+ surface: AppSurface;
80
+ targetUrl: string;
81
+ command: {
82
+ durationSeconds: number;
83
+ width: number;
84
+ height: number;
85
+ fps: number;
86
+ posterAtSeconds: number;
87
+ audio: boolean;
88
+ keepRaw: boolean;
89
+ };
90
+ measurement: HostedGameMeasurement;
91
+ recorder: ListingRecorderMetadata;
92
+ crop: CropRect;
93
+ rawVideo: {
94
+ path: string | null;
95
+ width: number;
96
+ height: number;
97
+ durationSeconds: number;
98
+ audioTrackCount: number;
99
+ };
100
+ finalVideo: {
101
+ path: string;
102
+ width: number;
103
+ height: number;
104
+ durationSeconds: number;
105
+ audioTrackCount: number;
106
+ fps: number | null;
107
+ };
108
+ posterPath: string;
109
+ warnings: string[];
110
+ };
111
+ export type ExportedListingAudio = {
112
+ mimeType: string;
113
+ base64: string;
114
+ };
73
115
  export declare function resolveListingCaptureRouteSegment(previewable: boolean): 'dev-preview';
74
116
  export declare function resolveCaptureListingDevRouterPort(env?: NodeJS.ProcessEnv): number;
75
117
  export declare function resolveListingCaptureLocalDevPort(devRouterPort: number): number | null;
@@ -84,6 +126,7 @@ export declare function buildListingCaptureFrameUrl({ webBase, currentUsername,
84
126
  }): string;
85
127
  export declare function resolveListingCaptureSceneId(width: number, height: number): 'listing-landscape' | 'listing-portrait';
86
128
  export declare function parseCaptureListingOptions(targetArg: string | undefined, options?: CaptureListingOptions): ParsedCaptureListingOptions;
129
+ export declare function resolveCaptureListingExplicitOutputDir(outputDirInput: string | null, cwdResolvedOutputDir: string | null, projectDir: string): string | null;
87
130
  export declare function assertSupportedListingEnvironment(platform?: NodeJS.Platform, macosVersion?: string): void;
88
131
  export declare function resolveListingRecorderPath(workerHome?: string): string;
89
132
  export declare function buildSurfaceOutputPaths(outputDir: string, surface: AppSurface, useSurfacePrefix: boolean): {
@@ -92,6 +135,7 @@ export declare function buildSurfaceOutputPaths(outputDir: string, surface: AppS
92
135
  posterPath: string;
93
136
  finalVideoPath: string;
94
137
  };
138
+ export declare function resolveWorkerCaptureOutputDir(projectDir: string, isWorkerGameTask: boolean): string | null;
95
139
  export declare function resolveCaptureDimensions(surface: AppSurface, parsedOptions: ParsedCaptureListingOptions): {
96
140
  width: number;
97
141
  height: number;
@@ -100,7 +144,15 @@ export declare function resolveListingCaptureBrowserContextOptions(surface: AppS
100
144
  width: number;
101
145
  height: number;
102
146
  }): BrowserContextOptions;
147
+ export declare function assertExportedListingAudio(value: unknown): ExportedListingAudio;
148
+ export declare function assertListingCaptureWindowCanContainViewport(measurement: HostedGameMeasurement, windowBounds: BrowserWindowBounds): void;
103
149
  export declare function computeRecordedCrop(measurement: HostedGameMeasurement, recorder: ListingRecorderMetadata, rawWidth: number, rawHeight: number): CropRect;
104
150
  export declare function runListingRecorder(recorderPath: string, pid: number, durationSeconds: number, rawOutputPath: string, metadataPath: string, audio: boolean, deadlineAt: number): Promise<ListingRecorderMetadata>;
151
+ export declare function formatCommandError(error: Error): {
152
+ message: string;
153
+ suggestions: string[];
154
+ };
155
+ export declare function captureCommandMatchesExceptPoster(capture: ListingCaptureSurfaceReport, surface: AppSurface, parsedOptions: ParsedCaptureListingOptions): boolean;
156
+ export declare function resolveCurrentCaptureReuseAction(captures: ListingCaptureSurfaceReport[], surfaces: AppSurface[], parsedOptions: ParsedCaptureListingOptions): 'reuse' | 'refresh_poster';
105
157
  export declare function captureListing(targetArg: string | undefined, options?: CaptureListingOptions): Promise<void>;
106
158
  export {};
@@ -39,13 +39,20 @@ exports.resolveListingCaptureLocalDevPort = resolveListingCaptureLocalDevPort;
39
39
  exports.buildListingCaptureFrameUrl = buildListingCaptureFrameUrl;
40
40
  exports.resolveListingCaptureSceneId = resolveListingCaptureSceneId;
41
41
  exports.parseCaptureListingOptions = parseCaptureListingOptions;
42
+ exports.resolveCaptureListingExplicitOutputDir = resolveCaptureListingExplicitOutputDir;
42
43
  exports.assertSupportedListingEnvironment = assertSupportedListingEnvironment;
43
44
  exports.resolveListingRecorderPath = resolveListingRecorderPath;
44
45
  exports.buildSurfaceOutputPaths = buildSurfaceOutputPaths;
46
+ exports.resolveWorkerCaptureOutputDir = resolveWorkerCaptureOutputDir;
45
47
  exports.resolveCaptureDimensions = resolveCaptureDimensions;
46
48
  exports.resolveListingCaptureBrowserContextOptions = resolveListingCaptureBrowserContextOptions;
49
+ exports.assertExportedListingAudio = assertExportedListingAudio;
50
+ exports.assertListingCaptureWindowCanContainViewport = assertListingCaptureWindowCanContainViewport;
47
51
  exports.computeRecordedCrop = computeRecordedCrop;
48
52
  exports.runListingRecorder = runListingRecorder;
53
+ exports.formatCommandError = formatCommandError;
54
+ exports.captureCommandMatchesExceptPoster = captureCommandMatchesExceptPoster;
55
+ exports.resolveCurrentCaptureReuseAction = resolveCurrentCaptureReuseAction;
49
56
  exports.captureListing = captureListing;
50
57
  const types_1 = require("@playdrop/types");
51
58
  const node_child_process_1 = require("node:child_process");
@@ -53,10 +60,12 @@ const node_crypto_1 = require("node:crypto");
53
60
  const promises_1 = require("node:fs/promises");
54
61
  const node_path_1 = require("node:path");
55
62
  const appUrls_1 = require("../appUrls");
63
+ const build_1 = require("../apps/build");
56
64
  const catalogue_1 = require("../catalogue");
57
65
  const catalogue_utils_1 = require("../catalogue-utils");
58
66
  const commandContext_1 = require("../commandContext");
59
67
  const http_1 = require("../http");
68
+ const listingPreflight_1 = require("../listingPreflight");
60
69
  const messages_1 = require("../messages");
61
70
  const playwright_1 = require("../playwright");
62
71
  const recorderRelease_1 = require("../recorderRelease");
@@ -66,6 +75,7 @@ const devRuntimeAssets_1 = require("./devRuntimeAssets");
66
75
  const devServer_1 = require("./devServer");
67
76
  const devShared_1 = require("./devShared");
68
77
  const taskCaptureSession_1 = require("./taskCaptureSession");
78
+ const workerAppProject_1 = require("../workerAppProject");
69
79
  const CAPTURE_FRAME_SELECTOR = 'iframe[title="Game"]';
70
80
  const DEFAULT_DURATION_SECONDS = 8;
71
81
  const DEFAULT_WIDTH = 1280;
@@ -158,7 +168,8 @@ function parseCaptureListingOptions(targetArg, options = {}) {
158
168
  minimum: 0,
159
169
  maximum: durationSeconds,
160
170
  });
161
- const outputDir = options.outputDir?.trim() ? (0, node_path_1.resolve)(process.cwd(), options.outputDir.trim()) : null;
171
+ const outputDirInput = options.outputDir?.trim() || null;
172
+ const outputDir = outputDirInput ? (0, node_path_1.resolve)(process.cwd(), outputDirInput) : null;
162
173
  return {
163
174
  targetArg,
164
175
  appName: options.app?.trim() || undefined,
@@ -169,10 +180,18 @@ function parseCaptureListingOptions(targetArg, options = {}) {
169
180
  posterAtSeconds,
170
181
  audio: Boolean(options.audio),
171
182
  outputDir,
183
+ outputDirInput,
172
184
  keepRaw: Boolean(options.keepRaw),
173
185
  explicitDimensions,
174
186
  };
175
187
  }
188
+ function resolveCaptureListingExplicitOutputDir(outputDirInput, cwdResolvedOutputDir, projectDir) {
189
+ if (!outputDirInput || !cwdResolvedOutputDir)
190
+ return null;
191
+ return (0, node_path_1.isAbsolute)(outputDirInput)
192
+ ? cwdResolvedOutputDir
193
+ : (0, node_path_1.resolve)(projectDir, outputDirInput);
194
+ }
176
195
  function readMacOsVersion() {
177
196
  const result = (0, node_child_process_1.spawnSync)('sw_vers', ['-productVersion'], {
178
197
  encoding: 'utf8',
@@ -231,6 +250,11 @@ function buildSurfaceOutputPaths(outputDir, surface, useSurfacePrefix) {
231
250
  finalVideoPath: (0, node_path_1.join)(outputDir, `${prefix}listing.mp4`),
232
251
  };
233
252
  }
253
+ function resolveWorkerCaptureOutputDir(projectDir, isWorkerGameTask) {
254
+ return isWorkerGameTask
255
+ ? (0, node_path_1.join)(projectDir, 'assets', 'marketing', 'playdrop', 'capture')
256
+ : null;
257
+ }
234
258
  function resolveCaptureDimensions(surface, parsedOptions) {
235
259
  if (parsedOptions.explicitDimensions) {
236
260
  return {
@@ -432,6 +456,21 @@ async function startHostedListingAudioCapture(page) {
432
456
  await hook.startAudioCapture();
433
457
  });
434
458
  }
459
+ function assertExportedListingAudio(value) {
460
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
461
+ throw new Error('listing_audio_capture_export_missing');
462
+ }
463
+ const mimeType = typeof value.mimeType === 'string'
464
+ ? value.mimeType.trim()
465
+ : '';
466
+ const base64 = typeof value.base64 === 'string'
467
+ ? value.base64.trim()
468
+ : '';
469
+ if (!mimeType || !base64) {
470
+ throw new Error('listing_audio_capture_export_missing');
471
+ }
472
+ return { mimeType, base64 };
473
+ }
435
474
  function resolveExportedAudioFileName(mimeType) {
436
475
  if (mimeType.includes('webm')) {
437
476
  return 'listing-audio.webm';
@@ -443,27 +482,27 @@ function resolveExportedAudioFileName(mimeType) {
443
482
  }
444
483
  async function stopHostedListingAudioCapture(page, outputDir) {
445
484
  const frame = await waitForHostedGameFrame(page, 20000);
446
- const exportedAudio = await frame.evaluate(async () => {
485
+ const exportedAudio = assertExportedListingAudio(await frame.evaluate(async () => {
447
486
  const captureWindow = window;
448
487
  const hook = captureWindow.__listingCapture;
449
488
  if (!hook || typeof hook.stopAudioCapture !== 'function') {
450
489
  throw new Error('listing_audio_capture_hook_missing');
451
490
  }
452
491
  return hook.stopAudioCapture();
453
- });
492
+ }));
454
493
  const filePath = (0, node_path_1.join)(outputDir, resolveExportedAudioFileName(exportedAudio.mimeType));
455
494
  await (0, promises_1.writeFile)(filePath, Buffer.from(exportedAudio.base64, 'base64'));
456
495
  return filePath;
457
496
  }
497
+ async function assertHostedListingAudioCaptureContract(page, outputDir) {
498
+ await startHostedListingAudioCapture(page);
499
+ await page.waitForTimeout(300);
500
+ await stopHostedListingAudioCapture(page, outputDir);
501
+ }
458
502
  async function fitWindowToRequestedGameplay(page, requestedWidth, requestedHeight) {
459
503
  const cdpSession = await page.context().newCDPSession(page);
460
504
  let measurement = await waitForHostedGameMeasurement(page, 15000);
461
505
  for (let attempt = 0; attempt < 5; attempt += 1) {
462
- const deltaWidth = requestedWidth - Math.round(measurement.iframeRect.width);
463
- const deltaHeight = requestedHeight - Math.round(measurement.iframeRect.height);
464
- if (Math.abs(deltaWidth) <= 1 && Math.abs(deltaHeight) <= 1) {
465
- return measurement;
466
- }
467
506
  const windowState = await cdpSession.send('Browser.getWindowForTarget');
468
507
  const currentWidth = typeof windowState.bounds.width === 'number'
469
508
  ? windowState.bounds.width
@@ -471,6 +510,15 @@ async function fitWindowToRequestedGameplay(page, requestedWidth, requestedHeigh
471
510
  const currentHeight = typeof windowState.bounds.height === 'number'
472
511
  ? windowState.bounds.height
473
512
  : Math.round(measurement.outerHeight);
513
+ const deltaWidth = requestedWidth - Math.round(measurement.iframeRect.width);
514
+ const deltaHeight = requestedHeight - Math.round(measurement.iframeRect.height);
515
+ if (Math.abs(deltaWidth) <= 1 && Math.abs(deltaHeight) <= 1) {
516
+ assertListingCaptureWindowCanContainViewport(measurement, {
517
+ width: currentWidth,
518
+ height: currentHeight,
519
+ });
520
+ return measurement;
521
+ }
474
522
  const nextWidth = Math.max(400, currentWidth + deltaWidth);
475
523
  const nextHeight = Math.max(300, currentHeight + deltaHeight);
476
524
  await cdpSession.send('Browser.setWindowBounds', {
@@ -485,6 +533,15 @@ async function fitWindowToRequestedGameplay(page, requestedWidth, requestedHeigh
485
533
  }
486
534
  throw new Error('hosted_game_resize_failed');
487
535
  }
536
+ function assertListingCaptureWindowCanContainViewport(measurement, windowBounds) {
537
+ const requiredWidth = Math.ceil(measurement.innerWidth);
538
+ const requiredHeight = Math.ceil(measurement.innerHeight);
539
+ const availableWidth = Math.floor(windowBounds.width);
540
+ const availableHeight = Math.floor(windowBounds.height);
541
+ if (availableWidth + 1 < requiredWidth || availableHeight + 1 < requiredHeight) {
542
+ throw new Error(`listing_capture_dimensions_exceed_display:${requiredWidth}x${requiredHeight}:${availableWidth}x${availableHeight}`);
543
+ }
544
+ }
488
545
  function roundCropValue(value) {
489
546
  return Math.round(value);
490
547
  }
@@ -666,6 +723,31 @@ function formatCommandError(error) {
666
723
  suggestions: ['Set previewable: true in catalogue.json, implement the preview scene and __listingCapture hooks, then rerun "playdrop project capture".'],
667
724
  };
668
725
  }
726
+ if (error.message === 'listing_audio_capture_export_missing') {
727
+ return {
728
+ message: 'The game preview audio hook did not export recorded audio.',
729
+ suggestions: ['Make window.__listingCapture.stopAudioCapture() resolve { mimeType, base64 }; toggling game audio on or off is not an export.'],
730
+ };
731
+ }
732
+ if (error.message === 'listing_capture_runtime_already_recorded') {
733
+ return {
734
+ message: 'This exact game runtime already has a canonical recording with different capture settings.',
735
+ suggestions: ['Use the existing video, or change and revalidate the preview implementation before recording the changed runtime once.'],
736
+ };
737
+ }
738
+ if (error.message === 'listing_capture_current_report_incomplete') {
739
+ return {
740
+ message: 'The canonical capture report is incomplete for this runtime.',
741
+ suggestions: ['Fix or remove the incomplete canonical capture directory, then record the runtime once.'],
742
+ };
743
+ }
744
+ if (error.message.startsWith('listing_capture_dimensions_exceed_display:')) {
745
+ const [, requested = 'unknown', available = 'unknown'] = error.message.split(':');
746
+ return {
747
+ message: `Requested capture size ${requested} cannot fit in this worker's ${available} browser window.`,
748
+ suggestions: ['Omit --width and --height to use the safe surface defaults, or request dimensions that fit this display.'],
749
+ };
750
+ }
669
751
  if (error.message.startsWith('listing_recorder_failed:')) {
670
752
  return {
671
753
  message: error.message.slice('listing_recorder_failed:'.length),
@@ -710,6 +792,77 @@ async function encodeListingVideo(rawVideoPath, finalVideoPath, posterPath, crop
710
792
  posterPath,
711
793
  ], 'ffmpeg_failed', deadlineAt);
712
794
  }
795
+ function captureCommandMatchesExceptPoster(capture, surface, parsedOptions) {
796
+ const dimensions = resolveCaptureDimensions(surface, parsedOptions);
797
+ return capture.command.durationSeconds === parsedOptions.durationSeconds
798
+ && capture.command.width === dimensions.width
799
+ && capture.command.height === dimensions.height
800
+ && capture.command.fps === parsedOptions.fps
801
+ && capture.command.audio === parsedOptions.audio
802
+ && capture.command.keepRaw === parsedOptions.keepRaw;
803
+ }
804
+ function resolveCurrentCaptureReuseAction(captures, surfaces, parsedOptions) {
805
+ if (captures.length !== surfaces.length) {
806
+ throw new Error('listing_capture_current_report_incomplete');
807
+ }
808
+ const orderedCaptures = surfaces.map((surface) => captures.find((candidate) => candidate.surface === surface));
809
+ if (orderedCaptures.some((capture) => !capture)) {
810
+ throw new Error('listing_capture_current_report_incomplete');
811
+ }
812
+ if (orderedCaptures.some((capture, index) => !captureCommandMatchesExceptPoster(capture, surfaces[index], parsedOptions))) {
813
+ throw new Error('listing_capture_runtime_already_recorded');
814
+ }
815
+ return orderedCaptures.some((capture) => capture.command.posterAtSeconds !== parsedOptions.posterAtSeconds)
816
+ ? 'refresh_poster'
817
+ : 'reuse';
818
+ }
819
+ async function reuseCurrentListingCapture(input) {
820
+ let report;
821
+ try {
822
+ report = JSON.parse(await (0, promises_1.readFile)(input.outputPaths.reportPath, 'utf8'));
823
+ }
824
+ catch (error) {
825
+ if (error?.code === 'ENOENT') {
826
+ return false;
827
+ }
828
+ throw new Error(`listing_capture_report_invalid:${input.outputPaths.reportPath}`);
829
+ }
830
+ if (report.appName !== input.appName) {
831
+ throw new Error(`listing_capture_report_app_mismatch:${report.appName}:${input.appName}`);
832
+ }
833
+ if (!input.runtimeBundleHash || report.binding?.runtimeBundleHash !== input.runtimeBundleHash) {
834
+ return false;
835
+ }
836
+ const action = resolveCurrentCaptureReuseAction(report.captures, input.surfaces, input.parsedOptions);
837
+ const captures = input.surfaces.map((surface) => report.captures.find((candidate) => candidate.surface === surface));
838
+ for (const capture of captures) {
839
+ await (0, promises_1.access)(capture.finalVideo.path);
840
+ }
841
+ if (action === 'reuse') {
842
+ console.log('[listing] The current runtime already has a matching canonical capture; reusing it.');
843
+ console.log(`[listing] Existing report: ${(0, node_path_1.relative)(process.cwd(), input.outputPaths.reportPath) || input.outputPaths.reportPath}`);
844
+ return true;
845
+ }
846
+ for (const capture of captures) {
847
+ runTool('ffmpeg', [
848
+ '-y',
849
+ '-ss',
850
+ String(input.parsedOptions.posterAtSeconds),
851
+ '-i',
852
+ capture.finalVideo.path,
853
+ '-frames:v',
854
+ '1',
855
+ capture.posterPath,
856
+ ], 'ffmpeg_failed', input.deadlineAt);
857
+ capture.command.posterAtSeconds = input.parsedOptions.posterAtSeconds;
858
+ const artifactKey = (0, node_path_1.relative)(input.outputPaths.outputDir, capture.posterPath) || 'poster.png';
859
+ report.binding.artifactHashes[artifactKey] = await computeFileSha256(capture.posterPath);
860
+ }
861
+ await (0, promises_1.writeFile)(input.outputPaths.reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
862
+ console.log(`[listing] Refreshed the canonical poster at ${input.parsedOptions.posterAtSeconds}s without relaunching the game.`);
863
+ console.log(`[listing] Updated report: ${(0, node_path_1.relative)(process.cwd(), input.outputPaths.reportPath) || input.outputPaths.reportPath}`);
864
+ return true;
865
+ }
713
866
  async function muxListingAudio(finalVideoPath, audioPath, deadlineAt) {
714
867
  const muxedVideoPath = `${finalVideoPath}.muxed.mp4`;
715
868
  runTool('ffmpeg', [
@@ -824,8 +977,20 @@ async function captureListing(targetArg, options = {}) {
824
977
  process.exitCode = 1;
825
978
  return;
826
979
  }
827
- const outputPaths = await ensureOutputPaths(appName, parsedOptions.outputDir);
828
980
  const projectInfo = (0, devShared_1.findProjectInfo)(resolvedTarget.htmlPath);
981
+ const projectDir = projectInfo.projectDir ?? (0, node_path_1.dirname)(resolvedTarget.htmlPath);
982
+ let isWorkerGameTask = false;
983
+ try {
984
+ isWorkerGameTask = Boolean((0, listingPreflight_1.readWorkerUploadPreflightContext)(projectDir));
985
+ }
986
+ catch (error) {
987
+ const detail = formatCommandError(error instanceof Error ? error : new Error(String(error)));
988
+ (0, messages_1.printErrorWithHelp)(detail.message, detail.suggestions, { command: 'project capture' });
989
+ process.exitCode = 1;
990
+ return;
991
+ }
992
+ const explicitOutputDir = resolveCaptureListingExplicitOutputDir(parsedOptions.outputDirInput, parsedOptions.outputDir, projectDir);
993
+ const outputPaths = await ensureOutputPaths(appName, explicitOutputDir ?? resolveWorkerCaptureOutputDir(projectDir, isWorkerGameTask));
829
994
  const devScriptAvailable = Boolean(projectInfo.projectDir && projectInfo.packageJson && typeof projectInfo.packageJson.scripts?.dev === 'string');
830
995
  await (0, commandContext_1.withEnvironment)('project capture', 'Capturing game media', async ({ client, env, envConfig, account, workspaceAuth }) => {
831
996
  let currentUsername = '';
@@ -902,6 +1067,40 @@ async function captureListing(targetArg, options = {}) {
902
1067
  process.exitCode = 1;
903
1068
  return;
904
1069
  }
1070
+ let runtimeBundleHash = null;
1071
+ if (taskLookup.task) {
1072
+ try {
1073
+ const captureTask = (0, listingPreflight_1.readWorkerUploadPreflightContext)(taskLookup.task.projectDir)
1074
+ ? (0, workerAppProject_1.prepareWorkerAppTask)(taskLookup.task)
1075
+ : taskLookup.task;
1076
+ runtimeBundleHash = (await (0, build_1.buildApp)(captureTask)).bundleHash;
1077
+ }
1078
+ catch (error) {
1079
+ (0, messages_1.printErrorWithHelp)(error instanceof Error ? error.message : String(error), ['Fix the game build before recording final gameplay.'], { command: 'project capture' });
1080
+ process.exitCode = 1;
1081
+ return;
1082
+ }
1083
+ }
1084
+ const captureDeadlineAt = Date.now() + listingCaptureLease_1.LISTING_CAPTURE_MAX_DURATION_MS;
1085
+ try {
1086
+ const reused = await reuseCurrentListingCapture({
1087
+ appName,
1088
+ outputPaths,
1089
+ parsedOptions,
1090
+ runtimeBundleHash,
1091
+ surfaces: resolvedTarget.surfaceTargets.list,
1092
+ deadlineAt: captureDeadlineAt,
1093
+ });
1094
+ if (reused) {
1095
+ return;
1096
+ }
1097
+ }
1098
+ catch (error) {
1099
+ const detail = formatCommandError(error instanceof Error ? error : new Error(String(error)));
1100
+ (0, messages_1.printErrorWithHelp)(detail.message, detail.suggestions, { command: 'project capture' });
1101
+ process.exitCode = 1;
1102
+ return;
1103
+ }
905
1104
  const localDevAppUrl = (0, devServer_1.buildLocalDevAppUrl)({
906
1105
  creatorUsername: currentUsername,
907
1106
  appType: appTypeSlug,
@@ -926,7 +1125,6 @@ async function captureListing(targetArg, options = {}) {
926
1125
  return;
927
1126
  }
928
1127
  }
929
- const captureDeadlineAt = Date.now() + listingCaptureLease_1.LISTING_CAPTURE_MAX_DURATION_MS;
930
1128
  let serverAlreadyRunning = false;
931
1129
  let devServerStartedByCapture = false;
932
1130
  let serverHandle = null;
@@ -1019,12 +1217,14 @@ async function captureListing(targetArg, options = {}) {
1019
1217
  await prepareHostedListingScene(browserHandle.page, captureSceneId);
1020
1218
  await browserHandle.page.waitForTimeout(1000);
1021
1219
  }
1220
+ const measurement = await fitWindowToRequestedGameplay(browserHandle.page, dimensions.width, dimensions.height);
1221
+ console.log(`[listing] Gameplay frame ${Math.round(measurement.iframeRect.width)}x${Math.round(measurement.iframeRect.height)} in window ${Math.round(measurement.outerWidth)}x${Math.round(measurement.outerHeight)}.`);
1022
1222
  if (shouldExportPreviewAudio) {
1223
+ console.log('[listing] Checking the in-app preview audio export contract.');
1224
+ await assertHostedListingAudioCaptureContract(browserHandle.page, outputPaths.outputDir);
1023
1225
  console.log('[listing] Starting in-app preview audio capture.');
1024
1226
  await startHostedListingAudioCapture(browserHandle.page);
1025
1227
  }
1026
- const measurement = await fitWindowToRequestedGameplay(browserHandle.page, dimensions.width, dimensions.height);
1027
- console.log(`[listing] Gameplay frame ${Math.round(measurement.iframeRect.width)}x${Math.round(measurement.iframeRect.height)} in window ${Math.round(measurement.outerWidth)}x${Math.round(measurement.outerHeight)}.`);
1028
1228
  await browserHandle.page.waitForTimeout(750);
1029
1229
  console.log('[listing] Waiting for the machine capture lease.');
1030
1230
  const captureLease = await (0, listingCaptureLease_1.acquireListingCaptureLease)({
@@ -1126,6 +1326,7 @@ async function captureListing(targetArg, options = {}) {
1126
1326
  binding: {
1127
1327
  artifactHashes,
1128
1328
  capturedSurfaces,
1329
+ runtimeBundleHash,
1129
1330
  },
1130
1331
  };
1131
1332
  await (0, promises_1.writeFile)(outputPaths.reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
@@ -1,5 +1,5 @@
1
1
  import type { ApiClient } from '@playdrop/api-client';
2
- import type { AgentExecutionTarget, UserResponse } from '@playdrop/types';
2
+ import type { AgentExecutionTarget, AppSurface, UserResponse } from '@playdrop/types';
3
3
  import { type AppTask } from '../catalogue';
4
4
  export type UploadCommandOptions = {
5
5
  env?: string;
@@ -17,6 +17,9 @@ export type WorkerAppPublishInput = {
17
17
  kind: 'NEW_GAME' | 'REMIX_GAME' | 'GAME_UPDATE';
18
18
  executionTarget?: AgentExecutionTarget;
19
19
  expectedAppName?: string | null;
20
+ expectedDisplayName?: string | null;
21
+ expectedSubtitle?: string | null;
22
+ expectedPrimarySurface?: AppSurface | null;
20
23
  remixSourceRef?: string | null;
21
24
  playdropAssetRequirement?: WorkerPlaydropAssetRequirement | null;
22
25
  creatorRequest?: string | null;
@@ -69,6 +72,14 @@ export declare function assertWorkerAppLocalUploadPreflight(input: {
69
72
  creatorUsername?: string | null;
70
73
  playdropAssetRequirement?: WorkerPlaydropAssetRequirement | null;
71
74
  creatorRequest?: string | null;
75
+ stage?: 'PRE_CAPTURE' | 'COMPLETE';
76
+ expectedRuntimeBundleHash?: string;
72
77
  }): Promise<void>;
78
+ export type WorkerAppPreflightResult = {
79
+ appName: string;
80
+ creatorUsername: string;
81
+ warnings: string[];
82
+ };
83
+ export declare function preflightWorkerAppProject(input: WorkerAppPublishInput): Promise<WorkerAppPreflightResult>;
73
84
  export declare function publishWorkerAppProject(input: WorkerAppPublishInput): Promise<WorkerAppPublishResult>;
74
85
  export declare function publishStaticHtmlProject(input: StaticHtmlPublishInput): Promise<StaticHtmlPublishResult>;