player.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. /*
  2. Cine Studio - preview player and compositor
  3. Draws the frame under the playhead onto the preview canvas by
  4. compositing every visible video-track clip (transform, crop, color
  5. filters, opacity) and keeps a pool of hidden <video>/<audio>
  6. elements time-synced for playback. The same pipeline is reused by
  7. the exporter (canvas captureStream + WebAudio mix bus).
  8. */
  9. "use strict";
  10. window.CS = window.CS || {};
  11. CS.player = {
  12. canvas: null,
  13. ctx: null,
  14. pool: {}, // clipId -> media element
  15. images: {}, // mediaId -> HTMLImageElement
  16. audioCtx: null,
  17. masterGain: null,
  18. exportDest: null,
  19. lastTick: 0,
  20. rafId: 0,
  21. rate: 1, //shuttle rate: 1 = normal, 2/4/8 = fast, negative = reverse
  22. init: function () {
  23. CS.player.canvas = document.getElementById("preview-canvas");
  24. CS.player.ctx = CS.player.canvas.getContext("2d");
  25. CS.player.applyProjectSize();
  26. document.getElementById("btn-play").addEventListener("click", CS.player.toggle);
  27. document.getElementById("btn-prev-edit").addEventListener("click", function () {
  28. CS.player.gotoEditPoint(-1);
  29. });
  30. document.getElementById("btn-next-edit").addEventListener("click", function () {
  31. CS.player.gotoEditPoint(1);
  32. });
  33. document.getElementById("btn-fullscreen").addEventListener("click", function () {
  34. var stage = document.getElementById("preview-stage");
  35. if (document.fullscreenElement) { document.exitFullscreen(); }
  36. else if (stage.requestFullscreen) { stage.requestFullscreen(); }
  37. });
  38. document.getElementById("btn-safe-area").addEventListener("click", function () {
  39. CS.state.safeArea = !CS.state.safeArea;
  40. this.classList.toggle("active", CS.state.safeArea);
  41. CS.player.render();
  42. });
  43. document.getElementById("btn-loop").addEventListener("click", function () {
  44. CS.state.loop = !CS.state.loop;
  45. this.classList.toggle("active", CS.state.loop);
  46. });
  47. var zoomBtn = document.getElementById("btn-preview-zoom");
  48. zoomBtn.addEventListener("click", function () {
  49. var levels = [
  50. { v: "fit", l: "Fit" },
  51. { v: "0.5", l: "50%" },
  52. { v: "1", l: "100%" },
  53. { v: "2", l: "200%" }
  54. ];
  55. CS.showMenuUnder(zoomBtn, levels.map(function (z) {
  56. return {
  57. label: z.l,
  58. checked: String(CS.state.previewZoom) === z.v,
  59. action: function () {
  60. CS.state.previewZoom = z.v;
  61. document.getElementById("preview-zoom-label").textContent = z.l;
  62. CS.player.applyPreviewZoom();
  63. }
  64. };
  65. }));
  66. });
  67. },
  68. applyProjectSize: function () {
  69. CS.player.canvas.width = CS.project.settings.width;
  70. CS.player.canvas.height = CS.project.settings.height;
  71. CS.player.applyPreviewZoom();
  72. CS.player.render();
  73. },
  74. applyPreviewZoom: function () {
  75. var c = CS.player.canvas;
  76. var stage = document.getElementById("preview-stage");
  77. if (CS.state.previewZoom === "fit") {
  78. c.style.width = "";
  79. c.style.height = "";
  80. c.style.maxWidth = "100%";
  81. c.style.maxHeight = "100%";
  82. stage.style.overflow = "hidden";
  83. } else {
  84. var z = parseFloat(CS.state.previewZoom);
  85. c.style.maxWidth = "none";
  86. c.style.maxHeight = "none";
  87. c.style.width = Math.round(CS.project.settings.width * z) + "px";
  88. c.style.height = Math.round(CS.project.settings.height * z) + "px";
  89. stage.style.overflow = "auto";
  90. }
  91. },
  92. reset: function () {
  93. CS.player.pause();
  94. var pool = document.getElementById("element-pool");
  95. if (pool) { pool.innerHTML = ""; }
  96. CS.player.pool = {};
  97. CS.player.images = {};
  98. if (CS.player.canvas) { CS.player.applyProjectSize(); }
  99. },
  100. /* ---------- element pool ---------- */
  101. ensureElement: function (clip, media) {
  102. var el = CS.player.pool[clip.id];
  103. if (el) { return el; }
  104. el = document.createElement(media.type === "audio" ? "audio" : "video");
  105. el.preload = "auto";
  106. el.setAttribute("playsinline", "");
  107. el.src = CS.media.mediaURL(media);
  108. el.addEventListener("seeked", function () {
  109. if (!CS.state.playing) { CS.player.render(); }
  110. });
  111. document.getElementById("element-pool").appendChild(el);
  112. CS.player.pool[clip.id] = el;
  113. CS.player.attachToMixBus(el);
  114. return el;
  115. },
  116. ensureImage: function (media) {
  117. var img = CS.player.images[media.id];
  118. if (img) { return img; }
  119. img = new Image();
  120. img.src = CS.media.mediaURL(media);
  121. img.onload = function () { if (!CS.state.playing) { CS.player.render(); } };
  122. CS.player.images[media.id] = img;
  123. return img;
  124. },
  125. //Drop pool entries whose clip no longer exists
  126. prunePool: function () {
  127. Object.keys(CS.player.pool).forEach(function (clipId) {
  128. if (!CS.getClip(clipId)) {
  129. var el = CS.player.pool[clipId];
  130. el.pause();
  131. el.remove();
  132. delete CS.player.pool[clipId];
  133. }
  134. });
  135. },
  136. /* ---------- audio mix bus (playback monitoring + export capture) ---------- */
  137. initAudioBus: function () {
  138. if (CS.player.audioCtx) {
  139. if (CS.player.audioCtx.state === "suspended") { CS.player.audioCtx.resume(); }
  140. return;
  141. }
  142. try {
  143. var AC = window.AudioContext || window.webkitAudioContext;
  144. CS.player.audioCtx = new AC();
  145. CS.player.masterGain = CS.player.audioCtx.createGain();
  146. CS.player.masterGain.connect(CS.player.audioCtx.destination);
  147. //Route the already-created elements through the bus
  148. Object.keys(CS.player.pool).forEach(function (clipId) {
  149. CS.player.attachToMixBus(CS.player.pool[clipId]);
  150. });
  151. } catch (e) {
  152. CS.player.audioCtx = null;
  153. }
  154. },
  155. attachToMixBus: function (el) {
  156. if (!CS.player.audioCtx || el._csRouted) { return; }
  157. try {
  158. var src = CS.player.audioCtx.createMediaElementSource(el);
  159. src.connect(CS.player.masterGain);
  160. el._csRouted = true;
  161. } catch (e) { /* element stays on direct output */ }
  162. },
  163. /* ---------- transport ---------- */
  164. toggle: function () {
  165. if (CS.state.playing) { CS.player.pause(); } else { CS.player.setRate(1); CS.player.play(); }
  166. },
  167. //JKL shuttle: L speeds up forward, J speeds up backward, K pauses
  168. setRate: function (r) {
  169. CS.player.rate = r;
  170. },
  171. shuttle: function (dir) {
  172. var r = CS.player.rate;
  173. if (!CS.state.playing || (dir > 0 && r < 0) || (dir < 0 && r > 0)) {
  174. r = dir; //start at 1x in the requested direction
  175. } else {
  176. r = CS.clamp(r * 2, -8, 8); //same direction: double up to 8x
  177. }
  178. CS.player.setRate(r);
  179. if (!CS.state.playing) { CS.player.play(); }
  180. CS.toast("Playback " + (r > 0 ? "" : "-") + Math.abs(r) + "x");
  181. },
  182. play: function () {
  183. var dur = CS.timelineDuration();
  184. if (dur <= 0) { CS.toast("Timeline is empty"); return; }
  185. if (CS.player.rate > 0 && CS.state.playhead >= dur - 0.01) { CS.state.playhead = 0; }
  186. if (CS.player.rate < 0 && CS.state.playhead <= 0.01) { CS.state.playhead = dur; }
  187. CS.player.initAudioBus();
  188. CS.state.playing = true;
  189. CS.player.lastTick = performance.now();
  190. CS.setIcon(document.getElementById("play-icon"), "pause");
  191. //Start the media elements inside the user gesture so autoplay
  192. //policies treat the playback as user initiated
  193. CS.player.syncElements();
  194. cancelAnimationFrame(CS.player.rafId);
  195. CS.player.rafId = requestAnimationFrame(CS.player.tick);
  196. },
  197. pause: function () {
  198. CS.state.playing = false;
  199. CS.player.rate = 1;
  200. cancelAnimationFrame(CS.player.rafId);
  201. Object.keys(CS.player.pool).forEach(function (id) {
  202. if (!CS.player.pool[id].paused) { CS.player.pool[id].pause(); }
  203. });
  204. var icon = document.getElementById("play-icon");
  205. if (icon) { CS.setIcon(icon, "play"); }
  206. if (CS.exporter && CS.exporter.onPlaybackStopped) { CS.exporter.onPlaybackStopped(); }
  207. },
  208. seek: function (t) {
  209. var dur = Math.max(CS.timelineDuration(), 0);
  210. CS.state.playhead = CS.clamp(t, 0, Math.max(dur, 0));
  211. CS.player.syncElements();
  212. CS.player.render();
  213. CS.player.updateTransportUI();
  214. CS.timeline.updatePlayhead();
  215. },
  216. gotoEditPoint: function (dir) {
  217. var pts = CS.editPoints();
  218. var t = CS.state.playhead;
  219. if (dir < 0) {
  220. for (var i = pts.length - 1; i >= 0; i--) {
  221. if (pts[i] < t - 0.02) { CS.player.seek(pts[i]); return; }
  222. }
  223. CS.player.seek(0);
  224. } else {
  225. for (var j = 0; j < pts.length; j++) {
  226. if (pts[j] > t + 0.02) { CS.player.seek(pts[j]); return; }
  227. }
  228. }
  229. },
  230. tick: function (now) {
  231. if (!CS.state.playing) { return; }
  232. var dt = (now - CS.player.lastTick) / 1000;
  233. CS.player.lastTick = now;
  234. CS.state.playhead += dt * CS.player.rate;
  235. var dur = CS.timelineDuration();
  236. //Reverse shuttle reached the head of the timeline
  237. if (CS.player.rate < 0 && CS.state.playhead <= 0) {
  238. CS.state.playhead = 0;
  239. CS.player.syncElements();
  240. CS.player.render();
  241. CS.player.updateTransportUI();
  242. CS.timeline.updatePlayhead();
  243. CS.player.pause();
  244. return;
  245. }
  246. if (CS.player.rate > 0 && CS.state.playhead >= dur) {
  247. if (CS.state.loop) {
  248. CS.state.playhead = 0; //loop back around and keep rolling
  249. } else {
  250. CS.state.playhead = dur;
  251. CS.player.syncElements();
  252. CS.player.render();
  253. CS.player.updateTransportUI();
  254. CS.timeline.updatePlayhead();
  255. CS.player.pause();
  256. return;
  257. }
  258. }
  259. CS.player.syncElements();
  260. CS.player.render();
  261. CS.player.updateTransportUI();
  262. CS.timeline.updatePlayhead();
  263. CS.player.rafId = requestAnimationFrame(CS.player.tick);
  264. },
  265. updateTransportUI: function () {
  266. document.getElementById("tc-current").textContent = CS.timecode(CS.state.playhead);
  267. document.getElementById("tc-total").textContent = CS.timecode(CS.timelineDuration());
  268. },
  269. /* ---------- element time sync ---------- */
  270. activeAt: function (clip, t) {
  271. return t >= clip.start && t < CS.clipEnd(clip);
  272. },
  273. syncElements: function () {
  274. var t = CS.state.playhead;
  275. var frozen = CS.transitions.frozenTargets(t);
  276. var rate = CS.player.rate;
  277. var reverse = CS.state.playing && rate < 0;
  278. //Track solo: when any audio track is soloed, other audio tracks mute
  279. var anySolo = CS.project.tracks.some(function (tr) { return tr.kind === "audio" && tr.solo; });
  280. CS.project.clips.forEach(function (clip) {
  281. if (clip.kind === "title" || clip.kind === "color") { return; }
  282. var media = CS.getMedia(clip.mediaId);
  283. if (!media || media.type === "image" || media.offline) { return; }
  284. var track = CS.getTrack(clip.trackId);
  285. var el = CS.player.ensureElement(clip, media);
  286. var active = CS.player.activeAt(clip, t) && track && track.visible;
  287. var speed = CS.clipSpeed(clip);
  288. var target = clip.in + (t - clip.start) * speed;
  289. //Volume: clip setting shaped by any fade in/out effects
  290. var vol = (clip.props.volume === undefined ? 100 : clip.props.volume) / 100;
  291. vol *= CS.effects.fadeAlpha(clip, t);
  292. el.volume = CS.clamp(vol, 0, 1);
  293. var soloMuted = anySolo && track && track.kind === "audio" && !track.solo;
  294. el.muted = !!(track && track.muted) || soloMuted;
  295. if (CS.state.playing && active && !reverse) {
  296. try { el.playbackRate = CS.clamp(speed * Math.max(rate, 0.0625), 0.0625, 16); } catch (e) {}
  297. if (el.paused) {
  298. try { el.currentTime = target; } catch (e) {}
  299. var p = el.play();
  300. if (p && p.catch) { p.catch(function () {}); }
  301. } else if (Math.abs(el.currentTime - target) > 0.14 * Math.max(1, Math.abs(rate))) {
  302. try { el.currentTime = target; } catch (e) {}
  303. }
  304. } else if (CS.state.playing && active && reverse) {
  305. //Media elements cannot play backwards: step frames by seeking
  306. if (!el.paused) { el.pause(); }
  307. if (Math.abs(el.currentTime - target) > 0.05) {
  308. try { el.currentTime = target; } catch (e) {}
  309. }
  310. } else if (CS.state.playing && !active) {
  311. if (!el.paused) { el.pause(); }
  312. } else {
  313. //Paused scrub: park the element on the exact frame; a clip
  314. //feeding a transition freezes on its own last frame instead
  315. if (!el.paused) { el.pause(); }
  316. var parkAt = active ? target : frozen[clip.id];
  317. if (parkAt !== undefined && Math.abs(el.currentTime - parkAt) > 0.03) {
  318. try { el.currentTime = parkAt; } catch (e) {}
  319. }
  320. }
  321. });
  322. },
  323. /* ---------- rendering ---------- */
  324. invalidate: function () {
  325. CS.player.prunePool();
  326. CS.player.syncElements();
  327. CS.player.render();
  328. CS.player.updateTransportUI();
  329. var empty = document.getElementById("preview-empty");
  330. empty.style.display = CS.project.clips.length ? "none" : "flex";
  331. },
  332. buildFilter: function (props) {
  333. var parts = [];
  334. var exposure = props.exposure || 0;
  335. var contrast = props.contrast || 0;
  336. var saturation = (props.saturation === undefined) ? 1 : props.saturation;
  337. if (exposure !== 0) { parts.push("brightness(" + (1 + exposure) + ")"); }
  338. if (contrast !== 0) { parts.push("contrast(" + (1 + contrast / 100) + ")"); }
  339. if (saturation !== 1) { parts.push("saturate(" + saturation + ")"); }
  340. if (props.preset === "warm") { parts.push("sepia(0.28)"); }
  341. if (props.preset === "cool") { parts.push("hue-rotate(-18deg)"); }
  342. return parts.length ? parts.join(" ") : "none";
  343. },
  344. render: function () {
  345. var ctx = CS.player.ctx;
  346. if (!ctx) { return; }
  347. CS.player.renderFrame(ctx, CS.state.playhead);
  348. if (CS.state.safeArea) { CS.player.drawSafeArea(ctx); }
  349. if (CS.previewctl && CS.previewctl.overlay) { CS.previewctl.redraw(); }
  350. },
  351. //Composite the frame at time t onto the given 2d context
  352. renderFrame: function (ctx, t) {
  353. var W = CS.project.settings.width;
  354. var H = CS.project.settings.height;
  355. ctx.filter = "none";
  356. ctx.globalAlpha = 1;
  357. ctx.fillStyle = "#000";
  358. ctx.fillRect(0, 0, W, H);
  359. CS.videoTracksInRenderOrder().forEach(function (track) {
  360. if (!track.visible) { return; }
  361. CS.clipsOnTrack(track.id).forEach(function (clip) {
  362. if (!CS.player.activeAt(clip, t)) { return; }
  363. var win = CS.transitions.windowAt(clip, t);
  364. if (win) {
  365. CS.transitions.draw(ctx, clip, t, W, H, win);
  366. } else {
  367. CS.player.drawClip(ctx, clip, W, H, t, {});
  368. }
  369. });
  370. });
  371. ctx.filter = "none";
  372. ctx.globalAlpha = 1;
  373. },
  374. drawClip: function (ctx, clip, W, H, t, opts) {
  375. opts = opts || {};
  376. var src, sw, sh;
  377. if (clip.kind === "title" || clip.kind === "color") {
  378. src = CS.titles.renderSource(clip, W, H);
  379. sw = src.width;
  380. sh = src.height;
  381. } else {
  382. var media = CS.getMedia(clip.mediaId);
  383. if (!media || media.offline) { return; }
  384. if (media.type === "video") {
  385. src = CS.player.pool[clip.id];
  386. if (!src || src.readyState < 2) { return; }
  387. sw = src.videoWidth;
  388. sh = src.videoHeight;
  389. } else if (media.type === "image") {
  390. src = CS.player.ensureImage(media);
  391. if (!src.complete || !src.naturalWidth) { return; }
  392. sw = src.naturalWidth;
  393. sh = src.naturalHeight;
  394. } else {
  395. return; //audio has no visual
  396. }
  397. }
  398. if (!sw || !sh) { return; }
  399. var p = clip.props;
  400. var fx = CS.effects.analyze(clip, t === undefined ? CS.state.playhead : t, W);
  401. if (fx.alpha <= 0) { return; }
  402. if (fx.pixelate > 1) {
  403. src = CS.effects.pixelateSource(src, sw, sh, fx.pixelate);
  404. sw = src.width;
  405. sh = src.height;
  406. }
  407. var dw, dh;
  408. if (p.crop === "stretch") {
  409. dw = W;
  410. dh = H;
  411. } else {
  412. var s = (p.crop === "fill") ? Math.max(W / sw, H / sh) : Math.min(W / sw, H / sh);
  413. dw = sw * s;
  414. dh = sh * s;
  415. }
  416. var userScale = (p.scale === undefined ? 100 : p.scale) / 100;
  417. dw *= userScale;
  418. dh *= userScale;
  419. ctx.save();
  420. if (p.blend && p.blend !== "normal") {
  421. ctx.globalCompositeOperation = p.blend;
  422. }
  423. ctx.translate(W / 2 + (p.x || 0), H / 2 + (p.y || 0));
  424. if (p.rotation) { ctx.rotate(p.rotation * Math.PI / 180); }
  425. var flipX = (fx.mirror ? -1 : 1) * (p.flipH ? -1 : 1);
  426. var flipY = p.flipV ? -1 : 1;
  427. if (flipX !== 1 || flipY !== 1) { ctx.scale(flipX, flipY); }
  428. var alpha = CS.clamp((p.opacity === undefined ? 100 : p.opacity) / 100, 0, 1) * fx.alpha;
  429. if (opts.alphaMul !== undefined) { alpha *= CS.clamp(opts.alphaMul, 0, 1); }
  430. ctx.globalAlpha = alpha;
  431. var baseFilter = CS.player.buildFilter(p);
  432. var filterStr = ((baseFilter === "none" ? "" : baseFilter) + fx.filter).trim();
  433. ctx.filter = filterStr || "none";
  434. if (fx.pixelate > 1) { ctx.imageSmoothingEnabled = false; }
  435. try {
  436. ctx.drawImage(src, -dw / 2, -dh / 2, dw, dh);
  437. } catch (e) { /* frame not decodable yet */ }
  438. ctx.restore();
  439. //Full-frame overlays after the clip itself
  440. if (fx.vignette > 0) { CS.effects.drawVignette(ctx, W, H, fx.vignette * fx.alpha); }
  441. if (fx.grain > 0) { CS.effects.drawGrain(ctx, W, H, fx.grain); }
  442. },
  443. drawSafeArea: function (ctx) {
  444. var W = CS.project.settings.width;
  445. var H = CS.project.settings.height;
  446. ctx.save();
  447. ctx.strokeStyle = "rgba(255,255,255,0.35)";
  448. ctx.lineWidth = Math.max(1, W / 960);
  449. ctx.strokeRect(W * 0.05, H * 0.05, W * 0.9, H * 0.9);
  450. ctx.strokeRect(W * 0.1, H * 0.1, W * 0.8, H * 0.8);
  451. ctx.restore();
  452. }
  453. };