diff --git a/desktop/src/main/services/ffmpeg-worker.ts b/desktop/src/main/services/ffmpeg-worker.ts index c186f0a32b..86604c1f63 100644 --- a/desktop/src/main/services/ffmpeg-worker.ts +++ b/desktop/src/main/services/ffmpeg-worker.ts @@ -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] diff --git a/web/apps/photos/src/components/Sidebar.tsx b/web/apps/photos/src/components/Sidebar.tsx index d79afb1942..4690889d44 100644 --- a/web/apps/photos/src/components/Sidebar.tsx +++ b/web/apps/photos/src/components/Sidebar.tsx @@ -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 = ({ 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 = ({ label={t("advanced")} onClick={showAdvancedSettings} /> + {isHLSGenerationSupported() && ( + + }> + {t("labs")} + + + void toggleHLSGeneration()} + /> + + + )} = ({ Component, pageProps }) => { }; if (isMLSupported) initML(); + if (isHLSGenerationSupportedTemp()) void initVideoProcessing(); electron.onOpenEnteURL(handleOpenEnteURL); electron.onAppUpdateAvailable(showUpdateDialog); diff --git a/web/packages/base/utils/web.ts b/web/packages/base/utils/web.ts index f51583ae11..cb45280990 100644 --- a/web/packages/base/utils/web.ts +++ b/web/packages/base/utils/web.ts @@ -3,11 +3,12 @@ * folder by appending a temporary 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. * diff --git a/web/packages/gallery/components/viewer/data-source.ts b/web/packages/gallery/components/viewer/data-source.ts index 6c5d4a3727..1cf614d4c4 100644 --- a/web/packages/gallery/components/viewer/data-source.ts +++ b/web/packages/gallery/components/viewer/data-source.ts @@ -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; } diff --git a/web/packages/gallery/services/video.ts b/web/packages/gallery/services/video.ts index d038942598..75021e7248 100644 --- a/web/packages/gallery/services/video.ts +++ b/web/packages/gallery/services/video.ts @@ -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 => { +): Promise => { 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, ) => { - 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; } diff --git a/web/packages/media/file-metadata.ts b/web/packages/media/file-metadata.ts index 4fcda2e238..e59d8d88e5 100644 --- a/web/packages/media/file-metadata.ts +++ b/web/packages/media/file-metadata.ts @@ -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; } /** diff --git a/web/packages/new/photos/components/SearchBar.tsx b/web/packages/new/photos/components/SearchBar.tsx index 0240bbdf4c..0715727789 100644 --- a/web/packages/new/photos/components/SearchBar.tsx +++ b/web/packages/new/photos/components/SearchBar.tsx @@ -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 ( {people && people.length > 0 && ( @@ -473,7 +503,11 @@ const OptionContents = ({ data: option }: { data: SearchOption }) => ( > {option.suggestion.label} diff --git a/web/packages/new/photos/components/utils/use-snapshot.ts b/web/packages/new/photos/components/utils/use-snapshot.ts index 3cf14122aa..21073d6155 100644 --- a/web/packages/new/photos/components/utils/use-snapshot.ts +++ b/web/packages/new/photos/components/utils/use-snapshot.ts @@ -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, + ); diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index f97d9d91bd..f02078a99b 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -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();