This commit is contained in:
Manav Rathi
2025-04-17 14:39:08 +05:30
parent 1efaefbf9c
commit f6db2daaee
4 changed files with 77 additions and 17 deletions

View File

@@ -129,6 +129,11 @@ export const ffmpegConvertToMP4 = async (
await execAsync(cmd);
};
export interface FFmpegGenerateHLSPlaylistAndSegmentsResult {
playlistPath: string;
videoPath: string;
}
/**
* A bespoke variant of {@link ffmpegExec} for generation of HLS playlists for
* videos.
@@ -138,17 +143,18 @@ export const ffmpegConvertToMP4 = async (
* @param inputFilePath The path to a file on the user's local file system. This
* is the video we want to convert.
*
* @param outputPlaylistPath The path to a file on the user's local file system
* where we should write the generated HLS playlist.
* @param outputPathPrefix The path to unique and temporary prefix on the user's
* local file system - we should write the generated HLS playlist and video
* segments using this prefix and adding the necessary suffixes.
*
* @param outputVideoPath The path to a file on the user's local file system
* where we should write the transcoded and encrypted video that the HLS
* playlist refers to.
* @returns The paths to two files on the user's local file system - one
* containing the generated HLS playlist, and the other containing the
* transcoded and encrypted video segments that the HLS playlist refers to.
*/
export const ffmpegGenerateHLSPlaylist = async (
export const ffmpegGenerateHLSPlaylistAndSegments = async (
inputFilePath: string,
outputFilePath: string,
): Promise<void> => {
): Promise<FFmpegGenerateHLSPlaylistAndSegmentsResult> => {
// Current parameters
//
// - H264
@@ -186,4 +192,6 @@ export const ffmpegGenerateHLSPlaylist = async (
const cmd = substitutePlaceholders(command, inputFilePath, outputFilePath);
await execAsync(cmd);
throw new Error("TODO(HLS)");
};

View File

@@ -7,7 +7,11 @@ import fs from "node:fs/promises";
import { Writable } from "node:stream";
import { pathToFileURL } from "node:url";
import log from "./log";
import { ffmpegConvertToMP4 } from "./services/ffmpeg";
import {
ffmpegConvertToMP4,
ffmpegGenerateHLSPlaylistAndSegments,
type FFmpegGenerateHLSPlaylistAndSegmentsResult,
} from "./services/ffmpeg";
import { markClosableZip, openZip } from "./services/zip";
import { writeStream } from "./utils/stream";
import {
@@ -74,7 +78,7 @@ const handleStreamRequest = async (request: Request): Promise<Response> => {
case "convert-to-mp4":
return handleConvertToMP4Write(request);
case "generate-hls":
return new Response("TODO", { status: 400 });
return handleGenerateHLSWrite(request);
default:
return new Response(`Unknown op ${op}`, {
status: 404,
@@ -210,7 +214,7 @@ export const clearPendingVideoResults = () => pendingVideoResults.clear();
*
* renderer → main stream://video?op=convert-to-mp4
* → request.body is the original video
* ← response is a token
* ← response is [token]
*
* renderer → main stream://video?token=<token>
* ← response.body is the converted video
@@ -242,7 +246,7 @@ const handleConvertToMP4Write = async (request: Request) => {
const token = randomUUID();
pendingVideoResults.set(token, outputTempFilePath);
return new Response(token, { status: 200 });
return new Response(JSON.stringify([token]), { status: 200 });
};
const handleVideoRead = async (token: string) => {
@@ -263,3 +267,42 @@ const handleVideoDone = async (token: string) => {
pendingVideoResults.delete(token);
return new Response("", { status: 200 });
};
/**
* Generate a HLS playlist for the given video.
*
* See: [Note: Convert to MP4] for the general architecture of commands that do
* renderer <-> main I/O using streams.
*
* The difference here is that we the conversion generates two streams - one for
* the HLS playlist itself, and one for the file containing the encrypted and
* transcoded video chunks. So instead of returning a single token, we return a
* JSON array containing two tokens so that the renderer can read them off
* separately.
*/
const handleGenerateHLSWrite = async (request: Request) => {
const inputTempFilePath = await makeTempFilePath();
await writeStream(inputTempFilePath, request.body!);
const outputFilePathPrefix = await makeTempFilePath();
let paths: FFmpegGenerateHLSPlaylistAndSegmentsResult;
try {
paths = await ffmpegGenerateHLSPlaylistAndSegments(
inputTempFilePath,
outputFilePathPrefix,
);
} catch (e) {
log.error("Generate HLS failed", e);
throw e;
} finally {
await deleteTempFileIgnoringErrors(inputTempFilePath);
}
const playlistToken = randomUUID();
const videoToken = randomUUID();
pendingVideoResults.set(playlistToken, paths.playlistPath);
pendingVideoResults.set(videoToken, paths.videoPath);
return new Response(JSON.stringify([playlistToken, videoToken]), {
status: 200,
});
};

View File

@@ -266,7 +266,8 @@ export const convertToMP4 = async (blob: Blob): Promise<Blob | Uint8Array> => {
};
const convertToMP4Native = async (electron: Electron, blob: Blob) => {
const token = await writeVideoStream(electron, "convert-to-mp4", blob);
const tokens = await writeVideoStream(electron, "convert-to-mp4", blob);
const token = tokens[0]!;
const mp4Blob = await readVideoStream(electron, token);
await videoStreamDone(electron, token);
return mp4Blob;

View File

@@ -7,6 +7,7 @@
*/
import type { Electron, ElectronMLWorker, ZipItem } from "ente-base/types/ipc";
import { z } from "zod";
/**
* Stream the given file or zip entry from the user's local file system.
@@ -137,14 +138,22 @@ type VideoStreamOp = "convert-to-mp4" | "generate-hls";
* @param video The video to convert, as a {@link Blob} or a
* {@link ReadableStream}.
*
* @returns a token that can then be passed to {@link readVideoStream} to read
* back the processed video.
* @returns an array of token that can then be passed to {@link readVideoStream}
* to read back the processed video. The count (and semantics) of the tokens are
* dependent on the operation:
*
* - "convert-to-mp4" returns a single token (which can be used to retrieve the
* converted MP4 file).
*
* - "generate-hls" returns two tokens, first one that can be used to retrieve
* the generated HLS playlist, and the second one that can be used to retrieve
* the video (segments).
*/
export const writeVideoStream = async (
_: Electron,
op: VideoStreamOp,
video: Blob | ReadableStream,
) => {
): Promise<string[]> => {
const url = `stream://video?op=${op}`;
const req = new Request(url, {
@@ -160,8 +169,7 @@ export const writeVideoStream = async (
if (!res.ok)
throw new Error(`Failed to write stream to ${url}: HTTP ${res.status}`);
const token = res.text();
return token;
return z.array(z.string()).parse(await res.json());
};
/**