@aethervtt/shard-audio-editor 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/dom.d.ts +6 -0
- package/dist/dom.js +11 -0
- package/dist/drawing.d.ts +2 -0
- package/dist/drawing.js +12 -0
- package/dist/icons.d.ts +1 -0
- package/dist/icons.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/memory.d.ts +13 -0
- package/dist/memory.js +93 -0
- package/dist/methods.d.ts +4 -0
- package/dist/methods.js +79 -0
- package/dist/model.d.ts +61 -0
- package/dist/model.js +560 -0
- package/dist/panels/common.d.ts +33 -0
- package/dist/panels/common.js +1556 -0
- package/dist/panels/diagnostics.d.ts +2 -0
- package/dist/panels/diagnostics.js +7 -0
- package/dist/panels/event.d.ts +2 -0
- package/dist/panels/event.js +7 -0
- package/dist/panels/library.d.ts +2 -0
- package/dist/panels/library.js +7 -0
- package/dist/panels/mixer.d.ts +2 -0
- package/dist/panels/mixer.js +7 -0
- package/dist/panels/music.d.ts +2 -0
- package/dist/panels/music.js +7 -0
- package/dist/panels/parameters.d.ts +2 -0
- package/dist/panels/parameters.js +7 -0
- package/dist/preview-export.d.ts +68 -0
- package/dist/preview-export.js +143 -0
- package/dist/preview-render.d.ts +2 -0
- package/dist/preview-render.js +6 -0
- package/dist/preview-runtime.d.ts +114 -0
- package/dist/preview-runtime.js +414 -0
- package/dist/preview.d.ts +25 -0
- package/dist/preview.js +296 -0
- package/dist/project.d.ts +15 -0
- package/dist/project.js +332 -0
- package/dist/schema.d.ts +22 -0
- package/dist/schema.js +155 -0
- package/dist/styles.d.ts +2 -0
- package/dist/styles.js +192 -0
- package/dist/types.d.ts +113 -0
- package/dist/types.js +0 -0
- package/dist/waveform.d.ts +16 -0
- package/dist/waveform.js +154 -0
- package/package.json +83 -4
|
@@ -0,0 +1,1556 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pointer,
|
|
3
|
+
ShardError
|
|
4
|
+
} from "@aethervtt/shard-core";
|
|
5
|
+
import { drawAudioWaveform } from "../drawing.js";
|
|
6
|
+
import { audioIcon } from "../icons.js";
|
|
7
|
+
import { audioEditorTypes } from "../schema.js";
|
|
8
|
+
import { audioEditorStyles } from "../styles.js";
|
|
9
|
+
const panels = [
|
|
10
|
+
"library",
|
|
11
|
+
"event",
|
|
12
|
+
"mixer",
|
|
13
|
+
"music",
|
|
14
|
+
"parameters",
|
|
15
|
+
"diagnostics"
|
|
16
|
+
];
|
|
17
|
+
const titles = {
|
|
18
|
+
library: "Library",
|
|
19
|
+
event: "Event",
|
|
20
|
+
mixer: "Mixer",
|
|
21
|
+
music: "Music",
|
|
22
|
+
parameters: "Parameters",
|
|
23
|
+
diagnostics: "Diagnostics"
|
|
24
|
+
};
|
|
25
|
+
function title(value) {
|
|
26
|
+
return value.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (s) => s.toUpperCase());
|
|
27
|
+
}
|
|
28
|
+
function object(value) {
|
|
29
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
30
|
+
}
|
|
31
|
+
function assetId(value) {
|
|
32
|
+
const ref = object(value);
|
|
33
|
+
return typeof ref.guid === "string" ? ref.guid : typeof ref.path === "string" ? ref.path : "";
|
|
34
|
+
}
|
|
35
|
+
function at(value, path) {
|
|
36
|
+
let current = value;
|
|
37
|
+
for (const part of path.split("/").slice(1)) {
|
|
38
|
+
if (current === null || typeof current !== "object") return void 0;
|
|
39
|
+
current = current[part.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
40
|
+
}
|
|
41
|
+
return current;
|
|
42
|
+
}
|
|
43
|
+
function mountPanel(container, options, fixedPanel) {
|
|
44
|
+
const doc = container.ownerDocument;
|
|
45
|
+
const session = options.session;
|
|
46
|
+
const abort = new AbortController();
|
|
47
|
+
let disposed = false;
|
|
48
|
+
let generation = 0;
|
|
49
|
+
let selectedDocument = session.state.document?.id;
|
|
50
|
+
let active = fixedPanel ?? (session.state.document ? panelFor(session.state.document.type) : options.layout === "compact" ? "mixer" : "library");
|
|
51
|
+
let error = "";
|
|
52
|
+
let picker;
|
|
53
|
+
let search = "";
|
|
54
|
+
let typeFilter = "";
|
|
55
|
+
let cursor;
|
|
56
|
+
const cursors = [];
|
|
57
|
+
let libraryPage;
|
|
58
|
+
let inspection;
|
|
59
|
+
let presentationInspection = {};
|
|
60
|
+
let waveformId = "";
|
|
61
|
+
let waveform;
|
|
62
|
+
let rendering = false;
|
|
63
|
+
let pendingFocus;
|
|
64
|
+
let dragging = false;
|
|
65
|
+
let visible = true;
|
|
66
|
+
let meterTimer;
|
|
67
|
+
let meterBusy = false;
|
|
68
|
+
let meterSuspended = false;
|
|
69
|
+
const meters = /* @__PURE__ */ new Map();
|
|
70
|
+
const downloadUrls = /* @__PURE__ */ new Set();
|
|
71
|
+
const layerWaveforms = /* @__PURE__ */ new Map();
|
|
72
|
+
let waveformDocument = "";
|
|
73
|
+
let renameFrom = "";
|
|
74
|
+
let renameTo = "";
|
|
75
|
+
let renameScanKey = "";
|
|
76
|
+
let renameDependencies = [];
|
|
77
|
+
let renameBlocked = "";
|
|
78
|
+
const expanded = /* @__PURE__ */ new Map();
|
|
79
|
+
const element = doc.createElement("div");
|
|
80
|
+
element.dataset.audioEditor = fixedPanel ?? options.layout ?? "full";
|
|
81
|
+
element.dataset.theme = options.theme ?? "dark";
|
|
82
|
+
container.append(element);
|
|
83
|
+
const root = element.attachShadow({ mode: "open" });
|
|
84
|
+
if (options.stylesheetUrl) {
|
|
85
|
+
const link = doc.createElement("link");
|
|
86
|
+
link.rel = "stylesheet";
|
|
87
|
+
link.href = options.stylesheetUrl;
|
|
88
|
+
root.append(link);
|
|
89
|
+
} else {
|
|
90
|
+
const style = doc.createElement("style");
|
|
91
|
+
style.textContent = audioEditorStyles;
|
|
92
|
+
root.append(style);
|
|
93
|
+
}
|
|
94
|
+
const shell = el("div", "editor");
|
|
95
|
+
const toolbar = el("div", "toolbar");
|
|
96
|
+
toolbar.setAttribute("role", "toolbar");
|
|
97
|
+
toolbar.setAttribute("aria-label", "Audio editor commands");
|
|
98
|
+
const tabs = el("div", "tabs");
|
|
99
|
+
tabs.setAttribute("role", "tablist");
|
|
100
|
+
tabs.setAttribute("aria-label", "Audio editor panels");
|
|
101
|
+
const body = el("div", "body");
|
|
102
|
+
const status = el("div", "status");
|
|
103
|
+
status.setAttribute("role", "status");
|
|
104
|
+
status.setAttribute("aria-live", "polite");
|
|
105
|
+
shell.append(toolbar, tabs, body, status);
|
|
106
|
+
root.append(shell);
|
|
107
|
+
function text(key) {
|
|
108
|
+
return options.strings?.[key] ?? key;
|
|
109
|
+
}
|
|
110
|
+
function el(tag, cls = "", content) {
|
|
111
|
+
const node = doc.createElement(tag);
|
|
112
|
+
if (cls) node.className = cls;
|
|
113
|
+
if (content !== void 0) node.textContent = text(content);
|
|
114
|
+
return node;
|
|
115
|
+
}
|
|
116
|
+
function run(action) {
|
|
117
|
+
for (const group of root.querySelectorAll("details[data-group]"))
|
|
118
|
+
expanded.set(group.dataset.group, group.open);
|
|
119
|
+
error = "";
|
|
120
|
+
try {
|
|
121
|
+
const result = action();
|
|
122
|
+
if (result && typeof result.then === "function") {
|
|
123
|
+
void Promise.resolve(result).then(() => {
|
|
124
|
+
if (!disposed) render();
|
|
125
|
+
}, fail);
|
|
126
|
+
} else render();
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
fail(cause);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function fail(cause) {
|
|
132
|
+
if (disposed) return;
|
|
133
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
134
|
+
render();
|
|
135
|
+
}
|
|
136
|
+
async function inspectPresentation() {
|
|
137
|
+
const inspected = object(await session.preview?.inspect());
|
|
138
|
+
if (disposed) return;
|
|
139
|
+
presentationInspection = object(inspected.audio ?? inspected);
|
|
140
|
+
}
|
|
141
|
+
async function audition() {
|
|
142
|
+
await session.play();
|
|
143
|
+
await inspectPresentation();
|
|
144
|
+
}
|
|
145
|
+
function download(bytes, mime, filename) {
|
|
146
|
+
if (disposed) return;
|
|
147
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
|
|
148
|
+
downloadUrls.add(url);
|
|
149
|
+
const anchor = el("a");
|
|
150
|
+
anchor.href = url;
|
|
151
|
+
anchor.download = filename;
|
|
152
|
+
root.append(anchor);
|
|
153
|
+
anchor.click();
|
|
154
|
+
anchor.remove();
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
URL.revokeObjectURL(url);
|
|
157
|
+
downloadUrls.delete(url);
|
|
158
|
+
}, 0);
|
|
159
|
+
}
|
|
160
|
+
function button(name, action, disabled = false) {
|
|
161
|
+
const node = el("button");
|
|
162
|
+
node.append(el("span", "button-label", name));
|
|
163
|
+
node.dataset.command = name;
|
|
164
|
+
if (["Undo", "Redo", "Save", "Play", "Stop"].includes(name))
|
|
165
|
+
node.prepend(audioIcon(doc, name.toLowerCase()));
|
|
166
|
+
if (name.startsWith("Remove ")) node.dataset.intent = "danger";
|
|
167
|
+
if (name.startsWith("Add ")) node.dataset.intent = "add";
|
|
168
|
+
node.type = "button";
|
|
169
|
+
node.disabled = disabled;
|
|
170
|
+
node.addEventListener("click", () => run(action));
|
|
171
|
+
return node;
|
|
172
|
+
}
|
|
173
|
+
function inputLabel(name, input) {
|
|
174
|
+
const label = el("label", "field");
|
|
175
|
+
label.append(el("span", "field-label", name), input);
|
|
176
|
+
return label;
|
|
177
|
+
}
|
|
178
|
+
function details(name, path, open = false) {
|
|
179
|
+
const node = el("details");
|
|
180
|
+
node.dataset.group = path;
|
|
181
|
+
node.open = expanded.get(path) ?? open;
|
|
182
|
+
node.append(el("summary", "", name));
|
|
183
|
+
node.addEventListener("toggle", () => {
|
|
184
|
+
if (node.isConnected) expanded.set(path, node.open);
|
|
185
|
+
});
|
|
186
|
+
return node;
|
|
187
|
+
}
|
|
188
|
+
function edit(path, value) {
|
|
189
|
+
session.edit({ path, value });
|
|
190
|
+
}
|
|
191
|
+
function render() {
|
|
192
|
+
if (disposed || rendering || dragging) return;
|
|
193
|
+
rendering = true;
|
|
194
|
+
try {
|
|
195
|
+
const focused = root.activeElement;
|
|
196
|
+
const focusPath = focused?.dataset.path;
|
|
197
|
+
const focusAction = focused?.dataset.focus ?? (doc.activeElement === doc.body ? pendingFocus : void 0);
|
|
198
|
+
const scrollTop = body.scrollTop;
|
|
199
|
+
const state = session.state;
|
|
200
|
+
toolbar.replaceChildren();
|
|
201
|
+
const identity = el("div", "document-identity");
|
|
202
|
+
identity.dataset.dirty = String(state.dirty);
|
|
203
|
+
const mark = el("span", "document-mark");
|
|
204
|
+
mark.append(audioIcon(doc, state.document ? panelFor(state.document.type) : "library"));
|
|
205
|
+
const identityText = el("div", "document-text");
|
|
206
|
+
identityText.append(
|
|
207
|
+
el(
|
|
208
|
+
"span",
|
|
209
|
+
"document-kind",
|
|
210
|
+
state.document ? title(state.document.type.replace(/^audio\//, "").replace(/^Audio/, "")) : "Audio workspace"
|
|
211
|
+
),
|
|
212
|
+
el("span", "title", state.document?.name ?? "Audio editor")
|
|
213
|
+
);
|
|
214
|
+
identity.append(mark, identityText);
|
|
215
|
+
toolbar.append(identity);
|
|
216
|
+
toolbar.append(
|
|
217
|
+
button("Undo", () => session.undo(), !state.canUndo || state.readOnly),
|
|
218
|
+
button("Redo", () => session.redo(), !state.canRedo || state.readOnly),
|
|
219
|
+
button(
|
|
220
|
+
state.saving ? "Saving\u2026" : "Save",
|
|
221
|
+
() => session.save(),
|
|
222
|
+
!state.document || state.saving || state.readOnly || !!state.conflict || state.diagnostics.length > 0
|
|
223
|
+
),
|
|
224
|
+
button(
|
|
225
|
+
"Play",
|
|
226
|
+
audition,
|
|
227
|
+
!session.preview || session.preview.capabilities.play === false || !state.document || state.diagnostics.length > 0
|
|
228
|
+
),
|
|
229
|
+
button("Stop", () => session.stop(), !session.preview)
|
|
230
|
+
);
|
|
231
|
+
if (state.previewState === "playing" && state.previewDocumentId === state.document?.id)
|
|
232
|
+
toolbar.append(
|
|
233
|
+
button(
|
|
234
|
+
state.document?.type === "audio/AudioMixer" ? "Apply draft" : "Restart",
|
|
235
|
+
async () => {
|
|
236
|
+
const errors = await session.validate();
|
|
237
|
+
if (errors.length) throw errors[0];
|
|
238
|
+
if (state.document?.type !== "audio/AudioMixer") await session.stop();
|
|
239
|
+
await audition();
|
|
240
|
+
},
|
|
241
|
+
!session.preview || session.preview.capabilities.play === false || state.diagnostics.length > 0
|
|
242
|
+
)
|
|
243
|
+
);
|
|
244
|
+
if (state.dirty && state.document?.revision && !state.conflict)
|
|
245
|
+
toolbar.append(button("Discard changes", () => session.reload(), state.saving));
|
|
246
|
+
tabs.replaceChildren();
|
|
247
|
+
tabs.hidden = !!fixedPanel;
|
|
248
|
+
for (const panel of panels) {
|
|
249
|
+
if (options.layout === "compact" && !["library", "mixer", "parameters", "diagnostics"].includes(panel))
|
|
250
|
+
continue;
|
|
251
|
+
const tab = button(titles[panel], () => {
|
|
252
|
+
active = panel;
|
|
253
|
+
picker = void 0;
|
|
254
|
+
return panel === "library" ? loadLibrary() : void 0;
|
|
255
|
+
});
|
|
256
|
+
tab.prepend(audioIcon(doc, panel));
|
|
257
|
+
tab.setAttribute("role", "tab");
|
|
258
|
+
tab.setAttribute("aria-selected", String(active === panel));
|
|
259
|
+
tab.tabIndex = active === panel ? 0 : -1;
|
|
260
|
+
tab.dataset.focus = `tab-${panel}`;
|
|
261
|
+
tabs.append(tab);
|
|
262
|
+
}
|
|
263
|
+
body.replaceChildren();
|
|
264
|
+
meters.clear();
|
|
265
|
+
if (error) body.append(el("div", "error notice", error));
|
|
266
|
+
if (presentationInspection.auditionError)
|
|
267
|
+
body.append(
|
|
268
|
+
el(
|
|
269
|
+
"div",
|
|
270
|
+
"error notice",
|
|
271
|
+
String(
|
|
272
|
+
object(presentationInspection.auditionError).message ?? "Audio output could not start. Press Play to try again."
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
if (state.conflict) {
|
|
277
|
+
const conflict = el("div", "notice");
|
|
278
|
+
conflict.append(
|
|
279
|
+
el("p", "error", "This document changed on the server. Your draft has been kept."),
|
|
280
|
+
button("Discard draft and reload", () => session.reload()),
|
|
281
|
+
button(
|
|
282
|
+
"Save draft as new document",
|
|
283
|
+
() => session.saveAs(
|
|
284
|
+
globalThis.crypto.randomUUID(),
|
|
285
|
+
`${state.document?.name ?? "Audio"} copy`
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
);
|
|
289
|
+
const versions = details("Compare local and server versions", "conflict");
|
|
290
|
+
versions.append(
|
|
291
|
+
el(
|
|
292
|
+
"pre",
|
|
293
|
+
"",
|
|
294
|
+
JSON.stringify({ local: state.document?.value, remote: state.conflict.value }, null, 2)
|
|
295
|
+
)
|
|
296
|
+
);
|
|
297
|
+
conflict.append(versions);
|
|
298
|
+
body.append(conflict);
|
|
299
|
+
}
|
|
300
|
+
if (state.readOnly)
|
|
301
|
+
body.append(el("p", "notice", "Read-only document. Editing and saving are unavailable."));
|
|
302
|
+
if (picker) renderLibrary(true);
|
|
303
|
+
else if (active === "library") renderLibrary(false);
|
|
304
|
+
else if (active === "diagnostics") renderDiagnostics(state);
|
|
305
|
+
else renderAuthoring(state);
|
|
306
|
+
status.dataset.state = state.previewState;
|
|
307
|
+
status.textContent = [
|
|
308
|
+
state.loading ? "Loading" : state.dirty ? "Unsaved changes" : "Saved",
|
|
309
|
+
`${state.diagnostics.length} issue${state.diagnostics.length === 1 ? "" : "s"}`,
|
|
310
|
+
`Preview request: ${state.previewState}`,
|
|
311
|
+
presentationInspection.context === "suspended" ? "Output blocked \xB7 press Play to enable audio" : presentationInspection.context ? `Context: ${presentationInspection.context}` : "",
|
|
312
|
+
session.preview?.mode === "attached" ? `Attached: ${session.preview.target ?? "runtime"}` : session.preview ? "Isolated audition" : "Audition unavailable",
|
|
313
|
+
state.historyEvicted ? `${state.historyEvicted} history entries evicted` : ""
|
|
314
|
+
].filter(Boolean).join(" \xB7 ");
|
|
315
|
+
const focusCounts = /* @__PURE__ */ new Map();
|
|
316
|
+
for (const control of root.querySelectorAll(
|
|
317
|
+
"button,summary,input,select,textarea"
|
|
318
|
+
)) {
|
|
319
|
+
if (control.dataset.focus || control.dataset.path) continue;
|
|
320
|
+
const group = control.closest("details")?.dataset.group ?? "";
|
|
321
|
+
const label = control.tagName === "SUMMARY" ? "summary" : control.getAttribute("aria-label") ?? (control.tagName === "BUTTON" ? control.textContent : control.closest("label")?.querySelector(".field-label")?.textContent) ?? control.tagName;
|
|
322
|
+
const key = `${group}:${label === "Saving\u2026" ? "Save" : label}`;
|
|
323
|
+
const count = focusCounts.get(key) ?? 0;
|
|
324
|
+
focusCounts.set(key, count + 1);
|
|
325
|
+
control.dataset.focus = `${key}:${count}`;
|
|
326
|
+
}
|
|
327
|
+
body.scrollTop = scrollTop;
|
|
328
|
+
pendingFocus = focusAction;
|
|
329
|
+
if (focusPath || focusAction) {
|
|
330
|
+
for (const candidate of root.querySelectorAll("[data-path],[data-focus]")) {
|
|
331
|
+
if (focusPath && candidate.dataset.path === focusPath || focusAction && candidate.dataset.focus === focusAction) {
|
|
332
|
+
candidate.focus({ preventScroll: true });
|
|
333
|
+
if (root.activeElement === candidate) pendingFocus = void 0;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (!root.activeElement && picker && focusAction) {
|
|
339
|
+
root.querySelector('[data-focus="catalog-search"]')?.focus({ preventScroll: true });
|
|
340
|
+
pendingFocus = void 0;
|
|
341
|
+
}
|
|
342
|
+
} finally {
|
|
343
|
+
rendering = false;
|
|
344
|
+
updateMeterPolling();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function updateMeterPolling() {
|
|
348
|
+
if (session.state.previewState !== "playing") meterSuspended = false;
|
|
349
|
+
const enabled = !disposed && visible && !doc.hidden && active === "mixer" && meters.size > 0 && !meterSuspended && session.state.previewState === "playing" && !!session.preview?.command;
|
|
350
|
+
if (!enabled && meterTimer !== void 0) {
|
|
351
|
+
clearInterval(meterTimer);
|
|
352
|
+
meterTimer = void 0;
|
|
353
|
+
}
|
|
354
|
+
if (enabled && meterTimer === void 0)
|
|
355
|
+
meterTimer = setInterval(() => {
|
|
356
|
+
void refreshMeters();
|
|
357
|
+
}, 100);
|
|
358
|
+
}
|
|
359
|
+
async function refreshMeters() {
|
|
360
|
+
if (meterBusy || disposed || doc.hidden || !visible) return;
|
|
361
|
+
meterBusy = true;
|
|
362
|
+
try {
|
|
363
|
+
for (const [bus, controls] of [...meters]) {
|
|
364
|
+
if (disposed || !visible || doc.hidden || active !== "mixer" || session.state.previewState !== "playing")
|
|
365
|
+
break;
|
|
366
|
+
const measurement = object(await session.preview.command("audio.meters", { bus }));
|
|
367
|
+
if (disposed || !controls.meter.isConnected) continue;
|
|
368
|
+
controls.meter.value = measurement.available ? Math.min(1, Number(measurement.peak) || 0) : 0;
|
|
369
|
+
controls.label.textContent = measurement.available ? `Measured peak ${Number(measurement.peak).toFixed(3)}, RMS ${Number(measurement.rms).toFixed(3)} \xB7 at ${Number(measurement.at).toFixed(3)}s` : "Measured meter unavailable on this backend";
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
meterSuspended = true;
|
|
373
|
+
if (meterTimer !== void 0) {
|
|
374
|
+
clearInterval(meterTimer);
|
|
375
|
+
meterTimer = void 0;
|
|
376
|
+
}
|
|
377
|
+
for (const controls of meters.values())
|
|
378
|
+
controls.label.textContent = "Measured meter unavailable: preview disconnected";
|
|
379
|
+
} finally {
|
|
380
|
+
meterBusy = false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async function loadLibrary() {
|
|
384
|
+
const token = ++generation;
|
|
385
|
+
const page = await session.assets.list({
|
|
386
|
+
search,
|
|
387
|
+
type: picker?.type ?? (typeFilter || void 0),
|
|
388
|
+
cursor,
|
|
389
|
+
limit: 60,
|
|
390
|
+
signal: abort.signal
|
|
391
|
+
});
|
|
392
|
+
if (disposed || token !== generation) return;
|
|
393
|
+
libraryPage = { ...page, items: page.items.slice(0, 60) };
|
|
394
|
+
render();
|
|
395
|
+
}
|
|
396
|
+
function renderLibrary(choosing) {
|
|
397
|
+
body.append(
|
|
398
|
+
el("h2", "", choosing ? `Choose ${picker?.label ?? "asset"}` : "Host asset library")
|
|
399
|
+
);
|
|
400
|
+
body.append(
|
|
401
|
+
el(
|
|
402
|
+
"p",
|
|
403
|
+
"hint",
|
|
404
|
+
"Audio comes from your host asset catalog. References keep stable asset IDs."
|
|
405
|
+
)
|
|
406
|
+
);
|
|
407
|
+
if (choosing)
|
|
408
|
+
body.append(
|
|
409
|
+
button("Cancel selection", () => {
|
|
410
|
+
picker = void 0;
|
|
411
|
+
})
|
|
412
|
+
);
|
|
413
|
+
const searchInput = el("input");
|
|
414
|
+
searchInput.type = "search";
|
|
415
|
+
searchInput.value = search;
|
|
416
|
+
searchInput.dataset.focus = "catalog-search";
|
|
417
|
+
searchInput.addEventListener("change", () => {
|
|
418
|
+
search = searchInput.value;
|
|
419
|
+
cursor = void 0;
|
|
420
|
+
cursors.length = 0;
|
|
421
|
+
run(loadLibrary);
|
|
422
|
+
});
|
|
423
|
+
body.append(inputLabel("Search assets", searchInput));
|
|
424
|
+
if (!choosing) {
|
|
425
|
+
const filter = el("select");
|
|
426
|
+
filter.append(new Option("All audio types", ""));
|
|
427
|
+
for (const schema of audioEditorTypes) filter.append(new Option(schema.name, schema.name));
|
|
428
|
+
filter.append(new Option("Audio clips", "AudioClip"));
|
|
429
|
+
filter.value = typeFilter;
|
|
430
|
+
filter.addEventListener("change", () => {
|
|
431
|
+
typeFilter = filter.value;
|
|
432
|
+
cursor = void 0;
|
|
433
|
+
cursors.length = 0;
|
|
434
|
+
run(loadLibrary);
|
|
435
|
+
});
|
|
436
|
+
body.append(inputLabel("Asset type", filter));
|
|
437
|
+
renderCreate();
|
|
438
|
+
}
|
|
439
|
+
const list = el("div", "catalog");
|
|
440
|
+
list.setAttribute("aria-label", "Host assets");
|
|
441
|
+
list.setAttribute("role", "list");
|
|
442
|
+
for (const asset of libraryPage?.items ?? []) {
|
|
443
|
+
const row = el("div", "catalog-row");
|
|
444
|
+
row.setAttribute("role", "listitem");
|
|
445
|
+
const select = button(asset.name, async () => {
|
|
446
|
+
if (picker) {
|
|
447
|
+
const path = picker.path;
|
|
448
|
+
const sceneEdit = picker.edit;
|
|
449
|
+
picker = void 0;
|
|
450
|
+
if (sceneEdit) sceneEdit({ path, value: { guid: asset.id } });
|
|
451
|
+
else edit(path, { guid: asset.id });
|
|
452
|
+
} else if (asset.type === "AudioClip") await showWaveform(asset.id);
|
|
453
|
+
else {
|
|
454
|
+
await session.open(asset.id);
|
|
455
|
+
active = panelFor(asset.type);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
select.title = `${asset.name} (${asset.id})`;
|
|
459
|
+
const symbol = el("span", "catalog-symbol");
|
|
460
|
+
symbol.append(audioIcon(doc, asset.type === "AudioClip" ? "clip" : panelFor(asset.type)));
|
|
461
|
+
row.dataset.selected = String(session.state.document?.id === asset.id);
|
|
462
|
+
row.append(
|
|
463
|
+
symbol,
|
|
464
|
+
select,
|
|
465
|
+
el("span", "badge", asset.type.replace("audio/", "").replace(/^Audio/, ""))
|
|
466
|
+
);
|
|
467
|
+
list.append(row);
|
|
468
|
+
}
|
|
469
|
+
if (!libraryPage?.items.length) list.append(el("p", "empty", "No matching assets."));
|
|
470
|
+
body.append(list);
|
|
471
|
+
const paging = el("div", "actions");
|
|
472
|
+
paging.append(
|
|
473
|
+
button(
|
|
474
|
+
"Previous assets",
|
|
475
|
+
async () => {
|
|
476
|
+
cursor = cursors.pop();
|
|
477
|
+
await loadLibrary();
|
|
478
|
+
},
|
|
479
|
+
cursors.length === 0
|
|
480
|
+
),
|
|
481
|
+
button(
|
|
482
|
+
"Next assets",
|
|
483
|
+
async () => {
|
|
484
|
+
cursors.push(cursor);
|
|
485
|
+
cursor = libraryPage?.nextCursor;
|
|
486
|
+
await loadLibrary();
|
|
487
|
+
},
|
|
488
|
+
!libraryPage?.nextCursor
|
|
489
|
+
),
|
|
490
|
+
el(
|
|
491
|
+
"span",
|
|
492
|
+
"hint",
|
|
493
|
+
libraryPage?.total === void 0 ? "Up to 60 assets per page" : `${libraryPage.total} catalog assets`
|
|
494
|
+
)
|
|
495
|
+
);
|
|
496
|
+
body.append(paging);
|
|
497
|
+
body.append(button("Refresh assets", loadLibrary));
|
|
498
|
+
if (!choosing && session.state.document?.type.includes("Collection"))
|
|
499
|
+
renderAuthoring(session.state);
|
|
500
|
+
renderWaveform();
|
|
501
|
+
}
|
|
502
|
+
function renderCreate() {
|
|
503
|
+
const create = details("Create or duplicate document", "create");
|
|
504
|
+
const name = el("input");
|
|
505
|
+
name.value = "New audio document";
|
|
506
|
+
const type = el("select");
|
|
507
|
+
for (const schema of audioEditorTypes)
|
|
508
|
+
type.append(new Option(schema.name.replace("audio/", ""), schema.name));
|
|
509
|
+
create.append(
|
|
510
|
+
inputLabel("Document name", name),
|
|
511
|
+
inputLabel("Document type", type),
|
|
512
|
+
button(
|
|
513
|
+
"Create document",
|
|
514
|
+
async () => {
|
|
515
|
+
await session.createNew(type.value, name.value);
|
|
516
|
+
active = panelFor(type.value);
|
|
517
|
+
},
|
|
518
|
+
!session.documents.commit
|
|
519
|
+
),
|
|
520
|
+
button(
|
|
521
|
+
"Duplicate document",
|
|
522
|
+
() => session.duplicate(name.value),
|
|
523
|
+
!session.state.document || !session.documents.commit
|
|
524
|
+
)
|
|
525
|
+
);
|
|
526
|
+
body.append(create);
|
|
527
|
+
}
|
|
528
|
+
function panelFor(type) {
|
|
529
|
+
if (type.includes("Music")) return "music";
|
|
530
|
+
if (type.includes("Mixer") || type.includes("Snapshot")) return "mixer";
|
|
531
|
+
if (type.includes("Parameters")) return "parameters";
|
|
532
|
+
if (type.includes("Collection")) return "library";
|
|
533
|
+
return "event";
|
|
534
|
+
}
|
|
535
|
+
function renderAuthoring(state) {
|
|
536
|
+
if (!state.document || !state.schema) {
|
|
537
|
+
body.append(el("p", "empty", "Open an audio document from the Library."));
|
|
538
|
+
if (fixedPanel)
|
|
539
|
+
body.append(
|
|
540
|
+
button("Browse documents", () => {
|
|
541
|
+
active = "library";
|
|
542
|
+
return loadLibrary();
|
|
543
|
+
})
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const value = state.effective ?? state.document.value;
|
|
548
|
+
const layout = el("div", "authoring-layout");
|
|
549
|
+
const overview = el("section", "overview");
|
|
550
|
+
const inspector = el("section", "inspector");
|
|
551
|
+
const heading = el("div", "overview-heading");
|
|
552
|
+
const headings = {
|
|
553
|
+
event: "Layers & waveforms",
|
|
554
|
+
mixer: "Mixing desk",
|
|
555
|
+
music: "Music arrangement",
|
|
556
|
+
parameters: "Parameter controls"
|
|
557
|
+
};
|
|
558
|
+
heading.append(
|
|
559
|
+
el("span", "eyebrow", "AUTHORING"),
|
|
560
|
+
el("h2", "", headings[active] ?? "Audio document")
|
|
561
|
+
);
|
|
562
|
+
overview.append(heading);
|
|
563
|
+
inspector.setAttribute("aria-label", "Document properties");
|
|
564
|
+
layout.append(overview, inspector);
|
|
565
|
+
body.append(layout);
|
|
566
|
+
if (active === "event") renderEvent(value, overview);
|
|
567
|
+
if (active === "music") renderMusic(value, overview);
|
|
568
|
+
if (active === "mixer") renderMixer(value, overview);
|
|
569
|
+
if (active === "parameters") renderParameters(value, overview);
|
|
570
|
+
if (fixedPanel)
|
|
571
|
+
body.append(
|
|
572
|
+
button("Browse documents", () => {
|
|
573
|
+
active = "library";
|
|
574
|
+
return loadLibrary();
|
|
575
|
+
})
|
|
576
|
+
);
|
|
577
|
+
const propertyHeading = el("h3", "inspector-heading", "Properties");
|
|
578
|
+
inspector.append(propertyHeading);
|
|
579
|
+
const properties = el("div", "properties");
|
|
580
|
+
for (const [name, field] of Object.entries(state.schema.fields)) {
|
|
581
|
+
if (field.options.hidden) continue;
|
|
582
|
+
if (active === "parameters" && name !== "parameters" && name !== "globals") continue;
|
|
583
|
+
renderField(properties, name, field, pointer("", name), value[name], 0);
|
|
584
|
+
}
|
|
585
|
+
inspector.append(properties);
|
|
586
|
+
renderWaveform(overview);
|
|
587
|
+
const raw = details("Document JSON", "raw");
|
|
588
|
+
const json = el("textarea");
|
|
589
|
+
json.value = JSON.stringify(state.document.value, null, 2);
|
|
590
|
+
json.setAttribute("aria-label", "Document JSON");
|
|
591
|
+
raw.append(
|
|
592
|
+
json,
|
|
593
|
+
button("Apply JSON draft", () => session.replace(JSON.parse(json.value)), state.readOnly)
|
|
594
|
+
);
|
|
595
|
+
inspector.append(raw);
|
|
596
|
+
}
|
|
597
|
+
function renderField(parent, name, field, path, value, depth, target) {
|
|
598
|
+
if (field.options.hidden) return;
|
|
599
|
+
const state = session.state;
|
|
600
|
+
const readonly = (target ? target.readOnly === true : state.readOnly) || field.options.readonly === true;
|
|
601
|
+
const edit2 = (path2, value2) => target ? target.edit({ path: path2, value: value2 }) : session.edit({ path: path2, value: value2 });
|
|
602
|
+
const displayName = title(name);
|
|
603
|
+
const resolved = value ?? field.toJson(field.defaultValue());
|
|
604
|
+
const inherited = !target && typeof state.document?.value.$extends === "string";
|
|
605
|
+
const authored = at(target?.value ?? state.document?.value, path) !== void 0;
|
|
606
|
+
const resettable = inherited && authored && !path.split("/").some((part) => /^\d+$/.test(part));
|
|
607
|
+
const resetOverride = (container2) => {
|
|
608
|
+
if (resettable)
|
|
609
|
+
container2.append(
|
|
610
|
+
button(`Reset ${name} override`, () => session.edit({ path, remove: true }), readonly)
|
|
611
|
+
);
|
|
612
|
+
};
|
|
613
|
+
if (field.kind === "struct" && field.fields) {
|
|
614
|
+
const group = details(displayName, path, depth < 1);
|
|
615
|
+
for (const [key, child] of Object.entries(field.fields))
|
|
616
|
+
renderField(group, key, child, pointer(path, key), object(resolved)[key], depth + 1, target);
|
|
617
|
+
resetOverride(group);
|
|
618
|
+
parent.append(group);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (field.kind === "list" && field.item) {
|
|
622
|
+
const values = Array.isArray(resolved) ? resolved : [];
|
|
623
|
+
const group = details(`${displayName} (${values.length})`, path, depth < 1);
|
|
624
|
+
for (let index = 0; index < values.length; index++) {
|
|
625
|
+
const row = el("div", "list-item");
|
|
626
|
+
const heading = el("div", "item-title");
|
|
627
|
+
const itemName = object(values[index]).id ?? object(values[index]).name ?? `${index + 1}`;
|
|
628
|
+
heading.append(
|
|
629
|
+
el("span", "", String(itemName)),
|
|
630
|
+
button(
|
|
631
|
+
`Move ${name} ${index + 1} up`,
|
|
632
|
+
() => {
|
|
633
|
+
const next = [...values];
|
|
634
|
+
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
|
635
|
+
edit2(path, next);
|
|
636
|
+
},
|
|
637
|
+
readonly || index === 0
|
|
638
|
+
),
|
|
639
|
+
button(
|
|
640
|
+
`Remove ${name} ${index + 1}`,
|
|
641
|
+
() => edit2(path, values.filter((_, i) => i !== index)),
|
|
642
|
+
readonly
|
|
643
|
+
)
|
|
644
|
+
);
|
|
645
|
+
row.append(heading);
|
|
646
|
+
renderField(
|
|
647
|
+
row,
|
|
648
|
+
`${name} ${index + 1}`,
|
|
649
|
+
field.item,
|
|
650
|
+
pointer(path, index),
|
|
651
|
+
values[index],
|
|
652
|
+
depth + 1,
|
|
653
|
+
target
|
|
654
|
+
);
|
|
655
|
+
group.append(row);
|
|
656
|
+
}
|
|
657
|
+
group.append(
|
|
658
|
+
button(
|
|
659
|
+
`Add ${name}`,
|
|
660
|
+
() => {
|
|
661
|
+
const item = field.item.toJson(field.item.defaultValue());
|
|
662
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
663
|
+
if ("id" in item) item.id = `${name.replace(/s$/, "")}-${values.length + 1}`;
|
|
664
|
+
if ("name" in item) item.name = `${name.replace(/s$/, "")}-${values.length + 1}`;
|
|
665
|
+
}
|
|
666
|
+
expanded.set(path, true);
|
|
667
|
+
edit2(path, [...values, item]);
|
|
668
|
+
},
|
|
669
|
+
readonly
|
|
670
|
+
)
|
|
671
|
+
);
|
|
672
|
+
resetOverride(group);
|
|
673
|
+
parent.append(group);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const wrap = el("div", "field");
|
|
677
|
+
if (field.kind === "handle") {
|
|
678
|
+
const id = assetId(resolved);
|
|
679
|
+
wrap.append(
|
|
680
|
+
el("span", "field-label", displayName),
|
|
681
|
+
el("span", "hint", id || "No asset selected")
|
|
682
|
+
);
|
|
683
|
+
const actions = el("div", "actions");
|
|
684
|
+
actions.append(
|
|
685
|
+
button(
|
|
686
|
+
`Choose ${name}`,
|
|
687
|
+
async () => {
|
|
688
|
+
picker = { path, type: field.assetType ?? "", label: name, edit: target?.edit };
|
|
689
|
+
cursor = void 0;
|
|
690
|
+
cursors.length = 0;
|
|
691
|
+
search = "";
|
|
692
|
+
await loadLibrary();
|
|
693
|
+
},
|
|
694
|
+
readonly
|
|
695
|
+
),
|
|
696
|
+
button(`Clear ${name}`, () => edit2(path, null), readonly || !id)
|
|
697
|
+
);
|
|
698
|
+
if (field.assetType === "AudioClip" && id)
|
|
699
|
+
actions.append(
|
|
700
|
+
button(`Waveform ${name}`, () => showWaveform(id), !session.preview?.waveform)
|
|
701
|
+
);
|
|
702
|
+
wrap.append(actions);
|
|
703
|
+
} else if (field.stride > 1) {
|
|
704
|
+
const values = Array.isArray(resolved) ? resolved : [];
|
|
705
|
+
wrap.append(el("span", "field-label", displayName));
|
|
706
|
+
const components = el("div", "field-inline");
|
|
707
|
+
for (let i = 0; i < field.stride; i++) {
|
|
708
|
+
const input = el("input");
|
|
709
|
+
input.type = "number";
|
|
710
|
+
input.step = "any";
|
|
711
|
+
input.value = String(values[i] ?? 0);
|
|
712
|
+
input.dataset.path = `${path}/${i}`;
|
|
713
|
+
input.setAttribute("aria-label", `${displayName} ${i + 1}`);
|
|
714
|
+
input.disabled = readonly;
|
|
715
|
+
input.addEventListener(
|
|
716
|
+
"change",
|
|
717
|
+
() => run(() => {
|
|
718
|
+
const next = [...values];
|
|
719
|
+
next[i] = Number(input.value);
|
|
720
|
+
edit2(path, next);
|
|
721
|
+
})
|
|
722
|
+
);
|
|
723
|
+
components.append(input);
|
|
724
|
+
}
|
|
725
|
+
wrap.append(components);
|
|
726
|
+
} else {
|
|
727
|
+
const schema = field.jsonSchema();
|
|
728
|
+
let control;
|
|
729
|
+
if (field.kind === "enum") {
|
|
730
|
+
const select = el("select");
|
|
731
|
+
for (const choice of schema.enum ?? [])
|
|
732
|
+
select.append(new Option(choice, choice));
|
|
733
|
+
select.value = String(resolved ?? "");
|
|
734
|
+
control = select;
|
|
735
|
+
} else if (field.kind === "json") {
|
|
736
|
+
const textarea = el("textarea");
|
|
737
|
+
textarea.value = JSON.stringify(resolved, null, 2);
|
|
738
|
+
control = textarea;
|
|
739
|
+
} else {
|
|
740
|
+
const input = el("input");
|
|
741
|
+
if (field.kind === "bool") {
|
|
742
|
+
input.type = "checkbox";
|
|
743
|
+
input.checked = !!resolved;
|
|
744
|
+
} else if (field.kind === "string") {
|
|
745
|
+
input.type = "text";
|
|
746
|
+
input.value = String(resolved ?? "");
|
|
747
|
+
} else {
|
|
748
|
+
input.type = "number";
|
|
749
|
+
input.step = field.kind.startsWith("f") ? "any" : "1";
|
|
750
|
+
input.value = String(resolved ?? 0);
|
|
751
|
+
if (field.options.min !== void 0) input.min = String(field.options.min);
|
|
752
|
+
if (field.options.max !== void 0) input.max = String(field.options.max);
|
|
753
|
+
}
|
|
754
|
+
control = input;
|
|
755
|
+
}
|
|
756
|
+
control.disabled = readonly;
|
|
757
|
+
control.dataset.path = path;
|
|
758
|
+
control.addEventListener(
|
|
759
|
+
"change",
|
|
760
|
+
() => run(() => {
|
|
761
|
+
const value2 = field.kind === "bool" ? control.checked : field.kind === "json" ? JSON.parse(control.value) : field.kind === "enum" || field.kind === "string" ? control.value : Number(control.value);
|
|
762
|
+
edit2(path, value2);
|
|
763
|
+
})
|
|
764
|
+
);
|
|
765
|
+
wrap.append(
|
|
766
|
+
inputLabel(
|
|
767
|
+
`${displayName}${field.options.unit ? ` (${field.options.unit})` : ""}`,
|
|
768
|
+
control
|
|
769
|
+
)
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
if (field.options.description) wrap.append(el("p", "hint", field.options.description));
|
|
773
|
+
if (!authored && inherited) wrap.append(el("span", "hint", "Inherited"));
|
|
774
|
+
resetOverride(wrap);
|
|
775
|
+
for (const diagnostic of target ? [] : state.diagnostics)
|
|
776
|
+
if (diagnostic.path === path) wrap.append(el("p", "error", diagnostic.message));
|
|
777
|
+
parent.append(wrap);
|
|
778
|
+
}
|
|
779
|
+
function renderEvent(value, target = body) {
|
|
780
|
+
const layers = Array.isArray(value.layers) ? value.layers : [];
|
|
781
|
+
const waveKey = `${session.assets.scope}:${session.state.document?.id}:${session.state.document?.revision}`;
|
|
782
|
+
if (waveKey !== waveformDocument) {
|
|
783
|
+
layerWaveforms.clear();
|
|
784
|
+
waveformDocument = waveKey;
|
|
785
|
+
}
|
|
786
|
+
target.append(
|
|
787
|
+
el(
|
|
788
|
+
"p",
|
|
789
|
+
"hint",
|
|
790
|
+
`${layers.length} layers \xB7 Source waveforms. Timing and gain are authored per layer.`
|
|
791
|
+
)
|
|
792
|
+
);
|
|
793
|
+
if (!layers.length)
|
|
794
|
+
target.append(el("p", "empty", "Add a layer in Properties to start building this event."));
|
|
795
|
+
const waves = el("div", "event-waves");
|
|
796
|
+
for (let index = 0; index < layers.length; index++) {
|
|
797
|
+
const layer = object(layers[index]);
|
|
798
|
+
const choices = Array.isArray(layer.choices) ? layer.choices : [];
|
|
799
|
+
const clipId = assetId(object(choices[0]).clip);
|
|
800
|
+
const lane = el("div", "wave-lane");
|
|
801
|
+
const header = el("div", "track-header");
|
|
802
|
+
const selectLayer = button(String(layer.id ?? "Layer"), () => {
|
|
803
|
+
expanded.set("/layers", true);
|
|
804
|
+
expanded.set(`/layers/${index}`, true);
|
|
805
|
+
render();
|
|
806
|
+
const summary = root.querySelector(
|
|
807
|
+
`details[data-group="/layers/${index}"] > summary`
|
|
808
|
+
);
|
|
809
|
+
summary?.focus();
|
|
810
|
+
summary?.scrollIntoView({ block: "nearest" });
|
|
811
|
+
});
|
|
812
|
+
selectLayer.className = "track-name";
|
|
813
|
+
selectLayer.setAttribute("aria-label", `Edit layer ${layer.id ?? index + 1}`);
|
|
814
|
+
header.append(
|
|
815
|
+
el("span", "track-number", String(index + 1).padStart(2, "0")),
|
|
816
|
+
selectLayer,
|
|
817
|
+
el("span", "badge", layer.loop ? "Loop" : "One-shot")
|
|
818
|
+
);
|
|
819
|
+
lane.append(header);
|
|
820
|
+
const metadata = el("div", "track-meta");
|
|
821
|
+
metadata.append(
|
|
822
|
+
el("span", "", `Delay ${layer.delay ?? 0}s`),
|
|
823
|
+
el("span", "", layer.duration ? `Duration ${layer.duration}s` : "Source length"),
|
|
824
|
+
el("span", "", `${layer.gainDb ?? 0} dB`)
|
|
825
|
+
);
|
|
826
|
+
if (choices.length > 1) metadata.append(el("span", "", `First of ${choices.length} choices`));
|
|
827
|
+
waves.append(lane);
|
|
828
|
+
if (!clipId || !session.preview?.waveform) {
|
|
829
|
+
lane.append(
|
|
830
|
+
el(
|
|
831
|
+
"div",
|
|
832
|
+
"wave-placeholder",
|
|
833
|
+
clipId ? "Waveform unavailable" : "Choose an audio asset in Properties"
|
|
834
|
+
),
|
|
835
|
+
metadata
|
|
836
|
+
);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
const canvas = el("canvas");
|
|
840
|
+
canvas.width = 1024;
|
|
841
|
+
canvas.height = 96;
|
|
842
|
+
canvas.setAttribute("role", "img");
|
|
843
|
+
canvas.setAttribute("aria-label", `${layer.id} audio waveform`);
|
|
844
|
+
lane.append(canvas, metadata);
|
|
845
|
+
let pending = layerWaveforms.get(clipId);
|
|
846
|
+
if (!pending) {
|
|
847
|
+
pending = session.preview.waveform(clipId, 512, abort.signal);
|
|
848
|
+
layerWaveforms.set(clipId, pending);
|
|
849
|
+
}
|
|
850
|
+
void pending.then((wave) => {
|
|
851
|
+
if (disposed || !canvas.isConnected) return;
|
|
852
|
+
const context = canvas.getContext("2d");
|
|
853
|
+
if (context)
|
|
854
|
+
drawAudioWaveform(
|
|
855
|
+
context,
|
|
856
|
+
wave.peaks,
|
|
857
|
+
canvas.width,
|
|
858
|
+
canvas.height,
|
|
859
|
+
doc.defaultView.getComputedStyle(lane).getPropertyValue("--ae-track").trim()
|
|
860
|
+
);
|
|
861
|
+
canvas.setAttribute(
|
|
862
|
+
"aria-label",
|
|
863
|
+
`${layer.id} waveform, ${wave.duration.toFixed(3)} seconds`
|
|
864
|
+
);
|
|
865
|
+
}).catch(() => {
|
|
866
|
+
if (!disposed && canvas.isConnected)
|
|
867
|
+
canvas.replaceWith(el("div", "wave-placeholder", "Waveform unavailable"));
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
target.append(waves);
|
|
871
|
+
if (layers.length) {
|
|
872
|
+
const timing = details("Layer timing \xB7 seconds", "event-timing");
|
|
873
|
+
timing.append(
|
|
874
|
+
el(
|
|
875
|
+
"p",
|
|
876
|
+
"hint",
|
|
877
|
+
"Bars show authored durations. Dots mark source-length layers; looping and random choices can change their end time."
|
|
878
|
+
)
|
|
879
|
+
);
|
|
880
|
+
let end = 1;
|
|
881
|
+
for (const item of layers) {
|
|
882
|
+
const layer = object(item);
|
|
883
|
+
end = Math.max(end, Number(layer.delay ?? 0) + Number(layer.duration ?? 0));
|
|
884
|
+
}
|
|
885
|
+
const ruler = el("div", "timing-ruler");
|
|
886
|
+
ruler.append(el("span", "", "0s"), el("span", "", `${end}s`));
|
|
887
|
+
timing.append(ruler);
|
|
888
|
+
for (const item of layers) {
|
|
889
|
+
const layer = object(item);
|
|
890
|
+
const row = el("div", "timing-row");
|
|
891
|
+
const name = el("span", "hint", `${layer.id} \xB7 ${layer.delay ?? 0}s`);
|
|
892
|
+
const track = el("div", "timing-track");
|
|
893
|
+
const mark = el("span", "timing-mark");
|
|
894
|
+
mark.style.left = `${Number(layer.delay ?? 0) / end * 100}%`;
|
|
895
|
+
mark.style.width = layer.duration ? `${Number(layer.duration) / end * 100}%` : "6px";
|
|
896
|
+
mark.dataset.sourceLength = String(!layer.duration);
|
|
897
|
+
track.append(mark);
|
|
898
|
+
row.append(name, track);
|
|
899
|
+
timing.append(row);
|
|
900
|
+
}
|
|
901
|
+
target.append(timing);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function renderMixer(value, target = body) {
|
|
905
|
+
if (session.state.document?.type === "audio/AudioSnapshot") {
|
|
906
|
+
const snapshot = object(session.state.previewResult).snapshot;
|
|
907
|
+
const weight = el("input");
|
|
908
|
+
weight.type = "number";
|
|
909
|
+
weight.min = "0";
|
|
910
|
+
weight.max = "1";
|
|
911
|
+
weight.step = "0.01";
|
|
912
|
+
weight.value = "1";
|
|
913
|
+
target.append(
|
|
914
|
+
inputLabel("Audition snapshot weight", weight),
|
|
915
|
+
button(
|
|
916
|
+
"Apply snapshot weight",
|
|
917
|
+
() => session.preview?.command?.("audio.snapshot.set", {
|
|
918
|
+
snapshot,
|
|
919
|
+
weight: Number(weight.value)
|
|
920
|
+
}),
|
|
921
|
+
typeof snapshot !== "number" || !session.preview?.command || session.state.previewDocumentId !== session.state.document?.id
|
|
922
|
+
)
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
const buses = Array.isArray(value.buses) ? value.buses : [];
|
|
926
|
+
if (!buses.length) return;
|
|
927
|
+
const grid = el("div", "mixer-strips");
|
|
928
|
+
for (let index = 0; index < buses.length; index++) {
|
|
929
|
+
const bus = object(buses[index]);
|
|
930
|
+
const card = el("div", "mixer-strip");
|
|
931
|
+
card.append(
|
|
932
|
+
el("h3", "", String(bus.id)),
|
|
933
|
+
el("span", "bus-route", `\u2192 ${bus.parent || "output"}`)
|
|
934
|
+
);
|
|
935
|
+
const fader = el("input");
|
|
936
|
+
fader.type = "range";
|
|
937
|
+
fader.min = "-120";
|
|
938
|
+
fader.max = "24";
|
|
939
|
+
fader.step = "0.1";
|
|
940
|
+
fader.value = String(bus.gainDb ?? 0);
|
|
941
|
+
fader.disabled = session.state.readOnly;
|
|
942
|
+
fader.dataset.path = `/buses/${index}/gainDb/fader`;
|
|
943
|
+
let transaction = false;
|
|
944
|
+
fader.addEventListener("pointerdown", () => {
|
|
945
|
+
dragging = true;
|
|
946
|
+
session.beginTransaction("Bus gain");
|
|
947
|
+
transaction = true;
|
|
948
|
+
});
|
|
949
|
+
fader.addEventListener("input", () => {
|
|
950
|
+
if (!transaction) {
|
|
951
|
+
dragging = true;
|
|
952
|
+
session.beginTransaction("Bus gain");
|
|
953
|
+
transaction = true;
|
|
954
|
+
}
|
|
955
|
+
session.edit({ path: `/buses/${index}/gainDb`, value: Number(fader.value) });
|
|
956
|
+
});
|
|
957
|
+
const finish = () => {
|
|
958
|
+
if (transaction) {
|
|
959
|
+
transaction = false;
|
|
960
|
+
dragging = false;
|
|
961
|
+
session.endTransaction();
|
|
962
|
+
render();
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
fader.addEventListener("change", finish);
|
|
966
|
+
fader.addEventListener("pointerup", finish);
|
|
967
|
+
fader.addEventListener("blur", finish);
|
|
968
|
+
fader.addEventListener("pointercancel", () => {
|
|
969
|
+
if (transaction) {
|
|
970
|
+
transaction = false;
|
|
971
|
+
dragging = false;
|
|
972
|
+
session.cancelTransaction();
|
|
973
|
+
render();
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
fader.addEventListener("keydown", (event) => {
|
|
977
|
+
if (event.key === "Escape" && transaction) {
|
|
978
|
+
event.preventDefault();
|
|
979
|
+
transaction = false;
|
|
980
|
+
dragging = false;
|
|
981
|
+
session.cancelTransaction();
|
|
982
|
+
render();
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
const faderRow = el("div", "fader-row");
|
|
986
|
+
fader.setAttribute("aria-label", `${bus.id} authored gain (dB)`);
|
|
987
|
+
faderRow.append(fader);
|
|
988
|
+
const gain = el("input");
|
|
989
|
+
gain.type = "number";
|
|
990
|
+
gain.min = "-120";
|
|
991
|
+
gain.max = "24";
|
|
992
|
+
gain.step = "0.1";
|
|
993
|
+
gain.value = String(bus.gainDb ?? 0);
|
|
994
|
+
gain.disabled = session.state.readOnly;
|
|
995
|
+
gain.dataset.path = `/buses/${index}/gainDb/numeric`;
|
|
996
|
+
gain.addEventListener(
|
|
997
|
+
"change",
|
|
998
|
+
() => run(() => edit(`/buses/${index}/gainDb`, Number(gain.value)))
|
|
999
|
+
);
|
|
1000
|
+
gain.setAttribute("aria-label", `${bus.id} exact authored gain (dB)`);
|
|
1001
|
+
faderRow.append(gain, el("span", "hint", "dB"));
|
|
1002
|
+
card.append(faderRow);
|
|
1003
|
+
const meter = el("meter");
|
|
1004
|
+
meter.min = 0;
|
|
1005
|
+
meter.max = 1;
|
|
1006
|
+
meter.value = 0;
|
|
1007
|
+
meter.setAttribute("aria-label", `${bus.id} measured peak`);
|
|
1008
|
+
const meterLabel = el("span", "hint meter-caption", "Meter unavailable");
|
|
1009
|
+
meters.set(String(bus.id), { meter, label: meterLabel });
|
|
1010
|
+
card.append(meter, meterLabel);
|
|
1011
|
+
grid.append(card);
|
|
1012
|
+
}
|
|
1013
|
+
target.append(grid);
|
|
1014
|
+
const preferences = details("Audition category controls (not saved)", "user-controls");
|
|
1015
|
+
for (const bus of ["master", "music", "sfx", "voice", "ambient", "ui"]) {
|
|
1016
|
+
const volume = el("input");
|
|
1017
|
+
volume.type = "number";
|
|
1018
|
+
volume.min = "0";
|
|
1019
|
+
volume.max = "1";
|
|
1020
|
+
volume.step = "0.01";
|
|
1021
|
+
volume.value = "1";
|
|
1022
|
+
const muted = el("input");
|
|
1023
|
+
muted.type = "checkbox";
|
|
1024
|
+
const row = el("div", "card");
|
|
1025
|
+
const disabled = !session.preview?.capabilities.userControls;
|
|
1026
|
+
volume.disabled = disabled;
|
|
1027
|
+
muted.disabled = disabled;
|
|
1028
|
+
row.append(
|
|
1029
|
+
inputLabel(`${bus} user volume`, volume),
|
|
1030
|
+
inputLabel(`${bus} user muted`, muted),
|
|
1031
|
+
button(
|
|
1032
|
+
`Apply ${bus} user preference`,
|
|
1033
|
+
() => session.preview?.command?.("audio.user.set", {
|
|
1034
|
+
bus,
|
|
1035
|
+
volume: Number(volume.value),
|
|
1036
|
+
muted: muted.checked
|
|
1037
|
+
}),
|
|
1038
|
+
disabled
|
|
1039
|
+
)
|
|
1040
|
+
);
|
|
1041
|
+
preferences.append(row);
|
|
1042
|
+
}
|
|
1043
|
+
target.append(
|
|
1044
|
+
preferences,
|
|
1045
|
+
button(
|
|
1046
|
+
"Inspect effective mixer values",
|
|
1047
|
+
async () => {
|
|
1048
|
+
inspection = await session.preview?.command?.("audio.mix.inspect", {});
|
|
1049
|
+
},
|
|
1050
|
+
!session.preview?.command
|
|
1051
|
+
)
|
|
1052
|
+
);
|
|
1053
|
+
if (inspection !== void 0) {
|
|
1054
|
+
const effective = details("Preview inspection", "mix-inspection");
|
|
1055
|
+
effective.append(el("pre", "", JSON.stringify(inspection, null, 2)));
|
|
1056
|
+
target.append(effective);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
function renderMusic(value, target = body) {
|
|
1060
|
+
const transport = object(session.state.previewResult).transport;
|
|
1061
|
+
const canControl = session.state.previewDocumentId === session.state.document?.id && typeof transport === "number" && !!session.preview?.command && session.preview.capabilities.control !== false;
|
|
1062
|
+
target.append(
|
|
1063
|
+
el(
|
|
1064
|
+
"p",
|
|
1065
|
+
"notice",
|
|
1066
|
+
`${value.bpm ?? 120} BPM \xB7 ${value.numerator ?? 4}/${value.denominator ?? 4} \xB7 960 ticks per quarter note`
|
|
1067
|
+
)
|
|
1068
|
+
);
|
|
1069
|
+
const sections = Array.isArray(value.sections) ? value.sections : [];
|
|
1070
|
+
for (const sectionValue of sections) {
|
|
1071
|
+
const section = object(sectionValue);
|
|
1072
|
+
const card = el("div", "card music-section");
|
|
1073
|
+
card.append(el("h3", "", `${section.id} \xB7 ${section.bars} bars`));
|
|
1074
|
+
card.append(
|
|
1075
|
+
button(
|
|
1076
|
+
`Audition section ${section.id}`,
|
|
1077
|
+
async () => {
|
|
1078
|
+
inspection = await session.preview?.command?.("audio.music.transition", {
|
|
1079
|
+
transport,
|
|
1080
|
+
section: section.id,
|
|
1081
|
+
quantize: "bar"
|
|
1082
|
+
});
|
|
1083
|
+
},
|
|
1084
|
+
!canControl
|
|
1085
|
+
)
|
|
1086
|
+
);
|
|
1087
|
+
for (const stemValue of Array.isArray(section.stems) ? section.stems : []) {
|
|
1088
|
+
const stem = object(stemValue);
|
|
1089
|
+
card.append(
|
|
1090
|
+
el("p", "hint", `${stem.id}: samples ${stem.startSample ?? 0}\u2013${stem.endSample ?? 0}`)
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
target.append(card);
|
|
1094
|
+
}
|
|
1095
|
+
const transportControls = el("div", "actions");
|
|
1096
|
+
for (const action of ["pause", "resume"])
|
|
1097
|
+
transportControls.append(
|
|
1098
|
+
button(
|
|
1099
|
+
`${title(action)} music`,
|
|
1100
|
+
() => session.preview?.command?.(`audio.music.${action}`, { transport }),
|
|
1101
|
+
!canControl
|
|
1102
|
+
)
|
|
1103
|
+
);
|
|
1104
|
+
const tick = el("input");
|
|
1105
|
+
tick.type = "number";
|
|
1106
|
+
tick.min = "0";
|
|
1107
|
+
tick.step = "1";
|
|
1108
|
+
tick.value = "0";
|
|
1109
|
+
target.append(
|
|
1110
|
+
transportControls,
|
|
1111
|
+
inputLabel("Audition seek tick (960 PPQ)", tick),
|
|
1112
|
+
button(
|
|
1113
|
+
"Seek music",
|
|
1114
|
+
() => session.preview?.command?.("audio.music.seek", { transport, tick: Number(tick.value) }),
|
|
1115
|
+
!canControl
|
|
1116
|
+
)
|
|
1117
|
+
);
|
|
1118
|
+
for (const stingerValue of Array.isArray(value.stingers) ? value.stingers : []) {
|
|
1119
|
+
const stinger = object(stingerValue);
|
|
1120
|
+
target.append(
|
|
1121
|
+
button(
|
|
1122
|
+
`Audition stinger ${stinger.id}`,
|
|
1123
|
+
() => session.preview?.command?.("audio.music.stinger", {
|
|
1124
|
+
transport,
|
|
1125
|
+
stinger: stinger.id,
|
|
1126
|
+
quantize: "beat"
|
|
1127
|
+
}),
|
|
1128
|
+
!canControl
|
|
1129
|
+
)
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
renderParameters(value, target);
|
|
1133
|
+
target.append(
|
|
1134
|
+
el(
|
|
1135
|
+
"p",
|
|
1136
|
+
"hint",
|
|
1137
|
+
"Sections, transition boundaries, stem sample markers and stingers are editable in the schema controls below."
|
|
1138
|
+
)
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
function renderParameters(value, target = body) {
|
|
1142
|
+
const parameters = Array.isArray(value.parameters) ? value.parameters : [];
|
|
1143
|
+
if (!parameters.length) return;
|
|
1144
|
+
renderRename(parameters, target);
|
|
1145
|
+
const section = details("Audition values (not saved)", "audition", true);
|
|
1146
|
+
for (const item of parameters) {
|
|
1147
|
+
const parameter = object(item);
|
|
1148
|
+
const input = parameter.kind === "label" ? el("select") : el("input");
|
|
1149
|
+
if (input instanceof HTMLSelectElement) {
|
|
1150
|
+
for (const label of Array.isArray(parameter.labels) ? parameter.labels : [])
|
|
1151
|
+
input.append(new Option(String(label), String(label)));
|
|
1152
|
+
input.selectedIndex = Number(parameter.initial ?? 0);
|
|
1153
|
+
} else {
|
|
1154
|
+
input.type = "number";
|
|
1155
|
+
input.step = parameter.kind === "discrete" ? "1" : "any";
|
|
1156
|
+
input.value = String(parameter.initial ?? 0);
|
|
1157
|
+
input.min = String(parameter.min ?? 0);
|
|
1158
|
+
input.max = String(parameter.max ?? 1);
|
|
1159
|
+
}
|
|
1160
|
+
const result = object(session.state.previewResult);
|
|
1161
|
+
const local = session.state.document?.type !== "audio/AudioParameters";
|
|
1162
|
+
const music = local && typeof result.transport === "number";
|
|
1163
|
+
const disabled = !session.preview?.command || !!parameter.readonly || local && session.state.previewDocumentId !== session.state.document?.id || local && !music && typeof result.instance !== "number";
|
|
1164
|
+
input.disabled = disabled;
|
|
1165
|
+
section.append(
|
|
1166
|
+
inputLabel(String(parameter.name), input),
|
|
1167
|
+
button(
|
|
1168
|
+
`Audition ${parameter.name}`,
|
|
1169
|
+
() => session.preview?.command?.(music ? "audio.music.parameter" : "audio.parameter.set", {
|
|
1170
|
+
...music ? { transport: result.transport } : { instance: local ? result.instance : 0 },
|
|
1171
|
+
name: parameter.name,
|
|
1172
|
+
value: parameter.kind === "label" ? input.value : Number(input.value)
|
|
1173
|
+
}),
|
|
1174
|
+
disabled
|
|
1175
|
+
)
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
target.append(section);
|
|
1179
|
+
}
|
|
1180
|
+
function referencePaths(value, name, path = "") {
|
|
1181
|
+
const paths = [];
|
|
1182
|
+
if (!value || typeof value !== "object") return paths;
|
|
1183
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1184
|
+
const childPath = pointer(path, key);
|
|
1185
|
+
if ((key === "parameter" || key === "selectParameter") && child === name)
|
|
1186
|
+
paths.push(childPath);
|
|
1187
|
+
else if (child && typeof child === "object")
|
|
1188
|
+
paths.push(...referencePaths(child, name, childPath));
|
|
1189
|
+
}
|
|
1190
|
+
return paths;
|
|
1191
|
+
}
|
|
1192
|
+
function renderRename(parameters, target = body) {
|
|
1193
|
+
const state = session.state;
|
|
1194
|
+
const document = state.document;
|
|
1195
|
+
const global = document.type === "audio/AudioParameters";
|
|
1196
|
+
const rename = details("Rename a parameter safely", "rename");
|
|
1197
|
+
const source = el("select");
|
|
1198
|
+
for (const parameter of parameters) {
|
|
1199
|
+
const name2 = String(object(parameter).name ?? "");
|
|
1200
|
+
source.append(new Option(name2, name2));
|
|
1201
|
+
}
|
|
1202
|
+
if (!parameters.some((parameter) => object(parameter).name === renameFrom))
|
|
1203
|
+
renameFrom = String(object(parameters[0]).name ?? "");
|
|
1204
|
+
source.value = renameFrom;
|
|
1205
|
+
source.addEventListener("change", () => {
|
|
1206
|
+
renameFrom = source.value;
|
|
1207
|
+
renameScanKey = "";
|
|
1208
|
+
render();
|
|
1209
|
+
});
|
|
1210
|
+
const name = el("input");
|
|
1211
|
+
name.value = renameTo;
|
|
1212
|
+
name.addEventListener("input", () => {
|
|
1213
|
+
renameTo = name.value;
|
|
1214
|
+
});
|
|
1215
|
+
rename.append(inputLabel("Parameter to rename", source), inputLabel("New parameter name", name));
|
|
1216
|
+
const localReferences = referencePaths(state.effective, renameFrom);
|
|
1217
|
+
rename.append(
|
|
1218
|
+
el("p", "hint", `${localReferences.length} references in this document will change.`)
|
|
1219
|
+
);
|
|
1220
|
+
if (localReferences.length) rename.append(el("pre", "", localReferences.join("\n")));
|
|
1221
|
+
const key = `${document.id}:${document.revision}:${renameFrom}`;
|
|
1222
|
+
if (global) {
|
|
1223
|
+
rename.append(
|
|
1224
|
+
button(
|
|
1225
|
+
"Find dependent documents",
|
|
1226
|
+
async () => {
|
|
1227
|
+
const dependencies = [];
|
|
1228
|
+
const allDocuments = [];
|
|
1229
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1230
|
+
for (const schema of audioEditorTypes) {
|
|
1231
|
+
let pageCursor;
|
|
1232
|
+
do {
|
|
1233
|
+
const page = await session.assets.list({
|
|
1234
|
+
type: schema.name,
|
|
1235
|
+
cursor: pageCursor,
|
|
1236
|
+
limit: 60,
|
|
1237
|
+
signal: abort.signal
|
|
1238
|
+
});
|
|
1239
|
+
if (disposed || session.state.document?.id !== document.id) return;
|
|
1240
|
+
for (const entry of page.items) {
|
|
1241
|
+
if (entry.id === document.id || seen.has(entry.id)) continue;
|
|
1242
|
+
seen.add(entry.id);
|
|
1243
|
+
const dependent = await session.documents.read(entry.id, abort.signal);
|
|
1244
|
+
if (disposed || session.state.document?.id !== document.id) return;
|
|
1245
|
+
allDocuments.push({
|
|
1246
|
+
id: dependent.id,
|
|
1247
|
+
name: dependent.name,
|
|
1248
|
+
value: dependent.value
|
|
1249
|
+
});
|
|
1250
|
+
if (object(dependent.value.globals).guid === document.id)
|
|
1251
|
+
dependencies.push({
|
|
1252
|
+
id: dependent.id,
|
|
1253
|
+
name: dependent.name,
|
|
1254
|
+
paths: referencePaths(dependent.value, renameFrom)
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
if (page.nextCursor === pageCursor && page.nextCursor !== void 0)
|
|
1258
|
+
throw new ShardError(
|
|
1259
|
+
"audio-editor/invalid-document",
|
|
1260
|
+
"The host asset catalog returned a repeated page cursor.",
|
|
1261
|
+
{ hint: "Ask the host to correct its catalog pagination." }
|
|
1262
|
+
);
|
|
1263
|
+
pageCursor = page.nextCursor;
|
|
1264
|
+
} while (pageCursor && !disposed);
|
|
1265
|
+
}
|
|
1266
|
+
if (disposed || session.state.document?.id !== document.id) return;
|
|
1267
|
+
const inherited = allDocuments.filter(
|
|
1268
|
+
(entry) => typeof entry.value.$extends === "string" && (entry.value.$extends === document.id || dependencies.some((dependent) => dependent.id === entry.value.$extends)) && referencePaths(entry.value, renameFrom).length > 0
|
|
1269
|
+
);
|
|
1270
|
+
renameBlocked = inherited.length ? `Inherited references need a host-aware rename: ${inherited.map((entry) => entry.name).join(", ")}` : "";
|
|
1271
|
+
renameDependencies = dependencies;
|
|
1272
|
+
renameScanKey = key;
|
|
1273
|
+
},
|
|
1274
|
+
!session.documents.atomic || state.dirty
|
|
1275
|
+
)
|
|
1276
|
+
);
|
|
1277
|
+
if (!session.documents.atomic)
|
|
1278
|
+
rename.append(
|
|
1279
|
+
el("p", "hint", "This host cannot rename references across documents atomically.")
|
|
1280
|
+
);
|
|
1281
|
+
if (renameScanKey === key) {
|
|
1282
|
+
rename.append(
|
|
1283
|
+
el(
|
|
1284
|
+
"p",
|
|
1285
|
+
"hint",
|
|
1286
|
+
`${renameDependencies.length} dependent documents will be updated atomically.`
|
|
1287
|
+
)
|
|
1288
|
+
);
|
|
1289
|
+
for (const dependent of renameDependencies)
|
|
1290
|
+
rename.append(el("p", "hint", `${dependent.name}: ${dependent.paths.length} references`));
|
|
1291
|
+
if (renameBlocked) rename.append(el("p", "error", renameBlocked));
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
rename.append(
|
|
1295
|
+
button(
|
|
1296
|
+
"Rename safely",
|
|
1297
|
+
async () => {
|
|
1298
|
+
await session.renameParameter(
|
|
1299
|
+
renameFrom,
|
|
1300
|
+
renameTo,
|
|
1301
|
+
global ? renameDependencies.map((dependent) => dependent.id) : []
|
|
1302
|
+
);
|
|
1303
|
+
renameFrom = renameTo;
|
|
1304
|
+
renameTo = "";
|
|
1305
|
+
renameScanKey = "";
|
|
1306
|
+
},
|
|
1307
|
+
state.readOnly || state.dirty || !document.revision || global && (!session.documents.atomic || renameScanKey !== key || !!renameBlocked)
|
|
1308
|
+
)
|
|
1309
|
+
);
|
|
1310
|
+
if (state.dirty) rename.append(el("p", "hint", "Save changes before renaming."));
|
|
1311
|
+
target.append(rename);
|
|
1312
|
+
}
|
|
1313
|
+
function renderDiagnostics(state) {
|
|
1314
|
+
body.append(el("h2", "", "Diagnostics"));
|
|
1315
|
+
if (state.document)
|
|
1316
|
+
body.append(
|
|
1317
|
+
el(
|
|
1318
|
+
"p",
|
|
1319
|
+
"hint",
|
|
1320
|
+
`${state.document.type} \xB7 ${state.document.id} \xB7 revision ${state.document.revision || "new"}`
|
|
1321
|
+
)
|
|
1322
|
+
);
|
|
1323
|
+
for (const diagnostic of state.diagnostics) {
|
|
1324
|
+
body.append(
|
|
1325
|
+
button(`${diagnostic.path ?? "/"}: ${diagnostic.message}`, () => {
|
|
1326
|
+
active = panelFor(state.document?.type ?? "");
|
|
1327
|
+
const path = diagnostic.path ?? "";
|
|
1328
|
+
const parts = path.split("/");
|
|
1329
|
+
for (let i = 1; i <= parts.length; i++) expanded.set(parts.slice(0, i).join("/"), true);
|
|
1330
|
+
render();
|
|
1331
|
+
for (const input of root.querySelectorAll("[data-path]"))
|
|
1332
|
+
if (input.dataset.path === path) input.focus();
|
|
1333
|
+
})
|
|
1334
|
+
);
|
|
1335
|
+
if (diagnostic.hint) body.append(el("p", "hint", diagnostic.hint));
|
|
1336
|
+
}
|
|
1337
|
+
if (!state.diagnostics.length) body.append(el("p", "", "No validation issues."));
|
|
1338
|
+
body.append(
|
|
1339
|
+
button("Validate references", () => session.validate()),
|
|
1340
|
+
button(
|
|
1341
|
+
"Refresh preview inspection",
|
|
1342
|
+
async () => {
|
|
1343
|
+
inspection = await session.preview?.inspect();
|
|
1344
|
+
presentationInspection = object(object(inspection).audio ?? inspection);
|
|
1345
|
+
},
|
|
1346
|
+
!session.preview
|
|
1347
|
+
)
|
|
1348
|
+
);
|
|
1349
|
+
body.append(
|
|
1350
|
+
el("p", "hint", "Inspection refreshes on request. Idle and hidden panels do not poll.")
|
|
1351
|
+
);
|
|
1352
|
+
if (inspection !== void 0) body.append(el("pre", "", JSON.stringify(inspection, null, 2)));
|
|
1353
|
+
if (!session.preview?.export)
|
|
1354
|
+
body.append(el("p", "hint", "WAV/trace export unavailable: this host has no export adapter."));
|
|
1355
|
+
else {
|
|
1356
|
+
const plan = el("textarea");
|
|
1357
|
+
plan.value = '{"seed":1}';
|
|
1358
|
+
plan.setAttribute("aria-label", "Preview export plan");
|
|
1359
|
+
body.append(
|
|
1360
|
+
plan,
|
|
1361
|
+
button(
|
|
1362
|
+
"Export WAV and trace",
|
|
1363
|
+
async () => {
|
|
1364
|
+
const result = await session.preview.export(
|
|
1365
|
+
state.document,
|
|
1366
|
+
JSON.parse(plan.value),
|
|
1367
|
+
abort.signal
|
|
1368
|
+
);
|
|
1369
|
+
if (disposed || !result || typeof result !== "object") return;
|
|
1370
|
+
const exported = result;
|
|
1371
|
+
if (exported.wav instanceof Uint8Array) {
|
|
1372
|
+
download(new Uint8Array(exported.wav).buffer, "audio/wav", "audio-preview.wav");
|
|
1373
|
+
const { wav: _wav, ...report } = exported;
|
|
1374
|
+
download(
|
|
1375
|
+
JSON.stringify(report, null, 2),
|
|
1376
|
+
"application/json",
|
|
1377
|
+
"audio-preview.trace.json"
|
|
1378
|
+
);
|
|
1379
|
+
} else {
|
|
1380
|
+
inspection = result;
|
|
1381
|
+
render();
|
|
1382
|
+
}
|
|
1383
|
+
},
|
|
1384
|
+
!state.document || state.diagnostics.length > 0
|
|
1385
|
+
)
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
const position = details("Audition listener and emitter", "position");
|
|
1389
|
+
const coords = [];
|
|
1390
|
+
for (const axis of ["X", "Y", "Z"]) {
|
|
1391
|
+
const input = el("input");
|
|
1392
|
+
input.type = "number";
|
|
1393
|
+
input.step = "any";
|
|
1394
|
+
input.value = "0";
|
|
1395
|
+
coords.push(input);
|
|
1396
|
+
position.append(inputLabel(`${axis} position`, input));
|
|
1397
|
+
}
|
|
1398
|
+
position.append(
|
|
1399
|
+
button(
|
|
1400
|
+
"Set audition listener",
|
|
1401
|
+
() => session.preview?.command?.("audio.listener.set", {
|
|
1402
|
+
position: coords.map((input) => Number(input.value))
|
|
1403
|
+
}),
|
|
1404
|
+
!session.preview?.capabilities.listener
|
|
1405
|
+
)
|
|
1406
|
+
);
|
|
1407
|
+
const instance = object(state.previewResult).instance;
|
|
1408
|
+
position.append(
|
|
1409
|
+
button(
|
|
1410
|
+
"Set audition emitter",
|
|
1411
|
+
() => session.preview?.command?.("audio.emitter.set", {
|
|
1412
|
+
instance,
|
|
1413
|
+
position: coords.map((input) => Number(input.value))
|
|
1414
|
+
}),
|
|
1415
|
+
!session.preview?.capabilities.emitter || typeof instance !== "number" || state.previewDocumentId !== state.document?.id
|
|
1416
|
+
)
|
|
1417
|
+
);
|
|
1418
|
+
body.append(position);
|
|
1419
|
+
if (options.scene) {
|
|
1420
|
+
const scene = details(`Scene component: ${options.scene.schema.name}`, "scene", true);
|
|
1421
|
+
for (const [name, field] of Object.entries(options.scene.schema.fields))
|
|
1422
|
+
renderField(
|
|
1423
|
+
scene,
|
|
1424
|
+
name,
|
|
1425
|
+
field,
|
|
1426
|
+
pointer("", name),
|
|
1427
|
+
options.scene.value[name],
|
|
1428
|
+
0,
|
|
1429
|
+
options.scene
|
|
1430
|
+
);
|
|
1431
|
+
body.append(scene);
|
|
1432
|
+
} else
|
|
1433
|
+
body.append(
|
|
1434
|
+
el(
|
|
1435
|
+
"p",
|
|
1436
|
+
"hint",
|
|
1437
|
+
"Scene component editing unavailable: this host has not supplied a scene selection adapter."
|
|
1438
|
+
)
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
async function showWaveform(id) {
|
|
1442
|
+
if (!session.preview?.waveform) {
|
|
1443
|
+
error = "Waveform unavailable: this host has no waveform capability.";
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const token = ++generation;
|
|
1447
|
+
const result = await session.preview.waveform(id, 512, abort.signal);
|
|
1448
|
+
if (disposed || token !== generation) return;
|
|
1449
|
+
waveformId = id;
|
|
1450
|
+
waveform = result;
|
|
1451
|
+
}
|
|
1452
|
+
function renderWaveform(target = body) {
|
|
1453
|
+
if (!waveform) return;
|
|
1454
|
+
const canvas = el("canvas");
|
|
1455
|
+
canvas.width = 1024;
|
|
1456
|
+
canvas.height = 160;
|
|
1457
|
+
canvas.setAttribute("role", "img");
|
|
1458
|
+
canvas.setAttribute("aria-label", `Waveform of ${waveformId}, ${waveform.duration} seconds`);
|
|
1459
|
+
target.append(
|
|
1460
|
+
canvas,
|
|
1461
|
+
el(
|
|
1462
|
+
"p",
|
|
1463
|
+
"hint",
|
|
1464
|
+
`${waveformId} \xB7 ${waveform.duration.toFixed(3)} seconds \xB7 ${waveform.sampleRate} Hz`
|
|
1465
|
+
)
|
|
1466
|
+
);
|
|
1467
|
+
const context = canvas.getContext("2d");
|
|
1468
|
+
if (!context) return;
|
|
1469
|
+
drawAudioWaveform(
|
|
1470
|
+
context,
|
|
1471
|
+
waveform.peaks,
|
|
1472
|
+
canvas.width,
|
|
1473
|
+
canvas.height,
|
|
1474
|
+
doc.defaultView.getComputedStyle(element).getPropertyValue("--ae-accent").trim()
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
root.addEventListener(
|
|
1478
|
+
"keydown",
|
|
1479
|
+
(event) => {
|
|
1480
|
+
const key = event;
|
|
1481
|
+
if (key.target.getAttribute("role") === "tab" && ["ArrowLeft", "ArrowRight", "Home", "End"].includes(key.key)) {
|
|
1482
|
+
key.preventDefault();
|
|
1483
|
+
const buttons = [...tabs.querySelectorAll('[role="tab"]')];
|
|
1484
|
+
const current = buttons.indexOf(key.target);
|
|
1485
|
+
const next = key.key === "Home" ? 0 : key.key === "End" ? buttons.length - 1 : (current + (key.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length;
|
|
1486
|
+
buttons[next]?.click();
|
|
1487
|
+
tabs.children[next]?.focus();
|
|
1488
|
+
}
|
|
1489
|
+
if (key.key === "Escape" && picker) {
|
|
1490
|
+
key.preventDefault();
|
|
1491
|
+
picker = void 0;
|
|
1492
|
+
render();
|
|
1493
|
+
}
|
|
1494
|
+
if (!(key.ctrlKey || key.metaKey)) return;
|
|
1495
|
+
if (key.key.toLowerCase() === "s") {
|
|
1496
|
+
key.preventDefault();
|
|
1497
|
+
run(() => session.save());
|
|
1498
|
+
}
|
|
1499
|
+
if (key.key.toLowerCase() === "z") {
|
|
1500
|
+
key.preventDefault();
|
|
1501
|
+
run(() => key.shiftKey ? session.redo() : session.undo());
|
|
1502
|
+
}
|
|
1503
|
+
},
|
|
1504
|
+
{ signal: abort.signal }
|
|
1505
|
+
);
|
|
1506
|
+
const unsubscribe = session.subscribe(() => {
|
|
1507
|
+
if (session.state.document?.id !== selectedDocument) {
|
|
1508
|
+
selectedDocument = session.state.document?.id;
|
|
1509
|
+
if (!fixedPanel && session.state.document) active = panelFor(session.state.document.type);
|
|
1510
|
+
}
|
|
1511
|
+
render();
|
|
1512
|
+
});
|
|
1513
|
+
const unsubscribeAssets = session.assets.subscribe?.(() => {
|
|
1514
|
+
layerWaveforms.clear();
|
|
1515
|
+
waveform = void 0;
|
|
1516
|
+
renameScanKey = "";
|
|
1517
|
+
if (!disposed) render();
|
|
1518
|
+
});
|
|
1519
|
+
const visibility = () => updateMeterPolling();
|
|
1520
|
+
doc.addEventListener("visibilitychange", visibility, { signal: abort.signal });
|
|
1521
|
+
const observer = typeof IntersectionObserver === "undefined" ? void 0 : new IntersectionObserver((entries) => {
|
|
1522
|
+
visible = entries.some((entry) => entry.isIntersecting);
|
|
1523
|
+
updateMeterPolling();
|
|
1524
|
+
});
|
|
1525
|
+
observer?.observe(element);
|
|
1526
|
+
render();
|
|
1527
|
+
if (active === "library") run(loadLibrary);
|
|
1528
|
+
return {
|
|
1529
|
+
element,
|
|
1530
|
+
root,
|
|
1531
|
+
refresh: render,
|
|
1532
|
+
setTheme(theme) {
|
|
1533
|
+
element.dataset.theme = theme;
|
|
1534
|
+
render();
|
|
1535
|
+
},
|
|
1536
|
+
dispose() {
|
|
1537
|
+
if (disposed) return;
|
|
1538
|
+
disposed = true;
|
|
1539
|
+
generation++;
|
|
1540
|
+
abort.abort();
|
|
1541
|
+
unsubscribe();
|
|
1542
|
+
unsubscribeAssets?.();
|
|
1543
|
+
observer?.disconnect();
|
|
1544
|
+
if (meterTimer !== void 0) clearInterval(meterTimer);
|
|
1545
|
+
for (const url of downloadUrls) URL.revokeObjectURL(url);
|
|
1546
|
+
downloadUrls.clear();
|
|
1547
|
+
if (dragging) session.cancelTransaction();
|
|
1548
|
+
element.remove();
|
|
1549
|
+
waveform = void 0;
|
|
1550
|
+
layerWaveforms.clear();
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
export {
|
|
1555
|
+
mountPanel
|
|
1556
|
+
};
|