Files
ente/web/packages/gallery/utils/native-stream.ts
Manav Rathi 2d3734bf14 Relay
2025-04-29 11:18:01 +05:30

296 lines
10 KiB
TypeScript

/**
* @file Streaming IPC communication with the Node.js layer of our desktop app.
*
* NOTE: These functions only work when we're running in our desktop app.
*
* See: [Note: IPC streams].
*/
import type { Electron, ElectronMLWorker, ZipItem } from "ente-base/types/ipc";
import { z } from "zod";
import type { UploadItem } from "../services/upload";
/**
* Stream the given file or zip entry from the user's local file system.
*
* This only works when we're running in our desktop app since it uses the
* "stream://" protocol handler exposed by our custom code in the Node.js layer.
* See: [Note: IPC streams].
*
* To avoid accidentally invoking it in a non-desktop app context, it requires
* the {@link Electron} (or a functionally similar) object as a parameter (even
* though it doesn't need or use it).
*
* @param pathOrZipItem Either the path on the file on the user's local file
* system whose contents we want to stream. Or a tuple containing the path to a
* zip file and the name of the entry within it.
*
* @return A ({@link Response}, size, lastModifiedMs) triple.
*
* * The response contains the contents of the file. In particular, the `body`
* {@link ReadableStream} property of this response can be used to read the
* files contents in a streaming manner.
*
* * The size is the size of the file that we'll be reading from disk.
*
* * The lastModifiedMs value is the last modified time of the file that we're
* reading, expressed as epoch milliseconds.
*/
export const readStream = async (
_: Electron | ElectronMLWorker,
pathOrZipItem: string | ZipItem,
): Promise<{ response: Response; size: number; lastModifiedMs: number }> => {
let url: URL;
if (typeof pathOrZipItem == "string") {
const params = new URLSearchParams({ path: pathOrZipItem });
url = new URL(`stream://read?${params.toString()}`);
} else {
const [zipPath, entryName] = pathOrZipItem;
const params = new URLSearchParams({ zipPath, entryName });
url = new URL(`stream://read-zip?${params.toString()}`);
}
const req = new Request(url, { method: "GET" });
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to read stream from ${url.href}: HTTP ${res.status}`,
);
const size = readNumericHeader(res, "Content-Length");
const lastModifiedMs = readNumericHeader(res, "X-Last-Modified-Ms");
return { response: res, size, lastModifiedMs };
};
const readNumericHeader = (res: Response, key: string) => {
const valueText = res.headers.get(key);
const value = valueText === null ? NaN : +valueText;
if (isNaN(value))
throw new Error(
`Expected a numeric ${key} when reading a stream response, instead got ${valueText}`,
);
return value;
};
/**
* Write the given stream to a file on the local machine.
*
* This only works when we're running in our desktop app since it uses the
* "stream://" protocol handler exposed by our custom code in the Node.js layer.
* See: [Note: IPC streams].
*
* To avoid accidentally invoking it in a non-desktop app context, it requires
* the {@link Electron} object as a parameter (even though it doesn't use it).
*
* @param path The path on the local machine where to write the file to.
*
* @param stream The stream which should be written into the file.
*/
export const writeStream = async (
_: Electron,
path: string,
stream: ReadableStream,
) => {
const params = new URLSearchParams({ path });
const url = new URL(`stream://write?${params.toString()}`);
// The duplex parameter needs to be set to 'half' when streaming requests.
//
// Currently browsers, and specifically in our case, since this code runs
// only within our desktop (Electron) app, Chromium, don't support 'full'
// duplex mode (i.e. streaming both the request and the response).
// https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
const req = new Request(url, {
// GET can't have a body
method: "POST",
body: stream,
// @ts-expect-error TypeScript's libdom.d.ts does not include the
// "duplex" parameter, e.g. see
// https://github.com/node-fetch/node-fetch/issues/1769.
duplex: "half",
});
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to write stream to ${url.href}: HTTP ${res.status}`,
);
};
/**
* Initate a conversion to MP4, streaming the video contents to the node side.
*
* This is a variant of {@link writeStream} tailored for the conversion to MP4.
*
* @param _ An {@link Electron} instance, witness to the fact that we're running
* in the context of the desktop app. It is otherwise not used.
*
* @param video A {@link Blob} containing the video to convert.
*
* @returns a token that can then be passed to {@link readVideoStream} to
* retrieve the converted MP4 file. This three step sequence (write/read/done)
* can then be ended by using {@link videoStreamDone}).
*
* See: [Note: Convert to MP4].
*/
export const initiateConvertToMP4 = async (
_: Electron,
video: Blob,
): Promise<string> => {
const url = "stream://video?op=convert-to-mp4";
const res = await fetch(url, { method: "POST", body: video });
if (!res.ok)
throw new Error(`Failed to write stream to ${url}: HTTP ${res.status}`);
return res.text();
};
const GenerateHLSResult = z.object({
/**
* A token that can be used to passed to {@link readVideoStream} to retrieve
* the generated HLS playlist.
*/
playlistToken: z.string(),
/**
* The dimensions (width and height in pixels) of the generated video stream.
*/
dimensions: z.object({ width: z.number(), height: z.number() }),
/**
* The size (in bytes) of the file containing the encrypted video segments.
*/
videoSize: z.number(),
});
export type GenerateHLSResult = z.infer<typeof GenerateHLSResult>;
/**
* Initate the generation of a HLS stream, streaming the source video contents
* to the node side.
*
* This is a variant of {@link writeStream} tailored for the HLS generation. It
* is similar to {@link initiateConvertToMP4}, but also supports streaming
* {@link UploadItem}s and {@link ReadableStream}s.
*
* @param _ An {@link Electron} instance, witness to the fact that we're running
* in the context of the desktop app. It is otherwise not used.
*
* @param video The video to convert.
*
* - If we're called during the upload process, then this will be set to the
* {@link UploadItem} that was uploaded. This way, we can directly use the
* on-disk file instead of needing to download the original from remote.
*
* - Otherwise it should be a {@link ReadableStream} of the video contents.
*
* @param objectUploadURL A presigned URL where the video segments should be
* uploaded to.
*
* @returns a token that can be used to retrieve the generated HLS playlist, and
* metadata about the generated video (its byte size and dimensions). See {@link
* GenerateHLSResult.
*
* In case the video is such that it doesn't require a separate stream to be
* generated (e.g. it is a small video using an already compatible codec), then
* this function will return `undefined`.
*
* See: [Note: Preview variant of videos].
*/
export const initiateGenerateHLS = async (
_: Electron,
video: UploadItem | ReadableStream,
objectUploadURL: string,
): Promise<GenerateHLSResult | undefined> => {
const params = new URLSearchParams({ op: "generate-hls", objectUploadURL });
let body: ReadableStream | null;
if (video instanceof ReadableStream) {
body = video;
} else {
// video is an UploadItem
body = null;
if (typeof video == "string") {
// Path to a regular file on the user's filesystem.
params.set("path", video);
} else if (Array.isArray(video)) {
// Path within a zip file on the user's filesystem.
const [zipPath, entryName] = video;
params.set("zipPath", zipPath);
params.set("entryName", entryName);
} else if (video instanceof File) {
// A drag and dropped file, but without a path. This is a browser
// specific case which shouldn't happen when we're running in the
// desktop app. Bail.
throw new Error("Unexpected file without path");
} else {
// A File with a path. Use the path.
params.set("path", video.path);
}
}
const url = `stream://video?${params.toString()}`;
const res = await fetch(url, {
method: "POST",
// The duplex option is required when body is a stream.
//
// @ts-expect-error TypeScript's libdom.d.ts does not include the
// "duplex" parameter, e.g. see
// https://github.com/node-fetch/node-fetch/issues/1769.
duplex: "half",
body,
});
if (!res.ok)
throw new Error(`Failed to write stream to ${url}: HTTP ${res.status}`);
if (res.status == 204) return undefined;
return GenerateHLSResult.parse(await res.json());
};
/**
* Variant of {@link readStream} tailored for video conversion.
*
* @param token A token obtained from {@link writeVideoStream}.
*
* @returns a Response that contains the contents of the processed video.
*/
export const readVideoStream = async (
_: Electron,
token: string,
): Promise<Response> => {
const params = new URLSearchParams({ token });
const url = new URL(`stream://video?${params.toString()}`);
const req = new Request(url, { method: "GET" });
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to read stream from ${url.href}: HTTP ${res.status}`,
);
return res;
};
/**
* Sibling of {@link readConvertToMP4Stream} to let the native side know when we
* are done reading the response, so it can dispose any temporary resources.
*
* @param token A token obtained from {@link writeVideoStream}.
*/
export const videoStreamDone = async (
_: Electron,
token: string,
): Promise<void> => {
// The value for `done` is arbitrary, only its presence matters.
const params = new URLSearchParams({ token, done: "1" });
const url = new URL(`stream://video?${params.toString()}`);
const req = new Request(url, { method: "GET" });
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to close stream at ${url.href}: HTTP ${res.status}`,
);
};