Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -10,6 +10,9 @@ import path from 'node:path';
import { loadEnv, type Plugin } from 'vite';
import { createCharacterAssetStudioPlugins } from './characterAssetStudioPlugins';
import { createQwenSpriteSheetToolPlugins } from './qwenSpriteSheetToolPlugins';
const LLM_PROXY_PATH = '/api/llm/chat/completions';
const ITEM_CATALOG_PATH = '/api/item-catalog';
const ITEM_OVERRIDES_PATH = '/api/item-overrides';
@@ -486,19 +489,55 @@ function isStringArray(value: unknown): value is string[] {
);
}
function decodeDataUrl(dataUrl: string) {
const matched = /^data:(image\/png|image\/jpeg);base64,(.+)$/u.exec(dataUrl);
if (!matched) {
throw new Error(
'Unsupported image payload. Expected PNG or JPEG data URL.',
);
async function resolveAssetSourcePayload(
rootDir: string,
source: string,
fallbackMessage: string,
) {
const dataUrlMatch =
/^data:(image\/png|image\/jpeg|image\/webp);base64,(.+)$/u.exec(source);
if (dataUrlMatch) {
const mimeType = dataUrlMatch[1];
const base64Payload = dataUrlMatch[2];
return {
buffer: Buffer.from(base64Payload, 'base64'),
extension:
mimeType === 'image/jpeg'
? 'jpg'
: mimeType === 'image/webp'
? 'webp'
: 'png',
};
}
if (!source.startsWith('/')) {
throw new Error(fallbackMessage);
}
const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, '');
const absolutePath = path.resolve(
rootDir,
'public',
...normalizedSource.split('/'),
);
const publicRoot = path.resolve(rootDir, 'public');
if (!absolutePath.startsWith(publicRoot)) {
throw new Error('Asset source points outside the public directory.');
}
const buffer = await readFile(absolutePath);
const extension = path
.extname(absolutePath)
.replace(/^\./u, '')
.toLowerCase();
if (!extension) {
throw new Error(fallbackMessage);
}
const mimeType = matched[1];
const base64Payload = matched[2];
return {
buffer: Buffer.from(base64Payload, 'base64'),
extension: mimeType === 'image/jpeg' ? 'jpg' : 'png',
buffer,
extension,
};
}
@@ -645,6 +684,7 @@ type PublishedAnimationManifest = {
loop: boolean;
frameWidth: number;
frameHeight: number;
previewVideoPath?: string;
framePaths: string[];
};
@@ -1134,12 +1174,12 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
typeof body.promptText === 'string' && body.promptText.trim()
? body.promptText.trim()
: undefined;
const selectedPreviewDataUrl =
typeof body.selectedPreviewDataUrl === 'string'
? body.selectedPreviewDataUrl
const selectedPreviewSource =
typeof body.selectedPreviewSource === 'string'
? body.selectedPreviewSource
: '';
const previewDataUrls = isStringArray(body.previewDataUrls)
? body.previewDataUrls
const previewSources = isStringArray(body.previewSources)
? body.previewSources
: [];
const width =
typeof body.width === 'number' && Number.isFinite(body.width)
@@ -1155,9 +1195,9 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
return;
}
if (!selectedPreviewDataUrl) {
if (!selectedPreviewSource) {
sendJson(res, 400, {
error: { message: 'selectedPreviewDataUrl is required.' },
error: { message: 'selectedPreviewSource is required.' },
});
return;
}
@@ -1173,14 +1213,22 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
);
await mkdir(visualDir, { recursive: true });
const masterPayload = decodeDataUrl(selectedPreviewDataUrl);
const masterPayload = await resolveAssetSourcePayload(
rootDir,
selectedPreviewSource,
'Unsupported image payload. Expected PNG/JPEG/WEBP data URL or public asset path.',
);
const masterFileName = `master.${masterPayload.extension}`;
const masterFilePath = path.join(visualDir, masterFileName);
await writeFile(masterFilePath, masterPayload.buffer);
const previewImagePaths: string[] = [];
for (let index = 0; index < previewDataUrls.length; index += 1) {
const previewPayload = decodeDataUrl(previewDataUrls[index] ?? '');
for (let index = 0; index < previewSources.length; index += 1) {
const previewPayload = await resolveAssetSourcePayload(
rootDir,
previewSources[index] ?? '',
'Unsupported image payload. Expected PNG/JPEG/WEBP data URL or public asset path.',
);
const previewFileName = `preview-${index + 1}.${previewPayload.extension}`;
await writeFile(
path.join(visualDir, previewFileName),
@@ -1329,6 +1377,7 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
loop?: unknown;
frameWidth?: unknown;
frameHeight?: unknown;
previewVideoPath?: unknown;
};
const framesDataUrls = isStringArray(typedAnimation.framesDataUrls)
? typedAnimation.framesDataUrls
@@ -1359,7 +1408,11 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
const framePaths: string[] = [];
for (let index = 0; index < framesDataUrls.length; index += 1) {
const framePayload = decodeDataUrl(framesDataUrls[index] ?? '');
const framePayload = await resolveAssetSourcePayload(
rootDir,
framesDataUrls[index] ?? '',
'Unsupported frame payload. Expected PNG/JPEG/WEBP data URL or public asset path.',
);
const frameFileName = `frame${String(index + 1).padStart(2, '0')}.${framePayload.extension}`;
await writeFile(
path.join(actionDir, frameFileName),
@@ -1371,6 +1424,11 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
}
const basePath = `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}`;
const previewVideoPath =
typeof typedAnimation.previewVideoPath === 'string' &&
typedAnimation.previewVideoPath.trim()
? typedAnimation.previewVideoPath.trim()
: undefined;
const manifest: PublishedAnimationManifest = {
id: `${animationSetId}-${actionKey}`,
animationSetId,
@@ -1382,6 +1440,7 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
loop,
frameWidth,
frameHeight,
previewVideoPath,
framePaths,
};
@@ -1476,6 +1535,8 @@ export function createLocalApiPlugins(
env: Record<string, string>,
): Plugin[] {
return [
...createCharacterAssetStudioPlugins(rootDir, mode, env),
...createQwenSpriteSheetToolPlugins(rootDir, mode, env),
createLlmProxyPlugin(rootDir, mode, env),
createCustomWorldSceneImagePlugin(rootDir, mode, env),
createItemCatalogPlugin(rootDir),