diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml new file mode 100644 index 0000000..1b22075 --- /dev/null +++ b/.github/workflows/release-beta.yml @@ -0,0 +1,30 @@ +name: publish package + +on: + workflow_dispatch: + +jobs: + publish: + runs-on: macos-12 + permissions: + contents: read + packages: write + steps: + - name: Download Artifact + id: download-artifact + uses: dawidd6/action-download-artifact@v2 + with: + name: output + search_artifacts: true + workflow: build.yml + + - name: npm release + # # Setup .npmrc file to publish to GitHub Packages + uses: actions/setup-node@v3 + with: + node-version: '16.x' + registry-url: 'https://registry.npmjs.org' + # # - run: npm install + - run: ls && tar -xvf output.tar.gz && cd output && npm publish --access public --tag beta + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 07684db..d4e6d5e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules script/ffmpeg-3.4.8 emsdk dist +.DS_Store diff --git a/README.md b/README.md index 27ef0ba..4d81b1f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ 基于自定义编译ffmpeg的截帧工具,支持Mp4、Mov、Avi、Webm、Mkv等主流格式。 ## API +> 整体调用流程,调用`initCapture`方法,传入worker和wasm路径,返回cheetahCapture对象,调用cheetahCapture上的方法进行mount文件、抽帧、获取元数据等操作,结束后调用free进行释放。 `initCapture`:`({workerPath, wasmPath}) => Promise` 初始化worker环境,拉取wasm,返回capture方法 接受参数如下 @@ -14,18 +15,32 @@ | workerPath | URL / string | woker路径,eg:node_modules/dist/capture.worker.js,因为有woker有同源限制,你可以传递BlobUrl来解决 | y | | wasmPath | URL / string | wasm路径,eg:node_modules/dist/capture.worker.wasm | y | +`mountFile`: `(file: File) => string` 挂载文件,返回mountFile的`fileName`,接受参数如下 +接受参数如下 +| 参数 | 类型 |含义 | 是否必须 | +| ---- | ---- |---- |---- | +| path | string | workerfs建立文件目录 | n,默认`/working` | +| file | File / blob | 文件 | y | + +为了兼容V0.1.x版本,`capture`方法兼容不调用mountFile可以直接使用,传入mountFile需要的参数,会在capture内部进行mountFile,如果您选择这种方式我们将会主动接管你的生命周期,为你进行内存释放操作。 `capture`: `(args) => void` 在worker里执行截帧方法 接受参数如下 | 参数 | 类型 |含义 | 是否必须 | | ---- | ---- |---- |---- | | info | number[] / number | 传递number是按照数目抽帧,传递数组是指定抽帧的时间 | y | | path | string | workerfs建立文件目录 | n | -| file | File / blob | 文件 | y | +| file | File / blob | 文件 | n,v0.1必须 | | onChange | (prev: PrevType, now: nowType, info: {width: number, height: number, duration: number}) => void | 当抽帧结果变化的回调 | n | | onSuccess | (prev: PrevType) => void | 当抽帧结束并成功的回调 | n | | onError | (errmeg: string) => void | 当抽帧过程出现错误的回调 | n | -例子可以参考 `demo/index.html`。 +`getMetadata: (args: {info: string; })=> void` 获取视频元数据,具体args参数如下 +| 参数 | 类型 |含义 | 是否必须 | +| ---- | ---- |---- |---- | +| info | string | 要获取的元数据的key | y | +| onSuccess | (args: {meta: string}) => void | 读取成功的回调,无论是否有该key都会执行,没有返回的空字符串 | n | + +* 使用例子可以参考 `demo/index.html`。 ## 依赖库&编译工具 diff --git a/demo/index.html b/demo/index.html index e69f553..d1cce88 100644 --- a/demo/index.html +++ b/demo/index.html @@ -40,20 +40,33 @@ console.log('===>file', file); let startTime = Date.now(); capturePro.then((res) => { - res.capture({ + res.mountFile({ file, - info: 11, - onChange: (list={}, now={}, info={}) => { - console.log('==>onchange', list, now, info); - const { width, height, duration } = info; - const img = document.createElement('img'); - img.src = now.url; - resultContainer.append(img); + onSuccess: () => { + console.log('===>mountFile success'); + res.getMetadata({ + info: 'aigc', + onSuccess: (data) => { + console.log('===>getMetadata', data); + } + }); + res.capture({ + file, + info: 11, + onChange: (list = {}, now = {}, info = {}) => { + console.log('==>onchange', list, now, info); + const { width, height, duration } = info; + const img = document.createElement('img'); + img.src = now.url; + resultContainer.append(img); - infoContainer.innerHTML = `耗时:${Date.now() - startTime}ms
宽度:${width}
高度:${height}
时长:${duration}s`; - }, - onSuccess: (list) => { - console.log('==>onSuccess', list); + infoContainer.innerHTML = `耗时:${Date.now() - startTime}ms
宽度:${width}
高度:${height}
时长:${duration}s`; + }, + onSuccess: (list) => { + console.log('==>onSuccess', list); + res.free({onSuccess: () => {console.log('===free success')}}); + } + }) } }) }) diff --git a/package.json b/package.json index 285c4e6..90ffa33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cheetah-capture", - "version": "0.1.6", + "version": "0.2.0-beta.1", "description": "cheetah-capture是基于ffmpeg的wasm截取视频帧工具", "keywords": [ "ffmpeg", diff --git a/script/build_wasm.sh b/script/build_wasm.sh index bca967c..289f8db 100755 --- a/script/build_wasm.sh +++ b/script/build_wasm.sh @@ -21,7 +21,7 @@ emcc $WEB_CAPTURE_PATH/src/capture.c $FFMPEG_PATH/lib/libavformat.a $FFMPEG_PATH -s WASM=1 \ -s TOTAL_MEMORY=$TOTAL_MEMORY \ -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' \ - -s EXPORTED_FUNCTIONS='["_main", "_free", "_captureByMs", "_captureByCount"]' \ + -s EXPORTED_FUNCTIONS='["_main", "_free", "_captureByMs", "_captureByCount", "_getMetaDataByKey"]' \ -s ASSERTIONS=0 \ -s ALLOW_MEMORY_GROWTH=1 \ -o $WEB_CAPTURE_PATH/dist/capture.worker.js diff --git a/src/capture.c b/src/capture.c index 1a0b42c..0d269c6 100644 --- a/src/capture.c +++ b/src/capture.c @@ -554,6 +554,36 @@ ImageData **captureByMs(char *ms, char *path, int id) avformat_close_input(&pFormatCtx); return dataList; } +// 获取视频的元数据 +char *getMetaDataByKey(const char *key, const char *path) { + AVFormatContext *pFormatCtx = avformat_alloc_context(); + if (!pFormatCtx) { + fprintf(stderr, "Could not allocate AVFormatContext.\n"); + return NULL; + } + + AVCodec *pCodec = NULL; + int videoStream = -1; + AVCodecContext *pCodecCtx = initFileAndGetInfo(pFormatCtx, path, pCodec, &videoStream); + + if (!pCodecCtx) { + fprintf(stderr, "Failed to initialize file and get codec context.\n"); + avformat_free_context(pFormatCtx); + return NULL; + } + + const char *value = dump_metadata(NULL, pFormatCtx->metadata, key, ""); + avformat_free_context(pFormatCtx); + + if (!value) { + fprintf(stderr, "Failed to get metadata value.\n"); + return NULL; + } + + // 创建一个字符串副本,因为原始值可能随 pFormatCtx 释放而失效 + char *result = strdup(value); + return result; +} int main(int argc, char const *argv[]) { av_register_all(); diff --git a/src/capture.worker.ts b/src/capture.worker.ts index 6399760..58be461 100644 --- a/src/capture.worker.ts +++ b/src/capture.worker.ts @@ -1,3 +1,4 @@ +import {Events} from './consts'; interface CMsgRetType { width: number; height: number; @@ -12,6 +13,10 @@ class ImageCapture { imageList: Record; imgDataPtrList: number[]; imgBufferPtrList: number[]; + name: string; + path: string; + file: File | Blob; + constructor() { this.isMKDIR = false; this.cCaptureByCount = null; @@ -20,6 +25,9 @@ class ImageCapture { this.captureInfo = {}; this.imgDataPtrList = []; this.imgBufferPtrList = []; + this.name = ''; + this.file = null; + this.path = '/working'; } getImageInfo(imgDataPtr): CMsgRetType { const width = Module.HEAPU32[imgDataPtr]; @@ -90,11 +98,13 @@ class ImageCapture { return dataArr; } // 加载文件 - mountFile(file: File | Blob, MOUNT_DIR: string, id: number) { + mountFile(file: File | Blob, MOUNT_DIR: string = this.path, id: number) { + // 防止重复mount dir if (!this.isMKDIR) { FS.mkdir(MOUNT_DIR); this.isMKDIR = true; } + this.file = file; const data: {files?: File[], blobs?: Array<{name: string, data: Blob}>} = {}; let name: string = ''; // 判断类型 如果是blob转file @@ -108,9 +118,15 @@ class ImageCapture { } // @ts-ignore FS.mount(WORKERFS, data, MOUNT_DIR); + this.name = name; + this.path = MOUNT_DIR; + self.postMessage({ + type: Events.mountFileSuccess, + id, + }); return name; } - free() { + free({id}) { // 释放指针内存 this.imgDataPtrList.forEach(ptr => { Module._free(ptr); @@ -120,10 +136,26 @@ class ImageCapture { Module._free(ptr); }); this.imgBufferPtrList = []; + // 释放文件 + FS.unmount(this.path); + // 清除副作用 + this.isMKDIR = false; + this.name = ''; + this.file = null; + this.path = '/working'; + self.postMessage({ + type: Events.freeOnSuccess, + id, + }); } - capture({id, info, path = '/working', file}) { + capture({id, info, path = this.path, file = this.file}) { + let isOnce = false; + this.path = path; try { - const name = this.mountFile(file, path, id); + if (!this.name) { + isOnce = true; + this.name = this.mountFile(file, path, id); + } let retData = 0; this.imageList[id] = []; if (info instanceof Array) { @@ -132,22 +164,22 @@ class ImageCapture { if (!this.cCaptureByMs) { this.cCaptureByMs = Module.cwrap('captureByMs', 'number', ['string', 'string', 'number']); } - // const imgDataPtr = - retData = this.cCaptureByMs(info.join(','), `${path}/${name}`, id); - this.free(); + retData = this.cCaptureByMs(info.join(','), `${path}/${this.name}`, id); } else { this.captureInfo[id] = info; if (!this.cCaptureByCount) { this.cCaptureByCount = Module.cwrap('captureByCount', 'number', ['number', 'string', 'number']); } - retData = this.cCaptureByCount(info, `${path}/${name}`, id); - this.free(); - FS.unmount(path); + retData = this.cCaptureByCount(info, `${path}/${this.name}`, id); // 完善信息 这里需要一种模式 是否只一次性postmsg 不一张张读取 if (retData === 0) { + this.free(); throw new Error('Frame draw exception!'); } } + if (isOnce) { + this.free(); + } } catch (e) { console.log('Error occurred', e); // 如果发生错误 通知 @@ -158,9 +190,21 @@ class ImageCapture { }); } } - getMetaData(key) { - // 获取视频元数据 - + + getMetadata(key: string, id: number) { + // getMetadata(key, path, id) + if (!this.name) { + throw new Error('Please mount file first!'); + } + const cGetMetadata = Module.cwrap('getMetaDataByKey', 'string', ['string', 'string', 'number']); + const metadataValue = cGetMetadata(key, `${this.path}/${this.name}`, id); + // cGetMetadata(); + + self.postMessage({ + type: Events.getMetadataOnSuccess, + meta: metadataValue, + id, + }); } } @@ -169,6 +213,43 @@ const imageCapture = new ImageCapture(); let isInit = false; const metaDataMap = {}; + +self.addEventListener('message', e => { + const { + type, + id, + info, + path, + file, + } = e.data; + if (type === 'initPath') { + (self as any).goOnInit(info); + } + + if (type === 'mountFile') { + imageCapture.mountFile(file, path, id); + } + + if (isInit && type === 'startCapture') { + metaDataMap[id] = {}; + imageCapture.capture({ + id, + info, + path, + file, + }); + } + + if (type === Events.getMetadata) { + imageCapture.getMetadata(info, id); + } + + if (type === Events.free) { + imageCapture.free({id}); + } +}); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars function transpostFrame(ptr, id) { const data = imageCapture.getImageInfo(ptr / 4); @@ -182,7 +263,6 @@ function transpostFrame(ptr, id) { id, meta: metaDataMap[id] || {}, }); - // console.log('transpostFrame==>', id, imageCapture.captureInfo); if (imageCapture.imageList[id].length >= imageCapture.captureInfo[id]) { // 说明已经到了数目 可以postonfinish事件 self.postMessage({ @@ -205,29 +285,6 @@ self.setDescription = setDescription; const initPromise: Promise = new Promise(res => { (self as any).goOnInit = res; }); -self.addEventListener('message', e => { - // console.log('receivemessage', e.data); - const { - type, - id, - info, - path, - file, - } = e.data; - if (type === 'initPath') { - (self as any).goOnInit(info); - } - if (isInit && type === 'startCapture') { - metaDataMap[id] = {}; - imageCapture.capture({ - id, - info, - path, - file, - }); - } -}); - (self as any).Module = { instantiateWasm: async (info, receiveInstance) => { diff --git a/src/consts.ts b/src/consts.ts index 30ae22a..f7c4181 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -1,6 +1,12 @@ export const Events = { + mountFile: 'mountFile', + mountFileSuccess: 'mountFileSuccess', startCapture: 'startCapture', + getMetadata: 'getMetadata', + getMetadataOnSuccess: 'getMetadataOnSuccess', receiveImageOnchange: 'receiveImageOnchange', receiveImageOnSuccess: 'receiveImageOnSuccess', receiveError: 'receiveError', + free: 'free', + freeOnSuccess: 'freeOnSuccess', }; diff --git a/src/index.ts b/src/index.ts index 8fb59f1..be1b4a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-statements */ import {Events} from './consts'; let captureWorker: null | Worker = null; function workerPost(info: {type: string, [key: string]: unknown}) { @@ -26,7 +27,7 @@ async function initWorker(url: URL|string, wasmPath: URL|string): Promise void; - onSuccess?: (prev: PrevType & {meta: MetaDatatype}) => void; + onSuccess?: ((prev: PrevType & {meta: MetaDatatype | string}) => void) | (() => void); onError?: (errmeg: string) => void; } interface MapInfoType extends CallbackType{ - url: string[]; + url?: string[]; } interface CaptureInfo extends CallbackType{ info: number[] | number; path?: string; - file: File; + file: File | Blob; returnType?: 'blob' | 'base64'; // 默认blob } function createRequest() { let currentId = 0; - const map: Map = new Map(); + const map: Map = new Map(); return { // 获取视频唯一id - setFrameCallback(item: CallbackType) { + setCallback(item: CallbackType) { const id = ++currentId; map.set(currentId, { ...item, - url: [], + cache: {}, }); return id; }, @@ -67,6 +68,10 @@ function createRequest() { getCbk(idx: number) { return map.get(idx); }, + // 删除 + deleteCbk(idx: number) { + map.delete(idx); + }, }; } const pool = createRequest(); @@ -155,11 +160,52 @@ function startCapture(id: number, info: CaptureInfo['info'], path: CaptureInfo[' file, }); } -function capture(data: CaptureInfo) { - const {info, path, file, ...func} = data; - const id = pool.setFrameCallback(func); - startCapture(id, info, path, file); + +class Capture { + file: File | Blob = null; + path: string = null; + capture(data: CaptureInfo) { + const {info, path = this.path, file = this.file, ...func} = data; + this.file = file; + this.path = path; + const id = pool.setCallback(func); + startCapture(id, info, path, this.file); + } + + mountFile(data: CaptureInfo) { + const {file, path, info, ...func} = data; + this.file = file; + this.path = path; + const id = pool.setCallback(func); + workerPost({ + type: Events.mountFile, + id, + path, + file, + }); + } + + getMetadata(data: CaptureInfo) { + const {info, ...func} = data; + const id = pool.setCallback(func); + workerPost({ + type: Events.getMetadata, + id, + info, // 相当于是key + }); + } + + free(data: CaptureInfo) { + const id = pool.setCallback(data || {}); + workerPost({ + type: Events.free, + id, + }); + } + } + + export async function initCapture({ workerPath, wasmPath, @@ -177,17 +223,21 @@ export async function initCapture({ const cbk = pool.getCbk(id); const {onChange} = cbk; const info = {width, height, duration: Number(duration) / 1000}; - const {url} = pool.getCbk(id); - onChange && onChange({url}, img, info); - url.push(img.url); + const {cache} = pool.getCbk(id); + cache.url = cache?.url || []; + onChange && onChange({url: cache.url}, img, info); + cache.url.push(img.url); break; } case Events.receiveImageOnSuccess: { const {id, meta} = e.data || {}; const cbk = pool.getCbk(id); const {onSuccess} = cbk; - const {url} = pool.getCbk(id); - onSuccess && onSuccess({url, meta}); + const {cache} = pool.getCbk(id); + onSuccess && onSuccess({ + url: cache.url, + meta: meta as MetaDatatype, + }); break; } case Events.receiveError: { @@ -197,13 +247,31 @@ export async function initCapture({ onError && onError(errmsg); break; } - default: + case Events.getMetadataOnSuccess: { + const {id, meta} = e.data || {}; + const cbk = pool.getCbk(id); + const {onSuccess} = cbk; + onSuccess && onSuccess({meta: meta as string}); + break; + } + case Events.mountFileSuccess: { + const {id} = e.data || {}; + const cbk = pool.getCbk(id); + const {onSuccess} = cbk; + onSuccess && onSuccess(); break; + } + case Events.freeOnSuccess: { + const {id} = e.data || {}; + const cbk = pool.getCbk(id); + const {onSuccess} = cbk; + onSuccess && onSuccess(); + pool.deleteCbk(id); + break; + } } }); - return { - capture, - }; + return new Capture(); } export default initCapture;