From d64079f4f8b61d6d4f21e798379e234355cf210d Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Tue, 4 Aug 2026 04:41:11 +0530 Subject: [PATCH 1/3] fix(timeline): carry rounded timecode seconds --- src/lib/ai-edition/timeline/format.test.ts | 12 ++++++++++++ src/lib/ai-edition/timeline/format.ts | 22 ++++++++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/lib/ai-edition/timeline/format.test.ts b/src/lib/ai-edition/timeline/format.test.ts index f7f2bd67..c242d2e9 100644 --- a/src/lib/ai-edition/timeline/format.test.ts +++ b/src/lib/ai-edition/timeline/format.test.ts @@ -16,6 +16,10 @@ describe("formatSec", () => { expect(formatSec(Number.NaN)).toBe("0:00.0"); expect(formatSec(Number.POSITIVE_INFINITY)).toBe("0:00.0"); }); + + it("carries rounded seconds into the next minute", () => { + expect(formatSec(59.96)).toBe("1:00.0"); + }); }); describe("formatSeconds", () => { @@ -29,6 +33,10 @@ describe("formatSeconds", () => { expect(formatSeconds(-1)).toBe("0:00.0"); expect(formatSeconds(Number.NaN)).toBe("0:00.0"); }); + + it("carries rounded seconds into the next hour", () => { + expect(formatSeconds(3599.96)).toBe("1:00:00.0"); + }); }); describe("formatMs", () => { @@ -37,4 +45,8 @@ describe("formatMs", () => { expect(formatMs(-1)).toBe("0:00.0"); expect(formatMs(Number.NaN)).toBe("0:00.0"); }); + + it("inherits minute carry from formatSec", () => { + expect(formatMs(59_960)).toBe("1:00.0"); + }); }); diff --git a/src/lib/ai-edition/timeline/format.ts b/src/lib/ai-edition/timeline/format.ts index a9d1ff30..d1595585 100644 --- a/src/lib/ai-edition/timeline/format.ts +++ b/src/lib/ai-edition/timeline/format.ts @@ -10,20 +10,26 @@ // padded hours, no tenths) and timeUtils' `formatTimePadded` (mm:ss) are // different formats, not copies of these. +function splitRoundedTime(value: number): { totalMinutes: number; seconds: number } { + const safe = Number.isFinite(value) && value > 0 ? value : 0; + const totalTenths = Math.round(safe * 10); + return { + totalMinutes: Math.floor(totalTenths / 600), + seconds: (totalTenths % 600) / 10, + }; +} + /** `m:ss.t` — no hour field, ever. */ export function formatSec(sec: number): string { - const safe = Number.isFinite(sec) && sec > 0 ? sec : 0; - const m = Math.floor(safe / 60); - const s = (safe % 60).toFixed(1); - return `${m}:${s.padStart(4, "0")}`; + const { totalMinutes, seconds } = splitRoundedTime(sec); + return `${totalMinutes}:${seconds.toFixed(1).padStart(4, "0")}`; } /** `m:ss.t`, or `h:mm:ss.t` once past an hour. */ export function formatSeconds(value: number): string { - const safe = Number.isFinite(value) && value > 0 ? value : 0; - const hours = Math.floor(safe / 3600); - const minutes = Math.floor((safe % 3600) / 60); - const seconds = safe % 60; + const { totalMinutes, seconds } = splitRoundedTime(value); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; if (hours > 0) { return `${hours}:${String(minutes).padStart(2, "0")}:${seconds.toFixed(1).padStart(4, "0")}`; } From 193a72880f899a2fce545740bfed0da9c17b3b4e Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Tue, 4 Aug 2026 04:52:57 +0530 Subject: [PATCH 2/3] fix(timeline): avoid overflow in time rounding --- src/lib/ai-edition/timeline/format.test.ts | 8 ++++++++ src/lib/ai-edition/timeline/format.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lib/ai-edition/timeline/format.test.ts b/src/lib/ai-edition/timeline/format.test.ts index c242d2e9..7ffb33fb 100644 --- a/src/lib/ai-edition/timeline/format.test.ts +++ b/src/lib/ai-edition/timeline/format.test.ts @@ -20,6 +20,10 @@ describe("formatSec", () => { it("carries rounded seconds into the next minute", () => { expect(formatSec(59.96)).toBe("1:00.0"); }); + + it("keeps finite durations finite while rounding", () => { + expect(formatSec(Number.MAX_VALUE)).not.toMatch(/Infinity|NaN/); + }); }); describe("formatSeconds", () => { @@ -37,6 +41,10 @@ describe("formatSeconds", () => { it("carries rounded seconds into the next hour", () => { expect(formatSeconds(3599.96)).toBe("1:00:00.0"); }); + + it("keeps finite durations finite while rounding", () => { + expect(formatSeconds(Number.MAX_VALUE)).not.toMatch(/Infinity|NaN/); + }); }); describe("formatMs", () => { diff --git a/src/lib/ai-edition/timeline/format.ts b/src/lib/ai-edition/timeline/format.ts index d1595585..b35069a0 100644 --- a/src/lib/ai-edition/timeline/format.ts +++ b/src/lib/ai-edition/timeline/format.ts @@ -12,11 +12,13 @@ function splitRoundedTime(value: number): { totalMinutes: number; seconds: number } { const safe = Number.isFinite(value) && value > 0 ? value : 0; - const totalTenths = Math.round(safe * 10); - return { - totalMinutes: Math.floor(totalTenths / 600), - seconds: (totalTenths % 600) / 10, - }; + let totalMinutes = Math.floor(safe / 60); + let seconds = Math.round((safe % 60) * 10) / 10; + if (seconds >= 60) { + totalMinutes += 1; + seconds = 0; + } + return { totalMinutes, seconds }; } /** `m:ss.t` — no hour field, ever. */ From c3f3deab0e48d0f277ea1c2a5b23c0d31211bbab Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 14:04:14 +0200 Subject: [PATCH 3/3] fix(timeline): carry the rounded second in the last two copies too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format.ts was only two of the three places that floored the minute field off the raw value while rounding the second field off it separately, so `0:60.0` stayed renderable in the two this PR had not reached. TransportBar's `formatTC` was `formatSec` verbatim — same output for every input, junk guard written differently — so it just goes, and the transport bar imports the shared one. That is the copy that mattered: it feeds the live playhead readout, so the bad string showed up there once a minute during playback, which is the place a user is most likely to actually see it. LeftPanel's `formatTimecode` is a genuinely different shape (`h:mm:ss.t`, hours always shown), so it keeps formatting itself rather than being forced into one of format.ts's two shapes. It now shares `splitRoundedTime`, which is what this PR added and the only part that must not be re-derived. Fixed a second bug while there: `padStart(3, "0")` never padded anything, because `(5).toFixed(1)` is already three characters — a single-digit second rendered `0:00:5.0`. Also tightened the two `Number.MAX_VALUE` cases. `not.toMatch(/Infinity|NaN/)` passes against the pre-carry implementation too, so it pinned nothing; asserting the exact string means a regression is actually caught. --- src/components/ai-edition/LeftPanel.tsx | 15 ++++++++--- src/components/ai-edition/TransportBar.tsx | 12 +++------ src/lib/ai-edition/timeline/format.test.ts | 31 +++++++++++++++++++--- src/lib/ai-edition/timeline/format.ts | 16 +++++++++-- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 58822a62..1942d1da 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -10,6 +10,7 @@ import { useTranscriptionStore, } from "@/lib/ai-edition/store/transcriptionStore"; import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; +import { splitRoundedTime } from "@/lib/ai-edition/timeline/format"; import type { AssetTranscriptionView } from "@/lib/ai-edition/transcription/status"; import { nativeBridgeClient } from "@/native/client"; import type { @@ -36,12 +37,18 @@ export type LeftTab = "chat" | "media"; const THUMB_PALETTE = ["thumbRed", "thumbGreen", "thumbAmber", "thumbCyan"] as const; +// `h:mm:ss.t`, hours always shown — a third shape, so it formats itself rather +// than calling into format.ts. It shares `splitRoundedTime` because the carry is +// the part that must not be re-derived: deriving the minute field from the raw +// value while the second field rounded is what rendered `0:00:60.0`. function formatTimecode(sec: number | undefined): string { if (!sec || !Number.isFinite(sec)) return "0:00:00.0"; - const h = Math.floor(sec / 3600); - const m = Math.floor((sec % 3600) / 60); - const s = (sec % 60).toFixed(1); - return `${h}:${m.toString().padStart(2, "0")}:${s.padStart(3, "0")}`; + const { totalMinutes, seconds } = splitRoundedTime(sec); + const h = Math.floor(totalMinutes / 60); + const m = totalMinutes % 60; + // padStart(4), not (3): "5.0" is already 3 chars, so a single-digit second + // rendered as `0:00:5.0` instead of `0:00:05.0`. + return `${h}:${m.toString().padStart(2, "0")}:${seconds.toFixed(1).padStart(4, "0")}`; } function basename(path: string): string { diff --git a/src/components/ai-edition/TransportBar.tsx b/src/components/ai-edition/TransportBar.tsx index 3096c37d..b536aed6 100644 --- a/src/components/ai-edition/TransportBar.tsx +++ b/src/components/ai-edition/TransportBar.tsx @@ -4,15 +4,9 @@ import { useScopedT } from "@/contexts/I18nContext"; import { setUiProbeScrubbing } from "@/lib/ai-edition/perf/uiFrameProbe"; import type { AxcutClip } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { formatSec } from "@/lib/ai-edition/timeline/format"; import styles from "./NewEditorShell.module.css"; -function formatTC(sec: number): string { - if (!sec || !Number.isFinite(sec) || sec < 0) sec = 0; - const m = Math.floor(sec / 60); - const s = (sec % 60).toFixed(1); - return `${m}:${s.padStart(4, "0")}`; -} - interface TransportBarProps { playing: boolean; /** Live scrub position while a timeline drag is in flight; null = follow the store. */ @@ -177,9 +171,9 @@ export const TransportBar = memo(function TransportBar({ - {formatTC(currentTimeSec)} + {formatSec(currentTimeSec)} / - {formatTC(virtualDurationSec)} + {formatSec(virtualDurationSec)}
diff --git a/src/lib/ai-edition/timeline/format.test.ts b/src/lib/ai-edition/timeline/format.test.ts index 7ffb33fb..df4289e9 100644 --- a/src/lib/ai-edition/timeline/format.test.ts +++ b/src/lib/ai-edition/timeline/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatMs, formatSec, formatSeconds } from "./format"; +import { formatMs, formatSec, formatSeconds, splitRoundedTime } from "./format"; // These three replaced six private copies; the cases that differed between // those copies (negatives, NaN, the hour boundary) are what this pins down. @@ -22,7 +22,9 @@ describe("formatSec", () => { }); it("keeps finite durations finite while rounding", () => { - expect(formatSec(Number.MAX_VALUE)).not.toMatch(/Infinity|NaN/); + // Asserted exactly, not as `not.toMatch(/Infinity|NaN/)`: that weaker form + // passes against the pre-carry implementation too, so it pinned nothing. + expect(formatSec(Number.MAX_VALUE)).toBe("2.9961552247705265e+306:08.0"); }); }); @@ -43,7 +45,8 @@ describe("formatSeconds", () => { }); it("keeps finite durations finite while rounding", () => { - expect(formatSeconds(Number.MAX_VALUE)).not.toMatch(/Infinity|NaN/); + // Exact, for the same reason as the formatSec case above. + expect(formatSeconds(Number.MAX_VALUE)).toBe("4.993592041284211e+304:56:08.0"); }); }); @@ -58,3 +61,25 @@ describe("formatMs", () => { expect(formatMs(59_960)).toBe("1:00.0"); }); }); + +// Exported so LeftPanel's `formatTimecode` (h:mm:ss.t, a third shape that formats +// itself) shares the carry instead of re-deriving it. Pinned here because that +// caller has no test of its own. +describe("splitRoundedTime", () => { + it("carries a second that rounds up to 60 into the minute field", () => { + expect(splitRoundedTime(59.96)).toEqual({ totalMinutes: 1, seconds: 0 }); + }); + + it("does not carry when the second stays under 60", () => { + expect(splitRoundedTime(59.94)).toEqual({ totalMinutes: 0, seconds: 59.9 }); + }); + + it("carries across the hour boundary as plain minutes", () => { + expect(splitRoundedTime(3599.96)).toEqual({ totalMinutes: 60, seconds: 0 }); + }); + + it("floors junk to zero", () => { + expect(splitRoundedTime(Number.NaN)).toEqual({ totalMinutes: 0, seconds: 0 }); + expect(splitRoundedTime(-1)).toEqual({ totalMinutes: 0, seconds: 0 }); + }); +}); diff --git a/src/lib/ai-edition/timeline/format.ts b/src/lib/ai-edition/timeline/format.ts index b35069a0..eeac5cd0 100644 --- a/src/lib/ai-edition/timeline/format.ts +++ b/src/lib/ai-edition/timeline/format.ts @@ -8,9 +8,21 @@ // // Not covered here, deliberately: ExportDialog's `formatHms` (hh:mm:ss, always // padded hours, no tenths) and timeUtils' `formatTimePadded` (mm:ss) are -// different formats, not copies of these. +// different formats, not copies of these. LeftPanel's `formatTimecode` +// (h:mm:ss.t, hours always shown) is a third format for the same reason — it +// stays local, but it shares `splitRoundedTime` so the carry lives in one place. -function splitRoundedTime(value: number): { totalMinutes: number; seconds: number } { +/** + * Rounds to a tenth and carries the result, so the minute and second fields can + * never disagree. Doing the floor and the rounding independently is what made + * `0:60.0` renderable: at 59.96 the minutes field still saw 59.96 while the + * seconds field had already rounded to 60.0. + * + * Exported for the one formatter that lives outside this file (LeftPanel's + * `formatTimecode`) — its always-padded `h:mm:ss.t` matches neither shape here, + * so it formats itself, but it must not re-derive the carry. + */ +export function splitRoundedTime(value: number): { totalMinutes: number; seconds: number } { const safe = Number.isFinite(value) && value > 0 ? value : 0; let totalMinutes = Math.floor(safe / 60); let seconds = Math.round((safe % 60) * 10) / 10;