@flow-player/test-streams 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{src/cli/extend-vod.ts → dist/cli/extend-vod.js} +39 -61
- package/dist/cli/generate.js +473 -0
- package/dist/cli/serve.js +644 -0
- package/dist/config.d.ts +29 -0
- package/dist/fixtures/catalog.d.ts +37 -0
- package/dist/fixtures/catalog.js +63 -0
- package/dist/fixtures/codec.d.ts +10 -0
- package/dist/fixtures/format.d.ts +13 -0
- package/dist/fixtures/generate.d.ts +11 -0
- package/dist/fixtures/generate.js +361 -0
- package/dist/fixtures/matrix.d.ts +18 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +894 -0
- package/dist/server/live.d.ts +14 -0
- package/dist/server/routes.d.ts +18 -0
- package/dist/server/server.d.ts +18 -0
- package/dist/server/server.js +534 -0
- package/dist/server/static.d.ts +13 -0
- package/dist/vite-plugin.d.ts +12 -0
- package/dist/vite-plugin.js +58 -0
- package/package.json +24 -11
- package/src/cli/generate.ts +0 -138
- package/src/cli/serve.ts +0 -112
- package/src/config.ts +0 -34
- package/src/fixtures/catalog.ts +0 -99
- package/src/fixtures/codec.ts +0 -43
- package/src/fixtures/format.ts +0 -118
- package/src/fixtures/generate.ts +0 -209
- package/src/fixtures/matrix.ts +0 -94
- package/src/index.ts +0 -41
- package/src/server/live.ts +0 -220
- package/src/server/routes.ts +0 -141
- package/src/server/server.ts +0 -193
- package/src/server/static.ts +0 -83
- package/src/visual/analyze.ts +0 -234
- package/src/visual/dhash.ts +0 -76
- package/src/visual/index.ts +0 -18
- package/src/visual/report.ts +0 -80
- package/src/vite-plugin.d.ts +0 -10
- package/src/vite-plugin.ts +0 -76
package/src/server/server.ts
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
// Streams HTTP(S) server:托管 @flow-player/test-streams/fixtures 下生成的全部 fixture。
|
|
2
|
-
// 与 browser-playback-proxy-server 职责分离:本服务只读本地磁盘,不转发 LAN 上游。
|
|
3
|
-
import { createReadStream, readFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
type IncomingMessage,
|
|
6
|
-
type Server,
|
|
7
|
-
type ServerResponse,
|
|
8
|
-
createServer as createHttpServer,
|
|
9
|
-
} from "node:http";
|
|
10
|
-
import { createServer as createHttpsServer } from "node:https";
|
|
11
|
-
import { type AddressInfo } from "node:net";
|
|
12
|
-
import { extname, resolve, sep } from "node:path";
|
|
13
|
-
import { DEFAULT_HOST, DEFAULT_PORT } from "../config.ts";
|
|
14
|
-
import { type Manifest, loadManifest } from "../fixtures/catalog.ts";
|
|
15
|
-
import { applyCors, setRangeHeaders, writeBadRequest, writeNotFound } from "./static.ts";
|
|
16
|
-
import { handleHealth, handleList, handleVodList, resolveRoute } from "./routes.ts";
|
|
17
|
-
import { handleLiveStream, matchLivePath } from "./live.ts";
|
|
18
|
-
export type StreamsServerHandle = {
|
|
19
|
-
server: Server;
|
|
20
|
-
port: number;
|
|
21
|
-
host: string;
|
|
22
|
-
close: () => Promise<void>;
|
|
23
|
-
refreshManifest: () => Manifest | null;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export type StreamsServerOptions = {
|
|
27
|
-
port?: number;
|
|
28
|
-
host?: string;
|
|
29
|
-
fixtureDir: string;
|
|
30
|
-
cert?: string;
|
|
31
|
-
key?: string;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export function createStreamsServer(options: StreamsServerOptions): StreamsServerHandle {
|
|
35
|
-
const host = options.host ?? DEFAULT_HOST;
|
|
36
|
-
const port = options.port ?? DEFAULT_PORT;
|
|
37
|
-
const fixtureDir = resolve(options.fixtureDir);
|
|
38
|
-
|
|
39
|
-
const ctx: { manifest: Manifest | null; fixtureDir: string } = {
|
|
40
|
-
manifest: loadManifest(fixtureDir),
|
|
41
|
-
fixtureDir,
|
|
42
|
-
};
|
|
43
|
-
const refreshManifest = (): Manifest | null => {
|
|
44
|
-
ctx.manifest = loadManifest(fixtureDir);
|
|
45
|
-
return ctx.manifest;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const handler = (req: IncomingMessage, res: ServerResponse) => {
|
|
49
|
-
applyCors(res);
|
|
50
|
-
if (req.method === "OPTIONS") {
|
|
51
|
-
res.writeHead(204, {
|
|
52
|
-
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
|
53
|
-
"Access-Control-Allow-Headers": "Range, Content-Type, If-None-Match",
|
|
54
|
-
});
|
|
55
|
-
res.end();
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
59
|
-
res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
|
|
60
|
-
res.end("Method Not Allowed\n");
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
try {
|
|
64
|
-
const path = decodePath(req.url ?? "/");
|
|
65
|
-
if (path.includes("..")) {
|
|
66
|
-
writeBadRequest(res, "Path traversal not allowed");
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
if (path === "/streams/health" || path === "/streams/health/") {
|
|
70
|
-
handleHealth(res, ctx.manifest, fixtureDir);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (path === "/streams/list" || path === "/streams/list/") {
|
|
74
|
-
handleList(res, ctx.manifest);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
if (path === "/streams/vod-list" || path === "/streams/vod-list/") {
|
|
78
|
-
handleVodList(res, ctx.manifest, ctx.fixtureDir);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const resolved = resolveRoute(path);
|
|
82
|
-
if (resolved) {
|
|
83
|
-
serveEntry(req, res, fixtureDir, resolved);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const liveMatch = matchLivePath(path);
|
|
87
|
-
if (liveMatch) {
|
|
88
|
-
handleLiveStream(req, res, liveMatch.format, liveMatch.codec);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
writeNotFound(res);
|
|
92
|
-
} catch (err) {
|
|
93
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
94
|
-
if (!res.headersSent) {
|
|
95
|
-
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
96
|
-
res.end(`Internal Error: ${message}\n`);
|
|
97
|
-
} else {
|
|
98
|
-
res.destroy(err instanceof Error ? err : new Error(message));
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const server =
|
|
104
|
-
options.cert && options.key
|
|
105
|
-
? createHttpsServer(
|
|
106
|
-
{
|
|
107
|
-
cert: readFileSync(resolve(options.cert)),
|
|
108
|
-
key: readFileSync(resolve(options.key)),
|
|
109
|
-
},
|
|
110
|
-
handler,
|
|
111
|
-
)
|
|
112
|
-
: createHttpServer(handler);
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
server,
|
|
116
|
-
port,
|
|
117
|
-
host,
|
|
118
|
-
close: () =>
|
|
119
|
-
new Promise<void>((resolveClose, rejectClose) => {
|
|
120
|
-
server.close((err) => (err ? rejectClose(err) : resolveClose()));
|
|
121
|
-
}),
|
|
122
|
-
refreshManifest,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export async function listen(server: StreamsServerHandle): Promise<StreamsServerHandle> {
|
|
127
|
-
const { promise, resolve, reject } = Promise.withResolvers<StreamsServerHandle>();
|
|
128
|
-
server.server.once("error", reject);
|
|
129
|
-
server.server.listen(server.port, server.host, () => {
|
|
130
|
-
const addr = server.server.address() as AddressInfo | null;
|
|
131
|
-
if (!addr) {
|
|
132
|
-
reject(new Error("server.address() returned null after listen"));
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
server.port = addr.port;
|
|
136
|
-
resolve(server);
|
|
137
|
-
});
|
|
138
|
-
return promise;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const FIXTURE_MIME: Record<string, string> = {
|
|
142
|
-
".flv": "video/x-flv",
|
|
143
|
-
".mp4": "video/mp4",
|
|
144
|
-
".m4s": "video/iso.segment",
|
|
145
|
-
".ts": "video/mp2t",
|
|
146
|
-
".m3u8": "application/vnd.apple.mpegurl",
|
|
147
|
-
".json": "application/json; charset=utf-8",
|
|
148
|
-
".aac": "audio/aac",
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
function serveEntry(
|
|
152
|
-
req: IncomingMessage,
|
|
153
|
-
res: ServerResponse,
|
|
154
|
-
fixtureDir: string,
|
|
155
|
-
relPath: string,
|
|
156
|
-
): void {
|
|
157
|
-
const abs = resolve(fixtureDir, relPath);
|
|
158
|
-
const rel = relativePosix(fixtureDir, abs);
|
|
159
|
-
if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
160
|
-
writeNotFound(res);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
const mime = FIXTURE_MIME[extname(abs).toLowerCase()] ?? "application/octet-stream";
|
|
164
|
-
const rangeInfo = setRangeHeaders(req, res, abs, mime);
|
|
165
|
-
if (rangeInfo && "alreadyServed" in rangeInfo) return;
|
|
166
|
-
if (req.method === "HEAD") {
|
|
167
|
-
res.end();
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
const startEnd = rangeInfo && "start" in rangeInfo ? rangeInfo : null;
|
|
171
|
-
const stream = startEnd
|
|
172
|
-
? createReadStream(abs, { start: startEnd.start, end: startEnd.end })
|
|
173
|
-
: createReadStream(abs);
|
|
174
|
-
stream.on("error", (err) => res.destroy(err));
|
|
175
|
-
stream.pipe(res);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// 手动实现相对路径(避免引入 path.relative 的隐式语义),只接受 fixtureDir 子树。
|
|
179
|
-
function relativePosix(root: string, abs: string): string {
|
|
180
|
-
if (abs === root) return "";
|
|
181
|
-
if (abs.startsWith(root + sep)) return abs.slice(root.length + 1);
|
|
182
|
-
if (abs.startsWith(root)) return abs.slice(root.length);
|
|
183
|
-
return abs;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function decodePath(rawTarget: string): string {
|
|
187
|
-
const stripped = rawTarget.split(/[?#]/, 1)[0] || "/";
|
|
188
|
-
try {
|
|
189
|
-
return decodeURIComponent(stripped);
|
|
190
|
-
} catch {
|
|
191
|
-
return stripped;
|
|
192
|
-
}
|
|
193
|
-
}
|
package/src/server/static.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
// 静态文件 + Range 响应 + CORS 头 + JSON/NotFound 工具。
|
|
2
|
-
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
|
-
import { createReadStream, statSync } from "node:fs";
|
|
4
|
-
export function applyCors(res: ServerResponse): void {
|
|
5
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
6
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
|
7
|
-
res.setHeader("Access-Control-Allow-Headers", "Range, Content-Type, If-None-Match");
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
11
|
-
const text = JSON.stringify(body);
|
|
12
|
-
res.writeHead(status, {
|
|
13
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
14
|
-
"Content-Length": Buffer.byteLength(text),
|
|
15
|
-
"Cache-Control": "no-store",
|
|
16
|
-
});
|
|
17
|
-
res.end(text);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function writeNotFound(res: ServerResponse): void {
|
|
21
|
-
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
22
|
-
res.end("Not Found\n");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function writeBadRequest(res: ServerResponse, message: string): void {
|
|
26
|
-
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
27
|
-
res.end(`${message}\n`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function writeStaticFile(res: ServerResponse, fullPath: string, mime: string): void {
|
|
31
|
-
res.setHeader("Content-Type", mime);
|
|
32
|
-
res.setHeader("Cache-Control", "no-store");
|
|
33
|
-
const stream = createReadStream(fullPath);
|
|
34
|
-
stream.on("error", (err) => res.destroy(err));
|
|
35
|
-
stream.pipe(res);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 单段 Range 处理:仅支持 bytes=start-end,不支持 multi-range。
|
|
39
|
-
// 返回 RangeInfo | null:
|
|
40
|
-
// - { alreadyServed: true }: HEAD 或 416 边界情况, caller 不要再写。
|
|
41
|
-
// - { start, end }: 有效 Range, caller 需用 createReadStream(abs, {start, end}) + pipe。
|
|
42
|
-
// - null: 非 Range 请求, caller 用全文件 stream。
|
|
43
|
-
export type RangeInfo = { alreadyServed: true } | { start: number; end: number } | null;
|
|
44
|
-
|
|
45
|
-
export function setRangeHeaders(
|
|
46
|
-
req: IncomingMessage,
|
|
47
|
-
res: ServerResponse,
|
|
48
|
-
fullPath: string,
|
|
49
|
-
mime: string,
|
|
50
|
-
): RangeInfo {
|
|
51
|
-
const stat = statSync(fullPath);
|
|
52
|
-
const total = stat.size;
|
|
53
|
-
res.setHeader("Accept-Ranges", "bytes");
|
|
54
|
-
const range = req.headers.range;
|
|
55
|
-
if (!range) {
|
|
56
|
-
res.setHeader("Content-Length", total);
|
|
57
|
-
res.setHeader("Content-Type", mime);
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
61
|
-
if (!match) {
|
|
62
|
-
res.setHeader("Content-Length", total);
|
|
63
|
-
res.setHeader("Content-Type", mime);
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
const startRaw = match[1];
|
|
67
|
-
const endRaw = match[2];
|
|
68
|
-
let start = startRaw === "" ? Math.max(0, total - Number(endRaw || 0)) : Number(startRaw);
|
|
69
|
-
let end = endRaw === "" ? total - 1 : Number(endRaw);
|
|
70
|
-
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) {
|
|
71
|
-
res.setHeader("Content-Range", `bytes */${total}`);
|
|
72
|
-
res.writeHead(416);
|
|
73
|
-
res.end();
|
|
74
|
-
return { alreadyServed: true };
|
|
75
|
-
}
|
|
76
|
-
start = Math.max(0, start);
|
|
77
|
-
end = Math.min(total - 1, end);
|
|
78
|
-
res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
|
|
79
|
-
res.setHeader("Content-Length", end - start + 1);
|
|
80
|
-
res.setHeader("Content-Type", mime);
|
|
81
|
-
res.statusCode = 206;
|
|
82
|
-
return { start, end };
|
|
83
|
-
}
|
package/src/visual/analyze.ts
DELETED
|
@@ -1,234 +0,0 @@
|
|
|
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 };
|
package/src/visual/dhash.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
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
|
-
}
|
package/src/visual/index.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
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";
|
package/src/visual/report.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
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
|
-
}
|
package/src/vite-plugin.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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;
|