editing.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. /*
  2. Iteration 4 tests: autosave/recovery, recents, copy/paste/duplicate,
  3. multi-select, ripple delete, markers, snap toggle, clip speed,
  4. blend modes, flip, JKL shuttle, loop, export frame, detach audio,
  5. audio track solo.
  6. */
  7. "use strict";
  8. const { ok, fail, run } = require("../lib/harness");
  9. run("EDITING", async (page, browser) => {
  10. await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} });
  11. /* ---- seed real media ---- */
  12. await page.evaluate(async () => {
  13. function makeVideo(seconds, hue) {
  14. return new Promise((resolve) => {
  15. const cv = document.createElement("canvas");
  16. cv.width = 320; cv.height = 180;
  17. const ctx = cv.getContext("2d");
  18. const stream = cv.captureStream(15);
  19. const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
  20. const chunks = [];
  21. rec.ondataavailable = e => e.data.size && chunks.push(e.data);
  22. rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
  23. const t0 = performance.now();
  24. (function draw() {
  25. ctx.fillStyle = `hsl(${hue}, 65%, 45%)`;
  26. ctx.fillRect(0, 0, 320, 180);
  27. if ((performance.now() - t0) / 1000 < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
  28. })();
  29. rec.start(200);
  30. });
  31. }
  32. const v = await makeVideo(3, 210);
  33. window.__vid = CS.media.register({ name: "Base.webm", blobUrl: URL.createObjectURL(v), type: "video" });
  34. // asymmetric test image: left red, right blue
  35. const ic = document.createElement("canvas");
  36. ic.width = 320; ic.height = 180;
  37. const ictx = ic.getContext("2d");
  38. ictx.fillStyle = "#c62828"; ictx.fillRect(0, 0, 160, 180);
  39. ictx.fillStyle = "#1e63c9"; ictx.fillRect(160, 0, 160, 180);
  40. window.__img = CS.media.register({ name: "Split.png", blobUrl: ic.toDataURL("image/png"), type: "image" });
  41. });
  42. await page.waitForFunction(() => window.__vid.probed && window.__img.probed, { timeout: 30000 });
  43. const px = (fx, fy) => page.evaluate(([fx, fy]) => {
  44. const cv = document.getElementById("preview-canvas");
  45. const d = cv.getContext("2d").getImageData(Math.floor(cv.width * fx), Math.floor(cv.height * fy), 1, 1).data;
  46. return [d[0], d[1], d[2]];
  47. }, [fx, fy]);
  48. /* ---- 1. copy / paste / duplicate / multi-select / ripple ---- */
  49. const editing = await page.evaluate(() => {
  50. const c1 = CS.addClipToTimeline(window.__vid, "V1", 0);
  51. const c2 = CS.addClipToTimeline(window.__vid, "V1", CS.clipDuration(c1));
  52. CS.commit("seed");
  53. window.__c1 = c1; window.__c2 = c2;
  54. // multi-select both
  55. CS.selectClip(c1.id);
  56. CS.toggleSelectClip(c2.id);
  57. const multi = CS.state.selectedClipIds.length;
  58. // copy + paste at playhead 10
  59. CS.copySelectedClips();
  60. CS.state.playhead = 10; //seek() clamps to timeline duration
  61. CS.pasteClipsAtPlayhead();
  62. const afterPaste = CS.project.clips.length;
  63. // duplicate the pasted pair
  64. CS.duplicateSelectedClips();
  65. const afterDup = CS.project.clips.length;
  66. // group delete the 4 new ones
  67. CS.deleteSelectedClip(); // deletes duplicated selection (2)
  68. CS.selectClip(CS.project.clips.filter(c => c.start >= 9)[0].id);
  69. CS.toggleSelectClip(CS.project.clips.filter(c => c.start >= 9)[1].id);
  70. CS.deleteSelectedClip();
  71. const afterCleanup = CS.project.clips.length;
  72. // ripple delete c1: c2 shifts to 0
  73. CS.selectClip(window.__c1.id);
  74. CS.rippleDeleteSelected();
  75. return {
  76. multi, afterPaste, afterDup, afterCleanup,
  77. c2start: CS.getClip(window.__c2.id).start,
  78. n: CS.project.clips.length
  79. };
  80. });
  81. if (editing.multi !== 2) fail("multi-select failed");
  82. if (editing.afterPaste !== 4) fail("paste failed: " + editing.afterPaste);
  83. if (editing.afterDup !== 6) fail("duplicate failed: " + editing.afterDup);
  84. if (editing.afterCleanup !== 2) fail("group delete failed: " + editing.afterCleanup);
  85. if (editing.n !== 1 || editing.c2start > 0.01) fail("ripple delete did not close gap: " + JSON.stringify(editing));
  86. ok(`multi-select/copy/paste/duplicate/group-delete/ripple all work (c2 rippled to ${editing.c2start})`);
  87. /* ---- 2. markers ---- */
  88. const markers = await page.evaluate(() => {
  89. CS.player.seek(1.0); CS.toggleMarkerAtPlayhead();
  90. CS.player.seek(2.0); CS.toggleMarkerAtPlayhead();
  91. CS.player.seek(0);
  92. CS.gotoMarker(1);
  93. const jumped = CS.state.playhead;
  94. CS.player.seek(2.0);
  95. CS.toggleMarkerAtPlayhead(); // remove marker at 2.0
  96. return { n: CS.project.markers.length, jumped };
  97. });
  98. if (Math.abs(markers.jumped - 1.0) > 0.02) fail("goto marker failed: " + markers.jumped);
  99. if (markers.n !== 1) fail("marker toggle-remove failed: " + markers.n);
  100. ok("markers: add, jump, remove");
  101. /* ---- 3. snap toggle ---- */
  102. const snap = await page.evaluate(() => {
  103. const clip = CS.project.clips[0];
  104. CS.state.snap = true;
  105. const snapped = CS.timeline.applySnap(CS.state.playhead + 0.05, clip, "trim");
  106. CS.state.snap = false;
  107. const unsnapped = CS.timeline.applySnap(CS.state.playhead + 0.05, clip, "trim");
  108. CS.state.snap = true;
  109. return { snapped, unsnapped, ph: CS.state.playhead };
  110. });
  111. if (Math.abs(snap.snapped - snap.ph) > 0.001) fail("snap did not snap to playhead");
  112. if (Math.abs(snap.unsnapped - snap.ph) < 0.001) fail("snap-off still snapped");
  113. ok("snapping toggle works");
  114. /* ---- 4. clip speed ---- */
  115. const speed = await page.evaluate(() => {
  116. const clip = CS.project.clips[0];
  117. window.__c = clip;
  118. const durBefore = CS.clipDuration(clip);
  119. clip.props.speed = 2;
  120. CS.commit("speed");
  121. const durAfter = CS.clipDuration(clip);
  122. CS.player.seek(0.5);
  123. const el = CS.player.pool[clip.id];
  124. return { durBefore, durAfter, elTime: el ? el.currentTime : -1, expect: clip.in + 0.5 * 2 };
  125. });
  126. if (Math.abs(speed.durAfter - speed.durBefore / 2) > 0.01) fail("2x speed did not halve duration");
  127. if (Math.abs(speed.elTime - speed.expect) > 0.1) fail("speed-aware seek wrong: " + JSON.stringify(speed));
  128. ok(`clip speed 2x: duration ${speed.durBefore.toFixed(2)}s -> ${speed.durAfter.toFixed(2)}s, source seek correct`);
  129. await page.evaluate(() => { window.__c.props.speed = 1; CS.commit("speed reset"); });
  130. /* ---- 5. flip horizontal (asymmetric image) ---- */
  131. await page.evaluate(() => {
  132. CS.project.clips = [];
  133. const c = CS.addClipToTimeline(window.__img, "V1", 0);
  134. c.props.crop = "fill";
  135. window.__ic = c;
  136. CS.commit("img");
  137. CS.player.seek(0.5);
  138. });
  139. await page.waitForTimeout(300);
  140. const left0 = await px(0.25, 0.5);
  141. await page.evaluate(() => { window.__ic.props.flipH = true; CS.player.render(); });
  142. const left1 = await px(0.25, 0.5);
  143. if (!(left0[0] > 140 && left0[2] < 90)) fail("baseline left not red: " + left0);
  144. if (!(left1[2] > 120 && left1[0] < 90)) fail("flipH left not blue: " + left1);
  145. ok(`flip horizontal verified (left rgb ${left0} -> ${left1})`);
  146. /* ---- 6. blend mode (additive over color boards) ---- */
  147. const blend = await page.evaluate(() => {
  148. CS.project.clips = [];
  149. CS.player.seek(0.5);
  150. CS.titles.insertElement("red"); // lands on V1 (empty)
  151. const red = CS.selectedClip();
  152. red.start = 0;
  153. CS.titles.insertElement("blue"); // V1 busy -> V2
  154. const blue = CS.selectedClip();
  155. blue.start = 0;
  156. blue.props.blend = "lighter";
  157. CS.commit("blend");
  158. CS.player.render();
  159. const cv = document.getElementById("preview-canvas");
  160. const d = cv.getContext("2d").getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
  161. return [d[0], d[1], d[2]];
  162. });
  163. if (!(blend[0] > 140 && blend[2] > 140)) fail("additive blend not magenta-ish: " + blend);
  164. ok(`blend mode Add composites (rgb ${blend})`);
  165. /* ---- 7. JKL + loop ---- */
  166. await page.evaluate(() => {
  167. CS.project.clips = [];
  168. const c = CS.addClipToTimeline(window.__vid, "V1", 0);
  169. c.out = Math.min(c.out, 1.2);
  170. CS.commit("loop seed");
  171. CS.player.seek(0);
  172. });
  173. await page.keyboard.press("l");
  174. await page.keyboard.press("l");
  175. const rate = await page.evaluate(() => CS.player.rate);
  176. if (rate !== 2) fail("LL did not reach 2x: " + rate);
  177. await page.keyboard.press("k");
  178. const paused = await page.evaluate(() => !CS.state.playing);
  179. if (!paused) fail("K did not pause");
  180. await page.evaluate(() => { CS.state.loop = true; CS.player.seek(0); });
  181. await page.click("#btn-play");
  182. await page.waitForTimeout(1900); // > clip length: must have looped
  183. const loopState = await page.evaluate(() => ({ playing: CS.state.playing, ph: CS.state.playhead, dur: CS.timelineDuration() }));
  184. if (!loopState.playing) fail("loop did not keep playing: " + JSON.stringify(loopState));
  185. if (loopState.ph >= loopState.dur) fail("loop did not wrap");
  186. await page.evaluate(() => { CS.player.pause(); CS.state.loop = false; });
  187. ok(`JKL shuttle + loop playback (wrapped to ${loopState.ph.toFixed(2)}s)`);
  188. /* ---- 8. export current frame (PNG) ---- */
  189. const frameSize = await page.evaluate(() => new Promise((resolve) => {
  190. CS._forceStandalone = true; // use the download path in the test env
  191. CS.fileio.downloadBlob = function (blob) { resolve(blob.size); };
  192. CS.player.seek(0.4);
  193. CS.exporter.exportFrame();
  194. }));
  195. await page.evaluate(() => { CS._forceStandalone = false; });
  196. if (frameSize < 2000) fail("frame PNG too small: " + frameSize);
  197. ok(`export current frame produced PNG of ${frameSize} bytes`);
  198. /* ---- 9. detach audio ---- */
  199. const detach = await page.evaluate(() => {
  200. CS.project.clips = [];
  201. const c = CS.addClipToTimeline(window.__vid, "V1", 0);
  202. CS.commit("detach seed");
  203. CS.detachAudio(c);
  204. const audioClips = CS.project.clips.filter(cl => CS.getTrack(cl.trackId).kind === "audio");
  205. return {
  206. n: CS.project.clips.length,
  207. audio: audioClips.length,
  208. origVol: c.props.volume,
  209. flagged: c.props.audioDetached,
  210. track: audioClips.length ? audioClips[0].trackId : ""
  211. };
  212. });
  213. if (detach.n !== 2 || detach.audio !== 1) fail("detach did not create audio clip: " + JSON.stringify(detach));
  214. if (detach.origVol !== 0 || !detach.flagged) fail("original clip audio not silenced");
  215. ok(`detach audio -> ${detach.track}, original muted`);
  216. /* ---- 10. track solo ---- */
  217. const solo = await page.evaluate(() => {
  218. const a1 = CS.getTrack("A1");
  219. a1.solo = true;
  220. const det = CS.project.clips.filter(cl => CS.getTrack(cl.trackId).kind === "audio")[0];
  221. // move detached clip to A2 so it should be muted by the solo
  222. det.trackId = "A2";
  223. CS.player.syncElements();
  224. const el = CS.player.pool[det.id];
  225. const mutedWhenSolo = el.muted;
  226. a1.solo = false;
  227. CS.player.syncElements();
  228. return { mutedWhenSolo, mutedAfter: el.muted };
  229. });
  230. if (!solo.mutedWhenSolo) fail("solo did not mute other audio track");
  231. if (solo.mutedAfter) fail("unsolo did not restore");
  232. ok("audio track solo mutes other audio tracks");
  233. /* ---- 11. autosave + recovery ---- */
  234. await page.evaluate(() => {
  235. CS.project.name = "RecoverMe";
  236. CS.markDirty();
  237. CS.session.saveSnapshot();
  238. });
  239. const hasSnap = await page.evaluate(() => !!localStorage.getItem("cinestudio_autosave"));
  240. if (!hasSnap) fail("autosave snapshot missing");
  241. await page.reload({ waitUntil: "networkidle" });
  242. await page.waitForFunction(() => window.CS && CS.project);
  243. await page.waitForSelector(".modal", { timeout: 5000 }).catch(() => fail("recovery modal did not appear"));
  244. const modalText = await page.evaluate(() => document.querySelector(".modal").textContent);
  245. if (modalText.indexOf("RecoverMe") < 0) fail("recovery modal missing project name");
  246. await page.click(".modal-btn.primary"); // Restore
  247. const restored = await page.evaluate(() => ({
  248. name: CS.project.name,
  249. clips: CS.project.clips.length
  250. }));
  251. if (restored.name !== "RecoverMe") fail("restore did not recover name: " + restored.name);
  252. if (restored.clips < 2) fail("restore did not recover clips: " + restored.clips);
  253. ok(`autosave recovery restored "${restored.name}" with ${restored.clips} clips`);
  254. /* ---- 12. recent projects list ---- */
  255. const recents = await page.evaluate(() => {
  256. CS.session.recordRecent("Alpha", "user:/Cine Studio/Projects/Alpha.cine");
  257. CS.session.recordRecent("Beta", "user:/Cine Studio/Projects/Beta.cine");
  258. CS.session.recordRecent("Alpha", "user:/Cine Studio/Projects/Alpha.cine"); // dedupe to front
  259. return CS.session.recents().map(r => r.name);
  260. });
  261. if (recents.length !== 2 || recents[0] !== "Alpha") fail("recents list wrong: " + recents);
  262. ok("recent projects list records and dedupes");
  263. });