@fly-cut/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.
1 lines • 82.6 kB
Source Map (JSON)
{"version":3,"file":"av-canvas.umd.cjs","sources":["../src/types.ts","../src/utils.ts","../src/sprites/sprite-manager.ts","../src/sprites/render-ctrl.ts","../src/sprites/sprite-op.ts","../src/av-canvas.ts"],"sourcesContent":["import { Rect } from '@fly-cut/av-cliper';\n\n/**\n * 二维坐标系中的点\n */\nexport interface IPoint {\n x: number;\n y: number;\n}\n\n/**\n * 锚点(坐标系原点)\n */\nexport interface IAnchor extends IPoint {}\n\n/**\n * 分辨率(尺寸)\n */\nexport interface IResolution {\n width: number;\n height: number;\n}\n\n/**\n * 画布分辨率与实际宽高的比例\n */\nexport interface ICvsRatio {\n w: number;\n h: number;\n}\n\n/**\n * 控制点:上、下、左、右,左上、左下、右上、右下、旋转\n * 当 Rect 只允许等比例缩放时({@link Rect.fixedAspectRatio} = true),缺少 t、b、l、r 四个控制点\n */\nexport type RectCtrls = Partial<Record<'t' | 'b' | 'l' | 'r', Rect>> &\n Record<'lt' | 'lb' | 'rt' | 'rb' | 'rotate', Rect>;\n\nexport const CTRL_KEYS = [\n 't',\n 'b',\n 'l',\n 'r',\n 'lt',\n 'lb',\n 'rt',\n 'rb',\n 'rotate',\n] as const;\n\nexport type TCtrlKey = (typeof CTRL_KEYS)[number];\n","import { Rect } from '@fly-cut/av-cliper';\nimport { RectCtrls } from './types';\n\nexport function createEl(tagName: string): HTMLElement {\n return document.createElement(tagName);\n}\n\n/**\n * 根据 canvas 创建该画布上的 Sprite 控制点生成器\n * 因为控制点的大小需要根据画布的大小动态调整\n */\nexport function createCtrlsGetter(cvsEl: HTMLCanvasElement) {\n let ctrlSize = 16;\n const cvsResizeOb = new ResizeObserver((entries) => {\n const fisrtEntry = entries[0];\n if (fisrtEntry == null) return;\n ctrlSize = 10 / (fisrtEntry.contentRect.width / cvsEl.width);\n });\n cvsResizeOb.observe(cvsEl);\n function rectCtrlsGetter(rect: Rect): RectCtrls {\n const { w, h } = rect;\n // 控制点元素大小, 以 分辨率 为基准\n const sz = ctrlSize;\n // half size\n const hfSz = sz / 2;\n const hfW = w / 2;\n const hfH = h / 2;\n // rotate size\n const rtSz = sz * 1.5;\n const hfRtSz = rtSz / 2;\n // ctrl 坐标是相对于 sprite 中心点\n const tblr = rect.fixedAspectRatio\n ? {}\n : {\n t: new Rect(-hfSz, -hfH - hfSz, sz, sz, rect),\n b: new Rect(-hfSz, hfH - hfSz, sz, sz, rect),\n l: new Rect(-hfW - hfSz, -hfSz, sz, sz, rect),\n r: new Rect(hfW - hfSz, -hfSz, sz, sz, rect),\n };\n return {\n ...tblr,\n lt: new Rect(-hfW - hfSz, -hfH - hfSz, sz, sz, rect),\n lb: new Rect(-hfW - hfSz, hfH - hfSz, sz, sz, rect),\n rt: new Rect(hfW - hfSz, -hfH - hfSz, sz, sz, rect),\n rb: new Rect(hfW - hfSz, hfH - hfSz, sz, sz, rect),\n rotate: new Rect(-hfRtSz, -hfH - sz * 2 - hfRtSz, rtSz, rtSz, rect),\n };\n }\n return {\n rectCtrlsGetter,\n destroy: () => {\n cvsResizeOb.disconnect();\n },\n };\n}\n","import { VisibleSprite } from '@fly-cut/av-cliper';\nimport { EventTool } from '@fly-cut/internal-utils';\n\nexport enum ESpriteManagerEvt {\n ActiveSpriteChange = 'activeSpriteChange',\n AddSprite = 'addSprite',\n}\n\nexport class SpriteManager {\n #sprites: VisibleSprite[] = [];\n\n #activeSprite: VisibleSprite | null = null;\n\n #evtTool = new EventTool<{\n [ESpriteManagerEvt.AddSprite]: (s: VisibleSprite) => void;\n [ESpriteManagerEvt.ActiveSpriteChange]: (s: VisibleSprite | null) => void;\n }>();\n\n on = this.#evtTool.on;\n\n get activeSprite(): VisibleSprite | null {\n return this.#activeSprite;\n }\n set activeSprite(s: VisibleSprite | null) {\n if (s === this.#activeSprite) return;\n this.#activeSprite = s;\n this.#evtTool.emit(ESpriteManagerEvt.ActiveSpriteChange, s);\n }\n\n async addSprite(vs: VisibleSprite): Promise<void> {\n await vs.ready;\n this.#sprites.push(vs);\n this.#sprites = this.#sprites.sort((a, b) => a.zIndex - b.zIndex);\n vs.on('propsChange', (props) => {\n if (props.zIndex == null) return;\n this.#sprites = this.#sprites.sort((a, b) => a.zIndex - b.zIndex);\n });\n\n this.#evtTool.emit(ESpriteManagerEvt.AddSprite, vs);\n }\n\n removeSprite(spr: VisibleSprite): void {\n if (this.#activeSprite === spr) this.activeSprite = null;\n this.#sprites = this.#sprites.filter((s) => s !== spr);\n spr.destroy();\n }\n\n getSprites(filter: { time: boolean } = { time: true }): VisibleSprite[] {\n return this.#sprites.filter(\n (s) =>\n s.visible &&\n (filter.time\n ? this.#renderTime >= s.time.offset &&\n this.#renderTime <= s.time.offset + s.time.duration\n : true),\n );\n }\n\n #renderTime = 0;\n updateRenderTime(time: number) {\n this.#renderTime = time;\n\n // 避免素材不可见,但渲染了素材边框控制点\n const as = this.activeSprite;\n if (\n as != null &&\n (time < as.time.offset || time > as.time.offset + as.time.duration)\n ) {\n this.activeSprite = null;\n }\n }\n\n destroy(): void {\n this.#evtTool.destroy();\n this.#sprites.forEach((s) => s.destroy());\n this.#sprites = [];\n }\n}\n","import { CTRL_KEYS, ICvsRatio, RectCtrls, TCtrlKey } from '../types';\nimport { createEl } from '../utils';\nimport { VisibleSprite, Rect } from '@fly-cut/av-cliper';\nimport { ESpriteManagerEvt, SpriteManager } from './sprite-manager';\n\nexport function renderCtrls(\n container: HTMLElement,\n cvsEl: HTMLCanvasElement,\n sprMng: SpriteManager,\n rectCtrlsGetter: (rect: Rect) => RectCtrls,\n): () => void {\n const cvsRatio = {\n w: cvsEl.clientWidth / cvsEl.width,\n h: cvsEl.clientHeight / cvsEl.height,\n };\n\n const observer = new ResizeObserver(() => {\n cvsRatio.w = cvsEl.clientWidth / cvsEl.width;\n cvsRatio.h = cvsEl.clientHeight / cvsEl.height;\n\n if (sprMng.activeSprite == null) return;\n syncCtrlElPos(\n sprMng.activeSprite,\n rectEl,\n ctrlsEl,\n cvsRatio,\n rectCtrlsGetter,\n cvsEl,\n );\n });\n\n observer.observe(cvsEl);\n\n let lastActSprEvtClear = () => {};\n const { rectEl, ctrlsEl } = createRectAndCtrlEl(container);\n const offSprChange = sprMng.on(ESpriteManagerEvt.ActiveSpriteChange, (s) => {\n // 每次变更,需要清理上一个事件监听器\n lastActSprEvtClear();\n if (s == null) {\n rectEl.style.display = 'none';\n return;\n }\n syncCtrlElPos(s, rectEl, ctrlsEl, cvsRatio, rectCtrlsGetter, cvsEl);\n lastActSprEvtClear = s.on('propsChange', () => {\n syncCtrlElPos(s, rectEl, ctrlsEl, cvsRatio, rectCtrlsGetter, cvsEl);\n });\n rectEl.style.display = '';\n });\n\n return () => {\n observer.disconnect();\n offSprChange();\n rectEl.remove();\n lastActSprEvtClear();\n };\n}\n\nfunction createRectAndCtrlEl(container: HTMLElement): {\n rectEl: HTMLElement;\n ctrlsEl: Record<TCtrlKey, HTMLElement>;\n} {\n const rectEl = createEl('div');\n rectEl.style.cssText = `\n position: absolute;\n pointer-events: none;\n border: 1px solid #eee;\n box-sizing: border-box;\n display: none;\n `;\n const ctrlsEl = Object.fromEntries(\n CTRL_KEYS.map((k) => {\n const d = createEl('div');\n d.style.cssText = `\n display: none;\n position: absolute;\n border: 1px solid #3ee; border-radius: 50%;\n box-sizing: border-box;\n background-color: #fff;\n `;\n return [k, d];\n }),\n ) as Record<TCtrlKey, HTMLElement>;\n\n Object.values(ctrlsEl).forEach((d) => rectEl.appendChild(d));\n container.appendChild(rectEl);\n return {\n rectEl,\n ctrlsEl,\n };\n}\n\nfunction syncCtrlElPos(\n s: VisibleSprite,\n rectEl: HTMLElement,\n ctrlsEl: Record<TCtrlKey, HTMLElement>,\n cvsRatio: ICvsRatio,\n rectCtrlsGetter: (rect: Rect) => RectCtrls,\n cvsEl: HTMLCanvasElement,\n): void {\n const { x, y, w, h, angle } = s.rect;\n\n // 计算画布中心在容器坐标系中的坐标\n const cvsCenterXInContainer = (cvsEl.width / 2) * cvsRatio.w;\n const cvsCenterYInContainer = (cvsEl.height / 2) * cvsRatio.h;\n\n // VisibleSprite 的 rect.x, rect.y 是其中心点相对于画布中心的坐标。\n // 计算 VisibleSprite 视觉左上角相对于画布中心的坐标。\n const visualLeftXInCanvasCenter = x - w / 2;\n const visualTopYInCanvasCenter = y - h / 2;\n\n // 将 VisibleSprite 视觉左上角坐标从画布中心坐标系转换到容器左上角坐标系。\n Object.assign(rectEl.style, {\n left: `${cvsCenterXInContainer + visualLeftXInCanvasCenter * cvsRatio.w}px`,\n top: `${cvsCenterYInContainer + visualTopYInCanvasCenter * cvsRatio.h}px`,\n width: `${w * cvsRatio.w}px`,\n height: `${h * cvsRatio.h}px`,\n rotate: `${angle}rad`,\n });\n Object.entries(rectCtrlsGetter(s.rect)).forEach(([k, { x, y, w, h }]) => {\n // ctrl 是相对中心点定位的\n Object.assign(ctrlsEl[k as TCtrlKey].style, {\n display: 'block',\n left: '50%',\n top: '50%',\n width: `${w * cvsRatio.w}px`,\n height: `${h * cvsRatio.h}px`,\n // border 1px, 所以要 -1\n transform: `translate(${x * cvsRatio.w}px, ${y * cvsRatio.h}px)`,\n });\n });\n}\n","import { ESpriteManagerEvt, SpriteManager } from './sprite-manager';\nimport { ICvsRatio, IPoint, RectCtrls, TCtrlKey } from '../types';\nimport { VisibleSprite, Rect } from '@fly-cut/av-cliper';\nimport { createEl } from '../utils';\n\n/**\n * 鼠标点击,激活 sprite\n */\nexport function activeSprite(\n cvsEl: HTMLCanvasElement,\n sprMng: SpriteManager,\n rectCtrlsGetter: (rect: Rect) => RectCtrls,\n): () => void {\n const cvsRatio = {\n w: cvsEl.clientWidth / cvsEl.width,\n h: cvsEl.clientHeight / cvsEl.height,\n };\n\n const observer = new ResizeObserver(() => {\n cvsRatio.w = cvsEl.clientWidth / cvsEl.width;\n cvsRatio.h = cvsEl.clientHeight / cvsEl.height;\n });\n observer.observe(cvsEl);\n\n const onCvsMouseDown = (evt: MouseEvent): void => {\n if (evt.button !== 0) return;\n const { offsetX, offsetY } = evt;\n const ofx = offsetX / cvsRatio.w;\n const ofy = offsetY / cvsRatio.h;\n // 转换为相对于画布中心的坐标\n const cx = ofx - cvsEl.width / 2;\n const cy = ofy - cvsEl.height / 2;\n\n if (sprMng.activeSprite != null) {\n const [ctrlKey] =\n (Object.entries(rectCtrlsGetter(sprMng.activeSprite.rect)).find(\n ([, rect]) => rect.checkHit(cx, cy),\n ) as [TCtrlKey, Rect]) ?? [];\n if (ctrlKey != null) return;\n }\n sprMng.activeSprite =\n sprMng\n .getSprites()\n // 排在后面的层级更高\n .reverse()\n .find((s) => s.visible && s.selectable && s.rect.checkHit(cx, cy)) ??\n null;\n };\n\n cvsEl.addEventListener('pointerdown', onCvsMouseDown);\n\n return () => {\n observer.disconnect();\n cvsEl.removeEventListener('pointerdown', onCvsMouseDown);\n };\n}\n\n/**\n * 让canvas中的sprite可以被拖拽移动\n */\nexport function draggabelSprite(\n cvsEl: HTMLCanvasElement,\n sprMng: SpriteManager,\n container: HTMLElement,\n rectCtrlsGetter: (rect: Rect) => RectCtrls,\n): () => void {\n const cvsRatio = {\n w: cvsEl.clientWidth / cvsEl.width,\n h: cvsEl.clientHeight / cvsEl.height,\n };\n\n const observer = new ResizeObserver(() => {\n cvsRatio.w = cvsEl.clientWidth / cvsEl.width;\n cvsRatio.h = cvsEl.clientHeight / cvsEl.height;\n });\n observer.observe(cvsEl);\n\n let startX = 0;\n let startY = 0;\n let startRect: Rect | null = null;\n\n const refline = createRefline(cvsEl, container);\n\n let hitSpr: VisibleSprite | null = null;\n // sprMng.activeSprite 在 av-canvas.ts -> activeSprite 中被赋值\n const onCvsMouseDown = (evt: MouseEvent): void => {\n // 鼠标左键才能拖拽移动\n if (evt.button !== 0 || sprMng.activeSprite == null) return;\n hitSpr = sprMng.activeSprite;\n const { offsetX, offsetY, clientX, clientY } = evt;\n // 如果已有激活 sprite,先判定是否命中其 ctrls\n if (\n hitRectCtrls({\n rect: hitSpr.rect,\n offsetX,\n offsetY,\n clientX,\n clientY,\n cvsRatio,\n cvsEl,\n rectCtrlsGetter,\n })\n ) {\n // 命中 ctrl 是缩放 sprite,略过后续移动 sprite 逻辑\n return;\n }\n\n startRect = hitSpr.rect.clone();\n\n refline.magneticEffect(hitSpr.rect.x, hitSpr.rect.y, hitSpr.rect);\n\n startX = clientX;\n startY = clientY;\n window.addEventListener('pointermove', onMouseMove);\n window.addEventListener('pointerup', clearWindowEvt);\n };\n\n const onMouseMove = (evt: MouseEvent): void => {\n if (hitSpr == null || startRect == null) return;\n\n const { clientX, clientY } = evt;\n let expectX = startRect.x + (clientX - startX) / cvsRatio.w;\n let expectY = startRect.y + (clientY - startY) / cvsRatio.h;\n\n updateRectWithSafeMargin(\n hitSpr.rect,\n cvsEl,\n refline.magneticEffect(expectX, expectY, hitSpr.rect),\n );\n };\n\n cvsEl.addEventListener('pointerdown', onCvsMouseDown);\n\n const clearWindowEvt = (): void => {\n refline.hide();\n window.removeEventListener('pointermove', onMouseMove);\n window.removeEventListener('pointerup', clearWindowEvt);\n };\n\n return () => {\n observer.disconnect();\n refline.destroy();\n clearWindowEvt();\n cvsEl.removeEventListener('pointerdown', onCvsMouseDown);\n };\n}\n\n/**\n * 缩放 sprite\n */\nfunction scaleRect({\n sprRect,\n startX,\n startY,\n ctrlKey,\n cvsRatio,\n cvsEl,\n}: {\n sprRect: Rect;\n startX: number;\n startY: number;\n ctrlKey: TCtrlKey;\n cvsRatio: ICvsRatio;\n cvsEl: HTMLCanvasElement;\n}): void {\n const startRect = sprRect.clone();\n\n const onMouseMove = (evt: MouseEvent): void => {\n const { clientX, clientY } = evt;\n const deltaX = (clientX - startX) / cvsRatio.w;\n const deltaY = (clientY - startY) / cvsRatio.h;\n\n // 对角线上的点是等比例缩放,key 的长度为 2\n const scaler = ctrlKey.length === 1 ? stretchScale : fixedRatioScale;\n const { x, y, w, h } = startRect;\n // rect 对角线角度\n const diagonalAngle = Math.atan2(h, w);\n const { incW, incH, incS, rotateAngle } = scaler({\n deltaX,\n deltaY,\n angle: sprRect.angle,\n ctrlKey,\n diagonalAngle,\n });\n\n // 最小宽高缩放限定\n const minSize = 10;\n let newW = w;\n let newH = h;\n // 中心点缩放时,宽高增量是原来的两倍\n let newIncW = startRect.fixedScaleCenter ? incW * 2 : incW;\n let newIncH = startRect.fixedScaleCenter ? incH * 2 : incH;\n // 最小长度缩放限定\n let newIncS = incS;\n // 起始对角线长度\n const startS = Math.sqrt(h ** 2 + w ** 2);\n // 最小对角线长度\n const minS = Math.sqrt((minSize * (h / w)) ** 2 + minSize ** 2);\n switch (ctrlKey) {\n // 非等比例缩放时,变化的增量范围 由原宽高跟 minSize 的差值决定\n // 非等比例缩放时,根据ctrlKey的不同,固定宽高中的一个,另一个根据增量计算,并考虑最小值限定\n case 'l':\n newW = Math.max(w + newIncW, minSize);\n newIncS = Math.min(incS, w - minSize);\n break;\n case 'r':\n newW = Math.max(w + newIncW, minSize);\n newIncS = Math.max(incS, minSize - w);\n break;\n case 'b':\n newH = Math.max(h + newIncH, minSize);\n newIncS = Math.min(incS, h - minSize);\n break;\n case 't':\n newH = Math.max(h + newIncH, minSize);\n newIncS = Math.max(incS, minSize - h);\n break;\n // 等比例缩放时,变化(对角线长度)的增量范围由原对角线长度跟 minSize 对角线的差值决定\n // 等比例缩放时,某一边达到最小值时保持宽高比例不变\n case 'lt':\n case 'lb':\n newW = Math.max(w + newIncW, minSize);\n newH = newW === minSize ? (h / w) * newW : h + newIncH;\n newIncS = Math.min(incS, startS - minS);\n break;\n case 'rt':\n case 'rb':\n newW = Math.max(w + newIncW, minSize);\n newH = newW === minSize ? (h / w) * newW : h + newIncH;\n newIncS = Math.max(incS, minS - startS);\n break;\n }\n let newX = x;\n let newY = y;\n if (startRect.fixedScaleCenter) {\n newX = x + w / 2 - newW / 2;\n newY = y + h / 2 - newH / 2;\n } else {\n const newCenterX = (newIncS / 2) * Math.cos(rotateAngle) + x + w / 2;\n const newCenterY = (newIncS / 2) * Math.sin(rotateAngle) + y + h / 2;\n newX = newCenterX - newW / 2;\n newY = newCenterY - newH / 2;\n }\n\n updateRectWithSafeMargin(sprRect, cvsEl, {\n x: newX,\n y: newY,\n w: newW,\n h: newH,\n });\n };\n\n const clearWindowEvt = (): void => {\n window.removeEventListener('pointermove', onMouseMove);\n window.removeEventListener('pointerup', clearWindowEvt);\n };\n window.addEventListener('pointermove', onMouseMove);\n window.addEventListener('pointerup', clearWindowEvt);\n}\n\n/**\n * 拉伸缩放, 上t 下b 左l 右r\n */\nfunction stretchScale({\n deltaX,\n deltaY,\n angle,\n ctrlKey,\n}: {\n deltaX: number;\n deltaY: number;\n angle: number;\n ctrlKey: TCtrlKey;\n}): {\n incW: number;\n incH: number;\n incS: number;\n rotateAngle: number;\n} {\n // 计算矩形增加的宽度\n let incS = 0;\n let incW = 0;\n let incH = 0;\n let rotateAngle = angle;\n if (ctrlKey === 'l' || ctrlKey === 'r') {\n incS = deltaX * Math.cos(angle) + deltaY * Math.sin(angle);\n // l 缩放是反向的\n incW = incS * (ctrlKey === 'l' ? -1 : 1);\n } else if (ctrlKey === 't' || ctrlKey === 'b') {\n // 计算矩形增加的宽度,旋转坐标系让x轴与角度重合,鼠标位置在x轴的投影(x值)即为增加的高度\n rotateAngle = angle - Math.PI / 2;\n incS = deltaX * Math.cos(rotateAngle) + deltaY * Math.sin(rotateAngle);\n incH = incS * (ctrlKey === 'b' ? -1 : 1);\n }\n\n return { incW, incH, incS, rotateAngle };\n}\n\n/**\n * 等比例缩放\n */\nfunction fixedRatioScale({\n deltaX,\n deltaY,\n angle,\n ctrlKey,\n diagonalAngle,\n}: {\n deltaX: number;\n deltaY: number;\n angle: number;\n ctrlKey: TCtrlKey;\n diagonalAngle: number;\n}): {\n incW: number;\n incH: number;\n incS: number;\n rotateAngle: number;\n} {\n // 坐标系旋转角度, lb->rt的对角线的初始角度为负数,所以需要乘以-1\n const rotateAngle =\n (ctrlKey === 'lt' || ctrlKey === 'rb' ? 1 : -1) * diagonalAngle + angle;\n // 旋转坐标系让x轴与对角线重合,鼠标位置在x轴的投影(x值)即为增加的长度\n const incS = deltaX * Math.cos(rotateAngle) + deltaY * Math.sin(rotateAngle);\n // lb lt 缩放值是反向\n const coefficient = ctrlKey === 'lt' || ctrlKey === 'lb' ? -1 : 1;\n // 等比例缩放,增加宽高等于长度乘以对应的角度函数\n // 因为等比例缩放,中心及被拖拽的点,一定在对角线上\n const incW = incS * Math.cos(diagonalAngle) * coefficient;\n const incH = incS * Math.sin(diagonalAngle) * coefficient;\n\n return { incW, incH, incS, rotateAngle };\n}\n\nfunction hitRectCtrls({\n rect,\n cvsRatio,\n offsetX,\n offsetY,\n clientX,\n clientY,\n cvsEl,\n rectCtrlsGetter,\n}: {\n rect: Rect;\n cvsRatio: ICvsRatio;\n offsetX: number;\n offsetY: number;\n clientX: number;\n clientY: number;\n cvsEl: HTMLCanvasElement;\n rectCtrlsGetter: (rect: Rect) => RectCtrls;\n}): boolean {\n // 将鼠标点击偏移坐标映射成 canvas 坐,\n const ofx = offsetX / cvsRatio.w;\n const ofy = offsetY / cvsRatio.h;\n // 转换为相对于画布中心的坐标\n const cx = ofx - cvsEl.width / 2;\n const cy = ofy - cvsEl.height / 2;\n\n const [k] =\n (Object.entries(rectCtrlsGetter(rect)).find(([, rect]) =>\n rect.checkHit(cx, cy),\n ) as [TCtrlKey, Rect]) ?? [];\n\n if (k == null) return false;\n if (k === 'rotate') {\n rotateRect(rect, cntMap2Outer(rect.center, cvsRatio, cvsEl));\n } else {\n scaleRect({\n sprRect: rect,\n ctrlKey: k,\n startX: clientX,\n startY: clientY,\n cvsRatio,\n cvsEl,\n });\n }\n // 命中 ctrl 后续是缩放 sprite,略过移动 sprite 逻辑\n return true;\n}\n\n/**\n * 监听拖拽事件,将鼠标坐标转换为旋转角度\n * 旋转时,rect的坐标不变\n */\nfunction rotateRect(rect: Rect, outCnt: IPoint): void {\n const onMove = ({ clientX, clientY }: MouseEvent): void => {\n // 映射为 中心点坐标系\n const x = clientX - outCnt.x;\n const y = clientY - outCnt.y;\n // 旋转控制点在正上方,与 x 轴是 -90°, 所以需要加上 Math.PI / 2\n const angle = Math.atan2(y, x) + Math.PI / 2;\n rect.angle = angle;\n };\n const clear = (): void => {\n window.removeEventListener('pointermove', onMove);\n window.removeEventListener('pointerup', clear);\n };\n window.addEventListener('pointermove', onMove);\n window.addEventListener('pointerup', clear);\n}\n\n/**\n * canvas 内部(resolution)坐标映射成外部(DOM)坐标\n */\nfunction cntMap2Outer(\n cnt: IPoint,\n cvsRatio: ICvsRatio,\n cvsEl: HTMLElement,\n): IPoint {\n const x = cnt.x * cvsRatio.w;\n const y = cnt.y * cvsRatio.h;\n\n const { left, top } = cvsEl.getBoundingClientRect();\n return {\n x: x + left,\n y: y + top,\n };\n}\n\n/**\n * 限制安全范围,避免 sprite 完全超出边界\n */\nfunction updateRectWithSafeMargin(\n rect: Rect,\n cvsEl: HTMLCanvasElement,\n value: Partial<Pick<Rect, 'x' | 'y' | 'w' | 'h'>>,\n) {\n const newState = { x: rect.x, y: rect.y, w: rect.w, h: rect.h, ...value };\n const safeMarginRatio = 0.05; // Keep 5% of the sprite visible if it goes off-screen\n const minVisibleWidth = newState.w * safeMarginRatio;\n const minVisibleHeight = newState.h * safeMarginRatio;\n\n // Canvas boundaries in the center-origin coordinate system\n const canvasLeft = -cvsEl.width / 2;\n const canvasRight = cvsEl.width / 2;\n const canvasTop = -cvsEl.height / 2;\n const canvasBottom = cvsEl.height / 2;\n\n // Calculate the desired visible part if the sprite was fully on screen\n // For simplicity, let's ensure at least a small fixed part (e.g. 10px) or minVisibleWidth/Height is visible.\n // Or, more robustly, the safeX and safeY from the original code represented the part of the *canvas* that should remain clear.\n // Let's re-interpret safeWidth/safeHeight as the minimum portion of the *sprite* that must remain on canvas.\n\n // Min x for rect.x (center of sprite) such that right edge of sprite is at canvasLeft + minVisibleWidth\n const minX = canvasLeft - newState.w / 2 + minVisibleWidth;\n // Max x for rect.x (center of sprite) such that left edge of sprite is at canvasRight - minVisibleWidth\n const maxX = canvasRight + newState.w / 2 - minVisibleWidth;\n\n // Min y for rect.y (center of sprite) such that bottom edge of sprite is at canvasTop + minVisibleHeight\n const minY = canvasTop - newState.h / 2 + minVisibleHeight;\n // Max y for rect.y (center of sprite) such that top edge of sprite is at canvasBottom - minVisibleHeight\n const maxY = canvasBottom + newState.h / 2 - minVisibleHeight;\n\n if (newState.x < minX) {\n newState.x = minX;\n } else if (newState.x > maxX) {\n newState.x = maxX;\n }\n\n if (newState.y < minY) {\n newState.y = minY;\n } else if (newState.y > maxY) {\n newState.y = maxY;\n }\n\n rect.x = newState.x;\n rect.y = newState.y;\n rect.w = newState.w;\n rect.h = newState.h;\n}\n\n/**\n * 创建四周+中线参考线, 靠近具有磁吸效果\n */\nfunction createRefline(cvsEl: HTMLCanvasElement, container: HTMLElement) {\n const reflineBaseCSS = `display: none; position: absolute;`;\n const baseSettings = { w: 0, h: 0, x: 0, y: 0 } as const;\n const reflineSettings: Record<\n 'top' | 'bottom' | 'left' | 'right' | 'vertMiddle' | 'horMiddle',\n {\n // 四周加中线参考线,它们的坐标、宽高只能是 0 | 50 | 100\n w: 0 | 50 | 100;\n h: 0 | 50 | 100;\n x: 0 | 50 | 100;\n y: 0 | 50 | 100;\n ref: { prop: 'x' | 'y'; val: (rect: Rect) => number };\n }\n > = {\n vertMiddle: {\n ...baseSettings,\n h: 100,\n x: 50,\n ref: { prop: 'x', val: ({ w }) => (cvsEl.width - w) / 2 },\n },\n horMiddle: {\n ...baseSettings,\n w: 100,\n y: 50,\n ref: { prop: 'y', val: ({ h }) => (cvsEl.height - h) / 2 },\n },\n top: {\n ...baseSettings,\n w: 100,\n ref: { prop: 'y', val: () => 0 },\n },\n bottom: {\n ...baseSettings,\n w: 100,\n y: 100,\n ref: { prop: 'y', val: ({ h }) => cvsEl.height - h },\n },\n left: {\n ...baseSettings,\n h: 100,\n ref: { prop: 'x', val: () => 0 },\n },\n right: {\n ...baseSettings,\n h: 100,\n x: 100,\n ref: { prop: 'x', val: ({ w }) => cvsEl.width - w },\n },\n } as const;\n\n const lineWrap = createEl('div');\n lineWrap.style.cssText = `\n position: absolute;\n top: 0; left: 0;\n width: 100%; height: 100%;\n pointer-events: none;\n box-sizing: border-box;\n `;\n const reflineEls = Object.fromEntries(\n Object.entries(reflineSettings).map(([key, { w, h, x, y }]) => {\n const lineEl = createEl('div');\n lineEl.style.cssText = `\n ${reflineBaseCSS}\n border-${w > 0 ? 'top' : 'left'}: 1px solid #3ee;\n top: ${y}%; left: ${x}%;\n ${x === 100 ? 'margin-left: -1px' : ''};\n ${y === 100 ? 'margin-top: -1px' : ''};\n width: ${w}%; height: ${h}%;\n `;\n lineWrap.appendChild(lineEl);\n return [key, lineEl];\n }),\n ) as Record<keyof typeof reflineSettings, HTMLDivElement>;\n container.appendChild(lineWrap);\n\n const magneticDistance = 6 / (900 / cvsEl.width);\n return {\n magneticEffect(expectX: number, expectY: number, rect: Rect) {\n const retVal = { x: expectX, y: expectY };\n let reflineKey: keyof typeof reflineSettings;\n let correctionState = { x: false, y: false };\n for (reflineKey in reflineSettings) {\n const { prop, val } = reflineSettings[reflineKey].ref;\n if (correctionState[prop]) continue;\n\n const refVal = val(rect);\n if (\n Math.abs(rect[prop] - refVal) <= magneticDistance &&\n Math.abs(rect[prop] - (prop === 'x' ? expectX : expectY)) <=\n magneticDistance\n ) {\n retVal[prop] = refVal;\n reflineEls[reflineKey].style.display = 'block';\n correctionState[prop] = true;\n } else {\n reflineEls[reflineKey].style.display = 'none';\n }\n }\n return retVal;\n },\n hide() {\n Object.values(reflineEls).forEach((el) => (el.style.display = 'none'));\n },\n destroy() {\n lineWrap.remove();\n },\n };\n}\n\n/**\n * 根据当前位置(sprite & ctrls),动态调整鼠标样式\n */\nexport function dynamicCusor(\n cvsEl: HTMLCanvasElement,\n sprMng: SpriteManager,\n rectCtrlsGetter: (rect: Rect) => RectCtrls,\n): () => void {\n const cvsRatio = {\n w: cvsEl.clientWidth / cvsEl.width,\n h: cvsEl.clientHeight / cvsEl.height,\n };\n\n const observer = new ResizeObserver(() => {\n cvsRatio.w = cvsEl.clientWidth / cvsEl.width;\n cvsRatio.h = cvsEl.clientHeight / cvsEl.height;\n });\n observer.observe(cvsEl);\n\n const cvsStyle = cvsEl.style;\n\n let actSpr = sprMng.activeSprite;\n sprMng.on(ESpriteManagerEvt.ActiveSpriteChange, (s) => {\n actSpr = s;\n if (s == null) cvsStyle.cursor = '';\n });\n // 鼠标按下时,在操作过程中,不需要变换鼠标样式\n let isMSDown = false;\n const onDown = ({ offsetX, offsetY }: MouseEvent): void => {\n isMSDown = true;\n // 将鼠标点击偏移坐标映射成 canvas 坐,\n const ofx = offsetX / cvsRatio.w;\n const ofy = offsetY / cvsRatio.h;\n // 转换为相对于画布中心的坐标\n const cx = ofx - cvsEl.width / 2;\n const cy = ofy - cvsEl.height / 2;\n\n // 直接选中 sprite 时,需要改变鼠标样式为 move\n if (actSpr?.rect.checkHit(cx, cy) === true && cvsStyle.cursor === '') {\n cvsStyle.cursor = 'move';\n }\n };\n const onWindowUp = (): void => {\n isMSDown = false;\n };\n\n // 八个 ctrl 点位对应的鼠标样式,构成循环\n const curStyles = [\n 'ns-resize',\n 'nesw-resize',\n 'ew-resize',\n 'nwse-resize',\n 'ns-resize',\n 'nesw-resize',\n 'ew-resize',\n 'nwse-resize',\n ];\n const curInitIdx = { t: 0, rt: 1, r: 2, rb: 3, b: 4, lb: 5, l: 6, lt: 7 };\n\n const onMove = (evt: MouseEvent): void => {\n // 按下之后,不再变化,因为可能是在拖拽控制点\n if (actSpr == null || isMSDown) return;\n const { offsetX, offsetY } = evt;\n const ofx = offsetX / cvsRatio.w;\n const ofy = offsetY / cvsRatio.h;\n // 转换为相对于画布中心的坐标\n const cx = ofx - cvsEl.width / 2;\n const cy = ofy - cvsEl.height / 2;\n\n const [ctrlKey] =\n (Object.entries(rectCtrlsGetter(actSpr.rect)).find(([, rect]) =>\n rect.checkHit(cx, cy),\n ) as [TCtrlKey, Rect]) ?? [];\n\n if (ctrlKey != null) {\n if (ctrlKey === 'rotate') {\n cvsStyle.cursor = 'crosshair';\n return;\n }\n // 旋转后,控制点的箭头指向也需要修正\n const angle = actSpr.rect.angle;\n const oa = angle < 0 ? angle + 2 * Math.PI : angle;\n // 每个控制点的初始样式(idx) + 旋转角度导致的偏移,即为新鼠标样式\n // 每旋转45°,偏移+1,以此在curStyles中循环\n const idx =\n (curInitIdx[ctrlKey] + Math.floor((oa + Math.PI / 8) / (Math.PI / 4))) %\n 8;\n cvsStyle.cursor = curStyles[idx];\n return;\n }\n if (actSpr.rect.checkHit(cx, cy)) {\n cvsStyle.cursor = 'move';\n return;\n }\n // 未命中 ctrls、sprite,重置为默认鼠标样式\n cvsStyle.cursor = '';\n };\n\n cvsEl.addEventListener('pointermove', onMove);\n cvsEl.addEventListener('pointerdown', onDown);\n window.addEventListener('pointerup', onWindowUp);\n\n return () => {\n observer.disconnect();\n cvsEl.removeEventListener('pointermove', onMove);\n cvsEl.removeEventListener('pointerdown', onDown);\n window.removeEventListener('pointerup', onWindowUp);\n };\n}\n","import {\n Log,\n Combinator,\n OffscreenSprite,\n VisibleSprite,\n MediaStreamClip,\n ICombinatorOpts,\n} from '@fly-cut/av-cliper';\nimport { renderCtrls } from './sprites/render-ctrl';\nimport { ESpriteManagerEvt, SpriteManager } from './sprites/sprite-manager';\nimport {\n activeSprite,\n draggabelSprite,\n dynamicCusor,\n} from './sprites/sprite-op';\nimport { IAnchor, IResolution } from './types';\nimport { createCtrlsGetter, createEl } from './utils';\nimport { workerTimer, EventTool } from '@fly-cut/internal-utils';\n\n/**\n * 默认的音频设置,⚠️ 不要变更它的值 ⚠️\n */\nconst DEFAULT_AUDIO_CONF = {\n sampleRate: 48000,\n channelCount: 2,\n codec: 'mp4a.40.2',\n} as const;\n\nfunction createInitCvsEl(resolution: IResolution): HTMLCanvasElement {\n const cvsEl = createEl('canvas') as HTMLCanvasElement;\n cvsEl.style.cssText = `\n width: 100%;\n height: 100%;\n display: block;\n touch-action: none;\n `;\n cvsEl.width = resolution.width;\n cvsEl.height = resolution.height;\n\n return cvsEl;\n}\n\n/**\n *\n * 一个可交互的画布,让用户添加各种素材,支持基础交互(拖拽、缩放、旋转、时间偏移)\n *\n * 用于在 Web 环境中实现视频剪辑、直播推流工作台功能\n *\n * @description\n *\n - 添加/删除素材(视频、音频、图片、文字)\n - 分割(裁剪)素材\n - 控制素材在视频中的空间属性(坐标、旋转、缩放)\n - 控制素材在视频中的时间属性(偏移、时长)\n - 设置坐标系原点位置\n - 实时预览播放\n - 纯浏览器环境生成视频\n\n * @see [直播录制](https://webav-tech.github.io/WebAV/demo/4_2-recorder-avcanvas)\n * @see [视频剪辑](https://webav-tech.github.io/WebAV/demo/6_4-video-editor)\n * @example\n * const avCvs = new AVCanvas(document.querySelector('#app'), {\n * bgColor: '#333',\n * width: 1920,\n * height: 1080,\n * });\n *\n */\nexport class AVCanvas {\n #cvsEl: HTMLCanvasElement;\n\n #spriteManager: SpriteManager;\n\n #cvsCtx: CanvasRenderingContext2D;\n\n #destroyed = false;\n\n #clears: Array<() => void> = [];\n #stopRender: () => void;\n\n #evtTool = new EventTool<{\n timeupdate: (time: number) => void;\n paused: () => void;\n playing: () => void;\n activeSpriteChange: (sprite: VisibleSprite | null) => void;\n }>();\n on = this.#evtTool.on;\n\n #opts;\n\n #backgroundOptions: {\n mode: 'cover' | 'contain' | 'stretch' | 'repeat';\n opacity: number;\n blur: number;\n } = {\n mode: 'cover',\n opacity: 1,\n blur: 0,\n };\n\n // 在 AVCanvas 类中添加\n #backgroundImage: ImageBitmap | null = null;\n #originalBackgroundImage: ImageBitmap | null = null;\n\n // 添加锚点属性\n #anchor: IAnchor = { x: 0, y: 0 };\n\n /**\n * 创建 `AVCanvas` 类的实例。\n * @param attchEl - 要添加画布的元素。\n * @param opts - 画布的选项\n * @param opts.bgColor - 画布的背景颜色。\n * @param opts.width - 画布的宽度。\n * @param opts.height - 画布的高度。\n */\n constructor(\n attchEl: HTMLElement,\n opts: {\n bgColor: string;\n } & IResolution,\n ) {\n this.#opts = opts;\n this.#cvsEl = createInitCvsEl(opts);\n const ctx = this.#cvsEl.getContext('2d', { alpha: false });\n if (ctx == null) throw Error('canvas context is null');\n this.#cvsCtx = ctx;\n const container = createEl('div');\n container.style.cssText =\n 'width: 100%; height: 100%; position: relative; overflow: hidden;';\n container.appendChild(this.#cvsEl);\n attchEl.appendChild(container);\n\n createEmptyOscillatorNode(this.#audioCtx).connect(this.#captureAudioDest);\n\n this.#spriteManager = new SpriteManager();\n\n const { rectCtrlsGetter, destroy: ctrlGetterDestroy } = createCtrlsGetter(\n this.#cvsEl,\n );\n this.#clears.push(\n ctrlGetterDestroy,\n // 鼠标样式、控制 sprite 依赖 activeSprite,\n // activeSprite 需要在他们之前监听到 mousedown 事件 (代码顺序需要靠前)\n activeSprite(this.#cvsEl, this.#spriteManager, rectCtrlsGetter),\n dynamicCusor(this.#cvsEl, this.#spriteManager, rectCtrlsGetter),\n draggabelSprite(\n this.#cvsEl,\n this.#spriteManager,\n container,\n rectCtrlsGetter,\n ),\n renderCtrls(container, this.#cvsEl, this.#spriteManager, rectCtrlsGetter),\n // 因为默认为中心对齐,所以可以不用考虑居中的问题,0,0就是居中\n // this.#spriteManager.on(ESpriteManagerEvt.AddSprite, (s) => {\n // const { rect } = s;\n // // 默认居中\n // if (rect.x === 0 && rect.y === 0) {\n // // 考虑锚点的情况\n // rect.x = (this.#cvsEl.width - rect.w) / 2;\n // rect.y = (this.#cvsEl.height - rect.h) / 2;\n // }\n // }),\n EventTool.forwardEvent(this.#spriteManager, this.#evtTool, [\n ESpriteManagerEvt.ActiveSpriteChange,\n ]),\n );\n\n let lastRenderTime = this.#renderTime;\n let start = performance.now();\n let runCnt = 0;\n const expectFrameTime = 1000 / 30;\n this.#stopRender = workerTimer(() => {\n // workerTimer 会略快于真实时钟,使用真实时间(performance.now)作为基准\n // 跳过部分运行帧修正时间,避免导致音画不同步\n if ((performance.now() - start) / (expectFrameTime * runCnt) < 1) {\n return;\n }\n runCnt += 1;\n this.#render();\n\n if (lastRenderTime !== this.#renderTime) {\n lastRenderTime = this.#renderTime;\n this.#evtTool.emit('timeupdate', Math.round(lastRenderTime));\n }\n }, expectFrameTime);\n\n // ;(window as any).cvsEl = this.#cvsEl\n }\n\n #renderTime = 0e6;\n #updateRenderTime(time: number) {\n this.#renderTime = time;\n this.#spriteManager.updateRenderTime(time);\n }\n\n #pause() {\n const emitPaused = this.#playState.step !== 0;\n this.#playState.step = 0;\n if (emitPaused) {\n this.#evtTool.emit('paused');\n this.#audioCtx.suspend();\n }\n for (const asn of this.#playingAudioCache) {\n asn.stop();\n asn.disconnect();\n }\n this.#playingAudioCache.clear();\n }\n\n #audioCtx = new AudioContext();\n #captureAudioDest = this.#audioCtx.createMediaStreamDestination();\n\n #playingAudioCache: Set<AudioBufferSourceNode> = new Set();\n #render() {\n const cvsCtx = this.#cvsCtx;\n let ts = this.#renderTime;\n const { start, end, step, audioPlayAt } = this.#playState;\n if (step !== 0 && ts >= start && ts < end) {\n ts += step;\n } else {\n this.#pause();\n }\n this.#updateRenderTime(ts);\n\n // 清除画布\n cvsCtx.fillStyle = this.#opts.bgColor;\n cvsCtx.fillRect(0, 0, this.#cvsEl.width, this.#cvsEl.height);\n\n // 如果有背景图片,绘制背景图片\n if (this.#backgroundImage) {\n const { width, height } = this.#cvsEl;\n const { mode, opacity } = this.#backgroundOptions;\n\n // 保存当前上下文状态\n cvsCtx.save();\n\n // 设置透明度\n if (opacity !== 1) {\n cvsCtx.globalAlpha = opacity;\n }\n\n // 根据不同模式绘制背景\n switch (mode) {\n case 'cover':\n // 覆盖模式,保持宽高比填满整个画布\n drawImageCover(cvsCtx, this.#backgroundImage, 0, 0, width, height);\n break;\n case 'contain':\n // 包含模式,保持宽高比完整显示图片\n drawImageContain(cvsCtx, this.#backgroundImage, 0, 0, width, height);\n break;\n case 'stretch':\n // 拉伸模式,拉伸填满整个画布\n cvsCtx.drawImage(this.#backgroundImage, 0, 0, width, height);\n break;\n case 'repeat':\n // 重复模式,平铺填满整个画布\n const pattern = cvsCtx.createPattern(this.#backgroundImage, 'repeat');\n if (pattern) {\n cvsCtx.fillStyle = pattern;\n cvsCtx.fillRect(0, 0, width, height);\n }\n break;\n }\n\n // 恢复上下文状态\n cvsCtx.restore();\n }\n\n const ctxDestAudioData: Float32Array[][] = [];\n for (const s of this.#spriteManager.getSprites()) {\n cvsCtx.save();\n\n // 应用锚点变换\n // if (this.#anchor.x !== 0 || this.#anchor.y !== 0) {\n // // 保存当前的变换矩阵\n // cvsCtx.translate(this.#anchor.x, this.#anchor.y);\n // }\n\n const { audio } = s.render(cvsCtx, ts - s.time.offset, this.#anchor);\n cvsCtx.restore();\n\n ctxDestAudioData.push(audio);\n }\n cvsCtx.resetTransform();\n\n if (step !== 0) {\n const curAudioTime = Math.max(this.#audioCtx.currentTime, audioPlayAt);\n const audioSourceArr = convertPCM2AudioSource(\n ctxDestAudioData,\n this.#audioCtx,\n );\n\n let addTime = 0;\n for (const ads of audioSourceArr) {\n ads.start(curAudioTime);\n ads.connect(this.#audioCtx.destination);\n ads.connect(this.#captureAudioDest);\n\n this.#playingAudioCache.add(ads);\n ads.onended = () => {\n ads.disconnect();\n this.#playingAudioCache.delete(ads);\n };\n addTime = Math.max(addTime, ads.buffer?.duration ?? 0);\n }\n this.#playState.audioPlayAt = curAudioTime + addTime;\n }\n }\n\n #playState = {\n start: 0,\n end: 0,\n // paused state when step equal 0\n step: 0,\n // step: (1000 / 30) * 1000,\n audioPlayAt: 0,\n };\n /**\n * 每 33ms 更新一次画布,绘制已添加的 Sprite\n * @param opts - 播放选项\n * @param opts.start - 开始播放的时间(单位:微秒)\n * @param [opts.end] - 结束播放的时间(单位:微秒)。如果未指定,则播放到最后一个 Sprite 的结束时间\n * @param [opts.playbackRate] - 播放速率。1 表示正常速度,2 表示两倍速度,0.5 表示半速等。如果未指定,则默认为 1\n * @throws 如果开始时间大于等于结束时间或小于 0,则抛出错误\n */\n play(opts: { start: number; end?: number; playbackRate?: number }) {\n const spriteTimes = this.#spriteManager\n .getSprites({ time: false })\n .map((s) => s.time.offset + s.time.duration);\n const end =\n opts.end ??\n (spriteTimes.length > 0 ? Math.max(...spriteTimes) : Infinity);\n\n if (opts.start >= end || opts.start < 0) {\n throw Error(\n `Invalid time parameter, ${JSON.stringify({ start: opts.start, end })}`,\n );\n }\n\n this.#updateRenderTime(opts.start);\n this.#spriteManager.getSprites({ time: false }).forEach((vs) => {\n const { offset, duration } = vs.time;\n const selfOffset = this.#renderTime - offset;\n vs.preFrame(selfOffset > 0 && selfOffset < duration ? selfOffset : 0);\n });\n\n this.#playState.start = opts.start;\n this.#playState.end = end;\n // AVCanvas 30FPS,将播放速率转换成步长\n this.#playState.step = (opts.playbackRate ?? 1) * (1000 / 30) * 1000;\n this.#audioCtx.resume();\n this.#playState.audioPlayAt = 0;\n\n this.#evtTool.emit('playing');\n Log.info('AVCanvs play by:', this.#playState);\n }\n\n /**\n * 暂停播放,画布内容不再更新\n */\n pause() {\n this.#pause();\n }\n\n /**\n * 预览 `AVCanvas` 指定时间的图像帧\n */\n previewFrame(time: number) {\n this.#updateRenderTime(time);\n this.#pause();\n }\n\n /**\n * 获取当前帧的截图图像 返回的是一个base64\n */\n captureImage(): string {\n return this.#cvsEl.toDataURL();\n }\n\n get activeSprite() {\n return this.#spriteManager.activeSprite;\n }\n set activeSprite(s: VisibleSprite | null) {\n this.#spriteManager.activeSprite = s;\n }\n\n #sprMapAudioNode = new WeakMap<VisibleSprite, AudioNode>();\n /**\n * 添加 {@link VisibleSprite}\n * @param args {@link VisibleSprite}\n * @example\n * const sprite = new VisibleSprite(\n * new ImgClip({\n * type: 'image/gif',\n * stream: (await fetch('https://xx.gif')).body!,\n * }),\n * );\n */\n addSprite: SpriteManager['addSprite'] = async (vs) => {\n if (this.#audioCtx.state === 'suspended')\n this.#audioCtx.resume().catch(Log.error);\n\n const clip = vs.getClip();\n if (clip instanceof MediaStreamClip && clip.audioTrack != null) {\n const audioNode = this.#audioCtx.createMediaStreamSource(\n new MediaStream([clip.audioTrack]),\n );\n audioNode.connect(this.#captureAudioDest);\n this.#sprMapAudioNode.set(vs, audioNode);\n }\n await this.#spriteManager.addSprite(vs);\n vs.preFrame(0);\n };\n /**\n * 删除 {@link VisibleSprite}\n * @param args\n * @returns\n * @example\n * const sprite = new VisibleSprite();\n * avCvs.removeSprite(sprite);\n */\n removeSprite: SpriteManager['removeSprite'] = (vs) => {\n this.#sprMapAudioNode.get(vs)?.disconnect();\n this.#spriteManager.removeSprite(vs);\n };\n\n /**\n * 销毁实例\n */\n destroy(): void {\n if (this.#destroyed) return;\n this.#destroyed = true;\n\n this.#audioCtx.close();\n this.#captureAudioDest.disconnect();\n this.#evtTool.destroy();\n this.#stopRender();\n this.#cvsEl.parentElement?.remove();\n this.#clears.forEach((fn) => fn());\n this.#playingAudioCache.clear();\n this.#spriteManager.destroy();\n }\n\n /**\n * 合成所有素材的图像与音频,返回实时媒体流 `MediaStream`\n *\n * 可用于 WebRTC 推流,或由 {@link [AVRecorder](../../av-recorder/classes/AVRecorder.html)} 录制生成视频文件\n *\n * @see [直播录制](https://webav-tech.github.io/WebAV/demo/4_2-recorder-avcanvas)\n *\n */\n captureStream(): MediaStream {\n if (this.#audioCtx.state === 'suspended') {\n this.#audioCtx.resume().catch(Log.error);\n }\n\n const ms = new MediaStream(\n this.#cvsEl\n .captureStream()\n .getTracks()\n .concat(this.#captureAudioDest.stream.getTracks()),\n );\n Log.info(\n 'AVCanvas.captureStream, tracks:',\n ms.getTracks().map((t) => t.kind),\n );\n return ms;\n }\n\n /**\n * 创建一个视频合成器 {@link [Combinator](../../av-cliper/classes/Combinator.html)} 实例,用于将当前画布添加的 Sprite 导出为视频文件流\n *\n * @param opts - 创建 Combinator 的可选参数\n * @throws 如果没有添加素材,会抛出错误\n *\n * @example\n * avCvs.createCombinator().output() // => ReadableStream\n *\n * @see [视频剪辑](https://webav-tech.github.io/WebAV/demo/6_4-video-editor)\n */\n async createCombinator(opts: ICombinatorOpts = {}) {\n Log.info('AVCanvas.createCombinator, opts:', opts);\n\n const com = new Combinator({ ...this.#opts, ...opts });\n const sprites = this.#spriteManager.getSprites({ time: false });\n if (sprites.length === 0) throw Error('No sprite added');\n\n for (const vs of sprites) {\n const os = new OffscreenSprite(vs.getClip());\n os.time = { ...vs.time };\n vs.copyStateTo(os);\n await com.addSprite(os);\n }\n return com;\n }\n\n /**\n * 设置背景图片\n * @param image 背景图片(ImageBitmap、HTMLImageElement 或 URL)\n * @param options 可选配置(如拉伸模式、透明度等)\n */\n async setBackgroundImage(\n image: ImageBitmap | HTMLImageElement | string,\n options: {\n mode?: 'cover' | 'contain' | 'stretch' | 'repeat';\n opacity?: number;\n blur?: number;\n } = {},\n ): Promise<void> {\n // 如果传入的是 URL 字符串,先加载图片\n let originalImage: ImageBitmap;\n if (typeof image === 'string') {\n const response = await fetch(image);\n const blob = await response.blob();\n originalImage = await createImageBitmap(blob);\n } else if (image instanceof HTMLImageElement) {\n // 如果是 HTMLImageElement,转换为 ImageBitmap\n originalImage = await createImageBitmap(image);\n } else {\n originalImage = image;\n }\n\n // 保存原始图像用于重新处理\n this.#originalBackgroundImage = originalImage;\n\n // 保存选项\n this.#backgroundOptions = {\n mode: options.mode || 'cover',\n opacity: options.opacity !== undefined ? options.opacity : 1,\n blur: options.blur !== undefined ? options.blur : 0,\n };\n\n // 如果需要模糊效果,预先处理图像\n if (this.#backgroundOptions.blur > 0) {\n // 创建离屏 Canvas 来应用模糊效果\n const offscreenCanvas = new OffscreenCanvas(\n originalImage.width,\n originalImage.height,\n );\n const offscreenCtx = offscreenCanvas.getContext('2d');\n\n if (offscreenCtx) {\n // 应用模糊效果\n offscreenCtx.filter = `blur(${this.#backgroundOptions.blur}px)`;\n\n // 绘制图像\n offscreenCtx.drawImage(originalImage, 0, 0);\n\n // 创建处理后的 ImageBitmap\n this.#backgroundImage = await createImageBitmap(offscreenCanvas);\n } else {\n // 如果无法创建上下文,使用原始图像\n this.#backgroundImage = originalImage;\n }\n } else {\n // 不需要模糊效果,直接使用原始图像\n this.#backgroundImage = originalImage;\n }\n }\n\n /**\n * 更新背景图片的模糊效果或透明度\n * @param options 可选配置(模式、透明度、模糊度)\n */\n async updateBackgroundOptions(\n options: {\n mode?: 'cover' | 'contain' | 'stretch' | 'repeat';\n opacity?: number;\n blur?: number;\n } = {},\n ): Promise<void> {\n if (!this.#originalBackgroundImage) return;\n\n // 更新选项\n if (options.mode !== undefined) {\n this.#backgroundOptions.mode = options.mode;\n }\n if (options.opacity !== undefined) {\n this.#backgroundOptions.opacity = options.opacity;\n }\n if (options.blur !== undefined) {\n this.#backgroundOptions.blur = options.blur;\n }\n\n // 如果模糊度发生变化,重新处理图像\n if (options.blur !== undefined) {\n if (this.#backgroundOptions.blur > 0) {\n // 创建离屏 Canvas 来应用模糊效果\n const offscreenCanvas = new OffscreenCanvas(\n this.#originalBackgroundImage.width,\n this.#originalBackgroundImage.height,\n );\n const offscreenCtx = offscreenCanvas.getContext('2d');\n\n if (offscreenCtx) {\n // 应用模糊效果\n offscreenCtx.filter = `blur(${this.#backgroundOptions.blur}px)`;\n\n // 绘制图像\n offscreenCtx.drawImage(this.#originalBackgroundImage, 0, 0);\n\n // 创建处理后的 ImageBitmap\n this.#backgroundImage = await createImageBitmap(offscreenCanvas);\n }\n } else {\n // 不需要模糊效果,直接使用原始图像\n this.#backgroundImage = this.#originalBackgroundImage;\n }\n }\n }\n\n /**\n * 清除背景图片,恢复使用纯色背景\n */\n clearBackgroundImage(): void {\n this.#backgroundImage = null;\n this.#originalBackgroundImage = null;\n }\n\n /**\n * 刷新当前画布内容\n * @description 强制重新渲染当前画布的所有内容,包括背景和所有精灵\n */\n refresh(): void {\n // 更新渲染时间并暂停播放,确保所有内容都会被重新渲染\n this.#updateRenderTime(this.#renderTime);\n this.#pause();\n }\n\n /**\n * 设置画布的坐标原点\n * @param x - 原点的 x 坐标(0-1 之间表示百分比,大于 1 表示具体像素值)\n * @param y - 原点的 y 坐标(0-1 之间表示百分比,大于 1 表示具体像素值)\n */\n setAnchor(x: number, y: number): void {\n // 计算实际锚点坐标\n const width = this.#cvsEl.width;\n const height = this.#cvsEl.height;\n\n // 如果 x, y 在 0-1 之间,认为是百分比值\n const anchorX = x >= 0 && x <= 1 ? x * width : x;\n const anchorY = y >= 0 && y <= 1 ? y * height : y;\n\n this.#anchor = { x: anchorX, y: anchorY };\n\n // 只需要触发重新渲染,让画布使用新的锚点\n this.#render();\n }\n}\n\nfunction convertPCM2AudioSource(pcmData: Float32Array[][], ctx: AudioContext) {\n const asArr: AudioBufferSourceNode[] = [];\n if (pcmData.length === 0) return asArr;\n\n for (const [chan0Buf, chan1Buf] of pcmData) {\n if (chan0Buf == null) continue;\n if (chan0Buf.length <= 0) continue;\n\n const buf = ctx.createBuffer(\n 2,\n chan0Buf.length,\n DEFAULT_AUDIO_CONF.sampleRate,\n );\n buf.copyToChannel(chan0Buf, 0);\n buf.copyToChannel(chan1Buf ?? chan0Buf, 1);\n const audioSource = ctx.createBufferSource();\n audioSource.buffer = buf;\n asArr.push(audioSource);\n }\n return asArr;\n}\n\n/**\n * 空背景音,让 dest 能持续收到音频数据,否则时间会异常偏移\n */\nfunction createEmptyOscillatorNode(ctx: AudioContext) {\n const osc = ctx.createOscillator();\n const real = new Float32Array([0, 0]);\n const imag = new Float32Array([0, 0]);\n const wave = ctx.createPeriodicWave(real, imag, {\n disableNormalization: true,\n });\n osc.setPeriodicWave(wave);\n osc.start();\n return osc;\n}\n\n/**\n * 绘制图片并保持宽高比填满整个目标区域(类似CSS的background-size: cover)\n * 图片可能会被裁剪,但不会变形\n */\nfunction drawImageCover(\n ctx: CanvasRenderingContext2D,\n img: ImageBitmap,\n x: number,\n y: number,\n width: number,\n height: number,\n): void {\n const imgRatio = img.width / img.height;\n const targetRatio = width / height;\n\n let drawWidth = width;\n let drawHeight = height;\n let offsetX = 0;\n let offsetY = 0;\n\n // 计算绘制尺寸和偏移量,保持宽高比\n if (targetRatio > imgRatio) {\n // 目标区域更宽,需要裁剪高度\n drawHeight = (width / img.width) * img.height;\n offsetY = (height - drawHeight) / 2;\n } else {\n // 目标区域更高,需要裁剪宽度\n drawWidth = (height / img.height) * img.width;\n offsetX = (width - drawWidth) / 2;\n }\n\n ctx.drawImage(img, x + offsetX, y + offsetY, drawWidth, drawHeight);\n}\n\n/**\n * 绘制图片并保持宽高比完整显示在目标区域内(类似CSS的background-size: contain)\n * 图片完整显示,但可能会有空白区域\n */\nfunction drawImageContain(\n ctx: CanvasRenderingContext2D,\n img: ImageBitmap,\n x: number,\n y: number,\n width: number,\n height: number,\n): void {\n const imgRatio = img.width / img.height;\n const targetRatio = width / height;\n\n let drawWidth = width;\n let drawHeight = height;\n let offsetX = 0;\n let offsetY = 0;\n\n // 计算绘制尺寸和偏移量,保持宽高比\n if (targetRatio < imgRatio) {\n // 目标区域更窄,宽度撑满,高度等比缩放\n drawHeight = (width / img.width) * img.height;\n offsetY = (height - drawHeight) / 2;\n } else {\n // 目标区域更宽,高度撑满,宽度等比缩放\n drawWidth = (height / img.height) * img.width;\n offsetX = (width - drawWidth) / 2;\n }\n\n ctx.drawImage(img, x + offsetX, y + offsetY, drawWidth, drawHeight);\n}\n"],"names":["CTRL_KEYS","createEl","tagName","createCtrlsGetter","cvsEl","ctrlSize","cvsResizeOb","entries","fisrtEntry","rectCtrlsGetter","rect","w","sz","hfSz","hfW","hfH","rtSz","hfRtSz","Rect","ESpriteManagerEvt","SpriteManager","__privateAdd","_sprites","_activeSprite","_evtTool","EventTool","__publicField","__privateGet","_renderTime","s","__privateSet","vs","a","b","props","spr","filter","time","as","renderCtrls","container","sprMng","cvsRatio","observer","syncCtrlElPos","rectEl","ctrlsEl","lastActSprEvtClear","createRectAndCtrlEl","offSprChange","k","d","x","y","h","angle","cvsCenterXInContainer","cvsCenterYInContainer","visualLeftXInCanvasCenter","visualTopYInCanvasCenter","activeSprite",