features.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. /*
  2. Feature test for the second iteration: effects, titles, transitions,
  3. elements, filters, list view - verified end to end with real
  4. in-browser generated media and pixel sampling.
  5. */
  6. "use strict";
  7. const { ok, fail, run } = require("../lib/harness");
  8. run("FEATURE", async (page, browser) => {
  9. // traffic lights must be gone
  10. const hasTL = await page.evaluate(() => !!document.getElementById("traffic-lights"));
  11. if (hasTL) fail("traffic lights still present");
  12. ok("macOS traffic lights removed");
  13. /* ---- media: one real video clip ---- */
  14. await page.evaluate(async () => {
  15. function makeVideo(seconds, hue) {
  16. return new Promise((resolve) => {
  17. const cv = document.createElement("canvas");
  18. cv.width = 320; cv.height = 180;
  19. const ctx = cv.getContext("2d");
  20. const stream = cv.captureStream(15);
  21. const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
  22. const chunks = [];
  23. rec.ondataavailable = e => e.data.size && chunks.push(e.data);
  24. rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
  25. const t0 = performance.now();
  26. (function draw() {
  27. ctx.fillStyle = `hsl(${hue}, 65%, 45%)`;
  28. ctx.fillRect(0, 0, 320, 180);
  29. if ((performance.now() - t0) / 1000 < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
  30. })();
  31. rec.start(200);
  32. });
  33. }
  34. const blob = await makeVideo(3, 210);
  35. window.__m = CS.media.register({ name: "Base.webm", blobUrl: URL.createObjectURL(blob), type: "video" });
  36. });
  37. await page.waitForFunction(() => window.__m.probed, { timeout: 30000 });
  38. const samplePx = () => page.evaluate(() => {
  39. const cv = document.getElementById("preview-canvas");
  40. const d = cv.getContext("2d").getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
  41. return [d[0], d[1], d[2]];
  42. });
  43. // Sample until the frame satisfies `accept`, or the time is up (the last
  44. // sample is returned either way, so the caller's assertion reports a real
  45. // failure). A fixed sleep before sampling races the video decoder on a
  46. // slow CI runner.
  47. const sampleUntil = async (accept, timeoutMs = 8000) => {
  48. const deadline = Date.now() + timeoutMs;
  49. let px = await samplePx();
  50. while (!accept(px) && Date.now() < deadline) {
  51. await page.waitForTimeout(100);
  52. px = await samplePx();
  53. }
  54. return px;
  55. };
  56. const lit = (px) => px[0] + px[1] + px[2] >= 20;
  57. /* ---- effects ---- */
  58. await page.evaluate(() => {
  59. const c = CS.addClipToTimeline(window.__m, "V1", 0);
  60. window.__clip = c;
  61. CS.commit("seed");
  62. CS.selectClip(c.id);
  63. CS.player.seek(1.0);
  64. });
  65. const base = await sampleUntil(lit);
  66. if (base[0] + base[1] + base[2] < 20) fail("baseline frame is black");
  67. await page.evaluate(() => CS.effects.applyToClip(window.__clip, "bw"));
  68. const bw = await sampleUntil((px) => Math.abs(px[0] - px[1]) <= 6 && Math.abs(px[1] - px[2]) <= 6);
  69. if (Math.abs(bw[0] - bw[1]) > 6 || Math.abs(bw[1] - bw[2]) > 6) fail("B&W effect not applied: " + bw);
  70. ok(`B&W effect desaturates (rgb ${bw})`);
  71. await page.evaluate(() => {
  72. CS.effects.removeFromClip(window.__clip, "bw");
  73. CS.effects.applyToClip(window.__clip, "invert");
  74. });
  75. const inv = await sampleUntil((px) => Math.abs(px[0] - base[0]) >= 30 || Math.abs(px[2] - base[2]) >= 30);
  76. if (Math.abs(inv[0] - base[0]) < 30 && Math.abs(inv[2] - base[2]) < 30) fail("invert changed nothing: " + inv + " vs " + base);
  77. ok(`invert flips colors (rgb ${base} -> ${inv})`);
  78. await page.evaluate(() => {
  79. CS.effects.removeFromClip(window.__clip, "invert");
  80. CS.effects.applyToClip(window.__clip, "fadein"); // 1s default
  81. CS.player.seek(0.06);
  82. });
  83. const faded = await sampleUntil((px) => px[0] + px[1] + px[2] <= base[0] + base[1] + base[2] * 0.5);
  84. if (faded[0] + faded[1] + faded[2] > base[0] + base[1] + base[2] * 0.5) fail("fade-in start not dark: " + faded);
  85. await page.evaluate(() => CS.player.seek(2.0));
  86. const unfaded = await sampleUntil(lit);
  87. if (unfaded[0] + unfaded[1] + unfaded[2] < 20) fail("frame after fade window is dark");
  88. ok(`fade-in ramps alpha (start rgb ${faded}, later rgb ${unfaded})`);
  89. await page.evaluate(() => {
  90. CS.effects.applyToClip(window.__clip, "pixelate");
  91. CS.effects.applyToClip(window.__clip, "vignette");
  92. CS.effects.applyToClip(window.__clip, "grain");
  93. CS.player.seek(1.5);
  94. });
  95. await page.waitForTimeout(300);
  96. const fxCount = await page.evaluate(() => window.__clip.props.effects.length);
  97. if (fxCount !== 4) fail("expected 4 effects on clip, got " + fxCount);
  98. const fxBadge = await page.evaluate(() => !!document.querySelector(".tl-clip .clip-fx"));
  99. if (!fxBadge) fail("fx badge missing on timeline clip");
  100. ok("pixelate + vignette + grain stack renders, fx badge shown");
  101. /* ---- undo restores effect stack ---- */
  102. const undoCount = await page.evaluate(() => { CS.undo(); return window.__clip ? CS.getClip(window.__clip.id).props.effects.length : -1; });
  103. if (undoCount !== 3) fail("undo did not pop effect stack: " + undoCount);
  104. await page.evaluate(() => CS.redo());
  105. ok("undo/redo covers effects");
  106. /* ---- titles ---- */
  107. await page.evaluate(() => {
  108. CS.player.seek(1.5);
  109. CS.titles.insertPreset("title");
  110. const t = CS.selectedClip();
  111. t.props.text.content = "BIG";
  112. t.props.text.size = 400;
  113. CS.titles.invalidate(t);
  114. window.__title = t;
  115. CS.player.render();
  116. });
  117. await page.waitForTimeout(300);
  118. const titleInfo = await page.evaluate(() => ({
  119. kind: window.__title.kind,
  120. track: window.__title.trackId,
  121. tracks: CS.project.tracks.filter(t => t.kind === "video").length
  122. }));
  123. if (titleInfo.kind !== "title") fail("title clip not created");
  124. if (titleInfo.track === "V1") fail("title landed on the busy V1 track");
  125. const titlePx = await sampleUntil((px) => px[0] >= 180 && px[1] >= 180 && px[2] >= 180);
  126. if (titlePx[0] < 180 || titlePx[1] < 180 || titlePx[2] < 180) fail("title text not visible at center: " + titlePx);
  127. ok(`title clip renders on ${titleInfo.track} (center rgb ${titlePx})`);
  128. /* ---- elements (color board) ---- */
  129. await page.evaluate(() => {
  130. CS.deleteSelectedClip(); // remove title again
  131. CS.player.seek(10);
  132. CS.titles.insertElement("red");
  133. CS.player.render();
  134. });
  135. const redPx = await sampleUntil((px) => px[0] > 130 && px[1] < 90);
  136. if (!(redPx[0] > 130 && redPx[1] < 90)) fail("color element not red: " + redPx);
  137. await page.evaluate(() => { CS.deleteSelectedClip(); });
  138. ok(`color element renders (rgb ${redPx})`);
  139. /* ---- transitions ---- */
  140. await page.evaluate(() => {
  141. // second clip right after the first, dissolve into it
  142. const c2 = CS.addClipToTimeline(window.__m, "V1", CS.clipEnd(window.__clip));
  143. window.__clip2 = c2;
  144. CS.selectClip(c2.id);
  145. CS.transitions.applyToSelected("dissolve");
  146. window.__clip2 = CS.getClip(c2.id);
  147. });
  148. const trInfo = await page.evaluate(() => ({
  149. type: window.__clip2.props.transition && window.__clip2.props.transition.type,
  150. frozen: Object.keys(CS.transitions.frozenTargets(window.__clip2.start + 0.3)).length,
  151. marker: !!document.querySelector(".tl-clip .clip-tr")
  152. }));
  153. if (trInfo.type !== "dissolve") fail("transition not stored");
  154. if (trInfo.frozen !== 1) fail("frozen predecessor not detected");
  155. if (!trInfo.marker) fail("transition marker missing on clip");
  156. await page.evaluate(() => CS.player.seek(window.__clip2.start + 0.3));
  157. const trPx = await sampleUntil(lit);
  158. if (trPx[0] + trPx[1] + trPx[2] < 20) fail("transition window renders black: " + trPx);
  159. ok(`dissolve transition renders mid-window (rgb ${trPx})`);
  160. /* ---- filters panel + galleries render ---- */
  161. const galleries = await page.evaluate(() => {
  162. CS.panels.show("fx");
  163. const fx = document.querySelectorAll("#fx-grid .fx-card").length;
  164. CS.panels.show("titles");
  165. const ti = document.querySelectorAll("#titles-grid .fx-card").length;
  166. CS.panels.show("transitions");
  167. const tr = document.querySelectorAll("#transitions-grid .fx-card").length;
  168. CS.panels.show("elements");
  169. const el = document.querySelectorAll("#elements-grid .fx-card").length;
  170. CS.panels.show("filters");
  171. const fi = document.querySelectorAll("#filters-grid .fx-card").length;
  172. CS.panels.show("media");
  173. return { fx, ti, tr, el, fi };
  174. });
  175. if (galleries.fx < 10) fail("effects gallery incomplete: " + galleries.fx);
  176. if (galleries.ti < 4 || galleries.tr < 3 || galleries.el < 8 || galleries.fi < 5) {
  177. fail("gallery counts wrong: " + JSON.stringify(galleries));
  178. }
  179. ok(`galleries render (${galleries.fx} effects, ${galleries.ti} titles, ${galleries.tr} transitions, ${galleries.el} elements, ${galleries.fi} filters)`);
  180. /* ---- filter look via inspector.applyPreset ---- */
  181. await page.evaluate(() => {
  182. CS.selectClip(window.__clip.id);
  183. CS.inspector.applyPreset(CS.getClip(window.__clip.id), "mono");
  184. });
  185. const preset = await page.evaluate(() => CS.getClip(window.__clip.id).props.preset);
  186. if (preset !== "mono") fail("filter preset not applied");
  187. ok("filter look applies color preset");
  188. /* ---- list view toggle ---- */
  189. await page.click("#btn-bin-view");
  190. const listMode = await page.evaluate(() => document.getElementById("bin-grid").classList.contains("list-mode"));
  191. if (!listMode) fail("list view did not activate");
  192. await page.click("#btn-bin-view");
  193. ok("media bin list view toggles");
  194. /* ---- serialization round trip with generated clips ---- */
  195. const rt = await page.evaluate(() => {
  196. CS.player.seek(1.5);
  197. CS.titles.insertPreset("lowerthird");
  198. const before = CS.project.clips.length;
  199. const beforeTitles = CS.project.clips.filter(c => c.kind === "title").length;
  200. const json = CS.fileio.serializeProject();
  201. CS.fileio.loadProject(JSON.parse(json), "", "rt.cine");
  202. return {
  203. before, beforeTitles,
  204. after: CS.project.clips.length,
  205. afterTitles: CS.project.clips.filter(c => c.kind === "title").length,
  206. transition: CS.project.clips.some(c => c.props.transition && c.props.transition.type === "dissolve"),
  207. effects: CS.project.clips.some(c => (c.props.effects || []).length > 0)
  208. };
  209. });
  210. if (rt.after !== rt.before) fail("clips lost in round trip: " + JSON.stringify(rt));
  211. if (rt.afterTitles !== rt.beforeTitles) fail("title clips lost in round trip");
  212. if (!rt.transition) fail("transition lost in round trip");
  213. if (!rt.effects) fail("effects lost in round trip");
  214. ok("round trip preserves titles, transitions and effect stacks");
  215. });