diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 82c447baaa..73125f36d7 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -159,6 +159,14 @@ export class DriveService { // thunbnail, webpublic を必要なら生成 const alts = await this.generateAlts(path, type, !file.uri); + if (type && type.startsWith('video/')) { + try { + await this.videoProcessingService.webOptimizeVideo(path, type); + } catch (err) { + this.registerLogger.warn(`Video optimization failed: ${err instanceof Error ? err.message : String(err)}`, { error: err }); + } + } + if (this.meta.useObjectStorage) { //#region ObjectStorage params let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']); diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index 747fe4fc7e..3e4fd6a4b0 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -3,24 +3,41 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import fs from 'node:fs/promises'; import { Inject, Injectable } from '@nestjs/common'; import FFmpeg from 'fluent-ffmpeg'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { ImageProcessingService } from '@/core/ImageProcessingService.js'; import type { IImage } from '@/core/ImageProcessingService.js'; -import { createTempDir } from '@/misc/create-temp.js'; +import { createTemp, createTempDir } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { appendQuery, query } from '@/misc/prelude/url.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; + +// faststart is only supported for MP4, M4A, M4W and MOV files (the MOV family). +// WebM (and Matroska) files always support faststart-like behavior. +const supportedMimeTypes = new Map([ + ['video/mp4', 'mp4'], + ['video/m4a', 'mp4'], + ['video/m4v', 'mp4'], + ['video/quicktime', 'mov'], +]); @Injectable() export class VideoProcessingService { + private readonly logger: Logger; + constructor( @Inject(DI.config) private config: Config, private imageProcessingService: ImageProcessingService, + + private loggerService: LoggerService, ) { + this.logger = this.loggerService.getLogger('video-processing'); } @bindThis @@ -60,5 +77,50 @@ export class VideoProcessingService { }), ); } + + /** + * Optimize video for web playback by adding faststart flag. + * This allows the video to start playing before it is fully downloaded. + * The original file is modified in-place. + * @param source Path to the video file + * @param mimeType The MIME type of the video + * @returns Promise that resolves when optimization is complete + */ + @bindThis + public async webOptimizeVideo(source: string, mimeType: string): Promise { + const outputFormat = supportedMimeTypes.get(mimeType); + if (!outputFormat) { + this.logger.debug(`Skipping web optimization for unsupported MIME type: ${mimeType}`); + return; + } + + const [tempPath, cleanup] = await createTemp(); + + try { + await new Promise((resolve, reject) => { + FFmpeg(source) + .format(outputFormat) // Specify output format + .addOutputOptions('-c copy') // Copy streams without re-encoding + .addOutputOptions('-movflags +faststart') + .on('error', reject) + .on('end', async () => { + try { + // Replace original file with optimized version + await fs.copyFile(tempPath, source); + this.logger.info(`Web-optimized video: ${source}`); + resolve(); + } catch (copyError) { + reject(copyError); + } + }) + .save(tempPath); + }); + } catch (error) { + this.logger.warn(`Failed to web-optimize video: ${source}`, { error }); + throw error; + } finally { + cleanup(); + } + } }