functional.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /*
  2. Functional test: generate real webm/wav media in the browser, import
  3. them into Cine Studio, then exercise playback, editing (split/undo/
  4. drag), project serialization round-trip and the WebM export pipeline.
  5. */
  6. "use strict";
  7. const { ok, fail, run } = require("../lib/harness");
  8. run("FUNCTIONAL", async (page, browser) => {
  9. /* ---- 1. generate + import real media ---- */
  10. await page.evaluate(async () => {
  11. function makeVideo(name, seconds, hue) {
  12. return new Promise((resolve) => {
  13. const cv = document.createElement("canvas");
  14. cv.width = 320; cv.height = 180;
  15. const ctx = cv.getContext("2d");
  16. const stream = cv.captureStream(15);
  17. const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
  18. const chunks = [];
  19. rec.ondataavailable = e => e.data.size && chunks.push(e.data);
  20. rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
  21. const t0 = performance.now();
  22. (function draw() {
  23. const t = (performance.now() - t0) / 1000;
  24. ctx.fillStyle = `hsl(${hue}, 60%, 40%)`;
  25. ctx.fillRect(0, 0, 320, 180);
  26. ctx.fillStyle = "#fff";
  27. ctx.fillRect((t * 60) % 320, 60, 40, 60);
  28. if (t < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
  29. })();
  30. rec.start(200);
  31. });
  32. }
  33. function makeWav(seconds) {
  34. const sr = 22050, n = sr * seconds;
  35. const buf = new ArrayBuffer(44 + n * 2);
  36. const dv = new DataView(buf);
  37. function ws(o, s) { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); }
  38. ws(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); ws(8, "WAVEfmt ");
  39. dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
  40. dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true);
  41. dv.setUint16(32, 2, true); dv.setUint16(34, 16, true);
  42. ws(36, "data"); dv.setUint32(40, n * 2, true);
  43. for (let i = 0; i < n; i++) {
  44. dv.setInt16(44 + i * 2, Math.sin(2 * Math.PI * 330 * i / sr) * 12000, true);
  45. }
  46. return new Blob([buf], { type: "audio/wav" });
  47. }
  48. const v1 = await makeVideo("ClipA.webm", 3, 200);
  49. const v2 = await makeVideo("ClipB.webm", 2.5, 30);
  50. window.__testMedia = {
  51. v1: CS.media.register({ name: "ClipA.webm", blobUrl: URL.createObjectURL(v1), type: "video" }),
  52. v2: CS.media.register({ name: "ClipB.webm", blobUrl: URL.createObjectURL(v2), type: "video" }),
  53. a1: CS.media.register({ name: "Tone.wav", blobUrl: URL.createObjectURL(makeWav(4)), type: "audio" })
  54. };
  55. });
  56. await page.waitForFunction(() => {
  57. const m = window.__testMedia;
  58. return m.v1.probed && m.v2.probed && m.a1.probed;
  59. }, { timeout: 30000 });
  60. const probe = await page.evaluate(() => {
  61. const m = window.__testMedia;
  62. return {
  63. d1: m.v1.duration, d2: m.v2.duration, da: m.a1.duration,
  64. thumbs: m.v1.thumbs.length, peaks: (m.a1.peaks || []).length,
  65. offline: m.v1.offline || m.v2.offline || m.a1.offline,
  66. binItems: document.querySelectorAll("#bin-grid .bin-item").length
  67. };
  68. });
  69. if (probe.offline) fail("media marked offline");
  70. if (!(probe.d1 > 2 && probe.d1 < 4.5)) fail("ClipA duration wrong: " + probe.d1);
  71. if (!(probe.da > 3.5 && probe.da < 4.5)) fail("wav duration wrong: " + probe.da);
  72. if (probe.thumbs < 1) fail("no video thumbnails");
  73. if (probe.peaks < 100) fail("no audio peaks");
  74. if (probe.binItems !== 3) fail("bin should show 3 items");
  75. ok(`probe: video ${probe.d1.toFixed(2)}s + ${probe.d2.toFixed(2)}s, wav ${probe.da.toFixed(2)}s, ${probe.thumbs} thumbs, ${probe.peaks} peaks`);
  76. /* ---- 2. build a timeline ---- */
  77. await page.evaluate(() => {
  78. const m = window.__testMedia;
  79. const c1 = CS.addClipToTimeline(m.v1, "V1", 0);
  80. CS.addClipToTimeline(m.v2, "V1", CS.clipDuration(c1));
  81. CS.addClipToTimeline(m.a1, "A1", 0);
  82. CS.commit("Test Build");
  83. });
  84. const clipCount = await page.evaluate(() => CS.project.clips.length);
  85. if (clipCount !== 3) fail("expected 3 clips, got " + clipCount);
  86. ok("3 clips placed on timeline");
  87. /* ---- 3. playback ---- */
  88. await page.click("#btn-play");
  89. await page.waitForTimeout(1600);
  90. const playState = await page.evaluate(() => {
  91. const cv = document.getElementById("preview-canvas");
  92. const ctx = cv.getContext("2d");
  93. const px = ctx.getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
  94. return { playhead: CS.state.playhead, playing: CS.state.playing, px: Array.from(px) };
  95. });
  96. if (!playState.playing) fail("player is not playing");
  97. if (playState.playhead < 1.0) fail("playhead did not advance: " + playState.playhead);
  98. if (playState.px[0] + playState.px[1] + playState.px[2] < 20) fail("preview canvas looks black during playback");
  99. ok(`playback: playhead=${playState.playhead.toFixed(2)}s, center pixel rgb(${playState.px.slice(0, 3)})`);
  100. await page.click("#btn-play"); // pause
  101. /* ---- 4. split / undo / redo ---- */
  102. const split = await page.evaluate(() => {
  103. CS.player.seek(1.2);
  104. CS.selectClip(CS.project.clips[0].id);
  105. const before = CS.project.clips.length;
  106. CS.splitAtPlayhead();
  107. const after = CS.project.clips.length;
  108. CS.undo();
  109. const undone = CS.project.clips.length;
  110. CS.redo();
  111. const redone = CS.project.clips.length;
  112. return { before, after, undone, redone };
  113. });
  114. if (split.after !== split.before + 1) fail("split did not add a clip");
  115. if (split.undone !== split.before) fail("undo did not restore");
  116. if (split.redone !== split.before + 1) fail("redo did not reapply");
  117. ok(`split/undo/redo: ${split.before} -> ${split.after} -> ${split.undone} -> ${split.redone}`);
  118. /* ---- 5. drag a clip with the mouse ---- */
  119. const dragged = await page.evaluate(() => {
  120. window.__dragTarget = CS.project.clips.find(c => c.trackId === "A1");
  121. return { id: window.__dragTarget.id, start: window.__dragTarget.start };
  122. });
  123. const box = await page.locator(`.tl-clip[data-clip-id="${dragged.id}"]`).boundingBox();
  124. if (!box) fail("audio clip element not found");
  125. await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
  126. await page.mouse.down();
  127. await page.mouse.move(box.x + box.width / 2 + 120, box.y + box.height / 2, { steps: 8 });
  128. await page.mouse.up();
  129. const newStart = await page.evaluate(() => CS.getClip(window.__dragTarget.id).start);
  130. if (Math.abs(newStart - dragged.start) < 0.5) fail("mouse drag did not move the clip: " + newStart);
  131. ok(`mouse drag moved audio clip ${dragged.start.toFixed(2)}s -> ${newStart.toFixed(2)}s`);
  132. /* ---- 6. project serialization round trip ---- */
  133. const roundTrip = await page.evaluate(() => {
  134. const json = CS.fileio.serializeProject();
  135. const beforeClips = CS.project.clips.length;
  136. const beforeTracks = CS.project.tracks.length;
  137. CS.fileio.loadProject(JSON.parse(json), "", "roundtrip.cine");
  138. return {
  139. beforeClips, beforeTracks,
  140. afterClips: CS.project.clips.length,
  141. afterTracks: CS.project.tracks.length,
  142. name: CS.project.name,
  143. parses: !!json.length
  144. };
  145. });
  146. if (roundTrip.afterClips !== roundTrip.beforeClips) fail("clips lost in round trip");
  147. if (roundTrip.afterTracks !== roundTrip.beforeTracks) fail("tracks lost in round trip");
  148. ok(`project round trip: ${roundTrip.afterClips} clips, ${roundTrip.afterTracks} tracks preserved`);
  149. /* ---- 7. export to WebM (fresh short timeline) ---- */
  150. await page.evaluate(() => {
  151. const m = window.__testMedia;
  152. CS.newProject({ name: "ExportTest", width: 640, height: 360, fps: 30 });
  153. CS.player.applyProjectSize();
  154. // media pool was cleared by newProject: re-register the blobs
  155. const v = CS.media.register({ name: m.v1.name, blobUrl: m.v1.blobUrl, type: "video" });
  156. const a = CS.media.register({ name: m.a1.name, blobUrl: m.a1.blobUrl, type: "audio" });
  157. window.__exportSeed = { v, a };
  158. });
  159. await page.waitForFunction(() => window.__exportSeed.v.probed && window.__exportSeed.a.probed, { timeout: 20000 });
  160. await page.evaluate(() => {
  161. const s = window.__exportSeed;
  162. const c = CS.addClipToTimeline(s.v, "V1", 0);
  163. c.out = Math.min(c.out, 2.5);
  164. const ca = CS.addClipToTimeline(s.a, "A1", 0);
  165. ca.out = Math.min(ca.out, 2.5);
  166. CS.commit("Export Seed");
  167. window.__exportSize = -1;
  168. CS.fileio.downloadBlob = function (blob) { window.__exportSize = blob.size; };
  169. CS.exporter.start({ base: "test_export", format: "webm", destDir: "", toDevice: true });
  170. });
  171. await page.waitForFunction(() => window.__exportSize >= 0, { timeout: 30000 });
  172. const exportSize = await page.evaluate(() => window.__exportSize);
  173. if (exportSize < 5000) fail("export produced a suspiciously small file: " + exportSize);
  174. ok(`export produced WebM of ${exportSize} bytes`);
  175. });