anime4k超分使用自管理的 WebGPU 渲染器兼容firefox
This commit is contained in:
+19
-186
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { isAnimeCategoryText } from '@/lib/anime-keyword-expr';
|
import { isAnimeCategoryText } from '@/lib/anime-keyword-expr';
|
||||||
|
import { createAnime4KRenderer } from '@/lib/anime4k';
|
||||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||||
import {
|
import {
|
||||||
clearDanmakuCacheByTitle,
|
clearDanmakuCacheByTitle,
|
||||||
@@ -3913,7 +3914,6 @@ function PlayPageClient() {
|
|||||||
const initAnime4K = async () => {
|
const initAnime4K = async () => {
|
||||||
if (!artPlayerRef.current?.video) return;
|
if (!artPlayerRef.current?.video) return;
|
||||||
|
|
||||||
let frameRequestId: number | null = null; // 在外层声明,以便错误处理中使用
|
|
||||||
let outputCanvas: HTMLCanvasElement | null = null; // 在外层声明,以便错误处理中清理
|
let outputCanvas: HTMLCanvasElement | null = null; // 在外层声明,以便错误处理中清理
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3948,29 +3948,18 @@ function PlayPageClient() {
|
|||||||
throw new Error('无法获取视频尺寸');
|
throw new Error('无法获取视频尺寸');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查视频是否正在播放
|
// 使用用户选择的超分倍数
|
||||||
console.log('视频播放状态:', {
|
const scale = anime4kScaleRef.current;
|
||||||
paused: video.paused,
|
|
||||||
ended: video.ended,
|
|
||||||
readyState: video.readyState,
|
|
||||||
currentTime: video.currentTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 检测是否为Firefox
|
|
||||||
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
|
|
||||||
console.log('浏览器检测:', isFirefox ? 'Firefox' : 'Chrome/Edge/其他');
|
|
||||||
|
|
||||||
// 创建输出canvas(显示给用户的)
|
// 创建输出canvas(显示给用户的)
|
||||||
outputCanvas = document.createElement('canvas');
|
outputCanvas = document.createElement('canvas');
|
||||||
const container = artPlayerRef.current.template.$video.parentElement;
|
const container = artPlayerRef.current.template.$video.parentElement;
|
||||||
|
|
||||||
// 使用用户选择的超分倍数
|
outputCanvas.width = Math.floor(video.videoWidth * scale); // 确保是整数
|
||||||
const scale = anime4kScaleRef.current;
|
|
||||||
outputCanvas.width = Math.floor(video.videoWidth * scale); // 确保是整数
|
|
||||||
outputCanvas.height = Math.floor(video.videoHeight * scale);
|
outputCanvas.height = Math.floor(video.videoHeight * scale);
|
||||||
|
|
||||||
// 验证outputCanvas尺寸
|
// 验证outputCanvas尺寸
|
||||||
console.log('outputCanvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
|
console.log('输出Canvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
|
||||||
if (!outputCanvas.width || !outputCanvas.height ||
|
if (!outputCanvas.width || !outputCanvas.height ||
|
||||||
!isFinite(outputCanvas.width) || !isFinite(outputCanvas.height)) {
|
!isFinite(outputCanvas.width) || !isFinite(outputCanvas.height)) {
|
||||||
throw new Error(`outputCanvas尺寸无效: ${outputCanvas.width}x${outputCanvas.height}, scale: ${scale}`);
|
throw new Error(`outputCanvas尺寸无效: ${outputCanvas.width}x${outputCanvas.height}, scale: ${scale}`);
|
||||||
@@ -3987,77 +3976,16 @@ function PlayPageClient() {
|
|||||||
// 确保canvas背景透明,避免Firefox中的渲染问题
|
// 确保canvas背景透明,避免Firefox中的渲染问题
|
||||||
outputCanvas.style.backgroundColor = 'transparent';
|
outputCanvas.style.backgroundColor = 'transparent';
|
||||||
|
|
||||||
// Firefox兼容性处理:创建中间canvas
|
outputCanvas.addEventListener('click', () => {
|
||||||
let sourceCanvas: HTMLCanvasElement | null = null;
|
|
||||||
let sourceCtx: CanvasRenderingContext2D | null = null;
|
|
||||||
|
|
||||||
if (isFirefox) {
|
|
||||||
// Firefox的WebGPU不支持直接使用HTMLVideoElement
|
|
||||||
// 使用标准HTMLCanvasElement(更好的兼容性)
|
|
||||||
sourceCanvas = document.createElement('canvas');
|
|
||||||
|
|
||||||
// 获取视频尺寸并记录
|
|
||||||
const videoW = video.videoWidth;
|
|
||||||
const videoH = video.videoHeight;
|
|
||||||
console.log('Firefox:准备创建canvas - 视频尺寸:', videoW, 'x', videoH);
|
|
||||||
|
|
||||||
// 设置canvas尺寸
|
|
||||||
const canvasW = Math.floor(videoW);
|
|
||||||
const canvasH = Math.floor(videoH);
|
|
||||||
console.log('Firefox:计算后的canvas尺寸:', canvasW, 'x', canvasH);
|
|
||||||
|
|
||||||
sourceCanvas.width = canvasW;
|
|
||||||
sourceCanvas.height = canvasH;
|
|
||||||
|
|
||||||
// 立即验证赋值结果
|
|
||||||
console.log('Firefox:Canvas创建后立即检查:');
|
|
||||||
console.log(' - sourceCanvas.width:', sourceCanvas.width);
|
|
||||||
console.log(' - sourceCanvas.height:', sourceCanvas.height);
|
|
||||||
console.log(' - 赋值是否成功:', sourceCanvas.width === canvasW && sourceCanvas.height === canvasH);
|
|
||||||
|
|
||||||
// 验证sourceCanvas尺寸
|
|
||||||
if (!sourceCanvas.width || !sourceCanvas.height ||
|
|
||||||
!isFinite(sourceCanvas.width) || !isFinite(sourceCanvas.height)) {
|
|
||||||
throw new Error(`sourceCanvas尺寸无效: ${sourceCanvas.width}x${sourceCanvas.height}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceCanvas.width !== canvasW || sourceCanvas.height !== canvasH) {
|
|
||||||
throw new Error(`sourceCanvas尺寸赋值异常: 期望 ${canvasW}x${canvasH}, 实际 ${sourceCanvas.width}x${sourceCanvas.height}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceCtx = sourceCanvas.getContext('2d', {
|
|
||||||
willReadFrequently: true,
|
|
||||||
alpha: false // 禁用alpha通道,提高性能
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!sourceCtx) {
|
|
||||||
throw new Error('无法创建2D上下文');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先绘制一帧到canvas,确保有内容
|
|
||||||
if (video.readyState >= video.HAVE_CURRENT_DATA) {
|
|
||||||
sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height);
|
|
||||||
console.log('Firefox:已绘制初始帧到sourceCanvas');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Firefox检测:使用HTMLCanvasElement中转方案');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在outputCanvas上监听点击事件,触发播放器的暂停/播放切换
|
|
||||||
const handleCanvasClick = () => {
|
|
||||||
if (artPlayerRef.current) {
|
if (artPlayerRef.current) {
|
||||||
artPlayerRef.current.toggle();
|
artPlayerRef.current.toggle();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
outputCanvas.addEventListener('click', handleCanvasClick);
|
outputCanvas.addEventListener('dblclick', () => {
|
||||||
|
|
||||||
// 在outputCanvas上监听双击事件,触发全屏切换
|
|
||||||
const handleCanvasDblClick = () => {
|
|
||||||
if (artPlayerRef.current) {
|
if (artPlayerRef.current) {
|
||||||
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
|
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
outputCanvas.addEventListener('dblclick', handleCanvasDblClick);
|
|
||||||
|
|
||||||
// 隐藏原始video元素(使用opacity而不是display:none以保持视频解码)
|
// 隐藏原始video元素(使用opacity而不是display:none以保持视频解码)
|
||||||
// Firefox在display:none时可能会停止视频解码,导致黑屏
|
// Firefox在display:none时可能会停止视频解码,导致黑屏
|
||||||
@@ -4069,20 +3997,8 @@ function PlayPageClient() {
|
|||||||
// 插入outputCanvas到容器
|
// 插入outputCanvas到容器
|
||||||
container.insertBefore(outputCanvas, video);
|
container.insertBefore(outputCanvas, video);
|
||||||
|
|
||||||
// Firefox兼容性:创建视频帧捕获循环
|
|
||||||
if (isFirefox && sourceCtx && sourceCanvas) {
|
|
||||||
const captureVideoFrame = () => {
|
|
||||||
if (sourceCtx && sourceCanvas && video.readyState >= video.HAVE_CURRENT_DATA) {
|
|
||||||
sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height);
|
|
||||||
}
|
|
||||||
frameRequestId = requestAnimationFrame(captureVideoFrame);
|
|
||||||
};
|
|
||||||
captureVideoFrame();
|
|
||||||
console.log('Firefox:视频帧捕获循环已启动');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动态导入 anime4k-webgpu 及对应的模式
|
// 动态导入 anime4k-webgpu 及对应的模式
|
||||||
const { render: anime4kRender, ModeA, ModeB, ModeC, ModeAA, ModeBB, ModeCA } = await import('anime4k-webgpu');
|
const { ModeA, ModeB, ModeC, ModeAA, ModeBB, ModeCA } = await import('anime4k-webgpu');
|
||||||
|
|
||||||
let ModeClass: any;
|
let ModeClass: any;
|
||||||
const modeName = anime4kModeRef.current;
|
const modeName = anime4kModeRef.current;
|
||||||
@@ -4110,66 +4026,23 @@ function PlayPageClient() {
|
|||||||
ModeClass = ModeA;
|
ModeClass = ModeA;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用anime4k-webgpu的render函数
|
// 使用自管理的 WebGPU 渲染器。内部自动处理各浏览器的帧源差异:
|
||||||
// Firefox使用sourceCanvas,其他浏览器直接使用video
|
// 直接从 <video> 拷贝(Chrome/Edge),或退回 createImageBitmap 中转(Firefox)。
|
||||||
const renderConfig: any = {
|
|
||||||
video: isFirefox ? sourceCanvas : video, // Firefox使用canvas中转,其他浏览器直接使用video
|
|
||||||
canvas: outputCanvas,
|
|
||||||
pipelineBuilder: (device: GPUDevice, inputTexture: GPUTexture) => {
|
|
||||||
if (!outputCanvas) {
|
|
||||||
throw new Error('outputCanvas is null in pipelineBuilder');
|
|
||||||
}
|
|
||||||
const mode = new ModeClass({
|
|
||||||
device,
|
|
||||||
inputTexture,
|
|
||||||
nativeDimensions: {
|
|
||||||
width: Math.floor(video.videoWidth), // 确保是整数
|
|
||||||
height: Math.floor(video.videoHeight),
|
|
||||||
},
|
|
||||||
targetDimensions: {
|
|
||||||
width: Math.floor(outputCanvas.width), // 确保是整数
|
|
||||||
height: Math.floor(outputCanvas.height),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return [mode];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('开始初始化Anime4K渲染器...');
|
console.log('开始初始化Anime4K渲染器...');
|
||||||
console.log('输入源:', isFirefox ? 'HTMLCanvasElement (Firefox兼容)' : 'video (原生)');
|
|
||||||
console.log('视频尺寸:', video.videoWidth, 'x', video.videoHeight);
|
console.log('视频尺寸:', video.videoWidth, 'x', video.videoHeight);
|
||||||
console.log('输出Canvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
|
console.log('输出Canvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
|
||||||
console.log('nativeDimensions:', Math.floor(video.videoWidth), 'x', Math.floor(video.videoHeight));
|
|
||||||
console.log('targetDimensions:', Math.floor(outputCanvas.width), 'x', Math.floor(outputCanvas.height));
|
|
||||||
|
|
||||||
// Firefox调试:检查sourceCanvas状态
|
const controller = await createAnime4KRenderer({
|
||||||
if (isFirefox && sourceCanvas) {
|
video,
|
||||||
console.log('sourceCanvas详细信息:');
|
canvas: outputCanvas,
|
||||||
console.log(' - width:', sourceCanvas.width, 'height:', sourceCanvas.height);
|
scale,
|
||||||
console.log(' - clientWidth:', sourceCanvas.clientWidth, 'clientHeight:', sourceCanvas.clientHeight);
|
pipelineClass: ModeClass,
|
||||||
console.log(' - offsetWidth:', sourceCanvas.offsetWidth, 'offsetHeight:', sourceCanvas.offsetHeight);
|
});
|
||||||
|
|
||||||
// 尝试读取一个像素,确认canvas有内容
|
|
||||||
if (sourceCtx) {
|
|
||||||
try {
|
|
||||||
const imageData = sourceCtx.getImageData(0, 0, 1, 1);
|
|
||||||
console.log(' - 像素数据可读:', imageData.data.length > 0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(' - 无法读取像素数据:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = await anime4kRender(renderConfig);
|
|
||||||
console.log('Anime4K渲染器初始化成功');
|
console.log('Anime4K渲染器初始化成功');
|
||||||
|
|
||||||
anime4kRef.current = {
|
anime4kRef.current = {
|
||||||
controller,
|
controller,
|
||||||
canvas: outputCanvas,
|
canvas: outputCanvas,
|
||||||
sourceCanvas: isFirefox ? sourceCanvas : null,
|
|
||||||
frameRequestId: isFirefox ? frameRequestId : null,
|
|
||||||
handleCanvasClick,
|
|
||||||
handleCanvasDblClick,
|
|
||||||
};
|
};
|
||||||
syncAnime4KCanvasFlip();
|
syncAnime4KCanvasFlip();
|
||||||
|
|
||||||
@@ -4183,11 +4056,6 @@ function PlayPageClient() {
|
|||||||
artPlayerRef.current.notice.show = '超分启用失败:' + (err instanceof Error ? err.message : '未知错误');
|
artPlayerRef.current.notice.show = '超分启用失败:' + (err instanceof Error ? err.message : '未知错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止帧捕获循环
|
|
||||||
if (frameRequestId) {
|
|
||||||
cancelAnimationFrame(frameRequestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除outputCanvas(如果已创建)
|
// 移除outputCanvas(如果已创建)
|
||||||
if (outputCanvas && outputCanvas.parentNode) {
|
if (outputCanvas && outputCanvas.parentNode) {
|
||||||
outputCanvas.parentNode.removeChild(outputCanvas);
|
outputCanvas.parentNode.removeChild(outputCanvas);
|
||||||
@@ -4207,49 +4075,14 @@ function PlayPageClient() {
|
|||||||
const cleanupAnime4K = async () => {
|
const cleanupAnime4K = async () => {
|
||||||
if (anime4kRef.current) {
|
if (anime4kRef.current) {
|
||||||
try {
|
try {
|
||||||
// 停止帧捕获循环(仅Firefox)
|
// 停止渲染循环并释放 WebGPU 资源
|
||||||
if (anime4kRef.current.frameRequestId) {
|
|
||||||
cancelAnimationFrame(anime4kRef.current.frameRequestId);
|
|
||||||
console.log('Firefox:帧捕获循环已停止');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止渲染循环
|
|
||||||
anime4kRef.current.controller?.stop?.();
|
anime4kRef.current.controller?.stop?.();
|
||||||
|
|
||||||
// 移除canvas事件监听器
|
|
||||||
if (anime4kRef.current.canvas) {
|
|
||||||
if (anime4kRef.current.handleCanvasClick) {
|
|
||||||
anime4kRef.current.canvas.removeEventListener('click', anime4kRef.current.handleCanvasClick);
|
|
||||||
}
|
|
||||||
if (anime4kRef.current.handleCanvasDblClick) {
|
|
||||||
anime4kRef.current.canvas.removeEventListener('dblclick', anime4kRef.current.handleCanvasDblClick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除canvas
|
// 移除canvas
|
||||||
if (anime4kRef.current.canvas && anime4kRef.current.canvas.parentNode) {
|
if (anime4kRef.current.canvas && anime4kRef.current.canvas.parentNode) {
|
||||||
anime4kRef.current.canvas.parentNode.removeChild(anime4kRef.current.canvas);
|
anime4kRef.current.canvas.parentNode.removeChild(anime4kRef.current.canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理sourceCanvas(仅Firefox)
|
|
||||||
if (anime4kRef.current.sourceCanvas) {
|
|
||||||
if (anime4kRef.current.sourceCanvas instanceof OffscreenCanvas) {
|
|
||||||
// OffscreenCanvas的清理
|
|
||||||
const ctx = anime4kRef.current.sourceCanvas.getContext('2d');
|
|
||||||
if (ctx) {
|
|
||||||
ctx.clearRect(0, 0, anime4kRef.current.sourceCanvas.width, anime4kRef.current.sourceCanvas.height);
|
|
||||||
}
|
|
||||||
console.log('Firefox:OffscreenCanvas已清理');
|
|
||||||
} else {
|
|
||||||
// HTMLCanvasElement的清理
|
|
||||||
const ctx = anime4kRef.current.sourceCanvas.getContext('2d');
|
|
||||||
if (ctx) {
|
|
||||||
ctx.clearRect(0, 0, anime4kRef.current.sourceCanvas.width, anime4kRef.current.sourceCanvas.height);
|
|
||||||
}
|
|
||||||
console.log('Firefox:HTMLCanvasElement已清理');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
anime4kRef.current = null;
|
anime4kRef.current = null;
|
||||||
|
|
||||||
// 恢复原始video显示
|
// 恢复原始video显示
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// 自管理 WebGPU 超分渲染器:逐帧从 <video> 取图(必要时经 createImageBitmap 中转)
|
||||||
|
// 送入 anime4k-webgpu 管线放大后绘制到 canvas。
|
||||||
|
//
|
||||||
|
// 参考 Anime4K-WebExtension 的 renderer.ts 实现,解决 Firefox 下
|
||||||
|
// copyExternalImageToTexture({ source: DOM video/canvas }) 不可用的问题:
|
||||||
|
// Firefox 只可靠支持用 ImageBitmap 作为外部图像源,因此做主循环前先用
|
||||||
|
// canCopyExternalImageToTexture() 探测;探测失败则退回 createImageBitmap(video) 路径。
|
||||||
|
import type { Anime4KPipeline } from 'anime4k-webgpu';
|
||||||
|
|
||||||
|
export interface Anime4KModeConstructor {
|
||||||
|
new (args: {
|
||||||
|
device: GPUDevice;
|
||||||
|
inputTexture: GPUTexture;
|
||||||
|
nativeDimensions: { width: number; height: number };
|
||||||
|
targetDimensions: { width: number; height: number };
|
||||||
|
}): Anime4KPipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Anime4KRendererOptions {
|
||||||
|
video: HTMLVideoElement;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
/** 超分倍数,输出尺寸 = 视频原生尺寸 * scale */
|
||||||
|
scale: number;
|
||||||
|
/** Anime4K 模式类,如 anime4k-webgpu 的 ModeA / ModeB ... */
|
||||||
|
pipelineClass: Anime4KModeConstructor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Anime4KController {
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullscreenTexturedQuadWGSL = `
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) Position : vec4<f32>,
|
||||||
|
@location(0) fragUV : vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vert_main(@builtin(vertex_index) VertexIndex : u32) -> VertexOutput {
|
||||||
|
const pos = array(
|
||||||
|
vec2( 1.0, 1.0),
|
||||||
|
vec2( 1.0, -1.0),
|
||||||
|
vec2(-1.0, -1.0),
|
||||||
|
vec2( 1.0, 1.0),
|
||||||
|
vec2(-1.0, -1.0),
|
||||||
|
vec2(-1.0, 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uv = array(
|
||||||
|
vec2(1.0, 0.0),
|
||||||
|
vec2(1.0, 1.0),
|
||||||
|
vec2(0.0, 1.0),
|
||||||
|
vec2(1.0, 0.0),
|
||||||
|
vec2(0.0, 1.0),
|
||||||
|
vec2(0.0, 0.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
var output : VertexOutput;
|
||||||
|
output.Position = vec4(pos[VertexIndex], 0.0, 1.0);
|
||||||
|
output.fragUV = uv[VertexIndex];
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sampleExternalTextureWGSL = `
|
||||||
|
@group(0) @binding(1) var mySampler: sampler;
|
||||||
|
@group(0) @binding(2) var myTexture: texture_2d<f32>;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main(@location(0) fragUV : vec2f) -> @location(0) vec4f {
|
||||||
|
return textureSampleBaseClampToEdge(myTexture, mySampler, fragUV);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 探测当前 WebGPU 实现是否支持把画面拷贝进外部纹理。
|
||||||
|
* Firefox 不支持直接从 DOM video/canvas 拷贝,但支持 ImageBitmap,因此用它决定回退路径。
|
||||||
|
*/
|
||||||
|
async function canCopyExternalImageToTexture(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const adapter = await navigator.gpu.requestAdapter();
|
||||||
|
const device = await adapter?.requestDevice();
|
||||||
|
if (!device) return false;
|
||||||
|
|
||||||
|
const offscreen = new OffscreenCanvas(1, 1);
|
||||||
|
const ctx = offscreen.getContext('2d') as unknown as CanvasRenderingContext2D | null;
|
||||||
|
if (!ctx) return false;
|
||||||
|
ctx.fillRect(0, 0, 1, 1);
|
||||||
|
|
||||||
|
// 用 VideoFrame 探测外部图像拷贝支持:Firefox 的 WebGPU(wgpu)不支持把
|
||||||
|
// VideoFrame 作为 copyExternalImageToTexture 源,会抛异常 → 主循环退回 ImageBitmap 路径。
|
||||||
|
// 旧版 TS lib 没有 VideoFrame 类型,运行时取构造器即可。
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const VideoFrameCtor = (window as any).VideoFrame as unknown;
|
||||||
|
if (typeof VideoFrameCtor !== 'function') return false;
|
||||||
|
const frame = new (VideoFrameCtor as {
|
||||||
|
new (source: CanvasImageSource, opts: { timestamp: number }): ImageBitmap;
|
||||||
|
})(offscreen, { timestamp: 0 });
|
||||||
|
|
||||||
|
const texture = device.createTexture({
|
||||||
|
size: [1, 1],
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
device.queue.copyExternalImageToTexture({ source: frame }, { texture }, [1, 1]);
|
||||||
|
|
||||||
|
frame.close();
|
||||||
|
texture.destroy();
|
||||||
|
device.destroy();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAnime4KRenderer(
|
||||||
|
options: Anime4KRendererOptions
|
||||||
|
): Promise<Anime4KController> {
|
||||||
|
const { video, canvas, scale, pipelineClass } = options;
|
||||||
|
|
||||||
|
const srcW = video.videoWidth;
|
||||||
|
const srcH = video.videoHeight;
|
||||||
|
if (!srcW || !srcH) {
|
||||||
|
throw new Error('无法获取视频尺寸');
|
||||||
|
}
|
||||||
|
const outW = Math.floor(srcW * scale);
|
||||||
|
const outH = Math.floor(srcH * scale);
|
||||||
|
if (!outW || !outH || !Number.isFinite(outW) || !Number.isFinite(outH)) {
|
||||||
|
throw new Error(`输出Canvas尺寸无效: ${outW}x${outH}, scale: ${scale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = await navigator.gpu.requestAdapter();
|
||||||
|
if (!adapter) {
|
||||||
|
throw new Error('WebGPU 不支持:无法获取 GPU 适配器');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与播放页相同:请求尽可能大的 buffer 上限(不超过 2GB),兼容 anime4k 管线的高分辨率纹理
|
||||||
|
const adapterLimits = adapter.limits;
|
||||||
|
const device = await adapter.requestDevice({
|
||||||
|
requiredLimits: {
|
||||||
|
maxBufferSize: Math.min(adapterLimits.maxBufferSize || 2147483648, 2147483648),
|
||||||
|
maxStorageBufferBindingSize: Math.min(
|
||||||
|
adapterLimits.maxStorageBufferBindingSize || 1073741824,
|
||||||
|
1073741824
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = canvas.getContext('webgpu');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('无法获取 WebGPU canvas 上下文');
|
||||||
|
}
|
||||||
|
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
|
||||||
|
context.configure({
|
||||||
|
device,
|
||||||
|
format: presentationFormat,
|
||||||
|
alphaMode: 'premultiplied',
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputTexture = device.createTexture({
|
||||||
|
size: [srcW, srcH, 1],
|
||||||
|
format: 'rgba16float',
|
||||||
|
usage:
|
||||||
|
GPUTextureUsage.TEXTURE_BINDING |
|
||||||
|
GPUTextureUsage.COPY_DST |
|
||||||
|
GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pipeline = new pipelineClass({
|
||||||
|
device,
|
||||||
|
inputTexture,
|
||||||
|
nativeDimensions: { width: srcW, height: srcH },
|
||||||
|
targetDimensions: { width: outW, height: outH },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 最终合成:把管线输出纹理贴满 canvas
|
||||||
|
const bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
entries: [
|
||||||
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
||||||
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const renderPipeline = await device.createRenderPipelineAsync({
|
||||||
|
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
|
||||||
|
vertex: {
|
||||||
|
module: device.createShaderModule({ code: fullscreenTexturedQuadWGSL }),
|
||||||
|
entryPoint: 'vert_main',
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: device.createShaderModule({ code: sampleExternalTextureWGSL }),
|
||||||
|
entryPoint: 'main',
|
||||||
|
targets: [{ format: presentationFormat }],
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' },
|
||||||
|
});
|
||||||
|
const sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear' });
|
||||||
|
const bindGroup = device.createBindGroup({
|
||||||
|
layout: bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 1, resource: sampler },
|
||||||
|
{ binding: 2, resource: pipeline.getOutputTexture().createView() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const useImageBitmap = !(await canCopyExternalImageToTexture());
|
||||||
|
|
||||||
|
let destroyed = false;
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
const copyCurrentFrame = async (): Promise<boolean> => {
|
||||||
|
if (destroyed || video.readyState < video.HAVE_CURRENT_DATA || video.paused) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useImageBitmap) {
|
||||||
|
const bitmap = await createImageBitmap(video);
|
||||||
|
try {
|
||||||
|
device.queue.copyExternalImageToTexture(
|
||||||
|
{ source: bitmap },
|
||||||
|
{ texture: inputTexture },
|
||||||
|
[srcW, srcH]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
device.queue.copyExternalImageToTexture(
|
||||||
|
{ source: video },
|
||||||
|
{ texture: inputTexture },
|
||||||
|
[srcW, srcH]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = device.createCommandEncoder();
|
||||||
|
pipeline.pass(encoder);
|
||||||
|
const pass = encoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: context.getCurrentTexture().createView(),
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
} as GPURenderPassColorAttachment,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
pass.setPipeline(renderPipeline);
|
||||||
|
pass.setBindGroup(0, bindGroup);
|
||||||
|
pass.draw(6);
|
||||||
|
pass.end();
|
||||||
|
device.queue.submit([encoder.finish()]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loop = async (): Promise<void> => {
|
||||||
|
if (destroyed) return;
|
||||||
|
try {
|
||||||
|
await copyCurrentFrame();
|
||||||
|
} catch (err) {
|
||||||
|
if (!destroyed) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[Anime4K] 帧处理失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
const stop = (): void => {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
try {
|
||||||
|
// Anime4KPipeline 接口未声明 destroy,运行时可安全调用
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(pipeline as any).destroy?.();
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
inputTexture.destroy();
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.unconfigure();
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
device.destroy();
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { stop };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user