@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,123 @@
1
+ // The cinematic post stack, one toggle per effect.
2
+ //
3
+ // ABSENT MEANS OFF — there is no `enabled` flag anywhere in `SceneEffects`,
4
+ // because two ways to say "off" is one way to disagree. So a toggle here
5
+ // DELETES the key rather than setting a boolean, and the shader chain rebuilds
6
+ // from `postSignature`, which is derived from exactly which keys exist.
7
+ import type { ReactNode } from "react";
8
+ import { InspectorRow, InspectorSection, NumberField, Toggle } from "@dailies-studio/ui";
9
+ import type { SceneEffects } from "@dailies-studio/shot/doc";
10
+ import { useShotEditor, useStore } from "../../../state/context.js";
11
+
12
+ /** What each effect looks like when first switched on. Values that read as the
13
+ * effect rather than as nothing — a bloom at strength 0 is a toggle that
14
+ * appears not to work, and the author blames the renderer. */
15
+ const DEFAULTS = {
16
+ dof: { fStop: 2.8, bokehScale: 2, quality: "mip" },
17
+ bloom: { strength: 0.6, radius: 0.5, threshold: 0.7 },
18
+ vignette: { amount: 0.5, radius: 0.55, softness: 0.4 },
19
+ grain: { amount: 0.08, size: 1.5 },
20
+ colorSplit: { strength: 1.5, scale: 1.05 },
21
+ ghost: { amount: 0.25, offset: [0.004, 0.002], count: 3 },
22
+ grade: { slope: [1, 1, 1], offset: [0, 0, 0], power: [1, 1, 1], saturation: 1 },
23
+ edgeFade: { size: 0.12, softness: 0.08 },
24
+ motionBlur: { shutterDeg: 180, samples: 8 },
25
+ } as const satisfies Record<keyof SceneEffects, unknown>;
26
+
27
+ type EffectKey = keyof typeof DEFAULTS;
28
+
29
+ export function EffectsSection() {
30
+ const store = useStore();
31
+ const doc = useShotEditor((s) => s.doc);
32
+ const sceneId = useShotEditor((s) => s.sceneId);
33
+ if (doc === null || sceneId === null) return null;
34
+ const effects = doc.scenesById[sceneId]?.effects;
35
+ if (effects === undefined) return null;
36
+
37
+ // Block body: `edit` now answers whether it ran, and this caller has no use
38
+ // for the answer — see SceneSection.
39
+ const patch = (next: SceneEffects): void => {
40
+ store.edit((d) => ({
41
+ ...d,
42
+ scenesById: { ...d.scenesById, [sceneId]: { ...d.scenesById[sceneId]!, effects: next } },
43
+ }));
44
+ };
45
+
46
+ const toggle = (key: EffectKey, on: boolean): void => {
47
+ const next: Record<string, unknown> = { ...effects };
48
+ if (on) next[key] = DEFAULTS[key];
49
+ else delete next[key];
50
+ patch(next as SceneEffects);
51
+ };
52
+
53
+ const set = (key: EffectKey, field: string, value: number): void => {
54
+ const current = (effects as Record<string, Record<string, unknown>>)[key] ?? DEFAULTS[key];
55
+ patch({ ...effects, [key]: { ...current, [field]: value } } as SceneEffects);
56
+ };
57
+
58
+ return (
59
+ <InspectorSection title="Effects" persistKey="shot:effects">
60
+ <Row label="Depth of field" on={effects.dof !== undefined} onToggle={(v) => toggle("dof", v)}>
61
+ <NumberField value={effects.dof?.fStop ?? 2.8} label="f/" ariaLabel="Aperture" min={0.7} max={32} step={0.1} onCommit={(v) => set("dof", "fStop", v)} />
62
+ <NumberField value={effects.dof?.bokehScale ?? 2} label="Amount" ariaLabel="Bokeh scale" min={0} max={20} step={0.5} onCommit={(v) => set("dof", "bokehScale", v)} />
63
+ </Row>
64
+ <Row label="Bloom" on={effects.bloom !== undefined} onToggle={(v) => toggle("bloom", v)}>
65
+ <NumberField value={effects.bloom?.strength ?? 0.6} label="Amount" ariaLabel="Bloom strength" min={0} max={5} step={0.05} onCommit={(v) => set("bloom", "strength", v)} />
66
+ <NumberField value={effects.bloom?.threshold ?? 0.7} label="Cut" ariaLabel="Bloom threshold" min={0} max={2} step={0.05} onCommit={(v) => set("bloom", "threshold", v)} />
67
+ </Row>
68
+ <Row label="Vignette" on={effects.vignette !== undefined} onToggle={(v) => toggle("vignette", v)}>
69
+ <NumberField value={effects.vignette?.amount ?? 0.5} label="Amount" ariaLabel="Vignette amount" min={0} max={1} step={0.05} onCommit={(v) => set("vignette", "amount", v)} />
70
+ <NumberField value={effects.vignette?.radius ?? 0.55} label="Size" ariaLabel="Vignette radius" min={0} max={1.5} step={0.05} onCommit={(v) => set("vignette", "radius", v)} />
71
+ </Row>
72
+ <Row label="Grain" on={effects.grain !== undefined} onToggle={(v) => toggle("grain", v)}>
73
+ <NumberField value={effects.grain?.amount ?? 0.08} label="Amount" ariaLabel="Grain amount" min={0} max={1} step={0.01} onCommit={(v) => set("grain", "amount", v)} />
74
+ <NumberField value={effects.grain?.size ?? 1.5} label="Size" ariaLabel="Grain size" min={1} max={16} step={0.5} onCommit={(v) => set("grain", "size", v)} />
75
+ </Row>
76
+ <Row label="Color split" on={effects.colorSplit !== undefined} onToggle={(v) => toggle("colorSplit", v)}>
77
+ <NumberField value={effects.colorSplit?.strength ?? 1.5} label="Amount" ariaLabel="Colour split strength" min={0} max={20} step={0.1} onCommit={(v) => set("colorSplit", "strength", v)} />
78
+ </Row>
79
+ <Row label="Ghost" on={effects.ghost !== undefined} onToggle={(v) => toggle("ghost", v)}>
80
+ <NumberField value={effects.ghost?.amount ?? 0.25} label="Amount" ariaLabel="Ghost amount" min={0} max={1} step={0.05} onCommit={(v) => set("ghost", "amount", v)} />
81
+ {/* A LOOP BOUND, not a uniform — changing it recompiles the chain, which
82
+ is exactly why `postSignature` carries it and the other params do
83
+ not. */}
84
+ <NumberField value={effects.ghost?.count ?? 3} label="Copies" ariaLabel="Ghost count" min={1} max={8} step={1} onCommit={(v) => set("ghost", "count", v)} />
85
+ </Row>
86
+ <Row label="Edge fade" on={effects.edgeFade !== undefined} onToggle={(v) => toggle("edgeFade", v)}>
87
+ <NumberField value={effects.edgeFade?.size ?? 0.12} label="Size" ariaLabel="Edge fade size" min={0} max={0.5} step={0.01} onCommit={(v) => set("edgeFade", "size", v)} />
88
+ </Row>
89
+ <Row label="Grade" on={effects.grade !== undefined} onToggle={(v) => toggle("grade", v)}>
90
+ <NumberField value={effects.grade?.saturation ?? 1} label="Sat" ariaLabel="Saturation" min={0} max={3} step={0.05} onCommit={(v) => set("grade", "saturation", v)} />
91
+ </Row>
92
+ <Row label="Motion blur" on={effects.motionBlur !== undefined} onToggle={(v) => toggle("motionBlur", v)}>
93
+ {/* NOT A SHADER PASS. This feeds the export layer's sub-frame
94
+ accumulation, which is the only formulation compatible with
95
+ out-of-order rendering — see SceneEffects in types.ts. It therefore
96
+ shows in an EXPORT and not in the preview. */}
97
+ <NumberField value={effects.motionBlur?.shutterDeg ?? 180} label="Shutter" ariaLabel="Shutter angle" unit="deg" min={0} max={360} step={10} onCommit={(v) => set("motionBlur", "shutterDeg", v)} />
98
+ <NumberField value={effects.motionBlur?.samples ?? 8} label="Samples" ariaLabel="Motion blur samples" min={2} max={32} step={1} onCommit={(v) => set("motionBlur", "samples", v)} />
99
+ </Row>
100
+ </InspectorSection>
101
+ );
102
+ }
103
+
104
+ function Row({
105
+ label,
106
+ on,
107
+ onToggle,
108
+ children,
109
+ }: {
110
+ label: string;
111
+ on: boolean;
112
+ onToggle(on: boolean): void;
113
+ children: ReactNode;
114
+ }) {
115
+ return (
116
+ <div className="effect-row" data-effect={label}>
117
+ <InspectorRow label={label}>
118
+ <Toggle checked={on} ariaLabel={label} onChange={onToggle} />
119
+ </InspectorRow>
120
+ {on ? <div className="effect-params">{children}</div> : null}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,71 @@
1
+ // The scene's own properties, shown when nothing is selected. The subject
2
+ // switch is the SELECTION, not a tab: a layer inspector and a scene inspector
3
+ // are the same panel looking at whatever the author last clicked.
4
+ import { ColorPicker, InspectorRow, InspectorSection, NumberField, TextField } from "@dailies-studio/ui";
5
+ import type { Scene3D } from "@dailies-studio/shot/doc";
6
+ import { useShotEditor, useStore } from "../../../state/context.js";
7
+ import { NO_SCRUB } from "../useDocWrite.js";
8
+
9
+ export function SceneSection() {
10
+ const store = useStore();
11
+ const doc = useShotEditor((s) => s.doc);
12
+ const sceneId = useShotEditor((s) => s.sceneId);
13
+ if (doc === null || sceneId === null) return null;
14
+ const scene = doc.scenesById[sceneId];
15
+ if (scene === undefined) return null;
16
+
17
+ // A block body, not a concise one: `edit` reports whether it RAN (a drag
18
+ // holds the history open and refuses), and this caller has nothing to do with
19
+ // that answer — returning it out of a `void` handler is the type error that
20
+ // says so.
21
+ const patch = (next: Partial<Scene3D>): void => {
22
+ store.edit((d) => ({
23
+ ...d,
24
+ scenesById: { ...d.scenesById, [sceneId]: { ...d.scenesById[sceneId]!, ...next } },
25
+ }));
26
+ };
27
+
28
+ return (
29
+ <InspectorSection title="Shot" persistKey="shot:scene" collapsible={false}>
30
+ <InspectorRow label="Name">
31
+ <TextField value={scene.name} ariaLabel="Shot name" onCommit={(name) => patch({ name })} />
32
+ </InspectorRow>
33
+ <InspectorRow>
34
+ <NumberField
35
+ value={scene.durationFrames}
36
+ label="Frames"
37
+ ariaLabel="Shot duration"
38
+ min={1}
39
+ step={1}
40
+ onCommit={(durationFrames) => store.trimScene(sceneId, durationFrames)}
41
+ />
42
+ </InspectorRow>
43
+ <InspectorRow>
44
+ {/* THE ONLY CONTROL ON PARALLAX STRENGTH. A wide fov puts the camera
45
+ close, so a depth offset changes scale a lot; a long lens puts it far
46
+ and the same offset barely registers (resolve.ts's planeDistance). */}
47
+ <NumberField
48
+ value={scene.fovDeg}
49
+ label="Lens"
50
+ ariaLabel="Field of view"
51
+ unit="deg"
52
+ min={5}
53
+ max={120}
54
+ step={1}
55
+ onCommit={(fovDeg) => patch({ fovDeg })}
56
+ />
57
+ </InspectorRow>
58
+ <InspectorRow label="Background">
59
+ {/* A scene is OPAQUE by contract (types.ts), so there is no alpha here
60
+ and none is offered — the renderer clears with alpha 1, and a
61
+ transparent background would be a promise the export cannot keep. */}
62
+ <ColorPicker
63
+ value={scene.background.type === "solid" ? scene.background.color : "#0b0b0d"}
64
+ ariaLabel="Background colour"
65
+ scrub={NO_SCRUB}
66
+ commit={(color) => patch({ background: { type: "solid", color } })}
67
+ />
68
+ </InspectorRow>
69
+ </InspectorSection>
70
+ );
71
+ }
@@ -0,0 +1,136 @@
1
+ // How this shot hands over to the next one.
2
+ //
3
+ // A transition is stored on the OUTGOING shot — the one selected here — and
4
+ // plays over the opening frames of the one after it. That is why the section is
5
+ // titled "Into next shot" and why the last shot's controls are inert: there is
6
+ // nothing after it to blend into. It stays EDITABLE there rather than hidden,
7
+ // because reordering can make any shot last and then not last again, and a
8
+ // setting that vanishes when you drag a bar looks like data loss.
9
+ //
10
+ // The preset list comes from the ROOT of @dailies-studio/transitions, never from
11
+ // its ./gl entry: that entry's first import is `three/tsl`, and the chrome
12
+ // bundle must not carry a shader graph layer to draw a dropdown. The root's
13
+ // `glPresetSpecs` is the same data gl.ts builds against, asserted in lockstep
14
+ // at import.
15
+ import {
16
+ InspectorRow,
17
+ InspectorSection,
18
+ NumberField,
19
+ } from "@dailies-studio/ui";
20
+ import { glPresetSpecs, resolveGlPresetSpec } from "@dailies-studio/transitions";
21
+ import { MIN_TRANSITION_FRAMES } from "@dailies-studio/shot/doc";
22
+ import type { Value } from "@dailies-studio/engine";
23
+ import { useShotEditor, useStore } from "../../../state/context.js";
24
+ import { ParamRow } from "../ParamRow.js";
25
+
26
+ /** "no transition", as a select value. Not a preset id, and cannot be one. */
27
+ const CUT = "";
28
+
29
+ /** What a transition looks like when first chosen: a third of a second at 30fps,
30
+ * which reads as a transition rather than as a glitch. */
31
+ const DEFAULT_FRAMES = 10;
32
+
33
+ export function TransitionSection() {
34
+ const store = useStore();
35
+ const doc = useShotEditor((s) => s.doc);
36
+ const sceneId = useShotEditor((s) => s.sceneId);
37
+ if (doc === null || sceneId === null) return null;
38
+ const scene = doc.scenesById[sceneId];
39
+ if (scene === undefined) return null;
40
+
41
+ const transition = scene.transition;
42
+ const spec = transition === undefined ? undefined : resolveGlPresetSpec(transition.kind);
43
+ const isLast = doc.scenes[doc.scenes.length - 1] === sceneId;
44
+ const next = isLast ? null : (doc.scenesById[doc.scenes[doc.scenes.indexOf(sceneId) + 1]!] ?? null);
45
+
46
+ const setKind = (kind: string): void => {
47
+ if (kind === CUT) {
48
+ store.setSceneTransition(sceneId, null);
49
+ return;
50
+ }
51
+ // Params are PRESET-SCOPED, so switching preset starts from that preset's
52
+ // own defaults rather than carrying the previous one's names across — the
53
+ // same reasoning as setBlockPreset.
54
+ store.setSceneTransition(sceneId, {
55
+ kind,
56
+ durationFrames: transition?.durationFrames ?? DEFAULT_FRAMES,
57
+ });
58
+ };
59
+
60
+ const setParam = (name: string, value: Value): void => {
61
+ if (transition === undefined) return;
62
+ store.setSceneTransition(sceneId, {
63
+ ...transition,
64
+ params: { ...transition.params, [name]: value },
65
+ });
66
+ };
67
+
68
+ return (
69
+ <InspectorSection title="Into next shot" persistKey="shot:transition">
70
+ <InspectorRow label="Transition">
71
+ <select
72
+ className="select"
73
+ aria-label="Transition preset"
74
+ value={transition?.kind ?? CUT}
75
+ onChange={(e) => setKind(e.target.value)}
76
+ >
77
+ <option value={CUT}>Cut</option>
78
+ {Object.values(glPresetSpecs).map((p) => (
79
+ <option key={p.id} value={p.id}>
80
+ {p.label}
81
+ </option>
82
+ ))}
83
+ {/* A document may name a preset this build does not have — it renders
84
+ as a cut, and the picker says so instead of silently showing
85
+ "Cut" and rewriting it on the next edit. */}
86
+ {transition !== undefined && spec === undefined ? (
87
+ <option value={transition.kind}>{transition.kind} (unknown)</option>
88
+ ) : null}
89
+ </select>
90
+ </InspectorRow>
91
+
92
+ {transition !== undefined ? (
93
+ <>
94
+ <InspectorRow label="Frames">
95
+ <NumberField
96
+ value={transition.durationFrames}
97
+ ariaLabel="Transition frames"
98
+ min={MIN_TRANSITION_FRAMES}
99
+ step={1}
100
+ onCommit={(v) =>
101
+ store.setSceneTransition(sceneId, {
102
+ ...transition,
103
+ durationFrames: Math.max(MIN_TRANSITION_FRAMES, Math.round(v)),
104
+ })
105
+ }
106
+ />
107
+ </InspectorRow>
108
+ {/* The stored number is the AUTHOR's, and the resolver clamps it to
109
+ whatever the next shot can hold — so trimming that shot short and
110
+ back again does not quietly rewrite what was typed. Said here
111
+ because the discrepancy is otherwise invisible. */}
112
+ {next !== null && transition.durationFrames > next.durationFrames ? (
113
+ <p className="inspector-note">
114
+ Longer than {next.name} ({next.durationFrames}f) — it plays over
115
+ that whole shot.
116
+ </p>
117
+ ) : null}
118
+ {isLast ? (
119
+ <p className="inspector-note">
120
+ This is the last shot, so nothing plays. Kept for when one follows
121
+ it.
122
+ </p>
123
+ ) : null}
124
+ {spec?.params.map((p) => (
125
+ <ParamRow
126
+ key={p.name}
127
+ spec={p}
128
+ value={transition.params?.[p.name] ?? p.default}
129
+ onCommit={(v) => setParam(p.name, v)}
130
+ />
131
+ ))}
132
+ </>
133
+ ) : null}
134
+ </InspectorSection>
135
+ );
136
+ }
@@ -0,0 +1,268 @@
1
+ // The inspector's write and read seams.
2
+ //
3
+ // ── THE ASYMMETRY, WHICH IS THE WHOLE FILE ──────────────────────────────
4
+ //
5
+ // READOUTS come from the layer AS PAINTED at the playhead. WRITES derive from
6
+ // the base, compensated so the rendered value lands on what the author typed.
7
+ //
8
+ // Both halves are load-bearing and neither is optional:
9
+ //
10
+ // Reading BASE would tell the author 0 while the layer sat at 150, because
11
+ // what is on screen is `base ⊕ blocks(frame)`. That is the exact defect the
12
+ // 2D lane's read-effective rule was written to close, and it is worse here
13
+ // because a shot's blocks are the normal case rather than the exception.
14
+ //
15
+ // Writing the typed value into the BASE would make the field disagree with
16
+ // itself: type 100, watch it read back 250. So a write goes through
17
+ // `setEffectiveField`, which is the same arithmetic a canvas drag uses —
18
+ // the inspector and a drag are one operation with different input devices.
19
+ //
20
+ // ── WHAT IS GONE FROM THE 2D LANE'S VERSION ────────────────────────────
21
+ //
22
+ // `useNodeTrackField` and everything around it. There are no keyframe tracks in
23
+ // this lane, so there are no diamonds, no per-property sequenced/static fork,
24
+ // and — the real simplification — no `selectionRefusal`: the 2D lane's one row
25
+ // was "this prop is sequenced, select it alone to key it", which cannot arise
26
+ // here. `planSelection` collapses to dedupe plus document order.
27
+ import { useCallback, useEffect, useState } from "react";
28
+ import { animateFieldEdit, effectiveTransform, setEffectiveField } from "@dailies-studio/shot/doc";
29
+ import type { Doc3D, Layer3D, TransformField, Transform3D } from "@dailies-studio/shot/doc";
30
+ import { useShotEditor, useStore } from "../../state/context.js";
31
+
32
+ /** A colour swatch takes no drag, so it has no scrub — but `ColorPicker`'s
33
+ * contract requires the trio, because the same widget IS scrubbed when it sits
34
+ * on a keyframable property elsewhere. Three no-ops, in one place. */
35
+ export const NO_SCRUB = { start: () => {}, move: () => {}, end: () => {} };
36
+
37
+ /** THE one document-edit seam. Every inspector commit is a transaction, so it
38
+ * is one undo entry however many layers it touched. */
39
+ export function useDocWrite(): (write: (doc: Doc3D) => Doc3D) => void {
40
+ const store = useStore();
41
+ return useCallback((write) => store.edit(write), [store]);
42
+ }
43
+
44
+ /** MASTER frame -> the scene-local frame the effective read wants. Derived
45
+ * rather than stored: the scene's start is a prefix sum, so a trim upstream
46
+ * moves it and a cached offset would go stale silently. Pure and EXPORTED,
47
+ * because it has callers with different frames: the display hook below
48
+ * (coalesced) and every commit path (live at the click or keystroke —
49
+ * useTransformWrite here, ShotSection's "Set from current framing"). */
50
+ export function sceneLocalFrame(doc: Doc3D, sceneId: string, frame: number): number {
51
+ let start = 0;
52
+ for (const id of doc.scenes) {
53
+ const duration = Math.max(1, Math.trunc(doc.scenesById[id]?.durationFrames ?? 0));
54
+ if (id === sceneId) break;
55
+ start += duration;
56
+ }
57
+ return Math.max(0, frame - start);
58
+ }
59
+
60
+ /** How stale the inspector's DISPLAYED frame may run while the playhead is
61
+ * moving fast. ~10Hz keeps a scrub's numbers live enough to read against the
62
+ * canvas; 30Hz numbers are unreadable AND cost eleven sections a re-render
63
+ * and an `effectiveTransform` per selected layer, each. */
64
+ const INSPECTOR_FRAME_MS = 100;
65
+
66
+ /**
67
+ * The playhead as the inspector SHOWS it, coalesced.
68
+ *
69
+ * Two high-rate sources exist and `playing` only names one of them: scrubbing
70
+ * drives `seek()` at pointer-event rate with `playing === false` (the ruler,
71
+ * the transport slider, and any future source — none of which carry drag
72
+ * brackets). So the gate is on the RATE, not on a flag: held entirely during
73
+ * playback, throttled to ~10Hz otherwise, and always flushing the final frame
74
+ * once the stream quiets so the panel never rests on a stale number.
75
+ *
76
+ * Display only. Commits read the live frame at the keystroke (see
77
+ * `useTransformWrite`) — a write compensated against a coalesced frame would
78
+ * land the typed value at a playhead the render already left.
79
+ */
80
+ /** Exported for every chrome READOUT that resolves document state at the
81
+ * playhead (the inspector fields, the timeline's channel values): held while
82
+ * playing, ~10 Hz while scrubbing, leading edge on a lone seek. A readout
83
+ * that subscribed the raw frame would run its resolver fold per tick. */
84
+ export function useCoalescedFrame(): number {
85
+ const store = useStore();
86
+ const [frame, setFrame] = useState(() => store.getState().frame);
87
+ useEffect(() => {
88
+ let timer: ReturnType<typeof setTimeout> | null = null;
89
+ let lastPush = 0;
90
+ const push = (f: number): void => {
91
+ lastPush = performance.now();
92
+ setFrame(f);
93
+ };
94
+ const onChange = (): void => {
95
+ const s = store.getState();
96
+ // Held during playback. The pause itself notifies (playing flips), and
97
+ // that notification falls through to the flush below.
98
+ if (s.playing) return;
99
+ if (timer !== null) {
100
+ clearTimeout(timer);
101
+ timer = null;
102
+ }
103
+ const since = performance.now() - lastPush;
104
+ if (since >= INSPECTOR_FRAME_MS) {
105
+ push(s.frame);
106
+ return;
107
+ }
108
+ // The trailing flush: re-reads the store when it fires, so the value
109
+ // that lands is the newest one, not the one that armed the timer.
110
+ timer = setTimeout(() => {
111
+ timer = null;
112
+ push(store.getState().frame);
113
+ }, INSPECTOR_FRAME_MS - since);
114
+ };
115
+ // The frame may have moved between render and subscription. A SYNC-UP,
116
+ // not a rate event: it must not touch `lastPush`, or mounting would
117
+ // consume the leading edge and the very first seek after mount would
118
+ // coalesce instead of landing immediately.
119
+ setFrame(store.getState().frame);
120
+ const unsubscribe = store.subscribe(onChange);
121
+ return () => {
122
+ unsubscribe();
123
+ if (timer !== null) clearTimeout(timer);
124
+ };
125
+ }, [store]);
126
+ return frame;
127
+ }
128
+
129
+ /** The scene the inspector is looking at, and the SCENE-LOCAL frame — the two
130
+ * things every effective read needs. The frame is the COALESCED one. */
131
+ export function useInspectorFrame(): { sceneId: string | null; sceneFrame: number } {
132
+ const sceneId = useShotEditor((s) => s.sceneId);
133
+ const frame = useCoalescedFrame();
134
+ const doc = useShotEditor((s) => s.doc);
135
+ if (doc === null || sceneId === null) return { sceneId: null, sceneFrame: 0 };
136
+ return { sceneId, sceneFrame: sceneLocalFrame(doc, sceneId, frame) };
137
+ }
138
+
139
+ /** Every selected layer, in document order. The ONE place a panel gets its
140
+ * subjects, so no section re-derives the list with a different answer. */
141
+ export function useSelectedLayers(): Layer3D[] {
142
+ const doc = useShotEditor((s) => s.doc);
143
+ const sceneId = useShotEditor((s) => s.sceneId);
144
+ const selection = useShotEditor((s) => s.selection);
145
+ if (doc === null || sceneId === null) return [];
146
+ const scene = doc.scenesById[sceneId];
147
+ if (scene === undefined) return [];
148
+ return selection.map((id) => scene.layersById[id]).filter((l): l is Layer3D => l !== undefined);
149
+ }
150
+
151
+ export interface SelectionValue<T> {
152
+ /** The common value, or the FIRST layer's when they differ. */
153
+ value: T | undefined;
154
+ /** True when the selection does not agree. The widgets show "Mixed" and
155
+ * apply a typed value to the whole selection. */
156
+ mixed: boolean;
157
+ }
158
+
159
+ /**
160
+ * Read one property across the selection, from the layer AS PAINTED.
161
+ *
162
+ * `read` is handed the EFFECTIVE transform for transform properties and the
163
+ * layer itself for paint ones, because paint has no block deltas at all in v1 —
164
+ * so paint reads and writes are both base and cannot disagree.
165
+ */
166
+ export function useSelectionValue<T>(read: (layer: Layer3D, effective: Transform3D) => T): SelectionValue<T> {
167
+ const layers = useSelectedLayers();
168
+ const { sceneFrame } = useInspectorFrame();
169
+ const doc = useShotEditor((s) => s.doc);
170
+ if (layers.length === 0 || doc === null) return { value: undefined, mixed: false };
171
+ const values = layers.map((l) => read(l, effectiveTransform(l, sceneFrame, doc.fps)));
172
+ const first = values[0]!;
173
+ return { value: first, mixed: values.some((v) => !Object.is(v, first)) };
174
+ }
175
+
176
+ /**
177
+ * Write one property to EVERY selected layer, as ONE transaction.
178
+ *
179
+ * In document order and deduped, because `select` already keeps the selection
180
+ * that way — so a fan-out cannot depend on the order the author happened to
181
+ * click in, and an id cannot be visited twice.
182
+ */
183
+ export function useSelectionWrite(): (
184
+ build: (layer: Layer3D) => Layer3D,
185
+ opts?: { includeLocked?: boolean },
186
+ ) => void {
187
+ const store = useStore();
188
+ return useCallback(
189
+ (build, opts) => {
190
+ // LIVE at the commit, like the frame in useTransformWrite: a
191
+ // render-captured sceneId paired with a live frame would write into
192
+ // last render's scene when the commit lands right on a crossing.
193
+ const sceneId = store.getState().sceneId;
194
+ if (sceneId === null) return;
195
+ const ids = store.getState().selection;
196
+ if (ids.length === 0) return;
197
+ store.edit((doc) => {
198
+ const scene = doc.scenesById[sceneId];
199
+ if (scene === undefined) return doc;
200
+ const layersById = { ...scene.layersById };
201
+ let changed = false;
202
+ for (const id of ids) {
203
+ const layer = layersById[id];
204
+ // A locked layer is skipped — that is what locked MEANS. The one
205
+ // exception is the lock toggle itself, or a lock would be one-way.
206
+ if (layer === undefined || (layer.locked === true && opts?.includeLocked !== true)) continue;
207
+ const next = build(layer);
208
+ if (next === layer) continue;
209
+ layersById[id] = next;
210
+ changed = true;
211
+ }
212
+ return changed
213
+ ? { ...doc, scenesById: { ...doc.scenesById, [sceneId]: { ...scene, layersById } } }
214
+ : doc;
215
+ });
216
+ },
217
+ [store],
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Type a transform field across the selection.
223
+ *
224
+ * The refusal is REPORTED, not swallowed: a field that silently did nothing to
225
+ * one of four selected layers is the failure the whole two-truths rule exists
226
+ * to make impossible.
227
+ */
228
+ export function useTransformWrite(): (field: TransformField, value: number) => void {
229
+ const store = useStore();
230
+ const write = useSelectionWrite();
231
+ return useCallback(
232
+ (field, value) => {
233
+ // LIVE state at the keystroke, never the display hook's coalesced frame:
234
+ // the compensation arithmetic must target the frame actually painted, or
235
+ // the typed value lands up to 100ms of playhead away from where it reads
236
+ // back.
237
+ const s = store.getState();
238
+ const doc = s.doc;
239
+ if (doc === null || s.sceneId === null) return;
240
+ const sceneId = s.sceneId;
241
+ const sceneFrame = sceneLocalFrame(doc, sceneId, s.frame);
242
+ let refusal: string | null = null;
243
+ // ANIMATE ON: the typed value becomes a During block ending at the
244
+ // playhead — the same fork a stage drag takes (store.ts) and the camera
245
+ // takes (writeFraming), because the inspector and a drag are one
246
+ // operation with different input devices. OFF: the base write below.
247
+ if (s.animateMode) {
248
+ store.edit((base) => {
249
+ const out = animateFieldEdit(base, sceneId, s.selection, field, value, sceneFrame, base.fps);
250
+ refusal = out.refusals[0] ?? null;
251
+ return out.doc;
252
+ });
253
+ store.reportError(refusal);
254
+ return;
255
+ }
256
+ write((layer) => {
257
+ const out = setEffectiveField(layer, field, value, sceneFrame, doc.fps);
258
+ if (out.refusal !== null) {
259
+ refusal ??= out.refusal;
260
+ return layer;
261
+ }
262
+ return out.transform === layer.transform ? layer : { ...layer, transform: out.transform };
263
+ });
264
+ store.reportError(refusal);
265
+ },
266
+ [store, write],
267
+ );
268
+ }