feat: add compare output slider for image and videos

This commit is contained in:
niraj-khatiwada 2026-03-19 23:02:53 +05:45
parent 3b03f2c136
commit f68a1834c7
16 changed files with 543 additions and 64 deletions

View file

@ -47,6 +47,7 @@
"react": "^18",
"react-advanced-cropper": "^0.20.1",
"react-colorful": "^5.6.1",
"react-compare-slider": "^3.1.0",
"react-dom": "^18",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
@ -82,8 +83,6 @@
}
},
"lint-staged": {
"src/**/*.{js,ts,jsx,tsx}": [
"biome check --write"
]
"src/**/*.{js,ts,jsx,tsx}": ["biome check --write"]
}
}

View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@cut.media/react-gif-player':
specifier: ^1.0.2
version: 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@heroui/react':
specifier: 2.7.11
version: 2.7.11(@types/react@18.2.37)(framer-motion@11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.3)
@ -17,6 +20,9 @@ importers:
'@internationalized/date':
specifier: ^3.10.1
version: 3.10.1
'@remotion/gif':
specifier: ^4.0.437
version: 4.0.437(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-router':
specifier: ^1.97.21
version: 1.97.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -68,6 +74,9 @@ importers:
react-colorful:
specifier: ^5.6.1
version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-compare-slider:
specifier: ^3.1.0
version: 3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-dom:
specifier: ^18
version: 18.3.1(react@18.3.1)
@ -393,6 +402,15 @@ packages:
cpu: [x64]
os: [win32]
'@cut.media/gif-player@1.1.3':
resolution: {integrity: sha512-3vje409ZVspBA/d4Wt+V+qIiKCzrFL5t7wcNXdgtFk1h7tG3hoFdVkx3dsQf1Jsu+BMY6yAE3SWZHN3T+tBu5g==}
'@cut.media/react-gif-player@1.0.2':
resolution: {integrity: sha512-VFb7H+/GUo6cL5Nmu8pYQUP8cYThAa49Z85MCLlIB5psV0GLArnlGX7zV7TqQdgXMdEszRznkj1wvMzDwWqQBA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
'@emotion/is-prop-valid@0.8.8':
resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
@ -1998,6 +2016,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@remotion/gif@4.0.437':
resolution: {integrity: sha512-t1k8ww1gboLWDRbtWdwWe9n+LsCqsb0W+B1/6OAJt3mjoAhUDx5ociZLYynaclVDbbDPSGKrBAmsULflt9QeZg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@rollup/pluginutils@5.1.4':
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
engines: {node: '>=14.0.0'}
@ -3442,6 +3466,13 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-compare-slider@3.1.0:
resolution: {integrity: sha512-TQVbZYmYyTIeKRmQciVXCmUwHjTThQTON7GfWfzMAOInRRG9tCiQnVXnCUd5DJ5l3Hngh4IEzOb9TG82gjoEhQ==}
engines: {node: '>=16.9.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@ -3545,6 +3576,12 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
remotion@4.0.437:
resolution: {integrity: sha512-mQHiYZwt3HoMngJeTGyytVFdobf/mgsPTiQSUfPP43kA7bEpn4OdaF4hWoMfJhjfTC1ZdkTIRX/s3OKto0aWzg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -4218,6 +4255,15 @@ snapshots:
'@biomejs/cli-win32-x64@2.3.2':
optional: true
'@cut.media/gif-player@1.1.3': {}
'@cut.media/react-gif-player@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@cut.media/gif-player': 1.1.3
clsx: 2.1.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@emotion/is-prop-valid@0.8.8':
dependencies:
'@emotion/memoize': 0.7.4
@ -6539,6 +6585,12 @@ snapshots:
'@react-types/shared': 3.32.1(react@18.3.1)
react: 18.3.1
'@remotion/gif@4.0.437(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
remotion: 4.0.437(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@rollup/pluginutils@5.1.4(rollup@4.32.0)':
dependencies:
'@types/estree': 1.0.6
@ -8123,6 +8175,11 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-compare-slider@3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@ -8265,6 +8322,11 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
remotion@4.0.437(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}

View file

@ -0,0 +1,16 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<g clip-path="url(#clip0_105_1836)">
<path
d="M13 3.99976H6C4.89543 3.99976 4 4.89519 4 5.99976V17.9998C4 19.1043 4.89543 19.9998 6 19.9998H13M17 3.99976H18C19.1046 3.99976 20 4.89519 20 5.99976V6.99976M20 16.9998V17.9998C20 19.1043 19.1046 19.9998 18 19.9998H17M20 10.9998V12.9998M12 1.99976V21.9998"
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
</g>
<defs>
<clipPath id="clip0_105_1836">
<rect fill="currentColor" height="24" transform="translate(0 -0.000244141)" width="24"></rect>
</clipPath>
</defs>
</g>
</svg>

After

Width:  |  Height:  |  Size: 929 B

View file

@ -0,0 +1,14 @@
import {
ReactCompareSlider,
type ReactCompareSliderProps,
} from 'react-compare-slider'
type CompareSliderProps = {
style?: React.CSSProperties
} & ReactCompareSliderProps
function CompareSlider({ ...props }: CompareSliderProps) {
return <ReactCompareSlider {...props} />
}
export default CompareSlider

View file

@ -6,6 +6,7 @@ import Audio from '@/assets/icons/audio.svg?react'
import Back from '@/assets/icons/back.svg?react'
import Caret from '@/assets/icons/caret.svg?react'
import Chevron from '@/assets/icons/chevron.svg?react'
import Compare from '@/assets/icons/compare.svg?react'
import Copy from '@/assets/icons/copy.svg?react'
import Cross from '@/assets/icons/cross.svg?react'
import CurvedArrow from '@/assets/icons/curved-arrow.svg?react'
@ -84,6 +85,7 @@ const registry = asRegistry({
chevron: Chevron,
addMedia: AddMedia,
caret: Caret,
compare: Compare,
})
export default registry

View file

@ -1,28 +1,99 @@
import React from 'react'
import { Button } from '@heroui/react'
import { useCallback, useEffect, useRef } from 'react'
import { flushSync } from 'react-dom'
import { ThemeProxy, useTheme } from '@/hooks/useTheme'
import Button from '../Button'
import { useTheme } from '@/hooks/useTheme'
import { cn } from '@/utils/tailwind'
import Icon from '../Icon'
import Tooltip from '../Tooltip'
type ThemeSwitcherChildrenProps = ThemeProxy
interface ThemeSwitcherProps {
children?(props: ThemeSwitcherChildrenProps): React.ReactNode
interface ThemeSwitcherProps extends React.ComponentPropsWithoutRef<'button'> {
duration?: number
}
function ThemeSwitcher(props: ThemeSwitcherProps) {
const { children } = props
export const ThemeSwitcher = ({
className,
duration = 400,
}: ThemeSwitcherProps) => {
const { theme, setTheme, toggleTheme } = useTheme()
return children == null ? (
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const updateTheme = () => {
setTheme(
document.documentElement.classList.contains('dark') ? 'dark' : 'light',
)
}
updateTheme()
const observer = new MutationObserver(updateTheme)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
return () => observer.disconnect()
}, [setTheme])
const handleThemeToggle = useCallback(() => {
const button = buttonRef.current
if (!button) return
const { top, left, width, height } = button.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
const maxRadius = Math.hypot(
Math.max(x, viewportWidth - x),
Math.max(y, viewportHeight - y),
)
const applyTheme = () => {
toggleTheme()
document.documentElement.classList.toggle('dark')
}
if (typeof document.startViewTransition !== 'function') {
applyTheme()
return
}
const transition = document.startViewTransition(() => {
flushSync(applyTheme)
})
const ready = transition?.ready
if (ready && typeof ready.then === 'function') {
ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
},
)
})
}
}, [toggleTheme, duration])
return (
<Button
isIconOnly
size="sm"
onPress={() => {
toggleTheme()
}}
type="button"
ref={buttonRef}
radius="md"
className={cn(className)}
onPress={handleThemeToggle}
>
<Tooltip
content="Toggle theme"
@ -31,10 +102,7 @@ function ThemeSwitcher(props: ThemeSwitcherProps) {
>
<Icon name={theme === 'light' ? 'moon' : 'sun'} />
</Tooltip>
<span className="sr-only">Toggle theme</span>
</Button>
) : (
children({ theme, setTheme, toggleTheme })
)
}
export default ThemeSwitcher

View file

@ -44,6 +44,9 @@ export interface VideoPlayerProps extends BaseReactPlayerProps {
contextMenu?: React.ReactNode
onSpaceKeydownForPlayPause?: () => void
onArrowKeySeek?: (arrowKey: 'left' | 'right') => void
disablePlayPauseControlAtCenter?: boolean
autoPlay?: boolean
disablePlayPauseViaContainerClick?: boolean
}
const scales: TimelineScales = {
@ -63,6 +66,9 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
disableClosedCaptions,
contextMenu,
onSpaceKeydownForPlayPause,
disablePlayPauseControlAtCenter,
autoPlay,
disablePlayPauseViaContainerClick,
onArrowKeySeek,
onProgress,
onDuration,
@ -70,6 +76,7 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
onPause,
onEnded,
onReady,
...props
},
forwardedRef,
@ -319,7 +326,7 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
if (playPauseButtonRef.current) {
playPauseButtonRef.current.style.visibility = 'hidden'
}
}, 3000)
}, 1000)
} else {
clearInterval(playPauseButtonRefIntervalId.current!)
if (playPauseButtonRef.current) {
@ -357,8 +364,12 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
<div className={cn('w-full h-full', containerClassName)}>
<div
className="relative w-full h-full"
role="button"
onClick={togglePlayPause}
{...(!disablePlayPauseViaContainerClick
? {
role: 'button',
onClick: togglePlayPause,
}
: {})}
onContextMenu={(e) => {
if (contextMenu) {
handleContextMenu(e)
@ -403,22 +414,27 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
onReady={(player) => {
onReady?.(player)
toggleClosedCaptions(disableClosedCaptions)
if (autoPlay) {
setIsPlaying(true)
}
}}
{...props}
/>
<Button
ref={playPauseButtonRef}
onPress={togglePlayPause}
isIconOnly
radius="full"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-black/30 cursor-pointer"
>
<Icon
name={isPlaying ? 'pause' : 'play'}
size={28}
className="text-white drop-shadow-lg"
/>
</Button>
{!disablePlayPauseControlAtCenter ? (
<Button
ref={playPauseButtonRef}
onPress={togglePlayPause}
isIconOnly
radius="full"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-black/30 cursor-pointer"
>
<Icon
name={isPlaying ? 'pause' : 'play'}
size={28}
className="text-white drop-shadow-lg"
/>
</Button>
) : null}
</div>
{contextMenuOpen && contextMenuPosition && contextMenu ? (
<div

View file

@ -155,6 +155,29 @@ body > * {
cursor: text !important;
}
/* Tailwind CSS Animations */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
@layer base {
:root {
--animate-ripple: ripple var(--duration, 2s) ease calc(var(--i, 0) * 0.2s)
infinite;
}
@keyframes ripple {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(0.9);
}
}
}
.timeline-editor .ReactVirtualized__Grid,
.timeline-editor .ReactVirtualized__Grid::before,
.timeline-editor .ReactVirtualized__Grid::after {
@ -216,3 +239,8 @@ body > * {
.text-shadow {
text-shadow: 4px 4px 12px rgba(0, 0, 0, 0.35);
}
.__rcs-handle-button {
width: 45px !important;
height: 45px !important;
}

View file

@ -42,7 +42,7 @@ function MediaConfig() {
'relative w-full h-full px-4 py-6 rounded-xl border-2 border-zinc-200 dark:border-zinc-800',
)}
>
{selectedMediaIndexForCustomization === -1 ? (
{media.length > 1 && selectedMediaIndexForCustomization === -1 ? (
<motion.div
className="flex justify-center absolute left-1/2 top-[-16px]"
{...zoomInTransition}

View file

@ -0,0 +1,164 @@
import merge from 'lodash/merge'
import { memo, useCallback, useId } from 'react'
import { ReactCompareSliderHandle } from 'react-compare-slider'
import { PhotoView } from 'react-photo-view'
import { useSnapshot } from 'valtio'
import CompareSlider from '@/components/CompareSlider'
import Icon from '@/components/Icon'
import ImageViewer from '@/components/ImageViewer'
import Tooltip from '@/components/Tooltip'
import VideoPlayer from '@/components/VideoPlayer'
import { cn } from '@/utils/tailwind'
import { appProxy } from '../-state'
type MediaOutputCompareSliderProps = {
mediaIndex: number
}
type renderImageDefaultType = {
isOriginal?: boolean
enableZoomViewer?: boolean
}
const renderImageDefaultOptions: renderImageDefaultType = {
isOriginal: true,
enableZoomViewer: true,
}
type renderVideoDefaultType = {
isOriginal?: boolean
}
const renderVideoDefaultOptions: renderImageDefaultType = {
isOriginal: true,
}
function MediaOutputCompareSlider({
mediaIndex,
}: MediaOutputCompareSliderProps) {
if (mediaIndex < 0) return
const {
state: { media },
} = useSnapshot(appProxy)
const mediaFile = media.length === 1 ? media[0] : null
const id = useId()
const renderImage = useCallback(
(src: string, options?: renderImageDefaultType) => {
options = merge({}, renderImageDefaultOptions, options ?? {})
return (
<div
className="relative"
id={`image-comparison-${options?.isOriginal ? 0 : 1}`}
key={`image-comparison-${options?.isOriginal ? 0 : 1}`}
>
<img
src={src}
alt={`compare image ${options?.isOriginal ? 0 : 1}`}
className="max-h-[65vh] object-contain"
{...(mediaFile?.dimensions?.width
? { width: mediaFile?.dimensions?.width }
: {})}
/>
{options?.enableZoomViewer ? (
<div
className={cn(
'absolute bottom-4',
options?.isOriginal
? 'left-4 isolate z-[100]'
: 'right-4 z-[100]',
)}
>
<ImageViewer>
<PhotoView src={src!}>
<div>
<Tooltip
content={`Enlarge ${options?.isOriginal ? 'input' : 'output'}`}
>
<Icon name="zoom" size={18} className="cursor-pointer" />
</Tooltip>
</div>
</PhotoView>
</ImageViewer>
</div>
) : null}
</div>
)
},
[mediaFile?.dimensions?.width],
)
const renderVideo = useCallback(
(src: string, options?: renderVideoDefaultType) => {
options = merge({}, renderVideoDefaultOptions, options ?? {})
return (
<VideoPlayer
id={`video-comparison-${options?.isOriginal ? 0 : 1}`}
key={`video-comparison-${options?.isOriginal ? 0 : 1}`}
url={src}
autoPlay
loop
controls={false}
playPauseOnSpaceKeydown={false}
disableClosedCaptions
disablePlayPauseControlAtCenter
disablePlayPauseViaContainerClick
containerClassName="w-full h-full"
{...(mediaFile?.dimensions?.width
? { width: mediaFile?.dimensions?.width }
: {})}
style={{
width: '100%',
minWidth: '50vw',
maxHeight: '65vh',
aspectRatio:
(mediaFile?.dimensions?.width ?? 1) /
(mediaFile?.dimensions?.height ?? 1),
}}
muted={options?.isOriginal}
/>
)
},
[mediaFile?.dimensions?.width, mediaFile?.dimensions?.height],
)
return (
<div id={id} className="rounded-3xl overflow-hidden w-fit">
<div className="border-1 border-zinc-200 dark:border-zinc-900 rounded-3xl">
<CompareSlider
onlyHandleDraggable
itemOne={
mediaFile?.type === 'video'
? renderVideo(mediaFile?.path!)
: renderImage(mediaFile?.path!)
}
itemTwo={
mediaFile?.type === 'video'
? renderVideo(`${mediaFile?.compressedFile?.path!}?id={${id}}`, {
isOriginal: false,
})
: renderImage(`${mediaFile?.compressedFile?.path!}?id={${id}}`, {
isOriginal: false,
})
}
handle={<ReactCompareSliderHandle />}
style={{ width: 'fit-content' }}
/>
</div>
{mediaFile?.type === 'image' &&
mediaFile?.compressedFile?.extension === 'svg' &&
(mediaFile?.compressedFile?.sizeInBytes ?? 0) >= 5 * 1024 * 1024 ? (
<p className="text-xs my-2 flex justify-center items-center gap-1 text-warning-400">
<Icon name="warning" />
SVG file too large. Might affect rendering performance.
</p>
) : null}
</div>
)
}
export default memo(MediaOutputCompareSlider)

View file

@ -382,31 +382,41 @@ function MediaThumbnail({ mediaIndex }: MediaThumbnailProps) {
<div className="relative w-fit mx-auto">
<Image
alt="video to compress"
src={thumbnailPath as string}
src={
isProcessCompleted
? mediaFile?.type === 'video' &&
mediaFile?.previewMode === 'image'
? mediaFile?.thumbnailPath!
: mediaFile?.compressedFile?.path!
: (thumbnailPath as string)
}
className="object-contain rounded-3xl max-h-[65vh] border-1 border-zinc-200 dark:border-zinc-900 min-w-[100px] min-h-[100px]"
onError={() => {
if (!isProcessCompleted) {
handleRegenerateThumbnail('00:00:01.00', 0, true)
}
}}
{...(mediaFile?.dimensions?.width
? { width: mediaFile?.dimensions?.width }
: {})}
/>
<div className="absolute bottom-2 right-2 z-[20] flex items-center gap-3 bg-zinc-900/50 min-h-[25px] px-3 rounded-2xl">
<div className="absolute bottom-3 right-3 z-[20] flex items-center gap-3 bg-zinc-900/50 min-h-[25px] px-2 rounded-2xl">
{videoDuration && !isProcessCompleted ? (
<Tooltip content="Regenerate Thumbnail" className="w-0! h-0!">
<Button
size="sm"
variant="light"
isIconOnly
onPress={() => {
handleRegenerateThumbnail()
}}
isDisabled={isThumbnailRegenerating}
isLoading={isThumbnailRegenerating}
className="!p-0 !min-h-0 !h-[unset] !py-2 !w-[unset] !min-w-[unset] hover:bg-none"
>
<Button
size="sm"
variant="light"
isIconOnly
onPress={() => {
handleRegenerateThumbnail()
}}
isDisabled={isThumbnailRegenerating}
isLoading={isThumbnailRegenerating}
className="!p-0 !min-h-0 !py-2 !w-[unset] !min-w-[unset] !h-0 "
>
<Tooltip content="Regenerate Thumbnail" className="w-0! h-0!">
<Icon name="image" size={20} />
</Button>
</Tooltip>
</Tooltip>
</Button>
) : null}
<ImageViewer
// @ts-ignore
@ -418,7 +428,7 @@ function MediaThumbnail({ mediaIndex }: MediaThumbnailProps) {
>
<PhotoView src={thumbnailPath!}>
<div>
<Tooltip content="Enlarge Image">
<Tooltip content="Enlarge image">
<Icon name="zoom" size={18} className="cursor-pointer" />
</Tooltip>
</div>

View file

@ -1,5 +1,5 @@
import { motion } from 'framer-motion'
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import { useSnapshot } from 'valtio'
import Button from '@/components/Button'
@ -8,11 +8,14 @@ import Divider from '@/components/Divider'
import Icon from '@/components/Icon'
import Image from '@/components/Image'
import { CircularProgress } from '@/components/Progress'
import Switch from '@/components/Switch'
import { Ripple } from '@/ui/Patterns/Ripple'
import { slideUpTransition, zoomInTransition } from '@/utils/animation'
import { formatDuration } from '@/utils/string'
import { cn } from '@/utils/tailwind'
import { appProxy } from '../-state'
import Thumbnail from './MediaThumbnail'
import MediaOutputCompareSlider from './MediaOutputCompareSlider'
import MediaThumbnail from './MediaThumbnail'
import styles from './styles.module.css'
import VideoInfo from './VideoInfo'
@ -34,12 +37,15 @@ function PreviewSingleMedia({ mediaIndex }: PreviewSingleMediaProps) {
extension: mediaExtension,
compressionProgress,
compressedFile,
thumbnailPath,
} = mediaFile ?? {}
const { videoDuration, fps, thumbnailPath } =
const { videoDuration, fps } =
mediaFile?.type === 'video' ? (mediaFile ?? {}) : {}
const { isVideoTransformEditMode, isVideoTrimEditMode } =
mediaFile?.type === 'video' ? (mediaFile?.config ?? {}) : {}
const [compareOutput, setCompareOutput] = useState(true)
const compressedSizeDiff: number = useMemo(
() =>
typeof compressedFile?.sizeInBytes === 'number' &&
@ -72,12 +78,40 @@ function PreviewSingleMedia({ mediaIndex }: PreviewSingleMediaProps) {
</Code>
) : null}
{mediaFile ? <Thumbnail mediaIndex={mediaIndex} /> : null}
{isProcessCompleted &&
((mediaFile?.type === 'video' && mediaFile?.previewMode === 'video') ||
mediaFile?.type === 'image') ? (
<div className="flex justify-center my-4">
<Switch
size="sm"
isSelected={compareOutput}
onValueChange={() => {
setCompareOutput((s) => !s)
}}
>
<div className="flex justify-center items-center">
<span className="block mr-2 text-sm">Compare</span>
<Icon name="compare" />
</div>
</Switch>
</div>
) : null}
{mediaFile ? (
isProcessCompleted &&
compareOutput &&
((mediaFile?.type === 'video' && mediaFile?.previewMode === 'video') ||
mediaFile?.type === 'image') ? (
<MediaOutputCompareSlider mediaIndex={mediaIndex} />
) : (
<MediaThumbnail mediaIndex={mediaIndex} />
)
) : null}
{!(isVideoTransformEditMode || isVideoTrimEditMode) ? (
isProcessCompleted ? (
<section className="animate-appearance-in">
<div className="flex justify-center items-center mt-3">
<div className="flex justify-center items-center mt-2">
<p className="text-2xl font-bold mx-4">{mediaSize}</p>
<Icon
name="curvedArrow"
@ -198,12 +232,17 @@ function PreviewSingleMedia({ mediaIndex }: PreviewSingleMediaProps) {
</motion.div>
) : (
<motion.div
className="w-full h-full flex flex-col justify-center items-center flex-shrink-0"
className="relative w-full h-full flex flex-col justify-center items-center flex-shrink-0"
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', duration: 0.6 }}
>
<div className="relative">
<Ripple
mainCircleOpacity={0.2}
mainCircleSize={380}
className="absolute top-0 left-0"
/>
<CircularProgress
{...(videoDuration == null
? { isIndeterminate: true }

View file

@ -791,7 +791,7 @@ function SubtitleStreamsDisplay({
{stream.disposition.default ||
stream.disposition.forced ||
stream.disposition.attached_pic ||
stream.disposition.attachedPic ||
stream.disposition.comment ||
stream.disposition.karaoke ||
stream.disposition.lyrics ? (
@ -808,7 +808,7 @@ function SubtitleStreamsDisplay({
- Forced
</div>
) : null}
{stream.disposition.attached_pic ? (
{stream.disposition.attachedPic ? (
<div className="text-zinc-600 dark:text-zinc-400 text-xs">
- Attached Picture
</div>

View file

@ -12,7 +12,7 @@ import Icon from '@/components/Icon'
import Markdown from '@/components/Markdown'
import Modal, { ModalContent } from '@/components/Modal'
import ScrollShadow from '@/components/ScrollShadow'
import ThemeSwitcher from '@/components/ThemeSwitcher'
import { ThemeSwitcher } from '@/components/ThemeSwitcher'
import Title from '@/components/Title'
import { toast } from '@/components/Toast'
import Tooltip from '@/components/Tooltip'

View file

@ -0,0 +1,58 @@
import React, { type ComponentPropsWithoutRef, type CSSProperties } from 'react'
import { cn } from '@/utils/tailwind'
interface RippleProps extends ComponentPropsWithoutRef<'div'> {
mainCircleSize?: number
mainCircleOpacity?: number
numCircles?: number
}
export const Ripple = React.memo(function Ripple({
mainCircleSize = 210,
mainCircleOpacity = 0.24,
numCircles = 8,
className,
...props
}: RippleProps) {
return (
<div
className={cn(
'pointer-events-none absolute inset-0 mask-[linear-gradient(to_bottom,white,transparent)] select-none',
className,
)}
{...props}
>
{Array.from({ length: numCircles }, (_, i) => {
const size = mainCircleSize + i * 70
const opacity = mainCircleOpacity - i * 0.03
const animationDelay = `${i * 0.06}s`
const borderStyle = 'solid'
return (
<div
key={i}
className={`animate-ripple bg-foreground/25 absolute rounded-full border shadow-xl`}
style={
{
'--i': i,
width: `${size}px`,
height: `${size}px`,
opacity,
animationDelay,
borderStyle,
borderWidth: '1px',
borderColor: `var(--foreground)`,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%) scale(1)',
} as CSSProperties
}
/>
)
})}
</div>
)
})
Ripple.displayName = 'Ripple'

View file

@ -39,6 +39,9 @@ const config: Config = {
],
theme: {
extend: {
animation: {
ripple: 'var(--animate-ripple)',
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':