features.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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. /* ---- effects ---- */
  44. await page.evaluate(() => {
  45. const c = CS.addClipToTimeline(window.__m, "V1", 0);
  46. window.__clip = c;
  47. CS.commit("seed");
  48. CS.selectClip(c.id);
  49. CS.player.seek(1.0);
  50. });
  51. await page.waitForTimeout(700);
  52. const base = await samplePx();
  53. if (base[0] + base[1] + base[2] < 20) fail("baseline frame is black");
  54. await page.evaluate(() => CS.effects.applyToClip(window.__clip, "bw"));
  55. await page.waitForTimeout(250);
  56. const bw = await samplePx();
  57. if (Math.abs(bw[0] - bw[1]) > 6 || Math.abs(bw[1] - bw[2]) > 6) fail("B&W effect not applied: " + bw);
  58. ok(`B&W effect desaturates (rgb ${bw})`);
  59. await page.evaluate(() => {
  60. CS.effects.removeFromClip(window.__clip, "bw");
  61. CS.effects.applyToClip(window.__clip, "invert");
  62. });
  63. await page.waitForTimeout(250);
  64. const inv = await samplePx();
  65. if (Math.abs(inv[0] - base[0]) < 30 && Math.abs(inv[2] - base[2]) < 30) fail("invert changed nothing: " + inv + " vs " + base);
  66. ok(`invert flips colors (rgb ${base} -> ${inv})`);
  67. await page.evaluate(() => {
  68. CS.effects.removeFromClip(window.__clip, "invert");
  69. CS.effects.applyToClip(window.__clip, "fadein"); // 1s default
  70. CS.player.seek(0.06);
  71. });
  72. await page.waitForTimeout(500);
  73. const faded = await samplePx();
  74. if (faded[0] + faded[1] + faded[2] > base[0] + base[1] + base[2] * 0.5) fail("fade-in start not dark: " + faded);
  75. await page.evaluate(() => CS.player.seek(2.0));
  76. await page.waitForTimeout(400);
  77. const unfaded = await samplePx();
  78. if (unfaded[0] + unfaded[1] + unfaded[2] < 20) fail("frame after fade window is dark");
  79. ok(`fade-in ramps alpha (start rgb ${faded}, later rgb ${unfaded})`);
  80. await page.evaluate(() => {
  81. CS.effects.applyToClip(window.__clip, "pixelate");
  82. CS.effects.applyToClip(window.__clip, "vignette");
  83. CS.effects.applyToClip(window.__clip, "grain");
  84. CS.player.seek(1.5);
  85. });
  86. await page.waitForTimeout(300);
  87. const fxCount = await page.evaluate(() => window.__clip.props.effects.length);
  88. if (fxCount !== 4) fail("expected 4 effects on clip, got " + fxCount);
  89. const fxBadge = await page.evaluate(() => !!document.querySelector(".tl-clip .clip-fx"));
  90. if (!fxBadge) fail("fx badge missing on timeline clip");
  91. ok("pixelate + vignette + grain stack renders, fx badge shown");
  92. /* ---- undo restores effect stack ---- */
  93. const undoCount = await page.evaluate(() => { CS.undo(); return window.__clip ? CS.getClip(window.__clip.id).props.effects.length : -1; });
  94. if (undoCount !== 3) fail("undo did not pop effect stack: " + undoCount);
  95. await page.evaluate(() => CS.redo());
  96. ok("undo/redo covers effects");
  97. /* ---- titles ---- */
  98. await page.evaluate(() => {
  99. CS.player.seek(1.5);
  100. CS.titles.insertPreset("title");
  101. const t = CS.selectedClip();
  102. t.props.text.content = "BIG";
  103. t.props.text.size = 400;
  104. CS.titles.invalidate(t);
  105. window.__title = t;
  106. CS.player.render();
  107. });
  108. await page.waitForTimeout(300);
  109. const titleInfo = await page.evaluate(() => ({
  110. kind: window.__title.kind,
  111. track: window.__title.trackId,
  112. tracks: CS.project.tracks.filter(t => t.kind === "video").length
  113. }));
  114. if (titleInfo.kind !== "title") fail("title clip not created");
  115. if (titleInfo.track === "V1") fail("title landed on the busy V1 track");
  116. const titlePx = await samplePx();
  117. if (titlePx[0] < 180 || titlePx[1] < 180 || titlePx[2] < 180) fail("title text not visible at center: " + titlePx);
  118. ok(`title clip renders on ${titleInfo.track} (center rgb ${titlePx})`);
  119. /* ---- elements (color board) ---- */
  120. await page.evaluate(() => {
  121. CS.deleteSelectedClip(); // remove title again
  122. CS.player.seek(10);
  123. CS.titles.insertElement("red");
  124. CS.player.render();
  125. });
  126. await page.waitForTimeout(200);
  127. const redPx = await samplePx();
  128. if (!(redPx[0] > 130 && redPx[1] < 90)) fail("color element not red: " + redPx);
  129. await page.evaluate(() => { CS.deleteSelectedClip(); });
  130. ok(`color element renders (rgb ${redPx})`);
  131. /* ---- transitions ---- */
  132. await page.evaluate(() => {
  133. // second clip right after the first, dissolve into it
  134. const c2 = CS.addClipToTimeline(window.__m, "V1", CS.clipEnd(window.__clip));
  135. window.__clip2 = c2;
  136. CS.selectClip(c2.id);
  137. CS.transitions.applyToSelected("dissolve");
  138. window.__clip2 = CS.getClip(c2.id);
  139. });
  140. const trInfo = await page.evaluate(() => ({
  141. type: window.__clip2.props.transition && window.__clip2.props.transition.type,
  142. frozen: Object.keys(CS.transitions.frozenTargets(window.__clip2.start + 0.3)).length,
  143. marker: !!document.querySelector(".tl-clip .clip-tr")
  144. }));
  145. if (trInfo.type !== "dissolve") fail("transition not stored");
  146. if (trInfo.frozen !== 1) fail("frozen predecessor not detected");
  147. if (!trInfo.marker) fail("transition marker missing on clip");
  148. await page.evaluate(() => CS.player.seek(window.__clip2.start + 0.3));
  149. await page.waitForTimeout(600);
  150. const trPx = await samplePx();
  151. if (trPx[0] + trPx[1] + trPx[2] < 20) fail("transition window renders black: " + trPx);
  152. ok(`dissolve transition renders mid-window (rgb ${trPx})`);
  153. /* ---- filters panel + galleries render ---- */
  154. const galleries = await page.evaluate(() => {
  155. CS.panels.show("fx");
  156. const fx = document.querySelectorAll("#fx-grid .fx-card").length;
  157. CS.panels.show("titles");
  158. const ti = document.querySelectorAll("#titles-grid .fx-card").length;
  159. CS.panels.show("transitions");
  160. const tr = document.querySelectorAll("#transitions-grid .fx-card").length;
  161. CS.panels.show("elements");
  162. const el = document.querySelectorAll("#elements-grid .fx-card").length;
  163. CS.panels.show("filters");
  164. const fi = document.querySelectorAll("#filters-grid .fx-card").length;
  165. CS.panels.show("media");
  166. return { fx, ti, tr, el, fi };
  167. });
  168. if (galleries.fx < 10) fail("effects gallery incomplete: " + galleries.fx);
  169. if (galleries.ti < 4 || galleries.tr < 3 || galleries.el < 8 || galleries.fi < 5) {
  170. fail("gallery counts wrong: " + JSON.stringify(galleries));
  171. }
  172. ok(`galleries render (${galleries.fx} effects, ${galleries.ti} titles, ${galleries.tr} transitions, ${galleries.el} elements, ${galleries.fi} filters)`);
  173. /* ---- filter look via inspector.applyPreset ---- */
  174. await page.evaluate(() => {
  175. CS.selectClip(window.__clip.id);
  176. CS.inspector.applyPreset(CS.getClip(window.__clip.id), "mono");
  177. });
  178. const preset = await page.evaluate(() => CS.getClip(window.__clip.id).props.preset);
  179. if (preset !== "mono") fail("filter preset not applied");
  180. ok("filter look applies color preset");
  181. /* ---- list view toggle ---- */
  182. await page.click("#btn-bin-view");
  183. const listMode = await page.evaluate(() => document.getElementById("bin-grid").classList.contains("list-mode"));
  184. if (!listMode) fail("list view did not activate");
  185. await page.click("#btn-bin-view");
  186. ok("media bin list view toggles");
  187. /* ---- serialization round trip with generated clips ---- */
  188. const rt = await page.evaluate(() => {
  189. CS.player.seek(1.5);
  190. CS.titles.insertPreset("lowerthird");
  191. const before = CS.project.clips.length;
  192. const beforeTitles = CS.project.clips.filter(c => c.kind === "title").length;
  193. const json = CS.fileio.serializeProject();
  194. CS.fileio.loadProject(JSON.parse(json), "", "rt.cine");
  195. return {
  196. before, beforeTitles,
  197. after: CS.project.clips.length,
  198. afterTitles: CS.project.clips.filter(c => c.kind === "title").length,
  199. transition: CS.project.clips.some(c => c.props.transition && c.props.transition.type === "dissolve"),
  200. effects: CS.project.clips.some(c => (c.props.effects || []).length > 0)
  201. };
  202. });
  203. if (rt.after !== rt.before) fail("clips lost in round trip: " + JSON.stringify(rt));
  204. if (rt.afterTitles !== rt.beforeTitles) fail("title clips lost in round trip");
  205. if (!rt.transition) fail("transition lost in round trip");
  206. if (!rt.effects) fail("effects lost in round trip");
  207. ok("round trip preserves titles, transitions and effect stacks");
  208. });