diff --git a/CHANGELOG.md b/CHANGELOG.md index 534142f5a..7688161ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ metadata and the backend fallback mirror it. ### Fixed +- Carry rounded dubbing timestamp seconds into minutes, avoiding invalid labels such as `0:60.0` — thanks @rudycelekli! (#2660) + - MCP speech tools wait through model loading and progress-extended CPU renders instead of timing out before the backend (#2609) ## [0.5.7] — 2026-10-05 diff --git a/docs/dubbing/export.md b/docs/dubbing/export.md index 86f555a23..19ad3d0f4 100644 --- a/docs/dubbing/export.md +++ b/docs/dubbing/export.md @@ -23,3 +23,5 @@ filters; POSIX filenames keep literal backslashes. Two caption styles are availa Smart Fit exports burn captions after the video retime, so both styles follow the fitted timeline. The karaoke script can also be downloaded on its own from `GET /dub/ass/{job_id}` as an `.ass` sidecar. + +The dubbing timeline displays elapsed times in minutes and tenths of seconds. A rounded second carries into the minute, so a boundary such as 59.96 seconds displays as `1:00.0`, keeping timestamp labels consistent in the timeline and segment editor. Display rounding does not change segment timing or exported timestamps. diff --git a/electron/src/renderer/src/features/dub/dub-timeline.tsx b/electron/src/renderer/src/features/dub/dub-timeline.tsx index e21017610..f963d2647 100644 --- a/electron/src/renderer/src/features/dub/dub-timeline.tsx +++ b/electron/src/renderer/src/features/dub/dub-timeline.tsx @@ -1,3 +1,4 @@ +import { formatTime } from '@shared/utils/format'; import { HeadphonesIcon, LoaderCircleIcon, PlayIcon, TriangleAlertIcon, ZoomInIcon, ZoomOutIcon, MaximizeIcon } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,10 +29,7 @@ type Gesture = { end: number; }; -const formatTime = (seconds: number) => { - const minutes = Math.floor(seconds / 60); - return `${minutes}:${(seconds % 60).toFixed(1).padStart(4, '0')}`; -}; + export function DubTimeline({ segments, diff --git a/electron/src/shared/utils/format.d.ts b/electron/src/shared/utils/format.d.ts new file mode 100644 index 000000000..0774d2de7 --- /dev/null +++ b/electron/src/shared/utils/format.d.ts @@ -0,0 +1,2 @@ +export function formatTime(seconds: number): string; +export function probeAudioDuration(file: Blob): Promise; diff --git a/electron/src/shared/utils/format.js b/electron/src/shared/utils/format.js index b5ea73ff9..a79bf4d4e 100644 --- a/electron/src/shared/utils/format.js +++ b/electron/src/shared/utils/format.js @@ -1,6 +1,8 @@ export function formatTime(s) { - const m = Math.floor(s / 60); - const sec = (s % 60).toFixed(1); + // Round once before decomposing, so a tenths carry advances the minute. + const tenths = Math.round(s * 10); + const m = Math.floor(tenths / 600); + const sec = ((tenths % 600) / 10).toFixed(1); return `${m}:${sec.padStart(4, '0')}`; } diff --git a/electron/src/shared/utils/utils.test.js b/electron/src/shared/utils/utils.test.js index d2b0b78be..1a704ce6c 100644 --- a/electron/src/shared/utils/utils.test.js +++ b/electron/src/shared/utils/utils.test.js @@ -23,6 +23,10 @@ describe('format utils', () => { expect(formatTime(0)).toBe('0:00.0'); expect(formatTime(61)).toBe('1:01.0'); expect(formatTime(3661)).toBe('61:01.0'); + expect(formatTime(59.94)).toBe('0:59.9'); + expect(formatTime(59.96)).toBe('1:00.0'); + expect(formatTime(119.96)).toBe('2:00.0'); + expect(formatTime(3599.96)).toBe('60:00.0'); }); });