interaction.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. /*
  2. Iteration 3 tests: preview drag/resize overlay, auto track creation,
  3. Pixel Studio (.pxs) and Audio Studio (.asproj) imports.
  4. */
  5. "use strict";
  6. const { ok, fail, run } = require("../lib/harness");
  7. run("INTERACTION", async (page, browser) => {
  8. /* ---- seed one real video + audio ---- */
  9. await page.evaluate(async () => {
  10. function makeVideo(seconds, hue) {
  11. return new Promise((resolve) => {
  12. const cv = document.createElement("canvas");
  13. cv.width = 320; cv.height = 180;
  14. const ctx = cv.getContext("2d");
  15. const stream = cv.captureStream(15);
  16. const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
  17. const chunks = [];
  18. rec.ondataavailable = e => e.data.size && chunks.push(e.data);
  19. rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
  20. const t0 = performance.now();
  21. (function draw() {
  22. ctx.fillStyle = `hsl(${hue}, 65%, 45%)`;
  23. ctx.fillRect(0, 0, 320, 180);
  24. if ((performance.now() - t0) / 1000 < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
  25. })();
  26. rec.start(200);
  27. });
  28. }
  29. window.makeWavDataUrl = function (seconds, freq) {
  30. const sr = 22050, n = Math.floor(sr * seconds);
  31. const buf = new ArrayBuffer(44 + n * 2);
  32. const dv = new DataView(buf);
  33. function ws(o, s) { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); }
  34. ws(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); ws(8, "WAVEfmt ");
  35. dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
  36. dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true);
  37. dv.setUint16(32, 2, true); dv.setUint16(34, 16, true);
  38. ws(36, "data"); dv.setUint32(40, n * 2, true);
  39. for (let i = 0; i < n; i++) {
  40. dv.setInt16(44 + i * 2, Math.sin(2 * Math.PI * freq * i / sr) * 12000, true);
  41. }
  42. let bin = "";
  43. const u8 = new Uint8Array(buf);
  44. for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]);
  45. return "data:audio/wav;base64," + btoa(bin);
  46. };
  47. const v = await makeVideo(3, 210);
  48. window.__vid = CS.media.register({ name: "Base.webm", blobUrl: URL.createObjectURL(v), type: "video" });
  49. const wavResp = await fetch(window.makeWavDataUrl(3, 220));
  50. window.__aud = CS.media.register({ name: "Tone.wav", blobUrl: URL.createObjectURL(await wavResp.blob()), type: "audio" });
  51. });
  52. await page.waitForFunction(() => window.__vid.probed && window.__aud.probed, { timeout: 30000 });
  53. await page.evaluate(() => {
  54. window.__vclip = CS.addClipToTimeline(window.__vid, "V1", 0);
  55. window.__aclip = CS.addClipToTimeline(window.__aud, "A1", 0);
  56. CS.commit("seed");
  57. CS.selectClip(window.__vclip.id);
  58. CS.player.seek(1.0);
  59. });
  60. await page.waitForTimeout(600);
  61. /* ---- 1. preview overlay shows for selected clip ---- */
  62. const overlayBox = await page.evaluate(() => {
  63. const box = CS.previewctl.clipBox(window.__vclip);
  64. const ov = document.getElementById("preview-overlay");
  65. return { box: !!box, w: ov.clientWidth, h: ov.clientHeight };
  66. });
  67. if (!overlayBox.box || overlayBox.w < 100) fail("overlay not sized/positioned: " + JSON.stringify(overlayBox));
  68. ok("preview overlay active over canvas");
  69. /* ---- 2. drag clip in preview moves it ---- */
  70. const ovEl = page.locator("#preview-overlay");
  71. const ovRect = await ovEl.boundingBox();
  72. const cx = ovRect.x + ovRect.width / 2;
  73. const cy = ovRect.y + ovRect.height / 2;
  74. await page.mouse.move(cx, cy);
  75. await page.mouse.down();
  76. await page.mouse.move(cx + 60, cy + 30, { steps: 6 });
  77. await page.mouse.up();
  78. const moved = await page.evaluate(() => ({ x: window.__vclip.props.x, y: window.__vclip.props.y }));
  79. if (moved.x < 30 || moved.y < 15) fail("preview drag did not move clip: " + JSON.stringify(moved));
  80. ok(`preview drag moved clip to x=${moved.x}, y=${moved.y}`);
  81. /* ---- 3. corner handle resize changes scale ---- */
  82. await page.evaluate(() => {
  83. // bring the clip back fully on-screen so its handles are grabbable
  84. window.__vclip.props.x = 0;
  85. window.__vclip.props.y = 0;
  86. window.__vclip.props.scale = 60;
  87. CS.player.render();
  88. });
  89. const corner = await page.evaluate(() => {
  90. const box = CS.previewctl.clipBox(window.__vclip);
  91. const pts = CS.previewctl.corners(box);
  92. const rect = CS.player.canvas.getBoundingClientRect();
  93. const s = rect.width / CS.project.settings.width;
  94. return { x: rect.left + pts[2].x * s, y: rect.top + pts[2].y * s };
  95. });
  96. await page.mouse.move(corner.x, corner.y);
  97. await page.mouse.down();
  98. await page.mouse.move(corner.x - 80, corner.y - 45, { steps: 6 });
  99. await page.mouse.up();
  100. const scaled = await page.evaluate(() => window.__vclip.props.scale);
  101. if (scaled >= 60) fail("corner drag did not shrink clip: scale=" + scaled);
  102. ok(`corner resize scaled clip to ${scaled}%`);
  103. /* ---- 4. click other clip in preview selects it, empty deselects ---- */
  104. await page.evaluate(() => {
  105. window.__vclip.props.x = 0; window.__vclip.props.y = 0; window.__vclip.props.scale = 40;
  106. CS.player.render();
  107. });
  108. await page.mouse.click(ovRect.x + 20, ovRect.y + 20); // outside 40% box
  109. const sel = await page.evaluate(() => CS.state.selectedClipId);
  110. if (sel !== null) fail("click on empty preview did not deselect: " + sel);
  111. await page.mouse.click(cx, cy); // inside box again
  112. const sel2 = await page.evaluate(() => CS.state.selectedClipId);
  113. if (sel2 !== (await page.evaluate(() => window.__vclip.id))) fail("click on clip did not select it");
  114. ok("preview click selects / deselects clips");
  115. /* ---- 5. dragging audio clip below lanes creates a new audio track ---- */
  116. const beforeTracks = await page.evaluate(() => CS.project.tracks.length);
  117. const aBox = await page.locator(`.tl-clip[data-clip-id="${await page.evaluate(() => window.__aclip.id)}"]`).boundingBox();
  118. const tlRect = await page.locator("#tl-tracks").boundingBox();
  119. await page.mouse.move(aBox.x + aBox.width / 2, aBox.y + aBox.height / 2);
  120. await page.mouse.down();
  121. await page.mouse.move(aBox.x + aBox.width / 2 + 10, tlRect.y + tlRect.height + 30, { steps: 6 });
  122. await page.mouse.up();
  123. const trackInfo = await page.evaluate(() => ({
  124. n: CS.project.tracks.length,
  125. clipTrack: CS.getClip(window.__aclip.id).trackId,
  126. audioTracks: CS.project.tracks.filter(t => t.kind === "audio").length
  127. }));
  128. if (trackInfo.n !== beforeTracks + 1) fail("no new track created: " + JSON.stringify(trackInfo));
  129. if (trackInfo.clipTrack !== "A3") fail("clip not moved to new track: " + trackInfo.clipTrack);
  130. ok(`drag below lanes created ${trackInfo.clipTrack} (${trackInfo.audioTracks} audio tracks now)`);
  131. /* ---- 6. .pxs import (raster + text layers, blend/opacity) ---- */
  132. await page.evaluate(() => {
  133. const lc = document.createElement("canvas");
  134. lc.width = 400; lc.height = 300;
  135. const lctx = lc.getContext("2d");
  136. lctx.fillStyle = "#c62828";
  137. lctx.fillRect(0, 0, 400, 300);
  138. const pxs = {
  139. app: "PixelStudio", version: 1, width: 400, height: 300, active: 0,
  140. layers: [
  141. { name: "bg", visible: true, opacity: 1, blend: "source-over", type: "raster", data: lc.toDataURL("image/png") },
  142. { name: "txt", visible: true, opacity: 1, blend: "source-over", type: "text",
  143. text: { content: "Hi", x: 20, y: 20, size: 60, color: "#ffffff", font: "Arial", bold: true, italic: false } },
  144. { name: "hidden", visible: false, opacity: 1, blend: "source-over", type: "raster", data: lc.toDataURL("image/png") }
  145. ]
  146. };
  147. const blob = new Blob([JSON.stringify(pxs)], { type: "application/json" });
  148. window.__pxs = CS.media.register({
  149. name: "Poster.pxs", blobUrl: URL.createObjectURL(blob), type: "image", srcKind: "pxs"
  150. });
  151. });
  152. await page.waitForFunction(() => window.__pxs.probed, { timeout: 15000 });
  153. const pxsInfo = await page.evaluate(() => ({
  154. offline: window.__pxs.offline, w: window.__pxs.width, h: window.__pxs.height,
  155. thumbs: window.__pxs.thumbs.length, composite: !!window.__pxs.compositeUrl
  156. }));
  157. if (pxsInfo.offline) fail(".pxs marked offline");
  158. if (pxsInfo.w !== 400 || pxsInfo.h !== 300) fail(".pxs dimensions wrong: " + JSON.stringify(pxsInfo));
  159. if (!pxsInfo.thumbs || !pxsInfo.composite) fail(".pxs not composited");
  160. // place on timeline and verify the red pixel shows
  161. await page.evaluate(() => {
  162. CS.project.clips = CS.project.clips.filter(c => c.trackId !== "V1");
  163. const c = CS.addClipToTimeline(window.__pxs, "V1", 0);
  164. c.props.crop = "fill";
  165. CS.commit("pxs clip");
  166. CS.player.seek(0.5);
  167. });
  168. await page.waitForTimeout(400);
  169. const pxsPx = await page.evaluate(() => {
  170. const cv = document.getElementById("preview-canvas");
  171. const d = cv.getContext("2d").getImageData(Math.floor(cv.width * 0.7), Math.floor(cv.height * 0.7), 1, 1).data;
  172. return [d[0], d[1], d[2]];
  173. });
  174. if (!(pxsPx[0] > 140 && pxsPx[1] < 90)) fail(".pxs frame not red: " + pxsPx);
  175. ok(`.pxs imported, composited 400x300, renders red (rgb ${pxsPx})`);
  176. /* ---- 7. .asproj with rendered mixdown ---- */
  177. await page.evaluate(() => {
  178. const asproj = { app: "AudioStudio", version: 1, name: "Mix", mixdown: window.makeWavDataUrl(2, 440) };
  179. const blob = new Blob([JSON.stringify(asproj)], { type: "application/json" });
  180. window.__as1 = CS.media.register({
  181. name: "Song.asproj", blobUrl: URL.createObjectURL(blob), type: "audio", srcKind: "asproj"
  182. });
  183. });
  184. await page.waitForFunction(() => window.__as1.probed, { timeout: 15000 });
  185. const as1 = await page.evaluate(() => ({
  186. offline: window.__as1.offline, dur: window.__as1.duration, peaks: (window.__as1.peaks || []).length
  187. }));
  188. if (as1.offline) fail("mixdown .asproj offline");
  189. if (!(as1.dur > 1.8 && as1.dur < 2.2)) fail("mixdown duration wrong: " + as1.dur);
  190. ok(`.asproj mixdown imported (${as1.dur.toFixed(2)}s, ${as1.peaks} peaks)`);
  191. /* ---- 8. .asproj with tracks/clips mixed offline ---- */
  192. await page.evaluate(() => {
  193. const asproj = {
  194. app: "AudioStudio", version: 1,
  195. tracks: [
  196. { name: "T1", volume: 1, clips: [{ src: window.makeWavDataUrl(1.5, 330), start: 0 }] },
  197. { name: "T2", volume: 0.6, clips: [{ src: window.makeWavDataUrl(1.5, 550), start: 1.0 }] }
  198. ]
  199. };
  200. const blob = new Blob([JSON.stringify(asproj)], { type: "application/json" });
  201. window.__as2 = CS.media.register({
  202. name: "Session.asproj", blobUrl: URL.createObjectURL(blob), type: "audio", srcKind: "asproj"
  203. });
  204. });
  205. await page.waitForFunction(() => window.__as2.probed, { timeout: 20000 });
  206. const as2 = await page.evaluate(() => ({
  207. offline: window.__as2.offline, dur: window.__as2.duration, peaks: (window.__as2.peaks || []).length
  208. }));
  209. if (as2.offline) fail("track-mix .asproj offline");
  210. if (!(as2.dur > 2.3 && as2.dur < 2.7)) fail("mixed duration wrong (want ~2.5): " + as2.dur);
  211. ok(`.asproj track mix rendered offline (${as2.dur.toFixed(2)}s, ${as2.peaks} peaks)`);
  212. /* ---- 9. audio playback of imported asproj on the timeline ---- */
  213. await page.evaluate(() => {
  214. CS.addClipToTimeline(window.__as1, "A1", 0);
  215. CS.commit("asproj clip");
  216. });
  217. await page.click("#btn-play");
  218. await page.waitForTimeout(900);
  219. const playing = await page.evaluate(() => CS.state.playing && CS.state.playhead > 0.4);
  220. if (!playing) fail("timeline with imported media does not play");
  221. await page.click("#btn-play");
  222. ok("imported project media plays on the timeline");
  223. });