Files
LTX-DeskWEB/frontend/views/editor/video-editor-utils.ts
T

614 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}