Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { AnimationState } from '../../types';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CharacterAnimationConfig,
|
||||
} from '../../types';
|
||||
|
||||
export const MASTER_VISUAL_WIDTH = 1024;
|
||||
export const MASTER_VISUAL_HEIGHT = 1536;
|
||||
@@ -35,6 +39,80 @@ export type DraftAnimationClip = {
|
||||
loop: boolean;
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
previewVideoPath?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CHARACTER_ANIMATIONS: Record<
|
||||
AnimationState,
|
||||
CharacterAnimationConfig
|
||||
> = {
|
||||
[AnimationState.ACQUIRE]: {
|
||||
frames: 1,
|
||||
prefix: 'acquire',
|
||||
folder: 'acquire',
|
||||
},
|
||||
[AnimationState.ATTACK]: { frames: 1, prefix: 'Attack', folder: 'attack' },
|
||||
[AnimationState.RUN]: { frames: 1, prefix: 'Run', folder: 'run' },
|
||||
[AnimationState.DOUBLE_JUMP]: {
|
||||
frames: 1,
|
||||
prefix: 'double jump',
|
||||
folder: 'double jump',
|
||||
},
|
||||
[AnimationState.JUMP_ATTACK]: {
|
||||
frames: 1,
|
||||
prefix: 'jump attack',
|
||||
folder: 'jump attack',
|
||||
},
|
||||
[AnimationState.DASH]: { frames: 1, prefix: 'dash', folder: 'dash' },
|
||||
[AnimationState.HURT]: { frames: 1, prefix: 'hurt', folder: 'hurt' },
|
||||
[AnimationState.DIE]: { frames: 1, prefix: 'die', folder: 'die' },
|
||||
[AnimationState.CLIMB]: { frames: 1, prefix: 'Climb', folder: 'climb' },
|
||||
[AnimationState.SKILL1]: { frames: 1, prefix: 'skill1', folder: 'skill1' },
|
||||
[AnimationState.SKILL1_JUMP]: {
|
||||
frames: 1,
|
||||
prefix: 'skill1 jump',
|
||||
folder: 'skill1 jump',
|
||||
},
|
||||
[AnimationState.SKILL1_BULLET]: {
|
||||
frames: 1,
|
||||
prefix: 'skill1 bullet',
|
||||
folder: 'skill1 bullet',
|
||||
},
|
||||
[AnimationState.SKILL1_BULLET_FX]: {
|
||||
frames: 1,
|
||||
prefix: 'skill1 bullet FX',
|
||||
folder: 'skill1 bullet FX',
|
||||
},
|
||||
[AnimationState.SKILL2]: { frames: 1, prefix: 'skill2', folder: 'skill2' },
|
||||
[AnimationState.SKILL2_JUMP]: {
|
||||
frames: 1,
|
||||
prefix: 'skill2 jump',
|
||||
folder: 'skill2 jump',
|
||||
},
|
||||
[AnimationState.SKILL3]: { frames: 1, prefix: 'skill3', folder: 'skill3' },
|
||||
[AnimationState.SKILL3_JUMP]: {
|
||||
frames: 1,
|
||||
prefix: 'skill3 jump',
|
||||
folder: 'skill3 jump',
|
||||
},
|
||||
[AnimationState.SKILL3_BULLET]: {
|
||||
frames: 1,
|
||||
prefix: 'skill3 bullet',
|
||||
folder: 'skill3 bullet',
|
||||
},
|
||||
[AnimationState.SKILL3_BULLET_FX]: {
|
||||
frames: 1,
|
||||
prefix: 'skill3 bullet FX',
|
||||
folder: 'skill3 bullet FX',
|
||||
},
|
||||
[AnimationState.SKILL4]: { frames: 1, prefix: 'skill4', folder: 'skill4' },
|
||||
[AnimationState.WALL_SLIDE]: {
|
||||
frames: 1,
|
||||
prefix: 'Wall Slide',
|
||||
folder: 'Wall Slide',
|
||||
},
|
||||
[AnimationState.IDLE]: { frames: 1, prefix: 'Idle', folder: 'idle' },
|
||||
[AnimationState.JUMP]: { frames: 1, prefix: 'Jump', folder: 'jump' },
|
||||
};
|
||||
|
||||
type PoseTransform = {
|
||||
@@ -52,7 +130,11 @@ type ActionTemplate = {
|
||||
frames: number;
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
poseAt: (progress: number, frameIndex: number, totalFrames: number) => PoseTransform;
|
||||
poseAt: (
|
||||
progress: number,
|
||||
frameIndex: number,
|
||||
totalFrames: number,
|
||||
) => PoseTransform;
|
||||
};
|
||||
|
||||
const ACTION_TEMPLATES: Record<AnimationState, ActionTemplate> = {
|
||||
@@ -226,67 +308,133 @@ const ACTION_TEMPLATES: Record<AnimationState, ActionTemplate> = {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL1_JUMP]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL1_BULLET]: {
|
||||
frames: 4,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL1_BULLET_FX]: {
|
||||
frames: 4,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL2]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL2_JUMP]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL3]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL3_JUMP]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL3_BULLET]: {
|
||||
frames: 4,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL3_BULLET_FX]: {
|
||||
frames: 4,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL4]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
poseAt: () => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -309,6 +457,19 @@ export function loadImageFromSource(source: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadVideoFromSource(source: string) {
|
||||
return new Promise<HTMLVideoElement>((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.preload = 'auto';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.onloadeddata = () => resolve(video);
|
||||
video.onerror = () => reject(new Error(`加载视频失败:${source}`));
|
||||
video.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function createCanvas(width: number, height: number) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
@@ -317,7 +478,51 @@ function createCanvas(width: number, height: number) {
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
return {canvas, context};
|
||||
return { canvas, context };
|
||||
}
|
||||
|
||||
function drawContainedSource(
|
||||
context: CanvasRenderingContext2D,
|
||||
source: CanvasImageSource,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
options: {
|
||||
width: number;
|
||||
height: number;
|
||||
translateX?: number;
|
||||
translateY?: number;
|
||||
scale?: number;
|
||||
rotation?: number;
|
||||
alpha?: number;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
translateX = 0,
|
||||
translateY = 0,
|
||||
scale = 1,
|
||||
rotation = 0,
|
||||
alpha = 1,
|
||||
} = options;
|
||||
const fitScale = Math.min(width / sourceWidth, height / sourceHeight);
|
||||
const drawWidth = sourceWidth * fitScale * scale;
|
||||
const drawHeight = sourceHeight * fitScale * scale;
|
||||
const centerX = width / 2 + translateX;
|
||||
const centerY = height / 2 + translateY;
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = alpha;
|
||||
context.translate(centerX, centerY);
|
||||
context.rotate(rotation);
|
||||
context.drawImage(
|
||||
source,
|
||||
-drawWidth / 2,
|
||||
-drawHeight / 2,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawContainedImage(
|
||||
@@ -342,18 +547,15 @@ function drawContainedImage(
|
||||
rotation = 0,
|
||||
alpha = 1,
|
||||
} = options;
|
||||
const fitScale = Math.min(width / image.width, height / image.height);
|
||||
const drawWidth = image.width * fitScale * scale;
|
||||
const drawHeight = image.height * fitScale * scale;
|
||||
const centerX = width / 2 + translateX;
|
||||
const centerY = height / 2 + translateY;
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = alpha;
|
||||
context.translate(centerX, centerY);
|
||||
context.rotate(rotation);
|
||||
context.drawImage(image, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
|
||||
context.restore();
|
||||
drawContainedSource(context, image, image.width, image.height, {
|
||||
width,
|
||||
height,
|
||||
translateX,
|
||||
translateY,
|
||||
scale,
|
||||
rotation,
|
||||
alpha,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildVisualCandidatesFromSource(source: string) {
|
||||
@@ -365,13 +567,22 @@ export async function buildVisualCandidatesFromSource(source: string) {
|
||||
translateY: number;
|
||||
tint?: string;
|
||||
}> = [
|
||||
{id: 'balanced', label: '平衡构图', scale: 1, translateY: 0},
|
||||
{id: 'closer', label: '主体更近', scale: 1.08, translateY: 18},
|
||||
{id: 'lighter', label: '轻提主体', scale: 0.96, translateY: -22, tint: 'rgba(16, 185, 129, 0.08)'},
|
||||
{ id: 'balanced', label: '平衡构图', scale: 1, translateY: 0 },
|
||||
{ id: 'closer', label: '主体更近', scale: 1.08, translateY: 18 },
|
||||
{
|
||||
id: 'lighter',
|
||||
label: '轻提主体',
|
||||
scale: 0.96,
|
||||
translateY: -22,
|
||||
tint: 'rgba(16, 185, 129, 0.08)',
|
||||
},
|
||||
];
|
||||
|
||||
return variants.map((variant) => {
|
||||
const {canvas, context} = createCanvas(MASTER_VISUAL_WIDTH, MASTER_VISUAL_HEIGHT);
|
||||
const { canvas, context } = createCanvas(
|
||||
MASTER_VISUAL_WIDTH,
|
||||
MASTER_VISUAL_HEIGHT,
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width * 0.82,
|
||||
@@ -432,11 +643,11 @@ function drawTintOverlay(
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function renderPoseFrame(
|
||||
image: HTMLImageElement,
|
||||
pose: PoseTransform,
|
||||
) {
|
||||
const {canvas, context} = createCanvas(GENERATED_FRAME_WIDTH, GENERATED_FRAME_HEIGHT);
|
||||
function renderPoseFrame(image: HTMLImageElement, pose: PoseTransform) {
|
||||
const { canvas, context } = createCanvas(
|
||||
GENERATED_FRAME_WIDTH,
|
||||
GENERATED_FRAME_HEIGHT,
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawShadow(context, canvas.width, canvas.height, pose);
|
||||
|
||||
@@ -452,7 +663,13 @@ function renderPoseFrame(
|
||||
context.globalAlpha = alpha;
|
||||
context.translate(centerX + offsetX, bottomY);
|
||||
context.rotate(pose.rotation);
|
||||
context.drawImage(image, -drawWidth / 2, -drawHeight, drawWidth, drawHeight);
|
||||
context.drawImage(
|
||||
image,
|
||||
-drawWidth / 2,
|
||||
-drawHeight,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
);
|
||||
context.restore();
|
||||
};
|
||||
|
||||
@@ -476,11 +693,9 @@ export async function buildAnimationClipFromMaster(
|
||||
) {
|
||||
const image = await loadImageFromSource(masterSource);
|
||||
const template = ACTION_TEMPLATES[animation];
|
||||
const frames = Array.from({length: template.frames}, (_, frameIndex) => {
|
||||
const frames = Array.from({ length: template.frames }, (_, frameIndex) => {
|
||||
const progress =
|
||||
template.frames <= 1
|
||||
? 0
|
||||
: frameIndex / Math.max(1, template.frames - 1);
|
||||
template.frames <= 1 ? 0 : frameIndex / Math.max(1, template.frames - 1);
|
||||
return renderPoseFrame(
|
||||
image,
|
||||
template.poseAt(progress, frameIndex, template.frames),
|
||||
@@ -496,3 +711,309 @@ export async function buildAnimationClipFromMaster(
|
||||
frameHeight: GENERATED_FRAME_HEIGHT,
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
|
||||
function applyGreenScreenAlpha(
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const imageData = context.getImageData(0, 0, width, height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
const red = pixels[index] ?? 0;
|
||||
const green = pixels[index + 1] ?? 0;
|
||||
const blue = pixels[index + 2] ?? 0;
|
||||
const alpha = pixels[index + 3] ?? 0;
|
||||
const greenLead = green - Math.max(red, blue);
|
||||
|
||||
if (alpha === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (green > 96 && greenLead > 34) {
|
||||
const fade = Math.max(0, 255 - greenLead * 5);
|
||||
pixels[index + 3] = Math.min(alpha, fade);
|
||||
if (greenLead > 60) {
|
||||
pixels[index + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
async function normalizeFrameSourceToDataUrl(
|
||||
frameSource: string,
|
||||
options: {
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
applyChromaKey: boolean;
|
||||
},
|
||||
) {
|
||||
const image = await loadImageFromSource(frameSource);
|
||||
const { canvas, context } = createCanvas(
|
||||
options.frameWidth,
|
||||
options.frameHeight,
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function seekVideo(video: HTMLVideoElement, targetTime: number) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (Math.abs(video.currentTime - targetTime) < 0.001) {
|
||||
window.requestAnimationFrame(() => resolve());
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSeeked = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleError = () => {
|
||||
cleanup();
|
||||
reject(new Error('视频定位失败'));
|
||||
};
|
||||
const cleanup = () => {
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
video.removeEventListener('error', handleError);
|
||||
};
|
||||
|
||||
video.addEventListener('seeked', handleSeeked, { once: true });
|
||||
video.addEventListener('error', handleError, { once: true });
|
||||
video.currentTime = Math.max(0, targetTime);
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildAnimationClipFromImageSources(
|
||||
sources: string[],
|
||||
options: {
|
||||
animation: AnimationState;
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
applyChromaKey?: boolean;
|
||||
},
|
||||
) {
|
||||
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
|
||||
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
|
||||
const frames = await Promise.all(
|
||||
sources.map((source) =>
|
||||
normalizeFrameSourceToDataUrl(source, {
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
applyChromaKey: options.applyChromaKey ?? false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
animation: options.animation,
|
||||
frames,
|
||||
fps: Math.max(1, options.fps),
|
||||
loop: options.loop,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
|
||||
export async function buildAnimationClipFromVideoSource(
|
||||
videoSource: string,
|
||||
options: {
|
||||
animation: AnimationState;
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
frameCount?: number;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
applyChromaKey?: boolean;
|
||||
},
|
||||
) {
|
||||
const video = await loadVideoFromSource(videoSource);
|
||||
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
|
||||
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
|
||||
const duration =
|
||||
Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 1;
|
||||
const derivedFrameCount = Math.max(
|
||||
2,
|
||||
options.frameCount ?? Math.round(duration * Math.max(1, options.fps)),
|
||||
);
|
||||
const { canvas, context } = createCanvas(frameWidth, frameHeight);
|
||||
const frames: string[] = [];
|
||||
|
||||
for (let frameIndex = 0; frameIndex < derivedFrameCount; frameIndex += 1) {
|
||||
const progress = options.loop
|
||||
? frameIndex / derivedFrameCount
|
||||
: frameIndex / Math.max(1, derivedFrameCount - 1);
|
||||
const targetTime = Math.min(duration - 0.001, duration * progress);
|
||||
|
||||
await seekVideo(video, targetTime);
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedSource(context, video, video.videoWidth, video.videoHeight, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
frames.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
|
||||
return {
|
||||
animation: options.animation,
|
||||
frames,
|
||||
fps: Math.max(1, options.fps),
|
||||
loop: options.loop,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
previewVideoPath: videoSource,
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
|
||||
function getCharacterAnimationConfig(
|
||||
character: Character,
|
||||
animation: AnimationState,
|
||||
) {
|
||||
return (
|
||||
character.animationMap?.[animation] ??
|
||||
DEFAULT_CHARACTER_ANIMATIONS[animation] ??
|
||||
character.animationMap?.[AnimationState.IDLE] ??
|
||||
DEFAULT_CHARACTER_ANIMATIONS[AnimationState.IDLE]
|
||||
);
|
||||
}
|
||||
|
||||
function getCharacterAnimationFrameSources(
|
||||
character: Character,
|
||||
animation: AnimationState,
|
||||
) {
|
||||
const config = getCharacterAnimationConfig(character, animation);
|
||||
const startFrame = config.startFrame || 1;
|
||||
const frameCount = Math.max(1, config.frames);
|
||||
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
|
||||
|
||||
return Array.from({ length: frameCount }, (_, index) => {
|
||||
const frameNumber = String(startFrame + index).padStart(2, '0');
|
||||
|
||||
if (normalizedBasePath) {
|
||||
return config.file
|
||||
? `${normalizedBasePath}/${encodeURIComponent(config.file)}`
|
||||
: `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
|
||||
}
|
||||
|
||||
const folder = encodeURIComponent(character.assetFolder);
|
||||
const variant = encodeURIComponent(character.assetVariant);
|
||||
const animationFolder = encodeURIComponent(config.folder);
|
||||
return config.file
|
||||
? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}`
|
||||
: `/character/${folder}/${variant}/Hero/${animationFolder}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
|
||||
});
|
||||
}
|
||||
|
||||
function waitFrame(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读取 Blob 失败'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function pickRecordMimeType() {
|
||||
const candidates = [
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
];
|
||||
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (
|
||||
candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildReferenceVideoFromCharacterAnimation(
|
||||
character: Character,
|
||||
animation: AnimationState,
|
||||
options: {
|
||||
fps?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
repeatLoops?: number;
|
||||
} = {},
|
||||
) {
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
throw new Error('当前浏览器不支持 MediaRecorder,无法生成内置模板视频。');
|
||||
}
|
||||
|
||||
const frameSources = getCharacterAnimationFrameSources(character, animation);
|
||||
const images = await Promise.all(
|
||||
frameSources.map((frameSource) => loadImageFromSource(frameSource)),
|
||||
);
|
||||
const width = options.width ?? GENERATED_FRAME_WIDTH;
|
||||
const height = options.height ?? GENERATED_FRAME_HEIGHT;
|
||||
const fps = Math.max(1, options.fps ?? 8);
|
||||
const repeatLoops = Math.max(1, options.repeatLoops ?? 2);
|
||||
const { canvas, context } = createCanvas(width, height);
|
||||
const stream = canvas.captureStream(fps);
|
||||
const mimeType = pickRecordMimeType();
|
||||
const recorder = mimeType
|
||||
? new MediaRecorder(stream, { mimeType })
|
||||
: new MediaRecorder(stream);
|
||||
const chunks: BlobPart[] = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
const stopPromise = new Promise<Blob>((resolve) => {
|
||||
recorder.onstop = () => {
|
||||
resolve(
|
||||
new Blob(chunks, { type: recorder.mimeType || 'video/webm' }),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
recorder.start();
|
||||
|
||||
for (let loopIndex = 0; loopIndex < repeatLoops; loopIndex += 1) {
|
||||
for (const image of images) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
await waitFrame(Math.max(40, Math.round(1000 / fps)));
|
||||
}
|
||||
}
|
||||
|
||||
await waitFrame(80);
|
||||
recorder.stop();
|
||||
const blob = await stopPromise;
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user