Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/components/ai-edition/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
12 changes: 3 additions & 9 deletions src/components/ai-edition/TransportBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -177,9 +171,9 @@ export const TransportBar = memo(function TransportBar({
<SkipForward size={13} />
</button>
<span className={styles.time}>
<span>{formatTC(currentTimeSec)}</span>
<span>{formatSec(currentTimeSec)}</span>
<span className={styles.sep}>/</span>
<span className={styles.total}>{formatTC(virtualDurationSec)}</span>
<span className={styles.total}>{formatSec(virtualDurationSec)}</span>
</span>
<div className={styles.scrubBar}>
<div className={styles.scrubTrack}>
Expand Down
47 changes: 46 additions & 1 deletion src/lib/ai-edition/timeline/format.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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");
});
Comment thread
EtienneLescot marked this conversation as resolved.
});

describe("formatSeconds", () => {
Expand All @@ -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", () => {
Expand All @@ -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 });
});
});
38 changes: 29 additions & 9 deletions src/lib/ai-edition/timeline/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`;
}
Expand Down
Loading