@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/fixtures/generate.ts
DELETED
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
// ffmpeg 驱动的 fixture 生成器。按 codec × format 矩阵顺序执行 ffmpeg,
|
|
2
|
-
// 把每个 plan 的输出写到 fixtureDir,写完后输出 manifest.json。
|
|
3
|
-
// 设计:不引入 npm 依赖,直接 spawn ffmpeg 子进程。
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
|
-
import {
|
|
6
|
-
existsSync,
|
|
7
|
-
mkdirSync,
|
|
8
|
-
readdirSync,
|
|
9
|
-
readFileSync,
|
|
10
|
-
statSync,
|
|
11
|
-
unlinkSync,
|
|
12
|
-
writeFileSync,
|
|
13
|
-
} from "node:fs";
|
|
14
|
-
import { spawn } from "node:child_process";
|
|
15
|
-
import { join, resolve } from "node:path";
|
|
16
|
-
import {
|
|
17
|
-
ALL_CODECS,
|
|
18
|
-
ALL_FORMATS,
|
|
19
|
-
DEFAULT_DURATION_SECONDS,
|
|
20
|
-
DEFAULT_FIXTURE_DIR,
|
|
21
|
-
type GenerateOptions,
|
|
22
|
-
} from "../config.ts";
|
|
23
|
-
import { type FixturePlan, expandMatrix, VOD_SAMPLE_RELATIVE } from "./matrix.ts";
|
|
24
|
-
import { type Manifest, type ManifestEntry, type ManifestItem } from "./catalog.ts";
|
|
25
|
-
|
|
26
|
-
export type GenerateResult = {
|
|
27
|
-
fixtureDir: string;
|
|
28
|
-
manifest: Manifest;
|
|
29
|
-
generated: number;
|
|
30
|
-
skipped: number;
|
|
31
|
-
ffmpegPath: string;
|
|
32
|
-
ffmpegMissing: boolean;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const FFMPEG_PROBE_ARGS = ["-version"] as const;
|
|
36
|
-
|
|
37
|
-
export async function generateFixtures(opts: GenerateOptions = {}): Promise<GenerateResult> {
|
|
38
|
-
const ffmpegPath = opts.ffmpegPath ?? (await findFfmpeg());
|
|
39
|
-
if (!ffmpegPath) {
|
|
40
|
-
return {
|
|
41
|
-
fixtureDir: resolve(opts.fixtureDir ?? DEFAULT_FIXTURE_DIR),
|
|
42
|
-
manifest: emptyManifest(opts.durationSeconds ?? DEFAULT_DURATION_SECONDS),
|
|
43
|
-
generated: 0,
|
|
44
|
-
skipped: 0,
|
|
45
|
-
ffmpegPath: "ffmpeg",
|
|
46
|
-
ffmpegMissing: true,
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
const fixtureDir = resolve(opts.fixtureDir ?? DEFAULT_FIXTURE_DIR);
|
|
50
|
-
mkdirSync(fixtureDir, { recursive: true });
|
|
51
|
-
const duration = opts.durationSeconds ?? DEFAULT_DURATION_SECONDS;
|
|
52
|
-
const plans = expandMatrix({
|
|
53
|
-
codecs: opts.codecs ?? ALL_CODECS,
|
|
54
|
-
formats: opts.formats ?? ALL_FORMATS,
|
|
55
|
-
durationSeconds: duration,
|
|
56
|
-
});
|
|
57
|
-
const manifestItems: ManifestItem[] = [];
|
|
58
|
-
let generated = 0;
|
|
59
|
-
let skipped = 0;
|
|
60
|
-
for (const plan of plans) {
|
|
61
|
-
const item = await generateOne(
|
|
62
|
-
plan,
|
|
63
|
-
fixtureDir,
|
|
64
|
-
ffmpegPath,
|
|
65
|
-
opts.quiet ?? false,
|
|
66
|
-
opts.overwrite ?? false,
|
|
67
|
-
);
|
|
68
|
-
if (item) {
|
|
69
|
-
manifestItems.push(item);
|
|
70
|
-
generated += 1;
|
|
71
|
-
} else {
|
|
72
|
-
skipped += 1;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
copyVodSamples(fixtureDir);
|
|
76
|
-
const manifest: Manifest = {
|
|
77
|
-
version: 1,
|
|
78
|
-
generatedAt: new Date().toISOString(),
|
|
79
|
-
durationSeconds: duration,
|
|
80
|
-
fixtures: manifestItems,
|
|
81
|
-
};
|
|
82
|
-
writeFileSync(join(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
83
|
-
return { fixtureDir, manifest, generated, skipped, ffmpegPath, ffmpegMissing: false };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function generateOne(
|
|
87
|
-
plan: FixturePlan,
|
|
88
|
-
fixtureDir: string,
|
|
89
|
-
ffmpegPath: string,
|
|
90
|
-
quiet: boolean,
|
|
91
|
-
overwrite: boolean,
|
|
92
|
-
): Promise<ManifestItem | null> {
|
|
93
|
-
const outDir = join(fixtureDir, plan.containerDir);
|
|
94
|
-
mkdirSync(outDir, { recursive: true });
|
|
95
|
-
const outFile = join(fixtureDir, plan.mainFile);
|
|
96
|
-
if (!overwrite && existsSync(outFile)) {
|
|
97
|
-
return collectExisting(plan, fixtureDir);
|
|
98
|
-
}
|
|
99
|
-
if (plan.layout.isContainerDirectory) {
|
|
100
|
-
for (const f of readdirSync(outDir)) {
|
|
101
|
-
if (f === plan.layout.mainFileName) continue;
|
|
102
|
-
rmIfExists(join(outDir, f));
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
const args = plan.ffmpegOutputArgs.concat([outFile]);
|
|
106
|
-
if (!quiet) {
|
|
107
|
-
process.stdout.write(`[generate] ${plan.codec}/${plan.format} -> ${plan.mainFile}\n`);
|
|
108
|
-
}
|
|
109
|
-
const code = await runFfmpeg(ffmpegPath, args, quiet);
|
|
110
|
-
if (code !== 0) {
|
|
111
|
-
throw new Error(`ffmpeg exited with code ${code} for ${plan.codec}/${plan.format}`);
|
|
112
|
-
}
|
|
113
|
-
return collectExisting(plan, fixtureDir);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function collectExisting(plan: FixturePlan, fixtureDir: string): ManifestItem {
|
|
117
|
-
const entries: ManifestEntry[] = [];
|
|
118
|
-
for (const rel of plan.files) {
|
|
119
|
-
const abs = join(fixtureDir, rel);
|
|
120
|
-
if (!existsSync(abs)) continue;
|
|
121
|
-
entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
|
|
122
|
-
}
|
|
123
|
-
if (plan.layout.isContainerDirectory) {
|
|
124
|
-
// HLS / fMP4 容器目录内 ffmpeg 会按命名规则追加 segment(实际文件名不一定
|
|
125
|
-
// 等于 plan.files 预定义的 seg-00000.ts / seg-00000.m4s,可能形如 master0.ts)。
|
|
126
|
-
const absDir = join(fixtureDir, plan.containerDir);
|
|
127
|
-
if (existsSync(absDir)) {
|
|
128
|
-
for (const name of readdirSync(absDir).sort()) {
|
|
129
|
-
if (name === plan.layout.mainFileName) continue;
|
|
130
|
-
const rel = join(plan.containerDir, name);
|
|
131
|
-
if (entries.find((e) => e.path === rel)) continue;
|
|
132
|
-
const abs = join(absDir, name);
|
|
133
|
-
if (!statSync(abs).isFile()) continue;
|
|
134
|
-
entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return { codec: plan.codec, format: plan.format, entries };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function copyVodSamples(fixtureDir: string): void {
|
|
142
|
-
const src = join(fixtureDir, VOD_SAMPLE_RELATIVE);
|
|
143
|
-
if (!existsSync(src)) return;
|
|
144
|
-
const vodDir = join(fixtureDir, "vod");
|
|
145
|
-
mkdirSync(vodDir, { recursive: true });
|
|
146
|
-
for (const name of ["sample-1.mp4", "sample-2.mp4"]) {
|
|
147
|
-
const dst = join(vodDir, name);
|
|
148
|
-
writeFileSync(dst, readFileSync(src));
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function rmIfExists(abs: string): void {
|
|
153
|
-
if (!existsSync(abs)) return;
|
|
154
|
-
try {
|
|
155
|
-
unlinkSync(abs);
|
|
156
|
-
} catch {
|
|
157
|
-
// ignore
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function runFfmpeg(
|
|
162
|
-
ffmpegPath: string,
|
|
163
|
-
args: ReadonlyArray<string>,
|
|
164
|
-
quiet: boolean,
|
|
165
|
-
): Promise<number> {
|
|
166
|
-
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
|
167
|
-
const proc = spawn(ffmpegPath, args, {
|
|
168
|
-
stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit",
|
|
169
|
-
});
|
|
170
|
-
proc.once("error", reject);
|
|
171
|
-
proc.once("exit", (code) => resolve(code ?? 1));
|
|
172
|
-
return promise;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function findFfmpeg(): Promise<string | null> {
|
|
176
|
-
const candidates = [
|
|
177
|
-
"ffmpeg",
|
|
178
|
-
"/opt/homebrew/bin/ffmpeg",
|
|
179
|
-
"/usr/local/bin/ffmpeg",
|
|
180
|
-
"/usr/bin/ffmpeg",
|
|
181
|
-
];
|
|
182
|
-
for (const candidate of candidates) {
|
|
183
|
-
if (await canRun(candidate)) return candidate;
|
|
184
|
-
}
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async function canRun(path: string): Promise<boolean> {
|
|
189
|
-
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
190
|
-
const proc = spawn(path, FFMPEG_PROBE_ARGS as unknown as string[], { stdio: "ignore" });
|
|
191
|
-
proc.once("error", () => resolve(false));
|
|
192
|
-
proc.once("exit", (code) => resolve(code === 0));
|
|
193
|
-
return promise;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function hashFile(abs: string): string {
|
|
197
|
-
const hash = createHash("sha256");
|
|
198
|
-
hash.update(readFileSync(abs));
|
|
199
|
-
return hash.digest("hex");
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function emptyManifest(duration: number): Manifest {
|
|
203
|
-
return {
|
|
204
|
-
version: 1,
|
|
205
|
-
generatedAt: new Date(0).toISOString(),
|
|
206
|
-
durationSeconds: duration,
|
|
207
|
-
fixtures: [],
|
|
208
|
-
};
|
|
209
|
-
}
|
package/src/fixtures/matrix.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
// 完整的 codec × format 矩阵 + 单条 fixture plan。
|
|
2
|
-
// 仅依赖 codec.ts / format.ts / config.ts;不引入 ffmpeg,以便单测可脱机运行。
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { ALL_CODECS, ALL_FORMATS, type CodecId, type FormatId } from "../config.ts";
|
|
5
|
-
import { CODEC_SPEC, containerExtraArgs } from "./codec.ts";
|
|
6
|
-
import {
|
|
7
|
-
containerRelativeDir,
|
|
8
|
-
containerRelativePath,
|
|
9
|
-
layoutFor,
|
|
10
|
-
singleFileRelative,
|
|
11
|
-
type FormatLayout,
|
|
12
|
-
} from "./format.ts";
|
|
13
|
-
|
|
14
|
-
export type FixturePlan = {
|
|
15
|
-
codec: CodecId;
|
|
16
|
-
format: FormatId;
|
|
17
|
-
layout: FormatLayout;
|
|
18
|
-
containerDir: string;
|
|
19
|
-
mainFile: string;
|
|
20
|
-
files: ReadonlyArray<string>;
|
|
21
|
-
ffmpegOutputArgs: ReadonlyArray<string>;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export function buildPlan(codec: CodecId, format: FormatId, durationSeconds: number): FixturePlan {
|
|
25
|
-
const layout = layoutFor(codec, format);
|
|
26
|
-
const spec = CODEC_SPEC[codec];
|
|
27
|
-
const gop = String(durationSeconds * 25);
|
|
28
|
-
const videoInput = `testsrc2=size=1280x720:rate=25,format=yuv420p`;
|
|
29
|
-
const audioInput = `sine=frequency=1000:sample_rate=44100`;
|
|
30
|
-
|
|
31
|
-
// 关键 GOP 锁:25 fps × duration,保证每个 fixture 至少一个 IDR。
|
|
32
|
-
const ffmpegOutputArgs = [
|
|
33
|
-
"-y",
|
|
34
|
-
"-f",
|
|
35
|
-
"lavfi",
|
|
36
|
-
"-i",
|
|
37
|
-
videoInput,
|
|
38
|
-
"-f",
|
|
39
|
-
"lavfi",
|
|
40
|
-
"-i",
|
|
41
|
-
audioInput,
|
|
42
|
-
"-t",
|
|
43
|
-
String(durationSeconds),
|
|
44
|
-
"-c:v",
|
|
45
|
-
spec.encoder,
|
|
46
|
-
...spec.videoArgs,
|
|
47
|
-
"-pix_fmt",
|
|
48
|
-
spec.pixelFormat,
|
|
49
|
-
"-g",
|
|
50
|
-
gop,
|
|
51
|
-
"-keyint_min",
|
|
52
|
-
gop,
|
|
53
|
-
"-sc_threshold",
|
|
54
|
-
"0",
|
|
55
|
-
...containerExtraArgs(format, codec),
|
|
56
|
-
...spec.audioArgs,
|
|
57
|
-
...layout.containerArgs,
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
if (layout.isContainerDirectory) {
|
|
61
|
-
const containerDir = containerRelativeDir(codec, format);
|
|
62
|
-
const mainFile = containerRelativePath(codec, format, layout.mainFileName);
|
|
63
|
-
const files = [
|
|
64
|
-
mainFile,
|
|
65
|
-
...layout.sideFiles.map((s) => containerRelativePath(codec, format, s)),
|
|
66
|
-
];
|
|
67
|
-
return { codec, format, layout, containerDir, mainFile, files, ffmpegOutputArgs };
|
|
68
|
-
}
|
|
69
|
-
// 单文件格式
|
|
70
|
-
const mainFile = singleFileRelative(codec, format);
|
|
71
|
-
const containerDir = join(mainFile.split("/").slice(0, -1).join("/"));
|
|
72
|
-
return { codec, format, layout, containerDir, mainFile, files: [mainFile], ffmpegOutputArgs };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function expandMatrix(opts?: {
|
|
76
|
-
codecs?: ReadonlyArray<CodecId>;
|
|
77
|
-
formats?: ReadonlyArray<FormatId>;
|
|
78
|
-
durationSeconds?: number;
|
|
79
|
-
}): ReadonlyArray<FixturePlan> {
|
|
80
|
-
const codecs = opts?.codecs ?? ALL_CODECS;
|
|
81
|
-
const formats = opts?.formats ?? ALL_FORMATS;
|
|
82
|
-
const duration = opts?.durationSeconds ?? 5;
|
|
83
|
-
const plans: FixturePlan[] = [];
|
|
84
|
-
for (const codec of codecs) {
|
|
85
|
-
for (const format of formats) {
|
|
86
|
-
plans.push(buildPlan(codec, format, duration));
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return plans;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// VOD 段默认从 mp4/h264.mp4 派生(同 fixture)。generate.ts 在生成结束后
|
|
93
|
-
// 把真实 fileSize 写进 vodList.example.json;此处只描述派生约定。
|
|
94
|
-
export const VOD_SAMPLE_RELATIVE = "mp4/h264.mp4";
|
package/src/index.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// 公共 API 入口。generate / serve / catalog / server 各模块在此 re-export。
|
|
2
|
-
export {
|
|
3
|
-
buildPlan,
|
|
4
|
-
expandMatrix,
|
|
5
|
-
VOD_SAMPLE_RELATIVE,
|
|
6
|
-
type FixturePlan,
|
|
7
|
-
} from "./fixtures/matrix.ts";
|
|
8
|
-
export {
|
|
9
|
-
loadManifest,
|
|
10
|
-
lookupFixture,
|
|
11
|
-
summarizeCatalog,
|
|
12
|
-
verifyEntryOnDisk,
|
|
13
|
-
type CatalogSummary,
|
|
14
|
-
type FixtureLookup,
|
|
15
|
-
type Manifest,
|
|
16
|
-
type ManifestEntry,
|
|
17
|
-
type ManifestItem,
|
|
18
|
-
} from "./fixtures/catalog.ts";
|
|
19
|
-
export { CODEC_SPEC, type CodecSpec } from "./fixtures/codec.ts";
|
|
20
|
-
export {
|
|
21
|
-
layoutFor,
|
|
22
|
-
containerRelativeDir,
|
|
23
|
-
containerRelativePath,
|
|
24
|
-
type FormatLayout,
|
|
25
|
-
} from "./fixtures/format.ts";
|
|
26
|
-
export {
|
|
27
|
-
ALL_CODECS,
|
|
28
|
-
ALL_FORMATS,
|
|
29
|
-
DEFAULT_DURATION_SECONDS,
|
|
30
|
-
DEFAULT_FIXTURE_DIR,
|
|
31
|
-
DEFAULT_HLS_SEGMENT_SECONDS,
|
|
32
|
-
DEFAULT_HOST,
|
|
33
|
-
DEFAULT_PORT,
|
|
34
|
-
type CodecId,
|
|
35
|
-
type FormatId,
|
|
36
|
-
type GenerateOptions,
|
|
37
|
-
type ServeOptions,
|
|
38
|
-
} from "./config.ts";
|
|
39
|
-
export { createStreamsServer, type StreamsServerHandle } from "./server/server.ts";
|
|
40
|
-
export { generateFixtures, type GenerateResult } from "./fixtures/generate.ts";
|
|
41
|
-
export { handleLiveStream, matchLivePath, type LiveStreamOptions } from "./server/live.ts";
|
package/src/server/live.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
// /streams/live/<format>/<codec> 路径按需 spawn ffmpeg 子进程,
|
|
2
|
-
// pipe stdout 给 HTTP response,stream 永远不 EOF。
|
|
3
|
-
// ffmpeg 用 -stream_loop -1 + -re 实时速率无限循环,让浏览器端 fetch
|
|
4
|
-
// 持续拿到新字节,player.metrics 长时间累计,模拟真实 live 验收。
|
|
5
|
-
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
|
6
|
-
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
7
|
-
import { type Readable } from "node:stream";
|
|
8
|
-
import { type CodecId, type FormatId } from "../config.ts";
|
|
9
|
-
import { CODEC_SPEC } from "../fixtures/codec.ts";
|
|
10
|
-
import { writeJson } from "./static.ts";
|
|
11
|
-
|
|
12
|
-
const LIVE_MIME: Record<FormatId, string> = {
|
|
13
|
-
flv: "video/x-flv",
|
|
14
|
-
hls: "application/vnd.apple.mpegurl",
|
|
15
|
-
mp4: "video/mp4",
|
|
16
|
-
fmp4: "video/iso.segment",
|
|
17
|
-
ts: "video/mp2t",
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const CODEC_SET: Record<string, true> = { h264: true, hevc: true };
|
|
21
|
-
const FORMAT_SET: Record<string, true> = { flv: true, hls: true, mp4: true, fmp4: true, ts: true };
|
|
22
|
-
|
|
23
|
-
const LIVE_PATTERN = /^\/streams\/live\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
|
|
24
|
-
|
|
25
|
-
export function matchLivePath(path: string): { format: FormatId; codec: CodecId } | null {
|
|
26
|
-
const match = LIVE_PATTERN.exec(path);
|
|
27
|
-
if (!match) return null;
|
|
28
|
-
const [, formatRaw, codecRaw] = match;
|
|
29
|
-
if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
|
|
30
|
-
return { format: formatRaw as FormatId, codec: codecRaw as CodecId };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type LiveStreamOptions = {
|
|
34
|
-
ffmpegPath?: string;
|
|
35
|
-
width?: number;
|
|
36
|
-
height?: number;
|
|
37
|
-
fps?: number;
|
|
38
|
-
bitrate?: string;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export function handleLiveStream(
|
|
42
|
-
req: IncomingMessage,
|
|
43
|
-
res: ServerResponse,
|
|
44
|
-
format: FormatId,
|
|
45
|
-
codec: CodecId,
|
|
46
|
-
options: LiveStreamOptions = {},
|
|
47
|
-
): void {
|
|
48
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
49
|
-
res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
|
|
50
|
-
res.end("Method Not Allowed\n");
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (format === "hls") {
|
|
54
|
-
writeJson(res, 501, {
|
|
55
|
-
error: "HLS live streaming not yet supported in this build",
|
|
56
|
-
reason:
|
|
57
|
-
"use /streams/live/flv/hevc etc. for non-HLS live, or /streams/hls/hevc/master.m3u8 for VOD HLS",
|
|
58
|
-
});
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const ffmpegPath = options.ffmpegPath ?? "ffmpeg";
|
|
62
|
-
const width = options.width ?? 1280;
|
|
63
|
-
const height = options.height ?? 720;
|
|
64
|
-
const fps = options.fps ?? 25;
|
|
65
|
-
const bitrate = options.bitrate ?? "1500k";
|
|
66
|
-
const spec = CODEC_SPEC[codec];
|
|
67
|
-
const mime = LIVE_MIME[format];
|
|
68
|
-
const ffmpegArgs = buildLiveFfmpegArgs(format, codec, spec, {
|
|
69
|
-
width,
|
|
70
|
-
height,
|
|
71
|
-
fps,
|
|
72
|
-
bitrate,
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
if (req.method === "HEAD") {
|
|
76
|
-
res.writeHead(200, {
|
|
77
|
-
"Content-Type": mime,
|
|
78
|
-
"Access-Control-Allow-Origin": "*",
|
|
79
|
-
"X-Live-Source": "ffmpeg-stream-loop",
|
|
80
|
-
});
|
|
81
|
-
res.end();
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
let proc: ChildProcessByStdio<null, Readable, Readable>;
|
|
86
|
-
try {
|
|
87
|
-
proc = spawn(ffmpegPath, ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
|
88
|
-
} catch (err) {
|
|
89
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
-
writeJson(res, 503, { error: "ffmpeg spawn failed", reason: message });
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
proc.once("error", (err) => {
|
|
95
|
-
if (!res.headersSent) {
|
|
96
|
-
writeJson(res, 503, { error: "ffmpeg failed to start", reason: err.message });
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
res.destroy(err);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// ffmpeg 第一帧到达前 stderr 经常有 stat 消息;等 stdout 第一字节再发 head。
|
|
103
|
-
proc.stdout.once("data", () => {
|
|
104
|
-
if (!res.headersSent) {
|
|
105
|
-
res.writeHead(200, {
|
|
106
|
-
"Content-Type": mime,
|
|
107
|
-
"Access-Control-Allow-Origin": "*",
|
|
108
|
-
"Transfer-Encoding": "chunked",
|
|
109
|
-
"X-Live-Source": "ffmpeg-stream-loop",
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
proc.stdout.pipe(res);
|
|
115
|
-
|
|
116
|
-
const cleanup = (): void => {
|
|
117
|
-
if (!proc.killed) proc.kill("SIGKILL");
|
|
118
|
-
};
|
|
119
|
-
req.once("close", cleanup);
|
|
120
|
-
res.once("close", cleanup);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
type FfmpegSpec = {
|
|
124
|
-
encoder: string;
|
|
125
|
-
videoArgs: ReadonlyArray<string>;
|
|
126
|
-
audioArgs: ReadonlyArray<string>;
|
|
127
|
-
pixelFormat: string;
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
type LiveFfmpegOptions = {
|
|
131
|
-
width: number;
|
|
132
|
-
height: number;
|
|
133
|
-
fps: number;
|
|
134
|
-
bitrate: string;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
function buildLiveFfmpegArgs(
|
|
138
|
-
format: FormatId,
|
|
139
|
-
codec: CodecId,
|
|
140
|
-
spec: FfmpegSpec,
|
|
141
|
-
opts: LiveFfmpegOptions,
|
|
142
|
-
): string[] {
|
|
143
|
-
const gop = String(opts.fps);
|
|
144
|
-
const videoInput = `testsrc2=size=${opts.width}x${opts.height}:rate=${opts.fps},format=yuv420p`;
|
|
145
|
-
const audioInput = `sine=frequency=1000:sample_rate=44100`;
|
|
146
|
-
const hevcTag = codec === "hevc" && format === "mp4" ? ["-tag:v", "hvc1"] : [];
|
|
147
|
-
|
|
148
|
-
const baseArgs = [
|
|
149
|
-
"-hide_banner",
|
|
150
|
-
"-loglevel",
|
|
151
|
-
"error",
|
|
152
|
-
"-fflags",
|
|
153
|
-
"+genpts",
|
|
154
|
-
"-re",
|
|
155
|
-
"-stream_loop",
|
|
156
|
-
"-1",
|
|
157
|
-
"-f",
|
|
158
|
-
"lavfi",
|
|
159
|
-
"-i",
|
|
160
|
-
videoInput,
|
|
161
|
-
"-f",
|
|
162
|
-
"lavfi",
|
|
163
|
-
"-i",
|
|
164
|
-
audioInput,
|
|
165
|
-
"-c:v",
|
|
166
|
-
spec.encoder,
|
|
167
|
-
...spec.videoArgs,
|
|
168
|
-
...liveLowLatencyVideoArgs(codec),
|
|
169
|
-
"-pix_fmt",
|
|
170
|
-
spec.pixelFormat,
|
|
171
|
-
"-g",
|
|
172
|
-
gop,
|
|
173
|
-
"-keyint_min",
|
|
174
|
-
gop,
|
|
175
|
-
"-sc_threshold",
|
|
176
|
-
"0",
|
|
177
|
-
"-b:v",
|
|
178
|
-
opts.bitrate,
|
|
179
|
-
...hevcTag,
|
|
180
|
-
...spec.audioArgs,
|
|
181
|
-
];
|
|
182
|
-
|
|
183
|
-
if (format === "flv") {
|
|
184
|
-
return [...baseArgs, "-f", "flv", "-flvflags", "no_sequence_end", "-"];
|
|
185
|
-
}
|
|
186
|
-
if (format === "mp4") {
|
|
187
|
-
return [
|
|
188
|
-
...baseArgs,
|
|
189
|
-
"-f",
|
|
190
|
-
"mp4",
|
|
191
|
-
"-movflags",
|
|
192
|
-
"+faststart+frag_keyframe+empty_moov+default_base_moof",
|
|
193
|
-
"-",
|
|
194
|
-
];
|
|
195
|
-
}
|
|
196
|
-
if (format === "fmp4") {
|
|
197
|
-
return [
|
|
198
|
-
...baseArgs,
|
|
199
|
-
"-f",
|
|
200
|
-
"mp4",
|
|
201
|
-
"-movflags",
|
|
202
|
-
"frag_keyframe+empty_moov+default_base_moof",
|
|
203
|
-
"-frag_duration",
|
|
204
|
-
"2000000",
|
|
205
|
-
"-",
|
|
206
|
-
];
|
|
207
|
-
}
|
|
208
|
-
if (format === "ts") {
|
|
209
|
-
return [...baseArgs, "-f", "mpegts", "-"];
|
|
210
|
-
}
|
|
211
|
-
// hls 已在 handleLiveStream 早返;此处 unreachable。
|
|
212
|
-
throw new Error(`Unknown format: ${String(format)}`);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function liveLowLatencyVideoArgs(codec: CodecId): string[] {
|
|
216
|
-
if (codec === "h264" || codec === "hevc") {
|
|
217
|
-
return ["-tune", "zerolatency"];
|
|
218
|
-
}
|
|
219
|
-
return [];
|
|
220
|
-
}
|
package/src/server/routes.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
// 路由表:/streams/health、/streams/list、/streams/vod-list、/streams/:format/:codec、
|
|
2
|
-
// /streams/(hls|fmp4)/:codec/...、/streams/vod/:name。
|
|
3
|
-
// resolveRoute() 把 path 解析成 fixture-relative 路径;handleXxx 负责写 JSON 响应。
|
|
4
|
-
import { type ServerResponse } from "node:http";
|
|
5
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { ALL_CODECS, ALL_FORMATS, type CodecId, type FormatId } from "../config.ts";
|
|
8
|
-
import { type Manifest, summarizeCatalog } from "../fixtures/catalog.ts";
|
|
9
|
-
import { writeJson } from "./static.ts";
|
|
10
|
-
|
|
11
|
-
const FORMAT_SET: Record<string, true> = Object.fromEntries(ALL_FORMATS.map((f) => [f, true]));
|
|
12
|
-
const CODEC_SET: Record<string, true> = Object.fromEntries(ALL_CODECS.map((c) => [c, true]));
|
|
13
|
-
const HLS_FMP4: Record<string, true> = { hls: true, fmp4: true };
|
|
14
|
-
|
|
15
|
-
const SINGLE = /^\/streams\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
|
|
16
|
-
const PLAYLIST = /^\/streams\/(hls|fmp4)\/([a-z0-9]+)\/(.+)$/;
|
|
17
|
-
const VOD = /^\/streams\/vod\/([a-zA-Z0-9._-]+)\/?$/;
|
|
18
|
-
|
|
19
|
-
export function resolveRoute(path: string): string | null {
|
|
20
|
-
// 单文件:/streams/flv/h264、/streams/mp4/hevc、/streams/ts/h264
|
|
21
|
-
const single = SINGLE.exec(path);
|
|
22
|
-
if (single) {
|
|
23
|
-
const [, formatRaw, codecRaw] = single;
|
|
24
|
-
if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
|
|
25
|
-
if (HLS_FMP4[formatRaw]) return null; // hls/fmp4 必须带子文件路径
|
|
26
|
-
const format = formatRaw as FormatId;
|
|
27
|
-
const codec = codecRaw as CodecId;
|
|
28
|
-
return join(format, `${codec}.${format}`);
|
|
29
|
-
}
|
|
30
|
-
// playlist 内子文件
|
|
31
|
-
const playlist = PLAYLIST.exec(path);
|
|
32
|
-
if (playlist) {
|
|
33
|
-
const [, formatRaw, codecRaw, rest] = playlist;
|
|
34
|
-
if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
|
|
35
|
-
return join(formatRaw, codecRaw, rest);
|
|
36
|
-
}
|
|
37
|
-
// /streams/vod/:name
|
|
38
|
-
const vod = VOD.exec(path);
|
|
39
|
-
if (vod) {
|
|
40
|
-
const [, name] = vod;
|
|
41
|
-
return join("vod", name);
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function handleHealth(
|
|
47
|
-
res: ServerResponse,
|
|
48
|
-
manifest: Manifest | null,
|
|
49
|
-
fixtureDir: string,
|
|
50
|
-
): void {
|
|
51
|
-
const summary = summarizeCatalog(fixtureDir);
|
|
52
|
-
const status = manifest ? 200 : 503;
|
|
53
|
-
writeJson(res, status, {
|
|
54
|
-
status: summary.exists ? "ok" : "down",
|
|
55
|
-
codecs: summary.codecs,
|
|
56
|
-
formats: summary.formats,
|
|
57
|
-
fixtures: summary.fixtureCount,
|
|
58
|
-
reason: manifest ? undefined : summary.reason,
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function handleList(res: ServerResponse, manifest: Manifest | null): void {
|
|
63
|
-
if (!manifest) {
|
|
64
|
-
writeJson(res, 503, { error: "manifest not found" });
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
writeJson(res, 200, manifest);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export type VodSegmentDescriptor = {
|
|
71
|
-
url: string;
|
|
72
|
-
startTimestamp: number;
|
|
73
|
-
endTimestamp: number;
|
|
74
|
-
timeLen: number;
|
|
75
|
-
fileSize: number;
|
|
76
|
-
channelID: number;
|
|
77
|
-
RecordType: number;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
export type VodListResponse = {
|
|
81
|
-
segments: ReadonlyArray<VodSegmentDescriptor>;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export function handleVodList(
|
|
85
|
-
res: ServerResponse,
|
|
86
|
-
manifest: Manifest | null,
|
|
87
|
-
fixtureDir: string,
|
|
88
|
-
): void {
|
|
89
|
-
// 优先读 vod-list.json(由 extend-vod CLI 生成),支持任意段数 + 段长。
|
|
90
|
-
const vodListPath = join(fixtureDir, "vod", "vod-list.json");
|
|
91
|
-
if (existsSync(vodListPath)) {
|
|
92
|
-
try {
|
|
93
|
-
const raw = JSON.parse(readFileSync(vodListPath, "utf8")) as {
|
|
94
|
-
entries: ReadonlyArray<{ path: string; size: number }>;
|
|
95
|
-
};
|
|
96
|
-
const segMs = manifest?.durationSeconds ? manifest.durationSeconds * 1000 : 0;
|
|
97
|
-
const segments: VodSegmentDescriptor[] = raw.entries.map((e, i) => ({
|
|
98
|
-
url: `/streams/${e.path}`,
|
|
99
|
-
startTimestamp: i * segMs,
|
|
100
|
-
endTimestamp: (i + 1) * segMs,
|
|
101
|
-
timeLen: segMs,
|
|
102
|
-
fileSize: e.size,
|
|
103
|
-
channelID: 1,
|
|
104
|
-
RecordType: 1,
|
|
105
|
-
}));
|
|
106
|
-
writeJson(res, 200, { segments });
|
|
107
|
-
return;
|
|
108
|
-
} catch {
|
|
109
|
-
// fallthrough to legacy 2-segment path
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
if (!manifest) {
|
|
113
|
-
writeJson(res, 503, { error: "manifest not found" });
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
const h264mp4 = manifest.fixtures.find((f) => f.codec === "h264" && f.format === "mp4");
|
|
117
|
-
const fileSize = h264mp4?.entries[0]?.size ?? 0;
|
|
118
|
-
const durMs = manifest.durationSeconds * 1000;
|
|
119
|
-
writeJson(res, 200, {
|
|
120
|
-
segments: [
|
|
121
|
-
{
|
|
122
|
-
url: "/streams/vod/sample-1.mp4",
|
|
123
|
-
startTimestamp: 0,
|
|
124
|
-
endTimestamp: durMs,
|
|
125
|
-
timeLen: durMs,
|
|
126
|
-
fileSize,
|
|
127
|
-
channelID: 1,
|
|
128
|
-
RecordType: 1,
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
url: "/streams/vod/sample-2.mp4",
|
|
132
|
-
startTimestamp: durMs,
|
|
133
|
-
endTimestamp: durMs * 2,
|
|
134
|
-
timeLen: durMs,
|
|
135
|
-
fileSize,
|
|
136
|
-
channelID: 1,
|
|
137
|
-
RecordType: 1,
|
|
138
|
-
},
|
|
139
|
-
],
|
|
140
|
-
});
|
|
141
|
-
}
|