@hypit/hypit 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/dist/public/studio-adapter.d.ts +31 -2
  3. package/package.json +4 -1
  4. package/packages/audio-track-studio/src/index.ts +7 -4
  5. package/packages/build-result/README.md +58 -0
  6. package/packages/build-result/package.json +2 -1
  7. package/packages/build-result/src/decode.ts +10 -8
  8. package/packages/build-result/src/file-reference.ts +42 -0
  9. package/packages/build-result/src/index.ts +3 -0
  10. package/packages/build-result/src/replace-file-windows.ts +58 -0
  11. package/packages/build-result/src/replace-file.ts +11 -0
  12. package/packages/build-result/src/store.ts +44 -13
  13. package/packages/build-result/src/types.ts +38 -8
  14. package/packages/build-result/src/writer.ts +12 -1
  15. package/packages/build-result-fs/README.md +23 -1
  16. package/packages/build-result-s3/README.md +5 -0
  17. package/packages/build-result-s3/src/repository.ts +19 -5
  18. package/packages/caption-fine/README.md +17 -2
  19. package/packages/caption-fine/src/manifest.ts +1 -1
  20. package/packages/caption-fine/src/render.ts +48 -36
  21. package/packages/caption-fine-studio/package.json +8 -3
  22. package/packages/caption-fine-studio/src/index.ts +10 -1
  23. package/packages/cli/README.md +25 -0
  24. package/packages/cli/src/arguments.ts +16 -11
  25. package/packages/cli/src/build-planning.ts +6 -1
  26. package/packages/cli/src/command.ts +1 -1
  27. package/packages/cli/src/commands/environment.ts +42 -13
  28. package/packages/cli/src/machine-view.ts +12 -3
  29. package/packages/cli/src/main.ts +4 -0
  30. package/packages/cli/src/output.ts +28 -12
  31. package/packages/cli/src/result-export.ts +22 -13
  32. package/packages/cli/src/run-file.ts +19 -8
  33. package/packages/comment-sticker-studio/src/index.ts +2 -1
  34. package/packages/compiler-node/src/compiler.ts +1 -0
  35. package/packages/estimate/README.md +56 -3
  36. package/packages/estimate/src/program.ts +7 -8
  37. package/packages/fonts-open/README.md +5 -0
  38. package/packages/fonts-open/package.json +5 -1
  39. package/packages/fonts-open/src/studio.ts +21 -0
  40. package/packages/gpt-image-kits/README.md +17 -12
  41. package/packages/gpt-image-kits/kits/phone-ugc-v1.svs +8 -8
  42. package/packages/media-track-studio/src/index.ts +7 -2
  43. package/packages/provider-whisperx-local/README.md +57 -0
  44. package/packages/ranking/src/render.ts +11 -9
  45. package/packages/ranking-studio/README.md +11 -0
  46. package/packages/ranking-studio/package.json +8 -3
  47. package/packages/ranking-studio/src/index.ts +11 -4
  48. package/packages/runtime/src/catalog.ts +2 -0
  49. package/packages/runtime-host-node/src/index.ts +1 -0
  50. package/packages/runtime-local/README.md +19 -0
  51. package/packages/runtime-local/src/programs.ts +39 -24
  52. package/packages/runtime-local/src/runtime.ts +2 -0
  53. package/packages/script/README.md +22 -0
  54. package/packages/script/src/edit.ts +123 -73
  55. package/packages/script/src/lexical.ts +40 -6
  56. package/packages/script/src/parser.ts +36 -8
  57. package/packages/script/src/types.ts +4 -0
  58. package/packages/seedance-kits/README.md +3 -1
  59. package/packages/seedance-kits/kits/speaker-v1.svs +5 -5
  60. package/packages/speech-alignment/src/align.ts +45 -21
  61. package/packages/studio/INSPECTOR.md +104 -0
  62. package/packages/studio/README.md +65 -4
  63. package/packages/studio/src/build-library.ts +99 -84
  64. package/packages/studio/src/library-media.ts +19 -0
  65. package/packages/studio/src/parameter-values.ts +41 -1
  66. package/packages/studio/src/parameters.ts +20 -21
  67. package/packages/studio/src/server.ts +58 -30
  68. package/packages/studio/src/shared.ts +20 -6
  69. package/packages/studio/src/studio-registry.ts +22 -0
  70. package/packages/studio/src/style.css +135 -144
  71. package/packages/studio/src/temporal-edit.ts +131 -0
  72. package/packages/studio/src/ui/artifact-name.ts +80 -0
  73. package/packages/studio/src/ui/artifact-preview.ts +99 -0
  74. package/packages/studio/src/ui/icons.ts +5 -1
  75. package/packages/studio/src/ui/library.ts +373 -192
  76. package/packages/studio/src/ui/main.ts +153 -31
  77. package/packages/studio/src/ui/material-preview.ts +1 -1
  78. package/packages/studio/src/ui/overlay.ts +21 -20
  79. package/packages/studio/src/ui/sidebar-panel.ts +31 -0
  80. package/packages/studio/src/ui/stage.ts +124 -8
  81. package/packages/studio/src/ui/timeline.ts +16 -179
  82. package/packages/studio/start.ts +15 -3
  83. package/packages/studio-adapter/README.md +21 -0
  84. package/packages/studio-adapter/src/index.ts +26 -1
  85. package/packages/temporal-markup/EDITING.md +183 -0
  86. package/packages/temporal-markup/README.md +4 -0
  87. package/packages/typography-track-studio/package.json +8 -3
  88. package/packages/typography-track-studio/src/index.ts +5 -1
  89. package/packages/video-cli/README.md +12 -3
  90. package/packages/video-cli/src/creation.ts +11 -5
  91. package/packages/whisperx/README.md +7 -0
  92. package/packages/workspace/src/index.ts +2 -0
  93. package/packages/workspace-fs-node/README.md +4 -1
  94. package/packages/workspace-fs-node/src/workspace.ts +3 -1
