@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,3 +1,4 @@
1
+ import { fileReferenceIdentity } from "@hypit/build-result";
1
2
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
3
 
3
4
  import { buildIdCreatedAt } from "@hypit/protocol";
@@ -5,17 +6,16 @@ import type { BuildView, NodeRuntimeHost, RuntimeHostTransientExecution } from "
5
6
  import type { StoredValue, TypeRef } from "@hypit/protocol";
6
7
  import type {
7
8
  BuildResultFileRange,
8
- BuildResultFileRef,
9
9
  BuildResultManifest,
10
10
  BuildResultRepository,
11
11
  FinishedBuildResultManifest,
12
- RepositoryBuildResultOutput,
13
12
  } from "@hypit/build-result";
14
13
 
15
14
  import { resolveBuildResultValue } from "@hypit/cli";
16
15
  import { videoCliDistribution } from "@hypit/video-cli";
17
16
 
18
- import type { StudioArtifactView, StudioLibraryView, StudioTaskView } from "./shared.js";
17
+ import type { StudioArtifactView, StudioLibraryRequest, StudioLibraryView, StudioTaskView } from "./shared.js";
18
+ import { mergeStudioArtifacts } from "./library-media.js";
19
19
 
20
20
  type RuntimeControl = Awaited<ReturnType<NodeRuntimeHost["openControl"]>>;
21
21
 
