diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 168f0205a0..eee2f0dcf8 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -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", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 4dc389b815..e397e2d7d8 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -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: {} diff --git a/invokeai/frontend/web/src/app/logging/logger.ts b/invokeai/frontend/web/src/app/logging/logger.ts index 3b9fd153c3..1f753f97bb 100644 --- a/invokeai/frontend/web/src/app/logging/logger.ts +++ b/invokeai/frontend/web/src/app/logging/logger.ts @@ -26,6 +26,7 @@ export const zLogNamespace = z.enum([ 'system', 'queue', 'workflows', + 'video', ]); export type LogNamespace = z.infer; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx index 4b0d322c31..0188e7cc28 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx @@ -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 && ( - + )} {!videoDTO && } diff --git a/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx index a3eddefc3c..e67c734275 100644 --- a/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx +++ b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx @@ -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(null); - const lastSelectedItem = useAppSelector(selectLastSelectedItem); - const videoDTO = useVideoDTO(lastSelectedItem?.id); + const reactPlayerRef = useRef(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 ( - {videoDTO?.video_url && ( - - )} + + + + + + + + + + + + + + + + + + + + } + size="lg" + variant="unstyled" + onClick={onClick} + aria-label="Save Current Frame" + /> + + ); }); diff --git a/invokeai/frontend/web/src/features/video/components/VideoViewer.tsx b/invokeai/frontend/web/src/features/video/components/VideoViewer.tsx new file mode 100644 index 0000000000..966ba977b9 --- /dev/null +++ b/invokeai/frontend/web/src/features/video/components/VideoViewer.tsx @@ -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(null); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); + const videoDTO = useVideoDTO(lastSelectedItem?.id); + + useFocusRegion('video', ref); + + if (!videoDTO) { + return null; + } + + return ( + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/video/hooks/useCaptureVideoFrame.ts b/invokeai/frontend/web/src/features/video/hooks/useCaptureVideoFrame.ts new file mode 100644 index 0000000000..0e39e49229 --- /dev/null +++ b/invokeai/frontend/web/src/features/video/hooks/useCaptureVideoFrame.ts @@ -0,0 +1,57 @@ +import { useCallback, useRef } from 'react'; + +export const useCaptureVideoFrame = () => { + const videoRef = useRef(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, + }; +};