[desktop] Video generation integration - WIP Part x/x (#5896)
This commit is contained in:
@@ -227,6 +227,8 @@ const ffmpegGenerateHLSPlaylistAndSegments = async (
|
||||
// going to use for the conversion), then a streaming variant is not much
|
||||
// use. Skip such cases.
|
||||
//
|
||||
// See also: [Note: Marking files which do not need video processing]
|
||||
//
|
||||
// ---
|
||||
//
|
||||
// [Note: HEVC/H.265 issues]
|
||||
|
||||
@@ -7,6 +7,7 @@ import HealthAndSafetyIcon from "@mui/icons-material/HealthAndSafety";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import NorthEastIcon from "@mui/icons-material/NorthEast";
|
||||
import ScienceIcon from "@mui/icons-material/Science";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import {
|
||||
Box,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
RowButtonDivider,
|
||||
RowButtonGroup,
|
||||
RowButtonGroupHint,
|
||||
RowButtonGroupTitle,
|
||||
RowSwitch,
|
||||
} from "ente-base/components/RowButton";
|
||||
import { SpacedRow } from "ente-base/components/containers";
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
import { useBaseContext } from "ente-base/context";
|
||||
import {
|
||||
getLocaleInUse,
|
||||
pt,
|
||||
setLocaleInUse,
|
||||
supportedLocales,
|
||||
ut,
|
||||
@@ -61,6 +64,10 @@ import log from "ente-base/log";
|
||||
import { savedLogs } from "ente-base/log-web";
|
||||
import { customAPIHost } from "ente-base/origins";
|
||||
import { downloadString } from "ente-base/utils/web";
|
||||
import {
|
||||
isHLSGenerationSupported,
|
||||
toggleHLSGeneration,
|
||||
} from "ente-gallery/services/video";
|
||||
import { DeleteAccount } from "ente-new/photos/components/DeleteAccount";
|
||||
import { DropdownInput } from "ente-new/photos/components/DropdownInput";
|
||||
import { MLSettings } from "ente-new/photos/components/sidebar/MLSettings";
|
||||
@@ -71,6 +78,7 @@ import {
|
||||
} from "ente-new/photos/components/utils/dialog";
|
||||
import { downloadAppDialogAttributes } from "ente-new/photos/components/utils/download";
|
||||
import {
|
||||
useHLSGenerationStatusSnapshot,
|
||||
useSettingsSnapshot,
|
||||
useUserDetailsSnapshot,
|
||||
} from "ente-new/photos/components/utils/use-snapshot";
|
||||
@@ -769,6 +777,9 @@ const Preferences: React.FC<NestedSidebarDrawerVisibilityProps> = ({
|
||||
const { show: showMLSettings, props: mlSettingsVisibilityProps } =
|
||||
useModalVisibility();
|
||||
|
||||
const hlsGenStatusSnapshot = useHLSGenerationStatusSnapshot();
|
||||
const isHLSGenerationEnabled = !!hlsGenStatusSnapshot?.enabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void syncSettings();
|
||||
}, [open]);
|
||||
@@ -809,6 +820,22 @@ const Preferences: React.FC<NestedSidebarDrawerVisibilityProps> = ({
|
||||
label={t("advanced")}
|
||||
onClick={showAdvancedSettings}
|
||||
/>
|
||||
{isHLSGenerationSupported() && (
|
||||
<Stack>
|
||||
<RowButtonGroupTitle icon={<ScienceIcon />}>
|
||||
{t("labs")}
|
||||
</RowButtonGroupTitle>
|
||||
<RowButtonGroup>
|
||||
<RowSwitch
|
||||
label={
|
||||
/* TODO(HLS): */ pt("Streamable videos")
|
||||
}
|
||||
checked={isHLSGenerationEnabled}
|
||||
onClick={() => void toggleHLSGeneration()}
|
||||
/>
|
||||
</RowButtonGroup>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<MapSettings
|
||||
|
||||
@@ -46,6 +46,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { resumeExportsIfNeeded } from "services/export";
|
||||
import { photosLogout } from "services/logout";
|
||||
|
||||
import {
|
||||
initVideoProcessing,
|
||||
isHLSGenerationSupportedTemp,
|
||||
} from "ente-gallery/services/video";
|
||||
import "photoswipe/dist/photoswipe.css";
|
||||
import "styles/global.css";
|
||||
import "styles/photoswipe.css";
|
||||
@@ -108,6 +112,7 @@ const App: React.FC<AppProps> = ({ Component, pageProps }) => {
|
||||
};
|
||||
|
||||
if (isMLSupported) initML();
|
||||
if (isHLSGenerationSupportedTemp()) void initVideoProcessing();
|
||||
|
||||
electron.onOpenEnteURL(handleOpenEnteURL);
|
||||
electron.onAppUpdateAvailable(showUpdateDialog);
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* folder by appending a temporary <a> element to the DOM.
|
||||
*
|
||||
* @param url The URL that we want to download. See also
|
||||
* {@link downloadAndRevokeObjectURL} and {@link downloadString}.
|
||||
* {@link downloadAndRevokeObjectURL} and {@link downloadString}. The URL is
|
||||
* revoked after initiating the download.
|
||||
*
|
||||
* @param fileName The name of downloaded file.
|
||||
*/
|
||||
export const downloadURL = (url: string, fileName: string) => {
|
||||
export const downloadAndRevokeObjectURL = (url: string, fileName: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
@@ -18,15 +19,6 @@ export const downloadURL = (url: string, fileName: string) => {
|
||||
a.remove();
|
||||
};
|
||||
|
||||
/**
|
||||
* A variant of {@link downloadURL} that also revokes the provided
|
||||
* {@link objectURL} after initiating the download.
|
||||
*/
|
||||
export const downloadAndRevokeObjectURL = (url: string, fileName: string) => {
|
||||
downloadURL(url, fileName);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Save the given string {@link s} as a file in the user's download folder.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,7 @@ import { downloadManager } from "ente-gallery/services/download";
|
||||
import { extractRawExif, parseExif } from "ente-gallery/services/exif";
|
||||
import {
|
||||
hlsPlaylistDataForFile,
|
||||
type HLSPlaylistData,
|
||||
type HLSPlaylistDataForFile,
|
||||
} from "ente-gallery/services/video";
|
||||
import type { EnteFile } from "ente-media/file";
|
||||
import { fileCaption, filePublicMagicMetadata } from "ente-media/file-metadata";
|
||||
@@ -452,10 +452,14 @@ const enqueueUpdates = async (
|
||||
|
||||
const updateVideo = (
|
||||
videoURL: string | undefined,
|
||||
hlsPlaylistData: HLSPlaylistData | undefined,
|
||||
hlsPlaylistData: HLSPlaylistDataForFile,
|
||||
) => {
|
||||
const videoURLD = videoURL ? { videoURL } : {};
|
||||
if (hlsPlaylistData) {
|
||||
// See: [Note: Caching HLS playlist data]
|
||||
//
|
||||
// In brief, there are three cases:
|
||||
if (typeof hlsPlaylistData == "object") {
|
||||
// 1. If we have a playlist, we can cache it
|
||||
const {
|
||||
playlistURL: videoPlaylistURL,
|
||||
width,
|
||||
@@ -466,18 +470,9 @@ const enqueueUpdates = async (
|
||||
createHLSPlaylistItemDataValidity(),
|
||||
);
|
||||
} else {
|
||||
// See: [Note: Caching HLS playlist data]
|
||||
//
|
||||
// TODO(HLS): As an optimization, we can handle the logged in vs
|
||||
// public albums case separately once we have the status-diff state,
|
||||
// we don't need to mark status-diff case as transient.
|
||||
//
|
||||
// Note that setting the transient flag is not too expensive, since
|
||||
// the underlying videoURL is still cached by the download manager.
|
||||
// So effectively, under normal circumstance, it just adds one API
|
||||
// call (to recheck if an HLS playlist now exists for the given
|
||||
// file).
|
||||
update({ ...videoURLD, isTransient: true });
|
||||
// 2. if the file is not eligible ("skip"), we can cache it.
|
||||
// 3. Otherwise it's transient and shouldn't be cached indefinitely.
|
||||
update({ ...videoURLD, isTransient: hlsPlaylistData != "skip" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -526,7 +521,7 @@ const enqueueUpdates = async (
|
||||
}
|
||||
|
||||
try {
|
||||
let hlsPlaylistData: HLSPlaylistData | undefined;
|
||||
let hlsPlaylistData: HLSPlaylistDataForFile;
|
||||
if (file.metadata.fileType == FileType.video) {
|
||||
hlsPlaylistData = await hlsPlaylistDataForFile(
|
||||
file,
|
||||
@@ -534,7 +529,10 @@ const enqueueUpdates = async (
|
||||
);
|
||||
// We have a HLS playlist, and the user didn't request the original.
|
||||
// Early return so that we don't initiate a fetch for the original.
|
||||
if (hlsPlaylistData && opts?.videoQuality != "original") {
|
||||
if (
|
||||
typeof hlsPlaylistData == "object" &&
|
||||
opts?.videoQuality != "original"
|
||||
) {
|
||||
updateVideo(undefined, hlsPlaylistData);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
retryAsyncOperation,
|
||||
type PublicAlbumsCredentials,
|
||||
} from "ente-base/http";
|
||||
import { getKV, getKVN, setKV } from "ente-base/kv";
|
||||
import { getKV, getKVB, getKVN, setKV } from "ente-base/kv";
|
||||
import { ensureLocalUser } from "ente-base/local-user";
|
||||
import log from "ente-base/log";
|
||||
import { fileLogID, type EnteFile } from "ente-media/file";
|
||||
import {
|
||||
filePublicMagicMetadata,
|
||||
updateRemotePublicMagicMetadata,
|
||||
} from "ente-media/file-metadata";
|
||||
import { FileType } from "ente-media/file-type";
|
||||
import {
|
||||
getAllLocalFiles,
|
||||
@@ -45,6 +49,12 @@ import {
|
||||
type TimestampedFileSystemUploadItem,
|
||||
} from "./upload";
|
||||
|
||||
export type HLSGenerationEnabledStatus = "processing" | "idle";
|
||||
|
||||
export type HLSGenerationStatus =
|
||||
| { enabled: false }
|
||||
| { enabled: true; status?: HLSGenerationEnabledStatus };
|
||||
|
||||
interface VideoProcessingQueueItem {
|
||||
/**
|
||||
* The {@link EnteFile} (guaranteed to be of {@link FileType.video}) whose
|
||||
@@ -71,6 +81,25 @@ const idleWaitMax = idleWaitInitial * 2 ** 6; /* 640 sec */
|
||||
* This entire object will be reset on logout.
|
||||
*/
|
||||
class VideoState {
|
||||
/**
|
||||
* `true` if the generation of HLS streams has been enabled on this client.
|
||||
*/
|
||||
isHLSGenerationEnabled = false;
|
||||
/**
|
||||
* Subscriptions to {@link HLSGenerationStatus} updates attached using
|
||||
* {@link hlsGenerationStatusSubscribe}.
|
||||
*/
|
||||
hlsGenerationStatusListeners: (() => void)[] = [];
|
||||
/**
|
||||
* Snapshot of the {@link HLSGenerationStatus} returned by the
|
||||
* {@link hlsGenerationStatusSnapshot} function.
|
||||
*/
|
||||
hlsGenerationStatusSnapshot: HLSGenerationStatus | undefined;
|
||||
/**
|
||||
* Value of the {@link status} field in the last
|
||||
* {@link hlsGenerationStatusSnapshot}.
|
||||
*/
|
||||
lastEnabledStatus: HLSGenerationEnabledStatus | undefined;
|
||||
/**
|
||||
* Queue of recently uploaded items waiting to be processed.
|
||||
*/
|
||||
@@ -125,6 +154,136 @@ export const resetVideoState = () => {
|
||||
_state = new VideoState();
|
||||
};
|
||||
|
||||
/**
|
||||
* A function that can be used to subscribe to updates in the HLS generation
|
||||
* settings and status.
|
||||
*
|
||||
* See: [Note: Snapshots and useSyncExternalStore].
|
||||
*/
|
||||
export const hlsGenerationStatusSubscribe = (
|
||||
onChange: () => void,
|
||||
): (() => void) => {
|
||||
_state.hlsGenerationStatusListeners.push(onChange);
|
||||
return () => {
|
||||
_state.hlsGenerationStatusListeners =
|
||||
_state.hlsGenerationStatusListeners.filter((l) => l != onChange);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the last know, cached {@link HLSGenerationStatus}.
|
||||
*
|
||||
* See also {@link hlsGenerationStatusSubscribe}.
|
||||
*
|
||||
* This function can be safely called even if {@link isHLSGenerationSupported}
|
||||
* is `false` (in such cases, it will always return `undefined`). This is so
|
||||
* that it can be unconditionally called as part of a React hook.
|
||||
*
|
||||
* A return value of `undefined` indicates that the HLS generation subsystem has
|
||||
* not been initialized yet.
|
||||
*/
|
||||
export const hlsGenerationStatusSnapshot = () =>
|
||||
_state.hlsGenerationStatusSnapshot;
|
||||
|
||||
const setHLSGenerationStatusSnapshot = (snapshot: HLSGenerationStatus) => {
|
||||
_state.hlsGenerationStatusSnapshot = snapshot;
|
||||
_state.hlsGenerationStatusListeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
/**
|
||||
* A variant of {@link setHLSGenerationStatusSnapshot} that only triggers an
|
||||
* update of the snapshot if the enabled state is different from the last known
|
||||
* enabled state.
|
||||
*/
|
||||
const updateSnapshotIfNeeded = (
|
||||
status: HLSGenerationEnabledStatus | undefined,
|
||||
) => {
|
||||
const enabled = _state.isHLSGenerationEnabled;
|
||||
if (enabled && status != _state.lastEnabledStatus) {
|
||||
_state.lastEnabledStatus = status;
|
||||
setHLSGenerationStatusSnapshot({ enabled, status });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return `true` if this client is capable of generating HLS streams for
|
||||
* uploaded videos.
|
||||
*
|
||||
* This function implementation is fast and can be called many times (e.g.
|
||||
* during UI rendering).
|
||||
*/
|
||||
export const isHLSGenerationSupported = () =>
|
||||
// Keep this check fast, we get called many times.
|
||||
isDesktop &&
|
||||
// TODO(HLS):
|
||||
process.env.NEXT_PUBLIC_ENTE_WIP_VIDEO_STREAMING &&
|
||||
settingsSnapshot().isInternalUser;
|
||||
|
||||
// TODO(HLS): Only the isDesktop flag is needed eventually.
|
||||
export const isHLSGenerationSupportedTemp = () =>
|
||||
isDesktop &&
|
||||
// TODO(HLS):
|
||||
process.env.NEXT_PUBLIC_ENTE_WIP_VIDEO_STREAMING;
|
||||
|
||||
/**
|
||||
* Initialize the video processing subsystem if the user has enabled HLS
|
||||
* generation in settings.
|
||||
*/
|
||||
export const initVideoProcessing = async () => {
|
||||
let enabled = false;
|
||||
if (await savedGenerateHLS()) enabled = true;
|
||||
|
||||
_state.isHLSGenerationEnabled = enabled;
|
||||
|
||||
// Update snapshot to reflect the enabled setting. The status will get
|
||||
// filled in when we tick.
|
||||
setHLSGenerationStatusSnapshot({ enabled });
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the persisted user preference for HLS generation.
|
||||
*/
|
||||
const savedGenerateHLS = () => getKVB("generateHLS");
|
||||
|
||||
/**
|
||||
* Update the persisted user preference for HLS generation.
|
||||
*
|
||||
* Use {@link savedGenerateHLS} to get the persisted value back.
|
||||
*/
|
||||
const saveGenerateHLS = (enabled: boolean) => setKV("generateHLS", enabled);
|
||||
|
||||
/**
|
||||
* Enable or disable (toggle) the HLS generation on this client.
|
||||
*
|
||||
* When HLS generation is enabled, this client will process videos to generate a
|
||||
* streamable variant of them.
|
||||
*
|
||||
* Precondition: {@link isHLSGenerationSupported} must be `true`.
|
||||
*/
|
||||
export const toggleHLSGeneration = async () => {
|
||||
if (!isHLSGenerationSupported()) {
|
||||
assertionFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = !_state.isHLSGenerationEnabled;
|
||||
|
||||
// Clear transient fields.
|
||||
_state.lastEnabledStatus = undefined;
|
||||
|
||||
// Update disk.
|
||||
await saveGenerateHLS(enabled);
|
||||
// Update in memory.
|
||||
_state.isHLSGenerationEnabled = enabled;
|
||||
|
||||
// Update snapshot. Right now we only set the enabled setting. The status
|
||||
// will get filled in when we tick.
|
||||
setHLSGenerationStatusSnapshot({ enabled });
|
||||
|
||||
// Wake up the processor if needed.
|
||||
if (enabled) tickNow();
|
||||
};
|
||||
|
||||
export interface HLSPlaylistData {
|
||||
/** A data URL to a HLS playlist that streams the video. */
|
||||
playlistURL: string;
|
||||
@@ -134,6 +293,11 @@ export interface HLSPlaylistData {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* See: [Note: Caching HLS playlist data] for the semantics of "skip".
|
||||
*/
|
||||
export type HLSPlaylistDataForFile = HLSPlaylistData | "skip" | undefined;
|
||||
|
||||
/**
|
||||
* Return a HLS playlist that can be used to stream playback of then given video
|
||||
* {@link file}.
|
||||
@@ -167,34 +331,35 @@ export interface HLSPlaylistData {
|
||||
* regenerated again). All in all, this means that a positive result ("this
|
||||
* file has a playlist") can be cached indefinitely.
|
||||
*
|
||||
* - If a file does not have a HLS playlist, and it is eligible for being
|
||||
* streamed (e.g. it is not too small where the streaming overhead is not
|
||||
* required), then a client (this one, or a different one) can process it at
|
||||
* any arbitrary time. So the negative result ("this file does not have a
|
||||
* - If a file does not have a HLS playlist, it might be because it is not
|
||||
* eligible for being streamed (e.g. it is already small and in a compatible
|
||||
* codec). See [Note: Marking files which do not need video processing] for
|
||||
* more details of this case. In particular, we can cache this state in memory
|
||||
* indefinitely too, since there isn't a current case where either this
|
||||
* eligibility can change, or the client gain the ability to handle them
|
||||
* without restarting.
|
||||
*
|
||||
* - Finally, if a file does not have an HLS playlist but and it is eligible for
|
||||
* being streamed, then a client (this one, or a different one) can process it
|
||||
* at any arbitrary time. So the negative result ("this file does not have a
|
||||
* playlist") cannot be cached.
|
||||
*
|
||||
* So while we can easily cache the first case ("this file has a playlist"), we
|
||||
* need to deal with the second case ("this file does not have a playlist") a
|
||||
* bit more intricately:
|
||||
*
|
||||
* - If running in the context of a logged in user (e.g. photos app), we can use
|
||||
* the "/files/data/status-diff" API to be notified of any modifications to
|
||||
* the second case for the user's own files. This status-diff happens during
|
||||
* the regular "sync", and we can use that as a cue to selectively prune cache
|
||||
* entries for the second case (but can otherwise indefinitely cache it).
|
||||
*
|
||||
* - If the file is a shared file, the status-diff will not return it. And if
|
||||
* we're not running in the context of a logged in user (e.g. the public
|
||||
* albums app), then there is no status-diff to do. For these two scenarios,
|
||||
* we thus mark the cached values as "transient" and always recheck for a
|
||||
* playlist when opening the slide.
|
||||
* So while we can easily cache the first case ("this file has a playlist") and
|
||||
* second case ("this file doesn't need a streaming variant"), we need to deal
|
||||
* with the third case ("this file does not have a playlist") by marking the
|
||||
* cached values as "transient" and always recheck for a playlist when opening
|
||||
* the slide.
|
||||
*/
|
||||
export const hlsPlaylistDataForFile = async (
|
||||
file: EnteFile,
|
||||
publicAlbumsCredentials?: PublicAlbumsCredentials,
|
||||
): Promise<HLSPlaylistData | undefined> => {
|
||||
): Promise<HLSPlaylistDataForFile> => {
|
||||
ensurePrecondition(file.metadata.fileType == FileType.video);
|
||||
|
||||
if (filePublicMagicMetadata(file)?.sv == 1) {
|
||||
return "skip";
|
||||
}
|
||||
|
||||
const playlistFileData = await fetchFileData(
|
||||
"vid_preview",
|
||||
file.id,
|
||||
@@ -511,7 +676,7 @@ const syncProcessedFileIDs = async () =>
|
||||
export const videoPrunePermanentlyDeletedFileIDsIfNeeded = async (
|
||||
deletedFileIDs: Set<number>,
|
||||
) => {
|
||||
if (!isDesktop) return;
|
||||
if (!isHLSGenerationSupported()) return;
|
||||
|
||||
const existing = await savedProcessedVideoFileIDs();
|
||||
if (existing.size > 0) {
|
||||
@@ -540,11 +705,18 @@ export const videoPrunePermanentlyDeletedFileIDsIfNeeded = async (
|
||||
* that have already been processed elsewhere.
|
||||
*/
|
||||
export const videoProcessingSyncIfNeeded = async () => {
|
||||
if (!isDesktop) return;
|
||||
if (!isVideoProcessingEnabled()) return;
|
||||
if (!isHLSGenerationSupported()) return;
|
||||
|
||||
// The `haveSyncedOnce` flag tracks whether or not a sync has happened for
|
||||
// the app, and is not specific to video processing. We always set it even
|
||||
// if HLS generation is currently disabled so that we can immediately start
|
||||
// processing the backfill if it gets video processing gets enabled during
|
||||
// the app's session, without waiting for the next sync to happen.
|
||||
_state.haveSyncedOnce = true;
|
||||
|
||||
if (!isHLSGenerationEnabled()) return;
|
||||
|
||||
await syncProcessedFileIDs();
|
||||
_state.haveSyncedOnce = true;
|
||||
|
||||
tickNow(); /* if not already ticking */
|
||||
};
|
||||
@@ -576,8 +748,8 @@ export const processVideoNewUpload = (
|
||||
file: EnteFile,
|
||||
processableUploadItem: ProcessableUploadItem,
|
||||
) => {
|
||||
if (!isDesktop) return;
|
||||
if (!isVideoProcessingEnabled()) return;
|
||||
if (!isHLSGenerationSupported()) return;
|
||||
if (!isHLSGenerationEnabled()) return;
|
||||
if (file.metadata.fileType !== FileType.video) return;
|
||||
if (processableUploadItem instanceof File) {
|
||||
// While the types don't guarantee it, we really shouldn't be getting
|
||||
@@ -622,10 +794,7 @@ const tickNow = () => {
|
||||
_state.queueProcessor ??= processQueue();
|
||||
};
|
||||
|
||||
export const isVideoProcessingEnabled = () =>
|
||||
// TODO(HLS):
|
||||
process.env.NEXT_PUBLIC_ENTE_WIP_VIDEO_STREAMING &&
|
||||
settingsSnapshot().isInternalUser;
|
||||
export const isHLSGenerationEnabled = () => _state.isHLSGenerationEnabled;
|
||||
|
||||
/**
|
||||
* The video processing loop goes through videos one by one, preferring items in
|
||||
@@ -664,7 +833,7 @@ export const isVideoProcessingEnabled = () =>
|
||||
* batches, and the externally triggered processing of live uploads.
|
||||
*/
|
||||
const processQueue = async () => {
|
||||
if (!(isDesktop && isVideoProcessingEnabled())) {
|
||||
if (!isHLSGenerationSupported() || !isHLSGenerationEnabled()) {
|
||||
assertionFailed(); /* we shouldn't have come here */
|
||||
return;
|
||||
}
|
||||
@@ -672,32 +841,23 @@ const processQueue = async () => {
|
||||
const userID = ensureLocalUser().id;
|
||||
|
||||
let bq: typeof _state.liveQueue | undefined;
|
||||
while (isVideoProcessingEnabled()) {
|
||||
while (isHLSGenerationEnabled()) {
|
||||
let item = _state.liveQueue.shift();
|
||||
if (!item) {
|
||||
if (!bq && _state.haveSyncedOnce) {
|
||||
/* initialize */
|
||||
bq = await backfillQueue(userID);
|
||||
}
|
||||
if (bq) {
|
||||
switch (bq.length) {
|
||||
case 0:
|
||||
/* no more items to backfill */
|
||||
break;
|
||||
case 1 /* last item. take it, and refill queue */:
|
||||
item = bq.pop();
|
||||
bq = await backfillQueue(userID);
|
||||
break;
|
||||
default:
|
||||
/* more than one item. take it */
|
||||
item = bq.pop();
|
||||
break;
|
||||
// Initialize or refill queue.
|
||||
if (!bq?.length) {
|
||||
if (_state.haveSyncedOnce) {
|
||||
bq = await backfillQueue(userID);
|
||||
} else {
|
||||
log.info("Not attempting backfill until first sync");
|
||||
}
|
||||
} else {
|
||||
log.info("Not backfilling since we haven't synced yet");
|
||||
}
|
||||
// Take item if queue is not empty.
|
||||
if (bq?.length) item = bq.pop();
|
||||
}
|
||||
if (item) {
|
||||
updateSnapshotIfNeeded("processing");
|
||||
|
||||
try {
|
||||
await processQueueItem(item);
|
||||
await markProcessedVideoFileID(item.file.id);
|
||||
@@ -711,6 +871,8 @@ const processQueue = async () => {
|
||||
// There are no more items in either the live queue or backlog.
|
||||
// Go to sleep (for increasingly longer durations, capped at a
|
||||
// maximum).
|
||||
updateSnapshotIfNeeded("idle");
|
||||
|
||||
const idleWait = _state.idleWait;
|
||||
_state.idleWait = Math.min(idleWait * 2, idleWaitMax);
|
||||
|
||||
@@ -724,6 +886,8 @@ const processQueue = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
updateSnapshotIfNeeded(undefined);
|
||||
|
||||
_state.queueProcessor = undefined;
|
||||
};
|
||||
|
||||
@@ -743,9 +907,14 @@ const backfillQueue = async (
|
||||
const videoFiles = uniqueFilesByID(
|
||||
allCollectionFiles.filter(
|
||||
(f) =>
|
||||
// Only files the user owns.
|
||||
f.ownerID == userID &&
|
||||
// Only videos.
|
||||
f.metadata.fileType == FileType.video &&
|
||||
!localTrashFileIDs.has(f.id),
|
||||
// Not in trash.
|
||||
!localTrashFileIDs.has(f.id) &&
|
||||
// See: [Note: Marking files which do not need video processing]
|
||||
filePublicMagicMetadata(f)?.sv != 1,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -856,6 +1025,8 @@ const processQueueItem = async ({
|
||||
|
||||
if (!res) {
|
||||
log.info(`Generate HLS for ${fileLogID(file)} | not-required`);
|
||||
// See: [Note: Marking files which do not need video processing]
|
||||
await updateRemotePublicMagicMetadata(file, { sv: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -274,6 +274,33 @@ export interface PublicMagicMetadata {
|
||||
*/
|
||||
caption?: string;
|
||||
uploaderName?: string;
|
||||
/**
|
||||
* An arbitrary integer set to indicate that this file should be skipped for
|
||||
* the purpose of HLS generation.
|
||||
*
|
||||
* Current semantics:
|
||||
*
|
||||
* - if 1, skip this file
|
||||
* - otherwise attempt processing
|
||||
*
|
||||
* [Note: Marking files which do not need video processing]
|
||||
*
|
||||
* Some video files do not require generation of a HLS stream. The current
|
||||
* logic is H.264 files less than 10 MB, but this might change in future
|
||||
* clients.
|
||||
*
|
||||
* For such skipped files, there thus won't be a HLS playlist generated.
|
||||
* However, we still need a way to indicate to other clients that this file
|
||||
* has already been looked at.
|
||||
*
|
||||
* To that end, we add a flag to the public magic metadata for the file. To
|
||||
* allow future flexibility, this flag is an integer "streaming version".
|
||||
* Currently it is set to 1 by a client who recognizes that this file does
|
||||
* not need processing, and other clients can ignore this file if they find
|
||||
* sv == 1. In the future, there might be other values for sv (e.g. if the
|
||||
* skip logic changes).
|
||||
*/
|
||||
sv?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,10 +13,14 @@ import {
|
||||
useTheme,
|
||||
type Theme,
|
||||
} from "@mui/material";
|
||||
import { assertionFailed } from "ente-base/assert";
|
||||
import { EnteLogo, EnteLogoBox } from "ente-base/components/EnteLogo";
|
||||
import type { ButtonishProps } from "ente-base/components/mui";
|
||||
import { useIsSmallWidth } from "ente-base/components/utils/hooks";
|
||||
import { pt } from "ente-base/i18n";
|
||||
import {
|
||||
hlsGenerationStatusSnapshot,
|
||||
isHLSGenerationSupported,
|
||||
} from "ente-gallery/services/video";
|
||||
import { ItemCard, PreviewItemTile } from "ente-new/photos/components/Tiles";
|
||||
import { isMLSupported, mlStatusSnapshot } from "ente-new/photos/services/ml";
|
||||
import { searchOptionsForString } from "ente-new/photos/services/search";
|
||||
@@ -38,6 +42,7 @@ import AsyncSelect from "react-select/async";
|
||||
import { SearchPeopleList } from "./PeopleList";
|
||||
import { UnstyledButton } from "./UnstyledButton";
|
||||
import {
|
||||
useHLSGenerationStatusSnapshot,
|
||||
useMLStatusSnapshot,
|
||||
usePeopleStateSnapshot,
|
||||
} from "./utils/use-snapshot";
|
||||
@@ -382,11 +387,26 @@ const shouldShowEmptyState = (inputValue: string) => {
|
||||
// Don't show empty state if the user has entered search input.
|
||||
if (inputValue) return false;
|
||||
|
||||
// Don't show empty state if there is no ML related information.
|
||||
if (!isMLSupported) return false;
|
||||
// Don't show empty state if there is no ML related information AND we're
|
||||
// not processing videos.
|
||||
|
||||
const status = mlStatusSnapshot();
|
||||
if (!status || status.phase == "disabled") return false;
|
||||
if (!isMLSupported && !isHLSGenerationSupported()) {
|
||||
// Neither of ML or HLS generation is supported on current client. This
|
||||
// is the code path for web.
|
||||
return false;
|
||||
}
|
||||
|
||||
const mlStatus = mlStatusSnapshot();
|
||||
const vpStatus = hlsGenerationStatusSnapshot();
|
||||
if (
|
||||
(!mlStatus || mlStatus.phase == "disabled") &&
|
||||
(!vpStatus?.enabled || vpStatus.status != "processing")
|
||||
) {
|
||||
// ML is either not supported or currently disabled AND video processing
|
||||
// is either not supported or currently not happening. Don't show the
|
||||
// empty state.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show it otherwise.
|
||||
return true;
|
||||
@@ -401,15 +421,19 @@ const EmptyState: React.FC<
|
||||
> = ({ onSelectPeople, onSelectPerson }) => {
|
||||
const mlStatus = useMLStatusSnapshot();
|
||||
const people = usePeopleStateSnapshot()?.visiblePeople;
|
||||
|
||||
if (!mlStatus || mlStatus.phase == "disabled") {
|
||||
// The preflight check should've prevented us from coming here.
|
||||
assertionFailed();
|
||||
return <></>;
|
||||
}
|
||||
const vpStatus = useHLSGenerationStatusSnapshot();
|
||||
|
||||
let label: string | undefined;
|
||||
switch (mlStatus.phase) {
|
||||
switch (mlStatus?.phase) {
|
||||
case undefined:
|
||||
case "disabled":
|
||||
case "done":
|
||||
// If ML is not running, see if video processing is.
|
||||
if (vpStatus?.enabled && vpStatus.status == "processing") {
|
||||
// TODO(HLS):
|
||||
label = pt("Processing videos...");
|
||||
}
|
||||
break;
|
||||
case "scheduled":
|
||||
label = t("indexing_scheduled");
|
||||
break;
|
||||
@@ -424,6 +448,12 @@ const EmptyState: React.FC<
|
||||
break;
|
||||
}
|
||||
|
||||
// If ML is disabled and we're not video processing, then don't show the
|
||||
// empty state content.
|
||||
if ((!mlStatus || mlStatus.phase == "disabled") && !label) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ textAlign: "left" }}>
|
||||
{people && people.length > 0 && (
|
||||
@@ -473,7 +503,11 @@ const OptionContents = ({ data: option }: { data: SearchOption }) => (
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{ fontWeight: "medium", wordBreak: "break-word" }}
|
||||
sx={{
|
||||
color: "text.base",
|
||||
fontWeight: "medium",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{option.suggestion.label}
|
||||
</Typography>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
hlsGenerationStatusSnapshot,
|
||||
hlsGenerationStatusSubscribe,
|
||||
} from "ente-gallery/services/video";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
mlStatusSnapshot,
|
||||
@@ -38,3 +42,13 @@ export const useMLStatusSnapshot = () =>
|
||||
*/
|
||||
export const usePeopleStateSnapshot = () =>
|
||||
useSyncExternalStore(peopleStateSubscribe, peopleStateSnapshot);
|
||||
|
||||
/**
|
||||
* A convenience hook that returns {@link hlsGenerationStatusSnapshot}, and also
|
||||
* subscribes to updates.
|
||||
*/
|
||||
export const useHLSGenerationStatusSnapshot = () =>
|
||||
useSyncExternalStore(
|
||||
hlsGenerationStatusSubscribe,
|
||||
hlsGenerationStatusSnapshot,
|
||||
);
|
||||
|
||||
@@ -471,10 +471,16 @@ export const mlStatusSubscribe = (onChange: () => void): (() => void) => {
|
||||
*
|
||||
* See also {@link mlStatusSubscribe}.
|
||||
*
|
||||
* This function can be safely called even if {@link isMLSupported} is `false`
|
||||
* (in such cases, it will always return `undefined`). This is so that it can be
|
||||
* unconditionally called as part of a React hook.
|
||||
*
|
||||
* A return value of `undefined` indicates that we're still performing the
|
||||
* asynchronous tasks that are needed to get the status.
|
||||
*/
|
||||
export const mlStatusSnapshot = (): MLStatus | undefined => {
|
||||
if (!isMLSupported) return undefined;
|
||||
|
||||
const result = _state.mlStatusSnapshot;
|
||||
// We don't have it yet, trigger an update.
|
||||
if (!result) triggerStatusUpdate();
|
||||
|
||||
Reference in New Issue
Block a user