Skip to content

Commit

Permalink
feat: encode
Browse files Browse the repository at this point in the history
  • Loading branch information
qq15725 committed Oct 17, 2023
1 parent 522ea5b commit 6cbcbf5
Show file tree
Hide file tree
Showing 5 changed files with 128 additions and 55 deletions.
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,29 @@
<img src="https://img.shields.io/npm/l/modern-mp4.svg" alt="License">
</a>
</p>

## Install

```shell
npm i modern-mp4
```

## Usage

```ts
import { encode } from 'modern-mp4'

const output = await encode({
width: 1280,
height: 720,
audio: false,
frames: [
// data: string | CanvasImageSource | VideoFrame | AudioData
{ data: '/example1.png', duration: 3000 },
{ data: '/example1.png', duration: 3000 },
],
})

const blob = new Blob([output], { type: 'image/mp4' })
window.open(URL.createObjectURL(blob))
```
7 changes: 3 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</style>
</head>
<body>
<video style="width: 300px;" controls autoplay></video>
<video style="width: 300px;" controls autoplay loop></video>

<script type="module">
import { encode } from './src'
Expand All @@ -21,9 +21,8 @@
height: 720,
audio: false,
frames: [
{ data: '/docs/public/example.jpg', duration: 3000 * 1000, timestamp: 0 },
{ data: '/docs/public/example.png', duration: 3000 * 1000, timestamp: 3000 * 1000 },
{ data: '/docs/public/example.png', duration: 3000 * 1000, timestamp: 6000 * 1000 },
{ data: '/docs/public/example.jpg', duration: 3000 },
{ data: '/docs/public/example.png', duration: 3000 },
],
}).then(buffer => {
const blob = new Blob([buffer], { type: 'video/mp4' })
Expand Down
86 changes: 55 additions & 31 deletions src/Encoder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BoxParser, DataStream, createFile } from 'mp4box'
import { SUPPORTS_AUDIO_ENCODER, SUPPORTS_VIDEO_ENCODER } from './utils'
import type { SampleOptions } from 'mp4box'

export interface EncoderOptions {
Expand Down Expand Up @@ -28,26 +29,67 @@ export class Encoder {
},
}

file = createFile()
readonly file = createFile()

protected _audioReady = false
protected _videoReady = false

protected _videoEncoder = this._createVideoEncoder()
protected _videoChunks: Array<EncodedVideoChunk> = []
protected _videoTrackId?: number
protected _encodeQueueSize = 0

get encodeQueueSize() { return this._encodeQueueSize }

protected _audioEncoder = this.options.audio ? this._createAudioEncoder() : undefined
protected _audioEncoder = this._options.audio !== false ? this._createAudioEncoder() : undefined
protected _audioChunks: Array<EncodedAudioChunk> = []
protected _audioTrackId?: number

get videoConfig() {
const options = this._options
const defaultOptions = Encoder.DEFAULT_OPTIONS.video
return {
codec: options.codec ?? defaultOptions.codec,
framerate: options.fps ?? defaultOptions.fps,
// hardwareAcceleration: 'prefer-hardware',
bitrate: options.bitrate,
width: options.width,
height: options.height,
alpha: 'discard',
avc: { format: 'avc' },
}
}

get audioConfig() {
const options = this._options.audio !== false ? this._options.audio : undefined
const defaultOptions = Encoder.DEFAULT_OPTIONS.audio
return {
codec: options?.codec === 'aac' ? defaultOptions.codec : 'opus',
sampleRate: options?.sampleRate ?? defaultOptions.sampleRate,
numberOfChannels: options?.channelCount ?? defaultOptions.channelCount,
bitrate: defaultOptions.bitrate,
}
}

async isConfigSupported() {
try {
return Boolean((await VideoEncoder.isConfigSupported(this.videoConfig)).supported)
&& (
this._options.audio === false
|| Boolean((await AudioEncoder.isConfigSupported(this.audioConfig)).supported)
)
} catch (error) {
return false
}
}

constructor(
public options: EncoderOptions,
protected _options: EncoderOptions,
) {
//
if (!SUPPORTS_VIDEO_ENCODER) {
throw new Error('The current environment does not support VideoEncoder')
}

if (_options.audio !== false && !SUPPORTS_AUDIO_ENCODER) {
throw new Error('The current environment does not support AudioEncoder')
}
}

protected _addSample(chunk: EncodedAudioChunk | EncodedVideoChunk) {
Expand Down Expand Up @@ -101,8 +143,7 @@ export class Encoder {
}

protected _createVideoEncoder(): VideoEncoder {
const options = this.options
const defaultOptions = Encoder.DEFAULT_OPTIONS.video
const options = this._options

const encoder = new VideoEncoder({
error: (error: DOMException) => console.warn(error),
Expand All @@ -122,23 +163,13 @@ export class Encoder {
},
})

encoder.configure({
codec: options.codec ?? defaultOptions.codec,
framerate: options.fps ?? defaultOptions.fps,
// hardwareAcceleration: 'prefer-hardware',
bitrate: options.bitrate,
width: options.width,
height: options.height,
alpha: 'discard',
avc: { format: 'avc' },
})
encoder.configure(this.videoConfig)

return encoder
}

protected _createAudioEncoder(): AudioEncoder {
const options = this.options.audio as Record<string, any>
const defaultOptions = Encoder.DEFAULT_OPTIONS.audio
const options = this._options.audio as Record<string, any>

const encoder = new AudioEncoder({
error: (error: DOMException) => console.warn(error),
Expand All @@ -158,12 +189,7 @@ export class Encoder {
},
})

encoder.configure({
codec: options.codec === 'aac' ? defaultOptions.codec : 'opus',
sampleRate: options.sampleRate ?? defaultOptions.sampleRate,
numberOfChannels: options.channelCount ?? defaultOptions.channelCount,
bitrate: defaultOptions.bitrate,
})
encoder.configure(this.audioConfig)

return encoder
}
Expand Down Expand Up @@ -225,12 +251,10 @@ export class Encoder {
data.close()
} else if (data instanceof VideoFrame) {
this._videoEncoder.encode(data, options)
if (this._videoEncoder.encodeQueueSize > this._encodeQueueSize) {
this._encodeQueueSize = this._videoEncoder.encodeQueueSize
}
data.close()
} else {
this.encode(new VideoFrame(data, options), options)
const { keyFrame = false, ...restOptions } = options ?? {}
this.encode(new VideoFrame(data, restOptions), { keyFrame })
}
}

Expand Down
60 changes: 40 additions & 20 deletions src/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Encoder } from './Encoder'
import type { EncoderOptions } from './Encoder'

