Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: export transparent video #291

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions packages/core/src/app/Project.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {ImageExporterOptions} from '../exporter';
import {
FfmpegExporterOptions,
ImageExporterOptions,
WasmExporterOptions,
} from '../exporter';
import type {Plugin} from '../plugin';
import {SceneDescription} from '../scenes';
import {CanvasColorSpace, Color, Vector2} from '../types';
Expand Down Expand Up @@ -28,14 +32,16 @@ export type ExporterSettings =
}
| {
name: '@revideo/core/ffmpeg';
options: FfmpegExporterOptions;
}
| {
name: '@revideo/core/wasm';
options: WasmExporterOptions;
};

export interface ProjectSettings {
shared: {
background: Color | null;
background: Color;
range: [number, number];
size: Vector2;
};
Expand All @@ -51,10 +57,27 @@ export interface ProjectSettings {
};
}

export type PartialProjectSettings = {
shared?: Partial<ProjectSettings['shared']>;
rendering?: Partial<ProjectSettings['rendering']>;
preview?: Partial<ProjectSettings['preview']>;
export interface UserProjectSettings {
shared: {
range: [number, number];
background: string | null;
size: {x: number; y: number};
};
rendering: {
exporter: ExporterSettings;
fps: number;
resolutionScale: number;
colorSpace: CanvasColorSpace;
};
preview: {
fps: number;
resolutionScale: number;
};
}
export type PartialUserProjectSettings = {
shared?: Partial<UserProjectSettings['shared']>;
rendering?: Partial<UserProjectSettings['rendering']>;
preview?: Partial<UserProjectSettings['preview']>;
};

export interface UserProject {
Expand Down Expand Up @@ -91,10 +114,10 @@ export interface UserProject {
* Includes things like the background color, the resolution, the frame rate,
* and the exporter to use.
*/
settings?: PartialProjectSettings;
settings?: PartialUserProjectSettings;
}

export interface Project extends UserProject {
export interface Project extends Omit<UserProject, 'settings'> {
name: string;
settings: ProjectSettings;

Expand All @@ -105,12 +128,6 @@ export interface Project extends UserProject {
* // TODO(konsti): get rid of plugins
*
* A list of plugins to include in the project.
*
* @remarks
* When a string is provided, the plugin will be imported dynamically using
* the string as the module specifier. This is the preferred way to include
* editor plugins because it makes sure that the plugin's source code gets
* excluded from the production build.
*/
plugins: Plugin[];

Expand Down
26 changes: 19 additions & 7 deletions packages/core/src/app/makeProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {Color, Vector2} from '../types';
import {Logger} from './Logger';
import {
Project,
ProjectSettings,
UserProject,
UserProjectSettings,
createVersionObject,
} from './Project';

Expand All @@ -13,27 +13,30 @@ export function makeProject(project: UserProject): Project {
// TODO(konsti): Figure out how to get rid of this
void DefaultPlugin;

const defaultSettings: ProjectSettings = {
const defaultSettings: UserProjectSettings = {
shared: {
background: new Color('#FFFFFF'),
background: '#FFFFFF00',
range: [0, Infinity],
size: new Vector2(1920, 1080),
size: {x: 1920, y: 1080},
},
rendering: {
exporter: {
name: '@revideo/core/wasm',
options: {
format: 'mp4',
},
},
fps: 30,
resolutionScale: 1,
colorSpace: 'srgb',
},
preview: {
fps: 60,
fps: 30,
resolutionScale: 1,
},
};

const settings: ProjectSettings = {
const settings: UserProjectSettings = {
...defaultSettings,
...project.settings,
shared: {
Expand All @@ -50,10 +53,19 @@ export function makeProject(project: UserProject): Project {
},
};

const modifiedSettings = {
...settings,
shared: {
...settings.shared,
background: new Color(settings.shared.background ?? 'FFFFFF00'),
size: new Vector2(settings.shared.size),
},
};

return {
...project,
name: project.name ?? 'project',
settings,
settings: modifiedSettings,
plugins: [],
logger: new Logger(),
versions: createVersionObject('0.5.9'),
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/exporter/FFmpegExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ type ServerResponse =
message?: string;
};

export interface FfmpegExporterOptions {
format: 'mp4' | 'webm' | 'proRes';
}

/**
* FFmpeg video exporter.
*
Expand Down Expand Up @@ -67,8 +71,13 @@ export class FFmpegExporterClient implements Exporter {
}

public async handleFrame(canvas: HTMLCanvasElement): Promise<void> {
const format = (this.settings.exporter.options as FfmpegExporterOptions)
.format;
const blob = await new Promise<Blob | null>(resolve =>
canvas.toBlob(resolve, 'image/jpeg'),
canvas.toBlob(
resolve,
['proRes', 'webm'].includes(format) ? 'image/png' : 'image/jpeg',
),
);

if (!blob) {
Expand Down Expand Up @@ -130,12 +139,15 @@ export class FFmpegExporterClient implements Exporter {
public async mergeMedia(): Promise<void> {
const outputFilename = this.settings.name;
const tempDir = `revideo-${this.settings.name}-${this.settings.hiddenFolderId}`;
const format = (this.settings.exporter.options as FfmpegExporterOptions)
.format;

await fetch('/audio-processing/merge-media', {
method: 'POST',
body: JSON.stringify({
outputFilename,
tempDir,
format,
}),
});
}
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/exporter/WasmExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type {AssetInfo, RendererSettings} from '../app/Renderer';
import {Exporter} from './Exporter';
import {download} from './download-videos';

export interface WasmExporterOptions {
format: 'mp4';
}

export class WasmExporter implements Exporter {
public static readonly id = '@revideo/core/wasm';
public static readonly displayName = 'Video (Wasm)';
Expand Down Expand Up @@ -85,12 +89,15 @@ export class WasmExporter implements Exporter {
public async mergeMedia(): Promise<void> {
const outputFilename = this.settings.name;
const tempDir = `revideo-${this.settings.name}-${this.settings.hiddenFolderId}`;
const format = (this.settings.exporter.options as WasmExporterOptions)
.format;

await fetch('/audio-processing/merge-media', {
method: 'POST',
body: JSON.stringify({
outputFilename,
tempDir,
format,
}),
});
}
Expand Down
29 changes: 26 additions & 3 deletions packages/ffmpeg/src/ffmpeg-exporter-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type {RendererResult, RendererSettings} from '@revideo/core';
import type {
FfmpegExporterOptions,
RendererResult,
RendererSettings,
} from '@revideo/core';
import {EventName, sendEvent} from '@revideo/telemetry';
import * as ffmpeg from 'fluent-ffmpeg';
import * as os from 'os';
Expand All @@ -12,6 +16,18 @@ export interface FFmpegExporterSettings extends RendererSettings {
output: string;
}

const pixelFormats: Record<FfmpegExporterOptions['format'], string> = {
mp4: 'yuv420p',
webm: 'yuva420p',
proRes: 'yuva444p10le',
};

export const extensions: Record<FfmpegExporterOptions['format'], string> = {
mp4: 'mp4',
webm: 'webm',
proRes: 'mov',
};

/**
* The server-side implementation of the FFmpeg video exporter.
*/
Expand All @@ -21,9 +37,12 @@ export class FFmpegExporterServer {
private readonly promise: Promise<void>;
private readonly settings: FFmpegExporterSettings;
private readonly jobFolder: string;
private readonly format: FfmpegExporterOptions['format'];

public constructor(settings: FFmpegExporterSettings) {
this.settings = settings;
this.format = (settings.exporter.options as FfmpegExporterOptions).format;

this.jobFolder = path.join(
os.tmpdir(),
`revideo-${this.settings.name}-${settings.hiddenFolderId}`,
Expand All @@ -45,11 +64,15 @@ export class FFmpegExporterServer {
y: Math.round(settings.size.y * settings.resolutionScale),
};
this.command
.output(path.join(this.jobFolder, `visuals.mp4`))
.outputOptions(['-pix_fmt yuv420p', '-shortest'])
.output(path.join(this.jobFolder, `visuals.${extensions[this.format]}`))
.outputOptions([`-pix_fmt ${pixelFormats[this.format]}`, '-shortest'])
.outputFps(settings.fps)
.size(`${size.x}x${size.y}`);

if (this.format === 'proRes') {
this.command.outputOptions(['-c:v prores_ks', '-profile:v 4444']);
}

this.command.outputOptions(['-movflags +faststart']);
this.promise = new Promise<void>((resolve, reject) => {
this.command.on('end', resolve).on('error', reject);
Expand Down
24 changes: 19 additions & 5 deletions packages/ffmpeg/src/generate-audio.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import {AssetInfo} from '@revideo/core';
import {AssetInfo, FfmpegExporterOptions} from '@revideo/core';
import * as ffmpeg from 'fluent-ffmpeg';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {extensions} from './ffmpeg-exporter-server';
import {ffmpegSettings} from './settings';
import {
AudioCodec,
checkForAudioStream,
getSampleRate,
makeSureFolderExists,
mergeAudioWithVideo,
resolvePath,
} from './utils';

export const audioCodecs: Record<FfmpegExporterOptions['format'], AudioCodec> =
{
mp4: 'aac',
webm: 'libopus',
proRes: 'aac',
};

interface MediaAsset {
key: string;
src: string;
Expand Down Expand Up @@ -258,6 +267,7 @@ export async function mergeMedia(
outputFilename: string,
outputDir: string,
tempDir: string,
format: FfmpegExporterOptions['format'],
) {
const fullTempDir = path.join(os.tmpdir(), tempDir);
await makeSureFolderExists(outputDir);
Expand All @@ -267,13 +277,17 @@ export async function mergeMedia(
if (audioWavExists) {
await mergeAudioWithVideo(
path.join(fullTempDir, `audio.wav`),
path.join(fullTempDir, `visuals.mp4`),
path.join(outputDir, `${outputFilename}.mp4`),
path.join(fullTempDir, `visuals.${extensions[format]}`),
path.join(outputDir, `${outputFilename}.${extensions[format]}`),
audioCodecs[format],
);
} else {
const destination = path.join(outputDir, `${outputFilename}.mp4`);
const destination = path.join(
outputDir,
`${outputFilename}.${extensions[format]}`,
);
await fs.promises.copyFile(
path.join(fullTempDir, `visuals.mp4`),
path.join(fullTempDir, `visuals.${extensions[format]}`),
destination,
);
}
Expand Down
12 changes: 11 additions & 1 deletion packages/ffmpeg/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as path from 'path';
import {v4 as uuidv4} from 'uuid';
import {ffmpegSettings} from './settings';

export type AudioCodec = 'aac' | 'libopus';

export function resolvePath(output: string, assetPath: string) {
let resolvedPath: string;
if (
Expand Down Expand Up @@ -144,14 +146,22 @@ export async function mergeAudioWithVideo(
audioPath: string,
videoPath: string,
outputPath: string,
audioCodec: AudioCodec = 'aac',
): Promise<void> {
ffmpeg.setFfmpegPath(ffmpegSettings.getFfmpegPath());

return new Promise((resolve, reject) => {
ffmpeg()
.input(videoPath)
.input(audioPath)
.outputOptions(['-c:v', 'copy', '-c:a', 'aac', '-strict', 'experimental'])
.outputOptions([
'-c:v',
'copy', // Copy video codec without re-encoding
'-c:a',
audioCodec, // Use Opus for audio (compatible with WebM)
'-strict',
'experimental',
])
.on('end', () => {
resolve();
})
Expand Down
Loading
Loading