@vidtreo/recorder 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +1644 -1595
  2. package/dist/index.js +432 -69
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -300,7 +300,8 @@ function getDefaultConfigForFormat(format) {
300
300
  };
301
301
  }
302
302
  // src/core/processor/codec-detector.ts
303
- var VIDEO_CODEC_HEVC = "hevc";
303
+ var VIDEO_CODEC_AV1 = "av1";
304
+ var VIDEO_CODEC_VP9 = "vp9";
304
305
  var VIDEO_CODEC_AVC = "avc";
305
306
  var AUDIO_CODEC_AAC = "aac";
306
307
  var AUDIO_CODEC_OPUS = "opus";
@@ -347,6 +348,9 @@ function createVideoCodecSupportResult(supported) {
347
348
  function createVideoCodecSupportError(error) {
348
349
  return { kind: "error", error };
349
350
  }
351
+ async function getVideoCodecSupport(canEncodeVideo, codec, checkOptions) {
352
+ return await canEncodeVideo(codec, checkOptions).then((supported) => createVideoCodecSupportResult(supported)).catch((error) => createVideoCodecSupportError(error));
353
+ }
350
354
  function createAudioCodecLookupResult(codec) {
351
355
  return { kind: "result", codec };
352
356
  }
@@ -363,15 +367,41 @@ async function detectBestCodec(width, height, bitrate, dependencies) {
363
367
  return VIDEO_CODEC_AVC;
364
368
  }
365
369
  const checkOptions = buildVideoCodecCheckOptions(width, height, bitrate);
366
- const videoCodecSupportResult = await canEncodeVideo(VIDEO_CODEC_HEVC, checkOptions).then((supported) => createVideoCodecSupportResult(supported)).catch((error) => createVideoCodecSupportError(error));
370
+ const videoCodecSupportResult = await getVideoCodecSupport(canEncodeVideo, VIDEO_CODEC_AV1, checkOptions);
367
371
  if (videoCodecSupportResult.kind === "error") {
368
372
  return VIDEO_CODEC_AVC;
369
373
  }
370
374
  if (videoCodecSupportResult.supported) {
371
- return VIDEO_CODEC_HEVC;
375
+ return VIDEO_CODEC_AV1;
372
376
  }
373
377
  return VIDEO_CODEC_AVC;
374
378
  }
