@mokuyoaxis/dsh-iris 0.1.0

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/lib/locate.js ADDED
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 模型驱动定位(阶段 3A):iris_locate。
4
+ *
5
+ * 对齐 RESEARCH 4.2a:返回**原图像素 bbox**(x1/y1/x2/y2),不是相对或归一化坐标,
6
+ * 保证 iris_crop 可以无缝接力裁剪(vision-router 的 vision_ground 同款契约)。
7
+ *
8
+ * 实现:
9
+ * - 复用阶段 1 的 VisionBackend 链(askWithBackends)做识图;
10
+ * - 提示词强制模型只回严格 JSON bbox;
11
+ * - 解析/校验/钳制:JSON 提取、字段数字校验、x1<x2 且 y1<y2、轻微越界 clamp、
12
+ * 完全越界报错、found=false 明确返回。
13
+ */
14
+ import { askWithBackends } from './vision.js';
15
+
16
+ export class LocateError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = 'LocateError';
20
+ }
21
+ }
22
+
23
+ /** 从模型回答里提取第一个平衡的 {...} JSON 对象文本;无则 null */
24
+ export function extractBboxJson(answer) {
25
+ const text = String(answer || '');
26
+ const start = text.indexOf('{');
27
+ if (start < 0) return null;
28
+ let depth = 0;
29
+ for (let i = start; i < text.length; i++) {
30
+ if (text[i] === '{') depth++;
31
+ else if (text[i] === '}') {
32
+ depth--;
33
+ if (depth === 0) return text.slice(start, i + 1);
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * 在原图上定位目标,返回原像素 bbox。
41
+ * @param {Array} backends 有序视觉后端(buildVisionBackends 产物)
42
+ * @param {{target:string, imageDataUrl:string, width:number, height:number, signal?:AbortSignal}}
43
+ * @returns {Promise<{found:boolean, x1?:number, y1?:number, x2?:number, y2?:number, via?:string, model?:string}>}
44
+ * @throws LocateError 模型未返回有效 bbox / bbox 完全越界 / 字段非法
45
+ */
46
+ export async function locateObject(backends, { target, imageDataUrl, width, height, signal }) {
47
+ const W = Math.floor(width);
48
+ const H = Math.floor(height);
49
+ if (!Number.isFinite(W) || !Number.isFinite(H) || W <= 0 || H <= 0) {
50
+ throw new LocateError('iris_locate: 图片尺寸无效');
51
+ }
52
+ const prompt =
53
+ `在图片中定位「${String(target).slice(0, 200)}」。` +
54
+ `只返回一个 JSON 对象,不要输出任何其他文字或解释。格式:` +
55
+ `{"x1":左上角x,"y1":左上角y,"x2":右下角x,"y2":右下角y}` +
56
+ `(原像素坐标,图片左上角为(0,0),尺寸 ${W}x${H},要求 x1<x2 且 y1<y2)。` +
57
+ `请确保 bbox 完全包围目标,不得遗漏目标的任何部分。` +
58
+ `如果图片中没有该目标,返回 {"found":false}。`;
59
+
60
+ const { answer, via, model } = await askWithBackends(backends, { question: prompt, imageDataUrl, signal });
61
+
62
+ const jsonText = extractBboxJson(answer);
63
+ if (!jsonText) throw new LocateError('iris_locate: 模型未返回有效 JSON:' + String(answer).slice(0, 160));
64
+ let obj;
65
+ try {
66
+ obj = JSON.parse(jsonText);
67
+ } catch (_) {
68
+ throw new LocateError('iris_locate: bbox JSON 解析失败:' + jsonText.slice(0, 160));
69
+ }
70
+ if (obj && obj.found === false) return { found: false, via, model };
71
+
72
+ const { x1, y1, x2, y2 } = obj || {};
73
+ if (![x1, y1, x2, y2].every((v) => Number.isFinite(v))) {
74
+ throw new LocateError('iris_locate: bbox 字段必须是数字:' + jsonText.slice(0, 160));
75
+ }
76
+ if (!(x1 < x2 && y1 < y2)) {
77
+ throw new LocateError(`iris_locate: bbox 无效(要求 x1<x2 且 y1<y2,得到 ${x1},${y1},${x2},${y2})`);
78
+ }
79
+ // 完全越界(与图片无交集)→ 报错;否则钳制到图片边界
80
+ if (x1 >= W || x2 <= 0 || y1 >= H || y2 <= 0) {
81
+ throw new LocateError(`iris_locate: bbox 完全超出图片边界 ${W}x${H}:${x1},${y1},${x2},${y2}`);
82
+ }
83
+ const clamp = (v) => Math.max(0, Math.min(v < 0 ? Math.round(v) : Math.ceil(v), W));
84
+ const clampY = (v) => Math.max(0, Math.min(v < 0 ? Math.round(v) : Math.ceil(v), H));
85
+ return {
86
+ found: true,
87
+ x1: clamp(x1),
88
+ y1: clampY(y1),
89
+ x2: clamp(x2),
90
+ y2: clampY(y2),
91
+ via,
92
+ model
93
+ };
94
+ }
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 媒体分析探测(阶段 7.1)—— 视频抽帧。
4
+ *
5
+ * ffmpeg 是可选系统可执行文件:启动时用 `which` 探测,缺失则 `ffmpegAvailable()` 返回
6
+ * false,抽帧工具在执行时报清晰的人话错误(其余功能不受影响)。静态版 ffmpeg
7
+ * (johnvansickle)同样可用。本模块为纯后端:零 npm 依赖,可离线单测。
8
+ *
9
+ * 契约(对齐 RESEARCH §9.2):
10
+ * - 最多帧数上限 20(覆盖 5 分钟短视频,不超 LLM 上下文窗口);
11
+ * - 最大宽度 4096(含),缩放到 targetWidth,高度按比例取偶数;
12
+ * - 帧数 = 请求数(时间均匀采样),短视频也能取到整 N 帧;
13
+ * - 返回帧的时间戳 = i * duration / n(i 从 0 起,均匀分布)。
14
+ */
15
+ import { spawnSync, spawn } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ export const MAX_FRAMES = 20;
21
+ export const MAX_WIDTH = 4096;
22
+ export const MIN_WIDTH = 16;
23
+
24
+ /** 探测 ffmpeg / ffprobe 是否可用 */
25
+ export function ffmpegAvailable() {
26
+ try {
27
+ const ff = spawnSync('which', ['ffmpeg'], { encoding: 'utf8' });
28
+ const fp = spawnSync('which', ['ffprobe'], { encoding: 'utf8' });
29
+ return ff.status === 0 && fp.status === 0;
30
+ } catch (_) {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /** 用 ffprobe 读取视频元数据 */
36
+ export function probeVideo(inputPath) {
37
+ if (!ffmpegAvailable()) throw new Error('iris: ffmpeg 不可用,无法分析视频(可选安装 ffmpeg/ffprobe 后启用)');
38
+ if (!fs.existsSync(inputPath)) throw new Error('iris: 视频文件不存在: ' + inputPath);
39
+ const r = spawnSync('ffprobe', [
40
+ '-v', 'error',
41
+ '-show_entries', 'format=duration,size:stream=width,height,codec_name,codec_type',
42
+ '-of', 'json',
43
+ inputPath
44
+ ], { encoding: 'utf8' });
45
+ if (r.status !== 0) throw new Error('iris: ffprobe 读取失败: ' + (r.stderr || '').slice(0, 300));
46
+ let data;
47
+ try { data = JSON.parse(r.stdout); } catch (_) { throw new Error('iris: ffprobe 输出解析失败'); }
48
+ const fmt = data.format || {};
49
+ const streams = Array.isArray(data.streams) ? data.streams : [];
50
+ const videoStream = streams.find((s) => s.codec_type === 'video' || s.width);
51
+ const audioStream = streams.find((s) => s.codec_type === 'audio');
52
+ return {
53
+ durationSec: Number(fmt.duration) || 0,
54
+ sizeBytes: Number(fmt.size) || 0,
55
+ width: videoStream ? Number(videoStream.width) || 0 : 0,
56
+ height: videoStream ? Number(videoStream.height) || 0 : 0,
57
+ codec: videoStream ? videoStream.codec_name || '' : '',
58
+ hasAudio: !!audioStream,
59
+ audioCodec: audioStream ? audioStream.codec_name || '' : ''
60
+ };
61
+ }
62
+
63
+ /** 校验并收敛抽帧参数,返回规范化配置 */
64
+ export function normalizeFramesOptions({ maxFrames = 8, targetWidth = 640, quality = 85, format = 'jpeg' } = {}) {
65
+ if (format !== 'jpeg' && format !== 'png') throw new Error('iris: format 只支持 jpeg 或 png');
66
+ const n = Math.round(Number(maxFrames));
67
+ if (!Number.isFinite(n) || n < 1) throw new Error('iris: maxFrames 必须是 ≥1 的整数');
68
+ const clampedN = Math.min(n, MAX_FRAMES);
69
+ const w = Math.round(Number(targetWidth));
70
+ if (!Number.isFinite(w) || w < MIN_WIDTH) throw new Error(`iris: targetWidth 必须是 ≥${MIN_WIDTH} 的整数`);
71
+ const clampedW = Math.min(w, MAX_WIDTH);
72
+ const q = Math.round(Number(quality));
73
+ if (!Number.isFinite(q) || q < 1 || q > 100) throw new Error('iris: quality 必须是 1–100 的整数');
74
+ return { maxFrames: clampedN, targetWidth: clampedW, quality: q, format };
75
+ }
76
+
77
+ /** 目标缩放后尺寸:宽 = min(targetWidth, 源宽),高按比例取偶数 */
78
+ export function scaledDimensions(srcWidth, srcHeight, targetWidth) {
79
+ const outW = Math.min(targetWidth, Math.max(1, srcWidth));
80
+ const outH = Math.max(2, Math.round((srcHeight * outW) / Math.max(1, srcWidth)) & ~1);
81
+ return { width: outW, height: outH };
82
+ }
83
+
84
+ /** 异步跑 ffmpeg(不阻塞宿主事件循环),支持取消信号 */
85
+ function runFfmpeg(args, { signal } = {}) {
86
+ return new Promise((resolve, reject) => {
87
+ const child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
88
+ let stderr = '';
89
+ child.stderr.on('data', (c) => { stderr += c; });
90
+ child.on('error', (e) => reject(e));
91
+ child.on('close', (code) => {
92
+ if (code === 0) resolve();
93
+ else reject(new Error('iris: 抽帧失败: ' + stderr.slice(0, 300)));
94
+ });
95
+ if (signal) {
96
+ if (signal.aborted) { child.kill('SIGKILL'); reject(new Error('已取消')); return; }
97
+ signal.addEventListener('abort', () => { child.kill('SIGKILL'); reject(new Error('已取消')); }, { once: true });
98
+ }
99
+ });
100
+ }
101
+
102
+ /**
103
+ * 从视频抽取 N 张帧(时间均匀采样),输出 JPEG/PNG buffer 数组。
104
+ * @param {{inputPath:string, maxFrames?:number, targetWidth?:number, quality?:number, format?:'jpeg'|'png', signal?:AbortSignal}}
105
+ * @returns {Promise<Array<{buffer:Buffer, atSec:number, width:number, height:number}>>}
106
+ */
107
+ export async function extractFrames({ inputPath, maxFrames = 8, targetWidth = 640, quality = 85, format = 'jpeg', signal } = {}) {
108
+ const opts = normalizeFramesOptions({ maxFrames, targetWidth, quality, format });
109
+ if (!ffmpegAvailable()) throw new Error('iris: ffmpeg 不可用,无法抽帧(可选安装 ffmpeg/ffprobe 后启用)');
110
+ if (!inputPath || typeof inputPath !== 'string') throw new Error('iris: inputPath 必须是视频文件的绝对路径');
111
+ if (!fs.existsSync(inputPath)) throw new Error('iris: 视频文件不存在: ' + inputPath);
112
+ const meta = probeVideo(inputPath);
113
+ const duration = meta.durationSec;
114
+ if (!(duration > 0)) throw new Error('iris: 无法读取视频时长(文件可能不是有效视频)');
115
+ const n = opts.maxFrames;
116
+ const { width: outW, height: outH } = scaledDimensions(meta.width, meta.height, opts.targetWidth);
117
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'iris-frames-'));
118
+ const ext = opts.format === 'png' ? '.png' : '.jpg';
119
+ try {
120
+ // fps 滤镜均匀抽帧:fps = n/duration(分数形式对短视频也精确产出 N 帧)
121
+ const fps = `${n}/${duration}`;
122
+ const outFile = path.join(outDir, 'f%d' + ext);
123
+ const args = ['-v', 'error', '-i', inputPath, '-vf', `fps=${fps},scale='min(${opts.targetWidth},iw)':-2`];
124
+ if (opts.format === 'png') args.push(outFile);
125
+ else args.push('-q:v', String(opts.quality), outFile);
126
+ await runFfmpeg(args, { signal });
127
+ const frames = [];
128
+ const names = fs.readdirSync(outDir).filter((f) => f.endsWith('.jpg') || f.endsWith('.png')).sort();
129
+ for (const name of names) {
130
+ const idx = Number(name.replace(/^f/, '').replace(/\.(jpg|png)$/, ''));
131
+ if (!Number.isFinite(idx) || idx < 1 || idx > n) continue; // 只取前 N 帧
132
+ const atSec = duration > 0 ? ((idx - 1) * duration) / n : 0;
133
+ frames.push({ buffer: fs.readFileSync(path.join(outDir, name)), atSec, width: outW, height: outH });
134
+ }
135
+ if (!frames.length) throw new Error('iris: 抽帧结果为空(ffmpeg 未产出任何帧)');
136
+ return frames;
137
+ } finally {
138
+ fs.rmSync(outDir, { recursive: true, force: true });
139
+ }
140
+ }
141
+
142
+ /**
143
+ * 从视频提取音轨(mp3,libmp3lame;静态 ffmpeg 已内置)。7.3 摘要的音轨转写输入。
144
+ * @param {{inputPath:string, signal?:AbortSignal}} opts
145
+ * @returns {Promise<{filePath:string, outDir:string, mime:string}>} 调用方负责 finally 清理 outDir
146
+ */
147
+ export async function extractAudioTrack({ inputPath, signal } = {}) {
148
+ if (!ffmpegAvailable()) throw new Error('iris: ffmpeg 不可用,无法提取音轨(可选安装 ffmpeg/ffprobe 后启用)');
149
+ if (!inputPath || typeof inputPath !== 'string') throw new Error('iris: inputPath 必须是视频文件的绝对路径');
150
+ if (!fs.existsSync(inputPath)) throw new Error('iris: 视频文件不存在: ' + inputPath);
151
+ const meta = probeVideo(inputPath);
152
+ if (!meta.hasAudio) throw new Error('iris: 该视频没有音轨,无需提取');
153
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'iris-audio-'));
154
+ const filePath = path.join(outDir, 'track.mp3');
155
+ try {
156
+ await runFfmpeg([
157
+ '-v', 'error', '-i', inputPath,
158
+ '-vn', '-acodec', 'libmp3lame', '-q:a', '4',
159
+ '-y', filePath
160
+ ], { signal });
161
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).size === 0) {
162
+ throw new Error('iris: 音轨提取结果为空(ffmpeg 未产出音频)');
163
+ }
164
+ return { filePath, outDir, mime: 'audio/mpeg' };
165
+ } catch (err) {
166
+ fs.rmSync(outDir, { recursive: true, force: true });
167
+ throw err;
168
+ }
169
+ }
package/lib/media.js ADDED
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 媒体通道 —— 让视频/音频像图片一样在对话流里可点可播。
4
+ *
5
+ * 背景:DSH 附件服务只收图片(saveImage/readImage),mp4/wav 无原生通路。
6
+ * 方案(学 vision-mix):宿内 /iris/media 路由 + 工具结果返回播放链接。
7
+ *
8
+ * 安全边界:
9
+ * - token 是能力凭证:crypto 随机 128bit,只存在任务记录里,URL 不可枚举
10
+ * - 文件定位只信任务记录(taskId+token 精确匹配),URL 里的文件名段仅作展示,
11
+ * 绝不参与文件系统路径解析(防目录穿越)
12
+ * - 未命中一律 404(不给枚举线索);仅允许 GET/HEAD
13
+ */
14
+ import crypto from 'node:crypto';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import * as tasks from './tasks.js';
18
+
19
+ const MIME = {
20
+ '.mp4': 'video/mp4',
21
+ '.mov': 'video/quicktime',
22
+ '.webm': 'video/webm',
23
+ '.wav': 'audio/wav',
24
+ '.mp3': 'audio/mpeg',
25
+ '.png': 'image/png',
26
+ '.jpg': 'image/jpeg',
27
+ '.jpeg': 'image/jpeg',
28
+ '.webp': 'image/webp',
29
+ '.gif': 'image/gif'
30
+ };
31
+
32
+ /** Web 基址:默认本机 DSH GUI;反代/远程部署用 DSH_WEB_BASE 覆盖 */
33
+ export function webBase() {
34
+ return String(process.env.DSH_WEB_BASE || 'http://127.0.0.1:3080').replace(/\/+$/, '');
35
+ }
36
+
37
+ export function mimeOf(name) {
38
+ return MIME[path.extname(String(name)).toLowerCase()] || 'application/octet-stream';
39
+ }
40
+
41
+ function kindLabel(mime) {
42
+ if (mime.startsWith('video/')) return '▶ 视频';
43
+ if (mime.startsWith('audio/')) return '♪ 音频';
44
+ if (mime.startsWith('image/')) return '🖼 图片';
45
+ return '📄 文件';
46
+ }
47
+
48
+ /**
49
+ * 把一个已落盘的产物登记为可访问媒体:生成 token、写进任务记录、返回链接信息。
50
+ * @returns {{file,token,mime,url}|null}
51
+ */
52
+ export function registerMedia(taskId, absPath) {
53
+ const t = tasks.get(taskId);
54
+ if (!t) return null;
55
+ const file = path.basename(absPath);
56
+ if (!(t.media || []).some((e) => e.file === file)) {
57
+ const entry = {
58
+ file,
59
+ token: crypto.randomBytes(16).toString('hex'),
60
+ mime: mimeOf(file),
61
+ createdAt: new Date().toISOString()
62
+ };
63
+ tasks.update(taskId, { media: [...(t.media || []), entry] });
64
+ return { ...entry, url: mediaUrl(taskId, entry) };
65
+ }
66
+ const entry = t.media.find((e) => e.file === file);
67
+ return { ...entry, url: mediaUrl(taskId, entry) };
68
+ }
69
+
70
+ export function mediaUrl(taskId, entry) {
71
+ return `${webBase()}/iris/media/${taskId}/${entry.token}/${encodeURIComponent(entry.file)}`;
72
+ }
73
+
74
+ /** 任务记录里全部媒体的 Markdown 播放链接 */
75
+ export function mediaLinksOf(task) {
76
+ return (task.media || []).map((e) => `- [${kindLabel(mimeOf(e.file))} 播放](${mediaUrl(task.id, e)})`);
77
+ }
78
+
79
+ /** 解析并授权一次媒体请求;未授权返回 null */
80
+ export function authorizeMedia(taskId, token, name) {
81
+ const t = tasks.get(taskId);
82
+ const entry = t && (t.media || []).find((e) => e.token === token && e.file === name);
83
+ if (!entry) return null;
84
+ const abs = path.join(tasks.outputsDir(), entry.file);
85
+ if (!fs.existsSync(abs)) return null;
86
+ return { entry, abs, size: fs.statSync(abs).size };
87
+ }
88
+
89
+ /* ---------------- HTTP 服务(GET/HEAD + Range 流式) ---------------- */
90
+
91
+ function sendText(res, status, text) {
92
+ if (res.writableEnded || res.destroyed) return; // 已结束连接不再写(与 api.js sendJson 同纪律)
93
+ if (!res.headersSent) res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
94
+ res.end(text);
95
+ }
96
+
97
+ /**
98
+ * 解析单段 Range 头:bytes=start-end / bytes=start- / bytes=-suffix。
99
+ * @returns {null|{invalid:true}|{start:number,end:number}} null = 无/不可解析 → 走 200 全量
100
+ */
101
+ export function parseRange(header, size) {
102
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header || '').trim());
103
+ if (!m) return null;
104
+ let start = m[1] === '' ? undefined : Number(m[1]);
105
+ let end = m[2] === '' ? undefined : Number(m[2]);
106
+ if (start === undefined && end === undefined) return null;
107
+ if (start === undefined) {
108
+ const suffix = end;
109
+ if (!Number.isFinite(suffix) || suffix <= 0) return null;
110
+ start = Math.max(0, size - suffix);
111
+ end = size - 1;
112
+ } else {
113
+ if (!Number.isFinite(start) || start < 0 || start >= size) return { invalid: true };
114
+ if (end === undefined || !Number.isFinite(end) || end >= size) end = size - 1;
115
+ }
116
+ if (start > end) return null;
117
+ return { start, end };
118
+ }
119
+
120
+ /**
121
+ * 流式服务媒体产物:支持 HEAD、单段 Range(206 Partial Content / 416)、
122
+ * 全量/部分都用 createReadStream 流式,避免大视频整体进内存。
123
+ * 未命中一律 404;仅 GET/HEAD。
124
+ */
125
+ export function serveMedia(req, res) {
126
+ try {
127
+ const method = String(req.method || 'GET').toUpperCase();
128
+ if (method !== 'GET' && method !== 'HEAD') return sendText(res, 405, 'method not allowed');
129
+ const parts = String(req.url || '').split('?')[0].split('/').filter(Boolean);
130
+ // /iris/media/:taskId/:token/:name
131
+ if (parts.length !== 5 || parts[0] !== 'iris' || parts[1] !== 'media') return sendText(res, 404, 'not found');
132
+ let name;
133
+ try {
134
+ name = decodeURIComponent(parts[4]);
135
+ } catch (_) {
136
+ return sendText(res, 404, 'not found');
137
+ }
138
+ const hit = authorizeMedia(parts[2], parts[3], name);
139
+ if (!hit) return sendText(res, 404, 'not found'); // 未命中一律 404,不给枚举线索
140
+ const base = {
141
+ 'Content-Type': hit.entry.mime,
142
+ 'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(hit.entry.file)}`,
143
+ 'Accept-Ranges': 'bytes',
144
+ 'Cache-Control': 'private, max-age=3600',
145
+ 'X-Content-Type-Options': 'nosniff'
146
+ };
147
+ const range = parseRange(req.headers && req.headers.range, hit.size);
148
+ if (range && range.invalid) {
149
+ res.writeHead(416, { ...base, 'Content-Range': `bytes */${hit.size}` });
150
+ res.end();
151
+ return;
152
+ }
153
+ if (range) {
154
+ const len = range.end - range.start + 1;
155
+ res.writeHead(206, { ...base, 'Content-Length': len, 'Content-Range': `bytes ${range.start}-${range.end}/${hit.size}` });
156
+ if (method === 'HEAD') return res.end();
157
+ const stream = fs.createReadStream(hit.abs, { start: range.start, end: range.end });
158
+ stream.on('error', () => { try { res.destroy(); } catch (_) {} });
159
+ res.on('close', () => { if (!res.writableEnded) stream.destroy(); }); // 客户端中途断开:立即释放读流,不留 fd 到 EOF
160
+ stream.pipe(res);
161
+ return;
162
+ }
163
+ res.writeHead(200, { ...base, 'Content-Length': hit.size });
164
+ if (method === 'HEAD') return res.end();
165
+ const stream = fs.createReadStream(hit.abs);
166
+ stream.on('error', () => { try { res.destroy(); } catch (_) {} });
167
+ res.on('close', () => { if (!res.writableEnded) stream.destroy(); });
168
+ stream.pipe(res);
169
+ } catch (err) {
170
+ sendText(res, 500, 'media error');
171
+ }
172
+ }
package/lib/models.js ADDED
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 模型发现规则(阶段 6)。
4
+ *
5
+ * 目标:把「账号(key)」与「能力」解耦——一个 key 提供模型池,能力从池里选模型。
6
+ * 本模块是纯静态规则(零 API 调用):模型名 → 能力标签。
7
+ *
8
+ * 池的来源:
9
+ * ① provider 显式 `models` 数组(未来配置,可带 capabilities 覆盖)
10
+ * ② 旧模型字段迁移:imageModel→image-gen、videoModel→video-gen、ttsModel→tts、
11
+ * transcribeModel→transcribe、visionModel→vision
12
+ * ③ 裸 DashScope 账号:已知模型清单(wan 系列 / qwen-vl / qwen-tts)作为池
13
+ */
14
+ import { CAPABILITIES } from './capability.js';
15
+
16
+ /** 能力 → provider 上的模型字段(保持向后兼容的返回形状) */
17
+ export const CAP_FIELD = {
18
+ [CAPABILITIES.IMAGE]: 'imageModel',
19
+ [CAPABILITIES.VIDEO]: 'videoModel',
20
+ [CAPABILITIES.TTS]: 'ttsModel',
21
+ [CAPABILITIES.TRANSCRIBE]: 'transcribeModel',
22
+ [CAPABILITIES.VISION]: 'visionModel'
23
+ };
24
+
25
+ const MODEL_REF_SEP = '::';
26
+
27
+ /** 稳定的模型复合引用;provider/model 分别编码,避免同名模型跨账号碰撞。 */
28
+ export function modelRef(providerId, modelId) {
29
+ return encodeURIComponent(String(providerId || '')) + MODEL_REF_SEP + encodeURIComponent(String(modelId || ''));
30
+ }
31
+
32
+ /** 解析复合引用;旧的纯 model id 返回 null,由配置层按兼容规则解析。 */
33
+ export function parseModelRef(ref) {
34
+ if (typeof ref !== 'string') return null;
35
+ const i = ref.indexOf(MODEL_REF_SEP);
36
+ if (i <= 0) return null;
37
+ try {
38
+ const providerId = decodeURIComponent(ref.slice(0, i));
39
+ const modelId = decodeURIComponent(ref.slice(i + MODEL_REF_SEP.length));
40
+ return providerId && modelId ? { providerId, modelId } : null;
41
+ } catch (_) {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** 模型名模式 → 能力(静态规则,来源为 DashScope/OpenAI 已知模型命名族) */
47
+ const MODEL_CAP_RULES = [
48
+ // 视频先于图像:wan*-t2v/s2v/i2v/video 不能被图像规则误吞
49
+ [/^wan.*-(t2v|s2v|i2v|r2v|video)/i, [CAPABILITIES.VIDEO]],
50
+ [/^wan.*-t2i/i, [CAPABILITIES.IMAGE]],
51
+ [/^wan.*image/i, [CAPABILITIES.IMAGE]], // wan2.7-image / -pro
52
+ [/^qwen-image-edit/i, []], // 纯编辑模型没有文生图能力,不自动加入 iris_draw_image
53
+ [/^qwen-image/i, [CAPABILITIES.IMAGE]], // qwen-image-3.0 / -max
54
+ [/^z-image/i, [CAPABILITIES.IMAGE]],
55
+ [/^qwen\d?-vl/i, [CAPABILITIES.VISION]], // qwen-vl / qwen3-vl / -max / -ocr
56
+ [/^qwen\d?-tts/i, [CAPABILITIES.TTS]], // qwen-tts / qwen3-tts-flash
57
+ [/^cosyvoice/i, [CAPABILITIES.TTS]],
58
+ [/^(?:qwen-audio-.*-asr-.*filetrans|qwen3-asr-.*filetrans|fun-asr(?:-|$)|paraformer)/i, [CAPABILITIES.TRANSCRIBE]],
59
+ [/^(?:qwen-audio-turbo|sensevoice)/i, []], // 音频理解模型/待下线模型不自动分配给文件转写
60
+ [/^gpt-image/i, [CAPABILITIES.IMAGE]],
61
+ [/^dall-e/i, [CAPABILITIES.IMAGE]],
62
+ [/^gemini/i, [CAPABILITIES.VISION]] // Gemini 默认按视觉能力纳入(可被显式配置覆盖)
63
+ ];
64
+
65
+ /** 裸 DashScope 已知模型清单(无显式模型字段时作为池) */
66
+ const DASHSCOPE_KNOWN = [
67
+ { id: 'wan2.2-t2i-flash', caps: [CAPABILITIES.IMAGE] },
68
+ { id: 'wan2.2-t2v-flash', caps: [CAPABILITIES.VIDEO] },
69
+ { id: 'wan2.2-s2v-flash', caps: [CAPABILITIES.VIDEO] },
70
+ { id: 'qwen-vl-plus', caps: [CAPABILITIES.VISION] },
71
+ // VERIFY 2026-09-03 实证:locate grounding 零偏差(qwen-vl-plus 仅 25x25 框选)
72
+ { id: 'qwen3-vl-235b-a22b-thinking', caps: [CAPABILITIES.VISION] },
73
+ { id: 'qwen-tts-latest', caps: [CAPABILITIES.TTS] },
74
+ { id: 'qwen-audio-3.0-asr-flash-filetrans', caps: [CAPABILITIES.TRANSCRIBE] }
75
+ ];
76
+
77
+ /** 按模型名规则推断能力 */
78
+ export function capabilitiesOfModel(modelName) {
79
+ const name = String(modelName || '');
80
+ for (const [re, caps] of MODEL_CAP_RULES) {
81
+ if (re.test(name)) return [...caps];
82
+ }
83
+ return [];
84
+ }
85
+
86
+ /** 一个 provider 的模型池条目列表:[{ id, providerId, capabilities:[], verified?, source? }] */
87
+ export function providerModels(p) {
88
+ if (!p) return [];
89
+ const out = [];
90
+ const push = (id, caps, extra) => {
91
+ const name = String(id || '').trim();
92
+ if (!name || out.some((m) => m.id === name)) return;
93
+ out.push({ id: name, providerId: p.id, ref: modelRef(p.id, name), capabilities: [...caps], ...(extra || {}) });
94
+ };
95
+ // ① 显式 models 数组(带可选 capabilities 覆盖 + verified/source 透传)
96
+ if (Array.isArray(p.models) && p.models.length) {
97
+ for (const m of p.models) {
98
+ const id = typeof m === 'string' ? m : m && m.id;
99
+ // 显式空数组也是权威覆盖:用户可明确标记“此模型无能力”。
100
+ const caps = Array.isArray(m && m.capabilities) ? m.capabilities : capabilitiesOfModel(id);
101
+ const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source } : {};
102
+ push(id, caps, extra);
103
+ }
104
+ return out;
105
+ }
106
+ // ② 旧模型字段迁移
107
+ for (const [cap, field] of Object.entries(CAP_FIELD)) {
108
+ if (p[field]) push(p[field], [cap]);
109
+ }
110
+ if (out.length) return out;
111
+ // ③ 裸 DashScope 账号 → 已知模型池
112
+ if (/dashscope/i.test(String(p.baseUrl || ''))) {
113
+ for (const m of DASHSCOPE_KNOWN) push(m.id, m.caps);
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** 全局模型池:合并所有 provider 的模型 */
119
+ export function modelPool(providers) {
120
+ const pool = [];
121
+ for (const p of providers || []) pool.push(...providerModels(p));
122
+ return pool;
123
+ }
124
+
125
+ /** 从池里挑有某能力的模型(按池顺序) */
126
+ export function pickModel(pool, capability) {
127
+ return (pool || []).find((m) => m.capabilities.includes(capability)) || null;
128
+ }
package/lib/ocr.js ADDED
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 长截图 OCR(阶段 3B):视觉模型分块文字识别。
4
+ *
5
+ * 调研结论(RESEARCH.md §7):qwen-vl 视觉模型中文零误差,tesseract.js 准确率差且置信度虚高。
6
+ * 方案:复用阶段 1 VisionBackend 链,sharp 分块(1200px 高 + 120px 重叠避免切断文本行),
7
+ * 逐块 OCR 后拼接。
8
+ *
9
+ * 契约:
10
+ * - chunkHeight 默认 1200,overlap 默认 120
11
+ * - 每块独立调用视觉模型,结果按 y 坐标顺序拼接
12
+ * - 返回全文 + 分块明细 + 元数据
13
+ */
14
+ import sharp from 'sharp';
15
+ import { askWithBackends } from './vision.js';
16
+ import { imageDimensions } from './pixels.js';
17
+
18
+ export class OcrError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'OcrError';
22
+ }
23
+ }
24
+
25
+ const OCR_PROMPT = '请完整读出图片中的全部文字,包括标点符号。按从上到下的顺序逐行输出。不要添加额外解释。';
26
+
27
+ /**
28
+ * 分块 OCR:把长图按 chunkHeight 切片(重叠 overlap 避免切断文本行),逐块调用视觉模型,
29
+ * 拼接全文。
30
+ * @param {{input: Buffer|string, backends: Array, chunkHeight?:number, overlap?:number, signal?:AbortSignal, maxDimension?:number}}
31
+ * @returns {Promise<{fullText:string, chunks:Array, totalChunks:number, width:number, height:number}>}
32
+ * chunks: [{y, height, text, error?}]
33
+ */
34
+ export async function longOcr({ input, backends, chunkHeight = 1200, overlap = 120, signal, maxDimension = 2048 }) {
35
+ if (!Array.isArray(backends) || backends.length === 0) {
36
+ throw new OcrError('iris_long_ocr: 没有可用的视觉后端(至少需要一个 vision provider)');
37
+ }
38
+ const { width: W, height: H } = await imageDimensions(input);
39
+ if (W <= 0 || H <= 0) throw new OcrError('iris_long_ocr: 图片尺寸无效');
40
+ const ch = Math.max(100, Math.floor(chunkHeight));
41
+ const ov = Math.max(0, Math.min(Math.floor(overlap), ch - 1));
42
+ const step = ch - ov;
43
+
44
+ // 如果宽度超过 maxDimension,先等比缩放
45
+ let img = input;
46
+ if (W > maxDimension) {
47
+ const s = maxDimension / W;
48
+ const resized = await sharp(input).resize(Math.round(W * s), Math.round(H * s)).png().toBuffer();
49
+ img = resized;
50
+ }
51
+ const meta = await sharp(img).metadata();
52
+ const iw = meta.width || W;
53
+ const ih = meta.height || H;
54
+
55
+ const chunks = [];
56
+ let y = 0;
57
+ while (y < ih) {
58
+ const h = Math.min(ch, ih - y);
59
+ if (h <= 0) break;
60
+ try {
61
+ const png = await sharp(img).extract({ left: 0, top: y, width: iw, height: h }).png().toBuffer();
62
+ const dataUrl = `data:image/png;base64,${png.toString('base64')}`;
63
+ const { answer } = await askWithBackends(backends, {
64
+ question: OCR_PROMPT,
65
+ imageDataUrl: dataUrl,
66
+ signal
67
+ });
68
+ chunks.push({ y, height: h, text: String(answer || '').trim() });
69
+ } catch (err) {
70
+ chunks.push({ y, height: h, text: '', error: String((err && err.message) || err) });
71
+ }
72
+ y += step;
73
+ }
74
+ const fullText = chunks
75
+ .filter((c) => c.text)
76
+ .map((c, i) => `[第${i + 1}段 y=${c.y}] ${c.text}`)
77
+ .join('\n')
78
+ .trim();
79
+ return { fullText, chunks, totalChunks: chunks.length, width: meta.width || W, height: meta.height || H };
80
+ }