add save frame functionality

This commit is contained in:
Mary Hipp
2025-08-25 13:51:11 -04:00
committed by psychedelicious
parent 438658c89b
commit 01563eab3b
7 changed files with 203 additions and 15 deletions

View File

@@ -69,6 +69,7 @@
"linkify-react": "^4.3.1",
"linkifyjs": "^4.3.1",
"lru-cache": "^11.1.0",
"media-chrome": "^4.13.0",
"mtwist": "^1.0.2",
"nanoid": "^5.1.5",
"nanostores": "^1.0.1",

View File

@@ -98,6 +98,9 @@ importers:
lru-cache:
specifier: ^11.1.0
version: 11.1.0
media-chrome:
specifier: ^4.13.0
version: 4.13.0(react@18.3.1)
mtwist:
specifier: ^1.0.2
version: 1.0.2
@@ -3256,6 +3259,9 @@ packages:
media-chrome@4.11.1:
resolution: {integrity: sha512-+2niDc4qOwlpFAjwxg1OaizK/zKV6y7QqGm4nBFEVlSaG0ZBgOmfc4IXAPiirZqAlZGaFFUaMqCl1SpGU0/naA==}
media-chrome@4.13.0:
resolution: {integrity: sha512-DfX/Hwxjae/tEHjr1tVnV/6XDFHriMXI1ev8Ji4Z/YwXnqMhNfRtvNsMjefnQK5pkMS/9hC+jmdS+VDWZfsSIw==}
media-tracks@0.3.3:
resolution: {integrity: sha512-9P2FuUHnZZ3iji+2RQk7Zkh5AmZTnOG5fODACnjhCVveX1McY3jmCRHofIEI+yTBqplz7LXy48c7fQ3Uigp88w==}
@@ -7921,6 +7927,12 @@ snapshots:
transitivePeerDependencies:
- react
media-chrome@4.13.0(react@18.3.1):
dependencies:
ce-la-react: 0.3.1(react@18.3.1)
transitivePeerDependencies:
- react
media-tracks@0.3.3: {}
memoize-one@6.0.0: {}

View File

@@ -26,6 +26,7 @@ export const zLogNamespace = z.enum([
'system',
'queue',
'workflows',
'video',
]);
export type LogNamespace = z.infer<typeof zLogNamespace>;

View File

