mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-21 05:38:05 -05:00
add save frame functionality
This commit is contained in:
committed by
psychedelicious
parent
438658c89b
commit
01563eab3b
@@ -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",
|
||||
|
||||
12
invokeai/frontend/web/pnpm-lock.yaml
generated
12
invokeai/frontend/web/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const zLogNamespace = z.enum([
|
||||
'system',
|
||||
'queue',
|
||||
'workflows',
|
||||
'video',
|
||||
]);
|
||||
export type LogNamespace = z.infer<typeof zLogNamespace>;
|
||||
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user