@@ -24,7 +24,8 @@ export type StudioBuildLibrary = {
24
24
  readonly runtime?: Pick<RuntimeControl, "activity">;
25
25
  /** Runtime-owned, disposable execution for the current authoring session. */
26
26
  readonly transientExecution?: RuntimeHostTransientExecution;
27
- readonly library: (before?: string) => Promise<StudioLibraryView>;
27
+ readonly library: (request: StudioLibraryRequest) => Promise<StudioLibraryView>;
28
+ readonly renameArtifact: (build: string, output: string, displayName: string | null) => Promise<string | undefined>;
28
29
  readonly resolveHistoricalOutput: (
29
30
  build: string,
30
31
  output: string,
@@ -36,7 +37,6 @@ export type StudioBuildLibrary = {
36
37
  readonly openArtifact: (
37
38
  build: string,
38
39
  output: string,
39
- valuePath: string,
40
40
  ) => Promise<{
41
41
  readonly mediaType: string;
42
42
  readonly size: number;
@@ -51,7 +51,7 @@ function projectPath(root: string, path: string): string {
51
51
 
52
52
  function isWithin(root: string, path: string): boolean {
53
53
  const rel = relative(resolve(root), projectPath(root, path));
54
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
54
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
55
55
  }
56
56
 
57
57
  /**
@@ -65,15 +65,9 @@ function presentedPath(root: string, path: string): string {
65
65
  return (relative(resolve(root), absolute) || ".").split(sep).join("/");
66
66
  }
67
67
 
68
- function buildBelongsTo(root: string, view: BuildView): boolean {
69
- return view.source !== undefined
70
- && (isWithin(root, view.run?.path ?? view.source.path) || isWithin(root, view.source.path));
71
- }
72
-
73
68
  function taskStatus(view: BuildView): StudioTaskView["status"] {
74
- if (view.issue !== undefined) return "blocked";
69
+ if (view.issue !== undefined) return "attention";
75
70
  if (view.activity === "submitting" || view.activity === "ready") return "queued";
76
- if (view.activity === "saving-result") return "active";
77
71
  return view.activity;
78
72
  }
79
73
 
@@ -83,18 +77,20 @@ function taskView(
83
77
  result?: BuildResultManifest,
84
78
  ): StudioTaskView {
85
79
  if (view.source === undefined) throw new Error(`Active Build ${view.id} has no source`);
80
+ const detail = view.issue?.message ?? view.stop?.reason;
86
81
  return {
87
82
  id: view.id,
88
83
  ...(result?.title === undefined ? {} : { title: result.title }),
89
84
  ...(result?.note === undefined ? {} : { note: result.note }),
90
85
  ...(result?.highlightedOutputs === undefined ? {} : { highlightedOutputs: result.highlightedOutputs }),
91
86
  createdAt: view.createdAt,
87
+ ongoing: true,
92
88
  status: taskStatus(view),
89
+ ...(detail === undefined ? {} : { detail }),
90
+ ...(view.requests === undefined ? {} : { requests: view.requests }),
93
91
  source: presentedPath(root, view.source.path),
94
92
  ...(view.run === undefined ? {} : { run: presentedPath(root, view.run.path) }),
95
93
  targets: result?.targets ?? view.targets,
96
- acceptedRecords: view.acceptedRecords,
97
- outstandingCommands: view.outstandingCommands,
98
94
  operations: view.operations.map((operation) => ({
99
95
  status: operation.status,
100
96
  ...(operation.progress?.phase === undefined ? {} : { phase: operation.progress.phase }),
@@ -112,107 +108,117 @@ function resultTaskView(root: string, result: FinishedBuildResultManifest): Stud
112
108
  ...(result.note === undefined ? {} : { note: result.note }),
113
109
  ...(result.highlightedOutputs === undefined ? {} : { highlightedOutputs: result.highlightedOutputs }),
114
110
  createdAt: buildIdCreatedAt(result.id)!,
111
+ finishedAt: result.finishedAt,
112
+ ongoing: false,
115
113
  status: result.outcome,
114
+ ...(result.failure === undefined ? {} : { detail: result.failure }),
116
115
  source: presentedPath(root, result.source.path),
117
116
  ...(result.run === undefined ? {} : { run: presentedPath(root, result.run.path) }),
118
117
  targets: result.targets,
119
- acceptedRecords: Object.keys(result.outputs).length,
120
- outstandingCommands: 0,
121
118
  operations: [],
122
119
  };
123
120
  }
124
121
 
125
- function presentedValuePath(path: readonly (string | number)[]): string {
126
- return path.reduce<string>((current, segment) => typeof segment === "number"
127
- ? `${current}[${segment}]`
128
- : /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(segment)
129
- ? `${current}.${segment}`
130
- : `${current}[${JSON.stringify(segment)}]`, "$");
131
- }
132
-
133
- function filesInResolvedOutput(resolved: RepositoryBuildResultOutput): readonly {
134
- readonly valuePath: string;
135
- readonly file: BuildResultFileRef;
136
- }[] {
137
- if (resolved.value.kind === "build-file") return [{ valuePath: "$", file: resolved.value }];
138
- if (resolved.value.kind === "value") {
139
- return resolved.value.document.resources.map((binding) => ({
140
- valuePath: presentedValuePath(binding.at),
141
- file: binding.file,
142
- }));
143
- }
144
- return [];
122
+ function isMedia(mediaType: string): boolean {
123
+ return /^(image|video|audio)\//u.test(mediaType);
145
124
  }
146
125
 
147
126
  async function artifactsForResults(
148
127
  root: string,
149
128
  repository: BuildResultRepository,
150
129
  manifests: readonly BuildResultManifest[],
130
+ media?: StudioLibraryRequest["media"],
151
131
  ): Promise<readonly StudioArtifactView[]> {
152
132
  const nested = await Promise.all(manifests.flatMap((manifest) =>
153
- Object.keys(manifest.outputs).map(async (output) => {
154
- const resolved = await repository.resolve(manifest.id, output);
155
- if (resolved === undefined) return [];
156
- return filesInResolvedOutput(resolved).map(({ valuePath, file }) => ({
157
- id: `${manifest.id}:${output}:${valuePath}`,
133
+ Object.entries(manifest.outputs).map(async ([output, entry]): Promise<StudioArtifactView[]> => {
134
+ // Composite Outputs remain intact. Listing media never opens their Value Documents.
135
+ if (entry.value.kind !== "build-file" && entry.value.kind !== "external-file" && entry.value.kind !== "build-output") return [];
136
+ const description = entry.value.kind === "build-file" || entry.value.kind === "external-file"
137
+ ? { kind: "resource", mediaType: entry.value.mediaType }
138
+ : await repository.describeOutput(manifest.id, output);
139
+ if (description?.kind !== "resource" || (description.mediaType === undefined || !isMedia(description.mediaType))) return [];
140
+ if (media !== undefined && !description.mediaType.startsWith(`${media}/`)) return [];
141
+ const resolved = entry.value.kind === "build-file"
142
+ ? { build: manifest.id, output, value: entry.value }
143
+ : await repository.resolve(manifest.id, output);
144
+ if (resolved === undefined || (resolved.value.kind !== "build-file" && resolved.value.kind !== "external-file")) return [];
145
+ const file = resolved.value;
146
+ const source = presentedPath(root, manifest.source.path);
147
+ const run = manifest.run === undefined ? undefined : presentedPath(root, manifest.run.path);
148
+ return [{
149
+ id: fileReferenceIdentity(resolved.build, file),
158
150
  build: manifest.id,
159
151
  createdAt: buildIdCreatedAt(manifest.id)!,
160
152
  output,
153
+ nameEditable: manifest.outcome !== undefined,
154
+ ...(entry.displayName === undefined ? {} : { displayName: entry.displayName }),
161
155
  highlighted: manifest.highlightedOutputs?.includes(output) === true,
162
156
  ...(manifest.title === undefined ? {} : { buildTitle: manifest.title }),
163
157
  ...(manifest.note === undefined ? {} : { buildNote: manifest.note }),
164
- valuePath,
165
- ownerBuild: resolved.build,
158
+ ...(file.kind === "external-file" ? {} : { ownerBuild: file.build ?? resolved.build }),
166
159
  ownerOutput: resolved.output,
167
- filePath: file.path,
160
+ filePath: file.kind === "external-file" ? file.uri : file.path,
168
161
  size: file.size,
169
162
  mediaType: file.mediaType,
170
- source: presentedPath(root, manifest.source.path),
171
- ...(manifest.run === undefined ? {} : { run: presentedPath(root, manifest.run.path) }),
172
- }));
163
+ source,
164
+ ...(run === undefined ? {} : { run }),
165
+ origins: [{ build: manifest.id, output, source, ...(run === undefined ? {} : { run }) }],
166
+ }];
173
167
  })));
174
- return nested.flat().sort((left, right) =>
175
- Number(right.highlighted) - Number(left.highlighted)
176
- || right.createdAt - left.createdAt
177
- || left.output.localeCompare(right.output)
178
- || left.valuePath.localeCompare(right.valuePath));
168
+ return mergeStudioArtifacts(nested.flat());
179
169
  }
180
170
 
181
- /** Build the Studio library from execution status and project-owned Build Results. */
182
- export async function readStudioLibrary(input: {
171
+ /** Query only the selected library view, through Runtime and Repository interfaces. */
172
+ export async function readStudioLibrary(input: StudioLibraryRequest & {
183
173
  readonly profile?: string;
184
174
  readonly workspaceRoot: string;
185
175
  readonly runtime?: Pick<RuntimeControl, "activity">;
186
176
  readonly results: BuildResultRepository;
187
- readonly before?: string;
188
177
  }): Promise<StudioLibraryView> {
189
- const page = await input.results.browse({
190
- limit: 25,
191
- ...(input.before === undefined ? {} : { before: input.before }),
192
- });
193
- const manifests = page.results.filter((manifest) =>
194
- isWithin(input.workspaceRoot, manifest.run?.path ?? manifest.source.path)
195
- || isWithin(input.workspaceRoot, manifest.source.path));
196
- const resultsByBuild = new Map(manifests.map((manifest) => [manifest.id, manifest]));
178
+ const matches = (item: { readonly id: string; readonly source: { readonly path: string }; readonly run?: { readonly path: string } }) =>
179
+ (isWithin(input.workspaceRoot, item.run?.path ?? item.source.path) || isWithin(input.workspaceRoot, item.source.path))
180
+ && (input.build === undefined || item.id === input.build)
181
+ && (input.run === undefined || (item.run !== undefined
182
+ && projectPath(input.workspaceRoot, item.run.path) === projectPath(input.workspaceRoot, input.run)));
183
+ // Read activity before Results so a Build that finishes during this query is still represented.
197
184
  const active = input.runtime === undefined || input.before !== undefined
198
- ? []
199
- : (await input.runtime.activity()).builds;
200
- const views = active.filter((view) => buildBelongsTo(input.workspaceRoot, view));
185
+ ? [] : (await input.runtime.activity()).builds;
186
+ const views = active.filter((view) => view.source !== undefined && matches({ ...view, source: view.source }));
187
+ let page = input.build === undefined
188
+ ? await input.results.browse({ limit: 25, ...(input.before === undefined ? {} : { before: input.before }) })
189
+ : { results: [await input.results.read(input.build)].filter((item): item is BuildResultManifest => item !== undefined) };
190
+ const manifests = page.results.filter(matches);
191
+ const resultsByBuild = new Map(manifests.map((manifest) => [manifest.id, manifest]));
192
+ await Promise.all(views.filter((view) => !resultsByBuild.has(view.id)).map(async (view) => {
193
+ const result = await input.results.read(view.id);
194
+ if (result !== undefined && matches(result)) resultsByBuild.set(view.id, result);
195
+ }));
196
+ let artifacts: readonly StudioArtifactView[] = [];
197
+ if (input.section === "artifacts") {
198
+ artifacts = await artifactsForResults(input.workspaceRoot, input.results, [...resultsByBuild.values()], input.media);
199
+ // Page through Result metadata until there is a useful media page. The repository still
200
+ // owns Build order and cursors; no media directory or secondary persistent index is needed.
201
+ while (artifacts.length < 25 && "next" in page && page.next !== undefined) {
202
+ const cursor = page.next;
203
+ page = await input.results.browse({ limit: 25, before: cursor });
204
+ if ("next" in page && page.next === cursor) throw new Error("Result pagination did not advance");
205
+ artifacts = mergeStudioArtifacts([...artifacts,
206
+ ...await artifactsForResults(input.workspaceRoot, input.results, page.results.filter(matches), input.media)]);
207
+ }
208
+ }
201
209
  return {
210
+ section: input.section,
202
211
  environment: resolve(input.workspaceRoot),
203
212
  ...(input.profile === undefined ? {} : { runtime: resolve(input.profile) }),
204
- ...(page.next === undefined ? {} : { next: page.next }),
205
- tasks: [
206
- ...views.map((view) => taskView(
207
- input.workspaceRoot,
208
- view,
209
- resultsByBuild.get(view.id),
210
- )),
213
+ ...("next" in page && page.next !== undefined ? { next: page.next } : {}),
214
+ tasks: input.section !== "tasks" ? [] : [
215
+ ...views.map((view) => taskView(input.workspaceRoot, view, resultsByBuild.get(view.id))),
211
216
  ...manifests
212
- .filter((manifest) => !views.some((view) => view.id === manifest.id))
217
+ .filter((manifest): manifest is FinishedBuildResultManifest => manifest.outcome !== undefined
218
+ && !views.some((view) => view.id === manifest.id))
213
219
  .map((manifest) => resultTaskView(input.workspaceRoot, manifest)),
214
220
  ].sort((left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id)),
215
- artifacts: await artifactsForResults(input.workspaceRoot, input.results, manifests),
221
+ artifacts,
216
222
  };
217
223
  }
218
224
 
@@ -245,31 +251,40 @@ export async function openStudioBuildLibrary(
245
251
  throw error;
246
252
  }
247
253
  const results = openedResults.repository;
254
+ let presentationWrite: Promise<unknown> = Promise.resolve();
248
255
  return {
249
256
  ...(resolvedProfile === undefined ? {} : { profile: resolvedProfile }),
250
257
  ...(runtime === undefined ? {} : { runtime }),
251
258
  ...(transientExecution === undefined ? {} : { transientExecution }),
252
- async library(before) {
259
+ async library(request) {
253
260
  return await readStudioLibrary({
254
261
  ...(resolvedProfile === undefined ? {} : { profile: resolvedProfile }),
255
262
  workspaceRoot,
256
263
  ...(runtime === undefined ? {} : { runtime }),
257
264
  results,
258
- ...(before === undefined ? {} : { before }),
265
+ ...request,
266
+ });
267
+ },
268
+ async renameArtifact(build, output, displayName) {
269
+ const saved = presentationWrite.then(async () => {
270
+ const updated = await results.updatePresentation(build, { outputDisplayNames: { [output]: displayName } });
271
+ return updated.outputs[output]?.displayName;
259
272
  });
273
+ presentationWrite = saved.catch(() => undefined);
274
+ return await saved;
260
275
  },
261
276
  async resolveHistoricalOutput(build, output) {
262
277
  return await resolveBuildResultValue(results, build, output);
263
278
  },
264
- async openArtifact(build, output, valuePath) {
279
+ async openArtifact(build, output) {
265
280
  const resolved = await results.resolve(build, output);
266
281
  if (resolved === undefined) return undefined;
267
- const match = filesInResolvedOutput(resolved).find((item) => item.valuePath === valuePath);
268
- if (match === undefined) return undefined;
282
+ if ((resolved.value.kind !== "build-file" && resolved.value.kind !== "external-file") || !isMedia(resolved.value.mediaType)) return undefined;
283
+ const file = resolved.value;
269
284
  return {
270
- mediaType: match.file.mediaType,
271
- size: match.file.size,
272
- open: async (range) => await results.openFile(resolved.build, match.file, range),
285
+ mediaType: file.mediaType,
286
+ size: file.size,
287
+ open: async (range) => await results.openFile(resolved.build, file, range),
273
288
  };
274
289
  },
275
290
  async close() {
@@ -0,0 +1,19 @@
1
+ import type { StudioArtifactView } from "./shared.js";
2
+
3
+ /** Merge known references, without reading or comparing media bytes. */
4
+ export function mergeStudioArtifacts(items: readonly StudioArtifactView[]): StudioArtifactView[] {
5
+ const files = new Map<string, StudioArtifactView>();
6
+ for (const item of items) {
7
+ const prior = files.get(item.id);
8
+ if (prior === undefined) {
9
+ files.set(item.id, item);
10
+ continue;
11
+ }
12
+ const latest = item.createdAt > prior.createdAt ? item : prior;
13
+ const origins = new Map([...prior.origins, ...item.origins].map((origin) =>
14
+ [JSON.stringify([origin.build, origin.output]), origin]));
15
+ files.set(item.id, { ...latest, highlighted: prior.highlighted || item.highlighted, origins: [...origins.values()] });
16
+ }
17
+ return [...files.values()].sort((left, right) => Number(right.highlighted) - Number(left.highlighted)
18
+ || right.createdAt - left.createdAt || left.output.localeCompare(right.output));
19
+ }
@@ -1,5 +1,44 @@
1
1
  import type { CanonicalValue, ValueSchema } from "@hypit/protocol";
2
2
  import { formatSvsValue } from "@hypit/svs";
3
+ import type { StudioInspectorField, StudioParameterOption, StudioNumberPresentation } from "@hypit/studio-adapter";
4
+
5
+ export const parameterOption = (option: StudioParameterOption): Exclude<StudioParameterOption, string> =>
6
+ typeof option === "string" ? { value: option, label: option } : option;
7
+
8
+ // Keep binary floating-point residue out of decimal controls (0.55 × 100 is 55).
9
+ const scaledDecimal = (value: number, scale: number): number =>
10
+ scale === 1 ? value : Number((value * scale).toPrecision(15));
11
+
12
+ export function parameterNumber(value: CanonicalValue, presentation: StudioNumberPresentation = {}): { value: number; suffix: string } {
13
+ let held = value;
14
+ let suffix = "";
15
+ if (presentation.suffixes !== undefined) {
16
+ if (typeof held !== "string") throw new Error("A suffixed number must be authored as text.");
17
+ const text = held.trim();
18
+ const found = [...presentation.suffixes].sort((a, b) => b.length - a.length).find(item => text.endsWith(item));
19
+ if (found === undefined) throw new Error("The authored unit is not supported by this field.");
20
+ suffix = found;
21
+ held = text.slice(0, text.length - suffix.length).trim();
22
+ }
23
+ if (typeof held !== "number" && (typeof held !== "string" || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/iu.test(held.trim()))) {
24
+ throw new Error("This field needs a numeric value.");
25
+ }
26
+ const number = scaledDecimal(Number(held), presentation.scale ?? 1);
27
+ if (!Number.isFinite(number)) throw new Error("This field needs a finite number.");
28
+ return { value: number, suffix };
29
+ }
30
+
31
+ /** Decode the displayed edit before validating and serializing the authored value. */
32
+ export function parameterAuthorValue(field: StudioInspectorField, displayed: CanonicalValue): CanonicalValue {
33
+ if (field.control !== "number") return displayed;
34
+ if (typeof displayed !== "number" || !Number.isFinite(displayed)) throw new Error(`${field.label} expects a finite number.`);
35
+ const presentation = field.number ?? {};
36
+ if (presentation.minimum !== undefined && displayed < presentation.minimum) throw new Error(`${field.label} must be at least ${presentation.minimum}.`);
37
+ if (presentation.maximum !== undefined && displayed > presentation.maximum) throw new Error(`${field.label} must be at most ${presentation.maximum}.`);
38
+ const value = scaledDecimal(displayed, 1 / (presentation.scale ?? 1));
39
+ if (!Number.isFinite(value)) throw new Error(`${field.label} cannot be represented.`);
40
+ return presentation.suffixes === undefined ? value : `${value}${parameterNumber(field.value, presentation).suffix}`;
41
+ }
3
42
 
4
43
  function fail(path: string, expectation: string): never {
5
44
  throw new Error(`${path} ${expectation}.`);
@@ -76,6 +115,7 @@ export function validateParameterValue(value: CanonicalValue, schema: ValueSchem
76
115
 
77
116
  export function serializeParameterValue(value: CanonicalValue, language: "svml" | "svs" | "svrun"): string {
78
117
  if (language === "svs") return formatSvsValue(value);
79
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
118
+ if (typeof value === "string") return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
119
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
80
120
  throw new Error(`${language.toUpperCase()} parameter bindings currently accept scalar values only.`);
81
121
  }
@@ -238,8 +238,16 @@ function referencedParameters(input: {
238
238
  const file = sourceFor(input.root, target.sourcePath, input.files);
239
239
  if (file === undefined) return [];
240
240
  return input.declarations.flatMap((declaration) => {
241
+ const nestedPath = target.references[declaration.name];
242
+ const nested = nestedPath === undefined || declaration.referenced === undefined ? [] : referencedParameters({
243
+ root: input.root, files: input.files, draft: input.draft, placements: input.placements,
244
+ referenceName: `${input.referenceName}.${declaration.name}`,
245
+ referencePath: nestedPath,
246
+ ...(target.resolvedReferences[declaration.name] === undefined ? {} : { referenceRef: target.resolvedReferences[declaration.name] }),
247
+ declarations: declaration.referenced,
248
+ });
241
249
  const range = target.attributeValueRanges[declaration.name];
242
- if (range === undefined) return [];
250
+ if (range === undefined) return nested;
243
251
  const preimage = file.text.slice(range.start, range.end);
244
252
  const reference = target.references[declaration.name];
245
253
  const value = reference === undefined ? (target.attributes[declaration.name] ?? preimage) : reference;
@@ -263,7 +271,7 @@ function referencedParameters(input: {
263
271
  ...(!writable
264
272
  ? { disabledReason: reference === undefined ? "该几何值由组件声明为只读。" : "引用由作者在 SVML 中绑定,面板不替换引用关系。" }
265
273
  : {}),
266
- } satisfies StudioSourceBinding];
274
+ } satisfies StudioSourceBinding, ...nested];
267
275
  });
268
276
  }
269
277
 
@@ -484,7 +492,7 @@ export function resolveTimelineEditHandles(
484
492
  moveEffect?: "translate-window" | "move-start",
485
493
  ): StudioEditHandle => {
486
494
  const unavailable = affected.find(({ endpoint }) => endpoint.authority.kind === "fixed");
487
- if (unavailable !== undefined) return disabled(gesture, "该端点由 Program 或 Segment 结构固定,不能从组件时间线反写。");
495
+ if (unavailable !== undefined) return disabled(gesture, "该时间表达未开放时间轴回写。");
488
496
  const projected: ({ readonly missing: string } | StudioEditSource)[] = [];
489
497
  for (const { endpoint, role } of affected) {
490
498
  if (endpoint.authority.kind !== "parameter") continue;
@@ -527,24 +535,15 @@ export function resolveTimelineEditHandles(
527
535
  }
528
536
  const start = { endpoint: projection.start, role: "start" as const };
529
537
  const end = { endpoint: projection.end, role: "end" as const };
530
- const move = projection.end.authority.kind === "parameter" && projection.end.authority.relation === "after-start"
531
- ? [start]
532
- : projection.start.authority.kind === "parameter" && projection.start.authority.relation === "before-end"
533
- ? [end]
534
- : [start, end];
535
- const trimStart = projection.start.authority.kind === "parameter" && projection.start.authority.relation === "before-end"
536
- ? [start]
537
- : projection.end.authority.kind === "parameter" && projection.end.authority.relation === "after-start"
538
- ? [start, end]
539
- : [start];
540
- const trimEnd = projection.end.authority.kind === "parameter" && projection.end.authority.relation === "after-start"
541
- ? [end]
542
- : projection.start.authority.kind === "parameter" && projection.start.authority.relation === "before-end"
543
- ? [start, end]
544
- : [end];
538
+ if (projection.end.authority.kind === "parameter" && projection.end.authority.relation === "after-start") {
539
+ return [handle("move", [start], "translate-window"), handle("trim-end", [end])];
540
+ }
541
+ if (projection.start.authority.kind === "parameter" && projection.start.authority.relation === "before-end") {
542
+ return [handle("move", [end], "translate-window"), handle("trim-start", [start])];
543
+ }
545
544
  return [
546
- handle("move", move, "translate-window"),
547
- handle("trim-start", trimStart),
548
- handle("trim-end", trimEnd),
545
+ handle("move", [start, end], "translate-window"),
546
+ handle("trim-start", [start]),
547
+ handle("trim-end", [end]),
549
548
  ];
550
549
  }
@@ -15,12 +15,13 @@ import type { ServedFile } from "./compile.js";
15
15
  import type { StudioDomain } from "./domain.js";
16
16
  import type { StudioCompanionRegistry } from "./studio-registry.js";
17
17
  import { loadStudioRun } from "./run.js";
18
- import { serializeParameterValue, validateParameterValue } from "./parameter-values.js";
18
+ import { parameterAuthorValue, parameterOption, serializeParameterValue, validateParameterValue } from "./parameter-values.js";
19
19
  import { readStudioSession } from "./session.js";
20
- import type { Range, StudioFailure, StudioLibraryView, StudioMutation, StudioSnapshot } from "./shared.js";
20
+ import type { Range, StudioFailure, StudioLibraryRequest, StudioLibraryView, StudioMutation, StudioSnapshot } from "./shared.js";
21
21
  import { createStudioStoryboard } from "./storyboard.js";
22
22
  import type { StudioStoryboard } from "./storyboard.js";
23
23
  import { findSurfacePreview } from "./surface-preview.js";
24
+ import { formatTemporalPointEdit, semanticGestureSpan } from "./temporal-edit.js";
24
25
  import { replaceSourceFiles } from "./source-transaction.js";
25
26
 
26
27
  export type StudioPluginOptions = {
@@ -93,8 +94,9 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
93
94
  const watchedFiles = new Set<string>();
94
95
  const storyboards = new Map<string, Promise<StudioStoryboard>>();
95
96
 
96
- const readLibrary = async (before?: string): Promise<StudioLibraryView> => {
97
- return await options.buildLibrary?.library(before) ?? {
97
+ const readLibrary = async (request: StudioLibraryRequest): Promise<StudioLibraryView> => {
98
+ return await options.buildLibrary?.library(request) ?? {
99
+ section: request.section,
98
100
  environment: options.workspaceRoot,
99
101
  tasks: [],
100
102
  artifacts: [],
@@ -271,7 +273,7 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
271
273
  throw new Error("A timeline target must use valid whole frames.");
272
274
  }
273
275
  if (temporal.kind === "window") {
274
- if (mutation.gesture === "move"
276
+ if (mutation.gesture === "move" && handle.semantic?.kind !== "selection"
275
277
  && endFrameExclusive - startFrame !== clip.endFrameExclusive - clip.startFrame) {
276
278
  throw new Error("Move must preserve the Window duration.");
277
279
  }
@@ -287,6 +289,7 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
287
289
 
288
290
  const patches: Patch[] = [];
289
291
  const semanticTarget = mutation.target.semantic;
292
+ if (handle.semantic !== undefined && semanticTarget === undefined) throw new Error("A semantic edit requires explicit target anchors.");
290
293
  if (semanticTarget !== undefined) {
291
294
  if (handle.semantic?.kind !== semanticTarget.kind) {
292
295
  throw new Error(`Entity ${mutation.entityId} is not bound to a writable ${semanticTarget.kind}.`);
@@ -298,15 +301,9 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
298
301
  || script.narrativeId !== current.semantic.narrativeId) {
299
302
  throw new Error("The timeline entity and writable Script do not belong to the selected Narrative.");
300
303
  }
301
- if (semanticTarget.kind === "selection") {
302
- const startIndex = current.semantic.anchors.findIndex((anchor) => anchor.id === semanticTarget.startAnchorId);
303
- const endIndex = current.semantic.anchors.findIndex((anchor) => anchor.id === semanticTarget.endAnchorId);
304
- if (startIndex < 0 || endIndex < 0 || startIndex >= endIndex
305
- || current.semantic.anchors[startIndex]!.frame >= current.semantic.anchors[endIndex]!.frame) {
306
- throw new Error("A Selection must span two ordered semantic Anchors with positive time.");
307
- }
308
- } else if (!current.semantic.anchors.some((anchor) => anchor.id === semanticTarget.anchorId)) {
309
- throw new Error(`Moment Anchor ${semanticTarget.anchorId} does not exist in the current semantic Candidate.`);
304
+ const projected = semanticGestureSpan(current.semantic.anchors, handle, semanticTarget);
305
+ if (projected === undefined || projected.startFrame !== startFrame || projected.endFrameExclusive !== endFrameExclusive) {
306
+ throw new Error("The semantic edit does not produce the requested timeline projection.");
310
307
  }
311
308
  const absolute = resolve(options.workspaceRoot, script.sourcePath);
312
309
  const source = await readFile(absolute, "utf8");
@@ -366,20 +363,15 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
366
363
  }
367
364
  return snapshot?.semantic?.moments.find((candidate) => candidate.id === id)?.frame;
368
365
  };
369
- const projectedPointValue = (endpoint: StudioTemporalInstantProjection, desired: number): string => {
370
- if (endpoint.reference === "absolute") return frame(desired);
371
- const base = projectionBaseFrame(endpoint);
372
- if (base === undefined) throw new Error(`The ${endpoint.reference} projection base is unavailable.`);
373
- const offset = desired - base;
374
- return offset === 0 ? endpoint.reference : `${endpoint.reference}${offset > 0 ? "+" : ""}${offset}f`;
375
- };
366
+ const projectedPointValue = (endpoint: StudioTemporalInstantProjection, desired: number): string =>
367
+ formatTemporalPointEdit(endpoint.reference, desired, projectionBaseFrame(endpoint));
376
368
  const writeEndpoint = (
377
369
  endpoint: StudioTemporalInstantProjection,
378
370
  desired: number,
379
371
  role: "start" | "end",
380
372
  ): void => {
381
373
  if (desired === endpoint.frame) return;
382
- if (endpoint.authority.kind === "fixed") throw new Error(`The ${role} endpoint is structurally fixed.`);
374
+ if (endpoint.authority.kind === "fixed") throw new Error(`The ${role} endpoint has no timeline write target.`);
383
375
  if (endpoint.authority.kind === "semantic") {
384
376
  if (semanticFrame(endpoint) !== desired) throw new Error(`The ${role} endpoint does not match its semantic Anchor.`);
385
377
  return;
@@ -413,20 +405,22 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
413
405
  if (parameter === undefined) {
414
406
  throw new Error(`Entity ${mutation.entityId} has no writable parameter ${mutation.parameterId}.`);
415
407
  }
408
+ const authorValue = parameterAuthorValue(parameter, mutation.value);
416
409
  if (parameter.schema !== undefined) {
417
- validateParameterValue(mutation.value, parameter.schema, parameter.label);
410
+ validateParameterValue(authorValue, parameter.schema, parameter.label);
418
411
  } else if (parameter.control === "boolean" && typeof mutation.value !== "boolean") {
419
412
  throw new Error(`${parameter.label} expects true or false.`);
420
413
  } else if (parameter.control === "number" && (typeof mutation.value !== "number" || !Number.isFinite(mutation.value))) {
421
414
  throw new Error(`${parameter.label} expects a number.`);
422
- } else if ((parameter.control === "text" || parameter.control === "color" || parameter.control === "select")
415
+ } else if ((parameter.control === "text" || parameter.control === "color")
423
416
  && typeof mutation.value !== "string") {
424
417
  throw new Error(`${parameter.label} expects text.`);
425
418
  }
426
- if (parameter.options !== undefined && (typeof mutation.value !== "string" || !parameter.options.includes(mutation.value))) {
419
+ if (parameter.options !== undefined && !parameter.options.some(option => parameterOption(option).value === authorValue)) {
427
420
  throw new Error(`${parameter.label} does not accept ${mutation.value}.`);
428
421
  }
429
- const encoded = serializeParameterValue(mutation.value, parameter.language);
422
+ if (parameter.control === "color") validateParameterValue(authorValue, { kind: "string", format: "color" }, parameter.label);
423
+ const encoded = serializeParameterValue(authorValue, parameter.language);
430
424
  const replacement = `${parameter.source.prefix ?? ""}${encoded}${parameter.source.suffix ?? ""}`;
431
425
  return replacement === parameter.source.preimage ? [] : [{
432
426
  ...parameter.source,
@@ -525,6 +519,28 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
525
519
  })();
526
520
  return;
527
521
  }
522
+ if (request.method === "PUT" && url.pathname === "/__studio/artifact-name") {
523
+ void (async () => {
524
+ try {
525
+ const chunks: Buffer[] = [];
526
+ for await (const chunk of request) chunks.push(Buffer.from(chunk));
527
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
528
+ build?: unknown; output?: unknown; displayName?: unknown;
529
+ };
530
+ if (typeof body.build !== "string" || typeof body.output !== "string"
531
+ || (typeof body.displayName !== "string" && body.displayName !== null)) {
532
+ json(response, 400, { error: "Expected Build, Output and displayName" });
533
+ return;
534
+ }
535
+ if (options.buildLibrary === undefined) throw new Error("Result Repository is unavailable");
536
+ const displayName = await options.buildLibrary.renameArtifact(body.build, body.output, body.displayName);
537
+ json(response, 200, { displayName: displayName ?? null });
538
+ } catch (error) {
539
+ json(response, 422, { error: error instanceof Error ? error.message : String(error) });
540
+ }
541
+ })();
542
+ return;
543
+ }
528
544
  if (request.method === "POST" && url.pathname === "/__studio/mutation") {
529
545
  void (async () => {
530
546
  try {
@@ -577,8 +593,21 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
577
593
  return;
578
594
  }
579
595
  if (url.pathname === "/__studio/library") {
596
+ const section = url.searchParams.get("section");
597
+ if (section !== "tasks" && section !== "artifacts") {
598
+ json(response, 400, { error: "Choose tasks or artifacts with the section parameter" });
599
+ return;
600
+ }
601
+ const media = url.searchParams.get("media") ?? undefined;
602
+ if (media !== undefined && media !== "image" && media !== "video" && media !== "audio") {
603
+ json(response, 400, { error: "Choose image, video or audio with the media parameter" });
604
+ return;
605
+ }
580
606
  const before = url.searchParams.get("before") ?? undefined;
581
- void readLibrary(before).then(
607
+ const run = url.searchParams.get("run") ?? undefined;
608
+ const build = url.searchParams.get("build") ?? undefined;
609
+ void readLibrary({ section, ...(media === undefined ? {} : { media }), ...(before === undefined ? {} : { before }),
610
+ ...(run === undefined ? {} : { run }), ...(build === undefined ? {} : { build }) }).then(
582
611
  (view) => json(response, 200, view),
583
612
  (error) => json(response, 500, { error: error instanceof Error ? error.message : String(error) }),
584
613
  );
@@ -662,13 +691,12 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
662
691
  void (async () => {
663
692
  const build = url.searchParams.get("build");
664
693
  const output = url.searchParams.get("output");
665
- const valuePath = url.searchParams.get("path");
666
- if (build === null || output === null || valuePath === null || options.buildLibrary === undefined) {
694
+ if (build === null || output === null || options.buildLibrary === undefined) {
667
695
  response.statusCode = 404;
668
696
  response.end();
669
697
  return;
670
698
  }
671
- const artifact = await options.buildLibrary.openArtifact(build, output, valuePath);
699
+ const artifact = await options.buildLibrary.openArtifact(build, output);
672
700
  if (artifact === undefined) {
673
701
  response.statusCode = 404;
674
702
  response.end();