@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,118 @@
|
|
|
1
|
+
// The rail's icon set: hand-drawn 24-viewBox strokes at 1.5, tinted by the CSS
|
|
2
|
+
// `color` of whatever wraps them. Still no icon dependency — but a family of
|
|
3
|
+
// consistent strokes, where the old unicode glyphs never read as one voice.
|
|
4
|
+
//
|
|
5
|
+
// Sized by CSS (`.layer-kind svg`, `.layer-flag svg`), never by attributes, so
|
|
6
|
+
// one rule owns the scale everywhere the set appears.
|
|
7
|
+
import type { ReactElement, ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
function icon(children: ReactNode): ReactElement {
|
|
10
|
+
return (
|
|
11
|
+
<svg
|
|
12
|
+
viewBox="0 0 24 24"
|
|
13
|
+
fill="none"
|
|
14
|
+
stroke="currentColor"
|
|
15
|
+
strokeWidth={1.5}
|
|
16
|
+
strokeLinecap="round"
|
|
17
|
+
strokeLinejoin="round"
|
|
18
|
+
aria-hidden="true"
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</svg>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One icon per layer kind — the same grammar Figma settled: a lone diamond is
|
|
26
|
+
* an instance, four diamonds are a definition. */
|
|
27
|
+
const KIND_ICONS: Record<string, ReactElement> = {
|
|
28
|
+
text: icon(<path d="M6 6h12M12 6v13" />),
|
|
29
|
+
image: icon(
|
|
30
|
+
<>
|
|
31
|
+
<rect x="3.5" y="5" width="17" height="14" rx="2" />
|
|
32
|
+
<circle cx="9" cy="9.5" r="1.5" />
|
|
33
|
+
<path d="m4.5 17 4-4 4 4 3-3 4 4" />
|
|
34
|
+
</>,
|
|
35
|
+
),
|
|
36
|
+
video: icon(
|
|
37
|
+
<>
|
|
38
|
+
<rect x="3.5" y="5" width="17" height="14" rx="2" />
|
|
39
|
+
<path d="M10.5 9.5v5l4-2.5z" />
|
|
40
|
+
</>,
|
|
41
|
+
),
|
|
42
|
+
rect: icon(<rect x="4.5" y="6.5" width="15" height="11" rx="1.5" />),
|
|
43
|
+
ellipse: icon(<circle cx="12" cy="12" r="7.5" />),
|
|
44
|
+
line: icon(<path d="M5.5 18.5 18.5 5.5" />),
|
|
45
|
+
arrow: icon(<path d="M6 18 18 6M9 6h9v9" />),
|
|
46
|
+
path: icon(<path d="M4.5 19.5c7 0 8-15 15-15" />),
|
|
47
|
+
// Angle brackets: the one glyph nobody has to be told means markup.
|
|
48
|
+
html: icon(<path d="m9 8-4 4 4 4M15 8l4 4-4 4" />),
|
|
49
|
+
group: icon(
|
|
50
|
+
<>
|
|
51
|
+
<rect x="4" y="4" width="12" height="12" rx="2" />
|
|
52
|
+
<path d="M20 9v9a2 2 0 0 1-2 2H9" />
|
|
53
|
+
</>,
|
|
54
|
+
),
|
|
55
|
+
device: icon(
|
|
56
|
+
<>
|
|
57
|
+
<rect x="7" y="3.5" width="10" height="17" rx="2" />
|
|
58
|
+
<path d="M10.5 17.5h3" />
|
|
59
|
+
</>,
|
|
60
|
+
),
|
|
61
|
+
mesh: icon(<path d="M12 3.5 19.5 8v8L12 20.5 4.5 16V8z" />),
|
|
62
|
+
cloner: icon(
|
|
63
|
+
<>
|
|
64
|
+
<rect x="4.5" y="4.5" width="6" height="6" rx="1" />
|
|
65
|
+
<rect x="13.5" y="4.5" width="6" height="6" rx="1" />
|
|
66
|
+
<rect x="4.5" y="13.5" width="6" height="6" rx="1" />
|
|
67
|
+
<rect x="13.5" y="13.5" width="6" height="6" rx="1" />
|
|
68
|
+
</>,
|
|
69
|
+
),
|
|
70
|
+
instance: icon(<path d="m12 4 8 8-8 8-8-8z" />),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const FALLBACK = icon(<path d="m12 6 6 6-6 6-6-6z" />);
|
|
74
|
+
|
|
75
|
+
export function kindIcon(kind: string): ReactElement {
|
|
76
|
+
return KIND_ICONS[kind] ?? FALLBACK;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A shot row: a clapperboard. */
|
|
80
|
+
export const SCENE_ICON = icon(
|
|
81
|
+
<>
|
|
82
|
+
<rect x="3.5" y="5" width="17" height="14" rx="2" />
|
|
83
|
+
<path d="M3.5 9.5h17M9 5.5l1.5 4M14 5.5l1.5 4" />
|
|
84
|
+
</>,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/** A component definition: the four-diamond glyph. */
|
|
88
|
+
export const COMPONENT_ICON = icon(
|
|
89
|
+
<path d="M12 3.5 15 6.5 12 9.5 9 6.5zM17.5 9l3 3-3 3-3-3zM12 14.5l3 3-3 3-3-3zM6.5 9l3 3-3 3-3-3z" />,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
export const EYE_ICON = icon(
|
|
93
|
+
<>
|
|
94
|
+
<path d="M3.5 12S6.5 6 12 6s8.5 6 8.5 6-3 6-8.5 6S3.5 12 3.5 12z" />
|
|
95
|
+
<circle cx="12" cy="12" r="2.5" />
|
|
96
|
+
</>,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
export const EYE_OFF_ICON = icon(
|
|
100
|
+
<>
|
|
101
|
+
<path d="M3.5 12S6.5 6 12 6s8.5 6 8.5 6-3 6-8.5 6S3.5 12 3.5 12z" />
|
|
102
|
+
<path d="m5 4.5 14 15" />
|
|
103
|
+
</>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
export const LOCK_ICON = icon(
|
|
107
|
+
<>
|
|
108
|
+
<rect x="5.5" y="10.5" width="13" height="9" rx="2" />
|
|
109
|
+
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5" />
|
|
110
|
+
</>,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
export const UNLOCK_ICON = icon(
|
|
114
|
+
<>
|
|
115
|
+
<rect x="5.5" y="10.5" width="13" height="9" rx="2" />
|
|
116
|
+
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 6.8-1.2" />
|
|
117
|
+
</>,
|
|
118
|
+
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Which scene is open, and how to open another one.
|
|
2
|
+
//
|
|
3
|
+
// A `<select>` and not a custom popover, deliberately: it is the one control in
|
|
4
|
+
// this chrome that has to work before anything else does (there is no document
|
|
5
|
+
// yet), and the native element brings keyboard navigation, type-ahead and a
|
|
6
|
+
// scrolling list that never leaves the viewport for free.
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
9
|
+
|
|
10
|
+
export function DocSwitcher() {
|
|
11
|
+
const store = useStore();
|
|
12
|
+
const shots = useShotEditor((s) => s.shots);
|
|
13
|
+
const name = useShotEditor((s) => s.name);
|
|
14
|
+
const [creating, setCreating] = useState(false);
|
|
15
|
+
const [draft, setDraft] = useState("");
|
|
16
|
+
|
|
17
|
+
const create = (): void => {
|
|
18
|
+
const trimmed = draft.trim();
|
|
19
|
+
setCreating(false);
|
|
20
|
+
setDraft("");
|
|
21
|
+
if (trimmed.length > 0) void store.createShot(trimmed);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (creating) {
|
|
25
|
+
return (
|
|
26
|
+
<form
|
|
27
|
+
className="doc-switcher"
|
|
28
|
+
onSubmit={(e) => {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
create();
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<input
|
|
34
|
+
className="doc-switcher-input"
|
|
35
|
+
autoFocus
|
|
36
|
+
value={draft}
|
|
37
|
+
placeholder="scene name"
|
|
38
|
+
aria-label="New scene name"
|
|
39
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
40
|
+
onKeyDown={(e) => {
|
|
41
|
+
if (e.key === "Escape") {
|
|
42
|
+
e.stopPropagation();
|
|
43
|
+
setCreating(false);
|
|
44
|
+
setDraft("");
|
|
45
|
+
}
|
|
46
|
+
}}
|
|
47
|
+
/>
|
|
48
|
+
</form>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="doc-switcher">
|
|
54
|
+
<select
|
|
55
|
+
className="doc-switcher-select"
|
|
56
|
+
aria-label="Open scene"
|
|
57
|
+
value={name ?? ""}
|
|
58
|
+
onChange={(e) => {
|
|
59
|
+
const next = e.target.value;
|
|
60
|
+
// Closing DROPS the in-memory document, so the debounced autosave has
|
|
61
|
+
// to land first — the same order `openShot` uses. `flush` rejects
|
|
62
|
+
// when it could not write, which leaves the scene open with the
|
|
63
|
+
// failure already on screen instead of discarding it.
|
|
64
|
+
if (next === "") void store.flush().then(() => store.closeShot(), () => {});
|
|
65
|
+
else void store.openShot(next);
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<option value="">{shots.length === 0 ? "no scenes yet" : "choose a scene…"}</option>
|
|
69
|
+
{shots.map((s) => (
|
|
70
|
+
<option key={s.name} value={s.name}>
|
|
71
|
+
{s.name}
|
|
72
|
+
</option>
|
|
73
|
+
))}
|
|
74
|
+
</select>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
className="doc-switcher-new"
|
|
78
|
+
title="New scene"
|
|
79
|
+
onClick={() => setCreating(true)}
|
|
80
|
+
>
|
|
81
|
+
+
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The floating header: which document, what state it is in, and the transport.
|
|
2
|
+
//
|
|
3
|
+
// Everything here reads the store and writes through it. No panel holds a copy
|
|
4
|
+
// of the document, so there is no second place for the open document's identity
|
|
5
|
+
// to live and drift.
|
|
6
|
+
import { useEffect } from "react";
|
|
7
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
8
|
+
import { DocSwitcher } from "./DocSwitcher.js";
|
|
9
|
+
import { HistoryControls } from "./HistoryControls.js";
|
|
10
|
+
import { StatusBadges } from "./StatusBadges.js";
|
|
11
|
+
import { TransportBar } from "./TransportBar.js";
|
|
12
|
+
import { ZoomControls } from "./ZoomControls.js";
|
|
13
|
+
|
|
14
|
+
export function Header() {
|
|
15
|
+
const store = useStore();
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
void store.refreshShots();
|
|
18
|
+
}, [store]);
|
|
19
|
+
|
|
20
|
+
const name = useShotEditor((s) => s.name);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<header className="shell-header" data-testid="shot-header">
|
|
24
|
+
<DocSwitcher />
|
|
25
|
+
<HistoryControls />
|
|
26
|
+
{name === null ? null : <TransportBar />}
|
|
27
|
+
<div className="shell-header-spacer" />
|
|
28
|
+
{name === null ? null : <PanelToggles />}
|
|
29
|
+
<ZoomControls />
|
|
30
|
+
<StatusBadges />
|
|
31
|
+
<AgentToggle />
|
|
32
|
+
{name !== null && <ExportButton />}
|
|
33
|
+
</header>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Show and hide the three panels.
|
|
39
|
+
*
|
|
40
|
+
* IN THE HEADER, and not on the panels themselves, for one reason: a control
|
|
41
|
+
* that lives on the thing it hides disappears with it. A chevron on the left
|
|
42
|
+
* rail can collapse the rail and then has nowhere to be, which is how these
|
|
43
|
+
* end up needing a second floating "reopen" affordance nobody finds. The
|
|
44
|
+
* header never moves, so one button per panel is the whole story, and each is
|
|
45
|
+
* `aria-pressed` so its state is readable rather than inferred from the glyph.
|
|
46
|
+
*/
|
|
47
|
+
function PanelToggles() {
|
|
48
|
+
const store = useStore();
|
|
49
|
+
const rail = useShotEditor((s) => s.railCollapsed);
|
|
50
|
+
const inspector = useShotEditor((s) => s.inspectorCollapsed);
|
|
51
|
+
const dock = useShotEditor((s) => s.dockCollapsed);
|
|
52
|
+
const rows: ReadonlyArray<{ on: boolean; glyph: string; label: string; key: string; run: () => void }> = [
|
|
53
|
+
{ on: !rail, glyph: "▙", label: "Left panel", key: "[", run: () => store.setRailCollapsed(!rail) },
|
|
54
|
+
{ on: !dock, glyph: "▄", label: "Timeline", key: "", run: () => store.setDockCollapsed(!dock) },
|
|
55
|
+
{
|
|
56
|
+
on: !inspector,
|
|
57
|
+
glyph: "▟",
|
|
58
|
+
label: "Right panel",
|
|
59
|
+
key: "]",
|
|
60
|
+
run: () => store.setInspectorCollapsed(!inspector),
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
return (
|
|
64
|
+
<div className="panel-toggles" role="group" aria-label="Panels">
|
|
65
|
+
{rows.map(({ on, glyph, label, key, run }) => (
|
|
66
|
+
<button
|
|
67
|
+
key={label}
|
|
68
|
+
type="button"
|
|
69
|
+
className={`btn btn-icon${on ? " is-active" : ""}`}
|
|
70
|
+
aria-pressed={on}
|
|
71
|
+
title={`${on ? "Hide" : "Show"} ${label.toLowerCase()}${key === "" ? "" : ` (${key})`}`}
|
|
72
|
+
onClick={run}
|
|
73
|
+
>
|
|
74
|
+
{glyph}
|
|
75
|
+
</button>
|
|
76
|
+
))}
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
className="btn btn-icon"
|
|
80
|
+
title="Hide every panel (.)"
|
|
81
|
+
onClick={() => store.togglePanels()}
|
|
82
|
+
>
|
|
83
|
+
⛶
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Opens the drawer, which REPLACES the inspector rather than sitting beside
|
|
90
|
+
* it. One right rail: the shell grid has one column there, and a second
|
|
91
|
+
* floating panel over a 3D stage is a panel that covers the thing being
|
|
92
|
+
* discussed. */
|
|
93
|
+
function AgentToggle() {
|
|
94
|
+
const store = useStore();
|
|
95
|
+
const open = useShotEditor((s) => s.agentOpen);
|
|
96
|
+
const running = useShotEditor((s) => s.agentRunning);
|
|
97
|
+
return (
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
className={`btn${open ? " is-active" : ""}`}
|
|
101
|
+
aria-label="Agent"
|
|
102
|
+
aria-pressed={open}
|
|
103
|
+
title="Agent — describe the scene you want"
|
|
104
|
+
onClick={() => store.setAgentOpen(!open)}
|
|
105
|
+
>
|
|
106
|
+
{running ? "Agent…" : "Agent"}
|
|
107
|
+
</button>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The export button, doubling as the render's progress readout.
|
|
112
|
+
*
|
|
113
|
+
* One control, because "export" and "how is my export going" are the same
|
|
114
|
+
* question to the author — and because the dialog can be closed while a render
|
|
115
|
+
* runs, so a button that forgot would be the only place the progress lived. */
|
|
116
|
+
function ExportButton() {
|
|
117
|
+
const store = useStore();
|
|
118
|
+
const job = useShotEditor((s) => s.renderJob);
|
|
119
|
+
const running = job?.phase === "running";
|
|
120
|
+
const pct = running && job.total > 0 ? Math.round((job.done / job.total) * 100) : null;
|
|
121
|
+
return (
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
className={`btn is-primary header-export${running ? " is-running" : ""}`}
|
|
125
|
+
aria-label="Export"
|
|
126
|
+
title="Export (⌘E)"
|
|
127
|
+
onClick={() => store.setExportOpen(true)}
|
|
128
|
+
>
|
|
129
|
+
{pct === null ? "Export" : `Exporting ${pct}%`}
|
|
130
|
+
</button>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
2
|
+
|
|
3
|
+
/** Undo/redo. The enabled state comes from engine's history, never from a
|
|
4
|
+
* count this chrome keeps — a second source of truth for "can I undo" is a
|
|
5
|
+
* button that lies exactly when it matters. */
|
|
6
|
+
export function HistoryControls() {
|
|
7
|
+
const store = useStore();
|
|
8
|
+
const canUndo = useShotEditor((s) => s.canUndo);
|
|
9
|
+
const canRedo = useShotEditor((s) => s.canRedo);
|
|
10
|
+
return (
|
|
11
|
+
<div className="history-controls">
|
|
12
|
+
<button type="button" disabled={!canUndo} title="Undo (⌘Z)" onClick={() => store.undo()}>
|
|
13
|
+
↶
|
|
14
|
+
</button>
|
|
15
|
+
<button type="button" disabled={!canRedo} title="Redo (⌘⇧Z)" onClick={() => store.redo()}>
|
|
16
|
+
↷
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// What the chrome knows and the author cannot see: save state, codegen refusal,
|
|
2
|
+
// and which GL backend actually came up.
|
|
3
|
+
//
|
|
4
|
+
// The backend badge is not decoration, but read it for what it is: the shot
|
|
5
|
+
// lane PINS WebGL (`SHOT_STAGE_BACKEND`, packages/shot/src/stageBackend.ts) so
|
|
6
|
+
// the editor previews on the same backend the export renders on — "webgl" here
|
|
7
|
+
// is the pin working, not a fallback event. The badge earns its keep on the
|
|
8
|
+
// day something changes that pin: a backend the export lane cannot reproduce
|
|
9
|
+
// is the first thing to check when an effect looks wrong in one place and
|
|
10
|
+
// right in the other.
|
|
11
|
+
import { useShotEditor } from "../../state/context.js";
|
|
12
|
+
|
|
13
|
+
export function StatusBadges() {
|
|
14
|
+
const saveState = useShotEditor((s) => s.saveState);
|
|
15
|
+
const dirty = useShotEditor((s) => s.dirty);
|
|
16
|
+
const emitWarning = useShotEditor((s) => s.emitWarning);
|
|
17
|
+
const stageInfo = useShotEditor((s) => s.stageInfo);
|
|
18
|
+
const name = useShotEditor((s) => s.name);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="status-badges">
|
|
22
|
+
{stageInfo === null ? null : (
|
|
23
|
+
<span className="badge badge-quiet" title={`${stageInfo.width}x${stageInfo.height}`}>
|
|
24
|
+
{stageInfo.backend}
|
|
25
|
+
</span>
|
|
26
|
+
)}
|
|
27
|
+
{emitWarning === null ? null : (
|
|
28
|
+
<span className="badge badge-warn" title={emitWarning}>
|
|
29
|
+
codegen
|
|
30
|
+
</span>
|
|
31
|
+
)}
|
|
32
|
+
{name === null ? null : (
|
|
33
|
+
<span className={`badge badge-${saveState}`} title={saveLabel(saveState, dirty)}>
|
|
34
|
+
{saveLabel(saveState, dirty)}
|
|
35
|
+
</span>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saveLabel(saveState: string, dirty: boolean): string {
|
|
42
|
+
if (saveState === "error") return "save failed";
|
|
43
|
+
if (saveState === "saving") return "saving…";
|
|
44
|
+
// "saved" plus pending edits is not saved — the badge follows the DOCUMENT,
|
|
45
|
+
// not the last request, or an autosave debounce reads as "everything is on
|
|
46
|
+
// disk" for the whole 400ms an author might close the tab in.
|
|
47
|
+
if (dirty) return "unsaved";
|
|
48
|
+
return saveState === "saved" ? "saved" : "—";
|
|
49
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Play, pause, and the playhead.
|
|
2
|
+
//
|
|
3
|
+
// ── THE ONE THING WORTH READING ──────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Playback is a rAF loop that converts WALL TIME into an INTEGER FRAME and
|
|
6
|
+
// seeks to it. It never accumulates frames per tick.
|
|
7
|
+
//
|
|
8
|
+
// The difference matters: `frame++` per rAF makes playback speed a function of
|
|
9
|
+
// the monitor's refresh rate (a 120Hz laptop plays a 30fps shot at double
|
|
10
|
+
// speed) and makes a dropped frame permanently shift everything after it.
|
|
11
|
+
// Deriving the frame from elapsed time instead means the preview plays at the
|
|
12
|
+
// document's own rate on any display, drops frames under load rather than
|
|
13
|
+
// slowing down, and — because the seek target is `round(elapsed * fps)` — puts
|
|
14
|
+
// the same integer frame on screen that the export will render.
|
|
15
|
+
import { useEffect, useRef } from "react";
|
|
16
|
+
import { docDurationFrames } from "@dailies-studio/shot/doc";
|
|
17
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
18
|
+
|
|
19
|
+
/** The slider and the readout — the two things that show the frame — in their
|
|
20
|
+
* own component, so the per-frame tick during playback re-renders them alone
|
|
21
|
+
* and never the play button or the rAF loop's owner above. */
|
|
22
|
+
function TransportScrub({ total, playing }: { total: number; playing: boolean }) {
|
|
23
|
+
const store = useStore();
|
|
24
|
+
const frame = useShotEditor((s) => s.frame);
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<input
|
|
28
|
+
className="transport-scrub"
|
|
29
|
+
type="range"
|
|
30
|
+
min={0}
|
|
31
|
+
max={Math.max(0, total - 1)}
|
|
32
|
+
step={1}
|
|
33
|
+
value={frame}
|
|
34
|
+
aria-label="Playhead"
|
|
35
|
+
onChange={(e) => {
|
|
36
|
+
// Scrubbing pauses. A transport that kept playing under the pointer
|
|
37
|
+
// would fight the drag on every rAF.
|
|
38
|
+
if (playing) store.setPlaying(false);
|
|
39
|
+
store.seek(Number(e.target.value));
|
|
40
|
+
}}
|
|
41
|
+
/>
|
|
42
|
+
<span className="transport-readout" data-testid="frame-readout">
|
|
43
|
+
{frame} / {Math.max(0, total - 1)}
|
|
44
|
+
</span>
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function TransportBar() {
|
|
50
|
+
const store = useStore();
|
|
51
|
+
const playing = useShotEditor((s) => s.playing);
|
|
52
|
+
const doc = useShotEditor((s) => s.doc);
|
|
53
|
+
const total = doc === null ? 0 : docDurationFrames(doc);
|
|
54
|
+
const fps = doc === null ? 30 : doc.fps.num / doc.fps.den;
|
|
55
|
+
|
|
56
|
+
const startedAt = useRef(0);
|
|
57
|
+
const startedFrame = useRef(0);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!playing || doc === null || total <= 0) return;
|
|
60
|
+
startedAt.current = performance.now();
|
|
61
|
+
startedFrame.current = store.getState().frame;
|
|
62
|
+
let raf = 0;
|
|
63
|
+
const tick = (): void => {
|
|
64
|
+
const elapsed = (performance.now() - startedAt.current) / 1000;
|
|
65
|
+
const target = startedFrame.current + Math.round(elapsed * fps);
|
|
66
|
+
store.seek(((target % total) + total) % total);
|
|
67
|
+
raf = requestAnimationFrame(tick);
|
|
68
|
+
};
|
|
69
|
+
raf = requestAnimationFrame(tick);
|
|
70
|
+
return () => cancelAnimationFrame(raf);
|
|
71
|
+
}, [playing, doc, total, fps, store]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="transport">
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
className="transport-play"
|
|
78
|
+
title={playing ? "Pause (Space)" : "Play (Space)"}
|
|
79
|
+
onClick={() => store.setPlaying(!playing)}
|
|
80
|
+
>
|
|
81
|
+
{playing ? "❚❚" : "▶"}
|
|
82
|
+
</button>
|
|
83
|
+
<TransportScrub total={total} playing={playing} />
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
2
|
+
|
|
3
|
+
/** Zoom readout, plus fit. Routed by the ACTIVE pane, exactly as ⌘+/⌘−/⇧Z
|
|
4
|
+
* are: in a split the buttons address the pane the author last touched, and
|
|
5
|
+
* in working mode they dolly the working view instead of silently writing a
|
|
6
|
+
* camera-pane zoom nobody can see. The readout is a percentage only for the
|
|
7
|
+
* camera pane — "100%" has no meaning for an orbit camera, so the working
|
|
8
|
+
* pane shows which view the controls are pointed at instead. The stage OWNS
|
|
9
|
+
* the views (it knows the pane sizes), so "fit" asks the stage and mirrors
|
|
10
|
+
* what it answers rather than computing a second opinion here. */
|
|
11
|
+
export function ZoomControls() {
|
|
12
|
+
const store = useStore();
|
|
13
|
+
// The ZOOM alone, never the whole view object: `onViewChange` fires per
|
|
14
|
+
// pointermove of a camera pan, and subscribing the object re-rendered this
|
|
15
|
+
// readout at pointer rate to display an unchanged percentage.
|
|
16
|
+
const zoom = useShotEditor((s) => s.view.zoom);
|
|
17
|
+
const working = useShotEditor((s) => s.activePane === "working");
|
|
18
|
+
const open = useShotEditor((s) => s.name !== null);
|
|
19
|
+
if (!open) return null;
|
|
20
|
+
return (
|
|
21
|
+
<div className="zoom-controls">
|
|
22
|
+
<button type="button" title="Zoom out" onClick={() => store.zoomActivePane(-1)}>
|
|
23
|
+
−
|
|
24
|
+
</button>
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
className="zoom-readout"
|
|
28
|
+
title={working ? "Fit the working view (⇧Z)" : "Fit (⇧Z)"}
|
|
29
|
+
onClick={() => store.fitActivePane()}
|
|
30
|
+
>
|
|
31
|
+
{working ? "3D" : `${Math.round(zoom * 100)}%`}
|
|
32
|
+
</button>
|
|
33
|
+
<button type="button" title="Zoom in" onClick={() => store.zoomActivePane(1)}>
|
|
34
|
+
+
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|