154 lines
5.3 KiB
JavaScript
154 lines
5.3 KiB
JavaScript
// Isolated real HTTP + real FFmpeg + process restart. No paid model requests.
|
|
const fs = require("node:fs"),
|
|
path = require("node:path"),
|
|
crypto = require("node:crypto"),
|
|
assert = require("node:assert/strict");
|
|
const { spawn, execFileSync } = require("node:child_process");
|
|
const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
(async () => {
|
|
const dir = path.resolve(".qa/render-service");
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const data = fs.mkdtempSync(path.join(dir, "run-")),
|
|
token = crypto.randomBytes(32).toString("hex"),
|
|
base = "http://127.0.0.1:19340";
|
|
const tenant = sha("qa-user"),
|
|
cache = path.join(data, "cache", sha(tenant).slice(0, 20));
|
|
fs.mkdirSync(cache, { recursive: true });
|
|
const plan = JSON.parse(fs.readFileSync(".qa/smart-edit/fixture-plan.json"));
|
|
for (const [i, a] of plan.assets.entries()) {
|
|
const file = path.resolve(".qa/smart-edit", i ? "with-audio.mp4" : "colors.mp4"),
|
|
bytes = fs.readFileSync(file);
|
|
a.hash = sha(bytes);
|
|
fs.writeFileSync(path.join(cache, a.hash), bytes);
|
|
fs.writeFileSync(
|
|
path.join(cache, sha(a.url) + ".json"),
|
|
JSON.stringify({ hash: a.hash, created: Date.now() }),
|
|
);
|
|
}
|
|
plan.transition = 0.5;
|
|
plan.subtitles = true;
|
|
plan.clips[1].gain = 2;
|
|
plan.captions = [
|
|
{
|
|
id: "font-http",
|
|
start: 0,
|
|
end: 2e6,
|
|
text: "API 字体验证",
|
|
style: {
|
|
fontFamily: "noto-serif-sc",
|
|
referenceHeight: 1080,
|
|
fontSize: 56,
|
|
color: "#ffffff",
|
|
outlineColor: "#ffffff",
|
|
outlineWidth: 0,
|
|
opacity: 1,
|
|
spacing: 0,
|
|
},
|
|
},
|
|
];
|
|
let child;
|
|
const start = async () => {
|
|
child = spawn(process.execPath, ["services/video-render/dist/server.cjs"], {
|
|
env: {
|
|
...process.env,
|
|
RENDER_PORT: "19340",
|
|
RENDER_DATA_DIR: data,
|
|
SMART_EDIT_CACHE_DIR: path.join(data, "cache"),
|
|
RENDER_API_TOKEN: token,
|
|
},
|
|
detached: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
child.stdout.on("data", (b) => fs.appendFileSync(path.join(data, "log"), b));
|
|
child.stderr.on("data", (b) => fs.appendFileSync(path.join(data, "log"), b));
|
|
for (let i = 0; i < 60; i++) {
|
|
try {
|
|
if (
|
|
(await fetch(base + "/health", { headers: { authorization: "Bearer " + token } })).ok
|
|
)
|
|
return;
|
|
} catch {}
|
|
await sleep(100);
|
|
}
|
|
throw Error("service did not start");
|
|
};
|
|
const stop = async (signal = "SIGTERM") => {
|
|
if (child && child.exitCode === null) {
|
|
process.kill(-child.pid, signal);
|
|
await new Promise((r) => child.once("exit", r));
|
|
}
|
|
child = null;
|
|
};
|
|
const request = async (route, body) => {
|
|
const r = await fetch(base + route, {
|
|
method: body ? "POST" : "GET",
|
|
headers: { authorization: "Bearer " + token, "content-type": "application/json" },
|
|
body: body && JSON.stringify(body),
|
|
});
|
|
return { status: r.status, body: await r.json() };
|
|
};
|
|
try {
|
|
await start();
|
|
assert.equal((await fetch(base + "/health")).status, 401);
|
|
const id = sha("actual-render-restart"),
|
|
input = { id, tenant, plan };
|
|
assert.equal((await request("/v1/renders", input)).status, 202);
|
|
for (let i = 0; i < 100; i++) {
|
|
if (
|
|
(await request("/v1/renders/" + id)).body.status === "running" &&
|
|
JSON.parse(fs.readFileSync(path.join(data, id + ".json"))).status === "running"
|
|
)
|
|
break;
|
|
await sleep(50);
|
|
}
|
|
await stop("SIGKILL");
|
|
await start();
|
|
assert.equal((await request("/v1/renders", input)).status, 200);
|
|
assert.equal(
|
|
(await request("/v1/renders", { ...input, plan: { ...plan, title: "changed" } })).status,
|
|
409,
|
|
);
|
|
let state;
|
|
for (let i = 0; i < 300; i++) {
|
|
state = (await request("/v1/renders/" + id)).body;
|
|
if (["succeeded", "failed"].includes(state.status)) break;
|
|
await sleep(200);
|
|
}
|
|
assert.equal(state.status, "succeeded", JSON.stringify(state));
|
|
assert.equal(state.attempts, 2);
|
|
assert.equal((await fetch(base + "/v1/renders/" + id + "/video")).status, 401);
|
|
const result = await fetch(base + "/v1/renders/" + id + "/video", {
|
|
headers: { authorization: "Bearer " + token },
|
|
});
|
|
const out = path.join(data, "download.mp4");
|
|
fs.writeFileSync(out, Buffer.from(await result.arrayBuffer()));
|
|
const probe = JSON.parse(
|
|
execFileSync("ffprobe", [
|
|
"-v",
|
|
"error",
|
|
"-show_format",
|
|
"-show_streams",
|
|
"-of",
|
|
"json",
|
|
out,
|
|
]),
|
|
);
|
|
assert.ok(Math.abs(Number(probe.format.duration) - 3.5) < 0.15);
|
|
assert.equal(probe.streams[0].width, 1280);
|
|
assert.equal((await request("/v1/renders", input)).body.attempts, 2);
|
|
await stop();
|
|
await start();
|
|
assert.equal((await request("/v1/renders/" + id)).body.status, "succeeded");
|
|
console.log(
|
|
"PASS: authenticated API, real FFmpeg, persisted queue, kill/restart recovery, idempotency, conflicting request rejection, completed result survives restart",
|
|
);
|
|
fs.writeFileSync(path.join(dir, "last-run.json"), JSON.stringify({ data, result: state }));
|
|
} finally {
|
|
await stop();
|
|
}
|
|
})().catch((e) => {
|
|
console.error(e);
|
|
process.exitCode = 1;
|
|
});
|