fix(server): normalize probePackets values to prevent int32 overflow

probePackets() accumulates raw ffprobe packet values in time_base units
without normalization. When time_base has a large denominator (e.g.
nanosecond 1/1e9 from IP cameras) or when PTS values use the MPEG-TS
absolute clock (~5.5e9 at 90kHz), the accumulated values overflow
int32 columns in asset_keyframe (totalDuration, pts, accDuration,
ownDuration), causing PostgresError during AssetExtractMetadata.

Two normalization strategies applied:
1. Subtract first PTS from all packets (handles MPEG-TS absolute PTS
   clock that starts at ~5.5e9 regardless of clip length)
2. Rescale all values when timeBase > 90000 (handles nanosecond
   time_base where any clip > 2.1s overflows int32)

The rescaled timeBase is returned in VideoPacketInfo and used for
asset_video.timeBase, preserving the fps ratio used by HLS.

Fixes #29600
This commit is contained in:
mrwogu 2026-07-27 20:47:18 +02:00 committed by Wojciech Guziak
parent af5ff4983a
commit f995056ac4
4 changed files with 72 additions and 5 deletions

View file

@ -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<VideoPacketInfo | null> {
probePackets(input: string, streamIndex: number, timeBase?: number): Promise<VideoPacketInfo | null> {
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,
});
});
});

View file

@ -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));

View file

@ -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<ImmichTags, 'Duration' | 'Orientation' | 'ImageWidth' | 'ImageHeight'> = {};

View file

@ -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 {