@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.
- package/dist/assets/index-B7notge7.css +1 -0
- package/dist/assets/index-BBE6Ta9L.js +52 -0
- package/dist/index.html +13 -0
- package/package.json +45 -0
- package/src/api.ts +289 -0
- package/src/app/App.tsx +394 -0
- package/src/audio/index.tsx +74 -0
- package/src/audio/preview.ts +364 -0
- package/src/bridge.ts +45 -0
- package/src/bridgeTypes.ts +582 -0
- package/src/channels.ts +40 -0
- package/src/main.tsx +15 -0
- package/src/panels/ResizeHandle.tsx +113 -0
- package/src/panels/agent/AgentPanel.tsx +478 -0
- package/src/panels/agent/agent.css +322 -0
- package/src/panels/export/ExportDialog.tsx +284 -0
- package/src/panels/files/FilePanel.tsx +564 -0
- package/src/panels/files/McpSection.tsx +66 -0
- package/src/panels/files/icons.tsx +118 -0
- package/src/panels/header/DocSwitcher.tsx +85 -0
- package/src/panels/header/Header.tsx +132 -0
- package/src/panels/header/HistoryControls.tsx +20 -0
- package/src/panels/header/StatusBadges.tsx +49 -0
- package/src/panels/header/TransportBar.tsx +86 -0
- package/src/panels/header/ZoomControls.tsx +38 -0
- package/src/panels/help/HelpSection.tsx +190 -0
- package/src/panels/inspector/FramingRows.tsx +69 -0
- package/src/panels/inspector/InspectorPanel.tsx +178 -0
- package/src/panels/inspector/MarkerSection.tsx +63 -0
- package/src/panels/inspector/ParamRow.tsx +71 -0
- package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
- package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
- package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
- package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
- package/src/panels/inspector/animate/ShotSection.tsx +126 -0
- package/src/panels/inspector/animate/useBlocks.ts +43 -0
- package/src/panels/inspector/design/AlignRow.tsx +63 -0
- package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
- package/src/panels/inspector/design/ClonerSection.tsx +142 -0
- package/src/panels/inspector/design/ComponentSection.tsx +94 -0
- package/src/panels/inspector/design/CornerSection.tsx +52 -0
- package/src/panels/inspector/design/FillSection.tsx +89 -0
- package/src/panels/inspector/design/HtmlSection.tsx +50 -0
- package/src/panels/inspector/design/InstanceSection.tsx +302 -0
- package/src/panels/inspector/design/MediaSection.tsx +251 -0
- package/src/panels/inspector/design/MeshSection.tsx +172 -0
- package/src/panels/inspector/design/ShadowSection.tsx +73 -0
- package/src/panels/inspector/design/StrokeSection.tsx +85 -0
- package/src/panels/inspector/design/TransformSection.tsx +91 -0
- package/src/panels/inspector/design/TypographySection.tsx +170 -0
- package/src/panels/inspector/scene/CameraSection.tsx +59 -0
- package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
- package/src/panels/inspector/scene/SceneSection.tsx +71 -0
- package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
- package/src/panels/inspector/useDocWrite.ts +268 -0
- package/src/panels/material/MaterialPicker.tsx +172 -0
- package/src/panels/stage/StageMenu.tsx +38 -0
- package/src/panels/stage/StagePanel.tsx +131 -0
- package/src/panels/timeline/AudioLane.tsx +340 -0
- package/src/panels/timeline/BlockRow.tsx +242 -0
- package/src/panels/timeline/ChannelRow.tsx +266 -0
- package/src/panels/timeline/LifespanRow.tsx +165 -0
- package/src/panels/timeline/MarkerLane.tsx +146 -0
- package/src/panels/timeline/SceneStrip.tsx +145 -0
- package/src/panels/timeline/TimelineDock.tsx +488 -0
- package/src/panels/timeline/model.ts +641 -0
- package/src/panels/timeline/snap.ts +191 -0
- package/src/panels/toolbar/AddMenu.tsx +230 -0
- package/src/panels/toolbar/CameraViewBar.tsx +70 -0
- package/src/panels/toolbar/ShadingBar.tsx +42 -0
- package/src/panels/toolbar/ToolDockBar.tsx +110 -0
- package/src/panels/toolbar/ViewModeBar.tsx +49 -0
- package/src/shotTokens.css +131 -0
- package/src/stage/FrustumGizmo.tsx +184 -0
- package/src/stage/GridPlane.tsx +274 -0
- package/src/stage/MoveGizmo.tsx +411 -0
- package/src/stage/PerfHud.tsx +206 -0
- package/src/stage/SplitRenderer.tsx +511 -0
- package/src/stage/StageApp.tsx +3423 -0
- package/src/stage/StageTools.tsx +581 -0
- package/src/stage/bridgeClient.ts +278 -0
- package/src/stage/feed.ts +243 -0
- package/src/stage/frustumGeometry.ts +172 -0
- package/src/stage/gizmoMath.ts +494 -0
- package/src/stage/handles.ts +151 -0
- package/src/stage/instanceModules.ts +152 -0
- package/src/stage/interactions/move.ts +172 -0
- package/src/stage/interactions/panZoom.ts +60 -0
- package/src/stage/isolation.ts +155 -0
- package/src/stage/main.tsx +110 -0
- package/src/stage/modalState.ts +427 -0
- package/src/stage/overlays/Overlays.tsx +1046 -0
- package/src/stage/panes.ts +121 -0
- package/src/stage/penState.ts +112 -0
- package/src/stage/project.ts +402 -0
- package/src/stage/shading.ts +55 -0
- package/src/stage/tokenColor.ts +26 -0
- package/src/stage/view.ts +182 -0
- package/src/stage/workingView.ts +636 -0
- package/src/state/agentActions.ts +203 -0
- package/src/state/clientId.ts +25 -0
- package/src/state/context.tsx +33 -0
- package/src/state/insert.ts +507 -0
- package/src/state/keymap.ts +560 -0
- package/src/state/menu.ts +141 -0
- package/src/state/panelSize.ts +26 -0
- package/src/state/renderEvents.ts +100 -0
- package/src/state/store.ts +4094 -0
- package/src/state/triangles.ts +70 -0
- package/src/state/types.ts +384 -0
- package/src/styles.css +2948 -0
- package/src/tools.ts +181 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// The focused block's params, timing and easing.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY THIS IS NOT `KeyframeEditor` ───────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `@dailies-studio/timeline`'s ease editor is a fine component and its INPUTS
|
|
6
|
+
// are keyframe-shaped: `refs: KeyframeAddr[]` and a `TimelineGesture` whose
|
|
7
|
+
// capture emits `{kind:"setEase", refs}`. There are no keyframes in this lane,
|
|
8
|
+
// so every one of those would be a fabricated address. What DOES transfer is
|
|
9
|
+
// everything pure: `EASE_PRESETS`, `matchPreset`, `SPRING_DEFAULT`, and the pad
|
|
10
|
+
// geometry `padToPx`/`pxToPad`. Those are imported, not re-derived, so the two
|
|
11
|
+
// chromes cannot disagree about what "Ease Out" means.
|
|
12
|
+
//
|
|
13
|
+
// ── THE SPRING WART, AND THE TAIL THAT ADMITS IT ───────────────────────
|
|
14
|
+
//
|
|
15
|
+
// A spring has an INTRINSIC duration in seconds. So dragging a spring block's
|
|
16
|
+
// right edge changes where its window CLIPS, not how fast it settles — which is
|
|
17
|
+
// correct physics and matches springs everywhere else in the engine, but is
|
|
18
|
+
// invisible unless the UI says so. It says so twice: the duration field is
|
|
19
|
+
// disabled with the reason attached, and the preview draws the part of the
|
|
20
|
+
// settle that falls OUTSIDE the block window as a dashed tail. If the tail is
|
|
21
|
+
// long, the block is cutting the spring off mid-flight, and the author can see
|
|
22
|
+
// exactly that.
|
|
23
|
+
import { useRef } from "react";
|
|
24
|
+
import {
|
|
25
|
+
blockProgressAt,
|
|
26
|
+
motionPresets,
|
|
27
|
+
presetsForSlot,
|
|
28
|
+
MIN_ANIMATE_FRAMES,
|
|
29
|
+
} from "@dailies-studio/shot/doc";
|
|
30
|
+
import type { AnimBlock } from "@dailies-studio/shot/doc";
|
|
31
|
+
import { EASE_PRESETS, SPRING_DEFAULT, easeSelection, padToPx, pxToPad } from "@dailies-studio/timeline";
|
|
32
|
+
import type { PadGeom } from "@dailies-studio/timeline";
|
|
33
|
+
import { InspectorRow, InspectorSection, NumberField, polylinePoints } from "@dailies-studio/ui";
|
|
34
|
+
import type { Ease, SpringEase, Value } from "@dailies-studio/engine";
|
|
35
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
36
|
+
import { useShotEditor, useStore } from "../../../state/context.js";
|
|
37
|
+
import { ParamRow } from "../ParamRow.js";
|
|
38
|
+
import { useFocusedBlock } from "./useBlocks.js";
|
|
39
|
+
|
|
40
|
+
const PAD_GEOM: PadGeom = { width: 216, height: 132, padY: 30 };
|
|
41
|
+
const PREVIEW_W = 216;
|
|
42
|
+
const PREVIEW_H = 72;
|
|
43
|
+
const PREVIEW_PAD = 8;
|
|
44
|
+
const PREVIEW_SAMPLES = 96;
|
|
45
|
+
/** How far past the block's own window the settle tail is drawn, as a multiple
|
|
46
|
+
* of the window. Two windows is enough to show a spring that has not settled
|
|
47
|
+
* and short enough that a settled one draws a flat stub. */
|
|
48
|
+
const TAIL_FACTOR = 2;
|
|
49
|
+
|
|
50
|
+
export function BlockTiming() {
|
|
51
|
+
const store = useStore();
|
|
52
|
+
// A STATE SLICE OR NULL, never a fresh fallback object: a selector that
|
|
53
|
+
// mints `{num: 30, den: 1}` per call gives useSyncExternalStore a snapshot
|
|
54
|
+
// that is never Object.is-stable, which is an infinite re-render the moment
|
|
55
|
+
// this mounts with no document. Unreachable today only because the
|
|
56
|
+
// inspector early-returns on a null doc — this keeps it unreachable.
|
|
57
|
+
const fps = useShotEditor((s) => s.doc?.fps ?? null) ?? { num: 30, den: 1 };
|
|
58
|
+
const ref = useFocusedBlock();
|
|
59
|
+
if (ref === null) return null;
|
|
60
|
+
const { layer, block } = ref;
|
|
61
|
+
const preset = motionPresets[block.preset];
|
|
62
|
+
const duration = Math.max(1, Math.trunc(block.durationFrames));
|
|
63
|
+
const spring = block.ease?.type === "spring";
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<InspectorSection title="Timing" persistKey="shot:animate:timing">
|
|
67
|
+
<InspectorRow>
|
|
68
|
+
<NumberField
|
|
69
|
+
value={block.frame}
|
|
70
|
+
label="Start"
|
|
71
|
+
ariaLabel="Block start"
|
|
72
|
+
min={0}
|
|
73
|
+
step={1}
|
|
74
|
+
onCommit={(next) => store.setBlockTiming(layer.id, block.id, Math.max(0, next), duration)}
|
|
75
|
+
/>
|
|
76
|
+
<NumberField
|
|
77
|
+
value={duration}
|
|
78
|
+
label="Frames"
|
|
79
|
+
ariaLabel="Block frames"
|
|
80
|
+
min={MIN_ANIMATE_FRAMES}
|
|
81
|
+
step={1}
|
|
82
|
+
// A spring's duration is set by its physics. The field still SHOWS the
|
|
83
|
+
// window (it is real: it is where the block stops contributing), it
|
|
84
|
+
// just refuses to pretend that changing it retimes the motion.
|
|
85
|
+
disabled={spring}
|
|
86
|
+
{...(spring ? { title: "A spring settles in its own time — edit stiffness and damping." } : {})}
|
|
87
|
+
onCommit={(next) =>
|
|
88
|
+
store.setBlockTiming(layer.id, block.id, block.frame, Math.max(MIN_ANIMATE_FRAMES, next))
|
|
89
|
+
}
|
|
90
|
+
/>
|
|
91
|
+
</InspectorRow>
|
|
92
|
+
|
|
93
|
+
{preset !== undefined &&
|
|
94
|
+
preset.params.map((spec) => (
|
|
95
|
+
<ParamRow
|
|
96
|
+
key={spec.name}
|
|
97
|
+
spec={spec}
|
|
98
|
+
value={paramValue(block, spec.name, spec.default)}
|
|
99
|
+
onCommit={(next) => store.setBlockParams(layer.id, block.id, { [spec.name]: next })}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
|
|
103
|
+
<EaseControl
|
|
104
|
+
{...(block.ease === undefined ? {} : { ease: block.ease })}
|
|
105
|
+
onEase={(ease) => store.setBlockEase(layer.id, block.id, ease)}
|
|
106
|
+
onScrub={(ease) => store.setBlockEase(layer.id, block.id, ease)}
|
|
107
|
+
/>
|
|
108
|
+
<SettlePreview block={block} fps={fps} />
|
|
109
|
+
|
|
110
|
+
{/* The preset itself is swappable in place, so "make that a slide instead"
|
|
111
|
+
keeps the block's timing and easing rather than deleting and redoing. */}
|
|
112
|
+
<InspectorRow label="Preset">
|
|
113
|
+
<select
|
|
114
|
+
className="select"
|
|
115
|
+
aria-label="Block preset"
|
|
116
|
+
value={block.preset}
|
|
117
|
+
onChange={(e) => store.setBlockPreset(layer.id, block.id, e.target.value)}
|
|
118
|
+
>
|
|
119
|
+
{presetsForSlot(block.slot).map((p) => (
|
|
120
|
+
<option key={p.id} value={p.id}>
|
|
121
|
+
{p.label}
|
|
122
|
+
</option>
|
|
123
|
+
))}
|
|
124
|
+
</select>
|
|
125
|
+
</InspectorRow>
|
|
126
|
+
</InspectorSection>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function paramValue(block: AnimBlock, name: string, fallback: Value): Value {
|
|
131
|
+
const v = block.params[name];
|
|
132
|
+
return v === undefined ? fallback : v;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- easing ------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The ease row, plus whichever editor the chosen ease needs.
|
|
139
|
+
*
|
|
140
|
+
* Takes an `Ease` and not a block, because the CAMERA's shots carry one too and
|
|
141
|
+
* `clipProgress01` reads it from both the same way. Two ease editors would be
|
|
142
|
+
* two places for "Ease Out" to mean something slightly different, which is the
|
|
143
|
+
* whole reason `EASE_PRESETS` is imported rather than re-declared.
|
|
144
|
+
*/
|
|
145
|
+
export function EaseControl(props: {
|
|
146
|
+
ease?: Ease;
|
|
147
|
+
/** What the select is called. The subject differs between the two callers and
|
|
148
|
+
* the accessible name has to say which one the author is looking at. */
|
|
149
|
+
ariaLabel?: string;
|
|
150
|
+
onEase(ease: Ease): void;
|
|
151
|
+
onScrub(ease: Ease): void;
|
|
152
|
+
}) {
|
|
153
|
+
const ease = props.ease;
|
|
154
|
+
// `easeSelection` takes a LIST because the shared editor is multi-select. One
|
|
155
|
+
// block is a list of one, and reusing it keeps "Custom" meaning exactly what
|
|
156
|
+
// it means in the other two chromes.
|
|
157
|
+
const sel = easeSelection(ease === undefined ? [] : [ease]);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<>
|
|
161
|
+
<InspectorRow label="Ease">
|
|
162
|
+
<select
|
|
163
|
+
className="select"
|
|
164
|
+
aria-label={props.ariaLabel ?? "Block ease"}
|
|
165
|
+
value={sel.selectValue}
|
|
166
|
+
onChange={(e) => {
|
|
167
|
+
const preset = EASE_PRESETS.find((p) => p.id === e.target.value);
|
|
168
|
+
if (preset !== undefined) props.onEase(preset.ease);
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{EASE_PRESETS.map((p) => (
|
|
172
|
+
<option key={p.id} value={p.id}>
|
|
173
|
+
{p.label}
|
|
174
|
+
</option>
|
|
175
|
+
))}
|
|
176
|
+
{sel.selectValue === "custom" && (
|
|
177
|
+
<option value="custom" disabled>
|
|
178
|
+
Custom
|
|
179
|
+
</option>
|
|
180
|
+
)}
|
|
181
|
+
{sel.selectValue === "mixed" && (
|
|
182
|
+
<option value="mixed" disabled>
|
|
183
|
+
—
|
|
184
|
+
</option>
|
|
185
|
+
)}
|
|
186
|
+
</select>
|
|
187
|
+
</InspectorRow>
|
|
188
|
+
{sel.ease?.type === "bezier" && <BezierPad handles={sel.ease.handles} onEase={props.onScrub} />}
|
|
189
|
+
{sel.ease?.type === "spring" && <SpringFields spring={sel.ease} onEase={props.onEase} />}
|
|
190
|
+
</>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function BezierPad(props: {
|
|
195
|
+
handles: readonly [number, number, number, number];
|
|
196
|
+
onEase(ease: Ease): void;
|
|
197
|
+
}) {
|
|
198
|
+
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
199
|
+
const drag = useRef<{ index: 0 | 1; base: [number, number, number, number] } | null>(null);
|
|
200
|
+
|
|
201
|
+
const down =
|
|
202
|
+
(index: 0 | 1) =>
|
|
203
|
+
(e: ReactPointerEvent<SVGCircleElement>): void => {
|
|
204
|
+
if (e.button !== 0) return;
|
|
205
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
206
|
+
const h = props.handles;
|
|
207
|
+
drag.current = { index, base: [h[0], h[1], h[2], h[3]] };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const move = (e: ReactPointerEvent<SVGCircleElement>): void => {
|
|
211
|
+
const d = drag.current;
|
|
212
|
+
const svg = svgRef.current;
|
|
213
|
+
if (d === null || svg === null) return;
|
|
214
|
+
const rect = svg.getBoundingClientRect();
|
|
215
|
+
const [x, y] = pxToPad(PAD_GEOM, e.clientX - rect.left, e.clientY - rect.top);
|
|
216
|
+
// Rebuilt from the DRAG-START handles, so the untouched one cannot creep as
|
|
217
|
+
// the live value it is being compared against changes underneath.
|
|
218
|
+
const handles: [number, number, number, number] =
|
|
219
|
+
d.index === 0 ? [x, y, d.base[2], d.base[3]] : [d.base[0], d.base[1], x, y];
|
|
220
|
+
props.onEase({ type: "bezier", handles });
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const up = (e: ReactPointerEvent<SVGCircleElement>): void => {
|
|
224
|
+
if (drag.current === null) return;
|
|
225
|
+
drag.current = null;
|
|
226
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const h = props.handles;
|
|
230
|
+
const [ax, ay] = padToPx(PAD_GEOM, 0, 0);
|
|
231
|
+
const [bx, by] = padToPx(PAD_GEOM, 1, 1);
|
|
232
|
+
const [x1, y1] = padToPx(PAD_GEOM, h[0], h[1]);
|
|
233
|
+
const [x2, y2] = padToPx(PAD_GEOM, h[2], h[3]);
|
|
234
|
+
const handle = (index: 0 | 1) => ({
|
|
235
|
+
className: "insp-pad-handle",
|
|
236
|
+
r: 4.5,
|
|
237
|
+
onPointerDown: down(index),
|
|
238
|
+
onPointerMove: move,
|
|
239
|
+
onPointerUp: up,
|
|
240
|
+
onPointerCancel: () => {
|
|
241
|
+
drag.current = null;
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<svg ref={svgRef} className="insp-pad" width={PAD_GEOM.width} height={PAD_GEOM.height}>
|
|
247
|
+
<rect
|
|
248
|
+
className="insp-pad-box"
|
|
249
|
+
x={0.5}
|
|
250
|
+
y={PAD_GEOM.padY + 0.5}
|
|
251
|
+
width={PAD_GEOM.width - 1}
|
|
252
|
+
height={PAD_GEOM.height - 2 * PAD_GEOM.padY - 1}
|
|
253
|
+
rx={3}
|
|
254
|
+
/>
|
|
255
|
+
<path className="insp-pad-curve" d={`M ${ax} ${ay} C ${x1} ${y1} ${x2} ${y2} ${bx} ${by}`} />
|
|
256
|
+
<line className="insp-pad-arm" x1={ax} y1={ay} x2={x1} y2={y1} />
|
|
257
|
+
<line className="insp-pad-arm" x1={bx} y1={by} x2={x2} y2={y2} />
|
|
258
|
+
<circle {...handle(0)} cx={x1} cy={y1} />
|
|
259
|
+
<circle {...handle(1)} cx={x2} cy={y2} />
|
|
260
|
+
</svg>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const SPRING_FIELDS = [
|
|
265
|
+
{ key: "stiffness", label: "Stiffness", min: 1, max: 1000, step: 1 },
|
|
266
|
+
{ key: "damping", label: "Damping", min: 0, max: 200, step: 0.5 },
|
|
267
|
+
{ key: "mass", label: "Mass", min: 0.05, max: 20, step: 0.05 },
|
|
268
|
+
] as const;
|
|
269
|
+
|
|
270
|
+
function SpringFields(props: { spring: SpringEase; onEase(ease: Ease): void }) {
|
|
271
|
+
return (
|
|
272
|
+
<InspectorRow>
|
|
273
|
+
{SPRING_FIELDS.map((f) => (
|
|
274
|
+
<NumberField
|
|
275
|
+
key={f.key}
|
|
276
|
+
value={props.spring[f.key]}
|
|
277
|
+
label={f.label}
|
|
278
|
+
ariaLabel={`Spring ${f.label.toLowerCase()}`}
|
|
279
|
+
min={f.min}
|
|
280
|
+
max={f.max}
|
|
281
|
+
step={f.step}
|
|
282
|
+
onCommit={(v) => props.onEase({ ...SPRING_DEFAULT, ...props.spring, [f.key]: v })}
|
|
283
|
+
/>
|
|
284
|
+
))}
|
|
285
|
+
</InspectorRow>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- the preview, and the tail --------------------------------------------------
|
|
290
|
+
|
|
291
|
+
function SettlePreview(props: { block: AnimBlock; fps: { num: number; den: number } }) {
|
|
292
|
+
const { block, fps } = props;
|
|
293
|
+
const duration = Math.max(1, Math.trunc(block.durationFrames));
|
|
294
|
+
const spring = block.ease?.type === "spring";
|
|
295
|
+
// Sampled through `blockProgressAt` — the SAME function the renderer folds
|
|
296
|
+
// with. A preview drawn from a local re-implementation of the ease would
|
|
297
|
+
// eventually disagree with the pixels, which is the one thing a preview must
|
|
298
|
+
// never do.
|
|
299
|
+
const to = block.frame + (spring ? duration * (1 + TAIL_FACTOR) : duration);
|
|
300
|
+
const samples: Array<{ frame: number; value: number }> = [];
|
|
301
|
+
for (let i = 0; i < PREVIEW_SAMPLES; i += 1) {
|
|
302
|
+
const frame = block.frame + ((to - block.frame) * i) / (PREVIEW_SAMPLES - 1);
|
|
303
|
+
samples.push({ frame, value: blockProgressAt(block, frame, fps) ?? 0 });
|
|
304
|
+
}
|
|
305
|
+
const line = polylinePoints(samples, PREVIEW_W, PREVIEW_H, PREVIEW_PAD);
|
|
306
|
+
if (line === null) return null;
|
|
307
|
+
// The window's right edge in preview px, so the tail is drawn from exactly
|
|
308
|
+
// where the block stops contributing.
|
|
309
|
+
const edgeX = spring ? (duration / (to - block.frame)) * PREVIEW_W : PREVIEW_W;
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<div className="insp-preview animate-preview" data-testid="block-preview">
|
|
313
|
+
<svg width={PREVIEW_W} height={PREVIEW_H}>
|
|
314
|
+
{/* One polyline, clipped twice: solid inside the window, dashed after.
|
|
315
|
+
Two <polyline>s over the same points with opposite clips beats
|
|
316
|
+
splitting the sample array, because the split point then cannot land
|
|
317
|
+
between two samples and jitter as the duration changes. */}
|
|
318
|
+
<defs>
|
|
319
|
+
<clipPath id="blk-in">
|
|
320
|
+
<rect x={0} y={0} width={edgeX} height={PREVIEW_H} />
|
|
321
|
+
</clipPath>
|
|
322
|
+
<clipPath id="blk-out">
|
|
323
|
+
<rect x={edgeX} y={0} width={PREVIEW_W - edgeX} height={PREVIEW_H} />
|
|
324
|
+
</clipPath>
|
|
325
|
+
</defs>
|
|
326
|
+
<polyline className={spring ? "is-spring" : ""} points={line.points} clipPath="url(#blk-in)" />
|
|
327
|
+
{spring && (
|
|
328
|
+
<>
|
|
329
|
+
<polyline className="is-tail" points={line.points} clipPath="url(#blk-out)" />
|
|
330
|
+
<line className="animate-preview-edge" x1={edgeX} y1={0} x2={edgeX} y2={PREVIEW_H} />
|
|
331
|
+
</>
|
|
332
|
+
)}
|
|
333
|
+
</svg>
|
|
334
|
+
{spring && (
|
|
335
|
+
<span className="insp-dim animate-preview-note">
|
|
336
|
+
dashed = after the block ends; the spring holds at its clipped value
|
|
337
|
+
</span>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The During list: the blocks the Animate gesture writes, plus a palette to add
|
|
2
|
+
// one without a gesture.
|
|
3
|
+
//
|
|
4
|
+
// Each row FOCUSES rather than expanding inline. `state.selectedBlock` is the
|
|
5
|
+
// same bit the dock's bars write, so clicking a row here highlights the bar
|
|
6
|
+
// there and puts its timing in one editor below — instead of N accordion
|
|
7
|
+
// editors that each claim to be "the" timing controls.
|
|
8
|
+
import { motionPresets, presetsForSlot } from "@dailies-studio/shot/doc";
|
|
9
|
+
import { InspectorSection } from "@dailies-studio/ui";
|
|
10
|
+
import { useShotEditor, useStore } from "../../../state/context.js";
|
|
11
|
+
import { useSelectedLayers } from "../useDocWrite.js";
|
|
12
|
+
import { useBlocksInSlot } from "./useBlocks.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_FRAMES = 24;
|
|
15
|
+
|
|
16
|
+
export function DuringBlocks() {
|
|
17
|
+
const store = useStore();
|
|
18
|
+
const layers = useSelectedLayers();
|
|
19
|
+
const blocks = useBlocksInSlot("during");
|
|
20
|
+
const focused = useShotEditor((s) => s.selectedBlock);
|
|
21
|
+
// The inspector's subject scene. Its layers are the selection's, so this is
|
|
22
|
+
// the scene every row here belongs to by construction.
|
|
23
|
+
const sceneId = useShotEditor((s) => s.sceneId);
|
|
24
|
+
const multi = layers.length > 1;
|
|
25
|
+
if (layers.length === 0 || sceneId === null) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<InspectorSection title="During" persistKey="shot:animate:during">
|
|
29
|
+
{blocks.length === 0 ? (
|
|
30
|
+
<p className="inspector-empty">
|
|
31
|
+
Press ⇧A, then drag on the canvas. The edit becomes a block ending at the playhead.
|
|
32
|
+
</p>
|
|
33
|
+
) : (
|
|
34
|
+
<ul className="animate-blocks" aria-label="During blocks">
|
|
35
|
+
{blocks.map(({ layer, block }) => {
|
|
36
|
+
const on = focused?.layerId === layer.id && focused.blockId === block.id;
|
|
37
|
+
const label = motionPresets[block.preset]?.label ?? block.preset;
|
|
38
|
+
return (
|
|
39
|
+
<li key={`${layer.id}:${block.id}`}>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
className="animate-block-row"
|
|
43
|
+
aria-pressed={on}
|
|
44
|
+
aria-label={`Block ${label} on ${layer.label}`}
|
|
45
|
+
onClick={() => store.selectBlock({ sceneId, layerId: layer.id, blockId: block.id })}
|
|
46
|
+
>
|
|
47
|
+
<span className="animate-block-name">
|
|
48
|
+
{label}
|
|
49
|
+
{/* Which layer, but only when there IS more than one — on a
|
|
50
|
+
single selection the suffix would be noise on every row. */}
|
|
51
|
+
{multi && <em className="animate-block-owner"> · {layer.label}</em>}
|
|
52
|
+
</span>
|
|
53
|
+
<span className="animate-block-span">
|
|
54
|
+
f{block.frame}–{block.frame + Math.max(1, Math.trunc(block.durationFrames))}
|
|
55
|
+
</span>
|
|
56
|
+
</button>
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className="animate-block-remove"
|
|
60
|
+
aria-label={`Remove ${label} on ${layer.label}`}
|
|
61
|
+
onClick={() => store.removeBlock(layer.id, block.id)}
|
|
62
|
+
>
|
|
63
|
+
✕
|
|
64
|
+
</button>
|
|
65
|
+
</li>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</ul>
|
|
69
|
+
)}
|
|
70
|
+
<div className="animate-palette" role="group" aria-label="During presets">
|
|
71
|
+
{presetsForSlot("during").map((preset) => (
|
|
72
|
+
<button
|
|
73
|
+
key={preset.id}
|
|
74
|
+
type="button"
|
|
75
|
+
className="animate-preset"
|
|
76
|
+
aria-label={`Add ${preset.label}`}
|
|
77
|
+
onClick={() => store.addBlock("during", preset.id, DEFAULT_FRAMES)}
|
|
78
|
+
>
|
|
79
|
+
+ {preset.label}
|
|
80
|
+
</button>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
</InspectorSection>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The In and Out palettes: click a preset, every selected layer gets that block.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY THEY STACK RATHER THAN REPLACE ─────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Clicking Fade then Slide gives a layer that fades AND slides, because the
|
|
6
|
+
// channel combinators commute — the fold has no ordering rule to get wrong. So
|
|
7
|
+
// the palette is a set of buttons, not a radio group, and a preset already on
|
|
8
|
+
// the layer shows as active and REMOVES on a second click. A replace-only
|
|
9
|
+
// palette would make "fade and slide in" unauthorable, which is the single most
|
|
10
|
+
// common entrance in the entire category.
|
|
11
|
+
import { presetsForSlot } from "@dailies-studio/shot/doc";
|
|
12
|
+
import type { BlockSlot } from "@dailies-studio/shot/doc";
|
|
13
|
+
import { InspectorSection } from "@dailies-studio/ui";
|
|
14
|
+
import { useStore } from "../../../state/context.js";
|
|
15
|
+
import { useSelectedLayers } from "../useDocWrite.js";
|
|
16
|
+
import { useBlocksInSlot } from "./useBlocks.js";
|
|
17
|
+
|
|
18
|
+
/** The default window for a palette-added entrance or exit. */
|
|
19
|
+
const DEFAULT_FRAMES = 12;
|
|
20
|
+
|
|
21
|
+
export function InOutPresets(props: { slot: BlockSlot; title: string }) {
|
|
22
|
+
const store = useStore();
|
|
23
|
+
const layers = useSelectedLayers();
|
|
24
|
+
const present = useBlocksInSlot(props.slot);
|
|
25
|
+
if (layers.length === 0) return null;
|
|
26
|
+
|
|
27
|
+
// A preset counts as "on" when EVERY selected layer carries it. Partial
|
|
28
|
+
// coverage reads as off, so one more click completes the set rather than
|
|
29
|
+
// clearing the layers that already had it.
|
|
30
|
+
const coverage = new Map<string, number>();
|
|
31
|
+
for (const { block } of present) coverage.set(block.preset, (coverage.get(block.preset) ?? 0) + 1);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<InspectorSection title={props.title} persistKey={`shot:animate:${props.slot}`}>
|
|
35
|
+
<div className="animate-palette" role="group" aria-label={`${props.title} presets`}>
|
|
36
|
+
{presetsForSlot(props.slot).map((preset) => {
|
|
37
|
+
const on = (coverage.get(preset.id) ?? 0) >= layers.length;
|
|
38
|
+
return (
|
|
39
|
+
<button
|
|
40
|
+
key={preset.id}
|
|
41
|
+
type="button"
|
|
42
|
+
className="animate-preset"
|
|
43
|
+
aria-label={`${props.title} ${preset.label}`}
|
|
44
|
+
aria-pressed={on}
|
|
45
|
+
onClick={() => {
|
|
46
|
+
if (on) store.removeBlocksByPreset(props.slot, preset.id);
|
|
47
|
+
else store.addBlock(props.slot, preset.id, DEFAULT_FRAMES);
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{preset.label}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</div>
|
|
55
|
+
</InspectorSection>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// What the block stack ADDS UP TO on this layer, at this frame.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY A READOUT AND NOT SEVEN MORE FIELDS ─────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Blocks are DELTA writers and they compose (resolve.ts). So "what is this
|
|
6
|
+
// layer's opacity right now" has no single author: it is the base times every
|
|
7
|
+
// active block's contribution, and there is nothing for a field to write into
|
|
8
|
+
// that would not have to pick one of the contributors and hide the rest. The
|
|
9
|
+
// Design tab already solves the writable half — `setEffectiveField` compensates
|
|
10
|
+
// a typed value back through the fold — and this is the other half: a stacked
|
|
11
|
+
// delta model where the author can see the sum is legible, and one where they
|
|
12
|
+
// cannot is a pile of numbers that do not match the screen.
|
|
13
|
+
//
|
|
14
|
+
// ── IT CANNOT DISAGREE WITH THE STAGE, BY CONSTRUCTION ─────────────────
|
|
15
|
+
//
|
|
16
|
+
// Every number here comes out of `resolveLayerAt`, which is the same function
|
|
17
|
+
// the runtime writes onto the mesh and the same one the renderer samples. There
|
|
18
|
+
// is no second fold in this file and there must never be one: a readout that
|
|
19
|
+
// re-derived the composition would be a second answer to a question that has
|
|
20
|
+
// exactly one, and it would drift the first time a channel gained a combinator.
|
|
21
|
+
//
|
|
22
|
+
// ── "WRITING" MEANS DECLARED, NOT DIFFERENT ────────────────────────────
|
|
23
|
+
//
|
|
24
|
+
// A channel is marked when a live block's delta CONTAINS it, not when the
|
|
25
|
+
// folded value happens to differ from identity. The two come apart constantly:
|
|
26
|
+
// an out-fade at its first frame writes opacity 1, and an in-fade that finished
|
|
27
|
+
// forty frames ago still writes opacity 1 and still owns the channel. Marking
|
|
28
|
+
// on "differs from identity" would say "nothing is animating this" one frame
|
|
29
|
+
// before a fade visibly starts, and would need a second copy of resolve.ts's
|
|
30
|
+
// identity table to say it. Declared is both cheaper and truer: the mark means
|
|
31
|
+
// "a block governs this channel", which is exactly what an author about to type
|
|
32
|
+
// in the Design tab needs to know.
|
|
33
|
+
import { InspectorSection, formatValue } from "@dailies-studio/ui";
|
|
34
|
+
import {
|
|
35
|
+
blockProgressAt,
|
|
36
|
+
deltaFor,
|
|
37
|
+
layerDeltaAt,
|
|
38
|
+
motionPresets,
|
|
39
|
+
resolveLayerAt,
|
|
40
|
+
} from "@dailies-studio/shot/doc";
|
|
41
|
+
import type { Layer3D, LayerDelta } from "@dailies-studio/shot/doc";
|
|
42
|
+
import type { Rational } from "@dailies-studio/engine";
|
|
43
|
+
import { CHANNELS } from "../../../channels.js";
|
|
44
|
+
import type { Channel } from "../../../channels.js";
|
|
45
|
+
import { useShotEditor } from "../../../state/context.js";
|
|
46
|
+
import { useInspectorFrame, useSelectedLayers } from "../useDocWrite.js";
|
|
47
|
+
|
|
48
|
+
export function ResolvedChannels() {
|
|
49
|
+
const layers = useSelectedLayers();
|
|
50
|
+
const { sceneFrame } = useInspectorFrame();
|
|
51
|
+
const fps = useShotEditor((s) => s.doc?.fps ?? null);
|
|
52
|
+
// THE FIRST selected layer. A fan-out would have to answer "what is the
|
|
53
|
+
// resolved opacity of four layers", which is either four readouts or a
|
|
54
|
+
// "Mixed" that tells the author nothing about the composition they came here
|
|
55
|
+
// to understand. One subject, named in the header when there is a choice.
|
|
56
|
+
const layer = layers[0];
|
|
57
|
+
if (layer === undefined || fps === null) return null;
|
|
58
|
+
|
|
59
|
+
const state = resolveLayerAt(layer, sceneFrame, fps);
|
|
60
|
+
const delta = layerDeltaAt(layer, sceneFrame, fps);
|
|
61
|
+
const writers = writersAt(layer, sceneFrame, fps);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<InspectorSection
|
|
65
|
+
title="Resolved"
|
|
66
|
+
persistKey="shot:animate:resolved"
|
|
67
|
+
action={
|
|
68
|
+
<span className="insp-dim">
|
|
69
|
+
{layers.length > 1 ? `${layer.label} · ` : ""}f{sceneFrame}
|
|
70
|
+
</span>
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
<dl className="resolved-list" aria-label="Resolved channels">
|
|
74
|
+
{CHANNELS.map((channel) => {
|
|
75
|
+
const by = writers.get(channel.key);
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
key={channel.key}
|
|
79
|
+
className="resolved-row"
|
|
80
|
+
data-channel={channel.key}
|
|
81
|
+
data-live={by !== undefined}
|
|
82
|
+
>
|
|
83
|
+
<dt className="resolved-label">{channel.label}</dt>
|
|
84
|
+
<dd className="resolved-value">{channel.read(state)}</dd>
|
|
85
|
+
<dd
|
|
86
|
+
className="resolved-by"
|
|
87
|
+
title={by === undefined ? undefined : `${by.join(", ")} → ${deltaText(channel.key, delta)}`}
|
|
88
|
+
>
|
|
89
|
+
{by?.join(", ") ?? ""}
|
|
90
|
+
</dd>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</dl>
|
|
95
|
+
</InspectorSection>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Which live blocks declare each channel, by preset label.
|
|
101
|
+
*
|
|
102
|
+
* `blockProgressAt` + `deltaFor` is the same pair `layerDeltaAt` folds with, so
|
|
103
|
+
* a block counted here is exactly a block that contributed to the numbers
|
|
104
|
+
* beside it — including the outside-the-window holds, which are the cases a
|
|
105
|
+
* naive "is the playhead inside the bar" test gets wrong in both directions.
|
|
106
|
+
*/
|
|
107
|
+
function writersAt(layer: Layer3D, sceneFrame: number, fps: Rational): Map<Channel, string[]> {
|
|
108
|
+
const out = new Map<Channel, string[]>();
|
|
109
|
+
for (const block of layer.blocks) {
|
|
110
|
+
const p = blockProgressAt(block, sceneFrame, fps);
|
|
111
|
+
if (p === null) continue;
|
|
112
|
+
const label = motionPresets[block.preset]?.label ?? block.preset;
|
|
113
|
+
for (const key of Object.keys(deltaFor(block.preset, block.slot, block.params, p)) as Channel[]) {
|
|
114
|
+
const at = out.get(key);
|
|
115
|
+
if (at === undefined) out.set(key, [label]);
|
|
116
|
+
// Two Fades on one channel are one name: the row says WHAT governs the
|
|
117
|
+
// channel, and the count is the dock's job.
|
|
118
|
+
else if (!at.includes(label)) at.push(label);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The fold's own contribution to one channel, in that channel's combinator's
|
|
125
|
+
* terms — a multiply reads "×", an add reads signed. It is the answer to "how
|
|
126
|
+
* much of this number is the blocks", which the resolved value alone hides. */
|
|
127
|
+
function deltaText(key: Channel, d: Required<LayerDelta>): string {
|
|
128
|
+
switch (key) {
|
|
129
|
+
case "opacity":
|
|
130
|
+
return `×${formatValue(d.opacity, 0.01)}`;
|
|
131
|
+
case "offset":
|
|
132
|
+
return d.offset.map(signed).join(", ");
|
|
133
|
+
case "scale":
|
|
134
|
+
return d.scale.map((v) => `×${formatValue(v, 0.01)}`).join(", ");
|
|
135
|
+
case "tilt":
|
|
136
|
+
return d.tilt.map((v) => `${signed(v)}°`).join(", ");
|
|
137
|
+
case "roll":
|
|
138
|
+
return `${signed(d.roll)}°`;
|
|
139
|
+
case "blurPx":
|
|
140
|
+
return `${formatValue(d.blurPx, 0.1)} px`;
|
|
141
|
+
case "reveal":
|
|
142
|
+
return `${formatValue(d.reveal * 100, 1)}%`;
|
|
143
|
+
case "textPhase":
|
|
144
|
+
return `${formatValue(d.textPhase * 100, 1)}%`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function signed(v: number): string {
|
|
149
|
+
return `${v >= 0 ? "+" : ""}${formatValue(v, 0.1)}`;
|
|
150
|
+
}
|