mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
Improve export failure diagnostics
This commit is contained in:
@@ -290,6 +290,51 @@ describe("ModernVideoExporter native fallback routing", () => {
|
||||
expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("builds actionable diagnostics for input decoder failures", () => {
|
||||
vi.stubGlobal("navigator", {
|
||||
platform: "Win32",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
});
|
||||
const exporter = new ModernVideoExporter({
|
||||
videoUrl: "file:///recording.mp4",
|
||||
width: 1200,
|
||||
height: 570,
|
||||
frameRate: 60,
|
||||
bitrate: 8_000_000,
|
||||
backendPreference: "auto",
|
||||
} as never) as unknown as {
|
||||
buildLightningExportError: (error: unknown) => string;
|
||||
sourceVideoInfo: typeof mocks.videoInfo;
|
||||
renderBackend: "webgpu";
|
||||
encodeBackend: "ffmpeg";
|
||||
encoderName: string;
|
||||
processedFrameCount: number;
|
||||
totalExportStartTimeMs: number;
|
||||
mediaSourceRetryAttempted: boolean;
|
||||
};
|
||||
exporter.sourceVideoInfo = mocks.videoInfo;
|
||||
exporter.renderBackend = "webgpu";
|
||||
exporter.encodeBackend = "ffmpeg";
|
||||
exporter.encoderName = "h264-stream-copy";
|
||||
exporter.processedFrameCount = 314;
|
||||
exporter.totalExportStartTimeMs = 1;
|
||||
exporter.mediaSourceRetryAttempted = true;
|
||||
|
||||
const report = exporter.buildLightningExportError(
|
||||
new Error(
|
||||
"[VIDEO_DECODE_ENCODING_ERROR] VideoDecoder failure: EncodingError: bad frame",
|
||||
),
|
||||
);
|
||||
|
||||
expect(report).toContain("Failure code: VIDEO_DECODE_ENCODING_ERROR");
|
||||
expect(report).toContain("Failure stage: Input video decoding");
|
||||
expect(report).toContain("Source: h264 1920x1080 @ 30.000 FPS; 1.000s");
|
||||
expect(report).toContain("Progress at failure: 314 rendered frames after");
|
||||
expect(report).toContain("Media source retry: attempted with a fresh source");
|
||||
expect(report).toContain("If only this recording fails");
|
||||
expect(report).not.toContain("Windows Lightning exports can use WebCodecs or FFmpeg");
|
||||
});
|
||||
|
||||
it("forwards cursor click-effect settings into the modern frame renderer", async () => {
|
||||
const { ModernVideoExporter } = await import("./modernVideoExporter");
|
||||
const { FrameRenderer } = await import("./modernFrameRenderer");
|
||||
|
||||
@@ -367,6 +367,8 @@ export class ModernVideoExporter {
|
||||
private lastProgressSampleTimeMs = 0;
|
||||
private lastProgressSampleFrame = 0;
|
||||
private displayedRenderFps = 0;
|
||||
private sourceVideoInfo: DecodedVideoInfo | null = null;
|
||||
private mediaSourceRetryAttempted = false;
|
||||
|
||||
constructor(config: VideoExporterConfig) {
|
||||
this.config = config;
|
||||
@@ -375,6 +377,7 @@ export class ModernVideoExporter {
|
||||
async export(): Promise<ExportResult> {
|
||||
let useFallbackMediaSource = false;
|
||||
let retriedWithFallbackMediaSource = false;
|
||||
this.mediaSourceRetryAttempted = false;
|
||||
|
||||
while (true) {
|
||||
let shouldRetryWithFallbackMediaSource = false;
|
||||
@@ -386,6 +389,7 @@ export class ModernVideoExporter {
|
||||
this.nativeStaticLayoutSkipReason = null;
|
||||
this.nativeStaticLayoutSkipReasons = [];
|
||||
this.nativeStaticLayoutBackgroundSkipReason = null;
|
||||
this.sourceVideoInfo = null;
|
||||
this.totalExportStartTimeMs = this.getNowMs();
|
||||
const backendPreference = this.config.backendPreference ?? "auto";
|
||||
const runtimePlatform = this.getRuntimePlatform();
|
||||
@@ -526,6 +530,7 @@ export class ModernVideoExporter {
|
||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl, {
|
||||
useFallbackMediaSource,
|
||||
});
|
||||
this.sourceVideoInfo = videoInfo;
|
||||
this.metadataLoadTimeMs = this.getNowMs() - stageStartedAt;
|
||||
const nativeAudioPlan = this.buildNativeAudioPlan(videoInfo);
|
||||
const shouldUsePitchPreservingFfmpegAudio =
|
||||
@@ -894,6 +899,7 @@ export class ModernVideoExporter {
|
||||
this.shouldRetryWithFallbackMediaSource(error)
|
||||
) {
|
||||
retriedWithFallbackMediaSource = true;
|
||||
this.mediaSourceRetryAttempted = true;
|
||||
useFallbackMediaSource = true;
|
||||
shouldRetryWithFallbackMediaSource = true;
|
||||
console.warn(
|
||||
@@ -968,10 +974,23 @@ export class ModernVideoExporter {
|
||||
private getLightningErrorGuidance(message: string): string[] {
|
||||
const guidance = new Set<string>();
|
||||
const platform = this.getPlatformLabel();
|
||||
const isVideoDecodeFailure = /VideoDecoder failure|VIDEO_DECODE|VIDEO_CODEC/i.test(message);
|
||||
|
||||
guidance.add(
|
||||
"Lightning is designed to work on macOS, Windows, and Linux, but the available encoder path depends on WebCodecs support, GPU drivers, and the bundled FFmpeg encoders.",
|
||||
);
|
||||
if (isVideoDecodeFailure) {
|
||||
guidance.add(
|
||||
"The input video decoder failed before Recordly could finish rendering the source frames.",
|
||||
);
|
||||
guidance.add(
|
||||
"If only this recording fails, remux or convert it to a standard H.264 MP4; the source may contain a damaged or unsupported frame.",
|
||||
);
|
||||
guidance.add(
|
||||
"If every recording fails, update the GPU/media driver and retry at 30 FPS to reduce decoder pressure.",
|
||||
);
|
||||
} else {
|
||||
guidance.add(
|
||||
"Lightning is designed to work on macOS, Windows, and Linux, but the available encoder path depends on WebCodecs support, GPU drivers, and the bundled FFmpeg encoders.",
|
||||
);
|
||||
}
|
||||
|
||||
if (/even output dimensions/i.test(message)) {
|
||||
guidance.add(
|
||||
@@ -996,15 +1015,15 @@ export class ModernVideoExporter {
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === "Windows") {
|
||||
if (!isVideoDecodeFailure && platform === "Windows") {
|
||||
guidance.add(
|
||||
"Windows Lightning exports can use WebCodecs or FFmpeg encoders such as h264_nvenc, h264_qsv, h264_amf, h264_mf, or libx264 depending on the machine.",
|
||||
);
|
||||
} else if (platform === "Linux") {
|
||||
} else if (!isVideoDecodeFailure && platform === "Linux") {
|
||||
guidance.add(
|
||||
"Linux Lightning exports can use WebCodecs when supported, or FFmpeg encoders such as libx264 and optional GPU paths depending on the distro build.",
|
||||
);
|
||||
} else if (platform === "macOS") {
|
||||
} else if (!isVideoDecodeFailure && platform === "macOS") {
|
||||
guidance.add(
|
||||
"macOS Lightning exports can use WebCodecs or VideoToolbox/libx264 through Breeze depending on the output profile.",
|
||||
);
|
||||
@@ -1015,6 +1034,8 @@ export class ModernVideoExporter {
|
||||
|
||||
private buildLightningExportError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const failureCode = message.match(/\[([A-Z][A-Z0-9_]+)\]/)?.[1];
|
||||
const isVideoDecodeFailure = /VideoDecoder failure|VIDEO_DECODE|VIDEO_CODEC/i.test(message);
|
||||
const resolvedEncodePath =
|
||||
this.encodeBackend === "ffmpeg"
|
||||
? `${NATIVE_EXPORT_ENGINE_NAME} native`
|
||||
@@ -1023,12 +1044,34 @@ export class ModernVideoExporter {
|
||||
: null;
|
||||
const lines = [
|
||||
`${LIGHTNING_PIPELINE_NAME} export failed.`,
|
||||
...(failureCode ? [`Failure code: ${failureCode}`] : []),
|
||||
...(isVideoDecodeFailure ? ["Failure stage: Input video decoding"] : []),
|
||||
`Reason: ${message}`,
|
||||
`Platform: ${this.getPlatformLabel()}`,
|
||||
`Requested backend mode: ${this.config.backendPreference ?? "auto"}`,
|
||||
`Output: ${this.config.width}x${this.config.height} @ ${this.config.frameRate} FPS`,
|
||||
];
|
||||
|
||||
if (this.sourceVideoInfo) {
|
||||
lines.push(
|
||||
`Source: ${this.sourceVideoInfo.codec} ${this.sourceVideoInfo.width}x${this.sourceVideoInfo.height} @ ${this.sourceVideoInfo.frameRate.toFixed(3)} FPS; ${this.sourceVideoInfo.duration.toFixed(3)}s`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.totalExportStartTimeMs > 0) {
|
||||
const elapsedSeconds = Math.max(
|
||||
0,
|
||||
(this.getNowMs() - this.totalExportStartTimeMs) / 1000,
|
||||
);
|
||||
lines.push(
|
||||
`Progress at failure: ${this.processedFrameCount} rendered frames after ${elapsedSeconds.toFixed(2)}s`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.mediaSourceRetryAttempted) {
|
||||
lines.push("Media source retry: attempted with a fresh source");
|
||||
}
|
||||
|
||||
if (this.renderBackend) {
|
||||
lines.push(`Renderer: ${this.renderBackend}`);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildVideoDecodeFailure,
|
||||
getDecodedFrameStartupOffsetUs,
|
||||
getDecodedFrameTimelineOffsetUs,
|
||||
getVideoDecodeFailureCode,
|
||||
preserveFirstVideoDecodeFailure,
|
||||
StreamingVideoDecoder,
|
||||
} from "./streamingDecoder";
|
||||
|
||||
describe("buildVideoDecodeFailure", () => {
|
||||
it("assigns stable failure codes for common WebCodecs errors", () => {
|
||||
expect(getVideoDecodeFailureCode(new DOMException("bad frame", "EncodingError"))).toBe(
|
||||
"VIDEO_DECODE_ENCODING_ERROR",
|
||||
);
|
||||
expect(getVideoDecodeFailureCode(new DOMException("busy", "QuotaExceededError"))).toBe(
|
||||
"VIDEO_DECODER_RESOURCE_EXHAUSTED",
|
||||
);
|
||||
expect(getVideoDecodeFailureCode(new Error("demux read failed"))).toBe(
|
||||
"VIDEO_DECODE_FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the original decoder error with codec and chunk context", () => {
|
||||
const originalError = new DOMException("Failed to decode frame", "EncodingError");
|
||||
const chunk = {
|
||||
type: "delta",
|
||||
timestamp: 1_500_000,
|
||||
duration: 16_667,
|
||||
byteLength: 4,
|
||||
} as EncodedVideoChunk;
|
||||
|
||||
const error = buildVideoDecodeFailure(originalError, {
|
||||
decoderConfig: {
|
||||
codec: "avc1.640034",
|
||||
codedWidth: 1920,
|
||||
codedHeight: 1080,
|
||||
hardwareAcceleration: "prefer-hardware",
|
||||
},
|
||||
sourceMetadata: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 61.25,
|
||||
frameRate: 60,
|
||||
codec: "avc1.640034",
|
||||
hasAudio: true,
|
||||
},
|
||||
chunkIndex: 42,
|
||||
chunk,
|
||||
decoderState: "closed",
|
||||
decodeQueueSize: 7,
|
||||
});
|
||||
|
||||
expect(error.message).toContain("EncodingError: Failed to decode frame");
|
||||
expect(error.message).toContain("[VIDEO_DECODE_ENCODING_ERROR]");
|
||||
expect(error.message).toContain("codec=avc1.640034");
|
||||
expect(error.message).toContain("codedSize=1920x1080");
|
||||
expect(error.message).toContain("chunkIndex=42");
|
||||
expect(error.message).toContain("chunkType=delta");
|
||||
expect(error.message).toContain("chunkTimestampUs=1500000");
|
||||
expect(error.message).toContain("sourceTimeSec=1.500");
|
||||
expect(error.message).toContain("chunkDurationUs=16667");
|
||||
expect(error.message).toContain("chunkBytes=4");
|
||||
expect(error.message).toContain("sourceFps=60");
|
||||
expect(error.message).toContain("sourceDurationSec=61.25");
|
||||
expect(error.message).toContain("decoderState=closed");
|
||||
expect(error.cause).toBe(originalError);
|
||||
});
|
||||
|
||||
it("does not replace the original failure with a later closed-codec exception", () => {
|
||||
const originalFailure = new Error("VideoDecoder failure: EncodingError: bad frame");
|
||||
const result = preserveFirstVideoDecodeFailure(
|
||||
originalFailure,
|
||||
new DOMException("Cannot call 'decode' on a closed codec.", "InvalidStateError"),
|
||||
{
|
||||
decoderConfig: {
|
||||
codec: "avc1.640034",
|
||||
codedWidth: 1920,
|
||||
codedHeight: 1080,
|
||||
},
|
||||
decoderState: "closed",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(originalFailure);
|
||||
});
|
||||
});
|
||||
|
||||
const {
|
||||
mockDemuxerLoad,
|
||||
mockDemuxerGetMediaInfo,
|
||||
|
||||
@@ -27,6 +27,92 @@ interface StreamingVideoDecoderLoadOptions {
|
||||
useFallbackMediaSource?: boolean;
|
||||
}
|
||||
|
||||
interface VideoDecodeFailureContext {
|
||||
decoderConfig: VideoDecoderConfig;
|
||||
sourceMetadata?: DecodedVideoInfo;
|
||||
chunkIndex?: number;
|
||||
chunk?: EncodedVideoChunk;
|
||||
decoderState?: CodecState;
|
||||
decodeQueueSize?: number;
|
||||
}
|
||||
|
||||
export function getVideoDecodeFailureCode(error: unknown): string {
|
||||
const name = error instanceof DOMException ? error.name : "";
|
||||
switch (name) {
|
||||
case "EncodingError":
|
||||
return "VIDEO_DECODE_ENCODING_ERROR";
|
||||
case "NotSupportedError":
|
||||
return "VIDEO_CODEC_UNSUPPORTED";
|
||||
case "QuotaExceededError":
|
||||
return "VIDEO_DECODER_RESOURCE_EXHAUSTED";
|
||||
case "InvalidStateError":
|
||||
return "VIDEO_DECODER_INVALID_STATE";
|
||||
default:
|
||||
return "VIDEO_DECODE_FAILED";
|
||||
}
|
||||
}
|
||||
|
||||
function describeUnknownError(error: unknown): string {
|
||||
if (error instanceof DOMException) {
|
||||
return `${error.name}: ${error.message}`;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function buildVideoDecodeFailure(error: unknown, context: VideoDecodeFailureContext): Error {
|
||||
const details = [`codec=${context.decoderConfig.codec}`];
|
||||
const failureCode = getVideoDecodeFailureCode(error);
|
||||
const width = context.decoderConfig.codedWidth;
|
||||
const height = context.decoderConfig.codedHeight;
|
||||
if (width && height) {
|
||||
details.push(`codedSize=${width}x${height}`);
|
||||
}
|
||||
if (context.decoderConfig.hardwareAcceleration) {
|
||||
details.push(`hardwareAcceleration=${context.decoderConfig.hardwareAcceleration}`);
|
||||
}
|
||||
if (context.sourceMetadata) {
|
||||
details.push(`sourceFps=${context.sourceMetadata.frameRate}`);
|
||||
details.push(`sourceDurationSec=${context.sourceMetadata.duration}`);
|
||||
}
|
||||
if (context.chunkIndex !== undefined) {
|
||||
details.push(`chunkIndex=${context.chunkIndex}`);
|
||||
}
|
||||
if (context.chunk) {
|
||||
details.push(`chunkType=${context.chunk.type}`);
|
||||
details.push(`chunkTimestampUs=${context.chunk.timestamp}`);
|
||||
details.push(`sourceTimeSec=${(context.chunk.timestamp / 1_000_000).toFixed(3)}`);
|
||||
if (typeof context.chunk.duration === "number") {
|
||||
details.push(`chunkDurationUs=${context.chunk.duration}`);
|
||||
}
|
||||
details.push(`chunkBytes=${context.chunk.byteLength}`);
|
||||
}
|
||||
if (context.decoderState) {
|
||||
details.push(`decoderState=${context.decoderState}`);
|
||||
}
|
||||
if (context.decodeQueueSize !== undefined) {
|
||||
details.push(`decodeQueueSize=${context.decodeQueueSize}`);
|
||||
}
|
||||
|
||||
const failure = new Error(
|
||||
`[${failureCode}] VideoDecoder failure: ${describeUnknownError(error)} (${details.join(", ")})`,
|
||||
);
|
||||
(failure as Error & { cause?: unknown }).cause = error;
|
||||
return failure;
|
||||
}
|
||||
|
||||
export function preserveFirstVideoDecodeFailure(
|
||||
existingError: Error | null,
|
||||
error: unknown,
|
||||
context: VideoDecodeFailureContext,
|
||||
): Error {
|
||||
return existingError ?? buildVideoDecodeFailure(error, context);
|
||||
}
|
||||
|
||||
/** Decoder retains ownership of the VideoFrame and closes it after use. */
|
||||
type OnFrameCallback = (
|
||||
frame: VideoFrame,
|
||||
@@ -260,6 +346,31 @@ export class StreamingVideoDecoder {
|
||||
let decodeDone = false;
|
||||
let firstDecodedFrameTimestampUs: number | null = null;
|
||||
let decodedFrameTimelineOffsetUs = 0;
|
||||
let submittedChunkCount = 0;
|
||||
let lastSubmittedChunk: EncodedVideoChunk | undefined;
|
||||
let lastSubmittedChunkIndex: number | undefined;
|
||||
const preferredDecoderConfig = shouldPreferSoftwareDecode
|
||||
? {
|
||||
...decoderConfig,
|
||||
hardwareAcceleration: "prefer-software" as const,
|
||||
}
|
||||
: decoderConfig;
|
||||
let activeDecoderConfig = preferredDecoderConfig;
|
||||
const getDecoderFailureContext = (): VideoDecodeFailureContext => ({
|
||||
decoderConfig: activeDecoderConfig,
|
||||
sourceMetadata: this.metadata ?? undefined,
|
||||
chunkIndex: lastSubmittedChunkIndex,
|
||||
chunk: lastSubmittedChunk,
|
||||
decoderState: this.decoder?.state,
|
||||
decodeQueueSize: this.decoder?.decodeQueueSize,
|
||||
});
|
||||
const recordFirstDecodeError = (error: unknown) => {
|
||||
decodeError = preserveFirstVideoDecodeFailure(
|
||||
decodeError,
|
||||
error,
|
||||
getDecoderFailureContext(),
|
||||
);
|
||||
};
|
||||
|
||||
this.decoder = new VideoDecoder({
|
||||
output: (frame: VideoFrame) => {
|
||||
@@ -273,7 +384,7 @@ export class StreamingVideoDecoder {
|
||||
notifyBackpressureProgress();
|
||||
},
|
||||
error: (e: DOMException) => {
|
||||
decodeError = new Error(`VideoDecoder error: ${e.message}`);
|
||||
recordFirstDecodeError(e);
|
||||
if (frameResolve) {
|
||||
const resolve = frameResolve;
|
||||
frameResolve = null;
|
||||
@@ -282,25 +393,23 @@ export class StreamingVideoDecoder {
|
||||
notifyBackpressureProgress();
|
||||
},
|
||||
});
|
||||
const preferredDecoderConfig = shouldPreferSoftwareDecode
|
||||
? {
|
||||
...decoderConfig,
|
||||
hardwareAcceleration: "prefer-software" as const,
|
||||
}
|
||||
: decoderConfig;
|
||||
|
||||
try {
|
||||
this.decoder.configure(preferredDecoderConfig);
|
||||
} catch (error) {
|
||||
if (!shouldPreferSoftwareDecode) {
|
||||
throw error;
|
||||
throw buildVideoDecodeFailure(error, getDecoderFailureContext());
|
||||
}
|
||||
// Fall back to default decoder config if software preference is unsupported.
|
||||
this.decoder.configure(decoderConfig);
|
||||
activeDecoderConfig = decoderConfig;
|
||||
try {
|
||||
this.decoder.configure(decoderConfig);
|
||||
} catch (fallbackError) {
|
||||
throw buildVideoDecodeFailure(fallbackError, getDecoderFailureContext());
|
||||
}
|
||||
}
|
||||
|
||||
const getNextFrame = (): Promise<VideoFrame | null> => {
|
||||
if (decodeError) throw decodeError;
|
||||
if (decodeError) return Promise.resolve(null);
|
||||
if (pendingFrames.length > 0) {
|
||||
const frame = pendingFrames.shift()!;
|
||||
notifyBackpressureProgress();
|
||||
@@ -325,7 +434,7 @@ export class StreamingVideoDecoder {
|
||||
// Feed chunks to decoder in background with backpressure
|
||||
const feedPromise = (async () => {
|
||||
try {
|
||||
while (!this.cancelled) {
|
||||
while (!this.cancelled && !decodeError) {
|
||||
const { done, value: chunk } = await reader.read();
|
||||
if (done || !chunk) break;
|
||||
|
||||
@@ -347,22 +456,36 @@ export class StreamingVideoDecoder {
|
||||
|
||||
// Backpressure on both decode queue and decoded frame backlog.
|
||||
while (
|
||||
!decodeError &&
|
||||
this.decoder!.state === "configured" &&
|
||||
(this.decoder!.decodeQueueSize > decodeQueueLimit ||
|
||||
pendingFrames.length > pendingFrameLimit) &&
|
||||
!this.cancelled
|
||||
) {
|
||||
await waitForBackpressureProgress();
|
||||
}
|
||||
if (this.cancelled) break;
|
||||
if (this.cancelled || decodeError) break;
|
||||
if (this.decoder!.state !== "configured") {
|
||||
recordFirstDecodeError(
|
||||
new DOMException(
|
||||
"Decoder closed before the next video chunk was submitted.",
|
||||
"InvalidStateError",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
lastSubmittedChunk = chunk;
|
||||
lastSubmittedChunkIndex = submittedChunkCount;
|
||||
this.decoder!.decode(chunk);
|
||||
submittedChunkCount++;
|
||||
}
|
||||
|
||||
if (!this.cancelled && this.decoder!.state === "configured") {
|
||||
await this.decoder!.flush();
|
||||
}
|
||||
} catch (e) {
|
||||
decodeError = e instanceof Error ? e : new Error(String(e));
|
||||
recordFirstDecodeError(e);
|
||||
} finally {
|
||||
decodeDone = true;
|
||||
if (frameResolve) {
|
||||
@@ -535,7 +658,7 @@ export class StreamingVideoDecoder {
|
||||
}
|
||||
|
||||
// Drain leftover decoded frames
|
||||
while (!decodeDone) {
|
||||
while (!decodeDone && !decodeError) {
|
||||
const frame = await getNextFrame();
|
||||
if (!frame) break;
|
||||
frame.close();
|
||||
@@ -555,6 +678,10 @@ export class StreamingVideoDecoder {
|
||||
}
|
||||
this.decoder = null;
|
||||
|
||||
if (decodeError) {
|
||||
throw decodeError;
|
||||
}
|
||||
|
||||
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
|
||||
if (
|
||||
!this.cancelled &&
|
||||
|
||||
Reference in New Issue
Block a user