@@ -1,6 +1,6 @@
1
1
  import type { Clip, StudioFailure, StudioInspectorDomain, StudioSnapshot } from "../shared.js";
2
2
  import type { CanonicalValue, ValueSchema } from "@hypit/protocol";
3
- import { validateParameterValue } from "../parameter-values.js";
3
+ import { parameterAuthorValue, parameterNumber, parameterOption, validateParameterValue } from "../parameter-values.js";
4
4
  import { createCodePane } from "./code.js";
5
5
  import { icon } from "./icons.js";
6
6
  import { createLibraryPane } from "./library.js";
@@ -9,6 +9,8 @@ import type { Highlight } from "./code.js";
9
9
  import { intentAtOffset, spanAtOffset } from "./markers.js";
10
10
  import { clipAtOffset, createStore } from "./selection.js";
11
11
  import { createStage } from "./stage.js";
12
+ import { semanticGestureSpan } from "../temporal-edit.js";
13
+ import type { SemanticTarget } from "../temporal-edit.js";
12
14
  import { applyStudioMutation } from "./writeback.js";
13
15
  import { createTimeline } from "./timeline.js";
14
16
  import "../style.css";
@@ -60,9 +62,9 @@ app.innerHTML = `
60
62
 
61
63
  const store = createStore();
62
64
  const code = createCodePane();
63
- const library = createLibraryPane(code);
65
+ const stage = createStage(store, (id) => library.selectArtifact(id));
66
+ const library = createLibraryPane(code, (artifact) => stage.openArtifact(artifact), (artifact) => stage.renameArtifact(artifact));
64
67
  const timeline = createTimeline(store);
65
- const stage = createStage(store);
66
68
  app.querySelector<HTMLElement>("[data-library]")!.append(library.element);
67
69
  app.querySelector<HTMLElement>("[data-timeline]")!.append(timeline.element);
68
70
  app.querySelector<HTMLElement>("[data-stage]")!.append(stage.element);
@@ -72,8 +74,9 @@ app.querySelector<HTMLElement>("[data-stage]")!.append(stage.element);
72
74
  const shell = app.querySelector<HTMLElement>(".studio-shell")!;
73
75
  const upperShell = app.querySelector<HTMLElement>(".upper-shell")!;
74
76
  const workspacePanel = app.querySelector<HTMLElement>(".workspace-panel")!;
77
+ const sidebarMinimum = 320;
75
78
  const sourceHandle = createHandle({
76
- axis: "column", initial: Math.round(window.innerWidth * 0.34), minimum: 280,
79
+ axis: "column", initial: 416, minimum: sidebarMinimum,
77
80
  maximum: () => Math.max(360, upperShell.clientWidth - 720),
78
81
  apply: (size) => { upperShell.style.setProperty("--source-width", `${size}px`); },
79
82
  remember: "hypit-studio.v3.source-width",
@@ -81,7 +84,7 @@ const sourceHandle = createHandle({
81
84
  sourceHandle.classList.add("source-handle");
82
85
  upperShell.insertBefore(sourceHandle, app.querySelector<HTMLElement>("[data-stage]")!);
83
86
  const workspaceHandle = createHandle({
84
- axis: "column", initial: Math.round(window.innerWidth * 0.22), minimum: 270, invert: true,
87
+ axis: "column", initial: Math.round(window.innerWidth * 0.22), minimum: sidebarMinimum, invert: true,
85
88
  maximum: () => Math.max(320, upperShell.clientWidth - 680),
86
89
  apply: (size) => { upperShell.style.setProperty("--workspace-width", `${size}px`); },
87
90
  remember: "hypit-studio.v3.workspace-width",
@@ -191,9 +194,22 @@ function textValue(value: CanonicalValue): string {
191
194
  return typeof value === "string" ? value : value === null ? "" : String(value);
192
195
  }
193
196
 
197
+ function restoreParameterControls(entityId: string): void {
198
+ const current = store.current();
199
+ if (current?.selection.kind === "clip" && current.selection.clipId === entityId) {
200
+ renderInspector(current.snapshot, entityId);
201
+ }
202
+ }
203
+
194
204
  function commitControl(entityId: string, parameter: Clip["inspector"][number], replacement: CanonicalValue): void {
195
- if (sameValue(replacement, parameter.value)) return;
196
- void writeParameter(entityId, parameter, replacement);
205
+ try {
206
+ if (sameValue(parameterAuthorValue(parameter, replacement), parameter.value)) return;
207
+ void writeParameter(entityId, parameter, replacement);
208
+ } catch (error) {
209
+ restoreParameterControls(entityId);
210
+ status.textContent = "Invalid value"; status.className = "status error";
211
+ status.title = error instanceof Error ? error.message : String(error);
212
+ }
197
213
  }
198
214
 
199
215
  let openColorPicker: {
@@ -227,8 +243,11 @@ function selectControl(
227
243
  trigger.setAttribute("aria-expanded", "false");
228
244
  const selected = document.createElement("span");
229
245
  selected.className = "parameter-select-value";
230
- const current = textValue(parameter.value);
231
- selected.textContent = current;
246
+ const choices = (parameter.options ?? []).map(parameterOption);
247
+ if (choices.some(option => option.description || option.preview)) control.classList.add("rich-options");
248
+ const current = choices.find(option => option.value === parameter.value)
249
+ ?? choices.find(option => String(option.value) === String(parameter.value));
250
+ selected.textContent = current?.label ?? textValue(parameter.value);
232
251
  const chevron = document.createElement("span");
233
252
  chevron.className = "parameter-select-chevron";
234
253
  chevron.innerHTML = icon("chevron");
@@ -242,22 +261,32 @@ function selectControl(
242
261
  menu.hidden = true;
243
262
  trigger.setAttribute("aria-controls", menu.id);
244
263
 
245
- const options = (parameter.options ?? []).map((option) => {
264
+ const options = choices.map((option) => {
246
265
  const item = document.createElement("button");
247
266
  item.type = "button";
248
267
  item.className = `parameter-select-option${option === current ? " active" : ""}`;
249
- item.textContent = option;
268
+ const label = document.createElement("span"); label.textContent = option.label;
269
+ item.append(label);
270
+ if (option.description) { const description = document.createElement("small"); description.textContent = option.description; item.append(description); }
271
+ if (option.preview?.kind === "color") {
272
+ const swatch = document.createElement("span"); swatch.className = "parameter-option-swatch";
273
+ swatch.style.backgroundColor = option.preview.color; item.prepend(swatch);
274
+ } else if (option.preview?.kind === "font") {
275
+ const sample = document.createElement("span"); sample.className = "parameter-option-font";
276
+ sample.style.fontFamily = option.preview.family; sample.textContent = option.preview.sample ?? option.label;
277
+ sample.setAttribute("aria-hidden", "true"); item.append(sample);
278
+ }
250
279
  item.setAttribute("role", "option");
251
280
  item.setAttribute("aria-selected", String(option === current));
252
281
  item.addEventListener("click", () => {
253
- selected.textContent = option;
282
+ selected.textContent = option.label;
254
283
  for (const sibling of options) {
255
284
  sibling.classList.toggle("active", sibling === item);
256
285
  sibling.setAttribute("aria-selected", String(sibling === item));
257
286
  }
258
287
  close(false);
259
288
  trigger.focus();
260
- commitControl(entityId, parameter, option);
289
+ commitControl(entityId, parameter, option.value);
261
290
  });
262
291
  return item;
263
292
  });
@@ -358,7 +387,7 @@ function colorValueControl(
358
387
  value.value = initial;
359
388
  value.setAttribute("aria-label", label);
360
389
  value.spellcheck = false;
361
- const alpha = exact && initial.length === 9 ? initial.slice(7) : "";
390
+ let alpha = exact && initial.length === 9 ? initial.slice(7) : "";
362
391
  const replacement = (): string => `${picker.value.toUpperCase()}${alpha}`;
363
392
  const close = (): void => {
364
393
  if (openColorPicker?.root === colorControl) openColorPicker = undefined;
@@ -389,7 +418,12 @@ function colorValueControl(
389
418
  });
390
419
  value.addEventListener(draft ? "input" : "change", () => {
391
420
  const next = value.value.trim();
392
- swatch.style.background = /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/iu.test(next) ? next : "transparent";
421
+ const valid = /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/iu.test(next);
422
+ swatch.style.background = valid ? next : "transparent";
423
+ if (valid) {
424
+ picker.value = next.slice(0, 7);
425
+ alpha = next.length === 9 ? next.slice(7) : "";
426
+ }
393
427
  change(next);
394
428
  });
395
429
  colorControl.append(swatch, picker);
@@ -611,40 +645,71 @@ function parameterControl(entityId: string, parameter: Clip["inspector"][number]
611
645
  name.title = parameter.summary ?? parameter.label;
612
646
  const right = document.createElement("span");
613
647
  right.className = "parameter-right parameter-control";
648
+ let unitLabel = parameter.unit;
614
649
  if (parameter.control === "select") {
615
650
  right.append(selectControl(entityId, parameter));
616
651
  } else if (parameter.control === "color") {
617
652
  right.append(colorValueControl(parameter.label, textValue(parameter.value), (next) => {
618
653
  commitControl(entityId, parameter, next);
619
654
  }));
655
+ if (parameter.swatches?.length) {
656
+ const palette = document.createElement("span"); palette.className = "parameter-swatches";
657
+ for (const color of parameter.swatches) {
658
+ const swatch = document.createElement("button"); swatch.type = "button";
659
+ swatch.style.backgroundColor = color; swatch.title = color; swatch.setAttribute("aria-label", `Use ${color}`);
660
+ swatch.addEventListener("click", () => commitControl(entityId, parameter, color)); palette.append(swatch);
661
+ }
662
+ right.append(palette);
663
+ }
620
664
  } else if (parameter.control === "list" || parameter.control === "record") {
621
665
  right.append(structuredControl(entityId, parameter));
622
666
  } else {
667
+ if (parameter.multiline && parameter.control === "text") {
668
+ const value = document.createElement("textarea"); value.className = "parameter-value parameter-multiline";
669
+ value.value = textValue(parameter.value); value.setAttribute("aria-label", parameter.label);
670
+ value.addEventListener("change", () => commitControl(entityId, parameter, value.value)); right.append(value);
671
+ row.append(name, right); return row;
672
+ }
623
673
  const value = document.createElement("input");
624
674
  value.type = parameter.control === "boolean" ? "checkbox" : "text";
625
- if (parameter.control === "number") value.inputMode = "decimal";
626
675
  value.className = "parameter-value";
627
676
  value.setAttribute("aria-label", parameter.label);
628
677
  value.spellcheck = false;
629
678
  if (value.type === "checkbox") value.checked = parameter.value === true || parameter.value === "true";
630
- else value.value = textValue(parameter.value);
679
+ else if (parameter.control === "number") {
680
+ try {
681
+ const numeric = parameterNumber(parameter.value, parameter.number);
682
+ value.type = "number"; value.inputMode = "decimal"; value.value = String(numeric.value);
683
+ const schema = parameter.schema?.kind === "number" ? parameter.schema : undefined;
684
+ const scale = parameter.number?.scale ?? 1;
685
+ const minimum = parameter.number?.minimum ?? (schema?.minimum === undefined ? undefined : schema.minimum * scale);
686
+ const maximum = parameter.number?.maximum ?? (schema?.maximum === undefined ? undefined : schema.maximum * scale);
687
+ value.step = String(parameter.number?.step ?? (schema?.integer ? scale : "any"));
688
+ if (minimum !== undefined) value.min = String(minimum);
689
+ if (maximum !== undefined) value.max = String(maximum);
690
+ value.required = true;
691
+ unitLabel = numeric.suffix || parameter.unit;
692
+ } catch (error) {
693
+ value.value = textValue(parameter.value); value.readOnly = true;
694
+ value.title = error instanceof Error ? error.message : String(error);
695
+ }
696
+ } else value.value = textValue(parameter.value);
631
697
  value.dataset.parameterId = parameter.id;
632
- value.title = parameter.summary ?? parameter.label;
698
+ if (!value.title) value.title = parameter.summary ?? parameter.label;
633
699
  value.addEventListener("change", () => {
634
700
  if (value.type === "checkbox") {
635
701
  commitControl(entityId, parameter, value.checked);
636
702
  } else if (parameter.control === "number") {
637
- const number = Number(value.value);
638
- if (Number.isFinite(number)) commitControl(entityId, parameter, number);
703
+ if (value.reportValidity()) commitControl(entityId, parameter, value.valueAsNumber);
639
704
  } else {
640
705
  commitControl(entityId, parameter, value.value);
641
706
  }
642
707
  });
643
708
  right.append(value);
644
709
  }
645
- if (parameter.unit !== undefined) {
710
+ if (unitLabel !== undefined) {
646
711
  const unit = document.createElement("small");
647
- unit.textContent = parameter.unit;
712
+ unit.textContent = unitLabel;
648
713
  right.append(unit);
649
714
  }
650
715
  row.append(name, right);
@@ -671,6 +736,7 @@ async function writeParameter(entityId: string, parameter: Clip["inspector"][num
671
736
  const state = store.current();
672
737
  if (state === undefined) return;
673
738
  parameterWriteState = "Saving";
739
+ status.title = "";
674
740
  status.textContent = parameterWriteState;
675
741
  status.className = "status saving";
676
742
  try {
@@ -685,9 +751,13 @@ async function writeParameter(entityId: string, parameter: Clip["inspector"][num
685
751
  status.textContent = parameterWriteState;
686
752
  status.className = "status saved";
687
753
  } catch (error) {
754
+ // Validation and stale-revision rejections do not publish a new snapshot.
755
+ // Restore the accepted value just as a rejected compilation does.
756
+ restoreParameterControls(entityId);
688
757
  parameterWriteState = "Failed";
689
758
  status.textContent = error instanceof Error ? "Save failed" : parameterWriteState;
690
759
  status.className = "status error";
760
+ status.title = error instanceof Error ? error.message : String(error);
691
761
  }
692
762
  }
693
763
 
@@ -784,24 +854,76 @@ function renderSemanticInspector(snapshot: StudioSnapshot, segmentId: string): v
784
854
  inspector.replaceChildren(empty);
785
855
  }
786
856
 
857
+ /** Exact identity choice for coincident anchors, through the same Companion-declared handles. */
858
+ function semanticAnchorInspector(snapshot: StudioSnapshot, kind: "selection" | "moment", id: string): HTMLElement {
859
+ const semantic = snapshot.semantic;
860
+ const consumers = snapshot.tracks.flatMap((track) => track.clips).flatMap((clip) => clip.editHandles
861
+ .filter((handle) => handle.enabled && handle.semantic?.kind === kind && handle.semantic.id === id)
862
+ .map((handle) => ({ clip, handle })));
863
+ const current = kind === "selection" ? semantic?.selections.find((item) => item.id === id)
864
+ : semantic?.moments.find((item) => item.id === id);
865
+ if (!semantic || !current) return group("Timing", []);
866
+ const endpoints = "anchorId" in current ? [["Moment", current.anchorId]]
867
+ : [["Start", current.startAnchorId], ["End", current.endAnchorId]];
868
+ return group("Semantic anchors", endpoints.map(([label, anchorId]) => {
869
+ const anchor = semantic.anchors.find((item) => item.id === anchorId)!;
870
+ const describe = (item: typeof anchor) => {
871
+ const word = semantic.tokens.find((token) => token.id === item.tokenId)?.text;
872
+ return `${item.kind.replaceAll("-", " ")}${word ? ` · ${word}` : ""}${item.segmentId ? ` · ${item.segmentId}` : ""}`;
873
+ };
874
+ const candidates = semantic.anchors.filter((item) => item.frame === anchor.frame).flatMap((item) => {
875
+ const target: SemanticTarget = "anchorId" in current ? { kind: "moment", anchorId: item.id }
876
+ : { kind: "selection", startAnchorId: label === "Start" ? item.id : current.startAnchorId,
877
+ endAnchorId: label === "End" ? item.id : current.endAnchorId };
878
+ const owner = consumers.find(({ handle }) => semanticGestureSpan(semantic.anchors, handle, target) !== undefined);
879
+ return owner ? [{ item, target, ...owner }] : [];
880
+ });
881
+ if (candidates.length < 2) return property(label!, describe(anchor));
882
+ const row = document.createElement("label");
883
+ row.className = "property";
884
+ const name = document.createElement("span");
885
+ name.textContent = label!;
886
+ const control = document.createElement("select");
887
+ control.className = "parameter-value";
888
+ control.setAttribute("aria-label", `${label} semantic anchor`);
889
+ for (const { item } of candidates) {
890
+ const option = document.createElement("option"); option.value = item.id; option.textContent = describe(item);
891
+ control.append(option);
892
+ }
893
+ control.value = anchorId!;
894
+ control.addEventListener("change", () => {
895
+ const choice = candidates.find(({ item }) => item.id === control.value)!;
896
+ const span = semanticGestureSpan(semantic.anchors, choice.handle, choice.target)!;
897
+ const temporal = choice.handle.temporal!;
898
+ control.disabled = true;
899
+ status.textContent = "Saving"; status.className = "status saving";
900
+ void applyStudioMutation({ type: "timeline.adjust", revision: snapshot.revision,
901
+ entityId: choice.clip.id, gesture: choice.handle.gesture,
902
+ target: temporal.kind === "instant" ? { kind: "instant", frame: span.startFrame, semantic: choice.target }
903
+ : { kind: "window", ...span, semantic: choice.target },
904
+ }).then(() => { status.textContent = "Saved"; status.className = "status saved"; })
905
+ .catch((error: unknown) => {
906
+ control.value = anchorId!; status.textContent = "Save failed"; status.className = "status error";
907
+ status.title = error instanceof Error ? error.message : String(error);
908
+ }).finally(() => { control.disabled = false; });
909
+ });
910
+ row.append(name, control);
911
+ return row;
912
+ }));
913
+ }
914
+
787
915
  function renderSemanticSelectionInspector(snapshot: StudioSnapshot, selectionId: string): void {
788
916
  const selection = snapshot.semantic?.selections.find((item) => item.id === selectionId);
789
917
  if (selection === undefined) { inspector.replaceChildren(); return; }
790
918
  defaultWorkspaceHeading();
791
- const empty = document.createElement("div");
792
- empty.className = "inspector-empty";
793
- empty.textContent = "Adjust on the timeline";
794
- inspector.replaceChildren(empty);
919
+ inspector.replaceChildren(semanticAnchorInspector(snapshot, "selection", selectionId));
795
920
  }
796
921
 
797
922
  function renderSemanticMomentInspector(snapshot: StudioSnapshot, momentId: string): void {
798
923
  const moment = snapshot.semantic?.moments.find((item) => item.id === momentId);
799
924
  if (moment === undefined) { inspector.replaceChildren(); return; }
800
925
  defaultWorkspaceHeading();
801
- const empty = document.createElement("div");
802
- empty.className = "inspector-empty";
803
- empty.textContent = "Adjust on the timeline";
804
- inspector.replaceChildren(empty);
926
+ inspector.replaceChildren(semanticAnchorInspector(snapshot, "moment", momentId));
805
927
  }
806
928
 
807
929
  // The word being spoken at the playhead, which is the point of carrying token
@@ -966,6 +1088,7 @@ window.addEventListener("keydown", (event) => {
966
1088
  function applySnapshot(snapshot: StudioSnapshot): void {
967
1089
  failureView.textContent = "";
968
1090
  status.className = "status";
1091
+ status.title = "";
969
1092
  status.textContent = "";
970
1093
  renderMeta(snapshot);
971
1094
  library.show(snapshot);
@@ -993,7 +1116,6 @@ const response = await fetch("/__studio/session");
993
1116
  const initial = await response.json() as StudioSnapshot | StudioFailure;
994
1117
  if (response.ok && "tracks" in initial) applySnapshot(initial);
995
1118
  else applyFailure(initial as StudioFailure);
996
- void library.refresh();
997
1119
 
998
1120
  type Hot = { on(event: string, listener: (value: unknown) => void): void };
999
1121
  const hot = (import.meta as ImportMeta & { hot?: Hot }).hot;
@@ -129,7 +129,7 @@ async function audioWaveform(url: string): Promise<string | undefined> {
129
129
  }
130
130
  }
131
131
 
132
- function audioPreview(url: string): Promise<string | undefined> {
132
+ export function audioPreview(url: string): Promise<string | undefined> {
133
133
  const held = generatedAudio.get(url);
134
134
  if (held !== undefined) return held;
135
135
  const pending = audioWaveform(url).catch(() => undefined).then((waveform) => {
@@ -14,7 +14,7 @@ export type Box = {
14
14
  readonly widthPx: number; readonly heightPx: number;
15
15
  };
16
16
 
17
- export type Measure = (clipId: string) => Box | undefined;
17
+ export type Measure = (clipId: string) => (Box & { readonly stackOrder: number }) | undefined;
18
18
 
19
19
  export type Overlay = {
20
20
  readonly element: SVGSVGElement;
@@ -62,21 +62,24 @@ export function createOverlay(store: Store, measure: Measure): Overlay {
62
62
  element.setAttribute("class", "stage-overlay");
63
63
  element.setAttribute("preserveAspectRatio", "none");
64
64
 
65
- let live: readonly Clip[] = [];
65
+ let entities: readonly Clip[] = [];
66
66
  let canvas = { width: 1, height: 1 };
67
- let last: { snapshot: StudioSnapshot; selected: Clip | undefined; frame: number } | undefined;
67
+ let last: { snapshot: StudioSnapshot; selected: Clip | undefined } | undefined;
68
68
 
69
69
  /**
70
70
  * The box is measured off the picture rather than restated from the Source:
71
71
  * motion moves an element across its span, so only what was drawn knows where
72
72
  * it ended up. A Present that has not mounted has no box to draw.
73
73
  */
74
- const drawnBox = (clip: Clip): Box | undefined => {
75
- const ids = clip.presentId === undefined ? clip.renderIds : [clip.presentId];
76
- const boxes = ids.flatMap((id) => {
74
+ const drawnParts = (clip: Clip) => {
75
+ const ids = clip.renderIds.length > 0 ? clip.renderIds : clip.presentId === undefined ? [] : [clip.presentId];
76
+ return ids.flatMap((id) => {
77
77
  const found = measure(id);
78
78
  return found === undefined ? [] : [found];
79
79
  });
80
+ };
81
+ const drawnBox = (clip: Clip): Box | undefined => {
82
+ const boxes = drawnParts(clip);
80
83
  if (boxes.length === 0) return undefined;
81
84
  const left = Math.min(...boxes.map((box) => box.xPx));
82
85
  const top = Math.min(...boxes.map((box) => box.yPx));
@@ -87,23 +90,23 @@ export function createOverlay(store: Store, measure: Measure): Overlay {
87
90
 
88
91
  const draw = (): void => {
89
92
  if (last === undefined) return;
90
- const { snapshot, selected, frame } = last;
93
+ const { snapshot, selected } = last;
91
94
  canvas = { width: snapshot.space.canvasWidth, height: snapshot.space.canvasHeight };
92
95
  element.setAttribute("viewBox", `0 0 ${canvas.width} ${canvas.height}`);
93
96
  element.replaceChildren();
94
- // Only clips actually on screen can be picked out of the picture.
95
- live = store.clipsAt(frame);
97
+ // An editing interval can end before its visual representation disappears.
98
+ // The renderer decides which associated parts are visible at this frame.
99
+ entities = snapshot.tracks.flatMap((track) => track.clips);
96
100
 
97
101
  if (selected === undefined) return;
98
102
  const tones = intentTones(snapshot);
99
103
  const tone = tones.get(selected.authoredId);
100
- const onScreen = frame >= selected.startFrame && frame < selected.endFrameExclusive;
101
104
  const box = drawnBox(selected);
102
105
  // A Present that is not on screen at this frame was never drawn, so there
103
106
  // is nothing to put a box around.
104
107
  if (box === undefined) return;
105
108
  const group = document.createElementNS(SVG_NS, "g");
106
- group.setAttribute("class", `box${onScreen ? "" : " box-elsewhere"}${tone === undefined ? "" : ` tone-${tone}`}`);
109
+ group.setAttribute("class", `box${tone === undefined ? "" : ` tone-${tone}`}`);
107
110
 
108
111
  group.append(rect(box, "box-content"));
109
112
 
@@ -111,16 +114,15 @@ export function createOverlay(store: Store, measure: Measure): Overlay {
111
114
  label.setAttribute("x", String(box.xPx + 10));
112
115
  label.setAttribute("y", String(Math.max(28, box.yPx - 12)));
113
116
  label.setAttribute("class", "box-label");
114
- label.textContent = onScreen ? selected.display.title : `${selected.display.title} (not at this frame)`;
117
+ label.textContent = selected.display.title;
115
118
  group.append(label);
116
119
  element.append(group);
117
120
  };
118
121
 
119
- store.subscribe(({ snapshot, selection, playhead }) => {
122
+ store.subscribe(({ snapshot, selection }) => {
120
123
  last = {
121
124
  snapshot,
122
125
  selected: selection.kind === "clip" ? store.clip(selection.clipId) : undefined,
123
- frame: playhead.frame,
124
126
  };
125
127
  draw();
126
128
  });
@@ -133,12 +135,11 @@ export function createOverlay(store: Store, measure: Measure): Overlay {
133
135
  if (box.width === 0 || box.height === 0) return undefined;
134
136
  const x = (clientX - box.left) / box.width * canvas.width;
135
137
  const y = (clientY - box.top) / box.height * canvas.height;
136
- // clipsAt already returns topmost first, so the first hit is the one an
137
- // author would say they clicked on.
138
- return live.find((clip) => {
139
- const box = drawnBox(clip);
140
- return box !== undefined && inside(box, x, y);
141
- });
138
+ // Hit individual parts, not the empty space inside a group's union box.
139
+ return entities.flatMap((clip) => drawnParts(clip)
140
+ .filter((part) => inside(part, x, y))
141
+ .map((part) => ({ clip, order: part.stackOrder })))
142
+ .sort((left, right) => right.order - left.order)[0]?.clip;
142
143
  },
143
144
  };
144
145
  }
@@ -0,0 +1,31 @@
1
+ import { icon } from "./icons.js";
2
+
3
+ /** Shared chrome; each view owns its navigation choices, toolbar and content. */
4
+ export function createSidebarPanel(mark: string, navigationLabel: string) {
5
+ const element = document.createElement("div");
6
+ element.className = "sidebar-panel";
7
+ element.innerHTML = `
8
+ <div class="sidebar-panel-heading"><span class="sidebar-panel-mark" aria-hidden="true">${icon(mark)}</span></div>
9
+ <div class="sidebar-panel-toolbar"></div>
10
+ <nav class="sidebar-panel-nav"></nav>
11
+ <div class="sidebar-panel-content"></div>`;
12
+ const navigation = element.querySelector<HTMLElement>("nav")!;
13
+ navigation.setAttribute("aria-label", navigationLabel);
14
+ return {
15
+ element, navigation,
16
+ toolbar: element.querySelector<HTMLElement>(".sidebar-panel-toolbar")!,
17
+ content: element.querySelector<HTMLElement>(".sidebar-panel-content")!,
18
+ };
19
+ }
20
+
21
+ export function sidebarItem(input: { label: string; icon: string; selected: boolean; select(): void }) {
22
+ const button = document.createElement("button");
23
+ button.type = "button";
24
+ button.className = `sidebar-panel-item${input.selected ? " active" : ""}`;
25
+ button.setAttribute("aria-label", input.label);
26
+ button.setAttribute("aria-pressed", String(input.selected));
27
+ button.title = input.label;
28
+ button.innerHTML = icon(input.icon);
29
+ button.addEventListener("click", input.select);
30
+ return button;
31
+ }