diff --git a/locales/en/translation.json b/locales/en/translation.json index 7488aefb..79fe8cb1 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -787,6 +787,7 @@ "This experimental feature will re-encode the part of the video from the cutpoint until the next keyframe in order to attempt to make a 100% accurate cut. Only works on some files. I've had success with some h264 files, and only a few h265 files. See more here: {{url}}": "This experimental feature will re-encode the part of the video from the cutpoint until the next keyframe in order to attempt to make a 100% accurate cut. Only works on some files. I've had success with some h264 files, and only a few h265 files. See more here: {{url}}", "This file contains an audio track that FFmpeg is unable to mux into the MP4 format, so MOV has been auto-selected as the default output format.": "This file contains an audio track that FFmpeg is unable to mux into the MP4 format, so MOV has been auto-selected as the default output format.", "This file does not have a valid duration. This may cause issues. You can try to fix the file's duration from the File menu": "This file does not have a valid duration. This may cause issues. You can try to fix the file's duration from the File menu", + "This file has broken track interleaving": "This file has broken track interleaving (some tracks are not interleaved properly). LosslessCut will automatically work around this when exporting, so that exported segments get correct timestamps.", "This file has embedded chapters. Do you want to import the chapters as cut-segments?": "This file has embedded chapters. Do you want to import the chapters as cut-segments?", "This gives you an overview of the export and allows you to customise more parameters before exporting, like changing the output file name.": "This gives you an overview of the export and allows you to customise more parameters before exporting, like changing the output file name.", "This is hardcoded by FFmpeg and cannot be changed.": "This is hardcoded by FFmpeg and cannot be changed.", diff --git a/locales/zh_Hans/translation.json b/locales/zh_Hans/translation.json index 4375fbb2..1e849474 100644 --- a/locales/zh_Hans/translation.json +++ b/locales/zh_Hans/translation.json @@ -310,6 +310,7 @@ "Failed to fix file duration": "修复文件时长失败", "Fixing file duration": "正在修复文件时长", "This file does not have a valid duration. This may cause issues. You can try to fix the file's duration from the File menu": "此文件的时长无效。这可能有问题。你可以尝试使用文件菜单中的修复错误时长", + "This file has broken track interleaving": "此文件的轨道交织损坏(部分轨道未正确交织存储)。导出时 LosslessCut 会自动采用安全切割方式规避该问题,以保证导出片段的时间戳正确。", "Change value": "改变值", "Other operations": "其它操作", "Enable MOV Faststart?": "启用 MOV 快速启动", diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index b86be90f..bc1384dc 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -1,5 +1,5 @@ import { join } from 'node:path'; -import { access } from 'node:fs/promises'; +import { access, open as openFile, type FileHandle } from 'node:fs/promises'; import readline from 'node:readline'; import stringToStream from 'string-to-stream'; import type { Options as ExecaOptions, ResultPromise } from 'execa'; @@ -588,6 +588,122 @@ export async function getDuration(filePath: string) { return parseFfprobeDuration((await readFormatData(filePath)).duration); } +// Detection of Matroska files with broken track interleaving, e.g. where all audio data is stored at the very end of the file instead of being interleaved with the video data. +// ffmpeg's multi-stream interleaved reading of such files produces outputs with wrong timestamps (e.g. audio starting at 17s instead of 0s), while reading each track type individually works fine. +// Only EBML cluster headers are scanned (no media data is read), so this is fast even for large files. +export async function checkMatroskaTrackInterleaving(filePath: string): Promise<{ hasProblems: boolean }> { + let handle: FileHandle; + try { + handle = await openFile(filePath, 'r'); + } catch { + return { hasProblems: false }; + } + + try { + const { size: fileSize } = await handle.stat(); + + const cacheSize = 128 * 1024; + let cacheStart = -1; + let cacheBuf = Buffer.allocUnsafe(0); + + const readAt = async (position: number, length: number): Promise => { + if (cacheStart >= 0 && position >= cacheStart && position + length <= cacheStart + cacheBuf.length) { + return cacheBuf.subarray(position - cacheStart, position - cacheStart + length); + } + const readLength = Math.min(Math.max(length, cacheSize), fileSize - position); + const buf = Buffer.allocUnsafe(readLength); + await handle.read(buf, 0, readLength, position); + cacheStart = position; + cacheBuf = buf; + return buf; + }; + + // Parse an EBML variable size integer, see Matroska spec + const readVint = async (position: number): Promise<{ value: bigint, length: number, allOnes: boolean }> => { + const [first] = await readAt(position, 1); + let length = 0; + for (let i = 7; i >= 0; i -= 1) { + if ((first! & (1 << i)) !== 0) { length = 8 - i; break; } + } + if (length === 0) throw new Error('Invalid EBML vint'); + const bytes = await readAt(position, length); + let value = BigInt(bytes[0]! & ((1 << (8 - length)) - 1)); + let allOnes = (bytes[0]! & ((1 << (8 - length)) - 1)) === ((1 << (8 - length)) - 1); + for (let i = 1; i < length; i += 1) { + value = (value << 8n) | BigInt(bytes[i]!); + if (bytes[i] !== 0xff) allOnes = false; + } + return { value, length, allOnes }; + }; + + const readEbmlElementHeader = async (position: number): Promise<{ id: bigint, size: bigint, unknownSize: boolean, headerLength: number }> => { + const idInfo = await readVint(position); + const id = idInfo.value | (1n << BigInt(7 * idInfo.length)); // keep the marker bit so that IDs match the spec values + const sizeInfo = await readVint(position + idInfo.length); + return { id, size: sizeInfo.value, unknownSize: sizeInfo.allOnes, headerLength: idInfo.length + sizeInfo.length }; + }; + + const ID_EBML = 0x1A45DFA3n; + const ID_SEGMENT = 0x18538067n; + const ID_CLUSTER = 0x1F43B675n; + const ID_TIMESTAMP = 0xE7n; + + const top = await readEbmlElementHeader(0); + if (top.id !== ID_EBML) return { hasProblems: false }; // not an EBML/Matroska file + let offset = top.headerLength + Number(top.size); + const segment = await readEbmlElementHeader(offset); + if (segment.id !== ID_SEGMENT) return { hasProblems: false }; + const segmentLimit = segment.unknownSize ? fileSize : Math.min(fileSize, offset + segment.headerLength + Number(segment.size)); + offset += segment.headerLength; + + let lastClusterTimestamp: number | undefined; + let hasProblems = false; + + // Iterate over the Segment's top-level elements, only parsing cluster headers (no media data is read). + // Cluster timestamps increase monotonically in a properly interleaved file; a large jump backwards indicates that clusters of some track(s) were written after clusters of other tracks. + while (offset < segmentLimit) { + const element = await readEbmlElementHeader(offset); + if (element.unknownSize) break; // e.g. live stream, cannot reliably scan + const dataStart = offset + element.headerLength; + + if (element.id === ID_CLUSTER) { + // Look for the Timestamp child element (only scan a bounded part of the cluster's children) + let inner = dataStart; + const innerLimit = Math.min(dataStart + Number(element.size), dataStart + 4096); + let clusterTimestamp: number | undefined; + while (inner < innerLimit) { + const child = await readEbmlElementHeader(inner); + if (child.unknownSize) break; + if (child.id === ID_TIMESTAMP) { + const tsBytes = await readAt(inner + child.headerLength, Number(child.size)); + let v = 0; + // eslint-disable-next-line no-restricted-syntax + for (const b of tsBytes) v = v * 256 + b; + clusterTimestamp = v; + break; + } + inner += child.headerLength + Number(child.size); + } + if (clusterTimestamp != null) { + // Timestamps are in TimestampScale units (normally 1ms). Healthy files only ever increase. + // A backward jump of more than 2 seconds is always a sign of broken interleaving. + if (lastClusterTimestamp != null && clusterTimestamp < lastClusterTimestamp - 2000) hasProblems = true; + lastClusterTimestamp = Math.max(lastClusterTimestamp ?? 0, clusterTimestamp); + } + } + + offset = dataStart + Number(element.size); + } + if (hasProblems) logger.info('Detected broken track interleaving in Matroska file', filePath); + return { hasProblems }; + } catch (err) { + logger.warn('Failed to check Matroska track interleaving', err); + return { hasProblems: false }; + } finally { + await handle.close(); + } +} + const enableLog = false; const encode = true; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 35cf77de..56fe3d1c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -63,6 +63,7 @@ import { RefuseOverwriteError, extractSubtitleTrackToSegments, mapRecommendedDefaultFormat, getFfCommandLine, + checkMatroskaTrackInterleaving, } from './ffmpeg'; import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, getSubtitleStreams, enableVideoTrack, enableAudioTrack, canHtml5PlayerPlayStreams, isMatroska } from './util/streams'; import { exportEdlFile, readEdlFile, loadLlcProject, askForEdlImport } from './edlStore'; @@ -146,6 +147,7 @@ function App() { const [progress, setProgress] = useState(); const [startTimeOffset, setStartTimeOffset] = useState(0); const [filePath, setFilePath] = useState(); + const [fileHasTrackInterleavingProblem, setFileHasTrackInterleavingProblem] = useState(false); const [fileDuration, setFileDuration] = useState(); const [externalFilesMeta, setExternalFilesMeta] = useState({}); const [paramsByFile, setParamsByFile] = useState(new Map()); @@ -593,7 +595,7 @@ function App() { const { concatFiles, html5ifyDummy, cutMultiple, concatCutSegments, html5ify, fixInvalidDuration, decimate, extractStreams, tryDeleteFiles, - } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate: encBitrate, appendFfmpegCommandLog, ffmpegHwaccel }); + } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate: encBitrate, appendFfmpegCommandLog, ffmpegHwaccel, fileHasTrackInterleavingProblem }); const { previewFilePath, setPreviewFilePath, usingDummyVideo, setUsingDummyVideo, userHtml5ifyCurrentFile, convertFormatBatch, html5ifyAndLoadWithPreferences } = useHtml5ify({ filePath, hasVideo, hasAudio, workingRef, setWorking, ensureWritableOutDir, customOutDir, batchFiles, enableAutoHtml5ify, setProgress, html5ify, html5ifyDummy, withErrorHandling, showGenericDialog, @@ -695,6 +697,7 @@ function App() { setExportConfirmOpen(false); setOutputPlaybackRateState(1); setCurrentFileExportCount(0); + setFileHasTrackInterleavingProblem(false); }, [videoRef, setCommandedTime, setPlaybackRate, setPreviewFilePath, setUsingDummyVideo, setPlaying, playingRef, setPlaybackMode, cutSegmentsHistory, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setSubtitlesByStreamId, setOutputPlaybackRateState]); @@ -1492,6 +1495,9 @@ function App() { const needsAutoHtml5ify = !existingHtml5FriendlyFile && !willPlayerProperlyHandleVideo({ streams: ffprobeMeta.streams, hevcPlaybackSupported, isMasBuild }) && validDuration; + // Detect Matroska files with broken track interleaving: exporting them needs a special workaround, so detect it now (when the file is opened) instead of having to deal with it after exporting. + const trackInterleavingProblemDetected = needsAutoHtml5ify ? false : (isMatroska(fileFormatNew) ? await checkMatroskaTrackInterleaving(fp) : { hasProblems: false }).hasProblems; + console.log('loadMedia', { filePath: fp, customOutDir: cod, projectPath }); // BEGIN STATE UPDATES: @@ -1532,6 +1538,7 @@ function App() { setMainFileMeta({ ffprobeMeta, stats: { size: fileStats.size, atime: fileStats.atimeMs, mtime: fileStats.mtimeMs, ctime: fileStats.ctimeMs, birthtime: fileStats.birthtimeMs } }); setCopyStreamIdsForPath(fp, () => copyStreamIdsForPathNew); setDetectedFileFormat(fileFormatNew); + setFileHasTrackInterleavingProblem(trackInterleavingProblemDetected); if (outFormatLocked) { setFileFormat(outFormatLocked); } else { @@ -1549,6 +1556,8 @@ function App() { showNotification({ icon: 'info', text: i18n.t('The audio track is not supported while previewing. You can convert to a supported format from the menu') }); } else if (!validDuration) { getSwal().toast.fire({ icon: 'warning', timer: 10000, text: i18n.t('This file does not have a valid duration. This may cause issues. You can try to fix the file\'s duration from the File menu') }); + } else if (trackInterleavingProblemDetected) { + getSwal().toast.fire({ icon: 'info', timer: 15000, showConfirmButton: true, text: i18n.t('This file has broken track interleaving') }); } // This needs to be last, because it triggers