From e9f22cff93016d438b9d828b5697f29a49440cc1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 10:27:11 +0530 Subject: [PATCH 01/15] case --- web/packages/gallery/services/ffmpeg/web.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/packages/gallery/services/ffmpeg/web.ts b/web/packages/gallery/services/ffmpeg/web.ts index 1bcbe6b948..acf2eb5dda 100644 --- a/web/packages/gallery/services/ffmpeg/web.ts +++ b/web/packages/gallery/services/ffmpeg/web.ts @@ -164,7 +164,7 @@ const substitutePlaceholders = ( }) .filter((s) => s !== undefined); -const isHDRVideoFFProbeOutput = z.object({ +const IsHDRVideoFFProbeOutput = z.object({ streams: z.array(z.object({ color_transfer: z.string().optional() })), }); @@ -201,7 +201,7 @@ const isHDRVideo = async (ffmpeg: FFmpeg, inputFilePath: string) => { "output.json", ); - const output = isHDRVideoFFProbeOutput.parse(JSON.parse(jsonString)); + const output = IsHDRVideoFFProbeOutput.parse(JSON.parse(jsonString)); switch (output.streams[0]?.color_transfer) { case "smpte2084": case "arib-std-b67": From f92101eaf81929a60e64c9c1d898e4048c5a0984 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 11:57:57 +0530 Subject: [PATCH 02/15] Outline --- web/packages/gallery/services/ffmpeg/index.ts | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/web/packages/gallery/services/ffmpeg/index.ts b/web/packages/gallery/services/ffmpeg/index.ts index a182ee1400..4a9f27595f 100644 --- a/web/packages/gallery/services/ffmpeg/index.ts +++ b/web/packages/gallery/services/ffmpeg/index.ts @@ -116,18 +116,17 @@ const _makeGenThumbnailCommand = (seekTime: number, forHDR: boolean) => [ ]; /** - * Extract metadata from the given video + * Extract metadata from the given video. * - * When we're running in the context of our desktop app _and_ we're passed a - * file path , this uses the native FFmpeg bundled with our desktop app. - * Otherwise it uses a Wasm build of FFmpeg running in a web worker. + * When we're running in the context of our desktop app _and_ we're passed an + * upload item that resolves to a path of the user's file system, this uses the + * native FFmpeg bundled with our desktop app. Otherwise it uses a Wasm build of + * FFmpeg running in a web worker. * - * This function is called during upload, when we need to extract the metadata - * of videos that the user is uploading. + * This function is called during upload, when we need to extract the + * "ffmetadata" of videos that the user is uploading. * - * @param uploadItem A {@link File}, or the absolute path to a file on the - * user's local file system. A path can only be provided when we're running in - * the context of our desktop app. + * @param uploadItem The video item being uploaded. */ export const extractVideoMetadata = async ( uploadItem: UploadItem, @@ -260,6 +259,26 @@ const parseFFMetadataDate = (s: string | undefined) => { return d; }; +/** + * Extract the duration (in seconds) from the given video + * + * This is a sibling of {@link extractVideoMetadata}, except it tries to + * determine the duration of the video. The duration is not part of the + * "ffmetadata", and is instead a property of the video itself. + * + * @param uploadItem The video item being uploaded. + * + * @return the duration of the video in seconds (a floating point number). + */ +export const determineVideoDuration = async ( + uploadItem: UploadItem, +): Promise => + uploadItem instanceof File + ? await determineVideoDurationWeb(uploadItem) + : await ensureElectron().ffmpegDetermineVideoDuration( + toDataOrPathOrZipEntry(uploadItem), + ); + /** * Convert a video from a format that is not supported in the browser to MP4. * From 3677d53ea9d1d4858c7bc40910ca7e45c9e3e2ef Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 12:14:48 +0530 Subject: [PATCH 03/15] Sketch --- web/packages/gallery/services/ffmpeg/index.ts | 2 +- web/packages/gallery/services/ffmpeg/web.ts | 163 ++++++++++++------ 2 files changed, 116 insertions(+), 49 deletions(-) diff --git a/web/packages/gallery/services/ffmpeg/index.ts b/web/packages/gallery/services/ffmpeg/index.ts index 4a9f27595f..fd44297021 100644 --- a/web/packages/gallery/services/ffmpeg/index.ts +++ b/web/packages/gallery/services/ffmpeg/index.ts @@ -20,7 +20,7 @@ import { inputPathPlaceholder, outputPathPlaceholder, } from "./constants"; -import { ffmpegExecWeb } from "./web"; +import { determineVideoDurationWeb, ffmpegExecWeb } from "./web"; /** * Generate a thumbnail for the given video using a Wasm FFmpeg running in a web diff --git a/web/packages/gallery/services/ffmpeg/web.ts b/web/packages/gallery/services/ffmpeg/web.ts index acf2eb5dda..bbcd67beff 100644 --- a/web/packages/gallery/services/ffmpeg/web.ts +++ b/web/packages/gallery/services/ffmpeg/web.ts @@ -15,7 +15,7 @@ import { let _ffmpeg: Promise | undefined; /** Queue of in-flight requests. */ -const _ffmpegTaskQueue = new PromiseQueue(); +const _ffmpegTaskQueue = new PromiseQueue(); /** * Return the shared {@link FFmpeg} instance, lazily creating and loading it if @@ -45,7 +45,7 @@ const createFFmpeg = async () => { * * @param command The FFmpeg command to execute. * - * @param blob The input data on which to run the command, provided as a blob. + * @param blob The input blob on which to run the command. * * @param outputFileExtension The extension of the (temporary) output file which * will be generated by the command. @@ -66,7 +66,26 @@ export const ffmpegExecWeb = async ( // So serialize them using a promise queue. return _ffmpegTaskQueue.add(() => ffmpegExec(ffmpeg, command, outputFileExtension, blob), - ); + ) as Promise; +}; + +/** + * Determine the duration of the given video blob. + * + * This is a specialized variant of {@link ffmpegExecWeb} that uses the same + * queue but internally uses ffprobe to try and determine the video's duration. + * + * @param blob The input blob on which to run the command, provided as a blob. + * + * @returns The duration of the {@link blob} (if it indeed is a video). + */ +export const determineVideoDurationWeb = async ( + blob: Blob, +): Promise => { + const ffmpeg = await ffmpegLazy(); + return _ffmpegTaskQueue.add(() => + ffprobeExecVideoDuration(ffmpeg, blob), + ) as Promise; }; const ffmpegExec = async ( @@ -75,53 +94,78 @@ const ffmpegExec = async ( outputFileExtension: string, blob: Blob, ) => { - const mountDir = "/mount"; - const inputFileName = newID("in_"); - const inputPath = joinPath(mountDir, inputFileName); - const outputSuffix = outputFileExtension ? "." + outputFileExtension : ""; const outputPath = newID("out_") + outputSuffix; - const inputFile = new File([blob], inputFileName); - // Exit status of the ffmpeg.exec invocation. // `0` if no error, `!= 0` if timeout (1) or error. let status: number | undefined; - try { - const startTime = Date.now(); + return withInputMount(ffmpeg, blob, async (inputPath) => { + try { + const startTime = Date.now(); + let resolvedCommand: string[]; + if (Array.isArray(command)) { + resolvedCommand = command; + } else { + const isHDR = await isHDRVideo(ffmpeg, inputPath); + resolvedCommand = isHDR ? command.hdr : command.default; + } + + const cmd = substitutePlaceholders( + resolvedCommand, + inputPath, + outputPath, + ); + + status = await ffmpeg.exec(cmd); + if (status !== 0) { + log.info( + `[wasm] ffmpeg command failed with exit code ${status}: ${cmd.join(" ")}`, + ); + throw new Error( + `ffmpeg command failed with exit code ${status}`, + ); + } + + const result = await ffmpeg.readFile(outputPath); + if (typeof result == "string") + throw new Error("Expected binary data"); + + const ms = Date.now() - startTime; + log.debug(() => `[wasm] ffmpeg ${cmd.join(" ")} (${ms} ms)`); + return result; + } finally { + try { + await ffmpeg.deleteFile(outputPath); + } catch (e) { + // Output file might not even exist if the command did not succeed, + // so only log on success. + if (status === 0) { + log.error(`Failed to remove output ${outputPath}`, e); + } + } + } + }); +}; + +const withInputMount = async ( + ffmpeg: FFmpeg, + blob: Blob, + f: (inputPath: string) => Promise, +) => { + const mountDir = "/mount"; + const inputFileName = newID("in_"); + const inputPath = joinPath(mountDir, inputFileName); + + const inputFile = new File([blob], inputFileName); + + try { await ffmpeg.createDir(mountDir); await ffmpeg.mount(FFFSType.WORKERFS, { files: [inputFile] }, mountDir); - let resolvedCommand: string[]; - if (Array.isArray(command)) { - resolvedCommand = command; - } else { - const isHDR = await isHDRVideo(ffmpeg, inputPath); - resolvedCommand = isHDR ? command.hdr : command.default; - } - - const cmd = substitutePlaceholders( - resolvedCommand, - inputPath, - outputPath, - ); - - status = await ffmpeg.exec(cmd); - if (status !== 0) { - log.info( - `[wasm] ffmpeg command failed with exit code ${status}: ${cmd.join(" ")}`, - ); - throw new Error(`ffmpeg command failed with exit code ${status}`); - } - - const result = await ffmpeg.readFile(outputPath); - if (typeof result == "string") throw new Error("Expected binary data"); - - const ms = Date.now() - startTime; - log.debug(() => `[wasm] ffmpeg ${cmd.join(" ")} (${ms} ms)`); - return result; + return await f(inputPath); } finally { try { await ffmpeg.unmount(mountDir); @@ -133,15 +177,6 @@ const ffmpegExec = async ( } catch (e) { log.error(`Failed to delete mount directory ${mountDir}`, e); } - try { - await ffmpeg.deleteFile(outputPath); - } catch (e) { - // Output file might not even exist if the command did not succeed, - // so only log on success. - if (status === 0) { - log.error(`Failed to remove output ${outputPath}`, e); - } - } } }; @@ -258,3 +293,35 @@ const ffprobeOutput = async ( } } }; + +const ffprobeExecVideoDuration = async (ffmpeg: FFmpeg, blob: Blob) => + withInputMount(ffmpeg, blob, async (inputPath) => { + const jsonString = await ffprobeOutput( + ffmpeg, + [ + ["-i", inputPath], + // Show information about streams. + "-show_streams", + // Select the first video stream. This is not necessarily + // correct in a multi stream file because the ffmpeg automatic + // mapping will use the highest resolution stream, but short of + // reinventing ffmpeg's resolution mechanism, it is a reasonable + // assumption for our current heuristic check. + ["-select_streams", "v:0"], + // Output JSON + ["-of", "json"], + ["-o", "output.json"], + ].flat(), + "output.json", + ); + + const output = IsHDRVideoFFProbeOutput.parse(JSON.parse(jsonString)); + switch (output.streams[0]?.color_transfer) { + case "smpte2084": + case "arib-std-b67": + // TODO: Implement me + return 1; + default: + return 0; + } + }); From be65f0fba8dd00d259181e06d79139b9cad10ce1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 12:20:21 +0530 Subject: [PATCH 04/15] tailored log inputPath is a random id in this context and doesn't provide any extra info --- web/packages/gallery/services/ffmpeg/web.ts | 30 ++++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/web/packages/gallery/services/ffmpeg/web.ts b/web/packages/gallery/services/ffmpeg/web.ts index bbcd67beff..f3484ee8d9 100644 --- a/web/packages/gallery/services/ffmpeg/web.ts +++ b/web/packages/gallery/services/ffmpeg/web.ts @@ -216,8 +216,9 @@ const IsHDRVideoFFProbeOutput = z.object({ * `false` to make this function safe to invoke without breaking the happy path. */ const isHDRVideo = async (ffmpeg: FFmpeg, inputFilePath: string) => { + let jsonString: string | undefined; try { - const jsonString = await ffprobeOutput( + jsonString = await ffprobeOutput( ffmpeg, [ ["-i", inputFilePath], @@ -245,7 +246,8 @@ const isHDRVideo = async (ffmpeg: FFmpeg, inputFilePath: string) => { return false; } } catch (e) { - log.warn(`Could not detect HDR status of ${inputFilePath}`, e); + log.warn("Could not detect HDR status", e); + if (jsonString) log.debug(() => ["ffprobe-output", jsonString]); return false; } }; @@ -315,13 +317,21 @@ const ffprobeExecVideoDuration = async (ffmpeg: FFmpeg, blob: Blob) => "output.json", ); - const output = IsHDRVideoFFProbeOutput.parse(JSON.parse(jsonString)); - switch (output.streams[0]?.color_transfer) { - case "smpte2084": - case "arib-std-b67": - // TODO: Implement me - return 1; - default: - return 0; + try { + const output = IsHDRVideoFFProbeOutput.parse( + JSON.parse(jsonString), + ); + switch (output.streams[0]?.color_transfer) { + case "smpte2084": + case "arib-std-b67": + // TODO: Implement me + return 1; + default: + return 0; + } + } catch (e) { + log.warn("Could not determine video duration", e); + log.debug(() => ["ffprobe-output", jsonString]); + throw e; } }); From 790c022730ed249746ea61dc1553bcd70721b6f9 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 12:37:54 +0530 Subject: [PATCH 05/15] Impl --- web/packages/gallery/services/ffmpeg/index.ts | 4 +- web/packages/gallery/services/ffmpeg/web.ts | 51 ++++++++----------- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/web/packages/gallery/services/ffmpeg/index.ts b/web/packages/gallery/services/ffmpeg/index.ts index fd44297021..2fc5b12a54 100644 --- a/web/packages/gallery/services/ffmpeg/index.ts +++ b/web/packages/gallery/services/ffmpeg/index.ts @@ -275,9 +275,9 @@ export const determineVideoDuration = async ( ): Promise => uploadItem instanceof File ? await determineVideoDurationWeb(uploadItem) - : await ensureElectron().ffmpegDetermineVideoDuration( + : 0; /*, await ensureElectron().ffmpegDetermineVideoDuration( toDataOrPathOrZipEntry(uploadItem), - ); + ));*/ /** * Convert a video from a format that is not supported in the browser to MP4. diff --git a/web/packages/gallery/services/ffmpeg/web.ts b/web/packages/gallery/services/ffmpeg/web.ts index f3484ee8d9..3b875c37ac 100644 --- a/web/packages/gallery/services/ffmpeg/web.ts +++ b/web/packages/gallery/services/ffmpeg/web.ts @@ -298,40 +298,33 @@ const ffprobeOutput = async ( const ffprobeExecVideoDuration = async (ffmpeg: FFmpeg, blob: Blob) => withInputMount(ffmpeg, blob, async (inputPath) => { - const jsonString = await ffprobeOutput( + // Determine the video duration from the container, bypassing the issues + // with stream selection. + // + // ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 input.mp4 + // + // Source: + // https://trac.ffmpeg.org/wiki/FFprobeTips#Formatcontainerduration + // + // Reference: https://ffmpeg.org/ffprobe.html + + const durationString = await ffprobeOutput( ffmpeg, [ ["-i", inputPath], - // Show information about streams. - "-show_streams", - // Select the first video stream. This is not necessarily - // correct in a multi stream file because the ffmpeg automatic - // mapping will use the highest resolution stream, but short of - // reinventing ffmpeg's resolution mechanism, it is a reasonable - // assumption for our current heuristic check. - ["-select_streams", "v:0"], - // Output JSON - ["-of", "json"], - ["-o", "output.json"], + ["-v", "error"], + ["-show_entries", "format=duration"], + ["-of", "default=noprint_wrappers=1:nokey=1"], + ["-o", "output.txt"], ].flat(), - "output.json", + "output.txt", ); - try { - const output = IsHDRVideoFFProbeOutput.parse( - JSON.parse(jsonString), - ); - switch (output.streams[0]?.color_transfer) { - case "smpte2084": - case "arib-std-b67": - // TODO: Implement me - return 1; - default: - return 0; - } - } catch (e) { - log.warn("Could not determine video duration", e); - log.debug(() => ["ffprobe-output", jsonString]); - throw e; + const duration = parseFloat(durationString); + if (isNaN(duration)) { + const msg = "Could not parse video duration"; + log.warn(msg, durationString); + throw new Error(msg); } + return duration; }); From d1d91338af02a29cb5d8ea4ee7b3f0dd1cf9f804 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 12:47:05 +0530 Subject: [PATCH 06/15] Dur 1 --- .../gallery/services/upload/upload-service.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/web/packages/gallery/services/upload/upload-service.ts b/web/packages/gallery/services/upload/upload-service.ts index 862820df86..96f091f7ad 100644 --- a/web/packages/gallery/services/upload/upload-service.ts +++ b/web/packages/gallery/services/upload/upload-service.ts @@ -1,6 +1,7 @@ // TODO: Audit this file /* eslint-disable @typescript-eslint/ban-ts-comment */ +import { isDesktop } from "ente-base/app"; import { streamEncryptionChunkSize } from "ente-base/crypto/libsodium"; import type { BytesOrB64 } from "ente-base/crypto/types"; import { type CryptoWorker } from "ente-base/crypto/worker"; @@ -9,7 +10,10 @@ import { basename, nameAndExtension } from "ente-base/file-name"; import type { PublicAlbumsCredentials } from "ente-base/http"; import log from "ente-base/log"; import { extractExif } from "ente-gallery/services/exif"; -import { extractVideoMetadata } from "ente-gallery/services/ffmpeg"; +import { + determineVideoDuration, + extractVideoMetadata, +} from "ente-gallery/services/ffmpeg"; import { getNonEmptyMagicMetadataProps, updateMagicMetadata, @@ -37,6 +41,7 @@ import { import { FileType, type FileTypeInfo } from "ente-media/file-type"; import { encodeLivePhoto } from "ente-media/live-photo"; import { addToCollection } from "ente-new/photos/services/collection"; +import { settingsSnapshot } from "ente-new/photos/services/settings"; import { CustomError, handleUploadError } from "ente-shared/error"; import { mergeUint8Arrays } from "ente-utils/array"; import { ensureInteger, ensureNumber } from "ente-utils/ensure"; @@ -1043,6 +1048,18 @@ const extractImageOrVideoMetadata = async ( tryParseEpochMicrosecondsFromFileName(fileName) ?? modificationTime; } + // Video duration + let duration: number | undefined; + // TODO(HLS): + if ( + !isDesktop && + fileType == FileType.video && + settingsSnapshot().isInternalUser + ) { + duration = await tryDetermineVideoDuration(uploadItem); + log.debug(() => ["extracted duration", duration]); + } + // To avoid introducing malformed data into the metadata fields (which the // other clients might not expect and handle), we have extra "ensure" checks // here that act as a safety valve if somehow the TypeScript type is lying. @@ -1119,6 +1136,16 @@ const tryExtractVideoMetadata = async (uploadItem: UploadItem) => { } }; +const tryDetermineVideoDuration = async (uploadItem: UploadItem) => { + try { + return await determineVideoDuration(uploadItem); + } catch (e) { + const fileName = uploadItemFileName(uploadItem); + log.error(`Failed to extract video duration for ${fileName}`, e); + return undefined; + } +}; + const computeHash = async (uploadItem: UploadItem, worker: CryptoWorker) => { const { stream, chunkCount } = await readUploadItem(uploadItem); const hashState = await worker.initChunkHashing(); From 5e4de0793a33eaf4c106073c8b1f929b304cf782 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 15:23:30 +0530 Subject: [PATCH 07/15] json a sample file (ElephantsDream.mp4, might be obtainable online) was causing the string based duration format to fail, but is working with the json variant --- web/packages/gallery/services/ffmpeg/web.ts | 29 ++++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/web/packages/gallery/services/ffmpeg/web.ts b/web/packages/gallery/services/ffmpeg/web.ts index 3b875c37ac..d290010562 100644 --- a/web/packages/gallery/services/ffmpeg/web.ts +++ b/web/packages/gallery/services/ffmpeg/web.ts @@ -199,7 +199,7 @@ const substitutePlaceholders = ( }) .filter((s) => s !== undefined); -const IsHDRVideoFFProbeOutput = z.object({ +const FFProbeOutputIsHDR = z.object({ streams: z.array(z.object({ color_transfer: z.string().optional() })), }); @@ -237,7 +237,7 @@ const isHDRVideo = async (ffmpeg: FFmpeg, inputFilePath: string) => { "output.json", ); - const output = IsHDRVideoFFProbeOutput.parse(JSON.parse(jsonString)); + const output = FFProbeOutputIsHDR.parse(JSON.parse(jsonString)); switch (output.streams[0]?.color_transfer) { case "smpte2084": case "arib-std-b67": @@ -296,30 +296,45 @@ const ffprobeOutput = async ( } }; +const FFProbeOutputDuration = z.object({ + format: z.object({ duration: z.string() }), +}); + const ffprobeExecVideoDuration = async (ffmpeg: FFmpeg, blob: Blob) => withInputMount(ffmpeg, blob, async (inputPath) => { // Determine the video duration from the container, bypassing the issues // with stream selection. // - // ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 input.mp4 + // ffprobe -v error -show_entries format=duration -of + // default=noprint_wrappers=1:nokey=1 input.mp4 // // Source: // https://trac.ffmpeg.org/wiki/FFprobeTips#Formatcontainerduration // // Reference: https://ffmpeg.org/ffprobe.html + // + // Since we cannot grab the stdout easily, the command has been modified + // to output to a file instead. However, in doing the command seems to + // have become flaky - for certain videos, it outputs extra lines and + // not just the duration. So we also switch to the JSON output for more + // robust behaviour, and parse the duration from it. - const durationString = await ffprobeOutput( + const jsonString = await ffprobeOutput( ffmpeg, [ ["-i", inputPath], ["-v", "error"], ["-show_entries", "format=duration"], - ["-of", "default=noprint_wrappers=1:nokey=1"], - ["-o", "output.txt"], + ["-of", "json"], + ["-o", "output.json"], ].flat(), - "output.txt", + "output.json", ); + const durationString = FFProbeOutputDuration.parse( + JSON.parse(jsonString), + ).format.duration; + const duration = parseFloat(durationString); if (isNaN(duration)) { const msg = "Could not parse video duration"; From 6062c2025195597bbe06865d63df0d01cc966638 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 15:29:52 +0530 Subject: [PATCH 08/15] Attach --- web/packages/gallery/services/upload/upload-service.ts | 8 ++++++-- web/packages/media/file-metadata.ts | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/web/packages/gallery/services/upload/upload-service.ts b/web/packages/gallery/services/upload/upload-service.ts index 96f091f7ad..6eb7fb383e 100644 --- a/web/packages/gallery/services/upload/upload-service.ts +++ b/web/packages/gallery/services/upload/upload-service.ts @@ -1050,10 +1050,10 @@ const extractImageOrVideoMetadata = async ( // Video duration let duration: number | undefined; - // TODO(HLS): if ( - !isDesktop && fileType == FileType.video && + // TODO(HLS): + !isDesktop && settingsSnapshot().isInternalUser ) { duration = await tryDetermineVideoDuration(uploadItem); @@ -1077,6 +1077,10 @@ const extractImageOrVideoMetadata = async ( hash, }; + if (duration) { + metadata.duration = ensureNumber(duration); + } + const location = parsedMetadataJSON?.location ?? parsedMetadata?.location; if (location) { metadata.latitude = ensureNumber(location.latitude); diff --git a/web/packages/media/file-metadata.ts b/web/packages/media/file-metadata.ts index e59d8d88e5..f3fdee0318 100644 --- a/web/packages/media/file-metadata.ts +++ b/web/packages/media/file-metadata.ts @@ -140,6 +140,12 @@ export interface Metadata { * older clients. */ videoHash?: string; + /** + * The duration (in seconds) of the video. + * + * Only present for videos (`fileType == FileType.video`). + */ + duration?: number; hasStaticThumbnail?: boolean; localID?: number; version?: number; From bb352f32665ec4c781d1d23db392265f327fef82 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 15:43:32 +0530 Subject: [PATCH 09/15] mobile compat --- web/packages/gallery/services/upload/upload-service.ts | 2 +- web/packages/media/file-metadata.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/packages/gallery/services/upload/upload-service.ts b/web/packages/gallery/services/upload/upload-service.ts index 6eb7fb383e..0a6e6f2e7f 100644 --- a/web/packages/gallery/services/upload/upload-service.ts +++ b/web/packages/gallery/services/upload/upload-service.ts @@ -1078,7 +1078,7 @@ const extractImageOrVideoMetadata = async ( }; if (duration) { - metadata.duration = ensureNumber(duration); + metadata.duration = ensureInteger(Math.ceil(duration)); } const location = parsedMetadataJSON?.location ?? parsedMetadata?.location; diff --git a/web/packages/media/file-metadata.ts b/web/packages/media/file-metadata.ts index f3fdee0318..900dd89f4a 100644 --- a/web/packages/media/file-metadata.ts +++ b/web/packages/media/file-metadata.ts @@ -141,9 +141,11 @@ export interface Metadata { */ videoHash?: string; /** - * The duration (in seconds) of the video. + * The duration (in integral seconds) of the video. * - * Only present for videos (`fileType == FileType.video`). + * Only present for videos (`fileType == FileType.video`). For compatibility + * with other clients, this must be a integer number of seconds, without any + * sub-second fraction. */ duration?: number; hasStaticThumbnail?: boolean; From 9424d26f559ba99c42fc852dece2fcaadc251423 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 16:07:27 +0530 Subject: [PATCH 10/15] Show --- web/apps/photos/src/components/FileList.tsx | 27 +++++++++++---- web/packages/media/file-metadata.ts | 38 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/web/apps/photos/src/components/FileList.tsx b/web/apps/photos/src/components/FileList.tsx index dfba4ab60c..9039b933ce 100644 --- a/web/apps/photos/src/components/FileList.tsx +++ b/web/apps/photos/src/components/FileList.tsx @@ -9,6 +9,7 @@ import { isSameDay } from "ente-base/date"; import { formattedDateRelative } from "ente-base/i18n-date"; import { downloadManager } from "ente-gallery/services/download"; import { EnteFile, enteFileDeletionDate } from "ente-media/file"; +import { fileDurationString } from "ente-media/file-metadata"; import { FileType } from "ente-media/file-type"; import { GAP_BTW_TILES, @@ -1198,15 +1199,13 @@ const FileThumbnail: React.FC = ({ ) : ( )} - {file.metadata.fileType === FileType.livePhoto ? ( + {file.metadata.fileType == FileType.livePhoto ? ( - + ) : ( - file.metadata.fileType === FileType.video && ( - - - + file.metadata.fileType == FileType.video && ( + ) )} {selected && } @@ -1400,3 +1399,19 @@ const SelectedOverlay = styled(Overlay)( border-radius: 4px; `, ); + +interface VideoDurationOverlayProps { + duration: string | undefined; +} + +const VideoDurationOverlay: React.FC = ({ + duration, +}) => ( + + {duration ? ( + {duration} + ) : ( + + )} + +); diff --git a/web/packages/media/file-metadata.ts b/web/packages/media/file-metadata.ts index 900dd89f4a..0056e4928f 100644 --- a/web/packages/media/file-metadata.ts +++ b/web/packages/media/file-metadata.ts @@ -767,6 +767,44 @@ export const fileLocation = (file: EnteFile): Location | undefined => { return { latitude, longitude }; }; +/** + * Return the duration of the video as a formatted "HH:mm:ss" string (when + * present) for the given {@link EnteFile}. + * + * Only files with type `FileType.video` are expected to have a duration. + * + * @returns The duration of the video as a string of the form "HH:mm:ss". The + * underlying duration present in the file's metadata is guaranteed to be + * integral, so there will never be a subsecond component. + * + * - If the hour component is all zeroes, it will be omitted. + * + * - Leading zeros in the minutes component will be trimmed off if an hour + * component is not present. If minutes is all zeros, then "0" will be used. + * + * - For example, an underlying duration of 595 seconds will result in a + * formatted string of the form "9:55". While an underlying duration of 9 + * seconds will be returned as a string "0:09". + * + * - A zero duration will be treated as undefined. + */ +export const fileDurationString = (file: EnteFile): string | undefined => { + const d = file.metadata.duration; + if (!d) return undefined; + + const s = d % 60; + const m = Math.floor(d / 60) % 60; + const h = Math.floor(d / 3600); + + const ss = s > 9 ? `${s}` : `0${s}`; + if (h) { + const mm = m > 9 ? `${m}` : `0${m}`; + return `${h}:${mm}:${ss}`; + } else { + return `${m}:${ss}`; + } +}; + /** * Return the caption, aka "description", (if any) attached to the given * {@link EnteFile}. From f8e90e765ff7f2520fd52fda010c054c3cd3451e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 16:41:39 +0530 Subject: [PATCH 11/15] web side --- web/packages/base/types/ipc.ts | 43 +++++++++++++------ web/packages/gallery/services/ffmpeg/index.ts | 14 +++--- web/packages/gallery/services/upload/index.ts | 2 +- .../gallery/services/upload/thumbnail.ts | 4 +- .../gallery/services/upload/upload-service.ts | 3 +- 5 files changed, 41 insertions(+), 25 deletions(-) diff --git a/web/packages/base/types/ipc.ts b/web/packages/base/types/ipc.ts index 083cc81037..d44a92e980 100644 --- a/web/packages/base/types/ipc.ts +++ b/web/packages/base/types/ipc.ts @@ -316,10 +316,10 @@ export interface Electron { * The behaviour is OS dependent. On macOS we use the `sips` utility, while * on Linux and Windows we use a `vips` bundled with our desktop app. * - * @param dataOrPathOrZipItem The file whose thumbnail we want to generate. - * It can be provided as raw image data (the contents of the image file), or - * the path to the image file, or a tuple containing the path of the zip - * file along with the name of an entry in it. + * @param pathOrZipItem The file whose thumbnail we want to generate. It can + * be provided as raw image data (the contents of the image file), or the + * path to the image file, or a tuple containing the path of the zip file + * along with the name of an entry in it. * * @param maxDimension The maximum width or height of the generated * thumbnail. @@ -329,14 +329,13 @@ export interface Electron { * @returns JPEG data of the generated thumbnail. */ generateImageThumbnail: ( - dataOrPathOrZipItem: Uint8Array | string | ZipItem, + pathOrZipItem: string | ZipItem, maxDimension: number, maxSize: number, ) => Promise; /** - * Execute a FFmpeg {@link command} on the given - * {@link dataOrPathOrZipItem}. + * Execute a FFmpeg {@link command} on the given {@link pathOrZipItem}. * * This executes the command using a FFmpeg executable we bundle with our * desktop app. We also have a Wasm FFmpeg implementation that we use when @@ -349,11 +348,11 @@ export interface Electron { * (respectively {@link inputPathPlaceholder}, * {@link outputPathPlaceholder}, {@link ffmpegPathPlaceholder}). * - * @param dataOrPathOrZipItem The bytes of the input file, or the path to - * the input file on the user's local disk, or the path to a zip file on the - * user's disk and the name of an entry in it. In all three cases, the data - * gets serialized to a temporary file, and then that path gets substituted - * in the FFmpeg {@link command} in lieu of {@link inputPathPlaceholder}. + * @param pathOrZipItem The path to the input file on the user's local disk, + * or the path to a zip file on the user's disk and the name of an entry in + * it. In the second case, the data gets serialized to a temporary file, and + * then that path (or if it was already a path) gets substituted in the + * FFmpeg {@link command} in lieu of {@link inputPathPlaceholder}. * * @param outputFileExtension The extension (without the dot, e.g. "jpeg") * to use for the output file that we ask FFmpeg to create in @@ -366,10 +365,28 @@ export interface Electron { */ ffmpegExec: ( command: FFmpegCommand, - dataOrPathOrZipItem: Uint8Array | string | ZipItem, + pathOrZipItem: string | ZipItem, outputFileExtension: string, ) => Promise; + /** + * Determine the duration (in seconds) of the video present at + * {@link pathOrZipItem} using ffmpeg. + * + * This is a bespoke variant of {@link ffmpegExec} for the sole purpose of + * retrieving the video duration. + * + * @param pathOrZipItem The input file whose duration we want to determine. + * For more details, see the documentation of the {@link ffmpegExec} + * parameter with the same name. + * + * @returns The duration (in seconds) of the video referred to by + * {@link pathOrZipItem}. + */ + ffmpegDetermineVideoDuration: ( + pathOrZipItem: string | ZipItem, + ) => Promise; + // - Utility process /** diff --git a/web/packages/gallery/services/ffmpeg/index.ts b/web/packages/gallery/services/ffmpeg/index.ts index 2fc5b12a54..b31f92b80d 100644 --- a/web/packages/gallery/services/ffmpeg/index.ts +++ b/web/packages/gallery/services/ffmpeg/index.ts @@ -2,7 +2,7 @@ import { ensureElectron } from "ente-base/electron"; import log from "ente-base/log"; import type { Electron } from "ente-base/types/ipc"; import { - toDataOrPathOrZipEntry, + toPathOrZipEntry, type FileSystemUploadItem, type UploadItem, } from "ente-gallery/services/upload"; @@ -74,7 +74,7 @@ export const generateVideoThumbnailNative = async ( _generateVideoThumbnail((seekTime: number) => electron.ffmpegExec( makeGenThumbnailCommand(seekTime), - toDataOrPathOrZipEntry(fsUploadItem), + toPathOrZipEntry(fsUploadItem), "jpeg", ), ); @@ -137,7 +137,7 @@ export const extractVideoMetadata = async ( ? await ffmpegExecWeb(command, uploadItem, "txt") : await ensureElectron().ffmpegExec( command, - toDataOrPathOrZipEntry(uploadItem), + toPathOrZipEntry(uploadItem), "txt", ), ); @@ -274,10 +274,10 @@ export const determineVideoDuration = async ( uploadItem: UploadItem, ): Promise => uploadItem instanceof File - ? await determineVideoDurationWeb(uploadItem) - : 0; /*, await ensureElectron().ffmpegDetermineVideoDuration( - toDataOrPathOrZipEntry(uploadItem), - ));*/ + ? determineVideoDurationWeb(uploadItem) + : ensureElectron().ffmpegDetermineVideoDuration( + toPathOrZipEntry(uploadItem), + ); /** * Convert a video from a format that is not supported in the browser to MP4. diff --git a/web/packages/gallery/services/upload/index.ts b/web/packages/gallery/services/upload/index.ts index 57c50e5f8c..040fd5d082 100644 --- a/web/packages/gallery/services/upload/index.ts +++ b/web/packages/gallery/services/upload/index.ts @@ -283,7 +283,7 @@ export const fileSystemUploadItemIfUnchanged = async ( * context of our desktop app, return a value that can be passed to * {@link Electron} functions over IPC. */ -export const toDataOrPathOrZipEntry = (fsUploadItem: FileSystemUploadItem) => +export const toPathOrZipEntry = (fsUploadItem: FileSystemUploadItem) => typeof fsUploadItem == "string" || Array.isArray(fsUploadItem) ? fsUploadItem : fsUploadItem.path; diff --git a/web/packages/gallery/services/upload/thumbnail.ts b/web/packages/gallery/services/upload/thumbnail.ts index 49c8b78135..a393397921 100644 --- a/web/packages/gallery/services/upload/thumbnail.ts +++ b/web/packages/gallery/services/upload/thumbnail.ts @@ -2,7 +2,7 @@ import log from "ente-base/log"; import { type Electron } from "ente-base/types/ipc"; import * as ffmpeg from "ente-gallery/services/ffmpeg"; import { - toDataOrPathOrZipEntry, + toPathOrZipEntry, type FileSystemUploadItem, } from "ente-gallery/services/upload"; import { FileType, type FileTypeInfo } from "ente-media/file-type"; @@ -196,7 +196,7 @@ export const generateThumbnailNative = async ( ): Promise => fileTypeInfo.fileType === FileType.image ? await electron.generateImageThumbnail( - toDataOrPathOrZipEntry(fsUploadItem), + toPathOrZipEntry(fsUploadItem), maxThumbnailDimension, maxThumbnailSize, ) diff --git a/web/packages/gallery/services/upload/upload-service.ts b/web/packages/gallery/services/upload/upload-service.ts index 0a6e6f2e7f..0dee402d1a 100644 --- a/web/packages/gallery/services/upload/upload-service.ts +++ b/web/packages/gallery/services/upload/upload-service.ts @@ -1,7 +1,6 @@ // TODO: Audit this file /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { isDesktop } from "ente-base/app"; import { streamEncryptionChunkSize } from "ente-base/crypto/libsodium"; import type { BytesOrB64 } from "ente-base/crypto/types"; import { type CryptoWorker } from "ente-base/crypto/worker"; @@ -1053,10 +1052,10 @@ const extractImageOrVideoMetadata = async ( if ( fileType == FileType.video && // TODO(HLS): - !isDesktop && settingsSnapshot().isInternalUser ) { duration = await tryDetermineVideoDuration(uploadItem); + // TODO(HLS): log.debug(() => ["extracted duration", duration]); } From 2be8db783cbe72416f470c34e5f47f2f3cd7be0c Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 16:50:50 +0530 Subject: [PATCH 12/15] node 1 --- desktop/src/main/ipc.ts | 8 ++++---- desktop/src/main/services/ffmpeg.ts | 6 +++--- desktop/src/main/services/image.ts | 6 +++--- desktop/src/main/stream.ts | 6 +++--- desktop/src/main/utils/temp.ts | 20 +++++++++----------- desktop/src/preload.ts | 8 ++++---- 6 files changed, 26 insertions(+), 28 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index b74b19ec6c..3782033bb8 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -182,10 +182,10 @@ export const attachIPCHandlers = () => { "generateImageThumbnail", ( _, - dataOrPathOrZipItem: Uint8Array | string | ZipItem, + pathOrZipItem: string | ZipItem, maxDimension: number, maxSize: number, - ) => generateImageThumbnail(dataOrPathOrZipItem, maxDimension, maxSize), + ) => generateImageThumbnail(pathOrZipItem, maxDimension, maxSize), ); ipcMain.handle( @@ -193,9 +193,9 @@ export const attachIPCHandlers = () => { ( _, command: FFmpegCommand, - dataOrPathOrZipItem: Uint8Array | string | ZipItem, + pathOrZipItem: string | ZipItem, outputFileExtension: string, - ) => ffmpegExec(command, dataOrPathOrZipItem, outputFileExtension), + ) => ffmpegExec(command, pathOrZipItem, outputFileExtension), ); // - Upload diff --git a/desktop/src/main/services/ffmpeg.ts b/desktop/src/main/services/ffmpeg.ts index 1b0e623faa..69edefafe0 100644 --- a/desktop/src/main/services/ffmpeg.ts +++ b/desktop/src/main/services/ffmpeg.ts @@ -8,7 +8,7 @@ import fs from "node:fs/promises"; import type { FFmpegCommand, ZipItem } from "../../types/ipc"; import { deleteTempFileIgnoringErrors, - makeFileForDataOrStreamOrPathOrZipItem, + makeFileForStreamOrPathOrZipItem, makeTempFilePath, } from "../utils/temp"; import type { FFmpegUtilityProcess } from "./ffmpeg-worker"; @@ -29,7 +29,7 @@ export const ffmpegUtilityProcess = () => */ export const ffmpegExec = async ( command: FFmpegCommand, - dataOrPathOrZipItem: Uint8Array | string | ZipItem, + pathOrZipItem: string | ZipItem, outputFileExtension: string, ): Promise => { const worker = await ffmpegUtilityProcess(); @@ -38,7 +38,7 @@ export const ffmpegExec = async ( path: inputFilePath, isFileTemporary: isInputFileTemporary, writeToTemporaryFile: writeToTemporaryInputFile, - } = await makeFileForDataOrStreamOrPathOrZipItem(dataOrPathOrZipItem); + } = await makeFileForStreamOrPathOrZipItem(pathOrZipItem); const outputFilePath = await makeTempFilePath(outputFileExtension); try { diff --git a/desktop/src/main/services/image.ts b/desktop/src/main/services/image.ts index 7daa101d2a..0ac99299eb 100644 --- a/desktop/src/main/services/image.ts +++ b/desktop/src/main/services/image.ts @@ -6,7 +6,7 @@ import { type ZipItem } from "../../types/ipc"; import { execAsync, isDev } from "../utils/electron"; import { deleteTempFileIgnoringErrors, - makeFileForDataOrStreamOrPathOrZipItem, + makeFileForStreamOrPathOrZipItem, makeTempFilePath, } from "../utils/temp"; @@ -61,7 +61,7 @@ const vipsPath = () => ); export const generateImageThumbnail = async ( - dataOrPathOrZipItem: Uint8Array | string | ZipItem, + pathOrZipItem: string | ZipItem, maxDimension: number, maxSize: number, ): Promise => { @@ -69,7 +69,7 @@ export const generateImageThumbnail = async ( path: inputFilePath, isFileTemporary: isInputFileTemporary, writeToTemporaryFile: writeToTemporaryInputFile, - } = await makeFileForDataOrStreamOrPathOrZipItem(dataOrPathOrZipItem); + } = await makeFileForStreamOrPathOrZipItem(pathOrZipItem); const outputFilePath = await makeTempFilePath("jpeg"); diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 85d572870f..58767c5ae1 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -14,7 +14,7 @@ import { writeStream } from "./utils/stream"; import { deleteTempFile, deleteTempFileIgnoringErrors, - makeFileForDataOrStreamOrPathOrZipItem, + makeFileForStreamOrPathOrZipItem, makeTempFilePath, } from "./utils/temp"; @@ -292,7 +292,7 @@ const handleGenerateHLSWrite = async ( const objectUploadURL = params.get("objectUploadURL"); if (!objectUploadURL) throw new Error("Missing objectUploadURL"); - let inputItem: Parameters[0]; + let inputItem: Parameters[0]; const path = params.get("path"); if (path) { inputItem = path; @@ -314,7 +314,7 @@ const handleGenerateHLSWrite = async ( path: inputFilePath, isFileTemporary: isInputFileTemporary, writeToTemporaryFile: writeToTemporaryInputFile, - } = await makeFileForDataOrStreamOrPathOrZipItem(inputItem); + } = await makeFileForStreamOrPathOrZipItem(inputItem); const outputFilePathPrefix = await makeTempFilePath(); let result: FFmpegGenerateHLSPlaylistAndSegmentsResult | undefined; diff --git a/desktop/src/main/utils/temp.ts b/desktop/src/main/utils/temp.ts index 67fd9ae1b9..ad874dd216 100644 --- a/desktop/src/main/utils/temp.ts +++ b/desktop/src/main/utils/temp.ts @@ -80,8 +80,8 @@ export const deleteTempFileIgnoringErrors = async (tempFilePath: string) => { } }; -/** The result of {@link makeFileForDataOrStreamOrPathOrZipItem}. */ -interface FileForDataOrPathOrZipItem { +/** The result of {@link makeFileForStreamOrPathOrZipItem}. */ +interface FileForStreamOrPathOrZipItem { /** * The path to the file (possibly temporary). */ @@ -107,13 +107,13 @@ interface FileForDataOrPathOrZipItem { * that needs to be deleted after processing, and a function to write the given * {@link item} into that temporary file if needed. * - * @param item The contents of the file (bytes), or a {@link ReadableStream} - * with the contents of the file, or the path to an existing file, or a (path to - * a zip file, name of an entry within that zip file) tuple. + * @param item A {@link ReadableStream} with the contents of the file, or the + * path to an existing file, or a (path to a zip file, name of an entry within + * that zip file) tuple. */ -export const makeFileForDataOrStreamOrPathOrZipItem = async ( - item: Uint8Array | ReadableStream | string | ZipItem, -): Promise => { +export const makeFileForStreamOrPathOrZipItem = async ( + item: ReadableStream | string | ZipItem, +): Promise => { let path: string; let isFileTemporary: boolean; let writeToTemporaryFile = async () => { @@ -126,9 +126,7 @@ export const makeFileForDataOrStreamOrPathOrZipItem = async ( } else { path = await makeTempFilePath(); isFileTemporary = true; - if (item instanceof Uint8Array) { - writeToTemporaryFile = () => fs.writeFile(path, item); - } else if (item instanceof ReadableStream) { + if (item instanceof ReadableStream) { writeToTemporaryFile = () => writeStream(path, item); } else { writeToTemporaryFile = async () => { diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 20ace5ab8c..d923a72613 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -193,26 +193,26 @@ const convertToJPEG = (imageData: Uint8Array) => ipcRenderer.invoke("convertToJPEG", imageData); const generateImageThumbnail = ( - dataOrPathOrZipItem: Uint8Array | string | ZipItem, + pathOrZipItem: string | ZipItem, maxDimension: number, maxSize: number, ) => ipcRenderer.invoke( "generateImageThumbnail", - dataOrPathOrZipItem, + pathOrZipItem, maxDimension, maxSize, ); const ffmpegExec = ( command: FFmpegCommand, - dataOrPathOrZipItem: Uint8Array | string | ZipItem, + pathOrZipItem: string | ZipItem, outputFileExtension: string, ) => ipcRenderer.invoke( "ffmpegExec", command, - dataOrPathOrZipItem, + pathOrZipItem, outputFileExtension, ); From 72acefadd4b1525929aeb48ea2c95c5245a53850 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 16:59:40 +0530 Subject: [PATCH 13/15] node 2 --- desktop/src/main/ipc.ts | 8 ++++- desktop/src/main/services/ffmpeg.ts | 33 +++++++++++++++++---- desktop/src/preload.ts | 4 +++ web/packages/gallery/services/ffmpeg/web.ts | 2 +- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 3782033bb8..b4e7eff0ca 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -32,7 +32,7 @@ import { openLogDirectory, selectDirectory, } from "./services/dir"; -import { ffmpegExec } from "./services/ffmpeg"; +import { ffmpegDetermineVideoDuration, ffmpegExec } from "./services/ffmpeg"; import { fsExists, fsFindFiles, @@ -198,6 +198,12 @@ export const attachIPCHandlers = () => { ) => ffmpegExec(command, pathOrZipItem, outputFileExtension), ); + ipcMain.handle( + "ffmpegDetermineVideoDuration", + (_, pathOrZipItem: string | ZipItem) => + ffmpegDetermineVideoDuration(pathOrZipItem), + ); + // - Upload ipcMain.handle("listZipItems", (_, zipPath: string) => diff --git a/desktop/src/main/services/ffmpeg.ts b/desktop/src/main/services/ffmpeg.ts index 69edefafe0..ead6c4c63e 100644 --- a/desktop/src/main/services/ffmpeg.ts +++ b/desktop/src/main/services/ffmpeg.ts @@ -31,7 +31,21 @@ export const ffmpegExec = async ( command: FFmpegCommand, pathOrZipItem: string | ZipItem, outputFileExtension: string, -): Promise => { +): Promise => + withInputFile(pathOrZipItem, async (worker, inputFilePath) => { + const outputFilePath = await makeTempFilePath(outputFileExtension); + try { + await worker.ffmpegExec(command, inputFilePath, outputFilePath); + return await fs.readFile(outputFilePath); + } finally { + await deleteTempFileIgnoringErrors(outputFilePath); + } + }); + +export const withInputFile = async ( + pathOrZipItem: string | ZipItem, + f: (worker: FFmpegUtilityProcess, inputFilePath: string) => Promise, +): Promise => { const worker = await ffmpegUtilityProcess(); const { @@ -40,16 +54,23 @@ export const ffmpegExec = async ( writeToTemporaryFile: writeToTemporaryInputFile, } = await makeFileForStreamOrPathOrZipItem(pathOrZipItem); - const outputFilePath = await makeTempFilePath(outputFileExtension); try { await writeToTemporaryInputFile(); - await worker.ffmpegExec(command, inputFilePath, outputFilePath); - - return await fs.readFile(outputFilePath); + return await f(worker, inputFilePath); } finally { if (isInputFileTemporary) await deleteTempFileIgnoringErrors(inputFilePath); - await deleteTempFileIgnoringErrors(outputFilePath); } }; + +/** + * Implement the IPC "ffmpegDetermineVideoDuration" contract, writing the input + * to temporary files as needed, and then forward to the + * {@link ffmpegDetermineVideoDuration} running in the utility process. + */ +export const ffmpegDetermineVideoDuration = async ( + pathOrZipItem: string | ZipItem, +): Promise => { + throw new Error("todo"); +}; diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index d923a72613..e983855d5f 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -216,6 +216,9 @@ const ffmpegExec = ( outputFileExtension, ); +const ffmpegDetermineVideoDuration = (pathOrZipItem: string | ZipItem) => + ipcRenderer.invoke("ffmpegDetermineVideoDuration", pathOrZipItem); + // - Utility processes const triggerCreateUtilityProcess = (type: UtilityProcessType) => { @@ -392,6 +395,7 @@ contextBridge.exposeInMainWorld("electron", { convertToJPEG, generateImageThumbnail, ffmpegExec, + ffmpegDetermineVideoDuration, // - ML diff --git a/web/packages/gallery/services/ffmpeg/web.ts b/web/packages/gallery/services/ffmpeg/web.ts index d290010562..0074cc70ea 100644 --- a/web/packages/gallery/services/ffmpeg/web.ts +++ b/web/packages/gallery/services/ffmpeg/web.ts @@ -154,7 +154,7 @@ const withInputMount = async ( ffmpeg: FFmpeg, blob: Blob, f: (inputPath: string) => Promise, -) => { +): Promise => { const mountDir = "/mount"; const inputFileName = newID("in_"); const inputPath = joinPath(mountDir, inputFileName); From 4263906c611e22a7e366d540ffe755b23b50d83b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 18:38:00 +0530 Subject: [PATCH 14/15] node impl --- desktop/src/main/services/ffmpeg-worker.ts | 55 ++++++++++++++++++++++ desktop/src/main/services/ffmpeg.ts | 7 +-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/desktop/src/main/services/ffmpeg-worker.ts b/desktop/src/main/services/ffmpeg-worker.ts index 86604c1f63..7ea0d652bc 100644 --- a/desktop/src/main/services/ffmpeg-worker.ts +++ b/desktop/src/main/services/ffmpeg-worker.ts @@ -45,6 +45,8 @@ export interface FFmpegUtilityProcess { outputPathPrefix: string, outputUploadURL: string, ) => Promise; + + ffmpegDetermineVideoDuration: (inputFilePath: string) => Promise; } log.debugString("Started ffmpeg utility process"); @@ -57,6 +59,7 @@ process.parentPort.once("message", (e) => { ffmpegExec, ffmpegConvertToMP4, ffmpegGenerateHLSPlaylistAndSegments, + ffmpegDetermineVideoDuration, } satisfies FFmpegUtilityProcess, messagePortMainEndpoint(e.ports[0]!), ); @@ -548,6 +551,7 @@ interface VideoCharacteristics { isBT709: boolean; bitrate: number | undefined; } + /** * Heuristically determine information about the video at the given * {@link inputFilePath}: @@ -821,3 +825,54 @@ const uploadVideoSegments = async ( } } }; + +/** + * A regex that matches the first line of the form + * + * Duration: 00:00:03.13, start: 0.000000, bitrate: 16088 kb/s + * + * The part after Duration: and until the first non-digit or colon is the first + * capture group. + */ +const videoDurationLineRegex = /\s\sDuration: ([0-9:]+)/; + +/** + * Determine the duration of the video at the given {@link inputFilePath}. + * + * While the detection works for all known cases, it is still heuristic because + * it uses ffmpeg output instead of ffprobe (which we don't have access to). + * See: [Note: Parsing CLI output might break on ffmpeg updates]. + */ +export const ffmpegDetermineVideoDuration = async (inputFilePath: string) => { + const videoInfo = await pseudoFFProbeVideo(inputFilePath); + const videoDurationMatch = videoDurationLineRegex.exec(videoInfo)?.at(1); + + const fail = () => { + throw new Error(`Cannot parse video duration '${videoDurationMatch}'`); + }; + + const nums = (videoDurationMatch ?? "") + .split(":") + .map((s) => parseInt(s, 10) || 0); + let [h, m, s] = [0, 0, 0]; + switch (nums.length) { + case 1: + s = nums[0]!; + break; + case 2: + m = nums[0]!; + s = nums[1]!; + break; + case 3: + h = nums[0]!; + m = nums[1]!; + s = nums[2]!; + break; + default: + fail(); + } + + const duration = h * 3600 + m * 60 + s; + if (!duration) fail(); + return duration; +}; diff --git a/desktop/src/main/services/ffmpeg.ts b/desktop/src/main/services/ffmpeg.ts index ead6c4c63e..f64a721a05 100644 --- a/desktop/src/main/services/ffmpeg.ts +++ b/desktop/src/main/services/ffmpeg.ts @@ -71,6 +71,7 @@ export const withInputFile = async ( */ export const ffmpegDetermineVideoDuration = async ( pathOrZipItem: string | ZipItem, -): Promise => { - throw new Error("todo"); -}; +): Promise => + withInputFile(pathOrZipItem, async (worker, inputFilePath) => + worker.ffmpegDetermineVideoDuration(inputFilePath), + ); From 62bd2d13d6d6f70098d0ed27eb0dcdc03fc29b36 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 14 May 2025 18:55:30 +0530 Subject: [PATCH 15/15] Match the web behaviour for sub seconds ceil --- desktop/src/main/services/ffmpeg-worker.ts | 31 +++++++++++++--------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/desktop/src/main/services/ffmpeg-worker.ts b/desktop/src/main/services/ffmpeg-worker.ts index 7ea0d652bc..9c9afa2564 100644 --- a/desktop/src/main/services/ffmpeg-worker.ts +++ b/desktop/src/main/services/ffmpeg-worker.ts @@ -832,9 +832,9 @@ const uploadVideoSegments = async ( * Duration: 00:00:03.13, start: 0.000000, bitrate: 16088 kb/s * * The part after Duration: and until the first non-digit or colon is the first - * capture group. + * capture group, while after the dot is an optional second capture group. */ -const videoDurationLineRegex = /\s\sDuration: ([0-9:]+)/; +const videoDurationLineRegex = /\s\sDuration: ([0-9:]+)(.[0-9]+)?/; /** * Determine the duration of the video at the given {@link inputFilePath}. @@ -845,34 +845,39 @@ const videoDurationLineRegex = /\s\sDuration: ([0-9:]+)/; */ export const ffmpegDetermineVideoDuration = async (inputFilePath: string) => { const videoInfo = await pseudoFFProbeVideo(inputFilePath); - const videoDurationMatch = videoDurationLineRegex.exec(videoInfo)?.at(1); + const matches = videoDurationLineRegex.exec(videoInfo); const fail = () => { - throw new Error(`Cannot parse video duration '${videoDurationMatch}'`); + throw new Error(`Cannot parse video duration '${matches?.at(0)}'`); }; - const nums = (videoDurationMatch ?? "") + // The HH:mm:ss. + const ints = (matches?.at(1) ?? "") .split(":") .map((s) => parseInt(s, 10) || 0); let [h, m, s] = [0, 0, 0]; - switch (nums.length) { + switch (ints.length) { case 1: - s = nums[0]!; + s = ints[0]!; break; case 2: - m = nums[0]!; - s = nums[1]!; + m = ints[0]!; + s = ints[1]!; break; case 3: - h = nums[0]!; - m = nums[1]!; - s = nums[2]!; + h = ints[0]!; + m = ints[1]!; + s = ints[2]!; break; default: fail(); } - const duration = h * 3600 + m * 60 + s; + // Optional subseconds. + const ss = parseFloat(`0${matches?.at(2) ?? ""}`); + + // Follow the same round up behaviour that the web side uses. + const duration = Math.ceil(h * 3600 + m * 60 + s + ss); if (!duration) fail(); return duration; };