feat: wip

This commit is contained in:
bwees
2026-03-11 10:16:47 -05:00
parent 8fd7d154c6
commit 1a07f33593
9 changed files with 156 additions and 27 deletions

View File

@@ -18160,6 +18160,7 @@
"AssetDetectDuplicatesQueueAll", "AssetDetectDuplicatesQueueAll",
"AssetDetectDuplicates", "AssetDetectDuplicates",
"AssetEditThumbnailGeneration", "AssetEditThumbnailGeneration",
"AssetEditTranscodeGeneration",
"AssetEncodeVideoQueueAll", "AssetEncodeVideoQueueAll",
"AssetEncodeVideo", "AssetEncodeVideo",
"AssetEmptyTrash", "AssetEmptyTrash",

View File

@@ -589,6 +589,7 @@ export enum JobName {
AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll', AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll',
AssetDetectDuplicates = 'AssetDetectDuplicates', AssetDetectDuplicates = 'AssetDetectDuplicates',
AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration', AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration',
AssetEditTranscodeGeneration = 'AssetEditTranscodeGeneration',
AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll', AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll',
AssetEncodeVideo = 'AssetEncodeVideo', AssetEncodeVideo = 'AssetEncodeVideo',
AssetEmptyTrash = 'AssetEmptyTrash', AssetEmptyTrash = 'AssetEmptyTrash',

View File

@@ -335,6 +335,7 @@ export class AssetJobRepository {
.selectFrom('asset') .selectFrom('asset')
.select(['asset.id', 'asset.ownerId', 'asset.originalPath']) .select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
.select(withFiles) .select(withFiles)
.select(withEdits)
.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

@@ -29,7 +29,7 @@ const probe = (input: string, options: string[]): Promise<FfprobeData> =>
sharp.concurrency(0); sharp.concurrency(0);
sharp.cache({ files: 0 }); sharp.cache({ files: 0 });
type ProgressEvent = { export type ProgressEvent = {
frames: number; frames: number;
currentFps: number; currentFps: number;
currentKbps: number; currentKbps: number;
@@ -327,7 +327,7 @@ export class MediaRepository {
const { frameCount, percentInterval } = options.progress; const { frameCount, percentInterval } = options.progress;
const frameInterval = Math.ceil(frameCount / (100 / percentInterval)); const frameInterval = Math.ceil(frameCount / (100 / percentInterval));
if (this.logger.isLevelEnabled(LogLevel.Debug) && frameCount && frameInterval) { if (frameCount && frameInterval) {
let lastProgressFrame: number = 0; let lastProgressFrame: number = 0;
ffmpegCall.on('progress', (progress: ProgressEvent) => { ffmpegCall.on('progress', (progress: ProgressEvent) => {
if (progress.frames - lastProgressFrame < frameInterval) { if (progress.frames - lastProgressFrame < frameInterval) {
@@ -336,12 +336,17 @@ export class MediaRepository {
lastProgressFrame = progress.frames; lastProgressFrame = progress.frames;
const percent = ((progress.frames / frameCount) * 100).toFixed(2); const percent = ((progress.frames / frameCount) * 100).toFixed(2);
const ms = progress.currentFps ? Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000 : 0;
const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : ''; options.progress.callback?.(progress.frames / frameCount, progress.frames);
const outputText = output instanceof Writable ? 'stream' : output.split('/').pop();
this.logger.debug( if (this.logger.isLevelEnabled(LogLevel.Debug)) {
`Transcoding ${percent}% done${duration ? `, estimated ${duration} remaining` : ''} for output ${outputText}`, const ms = progress.currentFps ? Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000 : 0;
); const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : '';
const outputText = output instanceof Writable ? 'stream' : output.split('/').pop();
this.logger.debug(
`Transcoding ${percent}% done${duration ? `, estimated ${duration} remaining` : ''} for output ${outputText}`,
);
}
}); });
} }

View File

@@ -565,10 +565,6 @@ export class AssetService extends BaseService {
throw new BadRequestException('Only images can be edited'); throw new BadRequestException('Only images can be edited');
} }
if (asset.livePhotoVideoId) {
throw new BadRequestException('Editing live photos is not supported');
}
if (isPanorama(asset)) { if (isPanorama(asset)) {
throw new BadRequestException('Editing panorama images is not supported'); throw new BadRequestException('Editing panorama images is not supported');
} }
@@ -611,6 +607,10 @@ export class AssetService extends BaseService {
const newEdits = await this.assetEditRepository.replaceAll(id, edits); const newEdits = await this.assetEditRepository.replaceAll(id, edits);
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } }); await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
if (asset.livePhotoVideoId) {
await this.jobRepository.queue({ name: JobName.AssetEditTranscodeGeneration, data: { id } });
}
// Return the asset and its applied edits // Return the asset and its applied edits
return { return {
assetId: id, assetId: id,

View File

@@ -207,6 +207,51 @@ export class MediaService extends BaseService {
return JobStatus.Success; return JobStatus.Success;
} }
@OnJob({ name: JobName.AssetEditTranscodeGeneration, queue: QueueName.Editor })
async handleAssetEditTranscodeGeneration({ id }: JobOf<JobName.AssetEditTranscodeGeneration>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForVideoConversion(id);
if (!asset) {
return JobStatus.Failed;
}
const input = asset.originalPath;
const output = StorageCore.getEncodedVideoPath(asset);
this.storageCore.ensureFolders(output);
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
countFrames: this.logger.isLevelEnabled(LogLevel.Debug), // makes frame count more reliable for progress logs
});
const videoStream = this.getMainStream(videoStreams);
const audioStream = this.getMainStream(audioStreams);
if (!videoStream || !format.formatName) {
return JobStatus.Failed;
}
if (!videoStream.height || !videoStream.width) {
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
return JobStatus.Failed;
}
let { ffmpeg } = await this.getConfig({ withCache: true });
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
TranscodeTarget.All,
videoStream,
audioStream,
undefined, // TODO: cleaner way to do this?
asset.edits,
);
await this.mediaRepository.transcode(input, output, command);
this.logger.log(`Successfully encoded ${asset.id}`);
console.log(`Successfully encoded ${asset.id}`);
console.log(`New Path: ${output}`);
await this.assetRepository.update({ id: asset.id, encodedVideoPath: output });
return JobStatus.Success;
}
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration }) @OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> { async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
@@ -649,7 +694,7 @@ export class MediaService extends BaseService {
if (!partialFallbackSuccess) { if (!partialFallbackSuccess) {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`); this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled }; ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled }; // TODO: USE THIS TO DISABLE CPU ENCODING
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream); const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
await this.mediaRepository.transcode(input, output, command); await this.mediaRepository.transcode(input, output, command);
} }

View File

@@ -130,6 +130,7 @@ export interface TranscodeCommand {
progress: { progress: {
frameCount: number; frameCount: number;
percentInterval: number; percentInterval: number;
callback: (percent: number, frame: number) => void;
}; };
} }
@@ -151,6 +152,7 @@ export interface VideoCodecSWConfig {
videoStream: VideoStreamInfo, videoStream: VideoStreamInfo,
audioStream: AudioStreamInfo, audioStream: AudioStreamInfo,
format?: VideoFormat, format?: VideoFormat,
edits?: AssetEditActionItem[],
): TranscodeCommand; ): TranscodeCommand;
} }
@@ -389,7 +391,8 @@ export type JobItem =
| { name: JobName.WorkflowRun; data: IWorkflowJob } | { name: JobName.WorkflowRun; data: IWorkflowJob }
// Editor // Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }
| { name: JobName.AssetEditTranscodeGeneration; data: IEntityJob };
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];

View File

@@ -1,6 +1,14 @@
import { AUDIO_ENCODER } from 'src/constants'; import { AUDIO_ENCODER } from 'src/constants';
import {
AssetEditAction,
AssetEditActionItem,
CropParameters,
MirrorAxis,
MirrorParameters,
RotateParameters,
} from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum'; import { CQMode, LogLevel, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
import { import {
AudioStreamInfo, AudioStreamInfo,
BitrateDistribution, BitrateDistribution,
@@ -88,6 +96,7 @@ export class BaseConfig implements VideoCodecSWConfig {
videoStream: VideoStreamInfo, videoStream: VideoStreamInfo,
audioStream?: AudioStreamInfo, audioStream?: AudioStreamInfo,
format?: VideoFormat, format?: VideoFormat,
edits: AssetEditActionItem[] = [],
) { ) {
const options = { const options = {
inputOptions: this.getBaseInputOptions(videoStream, format), inputOptions: this.getBaseInputOptions(videoStream, format),
@@ -95,8 +104,9 @@ export class BaseConfig implements VideoCodecSWConfig {
twoPass: this.eligibleForTwoPass(), twoPass: this.eligibleForTwoPass(),
progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
} as TranscodeCommand; } as TranscodeCommand;
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) { if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
const filters = this.getFilterOptions(videoStream); const filters = this.getFilterOptions(videoStream, edits);
if (filters.length > 0) { if (filters.length > 0) {
options.outputOptions.push(`-vf ${filters.join(',')}`); options.outputOptions.push(`-vf ${filters.join(',')}`);
} }
@@ -156,10 +166,46 @@ export class BaseConfig implements VideoCodecSWConfig {
return options; return options;
} }
getFilterOptions(videoStream: VideoStreamInfo) { getEditOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[]) {
const options = []; const options = [];
if (this.shouldScale(videoStream)) { let currentDimensions = { width: videoStream.width, height: videoStream.height };
options.push(`scale=${this.getScaling(videoStream)}`);
// Apply CPU edit operations before hwupload
for (const edit of edits) {
switch (edit.action) {
case AssetEditAction.Crop: {
options.push(this.getCropOperation(edit.parameters));
currentDimensions = { width: edit.parameters.width, height: edit.parameters.height };
break;
}
case AssetEditAction.Rotate: {
const rotateFilter = this.getRotateOperation(edit.parameters);
if (rotateFilter) {
options.push(rotateFilter);
if (Math.abs(edit.parameters.angle) === 90 || Math.abs(edit.parameters.angle) === 270) {
currentDimensions = { width: currentDimensions.height, height: currentDimensions.width };
}
}
break;
}
case AssetEditAction.Mirror: {
options.push(this.getMirrorOperation(edit.parameters));
break;
}
}
}
return { options, currentDimensions };
}
getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []) {
const options = [];
const { options: editOptions, currentDimensions } = this.getEditOptions(videoStream, edits);
options.push(...editOptions);
// Apply scaling based on current dimensions after edits
if (this.shouldScale(videoStream, currentDimensions)) {
options.push(`scale=${this.getScaling(videoStream, 2, currentDimensions)}`);
} }
const tonemapOptions = this.getToneMapping(videoStream); const tonemapOptions = this.getToneMapping(videoStream);
@@ -238,9 +284,10 @@ export class BaseConfig implements VideoCodecSWConfig {
return target; return target;
} }
shouldScale(videoStream: VideoStreamInfo) { shouldScale(videoStream: VideoStreamInfo, currentDimensions?: { width: number; height: number }) {
const oddDimensions = videoStream.height % 2 !== 0 || videoStream.width % 2 !== 0; const dims = currentDimensions || { width: videoStream.width, height: videoStream.height };
const largerThanTarget = Math.min(videoStream.height, videoStream.width) > this.getTargetResolution(videoStream); const oddDimensions = dims.height % 2 !== 0 || dims.width % 2 !== 0;
const largerThanTarget = Math.min(dims.height, dims.width) > this.getTargetResolution(videoStream);
return oddDimensions || largerThanTarget; return oddDimensions || largerThanTarget;
} }
@@ -248,9 +295,11 @@ export class BaseConfig implements VideoCodecSWConfig {
return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled; return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled;
} }
getScaling(videoStream: VideoStreamInfo, mult = 2) { getScaling(videoStream: VideoStreamInfo, mult = 2, currentDimensions?: { width: number; height: number }) {
const dims = currentDimensions || { width: videoStream.width, height: videoStream.height };
const targetResolution = this.getTargetResolution(videoStream); const targetResolution = this.getTargetResolution(videoStream);
return this.isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`; const isVertical = dims.height > dims.width || this.isVideoRotated(videoStream);
return isVertical ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
} }
getSize(videoStream: VideoStreamInfo) { getSize(videoStream: VideoStreamInfo) {
@@ -329,6 +378,31 @@ export class BaseConfig implements VideoCodecSWConfig {
useCQP() { useCQP() {
return this.config.cqMode === CQMode.Cqp; return this.config.cqMode === CQMode.Cqp;
} }
// Edit operations (software filters)
getCropOperation({ x, y, width, height }: CropParameters): string {
return `crop=${width}:${height}:${x}:${y}`;
}
getRotateOperation({ angle }: RotateParameters): string {
switch (angle) {
case 90: {
return 'transpose=1'; // 90° clockwise
}
case 180: {
return 'hflip,vflip'; // 180°
}
case 270: {
return 'transpose=2'; // 90° counter-clockwise (270° clockwise)
}
}
return '';
}
getMirrorOperation({ axis }: MirrorParameters): string {
return axis === MirrorAxis.Horizontal ? 'hflip' : 'vflip';
}
} }
export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
@@ -423,14 +497,14 @@ export class ThumbnailConfig extends BaseConfig {
return ['-fps_mode vfr', '-frames:v 1', '-update 1']; return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
} }
getFilterOptions(videoStream: VideoStreamInfo): string[] { getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []): string[] {
return [ return [
'fps=12:start_time=0:eof_action=pass:round=down', 'fps=12:start_time=0:eof_action=pass:round=down',
'thumbnail=12', 'thumbnail=12',
String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`, String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`,
'trim=end_frame=2', 'trim=end_frame=2',
'reverse', 'reverse',
...super.getFilterOptions(videoStream), ...super.getFilterOptions(videoStream, edits),
]; ];
} }

View File

@@ -243,7 +243,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
!sharedLink && !sharedLink &&
isOwner && isOwner &&
asset.type === AssetTypeEnum.Image && asset.type === AssetTypeEnum.Image &&
!asset.livePhotoVideoId &&
asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR && asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR &&
!asset.originalPath.toLowerCase().endsWith('.insp') && !asset.originalPath.toLowerCase().endsWith('.insp') &&
!asset.originalPath.toLowerCase().endsWith('.gif') && !asset.originalPath.toLowerCase().endsWith('.gif') &&