This commit is contained in:
Wojciech Guziak 2026-08-14 11:02:38 -04:00 committed by GitHub
commit 4b71a766b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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

@ -326,7 +326,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,
@ -1081,7 +1081,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

@ -129,6 +129,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 {