From 1068e6f41f5503f52913a050b5a94a0f2c5eeac4 Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sat, 13 Jan 2024 15:41:02 +0800 Subject: [PATCH 01/11] refactor: imporve the code structure of mp4-utils. --- .../__tests__/mp4-utils.test.ts | 4 +- .../src/{mp4-utils.ts => mp4-utils/index.ts} | 181 +----------------- .../av-cliper/src/mp4-utils/mp4-previewer.ts | 20 ++ .../av-cliper/src/mp4-utils/mp4box-utils.ts | 92 +++++++++ .../src/mp4-utils/sample-transform.ts | 91 +++++++++ 5 files changed, 211 insertions(+), 177 deletions(-) rename packages/av-cliper/src/{ => mp4-utils}/__tests__/mp4-utils.test.ts (96%) rename packages/av-cliper/src/{mp4-utils.ts => mp4-utils/index.ts} (84%) create mode 100644 packages/av-cliper/src/mp4-utils/mp4-previewer.ts create mode 100644 packages/av-cliper/src/mp4-utils/mp4box-utils.ts create mode 100644 packages/av-cliper/src/mp4-utils/sample-transform.ts diff --git a/packages/av-cliper/src/__tests__/mp4-utils.test.ts b/packages/av-cliper/src/mp4-utils/__tests__/mp4-utils.test.ts similarity index 96% rename from packages/av-cliper/src/__tests__/mp4-utils.test.ts rename to packages/av-cliper/src/mp4-utils/__tests__/mp4-utils.test.ts index 2b405ef4..2e462621 100644 --- a/packages/av-cliper/src/__tests__/mp4-utils.test.ts +++ b/packages/av-cliper/src/mp4-utils/__tests__/mp4-utils.test.ts @@ -1,7 +1,7 @@ -import './mock' +import '../../__tests__/mock' import { beforeAll, describe, expect, test, vi } from 'vitest' import mp4box from '@webav/mp4box.js' -import { file2stream } from '../mp4-utils' +import { file2stream } from '..' beforeAll(() => { vi.useFakeTimers() diff --git a/packages/av-cliper/src/mp4-utils.ts b/packages/av-cliper/src/mp4-utils/index.ts similarity index 84% rename from packages/av-cliper/src/mp4-utils.ts rename to packages/av-cliper/src/mp4-utils/index.ts index 4ac98a5e..00337935 100644 --- a/packages/av-cliper/src/mp4-utils.ts +++ b/packages/av-cliper/src/mp4-utils/index.ts @@ -5,22 +5,20 @@ import mp4box, { MP4Sample, SampleOpts, TrakBoxParser, - VideoTrackOpts, - AudioTrackOpts, - MP4ABoxParser } from '@webav/mp4box.js' -import { Log } from './log' +import { Log } from '../log' import { autoReadStream, extractPCM4AudioData, extractPCM4AudioBuffer, mixinPCM, ringSliceFloat32Array, - sleep, concatPCMFragments -} from './av-utils' -import { DEFAULT_AUDIO_CONF } from './clips' -import { EventTool } from './event-tool' +} from '../av-utils' +import { DEFAULT_AUDIO_CONF } from '../clips' +import { EventTool } from '../event-tool' +import { SampleTransform } from './sample-transform' +import { extractFileConfig } from './mp4box-utils' type TCleanFn = () => void @@ -379,94 +377,7 @@ export function _deprecated_stream2file(stream: ReadableStream): { } } -/** - * 将原始字节流转换成 MP4Sample 流 - */ -class SampleTransform { - readable: ReadableStream< - | { - chunkType: 'ready' - data: { info: MP4Info; file: MP4File } - } - | { - chunkType: 'samples' - data: { id: number; type: 'video' | 'audio'; samples: MP4Sample[] } - } - > - - writable: WritableStream - - #inputBufOffset = 0 - - constructor() { - const file = mp4box.createFile() - let outCtrlDesiredSize = 0 - let streamCancelled = false - this.readable = new ReadableStream( - { - start: ctrl => { - file.onReady = info => { - const vTrackId = info.videoTracks[0]?.id - if (vTrackId != null) - file.setExtractionOptions(vTrackId, 'video', { nbSamples: 100 }) - - const aTrackId = info.audioTracks[0]?.id - if (aTrackId != null) - file.setExtractionOptions(aTrackId, 'audio', { nbSamples: 100 }) - - ctrl.enqueue({ chunkType: 'ready', data: { info, file } }) - file.start() - } - - file.onSamples = (id, type, samples) => { - ctrl.enqueue({ - chunkType: 'samples', - data: { id, type, samples } - }) - outCtrlDesiredSize = ctrl.desiredSize ?? 0 - } - - file.onFlush = () => { - ctrl.close() - } - }, - pull: ctrl => { - outCtrlDesiredSize = ctrl.desiredSize ?? 0 - }, - cancel: () => { - file.stop() - streamCancelled = true - } - }, - { - // 每条消息 100 个 samples - highWaterMark: 50 - } - ) - - this.writable = new WritableStream({ - write: async ui8Arr => { - if (streamCancelled) { - this.writable.abort() - return - } - - const inputBuf = ui8Arr.buffer as MP4ArrayBuffer - inputBuf.fileStart = this.#inputBufOffset - this.#inputBufOffset += inputBuf.byteLength - file.appendBuffer(inputBuf) - // 等待输出的数据被消费 - while (outCtrlDesiredSize < 0) await sleep(50) - }, - close: () => { - file.flush() - file.stop() - file.onFlush?.() - } - }) - } -} export function file2stream( file: MP4File, @@ -661,24 +572,6 @@ function mp4File2OPFSFile(inMP4File: MP4File): () => (Promise) { } } -// track is H.264, H.265 or VPX. -function parseVideoCodecDesc(track: TrakBoxParser): Uint8Array { - for (const entry of track.mdia.minf.stbl.stsd.entries) { - // @ts-expect-error - const box = entry.avcC ?? entry.hvcC ?? entry.vpcC - if (box != null) { - const stream = new mp4box.DataStream( - undefined, - 0, - mp4box.DataStream.BIG_ENDIAN - ) - box.write(stream) - return new Uint8Array(stream.buffer.slice(8)) // Remove the box header. - } - } - throw Error('avcC, hvcC or VPX not found') -} - /** * EncodedAudioChunk | EncodedVideoChunk 转换为 MP4 addSample 需要的参数 */ @@ -699,61 +592,7 @@ function chunk2MP4SampleOpts( } } -function extractFileConfig(file: MP4File, info: MP4Info) { - const vTrack = info.videoTracks[0] - const rs: { - videoTrackConf?: VideoTrackOpts - videoDecoderConf?: Parameters[0] - audioTrackConf?: AudioTrackOpts - audioDecoderConf?: Parameters[0] - } = {} - if (vTrack != null) { - const videoDesc = parseVideoCodecDesc(file.getTrackById(vTrack.id)).buffer - const { descKey, type } = vTrack.codec.startsWith('avc1') - ? { descKey: 'avcDecoderConfigRecord', type: 'avc1' } - : vTrack.codec.startsWith('hvc1') - ? { descKey: 'hevcDecoderConfigRecord', type: 'hvc1' } - : { descKey: '', type: '' } - if (descKey !== '') { - rs.videoTrackConf = { - timescale: vTrack.timescale, - duration: vTrack.duration, - width: vTrack.video.width, - height: vTrack.video.height, - brands: info.brands, - type, - [descKey]: videoDesc - } - } - - rs.videoDecoderConf = { - codec: vTrack.codec, - codedHeight: vTrack.video.height, - codedWidth: vTrack.video.width, - description: videoDesc - } - } - const aTrack = info.audioTracks[0] - if (aTrack != null) { - rs.audioTrackConf = { - timescale: aTrack.timescale, - samplerate: aTrack.audio.sample_rate, - channel_count: aTrack.audio.channel_count, - hdlr: 'soun', - type: aTrack.codec.startsWith('mp4a') ? 'mp4a' : aTrack.codec, - description: getESDSBoxFromMP4File(file) - } - rs.audioDecoderConf = { - codec: aTrack.codec.startsWith('mp4a') - ? DEFAULT_AUDIO_CONF.codec - : aTrack.codec, - numberOfChannels: aTrack.audio.channel_count, - sampleRate: aTrack.audio.sample_rate - } - } - return rs -} /** * 快速顺序合并多个mp4流,要求所有mp4的属性是一致的 @@ -1168,11 +1007,3 @@ function createESDSBox(config: ArrayBuffer | ArrayBufferView) { return esdsBox } -function getESDSBoxFromMP4File(file: MP4File, codec = 'mp4a') { - const mp4aBox = file.moov?.traks.map( - t => t.mdia.minf.stbl.stsd.entries - ).flat() - .find(({ type }) => type === codec) as MP4ABoxParser - - return mp4aBox?.esds -} diff --git a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts new file mode 100644 index 00000000..75d898ff --- /dev/null +++ b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts @@ -0,0 +1,20 @@ +import { MP4Info, MP4File, MP4Sample } from "@webav/mp4box.js" +import { autoReadStream } from "../av-utils" +import { SampleTransform } from "./sample-transform" + +export class MP4Previewer { + constructor(stream: ReadableStream) { + autoReadStream(stream.pipeThrough(new SampleTransform()), { + onChunk: function (chunk: { chunkType: "ready"; data: { info: MP4Info; file: MP4File } } | { chunkType: "samples"; data: { id: number; type: "video" | "audio"; samples: MP4Sample[] } }): Promise { + throw new Error("Function not implemented.") + }, + onDone: function (): void { + throw new Error("Function not implemented.") + } + }) + } + + getVideoFrame(time: number): VideoFrame { + throw Error('Not implemented') + } +} diff --git a/packages/av-cliper/src/mp4-utils/mp4box-utils.ts b/packages/av-cliper/src/mp4-utils/mp4box-utils.ts new file mode 100644 index 00000000..b3c70ac2 --- /dev/null +++ b/packages/av-cliper/src/mp4-utils/mp4box-utils.ts @@ -0,0 +1,92 @@ +import mp4box, { + AudioTrackOpts, + MP4ABoxParser, + MP4File, + MP4Info, + TrakBoxParser, + VideoTrackOpts +} from '@webav/mp4box.js' +import { DEFAULT_AUDIO_CONF } from '../clips' + +export function extractFileConfig(file: MP4File, info: MP4Info) { + const vTrack = info.videoTracks[0] + const rs: { + videoTrackConf?: VideoTrackOpts + videoDecoderConf?: Parameters[0] + audioTrackConf?: AudioTrackOpts + audioDecoderConf?: Parameters[0] + } = {} + if (vTrack != null) { + const videoDesc = parseVideoCodecDesc(file.getTrackById(vTrack.id)).buffer + const { descKey, type } = vTrack.codec.startsWith('avc1') + ? { descKey: 'avcDecoderConfigRecord', type: 'avc1' } + : vTrack.codec.startsWith('hvc1') + ? { descKey: 'hevcDecoderConfigRecord', type: 'hvc1' } + : { descKey: '', type: '' } + if (descKey !== '') { + rs.videoTrackConf = { + timescale: vTrack.timescale, + duration: vTrack.duration, + width: vTrack.video.width, + height: vTrack.video.height, + brands: info.brands, + type, + [descKey]: videoDesc + } + } + + rs.videoDecoderConf = { + codec: vTrack.codec, + codedHeight: vTrack.video.height, + codedWidth: vTrack.video.width, + description: videoDesc + } + } + + const aTrack = info.audioTracks[0] + if (aTrack != null) { + rs.audioTrackConf = { + timescale: aTrack.timescale, + samplerate: aTrack.audio.sample_rate, + channel_count: aTrack.audio.channel_count, + hdlr: 'soun', + type: aTrack.codec.startsWith('mp4a') ? 'mp4a' : aTrack.codec, + description: getESDSBoxFromMP4File(file) + } + rs.audioDecoderConf = { + codec: aTrack.codec.startsWith('mp4a') + ? DEFAULT_AUDIO_CONF.codec + : aTrack.codec, + numberOfChannels: aTrack.audio.channel_count, + sampleRate: aTrack.audio.sample_rate + } + } + return rs +} + +// track is H.264, H.265 or VPX. +function parseVideoCodecDesc(track: TrakBoxParser): Uint8Array { + for (const entry of track.mdia.minf.stbl.stsd.entries) { + // @ts-expect-error + const box = entry.avcC ?? entry.hvcC ?? entry.vpcC + if (box != null) { + const stream = new mp4box.DataStream( + undefined, + 0, + mp4box.DataStream.BIG_ENDIAN + ) + box.write(stream) + return new Uint8Array(stream.buffer.slice(8)) // Remove the box header. + } + } + throw Error('avcC, hvcC or VPX not found') +} + +function getESDSBoxFromMP4File(file: MP4File, codec = 'mp4a') { + const mp4aBox = file.moov?.traks + .map(t => t.mdia.minf.stbl.stsd.entries) + .flat() + .find(({ type }) => type === codec) as MP4ABoxParser + + return mp4aBox?.esds +} diff --git a/packages/av-cliper/src/mp4-utils/sample-transform.ts b/packages/av-cliper/src/mp4-utils/sample-transform.ts new file mode 100644 index 00000000..9a8a7d8f --- /dev/null +++ b/packages/av-cliper/src/mp4-utils/sample-transform.ts @@ -0,0 +1,91 @@ +import mp4box, { MP4ArrayBuffer, MP4File, MP4Info, MP4Sample } from "@webav/mp4box.js"; +import { sleep } from "../av-utils"; + +/** + * 将原始字节流转换成 MP4Sample 流 + */ +export class SampleTransform { + readable: ReadableStream< + | { + chunkType: 'ready' + data: { info: MP4Info; file: MP4File } + } + | { + chunkType: 'samples' + data: { id: number; type: 'video' | 'audio'; samples: MP4Sample[] } + } + > + + writable: WritableStream + + #inputBufOffset = 0 + + constructor() { + const file = mp4box.createFile() + let outCtrlDesiredSize = 0 + let streamCancelled = false + this.readable = new ReadableStream( + { + start: ctrl => { + file.onReady = info => { + const vTrackId = info.videoTracks[0]?.id + if (vTrackId != null) + file.setExtractionOptions(vTrackId, 'video', { nbSamples: 100 }) + + const aTrackId = info.audioTracks[0]?.id + if (aTrackId != null) + file.setExtractionOptions(aTrackId, 'audio', { nbSamples: 100 }) + + ctrl.enqueue({ chunkType: 'ready', data: { info, file } }) + file.start() + } + + file.onSamples = (id, type, samples) => { + ctrl.enqueue({ + chunkType: 'samples', + data: { id, type, samples } + }) + outCtrlDesiredSize = ctrl.desiredSize ?? 0 + } + + file.onFlush = () => { + ctrl.close() + } + }, + pull: ctrl => { + outCtrlDesiredSize = ctrl.desiredSize ?? 0 + }, + cancel: () => { + file.stop() + streamCancelled = true + } + }, + { + // 每条消息 100 个 samples + highWaterMark: 50 + } + ) + + this.writable = new WritableStream({ + write: async ui8Arr => { + if (streamCancelled) { + this.writable.abort() + return + } + + const inputBuf = ui8Arr.buffer as MP4ArrayBuffer + inputBuf.fileStart = this.#inputBufOffset + this.#inputBufOffset += inputBuf.byteLength + file.appendBuffer(inputBuf) + + // 等待输出的数据被消费 + while (outCtrlDesiredSize < 0) await sleep(50) + }, + close: () => { + file.flush() + file.stop() + file.onFlush?.() + } + }) + } +} \ No newline at end of file From 5741ebf0cc6da7e2c2e565aebebe2c598f4fe5e1 Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sat, 13 Jan 2024 22:31:08 +0800 Subject: [PATCH 02/11] feat: MP4Previewer save data to opfs file --- packages/av-cliper/demo/mp4-previewer.demo.ts | 5 ++ packages/av-cliper/demo/mp4-previewer.html | 14 ++++ .../av-cliper/src/mp4-utils/mp4-previewer.ts | 73 ++++++++++++++++--- 3 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 packages/av-cliper/demo/mp4-previewer.demo.ts create mode 100644 packages/av-cliper/demo/mp4-previewer.html diff --git a/packages/av-cliper/demo/mp4-previewer.demo.ts b/packages/av-cliper/demo/mp4-previewer.demo.ts new file mode 100644 index 00000000..7f3e275a --- /dev/null +++ b/packages/av-cliper/demo/mp4-previewer.demo.ts @@ -0,0 +1,5 @@ +import { MP4Previewer } from "../src/mp4-utils/mp4-previewer"; + +const previewer = new MP4Previewer((await fetch('./video/webav1.mp4')).body!) + +console.log(previewer.getVideoFrame(5)) \ No newline at end of file diff --git a/packages/av-cliper/demo/mp4-previewer.html b/packages/av-cliper/demo/mp4-previewer.html new file mode 100644 index 00000000..037b2327 --- /dev/null +++ b/packages/av-cliper/demo/mp4-previewer.html @@ -0,0 +1,14 @@ + + + + + + + Chromakey + + + + + + + \ No newline at end of file diff --git a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts index 75d898ff..0b4c0b3c 100644 --- a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts +++ b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts @@ -1,17 +1,70 @@ -import { MP4Info, MP4File, MP4Sample } from "@webav/mp4box.js" -import { autoReadStream } from "../av-utils" -import { SampleTransform } from "./sample-transform" +import { MP4Info } from '@webav/mp4box.js' +import { autoReadStream } from '../av-utils' +import { Log } from '../log' +import { extractFileConfig } from './mp4box-utils' +import { SampleTransform } from './sample-transform' export class MP4Previewer { + #ready: Promise + constructor(stream: ReadableStream) { - autoReadStream(stream.pipeThrough(new SampleTransform()), { - onChunk: function (chunk: { chunkType: "ready"; data: { info: MP4Info; file: MP4File } } | { chunkType: "samples"; data: { id: number; type: "video" | "audio"; samples: MP4Sample[] } }): Promise { - throw new Error("Function not implemented.") - }, - onDone: function (): void { - throw new Error("Function not implemented.") - } + this.#ready = this.#init(stream) + } + + async #init(stream: ReadableStream) { + const opfsRoot = await navigator.storage.getDirectory() + const fileHandle = await opfsRoot.getFileHandle(Math.random().toString(), { + create: true + }) + const fileWriter = await fileHandle.createWritable() + const videoDecoder = new VideoDecoder({ + output: () => { }, + error: Log.error }) + + const trackIndex = [] + let offset = 0 + return new Promise((resolve, reject) => { + let mp4Info: MP4Info | null = null + autoReadStream(stream.pipeThrough(new SampleTransform()), { + onChunk: async ({ chunkType, data }): Promise => { + if (chunkType === 'ready') { + const { videoDecoderConf } = extractFileConfig(data.file, data.info) + if (videoDecoderConf == null) { + reject('Unsupported codec') + return + } + mp4Info = data.info + videoDecoder.configure(videoDecoderConf) + } + if (chunkType === 'samples' && data.type === 'video') { + for (const s of data.samples) { + trackIndex.push({ + offset, + size: s.data.byteLength, + cts: s.cts, + duration: s.duration + }) + offset += s.data.byteLength + await fileWriter.write(s.data) + } + } + }, + onDone: async () => { + await fileWriter.close() + console.log(4444, await fileHandle.getFile()) + if (mp4Info == null) { + reject('Parse failed') + return + } + resolve(mp4Info) + } + }) + }) + } + + async getInfo() { + return await this.#ready } getVideoFrame(time: number): VideoFrame { From 9292c00d23ea9f5be90c70f6923b110fb5756bf9 Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sun, 14 Jan 2024 17:58:14 +0800 Subject: [PATCH 03/11] feat: opfs file wrap --- .../av-cliper/src/mp4-utils/opfs-file-wrap.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts diff --git a/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts b/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts new file mode 100644 index 00000000..d803d9e8 --- /dev/null +++ b/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts @@ -0,0 +1,92 @@ + +interface FileSystemSyncAccessHandle { + read: (container: ArrayBuffer, opts: { at: number }) => void + write: (data: ArrayBuffer) => number + flush: () => void + getSize: () => number +} + +export class OPFSFileWrap { + #worker: Worker + + #cbId = 0 + + #cbFns: Record = {} + #initReady: Promise + + constructor(fileName: string) { + const createWorker = (): Worker => { + const blob = new Blob([`(${setup.toString()})()`]) + const url = URL.createObjectURL(blob) + return new Worker(url) + } + this.#worker = createWorker() + this.#worker.onmessage = this.#onMsg + this.#initReady = this.#postMsg('create', { fileName }) + } + + async #postMsg(evtType: 'create' | 'write' | 'read', args: any) { + if (evtType !== 'create') await this.#initReady + const cbId = this.#cbId + this.#cbId += 1 + + const rsP = new Promise((resolve) => { + this.#cbFns[cbId] = resolve + }) + + this.#worker.postMessage({ + cbId, + evtType, + args + }) + return rsP + } + + #onMsg = ({ data }: { data: { cbId: number, returnVal: unknown, evtType: string } }) => { + if (data.evtType === 'callback') { + this.#cbFns[data.cbId]?.(data.returnVal) + } + } + + async write(data: ArrayBuffer) { + return await this.#postMsg('write', { data }) + } + + async read(offset: number, size: number) { + return await this.#postMsg('read', { offset, size }) + } +} + +const setup = (): void => { + let accessHandle: FileSystemSyncAccessHandle + + async function createFile(fileName: string) { + const root = await navigator.storage.getDirectory(); + const draftHandle = await root.getFileHandle(fileName, { create: true }); + // @ts-expect-error + accessHandle = await draftHandle.createSyncAccessHandle() + } + + self.onmessage = async e => { + let returnVal + if (e.data.evtType === 'create') { + await createFile(e.data.args.fileName as string) + } else if (e.data.evtType === 'write') { + accessHandle.write(e.data.args.data) + accessHandle.flush() + } else if (e.data.evtType === 'read') { + const { offset, size } = e.data.args + const ab = new ArrayBuffer(size) + accessHandle.read(ab, { at: offset }) + returnVal = ab + } + const trans: Transferable[] = [] + if (returnVal instanceof ArrayBuffer) trans.push(returnVal) + self.postMessage({ + evtType: 'callback', + cbId: e.data.cbId, + returnVal + // @ts-expect-error + }, trans) + } +} \ No newline at end of file From 09fa636905518b3452964c98e6ad1b6c35bd243b Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sun, 14 Jan 2024 18:41:41 +0800 Subject: [PATCH 04/11] feat: find chunks --- packages/av-cliper/demo/mp4-previewer.demo.ts | 10 ++- packages/av-cliper/src/mp4-utils/index.ts | 13 +-- .../av-cliper/src/mp4-utils/mp4-previewer.ts | 81 ++++++++++++++----- .../av-cliper/src/mp4-utils/mp4box-utils.ts | 16 ++++ .../av-cliper/src/mp4-utils/opfs-file-wrap.ts | 2 +- 5 files changed, 88 insertions(+), 34 deletions(-) diff --git a/packages/av-cliper/demo/mp4-previewer.demo.ts b/packages/av-cliper/demo/mp4-previewer.demo.ts index 7f3e275a..faab5927 100644 --- a/packages/av-cliper/demo/mp4-previewer.demo.ts +++ b/packages/av-cliper/demo/mp4-previewer.demo.ts @@ -1,5 +1,13 @@ import { MP4Previewer } from "../src/mp4-utils/mp4-previewer"; +import { OPFSFileWrap } from "../src/mp4-utils/opfs-file-wrap"; const previewer = new MP4Previewer((await fetch('./video/webav1.mp4')).body!) -console.log(previewer.getVideoFrame(5)) \ No newline at end of file +console.log(previewer.getVideoFrame(5)) + + + +const opfsFile = new OPFSFileWrap('1111') + +await opfsFile.write(new Uint8Array([1, 2, 3, 4, 5])) +await opfsFile.write(new Uint8Array([6, 7, 8, 9, 0])) \ No newline at end of file diff --git a/packages/av-cliper/src/mp4-utils/index.ts b/packages/av-cliper/src/mp4-utils/index.ts index 00337935..615c305c 100644 --- a/packages/av-cliper/src/mp4-utils/index.ts +++ b/packages/av-cliper/src/mp4-utils/index.ts @@ -18,7 +18,7 @@ import { import { DEFAULT_AUDIO_CONF } from '../clips' import { EventTool } from '../event-tool' import { SampleTransform } from './sample-transform' -import { extractFileConfig } from './mp4box-utils' +import { extractFileConfig, sample2ChunkOpts } from './mp4box-utils' type TCleanFn = () => void @@ -949,17 +949,6 @@ export function mixinMP4AndAudio( return outStream } -function sample2ChunkOpts( - s: MP4Sample -): EncodedAudioChunkInit | EncodedVideoChunkInit { - return { - type: (s.is_sync ? 'key' : 'delta') as EncodedVideoChunkType, - timestamp: (1e6 * s.cts) / s.timescale, - duration: (1e6 * s.duration) / s.timescale, - data: s.data - } -} - function createESDSBox(config: ArrayBuffer | ArrayBufferView) { const configlen = config.byteLength const buf = new Uint8Array([ diff --git a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts index 0b4c0b3c..3115cade 100644 --- a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts +++ b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts @@ -1,58 +1,64 @@ -import { MP4Info } from '@webav/mp4box.js' +import { MP4Info, MP4Sample } from '@webav/mp4box.js' import { autoReadStream } from '../av-utils' import { Log } from '../log' -import { extractFileConfig } from './mp4box-utils' +import { extractFileConfig, sample2ChunkOpts } from './mp4box-utils' +import { OPFSFileWrap } from './opfs-file-wrap' import { SampleTransform } from './sample-transform' export class MP4Previewer { #ready: Promise + #videoSamples: Array & { + offset: number + timeEnd: number + data: null + }> = [] + + #opfsFile = new OPFSFileWrap(Math.random().toString()) + constructor(stream: ReadableStream) { this.#ready = this.#init(stream) } async #init(stream: ReadableStream) { - const opfsRoot = await navigator.storage.getDirectory() - const fileHandle = await opfsRoot.getFileHandle(Math.random().toString(), { - create: true - }) - const fileWriter = await fileHandle.createWritable() const videoDecoder = new VideoDecoder({ output: () => { }, error: Log.error }) - const trackIndex = [] let offset = 0 return new Promise((resolve, reject) => { let mp4Info: MP4Info | null = null autoReadStream(stream.pipeThrough(new SampleTransform()), { onChunk: async ({ chunkType, data }): Promise => { if (chunkType === 'ready') { - const { videoDecoderConf } = extractFileConfig(data.file, data.info) - if (videoDecoderConf == null) { + const { videoDecoderConf, videoTrackConf } = extractFileConfig(data.file, data.info) + if (videoDecoderConf == null || videoTrackConf == null) { reject('Unsupported codec') return } - mp4Info = data.info + mp4Info = { + ...data.info, + duration: videoTrackConf.duration ?? 0, + timescale: videoTrackConf.timescale + } videoDecoder.configure(videoDecoderConf) } if (chunkType === 'samples' && data.type === 'video') { for (const s of data.samples) { - trackIndex.push({ + this.#videoSamples.push({ + ...s, offset, - size: s.data.byteLength, - cts: s.cts, - duration: s.duration + timeEnd: s.cts + s.duration, + data: null }) offset += s.data.byteLength - await fileWriter.write(s.data) + await this.#opfsFile.write(s.data) } + // todo: 释放内存 } }, onDone: async () => { - await fileWriter.close() - console.log(4444, await fileHandle.getFile()) if (mp4Info == null) { reject('Parse failed') return @@ -63,11 +69,46 @@ export class MP4Previewer { }) } + #decodeVideoChunk(chunks: EncodedVideoChunk[]) { + + } + async getInfo() { return await this.#ready } - getVideoFrame(time: number): VideoFrame { - throw Error('Not implemented') + async getVideoFrame(time: number): Promise { + if (time < 0) return null + const info = await this.#ready + if (time > info.duration / info.timescale) return null + + let timeMapping = time * info.timescale + const chunks: EncodedVideoChunk[] = [] + // todo: 二分查找 + for (let i = 0; i < this.#videoSamples.length; i += 1) { + const si = this.#videoSamples[i] + if (si.cts <= timeMapping && si.timeEnd >= timeMapping) { + // 寻找最近的一个 关键帧 + if (!si.is_sync) { + for (let j = i - 1; j >= 0; j -= 1) { + const sj = this.#videoSamples[j] + if (sj.is_sync) { + chunks.push(new EncodedVideoChunk(sample2ChunkOpts({ + ...sj, + data: await this.#opfsFile.read(sj.offset, sj.size) + }))) + break + } + } + } + chunks.push(new EncodedVideoChunk(sample2ChunkOpts({ + ...si, + data: await this.#opfsFile.read(si.offset, si.size) + }))) + break + } + } + console.log(55555, chunks) } } + diff --git a/packages/av-cliper/src/mp4-utils/mp4box-utils.ts b/packages/av-cliper/src/mp4-utils/mp4box-utils.ts index b3c70ac2..7c94ed29 100644 --- a/packages/av-cliper/src/mp4-utils/mp4box-utils.ts +++ b/packages/av-cliper/src/mp4-utils/mp4box-utils.ts @@ -3,6 +3,7 @@ import mp4box, { MP4ABoxParser, MP4File, MP4Info, + MP4Sample, TrakBoxParser, VideoTrackOpts } from '@webav/mp4box.js' @@ -90,3 +91,18 @@ function getESDSBoxFromMP4File(file: MP4File, codec = 'mp4a') { return mp4aBox?.esds } + +export function sample2ChunkOpts(s: { + is_sync: boolean + cts: number + timescale: number + duration: number + data: ArrayBuffer +}): EncodedAudioChunkInit | EncodedVideoChunkInit { + return { + type: (s.is_sync ? 'key' : 'delta') as EncodedVideoChunkType, + timestamp: (1e6 * s.cts) / s.timescale, + duration: (1e6 * s.duration) / s.timescale, + data: s.data + } +} diff --git a/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts b/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts index d803d9e8..b03c544b 100644 --- a/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts +++ b/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts @@ -53,7 +53,7 @@ export class OPFSFileWrap { } async read(offset: number, size: number) { - return await this.#postMsg('read', { offset, size }) + return await this.#postMsg('read', { offset, size }) as ArrayBuffer } } From 04d35b2d90b07e16944e8c1a4004ccc6f41a1aa5 Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sun, 14 Jan 2024 20:47:40 +0800 Subject: [PATCH 05/11] feat: implement MP4Previewer.getVideoFrame --- packages/av-cliper/demo/mp4-previewer.demo.ts | 9 ++- .../av-cliper/src/mp4-utils/mp4-previewer.ts | 65 +++++++++++++++---- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/av-cliper/demo/mp4-previewer.demo.ts b/packages/av-cliper/demo/mp4-previewer.demo.ts index faab5927..a3e5d81f 100644 --- a/packages/av-cliper/demo/mp4-previewer.demo.ts +++ b/packages/av-cliper/demo/mp4-previewer.demo.ts @@ -3,10 +3,15 @@ import { OPFSFileWrap } from "../src/mp4-utils/opfs-file-wrap"; const previewer = new MP4Previewer((await fetch('./video/webav1.mp4')).body!) -console.log(previewer.getVideoFrame(5)) - +for (let i = 0; i < 10; i += 1) { + const t = performance.now() + const vf = await previewer.getVideoFrame(i) + console.log('cost:', performance.now() - t) + vf?.close() +} +// 测试 OPFSFileWrap const opfsFile = new OPFSFileWrap('1111') await opfsFile.write(new Uint8Array([1, 2, 3, 4, 5])) diff --git a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts index 3115cade..6c2c9c3f 100644 --- a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts +++ b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts @@ -16,16 +16,13 @@ export class MP4Previewer { #opfsFile = new OPFSFileWrap(Math.random().toString()) + #wrapDecoder: ReturnType | null = null + constructor(stream: ReadableStream) { this.#ready = this.#init(stream) } async #init(stream: ReadableStream) { - const videoDecoder = new VideoDecoder({ - output: () => { }, - error: Log.error - }) - let offset = 0 return new Promise((resolve, reject) => { let mp4Info: MP4Info | null = null @@ -42,7 +39,8 @@ export class MP4Previewer { duration: videoTrackConf.duration ?? 0, timescale: videoTrackConf.timescale } - videoDecoder.configure(videoDecoderConf) + + this.#wrapDecoder = wrapVideoDecoder(videoDecoderConf) } if (chunkType === 'samples' && data.type === 'video') { for (const s of data.samples) { @@ -69,14 +67,11 @@ export class MP4Previewer { }) } - #decodeVideoChunk(chunks: EncodedVideoChunk[]) { - - } - async getInfo() { return await this.#ready } + // time 单位秒 s async getVideoFrame(time: number): Promise { if (time < 0) return null const info = await this.#ready @@ -108,7 +103,55 @@ export class MP4Previewer { break } } - console.log(55555, chunks) + return new Promise((resolve) => { + this.#wrapDecoder?.decode(chunks, (vf, done) => { + if (done) resolve(vf) + else vf.close() + }) + }) } } +function wrapVideoDecoder(conf: VideoDecoderConfig) { + type OutputHandle = (vf: VideoFrame, done: boolean) => void + + let curCb: ((vf: VideoFrame) => void) | null = null + const vdec = new VideoDecoder({ + output: (vf) => { + curCb?.(vf) + }, + error: Log.error + }) + vdec.configure(conf) + + let tasks: Array<{ + chunks: EncodedVideoChunk[] + cb: (vf: VideoFrame, done: boolean) => void + }> = [] + + async function run() { + if (curCb != null) return + + const t = tasks.shift() + if (t == null) return + let i = 0 + curCb = (vf) => { + i += 1 + const done = i >= t.chunks.length + t.cb(vf, done) + if (done) { + curCb = null + run().catch(Log.error) + } + } + for (const chunk of t.chunks) vdec.decode(chunk) + await vdec.flush() + } + + return { + decode(chunks: EncodedVideoChunk[], cb: OutputHandle) { + tasks.push({ chunks, cb }) + run().catch(Log.error) + } + } +} \ No newline at end of file From 701791e1bbf18355ebfc59e711cce72b7d4bddd1 Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sun, 14 Jan 2024 21:09:14 +0800 Subject: [PATCH 06/11] feat: releaseUsedSamples --- .../av-cliper/src/mp4-utils/mp4-previewer.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts index 6c2c9c3f..e092cc4e 100644 --- a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts +++ b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts @@ -1,4 +1,4 @@ -import { MP4Info, MP4Sample } from '@webav/mp4box.js' +import { MP4File, MP4Info, MP4Sample } from '@webav/mp4box.js' import { autoReadStream } from '../av-utils' import { Log } from '../log' import { extractFileConfig, sample2ChunkOpts } from './mp4box-utils' @@ -18,6 +18,8 @@ export class MP4Previewer { #wrapDecoder: ReturnType | null = null + #cvs: OffscreenCanvas | null = null + constructor(stream: ReadableStream) { this.#ready = this.#init(stream) } @@ -26,6 +28,7 @@ export class MP4Previewer { let offset = 0 return new Promise((resolve, reject) => { let mp4Info: MP4Info | null = null + let mp4boxFile: MP4File | null = null autoReadStream(stream.pipeThrough(new SampleTransform()), { onChunk: async ({ chunkType, data }): Promise => { if (chunkType === 'ready') { @@ -39,21 +42,24 @@ export class MP4Previewer { duration: videoTrackConf.duration ?? 0, timescale: videoTrackConf.timescale } + mp4boxFile = data.file this.#wrapDecoder = wrapVideoDecoder(videoDecoderConf) } - if (chunkType === 'samples' && data.type === 'video') { - for (const s of data.samples) { - this.#videoSamples.push({ - ...s, - offset, - timeEnd: s.cts + s.duration, - data: null - }) - offset += s.data.byteLength - await this.#opfsFile.write(s.data) + if (chunkType === 'samples') { + if (data.type === 'video') { + for (const s of data.samples) { + this.#videoSamples.push({ + ...s, + offset, + timeEnd: s.cts + s.duration, + data: null + }) + offset += s.data.byteLength + await this.#opfsFile.write(s.data) + } } - // todo: 释放内存 + mp4boxFile?.releaseUsedSamples(data.id, data.samples.length) } }, onDone: async () => { From a351e82a8de3794462b80d02a2bcaedd9dbcc3ec Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sun, 14 Jan 2024 21:09:26 +0800 Subject: [PATCH 07/11] chore: rename symbol --- packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts b/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts index b03c544b..79a54520 100644 --- a/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts +++ b/packages/av-cliper/src/mp4-utils/opfs-file-wrap.ts @@ -16,7 +16,7 @@ export class OPFSFileWrap { constructor(fileName: string) { const createWorker = (): Worker => { - const blob = new Blob([`(${setup.toString()})()`]) + const blob = new Blob([`(${opfsWorkerSetup.toString()})()`]) const url = URL.createObjectURL(blob) return new Worker(url) } @@ -57,7 +57,7 @@ export class OPFSFileWrap { } } -const setup = (): void => { +const opfsWorkerSetup = (): void => { let accessHandle: FileSystemSyncAccessHandle async function createFile(fileName: string) { From 2d804ce6c0a73b6c0c44476a1749a3983ac03444 Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sun, 14 Jan 2024 21:25:24 +0800 Subject: [PATCH 08/11] feat: MP4Previewer.getImage --- packages/av-cliper/demo/mp4-previewer.demo.ts | 8 +++++--- packages/av-cliper/demo/mp4-previewer.html | 7 +++++++ packages/av-cliper/src/mp4-utils/mp4-previewer.ts | 15 +++++++++++++++ packages/av-cliper/src/mp4-utils/mp4box-utils.ts | 1 - 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/av-cliper/demo/mp4-previewer.demo.ts b/packages/av-cliper/demo/mp4-previewer.demo.ts index a3e5d81f..ea7e1d58 100644 --- a/packages/av-cliper/demo/mp4-previewer.demo.ts +++ b/packages/av-cliper/demo/mp4-previewer.demo.ts @@ -2,12 +2,14 @@ import { MP4Previewer } from "../src/mp4-utils/mp4-previewer"; import { OPFSFileWrap } from "../src/mp4-utils/opfs-file-wrap"; const previewer = new MP4Previewer((await fetch('./video/webav1.mp4')).body!) +const imgEl = document.querySelector('#img') as HTMLImageElement for (let i = 0; i < 10; i += 1) { const t = performance.now() - const vf = await previewer.getVideoFrame(i) - console.log('cost:', performance.now() - t) - vf?.close() + const img = await previewer.getImage(i) + console.log('cost:', performance.now() - t, img) + if (img == null) break + imgEl.src = img } diff --git a/packages/av-cliper/demo/mp4-previewer.html b/packages/av-cliper/demo/mp4-previewer.html index 037b2327..20054adf 100644 --- a/packages/av-cliper/demo/mp4-previewer.html +++ b/packages/av-cliper/demo/mp4-previewer.html @@ -5,9 +5,16 @@ Chromakey + + diff --git a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts index e092cc4e..cc9eda4a 100644 --- a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts +++ b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts @@ -19,6 +19,7 @@ export class MP4Previewer { #wrapDecoder: ReturnType | null = null #cvs: OffscreenCanvas | null = null + #ctx: OffscreenCanvasRenderingContext2D | null = null constructor(stream: ReadableStream) { this.#ready = this.#init(stream) @@ -43,6 +44,9 @@ export class MP4Previewer { timescale: videoTrackConf.timescale } mp4boxFile = data.file + const { width, height } = data.info.videoTracks[0].video + this.#cvs = new OffscreenCanvas(width, height) + this.#ctx = this.#cvs.getContext('2d') this.#wrapDecoder = wrapVideoDecoder(videoDecoderConf) } @@ -116,6 +120,17 @@ export class MP4Previewer { }) }) } + + async getImage(time: number) { + const vf = await this.getVideoFrame(time) + if (vf == null || this.#cvs == null || this.#ctx == null) return + + this.#ctx.drawImage(vf, 0, 0) + vf.close() + const src = URL.createObjectURL(await this.#cvs.convertToBlob()) + this.#ctx.clearRect(0, 0, this.#cvs.width, this.#cvs.height) + return src + } } function wrapVideoDecoder(conf: VideoDecoderConfig) { diff --git a/packages/av-cliper/src/mp4-utils/mp4box-utils.ts b/packages/av-cliper/src/mp4-utils/mp4box-utils.ts index 7c94ed29..e32b0b90 100644 --- a/packages/av-cliper/src/mp4-utils/mp4box-utils.ts +++ b/packages/av-cliper/src/mp4-utils/mp4box-utils.ts @@ -3,7 +3,6 @@ import mp4box, { MP4ABoxParser, MP4File, MP4Info, - MP4Sample, TrakBoxParser, VideoTrackOpts } from '@webav/mp4box.js' From 925c71893fdf2b25dac0aa6036ce1fc5961eeeb1 Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sun, 14 Jan 2024 22:44:07 +0800 Subject: [PATCH 09/11] feat: decode GOP --- packages/av-cliper/demo/mp4-previewer.html | 6 ++--- packages/av-cliper/src/mp4-utils/index.ts | 2 ++ .../av-cliper/src/mp4-utils/mp4-previewer.ts | 24 ++++++++++++------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/av-cliper/demo/mp4-previewer.html b/packages/av-cliper/demo/mp4-previewer.html index 20054adf..fc0944dd 100644 --- a/packages/av-cliper/demo/mp4-previewer.html +++ b/packages/av-cliper/demo/mp4-previewer.html @@ -4,11 +4,11 @@ - Chromakey + MP4Previewer diff --git a/packages/av-cliper/src/mp4-utils/index.ts b/packages/av-cliper/src/mp4-utils/index.ts index 615c305c..6944262b 100644 --- a/packages/av-cliper/src/mp4-utils/index.ts +++ b/packages/av-cliper/src/mp4-utils/index.ts @@ -20,6 +20,8 @@ import { EventTool } from '../event-tool' import { SampleTransform } from './sample-transform' import { extractFileConfig, sample2ChunkOpts } from './mp4box-utils' +export { MP4Previewer } from './mp4-previewer' + type TCleanFn = () => void interface IWorkerOpts { diff --git a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts index cc9eda4a..421899cf 100644 --- a/packages/av-cliper/src/mp4-utils/mp4-previewer.ts +++ b/packages/av-cliper/src/mp4-utils/mp4-previewer.ts @@ -88,31 +88,36 @@ export class MP4Previewer { if (time > info.duration / info.timescale) return null let timeMapping = time * info.timescale - const chunks: EncodedVideoChunk[] = [] // todo: 二分查找 + let start = 0 + let end = 0 for (let i = 0; i < this.#videoSamples.length; i += 1) { const si = this.#videoSamples[i] if (si.cts <= timeMapping && si.timeEnd >= timeMapping) { + end = i // 寻找最近的一个 关键帧 if (!si.is_sync) { for (let j = i - 1; j >= 0; j -= 1) { const sj = this.#videoSamples[j] if (sj.is_sync) { - chunks.push(new EncodedVideoChunk(sample2ChunkOpts({ - ...sj, - data: await this.#opfsFile.read(sj.offset, sj.size) - }))) + start = j break } } } - chunks.push(new EncodedVideoChunk(sample2ChunkOpts({ - ...si, - data: await this.#opfsFile.read(si.offset, si.size) - }))) break } } + + const chunks = await Promise.all( + this.#videoSamples.slice(start, end + 1) + .map(async s => new EncodedVideoChunk(sample2ChunkOpts({ + ...s, + data: await this.#opfsFile.read(s.offset, s.size) + }))) + ) + if (chunks.length === 0) return Promise.resolve(null) + return new Promise((resolve) => { this.#wrapDecoder?.decode(chunks, (vf, done) => { if (done) resolve(vf) @@ -133,6 +138,7 @@ export class MP4Previewer { } } +// 封装 decoder,一次解析一个 GOP function wrapVideoDecoder(conf: VideoDecoderConfig) { type OutputHandle = (vf: VideoFrame, done: boolean) => void From 053d91ebcf76a344c3b18ea71e29a7bd052c0808 Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Sun, 14 Jan 2024 22:55:28 +0800 Subject: [PATCH 10/11] docs: MP4Previewer --- doc-site/docs/demo/1_4-mp4-previewer.md | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 doc-site/docs/demo/1_4-mp4-previewer.md diff --git a/doc-site/docs/demo/1_4-mp4-previewer.md b/doc-site/docs/demo/1_4-mp4-previewer.md new file mode 100644 index 00000000..19d74a62 --- /dev/null +++ b/doc-site/docs/demo/1_4-mp4-previewer.md @@ -0,0 +1,46 @@ +--- +nav: DEMO +group: 解码 +order: 4 +--- + +# 视频预览 + +从 MP4 文件中提取指定时间的图像。 + +```tsx +import React, { useState } from 'react'; +import { Slider } from 'antd'; +import { MP4Previewer } from '@webav/av-cliper'; +import { assetsPrefix } from './utils'; + +const videoSrc = assetsPrefix(['video/webav1.mp4']); + +const previewer = new MP4Previewer((await fetch(videoSrc)).body!); +const mp4Info = await previewer.getInfo(); +const duration = Number((mp4Info.duration / mp4Info.timescale).toFixed(0)); + +export default function UI() { + const [imgSrc, setImgSrc] = useState(''); + + return ( +
+ { + setImgSrc(await previewer.getImage(val)); + }} + /> + {imgSrc && } +
+ ); +} +``` + +:::info +如果只是为了绘制图像,使用视频帧更合适,`await previewer.getVideoFrame(time)`。 + +**注意**,视频帧使用完需要立即调用 `videoFrame.close()` +::: From 59062707d67ede8c875aa736ecebec39ab7382bb Mon Sep 17 00:00:00 2001 From: hughfenghen Date: Mon, 15 Jan 2024 10:30:19 +0800 Subject: [PATCH 11/11] docs: MP4Previewer --- doc-site/docs/demo/1_4-mp4-previewer.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/doc-site/docs/demo/1_4-mp4-previewer.md b/doc-site/docs/demo/1_4-mp4-previewer.md index 19d74a62..5ff27960 100644 --- a/doc-site/docs/demo/1_4-mp4-previewer.md +++ b/doc-site/docs/demo/1_4-mp4-previewer.md @@ -6,7 +6,7 @@ order: 4 # 视频预览 -从 MP4 文件中提取指定时间的图像。 +从 MP4 文件中提取指定时间的图像,点击 Slider 预览任意时间点的图像。 ```tsx import React, { useState } from 'react'; @@ -25,14 +25,19 @@ export default function UI() { return (
- { - setImgSrc(await previewer.getImage(val)); - }} - /> +
+ 时间: +
+ { + setImgSrc(await previewer.getImage(val)); + }} + /> +
+
{imgSrc && }
);