@mokuyoaxis/dsh-iris 0.2.0 → 0.2.2
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/.dsh/skills/iris-compose-media/SKILL.md +10 -5
- package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
- package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
- package/CHANGELOG.md +69 -2
- package/README.en.md +67 -31
- package/README.md +67 -31
- package/bin/dsh-iris.js +222 -21
- package/docs/AGENT_QUICKSTART.md +10 -6
- package/docs/ARCHITECTURE.md +34 -19
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +1 -1
- package/docs/CLI_MANAGEMENT.md +183 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +16 -7
- package/docs/DSH_CORE_MIGRATION.md +48 -19
- package/docs/DSH_RC2_ADAPTATION.md +4 -2
- package/docs/FAULT_INJECTION.md +21 -2
- package/docs/HEADLESS_CLI.md +15 -7
- package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +21 -14
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +77 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +15 -5
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +37 -19
- package/docs/SECURITY.md +5 -5
- package/docs/TASK_SEMANTICS.md +2 -2
- package/docs/VISION_CLI.md +132 -0
- package/docs/VISION_MODEL.md +51 -0
- package/docs/WORKBENCH_ARTIFACTS.md +67 -0
- package/docs/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.1.md +56 -0
- package/docs/releases/0.2.2.md +31 -0
- package/lib/actions.js +199 -286
- package/lib/adapters.js +216 -41
- package/lib/api.js +49 -7
- package/lib/capability.js +23 -16
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +933 -117
- package/lib/command-service.js +121 -7
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +102 -14
- package/lib/core-artifact-store.js +45 -13
- package/lib/core-lease-recovery.js +64 -92
- package/lib/core-maintenance.js +213 -0
- package/lib/core-runtime.js +45 -47
- package/lib/core-tasks.js +12 -5
- package/lib/core-user-projection.js +1 -0
- package/lib/core-writer-lease.js +134 -0
- package/lib/dsh-core-adapter.js +63 -9
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +36 -5
- package/lib/headless-vision.js +181 -0
- package/lib/host-runtime.js +1 -0
- package/lib/http-vision-model-adapter.js +187 -0
- package/lib/image-edit-input.js +34 -0
- package/lib/index.js +205 -175
- package/lib/locate.js +86 -28
- package/lib/media-probe.js +2 -1
- package/lib/model-call-runtime.js +45 -0
- package/lib/model-invoker.js +4 -2
- package/lib/model-port-contract.js +13 -5
- package/lib/models.js +29 -42
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +21 -10
- package/lib/prompt-optimizer-core.js +247 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapters.js +87 -10
- package/lib/provider-catalog.js +19 -7
- package/lib/provider-config-service.js +235 -0
- package/lib/provider-contract.js +3 -2
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +84 -0
- package/lib/provider-task-runner.js +63 -19
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- package/lib/video-input.js +30 -0
- package/lib/vision-core.js +42 -0
- package/lib/vision-image-input.js +83 -0
- package/lib/vision-input.js +31 -0
- package/lib/vision-model-routing.js +180 -0
- package/lib/vision.js +3 -2
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +1 -1
- package/user_guide.md +74 -30
package/lib/provider-protocol.js
CHANGED
|
@@ -45,6 +45,29 @@ export function selectMediaProtocol(provider) {
|
|
|
45
45
|
return { mediaProtocol: detected || 'openai-images', protocolInferred: !detected };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** 模型图片协议只覆盖生图;auto 沿用账号级默认,不另外猜测端点。 */
|
|
49
|
+
export function normalizeImageProtocol(value) {
|
|
50
|
+
const protocol = typeof value === 'string' ? value.trim() : '';
|
|
51
|
+
if (!['auto', 'dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol)) {
|
|
52
|
+
throw Object.assign(new TypeError('imageProtocol 必须是 auto、dashscope、openai-images、openai-chat-images 或 openai-responses-images'), {
|
|
53
|
+
code: 'IRIS_PROVIDER_IMAGE_PROTOCOL_INVALID'
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return protocol;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function selectImageProtocol(provider, modelId) {
|
|
60
|
+
const entry = Array.isArray(provider?.models) ? provider.models.find(model => model?.id?.trim() === modelId) : null;
|
|
61
|
+
if (entry?.imageProtocol === undefined) return selectMediaProtocol(provider);
|
|
62
|
+
const protocol = normalizeImageProtocol(entry.imageProtocol);
|
|
63
|
+
return protocol === 'auto' ? selectMediaProtocol(provider) : { mediaProtocol: protocol, protocolInferred: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 给已绑定图片模型的 Adapter/binding 投影协议,不改写账号默认值。 */
|
|
67
|
+
export function providerForImageModel(provider, modelId) {
|
|
68
|
+
return { ...provider, ...selectImageProtocol(provider, modelId) };
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
export function unsupportedProtocolError(protocol, kind = 'media') {
|
|
49
72
|
const safeName = /^[a-z][a-z0-9-]{0,63}$/.test(String(protocol)) ? protocol : '(invalid)';
|
|
50
73
|
const error = new TypeError('iris: 不支持的 ' + (kind === 'vision' ? 'Vision' : 'Provider') + ' 协议:' + safeName);
|
|
@@ -72,3 +95,64 @@ export function isConfiguredProvider(provider) {
|
|
|
72
95
|
&& providerMediaBaseUrl(provider)
|
|
73
96
|
&& (provider.auth === 'none' || (typeof provider.apiKey === 'string' && provider.apiKey.trim())));
|
|
74
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 生图尺寸的协议差异集中在这里,调用点不再自带厂商假设。
|
|
101
|
+
*
|
|
102
|
+
* 分隔符(2026-10-11 实测线上端点):
|
|
103
|
+
* - dashscope 用 `宽*高`;多模态端点严格校验格式,传 `100x100` 直接回
|
|
104
|
+
* `Invalid size format`;旧异步端点提交期完全不校验(`100x100`/`100X100`/`auto` 均 200),
|
|
105
|
+
* 由具体模型在生成期报错。
|
|
106
|
+
* - OpenAI 兼容三家用 `宽x高`;尺寸集合由各家网关决定,只做格式归一,不设数值白名单。
|
|
107
|
+
*
|
|
108
|
+
* 数值边界只登记服务端确证会拒绝的那一条:
|
|
109
|
+
* - dashscope 多模态:`Image area must be between 262144 (512x512) and 4194304 (2048x2048)`。
|
|
110
|
+
* - 其余协议暂无证据,一律留空 —— 本地不拦,交给上游报错,避免比服务端更严。
|
|
111
|
+
*/
|
|
112
|
+
export const IMAGE_SIZE_RULES = Object.freeze({
|
|
113
|
+
dashscope: Object.freeze({ separator: '*' }),
|
|
114
|
+
'openai-images': Object.freeze({ separator: 'x' }),
|
|
115
|
+
'openai-chat-images': Object.freeze({ separator: 'x' }),
|
|
116
|
+
'openai-responses-images': Object.freeze({ separator: 'x' }),
|
|
117
|
+
'dashscope/multimodal-sync': Object.freeze({
|
|
118
|
+
separator: '*', minArea: 262144, maxArea: 4194304
|
|
119
|
+
})
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** 协议级规则优先于账号级;都没有则返回 null(= 本地不约束)。 */
|
|
123
|
+
export function imageSizeRule(protocol, mode) {
|
|
124
|
+
const key = mode ? String(protocol) + '/' + String(mode) : String(protocol);
|
|
125
|
+
return IMAGE_SIZE_RULES[key] || IMAGE_SIZE_RULES[String(protocol)] || null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 解析 `宽<分隔符>高`;无法识别返回 null(含 `auto` 这类自由取值)。 */
|
|
129
|
+
export function parseImageSize(size) {
|
|
130
|
+
const m = /^(\d+)\s*([*x×X])\s*(\d+)$/.exec(String(size ?? '').trim());
|
|
131
|
+
if (!m) return null;
|
|
132
|
+
const width = Number(m[1]);
|
|
133
|
+
const height = Number(m[3]);
|
|
134
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
|
|
135
|
+
return { width, height, separator: m[2] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 把尺寸归一成该协议的规范分隔符;无法识别的原样返回。 */
|
|
139
|
+
export function normalizeImageSize({ protocol, mode, size } = {}) {
|
|
140
|
+
const rule = imageSizeRule(protocol, mode);
|
|
141
|
+
if (!rule || !rule.separator) return size;
|
|
142
|
+
const parsed = parseImageSize(size);
|
|
143
|
+
if (!parsed) return size;
|
|
144
|
+
return parsed.width + rule.separator + parsed.height;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 返回违规说明;合规、无规则或无法识别时返回 null。纯函数:不抛错、不依赖具体错误类型。 */
|
|
148
|
+
export function imageSizeViolation({ protocol, mode, size } = {}) {
|
|
149
|
+
const rule = imageSizeRule(protocol, mode);
|
|
150
|
+
if (!rule || rule.minArea === undefined) return null;
|
|
151
|
+
const parsed = parseImageSize(size);
|
|
152
|
+
if (!parsed) return null;
|
|
153
|
+
const { width, height } = parsed;
|
|
154
|
+
const area = width * height;
|
|
155
|
+
if (area >= rule.minArea && area <= rule.maxArea) return null;
|
|
156
|
+
const label = mode ? protocol + '/' + mode : protocol;
|
|
157
|
+
return `尺寸 ${width}*${height} 不被 ${label} 接受:总像素需在 ${rule.minArea}–${rule.maxArea} 之间`;
|
|
158
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { adoptCoreArtifactFile, createCoreArtifact } from './core-artifacts.js';
|
|
7
8
|
import {
|
|
8
9
|
activateCoreWatch,
|
|
9
10
|
beginCoreAttempt,
|
|
@@ -28,7 +29,8 @@ import {
|
|
|
28
29
|
hasProviderOperation,
|
|
29
30
|
invokeProviderOperation
|
|
30
31
|
} from './provider-adapter.js';
|
|
31
|
-
import { providerErrorRecord, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
32
|
+
import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
33
|
+
import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
|
|
32
34
|
|
|
33
35
|
export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
34
36
|
|
|
@@ -38,7 +40,7 @@ export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
|
38
40
|
* 失败,不会按图片语义猜。
|
|
39
41
|
*/
|
|
40
42
|
export const DELIVERY_PROFILES = Object.freeze({
|
|
41
|
-
image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png']), defaultMediaType: 'image/png' }),
|
|
43
|
+
image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp']), defaultMediaType: 'image/png' }),
|
|
42
44
|
video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4']), defaultMediaType: 'video/mp4' }),
|
|
43
45
|
// TTS 同步完成型:submit 内 completed → 同一次调用直接交付(无 remote 长轮询)。
|
|
44
46
|
tts: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
|
|
@@ -81,7 +83,8 @@ function normalizedCandidates(capability, candidates) {
|
|
|
81
83
|
|| (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))) {
|
|
82
84
|
throw new TypeError('Provider 候选 ' + (index + 1) + ' 不支持 Task capability 或模型复合身份无效');
|
|
83
85
|
}
|
|
84
|
-
|
|
86
|
+
if (candidate.prepareInput !== undefined && typeof candidate.prepareInput !== 'function') throw new TypeError('候选 prepareInput 必须是函数');
|
|
87
|
+
return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, selectionReason, prepareInput: candidate.prepareInput };
|
|
85
88
|
});
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -91,6 +94,21 @@ function stagingFile(dataRoot) {
|
|
|
91
94
|
return path.join(directory, 'download-' + process.pid + '-' + crypto.randomBytes(8).toString('hex') + '.part');
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
async function generatedImageMediaType(bytes) {
|
|
98
|
+
let format;
|
|
99
|
+
try { format = (await sharp(bytes).metadata()).format; }
|
|
100
|
+
catch (_) {
|
|
101
|
+
throw new ProviderContractError('供应商产物不是可识别的图片', {
|
|
102
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const mediaType = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' }[format];
|
|
106
|
+
if (!mediaType) throw new ProviderContractError('供应商图片格式仅支持 PNG、JPEG、WebP', {
|
|
107
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
108
|
+
});
|
|
109
|
+
return mediaType;
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, signal) {
|
|
95
113
|
beginCoreDelivery(dataRoot, taskId);
|
|
96
114
|
const artifactIds = [];
|
|
@@ -110,7 +128,7 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
|
|
|
110
128
|
}
|
|
111
129
|
for (const remote of remoteArtifacts) {
|
|
112
130
|
const mediaType = String(remote.mediaType || profile.defaultMediaType);
|
|
113
|
-
if (!profile.mediaTypes.includes(mediaType)) {
|
|
131
|
+
if (task.capability !== 'image' && !profile.mediaTypes.includes(mediaType)) {
|
|
114
132
|
throw Object.assign(new Error('Provider Task Runner 交付媒体类型不在 Profile 白名单:' + mediaType), {
|
|
115
133
|
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
116
134
|
});
|
|
@@ -129,15 +147,23 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
|
|
|
129
147
|
stage: 'download', category: 'local_io', acceptance: 'accepted'
|
|
130
148
|
});
|
|
131
149
|
}
|
|
150
|
+
const staged = stagedFiles.at(-1);
|
|
151
|
+
if (task.capability === 'image') {
|
|
152
|
+
staged.bytes = fs.readFileSync(target);
|
|
153
|
+
staged.mediaType = await generatedImageMediaType(staged.bytes);
|
|
154
|
+
}
|
|
132
155
|
}
|
|
133
156
|
recordCoreDownloadCompleted(dataRoot, taskId);
|
|
134
157
|
for (const staged of stagedFiles) {
|
|
135
|
-
const
|
|
158
|
+
const input = {
|
|
136
159
|
kind: profile.kind,
|
|
137
160
|
mediaType: staged.mediaType,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
161
|
+
metadata: { taskId, capability: task.capability },
|
|
162
|
+
...(task.sourceArtifactId ? { relations: [{ type: 'derived-from', artifactId: task.sourceArtifactId }] } : {})
|
|
163
|
+
};
|
|
164
|
+
const artifact = staged.bytes
|
|
165
|
+
? createCoreArtifact(dataRoot, { ...input, bytes: staged.bytes })
|
|
166
|
+
: adoptCoreArtifactFile(dataRoot, { ...input, sourcePath: staged.target });
|
|
141
167
|
artifactIds.push(artifact.id);
|
|
142
168
|
}
|
|
143
169
|
return completeCoreDelivery(dataRoot, taskId, artifactIds);
|
|
@@ -199,21 +225,39 @@ export function createProviderTaskRunner(runtime) {
|
|
|
199
225
|
|
|
200
226
|
async function submit(input = {}) {
|
|
201
227
|
return runtime.run('execute', async ({ dataRoot, signal }) => {
|
|
228
|
+
if (signal.aborted) throw new ProviderTaskRunnerError('IRIS_PROVIDER_ABORTED', '提交已取消');
|
|
202
229
|
const capability = String(input.capability || '').trim();
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
230
|
+
let candidates = normalizedCandidates(capability, input.candidates);
|
|
231
|
+
let providerInput = input.providerInput ?? {};
|
|
232
|
+
if (input.sourceArtifactId !== undefined) {
|
|
233
|
+
if (capability !== 'image') throw new TypeError('sourceArtifactId 仅用于图片编辑');
|
|
234
|
+
candidates = imageEditCandidates(candidates);
|
|
235
|
+
const image = await readImageEditArtifact(dataRoot, input.sourceArtifactId, signal);
|
|
236
|
+
providerInput = { ...providerInput, image };
|
|
237
|
+
}
|
|
238
|
+
const task = createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
|
|
239
|
+
const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
|
|
206
240
|
id: adapter.id,
|
|
207
241
|
model,
|
|
208
242
|
adapter,
|
|
209
|
-
submit: (providerInput, context) =>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
243
|
+
submit: async (providerInput, context) => {
|
|
244
|
+
if (prepareInput) {
|
|
245
|
+
try { providerInput = await prepareInput({ providerInput, signal }); }
|
|
246
|
+
catch (error) { throw new ProviderContractError('视频输入准备失败:' + (error.safeMessage || error.message), {
|
|
247
|
+
...providerErrorRecord(error), stage: error.stage || 'prepare', category: error.category || 'local_io', acceptance: 'not_accepted'
|
|
248
|
+
}); }
|
|
249
|
+
}
|
|
250
|
+
if (signal.aborted) throw new ProviderContractError('提交已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
|
|
251
|
+
return invokeProviderOperation(
|
|
252
|
+
adapter,
|
|
253
|
+
'submit',
|
|
254
|
+
{ capability, model: providerModel, input: providerInput, signal },
|
|
255
|
+
{ ...context, signal, taskId: task.id }
|
|
256
|
+
);
|
|
257
|
+
}
|
|
215
258
|
}));
|
|
216
|
-
const submitted = await submitWithAcceptanceBoundary(wrapped,
|
|
259
|
+
const submitted = await submitWithAcceptanceBoundary(wrapped, providerInput, {
|
|
260
|
+
signal,
|
|
217
261
|
beforeAttempt: async (attempt) => {
|
|
218
262
|
const candidate = candidates[attempt.ordinal - 1];
|
|
219
263
|
return beginCoreAttempt(dataRoot, task.id, {
|
package/lib/summarize.js
CHANGED
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
* Iris 多模态上下文摘要(阶段 7.3)—— 把视频压成「一张联系表 + 一段转写文本」喂给视觉模型。
|
|
4
4
|
*
|
|
5
5
|
* 为什么是 contact sheet:
|
|
6
|
-
* -
|
|
6
|
+
* - Vision Port 一次 complete 只送一张图(字节与 MIME),
|
|
7
7
|
* 逐帧送 N 次要 N 次模型调用、费用与延迟都不可接受;
|
|
8
8
|
* - 把 N 帧缩略图按时间序拼成网格 + 每帧盖时间戳标签,一次视觉调用即可看全片(上下文压缩)。
|
|
9
9
|
*
|
|
10
10
|
* 契约:
|
|
11
|
-
* - buildContactSheet(frames, opts) → PNG buffer + 网格尺寸(纯 sharp,可离线测试)
|
|
12
|
-
* - summarizeMedia({
|
|
13
|
-
* →
|
|
11
|
+
* - buildContactSheet({frames, ...opts}) → PNG buffer + 网格尺寸(纯 sharp,可离线测试)
|
|
12
|
+
* - summarizeMedia({ports, frames, question?, transcript?, signal})
|
|
13
|
+
* → 一张拼图通过共享 Vision Ports,转写只消费显式输入
|
|
14
14
|
*/
|
|
15
15
|
import sharp from 'sharp';
|
|
16
|
+
import { completeVision, VISION_BUDGET } from './vision-core.js';
|
|
17
|
+
import { createModelOperation } from './model-invoker.js';
|
|
18
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
19
|
+
import { ModelPortError } from './model-port-contract.js';
|
|
16
20
|
|
|
17
21
|
export class SummarizeError extends Error {
|
|
18
22
|
constructor(message) {
|
|
@@ -28,7 +32,11 @@ const DEFAULT_QUESTION = '请根据这些视频关键帧(按时间顺序从左
|
|
|
28
32
|
* @param {{frames:Array<{buffer:Buffer, atSec:number, width:number, height:number}>, cols?:number, maxSheetWidth?:number}}
|
|
29
33
|
* @returns {Promise<{buffer:Buffer, width:number, height:number, rows:number, cols:number}>}
|
|
30
34
|
*/
|
|
31
|
-
export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024 }) {
|
|
35
|
+
export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024, signal }) {
|
|
36
|
+
return waitForModelWork(() => makeContactSheet({ frames, cols, maxSheetWidth, signal }), signal);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function makeContactSheet({ frames, cols, maxSheetWidth, signal }) {
|
|
32
40
|
if (!Array.isArray(frames) || frames.length === 0) {
|
|
33
41
|
throw new SummarizeError('iris_media_summarize: 没有可拼的帧');
|
|
34
42
|
}
|
|
@@ -47,10 +55,10 @@ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024
|
|
|
47
55
|
const w = Math.max(1, cellW);
|
|
48
56
|
const scale = w / Math.max(1, f.width || w);
|
|
49
57
|
const h = Math.max(1, Math.round((f.height || 0) * scale));
|
|
50
|
-
const thumb = await sharp(f.buffer)
|
|
58
|
+
const thumb = await waitForModelWork(() => sharp(f.buffer)
|
|
51
59
|
.resize(w, h, { fit: 'fill' })
|
|
52
60
|
.png()
|
|
53
|
-
.toBuffer();
|
|
61
|
+
.toBuffer(), signal);
|
|
54
62
|
// 时间戳标签(SVG 覆盖在缩略图下方)
|
|
55
63
|
const label = Buffer.from(
|
|
56
64
|
`<svg width="${w}" height="${labelH}" xmlns="http://www.w3.org/2000/svg">` +
|
|
@@ -58,10 +66,11 @@ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024
|
|
|
58
66
|
`<text x="4" y="${labelH - 7}" font-family="monospace" font-size="12" fill="#ffffff">` +
|
|
59
67
|
`${f.atSec.toFixed(1)}s</text></svg>`
|
|
60
68
|
);
|
|
61
|
-
const cell = await sharp(thumb)
|
|
69
|
+
const cell = await waitForModelWork(() => sharp(thumb)
|
|
70
|
+
.extend({ bottom: labelH, background: { r: 16, g: 16, b: 20 } })
|
|
62
71
|
.composite([{ input: label, top: h, left: 0 }])
|
|
63
72
|
.png()
|
|
64
|
-
.toBuffer();
|
|
73
|
+
.toBuffer(), signal);
|
|
65
74
|
cells.push({ buffer: cell, width: w, height: h + labelH });
|
|
66
75
|
}
|
|
67
76
|
|
|
@@ -73,12 +82,12 @@ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024
|
|
|
73
82
|
left: (i % colsN) * (cellW + gap),
|
|
74
83
|
top: Math.floor(i / colsN) * (cellH + gap)
|
|
75
84
|
}));
|
|
76
|
-
const buffer = await sharp({
|
|
85
|
+
const buffer = await waitForModelWork(() => sharp({
|
|
77
86
|
create: { width: sheetW, height: sheetH, channels: 3, background: { r: 16, g: 16, b: 20 } }
|
|
78
87
|
})
|
|
79
88
|
.composite(composite)
|
|
80
89
|
.png()
|
|
81
|
-
.toBuffer();
|
|
90
|
+
.toBuffer(), signal);
|
|
82
91
|
return { buffer, width: sheetW, height: sheetH, rows: rowsN, cols: colsN };
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -98,14 +107,16 @@ export function buildSummaryPrompt({ question, transcript }) {
|
|
|
98
107
|
|
|
99
108
|
/**
|
|
100
109
|
* 多模态摘要:contact sheet 图 + 可选转写文本 → 视觉后端链 → 摘要文本。
|
|
101
|
-
* @param {{
|
|
102
|
-
* @returns {Promise<
|
|
110
|
+
* @param {{ports:Array, frames:Array, question?:string, transcript?:string, signal?:AbortSignal}}
|
|
111
|
+
* @returns {Promise<object>} 完整模型结果和同一张拼图
|
|
103
112
|
*/
|
|
104
|
-
export async function summarizeMedia({
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
export async function summarizeMedia({ ports = [], frames, question, transcript, signal, operation, budget = VISION_BUDGET }) {
|
|
114
|
+
if (!ports.length) throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
115
|
+
const op = operation || createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: ports.length } });
|
|
116
|
+
try {
|
|
117
|
+
const sheet = await buildContactSheet({ frames, cols: 3, signal: op.signal });
|
|
118
|
+
const result = await completeVision(ports, { prompt: buildSummaryPrompt({ question, transcript }),
|
|
119
|
+
image: { bytes: new Uint8Array(sheet.buffer), mediaType: 'image/png' } }, { operation: op, budget });
|
|
120
|
+
return { ...result, sheet };
|
|
121
|
+
} finally { if (!operation) op.dispose(); }
|
|
111
122
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** CLI/Host 共用的摘要来源:已有帧只读,不导出媒体或重新抽帧。 */
|
|
3
|
+
import { CommandError } from './command-service.js';
|
|
4
|
+
import { readCoreArtifactBytes } from './core-artifacts.js';
|
|
5
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
6
|
+
|
|
7
|
+
function invalid(message) { throw new CommandError('IRIS_COMMAND_INPUT_INVALID', message); }
|
|
8
|
+
|
|
9
|
+
export function normalizeSummarySource(input) {
|
|
10
|
+
const hasFrames = input.frame_artifact_ids !== undefined;
|
|
11
|
+
const hasVideo = Boolean(input.video_path);
|
|
12
|
+
if (hasFrames === hasVideo) invalid('摘要必须且只能提供 video_path 或 frame_artifact_ids 之一');
|
|
13
|
+
if (hasFrames) {
|
|
14
|
+
const ids = input.frame_artifact_ids;
|
|
15
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 20
|
|
16
|
+
|| !ids.every(id => typeof id === 'string' && /^artifact_[a-f0-9]{24}$/.test(id))
|
|
17
|
+
|| new Set(ids).size !== ids.length) invalid('frame_artifact_ids 必须是 1–20 个不重复的 Core Artifact ID');
|
|
18
|
+
if (input.max_frames !== undefined || input.target_width !== undefined) invalid('已有帧不使用 max_frames 或 target_width;请直接选择帧 ID');
|
|
19
|
+
if (input.transcribe === true) invalid('自动音轨转写需要 video_path;已有帧可提供 transcribe_text');
|
|
20
|
+
}
|
|
21
|
+
if (input.transcribe_text !== undefined && typeof input.transcribe_text !== 'string') invalid('transcribe_text 必须是字符串');
|
|
22
|
+
if (input.transcribe === true && input.transcribe_text?.trim()) invalid('transcribe 与 transcribe_text 不能同时使用');
|
|
23
|
+
return hasFrames ? 'core-artifacts' : 'video';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readSummaryFrames(runtime, frameArtifactIds, signal) {
|
|
27
|
+
return waitForModelWork(() => runtime.run('inspect', ({ dataRoot }) => {
|
|
28
|
+
const frames = frameArtifactIds.map(id => {
|
|
29
|
+
const { artifact, bytes } = readCoreArtifactBytes(dataRoot, id);
|
|
30
|
+
const { frameIndex, atSec, width, height } = artifact.metadata;
|
|
31
|
+
if (artifact.kind !== 'video-frame' || !['image/png', 'image/jpeg'].includes(artifact.mediaType)
|
|
32
|
+
|| !Number.isFinite(atSec) || atSec < 0 || !Number.isSafeInteger(frameIndex) || frameIndex < 1
|
|
33
|
+
|| !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
|
|
34
|
+
invalid('摘要需要含有效帧序号、时间戳和尺寸的 video-frame Artifact');
|
|
35
|
+
}
|
|
36
|
+
return { artifactId: artifact.id, frameIndex, atSec, width, height, buffer: bytes };
|
|
37
|
+
}).sort((a, b) => a.atSec - b.atSec || a.frameIndex - b.frameIndex);
|
|
38
|
+
return { frames, meta: { source: 'core-artifacts', startSec: frames[0].atSec, endSec: frames.at(-1).atSec } };
|
|
39
|
+
}), signal);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function summaryFrameRecords(frames) {
|
|
43
|
+
return frames.map(({ artifactId, frameIndex, atSec, width, height }) => ({
|
|
44
|
+
...(artifactId ? { artifactId, frameIndex } : {}), atSec, width, height
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function summaryTimelineLabel(meta) {
|
|
49
|
+
return meta.source === 'core-artifacts'
|
|
50
|
+
? `${meta.startSec.toFixed(1)}–${meta.endSec.toFixed(1)}s 已选帧`
|
|
51
|
+
: `${meta.durationSec.toFixed(1)}s`;
|
|
52
|
+
}
|
package/lib/task-wait.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { inspectCoreTask } from './core-tasks.js';
|
|
3
|
+
|
|
4
|
+
export function coreTaskWaitFinished(task) {
|
|
5
|
+
return task.phase === 'terminal' || task.acceptance !== 'accepted'
|
|
6
|
+
|| !['none', 'unknown'].includes(task.outcome);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 有界观察原 Task,超时中止 poll/下载并保留事实;没有任何 submit/retry 路径。 */
|
|
10
|
+
export async function waitForCoreTask(runtime, taskId, { observe, timeoutMs = 120000, pollIntervalMs = 2500 } = {}) {
|
|
11
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 1200000
|
|
12
|
+
|| !Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 50 || pollIntervalMs > 60000) {
|
|
13
|
+
throw Object.assign(new Error('timeout_ms 必须是 1–1200000,poll_interval_ms 必须是 50–60000 的整数'), { code: 'IRIS_COMMAND_INPUT_INVALID' });
|
|
14
|
+
}
|
|
15
|
+
const dataRoot = await runtime.run('inspect', context => context.dataRoot);
|
|
16
|
+
let task = inspectCoreTask(dataRoot, taskId);
|
|
17
|
+
const completed = timedOut => ({ taskId, task, timedOut, ready: task.outcome === 'succeeded' && task.deliveryState === 'ready' });
|
|
18
|
+
if (coreTaskWaitFinished(task)) return completed(false);
|
|
19
|
+
if (typeof observe !== 'function') throw Object.assign(new Error('等待远端任务需要 Provider resolver'), { code: 'IRIS_COMMAND_PROVIDER_REQUIRED' });
|
|
20
|
+
let timedOut = false;
|
|
21
|
+
const timer = setTimeout(() => { timedOut = true; runtime.dispose().catch(() => {}); }, timeoutMs);
|
|
22
|
+
try {
|
|
23
|
+
while (!coreTaskWaitFinished(task)) {
|
|
24
|
+
runtime.signal.throwIfAborted();
|
|
25
|
+
try { task = (await observe()).task; }
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (runtime.signal.aborted) throw error;
|
|
28
|
+
if (error.code && !['IRIS_PROVIDER_POLL_FAILED', 'IRIS_PROVIDER_DOWNLOAD_FAILED'].includes(error.code)) throw error;
|
|
29
|
+
task = inspectCoreTask(dataRoot, taskId);
|
|
30
|
+
}
|
|
31
|
+
if (!coreTaskWaitFinished(task)) await delay(pollIntervalMs, undefined, { signal: runtime.signal });
|
|
32
|
+
}
|
|
33
|
+
return completed(false);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (!timedOut) throw error;
|
|
36
|
+
await runtime.dispose();
|
|
37
|
+
task = inspectCoreTask(dataRoot, taskId);
|
|
38
|
+
return completed(true);
|
|
39
|
+
} finally { clearTimeout(timer); }
|
|
40
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 视频输入按候选准备;短期地址和本地路径不落 Task。 */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { GenerationInputError } from './generation-input.js';
|
|
6
|
+
import { parseModelRef } from './models.js';
|
|
7
|
+
import { prepareProviderInput } from './provider-adapter.js';
|
|
8
|
+
import { ProviderContractError } from './provider-contract.js';
|
|
9
|
+
|
|
10
|
+
export function videoTaskCandidates(candidates, normalized) {
|
|
11
|
+
const s2v = normalized.videoMode === 's2v';
|
|
12
|
+
const compatible = candidates.filter(candidate => /s2v/i.test(parseModelRef(candidate.model)?.modelId || '') === s2v);
|
|
13
|
+
if (!compatible.length) throw new GenerationInputError(s2v
|
|
14
|
+
? 'audio_path 只能配合 s2v 数字人模型' : 's2v 模型需要首帧和音频输入');
|
|
15
|
+
return compatible.map(candidate => ({ ...candidate,
|
|
16
|
+
...(normalized.firstFramePath ? { async prepareInput({ signal }) {
|
|
17
|
+
if (signal.aborted) throw new ProviderContractError('视频输入准备已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
|
|
18
|
+
if (s2v) {
|
|
19
|
+
const model = parseModelRef(candidate.model).modelId;
|
|
20
|
+
const image = await prepareProviderInput(candidate.adapter, { model, filePath: normalized.firstFramePath, signal });
|
|
21
|
+
if (signal.aborted) throw new ProviderContractError('视频输入准备已取消', { stage: 'upload', category: 'aborted', acceptance: 'not_accepted' });
|
|
22
|
+
const audio = await prepareProviderInput(candidate.adapter, { model, filePath: normalized.audioPath, signal });
|
|
23
|
+
return { ...normalized.providerInput, imgDataUrl: image.url, audioUrl: audio.url };
|
|
24
|
+
}
|
|
25
|
+
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }[path.extname(normalized.firstFramePath).toLowerCase()];
|
|
26
|
+
if (!mime) throw new ProviderContractError('首帧格式不受支持', { stage: 'prepare', category: 'invalid_request', acceptance: 'not_accepted' });
|
|
27
|
+
return { ...normalized.providerInput, imgDataUrl: 'data:' + mime + ';base64,' + fs.readFileSync(normalized.firstFramePath).toString('base64') };
|
|
28
|
+
} } : {})
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 单图业务只消费显式字节和 Model Ports,不读取 Host/配置或保存产物。 */
|
|
3
|
+
import { createModelOperation } from './model-invoker.js';
|
|
4
|
+
import { ModelPortError, modelErrorRecord, modelPortSnapshot } from './model-port-contract.js';
|
|
5
|
+
|
|
6
|
+
export const VISION_BUDGET = Object.freeze({ timeoutMs: 120000, maxInputTextBytes: 32768,
|
|
7
|
+
maxOutputChars: 6000, maxImageBytes: 20 * 1024 * 1024 });
|
|
8
|
+
export const VISION_CANDIDATE_POLICY = Object.freeze({ skipUnavailable: true, allowRejected: true, allowEmptyResult: true });
|
|
9
|
+
|
|
10
|
+
export async function completeVision(ports, request, { signal, operation, budget = VISION_BUDGET } = {}) {
|
|
11
|
+
if (!Array.isArray(ports) || !ports.length) throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
12
|
+
const op = operation || createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: ports.length } });
|
|
13
|
+
const errors = [];
|
|
14
|
+
const observed = ports.map(port => {
|
|
15
|
+
let diagnosed = false;
|
|
16
|
+
return {
|
|
17
|
+
describe() {
|
|
18
|
+
const descriptor = modelPortSnapshot(port);
|
|
19
|
+
if (!diagnosed && descriptor.availability !== 'available') {
|
|
20
|
+
errors.push(modelErrorRecord(new ModelPortError(descriptor.reasonCode, { stage: 'prepare', backendId: descriptor.identity.backendId })));
|
|
21
|
+
diagnosed = true;
|
|
22
|
+
}
|
|
23
|
+
return descriptor;
|
|
24
|
+
},
|
|
25
|
+
async complete(input, options) {
|
|
26
|
+
try { return await port.complete(input, options); }
|
|
27
|
+
catch (error) {
|
|
28
|
+
errors.push({ ...modelErrorRecord(error), backendId: modelPortSnapshot(port).identity.backendId });
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const result = await op.runCandidates(observed, request, { budget }, VISION_CANDIDATE_POLICY);
|
|
36
|
+
return Object.freeze({ ...result, errors: Object.freeze([...errors]) });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// 受控记录供入口写健康反馈,不包含原始错误/图片/问题。
|
|
39
|
+
if (error instanceof ModelPortError) error.errors = Object.freeze([...errors]);
|
|
40
|
+
throw error;
|
|
41
|
+
} finally { if (!operation) op.dispose(); }
|
|
42
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 视觉发送副本;原始文件和 Core Artifact 始终只读。 */
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { ModelPortError } from './model-port-contract.js';
|
|
5
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
6
|
+
|
|
7
|
+
// 客户端默认发送预算,不表示任何供应商承诺的输入上限。
|
|
8
|
+
export const DEFAULT_VISION_INPUT = Object.freeze({ maxBytes: 8 * 1024 * 1024 });
|
|
9
|
+
|
|
10
|
+
export function normalizeVisionInputLimits(value) {
|
|
11
|
+
if (value === null || value === undefined) return undefined;
|
|
12
|
+
if (typeof value !== 'object' || Array.isArray(value)
|
|
13
|
+
|| Object.keys(value).some(key => !['maxBytes', 'maxDimension'].includes(key))
|
|
14
|
+
|| !Object.values(value).every(number => Number.isSafeInteger(number) && number > 0)) {
|
|
15
|
+
throw Object.assign(new Error('visionInput 只接受正整数 maxBytes、maxDimension,null 恢复继承'), { code: 'IRIS_CONFIG_INPUT_INVALID' });
|
|
16
|
+
}
|
|
17
|
+
return { ...value };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function visionInputLimits(provider, modelId) {
|
|
21
|
+
const model = provider.models?.find(value => typeof value === 'object' && value?.id === modelId);
|
|
22
|
+
return { ...DEFAULT_VISION_INPUT, ...normalizeVisionInputLimits(provider.visionInput),
|
|
23
|
+
...normalizeVisionInputLimits(model?.visionInput) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function processImage(processor, signal, output) {
|
|
27
|
+
const cancel = () => processor.destroy();
|
|
28
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
29
|
+
try { return await waitForModelWork(() => output(processor), signal); }
|
|
30
|
+
finally { signal?.removeEventListener('abort', cancel); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function prepareVisionImage(image, limits, { signal, orient = false } = {}) {
|
|
34
|
+
const { maxBytes, maxDimension } = { ...DEFAULT_VISION_INPUT, ...normalizeVisionInputLimits(limits) };
|
|
35
|
+
if (signal?.aborted) return waitForModelWork(() => image, signal);
|
|
36
|
+
if (image.bytes.byteLength <= maxBytes && !maxDimension && !orient) return image;
|
|
37
|
+
const tooLarge = () => new ModelPortError('IRIS_MODEL_IMAGE_TOO_LARGE', {
|
|
38
|
+
stage: 'prepare', imageBytes: image.bytes.byteLength, imageMaxBytes: maxBytes
|
|
39
|
+
});
|
|
40
|
+
const format = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' }[image.mediaType];
|
|
41
|
+
let metadata;
|
|
42
|
+
try { metadata = await processImage(sharp(Buffer.from(image.bytes)), signal, processor => processor.metadata()); }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof ModelPortError) throw error;
|
|
45
|
+
throw new ModelPortError('IRIS_MODEL_INPUT_INVALID', { stage: 'prepare' });
|
|
46
|
+
}
|
|
47
|
+
const sourceDimension = Math.max(metadata.width, metadata.height);
|
|
48
|
+
if (image.bytes.byteLength <= maxBytes && (!maxDimension || sourceDimension <= maxDimension)
|
|
49
|
+
&& (!orient || !metadata.orientation || metadata.orientation === 1)) return image;
|
|
50
|
+
// 合预算的 GIF/动画仍原字节透传,需要缩放时不静默压成一帧。
|
|
51
|
+
if (!format || metadata.pages > 1) throw tooLarge();
|
|
52
|
+
let dimension = Math.min(sourceDimension, maxDimension || sourceDimension);
|
|
53
|
+
let size = image.bytes.byteLength;
|
|
54
|
+
// 每次从原字节缩放,不反复压缩上一次副本;只进行本地处理,不重发模型请求。
|
|
55
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
56
|
+
if (size > maxBytes) dimension = Math.max(1, Math.min(dimension,
|
|
57
|
+
Math.floor((attempt === 0 ? sourceDimension : dimension) * Math.min(0.8, Math.sqrt(maxBytes / size) * 0.9))));
|
|
58
|
+
const processor = sharp(Buffer.from(image.bytes)).autoOrient().resize({ width: dimension, height: dimension,
|
|
59
|
+
fit: 'inside', withoutEnlargement: true }).toFormat(format);
|
|
60
|
+
const bytes = await processImage(processor, signal, value => value.toBuffer());
|
|
61
|
+
if (bytes.byteLength <= maxBytes) return { bytes: new Uint8Array(bytes), mediaType: image.mediaType };
|
|
62
|
+
size = bytes.byteLength;
|
|
63
|
+
if (dimension === 1) break;
|
|
64
|
+
}
|
|
65
|
+
throw tooLarge();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 每次调用的实际图片事实;仅包含尺寸/大小,不包含字节、路径或凭据。 */
|
|
69
|
+
export function createVisionInputObserver(inputs) {
|
|
70
|
+
return async ({ request, image, signal, identity }) => {
|
|
71
|
+
const [source, sent] = await waitForModelWork(() => Promise.all([
|
|
72
|
+
sharp(Buffer.from(request.image.bytes)).metadata(), sharp(Buffer.from(image.bytes)).metadata()
|
|
73
|
+
]), signal);
|
|
74
|
+
const record = {
|
|
75
|
+
source: { width: source.width, height: source.height, bytes: request.image.bytes.byteLength, mediaType: request.image.mediaType },
|
|
76
|
+
sent: { width: sent.width, height: sent.height, bytes: image.bytes.byteLength, mediaType: image.mediaType },
|
|
77
|
+
orientation: source.orientation || 1,
|
|
78
|
+
resized: (source.autoOrient?.width || source.width) !== sent.width || (source.autoOrient?.height || source.height) !== sent.height
|
|
79
|
+
};
|
|
80
|
+
inputs.set(identity.backendId, record);
|
|
81
|
+
return record;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 单图来源由入口选择;Core 图片以原字节/MIME 只读进入视觉业务。 */
|
|
3
|
+
import { CommandError } from './command-service.js';
|
|
4
|
+
import { inspectCoreArtifact, readCoreArtifactBytes } from './core-artifacts.js';
|
|
5
|
+
import { MODEL_IMAGE_MEDIA_TYPES } from './model-port-contract.js';
|
|
6
|
+
import { VISION_BUDGET } from './vision-core.js';
|
|
7
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
8
|
+
|
|
9
|
+
export function normalizeVisionImageSource(input, { allowAttachment = false } = {}) {
|
|
10
|
+
const fields = ['image_path', 'artifact_id', ...(allowAttachment ? ['attachment_id'] : [])];
|
|
11
|
+
const selected = fields.filter(field => input[field] !== undefined);
|
|
12
|
+
if (selected.length !== 1) throw new CommandError('IRIS_COMMAND_INPUT_INVALID',
|
|
13
|
+
'必须且只能提供 ' + fields.join('、') + ' 之一');
|
|
14
|
+
if (selected[0] === 'artifact_id'
|
|
15
|
+
&& (typeof input.artifact_id !== 'string' || !/^artifact_[a-f0-9]{24}$/.test(input.artifact_id))) {
|
|
16
|
+
throw new CommandError('IRIS_COMMAND_INPUT_INVALID', 'artifact_id 必须是有效的 Core Artifact ID');
|
|
17
|
+
}
|
|
18
|
+
return selected[0];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readVisionArtifact(runtime, artifactId, signal) {
|
|
22
|
+
return waitForModelWork(() => runtime.run('inspect', ({ dataRoot }) => {
|
|
23
|
+
// 先检查大小/MIME,再物化字节;沿用图片输入的 20 MiB 上限。
|
|
24
|
+
const artifact = inspectCoreArtifact(dataRoot, artifactId);
|
|
25
|
+
if (!MODEL_IMAGE_MEDIA_TYPES.includes(artifact.mediaType) || artifact.size > VISION_BUDGET.maxImageBytes) {
|
|
26
|
+
throw new CommandError('IRIS_COMMAND_INPUT_INVALID', '视觉 Artifact 必须是最多 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片');
|
|
27
|
+
}
|
|
28
|
+
const { bytes } = readCoreArtifactBytes(dataRoot, artifactId);
|
|
29
|
+
return { bytes: new Uint8Array(bytes), mediaType: artifact.mediaType };
|
|
30
|
+
}), signal);
|
|
31
|
+
}
|