@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,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
|
+
}
|