@@ -3,7 +3,7 @@ import { useAppSelector } from 'app/store/storeHooks';
import VideoMetadataViewer from 'features/gallery/components/ImageMetadataViewer/VideoMetadataViewer';
import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons';
import { selectShouldShowItemDetails } from 'features/ui/store/uiSelectors';
import { VideoPlayer } from 'features/video/components/VideoPlayer';
import { VideoViewer } from 'features/video/components/VideoViewer';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useCallback, useRef, useState } from 'react';
@@ -39,7 +39,7 @@ export const CurrentVideoPreview = memo(({ videoDTO }: { videoDTO: VideoDTO | nu
>
{videoDTO && videoDTO.video_url && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center">
<VideoPlayer />
<VideoViewer />
</Flex>
)}
{!videoDTO && <NoContentForViewer />}

View File

@@ -1,23 +1,114 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useFocusRegion } from 'common/hooks/focus';
import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
import { memo, useRef } from 'react';
import { Flex, Icon, IconButton } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { $authToken } from 'app/store/nanostores/authToken';
import { useVideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu';
import { toast } from 'features/toast/toast';
import { useCaptureVideoFrame } from 'features/video/hooks/useCaptureVideoFrame';
import {
MediaControlBar,
MediaController,
MediaFullscreenButton,
MediaPlayButton,
MediaTimeDisplay,
MediaTimeRange,
} from 'media-chrome/react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { PiArrowsOutBold, PiCameraBold, PiPauseFill, PiPlayFill, PiSpinnerBold } from 'react-icons/pi';
import ReactPlayer from 'react-player';
import { useVideoDTO } from 'services/api/endpoints/videos';
import { serializeError } from 'serialize-error';
import { uploadImage } from 'services/api/endpoints/images';
import type { VideoDTO } from 'services/api/types';
export const VideoPlayer = memo(() => {
const NoHoverBackground = {
'--media-control-hover-background': 'transparent',
'--media-text-color': 'base.200',
'--media-font-size': '12px',
} as CSSProperties;
const log = logger('video');
export const VideoPlayer = memo(({ videoDTO }: { videoDTO: VideoDTO }) => {
const ref = useRef<HTMLDivElement>(null);
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
const videoDTO = useVideoDTO(lastSelectedItem?.id);
const reactPlayerRef = useRef<HTMLVideoElement>(null);
useFocusRegion('video', ref);
const [capturing, setCapturing] = useState(false);
const { captureFrame } = useCaptureVideoFrame();
const onClick = useCallback(async () => {
setCapturing(true);
let file: File;
try {
const reactPlayer = reactPlayerRef.current;
if (!reactPlayer) {
return;
}
file = captureFrame(reactPlayer);
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]);
useVideoContextMenu(videoDTO, ref);
return (
<Flex ref={ref} w="full" h="full" flexDirection="column" gap={4} alignItems="center" justifyContent="center">
{videoDTO?.video_url && (
<ReactPlayer src={videoDTO.video_url} controls={true} width={videoDTO.width} height={videoDTO.height} />
)}
<MediaController
style={{
width: '100%',
aspectRatio: '16/9',
}}
>
<ReactPlayer
slot="media"
ref={reactPlayerRef}
src={videoDTO.video_url}
controls={false}
width={videoDTO.width}
height={videoDTO.height}
pip={false}
crossOrigin={$authToken.get() ? 'use-credentials' : 'anonymous'}
/>
<MediaControlBar>
<MediaPlayButton noTooltip={true} style={NoHoverBackground}>
<span slot="play">
<Icon as={PiPlayFill} boxSize={6} color="base.200" />
</span>
<span slot="pause">
<Icon as={PiPauseFill} boxSize={6} color="base.200" />
</span>
</MediaPlayButton>
<MediaTimeRange style={NoHoverBackground} />
<MediaTimeDisplay showDuration style={NoHoverBackground} noToggle={true} />
<MediaFullscreenButton noTooltip={true} style={NoHoverBackground}>
<span slot="icon">
<Icon as={PiArrowsOutBold} boxSize={6} color="base.200" />
</span>
</MediaFullscreenButton>
<IconButton
tooltip={capturing ? 'Capturing...' : 'Save Current Frame as Asset'}
icon={<Icon as={capturing ? PiSpinnerBold : PiCameraBold} boxSize={6} color="base.200" />}
size="lg"
variant="unstyled"
onClick={onClick}
aria-label="Save Current Frame"
/>
</MediaControlBar>
</MediaController>
</Flex>
);
});

View File

@@ -0,0 +1,26 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useFocusRegion } from 'common/hooks/focus';
import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
import { useRef } from 'react';
import { useVideoDTO } from 'services/api/endpoints/videos';
import { VideoPlayer } from './VideoPlayer';
export const VideoViewer = () => {
const ref = useRef<HTMLDivElement>(null);
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
const videoDTO = useVideoDTO(lastSelectedItem?.id);
useFocusRegion('video', ref);
if (!videoDTO) {
return null;
}
return (
<Flex ref={ref} w="full" h="full" flexDirection="column" gap={4} alignItems="center" justifyContent="center">
<VideoPlayer videoDTO={videoDTO} />
</Flex>
);
};

View File

@@ -0,0 +1,57 @@
import { useCallback, useRef } from 'react';
export const useCaptureVideoFrame = () => {
const videoRef = useRef<HTMLVideoElement>(null);
const captureFrame = useCallback((video: HTMLVideoElement): File => {
// Validate video element
if (!video || video.videoWidth === 0 || video.videoHeight === 0) {
throw new Error('Invalid video element or video not loaded');
}
// Check if video is ready for capture
if (video.readyState < 2) {
// HAVE_CURRENT_DATA = 2
throw new Error('Video is not ready for frame capture');
}
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
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;
}, []);
return {
captureFrame,
videoRef,
};
};