379
+ async function detectBestWebmCodec(width, height, bitrate, dependencies) {
380
+ const mediabunnyModuleResult = await resolveMediabunnyModule(dependencies).then((module) => createMediabunnyModuleResult(module)).catch((error) => createMediabunnyModuleError(error));
381
+ if (mediabunnyModuleResult.kind === "error") {
382
+ return VIDEO_CODEC_VP9;
383
+ }
384
+ const { canEncodeVideo } = mediabunnyModuleResult.module;
385
+ if (typeof canEncodeVideo !== "function") {
386
+ return VIDEO_CODEC_VP9;
387
+ }
388
+ const checkOptions = buildVideoCodecCheckOptions(width, height, bitrate);
389
+ const av1SupportResult = await getVideoCodecSupport(canEncodeVideo, VIDEO_CODEC_AV1, checkOptions);
390
+ if (av1SupportResult.kind === "error") {
391
+ return VIDEO_CODEC_VP9;
392
+ }
393
+ if (av1SupportResult.supported) {
394
+ return VIDEO_CODEC_AV1;
395
+ }
396
+ const vp9SupportResult = await getVideoCodecSupport(canEncodeVideo, VIDEO_CODEC_VP9, checkOptions);
397
+ if (vp9SupportResult.kind === "error") {
398
+ return VIDEO_CODEC_VP9;
399
+ }
400
+ if (vp9SupportResult.supported) {
401
+ return VIDEO_CODEC_VP9;
402
+ }
403
+ return VIDEO_CODEC_VP9;
404
+ }
375
405
  async function detectBestAudioCodec(bitrate, dependencies) {
376
406
  const mediabunnyModuleResult = await resolveMediabunnyModule(dependencies).then((module) => createMediabunnyModuleResult(module)).catch((error) => createMediabunnyModuleError(error));
377
407
  if (mediabunnyModuleResult.kind === "error") {
@@ -735,6 +765,7 @@ import {
735
765
  WebMOutputFormat
736
766
  } from "mediabunny";
737
767
  var ALLOW_ROTATION_METADATA = false;
768
+ var OUTPUT_FORMAT_WEBM = "webm";
738
769
  function createSource(input) {
739
770
  if (typeof input === "string") {
740
771
  return new FilePathSource(input);
@@ -772,6 +803,12 @@ function getMimeTypeForFormat(format) {
772
803
  throw new Error(`Unsupported output format: ${format}`);
773
804
  }
774
805
  }
806
+ async function resolvePreferredVideoCodec(config) {
807
+ if (config.format === OUTPUT_FORMAT_WEBM) {
808
+ return await detectBestWebmCodec(config.width, config.height, config.bitrate);
809
+ }
810
+ return await detectBestCodec(config.width, config.height, config.bitrate);
811
+ }
775
812
  function createConversionOptions(config, optimizeForSpeed = false) {
776
813
  const audioCodec = getAudioCodecForFormat(config.format, config.audioCodec);
777
814
  const video = {
@@ -822,6 +859,7 @@ async function transcodeVideo(input, config = {}, onProgress) {
822
859
  if (!finalConfig.audioCodec) {
823
860
  finalConfig.audioCodec = await detectBestAudioCodec(finalConfig.audioBitrate);
824
861
  }
862
+ finalConfig.codec = await resolvePreferredVideoCodec(finalConfig);
825
863
  const source = createSource(input);
826
864
  const mediabunnyInput = new Input2({
827
865
  formats: ALL_FORMATS,
@@ -861,6 +899,7 @@ async function transcodeVideoForNativeCamera(file, config = {}, onProgress) {
861
899
  if (!finalConfig.audioCodec) {
862
900
  finalConfig.audioCodec = await detectBestAudioCodec(finalConfig.audioBitrate);
863
901
  }
902
+ finalConfig.codec = await resolvePreferredVideoCodec(finalConfig);
864
903
  const source = new BlobSource2(file);
865
904
  const mediabunnyInput = new Input2({
866
905
  formats: ALL_FORMATS,
@@ -1341,7 +1380,7 @@ function getEmptyProbeResult() {
1341
1380
  }
1342
1381
  // src/core/storage/video-storage.ts
1343
1382
  var DB_NAME = "vidtreo-recorder";
1344
- var DB_VERSION = 1;
1383
+ var DB_VERSION = 2;
1345
1384
  var STORE_NAME = "pending-uploads";
1346
1385
  var STATUS_INDEX = "status";
1347
1386
  var CREATED_AT_INDEX = "createdAt";
@@ -1350,28 +1389,55 @@ var MAX_RETRIES = 10;
1350
1389
  var MILLISECONDS_PER_HOUR = 60 * 60 * 1000;
1351
1390
  var ID_PREFIX = "upload-";
1352
1391
  var ID_RANDOM_LENGTH = 9;
1392
+ var VERSION_ERROR_NAME = "VersionError";
1393
+ var ERROR_SCHEMA_MISSING_STORE = "Database schema is missing required object store: pending-uploads";
1394
+ var ERROR_SCHEMA_MISSING_STATUS_INDEX = "Database schema is missing required index: status";
1395
+ var ERROR_SCHEMA_MISSING_CREATED_AT_INDEX = "Database schema is missing required index: createdAt";
1353
1396
 
1354
1397
  class VideoStorageService {
1355
1398
  db = null;
1399
+ databaseFactory;
1400
+ constructor(databaseFactory) {
1401
+ if (databaseFactory) {
1402
+ this.databaseFactory = databaseFactory;
1403
+ return;
1404
+ }
1405
+ this.databaseFactory = indexedDB;
1406
+ }
1356
1407
  init() {
1357
1408
  if (this.db) {
1358
1409
  return Promise.resolve();
1359
1410
  }
1411
+ return this.openDatabase(DB_VERSION, true);
1412
+ }
1413
+ openDatabase(databaseVersion, canRetryWithoutVersion) {
1360
1414
  return new Promise((resolve, reject) => {
1361
- const request = indexedDB.open(DB_NAME, DB_VERSION);
1415
+ const request = this.createOpenRequest(databaseVersion);
1362
1416
  request.onerror = () => {
1363
- if (request.error) {
1364
- reject(request.error);
1365
- } else {
1366
- reject(new Error("Failed to open database"));
1417
+ const requestError = request.error;
1418
+ if (canRetryWithoutVersion && requestError && requestError.name === VERSION_ERROR_NAME) {
1419
+ this.openDatabase(undefined, false).then(resolve).catch(reject);
1420
+ return;
1367
1421
  }
1422
+ if (requestError) {
1423
+ reject(requestError);
1424
+ return;
1425
+ }
1426
+ reject(new Error("Failed to open database"));
1368
1427
  };
1369
1428
  request.onsuccess = () => {
1370
1429
  if (!request.result) {
1371
1430
  reject(new Error("Database result is null"));
1372
1431
  return;
1373
1432
  }
1374
- this.db = request.result;
1433
+ const database = request.result;
1434
+ const schemaValidationError = this.validateRequiredSchema(database);
1435
+ if (schemaValidationError) {
1436
+ database.close();
1437
+ reject(schemaValidationError);
1438
+ return;
1439
+ }
1440
+ this.db = database;
1375
1441
  resolve();
1376
1442
  };
1377
1443
  request.onupgradeneeded = (event) => {
@@ -1380,16 +1446,42 @@ class VideoStorageService {
1380
1446
  reject(new Error("Database upgrade result is null"));
1381
1447
  return;
1382
1448
  }
1383
- if (!db.objectStoreNames.contains(STORE_NAME)) {
1384
- const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
1385
- store.createIndex(STATUS_INDEX, STATUS_INDEX, { unique: false });
1386
- store.createIndex(CREATED_AT_INDEX, CREATED_AT_INDEX, {
1387
- unique: false
1388
- });
1389
- }
1449
+ this.initializeStoreSchema(db);
1390
1450
  };
1391
1451
  });
1392
1452
  }
1453
+ createOpenRequest(databaseVersion) {
1454
+ if (databaseVersion === undefined) {
1455
+ return this.databaseFactory.open(DB_NAME);
1456
+ }
1457
+ return this.databaseFactory.open(DB_NAME, databaseVersion);
1458
+ }
1459
+ initializeStoreSchema(database) {
1460
+ if (database.objectStoreNames.contains(STORE_NAME)) {
1461
+ return;
1462
+ }
1463
+ const objectStore = database.createObjectStore(STORE_NAME, {
1464
+ keyPath: "id"
1465
+ });
1466
+ objectStore.createIndex(STATUS_INDEX, STATUS_INDEX, { unique: false });
1467
+ objectStore.createIndex(CREATED_AT_INDEX, CREATED_AT_INDEX, {
1468
+ unique: false
1469
+ });
1470
+ }
1471
+ validateRequiredSchema(database) {
1472
+ if (!database.objectStoreNames.contains(STORE_NAME)) {
1473
+ return new Error(ERROR_SCHEMA_MISSING_STORE);
1474
+ }
1475
+ const transaction = database.transaction([STORE_NAME], "readonly");
1476
+ const store = transaction.objectStore(STORE_NAME);
1477
+ if (!store.indexNames.contains(STATUS_INDEX)) {
1478
+ return new Error(ERROR_SCHEMA_MISSING_STATUS_INDEX);
1479
+ }
1480
+ if (!store.indexNames.contains(CREATED_AT_INDEX)) {
1481
+ return new Error(ERROR_SCHEMA_MISSING_CREATED_AT_INDEX);
1482
+ }
1483
+ return null;
1484
+ }
1393
1485
  isInitialized() {
1394
1486
  return this.db !== null;
1395
1487
  }
@@ -3105,9 +3197,11 @@ class StreamRecordingState {
3105
3197
  this.startRecordingTimer();
3106
3198
  }
3107
3199
  async stopRecording() {
3200
+ const recordingElapsedSeconds = (performance.now() - this.recordingStartTime - this.totalPausedTime) / MILLISECONDS_PER_SECOND;
3108
3201
  logger.debug("[StreamRecordingState] stopRecording called", {
3109
3202
  hasStreamProcessor: !!this.streamProcessor,
3110
- isRecording: this.isRecording()
3203
+ isRecording: this.isRecording(),
3204
+ recordingElapsedSeconds
3111
3205
  });
3112
3206
  if (!(this.streamProcessor && this.isRecording())) {
3113
3207
  throw new Error("Not currently recording");
@@ -3429,7 +3523,7 @@ class CameraStreamManager {
3429
3523
  // package.json
3430
3524
  var package_default = {
3431
3525
  name: "@vidtreo/recorder",
3432
- version: "1.2.0",
3526
+ version: "1.2.1",
3433
3527
  type: "module",
3434
3528
  description: "Vidtreo SDK for browser-based video recording and transcoding. Features include camera/screen recording, real-time MP4 transcoding, audio level analysis, mute/pause controls, source switching, device selection, and automatic backend uploads. Similar to Ziggeo and Addpipe, Vidtreo provides enterprise-grade video processing capabilities for web applications.",
3435
3529
  main: "./dist/index.js",
@@ -4348,6 +4442,118 @@ function serializeBitrate(bitrate) {
4348
4442
  return "high";
4349
4443
  }
4350
4444
 
4445
+ // src/core/processor/mp4-container-guard.ts
4446
+ var MP4_BOX_HEADER_BYTES = 8;
4447
+ var MP4_BOX_TYPE_OFFSET_BYTES = 4;
4448
+ var MP4_EXTENDED_SIZE_MARKER = 1;
4449
+ var MP4_ZERO_SIZE_MARKER = 0;
4450
+ var MP4_EXTENDED_SIZE_BYTES = 8;
4451
+ var MP4_EXTENDED_BOX_HEADER_BYTES = MP4_BOX_HEADER_BYTES + MP4_EXTENDED_SIZE_BYTES;
4452
+ var TWO_POWER_32 = 4294967296;
4453
+ var MP4_FRAGMENT_BOX_TYPE_MOOF = "moof";
4454
+ var MP4_FRAGMENT_BOX_TYPE_MFRA = "mfra";
4455
+ var ERROR_RECORDING_INVALID_CONTAINER_LAYOUT = "recording.invalid-container-layout";
4456
+ function createInvalidMp4ContainerLayoutError(detectedBoxTypes) {
4457
+ const error = new Error(ERROR_RECORDING_INVALID_CONTAINER_LAYOUT);
4458
+ error.code = ERROR_RECORDING_INVALID_CONTAINER_LAYOUT;
4459
+ error.detectedBoxTypes = detectedBoxTypes;
4460
+ return error;
4461
+ }
4462
+ function toUint8Array(input) {
4463
+ if (input instanceof Uint8Array) {
4464
+ return input;
4465
+ }
4466
+ return new Uint8Array(input);
4467
+ }
4468
+ function readBoxType(view, offset) {
4469
+ const firstCharCode = view.getUint8(offset);
4470
+ const secondCharCode = view.getUint8(offset + 1);
4471
+ const thirdCharCode = view.getUint8(offset + 2);
4472
+ const fourthCharCode = view.getUint8(offset + 3);
4473
+ return String.fromCharCode(firstCharCode, secondCharCode, thirdCharCode, fourthCharCode);
4474
+ }
4475
+ function readLargeSize(view, offset) {
4476
+ const highBits = view.getUint32(offset, false);
4477
+ const lowBits = view.getUint32(offset + MP4_BOX_TYPE_OFFSET_BYTES, false);
4478
+ return highBits * TWO_POWER_32 + lowBits;
4479
+ }
4480
+ function getUniqueValues(values) {
4481
+ const uniqueValues = new Set;
4482
+ for (const value of values) {
4483
+ uniqueValues.add(value);
4484
+ }
4485
+ return [...uniqueValues];
4486
+ }
4487
+ function parseMp4TopLevelBoxes(input) {
4488
+ const bytes = toUint8Array(input);
4489
+ const totalBytes = bytes.byteLength;
4490
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
4491
+ const boxes = [];
4492
+ let currentOffset = 0;
4493
+ while (currentOffset < totalBytes) {
4494
+ const hasMinimumHeader = currentOffset + MP4_BOX_HEADER_BYTES <= totalBytes;
4495
+ if (!hasMinimumHeader) {
4496
+ const detectedBoxTypes = boxes.map((box) => box.type);
4497
+ throw createInvalidMp4ContainerLayoutError(detectedBoxTypes);
4498
+ }
4499
+ const declaredSize = view.getUint32(currentOffset, false);
4500
+ const typeOffset = currentOffset + MP4_BOX_TYPE_OFFSET_BYTES;
4501
+ const boxType = readBoxType(view, typeOffset);
4502
+ let boxSize = declaredSize;
4503
+ let headerSize = MP4_BOX_HEADER_BYTES;
4504
+ if (declaredSize === MP4_EXTENDED_SIZE_MARKER) {
4505
+ const largeSizeOffset = currentOffset + MP4_BOX_HEADER_BYTES;
4506
+ const hasExtendedHeader = largeSizeOffset + MP4_EXTENDED_SIZE_BYTES <= totalBytes;
4507
+ if (!hasExtendedHeader) {
4508
+ const detectedBoxTypes = boxes.map((box) => box.type);
4509
+ throw createInvalidMp4ContainerLayoutError(detectedBoxTypes);
4510
+ }
4511
+ boxSize = readLargeSize(view, largeSizeOffset);
4512
+ headerSize = MP4_EXTENDED_BOX_HEADER_BYTES;
4513
+ }
4514
+ if (declaredSize === MP4_ZERO_SIZE_MARKER) {
4515
+ boxSize = totalBytes - currentOffset;
4516
+ }
4517
+ const hasValidHeaderSize = boxSize >= headerSize;
4518
+ if (!hasValidHeaderSize) {
4519
+ const detectedBoxTypes = boxes.map((box) => box.type);
4520
+ throw createInvalidMp4ContainerLayoutError(detectedBoxTypes);
4521
+ }
4522
+ const nextOffset = currentOffset + boxSize;
4523
+ const hasValidBoxRange = nextOffset <= totalBytes;
4524
+ if (!hasValidBoxRange) {
4525
+ const detectedBoxTypes = boxes.map((box) => box.type);
4526
+ throw createInvalidMp4ContainerLayoutError(detectedBoxTypes);
4527
+ }
4528
+ boxes.push({
4529
+ type: boxType,
4530
+ size: boxSize,
4531
+ startOffset: currentOffset,
4532
+ endOffset: nextOffset
4533
+ });
4534
+ currentOffset = nextOffset;
4535
+ }
4536
+ return boxes;
4537
+ }
4538
+ function assertMp4ContainerIsNonFragmented(input) {
4539
+ const topLevelBoxes = parseMp4TopLevelBoxes(input);
4540
+ const detectedFragmentBoxes = topLevelBoxes.filter((box) => {
4541
+ if (box.type === MP4_FRAGMENT_BOX_TYPE_MOOF) {
4542
+ return true;
4543
+ }
4544
+ if (box.type === MP4_FRAGMENT_BOX_TYPE_MFRA) {
4545
+ return true;
4546
+ }
4547
+ return false;
4548
+ }).map((box) => box.type);
4549
+ const hasFragmentBoxes = detectedFragmentBoxes.length > 0;
4550
+ if (!hasFragmentBoxes) {
4551
+ return;
4552
+ }
4553
+ const uniqueDetectedBoxTypes = getUniqueValues(detectedFragmentBoxes);
4554
+ throw createInvalidMp4ContainerLayoutError(uniqueDetectedBoxTypes);
4555
+ }
4556
+
4351
4557
  // src/core/utils/shared-object-url-store.ts
4352
4558
  function createSharedObjectUrlStore(dependencies) {
4353
4559
  let activeUrl = null;
@@ -13374,6 +13580,54 @@ class FrameCompositor {
13374
13580
  }
13375
13581
  }
13376
13582
 
13583
+ // src/core/processor/worker/stop-finalization.ts
13584
+ var STOP_PENDING_WRITES_TIMEOUT_MILLISECONDS = 500;
13585
+ var STOP_PENDING_WRITES_POLL_INTERVAL_MILLISECONDS = 10;
13586
+ var ERROR_STOP_PENDING_WRITES_TIMEOUT = "stop.pending-writes-timeout";
13587
+ function createDefaultNowMilliseconds() {
13588
+ return () => performance.now();
13589
+ }
13590
+ function createDefaultWaitMilliseconds() {
13591
+ return (milliseconds) => new Promise((resolve) => {
13592
+ globalThis.setTimeout(resolve, milliseconds);
13593
+ });
13594
+ }
13595
+ async function waitForPendingWritesToDrain(dependencies) {
13596
+ let getNowMilliseconds = dependencies.getNowMilliseconds;
13597
+ if (!getNowMilliseconds) {
13598
+ getNowMilliseconds = createDefaultNowMilliseconds();
13599
+ }
13600
+ let waitMilliseconds = dependencies.waitMilliseconds;
13601
+ if (!waitMilliseconds) {
13602
+ waitMilliseconds = createDefaultWaitMilliseconds();
13603
+ }
13604
+ let timeoutMilliseconds = dependencies.timeoutMilliseconds;
13605
+ if (timeoutMilliseconds === undefined) {
13606
+ timeoutMilliseconds = STOP_PENDING_WRITES_TIMEOUT_MILLISECONDS;
13607
+ }
13608
+ const startedAtMilliseconds = getNowMilliseconds();
13609
+ let pendingWriteCount = dependencies.getPendingWriteCount();
13610
+ while (pendingWriteCount > 0) {
13611
+ const elapsedMilliseconds = getNowMilliseconds() - startedAtMilliseconds;
13612
+ if (elapsedMilliseconds >= timeoutMilliseconds) {
13613
+ throw new Error(ERROR_STOP_PENDING_WRITES_TIMEOUT);
13614
+ }
13615
+ await waitMilliseconds(STOP_PENDING_WRITES_POLL_INTERVAL_MILLISECONDS);
13616
+ pendingWriteCount = dependencies.getPendingWriteCount();
13617
+ }
13618
+ }
13619
+
13620
+ // src/core/processor/worker/stop-transition.ts
13621
+ async function runStopTransition(dependencies) {
13622
+ await dependencies.finalizeStopSequence().then(() => dependencies.completeStop()).catch((error) => {
13623
+ return dependencies.recoverStopFailure().then(() => {
13624
+ throw error;
13625
+ });
13626
+ }).finally(() => {
13627
+ dependencies.clearStoppingFlag();
13628
+ });
13629
+ }
13630
+
13377
13631
  // src/core/processor/worker/timestamp-manager.ts
13378
13632
  var DEFAULT_FRAME_RATE = 30;
13379
13633
  var DEFAULT_KEY_FRAME_INTERVAL_SECONDS = 5;
@@ -13584,7 +13838,6 @@ function clampValue(value, min, max) {
13584
13838
  var WORKER_MESSAGE_TYPE_PROBE = "probe";
13585
13839
  var WORKER_MESSAGE_TYPE_AUDIO_CHUNK = "audioChunk";
13586
13840
  var WORKER_RESPONSE_TYPE_PROBE_RESULT = "probeResult";
13587
- var WORKER_RESPONSE_TYPE_DEBUG_LOG = "debugLog";
13588
13841
  var WORKER_AUDIO_SAMPLE_FORMAT_F32_PLANAR = "f32-planar";
13589
13842
 
13590
13843
  // src/core/processor/worker/visibility-tracker.ts
@@ -13707,6 +13960,8 @@ var ERROR_AUDIO_CHANNELS_INVALID = "Audio channels must be greater than zero";
13707
13960
  var ERROR_AUDIO_FRAMES_INVALID = "Audio frames must be greater than zero";
13708
13961
  var STEREO_CHANNEL_COUNT = 2;
13709
13962
  var AUDIO_SAMPLE_AVERAGE_SCALE = 0.5;
13963
+ var STOP_PENDING_WRITES_TIMEOUT_MILLISECONDS2 = 500;
13964
+ var MP4_FAST_START_DISABLED = false;
13710
13965
 
13711
13966
  class RecorderWorker {
13712
13967
  output = null;
@@ -13729,6 +13984,7 @@ class RecorderWorker {
13729
13984
  totalSize = 0;
13730
13985
  expectedAudioChannels = null;
13731
13986
  expectedAudioSampleRate = null;
13987
+ pendingWriteCount = 0;
13732
13988
  constructor() {
13733
13989
  this.bufferTracker = new BufferTracker({
13734
13990
  getBufferSize: () => this.totalSize,
@@ -13759,14 +14015,6 @@ class RecorderWorker {
13759
14015
  },
13760
14016
  getNowMilliseconds: () => performance.now()
13761
14017
  });
13762
- const sendDebugLog = (message, payload) => {
13763
- const response = {
13764
- type: WORKER_RESPONSE_TYPE_DEBUG_LOG,
13765
- message,
13766
- payload
13767
- };
13768
- self.postMessage(response);
13769
- };
13770
14018
  this.frameCompositor = new FrameCompositor({
13771
14019
  logger: {
13772
14020
  debug: (message, data) => logger.debug(message, data),
@@ -13775,7 +14023,9 @@ class RecorderWorker {
13775
14023
  },
13776
14024
  fetchResource: (input, init) => fetch(input, init),
13777
14025
  createImageBitmap: (image) => createImageBitmap(image),
13778
- sendDebugLog
14026
+ sendDebugLog: (_message, _payload) => {
14027
+ return;
14028
+ }
13779
14029
  });
13780
14030
  self.addEventListener("message", this.handleMessage);
13781
14031
  }
@@ -13909,6 +14159,7 @@ class RecorderWorker {
13909
14159
  this.audioState.reset();
13910
14160
  this.expectedAudioChannels = null;
13911
14161
  this.expectedAudioSampleRate = null;
14162
+ this.pendingWriteCount = 0;
13912
14163
  this.videoProcessingActive = false;
13913
14164
  this.frameCompositor.reset();
13914
14165
  this.recordingStartTime = 0;
@@ -13939,13 +14190,11 @@ class RecorderWorker {
13939
14190
  }
13940
14191
  createOutput() {
13941
14192
  const writable = new WritableStream({
13942
- write: (chunk) => {
13943
- this.sendChunk(chunk.data, chunk.position);
13944
- }
14193
+ write: (chunk) => this.handleOutputChunkWrite(chunk)
13945
14194
  });
13946
14195
  this.output = new Output({
13947
14196
  format: new Mp4OutputFormat({
13948
- fastStart: "fragmented"
14197
+ fastStart: MP4_FAST_START_DISABLED
13949
14198
  }),
13950
14199
  target: new StreamTarget(writable, {
13951
14200
  chunked: true,
@@ -13953,6 +14202,24 @@ class RecorderWorker {
13953
14202
  })
13954
14203
  });
13955
14204
  }
14205
+ decrementPendingWriteCount() {
14206
+ this.pendingWriteCount -= 1;
14207
+ if (this.pendingWriteCount < 0) {
14208
+ this.pendingWriteCount = 0;
14209
+ }
14210
+ }
14211
+ handleOutputChunkWrite(chunk) {
14212
+ this.pendingWriteCount += 1;
14213
+ const writeOperation = Promise.resolve().then(() => {
14214
+ this.sendChunk(chunk.data, chunk.position);
14215
+ });
14216
+ return writeOperation.then(() => {
14217
+ this.decrementPendingWriteCount();
14218
+ }, (error) => {
14219
+ this.decrementPendingWriteCount();
14220
+ throw error;
14221
+ });
14222
+ }
13956
14223
  createVideoSource(config) {
13957
14224
  const fps = this.timestampManager.getFrameRate();
13958
14225
  const keyFrameIntervalSeconds = config.keyFrameInterval;
@@ -14442,19 +14709,42 @@ class RecorderWorker {
14442
14709
  }
14443
14710
  this.sendStateChange("recording");
14444
14711
  }
14445
- async handleStop() {
14712
+ handleStop() {
14446
14713
  if (this.isStopping) {
14447
14714
  logger.debug("[RecorderWorker] handleStop ignored (stopping/finalized)");
14448
- return;
14715
+ return Promise.resolve();
14449
14716
  }
14450
14717
  if (this.isFinalized) {
14451
14718
  logger.debug("[RecorderWorker] handleStop ignored (stopping/finalized)");
14452
- return;
14719
+ return Promise.resolve();
14453
14720
  }
14454
14721
  this.isStopping = true;
14455
14722
  this.isFinalized = true;
14456
14723
  this.videoProcessingActive = false;
14457
14724
  this.audioState.setProcessingActive(false);
14725
+ return runStopTransition({
14726
+ finalizeStopSequence: () => this.finalizeStopSequence(),
14727
+ completeStop: () => this.completeStop(),
14728
+ recoverStopFailure: () => {
14729
+ if (this.isFinalized) {
14730
+ this.resetStopStateAfterFailure();
14731
+ }
14732
+ return this.cleanup().catch((cleanupError) => {
14733
+ logger.error("[RecorderWorker] Stop failure cleanup failed", {
14734
+ error: extractErrorMessage(cleanupError)
14735
+ });
14736
+ });
14737
+ },
14738
+ clearStoppingFlag: () => {
14739
+ this.isStopping = false;
14740
+ }
14741
+ });
14742
+ }
14743
+ async completeStop() {
14744
+ await this.cleanup();
14745
+ this.sendStateChange("stopped");
14746
+ }
14747
+ async finalizeStopSequence() {
14458
14748
  if (this.videoProcessor) {
14459
14749
  await this.videoProcessor.cancel();
14460
14750
  this.videoProcessor = null;
@@ -14464,13 +14754,17 @@ class RecorderWorker {
14464
14754
  this.audioProcessor = null;
14465
14755
  }
14466
14756
  if (this.output) {
14467
- await this.output.finalize().catch((error) => {
14468
- logger.warn("[RecorderWorker] finalize failed (ignored, already finalized?)", error);
14469
- });
14757
+ await this.output.finalize();
14470
14758
  }
14471
- await this.cleanup();
14472
- this.sendStateChange("stopped");
14473
- this.isStopping = false;
14759
+ await waitForPendingWritesToDrain({
14760
+ getPendingWriteCount: () => this.pendingWriteCount,
14761
+ timeoutMilliseconds: STOP_PENDING_WRITES_TIMEOUT_MILLISECONDS2
14762
+ });
14763
+ }
14764
+ resetStopStateAfterFailure() {
14765
+ this.isFinalized = false;
14766
+ this.videoProcessingActive = false;
14767
+ this.audioState.setProcessingActive(false);
14474
14768
  }
14475
14769
  handleToggleMute() {
14476
14770
  this.audioState.toggleMuted();
@@ -14586,6 +14880,7 @@ class RecorderWorker {
14586
14880
  this.isScreenCapture = false;
14587
14881
  this.expectedAudioChannels = null;
14588
14882
  this.expectedAudioSampleRate = null;
14883
+ this.pendingWriteCount = 0;
14589
14884
  this.visibilityTracker.reset(this.recordingStartTime, this.isScreenCapture);
14590
14885
  }
14591
14886
  setExpectedAudioFormat(sampleRate, numberOfChannels) {
@@ -14708,6 +15003,7 @@ var WORKER_PROBE_TIMEOUT_MILLISECONDS = 2000;
14708
15003
  var FINALIZE_TIMEOUT_MILLISECONDS = 30000;
14709
15004
  var MILLISECONDS_PER_SECOND2 = 1000;
14710
15005
  var DEFAULT_RECORDING_FORMAT = "mp4";
15006
+ var OUTPUT_FORMAT_WEBM2 = "webm";
14711
15007
 
14712
15008
  class WorkerProcessor {
14713
15009
  worker = null;
@@ -14876,7 +15172,7 @@ class WorkerProcessor {
14876
15172
  this.stopAudioWorklet();
14877
15173
  const format = this.resolveRecordingFormat(config);
14878
15174
  const audioCodec = await this.resolveAudioCodec(config);
14879
- const codec = await this.resolveVideoCodec(config);
15175
+ const codec = await this.resolveVideoCodec(config, format);
14880
15176
  const isScreenCapture = isScreenCaptureStream(stream);
14881
15177
  logger.debug("[WorkerProcessor] Starting processing", {
14882
15178
  isScreenCapture,
@@ -14967,12 +15263,11 @@ class WorkerProcessor {
14967
15263
  }
14968
15264
  return audioCodec;
14969
15265
  }
14970
- async resolveVideoCodec(config) {
14971
- let codec = config.codec;
14972
- if (!codec) {
14973
- codec = await detectBestCodec(config.width, config.height, config.bitrate);
15266
+ async resolveVideoCodec(config, format) {
15267
+ if (format === OUTPUT_FORMAT_WEBM2) {
15268
+ return await detectBestWebmCodec(config.width, config.height, config.bitrate);
14974
15269
  }
14975
- return codec;
15270
+ return await detectBestCodec(config.width, config.height, config.bitrate);
14976
15271
  }
14977
15272
  buildWorkerTranscodeConfig(config, audioCodec, codec, format) {
14978
15273
  return {
@@ -14990,6 +15285,7 @@ class WorkerProcessor {
14990
15285
  }
14991
15286
  async prepareAudioPipeline(audioTrack, workerProbeResult) {
14992
15287
  if (!audioTrack) {
15288
+ logger.debug("[WorkerProcessor] Audio pipeline disabled (no track)");
14993
15289
  return {
14994
15290
  audioConfig: null,
14995
15291
  audioStream: null,
@@ -15000,6 +15296,10 @@ class WorkerProcessor {
15000
15296
  if (canUseMainThreadAudioPipeline) {
15001
15297
  const audioStream = this.createAudioStreamFromTrack(audioTrack);
15002
15298
  if (audioStream) {
15299
+ logger.debug("[WorkerProcessor] Audio pipeline selected", {
15300
+ path: "main-thread-audio-stream",
15301
+ hasAudioDataInWorker: workerProbeResult.hasAudioData
15302
+ });
15003
15303
  return {
15004
15304
  audioConfig: null,
15005
15305
  audioStream,
@@ -15009,6 +15309,11 @@ class WorkerProcessor {
15009
15309
  }
15010
15310
  const audioConfig = await this.prepareAudioConfig(audioTrack);
15011
15311
  if (audioConfig) {
15312
+ logger.debug("[WorkerProcessor] Audio pipeline selected", {
15313
+ path: "audio-worklet-chunks",
15314
+ sampleRate: audioConfig.sampleRate,
15315
+ numberOfChannels: audioConfig.numberOfChannels
15316
+ });
15012
15317
  return {
15013
15318
  audioConfig,
15014
15319
  audioStream: null,
@@ -15143,42 +15448,88 @@ class WorkerProcessor {
15143
15448
  if (!this.isWorkerActive()) {
15144
15449
  throw new Error("Processing not active");
15145
15450
  }
15451
+ const finalizeStartedAtMilliseconds = performance.now();
15146
15452
  return new Promise((resolve, reject) => {
15147
- const timeout = setTimeout(() => {
15148
- reject(new Error("Finalize timeout"));
15149
- }, FINALIZE_TIMEOUT_MILLISECONDS);
15150
- const removeWorkerListener = () => {
15151
- if (!this.worker) {
15453
+ const worker = this.worker;
15454
+ if (!worker) {
15455
+ reject(new Error("Worker not initialized"));
15456
+ return;
15457
+ }
15458
+ let timeoutId = null;
15459
+ const clearFinalizeTimeout = () => {
15460
+ if (timeoutId === null) {
15152
15461
  return;
15153
15462
  }
15154
- this.worker.removeEventListener("message", messageHandler);
15463
+ clearTimeout(timeoutId);
15464
+ timeoutId = null;
15465
+ };
15466
+ const removeWorkerListener = () => {
15467
+ worker.removeEventListener("message", messageHandler);
15468
+ };
15469
+ let hasSettled = false;
15470
+ const settleOnce = () => {
15471
+ if (hasSettled) {
15472
+ return false;
15473
+ }
15474
+ hasSettled = true;
15475
+ clearFinalizeTimeout();
15476
+ removeWorkerListener();
15477
+ return true;
15155
15478
  };
15156
15479
  const messageHandler = (event) => {
15480
+ if (hasSettled) {
15481
+ return;
15482
+ }
15157
15483
  const response = event.data;
15158
15484
  const isStopped = response.type === "stateChange" && response.state === "stopped";
15159
15485
  if (isStopped) {
15160
- removeWorkerListener();
15161
- clearTimeout(timeout);
15162
- this.isActive = false;
15163
- this.stopAudioWorklet();
15164
- resolve(this.createBlobFromChunks());
15486
+ const canSettle2 = settleOnce();
15487
+ if (!canSettle2) {
15488
+ return;
15489
+ }
15490
+ this.resetFinalizeRuntimeState();
15491
+ Promise.resolve().then(() => this.createBlobFromChunks()).then((streamProcessorResult) => {
15492
+ resolve(streamProcessorResult);
15493
+ }, (error) => {
15494
+ this.rejectFinalizeBlobCreationError(reject, error, finalizeStartedAtMilliseconds);
15495
+ });
15165
15496
  return;
15166
15497
  }
15167
15498
  const isError = response.type === "error";
15168
- if (isError) {
15169
- removeWorkerListener();
15170
- clearTimeout(timeout);
15171
- this.stopAudioWorklet();
15172
- reject(new Error(response.error));
15499
+ if (!isError) {
15500
+ return;
15173
15501
  }
15502
+ const canSettle = settleOnce();
15503
+ if (!canSettle) {
15504
+ return;
15505
+ }
15506
+ this.resetFinalizeRuntimeState();
15507
+ logger.error("[WorkerProcessor] Finalize failed", {
15508
+ elapsedSeconds: (performance.now() - finalizeStartedAtMilliseconds) / MILLISECONDS_PER_SECOND2,
15509
+ error: response.error
15510
+ });
15511
+ reject(new Error(response.error));
15174
15512
  };
15175
- if (this.worker) {
15176
- this.worker.addEventListener("message", messageHandler);
15177
- const message = { type: "stop" };
15178
- this.worker.postMessage(message);
15179
- }
15513
+ timeoutId = setTimeout(() => {
15514
+ const canSettle = settleOnce();
15515
+ if (!canSettle) {
15516
+ return;
15517
+ }
15518
+ logger.error("[WorkerProcessor] Finalize timeout reached", {
15519
+ elapsedSeconds: (performance.now() - finalizeStartedAtMilliseconds) / MILLISECONDS_PER_SECOND2
15520
+ });
15521
+ this.resetFinalizeRuntimeState();
15522
+ reject(new Error("Finalize timeout"));
15523
+ }, FINALIZE_TIMEOUT_MILLISECONDS);
15524
+ worker.addEventListener("message", messageHandler);
15525
+ const message = { type: "stop" };
15526
+ worker.postMessage(message);
15180
15527
  });
15181
15528
  }
15529
+ resetFinalizeRuntimeState() {
15530
+ this.isActive = false;
15531
+ this.stopAudioWorklet();
15532
+ }
15182
15533
  createBlobFromChunks() {
15183
15534
  const sortedChunks = [...this.chunks].sort((a, b) => a.position - b.position);
15184
15535
  const buffer = new ArrayBuffer(this.totalSize);
@@ -15186,12 +15537,24 @@ class WorkerProcessor {
15186
15537
  for (const chunk of sortedChunks) {
15187
15538
  view.set(chunk.data, chunk.position);
15188
15539
  }
15540
+ assertMp4ContainerIsNonFragmented(buffer);
15189
15541
  const blob = new Blob([buffer], { type: "video/mp4" });
15190
15542
  return {
15191
15543
  blob,
15192
15544
  totalSize: this.totalSize
15193
15545
  };
15194
15546
  }
15547
+ rejectFinalizeBlobCreationError(reject, error, finalizeStartedAtMilliseconds) {
15548
+ logger.error("[WorkerProcessor] Finalize failed while creating blob", {
15549
+ elapsedSeconds: (performance.now() - finalizeStartedAtMilliseconds) / MILLISECONDS_PER_SECOND2,
15550
+ error: extractErrorMessage(error)
15551
+ });
15552
+ if (error instanceof Error) {
15553
+ reject(error);
15554
+ return;
15555
+ }
15556
+ reject(new Error(extractErrorMessage(error)));
15557
+ }
15195
15558
  cancel() {
15196
15559
  if (this.worker && this.isActive) {
15197
15560
  const message = { type: "stop" };