diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index c1e8cfd234..93c0f1aa54 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -287,8 +287,13 @@ export class MediaRepository { /** * Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved. * Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video. + * + * Values are normalized to fit int32 columns: (1) PTS values are made relative by subtracting the + * first packet's PTS (handles MPEG-TS absolute PTS clock), and (2) when timeBase exceeds 90 kHz, + * all values are rescaled to 90 kHz or lower (handles nanosecond time_base from IP cameras, etc.). + * See https://github.com/immich-app/immich/issues/29600 */ - probePackets(input: string, streamIndex: number): Promise { + probePackets(input: string, streamIndex: number, timeBase?: number): Promise { const ffprobe = spawn( 'ffprobe', [ @@ -305,7 +310,15 @@ export class MediaRepository { { stdio: ['ignore', 'pipe', 'pipe'] }, ); + // Normalize to 90 kHz target to prevent int32 overflow in asset_keyframe columns. + // Two overflow paths: (1) nanosecond time_base (1/1e9) → totalDuration overflow, + // (2) MPEG-TS absolute PTS clock → pts array overflow. See #29600. + const TARGET_TIME_BASE = 90_000; + const rescaleFactor = timeBase && timeBase > TARGET_TIME_BASE ? Math.ceil(timeBase / TARGET_TIME_BASE) : 1; + const rescaledTimeBase = timeBase ? Math.floor(timeBase / rescaleFactor) : 0; + let totalDuration = 0; + let firstPts: number | null = null; const keyframePts: number[] = []; const keyframeAccDuration: number[] = []; const keyframeOwnDuration: number[] = []; @@ -315,11 +328,22 @@ export class MediaRepository { return; } const [ptsStr, durationStr, flags] = line.split(',', 3); - const pts = Number.parseInt(ptsStr); - const duration = Number.parseInt(durationStr); + let pts = Number.parseInt(ptsStr); + let duration = Number.parseInt(durationStr); if (Number.isNaN(pts) || Number.isNaN(duration) || !flags) { return; } + // Make PTS relative to the first packet to handle MPEG-TS absolute PTS clock + // (PTS can start at ~5.5e9 with 90kHz time_base, overflowing int32). + if (firstPts === null) { + firstPts = pts; + } + pts -= firstPts; + // Rescale for large time_base (e.g. nanosecond 1/1e9 → ~90kHz) + if (rescaleFactor > 1) { + pts = Math.floor(pts / rescaleFactor); + duration = Math.floor(duration / rescaleFactor); + } // Discarded packets don't contribute to packet count, but still contribute to video duration totalDuration += duration; if (flags[1] !== 'D') { @@ -367,6 +391,7 @@ export class MediaRepository { keyframePts, keyframeAccDuration, keyframeOwnDuration, + timeBase: rescaledTimeBase, }); }); }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 57c029961e..72f69aea74 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -69,6 +69,7 @@ const emptyPackets = { keyframePts: [], keyframeAccDuration: [], keyframeOwnDuration: [], + timeBase: 0, }; describe(MetadataService.name, () => { @@ -713,6 +714,7 @@ describe(MetadataService.name, () => { keyframePts: [-590, 10, 611, 1211], keyframeAccDuration: [10, 610, 6110, 12_080], keyframeOwnDuration: [10, 10, 10, 10], + timeBase: 600, }); mockReadTags({}); @@ -747,6 +749,44 @@ describe(MetadataService.name, () => { ); }); + it('should use rescaled timeBase from probePackets for nanosecond time_base videos', async () => { + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + // Video with nanosecond time_base (1/1e9) - causes int32 overflow without rescaling + mocks.media.probe.mockResolvedValue({ + ...videoInfoStub.videoStreamHDR10, + videoStreams: [ + { + ...videoInfoStub.videoStreamHDR10.videoStreams[0], + timeBase: 1_000_000_000, + }, + ], + }); + // probePackets rescales to ~90kHz and returns adjusted timeBase + mocks.media.probePackets.mockResolvedValue({ + totalDuration: 270_000, + packetCount: 81, + outputFrames: 82, + keyframePts: [0, 90_000, 180_000], + keyframeAccDuration: [90_000, 180_000, 270_000], + keyframeOwnDuration: [90_000, 90_000, 90_000], + timeBase: 90_000, + }); + mockReadTags({}); + + await sut.handleMetadataExtraction({ id: asset.id }); + + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + video: expect.objectContaining({ timeBase: 90_000 }), + keyframes: expect.objectContaining({ + totalDuration: 270_000, + pts: [0, 90_000, 180_000], + }), + }), + ); + }); + it('should prefer ffprobe frameRate over exiftool VideoFrameRate', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 171dcfe514..ae05e993ca 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -329,7 +329,7 @@ export class MetadataService extends BaseService { assetId: asset.id, bitrate: video.bitrate, frameCount: video.frameCount, - timeBase: video.timeBase, + timeBase: packets?.timeBase ?? video.timeBase, index: video.index, profile: video.profile, level: video.level, @@ -1094,7 +1094,7 @@ export class MetadataService extends BaseService { const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(originalPath); const video = videoStreams[0]; const audio = audioStreams[0]; - const packets = video?.timeBase ? await this.mediaRepository.probePackets(originalPath, video.index) : null; + const packets = video?.timeBase ? await this.mediaRepository.probePackets(originalPath, video.index, video.timeBase) : null; const tags: Pick = {}; diff --git a/server/src/types.ts b/server/src/types.ts index dca3119c76..ea03c7d1fc 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -128,6 +128,8 @@ export interface VideoPacketInfo { keyframeAccDuration: number[]; /** Each keyframe's own packet duration (needed for VFR). */ keyframeOwnDuration: number[]; + /** Rescaled timeBase denominator (adjusted when source time_base would overflow int32). */ + timeBase?: number; } export interface VideoFormat {