export interface EncodeOptions extends EncoderOptions {
frame: Array<{
data: CanvasImageSource
duration: number
} | VideoFrame>
frames: Array<
(VideoEncoderEncodeOptions & VideoFrameInit & { data: string })
| (VideoEncoderEncodeOptions & VideoFrameInit & { data: CanvasImageSource })
| (VideoEncoderEncodeOptions & { data: VideoFrame })
| { data: AudioData }
>
}

function loadImage(src: string) {
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise(resolve => {
const img = new Image()
img.onload = () => resolve(img)
Expand All @@ -17,25 +19,43 @@ function loadImage(src: string) {
})
}

export async function encode(
options: EncoderOptions & {
frames: Array<
(VideoEncoderEncodeOptions & VideoFrameInit & { data: string })
| (VideoEncoderEncodeOptions & VideoFrameInit & { data: CanvasImageSource })
| (VideoEncoderEncodeOptions & { data: VideoFrame })
| AudioData
>
},
): Promise<ArrayBuffer> {
const encoder = new Encoder(options)
for (let len = options.frames.length, i = 0; i < len; i++) {
// @ts-expect-error let
function resolveInitOptions(options: VideoEncoderEncodeOptions & VideoFrameInit, timestamp: number): number {
if (typeof options.keyFrame === 'undefined') options.keyFrame = true
if (typeof options.duration === 'undefined') options.duration = 3000
if ('timestamp' in options) options.timestamp! *= 1000
if ('duration' in options) options.duration! *= 1000
if (typeof options.timestamp === 'undefined') options.timestamp = timestamp
timestamp += options.duration
return timestamp
}

export async function encode(options: EncodeOptions): Promise<ArrayBuffer> {
const { frames, ...encoderOptions } = options

const encoder = new Encoder(encoderOptions)

let timestamp = 1
for (let len = frames.length, i = 0; i < len; i++) {
// eslint-disable-next-line prefer-const
let { data, ...rest } = options.frames[i]
let { data, ...restOptions } = frames[i]
if (data instanceof AudioData) {
encoder.encode(data)
} else {
if (typeof data === 'string') {
data = await loadImage(data)
timestamp = resolveInitOptions(restOptions as any, timestamp)
}
encoder.encode(data as any, restOptions)
}
}

if (timestamp > 1) {
let { data } = frames[frames.length - 1]
if (typeof data === 'string') {
data = await loadImage(data)
encoder.encode(data, { timestamp, keyFrame: true, duration: 1 })
}
encoder.encode(data, rest)
}

return encoder.flush()
}
4 changes: 4 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Constants
export const IN_BROWSER = typeof window !== 'undefined'
export const SUPPORTS_VIDEO_ENCODER = IN_BROWSER && 'VideoEncoder' in window
export const SUPPORTS_AUDIO_ENCODER = IN_BROWSER && 'AudioEncoder' in window

0 comments on commit 6cbcbf5

Please sign in to comment.