085-cinestudio-server-render.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. /*
  2. Cine Studio: server-side export and proxies, against a real ArozOS server.
  3. Proves that inside ArozOS the Export dialog renders on the server (the
  4. ffmpeg.renderTimeline AGI job) from the original media, and does NOT
  5. fall back to recording the preview in the browser (MediaRecorder):
  6. 1. boot ArozOS mode, server ffmpeg detected, server dialog
  7. 2. render + verify a real timeline (cut, dissolve, title, audio) is
  8. exported through the dialog in every server format;
  9. each output is downloaded and inspected with
  10. ffprobe / ffmpeg (codec, size, duration, pixels,
  11. audio level)
  12. 3. options half size, low quality, in/out range only
  13. 4. effects a footage file the browser cannot decode gets a
  14. server proxy for the preview, but the render uses
  15. the original file
  16. 5. proxies makeProxy jobs: browser-playable H.264 output, capped
  17. height, audio proxy
  18. 6. cancel cancelling a running render stops it and leaves no
  19. output behind
  20. 7. spies MediaRecorder was never constructed and
  21. startRecorder never called on any server export
  22. 8. control with server export forced off, the same spies DO
  23. see the browser recorder, so the checks above bite
  24. Needs ffmpeg + ffprobe on the machine running the spec (they generate the
  25. test footage and inspect the results) and on the server under test.
  26. */
  27. "use strict";
  28. const fs = require("fs");
  29. const os = require("os");
  30. const path = require("path");
  31. const { spawnSync } = require("child_process");
  32. const h = require("../lib/system-harness");
  33. const APP = "/Cine Studio/index.html";
  34. const MEDIA_DIR = "user:/Cine Studio/Media";
  35. const EXPORT_DIR = "user:/Cine Studio/Exports";
  36. /* ---------------- local media tooling ---------------- */
  37. function haveTool(name) {
  38. const r = spawnSync(name, ["-version"], { encoding: "utf8" });
  39. return !r.error && r.status === 0;
  40. }
  41. function run(cmd, args) {
  42. const r = spawnSync(cmd, args, { encoding: "buffer", maxBuffer: 64 << 20 });
  43. if (r.status !== 0) {
  44. throw new Error(cmd + " " + args.join(" ") + " failed: " + String(r.stderr || "").slice(-400));
  45. }
  46. return r.stdout;
  47. }
  48. function probe(file) {
  49. return JSON.parse(run("ffprobe", ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", file]).toString());
  50. }
  51. function stream(info, type) {
  52. return (info.streams || []).filter(function (s) { return s.codec_type === type; })[0] || null;
  53. }
  54. // Mean colour of the whole frame at time t
  55. function meanRGB(file, t) {
  56. const out = run("ffmpeg", ["-v", "error", "-ss", String(t), "-i", file, "-frames:v", "1",
  57. "-vf", "scale=1:1:flags=area", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]);
  58. return [out[0], out[1], out[2]];
  59. }
  60. // How many near-white pixels a 160x90 rendition of the frame at t contains
  61. function whitePixels(file, t) {
  62. const out = run("ffmpeg", ["-v", "error", "-ss", String(t), "-i", file, "-frames:v", "1",
  63. "-vf", "scale=160:90", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]);
  64. let n = 0;
  65. for (let i = 0; i + 2 < out.length; i += 3) {
  66. if (out[i] > 200 && out[i + 1] > 200 && out[i + 2] > 200) { n++; }
  67. }
  68. return n;
  69. }
  70. // Mean volume in dB of the first audio stream (null when silent / no audio)
  71. function meanVolume(file) {
  72. const r = spawnSync("ffmpeg", ["-v", "info", "-i", file, "-vn", "-af", "volumedetect", "-f", "null", "-"], { encoding: "utf8" });
  73. const m = /mean_volume:\s*(-?[\d.]+) dB/.exec(r.stderr || "");
  74. return m ? parseFloat(m[1]) : null;
  75. }
  76. // libx264 writes its version banner into the H.264 stream; hardware
  77. // encoders do not, which tells the two apart without trusting any log
  78. function encodedBySoftwareX264(file) {
  79. return fs.readFileSync(file).indexOf("x264 - core") !== -1;
  80. }
  81. function makeFootage(dir) {
  82. const f = {};
  83. function gen(name, args) {
  84. f[name] = path.join(dir, name);
  85. run("ffmpeg", ["-y", "-v", "error"].concat(args, [f[name]]));
  86. }
  87. gen("red.mp4", ["-f", "lavfi", "-i", "color=c=red:s=640x360:r=30:d=3", "-f", "lavfi", "-i", "sine=frequency=440:d=3",
  88. "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]);
  89. gen("blue.mp4", ["-f", "lavfi", "-i", "color=c=blue:s=640x360:r=30:d=3", "-f", "lavfi", "-i", "sine=frequency=880:d=3",
  90. "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]);
  91. gen("tone.wav", ["-f", "lavfi", "-i", "sine=frequency=660:d=6"]);
  92. // MPEG-2 in Matroska: no browser plays this, so it must be proxied
  93. gen("green.mkv", ["-f", "lavfi", "-i", "color=c=0x00c800:s=1280x720:r=25:d=3", "-c:v", "mpeg2video", "-q:v", "2"]);
  94. return f;
  95. }
  96. /* ---------------- server / page helpers ---------------- */
  97. async function upload(page, base, file, dir) {
  98. const res = await page.request.post(base + "/system/file_system/upload", {
  99. multipart: {
  100. path: dir,
  101. file: { name: path.basename(file), mimeType: "application/octet-stream", buffer: fs.readFileSync(file) }
  102. }
  103. });
  104. const text = (await res.text()).trim().toLowerCase();
  105. if (text.indexOf("ok") === -1) { throw new Error("upload of " + file + " failed: " + text); }
  106. }
  107. async function download(page, base, vpath, dest) {
  108. const res = await page.request.get(base + "/media/?file=" + encodeURIComponent(vpath));
  109. if (!res.ok()) { throw new Error("download of " + vpath + " failed: HTTP " + res.status()); }
  110. fs.writeFileSync(dest, await res.body());
  111. return dest;
  112. }
  113. async function exists(page, base, vpath) {
  114. const dir = vpath.slice(0, vpath.lastIndexOf("/"));
  115. const name = vpath.slice(vpath.lastIndexOf("/") + 1);
  116. const listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: dir });
  117. return listing.indexOf(name) !== -1;
  118. }
  119. // Network-level record of everything the page asks the server to do
  120. function attachSpies(page) {
  121. const log = { agi: [], uploads: [], renderSpecs: [], responses: [] };
  122. page.on("request", function (req) {
  123. const url = req.url();
  124. if (url.indexOf("/system/ajgi/interface") !== -1 && url.indexOf("ffmpegtools.js") !== -1) {
  125. const params = new URLSearchParams(req.postData() || "");
  126. log.agi.push({ action: params.get("action"), params: params });
  127. if (params.get("action") === "render") {
  128. try { log.renderSpecs.push(JSON.parse(params.get("spec"))); } catch (e) { /* recorded as missing */ }
  129. }
  130. }
  131. if (url.indexOf("/system/file_system/upload") !== -1) {
  132. const body = req.postData() || "";
  133. const m = /name="path"\r\n\r\n([^\r]*)/.exec(body);
  134. log.uploads.push(m ? m[1] : "?");
  135. }
  136. });
  137. return log;
  138. }
  139. function countAction(log, action) {
  140. return log.agi.filter(function (a) { return a.action === action; }).length;
  141. }
  142. /* ---------------- the spec ---------------- */
  143. h.run("CINESTUDIO-SERVER-RENDER", async function (env) {
  144. if (!haveTool("ffmpeg") || !haveTool("ffprobe")) {
  145. console.log(" SKIP: ffmpeg / ffprobe not installed on this machine");
  146. return;
  147. }
  148. const base = env.baseURL;
  149. const work = fs.mkdtempSync(path.join(os.tmpdir(), "cs-server-render-"));
  150. const footage = makeFootage(work);
  151. const page = await h.newPage(env.browser);
  152. await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
  153. page.on("dialog", function (d) { d.dismiss().catch(function () {}); });
  154. page.on("console", function (m) { if (m.type() === "error") { console.log(" [console.error] " + m.text().slice(0, 200)); } });
  155. const log = attachSpies(page);
  156. // MediaRecorder spy, installed before any app script runs
  157. await page.addInitScript(function () {
  158. window.__mr = { ctor: 0 };
  159. const Real = window.MediaRecorder;
  160. if (Real) {
  161. const Spy = function (a, b) { window.__mr.ctor++; return new Real(a, b); };
  162. Spy.prototype = Real.prototype;
  163. Spy.isTypeSupported = Real.isTypeSupported.bind(Real);
  164. window.MediaRecorder = Spy;
  165. }
  166. });
  167. /* ---- 1. boot ---- */
  168. await page.goto(base + APP, { waitUntil: "domcontentloaded" });
  169. await page.waitForFunction(function () { return window.CS && CS._ffmpegChecked && CS.project; }, null, { timeout: 30000 });
  170. const boot = await page.evaluate(function () {
  171. return { aroz: CS.inArozOS(), ffmpeg: CS.serverFFmpeg, hw: CS.serverHWEncoder, useServer: CS.exporter.useServer() };
  172. });
  173. if (!boot.aroz) { h.fail("Cine Studio did not detect ArozOS mode"); }
  174. if (!boot.ffmpeg) { h.fail("server reports no ffmpeg - server-side export is unavailable on this instance"); }
  175. if (!boot.useServer) { h.fail("CS.exporter.useServer() is false even though ffmpeg is present"); }
  176. h.ok("ArozOS mode, server ffmpeg detected, server export selected (hardware encoder: " + (boot.hw || "none, software x264") + ")");
  177. await page.evaluate(function () {
  178. window.__toasts = [];
  179. const t = CS.toast;
  180. CS.toast = function (msg, isErr) { window.__toasts.push((isErr ? "ERR: " : "") + msg); return t.apply(this, arguments); };
  181. window.__recorderCalls = 0;
  182. const sr = CS.exporter.startRecorder;
  183. CS.exporter.startRecorder = function () { window.__recorderCalls++; return sr.apply(this, arguments); };
  184. window.__serverCalls = 0;
  185. const ss = CS.exporter.startServer;
  186. CS.exporter.startServer = function () { window.__serverCalls++; return ss.apply(this, arguments); };
  187. });
  188. // The app creates its folders on first open
  189. for (let i = 0; i < 20; i++) {
  190. const listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/Cine Studio" });
  191. if (listing.indexOf("Media") !== -1) { break; }
  192. await page.waitForTimeout(500);
  193. }
  194. for (const name of ["red.mp4", "blue.mp4", "tone.wav", "green.mkv"]) {
  195. await upload(page, base, footage[name], MEDIA_DIR);
  196. }
  197. h.ok("test footage uploaded to the user's Cine Studio/Media folder");
  198. /* ---- build the project the way a user would ---- */
  199. await page.evaluate(function (dir) {
  200. CS.newProject({ name: "ServerRender", width: 640, height: 360, fps: 30 });
  201. CS.player.applyProjectSize();
  202. window.__m = {};
  203. ["red.mp4", "blue.mp4", "tone.wav"].forEach(function (n) {
  204. window.__m[n] = CS.media.addFromVpath(dir + "/" + n, n);
  205. });
  206. }, MEDIA_DIR);
  207. await page.waitForFunction(function () {
  208. return Object.keys(__m).every(function (k) { return __m[k].probed && __m[k].proxyState !== "pending"; });
  209. }, null, { timeout: 60000 });
  210. const built = await page.evaluate(function () {
  211. const a = CS.addClipToTimeline(__m["red.mp4"], "V1", 0);
  212. const b = CS.addClipToTimeline(__m["blue.mp4"], "V1", CS.clipDuration(a));
  213. b.props.transition = { type: "dissolve", duration: 1 };
  214. CS.addClipToTimeline(__m["tone.wav"], "A1", 0);
  215. CS.player.seek(1);
  216. CS.titles.insertPreset("title");
  217. const t = CS.selectedClip();
  218. t.props.text.content = "HELLO";
  219. t.props.text.size = 200;
  220. CS.titles.invalidate(t);
  221. CS.commit("Server render seed");
  222. return { total: CS.timelineDuration(), titleKind: t.kind, titleStart: t.start, titleEnd: t.start + CS.clipDuration(t) };
  223. });
  224. h.ok("timeline built: cut + 1s dissolve + title + audio, " + built.total.toFixed(2) + "s");
  225. /* ---- export through the real dialog ---- */
  226. async function doExport(opts) {
  227. await page.click("#btn-export");
  228. await page.waitForSelector(".modal .modal-title");
  229. const row = function (label) { return page.locator(".modal-row", { hasText: label }); };
  230. await row("Filename").locator("input").fill(opts.name);
  231. await row("Format").locator("select").selectOption(opts.format);
  232. if (opts.size) { await row("Size").locator("select").selectOption(opts.size); }
  233. if (opts.quality) { await row("Quality").locator("select").selectOption(opts.quality); }
  234. if (opts.hardware) { await row("Encoder").locator("input").check(); }
  235. const failsBefore = await page.evaluate(function () { return __toasts.length; });
  236. await page.click(".modal-btn.primary");
  237. await page.waitForFunction(function () { return CS.exporter.job === null && !document.querySelector("#modal-holder .modal"); },
  238. null, { timeout: 300000 });
  239. const toasts = await page.evaluate(function (n) { return __toasts.slice(n); }, failsBefore);
  240. const failed = toasts.filter(function (t) { return /Export failed|ERR:/.test(t); });
  241. if (failed.length) { h.fail(opts.name + "." + opts.format + " export reported: " + failed.join(" | ")); }
  242. const vpath = EXPORT_DIR + "/" + opts.name + "." + opts.format;
  243. if (!(await exists(page, base, vpath))) { h.fail("server did not produce " + vpath); }
  244. return download(page, base, vpath, path.join(work, "out_" + opts.name + "." + opts.format));
  245. }
  246. // dialog content: server formats, no browser-recorder-only choice
  247. await page.click("#btn-export");
  248. await page.waitForSelector(".modal .modal-title");
  249. const dlg = await page.evaluate(function () {
  250. const sel = document.querySelector(".modal-row select");
  251. return {
  252. formats: Array.from(sel.options).map(function (o) { return o.value; }),
  253. text: document.querySelector(".modal-body").textContent
  254. };
  255. });
  256. await page.evaluate(function () { CS.closeModal(); });
  257. if (dlg.formats.join(",") !== "mp4,mov,mkv,webm,gif,m4a") { h.fail("unexpected server formats: " + dlg.formats.join(",")); }
  258. if (dlg.text.indexOf("rendered on the server") === -1) { h.fail("dialog does not say it renders on the server"); }
  259. if (/recorded in browser|Keep this window visible/.test(dlg.text)) { h.fail("dialog still shows the browser-recording wording"); }
  260. h.ok("Export dialog offers the server formats (" + dlg.formats.join(", ") + ") and says it renders on the server");
  261. /* ---- 2. render + verify ---- */
  262. const t0 = Date.now();
  263. const mp4 = await doExport({ name: "full", format: "mp4" });
  264. const info = probe(mp4);
  265. const v = stream(info, "video"), a = stream(info, "audio");
  266. if (!v || v.codec_name !== "h264") { h.fail("mp4 video is not H.264: " + (v && v.codec_name)); }
  267. if (v.width !== 640 || v.height !== 360) { h.fail("mp4 size " + v.width + "x" + v.height + ", want 640x360"); }
  268. if (!a || a.codec_name !== "aac") { h.fail("mp4 audio is not AAC: " + (a && a.codec_name)); }
  269. const dur = parseFloat(info.format.duration);
  270. if (Math.abs(dur - built.total) > 0.25) { h.fail("mp4 duration " + dur + "s, want about " + built.total + "s"); }
  271. const enc = (info.format.tags && info.format.tags.encoder) || "";
  272. if (!/Lavf/.test(enc)) { h.fail("mp4 was not muxed by ffmpeg (encoder tag: '" + enc + "')"); }
  273. h.ok("mp4: H.264 640x360 + AAC, " + dur.toFixed(2) + "s, muxed by " + enc + " (" + ((Date.now() - t0) / 1000).toFixed(1) + "s)");
  274. const red = meanRGB(mp4, 0.5), mid = meanRGB(mp4, 3.5), blue = meanRGB(mp4, 5.5);
  275. if (!(red[0] > 150 && red[2] < 90)) { h.fail("t=0.5s should be red, got rgb(" + red + ")"); }
  276. if (!(blue[2] > 150 && blue[0] < 90)) { h.fail("t=5.5s should be blue, got rgb(" + blue + ")"); }
  277. if (!(mid[0] > 40 && mid[2] > 40)) { h.fail("t=3.5s should be a red/blue dissolve mix, got rgb(" + mid + ")"); }
  278. h.ok("frames: red rgb(" + red + "), dissolve mix rgb(" + mid + "), blue rgb(" + blue + ")");
  279. const withTitle = whitePixels(mp4, 2.5), without = whitePixels(mp4, 5.5);
  280. if (withTitle < 60 || withTitle <= without + 40) { h.fail("title not rendered: " + withTitle + " white px at 2.5s vs " + without + " at 5.5s"); }
  281. h.ok("title composited by the server render (" + withTitle + " white px while shown, " + without + " after)");
  282. const vol = meanVolume(mp4);
  283. if (vol === null || vol < -50) { h.fail("mp4 audio is silent (mean " + vol + " dB)"); }
  284. h.ok("audio mixed into the render (mean volume " + vol + " dB)");
  285. for (const f of [
  286. { format: "mov", vcodec: "h264", acodec: "aac" },
  287. { format: "mkv", vcodec: "h264", acodec: "aac" },
  288. { format: "webm", vcodec: "vp9", acodec: "opus" },
  289. { format: "gif", vcodec: "gif", acodec: null },
  290. { format: "m4a", vcodec: null, acodec: "aac" }
  291. ]) {
  292. const file = await doExport({ name: "fmt_" + f.format, format: f.format, size: f.format === "gif" ? "0.25" : undefined });
  293. const i = probe(file), sv = stream(i, "video"), sa = stream(i, "audio");
  294. if (f.vcodec && (!sv || sv.codec_name !== f.vcodec)) { h.fail(f.format + " video codec " + (sv && sv.codec_name) + ", want " + f.vcodec); }
  295. if (!f.vcodec && sv) { h.fail(f.format + " should have no video stream"); }
  296. if (f.acodec && (!sa || sa.codec_name !== f.acodec)) { h.fail(f.format + " audio codec " + (sa && sa.codec_name) + ", want " + f.acodec); }
  297. if (!f.acodec && sa) { h.fail(f.format + " should have no audio stream"); }
  298. const d = parseFloat(i.format.duration);
  299. if (Math.abs(d - built.total) > 0.35) { h.fail(f.format + " duration " + d + "s, want about " + built.total + "s"); }
  300. h.ok(f.format + ": " + (sv ? sv.codec_name + " " : "") + (sa ? sa.codec_name + " " : "") + d.toFixed(2) + "s");
  301. }
  302. /* ---- 3. options ---- */
  303. const small = await doExport({ name: "half_low", format: "mp4", size: "0.5", quality: "low" });
  304. const sv = stream(probe(small), "video");
  305. if (sv.width !== 320 || sv.height !== 180) { h.fail("half size export is " + sv.width + "x" + sv.height + ", want 320x180"); }
  306. if (fs.statSync(small).size >= fs.statSync(mp4).size) { h.fail("low quality half size file is not smaller than the full export"); }
  307. h.ok("size 1/2 + low quality: " + sv.width + "x" + sv.height + ", " + fs.statSync(small).size + " B vs " + fs.statSync(mp4).size + " B full");
  308. await page.evaluate(function () { CS.setInPoint(1); CS.setOutPoint(4); });
  309. const ranged = await doExport({ name: "range", format: "mp4" });
  310. const rd = parseFloat(probe(ranged).format.duration);
  311. if (Math.abs(rd - 3) > 0.25) { h.fail("in/out range export lasts " + rd + "s, want 3s"); }
  312. const r0 = meanRGB(ranged, 0.3);
  313. if (!(r0[0] > 150 && r0[2] < 90)) { h.fail("range export should start on red, got rgb(" + r0 + ")"); }
  314. await page.evaluate(function () { CS.clearInOut("both"); });
  315. h.ok("in/out range 1s-4s exported as " + rd.toFixed(2) + "s starting on red");
  316. /* ---- 3b. hardware encoder (only when the server found one) ---- */
  317. if (boot.hw) {
  318. const soft = await doExport({ name: "enc_software", format: "mp4" });
  319. const hard = await doExport({ name: "enc_hardware", format: "mp4", hardware: true });
  320. if (!encodedBySoftwareX264(soft)) { h.fail("the software export does not carry the libx264 banner - the marker check is unreliable"); }
  321. if (encodedBySoftwareX264(hard)) { h.fail("hardware export was still encoded by libx264"); }
  322. const hv = stream(probe(hard), "video");
  323. if (!hv || hv.codec_name !== "h264" || hv.width !== 640 || hv.height !== 360) { h.fail("hardware export is not 640x360 H.264"); }
  324. const hred = meanRGB(hard, 0.5), hblue = meanRGB(hard, 5.5);
  325. if (!(hred[0] > 150 && hred[2] < 90 && hblue[2] > 150 && hblue[0] < 90)) { h.fail("hardware export has wrong colours: rgb(" + hred + ") / rgb(" + hblue + ")"); }
  326. h.ok("hardware encoder (" + boot.hw + "): H.264 " + hv.width + "x" + hv.height + ", no libx264 banner (software export has it), colours correct");
  327. } else {
  328. console.log(" SKIP: server reports no hardware encoder, so the hardware option was not tested");
  329. }
  330. /* ---- 4. footage the browser cannot decode: proxy for preview, original for render ---- */
  331. const proxyBefore = countAction(log, "proxy");
  332. await page.evaluate(function (dir) {
  333. CS.newProject({ name: "OddFootage", width: 640, height: 360, fps: 30 });
  334. CS.player.applyProjectSize();
  335. window.__g = CS.media.addFromVpath(dir + "/green.mkv", "green.mkv");
  336. }, MEDIA_DIR);
  337. await page.waitForFunction(function () { return (__g.proxyState === "ready" && __g.probed) || __g.proxyState === "failed"; }, null, { timeout: 120000 });
  338. const prox = await page.evaluate(function () { return { state: __g.proxyState, vpath: __g.proxyVpath, probed: __g.probed }; });
  339. if (prox.state !== "ready") { h.fail("green.mkv proxy ended as '" + prox.state + "'"); }
  340. if (countAction(log, "proxy") <= proxyBefore) { h.fail("no proxy request reached ffmpegtools.js"); }
  341. if (prox.vpath.indexOf("/Cine Studio/Cache/") === -1) { h.fail("proxy is not in the Cache folder: " + prox.vpath); }
  342. const pinfo = probe(await download(page, base, prox.vpath, path.join(work, "green_proxy.mp4")));
  343. const pv = stream(pinfo, "video");
  344. if (!pv || pv.codec_name !== "h264") { h.fail("proxy is not browser-playable H.264: " + (pv && pv.codec_name)); }
  345. if (boot.hw && encodedBySoftwareX264(path.join(work, "green_proxy.mp4"))) { h.fail("proxy was encoded by libx264 although the server has a hardware encoder"); }
  346. h.ok("green.mkv (MPEG-2/Matroska) proxied on the server to " + pv.codec_name + " " + pv.width + "x" + pv.height + (boot.hw ? " with the hardware encoder" : ""));
  347. await page.evaluate(function () {
  348. const c = CS.addClipToTimeline(__g, "V1", 0);
  349. CS.effects.applyToClip(c, "invert");
  350. CS.commit("invert green");
  351. });
  352. const renderBefore = log.renderSpecs.length;
  353. const inv = await doExport({ name: "invert_original", format: "mp4" });
  354. const spec = log.renderSpecs[renderBefore];
  355. if (!spec) { h.fail("render request did not carry a spec"); }
  356. const vl = (spec.layers || []).filter(function (l) { return l.kind === "video"; });
  357. if (!vl.length || vl.some(function (l) { return l.src !== MEDIA_DIR + "/green.mkv"; })) {
  358. h.fail("render used " + JSON.stringify(vl.map(function (l) { return l.src; })) + " instead of the original green.mkv");
  359. }
  360. const ig = meanRGB(inv, 1);
  361. if (!(ig[0] > 200 && ig[1] < 90 && ig[2] > 200)) { h.fail("inverted green should be magenta, got rgb(" + ig + ")"); }
  362. h.ok("render read the ORIGINAL green.mkv (not the proxy) and applied Invert: rgb(" + ig + ")");
  363. /* ---- 5. proxy jobs directly ---- */
  364. const jobs = await page.evaluate(function (dir) {
  365. function agi(params) {
  366. return new Promise(function (resolve, reject) { CS.exporter.agi(params).then(resolve, reject); });
  367. }
  368. function wait(progress, target) {
  369. return new Promise(function (resolve, reject) {
  370. let n = 0;
  371. (function tick() {
  372. agi({ action: "progress", progress: progress, target: target }).then(function (p) {
  373. if (p.stage === "failed") { reject(new Error(p.error || "failed")); }
  374. else if (p.completed && p.exists) { resolve(p); }
  375. else if (++n > 300) { reject(new Error("timeout")); }
  376. else { setTimeout(tick, 500); }
  377. }, reject);
  378. })();
  379. });
  380. }
  381. return Promise.all([
  382. agi({ action: "proxy", src: dir + "/green.mkv", kind: "video", height: 240 }),
  383. agi({ action: "proxy", src: dir + "/tone.wav", kind: "audio", height: 0 })
  384. ]).then(function (r) {
  385. return Promise.all(r.map(function (x) {
  386. if (!x.ok) { throw new Error(x.error || "proxy rejected"); }
  387. return x.ready ? x : wait(x.progress, x.vpath).then(function () { return x; });
  388. }));
  389. }).then(function (r) { return r.map(function (x) { return x.vpath; }); });
  390. }, MEDIA_DIR);
  391. const p240 = stream(probe(await download(page, base, jobs[0], path.join(work, "p240.mp4"))), "video");
  392. if (!p240 || p240.codec_name !== "h264" || p240.height > 240) { h.fail("240p proxy is " + JSON.stringify(p240 && [p240.codec_name, p240.height])); }
  393. const paud = stream(probe(await download(page, base, jobs[1], path.join(work, "paudio.m4a"))), "audio");
  394. if (!paud || paud.codec_name !== "aac") { h.fail("audio proxy is not AAC: " + (paud && paud.codec_name)); }
  395. h.ok("makeProxy: video capped to " + p240.height + "p H.264, audio proxy AAC");
  396. /* ---- 6. cancel ---- */
  397. await page.evaluate(function (dir) {
  398. CS.newProject({ name: "Cancel", width: 1920, height: 1080, fps: 30 });
  399. CS.player.applyProjectSize();
  400. const m = CS.media.addFromVpath(dir + "/red.mp4", "red.mp4");
  401. window.__long = m;
  402. }, MEDIA_DIR);
  403. await page.waitForFunction(function () { return __long.probed && __long.proxyState !== "pending"; }, null, { timeout: 60000 });
  404. await page.evaluate(function () {
  405. for (let i = 0; i < 40; i++) {
  406. const c = CS.addClipToTimeline(__long, "V1", i * 3);
  407. CS.effects.applyToClip(c, "grain");
  408. }
  409. CS.commit("long timeline");
  410. });
  411. const cancelsBefore = countAction(log, "cancel");
  412. await page.click("#btn-export");
  413. await page.waitForSelector(".modal .modal-title");
  414. await page.locator(".modal-row", { hasText: "Filename" }).locator("input").fill("cancelled");
  415. await page.click(".modal-btn.primary");
  416. await page.waitForFunction(function () { return CS.exporter.job && CS.exporter.job.progress; }, null, { timeout: 120000 });
  417. await page.waitForTimeout(1500);
  418. await page.locator(".modal-btn", { hasText: "Cancel" }).click();
  419. await page.waitForFunction(function () { return CS.exporter.job === null; }, null, { timeout: 30000 });
  420. await page.waitForTimeout(3000);
  421. if (countAction(log, "cancel") <= cancelsBefore) { h.fail("Cancel did not send a cancel request to the server"); }
  422. if (await exists(page, base, EXPORT_DIR + "/cancelled.mp4")) { h.fail("cancelled render left an output file behind"); }
  423. h.ok("cancel stops the server render and leaves no output file");
  424. /* ---- 7. spies: nothing recorded in the browser ---- */
  425. const spy = await page.evaluate(function () {
  426. return { ctor: window.__mr.ctor, recorder: window.__recorderCalls, server: window.__serverCalls };
  427. });
  428. const renders = countAction(log, "render");
  429. if (spy.recorder !== 0) { h.fail("CS.exporter.startRecorder ran " + spy.recorder + " time(s) during server exports"); }
  430. if (spy.ctor !== 0) { h.fail("MediaRecorder was constructed " + spy.ctor + " time(s) during server exports"); }
  431. if (spy.server !== renders) { h.fail("startServer ran " + spy.server + " times but the server saw " + renders + " render requests"); }
  432. const videoUploads = log.uploads.filter(function (p) { return p.indexOf("/Exports") !== -1; });
  433. if (videoUploads.length) { h.fail("something was uploaded into the Exports folder from the browser: " + videoUploads.join(", ")); }
  434. const flow = ["jobdir", "render", "progress", "cleanup"].map(function (k) { return k + " x" + countAction(log, k); }).join(", ");
  435. h.ok(spy.server + " exports = " + renders + " server render jobs (" + flow + "); MediaRecorder constructed " + spy.ctor + "x, startRecorder " + spy.recorder + "x");
  436. /* ---- 8. control: force the browser path and make sure the spies notice ---- */
  437. await page.evaluate(function () {
  438. CS._forceStandalone = true;
  439. CS.newProject({ name: "Control", width: 320, height: 180, fps: 30 });
  440. CS.player.applyProjectSize();
  441. });
  442. await page.evaluate(function () {
  443. CS.addClipToTimeline(CS.project.media.length ? CS.project.media[0] : __long, "V1", 0);
  444. }).catch(function () {});
  445. const ctl = await page.evaluate(function () { return CS.exporter.useServer(); });
  446. if (ctl) { h.fail("control: useServer() still true with standalone forced"); }
  447. await page.evaluate(function () {
  448. const m = CS.media.register({ name: "ctl.png", type: "image", blobUrl: (function () {
  449. const c = document.createElement("canvas"); c.width = 32; c.height = 32;
  450. const x = c.getContext("2d"); x.fillStyle = "#f00"; x.fillRect(0, 0, 32, 32);
  451. return c.toDataURL("image/png");
  452. })() });
  453. CS.addClipToTimeline(m, "V1", 0);
  454. CS.commit("control");
  455. });
  456. await page.click("#btn-export");
  457. await page.waitForSelector(".modal .modal-title");
  458. const ctlDlg = await page.evaluate(function () {
  459. return { formats: Array.from(document.querySelector(".modal-row select").options).map(function (o) { return o.value; }),
  460. text: document.querySelector(".modal-body").textContent };
  461. });
  462. if (ctlDlg.formats.join(",") !== "webm" || ctlDlg.text.indexOf("Keep this window visible") === -1) {
  463. h.fail("control: forced-standalone dialog is not the browser-recording one: " + ctlDlg.formats.join(","));
  464. }
  465. await page.click(".modal-btn.primary");
  466. await page.waitForFunction(function () { return window.__mr.ctor > 0; }, null, { timeout: 15000 });
  467. const ctlSpy = await page.evaluate(function () { return { ctor: window.__mr.ctor, recorder: window.__recorderCalls }; });
  468. await page.evaluate(function () { try { CS.exporter.cancel(); } catch (e) { /* stopping is best effort */ } });
  469. if (ctlSpy.recorder < 1) { h.fail("control: startRecorder spy did not fire on the browser path"); }
  470. h.ok("control: browser path is detectable (MediaRecorder constructed " + ctlSpy.ctor + "x, startRecorder " + ctlSpy.recorder + "x)");
  471. fs.rmSync(work, { recursive: true, force: true });
  472. });