@hypit/hypit 0.1.3 → 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 (92) 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/media-track-studio/src/index.ts +7 -2
  41. package/packages/provider-whisperx-local/README.md +57 -0
  42. package/packages/ranking/src/render.ts +11 -9
  43. package/packages/ranking-studio/README.md +11 -0
  44. package/packages/ranking-studio/package.json +8 -3
  45. package/packages/ranking-studio/src/index.ts +11 -4
  46. package/packages/runtime/src/catalog.ts +2 -0
  47. package/packages/runtime-host-node/src/index.ts +1 -0
  48. package/packages/runtime-local/README.md +19 -0
  49. package/packages/runtime-local/src/programs.ts +39 -24
  50. package/packages/runtime-local/src/runtime.ts +2 -0
  51. package/packages/script/README.md +22 -0
  52. package/packages/script/src/edit.ts +123 -73
  53. package/packages/script/src/lexical.ts +40 -6
  54. package/packages/script/src/parser.ts +36 -8
  55. package/packages/script/src/types.ts +4 -0
  56. package/packages/seedance-kits/README.md +3 -1
  57. package/packages/seedance-kits/kits/speaker-v1.svs +5 -5
  58. package/packages/speech-alignment/src/align.ts +45 -21
  59. package/packages/studio/INSPECTOR.md +104 -0
  60. package/packages/studio/README.md +65 -4
  61. package/packages/studio/src/build-library.ts +99 -84
  62. package/packages/studio/src/library-media.ts +19 -0
  63. package/packages/studio/src/parameter-values.ts +41 -1
  64. package/packages/studio/src/parameters.ts +20 -21
  65. package/packages/studio/src/server.ts +58 -30
  66. package/packages/studio/src/shared.ts +20 -6
  67. package/packages/studio/src/studio-registry.ts +22 -0
  68. package/packages/studio/src/style.css +135 -144
  69. package/packages/studio/src/temporal-edit.ts +131 -0
  70. package/packages/studio/src/ui/artifact-name.ts +80 -0
  71. package/packages/studio/src/ui/artifact-preview.ts +99 -0
  72. package/packages/studio/src/ui/icons.ts +5 -1
  73. package/packages/studio/src/ui/library.ts +373 -192
  74. package/packages/studio/src/ui/main.ts +153 -31
  75. package/packages/studio/src/ui/material-preview.ts +1 -1
  76. package/packages/studio/src/ui/overlay.ts +21 -20
  77. package/packages/studio/src/ui/sidebar-panel.ts +31 -0
  78. package/packages/studio/src/ui/stage.ts +124 -8
  79. package/packages/studio/src/ui/timeline.ts +16 -179
  80. package/packages/studio/start.ts +15 -3
  81. package/packages/studio-adapter/README.md +21 -0
  82. package/packages/studio-adapter/src/index.ts +26 -1
  83. package/packages/temporal-markup/EDITING.md +183 -0
  84. package/packages/temporal-markup/README.md +4 -0
  85. package/packages/typography-track-studio/package.json +8 -3
  86. package/packages/typography-track-studio/src/index.ts +5 -1
  87. package/packages/video-cli/README.md +12 -3
  88. package/packages/video-cli/src/creation.ts +11 -5
  89. package/packages/whisperx/README.md +7 -0
  90. package/packages/workspace/src/index.ts +2 -0
  91. package/packages/workspace-fs-node/README.md +4 -1
  92. package/packages/workspace-fs-node/src/workspace.ts +3 -1
@@ -6,7 +6,11 @@ import type {
6
6
  StudioTaskView,
7
7
  } from "../shared.js";
8
8
  import type { CodePane } from "./code.js";
9
+ import { createArtifactName } from "./artifact-name.js";
10
+ import { audioPreview } from "./material-preview.js";
11
+ import { createSidebarPanel, sidebarItem } from "./sidebar-panel.js";
9
12
  import { icon } from "./icons.js";
13
+ import { mergeStudioArtifacts } from "../library-media.js";
10
14
 
11
15
  type LibrarySection = "source" | "tasks" | "artifacts";
12
16
 
@@ -14,6 +18,7 @@ export type LibraryPane = {
14
18
  readonly element: HTMLElement;
15
19
  show(snapshot: StudioSnapshot): void;
16
20
  refresh(): Promise<void>;
21
+ selectArtifact(id: string | undefined): void;
17
22
  };
18
23
 
