Compare commits

...

2 Commits

Author SHA1 Message Date
mertalev
87e0e9c6ce linting 2026-04-27 20:10:59 -04:00
mertalev
2f75d323e3 make timeBase non-nullable 2026-04-27 19:24:43 -04:00
9 changed files with 107 additions and 102 deletions

View File

@@ -627,9 +627,12 @@ select
"asset_audio"."profile", "asset_audio"."profile",
"asset_audio"."bitrate" "asset_audio"."bitrate"
from from
"asset_audio" (
select
1
) as "dummy"
where where
"asset_audio"."assetId" = "asset"."id" "asset_audio"."assetId" is not null
) as obj ) as obj
) as "audioStream", ) as "audioStream",
( (
@@ -695,7 +698,8 @@ select
from from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
left join "asset_video" on "asset_video"."assetId" = "asset"."id" inner join "asset_video" on "asset_video"."assetId" = "asset"."id"
left join "asset_audio" on "asset_audio"."assetId" = "asset"."id"
where where
"asset"."id" = $1 "asset"."id" = $1
and "asset"."type" = 'VIDEO' and "asset"."type" = 'VIDEO'

View File

@@ -9,7 +9,7 @@ import { DB } from 'src/schema';
import { import {
anyUuid, anyUuid,
asUuid, asUuid,
withAudioVideo, withAudioStream,
withDefaultVisibility, withDefaultVisibility,
withEdits, withEdits,
withExif, withExif,
@@ -17,6 +17,8 @@ import {
withFaces, withFaces,
withFilePath, withFilePath,
withFiles, withFiles,
withVideoFormat,
withVideoStream,
} from 'src/utils/database'; } from 'src/utils/database';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
@@ -135,7 +137,9 @@ export class AssetJobRepository {
) )
.select(withEdits) .select(withEdits)
.$call(withExifInner) .$call(withExifInner)
.$call(withAudioVideo) .leftJoin('asset_video', 'asset_video.assetId', 'asset.id')
.select((eb) => withVideoStream(eb).as('videoStream'))
.select((eb) => withVideoFormat(eb).as('format'))
.where('asset.id', '=', id) .where('asset.id', '=', id)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -336,9 +340,13 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.innerJoin('asset_video', 'asset_video.assetId', 'asset.id')
.leftJoin('asset_audio', 'asset_audio.assetId', 'asset.id')
.select(['asset.id', 'asset.ownerId', 'asset.originalPath']) .select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
.select(withFiles) .select(withFiles)
.$call((qb) => withAudioVideo(qb, true)) .select((eb) => withAudioStream(eb).as('audioStream'))
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
.select((eb) => withVideoFormat(eb).$notNull().as('format'))
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.type', '=', sql.lit(AssetType.Video)) .where('asset.type', '=', sql.lit(AssetType.Video))
.executeTakeFirst(); .executeTakeFirst();

View File

@@ -14,7 +14,7 @@ export async function up(db: Kysely<any>): Promise<void> {
"assetId" uuid NOT NULL, "assetId" uuid NOT NULL,
"bitrate" integer NOT NULL, "bitrate" integer NOT NULL,
"frameCount" integer NOT NULL, "frameCount" integer NOT NULL,
"timeBase" integer, "timeBase" integer NOT NULL,
"index" smallint NOT NULL, "index" smallint NOT NULL,
"profile" smallint, "profile" smallint,
"level" smallint, "level" smallint,

View File

@@ -32,8 +32,8 @@ export class AssetVideoTable {
@Column({ type: 'integer' }) @Column({ type: 'integer' })
frameCount!: number; frameCount!: number;
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer' })
timeBase!: number | null; timeBase!: number;
@Column({ type: smallint }) @Column({ type: smallint })
index!: number; index!: number;

View File

@@ -1,4 +1,4 @@
import { ShallowDehydrateObject } from 'kysely'; import { NotNull, ShallowDehydrateObject } from 'kysely';
import { OutputInfo } from 'sharp'; import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { Exif } from 'src/database'; import { Exif } from 'src/database';
@@ -508,7 +508,7 @@ describe(MediaService.name, () => {
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
inputOptions: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'], inputOptions: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'],
outputOptions: [ outputOptions: expect.arrayContaining([
'-fps_mode', '-fps_mode',
'vfr', 'vfr',
'-frames:v', '-frames:v',
@@ -519,7 +519,7 @@ describe(MediaService.name, () => {
'verbose', 'verbose',
'-vf', '-vf',
String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc`, String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc`,
], ]),
twoPass: false, twoPass: false,
}), }),
); );
@@ -557,7 +557,7 @@ describe(MediaService.name, () => {
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
inputOptions: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'], inputOptions: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'],
outputOptions: [ outputOptions: expect.arrayContaining([
'-fps_mode', '-fps_mode',
'vfr', 'vfr',
'-frames:v', '-frames:v',
@@ -567,8 +567,8 @@ describe(MediaService.name, () => {
'-v', '-v',
'verbose', 'verbose',
'-vf', '-vf',
String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`, String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:250:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`,
], ]),
twoPass: false, twoPass: false,
}), }),
); );
@@ -608,7 +608,7 @@ describe(MediaService.name, () => {
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
inputOptions: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'], inputOptions: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'],
outputOptions: [ outputOptions: expect.arrayContaining([
'-fps_mode', '-fps_mode',
'vfr', 'vfr',
'-frames:v', '-frames:v',
@@ -618,8 +618,8 @@ describe(MediaService.name, () => {
'-v', '-v',
'verbose', 'verbose',
'-vf', '-vf',
String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`, String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:250:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`,
], ]),
twoPass: false, twoPass: false,
}), }),
); );
@@ -1937,16 +1937,16 @@ describe(MediaService.name, () => {
describe('handleVideoConversion', () => { describe('handleVideoConversion', () => {
let asset: ReturnType<typeof AssetFactory.create> & { let asset: ReturnType<typeof AssetFactory.create> & {
videoStream: VideoStreamInfo | null; videoStream: VideoStreamInfo & { timeBase: NotNull };
audioStream: AudioStreamInfo | null; audioStream: AudioStreamInfo | null;
format: VideoFormat | null; format: VideoFormat;
}; };
beforeEach(() => { beforeEach(() => {
asset = { asset = {
...AssetFactory.create({ id: 'video-id', type: AssetType.Video, originalPath: '/original/path.ext' }), ...AssetFactory.create({ id: 'video-id', type: AssetType.Video, originalPath: '/original/path.ext' }),
videoStream: null, videoStream: probeStub.videoStreamH264.videoStream,
audioStream: null, audioStream: null,
format: null, format: probeStub.videoStreamH264.format,
}; };
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
sut.videoInterfaces = { dri: ['renderD128'], mali: true }; sut.videoInterfaces = { dri: ['renderD128'], mali: true };

View File

@@ -326,7 +326,7 @@ export class MetadataService extends BaseService {
: undefined; : undefined;
const videoData = const videoData =
format?.formatName && format?.formatLongName && video?.codecName format?.formatName && format?.formatLongName && video?.codecName && video?.timeBase
? { ? {
assetId: asset.id, assetId: asset.id,
bitrate: video.bitrate, bitrate: video.bitrate,

View File

@@ -101,83 +101,75 @@ export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
export const dummy = sql`(select 1)`.as('dummy'); export const dummy = sql`(select 1)`.as('dummy');
export function withAudioVideo<O>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', O>, withAudio = false) { export function withAudioStream(eb: ExpressionBuilder<DB, 'asset_exif' | 'asset_audio'>) {
return qb return jsonObjectFrom(
.$if(withAudio, (qb) => eb
qb.select((eb) => .selectFrom(dummy)
jsonObjectFrom( .select(['asset_audio.index', 'asset_audio.codecName', 'asset_audio.profile', 'asset_audio.bitrate'])
eb .where('asset_audio.assetId', 'is not', sql.lit(null))
.selectFrom('asset_audio') .$castTo<AudioStreamInfo | null>(),
.select(['asset_audio.index', 'asset_audio.codecName', 'asset_audio.profile', 'asset_audio.bitrate']) );
.whereRef('asset_audio.assetId', '=', 'asset.id'), }
)
.$castTo<AudioStreamInfo | null>() export function withVideoStream(eb: ExpressionBuilder<DB, 'asset_exif' | 'asset_video'>) {
.as('audioStream'), return jsonObjectFrom(
), eb
) .selectFrom(dummy)
.leftJoin('asset_video', 'asset_video.assetId', 'asset.id') .select((eb) => [
.select((eb) => 'asset_video.index',
jsonObjectFrom( 'asset_video.codecName',
'asset_video.profile',
'asset_video.level',
'asset_video.bitrate',
'asset_exif.exifImageWidth as width',
'asset_exif.exifImageHeight as height',
'asset_video.pixelFormat',
'asset_video.frameCount',
'asset_exif.fps as frameRate',
'asset_video.timeBase',
eb eb
.selectFrom(dummy) .case()
.where('asset_video.assetId', 'is not', sql.lit(null)) .when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate90CW.toString()))
.select((eb) => [ .then(sql.lit(-90))
'asset_video.index', .when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate270CW.toString()))
'asset_video.codecName', .then(sql.lit(90))
'asset_video.profile', .when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate180.toString()))
'asset_video.level', .then(sql.lit(180))
'asset_video.bitrate', .else(0)
'asset_exif.exifImageWidth as width', .end()
'asset_exif.exifImageHeight as height', .as('rotation'),
'asset_video.pixelFormat', 'asset_video.colorPrimaries',
'asset_video.frameCount', 'asset_video.colorMatrix',
'asset_exif.fps as frameRate', 'asset_video.colorTransfer',
'asset_video.timeBase', 'asset_video.dvProfile',
eb 'asset_video.dvLevel',
.case() 'asset_video.dvBlSignalCompatibilityId',
.when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate90CW.toString())) ])
.then(sql.lit(-90)) .where('asset_video.assetId', 'is not', sql.lit(null)),
.when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate270CW.toString())) ).$castTo<(VideoStreamInfo & { timeBase: NotNull }) | null>();
.then(sql.lit(90)) }
.when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate180.toString()))
.then(sql.lit(180)) export function withVideoFormat(eb: ExpressionBuilder<DB, 'asset' | 'asset_video'>) {
.else(0) return jsonObjectFrom(
.end() eb
.as('rotation'), .selectFrom(dummy)
'asset_video.colorPrimaries', .select([
'asset_video.colorMatrix', 'asset_video.formatName',
'asset_video.colorTransfer', 'asset_video.formatLongName',
'asset_video.dvProfile', // TODO: simplify after https://github.com/immich-app/immich/pull/28003
'asset_video.dvLevel',
'asset_video.dvBlSignalCompatibilityId',
])
.$castTo<VideoStreamInfo | null>(),
).as('videoStream'),
)
.select((eb) =>
jsonObjectFrom(
eb eb
.selectFrom(dummy) .case()
.where('asset_video.assetId', 'is not', sql.lit(null)) .when('asset.duration', '~', sql<string>`'^\\d{2}:\\d{2}:\\d{2}\\.\\d{3}$'`)
.select((eb) => [ .then(
'asset_video.formatName', sql<number>`substr(asset.duration, 1, 2)::int * 3600000 + substr(asset.duration, 4, 2)::int * 60000 + substr(asset.duration, 7, 2)::int * 1000 + substr(asset.duration, 10, 3)::int`,
'asset_video.formatLongName', )
// TODO: simplify after https://github.com/immich-app/immich/pull/28003 .else(sql.lit(0))
eb .end()
.case() .as('duration'),
.when('asset.duration', '~', sql<string>`'^\\d{2}:\\d{2}:\\d{2}\\.\\d{3}$'`) 'asset_video.bitrate',
.then( ])
sql<number>`substr(asset.duration, 1, 2)::int * 3600000 + substr(asset.duration, 4, 2)::int * 60000 + substr(asset.duration, 7, 2)::int * 1000 + substr(asset.duration, 10, 3)::int`, .where('asset_video.assetId', 'is not', sql.lit(null)),
) ).$castTo<VideoFormat | null>();
.else(sql.lit(0))
.end()
.as('duration'),
'asset_video.bitrate',
]),
)
.$castTo<VideoFormat | null>()
.as('format'),
);
} }
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'asset', O>) { export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {

View File

@@ -1,3 +1,4 @@
import { NotNull } from 'kysely';
import { ColorMatrix, ColorPrimaries, ColorTransfer, DvProfile, DvSignalCompatibility } from 'src/enum'; import { ColorMatrix, ColorPrimaries, ColorTransfer, DvProfile, DvSignalCompatibility } from 'src/enum';
import { AudioStreamInfo, VideoFormat, VideoInfo, VideoStreamInfo } from 'src/types'; import { AudioStreamInfo, VideoFormat, VideoInfo, VideoStreamInfo } from 'src/types';
@@ -392,9 +393,9 @@ export const videoInfoStub = {
}; };
interface SelectedStreams { interface SelectedStreams {
videoStream: VideoStreamInfo | null; videoStream: VideoStreamInfo & { timeBase: NotNull };
audioStream: AudioStreamInfo | null; audioStream: AudioStreamInfo | null;
format: VideoFormat | null; format: VideoFormat;
} }
const toSelectedStreams = (info: VideoInfo) => ({ const toSelectedStreams = (info: VideoInfo) => ({

View File

@@ -1,4 +1,4 @@
import { Selectable, ShallowDehydrateObject } from 'kysely'; import { NotNull, Selectable, ShallowDehydrateObject } from 'kysely';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { ActivityTable } from 'src/schema/tables/activity.table'; import { ActivityTable } from 'src/schema/tables/activity.table';
@@ -156,7 +156,7 @@ export const getForGenerateThumbnail = (asset: ReturnType<AssetFactory['build']>
files: asset.files.map((file) => getDehydrated(file)), files: asset.files.map((file) => getDehydrated(file)),
exifInfo: getDehydrated(asset.exifInfo), exifInfo: getDehydrated(asset.exifInfo),
edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[], edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[],
videoStream: null as VideoStreamInfo | null, videoStream: null as (VideoStreamInfo & { timeBase: NotNull }) | null,
audioStream: null as AudioStreamInfo | null, audioStream: null as AudioStreamInfo | null,
format: null as VideoFormat | null, format: null as VideoFormat | null,
}); });