@dnyantra/dtly 0.0.0-stage → 0.2.0
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/LICENSE +26 -0
- package/README.md +172 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +95 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/anim.d.ts +3 -0
- package/dist/core/anim.js +61 -0
- package/dist/core/anim.js.map +1 -0
- package/dist/core/color.d.ts +4 -0
- package/dist/core/color.js +28 -0
- package/dist/core/color.js.map +1 -0
- package/dist/core/ease.d.ts +8 -0
- package/dist/core/ease.js +90 -0
- package/dist/core/ease.js.map +1 -0
- package/dist/core/model.d.ts +229 -0
- package/dist/core/model.js +4 -0
- package/dist/core/model.js.map +1 -0
- package/dist/core/text.d.ts +41 -0
- package/dist/core/text.js +222 -0
- package/dist/core/text.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +193 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/node.d.ts +4 -0
- package/dist/node.js +6 -0
- package/dist/node.js.map +1 -0
- package/dist/ops/ops.d.ts +39 -0
- package/dist/ops/ops.js +259 -0
- package/dist/ops/ops.js.map +1 -0
- package/dist/ops/store.d.ts +4 -0
- package/dist/ops/store.js +22 -0
- package/dist/ops/store.js.map +1 -0
- package/dist/presets/index.d.ts +14 -0
- package/dist/presets/index.js +235 -0
- package/dist/presets/index.js.map +1 -0
- package/dist/presets/text.d.ts +5 -0
- package/dist/presets/text.js +19 -0
- package/dist/presets/text.js.map +1 -0
- package/dist/render/export.d.ts +43 -0
- package/dist/render/export.js +215 -0
- package/dist/render/export.js.map +1 -0
- package/dist/render/ffmpeg.d.ts +1 -0
- package/dist/render/ffmpeg.js +16 -0
- package/dist/render/ffmpeg.js.map +1 -0
- package/dist/render/node-host.d.ts +18 -0
- package/dist/render/node-host.js +168 -0
- package/dist/render/node-host.js.map +1 -0
- package/dist/render/renderer.d.ts +36 -0
- package/dist/render/renderer.js +372 -0
- package/dist/render/renderer.js.map +1 -0
- package/dist/starter.d.ts +5 -0
- package/dist/starter.js +32 -0
- package/dist/starter.js.map +1 -0
- package/dist/studio/server.d.ts +12 -0
- package/dist/studio/server.js +190 -0
- package/dist/studio/server.js.map +1 -0
- package/dist/studio-app/app.css +2 -0
- package/dist/studio-app/app.css.map +7 -0
- package/dist/studio-app/app.js +5 -0
- package/dist/studio-app/app.js.map +7 -0
- package/dist/studio-app/index.html +15 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/fonts/Baloo2.ttf +0 -0
- package/fonts/Mukta-Bold.ttf +0 -0
- package/fonts/OFL-Baloo2.txt +93 -0
- package/fonts/OFL-Mukta.txt +93 -0
- package/package.json +84 -4
- package/plugin/skills/dtly/SKILL.md +94 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
let checked = false;
|
|
3
|
+
// FFmpeg is required but not bundled (common static builds are GPL); fail early with a clear fix.
|
|
4
|
+
export const checkFfmpeg = () => {
|
|
5
|
+
if (checked)
|
|
6
|
+
return;
|
|
7
|
+
const r = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8' });
|
|
8
|
+
if (r.error || r.status !== 0) {
|
|
9
|
+
throw new Error('DT.ly needs FFmpeg on your PATH to read video and write MP4.\n' +
|
|
10
|
+
' macOS: brew install ffmpeg\n' +
|
|
11
|
+
' Ubuntu: sudo apt install ffmpeg\n' +
|
|
12
|
+
' Windows: winget install ffmpeg');
|
|
13
|
+
}
|
|
14
|
+
checked = true;
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=ffmpeg.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ffmpeg.js","sourceRoot":"","sources":["../../src/render/ffmpeg.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAE7C,IAAI,OAAO,GAAG,KAAK,CAAC;AAEpB,kGAAkG;AAClG,MAAM,CAAC,MAAM,WAAW,GAAG,GAAG,EAAE;IAC9B,IAAI,OAAO;QAAE,OAAO;IACpB,MAAM,CAAC,GAAG,SAAS,CAAC,QAAQ,EAAE,CAAC,UAAU,CAAC,EAAE,EAAC,QAAQ,EAAE,MAAM,EAAC,CAAC,CAAC;IAChE,IAAI,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CACb,gEAAgE;YAC9D,kCAAkC;YAClC,sCAAsC;YACtC,kCAAkC,CACrC,CAAC;IACJ,CAAC;IACD,OAAO,GAAG,IAAI,CAAC;AACjB,CAAC,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Canvas } from 'skia-canvas';
|
|
2
|
+
import { type Effect, type Project, type VideoLayer } from '../core/model.js';
|
|
3
|
+
import type { CanvasLike, Drawable, Host } from './renderer.js';
|
|
4
|
+
export declare const newCanvas: (w: number, h: number) => Canvas;
|
|
5
|
+
export declare const registerFonts: (project: Project, baseDir: string) => void;
|
|
6
|
+
export declare class NodeHost implements Host {
|
|
7
|
+
private baseDir;
|
|
8
|
+
defaultFont: string;
|
|
9
|
+
private images;
|
|
10
|
+
private readers;
|
|
11
|
+
constructor(baseDir: string);
|
|
12
|
+
resolve(src: string): string;
|
|
13
|
+
createCanvas(w: number, h: number): CanvasLike;
|
|
14
|
+
image(src: string): Promise<Drawable>;
|
|
15
|
+
videoFrame(layer: VideoLayer, sourceTime: number, w: number, h: number, fps: number): Promise<Drawable>;
|
|
16
|
+
bakedVideoEffects(layer: VideoLayer): Set<Effect>;
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Node host: skia-canvas for drawing, FFmpeg for decoding video frames.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { Canvas, FontLibrary, ImageData, loadImage } from 'skia-canvas';
|
|
6
|
+
import { isAnimated } from '../core/model.js';
|
|
7
|
+
// CPU rendering by default: deterministic, and the GPU backend can drop draws when a shader
|
|
8
|
+
// compile times out (seen with JPEG/YUV images on Metal). DTLY_GPU=1 opts back in.
|
|
9
|
+
export const newCanvas = (w, h) => {
|
|
10
|
+
const c = new Canvas(w, h);
|
|
11
|
+
c.gpu = process.env.DTLY_GPU === '1';
|
|
12
|
+
return c;
|
|
13
|
+
};
|
|
14
|
+
const BUNDLED_FONTS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../fonts');
|
|
15
|
+
let bundledLoaded = false;
|
|
16
|
+
export const registerFonts = (project, baseDir) => {
|
|
17
|
+
if (!bundledLoaded) {
|
|
18
|
+
FontLibrary.use('Baloo 2', [path.join(BUNDLED_FONTS, 'Baloo2.ttf')]);
|
|
19
|
+
FontLibrary.use('Mukta', [path.join(BUNDLED_FONTS, 'Mukta-Bold.ttf')]);
|
|
20
|
+
bundledLoaded = true;
|
|
21
|
+
}
|
|
22
|
+
for (const [family, files] of Object.entries(project.fonts ?? {})) {
|
|
23
|
+
FontLibrary.use(family, files.map((f) => path.resolve(baseDir, f)));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
// Streams frames sequentially from one FFmpeg process; seeks by restarting when needed.
|
|
27
|
+
class VideoReader {
|
|
28
|
+
file;
|
|
29
|
+
w;
|
|
30
|
+
h;
|
|
31
|
+
fit;
|
|
32
|
+
rate;
|
|
33
|
+
extraFilters;
|
|
34
|
+
proc;
|
|
35
|
+
chunks = [];
|
|
36
|
+
pending = 0; // bytes held in `chunks`
|
|
37
|
+
frames = [];
|
|
38
|
+
nextIndex = 0; // index (relative to start) of frames[0]
|
|
39
|
+
start = 0;
|
|
40
|
+
ended = false;
|
|
41
|
+
waiters = [];
|
|
42
|
+
canvas;
|
|
43
|
+
lastIndex = -1;
|
|
44
|
+
frameBytes;
|
|
45
|
+
constructor(file, w, h, fit, rate, extraFilters = []) {
|
|
46
|
+
this.file = file;
|
|
47
|
+
this.w = w;
|
|
48
|
+
this.h = h;
|
|
49
|
+
this.fit = fit;
|
|
50
|
+
this.rate = rate;
|
|
51
|
+
this.extraFilters = extraFilters;
|
|
52
|
+
this.frameBytes = w * h * 4;
|
|
53
|
+
this.canvas = newCanvas(w, h);
|
|
54
|
+
}
|
|
55
|
+
open(startSeconds) {
|
|
56
|
+
this.proc?.kill('SIGKILL');
|
|
57
|
+
this.chunks = [];
|
|
58
|
+
this.pending = 0;
|
|
59
|
+
this.frames = [];
|
|
60
|
+
this.nextIndex = 0;
|
|
61
|
+
this.ended = false;
|
|
62
|
+
this.start = startSeconds;
|
|
63
|
+
const { w, h, fit } = this;
|
|
64
|
+
const scale = fit === 'fill' ? `scale=${w}:${h}`
|
|
65
|
+
: fit === 'contain' ? `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=black@0`
|
|
66
|
+
: `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`;
|
|
67
|
+
this.proc = spawn('ffmpeg', ['-v', 'error', '-ss', String(Math.max(0, startSeconds)), '-i', this.file, '-vf', [`fps=${this.rate}`, scale, ...this.extraFilters, 'format=rgba'].join(','), '-f', 'rawvideo', '-pix_fmt', 'rgba', '-']);
|
|
68
|
+
const proc = this.proc;
|
|
69
|
+
proc.stdout.on('data', (chunk) => {
|
|
70
|
+
if (proc !== this.proc)
|
|
71
|
+
return;
|
|
72
|
+
// Gather chunks and copy once per frame (concatenating per chunk is quadratic at 8 MB/frame).
|
|
73
|
+
this.chunks.push(chunk);
|
|
74
|
+
this.pending += chunk.length;
|
|
75
|
+
while (this.pending >= this.frameBytes) {
|
|
76
|
+
const all = this.chunks.length === 1 ? this.chunks[0] : Buffer.concat(this.chunks, this.pending);
|
|
77
|
+
this.frames.push(all.subarray(0, this.frameBytes));
|
|
78
|
+
const rest = all.subarray(this.frameBytes);
|
|
79
|
+
this.chunks = rest.length ? [rest] : [];
|
|
80
|
+
this.pending = rest.length;
|
|
81
|
+
}
|
|
82
|
+
if (this.frames.length > 8)
|
|
83
|
+
proc.stdout.pause();
|
|
84
|
+
this.wake();
|
|
85
|
+
});
|
|
86
|
+
proc.stdout.on('end', () => { if (proc === this.proc) {
|
|
87
|
+
this.ended = true;
|
|
88
|
+
this.wake();
|
|
89
|
+
} });
|
|
90
|
+
proc.on('error', () => { this.ended = true; this.wake(); });
|
|
91
|
+
}
|
|
92
|
+
wake() { const w = this.waiters; this.waiters = []; w.forEach((f) => f()); }
|
|
93
|
+
async frame(sourceTime) {
|
|
94
|
+
const want = Math.max(0, Math.round(sourceTime * this.rate));
|
|
95
|
+
if (want === this.lastIndex)
|
|
96
|
+
return this.canvas;
|
|
97
|
+
const rel = want - Math.round(this.start * this.rate);
|
|
98
|
+
if (!this.proc || rel < this.nextIndex || rel - this.nextIndex > this.rate * 3)
|
|
99
|
+
this.open(want / this.rate);
|
|
100
|
+
const target = want - Math.round(this.start * this.rate);
|
|
101
|
+
while (true) {
|
|
102
|
+
while (this.frames.length && this.nextIndex < target) {
|
|
103
|
+
this.frames.shift();
|
|
104
|
+
this.nextIndex++;
|
|
105
|
+
}
|
|
106
|
+
if (this.frames.length && this.nextIndex === target)
|
|
107
|
+
break;
|
|
108
|
+
if (this.ended)
|
|
109
|
+
break; // past the end of the source: hold the last frame
|
|
110
|
+
this.proc.stdout.resume();
|
|
111
|
+
await new Promise((r) => this.waiters.push(r));
|
|
112
|
+
}
|
|
113
|
+
const data = this.frames[0];
|
|
114
|
+
if (data) {
|
|
115
|
+
const ctx = this.canvas.getContext('2d');
|
|
116
|
+
// skia-canvas records draw commands; clearing first stops full-frame puts piling up each frame.
|
|
117
|
+
ctx.clearRect(0, 0, this.w, this.h);
|
|
118
|
+
ctx.putImageData(new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.length), this.w, this.h), 0, 0);
|
|
119
|
+
this.lastIndex = want;
|
|
120
|
+
}
|
|
121
|
+
return this.canvas;
|
|
122
|
+
}
|
|
123
|
+
close() { this.proc?.kill('SIGKILL'); }
|
|
124
|
+
}
|
|
125
|
+
export class NodeHost {
|
|
126
|
+
baseDir;
|
|
127
|
+
defaultFont = 'Baloo 2';
|
|
128
|
+
images = new Map();
|
|
129
|
+
readers = new Map();
|
|
130
|
+
constructor(baseDir) {
|
|
131
|
+
this.baseDir = baseDir;
|
|
132
|
+
}
|
|
133
|
+
resolve(src) { return path.resolve(this.baseDir, src); }
|
|
134
|
+
createCanvas(w, h) { return newCanvas(w, h); }
|
|
135
|
+
image(src) {
|
|
136
|
+
if (!this.images.has(src))
|
|
137
|
+
this.images.set(src, loadImage(this.resolve(src)));
|
|
138
|
+
return this.images.get(src);
|
|
139
|
+
}
|
|
140
|
+
videoFrame(layer, sourceTime, w, h, fps) {
|
|
141
|
+
const rate = fps / (layer.speed ?? 1);
|
|
142
|
+
const key = [layer.id, layer.src, w, h, layer.fit, rate].join('|');
|
|
143
|
+
let r = this.readers.get(key);
|
|
144
|
+
if (!r)
|
|
145
|
+
this.readers.set(key, (r = new VideoReader(this.resolve(layer.src), w, h, layer.fit ?? 'cover', rate, [...this.bakedVideoEffects(layer)].map(ffmpegFilter))));
|
|
146
|
+
return r.frame(sourceTime);
|
|
147
|
+
}
|
|
148
|
+
// Static colour/blur effects on video run inside FFmpeg, in parallel with drawing.
|
|
149
|
+
bakedVideoEffects(layer) {
|
|
150
|
+
return new Set((layer.effects ?? []).filter((e) => BAKEABLE.has(e.type) && !isAnimated(e.radius ?? e.amount)));
|
|
151
|
+
}
|
|
152
|
+
close() { this.readers.forEach((r) => r.close()); this.readers.clear(); }
|
|
153
|
+
}
|
|
154
|
+
const BAKEABLE = new Set(['blur', 'brightness', 'saturate', 'contrast']);
|
|
155
|
+
// FFmpeg equivalents of the CSS filters the renderer would otherwise apply.
|
|
156
|
+
const ffmpegFilter = (e) => {
|
|
157
|
+
switch (e.type) {
|
|
158
|
+
case 'blur': return `gblur=sigma=${Number(e.radius)}`;
|
|
159
|
+
case 'brightness': {
|
|
160
|
+
const k = Number(e.amount) / 100;
|
|
161
|
+
return `colorchannelmixer=rr=${k}:gg=${k}:bb=${k}`;
|
|
162
|
+
}
|
|
163
|
+
case 'saturate': return `eq=saturation=${Number(e.amount) / 100}`;
|
|
164
|
+
case 'contrast': return `eq=contrast=${Number(e.amount) / 100}`;
|
|
165
|
+
default: return 'null';
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
//# sourceMappingURL=node-host.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-host.js","sourceRoot":"","sources":["../../src/render/node-host.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Composition, Effect, Layer, Project, Vec2, VideoLayer } from '../core/model.js';
|
|
2
|
+
type Ctx = CanvasRenderingContext2D;
|
|
3
|
+
export interface CanvasLike {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
getContext(type: '2d'): Ctx;
|
|
7
|
+
}
|
|
8
|
+
export interface Drawable {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Host {
|
|
13
|
+
createCanvas(w: number, h: number): CanvasLike;
|
|
14
|
+
image(src: string): Promise<Drawable>;
|
|
15
|
+
videoFrame(layer: VideoLayer, sourceTime: number, w: number, h: number, fps: number): Promise<Drawable>;
|
|
16
|
+
bakedVideoEffects?(layer: VideoLayer): Set<Effect>;
|
|
17
|
+
defaultFont: string;
|
|
18
|
+
}
|
|
19
|
+
type M = [number, number, number, number, number, number];
|
|
20
|
+
export declare class Renderer {
|
|
21
|
+
project: Project;
|
|
22
|
+
host: Host;
|
|
23
|
+
private pool;
|
|
24
|
+
constructor(project: Project, host: Host);
|
|
25
|
+
comp(id: string): Composition;
|
|
26
|
+
private acquire;
|
|
27
|
+
private release;
|
|
28
|
+
layerSize(layer: Layer, comp: Composition, t: number): Vec2;
|
|
29
|
+
matrix(layer: Layer, comp: Composition, t: number, depth?: number): M;
|
|
30
|
+
renderComp(ctx: Ctx, comp: Composition, t: number, withBackground?: boolean): Promise<void>;
|
|
31
|
+
isActive(layer: Layer, comp: Composition, t: number): boolean;
|
|
32
|
+
renderLayer(target: Ctx, layer: Layer, comp: Composition, t: number): Promise<void>;
|
|
33
|
+
private applyShadow;
|
|
34
|
+
private drawContent;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Environment-agnostic compositor: draws a composition at time t onto any Canvas2D context.
|
|
2
|
+
// The same code runs in Node (skia-canvas) for export and in the browser for preview.
|
|
3
|
+
import { val } from '../core/anim.js';
|
|
4
|
+
import { parseColor, rgbaString } from '../core/color.js';
|
|
5
|
+
import { drawText } from '../core/text.js';
|
|
6
|
+
const I = [1, 0, 0, 1, 0, 0];
|
|
7
|
+
const mul = (m, n) => [
|
|
8
|
+
m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
|
|
9
|
+
m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
|
|
10
|
+
m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5],
|
|
11
|
+
];
|
|
12
|
+
const BLEND = {
|
|
13
|
+
normal: 'source-over', multiply: 'multiply', screen: 'screen', overlay: 'overlay', add: 'lighter',
|
|
14
|
+
darken: 'darken', lighten: 'lighten', softLight: 'soft-light', difference: 'difference',
|
|
15
|
+
};
|
|
16
|
+
export class Renderer {
|
|
17
|
+
project;
|
|
18
|
+
host;
|
|
19
|
+
pool = new Map();
|
|
20
|
+
constructor(project, host) {
|
|
21
|
+
this.project = project;
|
|
22
|
+
this.host = host;
|
|
23
|
+
}
|
|
24
|
+
comp(id) {
|
|
25
|
+
const c = this.project.comps.find((c) => c.id === id);
|
|
26
|
+
if (!c)
|
|
27
|
+
throw new Error(`Unknown composition "${id}"`);
|
|
28
|
+
return c;
|
|
29
|
+
}
|
|
30
|
+
acquire(w, h) {
|
|
31
|
+
const list = this.pool.get(`${w}x${h}`);
|
|
32
|
+
const c = list?.pop() ?? this.host.createCanvas(w, h);
|
|
33
|
+
const ctx = c.getContext('2d');
|
|
34
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
35
|
+
ctx.clearRect(0, 0, w, h);
|
|
36
|
+
return c;
|
|
37
|
+
}
|
|
38
|
+
release(c) {
|
|
39
|
+
const k = `${c.width}x${c.height}`;
|
|
40
|
+
if (!this.pool.has(k))
|
|
41
|
+
this.pool.set(k, []);
|
|
42
|
+
this.pool.get(k).push(c);
|
|
43
|
+
}
|
|
44
|
+
// Natural size of a layer's content in layer space (used for default anchor points).
|
|
45
|
+
layerSize(layer, comp, t) {
|
|
46
|
+
switch (layer.type) {
|
|
47
|
+
case 'solid': return layer.size ?? [comp.width, comp.height];
|
|
48
|
+
case 'image':
|
|
49
|
+
case 'video': return layer.size ?? [comp.width, comp.height];
|
|
50
|
+
case 'precomp': {
|
|
51
|
+
const c = this.comp(layer.comp);
|
|
52
|
+
return [c.width, c.height];
|
|
53
|
+
}
|
|
54
|
+
default: return [0, 0];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Layer-space → comp-space matrix, including parents (AE parenting: transform only).
|
|
58
|
+
matrix(layer, comp, t, depth = 0) {
|
|
59
|
+
const tr = layer.transform ?? {};
|
|
60
|
+
const size = this.layerSize(layer, comp, t);
|
|
61
|
+
const anchor = val(tr.anchor, t, [size[0] / 2, size[1] / 2]);
|
|
62
|
+
const pos = val(tr.position, t, [comp.width / 2, comp.height / 2]);
|
|
63
|
+
const [sx, sy] = val(tr.scale, t, [100, 100]);
|
|
64
|
+
const rot = (val(tr.rotation, t, 0) * Math.PI) / 180;
|
|
65
|
+
const skew = Math.tan((val(tr.skew, t, 0) * Math.PI) / 180);
|
|
66
|
+
const cos = Math.cos(rot), sin = Math.sin(rot);
|
|
67
|
+
let m = [1, 0, 0, 1, pos[0], pos[1]];
|
|
68
|
+
m = mul(m, [cos, sin, -sin, cos, 0, 0]);
|
|
69
|
+
m = mul(m, [1, 0, skew, 1, 0, 0]);
|
|
70
|
+
m = mul(m, [sx / 100, 0, 0, sy / 100, 0, 0]);
|
|
71
|
+
m = mul(m, [1, 0, 0, 1, -anchor[0], -anchor[1]]);
|
|
72
|
+
if (layer.parent && depth < 20) {
|
|
73
|
+
const p = comp.layers.find((l) => l.id === layer.parent);
|
|
74
|
+
if (p)
|
|
75
|
+
m = mul(this.matrix(p, comp, t, depth + 1), m);
|
|
76
|
+
}
|
|
77
|
+
return m;
|
|
78
|
+
}
|
|
79
|
+
async renderComp(ctx, comp, t, withBackground = true) {
|
|
80
|
+
if (withBackground && comp.background) {
|
|
81
|
+
ctx.save();
|
|
82
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
83
|
+
ctx.fillStyle = comp.background;
|
|
84
|
+
ctx.fillRect(0, 0, comp.width, comp.height);
|
|
85
|
+
ctx.restore();
|
|
86
|
+
}
|
|
87
|
+
const matteSources = new Set(comp.layers.map((l) => l.matte?.layer).filter(Boolean));
|
|
88
|
+
for (let i = comp.layers.length - 1; i >= 0; i--) {
|
|
89
|
+
const layer = comp.layers[i];
|
|
90
|
+
if (layer.hidden || layer.type === 'audio' || layer.type === 'null')
|
|
91
|
+
continue;
|
|
92
|
+
if (matteSources.has(layer.id))
|
|
93
|
+
continue; // mattes only show through the layer that uses them
|
|
94
|
+
await this.renderLayer(ctx, layer, comp, t);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
isActive(layer, comp, t) {
|
|
98
|
+
return t >= (layer.inPoint ?? 0) && t < (layer.outPoint ?? comp.duration);
|
|
99
|
+
}
|
|
100
|
+
async renderLayer(target, layer, comp, t) {
|
|
101
|
+
if (!this.isActive(layer, comp, t))
|
|
102
|
+
return;
|
|
103
|
+
const tr = layer.transform ?? {};
|
|
104
|
+
const opacity = val(tr.opacity, t, 100) / 100;
|
|
105
|
+
if (opacity <= 0)
|
|
106
|
+
return;
|
|
107
|
+
const baked = layer.type === 'video' ? this.host.bakedVideoEffects?.(layer) : undefined;
|
|
108
|
+
const effects = (layer.effects ?? []).filter((e) => !baked?.has(e));
|
|
109
|
+
// Filters and drop shadows can be applied while drawing; only masks, mattes, motion blur,
|
|
110
|
+
// glow and fill need the layer composited in a full-frame buffer first (much slower).
|
|
111
|
+
const direct = !layer.masks?.length && !layer.matte && !layer.motionBlur && effects.every((e) => DIRECT_EFFECTS.has(e.type));
|
|
112
|
+
if (direct) {
|
|
113
|
+
target.save();
|
|
114
|
+
target.globalAlpha *= opacity;
|
|
115
|
+
target.globalCompositeOperation = BLEND[layer.blendMode ?? 'normal'];
|
|
116
|
+
const filters = filterString(effects, t);
|
|
117
|
+
if (filters)
|
|
118
|
+
target.filter = filters;
|
|
119
|
+
this.applyShadow(target, effects, t);
|
|
120
|
+
target.setTransform(...this.matrix(layer, comp, t));
|
|
121
|
+
await this.drawContent(target, layer, comp, t);
|
|
122
|
+
target.restore();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const buf = this.acquire(comp.width, comp.height);
|
|
126
|
+
const b = buf.getContext('2d');
|
|
127
|
+
if (layer.motionBlur) {
|
|
128
|
+
// Average sub-frame samples across the shutter (AE: 180° shutter, ~8-16 samples).
|
|
129
|
+
const n = Math.max(2, layer.motionBlur.samples ?? 8);
|
|
130
|
+
const span = ((layer.motionBlur.shutter ?? 180) / 360) / comp.fps;
|
|
131
|
+
for (let k = 0; k < n; k++) {
|
|
132
|
+
const st = t - span / 2 + (span * k) / (n - 1);
|
|
133
|
+
b.save();
|
|
134
|
+
b.globalAlpha = 1 / (k + 1);
|
|
135
|
+
b.setTransform(...this.matrix(layer, comp, st));
|
|
136
|
+
await this.drawContent(b, layer, comp, st);
|
|
137
|
+
b.restore();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
b.save();
|
|
142
|
+
b.setTransform(...this.matrix(layer, comp, t));
|
|
143
|
+
await this.drawContent(b, layer, comp, t);
|
|
144
|
+
b.restore();
|
|
145
|
+
}
|
|
146
|
+
if (layer.masks?.length) {
|
|
147
|
+
const m = this.matrix(layer, comp, t);
|
|
148
|
+
for (const mask of layer.masks) {
|
|
149
|
+
b.save();
|
|
150
|
+
b.setTransform(...m);
|
|
151
|
+
b.globalCompositeOperation = mask.inverted ? 'destination-out' : 'destination-in';
|
|
152
|
+
b.beginPath();
|
|
153
|
+
if (mask.shape === 'path' && mask.points?.length) {
|
|
154
|
+
mask.points.forEach(([x, y], i) => (i ? b.lineTo(x, y) : b.moveTo(x, y)));
|
|
155
|
+
b.closePath();
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
const [x, y, w, h] = val(mask.rect, t, [0, 0, 0, 0]);
|
|
159
|
+
if (mask.shape === 'ellipse')
|
|
160
|
+
b.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
|
161
|
+
else
|
|
162
|
+
b.roundRect(x, y, w, h, mask.radius ?? 0);
|
|
163
|
+
}
|
|
164
|
+
b.fillStyle = '#000';
|
|
165
|
+
b.fill();
|
|
166
|
+
b.restore();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const e of effects) {
|
|
170
|
+
if (e.type === 'fill') {
|
|
171
|
+
b.save();
|
|
172
|
+
b.globalCompositeOperation = 'source-in';
|
|
173
|
+
b.globalAlpha = val(e.opacity, t, 100) / 100;
|
|
174
|
+
b.fillStyle = e.color;
|
|
175
|
+
b.fillRect(0, 0, comp.width, comp.height);
|
|
176
|
+
b.restore();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (layer.matte) {
|
|
180
|
+
const src = comp.layers.find((l) => l.id === layer.matte.layer);
|
|
181
|
+
if (src) {
|
|
182
|
+
const mb = this.acquire(comp.width, comp.height);
|
|
183
|
+
await this.renderLayer(mb.getContext('2d'), { ...src, hidden: false, matte: undefined }, comp, t);
|
|
184
|
+
b.save();
|
|
185
|
+
b.globalCompositeOperation = layer.matte.mode === 'alphaInverted' ? 'destination-out' : 'destination-in';
|
|
186
|
+
b.drawImage(mb, 0, 0);
|
|
187
|
+
b.restore();
|
|
188
|
+
this.release(mb);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
target.save();
|
|
192
|
+
target.setTransform(1, 0, 0, 1, 0, 0);
|
|
193
|
+
target.globalAlpha *= opacity;
|
|
194
|
+
target.globalCompositeOperation = BLEND[layer.blendMode ?? 'normal'];
|
|
195
|
+
const filters = filterString(effects, t);
|
|
196
|
+
if (filters)
|
|
197
|
+
target.filter = filters;
|
|
198
|
+
this.applyShadow(target, effects, t);
|
|
199
|
+
const glow = effects.find((e) => e.type === 'glow');
|
|
200
|
+
if (glow) {
|
|
201
|
+
const strength = val(glow.strength, t, 100) / 100;
|
|
202
|
+
target.save();
|
|
203
|
+
target.shadowColor = 'transparent';
|
|
204
|
+
target.filter = `blur(${val(glow.radius, t, 24)}px)`;
|
|
205
|
+
target.globalCompositeOperation = 'lighter';
|
|
206
|
+
target.globalAlpha *= strength;
|
|
207
|
+
if (glow.color) {
|
|
208
|
+
const g = this.acquire(comp.width, comp.height);
|
|
209
|
+
const gc = g.getContext('2d');
|
|
210
|
+
gc.drawImage(buf, 0, 0);
|
|
211
|
+
gc.globalCompositeOperation = 'source-in';
|
|
212
|
+
gc.fillStyle = glow.color;
|
|
213
|
+
gc.fillRect(0, 0, comp.width, comp.height);
|
|
214
|
+
target.drawImage(g, 0, 0);
|
|
215
|
+
this.release(g);
|
|
216
|
+
}
|
|
217
|
+
else
|
|
218
|
+
target.drawImage(buf, 0, 0);
|
|
219
|
+
target.restore();
|
|
220
|
+
}
|
|
221
|
+
target.drawImage(buf, 0, 0);
|
|
222
|
+
target.restore();
|
|
223
|
+
this.release(buf);
|
|
224
|
+
}
|
|
225
|
+
applyShadow(ctx, effects, t) {
|
|
226
|
+
const shadow = effects.find((e) => e.type === 'dropShadow');
|
|
227
|
+
if (!shadow)
|
|
228
|
+
return;
|
|
229
|
+
const dist = val(shadow.distance, t, 8), ang = ((shadow.angle ?? 135) * Math.PI) / 180;
|
|
230
|
+
const c = parseColor(shadow.color ?? '#000') ?? [0, 0, 0, 1];
|
|
231
|
+
ctx.shadowColor = rgbaString([c[0], c[1], c[2], c[3] * (val(shadow.opacity, t, 60) / 100)]);
|
|
232
|
+
ctx.shadowBlur = val(shadow.blur, t, 16);
|
|
233
|
+
ctx.shadowOffsetX = Math.sin(ang) * dist;
|
|
234
|
+
ctx.shadowOffsetY = -Math.cos(ang) * dist;
|
|
235
|
+
}
|
|
236
|
+
async drawContent(ctx, layer, comp, t) {
|
|
237
|
+
const inP = layer.inPoint ?? 0;
|
|
238
|
+
switch (layer.type) {
|
|
239
|
+
case 'solid': {
|
|
240
|
+
const [w, h] = layer.size ?? [comp.width, comp.height];
|
|
241
|
+
ctx.fillStyle = val(layer.color, t, '#000');
|
|
242
|
+
ctx.fillRect(0, 0, w, h);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
case 'shape': return drawShape(ctx, layer, t);
|
|
246
|
+
case 'text': return drawText(ctx, layer, t, inP, layer.outPoint ?? comp.duration, this.project.fonts ? Object.keys(this.project.fonts)[0] ?? this.host.defaultFont : this.host.defaultFont);
|
|
247
|
+
case 'image': {
|
|
248
|
+
const img = await this.host.image(layer.src);
|
|
249
|
+
const [w, h] = layer.size ?? [comp.width, comp.height];
|
|
250
|
+
drawFitted(ctx, img, w, h, layer.fit ?? 'cover');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
case 'video': {
|
|
254
|
+
const [w, h] = layer.size ?? [comp.width, comp.height];
|
|
255
|
+
const srcTime = (layer.trimIn ?? 0) + (t - inP) * (layer.speed ?? 1);
|
|
256
|
+
const frame = await this.host.videoFrame(layer, srcTime, w, h, comp.fps);
|
|
257
|
+
ctx.drawImage(frame, 0, 0, w, h);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
case 'precomp': {
|
|
261
|
+
const child = this.comp(layer.comp);
|
|
262
|
+
const ct = (t - inP) * (layer.speed ?? 1) + (layer.timeOffset ?? 0);
|
|
263
|
+
const c = this.acquire(child.width, child.height);
|
|
264
|
+
await this.renderComp(c.getContext('2d'), child, ct, true);
|
|
265
|
+
ctx.drawImage(c, 0, 0);
|
|
266
|
+
this.release(c);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const DIRECT_EFFECTS = new Set(['blur', 'brightness', 'saturate', 'contrast', 'dropShadow']);
|
|
273
|
+
const filterString = (effects, t) => effects
|
|
274
|
+
.map((e) => {
|
|
275
|
+
if (e.type === 'blur') {
|
|
276
|
+
const r = val(e.radius, t, 0);
|
|
277
|
+
return r > 0.05 ? `blur(${r}px)` : '';
|
|
278
|
+
}
|
|
279
|
+
if (e.type === 'brightness')
|
|
280
|
+
return `brightness(${val(e.amount, t, 100)}%)`;
|
|
281
|
+
if (e.type === 'saturate')
|
|
282
|
+
return `saturate(${val(e.amount, t, 100)}%)`;
|
|
283
|
+
if (e.type === 'contrast')
|
|
284
|
+
return `contrast(${val(e.amount, t, 100)}%)`;
|
|
285
|
+
return '';
|
|
286
|
+
})
|
|
287
|
+
.filter(Boolean)
|
|
288
|
+
.join(' ');
|
|
289
|
+
const drawFitted = (ctx, img, w, h, fit) => {
|
|
290
|
+
if (fit === 'fill')
|
|
291
|
+
return ctx.drawImage(img, 0, 0, w, h);
|
|
292
|
+
const s = fit === 'cover' ? Math.max(w / img.width, h / img.height) : Math.min(w / img.width, h / img.height);
|
|
293
|
+
const dw = img.width * s, dh = img.height * s;
|
|
294
|
+
ctx.save();
|
|
295
|
+
ctx.beginPath();
|
|
296
|
+
ctx.rect(0, 0, w, h);
|
|
297
|
+
ctx.clip();
|
|
298
|
+
ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
|
|
299
|
+
ctx.restore();
|
|
300
|
+
};
|
|
301
|
+
// Shape outline as a polyline (layer space, centred on the origin), used for trim paths.
|
|
302
|
+
const outline = (s, t) => {
|
|
303
|
+
const [w, h] = val(s.size, t, [200, 200]);
|
|
304
|
+
switch (s.shape) {
|
|
305
|
+
case 'rect': return { pts: [[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]], closed: true };
|
|
306
|
+
case 'ellipse': return { pts: [...Array(96).keys()].map((i) => { const a = (i / 96) * Math.PI * 2 - Math.PI / 2; return [Math.cos(a) * w / 2, Math.sin(a) * h / 2]; }), closed: true };
|
|
307
|
+
case 'polygon': {
|
|
308
|
+
const r = val(s.radius, t, 100), n = s.sides ?? 6;
|
|
309
|
+
return { pts: [...Array(n).keys()].map((i) => { const a = (i / n) * Math.PI * 2 - Math.PI / 2; return [Math.cos(a) * r, Math.sin(a) * r]; }), closed: true };
|
|
310
|
+
}
|
|
311
|
+
default: return { pts: s.points ?? [], closed: s.closed ?? s.shape === 'path' };
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const drawShape = (ctx, s, t) => {
|
|
315
|
+
const start = val(s.trim?.start, t, 0) / 100, end = val(s.trim?.end, t, 100) / 100;
|
|
316
|
+
ctx.beginPath();
|
|
317
|
+
if (s.trim && (start > 0 || end < 1)) {
|
|
318
|
+
const { pts, closed } = outline(s, t);
|
|
319
|
+
const all = closed ? [...pts, pts[0]] : pts;
|
|
320
|
+
const lens = all.slice(1).map((p, i) => Math.hypot(p[0] - all[i][0], p[1] - all[i][1]));
|
|
321
|
+
const total = lens.reduce((a, b) => a + b, 0);
|
|
322
|
+
const a = Math.min(start, end) * total, z = Math.max(start, end) * total;
|
|
323
|
+
let acc = 0, started = false;
|
|
324
|
+
for (let i = 0; i < lens.length; i++) {
|
|
325
|
+
const s0 = acc, s1 = acc + lens[i];
|
|
326
|
+
acc = s1;
|
|
327
|
+
if (s1 < a || s0 > z)
|
|
328
|
+
continue;
|
|
329
|
+
const p = (d) => { const k = lens[i] ? (d - s0) / lens[i] : 0; return [all[i][0] + (all[i + 1][0] - all[i][0]) * k, all[i][1] + (all[i + 1][1] - all[i][1]) * k]; };
|
|
330
|
+
const from = p(Math.max(a, s0)), to = p(Math.min(z, s1));
|
|
331
|
+
if (!started) {
|
|
332
|
+
ctx.moveTo(...from);
|
|
333
|
+
started = true;
|
|
334
|
+
}
|
|
335
|
+
ctx.lineTo(...to);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else if (s.shape === 'rect') {
|
|
339
|
+
const [w, h] = val(s.size, t, [200, 200]);
|
|
340
|
+
ctx.roundRect(-w / 2, -h / 2, w, h, val(s.radius, t, 0));
|
|
341
|
+
}
|
|
342
|
+
else if (s.shape === 'ellipse') {
|
|
343
|
+
const [w, h] = val(s.size, t, [200, 200]);
|
|
344
|
+
ctx.ellipse(0, 0, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
const { pts, closed } = outline(s, t);
|
|
348
|
+
pts.forEach((p, i) => (i ? ctx.lineTo(...p) : ctx.moveTo(...p)));
|
|
349
|
+
if (closed)
|
|
350
|
+
ctx.closePath();
|
|
351
|
+
}
|
|
352
|
+
const trimmed = !!s.trim && (start > 0 || end < 1);
|
|
353
|
+
if (s.fill && !trimmed && s.shape !== 'line') {
|
|
354
|
+
if (typeof s.fill === 'object' && 'linear' in s.fill) {
|
|
355
|
+
const g = ctx.createLinearGradient(...s.fill.linear.from, ...s.fill.linear.to);
|
|
356
|
+
for (const [o, c] of s.fill.linear.stops)
|
|
357
|
+
g.addColorStop(o, c);
|
|
358
|
+
ctx.fillStyle = g;
|
|
359
|
+
}
|
|
360
|
+
else
|
|
361
|
+
ctx.fillStyle = val(s.fill, t, '#fff');
|
|
362
|
+
ctx.fill();
|
|
363
|
+
}
|
|
364
|
+
if (s.stroke) {
|
|
365
|
+
ctx.strokeStyle = val(s.stroke, t, '#fff');
|
|
366
|
+
ctx.lineWidth = val(s.strokeWidth, t, 4);
|
|
367
|
+
ctx.lineCap = s.lineCap ?? 'round';
|
|
368
|
+
ctx.lineJoin = 'round';
|
|
369
|
+
ctx.stroke();
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
//# sourceMappingURL=renderer.js.map
|