@hypit/hypit 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,11 @@
15
15
  <a href="https://github.com/hypit-ai/hypit/blob/main/README.zh-CN.md"><strong>简体中文</strong></a>
16
16
  </p>
17
17
 
18
+ <p align="center">
19
+ <a href="https://trendshift.io/repositories/229030"><img alt="Trendshift #1 Repository of the Day" src="https://trendshift.io/api/badge/trendshift/repositories/229030/daily"></a>
20
+ <a href="https://trendshift.io/repositories/229030"><img alt="Trendshift #1 TypeScript Repository of the Day" src="https://trendshift.io/api/badge/trendshift/repositories/229030/daily?language=TypeScript"></a>
21
+ </p>
22
+
18
23
  <p align="center">
19
24
  <img alt="Stars" src="https://img.shields.io/github/stars/hypit-ai/hypit?style=flat-square&color=FFD700&logo=github&logoColor=white&label=Stars">
20
25
  <a href="https://github.com/hypit-ai/hypit/blob/main/package.json"><img alt="Node 22.15+" src="https://img.shields.io/badge/Node.js-22.15%2B-5FA04E?style=flat-square&logo=nodedotjs&logoColor=white"></a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypit/hypit",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "homepage": "https://hypit.ai",
5
5
  "repository": {
6
6
  "type": "git",
@@ -279,6 +279,7 @@
279
279
  "@hypit/typography-track-studio": "workspace:*",
280
280
  "@hypit/visual-ir": "workspace:*",
281
281
  "@hypit/volcengine-matting": "workspace:*",
282
+ "@hypit/wan": "workspace:*",
282
283
  "@hypit/whisperx": "workspace:*",
283
284
  "@types/node": "24.10.1",
284
285
  "rollup": "4.62.4",
@@ -17,6 +17,7 @@ export function temporalizeCaptionDocument(
17
17
  }
18
18
  const breaks = new Set(document.cueBreaks.map((cueBreak) => cueBreak.afterUnitId));
19
19
  const timed: Array<{ unit: CaptionDocument["units"][number]; timing: TimedCaptionUnit }> = [];
20
+ const previousByRole = new Map<string | undefined, { timing: TimedCaptionUnit }>();
20
21
  for (const unit of document.units) {
21
22
  const window = tokenFrameSpan(semantic, unit.sourceTokenIds);
22
23
  if (window === undefined) continue;
@@ -25,7 +26,16 @@ export function temporalizeCaptionDocument(
25
26
  }
26
27
  const startFrame = window.startFrame;
27
28
  const endFrameExclusive = Math.max(startFrame + 1, window.endFrameExclusive);
28
- timed.push({ unit, timing: { unitId: unit.id, startFrame, endFrameExclusive } });
29
+ // Acoustic Word windows may overlap. The Timeline keeps those measurements; an authored unit
30
+ // gets one owner: as soon as the next unit of the same Role starts, this one stops. A Cue
31
+ // envelope ends at its last unit, so Cue boundaries are exclusive by the same rule.
32
+ const previous = previousByRole.get(unit.role);
33
+ if (previous !== undefined && startFrame > previous.timing.startFrame && startFrame < previous.timing.endFrameExclusive) {
34
+ previous.timing = { ...previous.timing, endFrameExclusive: startFrame };
35
+ }
36
+ const entry = { unit, timing: { unitId: unit.id, startFrame, endFrameExclusive } };
37
+ previousByRole.set(unit.role, entry);
38
+ timed.push(entry);
29
39
  }
30
40
  const cues: TimedCaptionCue[] = [];
31
41
  let current: { units: TimedCaptionUnit[]; segmentId: string; turnId: string } | undefined;
@@ -38,6 +38,9 @@ switch ($Operation) {
38
38
  # Add replaces the same resource/account; do not destroy the old value before it succeeds.
39
39
  $bytes = [Convert]::FromBase64String([string]$request.secret)
40
40
  $secret = [System.Text.Encoding]::UTF8.GetString($bytes)
41
+ if ($secret.Length -gt 512) {
42
+ throw "PasswordVault cannot store a $($secret.Length)-character secret (limit 512). Select @hypit/credential-store-file for OAuth tokens."
43
+ }
41
44
  $credential = New-Object Windows.Security.Credentials.PasswordCredential(
42
45
  [string]$request.service,
43
46
  [string]$request.account,
@@ -9,6 +9,19 @@ type WindowsCredentialResult = {
9
9
 
10
10
  const windowsScript = fileURLToPath(new URL("../runtime/windows-credential.ps1", import.meta.url));
11
11
 
12
+ /** PasswordVault password field; OAuth JSON routinely exceeds this. */
13
+ export const windowsLockerPasswordLimit = 512;
14
+
15
+ export function assertWindowsLockerSecret(secret: string, account: string): void {
16
+ if (secret.length > windowsLockerPasswordLimit) {
17
+ throw new Error(
18
+ `Windows Credential Locker cannot store the ${secret.length}-character secret for ${account}`
19
+ + ` (PasswordVault limit is ${windowsLockerPasswordLimit} characters).`
20
+ + " Select @hypit/credential-store-file for OAuth tokens.",
21
+ );
22
+ }
23
+ }
24
+
12
25
  /** The OS adapter owns this one child; Node owns its timeout, bounded output and termination. */
13
26
  export function windowsCredential(
14
27
  operation: "read" | "write" | "delete",
@@ -16,6 +29,7 @@ export function windowsCredential(
16
29
  account: string,
17
30
  secret?: string,
18
31
  ): Promise<WindowsCredentialResult> {
32
+ if (secret !== undefined) assertWindowsLockerSecret(secret, account);
19
33
  return new Promise((resolve, reject) => {
20
34
  const child = execFile("powershell.exe", [
21
35
  "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
@@ -1307,9 +1307,27 @@ export async function executeMuxProgramMedia(
1307
1307
  && compareTimestamp(finalVideo[0]!.startPts, finalAudio[0]!.startPts) === 0,
1308
1308
  "Final mux audio and video do not share one presentation origin");
1309
1309
  const finalAudioSpan = duration(finalAudio[0]!);
1310
- assert(roundPositive(finalAudioSpan.numerator * 48_000n, finalAudioSpan.denominator)
1311
- === need.audio.sampleFrames,
1312
- "Final mux audio presentation span differs from TimelineAudio");
1310
+ /*
1311
+ * The staged WAV is asserted sample-exact above; this checks what survived AAC.
1312
+ *
1313
+ * AAC cannot carry an arbitrary sample count: the encoder emits 1024-sample frames and
1314
+ * reports a priming delay that the MP4 muxer compensates with an edit list, so the
1315
+ * presented span lands a few samples away from the input. Measured here with ffmpeg 6.1
1316
+ * on synthetic tone (i.e. independent of any project's material): a 4 249 600-sample input
1317
+ * presents as 4 249 584 (-16), and 4 236 800 presents as 4 236 768 (-32). Padding the
1318
+ * timeline to a whole number of AAC frames does not remove it — the delay is not a
1319
+ * frame-alignment artifact.
1320
+ *
1321
+ * Demanding exact equality therefore rejects every correct mux whose length is not a
1322
+ * fixed point of that round trip. The real invariant AAC can hold is that no whole frame
1323
+ * of audio went missing, so the tolerance is one AAC frame rather than an arbitrary epsilon;
1324
+ * anything larger still means genuine desync and still fails.
1325
+ */
1326
+ const AAC_FRAME_SAMPLES = 1_024;
1327
+ const finalAudioSamples = roundPositive(finalAudioSpan.numerator * 48_000n, finalAudioSpan.denominator);
1328
+ assert(Math.abs(finalAudioSamples - need.audio.sampleFrames) < AAC_FRAME_SAMPLES,
1329
+ `Final mux audio presentation span differs from TimelineAudio by ${
1330
+ String(finalAudioSamples - need.audio.sampleFrames)} samples`);
1313
1331
  const artifact = await env.artifacts.putFile(output, "video/mp4");
1314
1332
  const value: MuxedMedia = sealMuxedMedia({
1315
1333
  frameRate: need.visual.frameRate,
@@ -54,6 +54,13 @@ Runtime Profile example:
54
54
 
55
55
  ```json
56
56
  {
57
+ "format": "hypit.runtime-local@1",
58
+ "dataRoot": ".hypit/runtimes/local",
59
+ "credentials": {
60
+ "platform": {
61
+ "use": "@hypit/credential-store-platform"
62
+ }
63
+ },
57
64
  "endpoints": {
58
65
  "hiapi.default": {
59
66
  "use": "@hypit/provider-hiapi",
@@ -64,7 +71,8 @@ Runtime Profile example:
64
71
  "pollIntervalMs": 10000
65
72
  }
66
73
  }
67
- }
74
+ },
75
+ "bindings": {}
68
76
  }
69
77
  ```
70
78
 
@@ -84,18 +84,26 @@ Runtime Profile example:
84
84
 
85
85
  ```json
86
86
  {
87
+ "format": "hypit.runtime-local@1",
88
+ "dataRoot": ".hypit/runtimes/local",
89
+ "credentials": {
90
+ "platform": {
91
+ "use": "@hypit/credential-store-platform"
92
+ }
93
+ },
87
94
  "endpoints": {
88
95
  "hypihub.default": {
89
96
  "use": "@hypit/provider-hypihub",
90
97
  "pool": "hypihub.default",
91
98
  "config": {
92
99
  "baseUrl": "https://hypit.ai",
93
- "apiKey": { "store": "os", "key": "hypihub.oauth" },
100
+ "apiKey": { "store": "platform", "key": "hypihub.oauth" },
94
101
  "defaultConcurrency": 3,
95
102
  "pollIntervalMs": 10000
96
103
  }
97
104
  }
98
- }
105
+ },
106
+ "bindings": {}
99
107
  }
100
108
  ```
101
109
 
@@ -1,8 +1,8 @@
1
1
  # `@hypit/provider-monid`
2
2
 
3
- Hypit Runtime Provider for a [Monid](https://monid.ai) workspace. It runs Monid's `bytedance`
4
- Seedance endpoints through `POST /v1/run`, polls `GET /v1/runs/{runId}` until the run is terminal,
5
- downloads the returned `video_url` and stores the video in the current Build.
3
+ Hypit Runtime Provider for a [Monid](https://monid.ai) workspace. It runs Monid's generation
4
+ endpoints through `POST /v1/run`, polls `GET /v1/runs/{runId}` until the run is terminal, downloads
5
+ every returned file and stores it in the current Build.
6
6
 
7
7
  | Capability | Monid endpoint |
8
8
  | --- | --- |
@@ -10,20 +10,44 @@ downloads the returned `video_url` and stores the video in the current Build.
10
10
  | `@hypit/seedance@1#seedance-2-fast` | `bytedance` `/v1/video/seedance-2.0-fast` |
11
11
  | `@hypit/seedance@1#seedance-2-mini` | `bytedance` `/v1/video/seedance-2.0-mini` |
12
12
  | `@hypit/seedance@1#seedance-2.5` | `bytedance` `/v1/video/seedance-2.5` |
13
+ | `@hypit/minimax-h3@1#minimax-h3` | `minimax` `/v1/video/minimax-h3` |
14
+ | `@hypit/wan@1#wan-2.7-image` | `alibaba` `/v1/image/wan2.7-image` |
15
+ | `@hypit/wan@1#wan-2.7-image-pro` | `alibaba` `/v1/image/wan2.7-image-pro` |
13
16
 
14
- Monid's catalogue also lists MiniMax H3 and other generation endpoints; their input schemas are
15
- published only through the authenticated `inspect` operation, so this Provider maps the Seedance
16
- endpoints whose request body Monid documents publicly.
17
+ Monid's catalogue lists further generation endpoints, including the H3 Fast, Max and Max Turbo
18
+ variants, Hailuo 2.3 and the Kling, Gemini and Qwen families; their models are not the ones the
19
+ Distribution describes. Input schemas are published through the authenticated `inspect` operation,
20
+ which is where the request bodies below come from.
17
21
 
18
- The run `input` is the BytePlus ModelArk request the endpoint relays: one `content` array holding
19
- the prompt and each media input as a typed item with its `role` (`first_frame`, `last_frame`,
20
- `reference_image`, `reference_video`, `reference_audio`), then `resolution`, `ratio`, `duration` and
21
- `generate_audio`. Monid documents no web search field for these endpoints, so `web-search="true"`
22
- is unsupported. Seedance 2.5 frame mode (`first-frame` present) requires `aspect-ratio="adaptive"`.
23
- Seedance visual references may carry `person-reference`; the Provider accepts the declaration and
24
- transmits nothing for it, since the endpoint has no field for it. Seedance rejects reference images
25
- and videos that contain a real human face; Monid offers no way to register authorized portrait
26
- material, so such a request fails with the upstream moderation error.
22
+ The video endpoints relay a BytePlus ModelArk request: one `content` array holding the prompt and
23
+ each media input as a typed item with its `role` (`first_frame`, `last_frame`, `reference_image`,
24
+ `reference_video`, `reference_audio`), then `resolution`, `ratio` and `duration`.
25
+
26
+ The Seedance endpoints add `generate_audio`. Monid documents no web search field for them, so
27
+ `web-search="true"` is unsupported, and Seedance 2.5 frame mode (`first-frame` present) requires
28
+ `aspect-ratio="adaptive"`. Seedance visual references may carry `person-reference`; the Provider
29
+ accepts the declaration and transmits nothing for it, since the endpoint has no field for it.
30
+ Seedance rejects reference images and videos that contain a real human face; Monid offers no way to
31
+ register authorized portrait material, so such a request fails with the upstream moderation error.
32
+
33
+ MiniMax H3 names its model in the body and takes neither of those two fields. The endpoint requires
34
+ a resolution, so a request that states none is sent at `2K`, the resolution the HypiHub Provider
35
+ also selects. It also requires a ratio that is not `adaptive` for text-to-video, while frame mode
36
+ resolves the framing from the uploaded image and reference-to-video defaults to adaptive: a
37
+ text-to-video request carrying no `aspect-ratio` is reported unsupported before any reference is
38
+ resolved, and the other two modes are sent as `adaptive`. H3 returns its video at `content.url`
39
+ rather than the ModelArk `video_url`.
40
+
41
+ The Wan image endpoints take their fields directly: `prompt`, `images` as plain URLs, `size` for the
42
+ band, `n`, `enable_sequential`, `thinking_mode`, `watermark` and `seed`. Both variants accept the
43
+ same fields, and the Pro variant adds the `4K` band. Two service limits are reported before any
44
+ reference is resolved: `4K` renders from a prompt alone, without reference images or an image set,
45
+ and a request renders at most four pictures outside an image set. An image set returns several
46
+ files, so collection downloads each one.
47
+
48
+ The endpoints also document a custom colour palette and per-image bounding boxes. Neither has a
49
+ representation in the model port vocabulary, which carries scalars and media rather than object
50
+ arrays, so the Provider transmits neither.
27
51
 
28
52
  Reference media are uploaded through the workspace file system Monid provides for this purpose
29
53
  (`sfs`): `/put` signs an upload for `hypit/<resource>.<ext>`, the bytes are `PUT` to that URL, and
@@ -40,6 +64,13 @@ Runtime Profile example:
40
64
 
41
65
  ```json
42
66
  {
67
+ "format": "hypit.runtime-local@1",
68
+ "dataRoot": ".hypit/runtimes/local",
69
+ "credentials": {
70
+ "platform": {
71
+ "use": "@hypit/credential-store-platform"
72
+ }
73
+ },
43
74
  "endpoints": {
44
75
  "monid.default": {
45
76
  "use": "@hypit/provider-monid",
@@ -50,7 +81,8 @@ Runtime Profile example:
50
81
  "pollIntervalMs": 10000
51
82
  }
52
83
  }
53
- }
84
+ },
85
+ "bindings": {}
54
86
  }
55
87
  ```
56
88
 
@@ -9,9 +9,11 @@
9
9
  "dependencies": {
10
10
  "@hypit/endpoint-kit": "workspace:*",
11
11
  "@hypit/generation": "workspace:*",
12
+ "@hypit/minimax-h3": "workspace:*",
12
13
  "@hypit/protocol": "workspace:*",
13
14
  "@hypit/runtime": "workspace:*",
14
- "@hypit/runtime-kit": "workspace:*"
15
+ "@hypit/runtime-kit": "workspace:*",
16
+ "@hypit/wan": "workspace:*"
15
17
  },
16
18
  "devDependencies": {
17
19
  "@hypit/seedance": "workspace:*"
@@ -2,13 +2,22 @@ import type { ModuleRef } from "@hypit/protocol";
2
2
  import type { GenerationWireMapping } from "@hypit/generation";
3
3
 
4
4
  const SEEDANCE: ModuleRef = { name: "@hypit/seedance", version: "1" };
5
+ const MINIMAX_H3: ModuleRef = { name: "@hypit/minimax-h3", version: "1" };
6
+ const WAN: ModuleRef = { name: "@hypit/wan", version: "1" };
7
+
8
+ /**
9
+ * One mapping plus the Monid provider that relays the endpoint. Monid addresses an endpoint by
10
+ * provider and path, so the provider travels with the mapping rather than being assumed.
11
+ */
12
+ export type MonidMapping = GenerationWireMapping & { readonly service: string };
5
13
 
6
14
  /**
7
15
  * Monid `bytedance` endpoints and the fields their ModelArk request body takes. Media fields name
8
16
  * the `role` of a `content` item; routes.ts folds them into that array. `personReference` is
9
17
  * accepted on visual references and not transmitted: the endpoint has no field for it.
10
18
  */
11
- const seedance = (name: string, endpoint: string): GenerationWireMapping => ({
19
+ const seedance = (name: string, endpoint: string): MonidMapping => ({
20
+ service: "bytedance",
12
21
  capability: { module: SEEDANCE, name }, result: "video", routes: [{ model: endpoint }],
13
22
  fields: {
14
23
  prompt: { as: "value", field: "text" },
@@ -25,9 +34,57 @@ const seedance = (name: string, endpoint: string): GenerationWireMapping => ({
25
34
  },
26
35
  });
27
36
 
28
- export const monidMappings: readonly GenerationWireMapping[] = [
37
+ /**
38
+ * Monid's `minimax` MiniMax-H3 endpoint. It takes the same role-tagged `content` array as the
39
+ * ModelArk endpoints above, names the model in the body, and carries neither a generated-audio nor
40
+ * a web-search field. `resolution` is required by the endpoint while the model's port is optional,
41
+ * so an unstated resolution is sent as the 2K the HypiHub Provider also selects.
42
+ */
43
+ const minimaxH3: MonidMapping = {
44
+ service: "minimax",
45
+ capability: { module: MINIMAX_H3, name: "minimax-h3" }, result: "video",
46
+ routes: [{ model: "/v1/video/minimax-h3" }],
47
+ constants: { model: "MiniMax-H3" },
48
+ fields: {
49
+ prompt: { as: "value", field: "text" },
50
+ referenceImage: { as: "urlArray", field: "reference_image" },
51
+ referenceVideo: { as: "urlArray", field: "reference_video" },
52
+ referenceAudio: { as: "urlArray", field: "reference_audio" },
53
+ firstFrame: { as: "url", field: "first_frame" },
54
+ lastFrame: { as: "url", field: "last_frame" },
55
+ resolution: { as: "value", field: "resolution", whenAbsent: "2K" },
56
+ aspectRatio: { as: "value", field: "ratio" },
57
+ duration: { as: "value", field: "duration" },
58
+ },
59
+ };
60
+
61
+ /**
62
+ * Monid's `alibaba` Wan 2.7 image endpoints. Both variants take the same fields; the Pro variant
63
+ * adds a 4K band. Input images are plain public URLs rather than role-tagged items, and the model
64
+ * reads exclusions from the prompt, so it has no negative-prompt field.
65
+ */
66
+ const wan = (name: string, endpoint: string): MonidMapping => ({
67
+ service: "alibaba",
68
+ capability: { module: WAN, name }, result: "image",
69
+ routes: [{ model: endpoint }],
70
+ fields: {
71
+ prompt: { as: "value", field: "prompt" },
72
+ images: { as: "urlArray", field: "images" },
73
+ resolution: { as: "value", field: "size" },
74
+ count: { as: "value", field: "n" },
75
+ imageSet: { as: "value", field: "enable_sequential" },
76
+ extendedReasoning: { as: "value", field: "thinking_mode" },
77
+ watermark: { as: "value", field: "watermark" },
78
+ seed: { as: "value", field: "seed" },
79
+ },
80
+ });
81
+
82
+ export const monidMappings: readonly MonidMapping[] = [
29
83
  seedance("seedance-2", "/v1/video/seedance-2.0"),
30
84
  seedance("seedance-2-fast", "/v1/video/seedance-2.0-fast"),
31
85
  seedance("seedance-2-mini", "/v1/video/seedance-2.0-mini"),
32
86
  seedance("seedance-2.5", "/v1/video/seedance-2.5"),
87
+ minimaxH3,
88
+ wan("wan-2.7-image", "/v1/image/wan2.7-image"),
89
+ wan("wan-2.7-image-pro", "/v1/image/wan2.7-image-pro"),
33
90
  ];
@@ -31,7 +31,7 @@ type Handle = {
31
31
  readonly runId: string;
32
32
  readonly route: string;
33
33
  readonly startedAt: number;
34
- readonly url?: string;
34
+ readonly urls?: readonly string[];
35
35
  };
36
36
 
37
37
  function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }
@@ -65,11 +65,22 @@ function httpsUrl(value: unknown, subject: string): string {
65
65
  assert(typeof value === "string" && /^https?:\/\//u.test(value), `${subject} has no URL`);
66
66
  return value;
67
67
  }
68
- /** The relayed ModelArk result; Monid returns the task's `video_url` in the run output. */
69
- function outputVideoUrl(run: Record<string, unknown>): string {
68
+ /**
69
+ * The relayed result. The ModelArk endpoints return the task's `video_url` and MiniMax-H3 its
70
+ * `content.url`; an image endpoint returns one entry per rendered picture. Every link expires, so
71
+ * collection downloads them rather than storing the addresses.
72
+ */
73
+ function outputUrls(run: Record<string, unknown>): readonly string[] {
70
74
  const output = object(run.output, "Monid run output");
71
- const nested = output.content !== undefined ? object(output.content, "Monid run output content").video_url : undefined;
72
- return httpsUrl(output.video_url ?? nested, "Monid run output");
75
+ const content = output.content === undefined ? undefined : object(output.content, "Monid run output content");
76
+ const listed = [output.results, output.images, output.urls, content?.results, content?.images]
77
+ .find((value): value is readonly unknown[] => Array.isArray(value) && value.length > 0);
78
+ if (listed !== undefined) {
79
+ return listed.map((item, index) => httpsUrl(
80
+ typeof item === "string" ? item : object(item, `Monid run output ${index}`).url,
81
+ `Monid run output ${index}`));
82
+ }
83
+ return [httpsUrl(output.video_url ?? output.url ?? content?.video_url ?? content?.url, "Monid run output")];
73
84
  }
74
85
  const extensions: Readonly<Record<string, string>> = {
75
86
  "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "video/mp4": "mp4", "video/quicktime": "mov",
@@ -160,7 +171,7 @@ function endpoint(client: MonidClient, pollIntervalMs: number, maxOperationMs: n
160
171
  const route = monidRouteForCapability(context.need.capability);
161
172
  assert(route !== undefined, "Monid does not implement this exact capability");
162
173
  const request = route.prepare(context.need.constraints);
163
- await context.reportProgress?.({ phase: `Preparing Monid request: bytedance ${request.endpoint}` });
174
+ await context.reportProgress?.({ phase: `Preparing Monid request: ${request.service} ${request.endpoint}` });
164
175
  let input: Record<string, unknown>;
165
176
  try {
166
177
  input = await request.compile(resolverFor(client, context, publicAssetUrl));
@@ -168,8 +179,8 @@ function endpoint(client: MonidClient, pollIntervalMs: number, maxOperationMs: n
168
179
  throw new MonidServiceError(error instanceof MonidServiceError ? error.code : "MONID_ERROR",
169
180
  `Monid request preparation failed; endpoint=${request.endpoint}; generation not submitted: ${failureMessage(error)}`);
170
181
  }
171
- await context.reportProgress?.({ phase: `Submitting Monid request: bytedance ${request.endpoint}` });
172
- const { body: run } = await client.run({ provider: "bytedance", endpoint: request.endpoint, input }, apiKey(context.credentials));
182
+ await context.reportProgress?.({ phase: `Submitting Monid request: ${request.service} ${request.endpoint}` });
183
+ const { body: run } = await client.run({ provider: request.service, endpoint: request.endpoint, input }, apiKey(context.credentials));
173
184
  const handle: Handle = { contract: "hypit.monid-operation@1", runId: runId(run), route: route.key, startedAt: Date.now() };
174
185
  const receipt = { id: handle.runId };
175
186
  const ended = monidTerminalStatuses.includes(String(run.status) as typeof monidTerminalStatuses[number]);
@@ -177,7 +188,7 @@ function endpoint(client: MonidClient, pollIntervalMs: number, maxOperationMs: n
177
188
  if (!ended) return { ...wakeAfter(canonicalize(handle), pollIntervalMs, Date.now(), { phase: String(run.status) }), receipt };
178
189
  const rejected = monidRunFailure(run, handle.runId);
179
190
  if (rejected !== undefined) return { ...failure(rejected), receipt };
180
- return { status: "ready", handle: canonicalize({ ...handle, url: outputVideoUrl(run) }), receipt };
191
+ return { status: "ready", handle: canonicalize({ ...handle, urls: outputUrls(run) }), receipt };
181
192
  } catch (error) {
182
193
  return failure(error);
183
194
  }
@@ -198,7 +209,7 @@ function endpoint(client: MonidClient, pollIntervalMs: number, maxOperationMs: n
198
209
  }
199
210
  const rejected = monidRunFailure(run, handle.runId);
200
211
  if (rejected !== undefined) return { ...failure(rejected), receipt };
201
- return { status: "ready", handle: canonicalize({ ...handle, url: outputVideoUrl(run) }), receipt };
212
+ return { status: "ready", handle: canonicalize({ ...handle, urls: outputUrls(run) }), receipt };
202
213
  } catch (error) {
203
214
  return failure(error);
204
215
  }
@@ -208,10 +219,15 @@ function endpoint(client: MonidClient, pollIntervalMs: number, maxOperationMs: n
208
219
  const handle = object(context.handle, "Monid handle") as unknown as Handle;
209
220
  const route = monidRouteForCapability(context.need.capability);
210
221
  assert(route !== undefined && handle.route === route.key, "Monid collection route differs");
211
- await context.reportProgress?.({ phase: "Receiving generated video" });
212
- const downloaded = await client.download(httpsUrl(handle.url, "Monid handle"));
213
- const blob = await context.resources.put(downloaded.bytes, downloaded.mediaType);
214
- return { status: "completed", result: { value: route.packageResult([blob]) }, receipt: { id: handle.runId } };
222
+ const urls = handle.urls ?? [];
223
+ assert(urls.length > 0, "Monid handle carries no result");
224
+ await context.reportProgress?.({ phase: `Receiving ${urls.length} generated ${route.media}${urls.length === 1 ? "" : "s"}` });
225
+ const blobs: BlobRef[] = [];
226
+ for (const url of urls) {
227
+ const downloaded = await client.download(httpsUrl(url, "Monid handle"));
228
+ blobs.push(await context.resources.put(downloaded.bytes, downloaded.mediaType));
229
+ }
230
+ return { status: "completed", result: { value: route.packageResult(blobs) }, receipt: { id: handle.runId } };
215
231
  } catch (error) {
216
232
  return failure(error);
217
233
  }
@@ -230,7 +246,7 @@ export function createMonidProvider(options: CreateMonidProviderOptions = {}) {
230
246
  const asyncEndpoint = endpoint(client, pollIntervalMs, operationTimeoutMs, options.publicAssetUrl);
231
247
  return defineEndpointPackage({
232
248
  module: monidProviderModuleRef, facet: "gateway", instance: options.instance ?? "monid.default", pool: options.pool ?? options.instance ?? "monid.default",
233
- pricing: { kind: "page", url: "https://monid.ai/tools/bytedance" },
249
+ pricing: { kind: "page", url: "https://monid.ai/tools" },
234
250
  credentials: { apiKey: options.apiKey ?? credentialRef("os", "monid.api-key") },
235
251
  credentialInputs: { apiKey: { label: "Monid API key" } },
236
252
  defaultConcurrency: options.defaultConcurrency ?? 4,
@@ -2,23 +2,29 @@ import {
2
2
  compileWireRequest,
3
3
  selectWireModelForRequest,
4
4
  generationTypes,
5
+ sealGeneratedImageSet,
5
6
  sealGeneratedVideoSet,
6
7
  } from "@hypit/generation";
7
- import type { GenerationArtifactUrlResolver, GenerationRequest, GenerationWireMapping } from "@hypit/generation";
8
+ import type { GenerationArtifactUrlResolver, GenerationRequest } from "@hypit/generation";
8
9
  import { canonicalize } from "@hypit/protocol";
9
10
  import type { BlobRef, CapabilityRef, CanonicalValue, StoredValue, TypeRef } from "@hypit/protocol";
10
11
  import type { EndpointRequest, EndpointSupport } from "@hypit/endpoint-kit";
11
12
  import { monidMappings } from "./mapping.js";
13
+ import type { MonidMapping } from "./mapping.js";
12
14
 
13
15
  export type MonidPreparedRequest = {
14
- /** Monid endpoint path under the `bytedance` provider. */
16
+ /** Monid provider relaying the endpoint. */
17
+ readonly service: string;
18
+ /** Monid endpoint path under that provider. */
15
19
  readonly endpoint: string;
16
20
  readonly compile: (resolve: GenerationArtifactUrlResolver) => Promise<Record<string, unknown>>;
17
21
  };
18
22
 
19
- export type MonidRoute = GenerationWireMapping & {
23
+ export type MonidRoute = MonidMapping & {
20
24
  readonly key: string;
21
25
  readonly returns: TypeRef;
26
+ /** What the run produces, which decides how many results collection expects. */
27
+ readonly media: "image" | "video";
22
28
  readonly supports: (request: EndpointRequest) => EndpointSupport;
23
29
  readonly prepare: (constraints: CanonicalValue) => MonidPreparedRequest;
24
30
  readonly packageResult: (artifacts: readonly BlobRef[]) => StoredValue;
@@ -35,7 +41,34 @@ function strings(value: unknown): readonly string[] {
35
41
  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
36
42
  }
37
43
 
38
- function rejection(mapping: GenerationWireMapping, request: GenerationRequest): string | undefined {
44
+ function referenceToVideo(request: GenerationRequest): boolean {
45
+ return present(request, "referenceImage") || present(request, "referenceVideo") || present(request, "referenceAudio");
46
+ }
47
+
48
+ function wanRejection(mapping: MonidMapping, request: GenerationRequest): string | undefined {
49
+ const set = scalar(request, "imageSet") === true;
50
+ if (scalar(request, "resolution") === "4K" && (present(request, "images") || set)) {
51
+ return "Wan renders 4K from a prompt alone, without reference images or an image set";
52
+ }
53
+ const count = scalar(request, "count");
54
+ if (typeof count === "number" && count > 4 && !set) {
55
+ return `Wan renders at most 4 pictures outside an image set, not ${count}`;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ function rejection(mapping: MonidMapping, request: GenerationRequest): string | undefined {
61
+ if (mapping.capability.module.name === "@hypit/wan") return wanRejection(mapping, request);
62
+ if (mapping.capability.name === "minimax-h3") {
63
+ // MiniMax-H3 resolves the framing from the uploaded image in frame mode and defaults
64
+ // reference-to-video to adaptive. Text-to-video carries no such source, and the endpoint
65
+ // takes no adaptive ratio there, so the aspect ratio has to be stated.
66
+ if (!present(request, "firstFrame") && !present(request, "lastFrame") && !referenceToVideo(request)
67
+ && scalar(request, "aspectRatio") === undefined) {
68
+ return "MiniMax H3 text-to-video on Monid requires an aspect ratio";
69
+ }
70
+ return undefined;
71
+ }
39
72
  if (scalar(request, "webSearch") === true) {
40
73
  return "Monid's Seedance endpoints document no web search field";
41
74
  }
@@ -54,6 +87,7 @@ function arkInput(input: Record<string, unknown>): Record<string, unknown> {
54
87
  const first = input.first_frame;
55
88
  const last = input.last_frame;
56
89
  return {
90
+ ...(typeof input.model === "string" ? { model: input.model } : {}),
57
91
  content: [
58
92
  { type: "text", text: input.text },
59
93
  ...(typeof first === "string" ? [contentItem("image_url", first, "first_frame")] : []),
@@ -69,6 +103,15 @@ function arkInput(input: Record<string, unknown>): Record<string, unknown> {
69
103
  };
70
104
  }
71
105
 
106
+ /**
107
+ * MiniMax-H3 takes a required ratio: frame mode and reference-to-video both resolve to adaptive,
108
+ * and rejection() has already required a stated ratio for text-to-video.
109
+ */
110
+ function minimaxH3Input(input: Record<string, unknown>): Record<string, unknown> {
111
+ const body = arkInput(input);
112
+ return { ...body, ratio: body.ratio ?? "adaptive" };
113
+ }
114
+
72
115
  function capabilityKey(capability: CapabilityRef): string {
73
116
  return `${capability.module.name}@${capability.module.version}#${capability.name}`;
74
117
  }
@@ -76,7 +119,8 @@ function capabilityKey(capability: CapabilityRef): string {
76
119
  export const monidRoutes: readonly MonidRoute[] = monidMappings.map((mapping) => ({
77
120
  ...mapping,
78
121
  key: capabilityKey(mapping.capability),
79
- returns: generationTypes.videoSet,
122
+ returns: mapping.result === "image" ? generationTypes.imageSet : generationTypes.videoSet,
123
+ media: mapping.result === "image" ? "image" as const : "video" as const,
80
124
  supports: (request) => {
81
125
  const reason = rejection(mapping, request.constraints as unknown as GenerationRequest);
82
126
  return reason === undefined ? { status: "supported" } : { status: "unsupported", reason };
@@ -85,14 +129,21 @@ export const monidRoutes: readonly MonidRoute[] = monidMappings.map((mapping) =>
85
129
  const request = constraints as unknown as GenerationRequest;
86
130
  const reason = rejection(mapping, request);
87
131
  if (reason !== undefined) throw new Error(reason);
132
+ // The Wan endpoints take the compiled fields as their body; the ModelArk endpoints fold their
133
+ // media into one role-tagged `content` array first.
134
+ const body = mapping.capability.module.name === "@hypit/wan" ? (input: Record<string, unknown>) => input
135
+ : mapping.capability.name === "minimax-h3" ? minimaxH3Input : arkInput;
88
136
  return {
137
+ service: mapping.service,
89
138
  endpoint: selectWireModelForRequest(mapping, request),
90
- compile: async (resolve) => arkInput((await compileWireRequest(mapping, request, resolve)).input as Record<string, unknown>),
139
+ compile: async (resolve) => body((await compileWireRequest(mapping, request, resolve)).input as Record<string, unknown>),
91
140
  };
92
141
  },
93
142
  packageResult: (artifacts) => ({
94
143
  kind: "inline",
95
- value: canonicalize(sealGeneratedVideoSet({ videos: artifacts })),
144
+ value: canonicalize(mapping.result === "image"
145
+ ? sealGeneratedImageSet({ images: artifacts })
146
+ : sealGeneratedVideoSet({ videos: artifacts })),
96
147
  }),
97
148
  }));
98
149
 
@@ -37,6 +37,13 @@ Runtime Profile example:
37
37
 
38
38
  ```json
39
39
  {
40
+ "format": "hypit.runtime-local@1",
41
+ "dataRoot": ".hypit/runtimes/local",
42
+ "credentials": {
43
+ "platform": {
44
+ "use": "@hypit/credential-store-platform"
45
+ }
46
+ },
40
47
  "endpoints": {
41
48
  "pollo.default": {
42
49
  "use": "@hypit/provider-pollo",
@@ -47,7 +54,8 @@ Runtime Profile example:
47
54
  "pollIntervalMs": 10000
48
55
  }
49
56
  }
50
- }
57
+ },
58
+ "bindings": {}
51
59
  }
52
60
  ```
53
61
 
@@ -46,6 +46,13 @@ Runtime Profile example:
46
46
 
47
47
  ```json
48
48
  {
49
+ "format": "hypit.runtime-local@1",
50
+ "dataRoot": ".hypit/runtimes/local",
51
+ "credentials": {
52
+ "platform": {
53
+ "use": "@hypit/credential-store-platform"
54
+ }
55
+ },
49
56
  "endpoints": {
50
57
  "tokendance.default": {
51
58
  "use": "@hypit/provider-tokendance",
@@ -56,7 +63,8 @@ Runtime Profile example:
56
63
  "pollIntervalMs": 10000
57
64
  }
58
65
  }
59
- }
66
+ },
67
+ "bindings": {}
60
68
  }
61
69
  ```
62
70
 
@@ -0,0 +1,31 @@
1
+ # `@hypit/wan`
2
+
3
+ Exact author/compute contracts and package-owned author Surfaces for Wan 2.7 Image and Wan 2.7 Image
4
+ Pro.
5
+
6
+ The model variants are separate endpoints with exact request validation. Their Surfaces project the
7
+ primary result to an ordinary image Artifact. The package contains no Provider selection, API key or
8
+ network execution. The selected Provider implements its exact capability.
9
+
10
+ Import the model variant you mean and connect prompt and references as ordinary graph edges:
11
+
12
+ ```xml
13
+ <wan:Image id="draft" prompt={prompt} resolution="2K">
14
+ <wan:Reference image={product.image}/>
15
+ </wan:Image>
16
+
17
+ <wan:ProImage id="hero" prompt={heroPrompt} resolution="4K"/>
18
+ ```
19
+
20
+ The Surface only lowers this syntax into the package's exact model request. It does not select a
21
+ Provider.
22
+
23
+ Both variants accept prompts up to 5,000 characters and up to nine references, and render at `1K` or
24
+ `2K`; the Pro variant adds `4K`. The model takes no aspect ratio: with references present the output
25
+ takes the shape of the last one, and a prompt-only element renders at the band's own framing. There
26
+ is no negative prompt either, so write exclusions into the prompt.
27
+
28
+ `count` renders up to four pictures. Setting `image-set` renders one storyline across several
29
+ pictures instead, where `count` names a ceiling of up to twelve and the model chooses how many it
30
+ returns. An image set shapes a storyline the way `extended-reasoning` shapes a single picture, so the
31
+ two are not stated together.
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@hypit/wan",
3
+ "version": "0.0.0-dev",
4
+ "license": "SEE LICENSE IN LICENSE",
5
+ "private": true,
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "hypit": {
11
+ "activation": "./src/activation.ts"
12
+ },
13
+ "dependencies": {
14
+ "@hypit/artifact": "workspace:*",
15
+ "@hypit/generation": "workspace:*",
16
+ "@hypit/markup": "workspace:*",
17
+ "@hypit/model-kit": "workspace:*",
18
+ "@hypit/protocol": "workspace:*",
19
+ "@hypit/text": "workspace:*"
20
+ }
21
+ }
@@ -0,0 +1,11 @@
1
+ import { createMarkupSurfaceHostFacet } from "@hypit/markup";
2
+ import { wanComponent, wanDefinition, wanManifest, wanModuleRef, wanMarkupSurfaces } from "./index.js";
3
+ import { decodeWanImageSurface, decodeWanProImageSurface } from "./surface.js";
4
+ export const hypitPackage = { format: "hypit.node-package@1" as const, modules: [{ manifest: wanManifest }], components: [wanComponent], hostFacets: [
5
+ wanDefinition.hostFacet,
6
+ createMarkupSurfaceHostFacet({ module: wanModuleRef,
7
+ declaration: wanMarkupSurfaces.find((item) => item.name === "image")!, handler: decodeWanImageSurface }),
8
+ createMarkupSurfaceHostFacet({ module: wanModuleRef,
9
+ declaration: wanMarkupSurfaces.find((item) => item.name === "pro-image")!, handler: decodeWanProImageSurface }),
10
+ ] };
11
+ export default hypitPackage;
@@ -0,0 +1,160 @@
1
+ import { artifactTypes } from "@hypit/artifact";
2
+ import { sealGenerationPortRequest, sealGenerationPortTable } from "@hypit/generation";
3
+ import type { GenerationPortTable, GenerationPortValue, GenerationRequest } from "@hypit/generation";
4
+ import type { SurfaceAttributeVocabulary, SurfaceChildVocabulary } from "@hypit/markup";
5
+ import { defineExactModelModule } from "@hypit/model-kit";
6
+ import { textTypes } from "@hypit/text";
7
+
8
+ export const wanModuleRef = { name: "@hypit/wan", version: "1" } as const;
9
+ export const wanModels = ["wan-2.7-image", "wan-2.7-image-pro"] as const;
10
+ export type WanModel = typeof wanModels[number];
11
+
12
+ /**
13
+ * The model renders at one of these bands. It takes no aspect ratio: with input images the output
14
+ * follows the last one, and a prompt-only request renders at the band's own framing.
15
+ */
16
+ const WAN_RESOLUTIONS = ["1K", "2K"] as const;
17
+ const WAN_PRO_RESOLUTIONS = ["1K", "2K", "4K"] as const;
18
+
19
+ function wanPortTable(model: WanModel): GenerationPortTable {
20
+ const pro = model === "wan-2.7-image-pro";
21
+ return sealGenerationPortTable({
22
+ model,
23
+ result: "image",
24
+ ports: [
25
+ { name: "prompt", value: { kind: "text", maxChars: 5_000 }, minItems: 1, maxItems: 1 },
26
+ { name: "images", value: { kind: "media", accepts: ["image"] }, minItems: 0, maxItems: 9 },
27
+ {
28
+ name: "resolution",
29
+ value: { kind: "enum", values: pro ? [...WAN_PRO_RESOLUTIONS] : [...WAN_RESOLUTIONS] },
30
+ minItems: 0,
31
+ maxItems: 1,
32
+ },
33
+ // One request renders up to four pictures, or up to twelve as a story-coherent set. The
34
+ // model chooses how many of an image set it returns, so this names a ceiling.
35
+ { name: "count", value: { kind: "number", integer: true, minimum: 1, maximum: 12 }, minItems: 0, maxItems: 1 },
36
+ { name: "imageSet", value: { kind: "boolean" }, minItems: 0, maxItems: 1 },
37
+ { name: "extendedReasoning", value: { kind: "boolean" }, minItems: 0, maxItems: 1 },
38
+ { name: "watermark", value: { kind: "boolean" }, minItems: 0, maxItems: 1 },
39
+ { name: "seed", value: { kind: "number", integer: true, minimum: 0, maximum: 2_147_483_647 }, minItems: 0, maxItems: 1 },
40
+ ],
41
+ // An image set renders one storyline in several pictures, which is what extended reasoning
42
+ // would otherwise shape for a single picture.
43
+ requires: [{ kind: "atMostOneOf", ports: ["imageSet", "extendedReasoning"] }],
44
+ });
45
+ }
46
+
47
+ export const wanPorts: Readonly<Record<WanModel, GenerationPortTable>> = {
48
+ "wan-2.7-image": wanPortTable("wan-2.7-image"),
49
+ "wan-2.7-image-pro": wanPortTable("wan-2.7-image-pro"),
50
+ };
51
+
52
+ export function sealWanRequest(
53
+ model: WanModel,
54
+ ports: Readonly<Record<string, readonly GenerationPortValue[]>>,
55
+ ): GenerationRequest {
56
+ return sealGenerationPortRequest(wanPorts[model], ports);
57
+ }
58
+
59
+ const wanBaseDefinition = defineExactModelModule({
60
+ module: wanModuleRef,
61
+ endpoints: wanModels.map((model) => ({
62
+ key: model === "wan-2.7-image" ? "image" : "pro",
63
+ requestTypeName: model === "wan-2.7-image" ? "WanImageRequest" : "WanProImageRequest",
64
+ producerName: `request-${model}`,
65
+ ports: wanPorts[model],
66
+ })),
67
+ });
68
+
69
+ export const wanEndpoints = wanBaseDefinition.endpoints;
70
+ export const wanComponent = wanBaseDefinition.component;
71
+
72
+ function wanAttributes(pro: boolean): readonly SurfaceAttributeVocabulary[] {
73
+ return [
74
+ { name: "id", kind: "identifier", required: true,
75
+ summary: "Names this generation and prefixes the bindings it publishes." },
76
+ { name: "prompt", kind: "reference", required: true, accepts: [textTypes.text],
77
+ summary: "The Text edge describing the picture, the edit to make, or the storyline of an image set." },
78
+ { name: "resolution", kind: "literal", required: false,
79
+ values: pro ? [...WAN_PRO_RESOLUTIONS] : [...WAN_RESOLUTIONS],
80
+ summary: "The size band the model renders at." },
81
+ { name: "count", kind: "literal", required: false,
82
+ summary: "How many pictures to render, up to four, or the ceiling of an image set." },
83
+ { name: "image-set", kind: "literal", required: false, values: ["true", "false"],
84
+ summary: "Renders one storyline as several pictures instead of one picture." },
85
+ { name: "extended-reasoning", kind: "literal", required: false, values: ["true", "false"],
86
+ summary: "Spends longer shaping a single picture before rendering it." },
87
+ { name: "watermark", kind: "literal", required: false, values: ["true", "false"],
88
+ summary: "Marks the returned pictures as AI generated." },
89
+ { name: "seed", kind: "literal", required: false,
90
+ summary: "Seeds the model's sampling so a rerun stays close to this one." },
91
+ ];
92
+ }
93
+
94
+ const wanChildren: readonly SurfaceChildVocabulary[] = [
95
+ { tag: "Reference", cardinality: "many",
96
+ summary: "Attaches one image Artifact the model edits or draws on.",
97
+ attributes: [
98
+ { name: "image", kind: "reference", required: true, accepts: [artifactTypes.blob],
99
+ summary: "Selects the image Artifact this reference contributes." },
100
+ ] },
101
+ ];
102
+
103
+ const surface = (
104
+ name: "image" | "pro-image",
105
+ tag: "Image" | "ProImage",
106
+ endpoint: (typeof wanEndpoints)["image" | "pro"],
107
+ pro: boolean,
108
+ summary: string,
109
+ example: string,
110
+ ) => ({
111
+ name,
112
+ tag,
113
+ mode: "structured" as const,
114
+ outputs: [endpoint!.draftType, endpoint!.mediaBindings.images!.type],
115
+ vocabulary: {
116
+ summary,
117
+ attributes: wanAttributes(pro),
118
+ children: wanChildren,
119
+ ports: [{
120
+ name: "image",
121
+ type: artifactTypes.blob,
122
+ summary: "The primary generated image, addressed as `<id>.image`.",
123
+ }],
124
+ example,
125
+ notes: [
126
+ "The element accepts at most 9 `Reference` children and no text content.",
127
+ "With references present the output takes the shape of the last one; a prompt-only element renders at the band's own framing.",
128
+ "The model reads exclusions from the prompt itself, so write them there.",
129
+ "Every `Reference` is an ordinary image Artifact edge; the Surface copies no runtime media into request metadata.",
130
+ "The Surface lowers the element into the package's exact model request and selects no Provider.",
131
+ ],
132
+ },
133
+ });
134
+
135
+ export const wanMarkupSurfaces = [
136
+ surface("image", "Image", wanEndpoints.image!, false,
137
+ "Generates pictures with the exact Wan 2.7 Image model from a Text prompt and optional reference images.",
138
+ `<wan:Image
139
+ id="draft"
140
+ prompt={prompt}
141
+ resolution="2K"
142
+ >
143
+ <wan:Reference image={product.image}/>
144
+ </wan:Image>`),
145
+ surface("pro-image", "ProImage", wanEndpoints.pro!, true,
146
+ "Generates pictures with the exact Wan 2.7 Image Pro model from a Text prompt and optional reference images.",
147
+ `<wan:ProImage
148
+ id="hero"
149
+ prompt={heroPrompt}
150
+ resolution="4K"
151
+ />`),
152
+ ] as const;
153
+
154
+ export const wanManifest = {
155
+ ...wanBaseDefinition.manifest,
156
+ };
157
+ export const wanDefinition = {
158
+ ...wanBaseDefinition,
159
+ manifest: wanManifest,
160
+ };
@@ -0,0 +1,147 @@
1
+ import { artifactTypes } from "@hypit/artifact";
2
+ import { generationPort, sealGenerationMediaBinding, sealGenerationRequestDraft } from "@hypit/generation";
3
+ import type { GenerationMediaPort, GenerationPortValue } from "@hypit/generation";
4
+ import {
5
+ createExactModelPrimaryGenerationFragment,
6
+ exactModelMediaInputNames,
7
+ exactModelTextInputName,
8
+ } from "@hypit/model-kit";
9
+ import type { ExactModelEndpoint } from "@hypit/model-kit";
10
+ import type {
11
+ MarkupAttributeValue,
12
+ StructuredElement,
13
+ StructuredSurfaceHandler,
14
+ SurfaceResolvedReference,
15
+ } from "@hypit/markup";
16
+ import type { CanonicalValue, TypeRef } from "@hypit/protocol";
17
+ import { textTypes, verifyText } from "@hypit/text";
18
+
19
+ import { wanEndpoints } from "./index.js";
20
+
21
+ function assert(condition: unknown, message: string): asserts condition {
22
+ if (!condition) throw new Error(message);
23
+ }
24
+
25
+ function localName(name: string): string {
26
+ return name.includes(":") ? name.slice(name.lastIndexOf(":") + 1) : name;
27
+ }
28
+
29
+ function sameType(left: TypeRef, right: TypeRef): boolean {
30
+ return left.name === right.name && left.module.name === right.module.name && left.module.version === right.module.version;
31
+ }
32
+
33
+ function exact(element: StructuredElement, allowed: readonly string[], required: readonly string[]): void {
34
+ const unknown = Object.keys(element.attributes).filter((name) => !allowed.includes(name));
35
+ assert(unknown.length === 0, `${element.name} does not accept ${unknown[0]}`);
36
+ const missing = required.filter((name) => element.attributes[name] === undefined);
37
+ assert(missing.length === 0, `${element.name} requires ${missing.join(", ")}`);
38
+ }
39
+
40
+ function text(element: StructuredElement, name: string): string {
41
+ const value = element.attributes[name];
42
+ assert(typeof value === "string" && value.trim().length > 0, `${element.name}.${name} must be text`);
43
+ return value.trim();
44
+ }
45
+
46
+ /** An absent optional attribute leaves its port unstated, which the model reads as its own default. */
47
+ function optionalText(element: StructuredElement, name: string): readonly GenerationPortValue[] | undefined {
48
+ return element.attributes[name] === undefined ? undefined : [text(element, name)];
49
+ }
50
+
51
+ function optionalFlag(element: StructuredElement, name: string): readonly GenerationPortValue[] | undefined {
52
+ if (element.attributes[name] === undefined) return undefined;
53
+ const value = text(element, name);
54
+ assert(value === "true" || value === "false", `${element.name}.${name} must be true or false`);
55
+ return [value === "true"];
56
+ }
57
+
58
+ function optionalInteger(element: StructuredElement, name: string): readonly GenerationPortValue[] | undefined {
59
+ if (element.attributes[name] === undefined) return undefined;
60
+ const value = text(element, name);
61
+ assert(/^\d+$/u.test(value), `${element.name}.${name} must be a whole number`);
62
+ return [Number(value)];
63
+ }
64
+
65
+ function ref(
66
+ element: StructuredElement,
67
+ name: string,
68
+ type: TypeRef,
69
+ resolve: (path: string) => SurfaceResolvedReference | undefined,
70
+ ): SurfaceResolvedReference {
71
+ const value: MarkupAttributeValue | undefined = element.attributes[name];
72
+ assert(typeof value === "object" && value.kind === "reference", `${element.name}.${name} must be a reference`);
73
+ const result = resolve(value.path);
74
+ assert(result !== undefined && sameType(result.type, type), `${element.name}.${name} has the wrong type`);
75
+ return result;
76
+ }
77
+
78
+ const attributes = ["id", "prompt", "resolution", "count", "image-set", "extended-reasoning", "watermark", "seed"] as const;
79
+
80
+ function decoder(endpoint: ExactModelEndpoint): StructuredSurfaceHandler {
81
+ return ({ element, resolveReference }) => {
82
+ exact(element, [...attributes], ["id", "prompt"]);
83
+ const id = text(element, "id");
84
+ const prompt = ref(element, "prompt", textTypes.text, resolveReference);
85
+ if (prompt.record !== undefined) {
86
+ assert(prompt.record.value.kind === "inline", `${element.name}.prompt must reference Text`);
87
+ verifyText(prompt.record.value.value);
88
+ }
89
+ const imagePort = generationPort(endpoint.ports, "images");
90
+ assert(imagePort.value.kind === "media", "Wan images port is not media");
91
+ const images: SurfaceResolvedReference[] = [];
92
+ for (const child of element.children) {
93
+ if (child.kind === "text") {
94
+ assert(child.value.trim().length === 0, `${element.name} accepts only Reference children`);
95
+ continue;
96
+ }
97
+ assert(localName(child.name) === "Reference", `${element.name} accepts only Reference children`);
98
+ exact(child, ["image"], ["image"]);
99
+ assert(!child.children.some((item) => item.kind === "element" || item.value.trim()), `${child.name} must be empty`);
100
+ const image = ref(child, "image", artifactTypes.blob, resolveReference);
101
+ if (image.record !== undefined) {
102
+ assert(image.record.value.kind === "blob" && image.record.value.mediaType.startsWith("image/"),
103
+ `${child.name}.image must reference image media`);
104
+ }
105
+ images.push(image);
106
+ }
107
+ assert(images.length <= imagePort.maxItems, `${element.name} accepts at most ${imagePort.maxItems} references`);
108
+ const stated: Record<string, readonly GenerationPortValue[] | undefined> = {
109
+ resolution: optionalText(element, "resolution"),
110
+ count: optionalInteger(element, "count"),
111
+ imageSet: optionalFlag(element, "image-set"),
112
+ extendedReasoning: optionalFlag(element, "extended-reasoning"),
113
+ watermark: optionalFlag(element, "watermark"),
114
+ seed: optionalInteger(element, "seed"),
115
+ };
116
+ const draft = sealGenerationRequestDraft(endpoint.ports, Object.fromEntries(
117
+ Object.entries(stated).filter((entry): entry is [string, readonly GenerationPortValue[]] => entry[1] !== undefined),
118
+ ));
119
+ const records: Array<{ id: string; type: TypeRef; value: { kind: "inline"; value: CanonicalValue }; range: StructuredElement["range"] }> = [{
120
+ id: `${id}.draft`, type: endpoint.draftType,
121
+ value: { kind: "inline", value: draft as unknown as CanonicalValue }, range: element.range,
122
+ }];
123
+ const inputs: Record<string, SurfaceResolvedReference["ref"] | { kind: "record"; id: string }> = {
124
+ draft: { kind: "record", id: `${id}.draft` }, [exactModelTextInputName("prompt")]: prompt.ref,
125
+ };
126
+ const media = images.map((image, index) => {
127
+ const name = `image-${String(index + 1).padStart(4, "0")}`;
128
+ const bindingId = `${id}.${name}.binding`;
129
+ records.push({
130
+ id: bindingId, type: endpoint.mediaBindings.images!.type,
131
+ value: { kind: "inline", value: sealGenerationMediaBinding(imagePort as GenerationMediaPort, { role: "image" }) as unknown as CanonicalValue },
132
+ range: element.range,
133
+ });
134
+ const names = exactModelMediaInputNames(name);
135
+ inputs[names.binding] = { kind: "record", id: bindingId };
136
+ inputs[names.artifact] = image.ref;
137
+ return { name, port: "images" } as const;
138
+ });
139
+ const fragment = createExactModelPrimaryGenerationFragment(endpoint, media, [{ name: "prompt", port: "prompt" }]);
140
+ return { records, fragments: [fragment], components: [{
141
+ id, fragment: fragment.id, inputs, outputs: { image: `${id}.image` }, range: element.range,
142
+ }] };
143
+ };
144
+ }
145
+
146
+ export const decodeWanImageSurface = decoder(wanEndpoints.image!);
147
+ export const decodeWanProImageSurface = decoder(wanEndpoints.pro!);