This commit is contained in:
eric
2026-03-08 01:10:10 -06:00
parent 0ef4d51406
commit f589b71b19
14 changed files with 2484 additions and 152 deletions

184
app/course/videoPoster.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* 生成视频封面图(数字游民插画风格,无序号)
* 返回 data URL可直接用作 video poster
*/
export function generateVideoPoster(
title: string,
_lessonId: string,
options?: {
width?: number;
height?: number;
bgGradient?: [string, string];
accentColor?: string;
}
): string {
const width = options?.width ?? 640;
const height = options?.height ?? 360;
const [from, to] = options?.bgGradient ?? ["#0ea5e9", "#06b6d4"];
const accent = options?.accentColor ?? "#ffffff";
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
// 数字游民插画:天空渐变背景
const skyGrad = ctx.createLinearGradient(0, 0, 0, height);
skyGrad.addColorStop(0, "#87CEEB");
skyGrad.addColorStop(0.5, "#B8E0F0");
skyGrad.addColorStop(0.75, "#E8F4F8");
skyGrad.addColorStop(1, "#F5E6D3");
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height);
// 海洋
ctx.fillStyle = "rgba(14, 165, 233, 0.25)";
ctx.fillRect(0, height * 0.65, width, height * 0.35);
ctx.strokeStyle = "rgba(255,255,255,0.4)";
ctx.lineWidth = 2;
for (let i = 0; i < 6; i++) {
ctx.beginPath();
const x = (i * width) / 5;
ctx.moveTo(x, height * 0.72);
ctx.quadraticCurveTo(x + 50, height * 0.68, x + 100, height * 0.72);
ctx.stroke();
}
// 太阳
const sunGrad = ctx.createRadialGradient(width * 0.85, height * 0.15, 0, width * 0.85, height * 0.15, 50);
sunGrad.addColorStop(0, "#FEF3C7");
sunGrad.addColorStop(0.6, "#FCD34D");
sunGrad.addColorStop(1, "rgba(252, 211, 77, 0)");
ctx.fillStyle = sunGrad;
ctx.beginPath();
ctx.arc(width * 0.85, height * 0.15, 50, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#FCD34D";
ctx.beginPath();
ctx.arc(width * 0.85, height * 0.15, 18, 0, Math.PI * 2);
ctx.fill();
// 椰子树(简化插画)
const palmX = width * 0.12;
const palmY = height * 0.55;
ctx.fillStyle = "#8B7355";
ctx.fillRect(palmX - 4, palmY, 8, height * 0.35);
ctx.fillStyle = "#22C55E";
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 0.8 - Math.PI * 0.2;
ctx.beginPath();
ctx.moveTo(palmX, palmY);
ctx.quadraticCurveTo(palmX + Math.cos(a) * 35, palmY - 25, palmX + Math.cos(a) * 55, palmY - 45);
ctx.strokeStyle = "#22C55E";
ctx.lineWidth = 6;
ctx.lineCap = "round";
ctx.stroke();
}
// 地球/ globe
const globeX = width * 0.78;
const globeY = height * 0.35;
ctx.strokeStyle = "rgba(255,255,255,0.6)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(globeX, globeY, 45, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.ellipse(globeX, globeY, 45, 15, 0.3, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.ellipse(globeX, globeY, 15, 45, -0.2, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "rgba(14, 165, 233, 0.15)";
ctx.fill();
// 笔记本电脑(数字游民工作象征)
const lapX = width * 0.25;
const lapY = height * 0.5;
ctx.fillStyle = "#E5E7EB";
ctx.fillRect(lapX - 35, lapY - 20, 70, 45);
ctx.fillStyle = "#94A3B8";
ctx.fillRect(lapX - 32, lapY - 18, 64, 35);
ctx.fillStyle = "#0ea5e9";
ctx.globalAlpha = 0.6;
ctx.fillRect(lapX - 28, lapY - 14, 56, 27);
ctx.globalAlpha = 1;
ctx.fillStyle = "#64748B";
ctx.fillRect(lapX - 38, lapY + 22, 76, 6);
ctx.fillRect(lapX - 2, lapY + 18, 4, 8);
// 云朵
ctx.fillStyle = "rgba(255,255,255,0.7)";
ctx.beginPath();
ctx.arc(width * 0.5, height * 0.2, 25, 0, Math.PI * 2);
ctx.arc(width * 0.55, height * 0.18, 20, 0, Math.PI * 2);
ctx.arc(width * 0.6, height * 0.22, 22, 0, Math.PI * 2);
ctx.fill();
// 底部半透明遮罩,让标题更清晰
const overlay = ctx.createLinearGradient(0, height * 0.5, 0, height);
overlay.addColorStop(0, "rgba(0,0,0,0)");
overlay.addColorStop(0.5, "rgba(0,0,0,0.1)");
overlay.addColorStop(1, "rgba(0,0,0,0.4)");
ctx.fillStyle = overlay;
ctx.fillRect(0, 0, width, height);
// 标题:多行自动换行
ctx.fillStyle = accent;
ctx.font = `600 ${Math.min(width * 0.045, 28)}px system-ui, "PingFang SC", sans-serif`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
const maxWidth = width * 0.9;
const lineHeight = Math.min(width * 0.05, 32);
const paddingX = width * 0.06;
const paddingY = height * 0.68;
const words = title.split("");
let line = "";
const lines: string[] = [];
for (const char of words) {
const testLine = line + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line) {
lines.push(line);
line = char;
} else {
line = testLine;
}
}
if (line) lines.push(line);
ctx.shadowColor = "rgba(0,0,0,0.35)";
ctx.shadowBlur = 6;
ctx.shadowOffsetY = 1;
const maxLines = 2;
const startY = paddingY;
lines.slice(0, maxLines).forEach((ln, i) => {
ctx.fillText(ln, paddingX, startY + i * lineHeight);
});
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
// 播放图标(右下角)
const playSize = Math.min(width, height) * 0.1;
const playX = width - width * 0.12;
const playY = height - height * 0.15;
ctx.fillStyle = "rgba(255,255,255,0.9)";
ctx.beginPath();
ctx.arc(playX, playY, playSize, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = from;
ctx.beginPath();
ctx.moveTo(playX - playSize * 0.3, playY - playSize * 0.5);
ctx.lineTo(playX - playSize * 0.3, playY + playSize * 0.5);
ctx.lineTo(playX + playSize * 0.5, playY);
ctx.closePath();
ctx.fill();
return canvas.toDataURL("image/jpeg", 0.92);
}