From dbc4f437953913ac3e24127ae045d06d18c1a420 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 11 Aug 2023 19:46:10 +1000 Subject: [PATCH 1/7] Add sprite info hook --- .../ScenePlayer/ScenePlayerScrubber.tsx | 85 +++++-------------- ui/v2.5/src/hooks/sprite.ts | 57 +++++++++++++ 2 files changed, 80 insertions(+), 62 deletions(-) create mode 100644 ui/v2.5/src/hooks/sprite.ts diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index 68dfbb406da..c6cf120fba1 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -6,15 +6,14 @@ import React, { useCallback, } from "react"; import { Button } from "react-bootstrap"; -import axios from "axios"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; -import { WebVTT } from "videojs-vtt.js"; import { Icon } from "src/components/Shared/Icon"; import { faChevronRight, faChevronLeft, } from "@fortawesome/free-solid-svg-icons"; +import { useSpriteInfo } from "src/hooks/sprite"; interface IScenePlayerScrubberProps { file: GQL.VideoFileDataFragment; @@ -29,42 +28,6 @@ interface ISceneSpriteItem { time: string; } -interface ISceneSpriteInfo { - url: string; - start: number; - end: number; - x: number; - y: number; - w: number; - h: number; -} - -async function fetchSpriteInfo(vttPath: string) { - const response = await axios.get(vttPath, { responseType: "text" }); - - const sprites: ISceneSpriteInfo[] = []; - - const parser = new WebVTT.Parser(window, WebVTT.StringDecoder()); - parser.oncue = (cue: VTTCue) => { - const match = cue.text.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i); - if (!match) return; - - sprites.push({ - url: new URL(match[1], vttPath).href, - start: cue.startTime, - end: cue.endTime, - x: Number(match[2]), - y: Number(match[3]), - w: Number(match[4]), - h: Number(match[5]), - }); - }; - parser.parse(response.data); - parser.flush(); - - return sprites; -} - export const ScenePlayerScrubber: React.FC = ({ file, scene, @@ -119,34 +82,32 @@ export const ScenePlayerScrubber: React.FC = ({ [onSeek, file.duration, scrubWidth] ); + const spriteInfo = useSpriteInfo(scene.paths.vtt ?? undefined); const [spriteItems, setSpriteItems] = useState(); useEffect(() => { - if (!scene.paths.vtt) return; - fetchSpriteInfo(scene.paths.vtt).then((sprites) => { - if (!sprites) return; - let totalWidth = 0; - const newSprites = sprites?.map((sprite, index) => { - totalWidth += sprite.w; - const left = sprite.w * index; - const style = { - width: `${sprite.w}px`, - height: `${sprite.h}px`, - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, - backgroundImage: `url(${sprite.url})`, - left: `${left}px`, - }; - const start = TextUtils.secondsToTimestamp(sprite.start); - const end = TextUtils.secondsToTimestamp(sprite.end); - return { - style, - time: `${start} - ${end}`, - }; - }); - setScrubWidth(totalWidth); - setSpriteItems(newSprites); + if (!spriteInfo) return; + let totalWidth = 0; + const newSprites = spriteInfo?.map((sprite, index) => { + totalWidth += sprite.w; + const left = sprite.w * index; + const style = { + width: `${sprite.w}px`, + height: `${sprite.h}px`, + backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + backgroundImage: `url(${sprite.url})`, + left: `${left}px`, + }; + const start = TextUtils.secondsToTimestamp(sprite.start); + const end = TextUtils.secondsToTimestamp(sprite.end); + return { + style, + time: `${start} - ${end}`, + }; }); - }, [scene]); + setScrubWidth(totalWidth); + setSpriteItems(newSprites); + }, [spriteInfo]); useEffect(() => { const onResize = (entries: ResizeObserverEntry[]) => { diff --git a/ui/v2.5/src/hooks/sprite.ts b/ui/v2.5/src/hooks/sprite.ts new file mode 100644 index 00000000000..29824ef53f9 --- /dev/null +++ b/ui/v2.5/src/hooks/sprite.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { WebVTT } from "videojs-vtt.js"; + +export interface ISceneSpriteInfo { + url: string; + start: number; + end: number; + x: number; + y: number; + w: number; + h: number; +} + +function getSpriteInfo(vttPath: string, response: string) { + const sprites: ISceneSpriteInfo[] = []; + + const parser = new WebVTT.Parser(window, WebVTT.StringDecoder()); + parser.oncue = (cue: VTTCue) => { + const match = cue.text.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i); + if (!match) return; + + sprites.push({ + url: new URL(match[1], vttPath).href, + start: cue.startTime, + end: cue.endTime, + x: Number(match[2]), + y: Number(match[3]), + w: Number(match[4]), + h: Number(match[5]), + }); + }; + parser.parse(response); + parser.flush(); + + return sprites; +} + +export function useSpriteInfo(vttPath: string | undefined) { + const [spriteInfo, setSpriteInfo] = useState< + ISceneSpriteInfo[] | undefined + >(); + + useEffect(() => { + if (!vttPath) { + setSpriteInfo(undefined); + return; + } + + fetch(vttPath).then((response) => { + response.text().then((text) => { + setSpriteInfo(getSpriteInfo(vttPath, text)); + }); + }); + }, [vttPath]); + + return spriteInfo; +} From a94944d3a0a58741ca8df20f83dbf44dd9cd32e1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 11 Aug 2023 19:50:09 +1000 Subject: [PATCH 2/7] Remove axios dependency --- ui/v2.5/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 60b2d35f477..98ec62dea30 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -32,7 +32,6 @@ "@silvermine/videojs-airplay": "^1.2.0", "@silvermine/videojs-chromecast": "^1.4.1", "apollo-upload-client": "^17.0.0", - "axios": "^1.3.3", "base64-blob": "^1.4.1", "bootstrap": "^4.6.2", "classnames": "^2.3.2", From bcd232c02b71557e2a959420f23c8bf8b5380841 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 12 Aug 2023 11:01:10 +1000 Subject: [PATCH 3/7] Add preview scrubber --- .../src/components/Scenes/PreviewScrubber.tsx | 121 ++++++++++++++++++ ui/v2.5/src/components/Scenes/SceneCard.tsx | 5 + ui/v2.5/src/components/Scenes/styles.scss | 31 +++++ ui/v2.5/src/hooks/sprite.ts | 5 + 4 files changed, 162 insertions(+) create mode 100644 ui/v2.5/src/components/Scenes/PreviewScrubber.tsx diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx new file mode 100644 index 00000000000..d28eae006dc --- /dev/null +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -0,0 +1,121 @@ +import React, { useMemo } from "react"; +import { useDebounce } from "src/hooks/debounce"; +import { useSpriteInfo } from "src/hooks/sprite"; + +interface IHoverScrubber { + totalSprites: number; + activeIndex: number | undefined; + setActiveIndex: (index: number | undefined) => void; +} + +const HoverScrubber: React.FC = ({ + totalSprites, + activeIndex, + setActiveIndex, +}) => { + function onMouseMove(e: React.MouseEvent) { + const relatedTarget = e.currentTarget; + + if (relatedTarget !== e.target) return; + + const { width } = relatedTarget.getBoundingClientRect(); + const x = e.nativeEvent.offsetX; + + const index = Math.floor((x / width) * totalSprites); + setActiveIndex(index); + } + + function onMouseLeave() { + setActiveIndex(undefined); + } + + const indicatorStyle = useMemo(() => { + if (activeIndex === undefined) return {}; + + const left = (activeIndex / totalSprites) * 100; + const width = 100 / totalSprites; + + return { + left: `${left}%`, + width: `${width}%`, + }; + }, [activeIndex, totalSprites]); + + return ( +
+
+ {activeIndex !== undefined && ( +
+ )} +
+
+ ); +}; + +interface IScenePreviewProps { + vttPath: string | undefined; +} + +export const PreviewScrubber: React.FC = ({ vttPath }) => { + const [activeIndex, setActiveIndex] = React.useState(); + + const debounceSetActiveIndex = useDebounce( + setActiveIndex, + [setActiveIndex], + 10 + ); + + const spriteInfo = useSpriteInfo(vttPath); + + const style = useMemo(() => { + if (!spriteInfo || activeIndex === undefined) { + return {}; + } + + const sprite = spriteInfo[activeIndex]; + const totalWidth = spriteInfo.reduce( + (acc, cur) => Math.max(acc, cur.x + cur.w), + 0 + ); + const totalHeight = spriteInfo.reduce( + (acc, cur) => Math.max(acc, cur.y + cur.h), + 0 + ); + + const spriteX = sprite.x / totalWidth; + const spriteY = sprite.y / totalHeight; + + const spritesX = Math.floor(totalWidth / sprite.w); + const spritesY = Math.floor(totalHeight / sprite.h); + + return { + "background-size": `calc(100% * ${spritesX}) calc(100% * ${spritesY})`, + backgroundPosition: `calc(${-spriteX} * 100% * ${spritesX}) calc(${-spriteY} * 100% * ${spritesY})`, + backgroundImage: `url(${sprite.url})`, + }; + }, [spriteInfo, activeIndex]); + + if (!spriteInfo) return null; + + return ( +
+ {activeIndex !== undefined && spriteInfo && ( +
+
+
+ )} + debounceSetActiveIndex(i)} + /> +
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 190c4b4697f..1907e0251f8 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -25,12 +25,14 @@ import { faTag, } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; +import { PreviewScrubber } from "./PreviewScrubber"; interface IScenePreviewProps { isPortrait: boolean; image?: string; video?: string; soundActive: boolean; + vttPath?: string; } export const ScenePreview: React.FC = ({ @@ -38,6 +40,7 @@ export const ScenePreview: React.FC = ({ video, isPortrait, soundActive, + vttPath, }) => { const videoEl = useRef(null); @@ -72,6 +75,7 @@ export const ScenePreview: React.FC = ({ ref={videoEl} src={video} /> + ); }; @@ -404,6 +408,7 @@ export const SceneCard: React.FC = ( video={props.scene.paths.preview ?? undefined} isPortrait={isPortrait()} soundActive={configuration?.interface?.soundOnPreview ?? false} + vttPath={props.scene.paths.vtt ?? undefined} /> {maybeRenderSceneSpecsOverlay()} diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 31e5de8d1fc..230c32668cd 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -643,3 +643,34 @@ input[type="range"].blue-slider { .scrape-dialog .rating-number.disabled { padding-left: 0.5em; } + +.preview-scrubber { + height: 100%; + position: absolute; + width: 100%; + + .scrubber-image { + height: 100%; + width: 100%; + } +} + +.hover-scrubber { + bottom: 0; + height: 20px; + position: absolute; + width: 100%; + z-index: 1; + + .hover-scrubber-indicator { + height: 100%; + position: absolute; + width: 100%; + + .hover-scrubber-indicator-marker { + background-color: rgba(255, 0, 0, 0.5); + height: 100%; + position: absolute; + } + } +} diff --git a/ui/v2.5/src/hooks/sprite.ts b/ui/v2.5/src/hooks/sprite.ts index 29824ef53f9..8d66c2fa568 100644 --- a/ui/v2.5/src/hooks/sprite.ts +++ b/ui/v2.5/src/hooks/sprite.ts @@ -47,6 +47,11 @@ export function useSpriteInfo(vttPath: string | undefined) { } fetch(vttPath).then((response) => { + if (!response.ok) { + setSpriteInfo(undefined); + return; + } + response.text().then((text) => { setSpriteInfo(getSpriteInfo(vttPath, text)); }); From 5b33d6bdd56b823b6eeb6bc04b81acc64f60c285 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 14 Aug 2023 10:16:01 +1000 Subject: [PATCH 4/7] Styling --- .../src/components/Scenes/PreviewScrubber.tsx | 9 ++++----- ui/v2.5/src/components/Scenes/styles.scss | 20 +++++++++++++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index d28eae006dc..f21523b2881 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -32,11 +32,9 @@ const HoverScrubber: React.FC = ({ const indicatorStyle = useMemo(() => { if (activeIndex === undefined) return {}; - const left = (activeIndex / totalSprites) * 100; - const width = 100 / totalSprites; + const width = (activeIndex / totalSprites) * 100; return { - left: `${left}%`, width: `${width}%`, }; }, [activeIndex, totalSprites]); @@ -44,10 +42,11 @@ const HoverScrubber: React.FC = ({ return (
+ >
+
{activeIndex !== undefined && (
Date: Mon, 14 Aug 2023 10:49:39 +1000 Subject: [PATCH 5/7] Add scrubber timestamp --- .../src/components/Scenes/PreviewScrubber.tsx | 18 +++++++++++++++++- ui/v2.5/src/components/Scenes/styles.scss | 9 +++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index f21523b2881..288940a64d5 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from "react"; import { useDebounce } from "src/hooks/debounce"; import { useSpriteInfo } from "src/hooks/sprite"; +import TextUtils from "src/utils/text"; interface IHoverScrubber { totalSprites: number; @@ -45,7 +46,7 @@ const HoverScrubber: React.FC = ({ className="hover-scrubber-area" onMouseMove={onMouseMove} onMouseLeave={onMouseLeave} - >
+ />
{activeIndex !== undefined && (
= ({ vttPath }) => { }; }, [spriteInfo, activeIndex]); + const currentTime = useMemo(() => { + if (!spriteInfo || activeIndex === undefined) { + return undefined; + } + + const sprite = spriteInfo[activeIndex]; + + const start = TextUtils.secondsToTimestamp(sprite.start); + + return start; + }, [activeIndex, spriteInfo]); + if (!spriteInfo) return null; return ( @@ -108,6 +121,9 @@ export const PreviewScrubber: React.FC = ({ vttPath }) => { {activeIndex !== undefined && spriteInfo && (
+ {currentTime !== undefined && ( +
{currentTime}
+ )}
)} Date: Mon, 14 Aug 2023 11:12:44 +1000 Subject: [PATCH 6/7] On click go to timestamp --- .../src/components/Scenes/PreviewScrubber.tsx | 44 ++++++++++++++++--- ui/v2.5/src/components/Scenes/SceneCard.tsx | 20 ++++++++- ui/v2.5/src/models/sceneQueue.ts | 4 ++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index 288940a64d5..9bcac82e8c3 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -7,29 +7,45 @@ interface IHoverScrubber { totalSprites: number; activeIndex: number | undefined; setActiveIndex: (index: number | undefined) => void; + onClick?: (index: number) => void; } const HoverScrubber: React.FC = ({ totalSprites, activeIndex, setActiveIndex, + onClick, }) => { + function getActiveIndex(e: React.MouseEvent) { + const { width } = e.currentTarget.getBoundingClientRect(); + const x = e.nativeEvent.offsetX; + + return Math.floor((x / width) * totalSprites); + } + function onMouseMove(e: React.MouseEvent) { const relatedTarget = e.currentTarget; if (relatedTarget !== e.target) return; - const { width } = relatedTarget.getBoundingClientRect(); - const x = e.nativeEvent.offsetX; - - const index = Math.floor((x / width) * totalSprites); - setActiveIndex(index); + setActiveIndex(getActiveIndex(e)); } function onMouseLeave() { setActiveIndex(undefined); } + function onScrubberClick(e: React.MouseEvent) { + if (!onClick) return; + + const relatedTarget = e.currentTarget; + + if (relatedTarget !== e.target) return; + + e.preventDefault(); + onClick(getActiveIndex(e)); + } + const indicatorStyle = useMemo(() => { if (activeIndex === undefined) return {}; @@ -46,6 +62,7 @@ const HoverScrubber: React.FC = ({ className="hover-scrubber-area" onMouseMove={onMouseMove} onMouseLeave={onMouseLeave} + onClick={onScrubberClick} />
{activeIndex !== undefined && ( @@ -61,9 +78,13 @@ const HoverScrubber: React.FC = ({ interface IScenePreviewProps { vttPath: string | undefined; + onClick?: (timestamp: number) => void; } -export const PreviewScrubber: React.FC = ({ vttPath }) => { +export const PreviewScrubber: React.FC = ({ + vttPath, + onClick, +}) => { const [activeIndex, setActiveIndex] = React.useState(); const debounceSetActiveIndex = useDebounce( @@ -114,6 +135,16 @@ export const PreviewScrubber: React.FC = ({ vttPath }) => { return start; }, [activeIndex, spriteInfo]); + function onScrubberClick(index: number) { + if (!spriteInfo || !onClick) { + return; + } + + const sprite = spriteInfo[index]; + + onClick(sprite.start); + } + if (!spriteInfo) return null; return ( @@ -130,6 +161,7 @@ export const PreviewScrubber: React.FC = ({ vttPath }) => { totalSprites={81} activeIndex={activeIndex} setActiveIndex={(i) => debounceSetActiveIndex(i)} + onClick={onScrubberClick} />
); diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 1907e0251f8..b01cf698faf 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; -import { Link } from "react-router-dom"; +import { Link, useHistory } from "react-router-dom"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; @@ -33,6 +33,7 @@ interface IScenePreviewProps { video?: string; soundActive: boolean; vttPath?: string; + onScrubberClick?: (timestamp: number) => void; } export const ScenePreview: React.FC = ({ @@ -41,6 +42,7 @@ export const ScenePreview: React.FC = ({ isPortrait, soundActive, vttPath, + onScrubberClick, }) => { const videoEl = useRef(null); @@ -75,7 +77,7 @@ export const ScenePreview: React.FC = ({ ref={videoEl} src={video} /> - +
); }; @@ -94,6 +96,7 @@ interface ISceneCardProps { export const SceneCard: React.FC = ( props: ISceneCardProps ) => { + const history = useHistory(); const { configuration } = React.useContext(ConfigurationContext); const file = useMemo( @@ -387,6 +390,18 @@ export const SceneCard: React.FC = ( }) : `/scenes/${props.scene.id}`; + function onScrubberClick(timestamp: number) { + const link = props.queue + ? props.queue.makeLink(props.scene.id, { + sceneIndex: props.index, + continue: cont, + start: timestamp, + }) + : `/scenes/${props.scene.id}?t=${timestamp}`; + + history.push(link); + } + return ( = ( isPortrait={isPortrait()} soundActive={configuration?.interface?.soundOnPreview ?? false} vttPath={props.scene.paths.vtt ?? undefined} + onScrubberClick={onScrubberClick} /> {maybeRenderSceneSpecsOverlay()} diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index de9cf2bbee2..14f81df5925 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -9,6 +9,7 @@ export interface IPlaySceneOptions { newPage?: number; autoPlay?: boolean; continue?: boolean; + start?: number; } export class SceneQueue { @@ -117,6 +118,9 @@ export class SceneQueue { if (options.continue !== undefined) { params.push("continue=" + options.continue); } + if (options.start !== undefined) { + params.push("t=" + options.start); + } return `/scenes/${sceneID}${params.length ? "?" + params.join("&") : ""}`; } } From 469cc36c2fc77f73d4cf92aec1921465d55394ba Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 14 Aug 2023 13:04:06 +1000 Subject: [PATCH 7/7] Scale correctly --- .../src/components/Scenes/PreviewScrubber.tsx | 41 +++++++++++-------- ui/v2.5/src/components/Scenes/styles.scss | 7 ++++ 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index 9bcac82e8c3..52be78c7b67 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -81,10 +81,24 @@ interface IScenePreviewProps { onClick?: (timestamp: number) => void; } +function scaleToFit(dimensions: { w: number; h: number }, bounds: DOMRect) { + const rw = bounds.width / dimensions.w; + const rh = bounds.height / dimensions.h; + + // for consistency, use max by default and min for portrait + if (dimensions.w > dimensions.h) { + return Math.max(rw, rh); + } + + return Math.min(rw, rh); +} + export const PreviewScrubber: React.FC = ({ vttPath, onClick, }) => { + const imageParentRef = React.useRef(null); + const [activeIndex, setActiveIndex] = React.useState(); const debounceSetActiveIndex = useDebounce( @@ -96,32 +110,23 @@ export const PreviewScrubber: React.FC = ({ const spriteInfo = useSpriteInfo(vttPath); const style = useMemo(() => { - if (!spriteInfo || activeIndex === undefined) { + if (!spriteInfo || activeIndex === undefined || !imageParentRef.current) { return {}; } const sprite = spriteInfo[activeIndex]; - const totalWidth = spriteInfo.reduce( - (acc, cur) => Math.max(acc, cur.x + cur.w), - 0 - ); - const totalHeight = spriteInfo.reduce( - (acc, cur) => Math.max(acc, cur.y + cur.h), - 0 - ); - - const spriteX = sprite.x / totalWidth; - const spriteY = sprite.y / totalHeight; - const spritesX = Math.floor(totalWidth / sprite.w); - const spritesY = Math.floor(totalHeight / sprite.h); + const clientRect = imageParentRef.current?.getBoundingClientRect(); + const scale = clientRect ? scaleToFit(sprite, clientRect) : 1; return { - "background-size": `calc(100% * ${spritesX}) calc(100% * ${spritesY})`, - backgroundPosition: `calc(${-spriteX} * 100% * ${spritesX}) calc(${-spriteY} * 100% * ${spritesY})`, + backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, backgroundImage: `url(${sprite.url})`, + width: `${sprite.w}px`, + height: `${sprite.h}px`, + transform: `scale(${scale})`, }; - }, [spriteInfo, activeIndex]); + }, [spriteInfo, activeIndex, imageParentRef]); const currentTime = useMemo(() => { if (!spriteInfo || activeIndex === undefined) { @@ -150,7 +155,7 @@ export const PreviewScrubber: React.FC = ({ return (
{activeIndex !== undefined && spriteInfo && ( -
+
{currentTime !== undefined && (
{currentTime}
diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index e7d76b9b4a8..a2c74cb8895 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -649,6 +649,13 @@ input[type="range"].blue-slider { position: absolute; width: 100%; + .scene-card-preview-image { + align-items: center; + display: flex; + justify-content: center; + overflow: hidden; + } + .scrubber-image { height: 100%; width: 100%;