1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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('提示词');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user