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 f7f2bd67..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. @@ -16,6 +16,16 @@ 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"); + }); + + it("keeps finite durations finite while rounding", () => { + // 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"); + }); }); describe("formatSeconds", () => { @@ -29,6 +39,15 @@ 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"); + }); + + it("keeps finite durations finite while rounding", () => { + // Exact, for the same reason as the formatSec case above. + expect(formatSeconds(Number.MAX_VALUE)).toBe("4.993592041284211e+304:56:08.0"); + }); }); describe("formatMs", () => { @@ -37,4 +56,30 @@ 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"); + }); +}); + +// 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 a9d1ff30..eeac5cd0 100644 --- a/src/lib/ai-edition/timeline/format.ts +++ b/src/lib/ai-edition/timeline/format.ts @@ -8,22 +8,42 @@ // // 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. + +/** + * 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; + if (seconds >= 60) { + totalMinutes += 1; + seconds = 0; + } + return { totalMinutes, seconds }; +} /** `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")}`; }