@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,172 @@
|
|
|
1
|
+
// Pick a TSL generator to paint over a model's albedo.
|
|
2
|
+
//
|
|
3
|
+
// ── NO GL IN HERE, AND THAT IS NOT A COMPROMISE ─────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The shot chrome is three-free by test, so a live preview tile would have to
|
|
6
|
+
// live in the stage iframe and be pushed across the bridge. It would also buy
|
|
7
|
+
// nothing: the studio already SHIPS a rendered PNG for every catalog entry
|
|
8
|
+
// (`packages/effects/thumbs`, served at the server's own thumbs route), made by
|
|
9
|
+
// the same generators on the same renderer. A live tile would show the same
|
|
10
|
+
// image for more machinery and one GL context per card.
|
|
11
|
+
//
|
|
12
|
+
// ── WHY IT IS A DIALOG AND NOT A PANEL ──────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// Picking a material is a MODE the author enters from one place and leaves the
|
|
15
|
+
// moment they choose — the same shape `ExportDialog` has. A permanent panel
|
|
16
|
+
// would need a permanent home in a shell whose three columns are all spoken
|
|
17
|
+
// for, and would ask "which layer is this for?" on every render instead of
|
|
18
|
+
// once at the point the author asked.
|
|
19
|
+
import { useEffect, useMemo, useState } from "react";
|
|
20
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
21
|
+
import type { ComponentRow } from "../../api.js";
|
|
22
|
+
|
|
23
|
+
/** The one module whose builtins are TSL generators. Paper's shaders are DOM
|
|
24
|
+
* background layers and particles are emitters; neither is a surface a mesh
|
|
25
|
+
* can wear, and both would be a card that produced a document the runtime
|
|
26
|
+
* cannot resolve. */
|
|
27
|
+
const EFFECTS_MODULE = "@dailies-studio/effects";
|
|
28
|
+
|
|
29
|
+
/** Fixed shelf order, mirroring the 2D lane's Library. A category this chrome
|
|
30
|
+
* does not know (a newer server) appends after, alphabetically, rather than
|
|
31
|
+
* vanishing. */
|
|
32
|
+
const CATEGORY_ORDER = ["noise", "patterns", "organic", "space", "material", "decor"];
|
|
33
|
+
|
|
34
|
+
const CATEGORY_LABELS: Record<string, string> = {
|
|
35
|
+
noise: "Noise",
|
|
36
|
+
patterns: "Patterns",
|
|
37
|
+
organic: "Organic",
|
|
38
|
+
space: "Space",
|
|
39
|
+
material: "Material",
|
|
40
|
+
decor: "Decor",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function categoryLabel(category: string): string {
|
|
44
|
+
return CATEGORY_LABELS[category] ?? category.charAt(0).toUpperCase() + category.slice(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A stable colour per id, for a card whose thumbnail has not been generated.
|
|
48
|
+
* Deterministic so the same generator always wears the same placeholder. */
|
|
49
|
+
function thumbGradient(id: string): string {
|
|
50
|
+
let h = 2166136261;
|
|
51
|
+
for (let i = 0; i < id.length; i += 1) {
|
|
52
|
+
h ^= id.charCodeAt(i);
|
|
53
|
+
h = Math.imul(h, 16777619);
|
|
54
|
+
}
|
|
55
|
+
h = h >>> 0;
|
|
56
|
+
const hue1 = h % 360;
|
|
57
|
+
const hue2 = (hue1 + 40 + ((h >>> 9) % 160)) % 360;
|
|
58
|
+
return `linear-gradient(${(h >>> 17) % 360}deg, hsl(${hue1} 45% 30%), hsl(${hue2} 60% 55%))`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function MaterialPicker() {
|
|
62
|
+
const store = useStore();
|
|
63
|
+
const request = useShotEditor((s) => s.materialPicker);
|
|
64
|
+
const library = useShotEditor((s) => s.library);
|
|
65
|
+
const libraryLoaded = useShotEditor((s) => s.libraryLoaded);
|
|
66
|
+
const [query, setQuery] = useState("");
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!libraryLoaded) void store.refreshLibrary();
|
|
70
|
+
}, [libraryLoaded, store]);
|
|
71
|
+
|
|
72
|
+
const shelves = useMemo(() => {
|
|
73
|
+
const needle = query.trim().toLowerCase();
|
|
74
|
+
const rows = library.filter(
|
|
75
|
+
(row): row is ComponentRow & { effect: NonNullable<ComponentRow["effect"]> } =>
|
|
76
|
+
row.builtin === true && row.host === "three" && row.rel === EFFECTS_MODULE && row.effect !== undefined,
|
|
77
|
+
);
|
|
78
|
+
const matching =
|
|
79
|
+
needle === ""
|
|
80
|
+
? rows
|
|
81
|
+
: rows.filter(
|
|
82
|
+
(row) =>
|
|
83
|
+
row.name.toLowerCase().includes(needle) ||
|
|
84
|
+
row.effect.tags.some((t) => t.toLowerCase().includes(needle)),
|
|
85
|
+
);
|
|
86
|
+
const byCategory = new Map<string, typeof matching>();
|
|
87
|
+
for (const row of matching) {
|
|
88
|
+
const list = byCategory.get(row.effect.category);
|
|
89
|
+
if (list) list.push(row);
|
|
90
|
+
else byCategory.set(row.effect.category, [row]);
|
|
91
|
+
}
|
|
92
|
+
return [...byCategory.entries()].sort((a, b) => {
|
|
93
|
+
const ai = CATEGORY_ORDER.indexOf(a[0]);
|
|
94
|
+
const bi = CATEGORY_ORDER.indexOf(b[0]);
|
|
95
|
+
if (ai === bi) return a[0].localeCompare(b[0]);
|
|
96
|
+
// An unknown category sorts after every known one rather than at the top.
|
|
97
|
+
return (ai < 0 ? CATEGORY_ORDER.length : ai) - (bi < 0 ? CATEGORY_ORDER.length : bi);
|
|
98
|
+
});
|
|
99
|
+
}, [library, query]);
|
|
100
|
+
|
|
101
|
+
if (request === null) return null;
|
|
102
|
+
const count = request.layerIds.length;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="dialog-overlay" onPointerDown={() => store.closeMaterialPicker()}>
|
|
106
|
+
<div
|
|
107
|
+
className="dialog material-picker"
|
|
108
|
+
role="dialog"
|
|
109
|
+
aria-label="Choose a material"
|
|
110
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
111
|
+
>
|
|
112
|
+
<div className="dialog-title">Material</div>
|
|
113
|
+
<div className="dialog-field">
|
|
114
|
+
<span className="dialog-label">{count === 1 ? "1 mesh layer" : `${count} mesh layers`}</span>
|
|
115
|
+
<input
|
|
116
|
+
className="material-search"
|
|
117
|
+
type="search"
|
|
118
|
+
placeholder="Search materials"
|
|
119
|
+
value={query}
|
|
120
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
121
|
+
autoFocus
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
<div className="dialog-body">
|
|
125
|
+
{!libraryLoaded ? (
|
|
126
|
+
<p className="dialog-message">Loading…</p>
|
|
127
|
+
) : shelves.length === 0 ? (
|
|
128
|
+
<p className="dialog-message">No material matches “{query}”.</p>
|
|
129
|
+
) : (
|
|
130
|
+
shelves.map(([category, rows]) => (
|
|
131
|
+
<section key={category} className="material-shelf">
|
|
132
|
+
<h3>{categoryLabel(category)}</h3>
|
|
133
|
+
<div className="material-grid">
|
|
134
|
+
{rows.map((row) => (
|
|
135
|
+
<button
|
|
136
|
+
key={row.id}
|
|
137
|
+
type="button"
|
|
138
|
+
className="material-card"
|
|
139
|
+
title={
|
|
140
|
+
row.effect.softwareRaster === "unfinished"
|
|
141
|
+
? `${row.name} — may not finish a software-rasterized render (--gl swangle); use --gl angle.`
|
|
142
|
+
: row.name
|
|
143
|
+
}
|
|
144
|
+
onClick={() => store.applyMeshMaterial(request.layerIds, row.id)}
|
|
145
|
+
>
|
|
146
|
+
{row.effect.thumb === undefined ? (
|
|
147
|
+
<span className="material-thumb" style={{ background: thumbGradient(row.id) }} />
|
|
148
|
+
) : (
|
|
149
|
+
<img className="material-thumb" src={row.effect.thumb} alt="" />
|
|
150
|
+
)}
|
|
151
|
+
<span className="material-name">{row.name}</span>
|
|
152
|
+
{row.effect.softwareRaster === "unfinished" ? (
|
|
153
|
+
<span className="material-badge" aria-hidden="true">
|
|
154
|
+
slow
|
|
155
|
+
</span>
|
|
156
|
+
) : null}
|
|
157
|
+
</button>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
</section>
|
|
161
|
+
))
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
<div className="dialog-actions">
|
|
165
|
+
<button type="button" onClick={() => store.closeMaterialPicker()}>
|
|
166
|
+
Cancel
|
|
167
|
+
</button>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The stage's right-click menu.
|
|
2
|
+
//
|
|
3
|
+
// It is one effect over a MAILBOX. The stage reports a right press, the store
|
|
4
|
+
// parks it on `stageMenu` with a fresh object every time, and this opens the
|
|
5
|
+
// shared `useContextMenu` at that point with the entries `layerMenuEntries`
|
|
6
|
+
// builds. Nothing here decides what a menu says, and nothing here edits a
|
|
7
|
+
// document — see state/menu.ts for why both belong there.
|
|
8
|
+
//
|
|
9
|
+
// ── WHY THE IDENTITY OF THE MAILBOX MATTERS ─────────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// A second right-click at the same point on the same layer is the same VALUE
|
|
12
|
+
// and a different EVENT. The effect keys on the object, so the store minting a
|
|
13
|
+
// fresh one per press is what makes the menu open again rather than once.
|
|
14
|
+
import { useEffect } from "react";
|
|
15
|
+
import { useContextMenu } from "@dailies-studio/ui";
|
|
16
|
+
import { layerMenuEntries, menuTargetsFor } from "../../state/menu.js";
|
|
17
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
18
|
+
|
|
19
|
+
export function StageMenu() {
|
|
20
|
+
const store = useStore();
|
|
21
|
+
const request = useShotEditor((s) => s.stageMenu);
|
|
22
|
+
const menu = useContextMenu();
|
|
23
|
+
const { open, close } = menu;
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (request === null) {
|
|
26
|
+
close();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Read at OPEN time, not through the selector: the store has already
|
|
30
|
+
// applied this press's selection policy by the time the mailbox lands, so
|
|
31
|
+
// the entries describe what is actually selected rather than what was.
|
|
32
|
+
const state = store.getState();
|
|
33
|
+
const entries = layerMenuEntries(store, state, menuTargetsFor(state, state.selection));
|
|
34
|
+
if (entries.length === 0) return;
|
|
35
|
+
open(request.at, entries);
|
|
36
|
+
}, [request, store, open, close]);
|
|
37
|
+
return <>{menu.menu}</>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The stage iframe. Mounted ONCE, for the life of the chrome.
|
|
2
|
+
//
|
|
3
|
+
// /shot-stage.html is served through the project's Vite
|
|
4
|
+
// (virtual:shot-stage-entry) so the three stack resolves to the studio's pinned
|
|
5
|
+
// copies. `connectStage` polls for `window.__shotStudio` after each load (a
|
|
6
|
+
// Vite-triggered reload mints a fresh bridge — reconnect and re-attach) and
|
|
7
|
+
// registers the STORE's handlers; the store then owns every push: doc on
|
|
8
|
+
// history change, frame on seek, selection on every selection write, and the
|
|
9
|
+
// full re-push on `onReady`.
|
|
10
|
+
//
|
|
11
|
+
// This component is DELIBERATELY nothing but the iframe and its connect effect.
|
|
12
|
+
// It renders no conditional siblings, holds no error state and takes no
|
|
13
|
+
// children, because it is the shell's backdrop: `App` mounts it as the
|
|
14
|
+
// unconditional, unkeyed FIRST child of the shell root and the CSS pins it
|
|
15
|
+
// `position: fixed; inset: 0` behind the floating panels. Anything conditional
|
|
16
|
+
// living in here would share a parent with the iframe, and the whole point of
|
|
17
|
+
// the arrangement is that no shell state can reorder, reparent or remount it.
|
|
18
|
+
//
|
|
19
|
+
// A REMOUNT IS EXPENSIVE HERE IN A WAY IT IS NOT IN THE 2D LANE: it costs a
|
|
20
|
+
// full iframe document load, a fresh WebGPU adapter request, every layer raster
|
|
21
|
+
// re-drawn, and a dead stage for at least one connect poll — and the module-
|
|
22
|
+
// level raster LRU lives in the iframe's realm, so it dies with it.
|
|
23
|
+
// test/stageIdentity.test.tsx is the gate.
|
|
24
|
+
//
|
|
25
|
+
// The connect FAILURE therefore reports upward, into the shell's overlay strip,
|
|
26
|
+
// rather than rendering a sibling strip here.
|
|
27
|
+
//
|
|
28
|
+
// Note main.tsx mounts under StrictMode, so this effect double-runs in dev:
|
|
29
|
+
// connect, abort + attachStage(null), connect again. The abort path rejects
|
|
30
|
+
// with "stage connect aborted" and is swallowed. The iframe ELEMENT is not
|
|
31
|
+
// remounted by that — only the effect re-runs — and any future effect touching
|
|
32
|
+
// the bridge must survive the same double-invoke.
|
|
33
|
+
import { useEffect, useRef } from "react";
|
|
34
|
+
import { useStore } from "../../state/context.js";
|
|
35
|
+
import { connectStage } from "../../bridge.js";
|
|
36
|
+
|
|
37
|
+
/** The stage URL, resolved ONCE at module load. Only the perf flag crosses the
|
|
38
|
+
* boundary — never the chrome's whole search string: the chrome rewrites
|
|
39
|
+
* ?shot/?scene via history.replaceState, and an iframe src interpolating live
|
|
40
|
+
* location.search would RELOAD the stage (fresh WebGPU adapter, every raster
|
|
41
|
+
* redrawn) on every document switch. */
|
|
42
|
+
const STAGE_SRC =
|
|
43
|
+
"/shot-stage.html" +
|
|
44
|
+
(typeof window !== "undefined" && new URLSearchParams(window.location.search).get("perf") === "1"
|
|
45
|
+
? "?perf=1"
|
|
46
|
+
: "");
|
|
47
|
+
|
|
48
|
+
export function StagePanel({
|
|
49
|
+
/** Report a stage connect failure to the shell's overlay strip (null clears). */
|
|
50
|
+
onConnectError,
|
|
51
|
+
}: {
|
|
52
|
+
onConnectError: (message: string | null) => void;
|
|
53
|
+
}) {
|
|
54
|
+
const store = useStore();
|
|
55
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const iframe = iframeRef.current;
|
|
59
|
+
if (!iframe) return;
|
|
60
|
+
const aborter = new AbortController();
|
|
61
|
+
const connect = (): void => {
|
|
62
|
+
onConnectError(null);
|
|
63
|
+
void connectStage(iframe, store.stageHandlers, { signal: aborter.signal })
|
|
64
|
+
.then((bridge) => {
|
|
65
|
+
if (!aborter.signal.aborted) store.attachStage(bridge);
|
|
66
|
+
})
|
|
67
|
+
.catch((e) => {
|
|
68
|
+
if (!aborter.signal.aborted) {
|
|
69
|
+
onConnectError(e instanceof Error ? e.message : String(e));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
iframe.addEventListener("load", connect);
|
|
74
|
+
connect(); // the load event may already have fired
|
|
75
|
+
return () => {
|
|
76
|
+
aborter.abort();
|
|
77
|
+
iframe.removeEventListener("load", connect);
|
|
78
|
+
store.attachStage(null);
|
|
79
|
+
// The error belonged to the connection being torn down; it is the shell's
|
|
80
|
+
// state now, so it has to be cleared explicitly rather than dying with a
|
|
81
|
+
// local useState.
|
|
82
|
+
onConnectError(null);
|
|
83
|
+
};
|
|
84
|
+
}, [store, onConnectError]);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<iframe
|
|
88
|
+
ref={iframeRef}
|
|
89
|
+
className="stage-frame"
|
|
90
|
+
src={STAGE_SRC}
|
|
91
|
+
title="Stage"
|
|
92
|
+
// The stage pins the cursor for the duration of a G / R / S transform,
|
|
93
|
+
// and an iframe cannot without the delegation. Without it
|
|
94
|
+
// `requestPointerLock` rejects and the modal falls back to cancelling
|
|
95
|
+
// when the pointer leaves — which is what it did before.
|
|
96
|
+
allow="pointer-lock"
|
|
97
|
+
onPointerEnter={focusStage}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* KEYBOARD FOCUS FOLLOWS THE POINTER ONTO THE STAGE.
|
|
104
|
+
*
|
|
105
|
+
* This lane spans two documents and keyboard events go to exactly one of them.
|
|
106
|
+
* The stage relays every key it receives UP into the same resolver the chrome
|
|
107
|
+
* uses (`onKey`), so a focused stage loses nothing — but the reverse is not
|
|
108
|
+
* true: the stage holds key state the chrome cannot see, and `spaceRef` (the
|
|
109
|
+
* pan gate in StageApp) is the load-bearing one. With focus in the chrome,
|
|
110
|
+
* holding space and dragging the stage did not pan; it played the shot and
|
|
111
|
+
* orbited, and nothing anywhere said why. On a fresh load focus starts in the
|
|
112
|
+
* chrome, so that was the FIRST thing an author met.
|
|
113
|
+
*
|
|
114
|
+
* Entering rather than pressing, because the gesture is "hold space, THEN
|
|
115
|
+
* press" — a focus move on pointerdown arrives one press too late.
|
|
116
|
+
*
|
|
117
|
+
* NOT WHILE SOMETHING IS BEING TYPED INTO. Sliding the pointer across the
|
|
118
|
+
* canvas while naming a layer must not swallow the rest of the name. The chrome
|
|
119
|
+
* can read its own `activeElement`, which is the only side of the boundary this
|
|
120
|
+
* check needs.
|
|
121
|
+
*/
|
|
122
|
+
function focusStage(e: React.PointerEvent<HTMLIFrameElement>): void {
|
|
123
|
+
const active = document.activeElement as HTMLElement | null;
|
|
124
|
+
if (
|
|
125
|
+
active !== null &&
|
|
126
|
+
(active.isContentEditable || active.tagName === "INPUT" || active.tagName === "TEXTAREA")
|
|
127
|
+
) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
e.currentTarget.focus();
|
|
131
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// The audio lane: one coloured band per clip at its MASTER position.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY IT IS ONE LANE AND NOT A TRACK PER CLIP ─────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `doc.audio` is a flat record of master-timed clips with no lane field, and
|
|
6
|
+
// the reference editor draws one music bed under the whole video. A lane-packer
|
|
7
|
+
// would be inventing a document concept the schema deliberately does not have,
|
|
8
|
+
// and the first thing it would need is somewhere to persist which lane a clip
|
|
9
|
+
// landed in. Clips overlap by drawing over each other, in document order.
|
|
10
|
+
//
|
|
11
|
+
// ── THE GESTURES ────────────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// BODY moves the clip — and every marker inside it (see writeAudioDrag).
|
|
14
|
+
// ⌥ BODY SLIPS the source under a bar that stays put.
|
|
15
|
+
// IN / OUT trim; the head trim moves `offsetFrames` with `frame` so the
|
|
16
|
+
// audio under the remaining bar does not slide.
|
|
17
|
+
// FADES the two corner handles, dragged along the bar.
|
|
18
|
+
// GAIN the horizontal rubber band, dragged vertically.
|
|
19
|
+
//
|
|
20
|
+
// All of them go through `beginBlockDrag`/`endBlockDrag`, which is the dock's
|
|
21
|
+
// ONE history scrub: each gesture is one undo entry and Escape restores the
|
|
22
|
+
// base by reference. All of them snap through the shared `snap.ts` — there is
|
|
23
|
+
// no second snapper in this lane.
|
|
24
|
+
import { useEffect, useRef, useState } from "react";
|
|
25
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
26
|
+
import { drawClipWaveform, frameToPx, loadPeaks, startDrag } from "@dailies-studio/timeline";
|
|
27
|
+
import type { PeakData } from "@dailies-studio/timeline";
|
|
28
|
+
import { framesToSeconds } from "@dailies-studio/engine";
|
|
29
|
+
import type { Rational } from "@dailies-studio/engine";
|
|
30
|
+
import { MAX_AUDIO_GAIN } from "@dailies-studio/shot/doc";
|
|
31
|
+
import type { AudioClipRef, ShotAsset } from "@dailies-studio/shot/doc";
|
|
32
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
33
|
+
import { audioClipView, frameDelta } from "./model.js";
|
|
34
|
+
import type { AudioDrag } from "./model.js";
|
|
35
|
+
import { isSnapSuppressed } from "./snap.js";
|
|
36
|
+
import type { SnapPress } from "./snap.js";
|
|
37
|
+
|
|
38
|
+
const WAVE_COLOR = "rgba(150, 205, 255, 0.7)";
|
|
39
|
+
/** Canvas px. Matches the bar's inner height in styles.css. */
|
|
40
|
+
const WAVE_H = 26;
|
|
41
|
+
|
|
42
|
+
export function AudioLane(props: { ppf: number; labelWidth: number; beginSnap?: SnapPress }) {
|
|
43
|
+
const { ppf, labelWidth, beginSnap } = props;
|
|
44
|
+
const store = useStore();
|
|
45
|
+
const doc = useShotEditor((s) => s.doc);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [importing, setImporting] = useState(false);
|
|
48
|
+
const [over, setOver] = useState(false);
|
|
49
|
+
const fileRef = useRef<HTMLInputElement | null>(null);
|
|
50
|
+
|
|
51
|
+
if (doc === null) return null;
|
|
52
|
+
const clips = Object.values(doc.audio);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* NEW CLIPS LAND AT THE PLAYHEAD, dropped ones included.
|
|
56
|
+
*
|
|
57
|
+
* A drop carries an x, and every NLE reads it as the position. This chrome
|
|
58
|
+
* has one creation rule and it is the playhead — the toolbar, the shot button
|
|
59
|
+
* and the layer tools all obey it — and a lane that quietly used a different
|
|
60
|
+
* one would make "where does the thing I make appear" a question with two
|
|
61
|
+
* answers. The clip is draggable the moment it exists.
|
|
62
|
+
*/
|
|
63
|
+
const importFiles = (files: FileList | null): void => {
|
|
64
|
+
const file = files?.[0];
|
|
65
|
+
if (file === undefined) return;
|
|
66
|
+
setError(null);
|
|
67
|
+
setImporting(true);
|
|
68
|
+
void store
|
|
69
|
+
.importAudio(file)
|
|
70
|
+
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
|
|
71
|
+
.finally(() => setImporting(false));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
className={`audio-lane${over ? " is-over" : ""}`}
|
|
77
|
+
data-testid="audio-lane"
|
|
78
|
+
onDragOver={(e) => {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
setOver(true);
|
|
81
|
+
}}
|
|
82
|
+
onDragLeave={() => setOver(false)}
|
|
83
|
+
onDrop={(e) => {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
setOver(false);
|
|
86
|
+
importFiles(e.dataTransfer?.files ?? null);
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<div className="audio-label-cell" style={{ width: labelWidth }}>
|
|
90
|
+
<span className="audio-lane-name">Audio</span>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
className="audio-add"
|
|
94
|
+
title="Import audio at the playhead"
|
|
95
|
+
disabled={importing}
|
|
96
|
+
onClick={() => fileRef.current?.click()}
|
|
97
|
+
>
|
|
98
|
+
{importing ? "…" : "+"}
|
|
99
|
+
</button>
|
|
100
|
+
<input
|
|
101
|
+
ref={fileRef}
|
|
102
|
+
className="audio-file-input"
|
|
103
|
+
type="file"
|
|
104
|
+
accept="audio/*"
|
|
105
|
+
hidden
|
|
106
|
+
data-testid="audio-file-input"
|
|
107
|
+
onChange={(e) => {
|
|
108
|
+
importFiles(e.currentTarget.files);
|
|
109
|
+
// Cleared so re-picking the SAME file fires change again.
|
|
110
|
+
e.currentTarget.value = "";
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="audio-track">
|
|
115
|
+
{clips.length === 0 && !importing ? (
|
|
116
|
+
<span className="audio-empty">Drop an audio file here, or press +</span>
|
|
117
|
+
) : null}
|
|
118
|
+
{clips.map((clip) => (
|
|
119
|
+
<AudioClipBar
|
|
120
|
+
key={clip.id}
|
|
121
|
+
clip={clip}
|
|
122
|
+
asset={doc.assets[clip.asset]}
|
|
123
|
+
fps={doc.fps}
|
|
124
|
+
ppf={ppf}
|
|
125
|
+
beginSnap={beginSnap}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
{error === null ? null : (
|
|
130
|
+
<p className="audio-error" role="status" data-testid="audio-error">
|
|
131
|
+
{error}
|
|
132
|
+
</p>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function AudioClipBar(props: {
|
|
139
|
+
clip: AudioClipRef;
|
|
140
|
+
asset: ShotAsset | undefined;
|
|
141
|
+
fps: Rational;
|
|
142
|
+
ppf: number;
|
|
143
|
+
beginSnap: SnapPress | undefined;
|
|
144
|
+
}) {
|
|
145
|
+
const { clip, asset, fps, ppf, beginSnap } = props;
|
|
146
|
+
const store = useStore();
|
|
147
|
+
const c = audioClipView(clip);
|
|
148
|
+
const left = frameToPx(c.frame, ppf);
|
|
149
|
+
const width = Math.max(8, frameToPx(c.durationFrames, ppf));
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One press handler for every frame-valued gesture.
|
|
153
|
+
*
|
|
154
|
+
* `anchor` is WHAT THE POINTER IS CARRYING, in master frames, because that is
|
|
155
|
+
* the edge the snap has to bite on: the out edge for an out drag, the fade
|
|
156
|
+
* corners for the fade handles, the clip's own start for a slip (the bar is
|
|
157
|
+
* not moving, but the source under it is measured from there).
|
|
158
|
+
*/
|
|
159
|
+
const drag =
|
|
160
|
+
(mode: AudioDrag) =>
|
|
161
|
+
(e: ReactPointerEvent<HTMLElement>): void => {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
e.stopPropagation();
|
|
164
|
+
// Read AT THE PRESS: a gesture whose meaning changed under a key held
|
|
165
|
+
// mid-drag would slip a clip the author was only moving.
|
|
166
|
+
const effective: AudioDrag = mode === "body" && e.altKey ? "slip" : mode;
|
|
167
|
+
const startX = e.clientX;
|
|
168
|
+
const snap = beginSnap?.({ audioClipId: clip.id }, isSnapSuppressed(e));
|
|
169
|
+
const anchor =
|
|
170
|
+
effective === "out"
|
|
171
|
+
? c.frame + c.durationFrames
|
|
172
|
+
: effective === "fade-in"
|
|
173
|
+
? c.frame + c.fadeInFrames
|
|
174
|
+
: effective === "fade-out"
|
|
175
|
+
? c.frame + c.durationFrames - c.fadeOutFrames
|
|
176
|
+
: c.frame;
|
|
177
|
+
store.beginBlockDrag();
|
|
178
|
+
startDrag(e, {
|
|
179
|
+
onMove(ev) {
|
|
180
|
+
const raw = frameDelta(ev.clientX - startX, ppf);
|
|
181
|
+
const delta = snap === undefined ? raw : snap(anchor + raw) - anchor;
|
|
182
|
+
store.dragAudio(clip.id, effective, delta);
|
|
183
|
+
},
|
|
184
|
+
onEnd: (commit) => store.endBlockDrag(commit),
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** The rubber band. Vertical, and NOT snapped: gain has no other gain to
|
|
189
|
+
* land on, and the frame targets are the wrong axis entirely. */
|
|
190
|
+
const dragGain = (e: ReactPointerEvent<HTMLElement>): void => {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
e.stopPropagation();
|
|
193
|
+
const startY = e.clientY;
|
|
194
|
+
const from = c.gain;
|
|
195
|
+
const height = e.currentTarget.parentElement?.clientHeight ?? WAVE_H;
|
|
196
|
+
store.beginBlockDrag();
|
|
197
|
+
startDrag(e, {
|
|
198
|
+
onMove(ev) {
|
|
199
|
+
// Down is louder-negative: the band's top is MAX_AUDIO_GAIN.
|
|
200
|
+
store.dragAudioGain(clip.id, from - ((ev.clientY - startY) / Math.max(1, height)) * MAX_AUDIO_GAIN);
|
|
201
|
+
},
|
|
202
|
+
onEnd: (commit) => store.endBlockDrag(commit),
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const fadeInPx = frameToPx(c.fadeInFrames, ppf);
|
|
207
|
+
const fadeOutPx = frameToPx(c.fadeOutFrames, ppf);
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
className={`audio-clip${c.muted ? " is-muted" : ""}`}
|
|
212
|
+
data-audio-clip={clip.id}
|
|
213
|
+
style={{ left, width }}
|
|
214
|
+
onPointerDown={drag("body")}
|
|
215
|
+
title={`${asset?.path ?? clip.asset} · drag to move it and its markers, ⌥drag to slip the source, ⌘drag to ignore snapping`}
|
|
216
|
+
>
|
|
217
|
+
<AudioWaveform clip={c} asset={asset} fps={fps} widthPx={width} />
|
|
218
|
+
|
|
219
|
+
{/* The fade ramps, drawn as ONE polyline over the whole bar so the two
|
|
220
|
+
fades and the flat middle cannot disagree about the level between
|
|
221
|
+
them. Inert: the corner handles below take the pointer. */}
|
|
222
|
+
{c.fadeInFrames > 0 || c.fadeOutFrames > 0 ? (
|
|
223
|
+
<svg className="audio-fade-curve" viewBox={`0 0 ${width} ${WAVE_H}`} preserveAspectRatio="none" aria-hidden="true">
|
|
224
|
+
<polyline
|
|
225
|
+
points={`0,${WAVE_H} ${fadeInPx},0 ${Math.max(fadeInPx, width - fadeOutPx)},0 ${width},${WAVE_H}`}
|
|
226
|
+
fill="none"
|
|
227
|
+
/>
|
|
228
|
+
</svg>
|
|
229
|
+
) : null}
|
|
230
|
+
|
|
231
|
+
<span
|
|
232
|
+
className="audio-gain-band"
|
|
233
|
+
data-handle="audio-gain"
|
|
234
|
+
style={{ bottom: `${(c.gain / MAX_AUDIO_GAIN) * 100}%` }}
|
|
235
|
+
onPointerDown={dragGain}
|
|
236
|
+
title={`Gain ${c.gain.toFixed(2)} — drag up or down`}
|
|
237
|
+
/>
|
|
238
|
+
|
|
239
|
+
<span className="audio-clip-name">{asset?.path.replace(/^[0-9a-f]{8}-/, "") ?? clip.asset}</span>
|
|
240
|
+
|
|
241
|
+
<span
|
|
242
|
+
className="audio-handle audio-handle-in"
|
|
243
|
+
data-handle="audio-in"
|
|
244
|
+
onPointerDown={drag("in")}
|
|
245
|
+
aria-hidden="true"
|
|
246
|
+
/>
|
|
247
|
+
<span
|
|
248
|
+
className="audio-handle audio-handle-out"
|
|
249
|
+
data-handle="audio-out"
|
|
250
|
+
onPointerDown={drag("out")}
|
|
251
|
+
aria-hidden="true"
|
|
252
|
+
/>
|
|
253
|
+
<span
|
|
254
|
+
className="audio-fade audio-fade-in"
|
|
255
|
+
data-handle="audio-fade-in"
|
|
256
|
+
style={{ left: fadeInPx }}
|
|
257
|
+
onPointerDown={drag("fade-in")}
|
|
258
|
+
title={`Fade in ${c.fadeInFrames}f`}
|
|
259
|
+
/>
|
|
260
|
+
<span
|
|
261
|
+
className="audio-fade audio-fade-out"
|
|
262
|
+
data-handle="audio-fade-out"
|
|
263
|
+
style={{ left: Math.max(0, width - fadeOutPx) }}
|
|
264
|
+
onPointerDown={drag("fade-out")}
|
|
265
|
+
title={`Fade out ${c.fadeOutFrames}f`}
|
|
266
|
+
/>
|
|
267
|
+
|
|
268
|
+
<span className="audio-clip-buttons">
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
className="audio-clip-btn"
|
|
272
|
+
aria-label={c.muted ? "Unmute clip" : "Mute clip"}
|
|
273
|
+
aria-pressed={c.muted}
|
|
274
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
275
|
+
onClick={() => store.toggleAudioMute(clip.id)}
|
|
276
|
+
>
|
|
277
|
+
{c.muted ? "🔇" : "🔊"}
|
|
278
|
+
</button>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
className="audio-clip-btn"
|
|
282
|
+
aria-label="Remove clip"
|
|
283
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
284
|
+
onClick={() => store.removeAudio(clip.id)}
|
|
285
|
+
>
|
|
286
|
+
×
|
|
287
|
+
</button>
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The clip's slice of its source, drawn from the asset's ONE fine peak level.
|
|
295
|
+
*
|
|
296
|
+
* `loadPeaks` is keyed on the content-addressed asset PATH, so every clip of
|
|
297
|
+
* the same track shares one decode and a zoom only picks a different integer
|
|
298
|
+
* decimation out of it — nothing here refetches. (Not the asset id: that is a
|
|
299
|
+
* filename slug this lane mints per document, so it repeats across shots.)
|
|
300
|
+
*/
|
|
301
|
+
function AudioWaveform(props: {
|
|
302
|
+
clip: { offsetFrames: number; durationFrames: number };
|
|
303
|
+
asset: ShotAsset | undefined;
|
|
304
|
+
fps: Rational;
|
|
305
|
+
widthPx: number;
|
|
306
|
+
}) {
|
|
307
|
+
const { clip, asset, fps, widthPx } = props;
|
|
308
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
309
|
+
const [data, setData] = useState<PeakData | null>(null);
|
|
310
|
+
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (asset === undefined) return;
|
|
313
|
+
let alive = true;
|
|
314
|
+
loadPeaks(asset)
|
|
315
|
+
.then((d) => {
|
|
316
|
+
if (alive) setData(d);
|
|
317
|
+
})
|
|
318
|
+
.catch(() => {
|
|
319
|
+
// Decoration. A clip with no waveform still trims, fades and plays.
|
|
320
|
+
});
|
|
321
|
+
return () => {
|
|
322
|
+
alive = false;
|
|
323
|
+
};
|
|
324
|
+
}, [asset]);
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
const canvas = canvasRef.current;
|
|
328
|
+
if (canvas === null || data === null || data.peaks.length === 0) return;
|
|
329
|
+
drawClipWaveform(canvas, {
|
|
330
|
+
data,
|
|
331
|
+
fromSeconds: framesToSeconds(clip.offsetFrames, fps),
|
|
332
|
+
toSeconds: framesToSeconds(clip.offsetFrames + clip.durationFrames, fps),
|
|
333
|
+
widthPx,
|
|
334
|
+
heightPx: WAVE_H,
|
|
335
|
+
color: WAVE_COLOR,
|
|
336
|
+
});
|
|
337
|
+
}, [data, widthPx, fps, clip.offsetFrames, clip.durationFrames]);
|
|
338
|
+
|
|
339
|
+
return <canvas className="audio-wave" ref={canvasRef} aria-hidden="true" />;
|
|
340
|
+
}
|