19
24
  function leaf(path: string): string {
@@ -26,10 +31,6 @@ function stem(path: string): string {
26
31
  return dot > 0 ? name.slice(0, dot) : name;
27
32
  }
28
33
 
29
- function compactId(value: string): string {
30
- return value.length <= 18 ? value : `${value.slice(0, 9)}…${value.slice(-6)}`;
31
- }
32
-
33
34
  function formatBytes(value: number): string {
34
35
  if (value < 1_000) return `${value} B`;
35
36
  if (value < 1_000_000) return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)} KB`;
@@ -55,7 +56,8 @@ function emptyState(iconName: string, title: string, detail: string): HTMLElemen
55
56
 
56
57
  function taskProgress(task: StudioTaskView): string | undefined {
57
58
  const progress = task.operations.find((operation) => operation.status === "pending" && operation.phase !== undefined);
58
- if (progress === undefined) return undefined;
59
+ if (progress === undefined) return task.requests === undefined ? undefined
60
+ : `${task.requests.completed}/${task.requests.total} steps complete`;
59
61
  if (progress.completed === undefined) return progress.phase;
60
62
  const amount = progress.total === undefined
61
63
  ? String(progress.completed)
@@ -63,33 +65,60 @@ function taskProgress(task: StudioTaskView): string | undefined {
63
65
  return `${progress.phase} · ${amount}${progress.unit === undefined ? "" : ` ${progress.unit}`}`;
64
66
  }
65
67
 
66
- function taskCard(task: StudioTaskView): HTMLElement {
68
+ function taskCard(task: StudioTaskView, selected: boolean, select: () => void, showMedia: () => void): HTMLElement {
67
69
  const node = document.createElement("article");
68
- node.className = `task-card task-${task.status}`;
69
- const title = task.title ?? (task.targets.length === 0 ? leaf(task.run ?? task.source) : task.targets.join(", "));
70
- const progress = taskProgress(task);
70
+ node.className = `task-card task-${task.status}${selected ? " selected" : ""}`;
71
+ node.tabIndex = 0;
72
+ node.setAttribute("aria-label", task.title ?? stem(task.run ?? task.source));
73
+ node.addEventListener("click", select);
74
+ node.addEventListener("keydown", (event) => {
75
+ if (event.target === node && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); select(); }
76
+ });
77
+ node.dataset.taskId = task.id;
78
+ const source = task.run ?? task.source;
79
+ const title = task.title ?? stem(source);
80
+ const statuses: Record<StudioTaskView["status"], string> = {
81
+ queued: "Queued", running: "Running", waiting: "Waiting", complete: "Completed",
82
+ failed: "Failed", cancelled: "Cancelled", "saving-result": "Saving", attention: "Needs attention",
83
+ };
71
84
  node.innerHTML = `
72
- <div class="task-state"><span></span></div>
85
+ <div class="task-state" aria-hidden="true">${icon(task.ongoing ? "when" : task.status === "complete" ? "check" : task.status === "failed" ? "alert" : "tasks")}</div>
73
86
  <div class="task-copy">
74
- <div class="task-title"><strong></strong><span class="task-status"></span></div>
75
- <p class="task-source"></p>
76
- <div class="task-facts"><span data-time></span><span data-records></span><span data-command></span></div>
87
+ <div class="task-title"><button type="button" class="task-name"></button></div>
88
+ <div class="task-facts"><span class="task-status"></span><time></time></div>
77
89
  <div class="task-progress" data-progress></div>
78
- </div>`;
79
- node.querySelector("strong")!.textContent = title;
80
- node.querySelector<HTMLElement>(".task-status")!.textContent = task.status;
81
- const source = task.run ?? task.source;
82
- node.querySelector<HTMLElement>(".task-source")!.textContent = source;
83
- node.querySelector<HTMLElement>(".task-source")!.title = source;
84
- node.querySelector<HTMLElement>("[data-time]")!.textContent = date.format(task.createdAt);
85
- node.querySelector<HTMLElement>("[data-records]")!.textContent = `${task.acceptedRecords} records`;
86
- node.querySelector<HTMLElement>("[data-command]")!.textContent = task.outstandingCommands === 0
87
- ? compactId(task.id)
88
- : `${task.outstandingCommands} remaining`;
90
+ <div class="task-detail" data-detail></div>
91
+ </div>
92
+ <button type="button" class="task-open" aria-label="View task media" title="View media">${icon("arrowRight")}</button>`;
93
+ const name = node.querySelector<HTMLButtonElement>(".task-name")!;
94
+ name.textContent = title;
95
+ name.addEventListener("click", (event) => { event.stopPropagation(); select(); });
96
+ node.querySelector<HTMLElement>(".task-status")!.textContent = statuses[task.status];
97
+ const time = node.querySelector("time")!;
98
+ time.dateTime = new Date(task.createdAt).toISOString();
99
+ time.textContent = date.format(task.createdAt);
100
+ const progress = task.ongoing ? taskProgress(task) : undefined;
89
101
  const progressNode = node.querySelector<HTMLElement>("[data-progress]")!;
90
102
  if (progress === undefined) progressNode.remove();
91
- else progressNode.textContent = progress;
92
- node.title = [title, task.id, source, task.note].filter((item) => item !== undefined).join("\n");
103
+ else { progressNode.textContent = progress; progressNode.title = progress; }
104
+ const detailNode = node.querySelector<HTMLElement>("[data-detail]")!;
105
+ if (task.detail === undefined) detailNode.remove();
106
+ else {
107
+ const message = document.createElement("div");
108
+ message.className = "task-error-message";
109
+ message.textContent = task.detail;
110
+ detailNode.append(message);
111
+ const copy = document.createElement("button");
112
+ copy.type = "button"; copy.className = "task-copy-error"; copy.textContent = "Copy error";
113
+ copy.addEventListener("click", (event) => {
114
+ event.stopPropagation();
115
+ void navigator.clipboard.writeText(task.detail!).then(() => { copy.textContent = "Copied"; }, () => { copy.textContent = "Select the text to copy"; });
116
+ });
117
+ detailNode.append(copy);
118
+ detailNode.addEventListener("click", (event) => { event.stopPropagation(); if (!node.classList.contains("selected")) select(); });
119
+ }
120
+ node.querySelector<HTMLButtonElement>(".task-open")!.addEventListener("click", (event) => { event.stopPropagation(); showMedia(); });
121
+ name.title = [title, source, task.id, task.note].filter((item) => item !== undefined).join("\n");
93
122
  return node;
94
123
  }
95
124
 
@@ -100,63 +129,115 @@ function artifactKind(mediaType: string): { readonly label: string; readonly ico
100
129
  return { label: "Data", icon: "code" };
101
130
  }
102
131
 
