Skip to content

Commit 016bc23

Browse files
sepion02EtienneLescot
authored andcommitted
fix(timeline): carry the rounded second in the last two copies too
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.
1 parent ac336eb commit 016bc23

4 files changed

Lines changed: 56 additions & 18 deletions

File tree

src/components/ai-edition/LeftPanel.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
useTranscriptionStore,
1111
} from "@/lib/ai-edition/store/transcriptionStore";
1212
import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus";
13+
import { splitRoundedTime } from "@/lib/ai-edition/timeline/format";
1314
import type { AssetTranscriptionView } from "@/lib/ai-edition/transcription/status";
1415
import { nativeBridgeClient } from "@/native/client";
1516
import type {
@@ -36,12 +37,18 @@ export type LeftTab = "chat" | "media";
3637

3738
const THUMB_PALETTE = ["thumbRed", "thumbGreen", "thumbAmber", "thumbCyan"] as const;
3839

40+
// `h:mm:ss.t`, hours always shown — a third shape, so it formats itself rather
41+
// than calling into format.ts. It shares `splitRoundedTime` because the carry is
42+
// the part that must not be re-derived: deriving the minute field from the raw
43+
// value while the second field rounded is what rendered `0:00:60.0`.
3944
function formatTimecode(sec: number | undefined): string {
4045
if (!sec || !Number.isFinite(sec)) return "0:00:00.0";
41-
const h = Math.floor(sec / 3600);
42-
const m = Math.floor((sec % 3600) / 60);
43-
const s = (sec % 60).toFixed(1);
44-
return `${h}:${m.toString().padStart(2, "0")}:${s.padStart(3, "0")}`;
46+
const { totalMinutes, seconds } = splitRoundedTime(sec);
47+
const h = Math.floor(totalMinutes / 60);
48+
const m = totalMinutes % 60;
49+
// padStart(4), not (3): "5.0" is already 3 chars, so a single-digit second
50+
// rendered as `0:00:5.0` instead of `0:00:05.0`.
51+
return `${h}:${m.toString().padStart(2, "0")}:${seconds.toFixed(1).padStart(4, "0")}`;
4552
}
4653

4754
function basename(path: string): string {

src/components/ai-edition/TransportBar.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,9 @@ import { useScopedT } from "@/contexts/I18nContext";
44
import { setUiProbeScrubbing } from "@/lib/ai-edition/perf/uiFrameProbe";
55
import type { AxcutClip } from "@/lib/ai-edition/schema";
66
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
7+
import { formatSec } from "@/lib/ai-edition/timeline/format";
78
import styles from "./NewEditorShell.module.css";
89

9-
function formatTC(sec: number): string {
10-
if (!sec || !Number.isFinite(sec) || sec < 0) sec = 0;
11-
const m = Math.floor(sec / 60);
12-
const s = (sec % 60).toFixed(1);
13-
return `${m}:${s.padStart(4, "0")}`;
14-
}
15-
1610
interface TransportBarProps {
1711
playing: boolean;
1812
/** Live scrub position while a timeline drag is in flight; null = follow the store. */
@@ -177,9 +171,9 @@ export const TransportBar = memo(function TransportBar({
177171
<SkipForward size={13} />
178172
</button>
179173
<span className={styles.time}>
180-
<span>{formatTC(currentTimeSec)}</span>
174+
<span>{formatSec(currentTimeSec)}</span>
181175
<span className={styles.sep}>/</span>
182-
<span className={styles.total}>{formatTC(virtualDurationSec)}</span>
176+
<span className={styles.total}>{formatSec(virtualDurationSec)}</span>
183177
</span>
184178
<div className={styles.scrubBar}>
185179
<div className={styles.scrubTrack}>

src/lib/ai-edition/timeline/format.test.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { formatMs, formatSec, formatSeconds } from "./format";
2+
import { formatMs, formatSec, formatSeconds, splitRoundedTime } from "./format";
33

44
// These three replaced six private copies; the cases that differed between
55
// those copies (negatives, NaN, the hour boundary) are what this pins down.
@@ -22,7 +22,9 @@ describe("formatSec", () => {
2222
});
2323

2424
it("keeps finite durations finite while rounding", () => {
25-
expect(formatSec(Number.MAX_VALUE)).not.toMatch(/Infinity|NaN/);
25+
// Asserted exactly, not as `not.toMatch(/Infinity|NaN/)`: that weaker form
26+
// passes against the pre-carry implementation too, so it pinned nothing.
27+
expect(formatSec(Number.MAX_VALUE)).toBe("2.9961552247705265e+306:08.0");
2628
});
2729
});
2830

@@ -43,7 +45,8 @@ describe("formatSeconds", () => {
4345
});
4446

4547
it("keeps finite durations finite while rounding", () => {
46-
expect(formatSeconds(Number.MAX_VALUE)).not.toMatch(/Infinity|NaN/);
48+
// Exact, for the same reason as the formatSec case above.
49+
expect(formatSeconds(Number.MAX_VALUE)).toBe("4.993592041284211e+304:56:08.0");
4750
});
4851
});
4952

@@ -58,3 +61,25 @@ describe("formatMs", () => {
5861
expect(formatMs(59_960)).toBe("1:00.0");
5962
});
6063
});
64+
65+
// Exported so LeftPanel's `formatTimecode` (h:mm:ss.t, a third shape that formats
66+
// itself) shares the carry instead of re-deriving it. Pinned here because that
67+
// caller has no test of its own.
68+
describe("splitRoundedTime", () => {
69+
it("carries a second that rounds up to 60 into the minute field", () => {
70+
expect(splitRoundedTime(59.96)).toEqual({ totalMinutes: 1, seconds: 0 });
71+
});
72+
73+
it("does not carry when the second stays under 60", () => {
74+
expect(splitRoundedTime(59.94)).toEqual({ totalMinutes: 0, seconds: 59.9 });
75+
});
76+
77+
it("carries across the hour boundary as plain minutes", () => {
78+
expect(splitRoundedTime(3599.96)).toEqual({ totalMinutes: 60, seconds: 0 });
79+
});
80+
81+
it("floors junk to zero", () => {
82+
expect(splitRoundedTime(Number.NaN)).toEqual({ totalMinutes: 0, seconds: 0 });
83+
expect(splitRoundedTime(-1)).toEqual({ totalMinutes: 0, seconds: 0 });
84+
});
85+
});

