mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge f995056ac4 into 652ef8a427
This commit is contained in:
commit
4b71a766b8
4 changed files with 72 additions and 5 deletions
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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'> = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue