@cut-workbench/core 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{canvas-size-Ct61OYjW.cjs → canvas-size-DIABzEW2.cjs} +212 -44
- package/dist/chunks/canvas-size-DIABzEW2.cjs.map +1 -0
- package/dist/chunks/{canvas-size-CM2oKApi.js → canvas-size-Dlnng74w.js} +168 -30
- package/dist/chunks/canvas-size-Dlnng74w.js.map +1 -0
- package/dist/chunks/id-CJsdlGei.js +35 -0
- package/dist/chunks/id-CJsdlGei.js.map +1 -0
- package/dist/chunks/id-DaCxD4-5.cjs +70 -0
- package/dist/chunks/id-DaCxD4-5.cjs.map +1 -0
- package/dist/chunks/{snap-DyIuO2hU.cjs → snap-Cfk15JVg.cjs} +2 -22
- package/dist/chunks/{snap-DyIuO2hU.cjs.map → snap-Cfk15JVg.cjs.map} +1 -1
- package/dist/chunks/{snap-CBV9G4xq.js → snap-DpkVPVJN.js} +3 -17
- package/dist/chunks/{snap-CBV9G4xq.js.map → snap-DpkVPVJN.js.map} +1 -1
- package/dist/chunks/thumbnails-DEjnao5r.cjs +81 -0
- package/dist/chunks/thumbnails-DEjnao5r.cjs.map +1 -0
- package/dist/chunks/thumbnails-qH0dX_Zs.js +64 -0
- package/dist/chunks/thumbnails-qH0dX_Zs.js.map +1 -0
- package/dist/chunks/{time-CnMfSVYX.cjs → time-CYEd91qN.cjs} +1 -69
- package/dist/chunks/time-CYEd91qN.cjs.map +1 -0
- package/dist/chunks/{time-C_gvkVl1.js → time-ChF1GwCy.js} +2 -34
- package/dist/chunks/time-ChF1GwCy.js.map +1 -0
- package/dist/chunks/{waveform-Ie-G5Lle.cjs → waveform-BYwDdKpC.cjs} +34 -15
- package/dist/chunks/waveform-BYwDdKpC.cjs.map +1 -0
- package/dist/chunks/{waveform-DJkRtMct.js → waveform-uyGNE01H.js} +32 -13
- package/dist/chunks/waveform-uyGNE01H.js.map +1 -0
- package/dist/engine/index.cjs +668 -76
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.js +648 -57
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/webav/exporter.d.ts +3 -1
- package/dist/engine/webav/import-files.d.ts +17 -2
- package/dist/engine/webav/import-pipeline.d.ts +10 -0
- package/dist/engine/webav/types.d.ts +15 -0
- package/dist/index.cjs +18 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/lib/error.d.ts +6 -0
- package/dist/media/thumbnails.d.ts +8 -0
- package/dist/project/schema.d.ts +16 -4
- package/dist/timeline/index.cjs +10 -6
- package/dist/timeline/index.d.ts +1 -0
- package/dist/timeline/index.js +4 -4
- package/dist/timeline/normalize.d.ts +28 -0
- package/dist/timeline/ops.d.ts +0 -4
- package/dist/timeline/snap.d.ts +0 -2
- package/dist/types.d.ts +14 -2
- package/package.json +1 -1
- package/dist/chunks/canvas-size-CM2oKApi.js.map +0 -1
- package/dist/chunks/canvas-size-Ct61OYjW.cjs.map +0 -1
- package/dist/chunks/import-pipeline-A8KkqtZT.cjs +0 -610
- package/dist/chunks/import-pipeline-A8KkqtZT.cjs.map +0 -1
- package/dist/chunks/import-pipeline-DLIdgrIb.js +0 -521
- package/dist/chunks/import-pipeline-DLIdgrIb.js.map +0 -1
- package/dist/chunks/time-C_gvkVl1.js.map +0 -1
- package/dist/chunks/time-CnMfSVYX.cjs.map +0 -1
- package/dist/chunks/waveform-DJkRtMct.js.map +0 -1
- package/dist/chunks/waveform-Ie-G5Lle.cjs.map +0 -1
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"import-pipeline-A8KkqtZT.cjs","names":["renderTxt2ImgBitmap","MP4Clip","fileToStream","AudioClip","ImgClip","DEFAULT_STILL_DURATION_US","EmbedSubtitlesClip","bufferToStream","usToMs","detectFileType","createId","asUs","computeWaveform","AudioClip","fileToStream"],"sources":["../../src/engine/webav/styles.ts","../../src/engine/webav/text-clip.ts","../../src/engine/webav/clip-factory.ts","../../src/media/thumbnails.ts","../../src/engine/webav/import-pipeline.ts"],"sourcesContent":["/**\r\n * 文字 / 字幕的默认样式。抽成独立模块,避免 clip-factory 与 text-clip 相互依赖样式常量。\r\n */\r\n\r\nimport type { SubtitleStyle, TextStyle } from '../../types'\r\n\r\nexport const DEFAULT_TEXT_STYLE: TextStyle = {\r\n fontSize: 64,\r\n color: '#ffffff',\r\n fontFamily: 'sans-serif',\r\n fontWeight: 600,\r\n fontStyle: 'normal',\r\n strokeColor: '#000000',\r\n strokeWidth: 0,\r\n shadowColor: 'rgba(0,0,0,0.6)',\r\n shadowBlur: 8,\r\n align: 'center',\r\n background: 'transparent',\r\n}\r\n\r\n/**\r\n * 字幕默认字体。\r\n *\r\n * ⚠️ 不要写 'Noto Sans SC' —— 大多数系统并未安装该字体,`EmbedSubtitlesClip` 会把\r\n * `ctx.font` 设为 `30px Noto Sans SC`,此时浏览器回退到默认字体,中文可能显示为方框。\r\n * 使用系统字体栈(sans-serif)由浏览器挑选可用的中文字体,最稳。\r\n */\r\nexport const DEFAULT_SUBTITLE_STYLE: SubtitleStyle = {\r\n color: '#ffffff',\r\n textBgColor: null,\r\n fontFamily: 'sans-serif',\r\n fontSize: 30,\r\n strokeStyle: '#000000',\r\n lineWidth: null,\r\n bottomOffset: 30,\r\n}\r\n","/**\r\n * 自定义 IClip:把一段文字渲染成逐帧位图,实现动画标题。\r\n *\r\n * 之所以需要自研而不是直接用 ImgClip:\r\n * - ImgClip 的静态图 `meta.duration === Infinity`,Combinator 无法推断结束时间;\r\n * - 标题常常需要「打字机 / 闪烁」这类只跟时间有关的动画,用 tick 直接生成最直观。\r\n *\r\n * 单位约定:所有时间参数为微秒。\r\n */\r\n\r\nimport type { IClip } from '@webav/av-cliper'\r\nimport type { ClipMetaBase as IClipMeta } from './meta'\r\nimport { renderTxt2ImgBitmap } from '@webav/av-cliper'\r\nimport type { TextStyle } from '../../types'\r\nimport { DEFAULT_TEXT_STYLE } from './styles'\r\n\r\nexport type TextAnimation = 'none' | 'typewriter' | 'blink'\r\n\r\nexport interface TextClipOptions {\r\n text: string\r\n style: TextStyle\r\n width: number\r\n height: number\r\n durationUs: number\r\n animation?: TextAnimation\r\n /** 打字机动画每秒显示的字数 */\r\n charsPerSecond?: number\r\n /**\r\n * 动画相位偏移(微秒)。split 之后右半段需要从分割点继续动画,\r\n * 直接复用原 duration 会让动画重头播放。\r\n */\r\n animOffsetUs?: number\r\n}\r\n\r\nfunction cssFromStyle(style: TextStyle): string {\r\n const shadow = style.shadowBlur > 0\r\n ? `text-shadow: 0 2px ${style.shadowBlur}px ${style.shadowColor};`\r\n : ''\r\n const stroke = style.strokeWidth > 0\r\n ? `-webkit-text-stroke: ${style.strokeWidth}px ${style.strokeColor};`\r\n : ''\r\n return [\r\n `font-size:${style.fontSize}px`,\r\n `color:${style.color}`,\r\n `font-family:${style.fontFamily}`,\r\n `font-weight:${style.fontWeight}`,\r\n `font-style:${style.fontStyle}`,\r\n `text-align:${style.align}`,\r\n `background:${style.background}`,\r\n 'line-height:1.25',\r\n 'padding:8px 16px',\r\n 'white-space:pre-wrap',\r\n 'max-width:80%',\r\n stroke,\r\n shadow,\r\n ]\r\n .filter(Boolean)\r\n .join(';')\r\n}\r\n\r\n/**\r\n * 文字素材。内部缓存最近一次渲染的 ImageBitmap,按「文本 + 样式」做键避免重复渲染。\r\n */\r\nexport class TextClip implements IClip {\r\n readonly ready: Promise<IClipMeta>\r\n private opts: Required<Omit<TextClipOptions, 'animation' | 'charsPerSecond' | 'animOffsetUs'>> & {\r\n animation: TextAnimation\r\n charsPerSecond: number\r\n animOffsetUs: number\r\n }\r\n private metaValue: IClipMeta\r\n private cacheKey = ''\r\n private cachedBitmap: ImageBitmap | null = null\r\n private destroyed = false\r\n\r\n constructor(options: TextClipOptions) {\r\n this.opts = {\r\n ...options,\r\n style: { ...DEFAULT_TEXT_STYLE, ...options.style },\r\n animation: options.animation ?? 'none',\r\n charsPerSecond: options.charsPerSecond ?? 12,\r\n animOffsetUs: options.animOffsetUs ?? 0,\r\n }\r\n this.metaValue = {\r\n width: options.width,\r\n height: options.height,\r\n duration: Math.max(1, options.durationUs),\r\n }\r\n this.ready = Promise.resolve(this.metaValue)\r\n }\r\n\r\n get meta(): IClipMeta {\r\n return this.metaValue\r\n }\r\n\r\n private visibleTextAt(timeUs: number): string | null {\r\n const { text, animation, charsPerSecond } = this.opts\r\n if (animation === 'none') return text\r\n const phaseUs = timeUs + this.opts.animOffsetUs\r\n if (animation === 'typewriter') {\r\n const visible = Math.floor((phaseUs / 1_000_000) * charsPerSecond)\r\n if (visible <= 0) return null\r\n return text.slice(0, visible)\r\n }\r\n // blink:以 1Hz 闪烁\r\n const phase = Math.floor((phaseUs / 1_000_000) * 2) % 2\r\n return phase === 0 ? text : null\r\n }\r\n\r\n async tick(time: number): Promise<{\r\n video?: VideoFrame | ImageBitmap | null\r\n audio?: Float32Array[]\r\n state: 'done' | 'success'\r\n }> {\r\n if (this.destroyed) return { state: 'done' }\r\n if (time >= this.opts.durationUs) return { state: 'done' }\r\n\r\n const visible = this.visibleTextAt(time)\r\n if (visible == null || visible.length === 0) {\r\n // 空文本时返回一张透明的同尺寸位图,避免 sprite 保留上一帧\r\n const bitmap = await this.renderEmpty()\r\n return { video: bitmap, state: 'success' }\r\n }\r\n\r\n const key = `${visible}|${JSON.stringify(this.opts.style)}`\r\n if (key !== this.cacheKey || this.cachedBitmap == null) {\r\n this.cachedBitmap?.close()\r\n this.cachedBitmap = await this.renderTextToFrame(visible)\r\n this.cacheKey = key\r\n }\r\n // ⚠️ 必须返回副本:Sprite 持有上一帧时会 close 掉它(实测 OffscreenSprite/VisibleSprite\r\n // 都会 `prev?.close()`),直接返回缓存位图会被关闭,下一帧读像素直接抛错。\r\n return { video: await createImageBitmap(this.cachedBitmap), state: 'success' }\r\n }\r\n\r\n /**\r\n * 把文字渲染成「整帧」位图(工程分辨率)。\r\n *\r\n * 为什么不能直接把 renderTxt2ImgBitmap 的结果当帧用:\r\n * 它返回的是文字自身尺寸的小图(几十到几百像素宽),Sprite 会把它拉伸到 rect 的\r\n * 1280×720 —— 文字被放大后严重模糊,且居中位置由 Sprite 决定而非文字块决定。\r\n * 因此这里先渲染文字小图,再按比例居中合成到工程尺寸的画布上。\r\n */\r\n private async renderTextToFrame(text: string): Promise<ImageBitmap> {\r\n const { width, height } = this.opts\r\n const canvas = new OffscreenCanvas(width, height)\r\n const ctx = canvas.getContext('2d')\r\n if (ctx == null) throw new Error('无法创建文字渲染上下文')\r\n\r\n const layer = await renderTxt2ImgBitmap(text, cssFromStyle(this.opts.style))\r\n const align = this.opts.style.align\r\n const margin = Math.round(width * 0.05)\r\n // 文字超宽时等比缩小,保证完整可见\r\n const maxWidth = width - margin * 2\r\n const scale = layer.width > maxWidth ? maxWidth / layer.width : 1\r\n const drawW = Math.round(layer.width * scale)\r\n const drawH = Math.round(layer.height * scale)\r\n const x =\r\n align === 'left' ? margin : align === 'right' ? width - margin - drawW : (width - drawW) / 2\r\n const y = Math.round((height - drawH) / 2)\r\n\r\n ctx.drawImage(layer, x, y, drawW, drawH)\r\n layer.close()\r\n return canvas.transferToImageBitmap()\r\n }\r\n\r\n private emptyBitmap: ImageBitmap | null = null\r\n\r\n private async renderEmpty(): Promise<ImageBitmap> {\r\n if (this.emptyBitmap != null) return this.emptyBitmap\r\n const canvas = new OffscreenCanvas(this.opts.width, this.opts.height)\r\n this.emptyBitmap = canvas.transferToImageBitmap()\r\n return this.emptyBitmap\r\n }\r\n\r\n async split(time: number): Promise<[this, this]> {\r\n if (time <= 0 || time >= this.opts.durationUs) {\r\n throw new Error('\"time\" out of bounds')\r\n }\r\n const left = new TextClip({ ...this.opts, durationUs: time })\r\n const right = new TextClip({\r\n ...this.opts,\r\n durationUs: this.opts.durationUs - time,\r\n // 右半段继承动画相位,避免打字机动画从 0 重新开始\r\n animOffsetUs: this.opts.animOffsetUs + time,\r\n })\r\n await Promise.all([left.ready, right.ready])\r\n return [left as this, right as this]\r\n }\r\n\r\n async clone(): Promise<this> {\r\n const copy = new TextClip({ ...this.opts })\r\n await copy.ready\r\n return copy as this\r\n }\r\n\r\n destroy(): void {\r\n this.destroyed = true\r\n this.cachedBitmap?.close()\r\n this.cachedBitmap = null\r\n this.emptyBitmap?.close()\r\n this.emptyBitmap = null\r\n }\r\n}\r\n\r\nexport { cssFromStyle }\r\n","/**\r\n * File → IClip 的分派工厂。\r\n *\r\n * ⚠️ 本文件是 `@webav/*` 的合法引用点之一(另一个是 core/webav 下的其他模块)。\r\n * 不要在 features / store / components 中直接 import WebAV。\r\n *\r\n * 所有 clip 实例的生命周期由 `ClipRegistry` 统一管理,这里只负责创建。\r\n */\r\n\r\nimport {\r\n AudioClip,\r\n EmbedSubtitlesClip,\r\n ImgClip,\r\n MP4Clip,\r\n} from '@webav/av-cliper'\r\nimport type { IClip } from '@webav/av-cliper'\r\nimport type { MediaAsset, SubtitleStyle, TextStyle } from '../../types'\r\nimport type { MP4ClipMeta } from './meta'\r\nimport { bufferToStream, fileToStream } from '../../media/file-reader'\r\nimport { DEFAULT_STILL_DURATION_US } from '../../constants'\r\nimport { TextClip } from './text-clip'\r\nimport { DEFAULT_SUBTITLE_STYLE, DEFAULT_TEXT_STYLE } from './styles'\r\n\r\nexport interface AssetMetaSnapshot {\r\n durationUs: number\r\n width: number\r\n height: number\r\n hasAudio: boolean\r\n}\r\n\r\n/**\r\n * 创建素材的「母版」clip 并读取 meta。\r\n *\r\n * 对静态图/文字/字幕,`meta.duration` 可能是 Infinity,这里会归一成有限值:\r\n * 真正的时长由时间轴 clip 决定,母版只用于 clone 与抽帧。\r\n */\r\nexport async function createClipFromFile(\r\n file: File,\r\n kind: MediaAsset['kind'],\r\n): Promise<{ clip: IClip; meta: AssetMetaSnapshot }> {\r\n switch (kind) {\r\n case 'video': {\r\n const clip = new MP4Clip(fileToStream(file))\r\n const meta = (await clip.ready) as MP4ClipMeta\r\n return {\r\n clip,\r\n meta: {\r\n durationUs: meta.duration,\r\n width: meta.width,\r\n height: meta.height,\r\n hasAudio: meta.audioChanCount > 0,\r\n },\r\n }\r\n }\r\n case 'audio': {\r\n const clip = new AudioClip(fileToStream(file))\r\n const meta = await clip.ready\r\n return {\r\n clip,\r\n meta: {\r\n durationUs: meta.duration,\r\n width: 0,\r\n height: 0,\r\n hasAudio: true,\r\n },\r\n }\r\n }\r\n case 'image': {\r\n const clip = new ImgClip(fileToStream(file))\r\n const meta = await clip.ready\r\n return {\r\n clip,\r\n meta: {\r\n durationUs: Number.isFinite(meta.duration) ? meta.duration : DEFAULT_STILL_DURATION_US,\r\n width: meta.width,\r\n height: meta.height,\r\n hasAudio: false,\r\n },\r\n }\r\n }\r\n case 'subtitle': {\r\n const text = await file.text()\r\n const clip = new EmbedSubtitlesClip(text, {\r\n videoWidth: 1280,\r\n videoHeight: 720,\r\n })\r\n const meta = await clip.ready\r\n return {\r\n clip,\r\n meta: {\r\n durationUs: Number.isFinite(meta.duration) ? meta.duration : DEFAULT_STILL_DURATION_US,\r\n width: meta.width,\r\n height: meta.height,\r\n hasAudio: false,\r\n },\r\n }\r\n }\r\n default:\r\n throw new Error(`Unsupported asset kind: ${kind}`)\r\n }\r\n}\r\n\r\nexport interface TextClipInit {\r\n text: string\r\n style: TextStyle\r\n width: number\r\n height: number\r\n durationUs: number\r\n}\r\n\r\n/** 文字素材:不走文件,直接由 TextClip 生成 */\r\nexport function createTextClip(init: TextClipInit): IClip {\r\n return new TextClip({\r\n text: init.text,\r\n style: init.style,\r\n width: init.width,\r\n height: init.height,\r\n durationUs: init.durationUs,\r\n })\r\n}\r\n\r\nexport interface SubtitleClipInit {\r\n srt: string\r\n style: SubtitleStyle\r\n width: number\r\n height: number\r\n}\r\n\r\nexport function createSubtitleClip(init: SubtitleClipInit): IClip {\r\n return new EmbedSubtitlesClip(init.srt, {\r\n videoWidth: init.width,\r\n videoHeight: init.height,\r\n color: init.style.color,\r\n textBgColor: init.style.textBgColor,\r\n fontFamily: init.style.fontFamily,\r\n fontSize: init.style.fontSize,\r\n strokeStyle: init.style.strokeStyle,\r\n lineWidth: init.style.lineWidth,\r\n bottomOffset: init.style.bottomOffset,\r\n })\r\n}\r\n\r\n/** Worker 内重建 clip:只支持可序列化的素材类型 */\r\nexport function createClipFromBuffer(\r\n buffer: ArrayBuffer,\r\n kind: MediaAsset['kind'],\r\n opts?: {\r\n width?: number\r\n height?: number\r\n text?: string\r\n textStyle?: TextStyle\r\n srt?: string\r\n subtitleStyle?: SubtitleStyle\r\n durationUs?: number\r\n },\r\n): IClip {\r\n switch (kind) {\r\n case 'video':\r\n return new MP4Clip(bufferToStream(buffer))\r\n case 'audio':\r\n return new AudioClip(bufferToStream(buffer))\r\n case 'image':\r\n return new ImgClip(bufferToStream(buffer))\r\n case 'subtitle':\r\n return createSubtitleClip({\r\n srt: opts?.srt ?? '',\r\n style: opts?.subtitleStyle ?? DEFAULT_SUBTITLE_STYLE,\r\n width: opts?.width ?? 1280,\r\n height: opts?.height ?? 720,\r\n })\r\n case 'text':\r\n return createTextClip({\r\n text: opts?.text ?? '',\r\n style: opts?.textStyle ?? DEFAULT_TEXT_STYLE,\r\n width: opts?.width ?? 1280,\r\n height: opts?.height ?? 720,\r\n durationUs: opts?.durationUs ?? DEFAULT_STILL_DURATION_US,\r\n })\r\n default:\r\n throw new Error(`Worker 内不支持重建素材类型:${kind}`)\r\n }\r\n}\r\n","/**\r\n * 缩略图生成。`MP4Clip.thumbnails()` 封装 + 内存缓存。\r\n *\r\n * 注意:`thumbnails()` 默认每个关键帧一张;调用方若只要 N 张,应传 `step`。\r\n * 返回值是 Blob,转成 object URL 后要记得在素材移除时 revoke。\r\n */\r\n\r\nimport type { Us } from '../timeline/time'\r\nimport { usToMs } from '../timeline/time'\r\n\r\nexport interface ThumbnailFrame {\r\n ts: number\r\n url: string\r\n}\r\n\r\nconst cache = new Map<string, ThumbnailFrame[]>()\r\n\r\n/** 缩略图抽帧所需的最小接口(避免 core/media 直接依赖 @webav) */\r\nexport interface ThumbnailSource {\r\n thumbnails(\r\n imgWidth?: number,\r\n opts?: { start?: number; end?: number; step?: number },\r\n ): Promise<Array<{ ts: number; img: Blob }>>\r\n}\r\n\r\n/**\r\n * 生成等间隔缩略图条。\r\n *\r\n * @param clip MP4Clip 实例(调用方负责生命周期)\r\n * @param assetId 缓存 key\r\n * @param count 期望张数\r\n * @param durationUs 素材总时长,用来算 step\r\n * @param width 单张宽度\r\n */\r\nexport async function generateThumbnails(\r\n clip: ThumbnailSource,\r\n assetId: string,\r\n count: number,\r\n durationUs: number,\r\n width = 100,\r\n): Promise<ThumbnailFrame[]> {\r\n const cached = cache.get(assetId)\r\n if (cached) return cached\r\n\r\n const stepUs = Math.max(Math.floor(durationUs / count), usToMs(200))\r\n const raw = await clip.thumbnails(width, {\r\n start: 0,\r\n end: Math.max(0, durationUs - 1),\r\n step: stepUs,\r\n })\r\n\r\n // step 模式可能产生远多于 count 的张数(步长被内部按关键帧吸附)。\r\n // 这里做一次等距抽取,保证最多 count 张。\r\n const picked: Array<{ ts: number; img: Blob }> = []\r\n if (raw.length <= count) {\r\n picked.push(...raw)\r\n } else {\r\n const stride = raw.length / count\r\n for (let i = 0; i < count; i += 1) {\r\n const item = raw[Math.min(raw.length - 1, Math.floor(i * stride))]\r\n if (item) picked.push(item)\r\n }\r\n }\r\n\r\n const frames = picked.map((item) => ({\r\n ts: item.ts,\r\n url: URL.createObjectURL(item.img),\r\n }))\r\n cache.set(assetId, frames)\r\n return frames\r\n}\r\n\r\n/** 释放某素材的缩略图 URL */\r\nexport function releaseThumbnails(assetId: string): void {\r\n const frames = cache.get(assetId)\r\n if (!frames) return\r\n for (const f of frames) URL.revokeObjectURL(f.url)\r\n cache.delete(assetId)\r\n}\r\n\r\n/** 抽帧结果转成时间戳列表(Lab 使用) */\r\nexport function toTimestampList(frames: Array<{ ts: number }>): Us[] {\r\n return frames.map((f) => f.ts as Us)\r\n}\r\n","/**\r\n * 素材导入流水线(供 feature 与 Lab 共用)。\r\n *\r\n * 步骤:校验类型 → 创建母版 IClip → 读 meta → 抽缩略图 / 波形 → 写入 registry 与 store。\r\n *\r\n * ⚠️ 所有 WebAV 调用都落在 core 内;本文件位于 core/webav 下,允许 import '@webav/*'。\r\n */\r\n\r\nimport { AudioClip } from '@webav/av-cliper'\r\nimport type { IClip } from '@webav/av-cliper'\r\nimport type { MP4Clip } from '@webav/av-cliper'\r\nimport type { MediaAsset, TextStyle } from '../../types'\r\nimport { detectFileType, fileToStream, UnsupportedFileError } from '../../media/file-reader'\r\nimport { generateThumbnails } from '../../media/thumbnails'\r\nimport { computeWaveform } from '../../media/waveform'\r\nimport { DEFAULT_STILL_DURATION_US } from '../../constants'\r\nimport { asUs } from '../../timeline/time'\r\nimport { createId } from '../../lib/id'\r\nimport { createClipFromFile, createSubtitleClip, createTextClip } from './clip-factory'\r\nimport { DEFAULT_SUBTITLE_STYLE, DEFAULT_TEXT_STYLE } from './styles'\r\nimport type { ClipRegistry } from './clip-registry'\r\n\r\nexport interface ImportedAsset {\r\n asset: MediaAsset\r\n clipId: string\r\n /** 母版 clip(重连素材时用于替换旧的母版) */\r\n master?: IClip\r\n}\r\n\r\nconst THUMBNAIL_COUNT = 4\r\nconst THUMBNAIL_WIDTH = 120\r\nconst WAVEFORM_BUCKETS = 400\r\n\r\nexport interface ImportFileOptions {\r\n /**\r\n * 指定素材 id(宿主把自己的素材记录 id 作为 `MediaAsset.id`)。\r\n *\r\n * 传了就必须保证会话内唯一:重复注册会覆盖 registry 里的旧素材。\r\n */\r\n id?: string\r\n}\r\n\r\n/**\r\n * 导入单个文件,生成素材记录。\r\n *\r\n * @param onProgress 0~1 进度(用于卡片骨架/进度条)\r\n * @throws UnsupportedFileError 类型不支持时\r\n */\r\nexport async function importFile(\r\n registry: ClipRegistry,\r\n file: File,\r\n onProgress?: (progress: number) => void,\r\n options: ImportFileOptions = {},\r\n): Promise<ImportedAsset> {\r\n const detected = detectFileType(file)\r\n onProgress?.(0.1)\r\n\r\n const { clip, meta } = await createClipFromFile(file, detected.kind)\r\n onProgress?.(0.4)\r\n\r\n const assetId = options.id ?? createId('asset')\r\n const asset: MediaAsset = {\r\n id: assetId,\r\n name: file.name,\r\n kind: detected.kind,\r\n mimeType: detected.mimeType,\r\n size: file.size,\r\n durationUs: asUs(meta.durationUs),\r\n width: meta.width,\r\n height: meta.height,\r\n hasAudio: meta.hasAudio,\r\n createdAt: Date.now(),\r\n }\r\n\r\n if (detected.kind === 'video') {\r\n try {\r\n const frames = await generateThumbnails(\r\n clip as MP4Clip,\r\n assetId,\r\n THUMBNAIL_COUNT,\r\n meta.durationUs,\r\n THUMBNAIL_WIDTH,\r\n )\r\n asset.thumbnailUrls = frames.map((f) => f.url)\r\n } catch (err) {\r\n // 抽帧失败不应阻断导入:给出警告但保留素材\r\n console.warn('[importFile] 缩略图生成失败', err)\r\n }\r\n onProgress?.(0.7)\r\n }\r\n\r\n if (detected.kind === 'audio' || (detected.kind === 'video' && meta.hasAudio)) {\r\n try {\r\n if (detected.kind === 'audio') {\r\n const pcm = (clip as AudioClip).getPCMData()\r\n asset.waveform = computeWaveform(pcm, WAVEFORM_BUCKETS)\r\n }\r\n } catch (err) {\r\n console.warn('[importFile] 波形生成失败', err)\r\n }\r\n onProgress?.(0.85)\r\n }\r\n\r\n registry.registerAsset(asset, file, clip)\r\n onProgress?.(1)\r\n return { asset, clipId: assetId, master: clip }\r\n}\r\n\r\n/** 新建文字素材(非文件来源,无需二进制) */\r\nexport function createTextAsset(params: {\r\n registry: ClipRegistry\r\n text: string\r\n style?: Partial<TextStyle>\r\n width: number\r\n height: number\r\n durationUs?: number\r\n /** 指定素材 id;不传则生成 `asset_xxx` */\r\n id?: string\r\n}): ImportedAsset {\r\n const assetId = params.id ?? createId('asset')\r\n const style: TextStyle = { ...DEFAULT_TEXT_STYLE, ...params.style }\r\n const durationUs = params.durationUs ?? DEFAULT_STILL_DURATION_US\r\n\r\n const master = createTextClip({\r\n text: params.text,\r\n style,\r\n width: params.width,\r\n height: params.height,\r\n durationUs,\r\n })\r\n\r\n const asset: MediaAsset = {\r\n id: assetId,\r\n name: params.text.slice(0, 24) || '文字',\r\n kind: 'text',\r\n mimeType: 'text/x-title',\r\n size: 0,\r\n durationUs: asUs(durationUs),\r\n width: params.width,\r\n height: params.height,\r\n hasAudio: false,\r\n createdAt: Date.now(),\r\n text: params.text,\r\n textStyle: style,\r\n }\r\n\r\n params.registry.registerAsset(asset, null, master)\r\n return { asset, clipId: assetId }\r\n}\r\n\r\n/**\r\n * 解码素材的完整 PCM(48kHz 双声道),供 Worker 导出使用。\r\n *\r\n * ⚠️ 实测:Worker 内没有 AudioContext / OfflineAudioContext,\r\n * `new AudioClip(stream)` 会在 `decodeAudioData` 处抛错,因此必须在主线程解码后\r\n * 把 Float32Array 传过去(`new AudioClip(Float32Array[])` 走纯归一化分支)。\r\n *\r\n * @returns 声道数组;失败或素材无音轨时返回 undefined\r\n */\r\nexport async function loadPcmForAsset(\r\n registry: ClipRegistry,\r\n assetId: string,\r\n): Promise<Float32Array[] | undefined> {\r\n const asset = registry.getAsset(assetId)\r\n if (asset == null) return undefined\r\n const file = registry.getFile(assetId)\r\n if (file == null) return undefined\r\n try {\r\n // 视频素材也能用 decodeAudioData 解析(内部解码 AAC 音轨)\r\n const clip = new AudioClip(fileToStream(file))\r\n await clip.ready\r\n const pcm = clip.getPCMData().map((channel) => new Float32Array(channel))\r\n clip.destroy()\r\n return pcm\r\n } catch (err) {\r\n console.warn('[loadPcmForAsset] 音频解码失败', err)\r\n return undefined\r\n }\r\n}\r\n\r\n/** 解析素材的音频 PCM(供预览音频引擎注册) */\r\nexport async function loadAudioClipForAsset(\r\n registry: ClipRegistry,\r\n assetId: string,\r\n): Promise<AudioClip | null> {\r\n const file = registry.getFile(assetId)\r\n if (file == null) return null\r\n const asset = registry.getAsset(assetId)\r\n if (asset == null) return null\r\n if (asset.kind !== 'audio' && asset.kind !== 'video') return null\r\n const clip = new AudioClip(fileToStream(file))\r\n await clip.ready\r\n return clip\r\n}\r\n\r\n/** 用第一条字幕文本给素材命名(无内容时回退为「字幕」) */\r\nfunction subtitleName(srt: string): string {\r\n const firstLine =\r\n srt\r\n .split(/\\r?\\n/)\r\n .map((line) => line.trim())\r\n .find((line) => line.length > 0 && !line.includes('-->') && !/^\\d+$/.test(line)) ?? ''\r\n const base = firstLine || '字幕'\r\n return base.length > 20 ? `${base.slice(0, 20)}…` : base\r\n}\r\n\r\n/** 新建字幕素材(内容来自粘贴的 SRT,无源文件) */\r\nexport function createSubtitleAsset(params: {\r\n registry: ClipRegistry\r\n srt: string\r\n width: number\r\n height: number\r\n durationUs?: number\r\n style?: Partial<import('../../types').SubtitleStyle>\r\n /** 指定素材 id;不传则生成 `asset_xxx` */\r\n id?: string\r\n}): ImportedAsset {\r\n const assetId = params.id ?? createId('asset')\r\n const style = { ...DEFAULT_SUBTITLE_STYLE, ...params.style }\r\n const durationUs = params.durationUs ?? DEFAULT_STILL_DURATION_US\r\n\r\n const master = createSubtitleClip({\r\n srt: params.srt,\r\n style,\r\n width: params.width,\r\n height: params.height,\r\n })\r\n\r\n const asset: MediaAsset = {\r\n id: assetId,\r\n name: subtitleName(params.srt),\r\n kind: 'subtitle',\r\n mimeType: 'text/plain',\r\n size: params.srt.length,\r\n durationUs: asUs(durationUs),\r\n width: params.width,\r\n height: params.height,\r\n hasAudio: false,\r\n createdAt: Date.now(),\r\n srt: params.srt,\r\n subtitleStyle: style,\r\n }\r\n\r\n params.registry.registerAsset(asset, null, master)\r\n return { asset, clipId: assetId }\r\n}\r\n\r\nexport { UnsupportedFileError }\r\n"],"mappings":"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"}
|
|
@@ -1,521 +0,0 @@
|
|
|
1
|
-
import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
|
|
2
|
-
import { _ as DEFAULT_STILL_DURATION_US, f as usToMs, h as createId, i as asUs } from "./time-C_gvkVl1.js";
|
|
3
|
-
import { n as computeWaveform } from "./waveform-DJkRtMct.js";
|
|
4
|
-
import { i as fileToStream, n as bufferToStream, r as detectFileType, t as UnsupportedFileError } from "./file-reader-OjeI7HTg.js";
|
|
5
|
-
import { AudioClip, EmbedSubtitlesClip, ImgClip, MP4Clip, renderTxt2ImgBitmap } from "@webav/av-cliper";
|
|
6
|
-
// src/engine/webav/styles.ts
|
|
7
|
-
var DEFAULT_TEXT_STYLE = {
|
|
8
|
-
fontSize: 64,
|
|
9
|
-
color: "#ffffff",
|
|
10
|
-
fontFamily: "sans-serif",
|
|
11
|
-
fontWeight: 600,
|
|
12
|
-
fontStyle: "normal",
|
|
13
|
-
strokeColor: "#000000",
|
|
14
|
-
strokeWidth: 0,
|
|
15
|
-
shadowColor: "rgba(0,0,0,0.6)",
|
|
16
|
-
shadowBlur: 8,
|
|
17
|
-
align: "center",
|
|
18
|
-
background: "transparent"
|
|
19
|
-
};
|
|
20
|
-
/**
|
|
21
|
-
* 字幕默认字体。
|
|
22
|
-
*
|
|
23
|
-
* ⚠️ 不要写 'Noto Sans SC' —— 大多数系统并未安装该字体,`EmbedSubtitlesClip` 会把
|
|
24
|
-
* `ctx.font` 设为 `30px Noto Sans SC`,此时浏览器回退到默认字体,中文可能显示为方框。
|
|
25
|
-
* 使用系统字体栈(sans-serif)由浏览器挑选可用的中文字体,最稳。
|
|
26
|
-
*/
|
|
27
|
-
var DEFAULT_SUBTITLE_STYLE = {
|
|
28
|
-
color: "#ffffff",
|
|
29
|
-
textBgColor: null,
|
|
30
|
-
fontFamily: "sans-serif",
|
|
31
|
-
fontSize: 30,
|
|
32
|
-
strokeStyle: "#000000",
|
|
33
|
-
lineWidth: null,
|
|
34
|
-
bottomOffset: 30
|
|
35
|
-
};
|
|
36
|
-
// src/engine/webav/text-clip.ts
|
|
37
|
-
function cssFromStyle(style) {
|
|
38
|
-
const shadow = style.shadowBlur > 0 ? `text-shadow: 0 2px ${style.shadowBlur}px ${style.shadowColor};` : "";
|
|
39
|
-
const stroke = style.strokeWidth > 0 ? `-webkit-text-stroke: ${style.strokeWidth}px ${style.strokeColor};` : "";
|
|
40
|
-
return [
|
|
41
|
-
`font-size:${style.fontSize}px`,
|
|
42
|
-
`color:${style.color}`,
|
|
43
|
-
`font-family:${style.fontFamily}`,
|
|
44
|
-
`font-weight:${style.fontWeight}`,
|
|
45
|
-
`font-style:${style.fontStyle}`,
|
|
46
|
-
`text-align:${style.align}`,
|
|
47
|
-
`background:${style.background}`,
|
|
48
|
-
"line-height:1.25",
|
|
49
|
-
"padding:8px 16px",
|
|
50
|
-
"white-space:pre-wrap",
|
|
51
|
-
"max-width:80%",
|
|
52
|
-
stroke,
|
|
53
|
-
shadow
|
|
54
|
-
].filter(Boolean).join(";");
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* 文字素材。内部缓存最近一次渲染的 ImageBitmap,按「文本 + 样式」做键避免重复渲染。
|
|
58
|
-
*/
|
|
59
|
-
var TextClip = class TextClip {
|
|
60
|
-
ready;
|
|
61
|
-
opts;
|
|
62
|
-
metaValue;
|
|
63
|
-
cacheKey = "";
|
|
64
|
-
cachedBitmap = null;
|
|
65
|
-
destroyed = false;
|
|
66
|
-
constructor(options) {
|
|
67
|
-
this.opts = {
|
|
68
|
-
...options,
|
|
69
|
-
style: {
|
|
70
|
-
...DEFAULT_TEXT_STYLE,
|
|
71
|
-
...options.style
|
|
72
|
-
},
|
|
73
|
-
animation: options.animation ?? "none",
|
|
74
|
-
charsPerSecond: options.charsPerSecond ?? 12,
|
|
75
|
-
animOffsetUs: options.animOffsetUs ?? 0
|
|
76
|
-
};
|
|
77
|
-
this.metaValue = {
|
|
78
|
-
width: options.width,
|
|
79
|
-
height: options.height,
|
|
80
|
-
duration: Math.max(1, options.durationUs)
|
|
81
|
-
};
|
|
82
|
-
this.ready = Promise.resolve(this.metaValue);
|
|
83
|
-
}
|
|
84
|
-
get meta() {
|
|
85
|
-
return this.metaValue;
|
|
86
|
-
}
|
|
87
|
-
visibleTextAt(timeUs) {
|
|
88
|
-
const { text, animation, charsPerSecond } = this.opts;
|
|
89
|
-
if (animation === "none") return text;
|
|
90
|
-
const phaseUs = timeUs + this.opts.animOffsetUs;
|
|
91
|
-
if (animation === "typewriter") {
|
|
92
|
-
const visible = Math.floor(phaseUs / 1e6 * charsPerSecond);
|
|
93
|
-
if (visible <= 0) return null;
|
|
94
|
-
return text.slice(0, visible);
|
|
95
|
-
}
|
|
96
|
-
return Math.floor(phaseUs / 1e6 * 2) % 2 === 0 ? text : null;
|
|
97
|
-
}
|
|
98
|
-
async tick(time) {
|
|
99
|
-
if (this.destroyed) return { state: "done" };
|
|
100
|
-
if (time >= this.opts.durationUs) return { state: "done" };
|
|
101
|
-
const visible = this.visibleTextAt(time);
|
|
102
|
-
if (visible == null || visible.length === 0) return {
|
|
103
|
-
video: await this.renderEmpty(),
|
|
104
|
-
state: "success"
|
|
105
|
-
};
|
|
106
|
-
const key = `${visible}|${JSON.stringify(this.opts.style)}`;
|
|
107
|
-
if (key !== this.cacheKey || this.cachedBitmap == null) {
|
|
108
|
-
this.cachedBitmap?.close();
|
|
109
|
-
this.cachedBitmap = await this.renderTextToFrame(visible);
|
|
110
|
-
this.cacheKey = key;
|
|
111
|
-
}
|
|
112
|
-
return {
|
|
113
|
-
video: await createImageBitmap(this.cachedBitmap),
|
|
114
|
-
state: "success"
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* 把文字渲染成「整帧」位图(工程分辨率)。
|
|
119
|
-
*
|
|
120
|
-
* 为什么不能直接把 renderTxt2ImgBitmap 的结果当帧用:
|
|
121
|
-
* 它返回的是文字自身尺寸的小图(几十到几百像素宽),Sprite 会把它拉伸到 rect 的
|
|
122
|
-
* 1280×720 —— 文字被放大后严重模糊,且居中位置由 Sprite 决定而非文字块决定。
|
|
123
|
-
* 因此这里先渲染文字小图,再按比例居中合成到工程尺寸的画布上。
|
|
124
|
-
*/
|
|
125
|
-
async renderTextToFrame(text) {
|
|
126
|
-
const { width, height } = this.opts;
|
|
127
|
-
const canvas = new OffscreenCanvas(width, height);
|
|
128
|
-
const ctx = canvas.getContext("2d");
|
|
129
|
-
if (ctx == null) throw new Error("无法创建文字渲染上下文");
|
|
130
|
-
const layer = await renderTxt2ImgBitmap(text, cssFromStyle(this.opts.style));
|
|
131
|
-
const align = this.opts.style.align;
|
|
132
|
-
const margin = Math.round(width * .05);
|
|
133
|
-
const maxWidth = width - margin * 2;
|
|
134
|
-
const scale = layer.width > maxWidth ? maxWidth / layer.width : 1;
|
|
135
|
-
const drawW = Math.round(layer.width * scale);
|
|
136
|
-
const drawH = Math.round(layer.height * scale);
|
|
137
|
-
const x = align === "left" ? margin : align === "right" ? width - margin - drawW : (width - drawW) / 2;
|
|
138
|
-
const y = Math.round((height - drawH) / 2);
|
|
139
|
-
ctx.drawImage(layer, x, y, drawW, drawH);
|
|
140
|
-
layer.close();
|
|
141
|
-
return canvas.transferToImageBitmap();
|
|
142
|
-
}
|
|
143
|
-
emptyBitmap = null;
|
|
144
|
-
async renderEmpty() {
|
|
145
|
-
if (this.emptyBitmap != null) return this.emptyBitmap;
|
|
146
|
-
const canvas = new OffscreenCanvas(this.opts.width, this.opts.height);
|
|
147
|
-
this.emptyBitmap = canvas.transferToImageBitmap();
|
|
148
|
-
return this.emptyBitmap;
|
|
149
|
-
}
|
|
150
|
-
async split(time) {
|
|
151
|
-
if (time <= 0 || time >= this.opts.durationUs) throw new Error("\"time\" out of bounds");
|
|
152
|
-
const left = new TextClip({
|
|
153
|
-
...this.opts,
|
|
154
|
-
durationUs: time
|
|
155
|
-
});
|
|
156
|
-
const right = new TextClip({
|
|
157
|
-
...this.opts,
|
|
158
|
-
durationUs: this.opts.durationUs - time,
|
|
159
|
-
animOffsetUs: this.opts.animOffsetUs + time
|
|
160
|
-
});
|
|
161
|
-
await Promise.all([left.ready, right.ready]);
|
|
162
|
-
return [left, right];
|
|
163
|
-
}
|
|
164
|
-
async clone() {
|
|
165
|
-
const copy = new TextClip({ ...this.opts });
|
|
166
|
-
await copy.ready;
|
|
167
|
-
return copy;
|
|
168
|
-
}
|
|
169
|
-
destroy() {
|
|
170
|
-
this.destroyed = true;
|
|
171
|
-
this.cachedBitmap?.close();
|
|
172
|
-
this.cachedBitmap = null;
|
|
173
|
-
this.emptyBitmap?.close();
|
|
174
|
-
this.emptyBitmap = null;
|
|
175
|
-
}
|
|
176
|
-
};
|
|
177
|
-
// src/engine/webav/clip-factory.ts
|
|
178
|
-
/**
|
|
179
|
-
* File → IClip 的分派工厂。
|
|
180
|
-
*
|
|
181
|
-
* ⚠️ 本文件是 `@webav/*` 的合法引用点之一(另一个是 core/webav 下的其他模块)。
|
|
182
|
-
* 不要在 features / store / components 中直接 import WebAV。
|
|
183
|
-
*
|
|
184
|
-
* 所有 clip 实例的生命周期由 `ClipRegistry` 统一管理,这里只负责创建。
|
|
185
|
-
*/
|
|
186
|
-
/**
|
|
187
|
-
* 创建素材的「母版」clip 并读取 meta。
|
|
188
|
-
*
|
|
189
|
-
* 对静态图/文字/字幕,`meta.duration` 可能是 Infinity,这里会归一成有限值:
|
|
190
|
-
* 真正的时长由时间轴 clip 决定,母版只用于 clone 与抽帧。
|
|
191
|
-
*/
|
|
192
|
-
async function createClipFromFile(file, kind) {
|
|
193
|
-
switch (kind) {
|
|
194
|
-
case "video": {
|
|
195
|
-
const clip = new MP4Clip(fileToStream(file));
|
|
196
|
-
const meta = await clip.ready;
|
|
197
|
-
return {
|
|
198
|
-
clip,
|
|
199
|
-
meta: {
|
|
200
|
-
durationUs: meta.duration,
|
|
201
|
-
width: meta.width,
|
|
202
|
-
height: meta.height,
|
|
203
|
-
hasAudio: meta.audioChanCount > 0
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
case "audio": {
|
|
208
|
-
const clip = new AudioClip(fileToStream(file));
|
|
209
|
-
return {
|
|
210
|
-
clip,
|
|
211
|
-
meta: {
|
|
212
|
-
durationUs: (await clip.ready).duration,
|
|
213
|
-
width: 0,
|
|
214
|
-
height: 0,
|
|
215
|
-
hasAudio: true
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
case "image": {
|
|
220
|
-
const clip = new ImgClip(fileToStream(file));
|
|
221
|
-
const meta = await clip.ready;
|
|
222
|
-
return {
|
|
223
|
-
clip,
|
|
224
|
-
meta: {
|
|
225
|
-
durationUs: Number.isFinite(meta.duration) ? meta.duration : DEFAULT_STILL_DURATION_US,
|
|
226
|
-
width: meta.width,
|
|
227
|
-
height: meta.height,
|
|
228
|
-
hasAudio: false
|
|
229
|
-
}
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
case "subtitle": {
|
|
233
|
-
const text = await file.text();
|
|
234
|
-
const clip = new EmbedSubtitlesClip(text, {
|
|
235
|
-
videoWidth: 1280,
|
|
236
|
-
videoHeight: 720
|
|
237
|
-
});
|
|
238
|
-
const meta = await clip.ready;
|
|
239
|
-
return {
|
|
240
|
-
clip,
|
|
241
|
-
meta: {
|
|
242
|
-
durationUs: Number.isFinite(meta.duration) ? meta.duration : DEFAULT_STILL_DURATION_US,
|
|
243
|
-
width: meta.width,
|
|
244
|
-
height: meta.height,
|
|
245
|
-
hasAudio: false
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
default: throw new Error(`Unsupported asset kind: ${kind}`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
/** 文字素材:不走文件,直接由 TextClip 生成 */
|
|
253
|
-
function createTextClip(init) {
|
|
254
|
-
return new TextClip({
|
|
255
|
-
text: init.text,
|
|
256
|
-
style: init.style,
|
|
257
|
-
width: init.width,
|
|
258
|
-
height: init.height,
|
|
259
|
-
durationUs: init.durationUs
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
function createSubtitleClip(init) {
|
|
263
|
-
return new EmbedSubtitlesClip(init.srt, {
|
|
264
|
-
videoWidth: init.width,
|
|
265
|
-
videoHeight: init.height,
|
|
266
|
-
color: init.style.color,
|
|
267
|
-
textBgColor: init.style.textBgColor,
|
|
268
|
-
fontFamily: init.style.fontFamily,
|
|
269
|
-
fontSize: init.style.fontSize,
|
|
270
|
-
strokeStyle: init.style.strokeStyle,
|
|
271
|
-
lineWidth: init.style.lineWidth,
|
|
272
|
-
bottomOffset: init.style.bottomOffset
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
/** Worker 内重建 clip:只支持可序列化的素材类型 */
|
|
276
|
-
function createClipFromBuffer(buffer, kind, opts) {
|
|
277
|
-
switch (kind) {
|
|
278
|
-
case "video": return new MP4Clip(bufferToStream(buffer));
|
|
279
|
-
case "audio": return new AudioClip(bufferToStream(buffer));
|
|
280
|
-
case "image": return new ImgClip(bufferToStream(buffer));
|
|
281
|
-
case "subtitle": return createSubtitleClip({
|
|
282
|
-
srt: opts?.srt ?? "",
|
|
283
|
-
style: opts?.subtitleStyle ?? DEFAULT_SUBTITLE_STYLE,
|
|
284
|
-
width: opts?.width ?? 1280,
|
|
285
|
-
height: opts?.height ?? 720
|
|
286
|
-
});
|
|
287
|
-
case "text": return createTextClip({
|
|
288
|
-
text: opts?.text ?? "",
|
|
289
|
-
style: opts?.textStyle ?? DEFAULT_TEXT_STYLE,
|
|
290
|
-
width: opts?.width ?? 1280,
|
|
291
|
-
height: opts?.height ?? 720,
|
|
292
|
-
durationUs: opts?.durationUs ?? 5e6
|
|
293
|
-
});
|
|
294
|
-
default: throw new Error(`Worker 内不支持重建素材类型:${kind}`);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
// src/media/thumbnails.ts
|
|
298
|
-
var cache = /* @__PURE__ */ new Map();
|
|
299
|
-
/**
|
|
300
|
-
* 生成等间隔缩略图条。
|
|
301
|
-
*
|
|
302
|
-
* @param clip MP4Clip 实例(调用方负责生命周期)
|
|
303
|
-
* @param assetId 缓存 key
|
|
304
|
-
* @param count 期望张数
|
|
305
|
-
* @param durationUs 素材总时长,用来算 step
|
|
306
|
-
* @param width 单张宽度
|
|
307
|
-
*/
|
|
308
|
-
async function generateThumbnails(clip, assetId, count, durationUs, width = 100) {
|
|
309
|
-
const cached = cache.get(assetId);
|
|
310
|
-
if (cached) return cached;
|
|
311
|
-
const stepUs = Math.max(Math.floor(durationUs / count), usToMs(200));
|
|
312
|
-
const raw = await clip.thumbnails(width, {
|
|
313
|
-
start: 0,
|
|
314
|
-
end: Math.max(0, durationUs - 1),
|
|
315
|
-
step: stepUs
|
|
316
|
-
});
|
|
317
|
-
const picked = [];
|
|
318
|
-
if (raw.length <= count) picked.push(...raw);
|
|
319
|
-
else {
|
|
320
|
-
const stride = raw.length / count;
|
|
321
|
-
for (let i = 0; i < count; i += 1) {
|
|
322
|
-
const item = raw[Math.min(raw.length - 1, Math.floor(i * stride))];
|
|
323
|
-
if (item) picked.push(item);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
const frames = picked.map((item) => ({
|
|
327
|
-
ts: item.ts,
|
|
328
|
-
url: URL.createObjectURL(item.img)
|
|
329
|
-
}));
|
|
330
|
-
cache.set(assetId, frames);
|
|
331
|
-
return frames;
|
|
332
|
-
}
|
|
333
|
-
/** 释放某素材的缩略图 URL */
|
|
334
|
-
function releaseThumbnails(assetId) {
|
|
335
|
-
const frames = cache.get(assetId);
|
|
336
|
-
if (!frames) return;
|
|
337
|
-
for (const f of frames) URL.revokeObjectURL(f.url);
|
|
338
|
-
cache.delete(assetId);
|
|
339
|
-
}
|
|
340
|
-
// src/engine/webav/import-pipeline.ts
|
|
341
|
-
/**
|
|
342
|
-
* 素材导入流水线(供 feature 与 Lab 共用)。
|
|
343
|
-
*
|
|
344
|
-
* 步骤:校验类型 → 创建母版 IClip → 读 meta → 抽缩略图 / 波形 → 写入 registry 与 store。
|
|
345
|
-
*
|
|
346
|
-
* ⚠️ 所有 WebAV 调用都落在 core 内;本文件位于 core/webav 下,允许 import '@webav/*'。
|
|
347
|
-
*/
|
|
348
|
-
var import_pipeline_exports = /* @__PURE__ */ __exportAll({
|
|
349
|
-
UnsupportedFileError: () => UnsupportedFileError,
|
|
350
|
-
createSubtitleAsset: () => createSubtitleAsset,
|
|
351
|
-
createTextAsset: () => createTextAsset,
|
|
352
|
-
importFile: () => importFile,
|
|
353
|
-
loadAudioClipForAsset: () => loadAudioClipForAsset,
|
|
354
|
-
loadPcmForAsset: () => loadPcmForAsset
|
|
355
|
-
});
|
|
356
|
-
var THUMBNAIL_COUNT = 4;
|
|
357
|
-
var THUMBNAIL_WIDTH = 120;
|
|
358
|
-
var WAVEFORM_BUCKETS = 400;
|
|
359
|
-
/**
|
|
360
|
-
* 导入单个文件,生成素材记录。
|
|
361
|
-
*
|
|
362
|
-
* @param onProgress 0~1 进度(用于卡片骨架/进度条)
|
|
363
|
-
* @throws UnsupportedFileError 类型不支持时
|
|
364
|
-
*/
|
|
365
|
-
async function importFile(registry, file, onProgress, options = {}) {
|
|
366
|
-
const detected = detectFileType(file);
|
|
367
|
-
onProgress?.(.1);
|
|
368
|
-
const { clip, meta } = await createClipFromFile(file, detected.kind);
|
|
369
|
-
onProgress?.(.4);
|
|
370
|
-
const assetId = options.id ?? createId("asset");
|
|
371
|
-
const asset = {
|
|
372
|
-
id: assetId,
|
|
373
|
-
name: file.name,
|
|
374
|
-
kind: detected.kind,
|
|
375
|
-
mimeType: detected.mimeType,
|
|
376
|
-
size: file.size,
|
|
377
|
-
durationUs: asUs(meta.durationUs),
|
|
378
|
-
width: meta.width,
|
|
379
|
-
height: meta.height,
|
|
380
|
-
hasAudio: meta.hasAudio,
|
|
381
|
-
createdAt: Date.now()
|
|
382
|
-
};
|
|
383
|
-
if (detected.kind === "video") {
|
|
384
|
-
try {
|
|
385
|
-
asset.thumbnailUrls = (await generateThumbnails(clip, assetId, THUMBNAIL_COUNT, meta.durationUs, THUMBNAIL_WIDTH)).map((f) => f.url);
|
|
386
|
-
} catch (err) {
|
|
387
|
-
console.warn("[importFile] 缩略图生成失败", err);
|
|
388
|
-
}
|
|
389
|
-
onProgress?.(.7);
|
|
390
|
-
}
|
|
391
|
-
if (detected.kind === "audio" || detected.kind === "video" && meta.hasAudio) {
|
|
392
|
-
try {
|
|
393
|
-
if (detected.kind === "audio") {
|
|
394
|
-
const pcm = clip.getPCMData();
|
|
395
|
-
asset.waveform = computeWaveform(pcm, WAVEFORM_BUCKETS);
|
|
396
|
-
}
|
|
397
|
-
} catch (err) {
|
|
398
|
-
console.warn("[importFile] 波形生成失败", err);
|
|
399
|
-
}
|
|
400
|
-
onProgress?.(.85);
|
|
401
|
-
}
|
|
402
|
-
registry.registerAsset(asset, file, clip);
|
|
403
|
-
onProgress?.(1);
|
|
404
|
-
return {
|
|
405
|
-
asset,
|
|
406
|
-
clipId: assetId,
|
|
407
|
-
master: clip
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
/** 新建文字素材(非文件来源,无需二进制) */
|
|
411
|
-
function createTextAsset(params) {
|
|
412
|
-
const assetId = params.id ?? createId("asset");
|
|
413
|
-
const style = {
|
|
414
|
-
...DEFAULT_TEXT_STYLE,
|
|
415
|
-
...params.style
|
|
416
|
-
};
|
|
417
|
-
const durationUs = params.durationUs ?? 5e6;
|
|
418
|
-
const master = createTextClip({
|
|
419
|
-
text: params.text,
|
|
420
|
-
style,
|
|
421
|
-
width: params.width,
|
|
422
|
-
height: params.height,
|
|
423
|
-
durationUs
|
|
424
|
-
});
|
|
425
|
-
const asset = {
|
|
426
|
-
id: assetId,
|
|
427
|
-
name: params.text.slice(0, 24) || "文字",
|
|
428
|
-
kind: "text",
|
|
429
|
-
mimeType: "text/x-title",
|
|
430
|
-
size: 0,
|
|
431
|
-
durationUs: asUs(durationUs),
|
|
432
|
-
width: params.width,
|
|
433
|
-
height: params.height,
|
|
434
|
-
hasAudio: false,
|
|
435
|
-
createdAt: Date.now(),
|
|
436
|
-
text: params.text,
|
|
437
|
-
textStyle: style
|
|
438
|
-
};
|
|
439
|
-
params.registry.registerAsset(asset, null, master);
|
|
440
|
-
return {
|
|
441
|
-
asset,
|
|
442
|
-
clipId: assetId
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
/**
|
|
446
|
-
* 解码素材的完整 PCM(48kHz 双声道),供 Worker 导出使用。
|
|
447
|
-
*
|
|
448
|
-
* ⚠️ 实测:Worker 内没有 AudioContext / OfflineAudioContext,
|
|
449
|
-
* `new AudioClip(stream)` 会在 `decodeAudioData` 处抛错,因此必须在主线程解码后
|
|
450
|
-
* 把 Float32Array 传过去(`new AudioClip(Float32Array[])` 走纯归一化分支)。
|
|
451
|
-
*
|
|
452
|
-
* @returns 声道数组;失败或素材无音轨时返回 undefined
|
|
453
|
-
*/
|
|
454
|
-
async function loadPcmForAsset(registry, assetId) {
|
|
455
|
-
if (registry.getAsset(assetId) == null) return void 0;
|
|
456
|
-
const file = registry.getFile(assetId);
|
|
457
|
-
if (file == null) return void 0;
|
|
458
|
-
try {
|
|
459
|
-
const clip = new AudioClip(fileToStream(file));
|
|
460
|
-
await clip.ready;
|
|
461
|
-
const pcm = clip.getPCMData().map((channel) => new Float32Array(channel));
|
|
462
|
-
clip.destroy();
|
|
463
|
-
return pcm;
|
|
464
|
-
} catch (err) {
|
|
465
|
-
console.warn("[loadPcmForAsset] 音频解码失败", err);
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
/** 解析素材的音频 PCM(供预览音频引擎注册) */
|
|
470
|
-
async function loadAudioClipForAsset(registry, assetId) {
|
|
471
|
-
const file = registry.getFile(assetId);
|
|
472
|
-
if (file == null) return null;
|
|
473
|
-
const asset = registry.getAsset(assetId);
|
|
474
|
-
if (asset == null) return null;
|
|
475
|
-
if (asset.kind !== "audio" && asset.kind !== "video") return null;
|
|
476
|
-
const clip = new AudioClip(fileToStream(file));
|
|
477
|
-
await clip.ready;
|
|
478
|
-
return clip;
|
|
479
|
-
}
|
|
480
|
-
/** 用第一条字幕文本给素材命名(无内容时回退为「字幕」) */
|
|
481
|
-
function subtitleName(srt) {
|
|
482
|
-
const base = (srt.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0 && !line.includes("-->") && !/^\d+$/.test(line)) ?? "") || "字幕";
|
|
483
|
-
return base.length > 20 ? `${base.slice(0, 20)}…` : base;
|
|
484
|
-
}
|
|
485
|
-
/** 新建字幕素材(内容来自粘贴的 SRT,无源文件) */
|
|
486
|
-
function createSubtitleAsset(params) {
|
|
487
|
-
const assetId = params.id ?? createId("asset");
|
|
488
|
-
const style = {
|
|
489
|
-
...DEFAULT_SUBTITLE_STYLE,
|
|
490
|
-
...params.style
|
|
491
|
-
};
|
|
492
|
-
const durationUs = params.durationUs ?? 5e6;
|
|
493
|
-
const master = createSubtitleClip({
|
|
494
|
-
srt: params.srt,
|
|
495
|
-
style,
|
|
496
|
-
width: params.width,
|
|
497
|
-
height: params.height
|
|
498
|
-
});
|
|
499
|
-
const asset = {
|
|
500
|
-
id: assetId,
|
|
501
|
-
name: subtitleName(params.srt),
|
|
502
|
-
kind: "subtitle",
|
|
503
|
-
mimeType: "text/plain",
|
|
504
|
-
size: params.srt.length,
|
|
505
|
-
durationUs: asUs(durationUs),
|
|
506
|
-
width: params.width,
|
|
507
|
-
height: params.height,
|
|
508
|
-
hasAudio: false,
|
|
509
|
-
createdAt: Date.now(),
|
|
510
|
-
srt: params.srt,
|
|
511
|
-
subtitleStyle: style
|
|
512
|
-
};
|
|
513
|
-
params.registry.registerAsset(asset, null, master);
|
|
514
|
-
return {
|
|
515
|
-
asset,
|
|
516
|
-
clipId: assetId
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
export { loadAudioClipForAsset as a, createClipFromBuffer as c, createTextClip as d, TextClip as f, DEFAULT_TEXT_STYLE as h, import_pipeline_exports as i, createClipFromFile as l, DEFAULT_SUBTITLE_STYLE as m, createTextAsset as n, loadPcmForAsset as o, cssFromStyle as p, importFile as r, releaseThumbnails as s, createSubtitleAsset as t, createSubtitleClip as u };
|
|
520
|
-
|
|
521
|
-
//# sourceMappingURL=import-pipeline-DLIdgrIb.js.map
|