This commit is contained in:
2026-04-18 13:05:29 +08:00
parent 09d4c0c31b
commit 5032701c38
77 changed files with 8538 additions and 2413 deletions

View File

@@ -726,16 +726,60 @@ function applyGreenScreenAlpha(
const blue = pixels[index + 2] ?? 0;
const alpha = pixels[index + 3] ?? 0;
const greenLead = green - Math.max(red, blue);
const greenRatio = green / Math.max(1, 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;
if (green > 72 && greenLead > 20 && greenRatio > 0.72) {
let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6));
if (green > 120 && greenLead > 48 && greenRatio > 1.12) {
nextAlpha = 0;
}
pixels[index + 3] = nextAlpha;
if (nextAlpha > 0) {
pixels[index + 1] = Math.min(
green,
Math.max(red, blue) + Math.max(6, Math.round(greenLead * 0.18)),
);
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
const alpha = pixels[index + 3] ?? 0;
if (alpha === 0) {
continue;
}
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const neighborAlphaValues = [
x > 0 ? (pixels[index - 1] ?? 255) : 255,
x + 1 < width ? (pixels[index + 7] ?? 255) : 255,
y > 0 ? (pixels[index - width * 4 + 3] ?? 255) : 255,
y + 1 < height ? (pixels[index + width * 4 + 3] ?? 255) : 255,
];
const touchesTransparentEdge = neighborAlphaValues.some(
(value) => value < 16,
);
if (!touchesTransparentEdge) {
continue;
}
if (green > Math.max(red, blue) + 4) {
pixels[index + 1] = Math.max(
Math.max(red, blue),
green - Math.round((green - Math.max(red, blue)) * 0.8),
);
}
}
}
@@ -866,6 +910,8 @@ export async function buildAnimationClipFromVideoSource(
frameWidth?: number;
frameHeight?: number;
applyChromaKey?: boolean;
sampleStartRatio?: number;
sampleEndRatio?: number;
},
) {
const video = await loadVideoFromSource(videoSource);
@@ -877,6 +923,15 @@ export async function buildAnimationClipFromVideoSource(
2,
options.frameCount ?? Math.round(duration * Math.max(1, options.fps)),
);
const sampleStartRatio = Math.min(
0.85,
Math.max(0, options.sampleStartRatio ?? 0),
);
const sampleEndRatio = Math.min(
1,
Math.max(sampleStartRatio + 0.05, options.sampleEndRatio ?? 1),
);
const sampleWindowDuration = duration * (sampleEndRatio - sampleStartRatio);
const { canvas, context } = createCanvas(frameWidth, frameHeight);
const frames: string[] = [];
@@ -884,7 +939,10 @@ export async function buildAnimationClipFromVideoSource(
const progress = options.loop
? frameIndex / derivedFrameCount
: frameIndex / Math.max(1, derivedFrameCount - 1);
const targetTime = Math.min(duration - 0.001, duration * progress);
const targetTime = Math.min(
duration - 0.001,
duration * sampleStartRatio + sampleWindowDuration * progress,
);
await seekVideo(video, targetTime);
@@ -912,6 +970,84 @@ export async function buildAnimationClipFromVideoSource(
} satisfies DraftAnimationClip;
}
async function buildReferenceVideoFromFrameSources(
frameSources: string[],
options: {
fps?: number;
width?: number;
height?: number;
repeatLoops?: number;
} = {},
) {
if (typeof MediaRecorder === 'undefined') {
throw new Error('当前浏览器不支持 MediaRecorder无法生成参考视频。');
}
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);
}
export async function buildReferenceVideoFromMasterAnimation(
masterSource: string,
animation: AnimationState,
options: {
fps?: number;
repeatLoops?: number;
width?: number;
height?: number;
} = {},
) {
const clip = await buildAnimationClipFromMaster(masterSource, animation);
return buildReferenceVideoFromFrameSources(clip.frames, {
fps: options.fps ?? clip.fps,
repeatLoops: options.repeatLoops ?? 2,
width: options.width ?? clip.frameWidth,
height: options.height ?? clip.frameHeight,
});
}
function getCharacterAnimationConfig(
character: Character,
animation: AnimationState,

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
describe('buildDefaultRolePromptBundle', () => {
it('prefers model-generated role descriptions instead of rule-based assembly', () => {
const result = buildDefaultRolePromptBundle({
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
visualDescription:
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
actionDescription:
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
sceneVisualDescription:
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
description: '熟悉裂潮边路的灰炬向导。',
});
expect(result.visualPromptText).toBe(
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
);
expect(result.animationPromptText).toBe(
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
);
expect(result.scenePromptText).toBe(
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
);
});
it('falls back to compact role descriptions without reintroducing built-in prompt rules', () => {
const result = buildDefaultRolePromptBundle({
name: '顾潮音',
title: '港口守望者',
role: '场景角色',
description: '总在潮雾港高处盯着来往船影的守望者。',
personality: '寡言、敏锐、先看人再开口。',
combatStyle: '长枪封线后借高差压制。',
motivation: '想在港口旧秩序彻底崩掉前找出新的站位。',
backstory: '他把许多没说出口的旧案痕迹留在港口高处。',
tags: ['潮雾港', '守望', '旧案'],
});
expect(result.visualPromptText).toContain('总在潮雾港高处盯着来往船影的守望者。');
expect(result.animationPromptText).toContain('长枪封线后借高差压制。');
expect(result.scenePromptText).toContain('他把许多没说出口的旧案痕迹留在港口高处。');
expect(result.visualPromptText).not.toContain('2D 横版 RPG');
expect(result.visualPromptText).not.toContain('纯绿色绿幕');
expect(result.visualPromptText).not.toContain('提示词');
});
});

View File

@@ -2,6 +2,9 @@ export type PromptDefaultRole = {
name: string;
title: string;
role: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string;
backstory?: string;
personality?: string;
@@ -20,57 +23,52 @@ function cleanSeedText(value: string | undefined, maxLength: number) {
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
}
function compactDescription(parts: Array<string | undefined>, maxLength: number) {
return parts
.map((item) => cleanSeedText(item, maxLength))
.filter(Boolean)
.join(' ')
.slice(0, maxLength);
}
export function buildDefaultRolePromptBundle(
role: PromptDefaultRole,
): CustomWorldRolePromptBundle {
const characterName = cleanSeedText(role.name, 40) || '该角色';
const roleAnchor =
[cleanSeedText(role.title, 60), cleanSeedText(role.role, 60)]
.filter(Boolean)
.join(' / ') || '关键角色';
const descriptionAnchor =
cleanSeedText(role.description, 220) ||
cleanSeedText(role.backstory, 260) ||
cleanSeedText(role.personality, 160) ||
'识别度鲜明';
const combatAnchor =
cleanSeedText(role.combatStyle, 180) ||
cleanSeedText(role.motivation, 180) ||
'动作重心稳定';
const tagAnchor =
role.tags && role.tags.length > 0
? `保留 ${role.tags.slice(0, 8).join('、')} 的角色识别点。`
: '';
const roleLabel = [cleanSeedText(role.name, 40), cleanSeedText(role.title, 40)]
.filter(Boolean)
.join('');
const fallbackVisualDescription = compactDescription(
[
roleLabel || cleanSeedText(role.role, 40),
role.description,
role.personality,
role.tags && role.tags.length > 0 ? role.tags.slice(0, 8).join('、') : '',
],
220,
);
const fallbackActionDescription = compactDescription(
[
role.actionDescription,
role.combatStyle,
role.motivation,
role.personality,
],
180,
);
const generatedSceneDescription = cleanSeedText(role.sceneVisualDescription, 220);
const fallbackSceneDescription = compactDescription(
[
role.backstory,
role.description,
role.motivation,
],
220,
);
return {
visualPromptText: [
`${characterName}${roleAnchor}`,
'单人全身2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。',
`外观气质围绕:${descriptionAnchor}`,
`动作识别点参考:${combatAnchor}`,
tagAnchor,
'构图干净,主体明确,不做正面立绘,不做夸张透视。',
]
.filter(Boolean)
.join(' '),
animationPromptText: [
`${characterName}核心动作试片。`,
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯自然。',
`动作气质参考:${combatAnchor}`,
role.personality ? `角色状态补充:${cleanSeedText(role.personality, 160)}` : '',
'起手清楚,发力明确,收招干净,避免漂移、乱摆和形体变形。',
]
.filter(Boolean)
.join(' '),
scenePromptText: [
`${characterName}关联主场景,适合作为首次登场区域或常驻活动空间。`,
'16:9 横版 RPG 场景背景,上半部分突出中远景氛围,下半部分是清晰可站立地面。',
`场景叙事气质围绕:${descriptionAnchor}`,
role.backstory ? `环境背景可埋入:${cleanSeedText(role.backstory, 260)}` : '',
role.motivation ? `场景目标暗示可参考:${cleanSeedText(role.motivation, 160)}` : '',
'整体风格统一克制,适合作为剧情探索与战斗底图。',
]
.filter(Boolean)
.join(' '),
visualPromptText:
cleanSeedText(role.visualDescription, 220) || fallbackVisualDescription,
animationPromptText: fallbackActionDescription,
scenePromptText: generatedSceneDescription || fallbackSceneDescription,
};
}