From d2daf1f21a81b487e77b00b20b5013bbc067076b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 1 Nov 2024 17:49:17 +1100 Subject: [PATCH 1/7] Allow DurationInput to accept/format timestamps with frames --- .../src/components/Shared/DurationInput.tsx | 20 ++++---- ui/v2.5/src/utils/text.ts | 47 ++++++++++++++++--- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/ui/v2.5/src/components/Shared/DurationInput.tsx b/ui/v2.5/src/components/Shared/DurationInput.tsx index 7c7fde43172..bc8fdb89d98 100644 --- a/ui/v2.5/src/components/Shared/DurationInput.tsx +++ b/ui/v2.5/src/components/Shared/DurationInput.tsx @@ -3,7 +3,7 @@ import { faChevronUp, faClock, } from "@fortawesome/free-solid-svg-icons"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Button, ButtonGroup, InputGroup, Form } from "react-bootstrap"; import { Icon } from "./Icon"; import TextUtils from "src/utils/text"; @@ -17,6 +17,8 @@ interface IProps { placeholder?: string; error?: string; allowNegative?: boolean; + // if set, allows sub-second precision based on frames + frameRate?: number; } export const DurationInput: React.FC = ({ @@ -28,6 +30,7 @@ export const DurationInput: React.FC = ({ placeholder, error, allowNegative = false, + frameRate, }) => { const [tmpValue, setTmpValue] = useState(); @@ -37,7 +40,7 @@ export const DurationInput: React.FC = ({ function onBlur() { if (tmpValue !== undefined) { - updateValue(TextUtils.timestampToSeconds(tmpValue)); + updateValue(TextUtils.timestampToSeconds(tmpValue, frameRate)); setTmpValue(undefined); } } @@ -96,12 +99,13 @@ export const DurationInput: React.FC = ({ } } - let inputValue = ""; - if (tmpValue !== undefined) { - inputValue = tmpValue; - } else if (value !== null && value !== undefined) { - inputValue = TextUtils.secondsToTimestamp(value); - } + const inputValue = useMemo(() => { + if (tmpValue !== undefined) { + return tmpValue; + } else if (value !== null && value !== undefined) { + return TextUtils.secondsToTimestamp(value, frameRate); + } + }, [value, tmpValue, frameRate]); if (placeholder) { placeholder = `${placeholder} (hh:mm:ss)`; diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index da7f7e0241d..4f94f542608 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -151,16 +151,22 @@ const fileSizeFractionalDigits = (unit: Unit) => { return 0; }; -// Converts seconds to a hh:mm:ss or mm:ss timestamp. +// Converts seconds to a [hh:]mm:ss[.ffff] where hh is only shown if hours is non-zero, +// and ffff is shown only if frameRate is set. // A negative input will result in a -hh:mm:ss or -mm:ss output. // Fractional inputs are truncated. -const secondsToTimestamp = (seconds: number) => { +const secondsToTimestamp = (secondsSub: number, frameRate?: number) => { let neg = false; - if (seconds < 0) { + if (secondsSub < 0) { neg = true; - seconds = -seconds; + secondsSub = -secondsSub; } - seconds = Math.trunc(seconds); + + const fracSeconds = secondsSub % 1; + const frame = + frameRate !== undefined ? Math.round(fracSeconds * frameRate) : 0; + + let seconds = Math.trunc(secondsSub); const s = seconds % 60; seconds = (seconds - s) / 60; @@ -177,6 +183,11 @@ const secondsToTimestamp = (seconds: number) => { ret = String(m).padStart(2, "0") + ":" + ret; ret = String(h) + ":" + ret; } + + if (frameRate !== undefined) { + ret += "." + frame.toString().padStart(4, "0"); + } + if (neg) { return "-" + ret; } else { @@ -191,7 +202,10 @@ const formatTimestampRange = (start: number, end: number | undefined) => { return `${secondsToTimestamp(start)}-${secondsToTimestamp(end)}`; }; -const timestampToSeconds = (v: string | null | undefined) => { +const timestampToSeconds = ( + v: string | null | undefined, + frameRate?: number +) => { if (!v) { return null; } @@ -202,6 +216,25 @@ const timestampToSeconds = (v: string | null | undefined) => { return null; } + let secondsPart = splits[splits.length - 1]; + let secondsFraction = 0; + if (secondsPart.includes(".") && frameRate !== undefined) { + const secondsParts = secondsPart.split("."); + if (secondsParts.length !== 2) { + return null; + } + + secondsPart = secondsParts[0]; + + const framePart = secondsParts[1]; + const frame = parseInt(framePart, 10); + if (Number.isNaN(frame)) { + return null; + } + + secondsFraction = frame / frameRate; + } + let seconds = 0; let factor = 1; while (splits.length > 0) { @@ -219,7 +252,7 @@ const timestampToSeconds = (v: string | null | undefined) => { factor *= 60; } - return seconds; + return seconds + secondsFraction; }; const fileNameFromPath = (path: string) => { From 5817f1ef86c3584fa69389d16a02f355f724a3e1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 1 Nov 2024 17:52:47 +1100 Subject: [PATCH 2/7] Allow setting marker time including frame --- ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx | 8 ++++++-- ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx | 2 +- .../Scenes/SceneDetails/SceneMarkerForm.tsx | 12 ++++++++++-- .../Scenes/SceneDetails/SceneMarkersPanel.tsx | 7 ++++--- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 6858e2cd15b..24453043bd8 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -297,9 +297,13 @@ export const ScenePlayer: React.FC = ({ sendSetTimestamp((value: number) => { const player = getPlayer(); if (player && value >= 0) { - player.play()?.then(() => { + if (player.hasStarted() && player.paused()) { player.currentTime(value); - }); + } else { + player.play()?.then(() => { + player.currentTime(value); + }); + } } }); }, [sendSetTimestamp, getPlayer]); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 4a0c67ff126..0cd4c176d8b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -510,7 +510,7 @@ const ScenePage: React.FC = ({ diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index 03fcb3b483f..0f9beaf30db 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -19,13 +19,13 @@ import { yupFormikValidate } from "src/utils/yup"; import { Tag, TagSelect } from "src/components/Tags/TagSelect"; interface ISceneMarkerForm { - sceneID: string; + scene: Pick; marker?: GQL.SceneMarkerDataFragment; onClose: () => void; } export const SceneMarkerForm: React.FC = ({ - sceneID, + scene, marker, onClose, }) => { @@ -72,6 +72,11 @@ export const SceneMarkerForm: React.FC = ({ [marker] ); + const frameRate = useMemo(() => { + if (scene.files.length === 0) return undefined; + return scene.files[0].frame_rate; + }, [scene.files]); + type InputValues = yup.InferType; const formik = useFormik({ @@ -109,6 +114,8 @@ export const SceneMarkerForm: React.FC = ({ ); }, [marker?.tags]); + const sceneID = scene.id; + async function onSave(input: InputValues) { try { if (isNew) { @@ -211,6 +218,7 @@ export const SceneMarkerForm: React.FC = ({ const control = ( formik.setFieldValue("seconds", v)} onReset={() => formik.setFieldValue("seconds", Math.round(getPlayerPosition() ?? 0)) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx index 331c58c784f..9e4d17af536 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx @@ -8,16 +8,17 @@ import { PrimaryTags } from "./PrimaryTags"; import { SceneMarkerForm } from "./SceneMarkerForm"; interface ISceneMarkersPanelProps { - sceneId: string; + scene: Pick; isVisible: boolean; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; } export const SceneMarkersPanel: React.FC = ({ - sceneId, + scene, isVisible, onClickMarker, }) => { + const sceneId = scene.id; const { data, loading } = GQL.useFindSceneMarkerTagsQuery({ variables: { id: sceneId }, }); @@ -51,7 +52,7 @@ export const SceneMarkersPanel: React.FC = ({ if (isEditorOpen) return ( From 6f9b3dd2dc0a44e0727ac2fb2b20f3e234493425 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 1 Nov 2024 20:16:06 +1100 Subject: [PATCH 3/7] Update placeholder text --- ui/v2.5/src/components/Shared/DurationInput.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Shared/DurationInput.tsx b/ui/v2.5/src/components/Shared/DurationInput.tsx index bc8fdb89d98..e25d8f95f38 100644 --- a/ui/v2.5/src/components/Shared/DurationInput.tsx +++ b/ui/v2.5/src/components/Shared/DurationInput.tsx @@ -107,10 +107,12 @@ export const DurationInput: React.FC = ({ } }, [value, tmpValue, frameRate]); + const format = frameRate ? "hh:mm:ss.ffff" : "hh:mm:ss"; + if (placeholder) { - placeholder = `${placeholder} (hh:mm:ss)`; + placeholder = `${placeholder} (${format})`; } else { - placeholder = "hh:mm:ss"; + placeholder = format; } return ( From eb4643205455541bd48ecb95bbcbd6343e725d28 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 1 Nov 2024 20:16:32 +1100 Subject: [PATCH 4/7] Don't include .ffff suffix if unnecessary --- ui/v2.5/src/utils/text.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index 4f94f542608..bd8aca9f328 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -152,9 +152,8 @@ const fileSizeFractionalDigits = (unit: Unit) => { }; // Converts seconds to a [hh:]mm:ss[.ffff] where hh is only shown if hours is non-zero, -// and ffff is shown only if frameRate is set. +// and ffff is shown only if frameRate is set, and the seconds includes a fractional component. // A negative input will result in a -hh:mm:ss or -mm:ss output. -// Fractional inputs are truncated. const secondsToTimestamp = (secondsSub: number, frameRate?: number) => { let neg = false; if (secondsSub < 0) { @@ -184,7 +183,7 @@ const secondsToTimestamp = (secondsSub: number, frameRate?: number) => { ret = String(h) + ":" + ret; } - if (frameRate !== undefined) { + if (frameRate !== undefined && frame > 0) { ret += "." + frame.toString().padStart(4, "0"); } From a159e34acc0cf3d41d58c97a79bd74d1f4aad528 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 1 Nov 2024 20:16:49 +1100 Subject: [PATCH 5/7] Get current frame at sub-second precision --- ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index 0f9beaf30db..115c8b251eb 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -221,7 +221,7 @@ export const SceneMarkerForm: React.FC = ({ frameRate={frameRate} setValue={(v) => formik.setFieldValue("seconds", v)} onReset={() => - formik.setFieldValue("seconds", Math.round(getPlayerPosition() ?? 0)) + formik.setFieldValue("seconds", getPlayerPosition() ?? 0) } error={error} /> From 7c093dad9a7cb5bf3b10129cf8214a8d59cd73a3 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 2 Nov 2024 11:45:50 +1100 Subject: [PATCH 6/7] Use hh:mm:ss.ms instead of frames Eliminates the need to include frameRate --- .../components/Scenes/SceneDetails/Scene.tsx | 2 +- .../Scenes/SceneDetails/SceneMarkerForm.tsx | 12 ++----- .../Scenes/SceneDetails/SceneMarkersPanel.tsx | 7 ++-- .../src/components/Shared/DurationInput.tsx | 12 +++---- ui/v2.5/src/utils/text.ts | 35 ++++++++----------- 5 files changed, 26 insertions(+), 42 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 0cd4c176d8b..4a0c67ff126 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -510,7 +510,7 @@ const ScenePage: React.FC = ({ diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index 115c8b251eb..baaf028dcce 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -19,13 +19,13 @@ import { yupFormikValidate } from "src/utils/yup"; import { Tag, TagSelect } from "src/components/Tags/TagSelect"; interface ISceneMarkerForm { - scene: Pick; + sceneID: string; marker?: GQL.SceneMarkerDataFragment; onClose: () => void; } export const SceneMarkerForm: React.FC = ({ - scene, + sceneID, marker, onClose, }) => { @@ -72,11 +72,6 @@ export const SceneMarkerForm: React.FC = ({ [marker] ); - const frameRate = useMemo(() => { - if (scene.files.length === 0) return undefined; - return scene.files[0].frame_rate; - }, [scene.files]); - type InputValues = yup.InferType; const formik = useFormik({ @@ -114,8 +109,6 @@ export const SceneMarkerForm: React.FC = ({ ); }, [marker?.tags]); - const sceneID = scene.id; - async function onSave(input: InputValues) { try { if (isNew) { @@ -218,7 +211,6 @@ export const SceneMarkerForm: React.FC = ({ const control = ( formik.setFieldValue("seconds", v)} onReset={() => formik.setFieldValue("seconds", getPlayerPosition() ?? 0) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx index 9e4d17af536..331c58c784f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx @@ -8,17 +8,16 @@ import { PrimaryTags } from "./PrimaryTags"; import { SceneMarkerForm } from "./SceneMarkerForm"; interface ISceneMarkersPanelProps { - scene: Pick; + sceneId: string; isVisible: boolean; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; } export const SceneMarkersPanel: React.FC = ({ - scene, + sceneId, isVisible, onClickMarker, }) => { - const sceneId = scene.id; const { data, loading } = GQL.useFindSceneMarkerTagsQuery({ variables: { id: sceneId }, }); @@ -52,7 +51,7 @@ export const SceneMarkersPanel: React.FC = ({ if (isEditorOpen) return ( diff --git a/ui/v2.5/src/components/Shared/DurationInput.tsx b/ui/v2.5/src/components/Shared/DurationInput.tsx index e25d8f95f38..f0871714ad4 100644 --- a/ui/v2.5/src/components/Shared/DurationInput.tsx +++ b/ui/v2.5/src/components/Shared/DurationInput.tsx @@ -17,8 +17,6 @@ interface IProps { placeholder?: string; error?: string; allowNegative?: boolean; - // if set, allows sub-second precision based on frames - frameRate?: number; } export const DurationInput: React.FC = ({ @@ -30,8 +28,8 @@ export const DurationInput: React.FC = ({ placeholder, error, allowNegative = false, - frameRate, }) => { + const includeMS = true; const [tmpValue, setTmpValue] = useState(); function onChange(e: React.ChangeEvent) { @@ -40,7 +38,7 @@ export const DurationInput: React.FC = ({ function onBlur() { if (tmpValue !== undefined) { - updateValue(TextUtils.timestampToSeconds(tmpValue, frameRate)); + updateValue(TextUtils.timestampToSeconds(tmpValue)); setTmpValue(undefined); } } @@ -103,11 +101,11 @@ export const DurationInput: React.FC = ({ if (tmpValue !== undefined) { return tmpValue; } else if (value !== null && value !== undefined) { - return TextUtils.secondsToTimestamp(value, frameRate); + return TextUtils.secondsToTimestamp(value, includeMS); } - }, [value, tmpValue, frameRate]); + }, [value, tmpValue, includeMS]); - const format = frameRate ? "hh:mm:ss.ffff" : "hh:mm:ss"; + const format = "hh:mm:ss.ms"; if (placeholder) { placeholder = `${placeholder} (${format})`; diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index bd8aca9f328..246dcb0e4ac 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -154,18 +154,17 @@ const fileSizeFractionalDigits = (unit: Unit) => { // Converts seconds to a [hh:]mm:ss[.ffff] where hh is only shown if hours is non-zero, // and ffff is shown only if frameRate is set, and the seconds includes a fractional component. // A negative input will result in a -hh:mm:ss or -mm:ss output. -const secondsToTimestamp = (secondsSub: number, frameRate?: number) => { +const secondsToTimestamp = (secondsInput: number, includeMS?: boolean) => { let neg = false; - if (secondsSub < 0) { + if (secondsInput < 0) { neg = true; - secondsSub = -secondsSub; + secondsInput = -secondsInput; } - const fracSeconds = secondsSub % 1; - const frame = - frameRate !== undefined ? Math.round(fracSeconds * frameRate) : 0; + const fracSeconds = secondsInput % 1; + const ms = Math.round(fracSeconds * 1000); - let seconds = Math.trunc(secondsSub); + let seconds = Math.trunc(secondsInput); const s = seconds % 60; seconds = (seconds - s) / 60; @@ -183,8 +182,8 @@ const secondsToTimestamp = (secondsSub: number, frameRate?: number) => { ret = String(h) + ":" + ret; } - if (frameRate !== undefined && frame > 0) { - ret += "." + frame.toString().padStart(4, "0"); + if (includeMS && ms > 0) { + ret += "." + ms.toString().padStart(3, "0"); } if (neg) { @@ -201,10 +200,7 @@ const formatTimestampRange = (start: number, end: number | undefined) => { return `${secondsToTimestamp(start)}-${secondsToTimestamp(end)}`; }; -const timestampToSeconds = ( - v: string | null | undefined, - frameRate?: number -) => { +const timestampToSeconds = (v: string | null | undefined) => { if (!v) { return null; } @@ -216,8 +212,8 @@ const timestampToSeconds = ( } let secondsPart = splits[splits.length - 1]; - let secondsFraction = 0; - if (secondsPart.includes(".") && frameRate !== undefined) { + let msFrac = 0; + if (secondsPart.includes(".")) { const secondsParts = secondsPart.split("."); if (secondsParts.length !== 2) { return null; @@ -225,13 +221,12 @@ const timestampToSeconds = ( secondsPart = secondsParts[0]; - const framePart = secondsParts[1]; - const frame = parseInt(framePart, 10); - if (Number.isNaN(frame)) { + const msPart = parseInt(secondsParts[1], 10); + if (Number.isNaN(msPart)) { return null; } - secondsFraction = frame / frameRate; + msFrac = msPart / 1000; } let seconds = 0; @@ -251,7 +246,7 @@ const timestampToSeconds = ( factor *= 60; } - return seconds + secondsFraction; + return seconds + msFrac; }; const fileNameFromPath = (path: string) => { From af780cbb401cd18f77a93c274da95653b1f06786 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 2 Nov 2024 11:50:48 +1100 Subject: [PATCH 7/7] Move constant out of functional component --- ui/v2.5/src/components/Shared/DurationInput.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Shared/DurationInput.tsx b/ui/v2.5/src/components/Shared/DurationInput.tsx index f0871714ad4..5bd58b1e5d0 100644 --- a/ui/v2.5/src/components/Shared/DurationInput.tsx +++ b/ui/v2.5/src/components/Shared/DurationInput.tsx @@ -19,6 +19,8 @@ interface IProps { allowNegative?: boolean; } +const includeMS = true; + export const DurationInput: React.FC = ({ disabled, value, @@ -29,7 +31,6 @@ export const DurationInput: React.FC = ({ error, allowNegative = false, }) => { - const includeMS = true; const [tmpValue, setTmpValue] = useState(); function onChange(e: React.ChangeEvent) { @@ -103,7 +104,7 @@ export const DurationInput: React.FC = ({ } else if (value !== null && value !== undefined) { return TextUtils.secondsToTimestamp(value, includeMS); } - }, [value, tmpValue, includeMS]); + }, [value, tmpValue]); const format = "hh:mm:ss.ms";