Files

421 lines
15 KiB
JavaScript
Raw Permalink Normal View History

// FreeCut 渲染引擎:Canvas 合成 + 时间线播放 + 实时导出(WebM)
class Renderer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.els = {}; // clipId -> {elem}
this.mediaEls = {}; // url -> HTMLMediaElement
this.imgEls = {}; // url -> Image
this.ac = MusicEngine.ctx();
this.master = this.ac.createGain();
this.master.gain.value = 1;
this.master.connect(this.ac.destination);
this.exportDest = null;
this.bgmGain = this.ac.createGain();
this.bgmGain.connect(this.master);
this.playing = false;
this.t = 0;
this.raf = 0;
this.lastPerf = 0;
this.bgmSrc = null;
this.bgmStartedAt = 0;
this.onTime = null;
this.onExport = null;
this.exporting = false;
}
setProject(proj) {
this.proj = proj || {};
this.els = {};
const p = this.proj;
this.canvas.width = p.width || 1080;
this.canvas.height = p.height || 1920;
this.mediaEls = {};
this.imgEls = {};
this.ensureMedia();
}
get duration() {
const p = this.proj;
let d = 0;
for (const c of p.clips || []) d = Math.max(d, (c.start || 0) + (c.duration || 0));
return Math.max(0.2, d);
}
get clips() { return (this.proj && this.proj.clips) || []; }
ensureMedia() {
for (const c of this.clips) {
if (c.assetType === 'video' || c.assetType === 'audio') {
if (!this.mediaEls[c.assetUrl]) {
const v = document.createElement('video');
v.crossOrigin = 'anonymous';
v.preload = 'auto';
v.playsInline = true;
v.muted = false;
v.src = c.assetUrl;
this.mediaEls[c.assetUrl] = v;
// 接入混音
try {
const src = this.ac.createMediaElementSource(v);
const g = this.ac.createGain();
g.gain.value = 1;
src.connect(g); g.connect(this.master);
v._srcNode = src; v._gain = g;
} catch (e) { /* 已接入过 */ }
}
this.els[c.id] = { elem: this.mediaEls[c.assetUrl] };
} else if (c.assetType === 'image') {
if (!this.imgEls[c.assetUrl]) {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = c.assetUrl;
this.imgEls[c.assetUrl] = img;
}
this.els[c.id] = { img: this.imgEls[c.assetUrl] };
} else if (c.assetType === 'text') {
this.els[c.id] = { text: true };
} else if (c.assetType === 'bgm') {
this.els[c.id] = { bgm: true };
}
}
}
// ---------- 播放控制 ----------
async play() {
if (this.playing) return;
if (this.ac.state === 'suspended') await this.ac.resume();
this.playing = true;
this.lastPerf = performance.now();
this.startMedia(this.t);
const loop = () => {
if (!this.playing) return;
const now = performance.now();
const dt = (now - this.lastPerf) / 1000;
this.lastPerf = now;
this.t += dt;
if (this.t >= this.duration) {
this.t = this.duration;
this.pause();
if (this.onTime) this.onTime(this.t);
return;
}
this.render();
this.syncMedia(this.t);
if (this.onTime) this.onTime(this.t);
this.raf = requestAnimationFrame(loop);
};
this.raf = requestAnimationFrame(loop);
}
pause() {
this.playing = false;
cancelAnimationFrame(this.raf);
this.stopMedia();
if (this.onTime) this.onTime(this.t);
}
seek(t) {
this.t = Math.max(0, Math.min(t, this.duration));
if (this.playing) { this.pause(); }
this.render();
if (this.onTime) this.onTime(this.t);
}
// 把当前 t 对应的所有媒体元素对齐到正确位置
startMedia(t) {
const ac = this.ac;
for (const c of this.clips) {
const e = this.els[c.id];
if (!e) continue;
const s = c.start || 0, d = c.duration || 0;
if (c.assetType === 'video' && e.elem) {
if (t >= s && t < s + d) {
const at = (c.trimStart || 0) + (t - s);
try { e.elem.currentTime = at; e.elem.volume = c.volume ?? 1; } catch (err) {}
const p = e.elem.play();
if (p) p.catch(() => {});
} else { try { e.elem.pause(); } catch (err) {} }
} else if (c.assetType === 'audio' && e.elem) {
if (t >= s && t < s + d) {
const at = (c.trimStart || 0) + (t - s);
try { e.elem.currentTime = at; e.elem.volume = c.volume ?? 1; } catch (err) {}
const p = e.elem.play();
if (p) p.catch(() => {});
} else { try { e.elem.pause(); } catch (err) {} }
}
}
// 配乐
this.startBgm(t);
}
syncMedia(t) {
for (const c of this.clips) {
const e = this.els[c.id];
if (!e || !e.elem) continue;
const s = c.start || 0, d = c.duration || 0;
const active = t >= s && t < s + d;
if ((c.assetType === 'video' || c.assetType === 'audio')) {
if (active) {
try { if (e.elem.paused) e.elem.play().catch(() => {}); } catch (err) {}
} else if (!e.elem.paused) {
try { e.elem.pause(); } catch (err) {}
}
}
}
// 配乐自动开始
const bgm = this.proj.bgm;
if (bgm) {
const b = this.clips.find(x => x.assetType === 'bgm');
if (b && t >= (b.start || 0) && !this.bgmSrc) this.startBgm(t);
}
}
async startBgm(t) {
const b = this.clips.find(x => x.assetType === 'bgm');
if (!b || !this.proj.bgm) return;
if (this.bgmSrc) return;
try {
const buf = await MusicEngine.getBuffer(this.proj.bgm.key);
const src = this.ac.createBufferSource();
src.buffer = buf;
src.loop = true;
this.bgmGain.gain.value = (this.proj.bgm.volume ?? 0.6);
src.connect(this.bgmGain);
const offset = Math.max(0, (t - (b.start || 0)) % buf.duration);
src.start(0, offset);
this.bgmSrc = src;
this.bgmStartedAt = t;
} catch (e) { console.warn('bgm start fail', e); }
}
stopMedia() {
for (const c of this.clips) {
const e = this.els[c.id];
if (e && e.elem) { try { e.elem.pause(); } catch (err) {} }
}
if (this.bgmSrc) { try { this.bgmSrc.stop(); } catch (e) {} this.bgmSrc = null; }
}
// ---------- 渲染一帧 ----------
render() {
const ctx = this.ctx;
const W = this.canvas.width, H = this.canvas.height;
const t = this.t;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.filter = 'none';
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
// 视频层(按数组顺序叠加)
const vidClips = this.clips.filter(c => c.track === 'video' || c.assetType === 'video');
const activeVids = vidClips.filter(c => t >= (c.start || 0) && t < (c.start || 0) + (c.duration || 0));
// 转场
const trans = this.activeTransitions(t);
for (const c of activeVids) {
const e = this.els[c.id];
let alpha = 1;
// 参与转场:确定进出场 alpha
const tr = trans.find(x => x.b === c.id);
const trOut = trans.find(x => x.a === c.id);
if (tr) {
const p = tr.p; // 0..1 进场进度
if (tr.type === 'fade') alpha = p;
else if (tr.type === 'slide') alpha = p;
else if (tr.type === 'zoom') alpha = p;
else alpha = 1;
} else if (trOut) {
const p = trOut.p;
if (trOut.type === 'fade') alpha = 1 - p;
else if (trOut.type === 'slide') alpha = 1 - p;
else if (trOut.type === 'zoom') alpha = 1 - p;
}
ctx.globalAlpha = alpha;
ctx.filter = c.filter ? Filters.toCss(c.filter) : 'none';
let dx = 0, dy = 0, sc = 1;
if (tr && tr.type === 'slide') { dx = (1 - tr.p) * W; }
if (tr && tr.type === 'zoom') { sc = 0.92 + 0.08 * tr.p; }
if (trOut && trOut.type === 'slide') { dx = -trOut.p * W; }
if (trOut && trOut.type === 'zoom') { sc = 1 - 0.08 * trOut.p; }
ctx.save();
ctx.translate(dx, dy);
ctx.translate(W / 2, H / 2); ctx.scale(sc, sc); ctx.translate(-W / 2, -H / 2);
const w = c.w || W, h = c.h || H, x = c.x ?? 0, y = c.y ?? 0;
ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
if (e && e.elem) {
// 视频:若媒体未就绪画最后一帧/黑色
drawMedia(e.elem, ctx, x, y, w, h);
// 对齐到当前帧
const at = (c.trimStart || 0) + (t - (c.start || 0));
if (Math.abs(e.elem.currentTime - at) > 0.05 && e.elem.readyState >= 1) {
try { e.elem.currentTime = at; } catch (err) {}
}
} else if (e && e.img) {
drawMedia(e.img, ctx, x, y, w, h);
}
ctx.restore();
}
// 文本层
ctx.globalAlpha = 1;
ctx.filter = 'none';
const textClips = this.clips.filter(c => c.assetType === 'text' && t >= (c.start || 0) && t < (c.start || 0) + (c.duration || 0));
for (const c of textClips) {
const lt = t - (c.start || 0);
drawText(c, ctx, W, H, lt);
}
ctx.globalAlpha = 1;
}
activeTransitions(t) {
const out = [];
for (const tr of (this.proj.transitions || [])) {
const a = this.clips.find(x => x.id === tr.a);
const b = this.clips.find(x => x.id === tr.b);
if (!a || !b) continue;
const dur = tr.dur || 0.5;
const t0 = b.start;
if (t >= t0 && t <= t0 + dur) {
out.push({ ...tr, a, b, p: (t - t0) / dur });
}
}
return out;
}
// ---------- 导出 ----------
async exportVideo(opts = {}) {
if (this.exporting) return { ok: false, error: '正在导出中' };
this.exporting = true;
try {
const fps = opts.fps || this.proj.fps || 30;
await this.waitMediaReady();
this.master.disconnect(this.exportDest);
this.exportDest = null;
return { ok: true, blob, mime, duration: dur, width: this.canvas.width, height: this.canvas.height };
} catch (e) {
console.error('export error', e);
try { this.master.disconnect(this.exportDest); } catch (err) {}
this.exportDest = null;
this.exporting = false;
return { ok: false, error: '导出失败:' + e.message };
}
}
waitMediaReady(timeout = 15000) {
const need = this.clips.filter(c => c.assetType === 'video' || c.assetType === 'image');
const t0 = Date.now();
return new Promise((res) => {
const check = () => {
const ready = need.every(c => {
const e = this.els[c.id];
if (!e) return true;
if (e.elem) return e.elem.readyState >= 2;
if (e.img) return e.img.complete && e.img.naturalWidth > 0;
return true;
});
if (ready || Date.now() - t0 > timeout) res();
else setTimeout(check, 150);
};
check();
});
}
dispose() {
this.pause();
this.stopMedia();
for (const k in this.mediaEls) {
try { this.mediaEls[k].pause(); this.mediaEls[k].src = ''; } catch (e) {}
}
}
}
// ---------- 工具 ----------
function drawMedia(media, ctx, x, y, w, h) {
if (!media) return;
let iw, ih;
if (media.videoWidth) { iw = media.videoWidth; ih = media.videoHeight; }
else if (media.naturalWidth) { iw = media.naturalWidth; ih = media.naturalHeight; }
else return;
if (!iw || !ih) return;
const scale = Math.max(w / iw, h / ih);
const dw = iw * scale, dh = ih * scale;
const dx = x + (w - dw) / 2, dy = y + (h - dh) / 2;
ctx.drawImage(media, dx, dy, dw, dh);
}
function drawText(c, ctx, W, H, lt) {
const fs = (c.fontSize || 72) * Math.max(W / 1080, 1);
const x = c.x ?? W / 2, y = c.y ?? H * 0.3;
let alpha = 1, dy = 0, sc = 1;
if (c.anim === 'fade') alpha = Math.min(1, lt / 0.5) * Math.min(1, (c.duration - lt) / 0.4 + 0.2);
if (c.anim === 'slide') { dy = (1 - Math.min(1, lt / 0.6)) * 60; alpha = Math.min(1, lt / 0.6); }
if (c.anim === 'zoom') { sc = 0.6 + 0.4 * Math.min(1, lt / 0.5); alpha = Math.min(1, lt / 0.4); }
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
ctx.translate(x, y + dy);
ctx.scale(sc, sc);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `bold ${fs}px "PingFang SC","Microsoft YaHei",sans-serif`;
if (c.bg) {
const bw = ctx.measureText(c.text || '').width + fs * 1.4;
ctx.fillStyle = c.bg;
roundRect(ctx, -bw / 2, -fs * 0.9, bw, fs * 1.8, fs * 0.5);
ctx.fill();
}
ctx.lineWidth = fs * 0.12;
ctx.strokeStyle = 'rgba(0,0,0,.6)';
ctx.strokeText(c.text || '', 0, 0);
ctx.fillStyle = c.color || '#ffffff';
ctx.fillText(c.text || '', 0, 0);
ctx.restore();
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
// ---------- 滤镜预设 ----------
const Filters = {
presets: {
none: { name: '原片', css: 'none', sw: 'linear-gradient(135deg,#aaa,#666)' },
warm: { name: '暖阳', css: 'sepia(0.35) saturate(1.15) contrast(1.05)', sw: 'linear-gradient(135deg,#ff9a56,#ff5e62)' },
cool: { name: '冷调', css: 'saturate(0.85) hue-rotate(12deg) brightness(1.02)', sw: 'linear-gradient(135deg,#4facfe,#00f2fe)' },
bw: { name: '黑白', css: 'grayscale(1) contrast(1.1)', sw: 'linear-gradient(135deg,#333,#999)' },
vintage: { name: '复古', css: 'sepia(0.5) contrast(0.95) brightness(0.95)', sw: 'linear-gradient(135deg,#c79081,#dfa579)' },
fresh: { name: '清新', css: 'saturate(1.2) brightness(1.05) contrast(0.98)', sw: 'linear-gradient(135deg,#a1ffce,#faffd1)' },
film: { name: '电影', css: 'contrast(1.15) saturate(1.1) brightness(0.94) sepia(0.12)', sw: 'linear-gradient(135deg,#373b44,#4286f4)' },
fade: { name: '褪色', css: 'saturate(0.6) brightness(1.06) contrast(0.92)', sw: 'linear-gradient(135deg,#bdc3c7,#2c3e50)' },
dream: { name: '梦幻', css: 'saturate(1.4) brightness(1.08) hue-rotate(-10deg)', sw: 'linear-gradient(135deg,#ff9a9e,#fecfef)' },
cyber: { name: '赛博', css: 'contrast(1.25) saturate(1.5) hue-rotate(200deg) brightness(1.02)', sw: 'linear-gradient(135deg,#00f2fe,#4facfe)' },
night: { name: '夜景', css: 'brightness(0.82) contrast(1.1) saturate(0.9) hue-rotate(200deg)', sw: 'linear-gradient(135deg,#0f2027,#2c5364)' },
vivid: { name: '鲜明', css: 'saturate(1.6) contrast(1.08)', sw: 'linear-gradient(135deg,#f7971e,#ffd200)' },
},
toCss(name) { return (this.presets[name] || this.presets.none).css; }
};
const Transitions = [
{ key: 'none', name: '硬切' },
{ key: 'fade', name: '淡入淡出' },
{ key: 'slide', name: '滑动' },
{ key: 'zoom', name: '缩放' },
];
const MusicLibrary = [
{ key: 'warm', name: '温馨治愈', icon: '🌷', desc: '76BPM · 适合生活/美食/日常' },
{ key: 'fresh', name: '清新活力', icon: '🌿', desc: '100BPM · 适合旅行/Vlog' },
{ key: 'epic', name: '激昂大片', icon: '🔥', desc: '120BPM · 适合预告/燃向' },
{ key: 'night', name: '深夜思绪', icon: '🌙', desc: '70BPM · 适合伤感/独白' },
{ key: 'tech', name: '科技未来', icon: '🤖', desc: '110BPM · 适合数码/演示' },
{ key: 'happy', name: '欢乐节奏', icon: '🎉', desc: '118BPM · 适合生日/聚会' },
];