@apocaliss92/scrypted-reolink-native 0.4.4 → 0.4.6

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.
package/src/utils.ts CHANGED
@@ -202,9 +202,9 @@ export async function recordingFileToVideoClip(
202
202
  resources:
203
203
  videoHref || thumbnailHref
204
204
  ? {
205
- ...(videoHref ? { video: { href: videoHref } } : {}),
206
- ...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
207
- }
205
+ ...(videoHref ? { video: { href: videoHref } } : {}),
206
+ ...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
207
+ }
208
208
  : undefined,
209
209
  };
210
210
  }
@@ -312,23 +312,153 @@ export async function getVideoClipWebhookUrls(props: {
312
312
  }
313
313
 
314
314
  const getHeader = (headers: Record<string, any> | undefined, key: string) => {
315
- return headers?.[key] ?? headers?.[key.toLowerCase()] ?? headers?.[key.toUpperCase()];
315
+ return (
316
+ headers?.[key] ??
317
+ headers?.[key.toLowerCase()] ??
318
+ headers?.[key.toUpperCase()]
319
+ );
320
+ };
321
+
322
+ const videoclipDownloadCache = new Map<
323
+ string,
324
+ { ts: number; promise: Promise<Buffer> }
325
+ >();
326
+ const VIDEOCLIP_DOWNLOAD_CACHE_TTL_MS = 2 * 60 * 1000;
327
+
328
+ const looksLikeMp4 = (buf: Buffer) => {
329
+ if (!buf || buf.length < 12) return false;
330
+ // ISO BMFF: [size(4)][ftyp(4)]
331
+ return buf.subarray(4, 8).toString("ascii") === "ftyp";
332
+ };
333
+
334
+ const bufferReadable = async (props: {
335
+ readable: AsyncIterable<Buffer>;
336
+ maxBytes: number;
337
+ }): Promise<Buffer> => {
338
+ const chunks: Buffer[] = [];
339
+ let total = 0;
340
+
341
+ for await (const chunk of props.readable) {
342
+ total += chunk.length;
343
+ if (total > props.maxBytes) {
344
+ throw new Error(
345
+ `MP4 buffer exceeded maxBytes=${props.maxBytes} (total=${total})`,
346
+ );
347
+ }
348
+ chunks.push(chunk);
349
+ }
350
+
351
+ return Buffer.concat(chunks);
352
+ };
353
+
354
+ const getCachedDownloadedVideoclip = async (props: {
355
+ cacheKey: string;
356
+ download: () => Promise<Buffer>;
357
+ }): Promise<Buffer> => {
358
+ const now = Date.now();
359
+ const existing = videoclipDownloadCache.get(props.cacheKey);
360
+ if (existing && now - existing.ts < VIDEOCLIP_DOWNLOAD_CACHE_TTL_MS) {
361
+ return await existing.promise;
362
+ }
363
+
364
+ const promise = props.download();
365
+ videoclipDownloadCache.set(props.cacheKey, { ts: now, promise });
366
+ try {
367
+ return await promise;
368
+ } catch (e) {
369
+ // Do not keep failed promises around.
370
+ videoclipDownloadCache.delete(props.cacheKey);
371
+ throw e;
372
+ }
316
373
  };
317
374
 
318
375
  export const getVideoclipClientInfo = (request: HttpRequest) => {
319
376
  return {
320
- userAgent: getHeader(request.headers, 'user-agent') ?? getHeader(request.headers, 'User-Agent'),
321
- accept: getHeader(request.headers, 'accept') ?? getHeader(request.headers, 'Accept'),
322
- range: getHeader(request.headers, 'range') ?? getHeader(request.headers, 'Range'),
323
- secChUa: getHeader(request.headers, 'sec-ch-ua') ?? getHeader(request.headers, 'Sec-CH-UA'),
324
- secChUaMobile: getHeader(request.headers, 'sec-ch-ua-mobile') ?? getHeader(request.headers, 'Sec-CH-UA-Mobile'),
325
- secChUaPlatform: getHeader(request.headers, 'sec-ch-ua-platform') ?? getHeader(request.headers, 'Sec-CH-UA-Platform'),
377
+ userAgent:
378
+ getHeader(request.headers, "user-agent") ??
379
+ getHeader(request.headers, "User-Agent"),
380
+ accept:
381
+ getHeader(request.headers, "accept") ??
382
+ getHeader(request.headers, "Accept"),
383
+ range:
384
+ getHeader(request.headers, "range") ??
385
+ getHeader(request.headers, "Range"),
386
+ secChUa:
387
+ getHeader(request.headers, "sec-ch-ua") ??
388
+ getHeader(request.headers, "Sec-CH-UA"),
389
+ secChUaMobile:
390
+ getHeader(request.headers, "sec-ch-ua-mobile") ??
391
+ getHeader(request.headers, "Sec-CH-UA-Mobile"),
392
+ secChUaPlatform:
393
+ getHeader(request.headers, "sec-ch-ua-platform") ??
394
+ getHeader(request.headers, "Sec-CH-UA-Platform"),
326
395
  };
327
396
  };
328
397
 
398
+ const getQueryParam = (requestUrl: string | undefined, key: string) => {
399
+ if (!requestUrl) return undefined;
400
+ try {
401
+ const url = new URL(requestUrl, "http://localhost");
402
+ const v = url.searchParams.get(key);
403
+ return v === null ? undefined : v;
404
+ } catch {
405
+ return undefined;
406
+ }
407
+ };
408
+
409
+ const withQueryParam = (requestUrl: string, key: string, value: string) => {
410
+ try {
411
+ const url = new URL(requestUrl, "http://localhost");
412
+ url.searchParams.set(key, value);
413
+ return url.pathname + (url.search ? url.search : "");
414
+ } catch {
415
+ const sep = requestUrl.includes("?") ? "&" : "?";
416
+ return `${requestUrl}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
417
+ }
418
+ };
419
+
420
+ let baichuanRuntimePromise:
421
+ | Promise<{
422
+ detectIosClient: (userAgent: string | undefined) => {
423
+ isIos: boolean;
424
+ isIosInstalledApp: boolean;
425
+ needsHls: boolean;
426
+ };
427
+ HlsSessionManager: new (
428
+ api: any,
429
+ options?: {
430
+ logger?: any;
431
+ sessionTtlMs?: number;
432
+ cleanupIntervalMs?: number;
433
+ },
434
+ ) => any;
435
+ }>
436
+ | undefined;
437
+
438
+ const getBaichuanRuntime = async () => {
439
+ if (!baichuanRuntimePromise) {
440
+ baichuanRuntimePromise = import("@apocaliss92/reolink-baichuan-js") as any;
441
+ }
442
+ return baichuanRuntimePromise;
443
+ };
444
+
445
+ const hlsManagersByApi = new WeakMap<object, any>();
446
+
447
+ const videoclipLogThrottle = new Map<string, number>();
448
+ const shouldLogThrottled = (key: string, intervalMs: number): boolean => {
449
+ const now = Date.now();
450
+ const last = videoclipLogThrottle.get(key);
451
+ if (last !== undefined && now - last < intervalMs) return false;
452
+ videoclipLogThrottle.set(key, now);
453
+ // Best-effort guard against unbounded growth.
454
+ if (videoclipLogThrottle.size > 5000) videoclipLogThrottle.clear();
455
+ return true;
456
+ };
457
+
329
458
  /**
330
459
  * Handle video clip webhook request
331
460
  * Uses progressive streaming for immediate playback.
461
+ * For iOS clients, uses HTTP download which is more compatible.
332
462
  * Stream management (stopping previous streams, cooldown) is handled by the API layer
333
463
  * in ReolinkBaichuanApi.createRecordingReplayMp4Stream via activeReplayStreams per channel.
334
464
  */
@@ -342,43 +472,245 @@ export async function handleVideoClipRequest(props: {
342
472
  }): Promise<void> {
343
473
  const { device, fileId, request, response } = props;
344
474
  const logger = device.getBaichuanLogger?.() || props.logger || console;
345
- const useHttpSource =
346
- device.storageSettings?.values?.videoclipSource === "HTTP";
347
475
 
348
- logger.log(
349
- `[VideoClip] REQUEST: fileId=${fileId.slice(-40)}, isOnNvr=${device.isOnNvr}, source=${useHttpSource ? "HTTP" : "Native"}`,
350
- );
476
+ // Check if iOS client
477
+ const clientInfo = getVideoclipClientInfo(request);
478
+ const hlsPath = getQueryParam(request.url, "hls");
479
+ const hlsSocketMode = (getQueryParam(request.url, "hlsSocket") ?? "device")
480
+ .toString()
481
+ .toLowerCase();
482
+
483
+ let ios: {
484
+ isIos: boolean;
485
+ isIosInstalledApp: boolean;
486
+ needsHls: boolean;
487
+ } = {
488
+ isIos: /iphone|ipad|ipod/i.test(clientInfo.userAgent ?? ""),
489
+ isIosInstalledApp: /(installedapp)/i.test(clientInfo.userAgent ?? ""),
490
+ needsHls: false,
491
+ };
492
+
493
+ try {
494
+ const mod = await getBaichuanRuntime();
495
+ ios = mod.detectIosClient(clientInfo.userAgent);
496
+ } catch {
497
+ // If dynamic import fails, keep best-effort UA detection.
498
+ ios.needsHls = ios.isIos && ios.isIosInstalledApp;
499
+ }
500
+
501
+ // iOS InstalledApp playback is most reliable via HLS.
502
+ // If `?hls=` is present, always serve HLS assets (playlist/segments).
503
+ const shouldUseHls = ios.needsHls || hlsPath !== undefined;
504
+
505
+ // Legacy iOS InstalledApp MP4 path: Range probes (e.g. bytes=0-1) expect
506
+ // a proper 206 with a total size in Content-Range.
507
+ const hasRange = !!clientInfo.range;
508
+ const preferDownloadForRange =
509
+ !shouldUseHls && ios.isIosInstalledApp && hasRange;
510
+ const useDownload = !shouldUseHls && ios.isIosInstalledApp && !hasRange;
511
+
512
+ // These endpoints can be very chatty (HLS playlist polling + segment fetch).
513
+ // Keep important transitions visible, but push repetitive per-request noise to debug.
514
+ const requestMode = shouldUseHls
515
+ ? "HLS"
516
+ : useDownload
517
+ ? "Download"
518
+ : preferDownloadForRange
519
+ ? "Download(Range)"
520
+ : "Stream";
521
+ const requestHlsPathForLog = shouldUseHls ? (hlsPath ?? "playlist.m3u8") : "";
522
+ const isHlsSegmentReq =
523
+ shouldUseHls && requestHlsPathForLog.toString().endsWith(".ts");
524
+ const reqLogKey = `VideoClip:REQ:${props.deviceId}:${fileId}:${requestMode}:${requestHlsPathForLog}`;
525
+ const reqLine = `[VideoClip] REQUEST: fileId=${fileId.slice(-40)}, isOnNvr=${device.isOnNvr}, isIos=${ios.isIos}, isIosInstalledApp=${ios.isIosInstalledApp}, hasRange=${hasRange}, hls=${shouldUseHls}, hlsPath=${JSON.stringify(hlsPath)}, hlsSocket=${hlsSocketMode}, mode=${requestMode}`;
526
+ if (hasRange) {
527
+ logger.log(reqLine);
528
+ } else if (isHlsSegmentReq) {
529
+ logger.debug?.(reqLine);
530
+ } else if (shouldLogThrottled(reqLogKey, 2000)) {
531
+ logger.log(reqLine);
532
+ } else {
533
+ logger.debug?.(reqLine);
534
+ }
351
535
 
352
536
  try {
353
537
  const api = await device.ensureClient();
354
538
  const channel = device.storageSettings?.values?.rtspChannel ?? 0;
355
539
 
356
- if (useHttpSource) {
357
- // HTTP mode: use CGI API to download the video file
358
- logger.debug(`[VideoClip] Using CGI API (HTTP) to download: ${fileId}`);
540
+ if (shouldUseHls) {
541
+ const mod = await getBaichuanRuntime();
542
+ let manager = hlsManagersByApi.get(api as any);
543
+ if (!manager) {
544
+ manager = new mod.HlsSessionManager(api as any, {
545
+ logger: logger as any,
546
+ // Keep very short: iOS HLS requests are frequent; if the client stops
547
+ // requesting playlists/segments we want to tear down quickly.
548
+ sessionTtlMs: 15 * 1000,
549
+ cleanupIntervalMs: 5 * 1000,
550
+ });
551
+ hlsManagersByApi.set(api as any, manager);
552
+ }
553
+ const sessionKey = `hls:${props.deviceId}:ch${channel}:${fileId}`;
554
+ const exclusiveKeyPrefix = `hls:${props.deviceId}:ch${channel}:`;
555
+
556
+ // Treat the base clip URL as a playlist request to avoid an extra 302
557
+ // round-trip on clip switches.
558
+ const effectiveHlsPath = hlsPath ?? "playlist.m3u8";
559
+
560
+ const result = await manager.handleRequest({
561
+ sessionKey,
562
+ hlsPath: effectiveHlsPath,
563
+ requestUrl: request.url ?? "",
564
+ exclusiveKeyPrefix,
565
+ createSession: () => ({
566
+ channel,
567
+ fileName: fileId,
568
+ isNvr: device.isOnNvr,
569
+ // Default: reuse a dedicated replay socket per device for fast clip switching.
570
+ // Override: `?hlsSocket=clip` forces a fresh socket per clip (more robust, slower).
571
+ deviceId:
572
+ hlsSocketMode === "clip"
573
+ ? `${props.deviceId}:${fileId}`
574
+ : props.deviceId,
575
+ // Lower duration improves startup latency on iOS.
576
+ hlsSegmentDuration: 1,
577
+ transcodeH265ToH264: true,
578
+ }),
579
+ });
359
580
 
360
- const mp4Buffer = await api.downloadVod(fileId, {
361
- output: fileId,
581
+ const bodyLen =
582
+ typeof result.body === "string"
583
+ ? result.body.length
584
+ : (result.body?.length ?? 0);
585
+
586
+ const respLine = `[VideoClip] HLS RESP: hlsPath=${JSON.stringify(effectiveHlsPath)}, status=${result.statusCode}, len=${bodyLen}`;
587
+ const isError = result.statusCode >= 400;
588
+ const isSegment = effectiveHlsPath.endsWith(".ts");
589
+ const respLogKey = `VideoClip:RESP:${props.deviceId}:${fileId}:${effectiveHlsPath}:${result.statusCode}`;
590
+ if (isError) {
591
+ (logger.warn ?? logger.log).call(logger, respLine);
592
+ } else if (isSegment) {
593
+ logger.debug?.(respLine);
594
+ } else if (shouldLogThrottled(respLogKey, 2000)) {
595
+ logger.log(respLine);
596
+ } else {
597
+ logger.debug?.(respLine);
598
+ }
599
+
600
+ response.send(result.body as any, {
601
+ code: result.statusCode,
602
+ headers: result.headers,
362
603
  });
604
+ return;
605
+ }
363
606
 
364
- logger.debug(`[VideoClip] Downloaded via CGI: ${mp4Buffer.length} bytes`);
607
+ // Range support for iOS: serve a file-like response with known total size.
608
+ // This is closer to the behavior in scrypted-advanced-notifier's sendVideo.
609
+ if (preferDownloadForRange) {
610
+ const rangeHeader = String(clientInfo.range).trim();
611
+ const m = /^bytes=(\d+)-(\d*)$/i.exec(rangeHeader);
612
+ if (!m) {
613
+ response.send("Invalid Range", { code: 416 });
614
+ return;
615
+ }
616
+
617
+ const start = Number.parseInt(m[1], 10);
618
+ const endRaw = m[2];
619
+
620
+ // Obtain an MP4 buffer (cached) so we can answer arbitrary byte ranges.
621
+ // On some NVRs, downloadRecording() returns BcMedia/raw, not an MP4 file.
622
+ // In that case, generate MP4 bytes via createRecordingDownloadMp4Stream.
623
+ let fileBuf: Buffer;
624
+ try {
625
+ fileBuf = await getCachedDownloadedVideoclip({
626
+ cacheKey: `${device.id || "device"}:${channel}:${fileId}`,
627
+ download: async () => {
628
+ logger.log(
629
+ `[VideoClip] Range requested; preparing MP4 buffer for byte-range support: channel=${channel}, fileId=${fileId}`,
630
+ );
631
+
632
+ const rawOrMp4 = await api.downloadRecording({
633
+ channel,
634
+ fileName: fileId,
635
+ });
636
+
637
+ if (looksLikeMp4(rawOrMp4)) {
638
+ return rawOrMp4;
639
+ }
640
+
641
+ logger.warn?.(
642
+ `[VideoClip] downloadRecording did not look like MP4 (ftyp missing). Generating MP4 via createRecordingDownloadMp4Stream...`,
643
+ );
644
+
645
+ const { mp4, stop } = await api.createRecordingDownloadMp4Stream({
646
+ channel,
647
+ fileName: fileId,
648
+ });
649
+
650
+ try {
651
+ const mp4Buf = await bufferReadable({
652
+ readable: mp4 as any,
653
+ maxBytes: 250 * 1024 * 1024,
654
+ });
655
+
656
+ if (!looksLikeMp4(mp4Buf)) {
657
+ throw new Error(
658
+ "createRecordingDownloadMp4Stream output did not look like MP4 (ftyp missing)",
659
+ );
660
+ }
661
+
662
+ return mp4Buf;
663
+ } finally {
664
+ await stop().catch(() => {});
665
+ }
666
+ },
667
+ });
668
+ } catch (e: any) {
669
+ logger.error(
670
+ `[VideoClip] Range download failed: ${e?.message || String(e)}`,
671
+ );
672
+ response.send(`Download failed: ${e?.message || "Unknown error"}`, {
673
+ code: 500,
674
+ });
675
+ return;
676
+ }
677
+
678
+ const fileSize = fileBuf.length;
679
+ const end = endRaw ? Number.parseInt(endRaw, 10) : fileSize - 1;
680
+ if (
681
+ !Number.isFinite(start) ||
682
+ !Number.isFinite(end) ||
683
+ start < 0 ||
684
+ end < start ||
685
+ start >= fileSize
686
+ ) {
687
+ response.send("Invalid Range", { code: 416 });
688
+ return;
689
+ }
690
+
691
+ const safeEnd = Math.min(end, fileSize - 1);
692
+ const chunkSize = safeEnd - start + 1;
693
+ const slice = fileBuf.subarray(start, safeEnd + 1);
365
694
 
366
- // Send the buffer as a complete response
367
- const CHUNK_SIZE = 64 * 1024; // 64KB chunks
368
695
  response.sendStream(
369
696
  (async function* () {
370
- let offset = 0;
371
- while (offset < mp4Buffer.length) {
372
- const end = Math.min(offset + CHUNK_SIZE, mp4Buffer.length);
373
- yield mp4Buffer.subarray(offset, end);
374
- offset = end;
697
+ // Yield in chunks to avoid large single-buffer writes.
698
+ const CHUNK = 64 * 1024;
699
+ for (let offset = 0; offset < slice.length; offset += CHUNK) {
700
+ yield slice.subarray(
701
+ offset,
702
+ Math.min(offset + CHUNK, slice.length),
703
+ );
375
704
  }
376
705
  })(),
377
706
  {
378
- code: 200,
707
+ code: 206,
379
708
  headers: {
709
+ "Content-Range": `bytes ${start}-${safeEnd}/${fileSize}`,
710
+ "Accept-Ranges": "bytes",
711
+ "Content-Length": chunkSize.toString(),
380
712
  "Content-Type": "video/mp4",
381
- "Content-Length": mp4Buffer.length.toString(),
713
+ "Content-Disposition": 'inline; filename="clip.mp4"',
382
714
  "Cache-Control": "no-cache",
383
715
  },
384
716
  },
@@ -386,7 +718,54 @@ export async function handleVideoClipRequest(props: {
386
718
  return;
387
719
  }
388
720
 
389
- // Native mode: use Baichuan streaming replay
721
+ if (useDownload) {
722
+ // Download mode: use native Baichuan download (works for both NVR and standalone)
723
+ logger.log(
724
+ `[VideoClip] Starting native download: channel=${channel}, fileId=${fileId}`,
725
+ );
726
+
727
+ try {
728
+ const mp4Buffer = await api.downloadRecording({
729
+ channel,
730
+ fileName: fileId,
731
+ });
732
+
733
+ logger.log(`[VideoClip] Downloaded: ${mp4Buffer.length} bytes`);
734
+
735
+ // Send the buffer as a complete response in chunks
736
+ const CHUNK_SIZE = 64 * 1024; // 64KB chunks
737
+ response.sendStream(
738
+ (async function* () {
739
+ let offset = 0;
740
+ while (offset < mp4Buffer.length) {
741
+ const end = Math.min(offset + CHUNK_SIZE, mp4Buffer.length);
742
+ yield mp4Buffer.subarray(offset, end);
743
+ offset = end;
744
+ }
745
+ })(),
746
+ {
747
+ code: 200,
748
+ headers: {
749
+ "Content-Type": "video/mp4",
750
+ "Content-Length": mp4Buffer.length.toString(),
751
+ "Cache-Control": "no-cache",
752
+ },
753
+ },
754
+ );
755
+ return;
756
+ } catch (downloadErr: any) {
757
+ logger.error(
758
+ `[VideoClip] Download failed: ${downloadErr?.message || String(downloadErr)}`,
759
+ );
760
+ response.send(
761
+ `Download failed: ${downloadErr?.message || "Unknown error"}`,
762
+ { code: 500 },
763
+ );
764
+ return;
765
+ }
766
+ }
767
+
768
+ // Stream mode: use Baichuan streaming replay
390
769
  // Add error handler to prevent uncaughtException from client socket errors
391
770
  const onClientError = (err: Error) => {
392
771
  logger.warn?.(
@@ -395,11 +774,6 @@ export async function handleVideoClipRequest(props: {
395
774
  };
396
775
  api.client.on("error", onClientError);
397
776
 
398
- const clientInfo = getVideoclipClientInfo(request);
399
- const ua = (clientInfo.userAgent ?? '').toLowerCase();
400
- const isIos = /iphone|ipad|ipod/.test(ua);
401
- const isIosInstalledApp = ua.includes('installedapp');
402
-
403
777
  // Use streaming replay - this starts immediately and produces fMP4 chunks
404
778
  // Stream management (stopping previous streams, cooldown) is handled by the API layer
405
779
  // Generate a unique session ID based on client fingerprint (UA + IP + other factors)
@@ -407,30 +781,42 @@ export async function handleVideoClipRequest(props: {
407
781
  const clientFingerprint = [
408
782
  request.headers?.["user-agent"] || "",
409
783
  request.headers?.["x-forwarded-for"] ||
410
- request.headers?.["x-real-ip"] ||
411
- "",
784
+ request.headers?.["x-real-ip"] ||
785
+ "",
412
786
  request.headers?.["accept-language"] || "",
413
787
  request.headers?.["accept-encoding"] || "",
414
788
  ].join("|");
789
+ const explicitSessionId =
790
+ getHeader(request.headers, "x-playback-session-id") ??
791
+ getHeader(request.headers, "X-Playback-Session-Id");
415
792
  const sessionId =
793
+ explicitSessionId ||
416
794
  request.headers?.["x-request-id"] ||
417
795
  crypto
418
796
  .createHash("sha256")
419
797
  .update(clientFingerprint)
420
798
  .digest("hex")
421
799
  .slice(0, 16);
800
+
801
+ logger.debug(
802
+ `[VideoClip] Client info: ${JSON.stringify({
803
+ clientInfo,
804
+ isIos: ios.isIos,
805
+ isIosInstalledApp: ios.isIosInstalledApp,
806
+ })}`,
807
+ );
808
+
422
809
  const { mp4: mp4Stream, stop } = await api.createRecordingReplayMp4Stream({
423
810
  channel,
424
811
  fileName: fileId,
425
812
  isNvr: device.isOnNvr,
426
813
  logger,
427
814
  deviceId: sessionId,
428
- transcodeH265ToH264: isIos && isIosInstalledApp,
429
815
  });
430
816
 
431
817
  let totalSize = 0;
432
818
 
433
- // Simple response - no range support
819
+ // No Range: stream the full fMP4 response (unknown total size)
434
820
  response.sendStream(
435
821
  (async function* () {
436
822
  try {
@@ -443,13 +829,14 @@ export async function handleVideoClipRequest(props: {
443
829
  } finally {
444
830
  // Remove the error handler
445
831
  api.client.off("error", onClientError);
446
- await stop().catch(() => { });
832
+ await stop().catch(() => {});
447
833
  }
448
834
  })(),
449
835
  {
450
836
  code: 200,
451
837
  headers: {
452
838
  "Content-Type": "video/mp4",
839
+ "Content-Disposition": 'inline; filename="clip.mp4"',
453
840
  "Cache-Control": "no-cache",
454
841
  },
455
842
  },