@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/pixels.js ADDED
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 确定性像素工具后端(阶段 2):iris_crop + iris_pixel_diff。
4
+ *
5
+ * 基于 sharp(Apache-2.0,本机 aarch64 proot Debian 已验证)。
6
+ * 契约(进入测试):
7
+ * - crop:区域 (left,top,width,height) 必须为正且不越界;输出 PNG + 实际尺寸。
8
+ * - diff:尺寸不一致时以「较小者」为基准强制归一化(fit=fill),上限
9
+ * MAX_DIFF_DIMENSION 防爆内存;alpha 通道不参与差异计算;颜色空间统一 sRGB。
10
+ * - 差异判定用 RGB 通道差之和阈值(容忍压缩噪声),diff ratio = 差异像素占比。
11
+ */
12
+ import sharp from 'sharp';
13
+
14
+ /** diff 归一化后的最大边长(防超大图整读内存) */
15
+ const MAX_DIFF_DIMENSION = 1024;
16
+ /** RGB 通道差之和超过该值才计为差异像素(容忍 JPEG 压缩噪声) */
17
+ const DIFF_THRESHOLD = 30;
18
+
19
+ export class PixelError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = 'PixelError';
23
+ }
24
+ }
25
+
26
+ /** 读图片宽高(iris_locate 等需要原图尺寸的地方用) */
27
+ export async function imageDimensions(input) {
28
+ const meta = await sharp(input).metadata();
29
+ if (!meta.width || !meta.height) throw new PixelError('iris: 图片尺寸读取失败');
30
+ return { width: meta.width, height: meta.height };
31
+ }
32
+
33
+ /**
34
+ * 裁剪图片区域,输出 PNG + 实际尺寸。
35
+ * @param {{input: Buffer|string, left:number, top:number, width:number, height:number}}
36
+ * @returns {Promise<{buffer:Buffer, width:number, height:number, mime:string}>}
37
+ */
38
+ export async function cropImage({ input, left, top, width, height }) {
39
+ const meta = await sharp(input).metadata();
40
+ const W = meta.width || 0;
41
+ const H = meta.height || 0;
42
+ const l = Math.floor(left);
43
+ const t = Math.floor(top);
44
+ const w = Math.floor(width);
45
+ const h = Math.floor(height);
46
+ for (const [name, v] of [['left', l], ['top', t], ['width', w], ['height', h]]) {
47
+ if (!Number.isFinite(v)) throw new PixelError(`iris_crop: ${name} 必须是数字`);
48
+ }
49
+ if (w <= 0 || h <= 0) throw new PixelError(`iris_crop: width/height 必须为正(得到 ${w}x${h})`);
50
+ if (l < 0 || t < 0 || l + w > W || t + h > H) {
51
+ throw new PixelError(`iris_crop: 区域 (${l},${t},${w},${h}) 超出图片边界 ${W}x${H}`);
52
+ }
53
+ const { data, info } = await sharp(input)
54
+ .extract({ left: l, top: t, width: w, height: h })
55
+ .png()
56
+ .toBuffer({ resolveWithObject: true });
57
+ return { buffer: data, width: info.width, height: info.height, mime: 'image/png' };
58
+ }
59
+
60
+ /**
61
+ * 像素级 diff:输出 diff ratio、最差区域、热力图 PNG。
62
+ * @param {{inputA: Buffer|string, inputB: Buffer|string, grid?:number, topRegions?:number}}
63
+ * @returns {Promise<{ratio:number, diffPixels:number, totalPixels:number, width:number, height:number,
64
+ * worstRegions:Array<{row:number,col:number,score:number}>, heatmap:Buffer, mime:string}>}
65
+ */
66
+ export async function pixelDiff({ inputA, inputB, grid = 8, topRegions = 3 }) {
67
+ const [metaA, metaB] = await Promise.all([sharp(inputA).metadata(), sharp(inputB).metadata()]);
68
+ if (!metaA.width || !metaA.height || !metaB.width || !metaB.height) {
69
+ throw new PixelError('iris_pixel_diff: 图片尺寸读取失败');
70
+ }
71
+ // 尺寸策略:以较小者为基准归一化;超上限等比缩到 MAX_DIFF_DIMENSION 内
72
+ let targetW = Math.min(metaA.width, metaB.width);
73
+ let targetH = Math.min(metaA.height, metaB.height);
74
+ const cap = Math.max(targetW, targetH);
75
+ if (cap > MAX_DIFF_DIMENSION) {
76
+ const s = MAX_DIFF_DIMENSION / cap;
77
+ targetW = Math.max(1, Math.round(targetW * s));
78
+ targetH = Math.max(1, Math.round(targetH * s));
79
+ }
80
+
81
+ // 读取两图 RGB raw(去 alpha,统一到目标尺寸,强制拉伸对齐)
82
+ const [rawA, rawB] = await Promise.all([
83
+ sharp(inputA).resize(targetW, targetH, { fit: 'fill' }).removeAlpha().raw().toBuffer(),
84
+ sharp(inputB).resize(targetW, targetH, { fit: 'fill' }).removeAlpha().raw().toBuffer()
85
+ ]);
86
+ if (rawA.length !== targetW * targetH * 3 || rawB.length !== targetW * targetH * 3) {
87
+ throw new PixelError('iris_pixel_diff: 像素读取失败(尺寸不匹配)');
88
+ }
89
+
90
+ const N = targetW * targetH;
91
+ const mag = new Float32Array(N); // 每像素归一化差异幅值 0..1
92
+ let diffPixels = 0;
93
+ for (let i = 0; i < N; i++) {
94
+ const o = i * 3;
95
+ const d =
96
+ Math.abs(rawA[o] - rawB[o]) +
97
+ Math.abs(rawA[o + 1] - rawB[o + 1]) +
98
+ Math.abs(rawA[o + 2] - rawB[o + 2]);
99
+ mag[i] = d / 765;
100
+ if (d > DIFF_THRESHOLD) diffPixels++;
101
+ }
102
+ const ratio = N ? diffPixels / N : 0;
103
+
104
+ // 8x8(默认)网格:统计每格平均差异,取最差 topRegions
105
+ const g = Math.max(1, Math.floor(grid));
106
+ const gw = Math.ceil(targetW / g);
107
+ const gh = Math.ceil(targetH / g);
108
+ const cells = [];
109
+ for (let r = 0; r < g; r++) {
110
+ for (let c = 0; c < g; c++) {
111
+ let sum = 0;
112
+ let cnt = 0;
113
+ const y0 = r * gh;
114
+ const y1 = Math.min((r + 1) * gh, targetH);
115
+ const x0 = c * gw;
116
+ const x1 = Math.min((c + 1) * gw, targetW);
117
+ for (let y = y0; y < y1; y++) {
118
+ for (let x = x0; x < x1; x++) {
119
+ sum += mag[y * targetW + x];
120
+ cnt++;
121
+ }
122
+ }
123
+ cells.push({ row: r, col: c, score: cnt ? sum / cnt : 0 });
124
+ }
125
+ }
126
+ cells.sort((a, b) => b.score - a.score);
127
+ const worstRegions = cells.slice(0, Math.max(1, Math.floor(topRegions)));
128
+
129
+ // 热力图:灰度 PNG,亮度 = 差异幅值(放大低差异可见性)
130
+ const heat = Buffer.alloc(N * 3);
131
+ for (let i = 0; i < N; i++) {
132
+ const v = Math.min(255, Math.round(mag[i] * 255 * 2));
133
+ heat[i * 3] = v;
134
+ heat[i * 3 + 1] = v;
135
+ heat[i * 3 + 2] = v;
136
+ }
137
+ const heatmap = await sharp(heat, { raw: { width: targetW, height: targetH, channels: 3 } }).png().toBuffer();
138
+
139
+ return { ratio, diffPixels, totalPixels: N, width: targetW, height: targetH, worstRegions, heatmap, mime: 'image/png' };
140
+ }
package/lib/render.js ADDED
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris HTML 渲染抽象(阶段 3C/HTML 截图)。
4
+ *
5
+ * HtmlRenderer 接口:render({ html, width?, height?, fullPage? }) →
6
+ * Promise<{ png: Buffer }>
7
+ *
8
+ * BrowserHtmlRenderer:基于宿主 webServer 的 /iris/render/ 静态路由 +
9
+ * dsh-builtin-browser 的 ctx.browser 服务(可选访问,软耦合)。
10
+ * - 不 import dsh-builtin-browser 包,只在运行时探测 ctx.get('browser')
11
+ * - HTML 通过宿主路由以同源 HTTP 打开(避免 file:// 的 CORS 限制)
12
+ * - 每次调用独立临时渲染目录,用后即删
13
+ */
14
+ import crypto from 'node:crypto';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { irisHome } from './config.js';
18
+ import { webBase, mimeOf } from './media.js';
19
+
20
+ export class RenderError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = 'RenderError';
24
+ }
25
+ }
26
+
27
+ /** 渲染临时目录根:$DSH_HOME/iris/v1/render/ */
28
+ export function renderRoot() {
29
+ return path.join(irisHome(), 'render');
30
+ }
31
+
32
+ /**
33
+ * 基于 ctx.browser(dsh-builtin-browser)的 HTML → PNG 渲染器。
34
+ * @param {{browser?: object, waitMs?: number}} opts browser 为 ctx.get('browser') 服务
35
+ */
36
+ export class BrowserHtmlRenderer {
37
+ constructor({ browser, waitMs = 1200 }) {
38
+ this.browser = browser;
39
+ this.waitMs = waitMs;
40
+ }
41
+
42
+ async render({ html, width, height, fullPage }) {
43
+ const b = this.browser;
44
+ if (!b || typeof b.open !== 'function' || typeof b.openUrl !== 'function' || typeof b.screenshot !== 'function') {
45
+ throw new RenderError('iris: 浏览器服务不可用(ctx.get("browser") 为空)——需要启用 dsh-builtin-browser 插件');
46
+ }
47
+ const text = String(html || '').trim();
48
+ if (!text) throw new RenderError('iris_html_screenshot: html 不能为空');
49
+
50
+ // 每次调用独立子目录(随机名),写 index.html
51
+ const dir = path.join(renderRoot(), crypto.randomBytes(6).toString('hex'));
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ let content = text;
54
+ if (width || height) {
55
+ const w = width ? Math.floor(width) : 'auto';
56
+ const h = height ? Math.floor(height) : 'auto';
57
+ content = `<div style="min-width:${w}px;min-height:${h}px">${text}</div>`;
58
+ }
59
+ const htmlPath = path.join(dir, 'index.html');
60
+ fs.writeFileSync(htmlPath, content, { mode: 0o600 });
61
+ const url = `${webBase()}/iris/render/${path.basename(dir)}/index.html`;
62
+ const pngPath = path.join(dir, 'render.png');
63
+
64
+ try {
65
+ const session = await b.open();
66
+ try {
67
+ await b.openUrl(session, { url, newTab: true });
68
+ if (this.waitMs > 0) {
69
+ await new Promise((r) => setTimeout(r, this.waitMs)); // 等页面加载(无加载完成事件可用的折中)
70
+ }
71
+ await b.screenshot(session, { fullPage: fullPage !== false, savePath: pngPath });
72
+ } finally {
73
+ try { await b.close(session); } catch (_) { /* 幂等 */ }
74
+ }
75
+ if (!fs.existsSync(pngPath)) throw new RenderError('iris_html_screenshot: 浏览器未产出截图文件');
76
+ return { png: fs.readFileSync(pngPath) };
77
+ } finally {
78
+ fs.rmSync(dir, { recursive: true, force: true }); // 用完即清
79
+ }
80
+ }
81
+ }
82
+
83
+ /* ---------------- /iris/render/ 静态路由(同源 HTTP 服务渲染目录) ---------------- */
84
+
85
+ /**
86
+ * webServer 前缀路由 handler:GET/HEAD /iris/render/<dir>/<file>。
87
+ * 只服务 renderRoot() 下两级路径(随机子目录 + 单文件),路径穿越一律 404。
88
+ */
89
+ export function serveRender(req, res) {
90
+ try {
91
+ const method = String(req.method || 'GET').toUpperCase();
92
+ if (method !== 'GET' && method !== 'HEAD') {
93
+ if (!res.headersSent) res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
94
+ res.end('method not allowed');
95
+ return;
96
+ }
97
+ const parts = String(req.url || '').split('?')[0].split('/').filter(Boolean);
98
+ // /iris/render/<dir>/<file> —— 恰好 4 段(iris/render/dir/file)
99
+ if (parts.length !== 4 || parts[0] !== 'iris' || parts[1] !== 'render') {
100
+ return send404(res);
101
+ }
102
+ const [, , subdir, file] = parts;
103
+ // 段必须是安全 basename(拒绝 .. / 空)
104
+ for (const seg of [subdir, file]) {
105
+ if (!seg || seg === '.' || seg === '..' || /[\\/]/.test(seg)) return send404(res);
106
+ }
107
+ const root = renderRoot();
108
+ const abs = path.join(root, subdir, file);
109
+ // 双保险:解析后必须仍在渲染根内
110
+ if (!abs.startsWith(root + path.sep) || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
111
+ return send404(res);
112
+ }
113
+ const size = fs.statSync(abs).size;
114
+ // .html 必须是 text/html(Chromium 在 nosniff 下拒绝 octet-stream 的 HTML 导航 → ERR_ABORTED)
115
+ const ext = path.extname(file).toLowerCase();
116
+ const contentType = ext === '.html' || ext === '.htm'
117
+ ? 'text/html; charset=utf-8'
118
+ : mimeOf(file);
119
+ res.writeHead(200, {
120
+ 'Content-Type': contentType,
121
+ 'Content-Length': size,
122
+ 'Cache-Control': 'no-store',
123
+ // 不可信 HTML 只负责排版截图:无脚本、无网络、无表单,且为 opaque origin。
124
+ 'Content-Security-Policy': "sandbox; default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-src 'none'",
125
+ 'Referrer-Policy': 'no-referrer',
126
+ 'X-Content-Type-Options': 'nosniff'
127
+ });
128
+ res.end(method === 'HEAD' ? undefined : fs.readFileSync(abs));
129
+ } catch (err) {
130
+ if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
131
+ res.end('render error');
132
+ }
133
+ }
134
+
135
+ function send404(res) {
136
+ if (!res.headersSent) res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
137
+ res.end('not found');
138
+ }
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 多模态上下文摘要(阶段 7.3)—— 把视频压成「一张联系表 + 一段转写文本」喂给视觉模型。
4
+ *
5
+ * 为什么是 contact sheet:
6
+ * - VisionBackend 一次 analyze 只送一张图(自持栈吃 dataUrl / 全局模型吃 attachment ref),
7
+ * 逐帧送 N 次要 N 次模型调用、费用与延迟都不可接受;
8
+ * - 把 N 帧缩略图按时间序拼成网格 + 每帧盖时间戳标签,一次视觉调用即可看全片(上下文压缩)。
9
+ *
10
+ * 契约:
11
+ * - buildContactSheet(frames, opts) → PNG buffer + 网格尺寸(纯 sharp,可离线测试)
12
+ * - summarizeMedia({backends, question?, transcript?, imageDataUrl, ref, signal})
13
+ * → askWithBackends(复用阶段 1 降级链),prompt 里嵌入转写文本(若有)
14
+ */
15
+ import sharp from 'sharp';
16
+
17
+ export class SummarizeError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'SummarizeError';
21
+ }
22
+ }
23
+
24
+ const DEFAULT_QUESTION = '请根据这些视频关键帧(按时间顺序从左到右、从上到下排列)总结视频的画面内容、场景与主题。';
25
+
26
+ /**
27
+ * 把 N 帧拼成带时间戳标签的联系表(PNG buffer)。
28
+ * @param {{frames:Array<{buffer:Buffer, atSec:number, width:number, height:number}>, cols?:number, maxSheetWidth?:number}}
29
+ * @returns {Promise<{buffer:Buffer, width:number, height:number, rows:number, cols:number}>}
30
+ */
31
+ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024 }) {
32
+ if (!Array.isArray(frames) || frames.length === 0) {
33
+ throw new SummarizeError('iris_media_summarize: 没有可拼的帧');
34
+ }
35
+ const n = frames.length;
36
+ const colsN = Math.max(1, Math.min(Math.floor(cols), n, 4));
37
+ const rowsN = Math.ceil(n / colsN);
38
+ // 每格宽度:以源帧宽为基准,但整表不超过 maxSheetWidth(防超上下文)
39
+ const cellW = Math.max(80, Math.min(frames[0].width || 640, Math.floor(maxSheetWidth / colsN)));
40
+ const labelH = 22; // 时间戳标签高度
41
+ const gap = 4;
42
+
43
+ // 先逐帧缩略图 + 时间戳标签合成单个 cell
44
+ const cells = [];
45
+ for (let i = 0; i < n; i++) {
46
+ const f = frames[i];
47
+ const w = Math.max(1, cellW);
48
+ const scale = w / Math.max(1, f.width || w);
49
+ const h = Math.max(1, Math.round((f.height || 0) * scale));
50
+ const thumb = await sharp(f.buffer)
51
+ .resize(w, h, { fit: 'fill' })
52
+ .png()
53
+ .toBuffer();
54
+ // 时间戳标签(SVG 覆盖在缩略图下方)
55
+ const label = Buffer.from(
56
+ `<svg width="${w}" height="${labelH}" xmlns="http://www.w3.org/2000/svg">` +
57
+ `<rect width="100%" height="100%" fill="rgba(0,0,0,0.62)"/>` +
58
+ `<text x="4" y="${labelH - 7}" font-family="monospace" font-size="12" fill="#ffffff">` +
59
+ `${f.atSec.toFixed(1)}s</text></svg>`
60
+ );
61
+ const cell = await sharp(thumb)
62
+ .composite([{ input: label, top: h, left: 0 }])
63
+ .png()
64
+ .toBuffer();
65
+ cells.push({ buffer: cell, width: w, height: h + labelH });
66
+ }
67
+
68
+ const cellH = cells[0].height;
69
+ const sheetW = colsN * cellW + (colsN - 1) * gap;
70
+ const sheetH = rowsN * cellH + (rowsN - 1) * gap;
71
+ const composite = cells.map((c, i) => ({
72
+ input: c.buffer,
73
+ left: (i % colsN) * (cellW + gap),
74
+ top: Math.floor(i / colsN) * (cellH + gap)
75
+ }));
76
+ const buffer = await sharp({
77
+ create: { width: sheetW, height: sheetH, channels: 3, background: { r: 16, g: 16, b: 20 } }
78
+ })
79
+ .composite(composite)
80
+ .png()
81
+ .toBuffer();
82
+ return { buffer, width: sheetW, height: sheetH, rows: rowsN, cols: colsN };
83
+ }
84
+
85
+ /** 组装摘要提示词:画面指令 + (可选)转写文本 */
86
+ export function buildSummaryPrompt({ question, transcript }) {
87
+ const q = String(question || '').trim() || DEFAULT_QUESTION;
88
+ let prompt = q;
89
+ if (transcript && String(transcript).trim()) {
90
+ const t = String(transcript).trim();
91
+ prompt +=
92
+ `\n\n视频音轨的语音转写文本如下(可能不完整或含识别噪声):\n` +
93
+ `"""\n${t.slice(0, 6000)}\n"""\n` +
94
+ `请结合转写内容补充说明视频的对话、旁白或口头信息(如有)。`;
95
+ }
96
+ return prompt;
97
+ }
98
+
99
+ /**
100
+ * 多模态摘要:contact sheet 图 + 可选转写文本 → 视觉后端链 → 摘要文本。
101
+ * @param {{backends:Array, question?:string, transcript?:string, imageDataUrl:string, ref?:object, signal?:AbortSignal}}
102
+ * @returns {Promise<{answer:string, via:string, model:string, backendId:string, errors:Array}>}
103
+ */
104
+ export async function summarizeMedia({ backends, question, transcript, imageDataUrl, ref, signal }) {
105
+ if (!Array.isArray(backends) || backends.length === 0) {
106
+ throw new SummarizeError('iris_media_summarize: 没有可用的视觉后端(至少需要一个 vision provider)');
107
+ }
108
+ const { askWithBackends } = await import('./vision.js');
109
+ const prompt = buildSummaryPrompt({ question, transcript });
110
+ return askWithBackends(backends, { question: prompt, imageDataUrl, ref, signal });
111
+ }