feat(ui): simplify and consolidate video capture logic

This commit is contained in:
psychedelicious
2025-08-26 16:02:19 +10:00
parent f8c940bc11
commit f5ac1df7d2
5 changed files with 103 additions and 165 deletions

View File

@@ -1,11 +1,8 @@
import { Button, Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { logger } from 'app/logging/logger';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import SingleSelectionVideoMenuItems from 'features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { toast } from 'features/toast/toast';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
@@ -15,13 +12,9 @@ import { memo, useCallback, useState } from 'react';
import { flushSync } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { PiCameraBold, PiCrosshairBold, PiDotsThreeOutlineFill, PiSpinnerBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
import { uploadImage } from 'services/api/endpoints/images';
import { useDeleteVideosMutation } from 'services/api/endpoints/videos';
import type { VideoDTO } from 'services/api/types';
const log = logger('video');
export const CurrentVideoButtons = memo(({ videoDTO }: { videoDTO: VideoDTO }) => {
const { t } = useTranslation();
const tab = useAppSelector(selectActiveTab);
@@ -30,10 +23,8 @@ export const CurrentVideoButtons = memo(({ videoDTO }: { videoDTO: VideoDTO }) =
const galleryPanel = useGalleryPanel(activeTab);
const [deleteVideos] = useDeleteVideosMutation();
// Video frame capture functionality
const { $videoRef } = useVideoViewerContext();
const videoRef = useStore($videoRef);
const { captureFrame } = useCaptureVideoFrame();
const captureVideoFrame = useCaptureVideoFrame();
const { videoRef } = useVideoViewerContext();
const [capturing, setCapturing] = useState(false);
const locateInGallery = useCallback(() => {
@@ -58,34 +49,9 @@ export const CurrentVideoButtons = memo(({ videoDTO }: { videoDTO: VideoDTO }) =
const onClickSaveFrame = useCallback(async () => {
setCapturing(true);
let file: File;
try {
if (!videoRef) {
toast({
status: 'error',
title: 'Video not ready',
description: 'Please wait for the video to load before capturing a frame.',
});
return;
}
file = captureFrame(videoRef);
await uploadImage({ file, image_category: 'user', is_intermediate: false, silent: true });
toast({
status: 'success',
title: 'Frame saved to assets tab',
});
} catch (error) {
log.error({ error: serializeError(error as Error) }, 'Failed to capture frame');
toast({
status: 'error',
title: 'Failed to capture frame',
description: 'There was an error capturing the current video frame.',
});
} finally {
setCapturing(false);
}
}, [captureFrame, videoRef]);
await captureVideoFrame(videoRef.current);
setCapturing(false);
}, [captureVideoFrame, videoRef]);
const doesTabHaveGallery = tab === 'canvas' || tab === 'generate' || tab === 'workflows' || tab === 'upscaling';

View File

@@ -1,10 +1,8 @@
import { Flex } from '@invoke-ai/ui-library';
import { $authToken } from 'app/store/nanostores/authToken';
import { useVideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu';
import { useVideoViewerContext } from 'features/video/context/VideoViewerContext';
import type { MediaStateOwner } from 'media-chrome/dist/media-store/state-mediator.js';
import { MediaController } from 'media-chrome/react';
import { memo, useCallback, useRef } from 'react';
import { memo, useRef } from 'react';
import ReactPlayer from 'react-player';
import type { VideoDTO } from 'services/api/types';
@@ -16,17 +14,8 @@ interface VideoPlayerProps {
export const VideoPlayer = memo(({ videoDTO }: VideoPlayerProps) => {
const ref = useRef<HTMLDivElement>(null);
const reactPlayerRef = useRef<MediaStateOwner | null>(null);
useVideoContextMenu(videoDTO, ref);
const { setVideoRef } = useVideoViewerContext();
const handleMediaRef = useCallback(
(mediaEl: MediaStateOwner | null | undefined) => {
reactPlayerRef.current = mediaEl || null;
setVideoRef(mediaEl || null);
},
[setVideoRef]
);
const { videoRef } = useVideoViewerContext();
return (
<Flex ref={ref} w="full" h="full" flexDirection="column" gap={4} alignItems="center" justifyContent="center">
@@ -38,13 +27,13 @@ export const VideoPlayer = memo(({ videoDTO }: VideoPlayerProps) => {
>
<ReactPlayer
slot="media"
ref={handleMediaRef}
ref={videoRef}
src={videoDTO.video_url}
controls={false}
width={videoDTO.width}
height={videoDTO.height}
pip={false}
crossOrigin={$authToken.get() ? 'use-credentials' : 'anonymous'}
// crossOrigin={$authToken.get() ? 'use-credentials' : 'anonymous'}
/>
<VideoPlayerControls />

View File

@@ -1,6 +1,4 @@
import { Icon, IconButton } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { toast } from 'features/toast/toast';
import { useVideoViewerContext } from 'features/video/context/VideoViewerContext';
import { useCaptureVideoFrame } from 'features/video/hooks/useCaptureVideoFrame';
import {
@@ -13,10 +11,6 @@ import {
import type { CSSProperties } from 'react';
import { useCallback, useState } from 'react';
import { PiArrowsOutBold, PiCameraBold, PiPauseFill, PiPlayFill, PiSpinnerBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
import { uploadImage } from 'services/api/endpoints/images';
const log = logger('video');
const NoHoverBackground = {
'--media-control-hover-background': 'transparent',
@@ -25,35 +19,15 @@ const NoHoverBackground = {
} as CSSProperties;
export const VideoPlayerControls = () => {
const { captureFrame } = useCaptureVideoFrame();
const captureVideoFrame = useCaptureVideoFrame();
const [capturing, setCapturing] = useState(false);
const { $videoRef } = useVideoViewerContext();
const { videoRef } = useVideoViewerContext();
const onClickSaveFrame = useCallback(async () => {
setCapturing(true);
let file: File;
try {
const videoRef = $videoRef.get();
if (!videoRef) {
return;
}
file = captureFrame(videoRef);
await uploadImage({ file, image_category: 'user', is_intermediate: false, silent: true });
toast({
status: 'success',
title: 'Frame saved to assets tab',
});
} catch (error) {
log.error({ error: serializeError(error as Error) }, 'Failed to capture frame');
toast({
status: 'error',
title: 'Failed to capture frame',
});
} finally {
setCapturing(false);
}
}, [captureFrame, $videoRef]);
await captureVideoFrame(videoRef.current);
setCapturing(false);
}, [captureVideoFrame, videoRef]);
return (
<MediaControlBar>

View File

@@ -1,33 +1,17 @@
import type { MediaStateOwner } from 'media-chrome/dist/media-store/state-mediator.js';
import { type Atom, atom } from 'nanostores';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useCallback, useContext, useMemo, useState } from 'react';
import type { PropsWithChildren, RefObject } from 'react';
import { createContext, memo, useContext, useMemo, useRef } from 'react';
import { assert } from 'tsafe';
type VideoViewerContextValue = {
$videoRef: Atom<MediaStateOwner | null>;
setVideoRef: (ref: MediaStateOwner | null) => void;
onLoadVideo: () => void;
videoRef: RefObject<HTMLVideoElement>;
};
const VideoViewerContext = createContext<VideoViewerContextValue | null>(null);
export const VideoViewerContextProvider = memo((props: PropsWithChildren) => {
const $videoRef = useState(() => atom<MediaStateOwner | null>(null))[0];
const videoRef = useRef<HTMLVideoElement>(null);
const setVideoRef = useCallback(
(ref: MediaStateOwner | null) => {
$videoRef.set(ref);
},
[$videoRef]
);
const onLoadVideo = useCallback(() => {
// Reset video ref when loading new video
$videoRef.set(null);
}, [$videoRef]);
const value = useMemo(() => ({ $videoRef, setVideoRef, onLoadVideo }), [$videoRef, setVideoRef, onLoadVideo]);
const value = useMemo(() => ({ videoRef }), [videoRef]);
return <VideoViewerContext.Provider value={value}>{props.children}</VideoViewerContext.Provider>;
});

View File

@@ -1,65 +1,90 @@
import { useStore } from '@nanostores/react';
import { useVideoViewerContext } from 'features/video/context/VideoViewerContext';
import type { MediaStateOwner } from 'media-chrome/dist/media-store/state-mediator.js';
import { logger } from 'app/logging/logger';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { serializeError } from 'serialize-error';
import { uploadImage } from 'services/api/endpoints/images';
const log = logger('video');
const captureFrame = (video: HTMLVideoElement): File => {
// Validate video element
if (video.videoWidth === 0 || video.videoHeight === 0) {
throw new Error('Invalid video element or video not loaded');
}
// Check if video is ready for capture
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
// 2 == HAVE_CURRENT_DATA
if (video.readyState < 2) {
throw new Error('Video is not ready for frame capture');
}
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 0;
canvas.height = video.videoHeight || 0;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to get canvas 2D context');
}
// Draw the current video frame to canvas
context.drawImage(video, 0, 0);
// Convert to data URL with proper format
const dataUri = canvas.toDataURL('image/png', 0.92);
const data = dataUri.split(',')[1];
const mimeType = dataUri.split(';')[0]?.slice(5);
if (!data || !mimeType) {
throw new Error('Failed to extract image data from canvas');
}
// Convert to blob
const bytes = window.atob(data);
const buf = new ArrayBuffer(bytes.length);
const arr = new Uint8Array(buf);
for (let i = 0; i < bytes.length; i++) {
arr[i] = bytes.charCodeAt(i);
}
const blob = new Blob([arr], { type: mimeType });
const file = new File([blob], 'frame.png', { type: mimeType });
return file;
};
export const useCaptureVideoFrame = () => {
const { $videoRef } = useVideoViewerContext();
const videoRef = useStore($videoRef);
const captureFrame = useCallback(
(video?: MediaStateOwner): File => {
// Use provided video or fall back to context video ref
const targetVideo = video || videoRef;
// Validate video element
if (!targetVideo || targetVideo.videoWidth === 0 || targetVideo.videoHeight === 0) {
throw new Error('Invalid video element or video not loaded');
/*
* Capture the current frame of the video uploading it as an asset.
*
* Toasts on success or failure. For convenience, accepts null but immediately creates a toast.
*/
const captureVideoFrame = useCallback(async (video: HTMLVideoElement | null) => {
try {
if (!video) {
toast({
status: 'error',
title: 'Video not ready',
description: 'Please wait for the video to load before capturing a frame.',
});
return;
}
const file = captureFrame(video);
await uploadImage({ file, image_category: 'user', is_intermediate: false, silent: true });
toast({
status: 'success',
title: 'Frame saved to assets tab',
});
} catch (error) {
log.error({ error: serializeError(error as Error) }, 'Failed to capture frame');
toast({
status: 'error',
title: 'Failed to capture frame',
description: 'There was an error capturing the current video frame.',
});
}
}, []);
// Check if video is ready for capture
if (targetVideo.readyState && targetVideo.readyState < 2) {
// HAVE_CURRENT_DATA = 2
throw new Error('Video is not ready for frame capture');
}
const canvas = document.createElement('canvas');
canvas.width = targetVideo.videoWidth || 0;
canvas.height = targetVideo.videoHeight || 0;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to get canvas 2D context');
}
// Draw the current video frame to canvas
context.drawImage(targetVideo as HTMLVideoElement, 0, 0);
// Convert to data URL with proper format
const dataUri = canvas.toDataURL('image/png', 0.92);
const data = dataUri.split(',')[1];
const mimeType = dataUri.split(';')[0]?.slice(5);
if (!data || !mimeType) {
throw new Error('Failed to extract image data from canvas');
}
// Convert to blob
const bytes = window.atob(data);
const buf = new ArrayBuffer(bytes.length);
const arr = new Uint8Array(buf);
for (let i = 0; i < bytes.length; i++) {
arr[i] = bytes.charCodeAt(i);
}
const blob = new Blob([arr], { type: mimeType });
const file = new File([blob], 'frame.png', { type: mimeType });
return file;
},
[videoRef]
);
return {
captureFrame,
};
return captureVideoFrame;
};