@rendiv/studio 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ import type { TimelineEntry, TimelineOverride } from '@rendiv/core';
2
+ import type { Track, TrackEntry } from './types';
3
+
4
+ /**
5
+ * Assign entries to tracks, respecting pinned trackIndex from overrides.
6
+ *
7
+ * 1. Entries with a trackIndex override are placed on their assigned track.
8
+ * 2. Remaining entries are auto-assigned via greedy bin-packing,
9
+ * skipping occupied time ranges on each track.
10
+ * 3. Only leaf entries (entries with no children) appear as blocks.
11
+ *
12
+ * Track 0 (top) renders in front; higher track indices render behind.
13
+ */
14
+ export function assignTracks(
15
+ entries: TimelineEntry[],
16
+ overrides: Map<string, TimelineOverride>,
17
+ ): Track[] {
18
+ // Find parent IDs to identify leaves
19
+ const parentIds = new Set(entries.map((e) => e.parentId).filter(Boolean));
20
+ const leaves = entries.filter((e) => !parentIds.has(e.id));
21
+
22
+ // Sort by from position (start time)
23
+ const sorted = [...leaves].sort((a, b) => a.from - b.from);
24
+
25
+ // Separate pinned (has trackIndex override) from unpinned
26
+ const pinned: { entry: TimelineEntry; trackIndex: number; hasOverride: boolean }[] = [];
27
+ const unpinned: TimelineEntry[] = [];
28
+
29
+ for (const entry of sorted) {
30
+ const override = overrides.get(entry.namePath);
31
+ if (override?.trackIndex !== undefined) {
32
+ pinned.push({ entry, trackIndex: override.trackIndex, hasOverride: true });
33
+ } else {
34
+ unpinned.push(entry);
35
+ }
36
+ }
37
+
38
+ // Determine how many tracks we need (at minimum, enough for pinned entries)
39
+ let maxTrack = -1;
40
+ for (const p of pinned) {
41
+ if (p.trackIndex > maxTrack) maxTrack = p.trackIndex;
42
+ }
43
+
44
+ // Build track structures — at least enough for pinned entries
45
+ const trackCount = maxTrack + 1;
46
+ const tracks: Track[] = [];
47
+ const trackEnds: number[] = [];
48
+ for (let i = 0; i < trackCount; i++) {
49
+ tracks.push({ id: i, entries: [] });
50
+ trackEnds.push(0);
51
+ }
52
+
53
+ // Place pinned entries first
54
+ for (const p of pinned) {
55
+ const idx = p.trackIndex;
56
+ const te: TrackEntry = { entry: p.entry, trackIndex: idx, hasOverride: true };
57
+ tracks[idx].entries.push(te);
58
+ const end = p.entry.from + p.entry.durationInFrames;
59
+ if (end > trackEnds[idx]) trackEnds[idx] = end;
60
+ }
61
+
62
+ // Greedy bin-pack unpinned entries into existing or new tracks
63
+ for (const entry of unpinned) {
64
+ const end = entry.from + entry.durationInFrames;
65
+ let assigned = false;
66
+ const hasOverride = overrides.has(entry.namePath);
67
+
68
+ for (let i = 0; i < tracks.length; i++) {
69
+ // Check if this entry fits without overlapping any existing entry on this track
70
+ const fits = !tracks[i].entries.some((te) => {
71
+ const teEnd = te.entry.from + te.entry.durationInFrames;
72
+ return entry.from < teEnd && end > te.entry.from;
73
+ });
74
+
75
+ if (fits) {
76
+ const te: TrackEntry = { entry, trackIndex: i, hasOverride };
77
+ tracks[i].entries.push(te);
78
+ if (end > trackEnds[i]) trackEnds[i] = end;
79
+ assigned = true;
80
+ break;
81
+ }
82
+ }
83
+
84
+ if (!assigned) {
85
+ const idx = tracks.length;
86
+ const te: TrackEntry = { entry, trackIndex: idx, hasOverride };
87
+ tracks.push({ id: idx, entries: [te] });
88
+ trackEnds.push(end);
89
+ assigned = true;
90
+ }
91
+ }
92
+
93
+ return tracks;
94
+ }
@@ -0,0 +1,39 @@
1
+ import type { TimelineEntry, TimelineOverride } from '@rendiv/core';
2
+
3
+ export interface TrackEntry {
4
+ entry: TimelineEntry;
5
+ trackIndex: number;
6
+ hasOverride: boolean;
7
+ }
8
+
9
+ export interface Track {
10
+ id: number;
11
+ entries: TrackEntry[];
12
+ }
13
+
14
+ export type DragEdge = 'body' | 'left' | 'right';
15
+
16
+ export interface DragOperation {
17
+ namePath: string;
18
+ edge: DragEdge;
19
+ startClientX: number;
20
+ startClientY: number;
21
+ originalFrom: number;
22
+ originalDuration: number;
23
+ originalTrackIndex: number;
24
+ }
25
+
26
+ export interface TimelineEditorProps {
27
+ entries: TimelineEntry[];
28
+ currentFrame: number;
29
+ totalFrames: number;
30
+ fps: number;
31
+ compositionName: string;
32
+ onSeek: (frame: number) => void;
33
+ overrides: Map<string, TimelineOverride>;
34
+ onOverrideChange: (namePath: string, override: TimelineOverride) => void;
35
+ onOverrideRemove: (namePath: string) => void;
36
+ onOverridesClear: () => void;
37
+ view: 'editor' | 'tree';
38
+ onViewChange: (view: 'editor' | 'tree') => void;
39
+ }
@@ -0,0 +1,98 @@
1
+ import { useRef, useCallback, useEffect } from 'react';
2
+ import type { DragOperation, DragEdge } from './types';
3
+
4
+ interface UseTimelineDragOptions {
5
+ pixelsPerFrame: number;
6
+ trackHeight: number;
7
+ trackGap: number;
8
+ onOverrideChange: (namePath: string, override: { from: number; durationInFrames: number; trackIndex?: number }) => void;
9
+ /** Check whether the dragged block can be placed on the target track without overlapping. */
10
+ canPlaceOnTrack: (namePath: string, from: number, duration: number, trackIndex: number) => boolean;
11
+ }
12
+
13
+ interface UseTimelineDragReturn {
14
+ isDragging: boolean;
15
+ dragNamePath: string | null;
16
+ startDrag: (namePath: string, edge: DragEdge, clientX: number, clientY: number, originalFrom: number, originalDuration: number, originalTrackIndex: number) => void;
17
+ }
18
+
19
+ export function useTimelineDrag({ pixelsPerFrame, trackHeight, trackGap, onOverrideChange, canPlaceOnTrack }: UseTimelineDragOptions): UseTimelineDragReturn {
20
+ const dragRef = useRef<DragOperation | null>(null);
21
+ const isDraggingRef = useRef(false);
22
+ const dragNamePathRef = useRef<string | null>(null);
23
+ // Force re-renders when drag state changes
24
+ const forceUpdateRef = useRef(0);
25
+
26
+ const startDrag = useCallback((namePath: string, edge: DragEdge, clientX: number, clientY: number, originalFrom: number, originalDuration: number, originalTrackIndex: number) => {
27
+ dragRef.current = { namePath, edge, startClientX: clientX, startClientY: clientY, originalFrom, originalDuration, originalTrackIndex };
28
+ isDraggingRef.current = true;
29
+ dragNamePathRef.current = namePath;
30
+ forceUpdateRef.current++;
31
+ }, []);
32
+
33
+ useEffect(() => {
34
+ const handleMouseMove = (e: MouseEvent) => {
35
+ const drag = dragRef.current;
36
+ if (!drag) return;
37
+
38
+ const deltaPixelsX = e.clientX - drag.startClientX;
39
+ const deltaFrames = Math.round(deltaPixelsX / pixelsPerFrame);
40
+
41
+ let newFrom = drag.originalFrom;
42
+ let newDuration = drag.originalDuration;
43
+ let newTrackIndex: number | undefined;
44
+
45
+ switch (drag.edge) {
46
+ case 'body': {
47
+ newFrom = Math.max(0, drag.originalFrom + deltaFrames);
48
+ // Vertical drag: compute new track from Y delta
49
+ const deltaPixelsY = e.clientY - drag.startClientY;
50
+ const rowHeight = trackHeight + trackGap;
51
+ const trackDelta = Math.round(deltaPixelsY / rowHeight);
52
+ const candidateTrack = Math.max(0, drag.originalTrackIndex + trackDelta);
53
+
54
+ // Only move to the new track if the block fits without overlapping
55
+ if (canPlaceOnTrack(drag.namePath, newFrom, newDuration, candidateTrack)) {
56
+ newTrackIndex = candidateTrack;
57
+ } else {
58
+ // Stay on current track
59
+ newTrackIndex = drag.originalTrackIndex;
60
+ }
61
+ break;
62
+ }
63
+ case 'left':
64
+ newFrom = Math.max(0, drag.originalFrom + deltaFrames);
65
+ newDuration = Math.max(1, drag.originalDuration - deltaFrames);
66
+ newTrackIndex = drag.originalTrackIndex;
67
+ break;
68
+ case 'right':
69
+ newDuration = Math.max(1, drag.originalDuration + deltaFrames);
70
+ newTrackIndex = drag.originalTrackIndex;
71
+ break;
72
+ }
73
+
74
+ onOverrideChange(drag.namePath, { from: newFrom, durationInFrames: newDuration, trackIndex: newTrackIndex });
75
+ };
76
+
77
+ const handleMouseUp = () => {
78
+ if (dragRef.current) {
79
+ dragRef.current = null;
80
+ isDraggingRef.current = false;
81
+ dragNamePathRef.current = null;
82
+ }
83
+ };
84
+
85
+ window.addEventListener('mousemove', handleMouseMove);
86
+ window.addEventListener('mouseup', handleMouseUp);
87
+ return () => {
88
+ window.removeEventListener('mousemove', handleMouseMove);
89
+ window.removeEventListener('mouseup', handleMouseUp);
90
+ };
91
+ }, [pixelsPerFrame, trackHeight, trackGap, onOverrideChange, canPlaceOnTrack]);
92
+
93
+ return {
94
+ isDragging: isDraggingRef.current,
95
+ dragNamePath: dragNamePathRef.current,
96
+ startDrag,
97
+ };
98
+ }
@@ -0,0 +1,65 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+
3
+ interface UseTimelineZoomOptions {
4
+ totalFrames: number;
5
+ containerRef: React.RefObject<HTMLDivElement | null>;
6
+ }
7
+
8
+ interface UseTimelineZoomReturn {
9
+ pixelsPerFrame: number;
10
+ scrollLeft: number;
11
+ setScrollLeft: (v: number) => void;
12
+ setPixelsPerFrame: (v: number) => void;
13
+ handleWheel: (e: WheelEvent) => void;
14
+ }
15
+
16
+ const MIN_PX_PER_FRAME = 0.5;
17
+ const MAX_PX_PER_FRAME = 20;
18
+
19
+ export function useTimelineZoom({ totalFrames, containerRef }: UseTimelineZoomOptions): UseTimelineZoomReturn {
20
+ const [pixelsPerFrame, setPixelsPerFrame] = useState(() => {
21
+ // Start at a reasonable default — will be adjusted on mount
22
+ return 3;
23
+ });
24
+ const [scrollLeft, setScrollLeft] = useState(0);
25
+ const ppfRef = useRef(pixelsPerFrame);
26
+ ppfRef.current = pixelsPerFrame;
27
+
28
+ // Fit to width on mount
29
+ useEffect(() => {
30
+ const el = containerRef.current;
31
+ if (!el || totalFrames <= 0) return;
32
+ const fitPpf = Math.max(MIN_PX_PER_FRAME, el.clientWidth / totalFrames);
33
+ setPixelsPerFrame(Math.min(fitPpf, MAX_PX_PER_FRAME));
34
+ setScrollLeft(0);
35
+ }, [totalFrames, containerRef]);
36
+
37
+ const handleWheel = useCallback((e: WheelEvent) => {
38
+ const el = containerRef.current;
39
+ if (!el) return;
40
+
41
+ if (e.ctrlKey || e.metaKey) {
42
+ // Zoom: Ctrl/Cmd + wheel
43
+ e.preventDefault();
44
+ const rect = el.getBoundingClientRect();
45
+ const cursorX = e.clientX - rect.left;
46
+ const oldPpf = ppfRef.current;
47
+ const zoomFactor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
48
+ const newPpf = Math.min(MAX_PX_PER_FRAME, Math.max(MIN_PX_PER_FRAME, oldPpf * zoomFactor));
49
+
50
+ // Keep the frame under the cursor in place
51
+ const frameAtCursor = (cursorX + el.scrollLeft) / oldPpf;
52
+ const newScrollLeft = Math.max(0, frameAtCursor * newPpf - cursorX);
53
+
54
+ setPixelsPerFrame(newPpf);
55
+ setScrollLeft(newScrollLeft);
56
+ } else {
57
+ // Horizontal scroll
58
+ e.preventDefault();
59
+ const delta = e.deltaX !== 0 ? e.deltaX : e.deltaY;
60
+ setScrollLeft((prev) => Math.max(0, prev + delta));
61
+ }
62
+ }, [containerRef]);
63
+
64
+ return { pixelsPerFrame, scrollLeft, setScrollLeft, setPixelsPerFrame, handleWheel };
65
+ }