diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index 14372c4b5..7c290c273 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -5,7 +5,7 @@ // `toolEnd` lifecycle + `error` events into the renderer through the // ChatEventSink. -import { v4 as uuidv4 } from "uuid"; +import { randomUUID } from "node:crypto"; import { type AxcutTimelineOperation, applyTimelineOperation, @@ -160,7 +160,7 @@ export function listSessions(projectId: string): ChatSessionSummary[] { export function createSession(projectId: string, title?: string): ChatSessionSummary { const m = getProjectSessions(projectId); - const id = `sess_${uuidv4()}`; + const id = `sess_${randomUUID()}`; const now = new Date().toISOString(); const session: ChatSession = { id, @@ -278,7 +278,7 @@ export async function runChat( } const userMessage: AiEditionChatMessage = { - id: uuidv4(), + id: randomUUID(), role: "user", content: message, createdAt: new Date().toISOString(), @@ -369,7 +369,7 @@ export async function runChat( } const assistantMessage: AiEditionChatMessage = { - id: uuidv4(), + id: randomUUID(), role: "assistant", content: result.text, createdAt: new Date().toISOString(), @@ -494,7 +494,7 @@ export async function runTimelineOperation( if (conversationMessage.trim() && session) { const assistantMessage: AiEditionChatMessage = { - id: `msg_${uuidv4()}`, + id: `msg_${randomUUID()}`, role: "assistant", content: conversationMessage.trim(), createdAt: new Date().toISOString(), diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts index 27a8a870c..e274b681f 100644 --- a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts @@ -9,6 +9,7 @@ import type { NativeCursorAsset, NativeCursorType, } from "../../../../src/native/contracts"; +import { clamp } from "../../../../src/utils/math"; import type { CursorRecordingSession } from "./session"; interface MacCursorAssetPayload { @@ -173,10 +174,6 @@ export async function requestMacCursorAccessibilityAccess() { ); } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - function normalizeCursorType(value: unknown): NativeCursorType | null { return value === "arrow" || value === "pointer" || value === "text" ? value : null; } diff --git a/electron/native-bridge/cursor/recording/telemetryRecordingSession.ts b/electron/native-bridge/cursor/recording/telemetryRecordingSession.ts index e719d8ee3..a6b58d445 100644 --- a/electron/native-bridge/cursor/recording/telemetryRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/telemetryRecordingSession.ts @@ -1,5 +1,6 @@ import { type Rectangle, screen } from "electron"; import type { CursorRecordingData, CursorRecordingSample } from "../../../../src/native/contracts"; +import { clamp } from "../../../../src/utils/math"; import type { CursorRecordingSession } from "./session"; interface TelemetryRecordingSessionOptions { @@ -9,10 +10,6 @@ interface TelemetryRecordingSessionOptions { startTimeMs?: number; } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - export class TelemetryRecordingSession implements CursorRecordingSession { private samples: CursorRecordingSample[] = []; private interval: NodeJS.Timeout | null = null; diff --git a/package.json b/package.json index ff602516f..78af3b72b 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,6 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", - "uuid": "^13.0.0", "web-demuxer": "^4.0.0", "zod": "^4.1.12", "zustand": "^5.0.8" diff --git a/scripts/build-windows-compositor-addon.mjs b/scripts/build-windows-compositor-addon.mjs index 8bea0e874..df2aee283 100644 --- a/scripts/build-windows-compositor-addon.mjs +++ b/scripts/build-windows-compositor-addon.mjs @@ -15,125 +15,20 @@ // shipped, and require() fails at runtime even with the right dir on PATH. // See poc-d3d/.cargo/config.toml for the pin. -import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { findVcVarsAll, run as spawnStep } from "./msvcEnv.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); const POC_D3D_DIR = path.join(ROOT, "poc-d3d"); const BUILD_OUT_DIR = path.join(ROOT, "electron", "native", "compositor-view", "build"); -function findVcVarsAll() { - const explicit = process.env.VCVARSALL; - if (explicit && fs.existsSync(explicit)) { - return explicit; - } - - const vswhere = "C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"; - if (fs.existsSync(vswhere)) { - const result = spawnSync( - vswhere, - [ - "-latest", - "-products", - "*", - "-requires", - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", - "-property", - "installationPath", - ], - { encoding: "utf8", windowsHide: true }, - ); - const installPath = result.stdout?.trim(); - if (result.status === 0 && installPath) { - const candidate = path.join(installPath, "VC", "Auxiliary", "Build", "vcvarsall.bat"); - if (fs.existsSync(candidate)) { - return candidate; - } - } - } - - if (process.env.VSINSTALLDIR) { - const candidate = path.join( - process.env.VSINSTALLDIR, - "VC", - "Auxiliary", - "Build", - "vcvarsall.bat", - ); - if (fs.existsSync(candidate)) { - return candidate; - } - } - - // vswhere doesn't always enumerate pre-release channels (e.g. "Insiders" - // builds) — walk the install roots generically so new VS releases and - // preview channels are found automatically (same policy as the wgc-capture - // helper build script). - const editions = ["Community", "Professional", "Enterprise", "BuildTools"]; - const installRoots = [ - "C:\\Program Files\\Microsoft Visual Studio", - "C:\\Program Files (x86)\\Microsoft Visual Studio", - ]; - - const listDirs = (dir) => { - try { - return fs - .readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(dir, entry.name)); - } catch { - return []; - } - }; - - for (const installRoot of installRoots) { - for (const versionDir of listDirs(installRoot)) { - for (const channelDir of [versionDir, ...listDirs(versionDir)]) { - const direct = path.join(channelDir, "VC", "Auxiliary", "Build", "vcvarsall.bat"); - if (fs.existsSync(direct)) { - return direct; - } - for (const edition of editions) { - const nested = path.join( - channelDir, - edition, - "VC", - "Auxiliary", - "Build", - "vcvarsall.bat", - ); - if (fs.existsSync(nested)) { - return nested; - } - } - } - } - } - - return null; -} - -function run(command, args, options = {}) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: POC_D3D_DIR, - stdio: "inherit", - windowsHide: true, - ...options, - }); - child.once("error", reject); - child.once("exit", (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`)); - } - }); - }); -} +// cwd defaults to poc-d3d/, not ROOT: cargo reads FFMPEG_DIR and LIBCLANG_PATH +// from poc-d3d/.cargo/config.toml, which only applies when it runs from there. +const run = (command, args, options = {}) => + spawnStep(command, args, { cwd: POC_D3D_DIR, ...options }); async function runInVsEnv(command) { const vcvarsAll = findVcVarsAll(); diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 60384efa3..063d81b30 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -1,8 +1,8 @@ -import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { findVcVarsAll, run as spawnStep } from "./msvcEnv.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); @@ -12,101 +12,6 @@ const COMPAT_LIB_DIR = path.join(BUILD_DIR, "compat-libs"); const BIN_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64"); const CMAKE = process.env.CMAKE_EXE ?? "cmake"; -function findVcVarsAll() { - const explicit = process.env.VCVARSALL; - if (explicit && fs.existsSync(explicit)) { - return explicit; - } - - const vswhere = "C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"; - if (fs.existsSync(vswhere)) { - const result = spawnSync( - vswhere, - [ - "-latest", - "-products", - "*", - "-requires", - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", - "-property", - "installationPath", - ], - { encoding: "utf8", windowsHide: true }, - ); - const installPath = result.stdout?.trim(); - if (result.status === 0 && installPath) { - const candidate = path.join(installPath, "VC", "Auxiliary", "Build", "vcvarsall.bat"); - if (fs.existsSync(candidate)) { - return candidate; - } - } - } - - if (process.env.VSINSTALLDIR) { - const candidate = path.join( - process.env.VSINSTALLDIR, - "VC", - "Auxiliary", - "Build", - "vcvarsall.bat", - ); - if (fs.existsSync(candidate)) { - return candidate; - } - } - - // vswhere doesn't always enumerate pre-release channels (e.g. "Insiders" - // builds), and the on-disk layout for those isn't a stable "\" - // path -- Visual Studio 2026 Insiders installs under a numeric product - // version folder like "18\Insiders\" instead of "2026\". - // Walk the install roots generically instead of hard-coding version/channel - // names so new VS releases and preview channels are found automatically. - const editions = ["Community", "Professional", "Enterprise", "BuildTools"]; - const installRoots = [ - "C:\\Program Files\\Microsoft Visual Studio", - "C:\\Program Files (x86)\\Microsoft Visual Studio", - ]; - - const listDirs = (dir) => { - try { - return fs - .readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(dir, entry.name)); - } catch { - return []; - } - }; - - for (const installRoot of installRoots) { - for (const versionDir of listDirs(installRoot)) { - // versionDir is either an edition directly ("2022\Community") or a - // channel that nests editions ("18\Insiders\Community"). - for (const channelDir of [versionDir, ...listDirs(versionDir)]) { - const direct = path.join(channelDir, "VC", "Auxiliary", "Build", "vcvarsall.bat"); - if (fs.existsSync(direct)) { - return direct; - } - for (const edition of editions) { - const nested = path.join( - channelDir, - edition, - "VC", - "Auxiliary", - "Build", - "vcvarsall.bat", - ); - if (fs.existsSync(nested)) { - return nested; - } - } - } - } - } - - return null; -} - function findWindowsSdkUmLibDir() { const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib"; if (!fs.existsSync(sdkLibRoot)) { @@ -122,24 +27,7 @@ function findWindowsSdkUmLibDir() { .at(-1); } -function run(command, args, options = {}) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: ROOT, - stdio: "inherit", - windowsHide: true, - ...options, - }); - child.once("error", reject); - child.once("exit", (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`)); - } - }); - }); -} +const run = (command, args, options = {}) => spawnStep(command, args, { cwd: ROOT, ...options }); async function runInVsEnv(command) { const vcvarsAll = findVcVarsAll(); diff --git a/scripts/msvcEnv.mjs b/scripts/msvcEnv.mjs new file mode 100644 index 000000000..ca2f68aa5 --- /dev/null +++ b/scripts/msvcEnv.mjs @@ -0,0 +1,122 @@ +// Shared MSVC bootstrap for the two Windows native build scripts +// (build-windows-wgc-helper.mjs and build-windows-compositor-addon.mjs). +// Both need the same vcvarsall.bat discovery; their runInVsEnv bodies differ +// (SDK lib compat shims vs cargo) and stay in their own scripts. + +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +export function findVcVarsAll() { + const explicit = process.env.VCVARSALL; + if (explicit && fs.existsSync(explicit)) { + return explicit; + } + + const vswhere = "C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"; + if (fs.existsSync(vswhere)) { + const result = spawnSync( + vswhere, + [ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", + "installationPath", + ], + { encoding: "utf8", windowsHide: true }, + ); + const installPath = result.stdout?.trim(); + if (result.status === 0 && installPath) { + const candidate = path.join(installPath, "VC", "Auxiliary", "Build", "vcvarsall.bat"); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + + if (process.env.VSINSTALLDIR) { + const candidate = path.join( + process.env.VSINSTALLDIR, + "VC", + "Auxiliary", + "Build", + "vcvarsall.bat", + ); + if (fs.existsSync(candidate)) { + return candidate; + } + } + + // vswhere doesn't always enumerate pre-release channels (e.g. "Insiders" + // builds), and the on-disk layout for those isn't a stable "\" + // path -- Visual Studio 2026 Insiders installs under a numeric product + // version folder like "18\Insiders\" instead of "2026\". + // Walk the install roots generically instead of hard-coding version/channel + // names so new VS releases and preview channels are found automatically. + const editions = ["Community", "Professional", "Enterprise", "BuildTools"]; + const installRoots = [ + "C:\\Program Files\\Microsoft Visual Studio", + "C:\\Program Files (x86)\\Microsoft Visual Studio", + ]; + + const listDirs = (dir) => { + try { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(dir, entry.name)); + } catch { + return []; + } + }; + + for (const installRoot of installRoots) { + for (const versionDir of listDirs(installRoot)) { + // versionDir is either an edition directly ("2022\Community") or a + // channel that nests editions ("18\Insiders\Community"). + for (const channelDir of [versionDir, ...listDirs(versionDir)]) { + const direct = path.join(channelDir, "VC", "Auxiliary", "Build", "vcvarsall.bat"); + if (fs.existsSync(direct)) { + return direct; + } + for (const edition of editions) { + const nested = path.join( + channelDir, + edition, + "VC", + "Auxiliary", + "Build", + "vcvarsall.bat", + ); + if (fs.existsSync(nested)) { + return nested; + } + } + } + } + } + + return null; +} + +/** spawn() as a promise. Callers pass their own cwd. */ +export function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: "inherit", + windowsHide: true, + ...options, + }); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`)); + } + }); + }); +} diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 0f96e832e..4812c3968 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -14,6 +14,7 @@ import type { AiEditionToolCallSummary, AxcutTimelineOperation, } from "@/native/contracts"; +import { formatBytes } from "@/utils/formatBytes"; import { getReasoningEffortLabel, getReasoningEffortOptions, @@ -37,14 +38,6 @@ function formatTimecode(sec: number | undefined): string { return `${h}:${m.toString().padStart(2, "0")}:${s.padStart(3, "0")}`; } -function formatSize(bytes: number | undefined): string { - if (!bytes || !Number.isFinite(bytes)) return "—"; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)} MB`; - return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; -} - function basename(path: string): string { return path.split(/[\\/]/).pop() ?? path; } @@ -81,7 +74,7 @@ function MediaList({ {assets.map((asset, i) => { const label = asset.label || basename(asset.originalPath); const tc = formatTimecode(asset.durationSec); - const size = formatSize(asset.sizeBytes); + const size = formatBytes(asset.sizeBytes); const palette = THUMB_PALETTE[i % THUMB_PALETTE.length]; const isReady = transcriptReadyIds?.has(asset.id); const status = isReady ? "complete" : (assetStatuses?.[asset.id] ?? "idle"); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index 20db96a68..9889754f9 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -24,7 +24,7 @@ import type { CropRegion } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import { toAxcutTranscriptDsl } from "@/lib/ai-edition/document/transcribe"; import type { AxcutClip, AxcutTranscript } from "@/lib/ai-edition/schema"; -import { formatSeconds } from "@/lib/ai-edition/timeline/virtual-preview"; +import { formatSec, formatSeconds } from "@/lib/ai-edition/timeline/format"; import styles from "./NewEditorShell.module.css"; import type { VideoSource } from "./VirtualPreview"; @@ -61,13 +61,6 @@ const LANGUAGE_LABELS: Record = { zh: "ZH", }; -function formatTc(sec: number): string { - if (!Number.isFinite(sec) || sec < 0) return "0:00.0"; - const m = Math.floor(sec / 60); - const s = sec - m * 60; - return `${m}:${s.toFixed(1).padStart(4, "0")}`; -} - interface BaseModalProps { open: boolean; onClose: () => void; @@ -1692,9 +1685,9 @@ export function SourceTranscriptModal({ gap: 4, }} > - {formatTc(playTime)} + {formatSec(playTime)} / - {duration ? formatTc(duration) : tcFormatted} + {duration ? formatSec(duration) : tcFormatted} {isTranscribing ? ( void; } -function clamp01(v: number): number { - return Math.min(1, Math.max(0, v)); -} - export function ZoomFocusOverlay({ region, isPlaying, diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 8d949395e..f7e9cb17f 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -37,8 +37,8 @@ import type { AxcutAnnotationRegion, AxcutClip } from "@/lib/ai-edition/schema"; import { rafCoalesce } from "@/lib/ai-edition/store/rafCoalesce"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; +import { formatSeconds } from "@/lib/ai-edition/timeline/format"; import { coalescedTrimGroups } from "@/lib/ai-edition/timeline/trim-mapping"; -import { formatSeconds } from "@/lib/ai-edition/timeline/virtual-preview"; import { CaptionsPane } from "../CaptionsPane"; import { ColorField } from "../ColorField"; import { diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index 83cdb41d9..540bef3c8 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -4,6 +4,8 @@ import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import type { AxcutAsset } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { formatSeconds } from "@/lib/ai-edition/timeline/format"; +import { formatBytes } from "@/utils/formatBytes"; import styles from "./EditorShellV4.module.css"; const ASSET_MIME = "application/x-axcut-asset"; @@ -18,23 +20,6 @@ function basename(path: string): string { return path.split(/[\\/]/).pop() ?? path; } -function formatTimecode(sec: number | undefined): string { - if (!sec || !Number.isFinite(sec)) return "0:00.0"; - const h = Math.floor(sec / 3600); - const m = Math.floor((sec % 3600) / 60); - const s = (sec % 60).toFixed(1); - return h > 0 - ? `${h}:${m.toString().padStart(2, "0")}:${s.padStart(4, "0")}` - : `${m}:${s.padStart(4, "0")}`; -} - -function formatSize(bytes: number | undefined): string { - if (!bytes || !Number.isFinite(bytes)) return "—"; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)} MB`; - return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; -} - export function MediaStage({ assetStatuses, onRegenerateAsset, @@ -150,8 +135,10 @@ export function MediaStage({ {asset.label || basename(asset.originalPath)}
- {formatTimecode(asset.durationSec)} - {formatSize(asset.sizeBytes)} + + {formatSeconds(asset.durationSec ?? 0)} + + {formatBytes(asset.sizeBytes)}
{status === "running" ? ( diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 3f1188bc3..236369ce4 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -36,6 +36,7 @@ import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; +import { formatSec } from "@/lib/ai-edition/timeline/format"; import { ventilateSpanAcrossClips } from "@/lib/ai-edition/timeline/region-ventilation"; import { coalesceRegionsForRuler } from "@/lib/ai-edition/timeline/timelineMap"; import { @@ -65,13 +66,6 @@ const ASSET_MIME = "application/x-axcut-asset"; type ToolId = "cut" | "comment" | "speed"; -function fmt(sec: number): string { - if (!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")}`; -} - // Ruler tick labels sit on whole-second "nice" steps, so tenths are always // ".0" noise — show clean M:SS (H:MM:SS past an hour) instead. function fmtTick(sec: number): string { @@ -365,7 +359,7 @@ export function V4Timeline({ kind: "trim", start: g.start, end: g.end, - label: fmt(g.end - g.start), + label: formatSec(g.end - g.start), sourceIds: g.ids, })); diff --git a/src/components/ui/gradient-editor.tsx b/src/components/ui/gradient-editor.tsx index 19bb98603..a29e1b923 100644 --- a/src/components/ui/gradient-editor.tsx +++ b/src/components/ui/gradient-editor.tsx @@ -1,5 +1,6 @@ import type { PointerEvent as ReactPointerEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { clamp } from "@/utils/math"; /* ---------- icons ---------- */ const PlusIcon = () => ( @@ -22,8 +23,6 @@ const RingsIcon = () => ( ); -const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); - const MAX_COLORS = 3; const MAIN_ID = "main"; const MAIN_MAX_RADIUS = 40; diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 1528d75d0..dbb1fc937 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -6,6 +6,7 @@ import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { type AspectRatio, isAspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; +import { clamp } from "@/utils/math"; import { DEFAULT_EDITOR_APPEARANCE_SETTINGS, DEFAULT_EDITOR_LAYOUT_SETTINGS, @@ -128,10 +129,6 @@ function computeNormalizedWebcamLayoutPreset( } } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - function encodePathSegments(pathname: string, keepWindowsDrive = false): string { return pathname .split("/") diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 9fc45ab0d..725cd970b 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -1,4 +1,5 @@ import type { WebcamLayoutPreset } from "@/lib/compositeLayout"; +import { clamp01 } from "@/utils/math"; export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6; export type ZoomFocusMode = "manual" | "auto"; @@ -444,14 +445,16 @@ export function getZoomScale(region: ZoomRegion): number { return ZOOM_DEPTH_SCALES[region.depth]; } +// An unknown focus means "centre", not "top-left" — so this one can't use the +// shared clamp, which floors non-finite input to `min`. Same convention as +// `finiteFraction` in useTimeline.ts. +function focusOrCentre(value: number): number { + return Number.isFinite(value) ? clamp01(value) : 0.5; +} + export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus { return { - cx: clamp(focus.cx, 0, 1), - cy: clamp(focus.cy, 0, 1), + cx: focusOrCentre(focus.cx), + cy: focusOrCentre(focus.cy), }; } - -function clamp(value: number, min: number, max: number) { - if (Number.isNaN(value)) return (min + max) / 2; - return Math.min(max, Math.max(min, value)); -} diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts index a0973ecda..a625df405 100644 --- a/src/components/video-editor/videoPlayback/focusUtils.ts +++ b/src/components/video-editor/videoPlayback/focusUtils.ts @@ -1,3 +1,4 @@ +import { clamp } from "@/utils/math"; import { clampFocusToDepth, ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus } from "../types"; interface StageSize { @@ -5,10 +6,6 @@ interface StageSize { height: number; } -function clamp(value: number, min: number, max: number) { - return Math.max(min, Math.min(max, value)); -} - function easeIntoBoundary(normalized: number) { const t = clamp(normalized, 0, 1); return -t * t * t + 2 * t * t; diff --git a/src/components/video-editor/videoPlayback/mathUtils.ts b/src/components/video-editor/videoPlayback/mathUtils.ts index 6553c622c..fdf1afb4f 100644 --- a/src/components/video-editor/videoPlayback/mathUtils.ts +++ b/src/components/video-editor/videoPlayback/mathUtils.ts @@ -1,6 +1,4 @@ -export function clamp01(value: number) { - return Math.max(0, Math.min(1, value)); -} +import { clamp01 } from "@/utils/math"; function sampleCubicBezier(a1: number, a2: number, t: number) { const oneMinusT = 1 - t; @@ -49,32 +47,10 @@ export function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: n return sampleCubicBezier(y1, y2, solvedT); } -export function easeOutExpo(t: number) { - const clamped = clamp01(t); - if (clamped === 1) { - return 1; - } - - return 1 - Math.pow(2, -7 * clamped); -} - export function easeOutScreenStudio(t: number) { return cubicBezier(0.16, 1, 0.3, 1, t); } -export function smoothStep(t: number) { - const clamped = clamp01(t); - return clamped * clamped * (3 - 2 * clamped); -} - -/** - * Ease-in-out cubic. Used for zoom-in transitions. - */ -export function easeInOutCubic(t: number) { - const x = clamp01(t); - return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; -} - /** * Ease-out cubic. Used for zoom-out transitions so strength eases to zero. */ diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 54edfbe2e..f4b6efa1b 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -1,9 +1,10 @@ +import { clamp01 } from "@/utils/math"; import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types"; import { DEFAULT_ROTATION_3D, getRotation3D, getZoomScale, lerpRotation3D } from "../types"; import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants"; import { interpolateCursorAt } from "./cursorFollowUtils"; import { clampFocusToScale } from "./focusUtils"; -import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils"; +import { cubicBezier, easeOutScreenStudio } from "./mathUtils"; const CHAINED_ZOOM_PAN_GAP_MS = 1500; const CONNECTED_ZOOM_PAN_DURATION_MS = 1000; diff --git a/src/lib/ai-edition/annotations/textAnimation.ts b/src/lib/ai-edition/annotations/textAnimation.ts index 61399d440..8576e1d06 100644 --- a/src/lib/ai-edition/annotations/textAnimation.ts +++ b/src/lib/ai-edition/annotations/textAnimation.ts @@ -2,6 +2,8 @@ // copied verbatim (only the annotation param type changed to structurally // match `AxcutAnnotationRegion` instead of the legacy `AnnotationRegion`). +import { clamp01 } from "@/utils/math"; + export type AnnotationTextAnimation = | "none" | "fade" @@ -32,17 +34,13 @@ export const TEXT_ANIMATION_VALUES: AnnotationTextAnimation[] = [ "pulse", ]; -function clamp(value: number, min = 0, max = 1) { - return Math.min(max, Math.max(min, value)); -} - function easeOutCubic(value: number) { - const t = clamp(value); + const t = clamp01(value); return 1 - (1 - t) ** 3; } function easeOutBack(value: number) { - const t = clamp(value); + const t = clamp01(value); const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2; @@ -64,7 +62,7 @@ export function getTextAnimationState( } const elapsedMs = Math.max(0, currentTimeMs - annotation.startMs); - const progress = clamp(elapsedMs / TEXT_ANIMATION_DURATION_MS); + const progress = clamp01(elapsedMs / TEXT_ANIMATION_DURATION_MS); const eased = easeOutCubic(progress); switch (animation) { diff --git a/src/lib/ai-edition/captions/settings.ts b/src/lib/ai-edition/captions/settings.ts index 1efe599c2..e3501d2c6 100644 --- a/src/lib/ai-edition/captions/settings.ts +++ b/src/lib/ai-edition/captions/settings.ts @@ -9,6 +9,7 @@ // `legacyEditor` passthrough blob), so caption settings round-trip through save // / load / undo with every other appearance setting and need no schema bump. +import { clamp } from "@/utils/math"; import type { AxcutDocument } from "../schema"; /** Vertical anchor of the caption band inside the frame. */ @@ -83,10 +84,6 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - function readNumber(value: unknown, fallback: number, min: number, max: number): number { return isFiniteNumber(value) ? clamp(value, min, max) : fallback; } diff --git a/src/lib/ai-edition/document/ids.ts b/src/lib/ai-edition/document/ids.ts index cd8fcb170..e0a560640 100644 --- a/src/lib/ai-edition/document/ids.ts +++ b/src/lib/ai-edition/document/ids.ts @@ -1,8 +1,6 @@ -import { v4 as uuidv4 } from "uuid"; - // ponytail: stable id format `${prefix}_${uuid}` — readable in stored documents // and grep-friendly. Replacement for axcut's packages/axcut-schema ids helper // until the full schema package lands in-tree. export function createId(prefix: string): string { - return `${prefix}_${uuidv4()}`; + return `${prefix}_${crypto.randomUUID()}`; } diff --git a/src/lib/ai-edition/document/operations.ts b/src/lib/ai-edition/document/operations.ts index f1a4db6e0..c0c372f3d 100644 --- a/src/lib/ai-edition/document/operations.ts +++ b/src/lib/ai-edition/document/operations.ts @@ -10,6 +10,7 @@ // and a summary string. import type { AxcutDocument } from "../schema"; +import { formatSec } from "../timeline/format"; import { duplicateClip, moveClip, @@ -79,13 +80,6 @@ export interface AppliedTimelineOperation { summary: string; } -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")}`; -} - function pickAssetId(document: AxcutDocument, explicit?: string): string | null { if (explicit) return explicit; return document.project.primaryAssetId ?? document.assets[0]?.id ?? null; diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 32294a223..0f72d7e93 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -91,6 +91,15 @@ describe("patchEditorSettings", () => { expect(snap.shadowIntensity).toBe(0.7); }); + it("treats an explicitly undefined key as absent, not as a clear", () => { + const seed = patchEditorSettings(baseDoc, { showBlur: true, shadowIntensity: 0.7 }); + const next = patchEditorSettings(seed, { showBlur: undefined, padding: 12 }); + const snap = getEditorSettings(next); + expect(snap.showBlur).toBe(true); + expect(snap.shadowIntensity).toBe(0.7); + expect(snap.padding).toBe(12); + }); + it("patches nested cursor settings without clobbering siblings", () => { const seed = patchEditorSettings(baseDoc, { cursor: { size: 4 } }); const next = patchEditorSettings(seed, { cursor: { smoothing: 0.9 } }); diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 85d7838a7..b4fa74c54 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -187,23 +187,18 @@ export interface EditorSettingsPatch { function nextLegacy(current: LegacyShape | null, patch: EditorSettingsPatch): LegacyShape { const base: LegacyShape = current ?? {}; - const next: LegacyShape = { ...base }; - if (patch.wallpaper !== undefined) next.wallpaper = patch.wallpaper; - if (patch.aspectRatio !== undefined) next.aspectRatio = patch.aspectRatio; - if (patch.shadowIntensity !== undefined) next.shadowIntensity = patch.shadowIntensity; - if (patch.showBlur !== undefined) next.showBlur = patch.showBlur; - if (patch.showTrimWaveform !== undefined) next.showTrimWaveform = patch.showTrimWaveform; - if (patch.motionBlurAmount !== undefined) next.motionBlurAmount = patch.motionBlurAmount; - if (patch.borderRadius !== undefined) next.borderRadius = patch.borderRadius; - if (patch.padding !== undefined) next.padding = patch.padding; - if (patch.cropRegion !== undefined) next.cropRegion = patch.cropRegion; - if (patch.webcamLayoutPreset !== undefined) next.webcamLayoutPreset = patch.webcamLayoutPreset; - if (patch.webcamMaskShape !== undefined) next.webcamMaskShape = patch.webcamMaskShape; - if (patch.webcamMirrored !== undefined) next.webcamMirrored = patch.webcamMirrored; - if (patch.webcamReactiveZoom !== undefined) next.webcamReactiveZoom = patch.webcamReactiveZoom; - if (patch.webcamSizePreset !== undefined) next.webcamSizePreset = patch.webcamSizePreset; - if (patch.webcamPosition !== undefined) next.webcamPosition = patch.webcamPosition; - if (patch.autoFocusAll !== undefined) next.autoFocusAll = patch.autoFocusAll; + // Spread, but only over keys the patch actually set — a plain {...base, + // ...patch} would write undefined over a value the caller left alone. + // `cursor` is handled separately below: its keys are renamed (size -> + // cursorSize), so it is not a straight passthrough. + // The Pick keeps what the 16 `if`s used to check: a patch key with no + // LegacyShape counterpart fails to compile instead of leaking into the + // persisted blob. + const { cursor: _cursor, ...rest } = patch; + const defined = Object.fromEntries( + Object.entries(rest).filter(([, v]) => v !== undefined), + ) as Partial>>; + const next: LegacyShape = { ...base, ...defined }; if (patch.cursor) { const c = patch.cursor; if (c.size !== undefined) next.cursorSize = c.size; diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index b972b5922..c9af6aa4b 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -30,6 +30,12 @@ import { resolveTimelineSpanToTrim } from "../timeline/trim-mapping"; import type { AutoZoomSuggestion } from "../timeline/zoom-suggestions"; import { useProjectStore } from "./projectStore"; +// NaN-guarded floors. Timeline inputs arrive from drag deltas and persisted +// documents, both of which can carry NaN; every action needs the same guard. +const finiteSec = (n: number) => (Number.isFinite(n) ? Math.max(0, n) : 0); +const finiteMs = (n: number) => (Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0); +const finiteFraction = (n: number) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.5); + // Placeholder duration applied to a freshly-inserted clip whose source asset hasn't // reported its real duration yet (media drag → drop before the preview video fires // `loadedmetadata`). `applyProbedDuration` (document layer) swaps it — and the @@ -340,9 +346,8 @@ export function useTimeline() { const updateTrimRange = useCallback( async (trimId: string, startSec: number, endSec: number) => { if (!document) return; - const clamp = (n: number) => (Number.isFinite(n) ? Math.max(0, n) : 0); - const s = clamp(startSec); - const e = clamp(endSec); + const s = finiteSec(startSec); + const e = finiteSec(endSec); const next: AxcutDocument = { ...document, timeline: { @@ -364,9 +369,8 @@ export function useTimeline() { const updateTrim = useCallback( async (trimId: string, next: { assetId: string; startSec: number; endSec: number }) => { if (!document) return; - const clamp = (n: number) => (Number.isFinite(n) ? Math.max(0, n) : 0); - const s = clamp(next.startSec); - const e = clamp(next.endSec); + const s = finiteSec(next.startSec); + const e = finiteSec(next.endSec); const nextDoc: AxcutDocument = { ...document, timeline: { @@ -403,11 +407,10 @@ export function useTimeline() { if (!doc) return; const managed = new Set([...entries.map((e) => e.id), ...dropIds]); const others = doc.timeline.trimRanges.filter((r) => !managed.has(r.id)); - const clamp = (n: number) => (Number.isFinite(n) ? Math.max(0, n) : 0); const rebuilt = entries.map((e) => { const prev = doc.timeline.trimRanges.find((r) => r.id === e.id); - const s = clamp(e.sourceStartSec); - const en = clamp(e.sourceEndSec); + const s = finiteSec(e.sourceStartSec); + const en = finiteSec(e.sourceEndSec); return { id: e.id, assetId: e.assetId, @@ -432,9 +435,8 @@ export function useTimeline() { const updateZoomSpan = useCallback( async (id: string, startMs: number, endMs: number) => { if (!document) return; - const clampMs = (n: number) => (Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0); - const s = clampMs(startMs); - const e = clampMs(endMs); + const s = finiteMs(startMs); + const e = finiteMs(endMs); const next: AxcutDocument = { ...document, zoomRanges: replacePillSpan( @@ -460,11 +462,10 @@ export function useTimeline() { (id: string, focus: { cx: number; cy: number }) => { const doc = useProjectStore.getState().document; if (!doc) return; - const clamp01 = (n: number) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.5); const next: AxcutDocument = { ...doc, zoomRanges: patchPillById(doc.zoomRanges, id, { - focus: { cx: clamp01(focus.cx), cy: clamp01(focus.cy) }, + focus: { cx: finiteFraction(focus.cx), cy: finiteFraction(focus.cy) }, }) as AxcutDocument["zoomRanges"], }; setDocument(next); @@ -539,9 +540,8 @@ export function useTimeline() { const updateAnnotationSpan = useCallback( async (id: string, startMs: number, endMs: number) => { if (!document) return; - const clampMs = (n: number) => (Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0); - const s = clampMs(startMs); - const e = clampMs(endMs); + const s = finiteMs(startMs); + const e = finiteMs(endMs); const next: AxcutDocument = { ...document, annotations: replacePillSpan( @@ -583,9 +583,8 @@ export function useTimeline() { const updateSpeedSpan = useCallback( async (id: string, startMs: number, endMs: number) => { if (!document) return; - const clampMs = (n: number) => (Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0); - const s = clampMs(startMs); - const e = clampMs(endMs); + const s = finiteMs(startMs); + const e = finiteMs(endMs); const legacy = (document.legacyEditor as Record) ?? {}; const prev = ((legacy.speedRegions as unknown[]) ?? []) as Array<{ id: string; @@ -615,9 +614,8 @@ export function useTimeline() { const updateCameraFullscreenSpan = useCallback( async (id: string, startMs: number, endMs: number) => { if (!document) return; - const clampMs = (n: number) => (Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0); - const s = clampMs(startMs); - const e = clampMs(endMs); + const s = finiteMs(startMs); + const e = finiteMs(endMs); const legacy = (document.legacyEditor as Record) ?? {}; const prev = ((legacy.cameraFullscreenRegions as unknown[]) ?? []) as Array<{ id: string; @@ -822,17 +820,16 @@ export function useTimeline() { // refine doesn't reject the save. Swap when end < start instead of // throwing — a user typing into a number input is expected to be // able to type in any order. - const clamp = (n: number) => (Number.isFinite(n) ? Math.max(0, n) : 0); const next: AxcutDocument["timeline"]["clips"][number] = { ...(document.timeline.clips.find((c) => c.id === clipId) as | AxcutDocument["timeline"]["clips"][number] | undefined), } as AxcutDocument["timeline"]["clips"][number]; if (!next?.id) return; - const sStart = clamp(patch.sourceStartSec ?? next.sourceStartSec); - const sEnd = clamp(patch.sourceEndSec ?? next.sourceEndSec ?? 0); - const tStart = clamp(patch.timelineStartSec ?? next.timelineStartSec); - const tEnd = clamp(patch.timelineEndSec ?? next.timelineEndSec); + const sStart = finiteSec(patch.sourceStartSec ?? next.sourceStartSec); + const sEnd = finiteSec(patch.sourceEndSec ?? next.sourceEndSec ?? 0); + const tStart = finiteSec(patch.timelineStartSec ?? next.timelineStartSec); + const tEnd = finiteSec(patch.timelineEndSec ?? next.timelineEndSec); const updated: AxcutDocument["timeline"]["clips"][number] = { ...next, sourceStartSec: Math.min(sStart, sEnd), diff --git a/src/lib/ai-edition/timeline/format.test.ts b/src/lib/ai-edition/timeline/format.test.ts new file mode 100644 index 000000000..f7f2bd67e --- /dev/null +++ b/src/lib/ai-edition/timeline/format.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { formatMs, formatSec, formatSeconds } 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. + +describe("formatSec", () => { + it("never shows an hour field", () => { + expect(formatSec(0)).toBe("0:00.0"); + expect(formatSec(65.4)).toBe("1:05.4"); + expect(formatSec(3661.5)).toBe("61:01.5"); + }); + + it("floors junk input to zero", () => { + expect(formatSec(-5)).toBe("0:00.0"); + expect(formatSec(Number.NaN)).toBe("0:00.0"); + expect(formatSec(Number.POSITIVE_INFINITY)).toBe("0:00.0"); + }); +}); + +describe("formatSeconds", () => { + it("adds the hour field only past an hour", () => { + expect(formatSeconds(65.4)).toBe("1:05.4"); + expect(formatSeconds(3599.9)).toBe("59:59.9"); + expect(formatSeconds(3661.5)).toBe("1:01:01.5"); + }); + + it("floors junk input to zero", () => { + expect(formatSeconds(-1)).toBe("0:00.0"); + expect(formatSeconds(Number.NaN)).toBe("0:00.0"); + }); +}); + +describe("formatMs", () => { + it("is formatSec over milliseconds", () => { + expect(formatMs(65_400)).toBe("1:05.4"); + expect(formatMs(-1)).toBe("0:00.0"); + expect(formatMs(Number.NaN)).toBe("0:00.0"); + }); +}); diff --git a/src/lib/ai-edition/timeline/format.ts b/src/lib/ai-edition/timeline/format.ts index f6f517227..a9d1ff301 100644 --- a/src/lib/ai-edition/timeline/format.ts +++ b/src/lib/ai-edition/timeline/format.ts @@ -1,14 +1,36 @@ -// Format milliseconds as the timeline-style timecode (m:ss.t). +// Timeline timecodes. Two shapes, one home — this used to be six near-identical +// private copies spread across Modals, V4Timeline, MediaStage, operations and +// virtual-preview. // -// Shared by Bottombar lane pills and TimelinePane so pill hover tips and the -// header readouts stay in sync. Kept tiny — it's used in dozens of pill titles -// and would otherwise duplicate `${Math.floor(sec/60)}:${(...).toFixed(1)}` -// paddings across the editor. +// `formatSeconds` shows the hour field only when there is one; `formatSec` +// never does (timeline pills and region readouts are always sub-hour and the +// leading "0:" is noise there). +// +// 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. -export function formatMs(ms: number): string { - if (!Number.isFinite(ms) || ms < 0) return "0:00.0"; - const sec = ms / 1000; - const m = Math.floor(sec / 60); - const s = (sec % 60).toFixed(1); +/** `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")}`; } + +/** `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; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${seconds.toFixed(1).padStart(4, "0")}`; + } + return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`; +} + +/** `formatSec` for callers holding milliseconds (lane pills, hover tips). */ +export function formatMs(ms: number): string { + return formatSec(ms / 1000); +} diff --git a/src/lib/ai-edition/timeline/virtual-preview.test.ts b/src/lib/ai-edition/timeline/virtual-preview.test.ts index 79a1ef141..5ceb94e66 100644 --- a/src/lib/ai-edition/timeline/virtual-preview.test.ts +++ b/src/lib/ai-edition/timeline/virtual-preview.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import type { AxcutClip } from "../schema"; +import { formatSeconds } from "./format"; import { clampVirtualTime, findNextKeptSegment, - formatSeconds, getRawVirtualStartTime, keptWordIdSet, locateSourcePosition, diff --git a/src/lib/ai-edition/timeline/virtual-preview.ts b/src/lib/ai-edition/timeline/virtual-preview.ts index 84616f899..aea0c8181 100644 --- a/src/lib/ai-edition/timeline/virtual-preview.ts +++ b/src/lib/ai-edition/timeline/virtual-preview.ts @@ -178,14 +178,3 @@ export function locateSourcePosition( export function keptWordIdSet(clips: AxcutClip[]): Set { return new Set(clips.flatMap((clip) => clip.wordRefs)); } - -export function formatSeconds(value: number): string { - const safe = Math.max(0, value); - const hours = Math.floor(safe / 3600); - const minutes = Math.floor((safe % 3600) / 60); - const seconds = safe % 60; - if (hours > 0) { - return `${hours}:${String(minutes).padStart(2, "0")}:${seconds.toFixed(1).padStart(4, "0")}`; - } - return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`; -} diff --git a/src/lib/annotationTextAnimation.ts b/src/lib/annotationTextAnimation.ts index 1993dbcba..f7be13eb7 100644 --- a/src/lib/annotationTextAnimation.ts +++ b/src/lib/annotationTextAnimation.ts @@ -1,4 +1,5 @@ import type { AnnotationTextAnimation } from "@/components/video-editor/types"; +import { clamp01 } from "@/utils/math"; export const TEXT_ANIMATION_DURATION_MS = 700; @@ -23,17 +24,13 @@ export const TEXT_ANIMATION_OPTIONS: Array<{ { value: "pulse", translationKey: "textAnimation.pulse" }, ]; -function clamp(value: number, min = 0, max = 1) { - return Math.min(max, Math.max(min, value)); -} - function easeOutCubic(value: number) { - const t = clamp(value); + const t = clamp01(value); return 1 - (1 - t) ** 3; } function easeOutBack(value: number) { - const t = clamp(value); + const t = clamp01(value); const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2; @@ -66,7 +63,7 @@ export function getTextAnimationState( } const elapsedMs = Math.max(0, currentTimeMs - annotation.startMs); - const progress = clamp(elapsedMs / TEXT_ANIMATION_DURATION_MS); + const progress = clamp01(elapsedMs / TEXT_ANIMATION_DURATION_MS); const eased = easeOutCubic(progress); switch (animation) { diff --git a/src/lib/blurEffects.ts b/src/lib/blurEffects.ts index 88bcfb0cb..99021281b 100644 --- a/src/lib/blurEffects.ts +++ b/src/lib/blurEffects.ts @@ -9,11 +9,7 @@ import { MIN_BLUR_BLOCK_SIZE, MIN_BLUR_INTENSITY, } from "@/components/video-editor/types"; - -function clamp(value: number, min: number, max: number) { - if (!Number.isFinite(value)) return min; - return Math.min(max, Math.max(min, value)); -} +import { clamp } from "@/utils/math"; export function normalizeBlurType(value: unknown): BlurType { void value; diff --git a/src/lib/cursor/cursorPathSmoothing.ts b/src/lib/cursor/cursorPathSmoothing.ts index e89b9575d..c550fa623 100644 --- a/src/lib/cursor/cursorPathSmoothing.ts +++ b/src/lib/cursor/cursorPathSmoothing.ts @@ -1,5 +1,6 @@ import { getCursorSpringConfig } from "@/components/video-editor/videoPlayback/motionSmoothing"; import type { CursorRecordingData, CursorRecordingSample } from "@/native/contracts"; +import { clamp } from "@/utils/math"; /** * Offline cursor-path smoothing for native recordings. @@ -31,10 +32,6 @@ interface SmoothedRun { ys: Float32Array; } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - function binarySearchAtOrBefore( times: Float32Array | number[], timeMs: number, diff --git a/src/lib/cursor/nativeCursor.ts b/src/lib/cursor/nativeCursor.ts index dcf55eea5..746492932 100644 --- a/src/lib/cursor/nativeCursor.ts +++ b/src/lib/cursor/nativeCursor.ts @@ -24,6 +24,7 @@ import type { NativeCursorAsset, NativeCursorType, } from "@/native/contracts"; +import { clamp } from "@/utils/math"; export interface ActiveNativeCursorFrame { asset: NativeCursorAsset; @@ -48,10 +49,6 @@ interface ProjectNativeCursorToStageOptions extends ProjectNativeCursorOptions { videoContainerPosition: { x: number; y: number }; } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - const NATIVE_CURSOR_CLICK_ANIMATION_MS = 260; const NATIVE_CURSOR_MOTION_BLUR_MAX_PX = 6; const nativeCursorAssetMapCache = new WeakMap< diff --git a/src/lib/cursor/pixiCursorRenderer.ts b/src/lib/cursor/pixiCursorRenderer.ts index d3bf6d8ab..1b4f567f9 100644 --- a/src/lib/cursor/pixiCursorRenderer.ts +++ b/src/lib/cursor/pixiCursorRenderer.ts @@ -7,6 +7,7 @@ import { resetSpringState, stepSpringValue, } from "@/components/video-editor/videoPlayback/motionSmoothing"; +import { clamp } from "@/utils/math"; import { UPLOADED_CURSOR_SAMPLE_SIZE, uploadedCursorAssets } from "./uploadedCursorAssets"; type CursorAssetKey = NonNullable; @@ -103,10 +104,6 @@ function loadImage(dataUrl: string) { }); } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - function getNormalizedAnchor( systemAsset: SystemCursorAsset | undefined, fallbackAnchor: { x: number; y: number }, diff --git a/src/lib/exporter/gradientParser.ts b/src/lib/exporter/gradientParser.ts index c643edcb4..90a4733d8 100644 --- a/src/lib/exporter/gradientParser.ts +++ b/src/lib/exporter/gradientParser.ts @@ -1,3 +1,5 @@ +import { clamp } from "@/utils/math"; + export interface ParsedGradientStop { color: string; offset: number; @@ -221,10 +223,6 @@ function normalizeStopOffsets( })); } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - function findLastDefinedIndex(values: Array) { for (let index = values.length - 1; index >= 0; index -= 1) { if (values[index] !== null) { diff --git a/src/lib/gradientBuilder.ts b/src/lib/gradientBuilder.ts index 69b44f416..b45da7975 100644 --- a/src/lib/gradientBuilder.ts +++ b/src/lib/gradientBuilder.ts @@ -1,4 +1,5 @@ import type { GradientEditorState } from "@/components/ui/gradient-editor"; +import { clamp } from "@/utils/math"; /* ---------- editor state -> CSS background ---------- The editor produces a clean 3-stop LINEAR gradient - the same shape @@ -35,7 +36,3 @@ function baseForBrightness(brightness: number): string { const l = Math.round((brightness / 100) * 18); return `hsl(0, 0%, ${clamp(l, 0, 22)}%)`; } - -function clamp(v: number, min: number, max: number): number { - return Math.min(max, Math.max(min, v)); -} diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 842f721df..602ecc80c 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -120,6 +120,43 @@ describe("buildSceneDescription.background", () => { }); }); + // The three below are why parseWallpaper delegates to parseCssGradient + // instead of splitting on commas: the flat split shredded rgba() stops and + // ignored keyword directions. The last one pins the failure mode — an + // unparseable gradient stays a gradient (the native side falls back to the + // layout background) rather than becoming an image path. + it("keeps rgba() stops whole", () => { + const doc = makeDoc({ + legacyEditor: { wallpaper: "linear-gradient(90deg, rgba(1,2,3,0.5), #fff)" }, + }); + expect(buildSceneDescription(doc).background).toEqual({ + kind: "gradient", + angleDeg: 90, + stops: ["rgba(1,2,3,0.5)", "#fff"], + }); + }); + + it('resolves "to bottom right" to 135deg', () => { + const doc = makeDoc({ + legacyEditor: { wallpaper: "linear-gradient(to bottom right, #a1b2c3, #d4e5f6)" }, + }); + expect(buildSceneDescription(doc).background).toEqual({ + kind: "gradient", + angleDeg: 135, + stops: ["#a1b2c3", "#d4e5f6"], + }); + }); + + it("stays a gradient when the parser rejects the string", () => { + // parseCssGradient anchors on a trailing ")" — a trailing space is enough. + const doc = makeDoc({ legacyEditor: { wallpaper: "linear-gradient(135deg, red, blue) " } }); + expect(buildSceneDescription(doc).background).toEqual({ + kind: "gradient", + angleDeg: 180, + stops: [], + }); + }); + it('"/wallpapers/x.jpg" → image', () => { const doc = makeDoc({ legacyEditor: { wallpaper: "/wallpapers/x.jpg" } }); expect(buildSceneDescription(doc).background).toEqual({ diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 66b0cad35..93e36a283 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -37,6 +37,7 @@ import { resolveWebcamReactiveZoom, webcamSizeToFraction, } from "@/lib/compositeLayout"; +import { parseCssGradient, resolveLinearGradientAngle } from "@/lib/exporter/gradientParser"; import type { CompositorClipInput } from "./contracts"; /** Background behind the screen. Parsed from `settings.wallpaper`. */ @@ -366,35 +367,24 @@ export interface SceneDescription { output: { width: number; height: number; fps: number | null }; } -/** Strip a trailing position percentage (or any whitespace tail) from a gradient stop token, - * returning the colour piece. The presets never nest parens so a whitespace split is fine. */ -function firstTokenOf(stop: string): string { - const trimmed = stop.trim(); - if (trimmed.length === 0) return trimmed; - const spaceIdx = trimmed.indexOf(" "); - return spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); -} - /** Parse the settings wallpaper string into the discriminated SceneBackground union. */ function parseWallpaper(wallpaper: string) { if (wallpaper.startsWith("#")) { return { kind: "color", color: wallpaper } as const; } if (wallpaper.startsWith("linear-gradient(")) { - // Strip the outer wrapper once — assume well-formed `linear-gradient(...)`. - const openIdx = wallpaper.indexOf("("); - const closeIdx = wallpaper.lastIndexOf(")"); - const inner = wallpaper.slice(openIdx + 1, closeIdx); - // Presets never contain nested parens; a flat comma split is sufficient. - const tokens = inner - .split(",") - .map((t) => t.trim()) - .filter((t) => t.length > 0); - const angleMatch = /^-?\d+(\.\d+)?deg$/.exec(tokens[0] ?? ""); - const angleDeg = angleMatch ? parseFloat(tokens[0]) : 180; - const stopsRaw = angleMatch ? tokens.slice(1) : tokens; - const stops = stopsRaw.map(firstTokenOf).filter((s) => s.length > 0); - return { kind: "gradient", angleDeg, stops } as const; + // parseCssGradient handles nested parens (rgba()/hsl() stops) and the + // "to bottom right" keyword directions that a flat comma split drops. + // It's anchored on a trailing ")", so it rejects strings this branch + // accepts (a trailing space is enough) — stay a gradient when it does, + // rather than falling through and handing the native side a CSS string + // as an image path. + const parsed = parseCssGradient(wallpaper); + return { + kind: "gradient", + angleDeg: resolveLinearGradientAngle(parsed?.descriptor ?? null), + stops: parsed?.stops.map((stop) => stop.color) ?? [], + } as const; } return { kind: "image", path: wallpaper } as const; } diff --git a/src/utils/formatBytes.ts b/src/utils/formatBytes.ts new file mode 100644 index 000000000..b7a15330d --- /dev/null +++ b/src/utils/formatBytes.ts @@ -0,0 +1,14 @@ +// Binary file sizes (1 KB = 1024 B), as the media panes have always shown them. +// +// Deliberately not Intl.NumberFormat's `unit: "byte"` + `notation: "compact"`: +// that formats in DECIMAL units (1 kB = 1000 B) and localises the unit name, so +// a French locale renders 1 GiB as "1,1 Mdo". Wrong magnitude, unreadable label. + +/** `—` for missing/invalid input, else e.g. `512 B`, `840 KB`, `12 MB`, `1.4 GB`. */ +export function formatBytes(bytes: number | undefined): string { + if (!bytes || !Number.isFinite(bytes)) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; +} diff --git a/src/utils/math.test.ts b/src/utils/math.test.ts new file mode 100644 index 000000000..6c1429ce2 --- /dev/null +++ b/src/utils/math.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { clampFocusToDepth } from "@/components/video-editor/types"; +import { getNormalizedBlurIntensity, getNormalizedMosaicBlockSize } from "@/lib/blurEffects"; +import { clamp, clamp01 } from "./math"; + +// Two of the 16 private clamps this file replaced guarded non-finite input. +// Folding them in dropped those guards, so this pins the guard down at the +// shared definition and at the two callers that relied on it. + +describe("clamp", () => { + it("constrains to [min, max]", () => { + expect(clamp(5, 0, 1)).toBe(1); + expect(clamp(-5, 0, 1)).toBe(0); + expect(clamp(0.4, 0, 1)).toBe(0.4); + expect(clamp01(2)).toBe(1); + }); + + it("floors non-finite input to min instead of propagating NaN", () => { + expect(clamp(Number.NaN, 2, 8)).toBe(2); + expect(clamp(Number.POSITIVE_INFINITY, 2, 8)).toBe(2); + expect(clamp(Number.NEGATIVE_INFINITY, 2, 8)).toBe(2); + expect(clamp01(Number.NaN)).toBe(0); + }); +}); + +describe("callers that depend on the guard", () => { + it("blur normalizers never hand a NaN to the canvas", () => { + expect(getNormalizedBlurIntensity({ intensity: Number.NaN } as never)).not.toBeNaN(); + expect(getNormalizedMosaicBlockSize({ blockSize: Number.NaN } as never)).not.toBeNaN(); + }); + + it("an unknown zoom focus recentres rather than jumping to the corner", () => { + expect(clampFocusToDepth({ cx: Number.NaN, cy: Number.NaN }, 2)).toEqual({ cx: 0.5, cy: 0.5 }); + expect(clampFocusToDepth({ cx: 2, cy: -1 }, 2)).toEqual({ cx: 1, cy: 0 }); + }); +}); diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 000000000..e8f4a1319 --- /dev/null +++ b/src/utils/math.ts @@ -0,0 +1,19 @@ +// Numeric helpers shared by the renderer, the electron main process and the +// native glue. `clamp` had 16 near-identical private copies before this file +// existed — two of them (blurEffects, video-editor/types) guarded non-finite +// input, so the shared one has to as well or those callers regress. + +/** + * Constrains `value` to [min, max]. NaN and ±Infinity fall to `min`: callers + * clamp persisted document values and drag deltas, and a NaN flowing on into a + * transform or a canvas op is always worse than the floor. + */ +export function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, value)); +} + +/** `clamp(value, 0, 1)` — the normalized-fraction case, common enough to name. */ +export function clamp01(value: number): number { + return clamp(value, 0, 1); +}