@mokuyoaxis/dsh-iris 0.1.4 → 0.2.1
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 +99 -1
- package/README.en.md +124 -31
- package/README.md +124 -31
- package/bin/dsh-iris.js +593 -13
- package/docs/AGENT_QUICKSTART.md +39 -0
- package/docs/ARCHITECTURE.md +87 -64
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CLI_MANAGEMENT.md +181 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
- package/docs/DSH_CORE_MIGRATION.md +135 -0
- package/docs/DSH_RC2_ADAPTATION.md +38 -0
- package/docs/FAULT_INJECTION.md +12 -2
- package/docs/HEADLESS_CLI.md +223 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
- package/docs/HOST_DOCTOR.md +4 -2
- package/docs/MODEL_PORT_CONTRACT.md +305 -0
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +49 -83
- package/docs/SECURITY.md +8 -7
- 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/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.0.md +48 -0
- package/docs/releases/0.2.1.md +56 -0
- package/lib/actions.js +520 -591
- package/lib/adapters.js +184 -36
- package/lib/api.js +200 -11
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +1390 -105
- package/lib/command-service.js +534 -0
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +143 -51
- package/lib/core-artifact-store.js +630 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-maintenance.js +213 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +567 -0
- package/lib/core-user-projection.js +193 -0
- package/lib/doctor.js +92 -33
- package/lib/dsh-core-adapter.js +653 -0
- package/lib/dsh-host-adapter.js +58 -7
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +126 -0
- package/lib/guard.js +3 -3
- 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 +323 -188
- 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 +220 -0
- package/lib/model-port-contract.js +341 -0
- package/lib/models.js +105 -14
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +2 -1
- package/lib/prompt-optimizer-core.js +121 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapter.js +49 -23
- package/lib/provider-adapters.js +165 -26
- package/lib/provider-catalog.js +183 -0
- package/lib/provider-config-service.js +236 -0
- package/lib/provider-contract.js +74 -4
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +74 -2
- package/lib/provider-task-runner.js +370 -0
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- package/lib/tasks.js +16 -4
- 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 +31 -9
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +8 -8
- package/user_guide.md +71 -26
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { createCoreArtifact } from './core-artifacts.js';
|
|
8
|
+
import {
|
|
9
|
+
activateCoreWatch,
|
|
10
|
+
beginCoreAttempt,
|
|
11
|
+
beginCoreDelivery,
|
|
12
|
+
completeCoreDelivery,
|
|
13
|
+
createCoreTask,
|
|
14
|
+
failCoreDelivery,
|
|
15
|
+
finalizeCoreNoAcceptance,
|
|
16
|
+
inspectCoreTask,
|
|
17
|
+
prepareCoreRedelivery,
|
|
18
|
+
recordCoreAttemptResult,
|
|
19
|
+
recordCoreCancelResult,
|
|
20
|
+
recordCoreDownloadCompleted,
|
|
21
|
+
recordCorePollResult,
|
|
22
|
+
recordCoreWatchError,
|
|
23
|
+
recoverCoreTask,
|
|
24
|
+
requestCoreCancel
|
|
25
|
+
} from './core-tasks.js';
|
|
26
|
+
import { ensurePrivateDir } from './private-storage.js';
|
|
27
|
+
import { parseModelRef } from './models.js';
|
|
28
|
+
import {
|
|
29
|
+
hasProviderOperation,
|
|
30
|
+
invokeProviderOperation
|
|
31
|
+
} from './provider-adapter.js';
|
|
32
|
+
import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
33
|
+
import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
|
|
34
|
+
|
|
35
|
+
export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 按 capability 冻结的交付 Profile(E 阶段视频迁移):媒体类型白名单、Artifact
|
|
39
|
+
* kind 与默认 metadata。新增 capability 必须显式登记;未登记的 capability 直接
|
|
40
|
+
* 失败,不会按图片语义猜。
|
|
41
|
+
*/
|
|
42
|
+
export const DELIVERY_PROFILES = Object.freeze({
|
|
43
|
+
image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp']), defaultMediaType: 'image/png' }),
|
|
44
|
+
video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4']), defaultMediaType: 'video/mp4' }),
|
|
45
|
+
// TTS 同步完成型:submit 内 completed → 同一次调用直接交付(无 remote 长轮询)。
|
|
46
|
+
tts: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
|
|
47
|
+
// 转写上传型异步:受理 + remoteTaskId → 长轮询 → 文本物化为 UTF-8 Artifact。
|
|
48
|
+
transcribe: Object.freeze({ kind: 'transcript', mediaTypes: Object.freeze(['text/plain']), defaultMediaType: 'text/plain' })
|
|
49
|
+
});
|
|
50
|
+
const runtimeTaskGates = new WeakMap();
|
|
51
|
+
|
|
52
|
+
export class ProviderTaskRunnerError extends Error {
|
|
53
|
+
constructor(code, message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'ProviderTaskRunnerError';
|
|
56
|
+
this.code = code;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkRuntime(runtime) {
|
|
61
|
+
if (!runtime || typeof runtime.run !== 'function') {
|
|
62
|
+
throw new TypeError('Provider Task Runner 需要已创建的 Core Runtime');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkAdapterForTask(task, adapter) {
|
|
67
|
+
if (!adapter || adapter.id !== task.providerId) {
|
|
68
|
+
throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Provider Adapter 与 Task 已持久化身份不一致');
|
|
69
|
+
}
|
|
70
|
+
return adapter;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizedCandidates(capability, candidates) {
|
|
74
|
+
if (!Array.isArray(candidates) || !candidates.length) throw new TypeError('至少需要一个 Provider 候选');
|
|
75
|
+
return candidates.map((candidate, index) => {
|
|
76
|
+
const adapter = candidate?.adapter;
|
|
77
|
+
const model = String(candidate?.model || '').trim();
|
|
78
|
+
const parsed = parseModelRef(model);
|
|
79
|
+
const selectionReason = candidate?.selectionReason;
|
|
80
|
+
if (!adapter || !adapter.capabilities?.includes(capability) || !parsed
|
|
81
|
+
|| parsed.providerId !== adapter.id || !parsed.modelId
|
|
82
|
+
|| (candidate.providerBinding !== undefined && !/^sha256:[a-f0-9]{64}$/.test(candidate.providerBinding))
|
|
83
|
+
|| (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))) {
|
|
84
|
+
throw new TypeError('Provider 候选 ' + (index + 1) + ' 不支持 Task capability 或模型复合身份无效');
|
|
85
|
+
}
|
|
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 };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stagingFile(dataRoot) {
|
|
92
|
+
const directory = path.join(dataRoot, 'provider-staging', 'v0');
|
|
93
|
+
ensurePrivateDir(directory);
|
|
94
|
+
return path.join(directory, 'download-' + process.pid + '-' + crypto.randomBytes(8).toString('hex') + '.part');
|
|
95
|
+
}
|
|
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
|
+
|
|
112
|
+
async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, signal) {
|
|
113
|
+
beginCoreDelivery(dataRoot, taskId);
|
|
114
|
+
const artifactIds = [];
|
|
115
|
+
const stagedFiles = [];
|
|
116
|
+
try {
|
|
117
|
+
if (!Array.isArray(remoteArtifacts) || remoteArtifacts.length === 0) {
|
|
118
|
+
throw Object.assign(new Error('Provider Task Runner 没有收到可物化产物'), {
|
|
119
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const task = inspectCoreTask(dataRoot, taskId);
|
|
123
|
+
const profile = DELIVERY_PROFILES[task.capability];
|
|
124
|
+
if (!profile) {
|
|
125
|
+
throw Object.assign(new Error('Provider Task Runner 没有该 capability 的交付 Profile:' + task.capability), {
|
|
126
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
for (const remote of remoteArtifacts) {
|
|
130
|
+
const mediaType = String(remote.mediaType || profile.defaultMediaType);
|
|
131
|
+
if (task.capability !== 'image' && !profile.mediaTypes.includes(mediaType)) {
|
|
132
|
+
throw Object.assign(new Error('Provider Task Runner 交付媒体类型不在 Profile 白名单:' + mediaType), {
|
|
133
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const target = stagingFile(dataRoot);
|
|
137
|
+
stagedFiles.push({ target, mediaType });
|
|
138
|
+
const downloaded = await invokeProviderOperation(adapter, 'download', {
|
|
139
|
+
capability: task.capability,
|
|
140
|
+
artifact: remote,
|
|
141
|
+
targetPath: target,
|
|
142
|
+
signal
|
|
143
|
+
}, { signal, taskId });
|
|
144
|
+
const stat = fs.lstatSync(target);
|
|
145
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== downloaded.bytes) {
|
|
146
|
+
throw Object.assign(new Error('Provider 物化结果与文件证据不一致'), {
|
|
147
|
+
stage: 'download', category: 'local_io', acceptance: 'accepted'
|
|
148
|
+
});
|
|
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
|
+
}
|
|
155
|
+
}
|
|
156
|
+
recordCoreDownloadCompleted(dataRoot, taskId);
|
|
157
|
+
for (const staged of stagedFiles) {
|
|
158
|
+
const artifact = createCoreArtifact(dataRoot, {
|
|
159
|
+
kind: profile.kind,
|
|
160
|
+
mediaType: staged.mediaType,
|
|
161
|
+
bytes: staged.bytes || fs.readFileSync(staged.target),
|
|
162
|
+
metadata: { taskId, capability: task.capability },
|
|
163
|
+
...(task.sourceArtifactId ? { relations: [{ type: 'derived-from', artifactId: task.sourceArtifactId }] } : {})
|
|
164
|
+
});
|
|
165
|
+
artifactIds.push(artifact.id);
|
|
166
|
+
}
|
|
167
|
+
return completeCoreDelivery(dataRoot, taskId, artifactIds);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return failCoreDelivery(dataRoot, taskId, error);
|
|
170
|
+
} finally {
|
|
171
|
+
for (const staged of stagedFiles) {
|
|
172
|
+
try { fs.rmSync(staged.target, { force: true }); } catch (_) { /* 后续显式清理可处理残留 */ }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function observeWithinRoot(dataRoot, taskId, adapter, signal) {
|
|
178
|
+
const before = inspectCoreTask(dataRoot, taskId);
|
|
179
|
+
checkAdapterForTask(before, adapter);
|
|
180
|
+
activateCoreWatch(dataRoot, taskId);
|
|
181
|
+
let result;
|
|
182
|
+
try {
|
|
183
|
+
result = await invokeProviderOperation(adapter, 'poll', {
|
|
184
|
+
capability: before.capability,
|
|
185
|
+
remoteTaskId: before.remoteTaskId,
|
|
186
|
+
signal
|
|
187
|
+
}, { signal, taskId });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return recordCoreWatchError(dataRoot, taskId, error);
|
|
190
|
+
}
|
|
191
|
+
const recorded = recordCorePollResult(dataRoot, taskId, result);
|
|
192
|
+
if (result.kind !== 'succeeded') return recorded;
|
|
193
|
+
return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* FakeProvider 与未来真实 Host 共用的生命周期调度候选。它不拥有 timer,也不会
|
|
198
|
+
* 自动循环轮询;每次 observe 只消费一个 poll 结果,使重启和故障边界可重复验证。
|
|
199
|
+
*/
|
|
200
|
+
export function createProviderTaskRunner(runtime) {
|
|
201
|
+
checkRuntime(runtime);
|
|
202
|
+
let activeTasks = runtimeTaskGates.get(runtime);
|
|
203
|
+
if (!activeTasks) {
|
|
204
|
+
activeTasks = new Set();
|
|
205
|
+
runtimeTaskGates.set(runtime, activeTasks);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function runExclusive(taskId, operation, callback) {
|
|
209
|
+
const key = String(taskId || '');
|
|
210
|
+
if (activeTasks.has(key)) {
|
|
211
|
+
throw new ProviderTaskRunnerError(
|
|
212
|
+
'IRIS_PROVIDER_TASK_BUSY',
|
|
213
|
+
'该 Task 已有生命周期操作正在执行'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
activeTasks.add(key);
|
|
217
|
+
try {
|
|
218
|
+
return await runtime.run(operation, callback);
|
|
219
|
+
} finally {
|
|
220
|
+
activeTasks.delete(key);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function submit(input = {}) {
|
|
225
|
+
return runtime.run('execute', async ({ dataRoot, signal }) => {
|
|
226
|
+
if (signal.aborted) throw new ProviderTaskRunnerError('IRIS_PROVIDER_ABORTED', '提交已取消');
|
|
227
|
+
const capability = String(input.capability || '').trim();
|
|
228
|
+
let candidates = normalizedCandidates(capability, input.candidates);
|
|
229
|
+
let providerInput = input.providerInput ?? {};
|
|
230
|
+
if (input.sourceArtifactId !== undefined) {
|
|
231
|
+
if (capability !== 'image') throw new TypeError('sourceArtifactId 仅用于图片编辑');
|
|
232
|
+
candidates = imageEditCandidates(candidates);
|
|
233
|
+
const image = await readImageEditArtifact(dataRoot, input.sourceArtifactId, signal);
|
|
234
|
+
providerInput = { ...providerInput, image };
|
|
235
|
+
}
|
|
236
|
+
const task = createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
|
|
237
|
+
const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
|
|
238
|
+
id: adapter.id,
|
|
239
|
+
model,
|
|
240
|
+
adapter,
|
|
241
|
+
submit: async (providerInput, context) => {
|
|
242
|
+
if (prepareInput) {
|
|
243
|
+
try { providerInput = await prepareInput({ providerInput, signal }); }
|
|
244
|
+
catch (error) { throw new ProviderContractError('视频输入准备失败:' + (error.safeMessage || error.message), {
|
|
245
|
+
...providerErrorRecord(error), stage: error.stage || 'prepare', category: error.category || 'local_io', acceptance: 'not_accepted'
|
|
246
|
+
}); }
|
|
247
|
+
}
|
|
248
|
+
if (signal.aborted) throw new ProviderContractError('提交已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
|
|
249
|
+
return invokeProviderOperation(
|
|
250
|
+
adapter,
|
|
251
|
+
'submit',
|
|
252
|
+
{ capability, model: providerModel, input: providerInput, signal },
|
|
253
|
+
{ ...context, signal, taskId: task.id }
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}));
|
|
257
|
+
const submitted = await submitWithAcceptanceBoundary(wrapped, providerInput, {
|
|
258
|
+
signal,
|
|
259
|
+
beforeAttempt: async (attempt) => {
|
|
260
|
+
const candidate = candidates[attempt.ordinal - 1];
|
|
261
|
+
return beginCoreAttempt(dataRoot, task.id, {
|
|
262
|
+
...attempt,
|
|
263
|
+
...(candidate.providerBinding !== undefined ? { providerBinding: candidate.providerBinding } : {}),
|
|
264
|
+
...(candidate.selectionReason !== undefined ? { selectionReason: candidate.selectionReason } : {})
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
afterResult: async (attempt) => recordCoreAttemptResult(dataRoot, task.id, attempt)
|
|
268
|
+
});
|
|
269
|
+
if (submitted.localError) {
|
|
270
|
+
return Object.freeze({ taskId: task.id, task: inspectCoreTask(dataRoot, task.id), localError: submitted.localError });
|
|
271
|
+
}
|
|
272
|
+
if (!submitted.result) throw new Error('Provider 提交流程没有返回结果');
|
|
273
|
+
if (submitted.result.kind === 'not_accepted') {
|
|
274
|
+
finalizeCoreNoAcceptance(dataRoot, task.id, submitted.result.error);
|
|
275
|
+
} else if (submitted.result.kind === 'completed') {
|
|
276
|
+
const artifacts = submitted.result.artifacts;
|
|
277
|
+
return Object.freeze({
|
|
278
|
+
taskId: task.id,
|
|
279
|
+
task: await deliverArtifacts(dataRoot, task.id, submitted.candidate.adapter, artifacts, signal),
|
|
280
|
+
attempts: submitted.attempts
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return Object.freeze({
|
|
284
|
+
taskId: task.id,
|
|
285
|
+
task: inspectCoreTask(dataRoot, task.id),
|
|
286
|
+
attempts: submitted.attempts
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function inspect(taskId) {
|
|
292
|
+
return runtime.run('inspect', ({ dataRoot }) => inspectCoreTask(dataRoot, taskId));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function observe(taskId, adapter, { resumeActive = false } = {}) {
|
|
296
|
+
return runExclusive(taskId, 'execute', ({ dataRoot, signal }) => {
|
|
297
|
+
if (resumeActive) {
|
|
298
|
+
const current = inspectCoreTask(dataRoot, taskId);
|
|
299
|
+
checkAdapterForTask(current, adapter);
|
|
300
|
+
if (current.watchState === 'active') recoverCoreTask(dataRoot, taskId);
|
|
301
|
+
}
|
|
302
|
+
return observeWithinRoot(dataRoot, taskId, adapter, signal);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function redeliver(taskId, adapter) {
|
|
307
|
+
return runExclusive(taskId, 'execute', async ({ dataRoot, signal }) => {
|
|
308
|
+
const task = inspectCoreTask(dataRoot, taskId);
|
|
309
|
+
checkAdapterForTask(task, adapter);
|
|
310
|
+
prepareCoreRedelivery(dataRoot, taskId);
|
|
311
|
+
let result;
|
|
312
|
+
try {
|
|
313
|
+
result = await invokeProviderOperation(adapter, 'poll', {
|
|
314
|
+
capability: task.capability,
|
|
315
|
+
remoteTaskId: task.remoteTaskId,
|
|
316
|
+
signal
|
|
317
|
+
}, { signal, taskId, redelivery: true });
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return failCoreDelivery(dataRoot, taskId, error);
|
|
320
|
+
}
|
|
321
|
+
if (result.kind !== 'succeeded') {
|
|
322
|
+
return failCoreDelivery(dataRoot, taskId, providerErrorRecord(
|
|
323
|
+
result.error || '重新查询没有得到可交付产物',
|
|
324
|
+
{ stage: 'download', category: 'protocol', acceptance: 'accepted' }
|
|
325
|
+
));
|
|
326
|
+
}
|
|
327
|
+
return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function cancel(taskId, adapter) {
|
|
332
|
+
return runExclusive(taskId, 'execute', async ({ dataRoot, signal }) => {
|
|
333
|
+
const task = inspectCoreTask(dataRoot, taskId);
|
|
334
|
+
checkAdapterForTask(task, adapter);
|
|
335
|
+
requestCoreCancel(dataRoot, taskId);
|
|
336
|
+
if (!hasProviderOperation(adapter, 'cancel')) {
|
|
337
|
+
return recordCoreCancelResult(dataRoot, taskId, { kind: 'not_supported' });
|
|
338
|
+
}
|
|
339
|
+
const result = await invokeProviderOperation(adapter, 'cancel', {
|
|
340
|
+
capability: task.capability,
|
|
341
|
+
remoteTaskId: task.remoteTaskId,
|
|
342
|
+
signal
|
|
343
|
+
}, { signal, taskId });
|
|
344
|
+
return recordCoreCancelResult(dataRoot, taskId, result);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function recover(taskId, adapter) {
|
|
349
|
+
return runExclusive(taskId, 'recover', async ({ dataRoot, signal }) => {
|
|
350
|
+
const recovered = recoverCoreTask(dataRoot, taskId);
|
|
351
|
+
if (recovered.acceptance === 'accepted' && recovered.remoteTaskId
|
|
352
|
+
&& recovered.outcome === 'none'
|
|
353
|
+
&& ['accepted', 'running'].includes(recovered.phase)) {
|
|
354
|
+
checkAdapterForTask(recovered, adapter);
|
|
355
|
+
return observeWithinRoot(dataRoot, taskId, adapter, signal);
|
|
356
|
+
}
|
|
357
|
+
return recovered;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return Object.freeze({
|
|
362
|
+
version: PROVIDER_TASK_RUNNER_VERSION,
|
|
363
|
+
submit,
|
|
364
|
+
inspect,
|
|
365
|
+
observe,
|
|
366
|
+
redeliver,
|
|
367
|
+
cancel,
|
|
368
|
+
recover
|
|
369
|
+
});
|
|
370
|
+
}
|
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
|
+
}
|
package/lib/tasks.js
CHANGED
|
@@ -455,15 +455,25 @@ export function all() {
|
|
|
455
455
|
return [...load().tasks].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
/** 终态或已显式处理提醒的 Task v2 可删;活跃/未处理任务仍受保护。 */
|
|
459
|
+
export function taskRecordRemovable(task) {
|
|
460
|
+
if (!task) return false;
|
|
461
|
+
if (task.status !== 'running') return true;
|
|
462
|
+
return isV2Task(task)
|
|
463
|
+
&& ATTENTION_USER_STATES.has(deriveUserState(task))
|
|
464
|
+
&& attentionDisposition(task).status === 'acknowledged';
|
|
465
|
+
}
|
|
466
|
+
|
|
458
467
|
/**
|
|
459
|
-
*
|
|
468
|
+
* 删除单条任务元数据。事实终态可直接删除;状态未知只有在用户已读/已重试归档后才可删除。
|
|
460
469
|
* 只删记录,产物文件留在 outputs/(媒体链接因 token 随记录消失而自然失效)。
|
|
461
470
|
* @returns {{ok:boolean, reason?:string}}
|
|
462
471
|
*/
|
|
463
472
|
export function remove(id) {
|
|
464
473
|
const t = get(id);
|
|
465
474
|
if (!t) return { ok: false, reason: '任务不存在' };
|
|
466
|
-
if (t
|
|
475
|
+
if (!taskRecordRemovable(t)) return { ok: false, reason: '活跃或待确认任务请先取消、完成提醒处置,或标为已读后再删除' };
|
|
476
|
+
stopWatch(id);
|
|
467
477
|
const arr = load().tasks;
|
|
468
478
|
const i = arr.findIndex((x) => x.id === id);
|
|
469
479
|
if (i >= 0) arr.splice(i, 1);
|
|
@@ -481,8 +491,10 @@ export function prune(pred) {
|
|
|
481
491
|
const removed = [];
|
|
482
492
|
const kept = [];
|
|
483
493
|
for (const t of arr) {
|
|
484
|
-
if (t
|
|
485
|
-
|
|
494
|
+
if (taskRecordRemovable(t) && pred(t)) {
|
|
495
|
+
stopWatch(t.id);
|
|
496
|
+
removed.push(t.id);
|
|
497
|
+
} else kept.push(t);
|
|
486
498
|
}
|
|
487
499
|
if (removed.length) {
|
|
488
500
|
load().tasks = kept;
|
|
@@ -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
|
+
}
|