Create standalone browser port of LTX Athena with authenticated native API transport

This commit is contained in:
Mikei386
2026-09-29 17:59:23 +02:00
commit 1333b0c36b
202 changed files with 63054 additions and 0 deletions
+613
View File
@@ -0,0 +1,613 @@
import React from 'react'
import {
MousePointer2, ChevronRight, Scissors,
ArrowLeftRight, GitMerge, MoveHorizontal, Gauge,
} from 'lucide-react'
import { formatKeyCombo, type ActionId, type KeyboardLayout } from '../../lib/keyboard-shortcuts'
export type { KeyboardLayout } from '../../lib/keyboard-shortcuts'
import type { TimelineClip, TransitionType, Track, ClipEffect, EffectMask } from '../../types/project-model'
import { DEFAULT_COLOR_CORRECTION } from '../../types/project-model'
// ── Tool types & definitions ────────────────────────────────────────
export type ToolType = 'select' | 'trackForward' | 'blade' | 'slip' | 'slide' | 'ripple' | 'roll'
export type ToolDef = { id: ToolType; icon: any; label: string; actionId: ActionId }
export const PRIMARY_TOOLS: ToolDef[] = [
{ id: 'select', icon: MousePointer2, label: 'Selection Tool', actionId: 'tool.select' },
{ id: 'trackForward', icon: ChevronRight, label: 'Track Select Forward (Shift: single track)', actionId: 'tool.trackForward' },
{ id: 'blade', icon: Scissors, label: 'Blade Tool', actionId: 'tool.blade' },
]
export const TRIM_TOOLS: ToolDef[] = [
{ id: 'ripple', icon: ArrowLeftRight, label: 'Ripple Trim', actionId: 'tool.ripple' },
{ id: 'roll', icon: GitMerge, label: 'Roll Trim (A/B)', actionId: 'tool.roll' },
{ id: 'slip', icon: MoveHorizontal, label: 'Slip Tool', actionId: 'tool.slip' },
{ id: 'slide', icon: Gauge, label: 'Slide Tool', actionId: 'tool.slide' },
]
// ── Color Labels (Premiere-style) ────────────────────────────────────
export interface ColorLabelDef {
id: string
label: string
color: string // Tailwind-friendly hex for rendering
bg: string // Background class for timeline clips
border: string // Border class for timeline clips
dot: string // Dot color class for menus
}
export const COLOR_LABELS: ColorLabelDef[] = [
{ id: 'violet', label: 'Violet', color: '#8b5cf6', bg: 'bg-violet-700/50', border: 'border-violet-500', dot: 'bg-violet-500' },
{ id: 'blue', label: 'Blue', color: '#3b82f6', bg: 'bg-blue-700/50', border: 'border-blue-500', dot: 'bg-blue-500' },
{ id: 'cyan', label: 'Cyan', color: '#06b6d4', bg: 'bg-cyan-700/50', border: 'border-cyan-500', dot: 'bg-cyan-500' },
{ id: 'teal', label: 'Teal', color: '#14b8a6', bg: 'bg-teal-700/50', border: 'border-teal-500', dot: 'bg-teal-500' },
{ id: 'green', label: 'Green', color: '#22c55e', bg: 'bg-green-700/50', border: 'border-green-500', dot: 'bg-green-500' },
{ id: 'yellow', label: 'Yellow', color: '#eab308', bg: 'bg-yellow-700/50', border: 'border-yellow-500', dot: 'bg-yellow-500' },
{ id: 'orange', label: 'Orange', color: '#f97316', bg: 'bg-orange-700/50', border: 'border-orange-500', dot: 'bg-orange-500' },
{ id: 'red', label: 'Red', color: '#ef4444', bg: 'bg-red-700/50', border: 'border-red-500', dot: 'bg-red-500' },
{ id: 'rose', label: 'Rose', color: '#f43f5e', bg: 'bg-rose-700/50', border: 'border-rose-500', dot: 'bg-rose-500' },
{ id: 'pink', label: 'Pink', color: '#ec4899', bg: 'bg-pink-700/50', border: 'border-pink-500', dot: 'bg-pink-500' },
]
export function getColorLabel(id: string | undefined): ColorLabelDef | undefined {
if (!id) return undefined
return COLOR_LABELS.find(c => c.id === id)
}
// ── Constants ────────────────────────────────────────────────────────
/** Debounce delay for auto-saving timeline changes to context (ms) */
export const AUTOSAVE_DELAY = 500
/** Tolerance in seconds for detecting adjacent clips (cut points) */
export const CUT_POINT_TOLERANCE = 0.05
/** Default cross-dissolve duration in seconds */
export const DEFAULT_DISSOLVE_DURATION = 0.5
// ── Resizable layout constants ───────────────────────────────────────
export const LAYOUT_STORAGE_KEY = 'ltx-video-editor-layout'
export interface EditorLayout {
leftPanelWidth: number // px
rightPanelWidth: number // px
timelineHeight: number // px
assetsHeight: number // px – height of assets section in left panel (timelines gets the rest)
}
export const DEFAULT_LAYOUT: EditorLayout = {
leftPanelWidth: 288, // w-72
rightPanelWidth: 256, // w-64
timelineHeight: 224, // h-56
assetsHeight: 0, // 0 = auto (use flex proportions)
}
export const LAYOUT_LIMITS = {
leftPanelWidth: { min: 180, max: 480 },
rightPanelWidth: { min: 200, max: 480 },
timelineHeight: { min: 120, max: 600 },
assetsHeight: { min: 120, max: 800 },
}
// ── Pure helper functions ────────────────────────────────────────────
/** Get the display shortcut string for an action from the active layout */
export function getShortcutLabel(layout: KeyboardLayout, actionId: ActionId): string {
const combos = layout[actionId]
if (!combos || combos.length === 0) return ''
return formatKeyCombo(combos[0])
}
/** Returns `"label (shortcut)"` when a shortcut exists, or just `"label"` when it doesn't. */
export function tooltipLabel(label: string, shortcut: string): string {
return shortcut ? `${label} (${shortcut})` : label
}
/**
* Overwrite helper: given a moved/placed clip, trim or split any clips
* on the same track that it overlaps. Returns the updated clips array.
* `movedIds` = IDs of the clip(s) being moved (they should not be trimmed).
*/
export function resolveOverlaps(
allClips: TimelineClip[],
movedIds: Set<string>,
): TimelineClip[] {
let result = [...allClips]
for (const movedId of movedIds) {
const moved = result.find(c => c.id === movedId)
if (!moved) continue
const movedStart = moved.startTime
const movedEnd = moved.startTime + moved.duration
const next: TimelineClip[] = []
for (const c of result) {
if (movedIds.has(c.id)) { next.push(c); continue }
if (c.trackIndex !== moved.trackIndex) { next.push(c); continue }
const cStart = c.startTime
const cEnd = c.startTime + c.duration
if (cEnd <= movedStart || cStart >= movedEnd) { next.push(c); continue }
if (cStart >= movedStart && cEnd <= movedEnd) continue
if (cStart < movedStart && cEnd > movedStart && cEnd <= movedEnd) {
const newDuration = movedStart - cStart
next.push({ ...c, duration: newDuration })
continue
}
if (cStart >= movedStart && cStart < movedEnd && cEnd > movedEnd) {
const trimAmount = movedEnd - cStart
const newTrimStart = c.trimStart + trimAmount * c.speed
next.push({
...c,
startTime: movedEnd,
duration: c.duration - trimAmount,
trimStart: newTrimStart,
})
continue
}
if (cStart < movedStart && cEnd > movedEnd) {
const leftDuration = movedStart - cStart
next.push({ ...c, duration: leftDuration })
const rightTrimAmount = (movedEnd - cStart) * c.speed
const rightDuration = cEnd - movedEnd
next.push({
...c,
id: `${c.id}-split-${Date.now()}`,
startTime: movedEnd,
duration: rightDuration,
trimStart: c.trimStart + rightTrimAmount,
})
continue
}
next.push(c)
}
result = next
}
return result
}
export function loadLayout(): EditorLayout {
try {
const stored = localStorage.getItem(LAYOUT_STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
return {
leftPanelWidth: clampVal(parsed.leftPanelWidth ?? DEFAULT_LAYOUT.leftPanelWidth, LAYOUT_LIMITS.leftPanelWidth),
rightPanelWidth: clampVal(parsed.rightPanelWidth ?? DEFAULT_LAYOUT.rightPanelWidth, LAYOUT_LIMITS.rightPanelWidth),
timelineHeight: clampVal(parsed.timelineHeight ?? DEFAULT_LAYOUT.timelineHeight, LAYOUT_LIMITS.timelineHeight),
assetsHeight: parsed.assetsHeight ? clampVal(parsed.assetsHeight, LAYOUT_LIMITS.assetsHeight) : 0,
}
}
} catch { /* ignore */ }
return { ...DEFAULT_LAYOUT }
}
export function saveLayout(layout: EditorLayout) {
try { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout)) } catch { /* ignore */ }
}
// ── Layout presets ──────────────────────────────────────────────────
export const LAYOUT_PRESETS_KEY = 'ltx-video-editor-layout-presets'
export interface LayoutPreset {
id: string
name: string
layout: EditorLayout
}
export function loadLayoutPresets(): LayoutPreset[] {
try {
const stored = localStorage.getItem(LAYOUT_PRESETS_KEY)
if (stored) return JSON.parse(stored) as LayoutPreset[]
} catch { /* ignore */ }
return []
}
export function saveLayoutPresets(presets: LayoutPreset[]) {
try { localStorage.setItem(LAYOUT_PRESETS_KEY, JSON.stringify(presets)) } catch { /* ignore */ }
}
export function clampVal(val: number, limits: { min: number; max: number }): number {
return Math.max(limits.min, Math.min(limits.max, val))
}
/** Migrate old clips that don't have new effect fields */
export function migrateClip(clip: TimelineClip): TimelineClip {
return {
...clip,
flipH: clip.flipH ?? false,
flipV: clip.flipV ?? false,
transitionIn: clip.transitionIn ?? { type: 'none', duration: 0.5 },
transitionOut: clip.transitionOut ?? { type: 'none', duration: 0.5 },
colorCorrection: clip.colorCorrection ?? { ...DEFAULT_COLOR_CORRECTION },
opacity: clip.opacity ?? 100,
isRegenerating: false,
}
}
/**
* Migrate tracks from old format (no kind) to new NLE layout.
* Heuristic: if a track has no `kind`, infer from its name or position.
*/
export function migrateTracks(tracks: Track[]): Track[] {
return tracks.map(t => {
if (t.kind) return t
if (t.type === 'subtitle') return t
if (/^A\d/i.test(t.name)) return { ...t, kind: 'audio' as const }
if (/^V\d/i.test(t.name)) return { ...t, kind: 'video' as const }
return { ...t, kind: 'video' as const }
})
}
/** Build CSS filter + transform strings from clip effects */
export function getClipEffectStyles(clip: TimelineClip, timeInClip?: number): React.CSSProperties {
const cc = clip.colorCorrection || DEFAULT_COLOR_CORRECTION
const filters: string[] = []
if (cc.brightness !== 0) filters.push(`brightness(${1 + cc.brightness / 100})`)
if (cc.contrast !== 0) filters.push(`contrast(${1 + cc.contrast / 100})`)
if (cc.saturation !== 0) filters.push(`saturate(${1 + cc.saturation / 100})`)
if (cc.exposure !== 0) filters.push(`brightness(${1 + cc.exposure / 200})`)
if (cc.temperature !== 0) {
const t = cc.temperature
if (t > 0) {
filters.push(`sepia(${t / 200})`)
filters.push(`hue-rotate(-${t * 0.1}deg)`)
} else {
filters.push(`hue-rotate(${Math.abs(t) * 0.4}deg)`)
}
}
if (cc.tint !== 0) {
filters.push(`hue-rotate(${cc.tint * 1.2}deg)`)
}
if (cc.highlights !== 0) filters.push(`brightness(${1 + cc.highlights / 300})`)
if (cc.shadows !== 0) filters.push(`contrast(${1 + cc.shadows / 300})`)
/* EFFECTS HIDDEN - skip effects iteration because effects are not applied during export
if (clip.effects) {
for (const fx of clip.effects) {
if (!fx.enabled) continue
if (fx.mask?.enabled) continue // masked effects rendered as separate overlay
const p = fx.params
switch (fx.type) {
case 'blur':
if (p.amount > 0) filters.push(`blur(${p.amount}px)`)
break
case 'sharpen': {
const s = p.amount / 100
if (s > 0) filters.push(`contrast(${1 + s * 0.3})`)
break
}
case 'glow': {
const intensity = p.amount / 100
const radius = p.radius || 10
if (intensity > 0) {
filters.push(`brightness(${1 + intensity * 0.3})`)
filters.push(`drop-shadow(0 0 ${radius * intensity}px rgba(255,255,255,${intensity * 0.4}))`)
}
break
}
case 'vignette':
case 'grain':
break
case 'lut-cinematic': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`contrast(${1 + 0.1 * t})`)
filters.push(`saturate(${1 - 0.15 * t})`)
filters.push(`sepia(${0.15 * t})`)
filters.push(`brightness(${1 - 0.05 * t})`)
}
break
}
case 'lut-vintage': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`sepia(${0.4 * t})`)
filters.push(`contrast(${1 - 0.1 * t})`)
filters.push(`saturate(${1 - 0.2 * t})`)
filters.push(`brightness(${1 + 0.05 * t})`)
}
break
}
case 'lut-bw': {
const t = (p.intensity ?? 100) / 100
if (t > 0) filters.push(`grayscale(${t})`)
break
}
case 'lut-cool': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`hue-rotate(${10 * t}deg)`)
filters.push(`saturate(${1 - 0.1 * t})`)
filters.push(`brightness(${1 + 0.02 * t})`)
}
break
}
case 'lut-warm': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`sepia(${0.2 * t})`)
filters.push(`hue-rotate(${-5 * t}deg)`)
filters.push(`saturate(${1 + 0.1 * t})`)
}
break
}
case 'lut-muted': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`saturate(${1 - 0.5 * t})`)
filters.push(`contrast(${1 - 0.05 * t})`)
}
break
}
case 'lut-vivid': {
const t = (p.intensity ?? 100) / 100
if (t > 0) {
filters.push(`saturate(${1 + 0.6 * t})`)
filters.push(`contrast(${1 + 0.1 * t})`)
}
break
}
}
}
}
EFFECTS HIDDEN */
const transforms: string[] = []
if (clip.flipH) transforms.push('scaleX(-1)')
if (clip.flipV) transforms.push('scaleY(-1)')
let opacity = (clip.opacity ?? 100) / 100
if (timeInClip !== undefined) {
const tIn = clip.transitionIn
const tOut = clip.transitionOut
if (tIn && tIn.duration > 0 && timeInClip < tIn.duration) {
if (tIn.type === 'fade-to-black' || tIn.type === 'fade-to-white') {
opacity = Math.min(opacity, timeInClip / tIn.duration)
}
}
if (tOut && tOut.duration > 0) {
const timeFromEnd = clip.duration - timeInClip
if (timeFromEnd < tOut.duration) {
if (tOut.type === 'fade-to-black' || tOut.type === 'fade-to-white') {
opacity = Math.min(opacity, timeFromEnd / tOut.duration)
}
}
}
}
let clipPath: string | undefined
if (timeInClip !== undefined) {
const tIn = clip.transitionIn
const tOut = clip.transitionOut
if (tIn && tIn.type.startsWith('wipe-') && tIn.duration > 0 && timeInClip < tIn.duration) {
const progress = timeInClip / tIn.duration
clipPath = getWipeClipPath(tIn.type as TransitionType, progress, true)
}
if (tOut && tOut.type.startsWith('wipe-') && tOut.duration > 0) {
const timeFromEnd = clip.duration - timeInClip
if (timeFromEnd < tOut.duration) {
const progress = timeFromEnd / tOut.duration
clipPath = getWipeClipPath(tOut.type as TransitionType, progress, false)
}
}
}
const style: React.CSSProperties = {}
if (filters.length > 0) style.filter = filters.join(' ')
if (transforms.length > 0) style.transform = transforms.join(' ')
if (opacity < 1) style.opacity = opacity
if (clipPath) style.clipPath = clipPath
return style
}
// ── Effect Mask Utilities ──────────────────────────────────────────────
/** Get the CSS filter string for a single effect */
export function getSingleEffectFilter(fx: ClipEffect): string {
if (!fx.enabled) return ''
const p = fx.params
const filters: string[] = []
switch (fx.type) {
case 'blur':
if (p.amount > 0) filters.push(`blur(${p.amount}px)`)
break
case 'sharpen': {
const s = p.amount / 100
if (s > 0) filters.push(`contrast(${1 + s * 0.3})`)
break
}
case 'glow': {
const intensity = p.amount / 100
const radius = p.radius || 10
if (intensity > 0) {
filters.push(`brightness(${1 + intensity * 0.3})`)
filters.push(`drop-shadow(0 0 ${radius * intensity}px rgba(255,255,255,${intensity * 0.4}))`)
}
break
}
case 'lut-cinematic': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`contrast(${1 + 0.1 * t})`); filters.push(`saturate(${1 - 0.15 * t})`); filters.push(`sepia(${0.15 * t})`); filters.push(`brightness(${1 - 0.05 * t})`) }
break
}
case 'lut-vintage': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`sepia(${0.4 * t})`); filters.push(`contrast(${1 - 0.1 * t})`); filters.push(`saturate(${1 - 0.2 * t})`); filters.push(`brightness(${1 + 0.05 * t})`) }
break
}
case 'lut-bw': {
const t = (p.intensity ?? 100) / 100
if (t > 0) filters.push(`grayscale(${t})`)
break
}
case 'lut-cool': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`hue-rotate(${10 * t}deg)`); filters.push(`saturate(${1 - 0.1 * t})`); filters.push(`brightness(${1 + 0.02 * t})`) }
break
}
case 'lut-warm': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`sepia(${0.2 * t})`); filters.push(`hue-rotate(${-5 * t}deg)`); filters.push(`saturate(${1 + 0.1 * t})`) }
break
}
case 'lut-muted': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`saturate(${1 - 0.5 * t})`); filters.push(`contrast(${1 - 0.05 * t})`) }
break
}
case 'lut-vivid': {
const t = (p.intensity ?? 100) / 100
if (t > 0) { filters.push(`saturate(${1 + 0.6 * t})`); filters.push(`contrast(${1 + 0.1 * t})`) }
break
}
default:
break
}
return filters.join(' ')
}
/** Build CSS mask-image string from an EffectMask */
export function buildMaskImageValue(mask: EffectMask): string {
const { shape, x, y, width, height, feather, invert, rotation } = mask
// Ellipse without rotation: use CSS radial-gradient (simpler, more reliable)
if (shape === 'ellipse' && rotation === 0) {
const innerStop = Math.max(0, 100 - feather)
if (invert) {
return `radial-gradient(ellipse ${width}% ${height}% at ${x}% ${y}%, transparent ${innerStop}%, black 100%)`
}
return `radial-gradient(ellipse ${width}% ${height}% at ${x}% ${y}%, black ${innerStop}%, transparent 100%)`
}
// Rectangle or rotated ellipse: use inline SVG for precise shape + feather + rotation
const svgW = 200
const svgH = 200
const cx = (x / 100) * svgW
const cy = (y / 100) * svgH
const w = (width / 100) * svgW
const h = (height / 100) * svgH
const featherDev = (feather / 100) * Math.max(w, h) * 0.5
const bg = invert ? 'white' : 'black'
const fg = invert ? 'black' : 'white'
const rotAttr = rotation !== 0 ? ` transform="rotate(${rotation} ${cx} ${cy})"` : ''
const filterDef = featherDev > 0.5 ? `<filter id="mf"><feGaussianBlur stdDeviation="${featherDev.toFixed(1)}"/></filter>` : ''
const filterAttr = featherDev > 0.5 ? ' filter="url(#mf)"' : ''
let shapeEl: string
if (shape === 'ellipse') {
shapeEl = `<ellipse cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" rx="${(w / 2).toFixed(1)}" ry="${(h / 2).toFixed(1)}" fill="${fg}"${rotAttr}/>`
} else {
shapeEl = `<rect x="${(cx - w / 2).toFixed(1)}" y="${(cy - h / 2).toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="${fg}"${rotAttr}/>`
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}"><defs>${filterDef}</defs><rect width="${svgW}" height="${svgH}" fill="${bg}"/><g${filterAttr}>${shapeEl}</g></svg>`
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
}
export interface MaskedEffectOverlay {
effectId: string
filterCSS: string
maskImageValue: string
mask: EffectMask
}
/** Get overlay data for each masked effect on a clip */
export function getMaskedEffectOverlays(clip: TimelineClip): MaskedEffectOverlay[] {
if (!clip.effects) return []
const overlays: MaskedEffectOverlay[] = []
for (const fx of clip.effects) {
if (!fx.enabled || !fx.mask?.enabled) continue
const filterStr = getSingleEffectFilter(fx)
if (!filterStr) continue
overlays.push({
effectId: fx.id,
filterCSS: filterStr,
maskImageValue: buildMaskImageValue(fx.mask),
mask: fx.mask,
})
}
return overlays
}
export function getWipeClipPath(type: TransitionType, progress: number, isIn: boolean): string {
const p = Math.max(0, Math.min(1, progress)) * 100
switch (type) {
case 'wipe-left':
return isIn ? `inset(0 ${100 - p}% 0 0)` : `inset(0 0 0 ${100 - p}%)`
case 'wipe-right':
return isIn ? `inset(0 0 0 ${100 - p}%)` : `inset(0 ${100 - p}% 0 0)`
case 'wipe-up':
return isIn ? `inset(0 0 ${100 - p}% 0)` : `inset(${100 - p}% 0 0 0)`
case 'wipe-down':
return isIn ? `inset(${100 - p}% 0 0 0)` : `inset(0 0 ${100 - p}% 0)`
default:
return ''
}
}
/** Get the background color for transition overlay */
export function getTransitionBgColor(type: TransitionType): string | null {
if (type === 'fade-to-black') return 'black'
if (type === 'fade-to-white') return 'white'
return null
}
/** Format seconds into HH:MM:SS:FF timecode (24fps) */
export function formatTime(seconds: number): string {
const hrs = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
const frames = Math.floor((seconds % 1) * 24)
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}:${frames.toString().padStart(2, '0')}`
}
/** Parse HH:MM:SS:FF timecode string back to seconds (24fps) */
export function parseTime(tc: string): number | null {
const cleaned = tc.replace(/[^0-9:;]/g, '').replace(/;/g, ':')
const parts = cleaned.split(':')
if (parts.length === 4) {
const hrs = parseInt(parts[0], 10)
const mins = parseInt(parts[1], 10)
const secs = parseInt(parts[2], 10)
const frames = parseInt(parts[3], 10)
if (isNaN(hrs) || isNaN(mins) || isNaN(secs) || isNaN(frames)) return null
return hrs * 3600 + mins * 60 + secs + frames / 24
}
if (parts.length === 3) {
const mins = parseInt(parts[0], 10)
const secs = parseInt(parts[1], 10)
const frames = parseInt(parts[2], 10)
if (isNaN(mins) || isNaN(secs) || isNaN(frames)) return null
return mins * 60 + secs + frames / 24
}
if (parts.length === 2) {
const mins = parseInt(parts[0], 10)
const secs = parseInt(parts[1], 10)
if (isNaN(mins) || isNaN(secs)) return null
return mins * 60 + secs
}
if (parts.length === 1) {
const secs = parseInt(parts[0], 10)
if (isNaN(secs)) return null
return secs
}
return null
}