103
- function artifactCard(artifact: StudioArtifactView): HTMLElement {
132
+ function sizeThumbnail(media: HTMLElement, width: number, height: number): void {
133
+ if (width <= 0 || height <= 0) return;
134
+ const longest = Math.max(width, height);
135
+ media.style.width = `${100 * width / longest}%`;
136
+ media.style.height = `${100 * height / longest}%`;
137
+ }
138
+
139
+ function artifactCard(artifact: StudioArtifactView, open: (artifact: StudioArtifactView) => void, renamed: (artifact: StudioArtifactView) => void): HTMLElement {
104
140
  const kind = artifactKind(artifact.mediaType);
105
- const link = document.createElement("a");
141
+ const link = document.createElement("div");
142
+ link.tabIndex = 0;
106
143
  link.className = `artifact-card artifact-${kind.label.toLowerCase()}${artifact.highlighted ? " artifact-highlighted" : ""}`;
107
144
  const query = new URLSearchParams({
108
145
  build: artifact.build,
109
146
  output: artifact.output,
110
- path: artifact.valuePath,
111
147
  });
112
- link.href = `/__studio/artifact?${query.toString()}`;
113
- link.target = "_blank";
114
- link.rel = "noreferrer";
115
- const title = artifact.valuePath === "$" ? artifact.output : `${artifact.output} ${artifact.valuePath}`;
148
+ const href = `/__studio/artifact?${query.toString()}`;
149
+
150
+ link.dataset.artifactId = artifact.id;
151
+ link.dataset.build = artifact.build;
152
+ link.dataset.output = artifact.output;
153
+ link.dataset.displayName = artifact.displayName ?? "";
154
+ link.dataset.nameEditable = String(artifact.nameEditable === true);
155
+ const title = artifact.displayName ?? artifact.output;
116
156
  link.innerHTML = `
117
157
  <div class="artifact-preview">
118
158
  <span class="artifact-glyph">${icon(kind.icon)}</span>
119
159
  <span class="artifact-type"></span>
120
- ${artifact.highlighted ? '<span class="artifact-highlight">Highlighted</span>' : ""}
121
- </div>
122
- <div class="artifact-copy">
123
- <strong></strong>
124
- <span class="artifact-meta"></span>
125
160
  </div>`;
126
161
  link.querySelector<HTMLElement>(".artifact-type")!.textContent = kind.label;
127
- link.querySelector("strong")!.textContent = title;
128
- link.querySelector<HTMLElement>(".artifact-meta")!.textContent = [
129
- artifact.buildTitle,
130
- formatBytes(artifact.size),
131
- date.format(artifact.createdAt),
132
- ].filter((item) => item !== undefined).join(" · ");
162
+ const preview = link.querySelector<HTMLElement>(".artifact-preview")!;
163
+ preview.setAttribute("role", "button");
164
+ preview.setAttribute("aria-label", `Preview ${title}`);
165
+ preview.addEventListener("click", () => open(artifact));
166
+ const name = createArtifactName({
167
+ name: title,
168
+ editable: artifact.nameEditable === true,
169
+ select: () => { if (!link.classList.contains("selected")) open(artifact); },
170
+ async save(displayName) {
171
+ const response = await fetch("/__studio/artifact-name", {
172
+ method: "PUT", headers: { "Content-Type": "application/json" },
173
+ body: JSON.stringify({ build: artifact.build, output: artifact.output, displayName }),
174
+ });
175
+ const result = await response.json() as { displayName?: string; error?: string };
176
+ if (!response.ok || typeof result.displayName !== "string") throw new Error(result.error ?? "Could not save the name");
177
+ artifact = { ...artifact, displayName: result.displayName };
178
+ link.dataset.displayName = result.displayName;
179
+ renamed(artifact);
180
+ preview.setAttribute("aria-label", `Preview ${result.displayName}`);
181
+ preview.title = [result.displayName, ...preview.title.split("\n").slice(1)].join("\n");
182
+ return result.displayName;
183
+ },
184
+ });
185
+ link.append(name.element);
186
+ link.addEventListener("keydown", (event) => {
187
+ if (event.target instanceof HTMLTextAreaElement) return;
188
+ if (event.key === "F2") { event.preventDefault(); name.edit(); }
189
+ else if (event.key === "Enter" && event.target === link) { event.preventDefault(); open(artifact); }
190
+ });
133
191
  if (artifact.mediaType.startsWith("image/")) {
134
192
  const image = document.createElement("img");
135
- image.src = link.href;
193
+ image.addEventListener("load", () => sizeThumbnail(image, image.naturalWidth, image.naturalHeight));
194
+ image.addEventListener("error", () => { link.classList.add("preview-unavailable"); link.querySelector<HTMLElement>(".artifact-type")!.textContent = "Preview unavailable"; });
195
+ image.src = href;
136
196
  image.alt = "";
137
197
  image.loading = "lazy";
138
198
  link.querySelector<HTMLElement>(".artifact-preview")!.prepend(image);
199
+ } else if (artifact.mediaType.startsWith("video/")) {
200
+ const video = document.createElement("video");
201
+ video.preload = "none";
202
+ video.muted = true;
203
+ video.playsInline = true;
204
+ video.dataset.previewSource = href;
205
+ video.setAttribute("aria-hidden", "true");
206
+ const label = link.querySelector<HTMLElement>(".artifact-type")!;
207
+ label.textContent = "Video · Loading preview";
208
+ const ready = () => {
209
+ if (video.readyState < 2 || video.seeking) return;
210
+ video.classList.add("is-ready");
211
+ label.textContent = "Video";
212
+ };
213
+ video.addEventListener("loadedmetadata", () => {
214
+ sizeThumbnail(video, video.videoWidth, video.videoHeight);
215
+ video.currentTime = Number.isFinite(video.duration) ? Math.min(0.1, video.duration / 2) : 0.1;
216
+ }, { once: true });
217
+ video.addEventListener("loadeddata", ready);
218
+ video.addEventListener("seeked", ready);
219
+ video.addEventListener("error", () => { label.textContent = "Preview unavailable"; link.classList.add("preview-unavailable"); });
220
+ link.querySelector<HTMLElement>(".artifact-preview")!.prepend(video);
221
+ } else if (artifact.mediaType.startsWith("audio/")) {
222
+ const waveform = document.createElement("img");
223
+ waveform.className = "artifact-waveform";
224
+ waveform.alt = "";
225
+ waveform.dataset.audioSource = href;
226
+ link.querySelector<HTMLElement>(".artifact-preview")!.prepend(waveform);
139
227
  }
140
- link.title = [
228
+ preview.title = [
141
229
  title,
142
230
  artifact.mediaType,
143
- `${artifact.ownerBuild}/${artifact.ownerOutput}/${artifact.filePath}`,
144
- artifact.run ?? artifact.source,
231
+ formatBytes(artifact.size),
232
+ date.format(artifact.createdAt),
233
+ artifact.ownerBuild === undefined ? artifact.filePath : `${artifact.ownerBuild}/${artifact.filePath}`,
234
+ ...artifact.origins.map((origin) => `${origin.run ?? origin.source} · ${origin.build} · ${origin.output}`),
145
235
  artifact.buildNote,
146
236
  ].filter((item) => item !== undefined).join("\n");
147
237
  return link;
148
238
  }
149
239
 
150
- function olderButton(load: () => void): HTMLButtonElement {
151
- const button = document.createElement("button");
152
- button.type = "button";
153
- button.className = "library-older";
154
- button.textContent = "Load older";
155
- button.addEventListener("click", load);
156
- return button;
157
- }
158
-
159
- export function createLibraryPane(code: CodePane): LibraryPane {
240
+ export function createLibraryPane(code: CodePane, openArtifact: (artifact: StudioArtifactView) => void, renamedArtifact: (artifact: StudioArtifactView) => void): LibraryPane {
160
241
  const element = document.createElement("section");
161
242
  element.className = "library";
162
243
  element.innerHTML = `
@@ -172,46 +253,83 @@ export function createLibraryPane(code: CodePane): LibraryPane {
172
253
  </button>
173
254
  </div>
174
255
  <section class="library-view active" data-library-view="source">
175
- <div class="source-workspace" data-source-workspace>
176
- <div class="source-sidebar-heading">
177
- <span class="source-sidebar-mark" aria-hidden="true">${icon("sidebarCollapse")}</span>
178
- </div>
179
- <div class="source-code-heading" data-source-toolbar></div>
180
- <nav class="source-files" aria-label="Referenced source files" data-source-files></nav>
181
- <div class="source-code" data-source-code></div>
182
- </div>
183
- </section>
184
- <section class="library-view" data-library-view="tasks">
185
- <div class="library-toolbar">
186
- <div><strong>Build Results</strong><small data-task-count></small></div>
187
- <button type="button" class="library-refresh" data-library-refresh aria-label="Refresh tasks" title="Refresh">${icon("refresh")}</button>
188
- </div>
189
- <div class="library-context-row" data-runtime-context></div>
190
- <div class="task-list" data-task-list></div>
191
256
  </section>
257
+ <section class="library-view" data-library-view="tasks"></section>
192
258
  <section class="library-view" data-library-view="artifacts">
193
- <div class="library-toolbar">
194
- <div><strong>Accepted artifacts</strong><small data-artifact-count></small></div>
195
- <button type="button" class="library-refresh" data-library-refresh aria-label="Refresh artifacts" title="Refresh">${icon("refresh")}</button>
196
- </div>
197
- <div class="artifact-filters" role="tablist" aria-label="Artifact kinds" data-artifact-filters></div>
198
- <div class="artifact-grid" data-artifact-grid></div>
199
259
  </section>`;
200
260
 
201
- element.querySelector<HTMLElement>("[data-source-toolbar]")!.append(code.toolbar);
202
- element.querySelector<HTMLElement>("[data-source-code]")!.append(code.element);
203
- const sourceList = element.querySelector<HTMLElement>("[data-source-files]")!;
261
+ const sourcePanel = createSidebarPanel("panel", "Referenced source files");
262
+ sourcePanel.toolbar.append(code.toolbar);
263
+ sourcePanel.content.classList.add("source-code");
264
+ sourcePanel.content.append(code.element);
265
+ element.querySelector('[data-library-view="source"]')!.append(sourcePanel.element);
266
+ const taskPanel = createSidebarPanel("panel", "Task categories");
267
+ taskPanel.toolbar.innerHTML = `<span class="code-location" data-task-heading>All tasks</span><div class="task-context" data-task-context hidden></div>
268
+ <button type="button" class="library-refresh" data-library-refresh aria-label="Refresh tasks" title="Refresh">${icon("refresh")}</button>`;
269
+ taskPanel.content.innerHTML = `<div class="media-status" data-library-status="tasks" role="status" hidden></div><div class="task-list" data-task-list></div>`;
270
+ element.querySelector('[data-library-view="tasks"]')!.append(taskPanel.element);
271
+ const mediaPanel = createSidebarPanel("panel", "Media categories");
272
+ mediaPanel.toolbar.innerHTML = `<span class="code-location" data-media-heading>All media</span><div class="task-context" data-task-context hidden></div>
273
+ <button type="button" class="library-refresh" data-library-refresh aria-label="Refresh media" title="Refresh">${icon("refresh")}</button>`;
274
+ mediaPanel.content.innerHTML = `<div class="media-status" data-library-status="artifacts" role="status" hidden></div><div class="artifact-grid" data-artifact-grid></div>`;
275
+ element.querySelector('[data-library-view="artifacts"]')!.append(mediaPanel.element);
276
+ const sourceList = sourcePanel.navigation;
204
277
  const taskList = element.querySelector<HTMLElement>("[data-task-list]")!;
205
278
  const artifactGrid = element.querySelector<HTMLElement>("[data-artifact-grid]")!;
206
- const artifactFilters = element.querySelector<HTMLElement>("[data-artifact-filters]")!;
207
- const runtimeContext = element.querySelector<HTMLElement>("[data-runtime-context]")!;
279
+ const artifactFilters = mediaPanel.navigation;
208
280
  let snapshot: StudioSnapshot | undefined;
209
281
  let selectedSource = "";
210
282
  let active: LibrarySection = "source";
211
- let library: StudioLibraryView | undefined;
283
+ const libraries: Partial<Record<"tasks" | "artifacts", StudioLibraryView>> = {};
284
+ let taskFilter: "all" | "ongoing" | "finished" = "all";
212
285
  let artifactFilter = "all";
213
- let refreshing = false;
214
- let expanded = false;
286
+ let selectedArtifact: string | undefined;
287
+ let selectedTask: StudioTaskView | undefined;
288
+ let request: AbortController | undefined;
289
+ const morePages = new IntersectionObserver((entries) => {
290
+ for (const entry of entries) {
291
+ const node = entry.target as HTMLElement;
292
+ const section = node.dataset.moreSection as "tasks" | "artifacts";
293
+ if (!entry.isIntersecting || request !== undefined || active !== section) continue;
294
+ morePages.unobserve(node);
295
+ void refresh(section, node.dataset.before);
296
+ }
297
+ }, { rootMargin: "0px 0px 120px 0px" });
298
+ const continuation = (section: "tasks" | "artifacts", cursor: string): HTMLElement => {
299
+ const node = document.createElement("div");
300
+ node.className = "library-continuation";
301
+ node.dataset.moreSection = section;
302
+ node.dataset.before = cursor;
303
+ node.textContent = "Loading more…";
304
+ return node;
305
+ };
306
+ const selectArtifact = (id: string | undefined): void => {
307
+ selectedArtifact = id;
308
+ for (const card of Array.from(artifactGrid.querySelectorAll<HTMLElement>("[data-artifact-id]"))) {
309
+ const selected = card.dataset.artifactId === id;
310
+ card.classList.toggle("selected", selected);
311
+ if (selected) card.setAttribute("aria-current", "true");
312
+ else card.removeAttribute("aria-current");
313
+ }
314
+ };
315
+ const videoPreviews = new IntersectionObserver((entries) => {
316
+ for (const entry of entries) {
317
+ if (!entry.isIntersecting) continue;
318
+ videoPreviews.unobserve(entry.target);
319
+ if (entry.target instanceof HTMLImageElement) {
320
+ const image = entry.target;
321
+ void audioPreview(image.dataset.audioSource!).then((url) => {
322
+ if (url === undefined || !image.isConnected) return;
323
+ image.src = url;
324
+ image.classList.add("is-ready");
325
+ });
326
+ continue;
327
+ }
328
+ const video = entry.target as HTMLVideoElement;
329
+ video.preload = "metadata";
330
+ video.src = video.dataset.previewSource!;
331
+ }
332
+ }, { root: artifactGrid });
215
333
 
216
334
  const renderSources = (): void => {
217
335
  if (snapshot === undefined) return;
@@ -224,75 +342,140 @@ export function createLibraryPane(code: CodePane): LibraryPane {
224
342
  selectedSource = code.activePath() ?? snapshot.source.path;
225
343
  }
226
344
  sourceList.replaceChildren(...files.map((file) => {
227
- const button = document.createElement("button");
228
- button.type = "button";
229
- button.className = `source-file${file.path === selectedSource ? " active" : ""}`;
230
- button.innerHTML = `<span class="source-file-icon">${icon(file.language === "svrun" ? "run" : file.language === "svs" ? "tune" : "code")}</span>`;
231
- button.setAttribute("aria-label", `${stem(file.path)} ${file.language.toUpperCase()}`);
232
- button.title = `${file.role} · ${file.path}${file.imports.length === 0 ? "" : `\nimports ${file.imports.join(", ")}`}`;
233
- button.addEventListener("click", () => {
234
- selectedSource = file.path;
235
- renderSources();
345
+ const button = sidebarItem({
346
+ label: `${stem(file.path)} ${file.language.toUpperCase()}`,
347
+ icon: file.language === "svrun" ? "run" : file.language === "svs" ? "tune" : "code",
348
+ selected: file.path === selectedSource,
349
+ select: () => { selectedSource = file.path; renderSources(); },
236
350
  });
351
+ button.title = `${file.role} · ${file.path}${file.imports.length === 0 ? "" : `\nimports ${file.imports.join(", ")}`}`;
237
352
  return button;
238
353
  }));
239
354
  };
240
355
 
356
+ const renderTaskContext = (): void => {
357
+ for (const node of Array.from(element.querySelectorAll<HTMLElement>("[data-task-context]"))) {
358
+ const title = selectedTask === undefined ? undefined : selectedTask.title ?? stem(selectedTask.run ?? selectedTask.source);
359
+ if (selectedTask !== undefined && node.dataset.taskId === selectedTask.id
360
+ && node.querySelector(".task-context-name span")?.textContent === title) continue;
361
+ node.replaceChildren();
362
+ node.hidden = selectedTask === undefined;
363
+ node.dataset.taskId = selectedTask?.id ?? "";
364
+ if (selectedTask === undefined) continue;
365
+ const task = selectedTask;
366
+ const back = document.createElement("button");
367
+ back.type = "button"; back.className = "task-context-name";
368
+ back.innerHTML = "<span></span>";
369
+ back.querySelector("span")!.textContent = task.title ?? stem(task.run ?? task.source);
370
+ back.title = `${task.title ?? stem(task.run ?? task.source)} · Return to task`;
371
+ back.addEventListener("click", () => {
372
+ taskFilter = "all";
373
+ switchTo("tasks", false);
374
+ renderTasks();
375
+ taskList.querySelector<HTMLElement>(`[data-task-id="${CSS.escape(task.id)}"]`)?.scrollIntoView({ block: "nearest" });
376
+ });
377
+ const clear = document.createElement("button");
378
+ clear.type = "button"; clear.className = "task-context-clear";
379
+ clear.innerHTML = icon("close"); clear.title = "Clear task filter"; clear.setAttribute("aria-label", clear.title);
380
+ clear.addEventListener("click", () => {
381
+ selectedTask = undefined; delete libraries.artifacts; renderTaskContext(); renderTasks();
382
+ if (active === "artifacts") { renderArtifacts(); void refresh("artifacts"); }
383
+ });
384
+ node.append(back, clear);
385
+ }
386
+ };
387
+
241
388
  const renderTasks = (): void => {
389
+ for (const node of Array.from(taskList.querySelectorAll("[data-more-section]"))) morePages.unobserve(node);
390
+ const library = libraries.tasks;
242
391
  const tasks = library?.tasks ?? [];
243
- const runtime = library?.runtime;
244
- element.querySelector<HTMLElement>("[data-task-count]")!.textContent = `${tasks.length} build${tasks.length === 1 ? "" : "s"}`;
245
- runtimeContext.replaceChildren();
246
- const context = document.createElement("span");
247
- context.className = "runtime-chip";
248
- context.innerHTML = `<span></span><strong></strong>`;
249
- context.querySelector("span")!.textContent = runtime === undefined ? "Runtime" : leaf(runtime);
250
- context.querySelector("strong")!.textContent = runtime === undefined ? "not selected" : leaf(library!.environment);
251
- context.title = runtime === undefined ? "No Runtime selected for this environment" : `${library!.environment}\n${runtime}`;
252
- runtimeContext.append(context);
253
- const entries: HTMLElement[] = tasks.map(taskCard);
254
- if (tasks.length === 0) {
255
- entries.push(emptyState("tasks",
256
- runtime === undefined ? "No Runtime selected" : "No builds yet",
257
- runtime === undefined
258
- ? "Select one with hypit runtime use, then reopen Studio."
259
- : "Builds created from this environment will appear here."));
260
- }
261
- if (library?.next !== undefined) entries.push(olderButton(() => void loadOlder()));
392
+ const filters = [["all", "All tasks", "component"], ["ongoing", "In progress", "when"], ["finished", "Finished", "check"]] as const;
393
+ const heading = taskPanel.toolbar.querySelector<HTMLElement>("[data-task-heading]")!;
394
+ heading.textContent = filters.find(([id]) => id === taskFilter)![1];
395
+ heading.title = library?.runtime === undefined ? "Saved project Results · no Runtime selected" : library.runtime;
396
+ taskPanel.navigation.replaceChildren(...filters.map(([id, label, mark]) => sidebarItem({
397
+ label, icon: mark, selected: id === taskFilter,
398
+ select: () => { taskFilter = id; taskList.scrollTop = 0; renderTasks(); },
399
+ })));
400
+ const tasksToShow = tasks.filter((task) => taskFilter === "all" || task.ongoing === (taskFilter === "ongoing"))
401
+ .sort((left, right) => Number(right.ongoing) - Number(left.ongoing) || right.createdAt - left.createdAt);
402
+ if (selectedTask !== undefined) selectedTask = tasks.find((task) => task.id === selectedTask!.id) ?? selectedTask;
403
+ const selectTask = (task: StudioTaskView): void => {
404
+ const changed = selectedTask?.id !== task.id;
405
+ selectedTask = task;
406
+ if (changed) delete libraries.artifacts;
407
+ renderTaskContext();
408
+ for (const card of Array.from(taskList.querySelectorAll<HTMLElement>("[data-task-id]"))) {
409
+ card.classList.toggle("selected", card.dataset.taskId === task.id);
410
+ }
411
+ };
412
+ const entries = tasksToShow.map((task) => taskCard(task, selectedTask?.id === task.id,
413
+ () => selectTask(task), () => { selectTask(task); switchTo("artifacts"); }));
414
+ renderTaskContext();
415
+ if (tasksToShow.length === 0) entries.push(emptyState("tasks",
416
+ library === undefined && request !== undefined ? "Loading tasks…" : taskFilter === "ongoing" ? "No tasks in progress" : "No tasks here",
417
+ taskFilter === "ongoing" && library?.runtime === undefined
418
+ ? "No Runtime is selected. Saved tasks are available under Finished."
419
+ : "Refresh to read the latest task status."));
420
+ // Runtime activity is returned in full on the first page. Older pages contain Results only.
421
+ if (taskFilter !== "ongoing" && library?.next !== undefined) entries.push(continuation("tasks", library.next));
262
422
  taskList.replaceChildren(...entries);
423
+ for (const node of Array.from(taskList.querySelectorAll("[data-more-section]"))) morePages.observe(node);
263
424
  };
264
425
 
265
426
  const renderArtifacts = (): void => {
427
+ videoPreviews.disconnect();
428
+ for (const node of Array.from(artifactGrid.querySelectorAll("[data-more-section]"))) morePages.unobserve(node);
429
+ const cards = new Map(Array.from(artifactGrid.querySelectorAll<HTMLElement>("[data-artifact-id]"), (card) => [card.dataset.artifactId!, card]));
430
+ const library = libraries.artifacts;
266
431
  const artifacts = library?.artifacts ?? [];
267
- element.querySelector<HTMLElement>("[data-artifact-count]")!.textContent = `${artifacts.length} public file${artifacts.length === 1 ? "" : "s"}`;
268
- const filters = [
269
- ["all", "All"],
270
- ["video", "Video"],
271
- ["image", "Image"],
272
- ["audio", "Audio"],
273
- ] as const;
274
- artifactFilters.replaceChildren(...filters.map(([id, label]) => {
275
- const button = document.createElement("button");
276
- button.type = "button";
277
- button.className = id === artifactFilter ? "active" : "";
278
- button.textContent = label;
279
- button.addEventListener("click", () => { artifactFilter = id; renderArtifacts(); });
280
- return button;
281
- }));
282
- const shown = artifactFilter === "all"
283
- ? artifacts
432
+ const filters = [["all", "All media", "component"], ["video", "Videos", "video"], ["image", "Images", "image"], ["audio", "Audio", "waveform"]] as const;
433
+ const heading = mediaPanel.toolbar.querySelector<HTMLElement>("[data-media-heading]")!;
434
+ heading.textContent = filters.find(([id]) => id === artifactFilter)?.[1] ?? "All media";
435
+ heading.title = "Media category";
436
+ renderTaskContext();
437
+ artifactFilters.replaceChildren(...filters.map(([id, label, mark]) => sidebarItem({
438
+ label, icon: mark, selected: id === artifactFilter,
439
+ select: () => {
440
+ artifactFilter = id;
441
+ delete libraries.artifacts;
442
+ artifactGrid.scrollTop = 0;
443
+ void refresh("artifacts");
444
+ renderArtifacts();
445
+ },
446
+ })));
447
+ const shown = artifactFilter === "all" ? artifacts
284
448
  : artifacts.filter((artifact) => artifact.mediaType.startsWith(`${artifactFilter}/`));
285
- const entries: HTMLElement[] = shown.map(artifactCard);
286
- if (shown.length === 0) {
287
- entries.push(emptyState("results",
288
- artifacts.length === 0 ? "No accepted artifacts" : `No ${artifactFilter} artifacts`,
289
- "Public files appear here when a Build Result contains them."));
449
+ const entries: HTMLElement[] = shown.map((artifact) => {
450
+ const held = cards.get(artifact.id);
451
+ cards.delete(artifact.id);
452
+ if (held !== undefined && held.dataset.build === artifact.build && held.dataset.output === artifact.output
453
+ && held.dataset.displayName === (artifact.displayName ?? "") && held.dataset.nameEditable === String(artifact.nameEditable === true)) return held;
454
+ const previousVideo = held?.querySelector("video");
455
+ if (previousVideo != null) { previousVideo.removeAttribute("src"); previousVideo.load(); }
456
+ return artifactCard(artifact, openArtifact, (updated) => {
457
+ const current = libraries.artifacts;
458
+ if (current !== undefined) libraries.artifacts = { ...current,
459
+ artifacts: current.artifacts.map((item) => item.id === updated.id ? { ...item, displayName: updated.displayName! } : item),
460
+ };
461
+ renamedArtifact(updated);
462
+ });
463
+ });
464
+ for (const card of cards.values()) {
465
+ const video = card.querySelector("video");
466
+ if (video !== null) { video.removeAttribute("src"); video.load(); }
290
467
  }
291
- if (library?.next !== undefined) entries.push(olderButton(() => void loadOlder()));
468
+ if (shown.length === 0) entries.push(emptyState("results",
469
+ library === undefined && request !== undefined ? "Loading media…" : artifactFilter === "all" ? "No media" : `No ${artifactFilter} files`,
470
+ "Images, videos and audio published as file Outputs appear here, including those from ongoing Builds."));
471
+ if (library?.next !== undefined) entries.push(continuation("artifacts", library.next));
292
472
  artifactGrid.replaceChildren(...entries);
473
+ selectArtifact(selectedArtifact);
474
+ for (const preview of Array.from(artifactGrid.querySelectorAll("video:not([src]), [data-audio-source]:not(.is-ready)"))) videoPreviews.observe(preview);
475
+ for (const node of Array.from(artifactGrid.querySelectorAll("[data-more-section]"))) morePages.observe(node);
293
476
  };
294
477
 
295
- const switchTo = (next: LibrarySection): void => {
478
+ const switchTo = (next: LibrarySection, reload = true): void => {
296
479
  active = next;
297
480
  for (const tab of Array.from(element.querySelectorAll<HTMLButtonElement>("[data-library-tab]"))) {
298
481
  const selected = tab.dataset.libraryTab === next;
@@ -302,77 +485,75 @@ export function createLibraryPane(code: CodePane): LibraryPane {
302
485
  for (const view of Array.from(element.querySelectorAll<HTMLElement>("[data-library-view]"))) {
303
486
  view.classList.toggle("active", view.dataset.libraryView === next);
304
487
  }
305
- if (next !== "source") void refresh();
488
+ if (next === "artifacts") renderArtifacts();
489
+ if (next === "tasks") renderTasks();
490
+ if (next !== "source" && reload) void refresh(next);
491
+ else request?.abort();
306
492
  };
307
493
 
308
- for (const tab of Array.from(element.querySelectorAll<HTMLButtonElement>("[data-library-tab]"))) {
309
- tab.addEventListener("click", () => switchTo(tab.dataset.libraryTab as LibrarySection));
310
- }
311
-
312
- const mergeLibrary = (value: StudioLibraryView, append: boolean): void => {
313
- if (library === undefined || (!append && !expanded)) {
314
- library = value;
315
- return;
316
- }
317
- const tasks = new Map(library.tasks.map((task) => [task.id, task]));
318
- const artifacts = new Map(library.artifacts.map((artifact) => [artifact.id, artifact]));
319
- for (const task of value.tasks) tasks.set(task.id, task);
320
- for (const artifact of value.artifacts) artifacts.set(artifact.id, artifact);
321
- library = {
322
- environment: value.environment,
323
- ...(value.runtime === undefined ? {} : { runtime: value.runtime }),
324
- ...((append || !expanded ? value.next : library.next) === undefined
325
- ? {}
326
- : { next: append || !expanded ? value.next : library.next }),
327
- tasks: [...tasks.values()].sort((left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id)),
328
- artifacts: [...artifacts.values()].sort((left, right) =>
329
- Number(right.highlighted) - Number(left.highlighted)
330
- || right.createdAt - left.createdAt
331
- || left.output.localeCompare(right.output)
332
- || left.valuePath.localeCompare(right.valuePath)),
333
- };
334
- };
335
-
336
- const refresh = async (before?: string): Promise<void> => {
337
- if (refreshing) return;
338
- refreshing = true;
494
+ const refresh = async (section: "tasks" | "artifacts", before?: string): Promise<void> => {
495
+ if (before !== undefined && request !== undefined) return;
496
+ request?.abort();
497
+ const controller = new AbortController();
498
+ request = controller;
499
+ const status = element.querySelector<HTMLElement>(`[data-library-status="${section}"]`)!;
500
+ status.textContent = "Refreshing…";
501
+ status.hidden = true;
339
502
  element.classList.add("is-refreshing");
340
503
  try {
341
- const query = before === undefined ? "" : `?${new URLSearchParams({ before }).toString()}`;
342
- const response = await fetch(`/__studio/library${query}`);
504
+ const query = new URLSearchParams({ section });
505
+ if (before !== undefined) query.set("before", before);
506
+ if (section === "artifacts" && artifactFilter !== "all") query.set("media", artifactFilter);
507
+ if (section === "artifacts" && selectedTask !== undefined) query.set("build", selectedTask.id);
508
+ const response = await fetch(`/__studio/library?${query.toString()}`, { signal: controller.signal });
343
509
  const value = await response.json() as StudioLibraryView | { readonly error: string };
344
510
  if (!response.ok || "error" in value) throw new Error("error" in value ? value.error : "Library unavailable");
345
- mergeLibrary(value, before !== undefined);
346
- if (before !== undefined) expanded = true;
347
- renderTasks();
348
- renderArtifacts();
511
+ if (controller.signal.aborted) return;
512
+ const previous = libraries[section];
513
+ if (before === undefined || previous === undefined) libraries[section] = value;
514
+ else {
515
+ const tasks = new Map(previous.tasks.map((task) => [task.id, task]));
516
+ for (const task of value.tasks) tasks.set(task.id, task);
517
+ libraries[section] = {
518
+ ...value,
519
+ tasks: [...tasks.values()].sort((left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id)),
520
+ artifacts: mergeStudioArtifacts([...previous.artifacts, ...value.artifacts]),
521
+ };
522
+ }
523
+ if (section === "tasks") renderTasks();
524
+ else renderArtifacts();
525
+ status.textContent = `Updated ${new Date().toLocaleTimeString()}`;
349
526
  } catch (error) {
350
- const detail = error instanceof Error ? error.message : String(error);
351
- if (active === "tasks") taskList.replaceChildren(emptyState("tasks", "Build Results unavailable", detail));
352
- if (active === "artifacts") artifactGrid.replaceChildren(emptyState("results", "Build Results unavailable", detail));
527
+ if (!controller.signal.aborted) {
528
+ const detail = error instanceof Error ? error.message : String(error);
529
+ status.hidden = false;
530
+ status.textContent = `Refresh failed: ${detail}${libraries[section] === undefined ? "" : " · Previous view retained"}`;
531
+ }
353
532
  } finally {
354
- refreshing = false;
355
- element.classList.remove("is-refreshing");
533
+ if (request === controller) {
534
+ request = undefined;
535
+ element.classList.remove("is-refreshing");
536
+ if (controller.signal.aborted) status.textContent = "Refresh interrupted";
537
+ }
356
538
  }
357
539
  };
358
540
 
359
- const loadOlder = async (): Promise<void> => {
360
- if (library?.next !== undefined) await refresh(library.next);
361
- };
362
-
541
+ for (const tab of Array.from(element.querySelectorAll<HTMLButtonElement>("[data-library-tab]"))) {
542
+ tab.addEventListener("click", () => {
543
+ switchTo(tab.dataset.libraryTab as LibrarySection, tab.dataset.libraryTab !== "tasks" || libraries.tasks === undefined);
544
+ });
545
+ }
363
546
  for (const button of Array.from(element.querySelectorAll<HTMLButtonElement>("[data-library-refresh]"))) {
364
- button.addEventListener("click", () => void refresh());
547
+ button.addEventListener("click", () => { if (active !== "source") void refresh(active); });
365
548
  }
366
- window.setInterval(() => {
367
- if (document.visibilityState === "visible" && active !== "source") void refresh();
368
- }, 3_000);
369
549
 
370
550
  return {
371
551
  element,
552
+ selectArtifact,
372
553
  show(value) {
373
554
  snapshot = value;
374
555
  renderSources();
375
556
  },
376
- refresh,
557
+ refresh: async () => { if (active !== "source") await refresh(active); },
377
558
  };
378
559
  }