@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,172 @@
1
+ // Only for MESH layers, and absent otherwise — the same rule TypographySection
2
+ // follows, for the same reason: a model section over a rectangle is six
3
+ // controls that write nothing.
4
+ //
5
+ // Deliberately the SHORT list: the model, how it is shaded, and when its clip
6
+ // runs. `fit`, the toon steps, the key direction and the rim all exist in the
7
+ // schema and are reachable from the agent lane; they are not here because a
8
+ // first section that shows every field is a section nobody can find the model
9
+ // in.
10
+ //
11
+ // ponytail: add the toon detail rows when someone is actually art-directing
12
+ // one.
13
+ import {
14
+ Combobox,
15
+ InspectorRow,
16
+ InspectorSection,
17
+ NumberField,
18
+ SegmentedControl,
19
+ TextField,
20
+ } from "@dailies-studio/ui";
21
+ import { MESH_LOOPS, MESH_SHADING_MODES } from "@dailies-studio/shot/doc";
22
+ import type { Layer3D, MeshLayer3D, MeshLoop, MeshShading } from "@dailies-studio/shot/doc";
23
+ import { useShotEditor, useStore } from "../../../state/context.js";
24
+ import { useSelectedLayers, useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
25
+
26
+ type MeshBlock = MeshLayer3D["mesh"];
27
+
28
+ export function MeshSection() {
29
+ const write = useSelectionWrite();
30
+ const layers = useSelectedLayers();
31
+ const store = useStore();
32
+ const doc = useShotEditor((s) => s.doc);
33
+ const library = useShotEditor((s) => s.library);
34
+ const mesh = useSelectionValue((layer) => (layer.kind === "mesh" ? layer.mesh : undefined));
35
+ if (!layers.some((l) => l.kind === "mesh") || mesh.value === undefined) return null;
36
+
37
+ const m = mesh.value;
38
+ // The catalog's display name, or the raw id when the library has not loaded
39
+ // (or when the document names a generator this build does not ship). Showing
40
+ // the id rather than "Choose…" is what makes the second case visible at all —
41
+ // a material that silently reads as unset is one an author would set twice.
42
+ const materialName =
43
+ m.material === undefined
44
+ ? null
45
+ : (library.find((row) => row.id === m.material?.generator)?.name ?? m.material.generator);
46
+ // Model assets only. Offering an image would let an author point a mesh layer
47
+ // at a PNG, which `validateShotDoc` refuses on save — a menu that can only
48
+ // produce an invalid document is worse than a short menu.
49
+ const models = Object.entries(doc?.assets ?? {})
50
+ .filter(([, asset]) => asset.kind === "model")
51
+ .map(([id, asset]) => ({ value: id, label: asset.path.split("/").pop() ?? id }));
52
+
53
+ /** Every write goes through one mapper so a field that CLEARS itself deletes
54
+ * its key rather than setting it to `undefined`: codegen prints the block by
55
+ * key order, and a present-but-undefined key is a key. */
56
+ const patch = (next: (block: MeshBlock) => MeshBlock): void =>
57
+ write((layer) => (layer.kind === "mesh" ? ({ ...layer, mesh: next(layer.mesh) } as Layer3D) : layer));
58
+
59
+ const set = <K extends keyof MeshBlock>(key: K, value: MeshBlock[K]): void =>
60
+ patch((block) => ({ ...block, [key]: value }));
61
+
62
+ return (
63
+ <InspectorSection title="Model" persistKey="shot:mesh">
64
+ <InspectorRow label="Asset">
65
+ <Combobox
66
+ options={models}
67
+ value={m.asset}
68
+ ariaLabel="Model asset"
69
+ placeholder="Search models"
70
+ onChange={(next) => set("asset", next)}
71
+ />
72
+ </InspectorRow>
73
+
74
+ <InspectorRow label="Shading">
75
+ <SegmentedControl
76
+ ariaLabel="Mesh shading"
77
+ value={m.shading?.mode ?? "unlit"}
78
+ options={MESH_SHADING_MODES.map((mode) => ({ value: mode, label: mode }))}
79
+ onChange={(next) => set("shading", { mode: next as MeshShading["mode"] } as MeshShading)}
80
+ />
81
+ </InspectorRow>
82
+
83
+ {/* THE MATERIAL IS A NAME AND TWO BUTTONS, not a list. The catalog is 48
84
+ entries with thumbnails, which is a browser and not an inspector row —
85
+ so this says what is on and opens the picker, exactly as the Asset row
86
+ would if a model were 48 things you had to look at to choose between.
87
+ The generator's own params are edited in the picker's own lane; this
88
+ row's job is "what is it, and take it off". */}
89
+ <InspectorRow label="Material">
90
+ <div className="mesh-material-row">
91
+ <button
92
+ type="button"
93
+ className="mesh-material-name"
94
+ onClick={() => store.openMaterialPicker(layers.filter((l) => l.kind === "mesh").map((l) => l.id))}
95
+ >
96
+ {materialName ?? "Choose…"}
97
+ </button>
98
+ {m.material === undefined ? null : (
99
+ <button
100
+ type="button"
101
+ className="mesh-material-clear"
102
+ aria-label="Remove material"
103
+ title="Back to the model's own materials"
104
+ onClick={() =>
105
+ store.removeMeshMaterial(layers.filter((l) => l.kind === "mesh").map((l) => l.id))
106
+ }
107
+ >
108
+ ×
109
+ </button>
110
+ )}
111
+ </div>
112
+ </InspectorRow>
113
+
114
+ {/* Free TEXT, not a dropdown. Clip names live inside the GLB and the
115
+ chrome never parses one — it holds a document, not a scene graph — so
116
+ a picker here could only ever offer the name that is already set.
117
+ Ceiling: no discovery; reporting the model's clips back over the
118
+ bridge is a feed field, not an inspector change. */}
119
+ <InspectorRow label="Clip">
120
+ <TextField
121
+ value={m.clip ?? ""}
122
+ ariaLabel="Animation clip"
123
+ onCommit={(next) =>
124
+ patch((block) => {
125
+ const trimmed = next.trim();
126
+ if (trimmed !== "") return { ...block, clip: trimmed };
127
+ // `clip: ""` would name a clip no glTF has: the layer would show
128
+ // its rest pose while the document claimed an animation. Absent
129
+ // is what "rest pose" actually spells.
130
+ const { clip: _drop, ...rest } = block;
131
+ return rest;
132
+ })
133
+ }
134
+ />
135
+ </InspectorRow>
136
+
137
+ <InspectorRow label="Start">
138
+ <NumberField
139
+ value={m.clipStartFrame ?? 0}
140
+ mixed={mesh.mixed}
141
+ ariaLabel="Clip start frame"
142
+ unit="f"
143
+ step={1}
144
+ onCommit={(next) => set("clipStartFrame", Math.round(next))}
145
+ />
146
+ </InspectorRow>
147
+
148
+ <InspectorRow label="Loop">
149
+ <SegmentedControl
150
+ ariaLabel="Clip loop"
151
+ value={m.loop ?? "hold"}
152
+ options={MESH_LOOPS.map((loop) => ({ value: loop, label: loop }))}
153
+ onChange={(next) => set("loop", next as MeshLoop)}
154
+ />
155
+ </InspectorRow>
156
+
157
+ <InspectorRow label="Rate">
158
+ <NumberField
159
+ value={m.timeScale ?? 1}
160
+ mixed={mesh.mixed}
161
+ ariaLabel="Clip rate"
162
+ step={0.1}
163
+ // Zero is REFUSED by validation — it freezes the clip forever, which
164
+ // is a pose you can already spell by clearing the clip, and it is
165
+ // indistinguishable from a slider that failed to write. Negative
166
+ // plays the clip backwards and is allowed.
167
+ onCommit={(next) => set("timeScale", next === 0 ? 1 : next)}
168
+ />
169
+ </InspectorRow>
170
+ </InspectorSection>
171
+ );
172
+ }
@@ -0,0 +1,73 @@
1
+ // Drop shadow AND glow — ONE field with two affordances rather than two fields
2
+ // that can disagree. A glow IS a shadow at zero offset with a bright colour,
3
+ // which is exactly how the rasterizer draws it (raster.ts), so modelling them
4
+ // separately would be two schema fields for one Canvas2D call.
5
+ import { ColorPicker, InspectorRow, InspectorSection, NumberField } from "@dailies-studio/ui";
6
+ import type { Shadow } from "@dailies-studio/shot/doc";
7
+ import { NO_SCRUB, useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
8
+
9
+ const DEFAULT_SHADOW: Shadow = { xPx: 0, yPx: 12, blurPx: 32, spreadPx: 0, color: "#00000080" };
10
+
11
+ export function ShadowSection() {
12
+ const write = useSelectionWrite();
13
+ const present = useSelectionValue((layer) => (layer.paint.shadows ?? []).length > 0);
14
+ const first = useSelectionValue((layer) => layer.paint.shadows?.[0]);
15
+
16
+ /** The FIRST shadow only. A layer may hold several (the schema is a list, and
17
+ * a glow plus a drop shadow is the common pair), but editing the second one
18
+ * needs a per-shadow list UI — which is a section of its own. */
19
+ const patch = (next: Partial<Shadow>): void =>
20
+ write((layer) => {
21
+ const shadows = [...(layer.paint.shadows ?? [DEFAULT_SHADOW])];
22
+ shadows[0] = { ...DEFAULT_SHADOW, ...shadows[0], ...next };
23
+ return { ...layer, paint: { ...layer.paint, shadows } };
24
+ });
25
+
26
+ const shadow = first.value ?? DEFAULT_SHADOW;
27
+
28
+ return (
29
+ <InspectorSection
30
+ title="Shadow"
31
+ persistKey="shot:shadow"
32
+ action={
33
+ <button
34
+ type="button"
35
+ className="section-action"
36
+ title={present.value === true ? "Remove shadow" : "Add shadow"}
37
+ onClick={() =>
38
+ present.value === true
39
+ ? write((layer) => ({ ...layer, paint: { ...layer.paint, shadows: [] } }))
40
+ : patch({})
41
+ }
42
+ >
43
+ {present.value === true ? "−" : "+"}
44
+ </button>
45
+ }
46
+ >
47
+ {present.value === true ? (
48
+ <>
49
+ <InspectorRow label="Color">
50
+ <ColorPicker
51
+ value={shadow.color}
52
+ ariaLabel="Shadow colour"
53
+ scrub={NO_SCRUB}
54
+ commit={(hex) => patch({ color: hex })}
55
+ />
56
+ </InspectorRow>
57
+ <InspectorRow>
58
+ <NumberField value={shadow.xPx} label="X" ariaLabel="Shadow X" unit="px" step={1} onCommit={(v) => patch({ xPx: v })} />
59
+ </InspectorRow>
60
+ <InspectorRow>
61
+ <NumberField value={shadow.yPx} label="Y" ariaLabel="Shadow Y" unit="px" step={1} onCommit={(v) => patch({ yPx: v })} />
62
+ </InspectorRow>
63
+ <InspectorRow>
64
+ <NumberField value={shadow.blurPx} label="Blur" ariaLabel="Shadow blur" unit="px" min={0} step={1} onCommit={(v) => patch({ blurPx: v })} />
65
+ </InspectorRow>
66
+ <InspectorRow>
67
+ <NumberField value={shadow.spreadPx} label="Spread" ariaLabel="Shadow spread" unit="px" step={1} onCommit={(v) => patch({ spreadPx: v })} />
68
+ </InspectorRow>
69
+ </>
70
+ ) : null}
71
+ </InspectorSection>
72
+ );
73
+ }
@@ -0,0 +1,85 @@
1
+ import { ColorPicker, InspectorRow, InspectorSection, NumberField, SegmentedControl } from "@dailies-studio/ui";
2
+ import type { Stroke } from "@dailies-studio/shot/doc";
3
+ import { NO_SCRUB, useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
4
+
5
+ const DEFAULT_STROKE: Stroke = { color: "#ffffff", widthPx: 1, align: "center" };
6
+
7
+ export function StrokeSection() {
8
+ const write = useSelectionWrite();
9
+ const present = useSelectionValue((layer) => layer.paint.stroke !== undefined);
10
+ const color = useSelectionValue((layer) => layer.paint.stroke?.color ?? DEFAULT_STROKE.color);
11
+ const width = useSelectionValue((layer) => layer.paint.stroke?.widthPx ?? DEFAULT_STROKE.widthPx);
12
+ const align = useSelectionValue((layer) => layer.paint.stroke?.align ?? DEFAULT_STROKE.align);
13
+
14
+ const patch = (next: Partial<Stroke>): void =>
15
+ write((layer) => ({
16
+ ...layer,
17
+ paint: { ...layer.paint, stroke: { ...DEFAULT_STROKE, ...layer.paint.stroke, ...next } },
18
+ }));
19
+
20
+ return (
21
+ <InspectorSection
22
+ title="Stroke"
23
+ persistKey="shot:stroke"
24
+ action={
25
+ <button
26
+ type="button"
27
+ className="section-action"
28
+ title={present.value === true ? "Remove stroke" : "Add stroke"}
29
+ onClick={() =>
30
+ present.value === true
31
+ ? write((layer) => ({ ...layer, paint: withoutStroke(layer.paint) }))
32
+ : patch({})
33
+ }
34
+ >
35
+ {present.value === true ? "−" : "+"}
36
+ </button>
37
+ }
38
+ >
39
+ {present.value === true ? (
40
+ <>
41
+ <InspectorRow label="Color">
42
+ <ColorPicker
43
+ value={color.value ?? DEFAULT_STROKE.color}
44
+ ariaLabel="Stroke colour"
45
+ scrub={NO_SCRUB}
46
+ commit={(hex) => patch({ color: hex })}
47
+ />
48
+ </InspectorRow>
49
+ <InspectorRow>
50
+ <NumberField
51
+ value={width.value ?? 1}
52
+ mixed={width.mixed}
53
+ label="Width"
54
+ ariaLabel="Stroke width"
55
+ unit="px"
56
+ min={0}
57
+ step={0.5}
58
+ onCommit={(next) => patch({ widthPx: next })}
59
+ />
60
+ </InspectorRow>
61
+ <InspectorRow label="Align">
62
+ {/* Canvas2D only CENTRES a stroke, so inside and outside are a
63
+ half-width inset either way — raster.ts does that, and the three
64
+ options are honest because the raster honours all three. */}
65
+ <SegmentedControl
66
+ ariaLabel="Stroke align"
67
+ value={align.value ?? "center"}
68
+ options={[
69
+ { value: "inside", label: "In" },
70
+ { value: "center", label: "Mid" },
71
+ { value: "outside", label: "Out" },
72
+ ]}
73
+ onChange={(next) => patch({ align: next as Stroke["align"] })}
74
+ />
75
+ </InspectorRow>
76
+ </>
77
+ ) : null}
78
+ </InspectorSection>
79
+ );
80
+ }
81
+
82
+ function withoutStroke(paint: { stroke?: Stroke }): typeof paint {
83
+ const { stroke: _drop, ...rest } = paint;
84
+ return rest;
85
+ }
@@ -0,0 +1,91 @@
1
+ // Position, size, depth, tilt, roll — every field the schema's `Transform3D`
2
+ // carries, and the section where the read/write asymmetry actually bites.
3
+ //
4
+ // Every readout is the layer AS PAINTED at the playhead and every commit goes
5
+ // through `setEffectiveField`, so the number typed is the number drawn. See
6
+ // ../useDocWrite.ts for why both halves are required.
7
+ import { InspectorRow, InspectorSection, NumberField, PropertyGrid } from "@dailies-studio/ui";
8
+ import type { TransformField } from "@dailies-studio/shot/doc";
9
+ import { useSelectionValue, useTransformWrite } from "../useDocWrite.js";
10
+ import { AlignRow } from "./AlignRow.js";
11
+
12
+ export function TransformSection() {
13
+ return (
14
+ <InspectorSection title="Transform" persistKey="shot:transform">
15
+ {/* Above the fields, the way Raylight leads its Position section: the
16
+ alignment buttons are how a layout actually gets made, and the
17
+ numbers are how it gets adjusted afterwards. */}
18
+ <AlignRow />
19
+ <PropertyGrid>
20
+ <Field field="x" label="X" unit="px" />
21
+ <Field field="y" label="Y" unit="px" />
22
+ <Field field="w" label="W" unit="px" min={1} />
23
+ <Field field="h" label="H" unit="px" min={1} />
24
+ {/* Depth is the field that makes this lane 3D, so it sits with the
25
+ position rather than in a section of its own. */}
26
+ <Field field="z" label="Z" unit="px" />
27
+ <Field field="roll" label="Roll" unit="°" step={1} />
28
+ <Field field="tiltX" label="Tilt X" unit="°" step={1} min={-180} max={180} />
29
+ <Field field="tiltY" label="Tilt Y" unit="°" step={1} min={-180} max={180} />
30
+ </PropertyGrid>
31
+ </InspectorSection>
32
+ );
33
+ }
34
+
35
+ function Field({
36
+ field,
37
+ label,
38
+ unit,
39
+ step = 1,
40
+ min,
41
+ max,
42
+ }: {
43
+ field: TransformField;
44
+ label: string;
45
+ unit?: string;
46
+ step?: number;
47
+ min?: number;
48
+ max?: number;
49
+ }) {
50
+ const write = useTransformWrite();
51
+ const { value, mixed } = useSelectionValue((_layer, effective) => readField(effective, field));
52
+ if (value === undefined) return null;
53
+ return (
54
+ <InspectorRow>
55
+ <NumberField
56
+ value={value}
57
+ mixed={mixed}
58
+ label={label}
59
+ ariaLabel={label}
60
+ {...(unit === undefined ? {} : { unit })}
61
+ step={step}
62
+ {...(min === undefined ? {} : { min })}
63
+ {...(max === undefined ? {} : { max })}
64
+ onCommit={(next) => write(field, next)}
65
+ />
66
+ </InspectorRow>
67
+ );
68
+ }
69
+
70
+ function readField(t: { position: readonly number[]; size: readonly number[]; tilt: readonly number[]; rollDeg: number; opacity: number }, field: TransformField): number {
71
+ switch (field) {
72
+ case "x":
73
+ return t.position[0]!;
74
+ case "y":
75
+ return t.position[1]!;
76
+ case "z":
77
+ return t.position[2]!;
78
+ case "w":
79
+ return t.size[0]!;
80
+ case "h":
81
+ return t.size[1]!;
82
+ case "tiltX":
83
+ return t.tilt[0]!;
84
+ case "tiltY":
85
+ return t.tilt[1]!;
86
+ case "roll":
87
+ return t.rollDeg;
88
+ case "opacity":
89
+ return t.opacity;
90
+ }
91
+ }
@@ -0,0 +1,170 @@
1
+ // Only for TEXT layers, and absent otherwise — a typography section over a
2
+ // rectangle is four controls that write nothing.
3
+ //
4
+ // `verticalAlign` is offered here and is HONOURED, unlike in the 2D lane where
5
+ // it is declared-and-inert: this lane owns the text box, so top/middle/bottom
6
+ // is one offset in the rasterizer rather than a change to a layout model.
7
+ import { ColorPicker, InspectorRow, InspectorSection, NumberField, SegmentedControl, TextField } from "@dailies-studio/ui";
8
+ import { DEFAULT_TEXT_STAGGER } from "@dailies-studio/shot/doc";
9
+ import type { TextStagger, TextStyle } from "@dailies-studio/shot/doc";
10
+ import type { Layer3D, TextLayer3D } from "@dailies-studio/shot/doc";
11
+ import { NO_SCRUB, useSelectedLayers, useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
12
+
13
+ export function TypographySection() {
14
+ const write = useSelectionWrite();
15
+ const layers = useSelectedLayers();
16
+ const anyText = layers.some((l) => l.kind === "text");
17
+ const style = useSelectionValue((layer) => (layer.kind === "text" ? layer.text.style : undefined));
18
+ const content = useSelectionValue((layer) => (layer.kind === "text" ? layer.text.content : ""));
19
+ if (!anyText || style.value === undefined) return null;
20
+
21
+ const patchStyle = (next: Partial<TextStyle>): void =>
22
+ write((layer) =>
23
+ layer.kind === "text"
24
+ ? ({ ...layer, text: { ...layer.text, style: { ...layer.text.style, ...next } } } as Layer3D)
25
+ : layer,
26
+ );
27
+
28
+ const s = style.value;
29
+ return (
30
+ <InspectorSection title="Text" persistKey="shot:text">
31
+ <InspectorRow label="Content">
32
+ <TextField
33
+ value={content.value ?? ""}
34
+ ariaLabel="Text content"
35
+ onCommit={(next) =>
36
+ write((layer) =>
37
+ layer.kind === "text" ? ({ ...layer, text: { ...layer.text, content: next } } as Layer3D) : layer,
38
+ )
39
+ }
40
+ />
41
+ </InspectorRow>
42
+ <InspectorRow label="Font">
43
+ <TextField value={s.fontFamily} ariaLabel="Font family" onCommit={(next) => patchStyle({ fontFamily: next })} />
44
+ </InspectorRow>
45
+ <InspectorRow>
46
+ <NumberField value={s.sizePx} label="Size" ariaLabel="Font size" unit="px" min={1} step={1} onCommit={(v) => patchStyle({ sizePx: v })} />
47
+ </InspectorRow>
48
+ <InspectorRow>
49
+ <NumberField value={s.weight} label="Weight" ariaLabel="Font weight" min={100} max={900} step={100} onCommit={(v) => patchStyle({ weight: v })} />
50
+ </InspectorRow>
51
+ <InspectorRow>
52
+ <NumberField value={s.lineHeight} label="Line" ariaLabel="Line height" step={0.05} min={0.5} onCommit={(v) => patchStyle({ lineHeight: v })} />
53
+ </InspectorRow>
54
+ <InspectorRow>
55
+ <NumberField value={s.letterSpacingPx} label="Letter" ariaLabel="Letter spacing" unit="px" step={0.1} onCommit={(v) => patchStyle({ letterSpacingPx: v })} />
56
+ </InspectorRow>
57
+ <InspectorRow label="Color">
58
+ <ColorPicker value={s.color} ariaLabel="Text colour" scrub={NO_SCRUB} commit={(hex) => patchStyle({ color: hex })} />
59
+ </InspectorRow>
60
+ <InspectorRow label="Align">
61
+ <SegmentedControl
62
+ ariaLabel="Text align"
63
+ value={s.align}
64
+ options={[
65
+ { value: "left", label: "L" },
66
+ { value: "center", label: "C" },
67
+ { value: "right", label: "R" },
68
+ ]}
69
+ onChange={(next) => patchStyle({ align: next as TextStyle["align"] })}
70
+ />
71
+ </InspectorRow>
72
+ <InspectorRow label="Vertical">
73
+ <SegmentedControl
74
+ ariaLabel="Vertical align"
75
+ value={s.verticalAlign ?? "top"}
76
+ options={[
77
+ { value: "top", label: "T" },
78
+ { value: "middle", label: "M" },
79
+ { value: "bottom", label: "B" },
80
+ ]}
81
+ onChange={(next) => patchStyle({ verticalAlign: next as NonNullable<TextStyle["verticalAlign"]> })}
82
+ />
83
+ </InspectorRow>
84
+ </InspectorSection>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The SHAPE of a per-unit stagger, which is a property of the text rather than
90
+ * of any one block — see `TextStagger` for why it cannot live on the block.
91
+ *
92
+ * Its own section rather than four more rows under Text: it is inert until a
93
+ * `stagger` block exists on the layer, so it deserves to be collapsed away for
94
+ * the layers that never animate.
95
+ *
96
+ * Every field writes a WHOLE record spread from the default, so the first edit
97
+ * materialises what "absent means the default" already meant. Reading does the
98
+ * same, so the fields show the real starting values instead of blanks.
99
+ */
100
+ export function TextStaggerSection() {
101
+ const write = useSelectionWrite();
102
+ const layers = useSelectedLayers();
103
+ const anyText = layers.some((l) => l.kind === "text");
104
+ const stagger = useSelectionValue((layer) =>
105
+ layer.kind === "text" ? (layer.text.stagger ?? DEFAULT_TEXT_STAGGER) : undefined,
106
+ );
107
+ if (!anyText || stagger.value === undefined) return null;
108
+
109
+ const patch = (next: Partial<TextStagger>): void =>
110
+ write((layer) =>
111
+ layer.kind === "text"
112
+ ? ({
113
+ ...layer,
114
+ text: { ...layer.text, stagger: { ...DEFAULT_TEXT_STAGGER, ...layer.text.stagger, ...next } },
115
+ } as Layer3D)
116
+ : layer,
117
+ );
118
+
119
+ const g = stagger.value;
120
+ return (
121
+ <InspectorSection title="Stagger" persistKey="shot:textStagger">
122
+ <InspectorRow label="Unit">
123
+ <SegmentedControl
124
+ ariaLabel="Stagger unit"
125
+ value={g.unit}
126
+ options={[
127
+ { value: "char", label: "Char" },
128
+ { value: "word", label: "Word" },
129
+ { value: "line", label: "Line" },
130
+ ]}
131
+ onChange={(next) => patch({ unit: next as TextStagger["unit"] })}
132
+ />
133
+ </InspectorRow>
134
+ <InspectorRow>
135
+ <NumberField
136
+ value={g.spread}
137
+ label="Spread"
138
+ ariaLabel="Stagger spread"
139
+ min={0}
140
+ max={1}
141
+ step={0.05}
142
+ onCommit={(v) => patch({ spread: v })}
143
+ />
144
+ </InspectorRow>
145
+ <InspectorRow>
146
+ <NumberField
147
+ value={g.risePx}
148
+ label="Rise"
149
+ ariaLabel="Stagger rise"
150
+ unit="px"
151
+ step={1}
152
+ onCommit={(v) => patch({ risePx: v })}
153
+ />
154
+ </InspectorRow>
155
+ <InspectorRow>
156
+ <NumberField
157
+ value={g.fadeFrom}
158
+ label="Fade from"
159
+ ariaLabel="Stagger fade from"
160
+ min={0}
161
+ max={1}
162
+ step={0.05}
163
+ onCommit={(v) => patch({ fadeFrom: v })}
164
+ />
165
+ </InspectorRow>
166
+ </InspectorSection>
167
+ );
168
+ }
169
+
170
+ void ({} as TextLayer3D);
@@ -0,0 +1,59 @@
1
+ // The scene's camera, as five fields. Shown under the scene, when nothing is
2
+ // selected.
3
+ //
4
+ // ── THE SAME TWO TRUTHS THE LAYER INSPECTOR FOLLOWS ─────────────────────
5
+ //
6
+ // READ EFFECTIVE: `cameraAt` at the playhead, which is what is on screen. A
7
+ // section that read `scene.camera` would say x=0 while a finished move held the
8
+ // camera at 400, and the author would type against a number nothing agrees
9
+ // with.
10
+ //
11
+ // WRITE BASE, AND REFUSE WHEN THAT IS INVISIBLE: from the first shot's start
12
+ // onwards `cameraAt` is painting a shot's framing, so writing `scene.camera`
13
+ // there would dirty the document and move nothing. That refusal is not written
14
+ // here. It is `writeFraming`'s, reached through the store's `writeCamera`, the
15
+ // same seam the frustum drag, the align and the fit all go through. This panel
16
+ // is a keyboard frustum and deliberately has no opinion of its own.
17
+ //
18
+ // The two halves are coherent by construction rather than by care:
19
+ // `shotOwningCamera` is null exactly when `cameraAt` returns `scene.camera`, so
20
+ // whenever a base write is allowed the effective value IS the base.
21
+ import { InspectorSection } from "@dailies-studio/ui";
22
+ import { cameraAt, shotOwningCamera } from "@dailies-studio/shot/doc";
23
+ import { useShotEditor, useStore } from "../../../state/context.js";
24
+ import { FramingRows } from "../FramingRows.js";
25
+ import { useInspectorFrame } from "../useDocWrite.js";
26
+
27
+ export function CameraSection() {
28
+ const store = useStore();
29
+ const doc = useShotEditor((s) => s.doc);
30
+ const animate = useShotEditor((s) => s.animateMode);
31
+ const { sceneId, sceneFrame } = useInspectorFrame();
32
+ if (doc === null || sceneId === null) return null;
33
+ const scene = doc.scenesById[sceneId];
34
+ if (scene === undefined) return null;
35
+
36
+ // Only worth saying with animate OFF: with it on the same write authors a
37
+ // move instead of being refused, so naming the owner would be a warning about
38
+ // something that is not going to happen.
39
+ const owner = animate ? null : shotOwningCamera(scene, sceneFrame);
40
+
41
+ return (
42
+ <InspectorSection title="Camera" persistKey="shot:camera">
43
+ <FramingRows
44
+ framing={cameraAt(scene, sceneFrame, doc.fps)}
45
+ name="Camera"
46
+ onCommit={(patch) => store.setSceneCamera(patch)}
47
+ />
48
+ {owner !== null && (
49
+ // The fields stay LIVE. Typing into one produces the refusal by name in
50
+ // the error strip, which is how the author learns the rule; a disabled
51
+ // field would only teach them that the panel is broken.
52
+ <span className="insp-dim" data-testid="camera-held-by">
53
+ Held by the camera move “{owner.label}” from frame {owner.frame}. Turn on Animate (⇧A) to author the
54
+ move, or put the playhead before it.
55
+ </span>
56
+ )}
57
+ </InspectorSection>
58
+ );
59
+ }