src/lib/ai-edition/timeline/format.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,21 @@
88
//
99
// Not covered here, deliberately: ExportDialog's `formatHms` (hh:mm:ss, always
1010
// padded hours, no tenths) and timeUtils' `formatTimePadded` (mm:ss) are
11-
// different formats, not copies of these.
11+
// different formats, not copies of these. LeftPanel's `formatTimecode`
12+
// (h:mm:ss.t, hours always shown) is a third format for the same reason — it
13+
// stays local, but it shares `splitRoundedTime` so the carry lives in one place.
1214

13-
function splitRoundedTime(value: number): { totalMinutes: number; seconds: number } {
15+
/**
16+
* Rounds to a tenth and carries the result, so the minute and second fields can
17+
* never disagree. Doing the floor and the rounding independently is what made
18+
* `0:60.0` renderable: at 59.96 the minutes field still saw 59.96 while the
19+
* seconds field had already rounded to 60.0.
20+
*
21+
* Exported for the one formatter that lives outside this file (LeftPanel's
22+
* `formatTimecode`) — its always-padded `h:mm:ss.t` matches neither shape here,
23+
* so it formats itself, but it must not re-derive the carry.
24+
*/
25+
export function splitRoundedTime(value: number): { totalMinutes: number; seconds: number } {
1426
const safe = Number.isFinite(value) && value > 0 ? value : 0;
1527
let totalMinutes = Math.floor(safe / 60);
1628
let seconds = Math.round((safe % 60) * 10) / 10;

0 commit comments

Comments
 (0)