add: starting refactoring of the timeline phase2 of the plan

This commit is contained in:
Alan Trebugeais
2026-05-08 11:18:23 +02:00
parent eef863198c
commit a9133158d6
15 changed files with 1247 additions and 514 deletions
+4
View File
@@ -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";
@@ -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é.
@@ -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
@@ -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<TimelineEditorHandle, TimelineEditorProps>(
}
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<TimelineEditorHandle, TimelineEditorProps>(
],
);
const timelineItems = useMemo<TimelineRenderItem[]>(() => {
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<TimelineRenderItem[]>(
() =>
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],
);
@@ -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 (
@@ -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;
@@ -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);
});
});
@@ -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;
}
@@ -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 };
}
@@ -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);
});
});
@@ -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`;
}
@@ -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 } });
});
});
@@ -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<DndEngineConfig, "totalMs" | "minItemDurationMs">): 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<DndEngineConfig, "totalMs" | "minVisibleRangeMs">): 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<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">): 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<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">): 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<DndEngineConfig, "totalMs" | "minItemDurationMs" | "allRegionSpans" | "hasOverlap">): 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<DndEngineConfig, "allRegionSpans" | "totalMs" | "minItemDurationMs" | "hasOverlap">,
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 };
}
@@ -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");
});
});
@@ -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;
}