codeplay-common 4.4.1 → 4.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,644 +1,644 @@
1
- //node take-screen-video.js
2
-
3
- //--audio without //with,without
4
- //--duration 0 //30,120 (0 is used "Enter" key to stop)
5
- //--countdown 0//0,5,10
6
- //--name test-name
7
- //--profiles youtube,non-spatial-xr,amazon
8
-
9
-
10
-
11
- const fs = require('node:fs');
12
- const path = require('node:path');
13
- const { spawn, spawnSync } = require('node:child_process');
14
-
15
- const scriptDirectory = __dirname;
16
- const projectDirectory = scriptDirectory;
17
- const temporaryDirectory = path.join(projectDirectory, 'agent-temp', 'store-video');
18
- const outputDirectory = path.join(projectDirectory, 'Auto-Screenshot', 'Video', 'Output');
19
- const deviceRecordingPath = '/sdcard/codeplay_store_video.mp4';
20
- const capacitorConfigPath = path.join(projectDirectory, 'capacitor.config.json');
21
-
22
- const videoProfiles = {
23
- youtube: {
24
- label: 'YouTube preview',
25
- directory: 'YouTube',
26
- fileSuffix: 'youtube',
27
- width: 1920,
28
- height: 1080,
29
- videoBitrate: '8M',
30
- h264Level: '4.2',
31
- },
32
- 'non-spatial-xr': {
33
- label: 'Google Play non-spatial XR',
34
- directory: 'Google-Play-XR-Non-Spatial',
35
- fileSuffix: 'non-spatial-xr',
36
- width: 3840,
37
- height: 2160,
38
- videoBitrate: '12M',
39
- h264Level: '5.1',
40
- },
41
- amazon: {
42
- label: 'Amazon Appstore',
43
- directory: 'Amazon',
44
- fileSuffix: 'amazon',
45
- width: 1920,
46
- height: 1080,
47
- videoBitrate: '4M',
48
- h264Level: '4.2',
49
- },
50
- };
51
-
52
- function run(command, args, options = {}) {
53
- const result = spawnSync(command, args, {
54
- encoding: 'utf8',
55
- windowsHide: true,
56
- stdio: options.inherit ? 'inherit' : 'pipe',
57
- ...options,
58
- });
59
-
60
- if (result.error) {
61
- if (result.error.code === 'ENOENT') {
62
- throw new Error(`${command} was not found in PATH.`);
63
- }
64
-
65
- throw result.error;
66
- }
67
-
68
- if (result.status !== 0) {
69
- throw new Error(
70
- String(result.stderr || result.stdout || `${command} exited with code ${result.status}.`).trim(),
71
- );
72
- }
73
-
74
- return String(result.stdout || '').trim();
75
- }
76
-
77
- function commandExists(command) {
78
- const result = spawnSync(command, ['-version'], {
79
- encoding: 'utf8',
80
- windowsHide: true,
81
- });
82
- return !result.error && result.status === 0;
83
- }
84
-
85
- function getExecutable(command) {
86
- return commandExists(command) ? command : '';
87
- }
88
-
89
- function wait(milliseconds) {
90
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
91
- }
92
-
93
- function runAsync(command, args, options = {}) {
94
- const child = spawn(command, args, {
95
- windowsHide: true,
96
- stdio: 'inherit',
97
- ...options,
98
- });
99
-
100
- return {
101
- child,
102
- completion: new Promise((resolve, reject) => {
103
- child.once('error', reject);
104
- child.once('exit', (code) => {
105
- if (code === 0) resolve();
106
- else reject(new Error(`${command} exited with code ${code}.`));
107
- });
108
- }),
109
- };
110
- }
111
-
112
- function getConnectedDevice() {
113
- const devices = run('adb', ['devices'])
114
- .split(/\r?\n/)
115
- .map((line) => line.match(/^(\S+)\s+device$/))
116
- .filter(Boolean)
117
- .map((match) => match[1]);
118
-
119
- if (!devices.length) {
120
- throw new Error('no_connected_device');
121
- }
122
-
123
- return devices[0];
124
- }
125
-
126
- function getScreenRecordingPids(deviceId) {
127
- const result = spawnSync('adb', ['-s', deviceId, 'shell', 'pidof', 'screenrecord'], {
128
- encoding: 'utf8',
129
- windowsHide: true,
130
- });
131
-
132
- if (result.error) throw result.error;
133
-
134
- const output = String(result.stdout || '').trim();
135
- if (result.status === 1 && !output) return [];
136
- if (result.status !== 0) {
137
- throw new Error(
138
- String(result.stderr || output || `adb exited with code ${result.status}.`).trim(),
139
- );
140
- }
141
-
142
- return output.split(/\s+/).filter(Boolean);
143
- }
144
-
145
- function waitForScreenRecordingPid(deviceId) {
146
- for (let attempt = 0; attempt < 50; attempt += 1) {
147
- const pids = getScreenRecordingPids(deviceId);
148
- if (pids.length === 1) return pids[0];
149
- if (pids.length > 1) {
150
- throw new Error(`Multiple screenrecord processes are running: ${pids.join(', ')}.`);
151
- }
152
- wait(100);
153
- }
154
-
155
- throw new Error('Android screenrecord did not start within 5 seconds.');
156
- }
157
-
158
- function stopScreenRecording(deviceId, recordingPid) {
159
- // SIGINT lets screenrecord write the MP4 index before its adb shell exits.
160
- run('adb', ['-s', deviceId, 'shell', 'kill', '-2', recordingPid]);
161
- }
162
-
163
- async function waitForRecordingFinalization(recordingCompletion) {
164
- let timeout;
165
- try {
166
- await Promise.race([
167
- recordingCompletion,
168
- new Promise((resolve, reject) => {
169
- timeout = setTimeout(
170
- () => reject(new Error('The recorder did not finalize within 15 seconds.')),
171
- 15000,
172
- );
173
- }),
174
- ]);
175
- } finally {
176
- clearTimeout(timeout);
177
- }
178
- }
179
-
180
- function sanitizeName(value) {
181
- const name = String(value || '')
182
- .trim()
183
- .replace(/\.mp4$/i, '')
184
- .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
185
- .replace(/[. ]+$/g, '');
186
- return name || 'store-video';
187
- }
188
-
189
- function getCapacitorConfig() {
190
- return JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
191
- }
192
-
193
- function getDefaultVideoName() {
194
- return sanitizeName(getCapacitorConfig().appName || 'store-video');
195
- }
196
-
197
- function parseArguments() {
198
- const args = process.argv.slice(2);
199
- const options = {
200
- audio: 'without',
201
- countdown: 5,
202
- duration: 0,
203
- help: false,
204
- input: '',
205
- list: false,
206
- name: '',
207
- profiles: 'youtube,non-spatial-xr,amazon',
208
- spatialInput: '',
209
- };
210
-
211
- for (let index = 0; index < args.length; index += 1) {
212
- const argument = args[index];
213
-
214
- if (argument === '--audio') {
215
- options.audio = String(args[index + 1] || '').toLowerCase();
216
- index += 1;
217
- } else if (argument === '--countdown') {
218
- options.countdown = Number(args[index + 1]);
219
- index += 1;
220
- } else if (argument === '--duration') {
221
- options.duration = Number(args[index + 1]);
222
- index += 1;
223
- } else if (argument === '--input') {
224
- options.input = args[index + 1] || '';
225
- index += 1;
226
- } else if (argument === '--name') {
227
- options.name = args[index + 1] || '';
228
- index += 1;
229
- } else if (argument === '--profiles') {
230
- options.profiles = args[index + 1] || '';
231
- index += 1;
232
- } else if (argument === '--spatial-input') {
233
- options.spatialInput = args[index + 1] || '';
234
- index += 1;
235
- } else if (argument === '--list') {
236
- options.list = true;
237
- } else if (argument === '--help' || argument === '-h') {
238
- options.help = true;
239
- } else {
240
- throw new Error(`Unknown argument: ${argument}`);
241
- }
242
- }
243
-
244
- if (!Number.isInteger(options.duration) || options.duration < 0 || options.duration > 180) {
245
- throw new Error('--duration must be 0 (unlimited) or a whole number from 1 to 180 seconds.');
246
- }
247
-
248
- if (!Number.isInteger(options.countdown) || options.countdown < 0 || options.countdown > 30) {
249
- throw new Error('--countdown must be a whole number from 0 to 30 seconds.');
250
- }
251
-
252
- if (!['with', 'without'].includes(options.audio)) {
253
- throw new Error('--audio must be with or without.');
254
- }
255
-
256
- options.name = options.name
257
- ? sanitizeName(options.name)
258
- : getDefaultVideoName();
259
- return options;
260
- }
261
-
262
-
263
- function selectProfiles(input) {
264
- const names = String(input)
265
- .split(',')
266
- .map((name) => name.trim().toLowerCase())
267
- .filter(Boolean);
268
-
269
- if (names.includes('all')) {
270
- return Object.keys(videoProfiles);
271
- }
272
-
273
- for (const name of names) {
274
- if (!videoProfiles[name] && name !== 'spatial-xr') {
275
- throw new Error(`Unknown profile: ${name}. Run with --list to see valid profiles.`);
276
- }
277
- }
278
-
279
- return [...new Set(names)];
280
- }
281
-
282
- function printProfiles() {
283
- for (const [name, profile] of Object.entries(videoProfiles)) {
284
- console.log(
285
- `${name.padEnd(16)} ${profile.label} (${profile.width}x${profile.height}, H.264 MP4)`,
286
- );
287
- }
288
-
289
- console.log('spatial-xr Genuine 360/180/3D source passthrough (requires --spatial-input)');
290
- }
291
-
292
- function printHelp() {
293
- console.log('Record the connected Android device and create store-ready video variants.');
294
- console.log('');
295
- console.log('Usage:');
296
- console.log(' node take-screen-video.js --name gameplay --duration 30');
297
- console.log(' node take-screen-video.js --audio with');
298
- console.log(' node take-screen-video.js --input recording.mp4 --profiles youtube,amazon');
299
- console.log(' node take-screen-video.js --profiles spatial-xr --spatial-input genuine-360.mp4');
300
- console.log(' node take-screen-video.js --profiles all');
301
- console.log(' node take-screen-video.js --list');
302
- console.log('');
303
- console.log('Defaults: --audio without --duration 0 (unlimited)');
304
- console.log(`Default name: ${getDefaultVideoName()} (capacitor.config.json appName)`);
305
- console.log('Manual mode: press Enter to stop; a positive --duration sets a safety maximum.');
306
- console.log('Audio recording uses scrcpy and requires Android 11 or newer.');
307
- }
308
-
309
- function waitForManualStop(recordingCompletion, duration) {
310
- if (!process.stdin.isTTY) {
311
- throw new Error('Manual interaction requires an interactive terminal so Enter can stop recording.');
312
- }
313
-
314
- return new Promise((resolve, reject) => {
315
- const finish = (reason) => {
316
- if (timeout) clearTimeout(timeout);
317
- process.stdin.removeListener('data', onInput);
318
- process.stdin.pause();
319
- resolve(reason);
320
- };
321
- const onInput = (input) => {
322
- if (/\r|\n/.test(String(input))) finish('enter');
323
- };
324
- const timeout = duration > 0
325
- ? setTimeout(() => finish('maximum-duration'), duration * 1000)
326
- : null;
327
-
328
- process.stdin.resume();
329
- process.stdin.on('data', onInput);
330
- recordingCompletion.then(
331
- () => finish('recorder-completed'),
332
- (error) => {
333
- if (timeout) clearTimeout(timeout);
334
- process.stdin.removeListener('data', onInput);
335
- process.stdin.pause();
336
- reject(error);
337
- },
338
- );
339
- });
340
- }
341
-
342
- function getScrcpyCommand() {
343
- return commandExists('scrcpy') ? 'scrcpy' : '';
344
- }
345
-
346
- async function recordConnectedDevice(options, destinationPath) {
347
- const { audio, countdown, duration } = options;
348
- const deviceId = getConnectedDevice();
349
- fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
350
- if (fs.existsSync(destinationPath)) {
351
- fs.unlinkSync(destinationPath);
352
- }
353
-
354
- console.log(`Connected device: ${deviceId}`);
355
- console.log(`Interaction: manual; audio: ${audio}.`);
356
- console.log('Open the required first app screen, then operate the physical device while recording.');
357
-
358
- for (let seconds = countdown; seconds > 0; seconds -= 1) {
359
- console.log(`Recording starts in ${seconds}...`);
360
- wait(1000);
361
- }
362
-
363
- console.log(
364
- duration > 0
365
- ? `RECORDING NOW — maximum ${duration} seconds.`
366
- : 'RECORDING NOW — unlimited duration; press Enter to stop.',
367
- );
368
-
369
- let recording;
370
- let recordingPid = '';
371
- if (audio === 'with') {
372
- const scrcpyCommand = getScrcpyCommand();
373
- if (!scrcpyCommand) {
374
- throw new Error(
375
- 'Audio recording requires scrcpy. Install scrcpy and add it to PATH.',
376
- );
377
- }
378
-
379
- const scrcpyArguments = [
380
- '--serial', deviceId,
381
- '--no-window',
382
- '--no-playback',
383
- '--no-control',
384
- '--require-audio',
385
- '--record', destinationPath,
386
- ];
387
- if (duration > 0) {
388
- scrcpyArguments.push('--time-limit', String(duration));
389
- }
390
- recording = runAsync(scrcpyCommand, scrcpyArguments);
391
- } else {
392
- const existingPids = getScreenRecordingPids(deviceId);
393
- if (existingPids.length) {
394
- throw new Error(
395
- `Android screenrecord is already running (PID ${existingPids.join(', ')}). Stop it before recording.`,
396
- );
397
- }
398
- run('adb', ['-s', deviceId, 'shell', 'rm', '-f', deviceRecordingPath]);
399
- recording = runAsync('adb', [
400
- '-s', deviceId,
401
- 'shell', 'screenrecord',
402
- '--bit-rate', '12000000',
403
- '--time-limit', String(duration),
404
- deviceRecordingPath,
405
- ], { stdio: ['ignore', 'inherit', 'inherit'] });
406
- recordingPid = waitForScreenRecordingPid(deviceId);
407
- }
408
-
409
- let manualStopRequested = false;
410
- console.log('MANUAL RECORDING — use the device, then press Enter here to stop and convert.');
411
- const stopReason = await waitForManualStop(recording.completion, duration);
412
-
413
- if (stopReason === 'enter') {
414
- manualStopRequested = true;
415
- console.log('Enter pressed — stopping and finalizing the recording...');
416
- if (audio === 'with') {
417
- run('adb', [
418
- '-s', deviceId,
419
- 'shell', 'pkill', '-TERM', '-f', 'com.genymobile.scrcpy.Server',
420
- ]);
421
- } else {
422
- stopScreenRecording(deviceId, recordingPid);
423
- }
424
- } else if (stopReason === 'maximum-duration') {
425
- console.log(`Safety maximum reached after ${duration} seconds.`);
426
- }
427
-
428
- await waitForRecordingFinalization(recording.completion);
429
-
430
- if (audio === 'without') {
431
- try {
432
- run('adb', ['-s', deviceId, 'pull', deviceRecordingPath, destinationPath], { inherit: true });
433
- } finally {
434
- try {
435
- run('adb', ['-s', deviceId, 'shell', 'rm', deviceRecordingPath]);
436
- } catch {
437
- // The device may have disconnected after recording.
438
- }
439
- }
440
- }
441
-
442
- console.log('RECORDING FINISHED — converting the captured video.');
443
- console.log(`Recorded connected device ${deviceId}: ${destinationPath}`);
444
- }
445
-
446
- function getUniqueOutputPath(profile, name) {
447
- const profileDirectory = path.join(outputDirectory, profile.directory);
448
- fs.mkdirSync(profileDirectory, { recursive: true });
449
-
450
- let outputPath = path.join(profileDirectory, `${name}-${profile.fileSuffix}.mp4`);
451
- let duplicateNumber = 2;
452
-
453
- while (fs.existsSync(outputPath)) {
454
- outputPath = path.join(
455
- profileDirectory,
456
- `${name}-${profile.fileSuffix}-${duplicateNumber}.mp4`,
457
- );
458
- duplicateNumber += 1;
459
- }
460
-
461
- return outputPath;
462
- }
463
-
464
- function getVideoDetails(ffprobeCommand, videoPath) {
465
- const output = run(ffprobeCommand, [
466
- '-v', 'error',
467
- '-show_entries', 'stream=width,height,codec_name,codec_type,bit_rate',
468
- '-show_entries', 'format=duration,size,bit_rate',
469
- '-of', 'json',
470
- videoPath,
471
- ]);
472
- return JSON.parse(output);
473
- }
474
-
475
- function validateVideo(ffprobeCommand, videoPath, profile) {
476
- const details = getVideoDetails(ffprobeCommand, videoPath);
477
- const stream = details.streams?.find((candidate) => candidate.codec_type === 'video') || {};
478
- const format = details.format || {};
479
- const bitRate = Number(stream.bit_rate || format.bit_rate || 0);
480
-
481
- if (stream.codec_name !== 'h264') {
482
- throw new Error(`Validation failed for ${videoPath}: expected H.264 video.`);
483
- }
484
-
485
- if (Number(stream.width) !== profile.width || Number(stream.height) !== profile.height) {
486
- throw new Error(
487
- `Validation failed for ${videoPath}: expected ${profile.width}x${profile.height}.`,
488
- );
489
- }
490
-
491
- if (profile === videoProfiles.amazon && bitRate < 1200000) {
492
- throw new Error(`Amazon video bitrate is below 1200 kbps: ${Math.round(bitRate / 1000)} kbps.`);
493
- }
494
-
495
- console.log(
496
- `Validated ${profile.label}: ${stream.width}x${stream.height}, H.264, `
497
- + `${(Number(format.duration) || 0).toFixed(1)}s, ${Math.round(bitRate / 1000)} kbps.`,
498
- );
499
- }
500
-
501
- function createVideoVariant(
502
- ffmpegCommand,
503
- ffprobeCommand,
504
- inputPath,
505
- profile,
506
- name,
507
- requireAudio,
508
- ) {
509
- const destinationPath = getUniqueOutputPath(profile, name);
510
- const videoFilter = [
511
- 'setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv',
512
- `scale=${profile.width}:${profile.height}:force_original_aspect_ratio=decrease`,
513
- `pad=${profile.width}:${profile.height}:(ow-iw)/2:(oh-ih)/2:color=black`,
514
- 'setsar=1',
515
- ].join(',');
516
-
517
- run(ffmpegCommand, [
518
- '-hide_banner',
519
- '-y',
520
- '-i', inputPath,
521
- '-map', '0:v:0',
522
- '-map', '0:a?',
523
- '-vf', videoFilter,
524
- '-c:v', 'libx264',
525
- '-preset', 'medium',
526
- '-profile:v', 'high',
527
- '-level', profile.h264Level,
528
- '-pix_fmt', 'yuv420p',
529
- '-b:v', profile.videoBitrate,
530
- '-minrate', profile.videoBitrate,
531
- '-maxrate', profile.videoBitrate,
532
- '-bufsize', profile.videoBitrate,
533
- '-x264-params', 'nal-hrd=cbr:force-cfr=1',
534
- '-r', '30',
535
- '-c:a', 'aac',
536
- '-b:a', '192k',
537
- '-movflags', '+faststart',
538
- destinationPath,
539
- ], { inherit: true });
540
-
541
- console.log(`Created ${profile.label}: ${destinationPath}`);
542
- validateVideo(ffprobeCommand, destinationPath, profile);
543
-
544
- if (requireAudio) {
545
- const details = getVideoDetails(ffprobeCommand, destinationPath);
546
- const audioStream = details.streams?.find((stream) => stream.codec_type === 'audio');
547
- if (!audioStream) {
548
- throw new Error(`Validation failed for ${destinationPath}: audio track is missing.`);
549
- }
550
- console.log(`Validated audio: ${audioStream.codec_name} track included.`);
551
- }
552
- }
553
-
554
- function copySpatialSource(spatialInput, name) {
555
- if (!spatialInput) {
556
- throw new Error(
557
- 'Spatial XR requires --spatial-input with a genuine 360-degree, 180-degree, or 3D video.',
558
- );
559
- }
560
-
561
- const sourcePath = path.resolve(spatialInput);
562
- if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
563
- throw new Error(`Spatial XR source was not found: ${sourcePath}`);
564
- }
565
-
566
- const spatialDirectory = path.join(outputDirectory, 'Google-Play-XR-Spatial');
567
- fs.mkdirSync(spatialDirectory, { recursive: true });
568
- const extension = path.extname(sourcePath) || '.mp4';
569
- let destinationPath = path.join(spatialDirectory, `${name}-spatial-xr${extension}`);
570
- let duplicateNumber = 2;
571
-
572
- while (fs.existsSync(destinationPath)) {
573
- destinationPath = path.join(
574
- spatialDirectory,
575
- `${name}-spatial-xr-${duplicateNumber}${extension}`,
576
- );
577
- duplicateNumber += 1;
578
- }
579
-
580
- fs.copyFileSync(sourcePath, destinationPath, fs.constants.COPYFILE_EXCL);
581
- console.log(`Copied genuine Spatial XR source without stripping its metadata: ${destinationPath}`);
582
- }
583
-
584
- async function main() {
585
- const options = parseArguments();
586
-
587
- if (options.help) {
588
- printHelp();
589
- return;
590
- }
591
-
592
- if (options.list) {
593
- printProfiles();
594
- return;
595
- }
596
-
597
-
598
- const selectedProfiles = selectProfiles(options.profiles);
599
- const flatProfiles = selectedProfiles.filter((name) => name !== 'spatial-xr');
600
-
601
- if (selectedProfiles.includes('spatial-xr')) {
602
- copySpatialSource(options.spatialInput, options.name);
603
- }
604
-
605
- if (!flatProfiles.length) {
606
- return;
607
- }
608
-
609
- const ffmpegCommand = getExecutable('ffmpeg');
610
- const ffprobeCommand = getExecutable('ffprobe');
611
-
612
- if (!ffmpegCommand || !ffprobeCommand) {
613
- throw new Error(
614
- 'FFmpeg was not found in PATH. Install FFmpeg, reopen the terminal, and run this script again.',
615
- );
616
- }
617
-
618
- let inputPath;
619
- if (options.input) {
620
- inputPath = path.resolve(options.input);
621
- if (!fs.existsSync(inputPath) || !fs.statSync(inputPath).isFile()) {
622
- throw new Error(`Input video was not found: ${inputPath}`);
623
- }
624
- } else {
625
- inputPath = path.join(temporaryDirectory, `${options.name}-adb-source.mp4`);
626
- await recordConnectedDevice(options, inputPath);
627
- }
628
-
629
- for (const profileName of flatProfiles) {
630
- createVideoVariant(
631
- ffmpegCommand,
632
- ffprobeCommand,
633
- inputPath,
634
- videoProfiles[profileName],
635
- options.name,
636
- options.audio === 'with',
637
- );
638
- }
639
- }
640
-
641
- main().catch((error) => {
642
- console.error(`Store video creation failed: ${error.message}`);
643
- process.exitCode = 1;
644
- });
1
+ //node take-screen-video.js
2
+
3
+ //--audio without //with,without
4
+ //--duration 0 //30,120 (0 is used "Enter" key to stop)
5
+ //--countdown 0//0,5,10
6
+ //--name test-name
7
+ //--profiles youtube,non-spatial-xr,amazon
8
+
9
+
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const { spawn, spawnSync } = require('node:child_process');
14
+
15
+ const scriptDirectory = __dirname;
16
+ const projectDirectory = scriptDirectory;
17
+ const temporaryDirectory = path.join(projectDirectory, 'agent-temp', 'store-video');
18
+ const outputDirectory = path.join(projectDirectory, 'Auto-Screenshot', 'Video', 'Output');
19
+ const deviceRecordingPath = '/sdcard/codeplay_store_video.mp4';
20
+ const capacitorConfigPath = path.join(projectDirectory, 'capacitor.config.json');
21
+
22
+ const videoProfiles = {
23
+ youtube: {
24
+ label: 'YouTube preview',
25
+ directory: 'YouTube',
26
+ fileSuffix: 'youtube',
27
+ width: 1920,
28
+ height: 1080,
29
+ videoBitrate: '8M',
30
+ h264Level: '4.2',
31
+ },
32
+ 'non-spatial-xr': {
33
+ label: 'Google Play non-spatial XR',
34
+ directory: 'Google-Play-XR-Non-Spatial',
35
+ fileSuffix: 'non-spatial-xr',
36
+ width: 3840,
37
+ height: 2160,
38
+ videoBitrate: '12M',
39
+ h264Level: '5.1',
40
+ },
41
+ amazon: {
42
+ label: 'Amazon Appstore',
43
+ directory: 'Amazon',
44
+ fileSuffix: 'amazon',
45
+ width: 1920,
46
+ height: 1080,
47
+ videoBitrate: '4M',
48
+ h264Level: '4.2',
49
+ },
50
+ };
51
+
52
+ function run(command, args, options = {}) {
53
+ const result = spawnSync(command, args, {
54
+ encoding: 'utf8',
55
+ windowsHide: true,
56
+ stdio: options.inherit ? 'inherit' : 'pipe',
57
+ ...options,
58
+ });
59
+
60
+ if (result.error) {
61
+ if (result.error.code === 'ENOENT') {
62
+ throw new Error(`${command} was not found in PATH.`);
63
+ }
64
+
65
+ throw result.error;
66
+ }
67
+
68
+ if (result.status !== 0) {
69
+ throw new Error(
70
+ String(result.stderr || result.stdout || `${command} exited with code ${result.status}.`).trim(),
71
+ );
72
+ }
73
+
74
+ return String(result.stdout || '').trim();
75
+ }
76
+
77
+ function commandExists(command) {
78
+ const result = spawnSync(command, ['-version'], {
79
+ encoding: 'utf8',
80
+ windowsHide: true,
81
+ });
82
+ return !result.error && result.status === 0;
83
+ }
84
+
85
+ function getExecutable(command) {
86
+ return commandExists(command) ? command : '';
87
+ }
88
+
89
+ function wait(milliseconds) {
90
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
91
+ }
92
+
93
+ function runAsync(command, args, options = {}) {
94
+ const child = spawn(command, args, {
95
+ windowsHide: true,
96
+ stdio: 'inherit',
97
+ ...options,
98
+ });
99
+
100
+ return {
101
+ child,
102
+ completion: new Promise((resolve, reject) => {
103
+ child.once('error', reject);
104
+ child.once('exit', (code) => {
105
+ if (code === 0) resolve();
106
+ else reject(new Error(`${command} exited with code ${code}.`));
107
+ });
108
+ }),
109
+ };
110
+ }
111
+
112
+ function getConnectedDevice() {
113
+ const devices = run('adb', ['devices'])
114
+ .split(/\r?\n/)
115
+ .map((line) => line.match(/^(\S+)\s+device$/))
116
+ .filter(Boolean)
117
+ .map((match) => match[1]);
118
+
119
+ if (!devices.length) {
120
+ throw new Error('no_connected_device');
121
+ }
122
+
123
+ return devices[0];
124
+ }
125
+
126
+ function getScreenRecordingPids(deviceId) {
127
+ const result = spawnSync('adb', ['-s', deviceId, 'shell', 'pidof', 'screenrecord'], {
128
+ encoding: 'utf8',
129
+ windowsHide: true,
130
+ });
131
+
132
+ if (result.error) throw result.error;
133
+
134
+ const output = String(result.stdout || '').trim();
135
+ if (result.status === 1 && !output) return [];
136
+ if (result.status !== 0) {
137
+ throw new Error(
138
+ String(result.stderr || output || `adb exited with code ${result.status}.`).trim(),
139
+ );
140
+ }
141
+
142
+ return output.split(/\s+/).filter(Boolean);
143
+ }
144
+
145
+ function waitForScreenRecordingPid(deviceId) {
146
+ for (let attempt = 0; attempt < 50; attempt += 1) {
147
+ const pids = getScreenRecordingPids(deviceId);
148
+ if (pids.length === 1) return pids[0];
149
+ if (pids.length > 1) {
150
+ throw new Error(`Multiple screenrecord processes are running: ${pids.join(', ')}.`);
151
+ }
152
+ wait(100);
153
+ }
154
+
155
+ throw new Error('Android screenrecord did not start within 5 seconds.');
156
+ }
157
+
158
+ function stopScreenRecording(deviceId, recordingPid) {
159
+ // SIGINT lets screenrecord write the MP4 index before its adb shell exits.
160
+ run('adb', ['-s', deviceId, 'shell', 'kill', '-2', recordingPid]);
161
+ }
162
+
163
+ async function waitForRecordingFinalization(recordingCompletion) {
164
+ let timeout;
165
+ try {
166
+ await Promise.race([
167
+ recordingCompletion,
168
+ new Promise((resolve, reject) => {
169
+ timeout = setTimeout(
170
+ () => reject(new Error('The recorder did not finalize within 15 seconds.')),
171
+ 15000,
172
+ );
173
+ }),
174
+ ]);
175
+ } finally {
176
+ clearTimeout(timeout);
177
+ }
178
+ }
179
+
180
+ function sanitizeName(value) {
181
+ const name = String(value || '')
182
+ .trim()
183
+ .replace(/\.mp4$/i, '')
184
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
185
+ .replace(/[. ]+$/g, '');
186
+ return name || 'store-video';
187
+ }
188
+
189
+ function getCapacitorConfig() {
190
+ return JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
191
+ }
192
+
193
+ function getDefaultVideoName() {
194
+ return sanitizeName(getCapacitorConfig().appName || 'store-video');
195
+ }
196
+
197
+ function parseArguments() {
198
+ const args = process.argv.slice(2);
199
+ const options = {
200
+ audio: 'without',
201
+ countdown: 5,
202
+ duration: 0,
203
+ help: false,
204
+ input: '',
205
+ list: false,
206
+ name: '',
207
+ profiles: 'youtube,non-spatial-xr,amazon',
208
+ spatialInput: '',
209
+ };
210
+
211
+ for (let index = 0; index < args.length; index += 1) {
212
+ const argument = args[index];
213
+
214
+ if (argument === '--audio') {
215
+ options.audio = String(args[index + 1] || '').toLowerCase();
216
+ index += 1;
217
+ } else if (argument === '--countdown') {
218
+ options.countdown = Number(args[index + 1]);
219
+ index += 1;
220
+ } else if (argument === '--duration') {
221
+ options.duration = Number(args[index + 1]);
222
+ index += 1;
223
+ } else if (argument === '--input') {
224
+ options.input = args[index + 1] || '';
225
+ index += 1;
226
+ } else if (argument === '--name') {
227
+ options.name = args[index + 1] || '';
228
+ index += 1;
229
+ } else if (argument === '--profiles') {
230
+ options.profiles = args[index + 1] || '';
231
+ index += 1;
232
+ } else if (argument === '--spatial-input') {
233
+ options.spatialInput = args[index + 1] || '';
234
+ index += 1;
235
+ } else if (argument === '--list') {
236
+ options.list = true;
237
+ } else if (argument === '--help' || argument === '-h') {
238
+ options.help = true;
239
+ } else {
240
+ throw new Error(`Unknown argument: ${argument}`);
241
+ }
242
+ }
243
+
244
+ if (!Number.isInteger(options.duration) || options.duration < 0 || options.duration > 180) {
245
+ throw new Error('--duration must be 0 (unlimited) or a whole number from 1 to 180 seconds.');
246
+ }
247
+
248
+ if (!Number.isInteger(options.countdown) || options.countdown < 0 || options.countdown > 30) {
249
+ throw new Error('--countdown must be a whole number from 0 to 30 seconds.');
250
+ }
251
+
252
+ if (!['with', 'without'].includes(options.audio)) {
253
+ throw new Error('--audio must be with or without.');
254
+ }
255
+
256
+ options.name = options.name
257
+ ? sanitizeName(options.name)
258
+ : getDefaultVideoName();
259
+ return options;
260
+ }
261
+
262
+
263
+ function selectProfiles(input) {
264
+ const names = String(input)
265
+ .split(',')
266
+ .map((name) => name.trim().toLowerCase())
267
+ .filter(Boolean);
268
+
269
+ if (names.includes('all')) {
270
+ return Object.keys(videoProfiles);
271
+ }
272
+
273
+ for (const name of names) {
274
+ if (!videoProfiles[name] && name !== 'spatial-xr') {
275
+ throw new Error(`Unknown profile: ${name}. Run with --list to see valid profiles.`);
276
+ }
277
+ }
278
+
279
+ return [...new Set(names)];
280
+ }
281
+
282
+ function printProfiles() {
283
+ for (const [name, profile] of Object.entries(videoProfiles)) {
284
+ console.log(
285
+ `${name.padEnd(16)} ${profile.label} (${profile.width}x${profile.height}, H.264 MP4)`,
286
+ );
287
+ }
288
+
289
+ console.log('spatial-xr Genuine 360/180/3D source passthrough (requires --spatial-input)');
290
+ }
291
+
292
+ function printHelp() {
293
+ console.log('Record the connected Android device and create store-ready video variants.');
294
+ console.log('');
295
+ console.log('Usage:');
296
+ console.log(' node take-screen-video.js --name gameplay --duration 30');
297
+ console.log(' node take-screen-video.js --audio with');
298
+ console.log(' node take-screen-video.js --input recording.mp4 --profiles youtube,amazon');
299
+ console.log(' node take-screen-video.js --profiles spatial-xr --spatial-input genuine-360.mp4');
300
+ console.log(' node take-screen-video.js --profiles all');
301
+ console.log(' node take-screen-video.js --list');
302
+ console.log('');
303
+ console.log('Defaults: --audio without --duration 0 (unlimited)');
304
+ console.log(`Default name: ${getDefaultVideoName()} (capacitor.config.json appName)`);
305
+ console.log('Manual mode: press Enter to stop; a positive --duration sets a safety maximum.');
306
+ console.log('Audio recording uses scrcpy and requires Android 11 or newer.');
307
+ }
308
+
309
+ function waitForManualStop(recordingCompletion, duration) {
310
+ if (!process.stdin.isTTY) {
311
+ throw new Error('Manual interaction requires an interactive terminal so Enter can stop recording.');
312
+ }
313
+
314
+ return new Promise((resolve, reject) => {
315
+ const finish = (reason) => {
316
+ if (timeout) clearTimeout(timeout);
317
+ process.stdin.removeListener('data', onInput);
318
+ process.stdin.pause();
319
+ resolve(reason);
320
+ };
321
+ const onInput = (input) => {
322
+ if (/\r|\n/.test(String(input))) finish('enter');
323
+ };
324
+ const timeout = duration > 0
325
+ ? setTimeout(() => finish('maximum-duration'), duration * 1000)
326
+ : null;
327
+
328
+ process.stdin.resume();
329
+ process.stdin.on('data', onInput);
330
+ recordingCompletion.then(
331
+ () => finish('recorder-completed'),
332
+ (error) => {
333
+ if (timeout) clearTimeout(timeout);
334
+ process.stdin.removeListener('data', onInput);
335
+ process.stdin.pause();
336
+ reject(error);
337
+ },
338
+ );
339
+ });
340
+ }
341
+
342
+ function getScrcpyCommand() {
343
+ return commandExists('scrcpy') ? 'scrcpy' : '';
344
+ }
345
+
346
+ async function recordConnectedDevice(options, destinationPath) {
347
+ const { audio, countdown, duration } = options;
348
+ const deviceId = getConnectedDevice();
349
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
350
+ if (fs.existsSync(destinationPath)) {
351
+ fs.unlinkSync(destinationPath);
352
+ }
353
+
354
+ console.log(`Connected device: ${deviceId}`);
355
+ console.log(`Interaction: manual; audio: ${audio}.`);
356
+ console.log('Open the required first app screen, then operate the physical device while recording.');
357
+
358
+ for (let seconds = countdown; seconds > 0; seconds -= 1) {
359
+ console.log(`Recording starts in ${seconds}...`);
360
+ wait(1000);
361
+ }
362
+
363
+ console.log(
364
+ duration > 0
365
+ ? `RECORDING NOW — maximum ${duration} seconds.`
366
+ : 'RECORDING NOW — unlimited duration; press Enter to stop.',
367
+ );
368
+
369
+ let recording;
370
+ let recordingPid = '';
371
+ if (audio === 'with') {
372
+ const scrcpyCommand = getScrcpyCommand();
373
+ if (!scrcpyCommand) {
374
+ throw new Error(
375
+ 'Audio recording requires scrcpy. Install scrcpy and add it to PATH.',
376
+ );
377
+ }
378
+
379
+ const scrcpyArguments = [
380
+ '--serial', deviceId,
381
+ '--no-window',
382
+ '--no-playback',
383
+ '--no-control',
384
+ '--require-audio',
385
+ '--record', destinationPath,
386
+ ];
387
+ if (duration > 0) {
388
+ scrcpyArguments.push('--time-limit', String(duration));
389
+ }
390
+ recording = runAsync(scrcpyCommand, scrcpyArguments);
391
+ } else {
392
+ const existingPids = getScreenRecordingPids(deviceId);
393
+ if (existingPids.length) {
394
+ throw new Error(
395
+ `Android screenrecord is already running (PID ${existingPids.join(', ')}). Stop it before recording.`,
396
+ );
397
+ }
398
+ run('adb', ['-s', deviceId, 'shell', 'rm', '-f', deviceRecordingPath]);
399
+ recording = runAsync('adb', [
400
+ '-s', deviceId,
401
+ 'shell', 'screenrecord',
402
+ '--bit-rate', '12000000',
403
+ '--time-limit', String(duration),
404
+ deviceRecordingPath,
405
+ ], { stdio: ['ignore', 'inherit', 'inherit'] });
406
+ recordingPid = waitForScreenRecordingPid(deviceId);
407
+ }
408
+
409
+ let manualStopRequested = false;
410
+ console.log('MANUAL RECORDING — use the device, then press Enter here to stop and convert.');
411
+ const stopReason = await waitForManualStop(recording.completion, duration);
412
+
413
+ if (stopReason === 'enter') {
414
+ manualStopRequested = true;
415
+ console.log('Enter pressed — stopping and finalizing the recording...');
416
+ if (audio === 'with') {
417
+ run('adb', [
418
+ '-s', deviceId,
419
+ 'shell', 'pkill', '-TERM', '-f', 'com.genymobile.scrcpy.Server',
420
+ ]);
421
+ } else {
422
+ stopScreenRecording(deviceId, recordingPid);
423
+ }
424
+ } else if (stopReason === 'maximum-duration') {
425
+ console.log(`Safety maximum reached after ${duration} seconds.`);
426
+ }
427
+
428
+ await waitForRecordingFinalization(recording.completion);
429
+
430
+ if (audio === 'without') {
431
+ try {
432
+ run('adb', ['-s', deviceId, 'pull', deviceRecordingPath, destinationPath], { inherit: true });
433
+ } finally {
434
+ try {
435
+ run('adb', ['-s', deviceId, 'shell', 'rm', deviceRecordingPath]);
436
+ } catch {
437
+ // The device may have disconnected after recording.
438
+ }
439
+ }
440
+ }
441
+
442
+ console.log('RECORDING FINISHED — converting the captured video.');
443
+ console.log(`Recorded connected device ${deviceId}: ${destinationPath}`);
444
+ }
445
+
446
+ function getUniqueOutputPath(profile, name) {
447
+ const profileDirectory = path.join(outputDirectory, profile.directory);
448
+ fs.mkdirSync(profileDirectory, { recursive: true });
449
+
450
+ let outputPath = path.join(profileDirectory, `${name}-${profile.fileSuffix}.mp4`);
451
+ let duplicateNumber = 2;
452
+
453
+ while (fs.existsSync(outputPath)) {
454
+ outputPath = path.join(
455
+ profileDirectory,
456
+ `${name}-${profile.fileSuffix}-${duplicateNumber}.mp4`,
457
+ );
458
+ duplicateNumber += 1;
459
+ }
460
+
461
+ return outputPath;
462
+ }
463
+
464
+ function getVideoDetails(ffprobeCommand, videoPath) {
465
+ const output = run(ffprobeCommand, [
466
+ '-v', 'error',
467
+ '-show_entries', 'stream=width,height,codec_name,codec_type,bit_rate',
468
+ '-show_entries', 'format=duration,size,bit_rate',
469
+ '-of', 'json',
470
+ videoPath,
471
+ ]);
472
+ return JSON.parse(output);
473
+ }
474
+
475
+ function validateVideo(ffprobeCommand, videoPath, profile) {
476
+ const details = getVideoDetails(ffprobeCommand, videoPath);
477
+ const stream = details.streams?.find((candidate) => candidate.codec_type === 'video') || {};
478
+ const format = details.format || {};
479
+ const bitRate = Number(stream.bit_rate || format.bit_rate || 0);
480
+
481
+ if (stream.codec_name !== 'h264') {
482
+ throw new Error(`Validation failed for ${videoPath}: expected H.264 video.`);
483
+ }
484
+
485
+ if (Number(stream.width) !== profile.width || Number(stream.height) !== profile.height) {
486
+ throw new Error(
487
+ `Validation failed for ${videoPath}: expected ${profile.width}x${profile.height}.`,
488
+ );
489
+ }
490
+
491
+ if (profile === videoProfiles.amazon && bitRate < 1200000) {
492
+ throw new Error(`Amazon video bitrate is below 1200 kbps: ${Math.round(bitRate / 1000)} kbps.`);
493
+ }
494
+
495
+ console.log(
496
+ `Validated ${profile.label}: ${stream.width}x${stream.height}, H.264, `
497
+ + `${(Number(format.duration) || 0).toFixed(1)}s, ${Math.round(bitRate / 1000)} kbps.`,
498
+ );
499
+ }
500
+
501
+ function createVideoVariant(
502
+ ffmpegCommand,
503
+ ffprobeCommand,
504
+ inputPath,
505
+ profile,
506
+ name,
507
+ requireAudio,
508
+ ) {
509
+ const destinationPath = getUniqueOutputPath(profile, name);
510
+ const videoFilter = [
511
+ 'setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv',
512
+ `scale=${profile.width}:${profile.height}:force_original_aspect_ratio=decrease`,
513
+ `pad=${profile.width}:${profile.height}:(ow-iw)/2:(oh-ih)/2:color=black`,
514
+ 'setsar=1',
515
+ ].join(',');
516
+
517
+ run(ffmpegCommand, [
518
+ '-hide_banner',
519
+ '-y',
520
+ '-i', inputPath,
521
+ '-map', '0:v:0',
522
+ '-map', '0:a?',
523
+ '-vf', videoFilter,
524
+ '-c:v', 'libx264',
525
+ '-preset', 'medium',
526
+ '-profile:v', 'high',
527
+ '-level', profile.h264Level,
528
+ '-pix_fmt', 'yuv420p',
529
+ '-b:v', profile.videoBitrate,
530
+ '-minrate', profile.videoBitrate,
531
+ '-maxrate', profile.videoBitrate,
532
+ '-bufsize', profile.videoBitrate,
533
+ '-x264-params', 'nal-hrd=cbr:force-cfr=1',
534
+ '-r', '30',
535
+ '-c:a', 'aac',
536
+ '-b:a', '192k',
537
+ '-movflags', '+faststart',
538
+ destinationPath,
539
+ ], { inherit: true });
540
+
541
+ console.log(`Created ${profile.label}: ${destinationPath}`);
542
+ validateVideo(ffprobeCommand, destinationPath, profile);
543
+
544
+ if (requireAudio) {
545
+ const details = getVideoDetails(ffprobeCommand, destinationPath);
546
+ const audioStream = details.streams?.find((stream) => stream.codec_type === 'audio');
547
+ if (!audioStream) {
548
+ throw new Error(`Validation failed for ${destinationPath}: audio track is missing.`);
549
+ }
550
+ console.log(`Validated audio: ${audioStream.codec_name} track included.`);
551
+ }
552
+ }
553
+
554
+ function copySpatialSource(spatialInput, name) {
555
+ if (!spatialInput) {
556
+ throw new Error(
557
+ 'Spatial XR requires --spatial-input with a genuine 360-degree, 180-degree, or 3D video.',
558
+ );
559
+ }
560
+
561
+ const sourcePath = path.resolve(spatialInput);
562
+ if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
563
+ throw new Error(`Spatial XR source was not found: ${sourcePath}`);
564
+ }
565
+
566
+ const spatialDirectory = path.join(outputDirectory, 'Google-Play-XR-Spatial');
567
+ fs.mkdirSync(spatialDirectory, { recursive: true });
568
+ const extension = path.extname(sourcePath) || '.mp4';
569
+ let destinationPath = path.join(spatialDirectory, `${name}-spatial-xr${extension}`);
570
+ let duplicateNumber = 2;
571
+
572
+ while (fs.existsSync(destinationPath)) {
573
+ destinationPath = path.join(
574
+ spatialDirectory,
575
+ `${name}-spatial-xr-${duplicateNumber}${extension}`,
576
+ );
577
+ duplicateNumber += 1;
578
+ }
579
+
580
+ fs.copyFileSync(sourcePath, destinationPath, fs.constants.COPYFILE_EXCL);
581
+ console.log(`Copied genuine Spatial XR source without stripping its metadata: ${destinationPath}`);
582
+ }
583
+
584
+ async function main() {
585
+ const options = parseArguments();
586
+
587
+ if (options.help) {
588
+ printHelp();
589
+ return;
590
+ }
591
+
592
+ if (options.list) {
593
+ printProfiles();
594
+ return;
595
+ }
596
+
597
+
598
+ const selectedProfiles = selectProfiles(options.profiles);
599
+ const flatProfiles = selectedProfiles.filter((name) => name !== 'spatial-xr');
600
+
601
+ if (selectedProfiles.includes('spatial-xr')) {
602
+ copySpatialSource(options.spatialInput, options.name);
603
+ }
604
+
605
+ if (!flatProfiles.length) {
606
+ return;
607
+ }
608
+
609
+ const ffmpegCommand = getExecutable('ffmpeg');
610
+ const ffprobeCommand = getExecutable('ffprobe');
611
+
612
+ if (!ffmpegCommand || !ffprobeCommand) {
613
+ throw new Error(
614
+ 'FFmpeg was not found in PATH. Install FFmpeg, reopen the terminal, and run this script again.',
615
+ );
616
+ }
617
+
618
+ let inputPath;
619
+ if (options.input) {
620
+ inputPath = path.resolve(options.input);
621
+ if (!fs.existsSync(inputPath) || !fs.statSync(inputPath).isFile()) {
622
+ throw new Error(`Input video was not found: ${inputPath}`);
623
+ }
624
+ } else {
625
+ inputPath = path.join(temporaryDirectory, `${options.name}-adb-source.mp4`);
626
+ await recordConnectedDevice(options, inputPath);
627
+ }
628
+
629
+ for (const profileName of flatProfiles) {
630
+ createVideoVariant(
631
+ ffmpegCommand,
632
+ ffprobeCommand,
633
+ inputPath,
634
+ videoProfiles[profileName],
635
+ options.name,
636
+ options.audio === 'with',
637
+ );
638
+ }
639
+ }
640
+
641
+ main().catch((error) => {
642
+ console.error(`Store video creation failed: ${error.message}`);
643
+ process.exitCode = 1;
644
+ });