@dailies-studio/shot-editor 0.1.0

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.
Files changed (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,146 @@
1
+ // The marker strip: one diamond per marker, at its MASTER frame.
2
+ //
3
+ // ── WHY THE STRIP SITS ABOVE THE AUDIO AND BELOW THE TRACKS ─────────────
4
+ //
5
+ // A marker is a note about the WHOLE video — `MarkerRef.frame` is a master
6
+ // frame and nothing scene-local can address one — so it cannot live inside a
7
+ // scene group. It sits directly over the audio lane because the marks are
8
+ // almost always beats in that audio, and reading a diamond against the waveform
9
+ // under it is the entire reason the strip is drawn rather than listed.
10
+ //
11
+ // ── ANCHORING: THE MARKS FOLLOW THE MUSIC ───────────────────────────────
12
+ //
13
+ // A beat marker that stays put when the music moves is the most-filed complaint
14
+ // against Premiere's sequence markers, and it is why Final Cut made clip
15
+ // ownership a fiat. The behaviour lives in `writeAudioDrag`, not here: a body
16
+ // drag on an audio clip carries every marker inside the clip's window, in the
17
+ // same scrub capture, so it is one undo entry and Escape restores the clip and
18
+ // its marks together.
19
+ //
20
+ // It is DERIVED CONTAINMENT and not a stored `marker.clipId`, and that choice is
21
+ // argued in model.ts's header. The short version: a stored owner needs an answer
22
+ // for clip-deleted, clip-trimmed-past-the-marker, two-clips-overlapping and
23
+ // marker-dragged-out, and each answer is a rule the author cannot see. What is
24
+ // on screen is the rule: the marks under the bar move with the bar.
25
+ //
26
+ // So this lane's own drag is the simple one. It moves ONE marker to a frame,
27
+ // through the dock's shared scrub and the shared `snap.ts` — there is no second
28
+ // snapper here either.
29
+ import type { PointerEvent as ReactPointerEvent } from "react";
30
+ import { frameToPx, startDrag } from "@dailies-studio/timeline";
31
+ import type { Rational } from "@dailies-studio/engine";
32
+ import type { MarkerRef } from "@dailies-studio/shot/doc";
33
+ import { useShotEditor, useStore } from "../../state/context.js";
34
+ import { frameDelta, timecode } from "./model.js";
35
+ import { isSnapSuppressed } from "./snap.js";
36
+ import type { SnapPress } from "./snap.js";
37
+
38
+ export function MarkerLane(props: { ppf: number; labelWidth: number; beginSnap?: SnapPress }) {
39
+ const { ppf, labelWidth, beginSnap } = props;
40
+ const store = useStore();
41
+ const doc = useShotEditor((s) => s.doc);
42
+ const selected = useShotEditor((s) => s.selectedMarker);
43
+ if (doc === null) return null;
44
+ const markers = doc.markers ?? [];
45
+
46
+ return (
47
+ <div className="marker-lane" data-testid="marker-lane">
48
+ <div className="marker-label-cell" style={{ width: labelWidth }}>
49
+ <span className="marker-lane-name">Markers</span>
50
+ <button
51
+ type="button"
52
+ className="marker-add"
53
+ title="Add a marker at the playhead (M)"
54
+ aria-label="Add a marker at the playhead"
55
+ onClick={() => store.addMarkerAtPlayhead()}
56
+ >
57
+ +
58
+ </button>
59
+ </div>
60
+ <div className="marker-track">
61
+ {markers.length === 0 ? (
62
+ <span className="marker-empty">Press M to mark this frame</span>
63
+ ) : (
64
+ markers.map((marker) => (
65
+ <MarkerPin
66
+ key={marker.id}
67
+ marker={marker}
68
+ selected={marker.id === selected}
69
+ ppf={ppf}
70
+ fps={doc.fps}
71
+ beginSnap={beginSnap}
72
+ />
73
+ ))
74
+ )}
75
+ </div>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ function MarkerPin(props: {
81
+ marker: MarkerRef;
82
+ selected: boolean;
83
+ ppf: number;
84
+ fps: Rational;
85
+ beginSnap: SnapPress | undefined;
86
+ }) {
87
+ const { marker, selected, ppf, fps, beginSnap } = props;
88
+ const store = useStore();
89
+ // Subscribed to the BOOLEAN, not the frame: useSyncExternalStore only
90
+ // notifies when the selected value changes, so a pin re-renders exactly
91
+ // twice per playhead pass-through instead of once per playback frame — and
92
+ // the lane above it never re-renders at all.
93
+ const atPlayhead = useShotEditor((s) => s.frame === marker.frame);
94
+
95
+ const drag = (e: ReactPointerEvent<HTMLElement>): void => {
96
+ e.preventDefault();
97
+ e.stopPropagation();
98
+ // Selecting on the PRESS and not on a click: the press is what opens the
99
+ // inspector on the thing about to move, and a drag never fires a click.
100
+ store.selectMarker(marker.id);
101
+ const startX = e.clientX;
102
+ // Its OWN frame is excluded, or the diamond sits on its own snap target and
103
+ // the drag never leaves the ground.
104
+ const snap = beginSnap?.({ markerId: marker.id }, isSnapSuppressed(e));
105
+ store.beginBlockDrag();
106
+ startDrag(e, {
107
+ onMove(ev) {
108
+ // Measured from the PRESS, never accumulated: every capture re-derives
109
+ // from the scrub's base, so a coalesced move or a snap that bites and
110
+ // un-bites cannot make the mark drift.
111
+ const raw = marker.frame + frameDelta(ev.clientX - startX, ppf);
112
+ store.dragMarker(marker.id, snap === undefined ? raw : snap(raw));
113
+ },
114
+ onEnd: (commit) => store.endBlockDrag(commit),
115
+ });
116
+ };
117
+
118
+ const label = marker.label === undefined || marker.label === "" ? "Marker" : marker.label;
119
+ return (
120
+ <span
121
+ className={`marker-pin${selected ? " is-selected" : ""}${atPlayhead ? " is-at-playhead" : ""}`}
122
+ data-marker={marker.id}
123
+ style={{ left: frameToPx(marker.frame, ppf) }}
124
+ role="button"
125
+ tabIndex={-1}
126
+ aria-label={`${label} at ${timecode(marker.frame, fps)}`}
127
+ aria-pressed={selected}
128
+ title={`${label} · ${timecode(marker.frame, fps)} · drag to move it, ⌘drag to ignore snapping`}
129
+ onPointerDown={drag}
130
+ >
131
+ {/* The diamond is a rotated square rather than an SVG: it is 8px of
132
+ decoration on a lane that may hold a hundred of them, and a rotation
133
+ on a span costs no node the browser has to lay out separately. Its own
134
+ `color` overrides the lane's default, which is the only thing
135
+ `MarkerRef.color` is for. */}
136
+ <span
137
+ className="marker-diamond"
138
+ style={marker.color === undefined ? undefined : { background: marker.color }}
139
+ aria-hidden="true"
140
+ />
141
+ {/* Hover (and selection) only, in CSS. Always-on labels turn a strip of
142
+ beats into a wall of overlapping text at any useful zoom. */}
143
+ <span className="marker-pin-label">{label}</span>
144
+ </span>
145
+ );
146
+ }
@@ -0,0 +1,145 @@
1
+ // The SCENE LANE: scenes back to back over the master strip.
2
+ //
3
+ // Slim and secondary, and the ONLY home for the three verbs that are about
4
+ // scenes rather than about time — create, reorder, trim. The tracks below show
5
+ // every scene at once now, so this lane no longer decides what is on screen; it
6
+ // is the handle on the document's coarsest structure, sitting directly above the
7
+ // boundaries it draws.
8
+ //
9
+ // A bar's WIDTH is `scene.durationFrames` and its POSITION is a prefix sum over
10
+ // `doc.scenes` — derived, never stored. So trimming a bar writes one field and
11
+ // every bar after it moves for free, and reordering is a splice. Storing
12
+ // per-scene offsets would desync on both, and the desync only shows up in an
13
+ // export.
14
+ import { frameToPx, startDrag } from "@dailies-studio/timeline";
15
+ import type { PointerEvent as ReactPointerEvent } from "react";
16
+ import { useShotEditor, useStore } from "../../state/context.js";
17
+ import { frameDelta, sceneBars } from "./model.js";
18
+
19
+ /** Its own component so the per-frame tick re-renders one absolutely
20
+ * positioned div, not every scene bar above it. */
21
+ function ScenePlayhead({ ppf }: { ppf: number }) {
22
+ const frame = useShotEditor((s) => s.frame);
23
+ return <div className="scene-playhead" style={{ left: frameToPx(frame, ppf) }} />;
24
+ }
25
+
26
+ export function SceneStrip({ ppf, labelWidth }: { ppf: number; labelWidth: number }) {
27
+ const store = useStore();
28
+ const doc = useShotEditor((s) => s.doc);
29
+ const sceneId = useShotEditor((s) => s.sceneId);
30
+ if (doc === null) return null;
31
+ const bars = sceneBars(doc);
32
+
33
+ return (
34
+ <div className="scene-strip" data-testid="scene-strip">
35
+ <div className="scene-strip-label" style={{ width: labelWidth }}>
36
+ <span>Shots</span>
37
+ <button
38
+ type="button"
39
+ className="scene-strip-add"
40
+ aria-label="Add shot"
41
+ title="Add a shot at the end, and go to it"
42
+ onClick={() => store.addScene()}
43
+ >
44
+ +
45
+ </button>
46
+ </div>
47
+ <div className="scene-strip-lane">
48
+ {bars.map((bar, index) => (
49
+ <div
50
+ key={bar.id}
51
+ className={`scene-bar${bar.id === sceneId ? " is-current" : ""}`}
52
+ style={{ left: frameToPx(bar.start, ppf), width: Math.max(8, frameToPx(bar.end - bar.start, ppf)) }}
53
+ data-scene={bar.id}
54
+ draggable
55
+ onDragStart={(e) => e.dataTransfer.setData("text/plain", bar.id)}
56
+ onDragOver={(e) => e.preventDefault()}
57
+ onDrop={(e) => {
58
+ e.preventDefault();
59
+ const from = e.dataTransfer.getData("text/plain");
60
+ if (from.length > 0 && from !== bar.id) store.reorderScene(from, index);
61
+ }}
62
+ onClick={() => {
63
+ // Clicking a scene both SELECTS it and moves the playhead into
64
+ // it: the micro lane follows the playhead, so selecting without
65
+ // seeking would show one scene's tracks under another's ruler.
66
+ // The frame is read AT THE CLICK, not subscribed: a handler
67
+ // needs the current value once, and the subscription was what
68
+ // re-rendered every bar on every playback frame.
69
+ store.setScene(bar.id);
70
+ const frame = store.getState().frame;
71
+ if (frame < bar.start || frame >= bar.end) store.seek(bar.start);
72
+ }}
73
+ // Zoom the dock to this shot. A COMPOUND on purpose: the single
74
+ // click above fires first on both clicks, so the shot is selected
75
+ // and the playhead is inside it before the lane reframes around
76
+ // it. `\` (fit) is the way back out.
77
+ onDoubleClick={() => store.fitDock(bar.id)}
78
+ title="Double-click to zoom the timeline to this shot"
79
+ >
80
+ <span className="scene-bar-name">{bar.name}</span>
81
+ <SceneTrimHandle
82
+ onDrag={(deltaPx) => store.trimScene(bar.id, bar.end - bar.start + frameDelta(deltaPx, ppf))}
83
+ onStart={() => store.beginBlockDrag()}
84
+ onEnd={(commit) => store.endBlockDrag(commit)}
85
+ />
86
+ </div>
87
+ ))}
88
+ {/* The boundaries themselves. A diamond per cut, filled when the shot
89
+ before it blends into the one after — strip geometry rather than bar
90
+ geometry, because a transition is about the SEAM and the bars stay
91
+ scene-local. Nothing after the last bar: there is no seam there. */}
92
+ {bars.slice(0, -1).map((bar) => (
93
+ <button
94
+ key={`cut-${bar.id}`}
95
+ type="button"
96
+ className={`scene-cut${doc.scenesById[bar.id]?.transition !== undefined ? " is-blend" : ""}`}
97
+ style={{ left: frameToPx(bar.end, ppf) }}
98
+ aria-label={`Transition after ${bar.name}`}
99
+ title={
100
+ doc.scenesById[bar.id]?.transition === undefined
101
+ ? `Cut after ${bar.name}`
102
+ : `${doc.scenesById[bar.id]!.transition!.kind} after ${bar.name}`
103
+ }
104
+ onClick={(e) => {
105
+ e.stopPropagation();
106
+ // Select the OUTGOING shot: that is where the transition is
107
+ // stored and where the inspector edits it. The playhead goes to
108
+ // the last frame it owns, so the stage shows the frame the blend
109
+ // starts from.
110
+ store.setScene(bar.id);
111
+ store.seek(Math.max(bar.start, bar.end - 1));
112
+ }}
113
+ />
114
+ ))}
115
+ {/* One playhead across the whole master timeline, so the macro lane
116
+ always shows where the export is. */}
117
+ <ScenePlayhead ppf={ppf} />
118
+ </div>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ function SceneTrimHandle({
124
+ onStart,
125
+ onDrag,
126
+ onEnd,
127
+ }: {
128
+ onStart(): void;
129
+ onDrag(deltaPx: number): void;
130
+ onEnd(commit: boolean): void;
131
+ }) {
132
+ const onPointerDown = (e: ReactPointerEvent<HTMLSpanElement>): void => {
133
+ e.preventDefault();
134
+ e.stopPropagation();
135
+ const startX = e.clientX;
136
+ onStart();
137
+ // Absolute from the PRESS, never cumulative — the same rule every drag in
138
+ // this lane follows, and the reason a coalesced move cannot make it drift.
139
+ startDrag(e, {
140
+ onMove: (ev) => onDrag(ev.clientX - startX),
141
+ onEnd: (commit) => onEnd(commit),
142
+ });
143
+ };
144
+ return <span className="scene-trim" data-handle="scene-out" onPointerDown={onPointerDown} aria-hidden="true" />;
145
+ }