UNPKG

@webav/av-canvas

Version:

Combine Text, Image, Video, Audio, UserMedia, DisplayMedia to generate MediaStream. With [AVRcorder](../av-recorder/README.md) you can output MP4 streams and save them as local files or push them to the server.

713 lines (712 loc) 22.3 kB
import { Rect as b, Log as T, MediaStreamClip as _, Combinator as G, OffscreenSprite as J } from "@webav/av-cliper"; import { EventTool as Y, debounce as Q, workerTimer as Z, throttle as K } from "@webav/internal-utils"; const k = [ "t", "b", "l", "r", "lt", "lb", "rt", "rb", "rotate" ]; function R(n) { return document.createElement(n); } const P = /* @__PURE__ */ new WeakMap(); function V(n, t) { if (P.has(n)) return P.get(n)(t); let e = 10; new ResizeObserver((s) => { const a = s[0]; a != null && (e = 10 / (a.contentRect.width / n.width)); }).observe(n); function r(s) { const { w: a, h } = s, o = e, c = o / 2, l = a / 2, d = h / 2, f = o * 1.5, p = f / 2; return { ...s.fixedAspectRatio ? {} : { t: new b(-c, -d - c, o, o, s), b: new b(-c, d - c, o, o, s), l: new b(-l - c, -c, o, o, s), r: new b(l - c, -c, o, o, s) }, lt: new b(-l - c, -d - c, o, o, s), lb: new b(-l - c, d - c, o, o, s), rt: new b(l - c, -d - c, o, o, s), rb: new b(l - c, d - c, o, o, s), rotate: new b(-p, -d - o * 2 - p, f, f, s) }; } return P.set(n, r), r(t); } const W = /* @__PURE__ */ new WeakMap(); function L(n) { if (W.has(n)) return W.get(n); const t = { w: n.clientWidth / n.width, h: n.clientHeight / n.height }; return new ResizeObserver(() => { t.w = n.clientWidth / n.width, t.h = n.clientHeight / n.height; }).observe(n), W.set(n, t), t; } var z = /* @__PURE__ */ ((n) => (n.ActiveSpriteChange = "activeSpriteChange", n.AddSprite = "addSprite", n))(z || {}); class tt { #t = []; #e = null; #n = new Y(); on = this.#n.on; get activeSprite() { return this.#e; } set activeSprite(t) { t !== this.#e && (this.#e = t, this.#n.emit("activeSpriteChange", t)); } activeSpriteByCoord(t, e) { this.activeSprite = this.getSprites().reverse().find((i) => i.visible && i.rect.checkHit(t, e)) ?? null; } async addSprite(t) { await t.ready, this.#t.push(t), this.#t = this.#t.sort((e, i) => e.zIndex - i.zIndex), t.on("propsChange", (e) => { e.zIndex != null && (this.#t = this.#t.sort((i, r) => i.zIndex - r.zIndex)); }), this.#n.emit("addSprite", t); } removeSprite(t) { this.#e === t && (this.activeSprite = null), this.#t = this.#t.filter((e) => e !== t), t.destroy(); } getSprites(t = { time: !0 }) { return this.#t.filter( (e) => e.visible && (t.time ? this.#s >= e.time.offset && this.#s <= e.time.offset + e.time.duration : !0) ); } #s = 0; updateRenderTime(t) { this.#s = t; const e = this.activeSprite; e != null && (t < e.time.offset || t > e.time.offset + e.time.duration) && (this.activeSprite = null); } destroy() { this.#n.destroy(), this.#t.forEach((t) => t.destroy()), this.#t = []; } } function et(n, t, e) { const i = L(t), r = new ResizeObserver(() => { e.activeSprite != null && D(e.activeSprite, t, a, h); }); r.observe(t); let s = () => { }; const { rectEl: a, ctrlsEl: h } = it(n); a.addEventListener("pointerdown", (c) => { if (Object.values(h).includes(c.target)) return; const l = t.getBoundingClientRect(), d = (c.clientX - l.left) / i.w, f = (c.clientY - l.top) / i.h; e.activeSpriteByCoord(d, f); }); const o = e.on(z.ActiveSpriteChange, (c) => { if (s(), c == null) { a.style.display = "none"; return; } D(c, t, a, h), s = c.on("propsChange", () => { D(c, t, a, h); }), a.style.display = ""; }); return () => { r.disconnect(), o(), a.remove(), s(); }; } function it(n) { const t = R("div"); t.classList.add("sprite-rect"), t.style.cssText = ` position: absolute; z-index: 3; pointer-events: auto; border: 1px solid #eee; box-sizing: border-box; display: none; cursor: move; `; const e = Object.fromEntries( k.map((i) => { const r = R("div"); return r.classList.add(`ctrl-key-${i}`), r.style.cssText = ` display: none; position: absolute; border: 1px solid #3ee; border-radius: 50%; box-sizing: border-box; background-color: #fff; pointer-events: auto; cursor: ${i === "rotate" ? "crosshair" : "default"}; user-select: none; `, [i, r]; }) ); return Object.values(e).forEach((i) => t.appendChild(i)), n.appendChild(t), { rectEl: t, ctrlsEl: e }; } function D(n, t, e, i) { const r = L(t), { x: s, y: a, w: h, h: o, angle: c } = n.rect; Object.assign(e.style, { left: `${s * r.w}px`, top: `${a * r.h}px`, width: `${h * r.w}px`, height: `${o * r.h}px`, rotate: `${c}rad` }), Object.entries(V(t, n.rect)).forEach(([l, { x: d, y: f, w: p, h: u }]) => { Object.assign(i[l].style, { display: "block", left: "50%", top: "50%", width: `${p * r.w}px`, height: `${u * r.h}px`, // border 1px, 所以要 -1 transform: `translate(${d * r.w}px, ${f * r.h}px)` }); }); } function nt(n, t) { const e = (i) => { if (i.button !== 0 || i.target !== n) return; const r = L(n), { offsetX: s, offsetY: a } = i, h = s / r.w, o = a / r.h; t.activeSpriteByCoord(h, o); }; return n.addEventListener("pointerdown", e), () => { n.removeEventListener("pointerdown", e); }; } function rt(n, t, e) { let i = 0, r = 0, s = null; const a = lt(n, e), h = e.querySelector(".sprite-rect"); if (!h) throw Error("sprite-rect DOM Node not found"); const o = (p) => { if (p.button !== 0 || t.activeSprite == null) return; const u = t.activeSprite, { clientX: m, clientY: v } = p; s = u.rect.clone(), a.magneticEffect(u.rect.x, u.rect.y, u.rect), i = m, r = v, window.addEventListener("pointermove", l), window.addEventListener("pointerup", d), p.stopPropagation(); }, c = L(n), l = (p) => { if (t.activeSprite == null || s == null) return; const { clientX: u, clientY: m } = p; let v = s.x + (u - i) / c.w, w = s.y + (m - r) / c.h; N( t.activeSprite.rect, n, a.magneticEffect(v, w, t.activeSprite.rect) ); }, d = () => { a.hide(), window.removeEventListener("pointermove", l), window.removeEventListener("pointerup", d); }; h.addEventListener("pointerdown", o), n.addEventListener("pointerdown", o); const f = st(n, h, t); return () => { a.destroy(), d(), h.removeEventListener("pointerdown", o), n.removeEventListener("pointerdown", o), f(); }; } function st(n, t, e) { const i = Array.from(t.children), r = L(n); i.forEach((c, l) => { const d = k[l]; c.addEventListener("pointerdown", (f) => { if (f.button !== 0 || e.activeSprite == null) return; const { clientX: p, clientY: u } = f; d === "rotate" ? ht( e.activeSprite.rect, dt(e.activeSprite.rect.center, r, n) ) : ot({ sprRect: e.activeSprite.rect, ctrlKey: d, startX: p, startY: u, cvsRatio: r, cvsEl: n }), f.stopPropagation(); }); }), i[k.indexOf("rotate")].style.cursor = "crosshair"; const s = [ "ns-resize", "nesw-resize", "ew-resize", "nwse-resize", "ns-resize", "nesw-resize", "ew-resize", "nwse-resize" ], a = { t: 0, rt: 1, r: 2, rb: 3, b: 4, lb: 5, l: 6, lt: 7 }; let h = () => { }; const o = e.on(z.ActiveSpriteChange, (c) => { if (h(), c == null) return; const l = Q(function() { const { angle: d } = c.rect, f = d < 0 ? d + 2 * Math.PI : d; i.forEach((p, u) => { const m = k[u]; if (m === "rotate") return; const v = (a[m] + Math.floor((f + Math.PI / 8) / (Math.PI / 4))) % 8; p.style.cursor = s[v]; }); }, 300); h = c.on("propsChange", (d) => { d.rect?.angle != null && l(); }), l(); }); return () => { h(), o(); }; } function ot({ sprRect: n, startX: t, startY: e, ctrlKey: i, cvsRatio: r, cvsEl: s }) { const a = n.clone(), h = (c) => { const { clientX: l, clientY: d } = c, f = (l - t) / r.w, p = (d - e) / r.h, u = i.length === 1 ? at : ct, { x: m, y: v, w, h: S } = a, A = Math.atan2(S, w), { incW: j, incH: B, incS: C, rotateAngle: H } = u({ deltaX: f, deltaY: p, angle: n.angle, ctrlKey: i, diagonalAngle: A }), y = 10; let x = w, M = S, E = a.fixedScaleCenter ? j * 2 : j, O = a.fixedScaleCenter ? B * 2 : B, g = C; const X = Math.sqrt(S ** 2 + w ** 2), F = Math.sqrt((y * (S / w)) ** 2 + y ** 2); switch (i) { // 非等比例缩放时,变化的增量范围 由原宽高跟 minSize 的差值决定 // 非等比例缩放时,根据ctrlKey的不同,固定宽高中的一个,另一个根据增量计算,并考虑最小值限定 case "l": x = Math.max(w + E, y), g = Math.min(C, w - y); break; case "r": x = Math.max(w + E, y), g = Math.max(C, y - w); break; case "b": M = Math.max(S + O, y), g = Math.min(C, S - y); break; case "t": M = Math.max(S + O, y), g = Math.max(C, y - S); break; // 等比例缩放时,变化(对角线长度)的增量范围由原对角线长度跟 minSize 对角线的差值决定 // 等比例缩放时,某一边达到最小值时保持宽高比例不变 case "lt": case "lb": x = Math.max(w + E, y), M = x === y ? S / w * x : S + O, g = Math.min(C, X - F); break; case "rt": case "rb": x = Math.max(w + E, y), M = x === y ? S / w * x : S + O, g = Math.max(C, F - X); break; } let I = m, $ = v; if (a.fixedScaleCenter) I = m + w / 2 - x / 2, $ = v + S / 2 - M / 2; else { const q = g / 2 * Math.cos(H) + m + w / 2, U = g / 2 * Math.sin(H) + v + S / 2; I = q - x / 2, $ = U - M / 2; } N(n, s, { x: I, y: $, w: x, h: M }); }, o = () => { window.removeEventListener("pointermove", h), window.removeEventListener("pointerup", o); }; window.addEventListener("pointermove", h), window.addEventListener("pointerup", o); } function at({ deltaX: n, deltaY: t, angle: e, ctrlKey: i }) { let r = 0, s = 0, a = 0, h = e; return i === "l" || i === "r" ? (r = n * Math.cos(e) + t * Math.sin(e), s = r * (i === "l" ? -1 : 1)) : (i === "t" || i === "b") && (h = e - Math.PI / 2, r = n * Math.cos(h) + t * Math.sin(h), a = r * (i === "b" ? -1 : 1)), { incW: s, incH: a, incS: r, rotateAngle: h }; } function ct({ deltaX: n, deltaY: t, angle: e, ctrlKey: i, diagonalAngle: r }) { const s = (i === "lt" || i === "rb" ? 1 : -1) * r + e, a = n * Math.cos(s) + t * Math.sin(s), h = i === "lt" || i === "lb" ? -1 : 1, o = a * Math.cos(r) * h, c = a * Math.sin(r) * h; return { incW: o, incH: c, incS: a, rotateAngle: s }; } function ht(n, t) { const e = ({ clientX: r, clientY: s }) => { const a = r - t.x, h = s - t.y, o = Math.atan2(h, a) + Math.PI / 2; n.angle = o; }, i = () => { window.removeEventListener("pointermove", e), window.removeEventListener("pointerup", i); }; window.addEventListener("pointermove", e), window.addEventListener("pointerup", i); } function dt(n, t, e) { const i = n.x * t.w, r = n.y * t.h, { left: s, top: a } = e.getBoundingClientRect(); return { x: i + s, y: r + a }; } function N(n, t, e) { const i = { x: n.x, y: n.y, w: n.w, h: n.h, ...e }, r = t.width * 0.05, s = t.height * 0.05; i.x < -i.w + r ? i.x = -i.w + r : i.x > t.width - r && (i.x = t.width - r), i.y < -i.h + s ? i.y = -i.h + s : i.y > t.height - s && (i.y = t.height - s), n.x = i.x, n.y = i.y, n.w = i.w, n.h = i.h; } function lt(n, t) { const e = "display: none; position: absolute;", i = { w: 0, h: 0, x: 0, y: 0 }, r = { vertMiddle: { ...i, h: 100, x: 50, ref: { prop: "x", val: ({ w: o }) => (n.width - o) / 2 } }, horMiddle: { ...i, w: 100, y: 50, ref: { prop: "y", val: ({ h: o }) => (n.height - o) / 2 } }, top: { ...i, w: 100, ref: { prop: "y", val: () => 0 } }, bottom: { ...i, w: 100, y: 100, ref: { prop: "y", val: ({ h: o }) => n.height - o } }, left: { ...i, h: 100, ref: { prop: "x", val: () => 0 } }, right: { ...i, h: 100, x: 100, ref: { prop: "x", val: ({ w: o }) => n.width - o } } }, s = R("div"); s.style.cssText = ` position: absolute; z-index: 4; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; box-sizing: border-box; `; const a = Object.fromEntries( Object.entries(r).map(([o, { w: c, h: l, x: d, y: f }]) => { const p = R("div"); return p.style.cssText = ` ${e} border-${c > 0 ? "top" : "left"}: 1px solid #3ee; top: ${f}%; left: ${d}%; ${d === 100 ? "margin-left: -1px" : ""}; ${f === 100 ? "margin-top: -1px" : ""}; width: ${c}%; height: ${l}%; `, s.appendChild(p), [o, p]; }) ); t.appendChild(s); const h = 6 / (900 / n.width); return { magneticEffect(o, c, l) { const d = { x: o, y: c }, f = { x: h, y: h }, p = { x: "", y: "" }; Object.values(a).forEach((u) => u.style.display = "none"); for (const u in r) { const { prop: m, val: v } = r[u].ref, w = v(l), A = Math.abs((m === "x" ? o : c) - w); A <= h && A < f[m] && (f[m] = A, d[m] = w, p[m] = u); } return p.x && (a[p.x].style.display = "block"), p.y && (a[p.y].style.display = "block"), d; }, hide() { Object.values(a).forEach((o) => o.style.display = "none"); }, destroy() { s.remove(); } }; } const pt = { sampleRate: 48e3 }; function ft(n) { const t = R("canvas"); return t.style.cssText = ` width: 100%; height: 100%; display: block; touch-action: none; `, t.width = n.width, t.height = n.height, t; } class yt { #t; #e; #n; #s = !1; #f = []; #u; #o = new Y(); on = this.#o.on; #w; /** * 创建 `AVCanvas` 类的实例。 * @param attchEl - 要添加画布的元素。 * @param opts - 画布的选项 * @param opts.bgColor - 画布的背景颜色。 * @param opts.width - 画布的宽度。 * @param opts.height - 画布的高度。 */ constructor(t, e) { this.#w = e, this.#t = ft(e); const i = this.#t.getContext("2d", { alpha: !1 }); if (i == null) throw Error("canvas context is null"); this.#n = i; const r = R("div"); r.style.cssText = "width: 100%; height: 100%; position: relative;", r.appendChild(this.#t), t.appendChild(r), wt(this.#i).connect(this.#c), V(this.#t, { x: 0, y: 0, w: 0, h: 0 }), this.#e = new tt(), this.#f.push( // 鼠标样式、控制 sprite 依赖 activeSprite, // activeSprite 需要在他们之前监听到 mousedown 事件 (代码顺序需要靠前) nt(this.#t, this.#e), et(r, this.#t, this.#e), rt(this.#t, this.#e, r), this.#e.on(z.AddSprite, (c) => { const { rect: l } = c; l.x === 0 && l.y === 0 && (l.x = (this.#t.width - l.w) / 2, l.y = (this.#t.height - l.h) / 2); }), Y.forwardEvent(this.#e, this.#o, [ z.ActiveSpriteChange ]) ); let s = this.#a, a = performance.now(), h = 0; const o = 1e3 / 30; this.#u = Z(() => { (performance.now() - a) / (o * h) < 1 || (h += 1, this.#n.fillStyle = e.bgColor, this.#n.fillRect(0, 0, this.#t.width, this.#t.height), this.#S(), s !== this.#a && (s = this.#a, this.#o.emit("timeupdate", Math.round(s)))); }, o); } #a = 0; #d(t) { this.#a = t, this.#e.updateRenderTime(t), this.#p.updateTime(t); } #l() { const t = this.#r.step !== 0; this.#r.step = 0, t && (this.#o.emit("paused"), this.#i.suspend()); for (const e of this.#h) e.stop(), e.disconnect(); this.#h.clear(), this.#p.reset(); } #i = new AudioContext(); #c = this.#i.createMediaStreamDestination(); #h = /* @__PURE__ */ new Set(); #S() { const t = this.#n; let e = this.#a; const { start: i, end: r, step: s, audioPlayAt: a } = this.#r; e += s, s !== 0 && e >= i && e < r ? this.#d(e) : this.#l(); const h = []; for (const o of this.#e.getSprites()) { t.save(); const { audio: c } = o.render(t, e - o.time.offset); t.restore(), h.push(c); } if (t.resetTransform(), s !== 0) { const o = Math.max(this.#i.currentTime, a), c = ut( h, this.#i ); let l = 0; for (const d of c) d.start(o), d.connect(this.#i.destination), d.connect(this.#c), this.#h.add(d), d.onended = () => { d.disconnect(), this.#h.delete(d); }, l = Math.max(l, d.buffer?.duration ?? 0); this.#r.audioPlayAt = o + l; } } #r = { start: 0, end: 0, // paused state when step equal 0 step: 0, // step: (1000 / 30) * 1000, audioPlayAt: 0 }; /** * 每 33ms 更新一次画布,绘制已添加的 Sprite * @param opts - 播放选项 * @param opts.start - 开始播放的时间(单位:微秒) * @param [opts.end] - 结束播放的时间(单位:微秒)。如果未指定,则播放到最后一个 Sprite 的结束时间 * @param [opts.playbackRate] - 播放速率。1 表示正常速度,2 表示两倍速度,0.5 表示半速等。如果未指定,则默认为 1 * @throws 如果开始时间大于等于结束时间或小于 0,则抛出错误 */ play(t) { const e = this.#e.getSprites({ time: !1 }).map((r) => r.time.offset + r.time.duration), i = t.end ?? (e.length > 0 ? Math.max(...e) : 1 / 0); if (t.start >= i || t.start < 0) throw Error( `Invalid time parameter, ${JSON.stringify({ start: t.start, end: i })}` ); this.#d(t.start), this.#p.reset(), this.#r.start = t.start, this.#r.end = i, this.#r.step = (t.playbackRate ?? 1) * (1e3 / 30) * 1e3, this.#i.resume(), this.#r.audioPlayAt = 0, this.#o.emit("playing"), T.info("AVCanvs play by:", this.#r); } #p = (() => { const t = /* @__PURE__ */ new Set(); return { reset() { t.clear(); }, updateTime: K((e) => { const r = this.#e.getSprites({ time: !1 }).filter((s) => { const { offset: a } = s.time; return a > e && a - 1e6 <= e; }); for (const s of r) t.has(s) || s.preFrame(0), t.add(s); }, 500) }; })(); /** * 暂停播放,画布内容不再更新 */ pause() { this.#l(); } /** * 预览 `AVCanvas` 指定时间的图像帧 */ previewFrame(t) { this.#e.getSprites().forEach((e) => { e.preFrame(t - e.time.offset); }), this.#d(t), this.#l(); } /** * 获取当前帧的截图图像 返回的是一个base64 */ captureImage() { return this.#t.toDataURL(); } get activeSprite() { return this.#e.activeSprite; } set activeSprite(t) { this.#e.activeSprite = t; } #m = /* @__PURE__ */ new WeakMap(); /** * 添加 {@link VisibleSprite} * @param args {@link VisibleSprite} * @example * const sprite = new VisibleSprite( * new ImgClip({ * type: 'image/gif', * stream: (await fetch('https://xx.gif')).body!, * }), * ); */ addSprite = async (t) => { this.#i.state === "suspended" && this.#i.resume().catch(T.error); const e = t.getClip(); if (e instanceof _ && e.audioTrack != null) { const i = this.#i.createMediaStreamSource( new MediaStream([e.audioTrack]) ); i.connect(this.#c), this.#m.set(t, i); } await this.#e.addSprite(t); }; /** * 删除 {@link VisibleSprite} * @param args * @returns * @example * const sprite = new VisibleSprite(); * avCvs.removeSprite(sprite); */ removeSprite = (t) => { this.#m.get(t)?.disconnect(), this.#e.removeSprite(t); }; /** * 销毁实例 */ destroy() { this.#s || (this.#s = !0, this.#i.close(), this.#c.disconnect(), this.#o.destroy(), this.#u(), this.#t.parentElement?.remove(), this.#f.forEach((t) => t()), this.#h.clear(), this.#e.destroy()); } /** * 合成所有素材的图像与音频,返回实时媒体流 `MediaStream` * * 可用于 WebRTC 推流,或由 {@link [AVRecorder](../../av-recorder/classes/AVRecorder.html)} 录制生成视频文件 * * @see [直播录制](https://webav-tech.github.io/WebAV/demo/4_2-recorder-avcanvas) * */ captureStream() { this.#i.state === "suspended" && this.#i.resume().catch(T.error); const t = new MediaStream( this.#t.captureStream().getTracks().concat(this.#c.stream.getTracks()) ); return T.info( "AVCanvas.captureStream, tracks:", t.getTracks().map((e) => e.kind) ), t; } /** * 创建一个视频合成器 {@link [Combinator](../../av-cliper/classes/Combinator.html)} 实例,用于将当前画布添加的 Sprite 导出为视频文件流 * * @param opts - 创建 Combinator 的可选参数 * @throws 如果没有添加素材,会抛出错误 * * @example * avCvs.createCombinator().output() // => ReadableStream * * @see [视频剪辑](https://webav-tech.github.io/WebAV/demo/6_4-video-editor) */ async createCombinator(t = {}) { T.info("AVCanvas.createCombinator, opts:", t); const e = new G({ ...this.#w, ...t }), i = this.#e.getSprites({ time: !1 }); if (i.length === 0) throw Error("No sprite added"); for (const r of i) { const s = new J(r.getClip()); s.time = { ...r.time }, r.copyStateTo(s), await e.addSprite(s); } return e; } } function ut(n, t) { const e = []; if (n.length === 0) return e; for (const [i, r] of n) { if (i == null || i.length <= 0) continue; const s = t.createBuffer( 2, i.length, pt.sampleRate ); s.copyToChannel(i, 0), s.copyToChannel(r ?? i, 1); const a = t.createBufferSource(); a.buffer = s, e.push(a); } return e; } function wt(n) { const t = n.createOscillator(), e = new Float32Array([0, 0]), i = new Float32Array([0, 0]), r = n.createPeriodicWave(e, i, { disableNormalization: !0 }); return t.setPeriodicWave(r), t.start(), t; } export { yt as AVCanvas }; //# sourceMappingURL=av-canvas.js.map