mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): simplify and consolidate video capture logic
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user