From a9133158d61ea1f4dbbf04fc63f6ba6481926ca3 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Fri, 8 May 2026 11:18:23 +0200 Subject: [PATCH] add: starting refactoring of the timeline phase2 of the plan --- src/components/video-editor/index.ts | 4 + .../timeline/.codex/plan_timeline_refacto | 116 ++++++ .../.codex/plan_timeline_refacto_phases.md | 306 +++++++++++++++ .../video-editor/timeline/TimelineEditor.tsx | 369 +++--------------- .../video-editor/timeline/TimelineWrapper.tsx | 240 ++---------- .../video-editor/timeline/core/constants.ts | 9 + .../video-editor/timeline/core/rows.test.ts | 23 ++ .../video-editor/timeline/core/rows.ts | 40 ++ .../video-editor/timeline/core/spans.ts | 21 + .../video-editor/timeline/core/time.test.ts | 36 ++ .../video-editor/timeline/core/time.ts | 116 ++++++ .../video-editor/timeline/dnd/engine.test.ts | 71 ++++ .../video-editor/timeline/dnd/engine.ts | 177 +++++++++ .../timeline/model/timelineModel.test.ts | 85 ++++ .../timeline/model/timelineModel.ts | 148 +++++++ 15 files changed, 1247 insertions(+), 514 deletions(-) create mode 100644 src/components/video-editor/timeline/.codex/plan_timeline_refacto create mode 100644 src/components/video-editor/timeline/.codex/plan_timeline_refacto_phases.md create mode 100644 src/components/video-editor/timeline/core/constants.ts create mode 100644 src/components/video-editor/timeline/core/rows.test.ts create mode 100644 src/components/video-editor/timeline/core/rows.ts create mode 100644 src/components/video-editor/timeline/core/spans.ts create mode 100644 src/components/video-editor/timeline/core/time.test.ts create mode 100644 src/components/video-editor/timeline/core/time.ts create mode 100644 src/components/video-editor/timeline/dnd/engine.test.ts create mode 100644 src/components/video-editor/timeline/dnd/engine.ts create mode 100644 src/components/video-editor/timeline/model/timelineModel.test.ts create mode 100644 src/components/video-editor/timeline/model/timelineModel.ts diff --git a/src/components/video-editor/index.ts b/src/components/video-editor/index.ts index 4fcca759..ed621d59 100644 --- a/src/components/video-editor/index.ts +++ b/src/components/video-editor/index.ts @@ -1,5 +1,9 @@ export { default as PlaybackControls } from "./PlaybackControls"; export { SettingsPanel } from "./SettingsPanel"; export { default as TimelineEditor } from "./timeline/TimelineEditor"; +export type { + TimelineEditorHandle, + TimelineEditorProps, +} from "./timeline/TimelineEditor"; export { default as VideoEditor } from "./VideoEditor"; export { default as VideoPlayback } from "./VideoPlayback"; diff --git a/src/components/video-editor/timeline/.codex/plan_timeline_refacto b/src/components/video-editor/timeline/.codex/plan_timeline_refacto new file mode 100644 index 00000000..adda622e --- /dev/null +++ b/src/components/video-editor/timeline/.codex/plan_timeline_refacto @@ -0,0 +1,116 @@ +# Refonte Totale de timeline avec Parité Fonctionnelle (Big Bang) + + ## Résumé + + Objectif: remplacer l’implémentation actuelle de TimelineEditor.tsx (2372 lignes) par une + architecture modulaire, lisible et testable, sans perte de fonctionnalités. + Stratégie retenue: big bang sans couche de compatibilité, avec conservation stricte du + contrat externe (TimelineEditor props + TimelineEditorHandle). + + ## Changements d’implémentation + + 1. Nouvelle architecture par dossiers (feature-first) + + - timeline/core/: constantes, types internes, utilitaires temporels (scale, range, format, + rowId, overlap). + - timeline/model/: mapping régions -> items timeline, labels, calculs de spans, sélection. + - timeline/hooks/: hooks métier découplés: + - useTimelineRange + - useTimelineSelection + - useTimelineKeyboardShortcuts + - useTimelineZoomActions + - useTimelineAudioActions + - useTimelineAnnotationsActions + - useTimelineNormalization + - timeline/dnd/: adaptation dnd-timeline (clamp drag/resize, voisinage, row resolve), + extraction de la logique actuelle de TimelineWrapper. + - timeline/components/: + - editor/TimelineEditorShell (orchestration haut niveau) + - toolbar/TimelineToolbar + - viewport/TimelineViewport + - axis/TimelineAxis + - playhead/PlaybackCursor + - rows/{ClipRow,ZoomRow,AnnotationRows,AudioRows} + - overlays/{ClipMarkerOverlay,GhostPlayhead,GhostZoom} + - keyframes/KeyframeMarkers + - timeline/services/: + - audioFilePlacementService (picker + metadata + track fit) + - zoomSuggestionService (wrapping buildInteractionZoomSuggestions) + + 2. Découpage de TimelineEditor.tsx + + - Garder uniquement: + - API publique (TimelineEditorProps, TimelineEditorHandle) + - composition des hooks/composants + - useImperativeHandle + - Déplacer tout le reste: + - handlers clavier/souris + - logique add/delete/select (zoom/clip/annotation/audio/keyframes) + - logique de normalisation spans + - logique pan/zoom range + - logique ghost/hover rows + - logique auto-suggest zooms + - logique audio placement multi-track + + 3. Contrats publics et interfaces + + - Inchangés: + - export default TimelineEditor + - export TimelineEditorHandle + - signature complète des props actuelles + - comportement des callbacks (onZoomAdded, onAudioAdded, onItemSpanChange, etc.) + - Ajouts internes uniquement: + - types internes (TimelineItemViewModel, RowDescriptor, SelectionState, + TimelineActionContext) + - interfaces de services purs pour tests unitaires + + 4. Nettoyage qualité + + - Éliminer duplications (ex: setRange(createInitialRange(totalMs)) présent 2 fois). + - Centraliser constantes magiques (1000, 150, labels clavier, etc.). + - Isoler tous les side-effects (window.addEventListener, electronAPI, toast) dans hooks + dédiés. + - Séparer strictement: + - logique pure (testable sans DOM) + - logique React/UI + - I/O plateforme (Electron/audio) + + ## Plan de tests (parité de features) + + 1. Tests unitaires (logique pure) + + - placement zoom: disponibilité, clip boundaries, overlap rules. + - placement audio: choix de piste, gap computation, fallback track. + - clamping drag/resize + minDuration + voisins. + - normalisation régions au changement de durée totale. + - formatters temps/axis scale/row id resolvers. + + 2. Tests d’intégration React + + - timeline click -> seek. + - drag playhead + snap keyframe. + - drag/resize item par row (zoom/clip/audio). + - ghost playhead + ghost zoom apparition/disparition. + - keyboard shortcuts (A, Z, C, delete, select all timeline blocks). + - cycle annotations Tab/Shift+Tab. + - auto-suggest zooms (cas succès + cas toast d’erreur). + + 3. Scénarios e2e ciblés (smoke) + + - session complète: add zoom, split clip, add annotation, add audio, move/resize/delete, + crop action, aspect ratio custom. + - validation “avant/après refacto” sur jeu de données identique (snapshot d’état timeline et + callbacks attendus). + + 4. Critères d’acceptation + - API publique strictement identique. + - composants/fichiers courts avec responsabilité unique. + - couverture forte sur logique timeline + interactions critiques. + + ## Hypothèses et choix verrouillés + + - Refonte en big bang (pas de couche compatibilité intermédiaire). + - Approche hooks modulaires (pas de reducer global/state machine). + - Niveau de validation: forte couverture + scénarios UI. + - Le périmètre visuel reste aligné au design actuel; priorité à la séparation propre et à la + maintenabilité. \ No newline at end of file diff --git a/src/components/video-editor/timeline/.codex/plan_timeline_refacto_phases.md b/src/components/video-editor/timeline/.codex/plan_timeline_refacto_phases.md new file mode 100644 index 00000000..bbb38479 --- /dev/null +++ b/src/components/video-editor/timeline/.codex/plan_timeline_refacto_phases.md @@ -0,0 +1,306 @@ +# Plan de Refacto Timeline - Déclinaison par Phases (Big Bang strict) + +Ce document décline `plan_timeline_refacto` en phases d’exécution explicites pour éviter les changements involontaires. + +Contraintes non négociables: +- Pas de couche de compatibilité +- Pas de fallback +- Big Bang unique en production (cutover final) +- Parité fonctionnelle stricte avec l’existant + +## Plan A (recommandé) - 7 phases avec verrous + +### Phase 1 - Cadrage technique figé +Objectif: +- Geler le contrat public (`TimelineEditorProps`, `TimelineEditorHandle`, exports). +- Établir une matrice de parité feature complète. +- Fixer les conventions d’architecture et de découpage. + +Travaux: +- Inventorier toutes les features actuelles: zoom, clip, annotation, audio, keyframes, shortcuts, toolbar, crop/aspect ratio. +- Écrire les critères observables de parité (entrées/sorties/callbacks). +- Définir les règles de taille et responsabilités par module. + +Definition of Done: +- Contrat API public verrouillé. +- Matrice de parité validée. +- Règles de découpage figées. + +--- + +### Phase 2 - Extraction du noyau pur (`core` + `model`) +Objectif: +- Sortir toute la logique pure hors de `TimelineEditor.tsx`. + +Travaux: +- `timeline/core/`: constantes, temps/range/scale/format, row IDs, overlap. +- `timeline/model/`: mapping régions -> items timeline, labels, spans, sélection. +- Suppression de la logique pure du composant monolithique. + +Definition of Done: +- Modules purs sans dépendance React/DOM/Electron. +- Comportement identique validé par tests unitaires. + +--- + +### Phase 3 - Extraction DnD (`dnd`) +Objectif: +- Isoler toute la logique drag/resize/collision/voisinage. + +Travaux: +- Migrer la logique de `TimelineWrapper` vers `timeline/dnd/`. +- Encapsuler clamp resize/drag, min duration, row resolve. +- Standardiser l’API DnD vers le reste du système. + +Definition of Done: +- DnD autonome et testable. +- Même sémantique d’overlap qu’avant. + +--- + +### Phase 4 - Extraction hooks métier (`hooks`) +Objectif: +- Décomposer la logique d’orchestration en hooks spécialisés. + +Travaux: +- Implémenter: + - `useTimelineRange` + - `useTimelineSelection` + - `useTimelineNormalization` + - `useTimelineZoomActions` + - `useTimelineAudioActions` + - `useTimelineAnnotationsActions` + - `useTimelineKeyboardShortcuts` +- Isoler side-effects (`window`, `electronAPI`, `toast`) dans ces hooks. + +Definition of Done: +- `TimelineEditor` n’héberge plus la logique métier. +- Side-effects regroupés et contrôlés. + +--- + +### Phase 5 - Découpage UI complet (`components`) +Objectif: +- Transformer la vue monolithique en composants de responsabilité unique. + +Travaux: +- Extraire: + - `components/editor/TimelineEditorShell` + - `components/toolbar/TimelineToolbar` + - `components/viewport/TimelineViewport` + - `components/axis/TimelineAxis` + - `components/playhead/PlaybackCursor` + - `components/rows/{ClipRow,ZoomRow,AnnotationRows,AudioRows}` + - `components/overlays/{ClipMarkerOverlay,GhostPlayhead,GhostZoom}` + - `components/keyframes/KeyframeMarkers` +- Rebrancher les props et callbacks sans changer les signatures publiques. + +Definition of Done: +- UI modulaire complète. +- Pas de régression interactionnelle (clic, hover, drag, resize, shortcuts). + +--- + +### Phase 6 - Recomposition finale Big Bang +Objectif: +- Bascule complète vers la nouvelle architecture. + +Travaux: +- Remplacer l’implémentation interne de `TimelineEditor` par la version modulaire. +- Supprimer code legacy et code mort. +- Conserver strictement le même point d’entrée public. + +Definition of Done: +- Aucune branche legacy. +- Aucune couche de compatibilité. +- Nouvelle architecture seule active. + +--- + +### Phase 7 - Validation finale parité + gate release +Objectif: +- Bloquer toute régression avant merge final. + +Travaux: +- Exécuter: + - Tests unitaires logique pure. + - Tests d’intégration React sur interactions clés. + - Smoke e2e sur scénarios d’édition complets. +- Vérifier la matrice de parité feature point par point. + +Definition of Done: +- API publique identique. +- Parité fonctionnelle validée. +- Couverture forte sur logique critique timeline. + +--- + +## Plan B (plus agressif) - 3 macro-phases + +### Phase 1 - Extraction massive moteur +- Extraire d’un coup `core`, `model`, `dnd`, `hooks`. +- Réduire `TimelineEditor` à orchestration. + +### Phase 2 - Reconstruction UI modulaire +- Recomposer la vue avec `components/*`. +- Rebrancher toutes les interactions et actions. + +### Phase 3 - Durcissement et cutover final +- Tests intégration + smoke. +- Suppression définitive du monolithe et de tout reliquat legacy. + +--- + +## Plan C (orienté anti-régression involontaire) - 5 phases + +### Phase 1 - Baseline comportementale +- Capturer états/callbacks attendus sur scénarios clés. + +### Phase 2 - Extraction moteur logique +- Extraire `core/model/dnd` et verrouiller par unit tests. + +### Phase 3 - Extraction actions + side-effects +- Extraire hooks actions/shortcuts/normalization et valider intégration. + +### Phase 4 - Extraction UI modulaire +- Extraire toolbar/viewport/rows/overlays et stabiliser interactions. + +### Phase 5 - Cutover Big Bang +- Basculer complètement. +- Supprimer ancien code. +- Valider parité complète. + +--- + +## Tests minimaux obligatoires (quel que soit le plan choisi) + +Unitaires: +- Placement zoom (disponibilité, overlap, clip boundaries). +- Placement audio (choix de piste, gap computation, fit réel). +- Clamp drag/resize (min duration, voisins, bornes timeline). +- Format/scale/range/row resolvers. + +Intégration: +- Timeline click -> seek. +- Drag playhead + snap keyframe. +- Drag/resize items par row. +- Ghost playhead / ghost zoom. +- Shortcuts: A, Z, C, Delete/Backspace, select-all blocks. +- Tab / Shift+Tab cycle annotations. + +Smoke: +- Session complète: add/move/resize/delete zoom/clip/annotation/audio + crop + aspect ratio custom. + +--- + +## Choix verrouillés +- Big Bang strict. +- Zéro compatibilité. +- Zéro fallback. +- Parité fonctionnelle stricte avant release. + +--- + +## Verrou Phase 1 (figé) + +### Contrat API public verrouillé +- Export public composant: + - `default TimelineEditor` + - `type TimelineEditorHandle` + - `type TimelineEditorProps` +- `TimelineEditorProps` gelé (sans fallback compat): + - Inputs: `videoDuration`, `currentTime`, `playheadTime`, `cursorTelemetry`, `autoSuggestZoomsTrigger`, `disableSuggestedZooms`, `zoomRegions`, `trimRegions`, `clipRegions`, `annotationRegions`, `speedRegions`, `audioRegions`, `aspectRatio`, `isCropped`, `videoPath`, `hideToolbar`. + - Callbacks: `onSeek`, `onAutoSuggestZoomsConsumed`, `onZoomAdded`, `onZoomSuggested`, `onZoomSpanChange`, `onZoomDelete`, `onSelectZoom`, `onTrimAdded`, `onTrimSpanChange`, `onTrimDelete`, `onSelectTrim`, `onClipSplit`, `onClipSpanChange`, `onClipDelete`, `onSelectClip`, `onAnnotationAdded`, `onAnnotationSpanChange`, `onAnnotationDelete`, `onSelectAnnotation`, `onSpeedAdded`, `onSpeedSpanChange`, `onSpeedDelete`, `onSelectSpeed`, `onAudioAdded`, `onAudioSpanChange`, `onAudioDelete`, `onSelectAudio`, `onAspectRatioChange`, `onOpenCropEditor`. +- `TimelineEditorHandle` gelé: + - Méthodes: `addZoom()`, `suggestZooms()`, `splitClip()`, `addAnnotation(trackIndex?)`, `addAudio(trackIndex?)`. + - État exposé: `keyframes: { id: string; time: number }[]`. + +### Matrice de parité feature (critères observables) +- Zoom: + - Entrées: `zoomRegions`, `cursorTelemetry`, `disableSuggestedZooms`. + - Sorties/callbacks: `onZoomAdded`, `onZoomSuggested`, `onZoomSpanChange`, `onZoomDelete`, `onSelectZoom`. + - Observables: ajout manuel, suggestions interaction, drag/resize sans overlap. +- Clip: + - Entrées: `clipRegions`, `currentTime`. + - Sorties/callbacks: `onClipSplit`, `onClipSpanChange`, `onClipDelete`, `onSelectClip`. + - Observables: split au playhead, sélection/resize clip. +- Annotation: + - Entrées: `annotationRegions`, `currentTime`. + - Sorties/callbacks: `onAnnotationAdded`, `onAnnotationSpanChange`, `onAnnotationDelete`, `onSelectAnnotation`. + - Observables: multi-track, Tab/Shift+Tab cycle sur overlaps. +- Audio: + - Entrées: `audioRegions`, `videoPath`, `currentTime`. + - Sorties/callbacks: `onAudioAdded`, `onAudioSpanChange`, `onAudioDelete`, `onSelectAudio`. + - Observables: placement piste libre, clamp durée au gap restant. +- Keyframes: + - Entrées: `currentTime`. + - Sorties: handle `keyframes`; interactions internes `add/move/delete`. + - Observables: snap playhead ±150ms sur keyframe en drag. +- Shortcuts: + - Entrées: focus timeline + keymap contexte shortcuts. + - Sorties: déclenche callbacks de feature ou suppression. + - Observables: `A`, `Z`, `C`, `Delete/Backspace`, `Ctrl/Cmd+A`, `Tab`, `Shift+Tab`. +- Toolbar: + - Entrées: `hideToolbar`, `aspectRatio`, `isCropped`. + - Sorties: actions zoom/annotation/audio/split/crop/aspect. + - Observables: parité boutons et menus. +- Crop / Aspect ratio: + - Entrées: `aspectRatio`, `isCropped`. + - Sorties: `onAspectRatioChange`, `onOpenCropEditor`. + - Observables: presets + custom ratio, indicateur crop actif. + +### Conventions d’architecture / découpage (figées) +- `timeline/core/*`: + - Pur (pas React/DOM/Electron). + - Responsabilités: temps/range/scale/format, rows IDs, overlap, normalisation span. +- `timeline/model/*`: + - Pur (pas React/DOM/Electron). + - Responsabilités: mapping régions -> items timeline, labels, spans DnD, resolve row. +- Contraintes module: + - Taille cible: `< 200` LOC/module pur, `< 300` LOC max exceptionnel. + - Fonctions exportées pures, typées explicitement, testées unitairement. + - Aucune dépendance croisée `model -> React` ou `core -> model UI`. + +## Verrou Phase 2 (extraction core+model) + +### Extraction réalisée +- `timeline/core/`: + - `constants.ts` + - `rows.ts` + - `spans.ts` + - `time.ts` +- `timeline/model/`: + - `timelineModel.ts` +- `TimelineEditor.tsx` rebranché: + - Utilise les fonctions `core`/`model` pour la logique pure extraite. + - Contrat public inchangé côté comportement. + +### Tests unitaires ajoutés +- `timeline/core/time.test.ts` +- `timeline/core/rows.test.ts` +- `timeline/model/timelineModel.test.ts` + +## Verrou Phase 3 (extraction DnD) + +### Extraction réalisée +- Nouveau module DnD pur: `timeline/dnd/engine.ts`. +- Logique migrée hors `TimelineWrapper`: + - clamp resize/drag + - clamp range viewport + - voisinage/siblings par row + - résolution d’overlap en fin de drag/resize +- `TimelineWrapper` devient adaptateur d’événements (`dnd-timeline` -> appels `engine`). + +### API DnD standardisée +- Entrée unifiée `DndEngineConfig`: + - `totalMs`, `minItemDurationMs`, `minVisibleRangeMs`, `allRegionSpans`, `hasOverlap`. +- Fonctions pures exportées: + - `clampSpanToBounds`, `clampRange` + - `clampResizedSpanToNeighbours`, `clampDraggedSpanToNeighbours` + - `resolveResizeEnd`, `resolveDragEnd` + +### Parité sémantique +- Pas de changement de comportement attendu: + - même conservation de durée sur drag + - même logique de clamp voisinage + - même fallback `return` quand overlap persiste après clamp diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index f79757e7..42a57040 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -52,7 +52,6 @@ import type { SpeedRegion, TrimRegion, ZoomFocus, - ZoomMode, ZoomRegion, } from "../types"; import AudioWaveform from "./AudioWaveform"; @@ -69,55 +68,13 @@ import { } from "./timelineLayout"; import { type AudioPeaksData, useAudioPeaks } from "./useAudioPeaks"; import { buildInteractionZoomSuggestions } from "./zoomSuggestionUtils"; +import { CLIP_ROW_ID, ZOOM_ROW_ID } from "./core/constants"; +import { getAnnotationTrackIndex, getAnnotationTrackRowId, getAudioTrackIndex, getAudioTrackRowId, isAnnotationTrackRowId, isAudioTrackRowId } from "./core/rows"; +import { normalizeRegionSpan, spansOverlap } from "./core/spans"; +import { calculateAxisScale, calculateTimelineScale, createInitialRange, formatPlayheadTime, formatTimeLabel, normalizeWheelDeltaToPixels } from "./core/time"; +import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId, type TimelineRenderItem } from "./model/timelineModel"; -const ZOOM_ROW_ID = "row-zoom"; -const CLIP_ROW_ID = "row-clip"; -const ANNOTATION_ROW_ID = "row-annotation"; -const AUDIO_ROW_ID = "row-audio"; -const ANNOTATION_ROW_PREFIX = `${ANNOTATION_ROW_ID}-`; -const AUDIO_ROW_PREFIX = "row-audio-"; -const FALLBACK_RANGE_MS = 1000; -const TARGET_MARKER_COUNT = 12; - -function getAnnotationTrackRowId(trackIndex: number) { - return `${ANNOTATION_ROW_ID}-${Math.max(0, Math.floor(trackIndex))}`; -} - -function isAnnotationTrackRowId(rowId: string) { - return rowId === ANNOTATION_ROW_ID || rowId.startsWith(ANNOTATION_ROW_PREFIX); -} - -function getAnnotationTrackIndex(rowId: string) { - if (rowId === ANNOTATION_ROW_ID) { - return 0; - } - - const parsed = Number.parseInt(rowId.slice(ANNOTATION_ROW_PREFIX.length), 10); - return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; -} - -function getAudioTrackRowId(trackIndex: number) { - return `${AUDIO_ROW_PREFIX}${Math.max(0, Math.floor(trackIndex))}`; -} - -function isAudioTrackRowId(rowId: string) { - return rowId === AUDIO_ROW_ID || rowId.startsWith(AUDIO_ROW_PREFIX); -} - -function getAudioTrackIndex(rowId: string) { - if (rowId === AUDIO_ROW_ID) { - return 0; - } - - const parsed = Number.parseInt(rowId.slice(AUDIO_ROW_PREFIX.length), 10); - return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; -} - -function spansOverlap(left: Span, right: Span) { - return left.end > right.start && left.start < right.end; -} - -interface TimelineEditorProps { +export interface TimelineEditorProps { videoDuration: number; currentTime: number; playheadTime?: number; @@ -180,128 +137,6 @@ export interface TimelineEditorHandle { keyframes: { id: string; time: number }[]; } -interface TimelineScaleConfig { - minItemDurationMs: number; - defaultItemDurationMs: number; - minVisibleRangeMs: number; -} - -interface TimelineRenderItem { - id: string; - rowId: string; - span: Span; - label: string; - zoomDepth?: number; - zoomMode?: ZoomMode; - speedValue?: number; - variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio"; -} - -const SCALE_CANDIDATES = [ - { intervalSeconds: 0.05, gridSeconds: 0.01 }, - { intervalSeconds: 0.1, gridSeconds: 0.02 }, - { intervalSeconds: 0.25, gridSeconds: 0.05 }, - { intervalSeconds: 0.5, gridSeconds: 0.1 }, - { intervalSeconds: 1, gridSeconds: 0.25 }, - { intervalSeconds: 2, gridSeconds: 0.5 }, - { intervalSeconds: 5, gridSeconds: 1 }, - { intervalSeconds: 10, gridSeconds: 2 }, - { intervalSeconds: 15, gridSeconds: 3 }, - { intervalSeconds: 30, gridSeconds: 5 }, - { intervalSeconds: 60, gridSeconds: 10 }, - { intervalSeconds: 120, gridSeconds: 20 }, - { intervalSeconds: 300, gridSeconds: 30 }, - { intervalSeconds: 600, gridSeconds: 60 }, - { intervalSeconds: 900, gridSeconds: 120 }, - { intervalSeconds: 1800, gridSeconds: 180 }, - { intervalSeconds: 3600, gridSeconds: 300 }, -]; - -function calculateAxisScale(visibleRangeMs: number): { intervalMs: number; gridMs: number } { - const visibleSeconds = visibleRangeMs / 1000; - const candidate = - SCALE_CANDIDATES.find((scaleCandidate) => { - if (visibleSeconds <= 0) { - return true; - } - return visibleSeconds / scaleCandidate.intervalSeconds <= TARGET_MARKER_COUNT; - }) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1]; - - return { - intervalMs: Math.round(candidate.intervalSeconds * 1000), - gridMs: Math.round(candidate.gridSeconds * 1000), - }; -} - -function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig { - const totalMs = Math.max(0, Math.round(durationSeconds * 1000)); - - const minItemDurationMs = 100; - - const defaultItemDurationMs = - totalMs > 0 - ? Math.max(minItemDurationMs, Math.min(Math.round(totalMs * 0.05), 30000)) - : Math.max(minItemDurationMs, 1000); - - const minVisibleRangeMs = 300; - - return { - minItemDurationMs, - defaultItemDurationMs, - minVisibleRangeMs, - }; -} - -function createInitialRange(totalMs: number): Range { - if (totalMs > 0) { - return { start: 0, end: totalMs }; - } - - return { start: 0, end: FALLBACK_RANGE_MS }; -} - -function normalizeWheelDeltaToPixels(delta: number, deltaMode: number) { - if (deltaMode === 1) { - return delta * 16; - } - - if (deltaMode === 2) { - return delta * 240; - } - - return delta; -} - -function formatTimeLabel(milliseconds: number, intervalMs: number) { - const totalSeconds = milliseconds / 1000; - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0; - - if (hours > 0) { - const minutesString = minutes.toString().padStart(2, "0"); - const secondsString = Math.floor(seconds).toString().padStart(2, "0"); - return `${hours}:${minutesString}:${secondsString}`; - } - - if (fractionalDigits > 0) { - const secondsWithFraction = seconds.toFixed(fractionalDigits); - const [wholeSeconds, fraction] = secondsWithFraction.split("."); - return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`; - } - - return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`; -} - -function formatPlayheadTime(ms: number): string { - const s = ms / 1000; - const min = Math.floor(s / 60); - const sec = s % 60; - if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, "0")}`; - return `${sec.toFixed(1)}s`; -} function PlaybackCursor({ currentTimeMs, @@ -1322,62 +1157,54 @@ const TimelineEditor = forwardRef( } zoomRegionsRef.current.forEach((region) => { - const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); - const minEnd = clampedStart + safeMinDurationMs; - const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); - const normalizedStart = Math.max( - 0, - Math.min(clampedStart, totalMs - safeMinDurationMs), - ); - const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); - if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { - onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd }); + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onZoomSpanChange(region.id, normalized); } }); trimRegionsRef.current.forEach((region) => { - const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); - const minEnd = clampedStart + safeMinDurationMs; - const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); - const normalizedStart = Math.max( - 0, - Math.min(clampedStart, totalMs - safeMinDurationMs), - ); - const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); - if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { - onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onTrimSpanChange?.(region.id, normalized); } }); speedRegionsRef.current.forEach((region) => { - const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); - const minEnd = clampedStart + safeMinDurationMs; - const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); - const normalizedStart = Math.max( - 0, - Math.min(clampedStart, totalMs - safeMinDurationMs), - ); - const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); - if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { - onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onSpeedSpanChange?.(region.id, normalized); } }); audioRegionsRef.current.forEach((region) => { - const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); - const minEnd = clampedStart + safeMinDurationMs; - const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); - const normalizedStart = Math.max( - 0, - Math.min(clampedStart, totalMs - safeMinDurationMs), - ); - const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); - if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { - onAudioSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onAudioSpanChange?.(region.id, normalized); } }); // Only re-run when the timeline scale changes, not on every region edit @@ -1916,109 +1743,31 @@ const TimelineEditor = forwardRef( ], ); - const timelineItems = useMemo(() => { - const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({ - id: region.id, - rowId: ZOOM_ROW_ID, - span: { start: region.startMs, end: region.endMs }, - label: `Zoom ${index + 1}`, - zoomDepth: region.depth, - zoomMode: region.mode ?? "auto", - variant: "zoom", - })); - - const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({ - id: region.id, - rowId: CLIP_ROW_ID, - span: { start: region.startMs, end: region.endMs }, - label: `Clip ${index + 1}`, - variant: "clip", - })); - - const annotations: TimelineRenderItem[] = annotationRegions.map((region) => { - let label: string; - - if (region.type === "text") { - // Show text preview - const preview = region.content.trim() || "Empty text"; - label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview; - } else if (region.type === "image") { - label = "Image"; - } else { - label = "Annotation"; - } - - return { - id: region.id, - rowId: getAnnotationTrackRowId(region.trackIndex ?? 0), - span: { start: region.startMs, end: region.endMs }, - label, - variant: "annotation", - }; - }); - - const audios: TimelineRenderItem[] = audioRegions.map((region) => { - const fileName = - region.audioPath - .split(/[\\/]/) - .pop() - ?.replace(/\.[^.]+$/, "") || "Audio"; - return { - id: region.id, - rowId: getAudioTrackRowId(region.trackIndex ?? 0), - span: { start: region.startMs, end: region.endMs }, - label: fileName, - variant: "audio", - }; - }); - - return [...zooms, ...clips, ...annotations, ...audios]; - }, [zoomRegions, clipRegions, annotationRegions, audioRegions]); + const timelineItems = useMemo( + () => + buildTimelineItems({ + zoomRegions, + clipRegions, + annotationRegions, + audioRegions, + }), + [zoomRegions, clipRegions, annotationRegions, audioRegions], + ); // Flat list of draggable row spans for neighbour-clamping during drag/resize. - const allRegionSpans = useMemo(() => { - const zooms = zoomRegions.map((r) => ({ - id: r.id, - start: r.startMs, - end: r.endMs, - rowId: ZOOM_ROW_ID, - })); - const clips = clipRegions.map((r) => ({ - id: r.id, - start: r.startMs, - end: r.endMs, - rowId: CLIP_ROW_ID, - })); - const audios = audioRegions.map((r) => ({ - id: r.id, - start: r.startMs, - end: r.endMs, - rowId: getAudioTrackRowId(r.trackIndex ?? 0), - })); - return [...zooms, ...clips, ...audios]; - }, [zoomRegions, clipRegions, audioRegions]); + const allRegionSpans = useMemo( + () => + buildAllRegionSpans({ + zoomRegions, + clipRegions, + audioRegions, + }), + [zoomRegions, clipRegions, audioRegions], + ); const getResolvedDropRowId = useCallback( - (id: string, proposedRowId: string) => { - const currentRowId = timelineItems.find((item) => item.id === id)?.rowId; - if (!currentRowId) { - return proposedRowId; - } - - if (isAnnotationTrackRowId(currentRowId)) { - return isAnnotationTrackRowId(proposedRowId) - ? getAnnotationTrackRowId(getAnnotationTrackIndex(proposedRowId)) - : currentRowId; - } - - if (isAudioTrackRowId(currentRowId)) { - return isAudioTrackRowId(proposedRowId) - ? getAudioTrackRowId(getAudioTrackIndex(proposedRowId)) - : currentRowId; - } - - return currentRowId; - }, + (id: string, proposedRowId: string) => + resolveDropRowId(id, proposedRowId, timelineItems), [timelineItems], ); diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx index 32a94565..3191dc4c 100644 --- a/src/components/video-editor/timeline/TimelineWrapper.tsx +++ b/src/components/video-editor/timeline/TimelineWrapper.tsx @@ -10,6 +10,12 @@ import type { import { TimelineContext } from "dnd-timeline"; import type { Dispatch, ReactNode, SetStateAction } from "react"; import { useCallback, useRef } from "react"; +import { + clampRange, + resolveDragEnd, + resolveResizeEnd, + type TimelineRegionSpan, +} from "./dnd/engine"; interface TimelineWrapperProps { children: ReactNode; @@ -22,7 +28,7 @@ interface TimelineWrapperProps { gridSizeMs?: number; onItemSpanChange: (id: string, span: Span, rowId?: string) => void; resolveTargetRowId?: (id: string, proposedRowId: string) => string; - allRegionSpans?: { id: string; start: number; end: number; rowId: string }[]; + allRegionSpans?: TimelineRegionSpan[]; } export default function TimelineWrapper({ @@ -40,181 +46,22 @@ export default function TimelineWrapper({ }: TimelineWrapperProps) { const totalMs = Math.max(0, Math.round(videoDuration * 1000)); - const clampSpanToBounds = useCallback( - (span: Span): Span => { - const rawDuration = Math.max(span.end - span.start, 0); - const normalizedStart = Number.isFinite(span.start) ? span.start : 0; - - if (totalMs === 0) { - const minDuration = Math.max(minItemDurationMs, 1); - const duration = Math.max(rawDuration, minDuration); - const start = Math.max(0, normalizedStart); - return { - start, - end: start + duration, - }; - } - - const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs); - const duration = Math.min(Math.max(rawDuration, minDuration), totalMs); - - const start = Math.max(0, Math.min(normalizedStart, totalMs - duration)); - const end = start + duration; - - return { start, end }; - }, - [minItemDurationMs, totalMs], - ); - - const clampRange = useCallback( - (candidate: Range): Range => { - if (totalMs === 0) { - const minSpan = Math.max(minVisibleRangeMs, 1); - const span = Math.max(candidate.end - candidate.start, minSpan); - const start = Math.max(0, Math.min(candidate.start, candidate.end - span)); - return { start, end: start + span }; - } - - const rawStart = Math.max(0, candidate.start); - const rawEnd = candidate.end; - const clampedEnd = Math.min(rawEnd, totalMs); - - const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs); - const desiredSpan = clampedEnd - rawStart; - const span = Math.min(Math.max(desiredSpan, minSpan), totalMs); - - let finalStart = rawStart; - let finalEnd = finalStart + span; - - if (finalEnd > totalMs) { - finalEnd = totalMs; - finalStart = Math.max(0, finalEnd - span); - } - - return { start: finalStart, end: finalEnd }; - }, - [minVisibleRangeMs, totalMs], - ); - - const getSiblingSpans = useCallback( - (activeItemId: string, rowId?: string) => { - const activeItem = allRegionSpans.find((region) => region.id === activeItemId); - const resolvedRowId = rowId ?? activeItem?.rowId; - if (!resolvedRowId) { - return []; - } - - return allRegionSpans - .filter((region) => region.id !== activeItemId && region.rowId === resolvedRowId) - .sort((left, right) => left.start - right.start); - }, - [allRegionSpans], - ); - - // When a resize overlaps neighbours, clamp the resized edge to the nearest boundary. - const clampResizedSpanToNeighbours = useCallback( - (span: Span, activeItemId: string): Span => { - const siblings = getSiblingSpans(activeItemId); - const activeItem = allRegionSpans.find((region) => region.id === activeItemId); - let { start, end } = span; - - for (const r of siblings) { - // Span's right edge crossed into a region to the right - if (end > r.start && start < r.start) { - end = r.start; - } - // Span's left edge crossed into a region to the left - if (start < r.end && end > r.end) { - start = r.end; - } - } - - // Ensure minimum duration after clamping - const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs); - if (end - start < minDur) { - const resizedLeft = Boolean( - activeItem && span.start !== activeItem.start && span.end === activeItem.end, - ); - if (resizedLeft) { - start = end - minDur; - } else { - end = start + minDur; - } - } - - return { start: Math.max(0, start), end: Math.min(end, totalMs || end) }; - }, - [allRegionSpans, getSiblingSpans, minItemDurationMs, totalMs], - ); - - // When a drag overlaps neighbours, keep duration fixed and stop at the nearest gap boundary. - const clampDraggedSpanToNeighbours = useCallback( - (span: Span, activeItemId: string, rowId?: string): Span => { - const activeItem = allRegionSpans.find((region) => region.id === activeItemId); - if (!activeItem) { - return clampSpanToBounds(span); - } - - const siblings = getSiblingSpans(activeItemId, rowId); - const duration = Math.max( - activeItem.end - activeItem.start, - Math.min(minItemDurationMs, totalMs || minItemDurationMs), - ); - const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start; - - const previousSibling = [...siblings] - .reverse() - .find((region) => region.end <= activeItem.start); - const nextSibling = siblings.find((region) => region.start >= activeItem.end); - - const minStart = previousSibling ? previousSibling.end : 0; - const maxStart = nextSibling - ? nextSibling.start - duration - : totalMs > 0 - ? totalMs - duration - : proposedStart; - - const start = Math.max(minStart, Math.min(proposedStart, maxStart)); - return clampSpanToBounds({ start, end: start + duration }); - }, - [allRegionSpans, clampSpanToBounds, getSiblingSpans, minItemDurationMs, totalMs], - ); - const onResizeEnd = useCallback( (event: ResizeEndEvent) => { const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event); if (!updatedSpan) return; const activeItemId = event.active.id as string; - let clampedSpan = clampSpanToBounds(updatedSpan); - - const effectiveMinDuration = - totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs; - if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) { - return; - } - - // Clamp to neighbour boundaries instead of rejecting - if (hasOverlap(clampedSpan, activeItemId)) { - clampedSpan = clampSpanToBounds( - clampResizedSpanToNeighbours(clampedSpan, activeItemId), - ); - // If still overlapping after clamping, fall back to original position - if (hasOverlap(clampedSpan, activeItemId)) { - return; - } - } - - onItemSpanChange(activeItemId, clampedSpan); + const resolvedSpan = resolveResizeEnd(activeItemId, updatedSpan, { + totalMs, + minItemDurationMs, + allRegionSpans, + hasOverlap, + }); + if (!resolvedSpan) return; + onItemSpanChange(activeItemId, resolvedSpan); }, - [ - clampResizedSpanToNeighbours, - clampSpanToBounds, - hasOverlap, - minItemDurationMs, - onItemSpanChange, - totalMs, - ], + [allRegionSpans, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs], ); const onDragEnd = useCallback( @@ -224,44 +71,28 @@ export default function TimelineWrapper({ if (!updatedSpan || !proposedRowId) return; const activeItemId = event.active.id as string; - const resolvedRowId = - resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId; - - // Drags are pure translations — always preserve the original duration. - // The span from getSpanFromDragEvent can drift due to pixel-to-ms - // rounding at different zoom levels, so pin to the known duration. - const activeItem = allRegionSpans.find((r) => r.id === activeItemId); - const originalDuration = activeItem - ? activeItem.end - activeItem.start - : updatedSpan.end - updatedSpan.start; - const dragSpan: Span = { - start: updatedSpan.start, - end: updatedSpan.start + originalDuration, - }; - - let clampedSpan = clampSpanToBounds(dragSpan); - - // Clamp to neighbour boundaries instead of rejecting - if (hasOverlap(clampedSpan, activeItemId, resolvedRowId)) { - clampedSpan = clampDraggedSpanToNeighbours( - clampedSpan, - activeItemId, - resolvedRowId, - ); - if (hasOverlap(clampedSpan, activeItemId, resolvedRowId)) { - return; - } - } - - onItemSpanChange(activeItemId, clampedSpan, resolvedRowId); + const resolved = resolveDragEnd( + activeItemId, + updatedSpan, + proposedRowId, + { + allRegionSpans, + totalMs, + minItemDurationMs, + hasOverlap, + }, + resolveTargetRowId, + ); + if (!resolved) return; + onItemSpanChange(activeItemId, resolved.span, resolved.rowId); }, [ allRegionSpans, - clampDraggedSpanToNeighbours, - clampSpanToBounds, hasOverlap, + minItemDurationMs, onItemSpanChange, resolveTargetRowId, + totalMs, ], ); @@ -350,17 +181,18 @@ export default function TimelineWrapper({ const handleRangeChange = useCallback( (updater: (previous: Range) => Range) => { onRangeChange((prev) => { - const normalized = totalMs > 0 ? clampRange(prev) : prev; + const normalized = + totalMs > 0 ? clampRange(prev, { totalMs, minVisibleRangeMs }) : prev; const desired = updater(normalized); if (totalMs > 0) { - return clampRange(desired); + return clampRange(desired, { totalMs, minVisibleRangeMs }); } return desired; }); }, - [clampRange, onRangeChange, totalMs], + [minVisibleRangeMs, onRangeChange, totalMs], ); return ( diff --git a/src/components/video-editor/timeline/core/constants.ts b/src/components/video-editor/timeline/core/constants.ts new file mode 100644 index 00000000..e13a204d --- /dev/null +++ b/src/components/video-editor/timeline/core/constants.ts @@ -0,0 +1,9 @@ +export const ZOOM_ROW_ID = "row-zoom"; +export const CLIP_ROW_ID = "row-clip"; +export const ANNOTATION_ROW_ID = "row-annotation"; +export const AUDIO_ROW_ID = "row-audio"; +export const ANNOTATION_ROW_PREFIX = `${ANNOTATION_ROW_ID}-`; +export const AUDIO_ROW_PREFIX = "row-audio-"; + +export const FALLBACK_RANGE_MS = 1000; +export const TARGET_MARKER_COUNT = 12; diff --git a/src/components/video-editor/timeline/core/rows.test.ts b/src/components/video-editor/timeline/core/rows.test.ts new file mode 100644 index 00000000..d53655cd --- /dev/null +++ b/src/components/video-editor/timeline/core/rows.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + getAnnotationTrackIndex, + getAnnotationTrackRowId, + getAudioTrackIndex, + getAudioTrackRowId, + isAnnotationTrackRowId, + isAudioTrackRowId, +} from "./rows"; + +describe("timeline core/rows", () => { + it("builds and parses annotation rows", () => { + expect(getAnnotationTrackRowId(2.9)).toBe("row-annotation-2"); + expect(getAnnotationTrackIndex("row-annotation-4")).toBe(4); + expect(isAnnotationTrackRowId("row-annotation")).toBe(true); + }); + + it("builds and parses audio rows", () => { + expect(getAudioTrackRowId(1.2)).toBe("row-audio-1"); + expect(getAudioTrackIndex("row-audio-3")).toBe(3); + expect(isAudioTrackRowId("row-audio")).toBe(true); + }); +}); diff --git a/src/components/video-editor/timeline/core/rows.ts b/src/components/video-editor/timeline/core/rows.ts new file mode 100644 index 00000000..7b645346 --- /dev/null +++ b/src/components/video-editor/timeline/core/rows.ts @@ -0,0 +1,40 @@ +import { + ANNOTATION_ROW_ID, + ANNOTATION_ROW_PREFIX, + AUDIO_ROW_ID, + AUDIO_ROW_PREFIX, +} from "./constants"; + +export function getAnnotationTrackRowId(trackIndex: number) { + return `${ANNOTATION_ROW_ID}-${Math.max(0, Math.floor(trackIndex))}`; +} + +export function isAnnotationTrackRowId(rowId: string) { + return rowId === ANNOTATION_ROW_ID || rowId.startsWith(ANNOTATION_ROW_PREFIX); +} + +export function getAnnotationTrackIndex(rowId: string) { + if (rowId === ANNOTATION_ROW_ID) { + return 0; + } + + const parsed = Number.parseInt(rowId.slice(ANNOTATION_ROW_PREFIX.length), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; +} + +export function getAudioTrackRowId(trackIndex: number) { + return `${AUDIO_ROW_PREFIX}${Math.max(0, Math.floor(trackIndex))}`; +} + +export function isAudioTrackRowId(rowId: string) { + return rowId === AUDIO_ROW_ID || rowId.startsWith(AUDIO_ROW_PREFIX); +} + +export function getAudioTrackIndex(rowId: string) { + if (rowId === AUDIO_ROW_ID) { + return 0; + } + + const parsed = Number.parseInt(rowId.slice(AUDIO_ROW_PREFIX.length), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; +} diff --git a/src/components/video-editor/timeline/core/spans.ts b/src/components/video-editor/timeline/core/spans.ts new file mode 100644 index 00000000..8f49973e --- /dev/null +++ b/src/components/video-editor/timeline/core/spans.ts @@ -0,0 +1,21 @@ +import type { Span } from "dnd-timeline"; + +export function spansOverlap(left: Span, right: Span) { + return left.end > right.start && left.start < right.end; +} + +export function normalizeRegionSpan(params: { + startMs: number; + endMs: number; + totalMs: number; + minDurationMs: number; +}) { + const { startMs, endMs, totalMs, minDurationMs } = params; + const clampedStart = Math.max(0, Math.min(startMs, totalMs)); + const minEnd = clampedStart + minDurationMs; + const clampedEnd = Math.min(totalMs, Math.max(minEnd, endMs)); + const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - minDurationMs)); + const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + + return { start: normalizedStart, end: normalizedEnd }; +} diff --git a/src/components/video-editor/timeline/core/time.test.ts b/src/components/video-editor/timeline/core/time.test.ts new file mode 100644 index 00000000..c82a9455 --- /dev/null +++ b/src/components/video-editor/timeline/core/time.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + calculateAxisScale, + calculateTimelineScale, + createInitialRange, + formatPlayheadTime, + formatTimeLabel, + normalizeWheelDeltaToPixels, +} from "./time"; + +describe("timeline core/time", () => { + it("creates fallback range for empty timeline", () => { + expect(createInitialRange(0)).toEqual({ start: 0, end: 1000 }); + expect(createInitialRange(2500)).toEqual({ start: 0, end: 2500 }); + }); + + it("computes scale bounds", () => { + expect(calculateTimelineScale(0).minItemDurationMs).toBe(100); + expect(calculateTimelineScale(100).defaultItemDurationMs).toBe(5000); + }); + + it("formats timeline and playhead labels", () => { + expect(formatTimeLabel(1234, 100)).toBe("0:01.23"); + expect(formatPlayheadTime(1234)).toBe("1.2s"); + }); + + it("normalizes wheel delta by deltaMode", () => { + expect(normalizeWheelDeltaToPixels(2, 0)).toBe(2); + expect(normalizeWheelDeltaToPixels(2, 1)).toBe(32); + expect(normalizeWheelDeltaToPixels(2, 2)).toBe(480); + }); + + it("picks axis interval based on visible range", () => { + expect(calculateAxisScale(2000).intervalMs).toBeGreaterThan(0); + }); +}); diff --git a/src/components/video-editor/timeline/core/time.ts b/src/components/video-editor/timeline/core/time.ts new file mode 100644 index 00000000..7e02f8ed --- /dev/null +++ b/src/components/video-editor/timeline/core/time.ts @@ -0,0 +1,116 @@ +import type { Range } from "dnd-timeline"; +import { FALLBACK_RANGE_MS, TARGET_MARKER_COUNT } from "./constants"; + +export interface TimelineScaleConfig { + minItemDurationMs: number; + defaultItemDurationMs: number; + minVisibleRangeMs: number; +} + +const SCALE_CANDIDATES = [ + { intervalSeconds: 0.05, gridSeconds: 0.01 }, + { intervalSeconds: 0.1, gridSeconds: 0.02 }, + { intervalSeconds: 0.25, gridSeconds: 0.05 }, + { intervalSeconds: 0.5, gridSeconds: 0.1 }, + { intervalSeconds: 1, gridSeconds: 0.25 }, + { intervalSeconds: 2, gridSeconds: 0.5 }, + { intervalSeconds: 5, gridSeconds: 1 }, + { intervalSeconds: 10, gridSeconds: 2 }, + { intervalSeconds: 15, gridSeconds: 3 }, + { intervalSeconds: 30, gridSeconds: 5 }, + { intervalSeconds: 60, gridSeconds: 10 }, + { intervalSeconds: 120, gridSeconds: 20 }, + { intervalSeconds: 300, gridSeconds: 30 }, + { intervalSeconds: 600, gridSeconds: 60 }, + { intervalSeconds: 900, gridSeconds: 120 }, + { intervalSeconds: 1800, gridSeconds: 180 }, + { intervalSeconds: 3600, gridSeconds: 300 }, +]; + +export function calculateAxisScale(visibleRangeMs: number): { + intervalMs: number; + gridMs: number; +} { + const visibleSeconds = visibleRangeMs / 1000; + const candidate = + SCALE_CANDIDATES.find((scaleCandidate) => { + if (visibleSeconds <= 0) { + return true; + } + return visibleSeconds / scaleCandidate.intervalSeconds <= TARGET_MARKER_COUNT; + }) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1]; + + return { + intervalMs: Math.round(candidate.intervalSeconds * 1000), + gridMs: Math.round(candidate.gridSeconds * 1000), + }; +} + +export function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig { + const totalMs = Math.max(0, Math.round(durationSeconds * 1000)); + const minItemDurationMs = 100; + + const defaultItemDurationMs = + totalMs > 0 + ? Math.max(minItemDurationMs, Math.min(Math.round(totalMs * 0.05), 30000)) + : Math.max(minItemDurationMs, 1000); + + const minVisibleRangeMs = 300; + + return { + minItemDurationMs, + defaultItemDurationMs, + minVisibleRangeMs, + }; +} + +export function createInitialRange(totalMs: number): Range { + if (totalMs > 0) { + return { start: 0, end: totalMs }; + } + + return { start: 0, end: FALLBACK_RANGE_MS }; +} + +export function normalizeWheelDeltaToPixels(delta: number, deltaMode: number) { + if (deltaMode === 1) { + return delta * 16; + } + + if (deltaMode === 2) { + return delta * 240; + } + + return delta; +} + +export function formatTimeLabel(milliseconds: number, intervalMs: number) { + const totalSeconds = milliseconds / 1000; + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0; + + if (hours > 0) { + const minutesString = minutes.toString().padStart(2, "0"); + const secondsString = Math.floor(seconds).toString().padStart(2, "0"); + return `${hours}:${minutesString}:${secondsString}`; + } + + if (fractionalDigits > 0) { + const secondsWithFraction = seconds.toFixed(fractionalDigits); + const [wholeSeconds, fraction] = secondsWithFraction.split("."); + return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`; + } + + return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`; +} + +export function formatPlayheadTime(ms: number): string { + const s = ms / 1000; + const min = Math.floor(s / 60); + const sec = s % 60; + if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, "0")}`; + return `${sec.toFixed(1)}s`; +} diff --git a/src/components/video-editor/timeline/dnd/engine.test.ts b/src/components/video-editor/timeline/dnd/engine.test.ts new file mode 100644 index 00000000..03db0e20 --- /dev/null +++ b/src/components/video-editor/timeline/dnd/engine.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + clampDraggedSpanToNeighbours, + clampRange, + clampResizedSpanToNeighbours, + clampSpanToBounds, + resolveDragEnd, + resolveResizeEnd, +} from "./engine"; + +const BASE_SPANS = [ + { id: "a", start: 0, end: 1000, rowId: "row-clip" }, + { id: "b", start: 1500, end: 2500, rowId: "row-clip" }, + { id: "c", start: 3000, end: 3600, rowId: "row-clip" }, +]; + +describe("timeline dnd engine", () => { + it("clamps item span to timeline bounds and min duration", () => { + expect(clampSpanToBounds({ start: -100, end: 20 }, { totalMs: 5000, minItemDurationMs: 100 })).toEqual({ start: 0, end: 120 }); + }); + + it("clamps visible range inside total duration", () => { + expect(clampRange({ start: 4900, end: 5200 }, { totalMs: 5000, minVisibleRangeMs: 300 })).toEqual({ start: 4700, end: 5000 }); + }); + + it("clamps resize against nearest neighbours", () => { + const resized = clampResizedSpanToNeighbours( + { start: 900, end: 2000 }, + "a", + { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, + ); + expect(resized.end).toBe(1500); + }); + + it("keeps drag unchanged when already inside valid neighbour gap", () => { + const dragged = clampDraggedSpanToNeighbours( + { start: 1400, end: 2400 }, + "b", + "row-clip", + { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, + ); + expect(dragged).toEqual({ start: 1400, end: 2400 }); + }); + + it("resolves resize end with overlap fallback semantics", () => { + const result = resolveResizeEnd("a", { start: 900, end: 2200 }, { + totalMs: 5000, + minItemDurationMs: 100, + allRegionSpans: BASE_SPANS, + hasOverlap: (span, id) => id === "a" && span.end > 1500, + }); + expect(result).toEqual({ start: 900, end: 1500 }); + }); + + it("resolves drag end with row resolver while preserving duration", () => { + const result = resolveDragEnd( + "b", + { start: 1200, end: 1800 }, + "row-clip", + { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => false, + }, + (id, rowId) => (id === "b" ? rowId : rowId), + ); + + expect(result).toEqual({ rowId: "row-clip", span: { start: 1200, end: 2200 } }); + }); +}); diff --git a/src/components/video-editor/timeline/dnd/engine.ts b/src/components/video-editor/timeline/dnd/engine.ts new file mode 100644 index 00000000..fbe5a457 --- /dev/null +++ b/src/components/video-editor/timeline/dnd/engine.ts @@ -0,0 +1,177 @@ +import type { Range, Span } from "dnd-timeline"; + +export interface TimelineRegionSpan { + id: string; + start: number; + end: number; + rowId: string; +} + +export interface DndEngineConfig { + totalMs: number; + minItemDurationMs: number; + minVisibleRangeMs: number; + allRegionSpans: TimelineRegionSpan[]; + hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean; +} + +export function clampSpanToBounds(span: Span, config: Pick): Span { + const { totalMs, minItemDurationMs } = config; + const rawDuration = Math.max(span.end - span.start, 0); + const normalizedStart = Number.isFinite(span.start) ? span.start : 0; + + if (totalMs === 0) { + const minDuration = Math.max(minItemDurationMs, 1); + const duration = Math.max(rawDuration, minDuration); + const start = Math.max(0, normalizedStart); + return { start, end: start + duration }; + } + + const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs); + const duration = Math.min(Math.max(rawDuration, minDuration), totalMs); + const start = Math.max(0, Math.min(normalizedStart, totalMs - duration)); + return { start, end: start + duration }; +} + +export function clampRange(candidate: Range, config: Pick): Range { + const { totalMs, minVisibleRangeMs } = config; + if (totalMs === 0) { + const minSpan = Math.max(minVisibleRangeMs, 1); + const span = Math.max(candidate.end - candidate.start, minSpan); + const start = Math.max(0, Math.min(candidate.start, candidate.end - span)); + return { start, end: start + span }; + } + + const rawStart = Math.max(0, candidate.start); + const rawEnd = candidate.end; + const clampedEnd = Math.min(rawEnd, totalMs); + const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs); + const desiredSpan = clampedEnd - rawStart; + const span = Math.min(Math.max(desiredSpan, minSpan), totalMs); + + let finalStart = rawStart; + let finalEnd = finalStart + span; + if (finalEnd > totalMs) { + finalEnd = totalMs; + finalStart = Math.max(0, finalEnd - span); + } + + return { start: finalStart, end: finalEnd }; +} + +export function getSiblingSpans(activeItemId: string, rowId: string | undefined, allRegionSpans: TimelineRegionSpan[]) { + const activeItem = allRegionSpans.find((region) => region.id === activeItemId); + const resolvedRowId = rowId ?? activeItem?.rowId; + if (!resolvedRowId) { + return []; + } + + return allRegionSpans + .filter((region) => region.id !== activeItemId && region.rowId === resolvedRowId) + .sort((left, right) => left.start - right.start); +} + +export function clampResizedSpanToNeighbours(span: Span, activeItemId: string, config: Pick): Span { + const { allRegionSpans, minItemDurationMs, totalMs } = config; + const siblings = getSiblingSpans(activeItemId, undefined, allRegionSpans); + const activeItem = allRegionSpans.find((region) => region.id === activeItemId); + let { start, end } = span; + + for (const r of siblings) { + if (end > r.start && start < r.start) { + end = r.start; + } + if (start < r.end && end > r.end) { + start = r.end; + } + } + + const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs); + if (end - start < minDur) { + const resizedLeft = Boolean(activeItem && span.start !== activeItem.start && span.end === activeItem.end); + if (resizedLeft) { + start = end - minDur; + } else { + end = start + minDur; + } + } + + return { start: Math.max(0, start), end: Math.min(end, totalMs || end) }; +} + +export function clampDraggedSpanToNeighbours(span: Span, activeItemId: string, rowId: string | undefined, config: Pick): Span { + const { allRegionSpans, minItemDurationMs, totalMs } = config; + const activeItem = allRegionSpans.find((region) => region.id === activeItemId); + if (!activeItem) { + return clampSpanToBounds(span, { totalMs, minItemDurationMs }); + } + + const siblings = getSiblingSpans(activeItemId, rowId, allRegionSpans); + const duration = Math.max( + activeItem.end - activeItem.start, + Math.min(minItemDurationMs, totalMs || minItemDurationMs), + ); + const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start; + + const previousSibling = [...siblings].reverse().find((region) => region.end <= activeItem.start); + const nextSibling = siblings.find((region) => region.start >= activeItem.end); + const minStart = previousSibling ? previousSibling.end : 0; + const maxStart = nextSibling ? nextSibling.start - duration : totalMs > 0 ? totalMs - duration : proposedStart; + + const start = Math.max(minStart, Math.min(proposedStart, maxStart)); + return clampSpanToBounds({ start, end: start + duration }, { totalMs, minItemDurationMs }); +} + +export function resolveResizeEnd(activeItemId: string, updatedSpan: Span, config: Pick): Span | null { + const { totalMs, minItemDurationMs, allRegionSpans, hasOverlap } = config; + let clamped = clampSpanToBounds(updatedSpan, { totalMs, minItemDurationMs }); + const effectiveMinDuration = totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs; + if (clamped.end - clamped.start < effectiveMinDuration) { + return null; + } + + if (hasOverlap(clamped, activeItemId)) { + clamped = clampSpanToBounds( + clampResizedSpanToNeighbours(clamped, activeItemId, { + allRegionSpans, + minItemDurationMs, + totalMs, + }), + { totalMs, minItemDurationMs }, + ); + if (hasOverlap(clamped, activeItemId)) { + return null; + } + } + + return clamped; +} + +export function resolveDragEnd( + activeItemId: string, + updatedSpan: Span, + proposedRowId: string, + config: Pick, + resolveTargetRowId?: (id: string, proposedRowId: string) => string, +): { span: Span; rowId: string } | null { + const { allRegionSpans, totalMs, minItemDurationMs, hasOverlap } = config; + const resolvedRowId = resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId; + + const activeItem = allRegionSpans.find((r) => r.id === activeItemId); + const originalDuration = activeItem ? activeItem.end - activeItem.start : updatedSpan.end - updatedSpan.start; + const dragSpan: Span = { start: updatedSpan.start, end: updatedSpan.start + originalDuration }; + + let clamped = clampSpanToBounds(dragSpan, { totalMs, minItemDurationMs }); + if (hasOverlap(clamped, activeItemId, resolvedRowId)) { + clamped = clampDraggedSpanToNeighbours(clamped, activeItemId, resolvedRowId, { + allRegionSpans, + minItemDurationMs, + totalMs, + }); + if (hasOverlap(clamped, activeItemId, resolvedRowId)) { + return null; + } + } + + return { span: clamped, rowId: resolvedRowId }; +} diff --git a/src/components/video-editor/timeline/model/timelineModel.test.ts b/src/components/video-editor/timeline/model/timelineModel.test.ts new file mode 100644 index 00000000..29f930a2 --- /dev/null +++ b/src/components/video-editor/timeline/model/timelineModel.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "./timelineModel"; + +describe("timeline model", () => { + it("maps regions to timeline items and labels", () => { + const items = buildTimelineItems({ + zoomRegions: [ + { id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, + ], + clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], + annotationRegions: [ + { + id: "a1", + startMs: 200, + endMs: 1200, + type: "text", + content: "Hello timeline", + position: { x: 0, y: 0 }, + size: { width: 1, height: 1 }, + style: { + fontSize: 12, + color: "#fff", + backgroundColor: "transparent", + borderRadius: 0, + fontFamily: "Inter", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + textAlign: "left", + }, + zIndex: 0, + trackIndex: 1, + }, + ], + audioRegions: [ + { + id: "au1", + startMs: 500, + endMs: 2000, + audioPath: "/tmp/foo.mp3", + volume: 1, + trackIndex: 0, + }, + ], + }); + + expect(items).toHaveLength(4); + expect(items.find((i) => i.id === "a1")?.rowId).toBe("row-annotation-1"); + expect(items.find((i) => i.id === "au1")?.label).toBe("foo"); + }); + + it("builds row spans for dnd constraints", () => { + const spans = buildAllRegionSpans({ + zoomRegions: [ + { id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, + ], + clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], + audioRegions: [ + { + id: "au1", + startMs: 500, + endMs: 2000, + audioPath: "x.wav", + volume: 1, + trackIndex: 2, + }, + ], + }); + expect(spans.map((s) => s.rowId)).toEqual(["row-zoom", "row-clip", "row-audio-2"]); + }); + + it("keeps items in their domain rows during dnd", () => { + const items = [ + { + id: "a1", + rowId: "row-annotation-1", + span: { start: 0, end: 1 }, + label: "A", + variant: "annotation" as const, + }, + ]; + expect(resolveDropRowId("a1", "row-audio-0", items)).toBe("row-annotation-1"); + expect(resolveDropRowId("a1", "row-annotation-3", items)).toBe("row-annotation-3"); + }); +}); diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts new file mode 100644 index 00000000..c2eeb5bc --- /dev/null +++ b/src/components/video-editor/timeline/model/timelineModel.ts @@ -0,0 +1,148 @@ +import type { Span } from "dnd-timeline"; +import type { + AnnotationRegion, + AudioRegion, + ClipRegion, + SpeedRegion, + TrimRegion, + ZoomMode, + ZoomRegion, +} from "../../types"; +import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants"; +import { + getAnnotationTrackIndex, + getAnnotationTrackRowId, + getAudioTrackIndex, + getAudioTrackRowId, + isAnnotationTrackRowId, + isAudioTrackRowId, +} from "../core/rows"; + +export interface TimelineRenderItem { + id: string; + rowId: string; + span: Span; + label: string; + zoomDepth?: number; + zoomMode?: ZoomMode; + speedValue?: number; + variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio"; +} + +export interface TimelineRegionSpan { + id: string; + start: number; + end: number; + rowId: string; +} + +export function getAnnotationLabel(region: AnnotationRegion): string { + if (region.type === "text") { + const preview = region.content.trim() || "Empty text"; + return preview.length > 20 ? `${preview.substring(0, 20)}...` : preview; + } + if (region.type === "image") { + return "Image"; + } + return "Annotation"; +} + +export function getAudioLabel(region: AudioRegion): string { + return region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") || "Audio"; +} + +export function buildTimelineItems(params: { + zoomRegions: ZoomRegion[]; + clipRegions: ClipRegion[]; + annotationRegions: AnnotationRegion[]; + audioRegions: AudioRegion[]; +}): TimelineRenderItem[] { + const { zoomRegions, clipRegions, annotationRegions, audioRegions } = params; + const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({ + id: region.id, + rowId: ZOOM_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `Zoom ${index + 1}`, + zoomDepth: region.depth, + zoomMode: region.mode ?? "auto", + variant: "zoom", + })); + + const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({ + id: region.id, + rowId: CLIP_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `Clip ${index + 1}`, + variant: "clip", + })); + + const annotations: TimelineRenderItem[] = annotationRegions.map((region) => ({ + id: region.id, + rowId: getAnnotationTrackRowId(region.trackIndex ?? 0), + span: { start: region.startMs, end: region.endMs }, + label: getAnnotationLabel(region), + variant: "annotation", + })); + + const audios: TimelineRenderItem[] = audioRegions.map((region) => ({ + id: region.id, + rowId: getAudioTrackRowId(region.trackIndex ?? 0), + span: { start: region.startMs, end: region.endMs }, + label: getAudioLabel(region), + variant: "audio", + })); + + return [...zooms, ...clips, ...annotations, ...audios]; +} + +export function buildAllRegionSpans(params: { + zoomRegions: ZoomRegion[]; + clipRegions: ClipRegion[]; + audioRegions: AudioRegion[]; +}): TimelineRegionSpan[] { + const { zoomRegions, clipRegions, audioRegions } = params; + const zooms = zoomRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: ZOOM_ROW_ID, + })); + const clips = clipRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: CLIP_ROW_ID, + })); + const audios = audioRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: getAudioTrackRowId(r.trackIndex ?? 0), + })); + return [...zooms, ...clips, ...audios]; +} + +export function resolveDropRowId( + id: string, + proposedRowId: string, + timelineItems: TimelineRenderItem[], +) { + const currentRowId = timelineItems.find((item) => item.id === id)?.rowId; + if (!currentRowId) { + return proposedRowId; + } + + if (isAnnotationTrackRowId(currentRowId)) { + return isAnnotationTrackRowId(proposedRowId) + ? getAnnotationTrackRowId(getAnnotationTrackIndex(proposedRowId)) + : currentRowId; + } + + if (isAudioTrackRowId(currentRowId)) { + return isAudioTrackRowId(proposedRowId) + ? getAudioTrackRowId(getAudioTrackIndex(proposedRowId)) + : currentRowId; + } + + return currentRowId; +}