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")}`;
}