@flow-player/test-streams 0.6.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.
@@ -0,0 +1,234 @@
1
+ // 帧差 / 掉帧 / 倍速密度 / 倒放判定 — 纯 node,无依赖。
2
+ // 输入:{ timestampMs, hash }[] 序列。
3
+ // 输出:可序列化的 JSON 报告 + 人类可读 ASCII timeline。
4
+
5
+ import { hammingDistance, isStaticFrame } from "./dhash.ts";
6
+
7
+ export type FrameSample = {
8
+ timestampMs: number;
9
+ hash: string;
10
+ };
11
+
12
+ export type FrameDeltas = {
13
+ timestampMs: number;
14
+ distance: number;
15
+ moving: boolean;
16
+ };
17
+
18
+ export type StuckStreak = {
19
+ startMs: number;
20
+ endMs: number;
21
+ durationMs: number;
22
+ frames: number;
23
+ };
24
+
25
+ export type MovingWindow = {
26
+ windowStartMs: number;
27
+ windowEndMs: number;
28
+ movingCount: number;
29
+ total: number;
30
+ density: number; // moving/total
31
+ };
32
+
33
+ export type ReverseCheck = {
34
+ passed: boolean;
35
+ reason: string;
36
+ firstHash: string;
37
+ expectedAnchor: string;
38
+ observedAt: number;
39
+ distanceToAnchor: number;
40
+ };
41
+
42
+ export type AnalysisReport = {
43
+ totalFrames: number;
44
+ durationMs: number;
45
+ firstHash: string;
46
+ lastHash: string;
47
+ staticThreshold: number;
48
+ movingFrames: number;
49
+ movingRatio: number;
50
+ deltas: FrameDeltas[];
51
+ stuckStreaks: StuckStreak[];
52
+ longestStuckMs: number;
53
+ movingDensity: MovingWindow[];
54
+ averageMovingDensity: number;
55
+ reverse: ReverseCheck;
56
+ };
57
+
58
+ const DEFAULT_STATIC_THRESHOLD = 2; // dHash hamming distance ≤ 2 视为静止
59
+
60
+ export function computeDeltas(
61
+ samples: ReadonlyArray<FrameSample>,
62
+ staticThreshold = DEFAULT_STATIC_THRESHOLD,
63
+ ): FrameDeltas[] {
64
+ const out: FrameDeltas[] = [];
65
+ for (let i = 1; i < samples.length; i += 1) {
66
+ const prev = samples[i - 1];
67
+ const cur = samples[i];
68
+ const distance = hammingDistance(prev.hash, cur.hash);
69
+ out.push({
70
+ timestampMs: cur.timestampMs,
71
+ distance,
72
+ moving: distance > staticThreshold,
73
+ });
74
+ }
75
+ return out;
76
+ }
77
+
78
+ export function findStuckStreaks(deltas: ReadonlyArray<FrameDeltas>): StuckStreak[] {
79
+ const streaks: StuckStreak[] = [];
80
+ let runStart: number | null = null;
81
+ for (let i = 0; i < deltas.length; i += 1) {
82
+ const d = deltas[i];
83
+ if (!d.moving) {
84
+ if (runStart === null) {
85
+ runStart = i;
86
+ }
87
+ } else if (runStart !== null) {
88
+ const start = deltas[runStart];
89
+ const end = deltas[i - 1];
90
+ streaks.push({
91
+ startMs: start.timestampMs,
92
+ endMs: end.timestampMs,
93
+ durationMs: end.timestampMs - start.timestampMs,
94
+ frames: i - runStart,
95
+ });
96
+ runStart = null;
97
+ }
98
+ }
99
+ if (runStart !== null && deltas.length > 0) {
100
+ const start = deltas[runStart];
101
+ const end = deltas[deltas.length - 1];
102
+ streaks.push({
103
+ startMs: start.timestampMs,
104
+ endMs: end.timestampMs,
105
+ durationMs: end.timestampMs - start.timestampMs,
106
+ frames: deltas.length - runStart,
107
+ });
108
+ }
109
+ return streaks;
110
+ }
111
+
112
+ export function movingDensityByWindow(
113
+ deltas: ReadonlyArray<FrameDeltas>,
114
+ windowMs = 1000,
115
+ ): MovingWindow[] {
116
+ if (deltas.length === 0) return [];
117
+ const out: MovingWindow[] = [];
118
+ let lo = 0;
119
+ for (let hi = 0; hi < deltas.length; hi += 1) {
120
+ while (deltas[hi].timestampMs - deltas[lo].timestampMs > windowMs) {
121
+ lo += 1;
122
+ }
123
+ const slice = deltas.slice(lo, hi + 1);
124
+ const movingCount = slice.reduce((acc, d) => acc + (d.moving ? 1 : 0), 0);
125
+ out.push({
126
+ windowStartMs: deltas[lo].timestampMs,
127
+ windowEndMs: deltas[hi].timestampMs,
128
+ movingCount,
129
+ total: slice.length,
130
+ density: slice.length === 0 ? 0 : movingCount / slice.length,
131
+ });
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // 倒放开锚判定:倒放段首帧 hash 与正播段尾帧 hash 距离应在阈值内。
137
+ // 阈值 6/64 ≈ 9% bit 差异,容纳 WebCodecs 解码噪声 + 截图重采样噪声。
138
+ const REVERSE_ANCHOR_DISTANCE = 6;
139
+
140
+ export function checkReverseAnchor(
141
+ forwardTail: FrameSample,
142
+ reverseHead: FrameSample,
143
+ tolerance = REVERSE_ANCHOR_DISTANCE,
144
+ ): ReverseCheck {
145
+ const distance = hammingDistance(forwardTail.hash, reverseHead.hash);
146
+ if (distance <= tolerance) {
147
+ return {
148
+ passed: true,
149
+ reason: `reverse head matches forward tail (distance=${distance})`,
150
+ firstHash: reverseHead.hash,
151
+ expectedAnchor: forwardTail.hash,
152
+ observedAt: reverseHead.timestampMs,
153
+ distanceToAnchor: distance,
154
+ };
155
+ }
156
+ return {
157
+ passed: false,
158
+ reason: `reverse head does NOT match forward tail (distance=${distance}, tolerance=${tolerance})`,
159
+ firstHash: reverseHead.hash,
160
+ expectedAnchor: forwardTail.hash,
161
+ observedAt: reverseHead.timestampMs,
162
+ distanceToAnchor: distance,
163
+ };
164
+ }
165
+
166
+ export function analyze(
167
+ samples: ReadonlyArray<FrameSample>,
168
+ options: {
169
+ staticThreshold?: number;
170
+ reverseAnchor?: FrameSample | null;
171
+ } = {},
172
+ ): AnalysisReport {
173
+ if (samples.length === 0) {
174
+ return {
175
+ totalFrames: 0,
176
+ durationMs: 0,
177
+ firstHash: "",
178
+ lastHash: "",
179
+ staticThreshold: options.staticThreshold ?? DEFAULT_STATIC_THRESHOLD,
180
+ movingFrames: 0,
181
+ movingRatio: 0,
182
+ deltas: [],
183
+ stuckStreaks: [],
184
+ longestStuckMs: 0,
185
+ movingDensity: [],
186
+ averageMovingDensity: 0,
187
+ reverse: {
188
+ passed: false,
189
+ reason: "no samples",
190
+ firstHash: "",
191
+ expectedAnchor: "",
192
+ observedAt: 0,
193
+ distanceToAnchor: 0,
194
+ },
195
+ };
196
+ }
197
+ const staticThreshold = options.staticThreshold ?? DEFAULT_STATIC_THRESHOLD;
198
+ const deltas = computeDeltas(samples, staticThreshold);
199
+ const streaks = findStuckStreaks(deltas);
200
+ const density = movingDensityByWindow(deltas);
201
+ const movingCount = deltas.reduce((acc, d) => acc + (d.moving ? 1 : 0), 0);
202
+ const last = samples[samples.length - 1];
203
+ const reverse: ReverseCheck = options.reverseAnchor
204
+ ? checkReverseAnchor(options.reverseAnchor, last, REVERSE_ANCHOR_DISTANCE)
205
+ : {
206
+ passed: false,
207
+ reason: "no reverse anchor provided",
208
+ firstHash: last.hash,
209
+ expectedAnchor: "",
210
+ observedAt: last.timestampMs,
211
+ distanceToAnchor: 0,
212
+ };
213
+ const longestStuck = streaks.reduce((acc, s) => Math.max(acc, s.durationMs), 0);
214
+ const avgDensity =
215
+ density.length === 0 ? 0 : density.reduce((acc, d) => acc + d.density, 0) / density.length;
216
+ return {
217
+ totalFrames: samples.length,
218
+ durationMs: last.timestampMs - samples[0].timestampMs,
219
+ firstHash: samples[0].hash,
220
+ lastHash: last.hash,
221
+ staticThreshold,
222
+ movingFrames: movingCount,
223
+ movingRatio: deltas.length === 0 ? 0 : movingCount / deltas.length,
224
+ deltas,
225
+ stuckStreaks: streaks,
226
+ longestStuckMs: longestStuck,
227
+ movingDensity: density,
228
+ averageMovingDensity: avgDensity,
229
+ reverse,
230
+ };
231
+ }
232
+
233
+ // 复用 dHash 的 isStaticFrame 给上层调用方,避免重复实现。
234
+ export { isStaticFrame };
@@ -0,0 +1,76 @@
1
+ // 8x8 difference hash (dHash) — 纯 node 实现,零依赖。
2
+ // 输入:raw RGBA 字节序 buffer (Uint8Array) + 原始宽高。
3
+ // 输出:16-char 十六进制字符串 (64 bit),相同/近似画面 = 相同/近似 hash。
4
+ //
5
+ // 步骤:
6
+ // 1. 9x8 cell 中心 1 像素采样 (nearest neighbor) — 不做 box-filter 平均
7
+ // 因为 testsrc2 / 横向彩条在 1280x720 上 box-filter 会把 9x8 全部平均成
8
+ // 相同值,hash 全 0。中心采样保留空间结构。
9
+ // 2. Y = 0.299 R + 0.587 G + 0.114 B 灰度
10
+ // 3. 横向相邻像素比较,8 行 × 8 列 = 64 bit
11
+ //
12
+ // 参考: https://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html
13
+ // 故意不引 sharp/jimp/pngjs:30fps × 30s = 900 帧 × 4 字节 = 1.1MB 原始数据,
14
+ // 缩放计算用纯 node 整数算,900 帧 < 50ms,远低于 sharp native startup 开销。
15
+
16
+ export type RgbaBuffer = Uint8Array | Uint8ClampedArray | Buffer;
17
+
18
+ const LUMA_R = 0.299;
19
+ const LUMA_G = 0.587;
20
+ const LUMA_B = 0.114;
21
+
22
+ export function dHash8x8(rgba: RgbaBuffer, width: number, height: number): string {
23
+ if (rgba.length < width * height * 4) {
24
+ throw new Error(
25
+ `dHash8x8: buffer too small (${rgba.length} bytes) for ${width}x${height} RGBA`,
26
+ );
27
+ }
28
+ const cellW = width / 9;
29
+ const cellH = height / 8;
30
+ const gray = new Float64Array(9 * 8);
31
+ for (let y = 0; y < 8; y += 1) {
32
+ const cy = Math.min(height - 1, Math.floor((y + 0.5) * cellH));
33
+ for (let x = 0; x < 9; x += 1) {
34
+ const cx = Math.min(width - 1, Math.floor((x + 0.5) * cellW));
35
+ const off = (cy * width + cx) * 4;
36
+ gray[y * 9 + x] = LUMA_R * rgba[off] + LUMA_G * rgba[off + 1] + LUMA_B * rgba[off + 2];
37
+ }
38
+ }
39
+ const hexChars = "0123456789abcdef";
40
+ let out = "";
41
+ for (let bit = 0; bit < 64; bit += 1) {
42
+ const y = Math.floor(bit / 8);
43
+ const x = bit % 8;
44
+ const left = gray[y * 9 + x];
45
+ const right = gray[y * 9 + x + 1];
46
+ out += left > right ? "1" : "0";
47
+ if ((bit + 1) % 4 === 0) {
48
+ const nibble = parseInt(out.slice(out.length - 4), 2);
49
+ out = out.slice(0, out.length - 4) + hexChars[nibble];
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export function hammingDistance(a: string, b: string): number {
56
+ if (a.length !== b.length) {
57
+ return Math.max(a.length, b.length) * 4;
58
+ }
59
+ let dist = 0;
60
+ for (let i = 0; i < a.length; i += 1) {
61
+ const xa = parseInt(a[i], 16);
62
+ const xb = parseInt(b[i], 16);
63
+ let v = xa ^ xb;
64
+ while (v > 0) {
65
+ dist += v & 1;
66
+ v >>= 1;
67
+ }
68
+ }
69
+ return dist;
70
+ }
71
+
72
+ // 阈值 2/64 = 3% bit 差异,认为画面静止。testsrc2 + sine 视频在动时
73
+ // 相邻帧至少 1 列变化 (8 bits) → distance ≥ 8。
74
+ export function isStaticFrame(a: string, b: string, threshold = 2): boolean {
75
+ return hammingDistance(a, b) <= threshold;
76
+ }
@@ -0,0 +1,18 @@
1
+ // 视觉验证工具汇总导出。
2
+ export { dHash8x8, hammingDistance, isStaticFrame, type RgbaBuffer } from "./dhash.ts";
3
+
4
+ export {
5
+ analyze,
6
+ computeDeltas,
7
+ findStuckStreaks,
8
+ movingDensityByWindow,
9
+ checkReverseAnchor,
10
+ type AnalysisReport,
11
+ type FrameDeltas,
12
+ type FrameSample,
13
+ type MovingWindow,
14
+ type ReverseCheck,
15
+ type StuckStreak,
16
+ } from "./analyze.ts";
17
+
18
+ export { asciiTimeline, writeHumanReport, writeJsonReport } from "./report.ts";
@@ -0,0 +1,80 @@
1
+ // 报告输出 — 纯 node,无依赖。
2
+ // JSON:完整 AnalysisReport。
3
+ // ASCII timeline:把 deltas 序列压缩到 80 列,用 ▁▂▃▄▅▆▇█ 标 distance 高度。
4
+
5
+ import type { AnalysisReport, FrameDeltas } from "./analyze.ts";
6
+
7
+ const BAR_CHARS = "▁▂▃▄▅▆▇█";
8
+
9
+ export function writeJsonReport(report: AnalysisReport): string {
10
+ return JSON.stringify(report, null, 2);
11
+ }
12
+
13
+ // 把 deltas 序列压缩到 `width` 列,每列取窗口内最大 distance。
14
+ export function asciiTimeline(
15
+ deltas: ReadonlyArray<FrameDeltas>,
16
+ options: { width?: number; maxDistance?: number } = {},
17
+ ): string {
18
+ const width = options.width ?? 80;
19
+ const maxDistance = options.maxDistance ?? 32; // dHash 0-64
20
+ if (deltas.length === 0) return "(no deltas)";
21
+ if (deltas.length <= width) {
22
+ return deltas
23
+ .map((d) => {
24
+ const idx = Math.min(
25
+ BAR_CHARS.length - 1,
26
+ Math.floor((d.distance / maxDistance) * BAR_CHARS.length),
27
+ );
28
+ return BAR_CHARS[idx];
29
+ })
30
+ .join("");
31
+ }
32
+ // 滑动窗口
33
+ const lines: string[] = [];
34
+ const bucketSize = Math.ceil(deltas.length / width);
35
+ for (let i = 0; i < deltas.length; i += bucketSize) {
36
+ const slice = deltas.slice(i, i + bucketSize);
37
+ const peak = slice.reduce((acc, d) => Math.max(acc, d.distance), 0);
38
+ const idx = Math.min(BAR_CHARS.length - 1, Math.floor((peak / maxDistance) * BAR_CHARS.length));
39
+ lines.push(BAR_CHARS[idx]);
40
+ }
41
+ return lines.join("");
42
+ }
43
+
44
+ export function writeHumanReport(report: AnalysisReport): string {
45
+ const lines: string[] = [];
46
+ lines.push(`# Visual Frame Analysis`);
47
+ lines.push("");
48
+ lines.push(`- Total frames: ${report.totalFrames}`);
49
+ lines.push(`- Duration: ${report.durationMs} ms`);
50
+ lines.push(`- Static threshold: distance <= ${report.staticThreshold}/64`);
51
+ lines.push(
52
+ `- Moving frames: ${report.movingFrames} (${(report.movingRatio * 100).toFixed(1)}%)`,
53
+ );
54
+ lines.push(`- Longest stuck: ${report.longestStuckMs} ms`);
55
+ lines.push(
56
+ `- Avg density: ${(report.averageMovingDensity * 100).toFixed(1)}% moving/1s window`,
57
+ );
58
+ lines.push(`- Stuck streaks: ${report.stuckStreaks.length}`);
59
+ lines.push(`- First hash: ${report.firstHash}`);
60
+ lines.push(`- Last hash: ${report.lastHash}`);
61
+ lines.push("");
62
+ lines.push(`## Reverse anchor check`);
63
+ lines.push(`- Passed: ${report.reverse.passed}`);
64
+ lines.push(`- Reason: ${report.reverse.reason}`);
65
+ lines.push(`- Distance: ${report.reverse.distanceToAnchor}/64`);
66
+ lines.push("");
67
+ lines.push(`## Timeline (deltas, max ${BAR_CHARS.length} levels)`);
68
+ lines.push("```");
69
+ lines.push(asciiTimeline(report.deltas));
70
+ lines.push("```");
71
+ if (report.stuckStreaks.length > 0) {
72
+ lines.push("");
73
+ lines.push(`## Top stuck streaks (longest 5)`);
74
+ const top = [...report.stuckStreaks].sort((a, b) => b.durationMs - a.durationMs).slice(0, 5);
75
+ for (const s of top) {
76
+ lines.push(`- [${s.startMs}ms → ${s.endMs}ms] ${s.durationMs}ms (${s.frames} frames)`);
77
+ }
78
+ }
79
+ return lines.join("\n");
80
+ }
@@ -0,0 +1,10 @@
1
+ import type { Plugin } from "vite";
2
+
3
+ export interface TestStreamsVitePluginOptions {
4
+ cert?: string;
5
+ key?: string;
6
+ healthUrl?: string;
7
+ logPath?: string;
8
+ }
9
+
10
+ export declare function testStreamsVitePlugin(options?: TestStreamsVitePluginOptions): Plugin;
@@ -0,0 +1,76 @@
1
+ // 本地测试流服务的 dev 自准备插件(仅 serve 生效):vite dev 启动时探测
2
+ // streams server 健康状态,未就绪则后台拉起 `src/cli/serve.ts`(detached,
3
+ // 日志写 /tmp/test-streams.log)。demo 等本地 consumer 无需再在 package.json
4
+ // 里编排 streams 启动命令。
5
+ import { spawn } from "node:child_process";
6
+ import { openSync } from "node:fs";
7
+ import https from "node:https";
8
+ import { dirname, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import type { Plugin } from "vite";
11
+
12
+ export interface TestStreamsVitePluginOptions {
13
+ /** TLS 证书路径,传给 streams serve(默认 /tmp/flow-player-local.pem)。 */
14
+ cert?: string;
15
+ /** TLS 私钥路径,传给 streams serve(默认 /tmp/flow-player-local-key.pem)。 */
16
+ key?: string;
17
+ /** streams server 健康检查 URL(默认 https://127.0.0.1:4177/streams/health)。 */
18
+ healthUrl?: string;
19
+ /** streams server 日志文件(默认 /tmp/test-streams.log)。 */
20
+ logPath?: string;
21
+ }
22
+
23
+ const DEFAULT_CERT = "/tmp/flow-player-local.pem";
24
+ const DEFAULT_KEY = "/tmp/flow-player-local-key.pem";
25
+ const DEFAULT_HEALTH_URL = "https://127.0.0.1:4177/streams/health";
26
+ const DEFAULT_LOG_PATH = "/tmp/test-streams.log";
27
+
28
+ function streamsHealthy(healthUrl: string): Promise<boolean> {
29
+ return new Promise((resolveHealthy) => {
30
+ const req = https.get(
31
+ healthUrl,
32
+ { rejectUnauthorized: false, timeout: 2000 },
33
+ (res) => {
34
+ res.resume();
35
+ resolveHealthy(
36
+ res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300,
37
+ );
38
+ },
39
+ );
40
+ req.on("timeout", () => {
41
+ req.destroy();
42
+ resolveHealthy(false);
43
+ });
44
+ req.on("error", () => resolveHealthy(false));
45
+ });
46
+ }
47
+
48
+ export function testStreamsVitePlugin(options: TestStreamsVitePluginOptions = {}): Plugin {
49
+ const {
50
+ cert = DEFAULT_CERT,
51
+ key = DEFAULT_KEY,
52
+ healthUrl = DEFAULT_HEALTH_URL,
53
+ logPath = DEFAULT_LOG_PATH,
54
+ } = options;
55
+ const serveScript = resolve(dirname(fileURLToPath(import.meta.url)), "cli/serve.ts");
56
+
57
+ return {
58
+ name: "flow-player-test-streams",
59
+ apply: "serve",
60
+ configureServer() {
61
+ void (async () => {
62
+ if (await streamsHealthy(healthUrl)) {
63
+ return;
64
+ }
65
+ const logFd = openSync(logPath, "w");
66
+ const child = spawn(process.execPath, [serveScript, "--cert", cert, "--key", key], {
67
+ detached: true,
68
+ windowsHide: true,
69
+ stdio: ["ignore", logFd, logFd],
70
+ });
71
+ child.unref();
72
+ console.log(`[test-streams] 未就绪,已在后台启动,日志: ${logPath}`);
73
+ })();
74
+ },
75
+ };
76
+ }