feat: expand novel IP and production workflows

This commit is contained in:
www
2026-09-18 08:14:05 +02:00
parent b2ae4600b4
commit d9c81a3ac0
235 changed files with 117971 additions and 2721 deletions
+95
View File
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Inject,
Param,
@@ -14,11 +15,13 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
AdminListAssetsQueryDto,
AdminUpdateAssetReviewStateDto,
AdminListCharactersQueryDto,
AdminListCopyrightRecordsQueryDto,
AdminListGlobalCharactersQueryDto,
AdminListHitAnalysesQueryDto,
AdminListNovelChaptersQueryDto,
AdminExportNovelSourceQueryDto,
AdminListNovelSourcesQueryDto,
AdminListOperationLogsQueryDto,
AdminBindCharacterGlobalDto,
@@ -30,9 +33,14 @@ import {
AdminListUsersQueryDto,
AdminListWorksQueryDto,
AdminListCreativePatternsQueryDto,
AdminBatchImportNovelChaptersDto,
AdminBindNovelVolumeDto,
AdminPromoteHitCasePatternsDto,
AdminResetUserPasswordDto,
AdminSaveNovelChapterDto,
AdminSaveNovelSourceDto,
AdminSaveGlobalCharacterDto,
AdminUpdateNovelSourceIpBibleDto,
AdminUpdateRouterAuditQualityDto,
AdminUpdateCreativePatternDto,
AdminUpdateCreativePatternStatusDto,
@@ -119,6 +127,15 @@ export class AdminController {
return this.adminService.listAssets(user, query);
}
@Patch('assets/:assetId/review-state')
updateAssetReviewState(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Body() dto: AdminUpdateAssetReviewStateDto
) {
return this.adminService.updateAssetReviewState(user, assetId, dto);
}
@Get('novel-sources')
listNovelSources(
@CurrentUser() user: AuthRequestUser,
@@ -127,6 +144,70 @@ export class AdminController {
return this.adminService.listNovelSources(user, query);
}
@Post('novel-sources')
createNovelSource(@CurrentUser() user: AuthRequestUser, @Body() dto: AdminSaveNovelSourceDto) {
return this.adminService.createNovelSource(user, dto);
}
@Patch('novel-sources/:sourceId')
updateNovelSource(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminSaveNovelSourceDto
) {
return this.adminService.updateNovelSource(user, sourceId, dto);
}
@Patch('novel-sources/:sourceId/ip-bible')
updateNovelSourceIpBible(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminUpdateNovelSourceIpBibleDto
) {
return this.adminService.updateNovelSourceIpBible(user, sourceId, dto);
}
@Get('novel-sources/:sourceId/export')
exportNovelSource(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Query() query: AdminExportNovelSourceQueryDto
) {
return this.adminService.exportNovelSource(user, sourceId, query);
}
@Delete('novel-sources/:sourceId')
deleteNovelSource(@CurrentUser() user: AuthRequestUser, @Param('sourceId') sourceId: string) {
return this.adminService.deleteNovelSource(user, sourceId);
}
@Post('novel-sources/:sourceId/chapters/batch')
batchImportNovelChapters(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminBatchImportNovelChaptersDto
) {
return this.adminService.batchImportNovelChapters(user, sourceId, dto);
}
@Patch('novel-sources/:sourceId/chapters/volume')
bindNovelVolume(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminBindNovelVolumeDto
) {
return this.adminService.bindNovelVolume(user, sourceId, dto);
}
@Post('novel-sources/:sourceId/chapters')
createNovelChapter(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminSaveNovelChapterDto
) {
return this.adminService.createNovelChapter(user, sourceId, dto);
}
@Get('novel-chapters')
listNovelChapters(
@CurrentUser() user: AuthRequestUser,
@@ -135,6 +216,20 @@ export class AdminController {
return this.adminService.listNovelChapters(user, query);
}
@Patch('novel-chapters/:chapterId')
updateNovelChapter(
@CurrentUser() user: AuthRequestUser,
@Param('chapterId') chapterId: string,
@Body() dto: AdminSaveNovelChapterDto
) {
return this.adminService.updateNovelChapter(user, chapterId, dto);
}
@Delete('novel-chapters/:chapterId')
deleteNovelChapter(@CurrentUser() user: AuthRequestUser, @Param('chapterId') chapterId: string) {
return this.adminService.deleteNovelChapter(user, chapterId);
}
@Get('characters')
listCharacters(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListCharactersQueryDto) {
return this.adminService.listCharacters(user, query);
+70
View File
@@ -14,26 +14,96 @@ export class AdminListUsersQueryDto {
export class AdminListAssetsQueryDto {
asset_type?: string;
status?: string;
selection_status?: 'all' | 'candidate' | 'selected' | 'rejected';
project_id?: string;
user_id?: string;
q?: string;
shot_no?: string;
shot_start?: string;
shot_end?: string;
page?: string;
limit?: string;
}
export class AdminUpdateAssetReviewStateDto {
display_name?: string | null;
selection_status?: 'candidate' | 'selected' | 'rejected';
selection_note?: string | null;
metadata_json?: unknown;
}
export class AdminListNovelSourcesQueryDto {
project_id?: string;
user_id?: string;
source_type?: string;
parse_status?: string;
page?: string;
limit?: string;
}
export class AdminListNovelChaptersQueryDto {
project_id?: string;
novel_source_id?: string;
volume_no?: string;
status?: string;
page?: string;
limit?: string;
}
export class AdminExportNovelSourceQueryDto {
start_chapter_no?: string;
end_chapter_no?: string;
}
export class AdminSaveNovelSourceDto {
project_id?: string;
title?: string;
author_name?: string;
source_type?: string;
intro_text?: string;
hook_text?: string;
genre?: string;
chapters_per_volume?: number | string | null;
design_text?: string;
design_json?: unknown;
volume_plan_json?: unknown;
status?: string;
}
export class AdminUpdateNovelSourceIpBibleDto {
ip_bible?: unknown;
}
export class AdminSaveNovelChapterDto {
novel_source_id?: string;
volume_no?: number | string | null;
volume_title?: string | null;
chapter_no?: number | string;
title?: string;
content?: string;
summary?: string;
visual_summary?: string;
outline_json?: unknown;
analysis_json?: unknown;
status?: string;
}
export class AdminBatchImportNovelChaptersDto {
text?: string;
volume_no?: number | string | null;
volume_title?: string | null;
start_chapter_no?: number | string;
overwrite?: boolean;
status?: string;
}
export class AdminBindNovelVolumeDto {
volume_no?: number | string | null;
volume_title?: string | null;
start_chapter_no?: number | string;
end_chapter_no?: number | string;
}
export class AdminListCharactersQueryDto {
project_id?: string;
global_character_id?: string;
+358 -1
View File
@@ -69,6 +69,44 @@ function createProject(overrides: Record<string, unknown> = {}) {
};
}
function createNovelSource(overrides: Record<string, unknown> = {}) {
return {
id: 50n,
project_id: 10n,
source_type: 'gpt_web',
title: '她签了,但没认输',
author_name: null,
raw_asset_id: null,
raw_text: null,
clean_text: null,
word_count: 0,
chapter_count: 0,
parse_status: 'parsed',
parse_report: {},
created_at: now,
...overrides
};
}
function createNovelChapter(overrides: Record<string, unknown> = {}) {
return {
id: 51n,
project_id: 10n,
novel_source_id: 50n,
volume_no: null,
volume_title: null,
chapter_no: 1,
title: '第1章:她签了,但没认输',
content: '她签下名字,抬头看向众人。',
summary: '她签下名字,抬头看向众人。',
visual_summary: '她签下名字,抬头看向众人。',
word_count: 14,
status: 'parsed',
created_at: now,
...overrides
};
}
function createEpisode(overrides: Record<string, unknown> = {}) {
return {
id: 11n,
@@ -426,6 +464,7 @@ describe('AdminService', () => {
beforeEach(() => {
prisma = {
$transaction: vi.fn(async (callback: (tx: any) => unknown) => callback(prisma)),
user: {
count: vi.fn().mockResolvedValue(2),
findUnique: vi.fn().mockResolvedValue(createUser()),
@@ -486,9 +525,19 @@ describe('AdminService', () => {
findMany: vi.fn().mockResolvedValue([])
},
novelSource: {
findMany: vi.fn().mockResolvedValue([])
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(createNovelSource()),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
createNovelSource({ ...data })
)
},
novelChapter: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createNovelChapter()),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0)
},
@@ -592,6 +641,314 @@ describe('AdminService', () => {
expect(result.projects[0].latest_task?.task_type).toBe('video_render');
});
it('lists assets with generation provider and model details', async () => {
prisma.asset.findMany.mockResolvedValue([createAsset({ id: 31n })]);
prisma.renderTask.findMany.mockResolvedValue([
createTask({
id: 15n,
provider_id: 13n,
task_type: 'live_action_video_clip_generate',
output_asset_id: 31n,
provider_request_id: 'kling-task-1',
cost_actual: new Prisma.Decimal(0.35)
})
]);
prisma.providerLog.findMany.mockResolvedValue([
createProviderLog({
task_id: 15n,
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1'
})
]);
prisma.providerConfig.findMany.mockResolvedValue([
createProviderConfig({
id: 13n,
provider_code: 'kling-image-to-video',
display_name: '可灵图生视频',
model_name: 'kling-v2-1'
})
]);
const result = await service.listAssets(admin, { limit: '10' });
expect(result.assets[0].asset.generation).toEqual(
expect.objectContaining({
provider_name: '可灵图生视频',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
task_id: '15',
provider_request_id: 'kling-task-1'
})
);
});
it('paginates assets, novel sources and novel chapters', async () => {
prisma.asset.findMany.mockResolvedValue([createAsset({ id: 31n })]);
prisma.asset.count.mockResolvedValue(101);
prisma.novelSource.findMany.mockResolvedValue([createNovelSource({ id: 50n })]);
prisma.novelSource.count.mockResolvedValue(41);
prisma.novelChapter.findMany.mockResolvedValue([createNovelChapter({ id: 51n })]);
prisma.novelChapter.count.mockResolvedValue(77);
const assetResult = await service.listAssets(admin, { page: '3', limit: '10' });
const sourceResult = await service.listNovelSources(admin, { page: '2', limit: '20' });
const chapterResult = await service.listNovelChapters(admin, { page: '4', limit: '15' });
expect(prisma.asset.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 10 }));
expect(prisma.novelSource.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 20 }));
expect(prisma.novelChapter.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 45, take: 15 }));
expect(assetResult).toEqual(expect.objectContaining({ page: 3, limit: 10, total: 101, total_pages: 11 }));
expect(sourceResult).toEqual(expect.objectContaining({ page: 2, limit: 20, total: 41, total_pages: 3 }));
expect(chapterResult).toEqual(expect.objectContaining({ page: 4, limit: 15, total: 77, total_pages: 6 }));
});
it('batch imports GPT markdown chapter headings as separate novel chapters', async () => {
const savedChapters = Array.from({ length: 5 }, (_, index) =>
createNovelChapter({
id: BigInt(51 + index),
chapter_no: index + 1,
title: `${index + 1}章:测试章节${index + 1}`,
content: `${index + 1}章正文`
})
);
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '1',
status: 'parsed',
text: [
'# 第一卷:离婚夜,她不装了',
'采用按卷推进,每次5章正文。',
'## 第1章—第5章正文',
'---',
'# 第1章:她签了,但没认输',
'第一章正文。',
'',
'# 第2章:她签了,但没认输',
'第二章正文。',
'',
'## 第3章:她没有回头',
'第三章正文。',
'',
'### 第4章:雨夜来客',
'第四章正文。',
'',
'# 第5章:旧账翻开',
'第五章正文。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toHaveLength(5);
expect(createManyArg.data.map((chapter: Record<string, unknown>) => chapter.chapter_no)).toEqual([1, 2, 3, 4, 5]);
expect(createManyArg.data.map((chapter: Record<string, unknown>) => chapter.volume_title)).toEqual([
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了'
]);
expect(createManyArg.data[1]).toEqual(
expect.objectContaining({
title: '第2章:她签了,但没认输',
content: '第二章正文。'
})
);
expect(result.imported_count).toBe(5);
});
it('keeps volume headings as chapter metadata during batch import', async () => {
const savedChapters = [
createNovelChapter({
id: 71n,
chapter_no: 1,
volume_no: 1,
volume_title: '第一卷:离婚夜',
title: '第1章:她签了',
content: '第一章正文。'
}),
createNovelChapter({
id: 72n,
chapter_no: 2,
volume_no: 2,
volume_title: '第二卷:反击',
title: '第2章:她反击',
content: '第二章正文。'
})
];
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '1',
status: 'parsed',
text: [
'第一卷:离婚夜',
'第1章:她签了',
'第一章正文。',
'',
'第二卷:反击',
'第2章:她反击',
'第二章正文。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toHaveLength(2);
expect(createManyArg.data).toEqual([
expect.objectContaining({
volume_no: 1,
volume_title: '第一卷:离婚夜',
chapter_no: 1,
title: '第1章:她签了'
}),
expect.objectContaining({
volume_no: 2,
volume_title: '第二卷:反击',
chapter_no: 2,
title: '第2章:她反击'
})
]);
});
it('auto assigns volumes from the source chapters-per-volume rule during batch import', async () => {
const source = createNovelSource({ parse_report: { chapters_per_volume: 30 } });
const savedChapters = [
createNovelChapter({ id: 91n, chapter_no: 31, volume_no: 2, volume_title: '第二卷' }),
createNovelChapter({ id: 92n, chapter_no: 32, volume_no: 2, volume_title: '第二卷' })
];
prisma.novelSource.findUnique.mockResolvedValue(source);
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '31',
status: 'parsed',
text: [
'===== 第31章:第二卷开场 =====',
'新的危机开始。',
'',
'===== 第32章:她再下一局 =====',
'她把底牌压到最后。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toEqual([
expect.objectContaining({
chapter_no: 31,
volume_no: 2,
volume_title: '第二卷'
}),
expect.objectContaining({
chapter_no: 32,
volume_no: 2,
volume_title: '第二卷'
})
]);
expect(result.imported_count).toBe(2);
});
it('binds a chapter range to a novel volume', async () => {
const savedChapters = [
createNovelChapter({ id: 81n, chapter_no: 1, volume_no: 1, volume_title: '第一卷:离婚夜' }),
createNovelChapter({ id: 82n, chapter_no: 2, volume_no: 1, volume_title: '第一卷:离婚夜' })
];
prisma.novelChapter.updateMany.mockResolvedValueOnce({ count: 2 });
prisma.novelChapter.findMany
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.bindNovelVolume(admin, '50', {
volume_no: '1',
volume_title: '第一卷:离婚夜',
start_chapter_no: '1',
end_chapter_no: '2'
});
expect(prisma.novelChapter.updateMany).toHaveBeenCalledWith({
where: {
novel_source_id: 50n,
chapter_no: { gte: 1, lte: 2 }
},
data: {
volume_no: 1,
volume_title: '第一卷:离婚夜'
}
});
expect(result.updated_count).toBe(2);
expect(result.chapters[0]).toMatchObject({
volume_no: 1,
volume_title: '第一卷:离婚夜'
});
});
it('batch imports chapters split by standalone GPT divider headings', async () => {
const savedChapters = Array.from({ length: 5 }, (_, index) =>
createNovelChapter({
id: BigInt(61 + index),
chapter_no: index + 6,
title: `${index + 6}章:测试章节${index + 6}`,
content: `${index + 6}章正文`
})
);
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '6',
status: 'parsed',
text: [
'后续我统一用这一行作为自动切割标识:',
'===== 第X章:章节标题 =====',
'',
'本次继续 第6章—第10章正文。',
'已思考 6m 51s',
'',
'===== 第6章:直播里的耳光 =====',
'顾氏股价崩了。',
'',
'###===第7章:她把证据甩上桌===###',
'她抬手投屏,会议室里鸦雀无声。',
'',
'===== 第8章:旧账翻开 =====',
'旧合同被重新翻出。',
'',
'===== 第9章:深夜来电 =====',
'电话那头只剩急促呼吸。',
'',
'===== 第10章:她没有回头 =====',
'她走出大楼,没有再回头。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toHaveLength(5);
expect(createManyArg.data.map((chapter: Record<string, unknown>) => chapter.chapter_no)).toEqual([6, 7, 8, 9, 10]);
expect(createManyArg.data[0]).toEqual(
expect.objectContaining({
title: '第6章:直播里的耳光',
content: '顾氏股价崩了。'
})
);
expect(createManyArg.data[1]).toEqual(
expect.objectContaining({
title: '第7章:她把证据甩上桌',
content: '她抬手投屏,会议室里鸦雀无声。'
})
);
expect(result.imported_count).toBe(5);
});
it('lists router quality audit rows with routing, repair and cost details', async () => {
prisma.project.findMany.mockResolvedValue([createProject({ output_mode: 'live_action_ai' })]);
prisma.episode.findMany.mockResolvedValue([createEpisode()]);
File diff suppressed because it is too large Load Diff
+23
View File
@@ -21,23 +21,46 @@ export function toSafeNovelSource(source: NovelSource) {
chapter_count: source.chapter_count,
parse_status: source.parse_status,
parse_report: source.parse_report,
ip_bible_json: sourceIpBibleFromReport(source.parse_report),
design_json: source.design_json,
volume_plan_json: source.volume_plan_json,
ai_provider_code: source.ai_provider_code,
ai_model_name: source.ai_model_name,
ai_cost_estimate: source.ai_cost_estimate ? Number(source.ai_cost_estimate.toString()) : null,
ai_cost_actual: source.ai_cost_actual ? Number(source.ai_cost_actual.toString()) : null,
text_preview: createTextPreview(source.clean_text || source.raw_text),
created_at: source.created_at.toISOString()
};
}
function sourceIpBibleFromReport(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const ipBible = (value as Record<string, unknown>).ip_bible_json;
return ipBible && typeof ipBible === 'object' && !Array.isArray(ipBible) ? ipBible : null;
}
export function toSafeNovelChapter(chapter: NovelChapter) {
return {
id: chapter.id.toString(),
project_id: chapter.project_id.toString(),
novel_source_id: chapter.novel_source_id?.toString() ?? null,
volume_no: chapter.volume_no,
volume_title: chapter.volume_title,
chapter_no: chapter.chapter_no,
title: chapter.title,
summary: chapter.summary,
visual_summary: chapter.visual_summary,
content: chapter.content,
content_preview: createTextPreview(chapter.content, 3000),
word_count: chapter.word_count,
outline_json: chapter.outline_json,
analysis_json: chapter.analysis_json,
status: chapter.status,
ai_provider_code: chapter.ai_provider_code,
ai_model_name: chapter.ai_model_name,
ai_cost_estimate: chapter.ai_cost_estimate ? Number(chapter.ai_cost_estimate.toString()) : null,
ai_cost_actual: chapter.ai_cost_actual ? Number(chapter.ai_cost_actual.toString()) : null,
created_at: chapter.created_at.toISOString()
};
}
@@ -180,6 +180,16 @@ describe('AiRouterService', () => {
mode: 'real',
is_enabled: false
}),
createProvider({
provider_code: 'volcengine_seedance_20_fast',
mode: 'real',
is_enabled: false
}),
createProvider({
provider_code: 'volcengine_seedance_20',
mode: 'real',
is_enabled: false
}),
createProvider({
provider_code: 'jimeng_seedance',
mode: 'real',
@@ -196,12 +206,87 @@ describe('AiRouterService', () => {
expect(decision.provider_code).toBe('mock-video');
expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([
'provider_disabled',
'provider_disabled',
'provider_disabled',
'provider_disabled',
'auto_normal_route'
]);
});
it('routes splus_v1 projects only through the formal Kling Omni and V3 chain', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
id: 103n,
provider_code: 'kling-v3-omni-native-audio-1080p-video',
display_name: 'Kling v3 Omni 1080P',
mode: 'real',
is_enabled: false
}),
createProvider({
id: 104n,
provider_code: 'kling-v3-native-audio-video',
display_name: 'Kling v3 1080P',
mode: 'real',
is_enabled: true,
cost_rule_json: { unit: 'video_seconds', price_per_second: 0.168, currency: 'USD' }
})
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject({ engine_version: 'splus_v1' }),
shot: createShot({ importance_score: 9, action_score: 7 }),
duration: 5
});
expect(decision.provider_code).toBe('kling-v3-native-audio-video');
expect(decision.fallback_chain).toEqual([
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-native-audio-video'
]);
expect(decision.routing_profile).toBe('splus_kling_v1');
expect(decision.capability_version).toBe('kling_video_capabilities_2026-07-15');
expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([
'provider_disabled',
'splus_kling_premium_route'
]);
});
it('rejects non-Kling manual providers for splus_v1 projects', async () => {
await expect(service.resolveLiveActionVideoRoute({
project: createProject({ engine_version: 'splus_v1' }),
shot: createShot(),
duration: 5,
manual_provider_code: 'jimeng_seedance',
allow_manual_override: true
})).rejects.toThrow('AI_ROUTER_SPLUS_PROVIDER_NOT_ALLOWED');
});
it('rejects a misconfigured splus route instead of silently selecting a legacy provider', async () => {
prisma.systemConfig.upsert.mockResolvedValueOnce({
config_key: 'ai.router.v1',
config_value: {
...DEFAULT_AI_ROUTER_CONFIG,
splus_live_action_video: {
'zh-CN': {
normal: {
provider_code: 'minimax_hailuo_23_fast',
fallback_chain: ['minimax_hailuo_23_fast', 'mock-video']
}
}
}
}
});
await expect(service.resolveLiveActionVideoRoute({
project: createProject({ engine_version: 'splus_v1' }),
shot: createShot({ route_tier: 'normal' }),
duration: 5,
language: 'zh-CN'
})).rejects.toThrow('AI_ROUTER_SPLUS_PROVIDER_CHAIN_EMPTY');
expect(prisma.providerConfig.findMany).not.toHaveBeenCalled();
});
it('keeps admin manual override as an explicit router decision', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
+52 -9
View File
@@ -5,6 +5,8 @@ import {
AI_ROUTER_CONFIG_KEY,
AI_ROUTER_DEFAULT_LANGUAGE,
DEFAULT_AI_ROUTER_CONFIG,
KLING_SPLUS_CAPABILITY_VERSION,
KLING_SPLUS_FORMAL_PROVIDER_CODES,
type AiRouteDecision,
type AiRouteTier,
type AiRouterShotScores
@@ -44,27 +46,50 @@ export class AiRouterService {
const scores = this.scoreLiveActionShot(input.shot);
const language = this.normalizeText(input.language) ?? (await this.resolveDefaultLanguage());
const manualProviderCode = this.normalizeText(input.manual_provider_code);
const isSplusProject = input.project.engine_version === 'splus_v1';
if (manualProviderCode && input.allow_manual_override) {
return this.resolveManualVideoProvider(manualProviderCode, language, input.duration, scores);
if (isSplusProject && !KLING_SPLUS_FORMAL_PROVIDER_CODES.includes(
manualProviderCode as (typeof KLING_SPLUS_FORMAL_PROVIDER_CODES)[number]
)) {
throw new BadRequestException('AI_ROUTER_SPLUS_PROVIDER_NOT_ALLOWED');
}
const decision = await this.resolveManualVideoProvider(manualProviderCode, language, input.duration, scores);
return isSplusProject ? this.withSplusMetadata(decision) : decision;
}
if (manualProviderCode && !input.allow_manual_override) {
throw new BadRequestException('AI_ROUTER_MANUAL_OVERRIDE_FORBIDDEN');
}
const config = await this.loadRouterConfig();
const languageConfig = this.resolveLiveActionLanguageConfig(config, language);
const languageConfig = this.resolveLiveActionLanguageConfig(
config,
language,
isSplusProject ? 'splus_live_action_video' : 'live_action_video'
);
const tierConfig = this.jsonObject(languageConfig[scores.route_tier]);
const primaryProviderCode =
this.normalizeText(tierConfig.provider_code) ??
(scores.route_tier === 'premium' ? 'kling-image-to-video' : 'minimax_hailuo_23_fast');
const fallbackChain = this.uniqueStrings([
(isSplusProject
? 'kling-v3-omni-native-audio-1080p-video'
: scores.route_tier === 'premium'
? 'kling-image-to-video'
: 'minimax_hailuo_23_fast');
const configuredFallbackChain = this.uniqueStrings([
primaryProviderCode,
...this.stringArray(tierConfig.fallback_chain),
'mock-video'
...(isSplusProject ? [] : ['mock-video'])
]);
const fallbackChain = isSplusProject
? configuredFallbackChain.filter((providerCode) => KLING_SPLUS_FORMAL_PROVIDER_CODES.includes(
providerCode as (typeof KLING_SPLUS_FORMAL_PROVIDER_CODES)[number]
))
: configuredFallbackChain;
if (isSplusProject && fallbackChain.length === 0) {
throw new BadRequestException('AI_ROUTER_SPLUS_PROVIDER_CHAIN_EMPTY');
}
return this.selectVideoProviderFromCandidates({
const decision = await this.selectVideoProviderFromCandidates({
language,
duration: input.duration,
scores,
@@ -72,8 +97,11 @@ export class AiRouterService {
maxCostPerClip: input.max_cost_per_clip ?? null,
dailyBudget: this.numberFromJson(this.jsonObject(config).daily_budget),
manualOverride: false,
defaultReason: `auto_${scores.route_tier}_route`
defaultReason: isSplusProject
? `splus_kling_${scores.route_tier}_route`
: `auto_${scores.route_tier}_route`
});
return isSplusProject ? this.withSplusMetadata(decision) : decision;
}
private async resolveManualVideoProvider(
@@ -210,8 +238,14 @@ export class AiRouterService {
return this.normalizeText(config.default_language) ?? AI_ROUTER_DEFAULT_LANGUAGE;
}
private resolveLiveActionLanguageConfig(config: Record<string, unknown>, language: string) {
const liveAction = this.jsonObject(config.live_action_video);
private resolveLiveActionLanguageConfig(
config: Record<string, unknown>,
language: string,
profileKey: 'live_action_video' | 'splus_live_action_video'
) {
const configuredProfile = this.jsonObject(config[profileKey]);
const defaultProfile = this.jsonObject(this.jsonObject(DEFAULT_AI_ROUTER_CONFIG)[profileKey]);
const liveAction = Object.keys(configuredProfile).length > 0 ? configuredProfile : defaultProfile;
const current = this.jsonObject(liveAction[language]);
if (Object.keys(current).length > 0) return current;
@@ -219,6 +253,15 @@ export class AiRouterService {
return this.jsonObject(liveAction[AI_ROUTER_DEFAULT_LANGUAGE]);
}
private withSplusMetadata(decision: AiRouteDecision): AiRouteDecision {
return {
...decision,
engine_version: 'splus_v1',
routing_profile: 'splus_kling_v1',
capability_version: KLING_SPLUS_CAPABILITY_VERSION
};
}
private estimateVideoCost(rule: Prisma.JsonValue | null, duration: number) {
const costRule = this.jsonObject(rule);
const flatCost = this.numberFromJson(costRule.flat_cost);
+32 -2
View File
@@ -2,6 +2,15 @@ import type { Prisma } from '@prisma/client';
export const AI_ROUTER_CONFIG_KEY = 'ai.router.v1';
export const AI_ROUTER_DEFAULT_LANGUAGE = 'zh-CN';
export const KLING_SPLUS_CAPABILITY_VERSION = 'kling_video_capabilities_2026-07-15';
export const KLING_SPLUS_FORMAL_PROVIDER_CODES = [
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-omni-native-audio-4k-video',
'kling-v3-native-audio-720p-video',
'kling-v3-native-audio-video',
'kling-v3-native-audio-4k-video'
] as const;
export const DEFAULT_AI_ROUTER_CONFIG = {
version: 1,
@@ -16,11 +25,29 @@ export const DEFAULT_AI_ROUTER_CONFIG = {
},
normal: {
provider_code: 'minimax_hailuo_23_fast',
fallback_chain: ['minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video']
fallback_chain: ['minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'volcengine_seedance_20', 'jimeng_seedance', 'mock-video']
},
premium: {
provider_code: 'kling-image-to-video',
fallback_chain: ['kling-image-to-video', 'minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video']
fallback_chain: ['kling-image-to-video', 'volcengine_seedance_20', 'minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'jimeng_seedance', 'mock-video']
}
}
},
splus_live_action_video: {
capability_version: KLING_SPLUS_CAPABILITY_VERSION,
formal_provider_codes: [...KLING_SPLUS_FORMAL_PROVIDER_CODES],
'zh-CN': {
thresholds: {
premium_importance_gt: 7,
premium_action_gt: 5
},
normal: {
provider_code: 'kling-v3-omni-native-audio-1080p-video',
fallback_chain: ['kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video']
},
premium: {
provider_code: 'kling-v3-omni-native-audio-1080p-video',
fallback_chain: ['kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video']
}
}
}
@@ -54,5 +81,8 @@ export interface AiRouteDecision {
decision_reason: string;
estimated_cost: number;
manual_override: boolean;
engine_version?: string;
routing_profile?: string;
capability_version?: string;
scores: AiRouterShotScores;
}
+6
View File
@@ -18,9 +18,12 @@ import { ImagesModule } from './images/images.module';
import { LiveActionModule } from './live-action/live-action.module';
import { MediaModule } from './media/media.module';
import { MemoriesModule } from './memories/memories.module';
import { ModelRegistryModule } from './model-registry/model-registry.module';
import { NovelsModule } from './novels/novels.module';
import { ProjectsModule } from './projects/projects.module';
import { ProviderLabModule } from './provider-lab/provider-lab.module';
import { ProvidersModule } from './providers/providers.module';
import { ProductionKernelModule } from './production-kernel/production-kernel.module';
import { PrismaModule } from './prisma/prisma.module';
import { QueuesModule } from './queues/queues.module';
import { ReviewsModule } from './reviews/reviews.module';
@@ -35,6 +38,9 @@ import { UsersModule } from './users/users.module';
AuthModule,
BillingModule,
ProjectsModule,
ProviderLabModule,
ModelRegistryModule,
ProductionKernelModule,
AssetsModule,
NovelsModule,
StoryBiblesModule,
+29 -2
View File
@@ -9,6 +9,23 @@ export interface StoredObject {
backend: 'local' | 'minio';
}
export interface SafeAssetGeneration {
source: string;
display_name: string | null;
provider_id: string | null;
provider_type: string | null;
provider_code: string | null;
provider_name: string | null;
model_name: string | null;
task_id: string | null;
task_type: string | null;
provider_request_id: string | null;
clip_id: string | null;
status: string | null;
cost_actual: string | null;
created_at: string | null;
}
export interface SafeAsset {
id: string;
user_id: string | null;
@@ -21,12 +38,17 @@ export interface SafeAsset {
duration: string | null;
size: string | null;
hash: string | null;
display_name: string | null;
selection_status: string;
selection_note: string | null;
metadata_json: unknown;
visibility: string;
status: string;
created_at: string;
generation: SafeAssetGeneration | null;
}
export function toSafeAsset(asset: Asset): SafeAsset {
export function toSafeAsset(asset: Asset, generation: SafeAssetGeneration | null = null): SafeAsset {
return {
id: asset.id.toString(),
user_id: asset.user_id?.toString() ?? null,
@@ -39,8 +61,13 @@ export function toSafeAsset(asset: Asset): SafeAsset {
duration: asset.duration?.toString() ?? null,
size: asset.size?.toString() ?? null,
hash: asset.hash,
display_name: asset.display_name ?? generation?.display_name ?? null,
selection_status: asset.selection_status,
selection_note: asset.selection_note,
metadata_json: asset.metadata_json,
visibility: asset.visibility,
status: asset.status,
created_at: asset.created_at.toISOString()
created_at: asset.created_at.toISOString(),
generation
};
}
+36 -8
View File
@@ -3,8 +3,10 @@ import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Patch,
Post,
Req,
Res,
@@ -21,7 +23,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import type { RequestWithApiCrypto } from '../common/api-crypto.service';
import { AssetsService } from './assets.service';
import { UploadAssetDto } from './upload.dto';
import { UpdateAssetReviewStateDto, UploadAssetDto } from './upload.dto';
const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = parseUploadLimitBytes(process.env.MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES);
@@ -99,16 +101,30 @@ export class AssetsController {
return this.assetsService.getAssetForUser(user, assetId);
}
@Get('assets/:assetId/preview-url')
getAssetPreviewUrl(@CurrentUser() user: AuthRequestUser, @Param('assetId') assetId: string) {
return this.assetsService.createPreviewUrlForUser(user, assetId);
}
@Patch('assets/:assetId/review-state')
updateAssetReviewState(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Body() dto: UpdateAssetReviewStateDto
) {
return this.assetsService.updateAssetReviewState(user, assetId, dto);
}
@Get('assets/:assetId/download')
async downloadAsset(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Headers('range') range: string | undefined,
@Req() request: RequestWithApiCrypto,
@Res({ passthrough: true }) response: Response
) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
if (request.apiCrypto) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return {
filename: result.filename,
@@ -118,15 +134,27 @@ export class AssetsController {
};
}
response.setHeader('Content-Type', result.asset.mime_type || 'application/octet-stream');
response.setHeader('Content-Length', result.buffer.length.toString());
const result = await this.assetsService.streamAssetForUser(user, assetId, range);
const mimeType = result.asset.mime_type || 'application/octet-stream';
const stream = result.stream;
const asciiFilename = result.filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '');
const dispositionType = mimeType.startsWith('video/') || mimeType.startsWith('audio/') ? 'inline' : 'attachment';
response.setHeader('Content-Type', mimeType);
response.setHeader('Accept-Ranges', 'bytes');
response.setHeader('Content-Length', stream.contentLength.toString());
response.setHeader(
'Content-Disposition',
`attachment; filename="${result.filename.replace(/"/g, '')}"`
`${dispositionType}; filename="${asciiFilename}"; filename*=UTF-8''${encodeURIComponent(result.filename)}`
);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
if (stream.statusCode === 206) {
response.status(206);
response.setHeader('Content-Range', `bytes ${stream.start}-${stream.end}/${stream.size}`);
}
response.setHeader('Cache-Control', 'private, max-age=3600');
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer);
return new StreamableFile(stream.stream);
}
private fileFromEncryptedBody(body: Record<string, unknown> | undefined) {
+109 -4
View File
@@ -29,10 +29,7 @@ function createFile(overrides: Partial<Express.Multer.File> = {}): Express.Multe
}
describe('AssetsService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn> };
asset: { create: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> };
};
let prisma: any;
let storage: Pick<StorageService, 'storePrivateFile' | 'readPrivateFile'>;
let projectsService: Pick<ProjectsService, 'assertProjectOwner'>;
let service: AssetsService;
@@ -45,6 +42,18 @@ describe('AssetsService', () => {
asset: {
create: vi.fn(),
findUnique: vi.fn()
},
renderTask: {
findMany: vi.fn().mockResolvedValue([])
},
providerLog: {
findMany: vi.fn().mockResolvedValue([])
},
providerConfig: {
findUnique: vi.fn().mockResolvedValue(null)
},
videoClip: {
findMany: vi.fn().mockResolvedValue([])
}
};
storage = {
@@ -148,4 +157,100 @@ describe('AssetsService', () => {
expect(result.filename).toBe('video-300.mp4');
expect(result.buffer.toString()).toBe('video bytes');
});
it('returns generation provider and model for direct asset detail', async () => {
prisma.asset.findUnique.mockResolvedValue({
id: 300n,
user_id: 1n,
project_id: 100n,
asset_type: 'video',
file_path: 'local://videos/final.mp4',
file_url: null,
mime_type: 'video/mp4',
width: 1080,
height: 1920,
duration: 4,
size: 11n,
hash: 'video-hash',
visibility: 'private',
status: 'active',
created_at: new Date('2026-05-31T00:00:00.000Z')
});
prisma.renderTask.findMany.mockResolvedValue([
{
id: 20n,
project_id: 100n,
episode_id: 10n,
shot_id: 8n,
task_type: 'live_action_video_clip_generate',
provider_id: 13n,
status: 'success',
input_json: {},
input_hash: null,
idempotency_key: null,
output_asset_id: 300n,
provider_request_id: 'task-external-1',
retry_count: 0,
max_retry: 0,
cost_estimate: null,
cost_actual: { toString: () => '0.35' },
error_code: null,
error_message: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
started_at: null,
finished_at: new Date('2026-05-31T00:00:00.000Z')
}
]);
prisma.providerLog.findMany.mockResolvedValue([
{
id: 40n,
provider_id: 13n,
task_id: 20n,
project_id: 100n,
provider_type: 'VideoProvider',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
request_json: {},
response_json: {},
input_size: null,
output_size: null,
cost_estimate: null,
cost_actual: null,
status: 'success',
error_code: null,
error_message: null,
started_at: null,
finished_at: null,
created_at: new Date('2026-05-31T00:00:00.000Z')
}
]);
prisma.providerConfig.findUnique.mockResolvedValue({
id: 13n,
provider_type: 'VideoProvider',
provider_code: 'kling-image-to-video',
display_name: '可灵图生视频',
mode: 'real',
model_name: 'kling-v2-1',
config_json: {},
fallback_provider_id: null,
is_enabled: true,
priority: 10,
rate_limit_json: {},
cost_rule_json: {},
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
});
const result = await service.getAssetForUser(user, '300');
expect(result.generation).toEqual(
expect.objectContaining({
provider_name: '可灵图生视频',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
task_id: '20',
provider_request_id: 'task-external-1'
})
);
});
});
+301 -8
View File
@@ -4,12 +4,13 @@ import {
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Asset } from '@prisma/client';
import { Prisma, type Asset, type ProviderConfig, type ProviderLog, type RenderTask, type VideoClip } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { AuthRequestUser } from '../auth/auth.types';
import { ProjectsService } from '../projects/projects.service';
import { toSafeAsset, type AssetType } from './asset.types';
import { toSafeAsset, type AssetType, type SafeAssetGeneration } from './asset.types';
import { StorageService } from './storage.service';
import type { UpdateAssetReviewStateDto } from './upload.dto';
const ALLOWED_NOVEL_MIME_TYPES = new Set([
'text/plain',
@@ -93,20 +94,97 @@ export class AssetsService {
async getAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
return toSafeAsset(asset);
const generation = await this.findAssetGeneration(asset);
return toSafeAsset(asset, generation);
}
async downloadAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const generation = await this.findAssetGeneration(asset);
const buffer = await this.storage.readPrivateFile(asset.file_path);
return {
asset: toSafeAsset(asset),
asset: toSafeAsset(asset, generation),
buffer,
filename: this.buildDownloadFilename(asset)
filename: this.buildDownloadFilename(asset, generation)
};
}
async streamAssetForUser(user: AuthRequestUser, assetId: string, rangeHeader?: string) {
const asset = await this.findAssetForUser(user, assetId);
const generation = await this.findAssetGeneration(asset);
const stream = await this.storage.streamPrivateFile(
asset.file_path,
asset.mime_type || 'application/octet-stream',
rangeHeader
);
return {
asset: toSafeAsset(asset, generation),
stream,
filename: this.buildDownloadFilename(asset, generation)
};
}
async createPreviewUrlForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const expiresInSeconds = this.previewUrlExpiresInSeconds(asset);
return {
asset: toSafeAsset(asset),
url: this.storage.createTemporaryPublicUrl({
filePath: asset.file_path,
mimeType: asset.mime_type || 'application/octet-stream',
expiresInSeconds
}),
expires_in_seconds: expiresInSeconds
};
}
async updateAssetReviewState(user: AuthRequestUser, assetId: string, dto: UpdateAssetReviewStateDto) {
const asset = await this.findEditableAssetForUser(user, assetId);
const data: Prisma.AssetUpdateInput = {};
if ('display_name' in dto) {
data.display_name = this.normalizeNullableText(dto.display_name, 255);
}
if ('selection_status' in dto) {
data.selection_status = this.normalizeSelectionStatus(dto.selection_status);
}
if ('selection_note' in dto) {
data.selection_note = this.normalizeNullableText(dto.selection_note, 1000);
}
if ('metadata_json' in dto) {
data.metadata_json = this.normalizeJsonObject(dto.metadata_json);
}
if (Object.keys(data).length === 0) {
return { asset: toSafeAsset(asset, await this.findAssetGeneration(asset)) };
}
const updated = await this.prisma.asset.update({
where: { id: asset.id },
data
});
const generation = await this.findAssetGeneration(updated);
return {
asset: toSafeAsset(updated, generation),
next_step: 'asset_review_state_saved'
};
}
private previewUrlExpiresInSeconds(asset: Asset) {
const mimeType = asset.mime_type || '';
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
return 24 * 60 * 60;
}
return 15 * 60;
}
private async findAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) }
@@ -116,20 +194,235 @@ export class AssetsService {
throw new NotFoundException('Asset not found');
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
if (user.role === 'admin' || asset.user_id?.toString() === user.id) {
return asset;
}
if (asset.project_id) {
const project = await this.prisma.project.findUnique({
where: { id: asset.project_id },
select: { user_id: true }
});
if (project?.user_id.toString() === user.id) {
return asset;
}
}
throw new NotFoundException('Asset not found');
}
private async findEditableAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) }
});
if (!asset) {
throw new NotFoundException('Asset not found');
}
return asset;
if (user.role === 'admin' || asset.user_id?.toString() === user.id) {
return asset;
}
if (asset.project_id) {
const project = await this.prisma.project.findUnique({ where: { id: asset.project_id } });
if (project?.user_id.toString() === user.id) {
return asset;
}
}
throw new NotFoundException('Asset not found');
}
private buildDownloadFilename(asset: Asset) {
private async findAssetGeneration(asset: Asset): Promise<SafeAssetGeneration | null> {
const tasks = await this.prisma.renderTask.findMany({
where: { output_asset_id: asset.id }
});
const task = tasks.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
if (task) {
const logs = await this.prisma.providerLog.findMany({
where: { task_id: task.id }
});
const log = logs.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
const provider = await this.findGenerationProvider(task.provider_id ?? log?.provider_id ?? null);
return this.createAssetGenerationFromTask(task, provider, log);
}
const clips = await this.prisma.videoClip.findMany({
where: { output_asset_id: asset.id }
});
const clip = clips.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
if (!clip) {
return null;
}
const provider = await this.findGenerationProvider(clip.provider_id);
return this.createAssetGenerationFromClip(clip, provider);
}
private async findGenerationProvider(providerId: bigint | null | undefined) {
if (!providerId) return null;
return this.prisma.providerConfig.findUnique({ where: { id: providerId } });
}
private createAssetGenerationFromTask(
task: RenderTask,
provider: ProviderConfig | null,
log: ProviderLog | null
): SafeAssetGeneration {
const response = this.jsonObject(log?.response_json ?? null);
const responseProviderRequestId =
this.stringifyJsonText(response.provider_request_id) ||
this.stringifyJsonText(response.task_id) ||
this.stringifyJsonText(response.id) ||
null;
const providerCode =
log?.provider_code ??
this.providerCodeFromTask(task) ??
provider?.provider_code ??
null;
return {
source: 'render_task',
display_name: this.displayNameFromTaskInput(task),
provider_id: provider?.id.toString() ?? task.provider_id?.toString() ?? log?.provider_id?.toString() ?? null,
provider_type: provider?.provider_type ?? log?.provider_type ?? null,
provider_code: providerCode,
provider_name: provider?.display_name ?? providerCode,
model_name: provider?.model_name ?? log?.model_name ?? this.modelNameFromTaskInput(task),
task_id: task.id.toString(),
task_type: task.task_type,
provider_request_id: task.provider_request_id ?? responseProviderRequestId,
clip_id: null,
status: task.status,
cost_actual: task.cost_actual?.toString() ?? log?.cost_actual?.toString() ?? null,
created_at: task.created_at.toISOString()
};
}
private createAssetGenerationFromClip(
clip: VideoClip,
provider: ProviderConfig | null
): SafeAssetGeneration {
return {
source: 'video_clip',
display_name: null,
provider_id: provider?.id.toString() ?? clip.provider_id?.toString() ?? null,
provider_type: provider?.provider_type ?? 'VideoProvider',
provider_code: provider?.provider_code ?? null,
provider_name: provider?.display_name ?? provider?.provider_code ?? null,
model_name: provider?.model_name ?? null,
task_id: null,
task_type: 'video_clip',
provider_request_id: null,
clip_id: clip.id.toString(),
status: clip.status,
cost_actual: clip.cost_actual?.toString() ?? null,
created_at: clip.created_at.toISOString()
};
}
private providerCodeFromTask(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
const routerDecision = this.jsonObject(inputJson.router_decision ?? null);
const repairContext = this.jsonObject(inputJson.repair_context ?? null);
return (
this.stringifyJsonText(repairContext.provider_code) ||
this.stringifyJsonText(routerDecision.provider_code) ||
this.stringifyJsonText(inputJson.provider) ||
null
);
}
private modelNameFromTaskInput(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
const routerDecision = this.jsonObject(inputJson.router_decision ?? null);
return (
this.stringifyJsonText(inputJson.model_name) ||
this.stringifyJsonText(inputJson.model) ||
this.stringifyJsonText(routerDecision.model_name) ||
this.stringifyJsonText(routerDecision.model) ||
null
);
}
private displayNameFromTaskInput(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
return (
this.stringifyJsonText(inputJson.render_title) ||
this.stringifyJsonText(inputJson.display_name) ||
this.stringifyJsonText(inputJson.title) ||
null
);
}
private jsonObject(value: Prisma.InputJsonValue | Prisma.JsonValue | null | undefined) {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, Prisma.InputJsonValue | Prisma.JsonValue>
: {};
}
private stringifyJsonText(value: unknown) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return '';
}
private buildDownloadFilename(asset: Asset, generation: SafeAssetGeneration | null = null) {
const extension = this.extensionFromMime(asset.mime_type) || this.extensionFromPath(asset.file_path);
const displayName = this.safeDownloadFilenameStem(asset.display_name ?? generation?.display_name ?? '');
if (displayName) {
return `${displayName}${extension}`;
}
const safeType = asset.asset_type.replace(/[^a-z0-9_-]/gi, '_') || 'asset';
return `${safeType}-${asset.id.toString()}${extension}`;
}
private safeDownloadFilenameStem(value: string) {
return value
.replace(/[\\/:*?"<>|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80);
}
private normalizeSelectionStatus(value: unknown) {
const normalized = this.normalizeNullableText(value, 30) ?? 'candidate';
if (!['candidate', 'selected', 'rejected'].includes(normalized)) {
throw new BadRequestException('Invalid selection_status');
}
return normalized;
}
private normalizeNullableText(value: unknown, maxLength: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
if (!text) return null;
return text.slice(0, maxLength);
}
private normalizeJsonObject(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return Prisma.JsonNull;
}
return value as Prisma.InputJsonObject;
}
private extensionFromPath(filePath: string) {
const match = /\.([a-z0-9]+)$/i.exec(filePath);
return match ? `.${match[1].toLowerCase()}` : '';
@@ -1,4 +1,4 @@
import { Controller, Get, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import { Controller, Get, Headers, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import type { Response } from 'express';
import { StorageService } from './storage.service';
@@ -9,16 +9,23 @@ export class PublicTempAssetsController {
@Get(':token')
async downloadTemporaryAsset(
@Param('token') token: string,
@Headers('range') range: string | undefined,
@Res({ passthrough: true }) response: Response
) {
const result = await this.storage.readTemporaryPublicFile(token);
const result = await this.storage.streamTemporaryPublicFile(token, range);
response.setHeader('Content-Type', result.mimeType);
response.setHeader('Content-Length', result.buffer.length.toString());
response.setHeader('Accept-Ranges', 'bytes');
response.setHeader('Content-Length', result.contentLength.toString());
if (result.statusCode === 206) {
response.status(206);
response.setHeader('Content-Range', `bytes ${result.start}-${result.end}/${result.size}`);
}
const cacheMaxAge = Math.max(0, Math.min(result.expiresAt - Math.floor(Date.now() / 1000), 24 * 60 * 60));
response.setHeader('Content-Disposition', 'inline');
response.setHeader('Cache-Control', 'private, max-age=0, no-store');
response.setHeader('Cache-Control', `private, max-age=${cacheMaxAge}, immutable`);
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer);
return new StreamableFile(result.stream);
}
}
+117 -5
View File
@@ -1,5 +1,6 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
import type { Readable } from 'node:stream';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
@@ -12,6 +13,12 @@ type TemporaryPublicFilePayload = {
expires_at: number;
nonce: string;
};
type ByteRange = {
start: number;
end: number;
contentLength: number;
statusCode: 200 | 206;
};
@Injectable()
export class StorageService {
@@ -43,6 +50,10 @@ export class StorageService {
throw new BadRequestException('Unsupported storage path');
}
async streamPrivateFile(filePath: string, mimeType?: string | null, rangeHeader?: string) {
return this.streamStoredFile(filePath, mimeType || 'application/octet-stream', rangeHeader);
}
createTemporaryPublicUrl(input: {
filePath: string;
mimeType?: string | null;
@@ -74,6 +85,60 @@ export class StorageService {
};
}
async streamTemporaryPublicFile(token: string, rangeHeader?: string) {
const payload = this.verifyTemporaryPublicToken(token);
const result = await this.streamStoredFile(payload.file_path, payload.mime_type, rangeHeader);
return {
...result,
filePath: payload.file_path,
expiresAt: payload.expires_at
};
}
private async streamStoredFile(filePath: string, mimeType: string, rangeHeader?: string) {
if (filePath.startsWith('local://')) {
const fullPath = this.localObjectFullPath(filePath);
const stats = await stat(fullPath);
const range = this.resolveByteRange(rangeHeader, stats.size);
return {
stream: createReadStream(fullPath, { start: range.start, end: range.end }),
mimeType: mimeType || 'application/octet-stream',
size: stats.size,
start: range.start,
end: range.end,
contentLength: range.contentLength,
statusCode: range.statusCode
};
}
if (filePath.startsWith('minio://')) {
const { client, bucket, objectName } = this.minioObject(filePath);
const stats = await client.statObject(bucket, objectName);
const size = Number(stats.size);
const range = this.resolveByteRange(rangeHeader, size);
const stream = range.statusCode === 206
? await (client as unknown as {
getPartialObject: (bucketName: string, object: string, offset: number, length: number) => Promise<Readable>;
}).getPartialObject(bucket, objectName, range.start, range.contentLength)
: await client.getObject(bucket, objectName);
return {
stream,
mimeType: mimeType || 'application/octet-stream',
size,
start: range.start,
end: range.end,
contentLength: range.contentLength,
statusCode: range.statusCode
};
}
throw new BadRequestException('Unsupported storage path');
}
private async storeLocally(
file: Express.Multer.File,
prefix: string
@@ -130,15 +195,25 @@ export class StorageService {
}
private async readLocalObject(filePath: string) {
return readFile(this.localObjectFullPath(filePath));
}
private async readMinioObject(filePath: string) {
const { client, bucket, objectName } = this.minioObject(filePath);
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
}
private localObjectFullPath(filePath: string) {
const objectName = filePath.replace(/^local:\/\//, '');
if (!objectName || objectName.includes('..')) {
throw new BadRequestException('Invalid local storage path');
}
return readFile(join(this.root, 'private', objectName));
return join(this.root, 'private', objectName);
}
private async readMinioObject(filePath: string) {
private minioObject(filePath: string) {
const match = /^minio:\/\/([^/]+)\/(.+)$/.exec(filePath);
if (!match) {
throw new BadRequestException('Invalid MinIO storage path');
@@ -152,8 +227,45 @@ export class StorageService {
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || ''
});
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
return { client, bucket, objectName };
}
private resolveByteRange(rangeHeader: string | undefined, size: number): ByteRange {
if (!rangeHeader) {
return {
start: 0,
end: Math.max(0, size - 1),
contentLength: size,
statusCode: 200
};
}
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!match || size <= 0) {
throw new BadRequestException('Invalid byte range');
}
const [, rawStart, rawEnd] = match;
const suffixLength = rawStart === '' ? Number(rawEnd) : null;
const start = suffixLength !== null
? Math.max(0, size - suffixLength)
: Number(rawStart);
const end = rawEnd && suffixLength === null
? Math.min(size - 1, Number(rawEnd))
: size - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= size) {
throw new BadRequestException('Invalid byte range');
}
return {
start,
end,
contentLength: end - start + 1,
statusCode: 206
};
}
private async streamToBuffer(stream: Readable) {
+7
View File
@@ -4,3 +4,10 @@ export class UploadAssetDto {
asset_type?: AssetType;
project_id?: string;
}
export class UpdateAssetReviewStateDto {
display_name?: string | null;
selection_status?: 'candidate' | 'selected' | 'rejected';
selection_note?: string | null;
metadata_json?: unknown;
}
+287
View File
@@ -2,10 +2,45 @@ import type { CharacterRoleType, CharacterStatus } from './character.types';
export class ExtractCharactersDto {
story_bible_id?: string;
provider_code?: string;
}
export class ExtractProjectIpAssetsDto {
source?: 'story_bible' | 'all' | string;
include_characters?: boolean | string;
refresh_existing?: boolean | string;
}
export class OptimizeProjectIpAssetPromptsDto {
asset_kind?: 'prop' | 'scene' | 'all' | string;
min_quality_score?: number | string | null;
include_pending?: boolean | string;
}
export class ImportCharacterExtractionDto {
raw_text?: string;
source_label?: string;
quality_score?: number;
review_comment?: string;
apply_to_story_bible?: boolean | string;
}
export class UpdateCharacterExtractionReviewDto {
quality_score?: number;
review_comment?: string;
status?: string;
}
export class AutoBindCharacterVoicesDto {
voice_provider_code?: string;
force?: boolean | string;
}
export class CreateCharacterDto {
story_character_id?: string;
global_character_id?: string;
global_character_look_version_id?: string;
global_character_asset_id?: string;
name?: string;
alias_names?: string[];
role_type?: CharacterRoleType;
@@ -30,9 +65,261 @@ export class CreateCharacterDto {
voice_id?: string;
voice_style?: string;
performance_style?: string;
inherit_voice_from_global?: boolean | string;
inherit_digital_human_from_global?: boolean | string;
importance_level?: number;
anchor_asset_id?: string;
}
export class UpdateCharacterDto extends CreateCharacterDto {
status?: CharacterStatus;
}
export class SaveMyGlobalCharacterDto {
name?: string;
display_name?: string;
role_archetype?: CharacterRoleType;
gender_label?: string;
age_group?: string;
identity_desc?: string;
appearance_desc?: string;
face_desc?: string;
hair_desc?: string;
eye_desc?: string;
body_desc?: string;
default_costume_rules?: string;
special_props?: string;
personality_desc?: string;
speech_style?: string;
voice_provider_code?: string;
voice_model?: string;
voice_id?: string;
voice_style?: string;
performance_style?: string;
negative_rules?: string;
anchor_asset_id?: string;
voice_sample_asset_id?: string;
status?: string;
}
export class SetMyGlobalCharacterAssetsDto {
asset_ids?: string[];
look_version_id?: string;
consent_confirmed?: boolean | string;
set_primary_anchor?: boolean | string;
label?: string;
notes?: string;
}
export class SaveGlobalCharacterLookVersionDto {
version_name?: string;
style_type?: string;
appearance_desc?: string;
hair_desc?: string;
makeup_desc?: string;
body_desc?: string;
costume_rules?: string;
color_palette?: string;
key_props?: string;
negative_rules?: string;
main_anchor_asset_id?: string;
status?: string;
quality_score?: number | string | null;
reviewer_comment?: string;
source_project_id?: string;
}
export class ImportGlobalCharacterAssetDto {
asset_id?: string;
look_version_id?: string;
asset_type?: string;
source_type?: string;
source_label?: string;
label?: string;
prompt_text?: string;
negative_prompt?: string;
model_name?: string;
seed?: string;
resolution?: string;
aspect_ratio?: string;
cost_estimate?: number | string | null;
cost_actual?: number | string | null;
quality_score?: number | string | null;
consistency_score?: number | string | null;
face_similarity_score?: number | string | null;
license_status?: string;
commercial_allowed?: boolean | string;
review_status?: string;
reviewer_note?: string;
is_primary?: boolean | string;
set_as_main_anchor?: boolean | string;
metadata_json?: unknown;
}
export class SetGlobalCharacterPrimaryAssetDto {
global_character_asset_id?: string;
scope?: 'character' | 'look_version' | 'both';
}
export class UpdateGlobalCharacterAssetReviewDto {
label?: string;
prompt_text?: string;
negative_prompt?: string;
model_name?: string;
cost_estimate?: number | string | null;
cost_actual?: number | string | null;
quality_score?: number | string | null;
consistency_score?: number | string | null;
face_similarity_score?: number | string | null;
license_status?: string;
commercial_allowed?: boolean | string;
review_status?: string;
reviewer_note?: string;
}
export class PromoteCharacterToGlobalDto {
version_name?: string;
style_type?: string;
source_label?: string;
set_project_binding?: boolean | string;
}
export class BindCharacterIpDto {
global_character_id?: string;
look_version_id?: string;
global_character_asset_id?: string;
inherit_voice?: boolean | string;
inherit_digital_human?: boolean | string;
}
export class CreateCharacterDesignVersionDto {
prompt_text?: string;
negative_prompt?: string;
image_asset_id?: string;
notes?: string;
source?: string;
is_final?: boolean;
}
export class SaveCharacterPromptVersionDto {
layer_code?: string;
channel?: string;
title?: string;
source_type?: string;
source_label?: string;
prompt_engine_version?: string;
prompt_text?: string;
negative_prompt?: string;
model_name?: string;
usage_note?: string;
quality_score?: number | string | null;
review_comment?: string;
is_active?: boolean | string;
status?: string;
look_version_id?: string;
metadata_json?: unknown;
}
export class CreateCharacterStateDto {
state_code?: string;
display_name?: string;
description?: string;
wardrobe_rules?: string;
emotion_rules?: string;
prompt_suffix?: string;
negative_rules?: string;
reference_asset_id?: string;
status?: string;
}
export class TestCharacterAnchorVideoDto {
provider_code?: string;
duration?: number | string;
force?: boolean | string;
purpose?: 'anchor_test' | 'video_character_element_source';
reference_mode?: 'first_frame' | 'omni_reference';
voice_line?: string;
generate_audio?: boolean | string;
source_asset_id?: string;
prompt_override?: string;
negative_prompt?: string;
resolution?: '720p' | '1080p' | '4k';
mode?: 'std' | 'pro' | '4k';
}
export class ImportCharacterProviderBindingDto {
look_version_id?: string;
source_asset_id?: string;
provider_code?: string;
provider_asset_type?: 'video_character_element' | 'multi_image_element';
provider_element_id?: string;
element_name?: string;
element_description?: string;
source_duration?: number | string | null;
voice_bound?: boolean | string;
voice_id?: string;
voice_description?: string;
validation_score?: number | string | null;
validation_report_json?: unknown;
status?: 'candidate' | 'approved' | 'rejected' | 'archived';
is_primary?: boolean | string;
}
export class UpdateCharacterProviderBindingDto {
element_name?: string;
element_description?: string;
voice_bound?: boolean | string;
voice_id?: string;
voice_description?: string;
validation_score?: number | string | null;
validation_report_json?: unknown;
status?: 'candidate' | 'approved' | 'rejected' | 'archived';
is_primary?: boolean | string;
}
export class CreateProjectVisualAssetDto {
asset_id?: string;
asset_kind?: 'character' | 'prop' | 'scene';
asset_type?: string;
name?: string;
label?: string;
ownership_type?:
| 'role_identity'
| 'role_exclusive'
| 'shared_story_asset'
| 'neutral_asset'
| 'scene_lock'
| 'exclusive_character'
| 'shared_story'
| 'neutral';
owner_character_id?: string | null;
exclusive_owner?: string | null;
aliases?: string[];
allowed_roles?: string[];
source_type?: string;
source_label?: string;
detected_from?: string;
importance?: number | string | null;
visual_lock?: string;
key_objects?: string[];
prompt_block?: string;
anchor_prompt?: string;
prompt_text?: string;
negative_prompt?: string;
reference_images?: string[];
anchor_images?: string[];
render_variants?: unknown;
linked_assets?: string[];
reuse_rule?: string;
story_scope?: string;
version_note?: string;
notes?: string;
tags?: string[];
usage_tags?: string[];
quality_score?: number | string | null;
is_primary?: boolean | string;
metadata_json?: unknown;
status?: string;
}
export class UpdateProjectVisualAssetDto extends CreateProjectVisualAssetDto {}
+428 -1
View File
@@ -1,4 +1,17 @@
import type { Character, GlobalCharacter, Prisma } from '@prisma/client';
import type {
Asset,
Character,
CharacterProviderBinding,
CharacterDesignVersion,
CharacterPromptVersion,
CharacterState,
GlobalCharacter,
GlobalCharacterAsset,
GlobalCharacterLookVersion,
Prisma,
ProjectVisualAsset
} from '@prisma/client';
import { toSafeAsset, type SafeAsset } from '../assets/asset.types';
export const CHARACTER_ROLE_TYPES = [
'protagonist',
@@ -22,7 +35,10 @@ export type CharacterStatus = (typeof CHARACTER_STATUSES)[number];
export interface SafeCharacter {
id: string;
project_id: string;
story_character_id: string | null;
global_character_id: string | null;
global_character_look_version_id: string | null;
global_character_asset_id: string | null;
name: string;
alias_names: Prisma.JsonValue | null;
role_type: string;
@@ -48,6 +64,8 @@ export interface SafeCharacter {
voice_id: string | null;
voice_style: string | null;
performance_style: string | null;
inherit_voice_from_global: boolean;
inherit_digital_human_from_global: boolean;
importance_level: number;
status: string;
created_at: string;
@@ -88,11 +106,207 @@ export interface SafeGlobalCharacter {
updated_at: string;
}
export interface SafeGlobalCharacterAsset {
id: string;
global_character_id: string;
look_version_id: string | null;
asset_id: string | null;
asset_type: string;
source_type: string;
source_label: string | null;
label: string | null;
prompt_text: string | null;
negative_prompt: string | null;
model_name: string | null;
seed: string | null;
resolution: string | null;
aspect_ratio: string | null;
cost_estimate: string | null;
cost_actual: string | null;
quality_score: string | null;
consistency_score: string | null;
face_similarity_score: string | null;
license_status: string;
commercial_allowed: boolean;
review_status: string;
reviewer_note: string | null;
is_primary: boolean;
usage_count: number;
metadata_json: Prisma.JsonValue | null;
status: string;
created_at: string;
asset?: SafeAsset | null;
}
export interface SafeCharacterProviderBinding {
id: string;
global_character_id: string;
look_version_id: string | null;
source_asset_id: string | null;
provider_code: string;
provider_asset_type: string;
provider_element_id: string;
element_name: string;
element_description: string | null;
source_duration: string | null;
voice_bound: boolean;
voice_id: string | null;
voice_description: string | null;
binding_version: number;
validation_score: string | null;
validation_report_json: Prisma.JsonValue | null;
status: string;
is_primary: boolean;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
source_asset?: SafeAsset | null;
}
export interface SafeGlobalCharacterLookVersion {
id: string;
global_character_id: string;
version_name: string;
style_type: string | null;
appearance_desc: string | null;
hair_desc: string | null;
makeup_desc: string | null;
body_desc: string | null;
costume_rules: string | null;
color_palette: string | null;
key_props: string | null;
negative_rules: string | null;
main_anchor_asset_id: string | null;
status: string;
quality_score: string | null;
reviewer_comment: string | null;
source_project_id: string | null;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
}
export interface SafeCharacterDesignVersion {
id: string;
project_id: string;
character_id: string;
version_no: number;
prompt_text: string | null;
negative_prompt: string | null;
image_asset_id: string | null;
notes: string | null;
source: string;
is_final: boolean;
created_by_user_id: string | null;
created_at: string;
}
export interface SafeCharacterPromptVersion {
id: string;
project_id: string | null;
character_id: string | null;
global_character_id: string | null;
look_version_id: string | null;
version_no: number;
layer_code: string;
channel: string;
title: string | null;
source_type: string;
source_label: string | null;
prompt_engine_version: string | null;
prompt_text: string;
negative_prompt: string | null;
model_name: string | null;
usage_note: string | null;
quality_score: string | null;
review_comment: string | null;
is_active: boolean;
metadata_json: Prisma.JsonValue | null;
created_by_user_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeCharacterState {
id: string;
project_id: string;
character_id: string;
state_code: string;
display_name: string | null;
description: string | null;
wardrobe_rules: string | null;
emotion_rules: string | null;
prompt_suffix: string | null;
negative_rules: string | null;
reference_asset_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeProjectVisualAsset {
id: string;
project_id: string;
user_id: string;
asset_id: string | null;
asset_kind: string;
asset_type: string;
name: string;
label: string | null;
ownership_type: string;
owner_character_id: string | null;
source_type: string;
source_label: string | null;
aliases_json: Prisma.JsonValue | null;
aliases: string[];
allowed_roles_json: Prisma.JsonValue | null;
allowed_roles: string[];
detected_from: string | null;
importance: number;
visual_lock: string | null;
key_objects_json: Prisma.JsonValue | null;
key_objects: string[];
prompt_block: string | null;
anchor_prompt: string | null;
prompt_text: string | null;
negative_prompt: string | null;
reference_images_json: Prisma.JsonValue | null;
reference_images: string[];
anchor_images_json: Prisma.JsonValue | null;
anchor_images: string[];
render_variants_json: Prisma.JsonValue | null;
linked_assets_json: Prisma.JsonValue | null;
linked_assets: string[];
reuse_rule: string | null;
story_scope: string | null;
version_note: string | null;
notes: string | null;
usage_tags_json: Prisma.JsonValue | null;
usage_tags: string[];
tags: string[];
quality_score: number | null;
is_primary: boolean;
metadata_json: Prisma.JsonValue | null;
status: string;
created_at: string;
updated_at: string;
asset?: SafeAsset | null;
owner_character?: SafeCharacter | null;
}
function jsonStringArray(value: Prisma.JsonValue | null | undefined) {
return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
}
export function toSafeCharacter(character: Character): SafeCharacter {
return {
id: character.id.toString(),
project_id: character.project_id.toString(),
story_character_id: character.story_character_id?.toString() ?? null,
global_character_id: character.global_character_id?.toString() ?? null,
global_character_look_version_id: character.global_character_look_version_id?.toString() ?? null,
global_character_asset_id: character.global_character_asset_id?.toString() ?? null,
name: character.name,
alias_names: character.alias_names,
role_type: character.role_type,
@@ -118,6 +332,8 @@ export function toSafeCharacter(character: Character): SafeCharacter {
voice_id: character.voice_id,
voice_style: character.voice_style,
performance_style: character.performance_style,
inherit_voice_from_global: character.inherit_voice_from_global,
inherit_digital_human_from_global: character.inherit_digital_human_from_global,
importance_level: character.importance_level,
status: character.status,
created_at: character.created_at.toISOString(),
@@ -125,6 +341,60 @@ export function toSafeCharacter(character: Character): SafeCharacter {
};
}
export function toSafeProjectVisualAsset(
row: ProjectVisualAsset & { asset?: Asset | null; owner_character?: Character | null }
): SafeProjectVisualAsset {
return {
id: row.id.toString(),
project_id: row.project_id.toString(),
user_id: row.user_id.toString(),
asset_id: row.asset_id?.toString() ?? null,
asset_kind: row.asset_kind,
asset_type: row.asset_type,
name: row.name,
label: row.label,
ownership_type: row.ownership_type,
owner_character_id: row.owner_character_id?.toString() ?? null,
source_type: row.source_type,
source_label: row.source_label,
aliases_json: row.aliases_json,
aliases: jsonStringArray(row.aliases_json),
allowed_roles_json: row.allowed_roles_json,
allowed_roles: jsonStringArray(row.allowed_roles_json),
detected_from: row.detected_from,
importance: row.importance,
visual_lock: row.visual_lock,
key_objects_json: row.key_objects_json,
key_objects: jsonStringArray(row.key_objects_json),
prompt_block: row.prompt_block,
anchor_prompt: row.anchor_prompt,
prompt_text: row.prompt_text,
negative_prompt: row.negative_prompt,
reference_images_json: row.reference_images_json,
reference_images: jsonStringArray(row.reference_images_json),
anchor_images_json: row.anchor_images_json,
anchor_images: jsonStringArray(row.anchor_images_json),
render_variants_json: row.render_variants_json,
linked_assets_json: row.linked_assets_json,
linked_assets: jsonStringArray(row.linked_assets_json),
reuse_rule: row.reuse_rule,
story_scope: row.story_scope,
version_note: row.version_note,
notes: row.notes,
usage_tags_json: row.usage_tags_json,
usage_tags: jsonStringArray(row.usage_tags_json),
tags: jsonStringArray(row.usage_tags_json),
quality_score: row.quality_score ? Number(row.quality_score.toString()) : null,
is_primary: row.is_primary,
metadata_json: row.metadata_json,
status: row.status,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
asset: row.asset ? toSafeAsset(row.asset) : undefined,
owner_character: row.owner_character ? toSafeCharacter(row.owner_character) : undefined
};
}
export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCharacter {
return {
id: character.id.toString(),
@@ -160,3 +430,160 @@ export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCha
updated_at: character.updated_at.toISOString()
};
}
export function toSafeGlobalCharacterAsset(
row: GlobalCharacterAsset & { asset?: Asset | null }
): SafeGlobalCharacterAsset {
return {
id: row.id.toString(),
global_character_id: row.global_character_id.toString(),
look_version_id: row.look_version_id?.toString() ?? null,
asset_id: row.asset_id?.toString() ?? null,
asset_type: row.asset_type,
source_type: row.source_type,
source_label: row.source_label,
label: row.label,
prompt_text: row.prompt_text,
negative_prompt: row.negative_prompt,
model_name: row.model_name,
seed: row.seed,
resolution: row.resolution,
aspect_ratio: row.aspect_ratio,
cost_estimate: row.cost_estimate?.toString() ?? null,
cost_actual: row.cost_actual?.toString() ?? null,
quality_score: row.quality_score?.toString() ?? null,
consistency_score: row.consistency_score?.toString() ?? null,
face_similarity_score: row.face_similarity_score?.toString() ?? null,
license_status: row.license_status,
commercial_allowed: row.commercial_allowed,
review_status: row.review_status,
reviewer_note: row.reviewer_note,
is_primary: row.is_primary,
usage_count: row.usage_count,
metadata_json: row.metadata_json,
status: row.status,
created_at: row.created_at.toISOString(),
asset: row.asset ? toSafeAsset(row.asset) : undefined
};
}
export function toSafeCharacterProviderBinding(
row: CharacterProviderBinding & { source_asset?: Asset | null }
): SafeCharacterProviderBinding {
return {
id: row.id.toString(),
global_character_id: row.global_character_id.toString(),
look_version_id: row.look_version_id?.toString() ?? null,
source_asset_id: row.source_asset_id?.toString() ?? null,
provider_code: row.provider_code,
provider_asset_type: row.provider_asset_type,
provider_element_id: row.provider_element_id,
element_name: row.element_name,
element_description: row.element_description,
source_duration: row.source_duration?.toString() ?? null,
voice_bound: row.voice_bound,
voice_id: row.voice_id,
voice_description: row.voice_description,
binding_version: row.binding_version,
validation_score: row.validation_score?.toString() ?? null,
validation_report_json: row.validation_report_json,
status: row.status,
is_primary: row.is_primary,
created_by_user_id: row.created_by_user_id?.toString() ?? null,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
source_asset: row.source_asset ? toSafeAsset(row.source_asset) : undefined
};
}
export function toSafeGlobalCharacterLookVersion(
version: GlobalCharacterLookVersion
): SafeGlobalCharacterLookVersion {
return {
id: version.id.toString(),
global_character_id: version.global_character_id.toString(),
version_name: version.version_name,
style_type: version.style_type,
appearance_desc: version.appearance_desc,
hair_desc: version.hair_desc,
makeup_desc: version.makeup_desc,
body_desc: version.body_desc,
costume_rules: version.costume_rules,
color_palette: version.color_palette,
key_props: version.key_props,
negative_rules: version.negative_rules,
main_anchor_asset_id: version.main_anchor_asset_id?.toString() ?? null,
status: version.status,
quality_score: version.quality_score?.toString() ?? null,
reviewer_comment: version.reviewer_comment,
source_project_id: version.source_project_id?.toString() ?? null,
created_by_user_id: version.created_by_user_id?.toString() ?? null,
created_at: version.created_at.toISOString(),
updated_at: version.updated_at.toISOString()
};
}
export function toSafeCharacterDesignVersion(version: CharacterDesignVersion): SafeCharacterDesignVersion {
return {
id: version.id.toString(),
project_id: version.project_id.toString(),
character_id: version.character_id.toString(),
version_no: version.version_no,
prompt_text: version.prompt_text,
negative_prompt: version.negative_prompt,
image_asset_id: version.image_asset_id?.toString() ?? null,
notes: version.notes,
source: version.source,
is_final: version.is_final,
created_by_user_id: version.created_by_user_id?.toString() ?? null,
created_at: version.created_at.toISOString()
};
}
export function toSafeCharacterPromptVersion(version: CharacterPromptVersion): SafeCharacterPromptVersion {
return {
id: version.id.toString(),
project_id: version.project_id?.toString() ?? null,
character_id: version.character_id?.toString() ?? null,
global_character_id: version.global_character_id?.toString() ?? null,
look_version_id: version.look_version_id?.toString() ?? null,
version_no: version.version_no,
layer_code: version.layer_code,
channel: version.channel,
title: version.title,
source_type: version.source_type,
source_label: version.source_label,
prompt_engine_version: version.prompt_engine_version,
prompt_text: version.prompt_text,
negative_prompt: version.negative_prompt,
model_name: version.model_name,
usage_note: version.usage_note,
quality_score: version.quality_score?.toString() ?? null,
review_comment: version.review_comment,
is_active: version.is_active,
metadata_json: version.metadata_json,
created_by_user_id: version.created_by_user_id?.toString() ?? null,
status: version.status,
created_at: version.created_at.toISOString(),
updated_at: version.updated_at.toISOString()
};
}
export function toSafeCharacterState(state: CharacterState): SafeCharacterState {
return {
id: state.id.toString(),
project_id: state.project_id.toString(),
character_id: state.character_id.toString(),
state_code: state.state_code,
display_name: state.display_name,
description: state.description,
wardrobe_rules: state.wardrobe_rules,
emotion_rules: state.emotion_rules,
prompt_suffix: state.prompt_suffix,
negative_rules: state.negative_rules,
reference_asset_id: state.reference_asset_id?.toString() ?? null,
status: state.status,
created_at: state.created_at.toISOString(),
updated_at: state.updated_at.toISOString()
};
}
+415 -1
View File
@@ -13,7 +13,32 @@ import {
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateCharacterDto, ExtractCharactersDto, UpdateCharacterDto } from './character.dto';
import {
AutoBindCharacterVoicesDto,
BindCharacterIpDto,
CreateCharacterDesignVersionDto,
CreateCharacterDto,
CreateCharacterStateDto,
CreateProjectVisualAssetDto,
ExtractCharactersDto,
ExtractProjectIpAssetsDto,
ImportGlobalCharacterAssetDto,
ImportCharacterProviderBindingDto,
ImportCharacterExtractionDto,
OptimizeProjectIpAssetPromptsDto,
PromoteCharacterToGlobalDto,
SaveMyGlobalCharacterDto,
SaveGlobalCharacterLookVersionDto,
SaveCharacterPromptVersionDto,
SetGlobalCharacterPrimaryAssetDto,
SetMyGlobalCharacterAssetsDto,
TestCharacterAnchorVideoDto,
UpdateCharacterProviderBindingDto,
UpdateCharacterExtractionReviewDto,
UpdateGlobalCharacterAssetReviewDto,
UpdateCharacterDto,
UpdateProjectVisualAssetDto
} from './character.dto';
import { CharactersService } from './characters.service';
@Controller()
@@ -39,6 +64,41 @@ export class CharactersController {
return this.charactersService.listCharacters(user, projectId, includeDeleted === 'true');
}
@Get('projects/:projectId/characters/anchor-video-tests')
listCharacterAnchorVideoTests(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.listCharacterAnchorVideoTests(user, projectId);
}
@Get('projects/:projectId/characters/extraction-versions')
listCharacterExtractionVersions(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.listCharacterExtractionVersions(user, projectId);
}
@Post('projects/:projectId/characters/extraction-versions/import')
importCharacterExtraction(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ImportCharacterExtractionDto
) {
return this.charactersService.importCharacterExtraction(user, projectId, dto);
}
@Patch('projects/:projectId/characters/extraction-versions/:versionId/review')
updateCharacterExtractionReview(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('versionId') versionId: string,
@Body() dto: UpdateCharacterExtractionReviewDto
) {
return this.charactersService.updateCharacterExtractionReview(user, projectId, versionId, dto);
}
@Post('projects/:projectId/characters')
createCharacter(
@CurrentUser() user: AuthRequestUser,
@@ -48,6 +108,149 @@ export class CharactersController {
return this.charactersService.createCharacter(user, projectId, dto);
}
@Get('me/global-characters')
listMyGlobalCharacters(@CurrentUser() user: AuthRequestUser) {
return this.charactersService.listMyGlobalCharacters(user);
}
@Post('me/global-characters')
createMyGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Body() dto: SaveMyGlobalCharacterDto
) {
return this.charactersService.createMyGlobalCharacter(user, dto);
}
@Patch('me/global-characters/:globalCharacterId')
updateMyGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SaveMyGlobalCharacterDto
) {
return this.charactersService.updateMyGlobalCharacter(user, globalCharacterId, dto);
}
@Get('me/global-characters/:globalCharacterId/provider-bindings')
listMyCharacterProviderBindings(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string
) {
return this.charactersService.listMyCharacterProviderBindings(user, globalCharacterId);
}
@Post('me/global-characters/:globalCharacterId/provider-bindings')
importMyCharacterProviderBinding(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: ImportCharacterProviderBindingDto
) {
return this.charactersService.importMyCharacterProviderBinding(user, globalCharacterId, dto);
}
@Patch('me/global-characters/:globalCharacterId/provider-bindings/:bindingId')
updateMyCharacterProviderBinding(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Param('bindingId') bindingId: string,
@Body() dto: UpdateCharacterProviderBindingDto
) {
return this.charactersService.updateMyCharacterProviderBinding(user, globalCharacterId, bindingId, dto);
}
@Post('me/global-characters/:globalCharacterId/assets')
setMyGlobalCharacterAssets(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SetMyGlobalCharacterAssetsDto
) {
return this.charactersService.setMyGlobalCharacterAssets(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/look-versions')
createGlobalCharacterLookVersion(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SaveGlobalCharacterLookVersionDto
) {
return this.charactersService.createGlobalCharacterLookVersion(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/assets/import')
importGlobalCharacterAsset(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: ImportGlobalCharacterAssetDto
) {
return this.charactersService.importGlobalCharacterAsset(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/assets/set-primary')
setGlobalCharacterPrimaryAsset(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SetGlobalCharacterPrimaryAssetDto
) {
return this.charactersService.setGlobalCharacterPrimaryAsset(user, globalCharacterId, dto);
}
@Get('me/global-characters/:globalCharacterId/visual-anchor-card')
getGlobalCharacterVisualAnchorCard(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Query('look_version_id') lookVersionId?: string
) {
return this.charactersService.getGlobalCharacterVisualAnchorCard(user, globalCharacterId, lookVersionId);
}
@Get('me/global-characters/:globalCharacterId/prompt-versions')
listGlobalCharacterPromptVersions(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string
) {
return this.charactersService.listGlobalCharacterPromptVersions(user, globalCharacterId);
}
@Post('me/global-characters/:globalCharacterId/prompt-versions')
createGlobalCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SaveCharacterPromptVersionDto
) {
return this.charactersService.createGlobalCharacterPromptVersion(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/prompt-versions/refresh-system')
refreshGlobalCharacterSystemPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string
) {
return this.charactersService.refreshGlobalCharacterSystemPrompt(user, globalCharacterId);
}
@Post('me/global-characters/:globalCharacterId/prompt-versions/:versionId/activate')
activateGlobalCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Param('versionId') versionId: string
) {
return this.charactersService.activateGlobalCharacterPromptVersion(user, globalCharacterId, versionId);
}
@Patch('me/global-characters/:globalCharacterId/assets/:globalCharacterAssetId/review')
updateGlobalCharacterAssetReview(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Param('globalCharacterAssetId') globalCharacterAssetId: string,
@Body() dto: UpdateGlobalCharacterAssetReviewDto
) {
return this.charactersService.updateGlobalCharacterAssetReview(
user,
globalCharacterId,
globalCharacterAssetId,
dto
);
}
@Post('projects/:projectId/characters/confirm')
confirmCharacters(
@CurrentUser() user: AuthRequestUser,
@@ -56,6 +259,96 @@ export class CharactersController {
return this.charactersService.confirmCharacters(user, projectId);
}
@Post('projects/:projectId/characters/voice-profiles/auto-bind')
autoBindCharacterVoices(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: AutoBindCharacterVoicesDto
) {
return this.charactersService.autoBindCharacterVoices(user, projectId, dto);
}
@Get('projects/:projectId/visual-assets')
listProjectVisualAssets(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('asset_kind') assetKind?: string
) {
return this.charactersService.listProjectVisualAssets(user, projectId, assetKind);
}
@Get('projects/:projectId/ops/ip-assets')
listProjectIpAssets(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('asset_kind') assetKind?: string
) {
return this.charactersService.listProjectIpAssets(user, projectId, assetKind);
}
@Get('projects/:projectId/ops/ip-assets/conflicts')
listProjectIpAssetConflicts(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.listProjectIpAssetConflicts(user, projectId);
}
@Post('projects/:projectId/ops/ip-assets/extract')
extractProjectIpAssets(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ExtractProjectIpAssetsDto
) {
return this.charactersService.extractProjectIpAssets(user, projectId, dto);
}
@Post('projects/:projectId/ops/ip-assets/optimize-prompts')
optimizeProjectIpAssetPrompts(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: OptimizeProjectIpAssetPromptsDto
) {
return this.charactersService.optimizeProjectIpAssetPrompts(user, projectId, dto);
}
@Post('projects/:projectId/ops/ip-assets')
createProjectIpAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateProjectVisualAssetDto
) {
return this.charactersService.createProjectIpAsset(user, projectId, dto);
}
@Delete('projects/:projectId/ops/ip-assets/:visualAssetId')
deleteProjectIpAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('visualAssetId') visualAssetId: string
) {
return this.charactersService.deleteProjectVisualAsset(user, projectId, visualAssetId);
}
@Post('projects/:projectId/visual-assets')
createProjectVisualAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateProjectVisualAssetDto
) {
return this.charactersService.createProjectVisualAsset(user, projectId, dto);
}
@Patch('projects/:projectId/visual-assets/:visualAssetId')
updateProjectVisualAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('visualAssetId') visualAssetId: string,
@Body() dto: UpdateProjectVisualAssetDto
) {
return this.charactersService.updateProjectVisualAsset(user, projectId, visualAssetId, dto);
}
@Patch('characters/:characterId')
updateCharacter(
@CurrentUser() user: AuthRequestUser,
@@ -65,6 +358,127 @@ export class CharactersController {
return this.charactersService.updateCharacter(user, characterId, dto);
}
@Post('characters/:characterId/promote-to-global')
promoteCharacterToGlobal(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: PromoteCharacterToGlobalDto
) {
return this.charactersService.promoteCharacterToGlobal(user, characterId, dto);
}
@Post('characters/:characterId/bind-global-ip')
bindCharacterIp(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: BindCharacterIpDto
) {
return this.charactersService.bindCharacterIp(user, characterId, dto);
}
@Get('characters/:characterId/visual-anchor-card')
getCharacterVisualAnchorCard(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Query('look_version_id') lookVersionId?: string
) {
return this.charactersService.getCharacterVisualAnchorCard(user, characterId, lookVersionId);
}
@Get('characters/:characterId/anchor-video-test')
getCharacterAnchorVideoTest(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.getCharacterAnchorVideoTest(user, characterId);
}
@Post('characters/:characterId/anchor-video-test')
createCharacterAnchorVideoTest(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: TestCharacterAnchorVideoDto
) {
return this.charactersService.createCharacterAnchorVideoTest(user, characterId, dto);
}
@Get('characters/:characterId/prompt-versions')
listCharacterPromptVersions(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.listCharacterPromptVersions(user, characterId);
}
@Post('characters/:characterId/prompt-versions')
createCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: SaveCharacterPromptVersionDto
) {
return this.charactersService.createCharacterPromptVersion(user, characterId, dto);
}
@Post('characters/:characterId/prompt-versions/refresh-system')
refreshCharacterSystemPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.refreshCharacterSystemPrompt(user, characterId);
}
@Post('characters/:characterId/prompt-versions/:versionId/activate')
activateCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Param('versionId') versionId: string
) {
return this.charactersService.activateCharacterPromptVersion(user, characterId, versionId);
}
@Get('characters/:characterId/design-versions')
listCharacterDesignVersions(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.listCharacterDesignVersions(user, characterId);
}
@Post('characters/:characterId/design-versions')
createCharacterDesignVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: CreateCharacterDesignVersionDto
) {
return this.charactersService.createCharacterDesignVersion(user, characterId, dto);
}
@Post('characters/:characterId/design-versions/:versionId/finalize')
finalizeCharacterDesignVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Param('versionId') versionId: string
) {
return this.charactersService.finalizeCharacterDesignVersion(user, characterId, versionId);
}
@Get('characters/:characterId/states')
listCharacterStates(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.listCharacterStates(user, characterId);
}
@Post('characters/:characterId/states')
upsertCharacterState(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: CreateCharacterStateDto
) {
return this.charactersService.upsertCharacterState(user, characterId, dto);
}
@Delete('characters/:characterId')
deleteCharacter(
@CurrentUser() user: AuthRequestUser,
+3 -1
View File
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { AssetsModule } from '../assets/assets.module';
import { AuthModule } from '../auth/auth.module';
import { ProvidersModule } from '../providers/providers.module';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
@Module({
imports: [AuthModule],
imports: [AuthModule, AssetsModule, ProvidersModule],
controllers: [CharactersController],
providers: [CharactersService],
exports: [CharactersService]
@@ -129,6 +129,10 @@ describe('CharactersService', () => {
updateMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
providerConfig: {
findUnique: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
characterMemory: { create: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>;
};
@@ -177,6 +181,25 @@ describe('CharactersService', () => {
updateMany: vi.fn(),
createMany: vi.fn()
},
providerConfig: {
findUnique: vi.fn().mockResolvedValue({
id: 1n,
provider_type: 'VoiceProvider',
provider_code: 'openai-tts',
display_name: 'OpenAI TTS',
mode: 'real',
model_name: 'gpt-4o-mini-tts',
config_json: { voice: 'coral' },
fallback_provider_id: null,
is_enabled: true,
priority: 100,
rate_limit_json: null,
cost_rule_json: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
}),
findFirst: vi.fn().mockResolvedValue(null)
},
characterMemory: {
create: vi.fn().mockResolvedValue({})
},
@@ -291,4 +314,35 @@ describe('CharactersService', () => {
ForbiddenException
);
});
it('keeps clear fictional identity faces while rejecting copied real identities', () => {
const conflictingRules = [
'不要真人照片级清晰正脸',
'不要证件照式正面大头照',
'不要真实演员肖像照',
'不要可识别真人脸特写',
'不要高精真人脸摄影棚写真'
];
const privateService = service as unknown as {
personNegativePromptCn: (extraRules?: string | null) => string;
seedanceSafeCharacterNegativePrompt: (text: string) => string;
};
const genericNegativePrompt = privateService.personNegativePromptCn(
'不得换脸;不要真人照片级清晰正脸'
);
const seedanceNegativePrompt = privateService.seedanceSafeCharacterNegativePrompt(
'不得换脸,不要可识别真人脸特写'
);
for (const prompt of [genericNegativePrompt, seedanceNegativePrompt]) {
expect(prompt).toContain('不要复制现实演员、公众人物或未经授权真人的可识别面貌');
expect(prompt).toContain('不要生活摄影、商业写真或平台肖像模板');
for (const rule of conflictingRules) {
expect(prompt).not.toContain(rule);
}
}
expect(genericNegativePrompt).toContain('不得换脸');
expect(seedanceNegativePrompt).toContain('不得换脸');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
buildCharacterTurnaroundPanelPrompt,
CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
type CharacterTurnaroundPanelType
} from './character-turnaround-panel-template';
const profile = {
name: '诸葛亮',
genderLabel: '男',
ageGroup: '中年',
identityDesc: '五丈原时期蜀汉军师',
appearanceDesc: '清癯、沉静、睿智,具有真实中年感',
faceDesc: '长脸,轻微眼袋、法令纹,短髭与山羊胡',
hairDesc: '黑发夹灰白,高道髻',
eyeDesc: '深琥珀色眼睛',
bodyDesc: '身形修长挺拔',
costumeRules: '白色交领军师袍,浅青内层,淡金云纹,深色布靴'
};
describe('character turnaround split-panel prompt', () => {
const cases: Array<[CharacterTurnaroundPanelType, string]> = [
['turnaround_identity_panel', '身份特写'],
['turnaround_front_panel', '严格正面'],
['turnaround_side_panel', '严格90度右向侧面'],
['turnaround_back_panel', '严格180度背面']
];
it.each(cases)('builds one auditable %s contract', (panelType, requiredGeometry) => {
const prompt = buildCharacterTurnaroundPanelPrompt({
profile,
panelType,
referenceImageCount: 2,
liveAction: true
});
expect(prompt).toContain(CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION);
expect(prompt).toContain(requiredGeometry);
expect(prompt).toContain('16:9横版');
expect(prompt).toContain('纯白无缝摄影棚背景');
expect(prompt).toContain('合成安全区');
expect(prompt).toContain('不得在同一张图中生成第二个人');
expect(prompt).toContain('双手空置');
expect(prompt).not.toContain('四个全身');
expect(prompt).not.toContain('两排');
});
it('makes side and back geometry mutually explicit', () => {
const side = buildCharacterTurnaroundPanelPrompt({
profile,
panelType: 'turnaround_side_panel',
referenceImageCount: 2,
liveAction: true
});
const back = buildCharacterTurnaroundPanelPrompt({
profile,
panelType: 'turnaround_back_panel',
referenceImageCount: 3,
liveAction: true
});
expect(side).toContain('禁止45度、三分之二侧面和回头看镜头');
expect(back).toContain('脸部完全不可见');
expect(back).toContain('禁止侧脸、回头、扭腰和三分之二背面');
});
});
@@ -0,0 +1,120 @@
export const CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION =
'character_turnaround_split_panel_v1_1';
export const CHARACTER_TURNAROUND_PANEL_TYPES = [
'turnaround_identity_panel',
'turnaround_front_panel',
'turnaround_side_panel',
'turnaround_back_panel'
] as const;
export type CharacterTurnaroundPanelType =
(typeof CHARACTER_TURNAROUND_PANEL_TYPES)[number];
export interface CharacterTurnaroundPanelProfile {
name: string;
descriptionOverride?: string | null;
roleType?: string | null;
genderLabel?: string | null;
ageGroup?: string | null;
identityDesc?: string | null;
appearanceDesc?: string | null;
faceDesc?: string | null;
hairDesc?: string | null;
eyeDesc?: string | null;
bodyDesc?: string | null;
costumeRules?: string | null;
}
export function isCharacterTurnaroundPanelType(
value: string
): value is CharacterTurnaroundPanelType {
return (CHARACTER_TURNAROUND_PANEL_TYPES as readonly string[]).includes(value);
}
export function characterTurnaroundPanelLabel(type: CharacterTurnaroundPanelType) {
const labels: Record<CharacterTurnaroundPanelType, string> = {
turnaround_identity_panel: '身份特写',
turnaround_front_panel: '严格正面',
turnaround_side_panel: '严格90度侧面',
turnaround_back_panel: '严格180度背面'
};
return labels[type];
}
export function buildCharacterTurnaroundPanelPrompt(input: {
profile: CharacterTurnaroundPanelProfile;
panelType: CharacterTurnaroundPanelType;
referenceImageCount: number;
liveAction: boolean;
}) {
const profile = input.profile;
const profileText = profile.descriptionOverride
? `DESCRIPTION_OVERRIDE_MODE,本次唯一角色设定:${profile.descriptionOverride}`
: [
`角色名:${profile.name}`,
`身份:${profile.identityDesc || profile.roleType || ''}`,
`性别与年龄:${profile.genderLabel || ''}${profile.ageGroup || ''}`,
`外貌气质:${profile.appearanceDesc || ''}`,
`脸部:${profile.faceDesc || ''}`,
`发型与毛发:${profile.hairDesc || ''}`,
`眼睛:${profile.eyeDesc || ''}`,
`体型:${profile.bodyDesc || ''}`,
`基础服装:${profile.costumeRules || ''}`
].join('\n');
const referenceMode = input.referenceImageCount > 0
? `REFERENCE_LOCK_MODE:已附带 ${input.referenceImageCount} 张同角色参考图。参考图是身份、年龄、脸型、发型、体型与基础服装结构的最高真值;保持同一虚构数字演员,禁止换脸和重新设计服装。`
: 'DIRECT_DESIGN_MODE:未附带参考图。只按角色设定建立一个唯一、稳定、可复用的原创数字演员。';
const common = [
CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
'S+影视角色母版拆分面板,16:9横版,单人,单一视角,纯白无缝摄影棚背景,均匀柔和棚拍光,低透视畸变,电影级写实与AAA角色资产质感。',
'使用当前模型与API参数允许的最高原生质量,保留适合4K放大、程序裁切和视频身份锁定的结构与微材质。',
'这不是多格设定板,不得在同一张图中生成第二个人、第二个角度、局部小窗、文字、标签、箭头、边框或水印。',
referenceMode,
profileText,
'身份、年龄、五官、发型、胡须、体型、服装版型、领口、腰带、袖口、鞋履和常驻穿戴配饰必须稳定。双手空置,不展示羽扇、武器、书卷、手机或剧情道具。',
'鞋履是跨面板硬锁定结构:必须符合角色所属时代与身份,使用同色软底传统鞋靴,鞋面、鞋底和包边保持深色同材质;禁止白色或浅色橡胶中底、运动鞋弧形鞋底、现代休闲鞋鞋头、拉链和工业胶边。',
'正面、侧面和背面必须保持完全相同的头顶至鞋底高度、肩宽、腰线、四肢长度与服装层级。中年或老年角色在侧面和背面也要通过灰白发分布、后颈、耳部、手部皮肤与克制姿态保持年龄,不得自动年轻化。',
input.liveAction
? '画面达到高预算历史电影官方角色资产母版:真实皮肤年龄纹理、清晰发丝与胡须根部、可辨布料织纹、缝线、刺绣、层叠和自然褶皱;克制写实,不偶像化,不仙侠海报化。'
: '高质量角色资产板,结构清楚,材质可辨,适合后续关键帧与视频一致性锁定。'
];
const panelRules: Record<CharacterTurnaroundPanelType, string[]> = {
turnaround_identity_panel: [
'面板任务:只生成身份特写。角色严格正对镜头,眼睛平视,相机与眼睛等高,中性克制表情,头部端正,不歪头,不做三分之二侧脸。',
'构图为头肩至胸上部特写,完整保留头顶、发髻、双耳、下巴、肩线和基础领口;脸部与肩部全部位于画面中央约27%的合成安全区,左右留出大量纯白空间。',
'两眼清晰、瞳孔方向一致,面部左右结构可信;放大后仍保留皮肤、眼周、法令纹、毛孔、胡须与发丝微细节。年龄必须贴合设定,不得比目标年龄明显年轻,也不得额外老化成高龄角色。'
],
turnaround_front_panel: [
'面板任务:只生成严格正面全身。角色身体、头部、肩线、骨盆和双脚全部正对镜头,左右对称,目视正前方。',
'标准角色建模中性站姿,双臂与躯干略微分开,双手和五指完全分离可见,双腿自然平行;从头顶到鞋底完整入画,同一水平基线。传统鞋靴必须是深色同材质软薄底,不能出现任何浅色现代鞋底边。',
'完整人物必须收纳在画面中央约22%的合成安全区,人物高度约占画面84%,两侧保持大面积纯白,不得裁手、裁脚、裁衣摆。'
],
turnaround_side_panel: [
'面板任务:只生成严格90度右向侧面全身。角色鼻尖、胸口、膝盖和脚尖统一朝画面右侧;只呈现标准侧面轮廓,禁止45度、三分之二侧面和回头看镜头。',
'相机与人物腰部等高,正交角色建模参考视角。胸腔、腰带、衣襟中线和鞋底压缩为真正侧面厚度,不展开任何正面结构;双臂自然下垂且略分离,头顶至鞋底完整入画,服装侧面层次与正面参考一致。传统鞋靴保持深色软薄底,无浅色胶边。',
'完整人物必须收纳在画面中央约22%的合成安全区,人物高度约占画面84%,两侧保持大面积纯白。'
],
turnaround_back_panel: [
'面板任务:只生成严格180度背面全身。角色后脑、双肩、脊柱、骨盆和脚跟正对镜头;脸部完全不可见,禁止侧脸、回头、扭腰和三分之二背面。',
'清晰展示发髻后部、发带、衣领后部、背部中心接缝、腰带后部、袍服垂坠、衣摆和鞋跟;长发自然分束并露出后领中心与腰带拓扑,结构必须能与正面、侧面参考对应。双手五指清晰分离,传统鞋靴保持深色同材质软薄底。',
'完整人物必须收纳在画面中央约22%的合成安全区,人物高度约占画面84%,双臂与身体略微分开,头顶至鞋底完整入画。'
]
};
return [...common, ...panelRules[input.panelType]].join('\n\n');
}
export function buildCharacterTurnaroundPanelNegativePrompt(extraRules?: string | null) {
return [
'multiple people, duplicate person, multiple views, split screen, collage, contact sheet, inset panel, text, label, arrow, watermark, logo',
'identity drift, face drift, age drift, different person, changed hairstyle, changed beard, changed costume, changed body proportion',
'45-degree view, three-quarter view, head turn, looking back, twisted torso, perspective distortion, wide-angle distortion',
'cropped head, cropped hair bun, cropped hands, cropped fingers, cropped feet, cropped shoes, cropped hem, extra limbs, extra fingers, fused hands, malformed anatomy',
'weapon, fan, book, scroll, phone, handheld prop, scene prop, dramatic environment, fantasy poster, glowing magic, aura, smoke, cinematic action pose',
'young idol face, beauty filter, plastic skin, anime, illustration, painterly, low resolution, blur, overexposure, crushed white fabric detail',
'modern sneakers, modern casual shoes, sports shoes, white rubber midsole, contrast sole edge, thick rubber sole, zipper boots, athletic curved toe',
extraRules || ''
].filter(Boolean).join(', ');
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import {
buildCharacterTurnaroundNegativePrompt,
buildCharacterTurnaroundPublicPrompt,
CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS,
CHARACTER_TURNAROUND_HARD_FAILURES,
CHARACTER_TURNAROUND_SCORE_DIMENSIONS,
CHARACTER_TURNAROUND_TEMPLATE_VERSION,
isCurrentCharacterTurnaroundPrompt
} from './character-turnaround-template';
const profile = {
name: '测试角色',
roleType: '谋士',
genderLabel: '男',
ageGroup: '48岁',
identityDesc: '古代军师',
appearanceDesc: '沉静克制',
faceDesc: '清癯长脸,眉眼锐利',
hairDesc: '束发,鬓角少量灰白',
eyeDesc: '深色眼睛',
bodyDesc: '修长挺拔',
costumeRules: '多层交领长袍,深色布靴'
};
describe('character turnaround public template', () => {
it('builds the same S+ industrial layout for reference-locked generation', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile,
referenceImageCount: 1,
liveAction: true
});
expect(prompt).toContain(CHARACTER_TURNAROUND_TEMPLATE_VERSION);
expect(prompt).toContain('当前模型与 API 参数允许的最高原生质量');
expect(prompt).toContain('REFERENCE_LOCK_MODE');
expect(prompt).toContain('16:9 横版角色');
expect(prompt).not.toContain('9:16');
expect(prompt).toContain('左侧约38%');
expect(prompt).toContain('严格正面全身、严格90度侧面全身、严格背面全身');
expect(prompt).toContain('中年保留适量额纹、眼周纹、法令纹');
expect(prompt).toContain('服装拓扑');
expect(prompt).toContain('默认双手自然下垂并保持空手');
expect(prompt).toContain('禁止现代运动鞋');
expect(prompt).toContain('浅色和白色服装必须压住高光');
expect(prompt).not.toContain('第一行:四个全身');
expect(prompt).not.toContain('八卦阵');
});
it('supports direct original generation without pretending a main anchor exists', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile,
referenceImageCount: 0,
liveAction: true
});
expect(prompt).toContain('DIRECT_DESIGN_MODE');
expect(prompt).toContain('本次不使用任何参考图片');
expect(prompt).not.toContain('已上传参考图是角色身份');
});
it('uses a direct description as the only character source and removes old profile fields', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile: {
...profile,
descriptionOverride: '62岁女性边关统帅,银灰短发,左眉旧伤,深红鳞甲与黑色战靴。'
},
referenceImageCount: 0,
liveAction: true
});
expect(prompt).toContain('DESCRIPTION_OVERRIDE_MODE');
expect(prompt).toContain('本次唯一角色描述:62岁女性边关统帅');
expect(prompt).toContain('禁止读取、补写、推断或混合角色库历史字段');
expect(prompt).not.toContain('角色类型:谋士');
expect(prompt).not.toContain('真实年龄或年龄段:48岁');
expect(prompt).not.toContain('清癯长脸');
expect(prompt).not.toContain('多层交领长袍');
});
it('exposes an optional-reference public copy mode for every character type', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile: { ...profile, name: '通用角色', roleType: '女侠', genderLabel: '女', ageGroup: '青年' },
referenceImageCount: null,
liveAction: true
});
expect(prompt).toContain('REFERENCE_OPTION_MODE');
expect(prompt).toContain('带主锚点锁定');
expect(prompt).toContain('无主锚点直接原创');
expect(prompt).not.toContain('诸葛亮');
});
it('defines hard blockers, a 100-point rubric and strict negative constraints', () => {
const negative = buildCharacterTurnaroundNegativePrompt('不要改变角色胎记');
expect(CHARACTER_TURNAROUND_HARD_FAILURES).toHaveLength(9);
expect(CHARACTER_TURNAROUND_SCORE_DIMENSIONS).toHaveLength(7);
expect(negative).toContain('45度或三分之二侧面冒充严格90度侧面');
expect(negative).toContain('服装拓扑错误:');
expect(negative).toContain('衣领');
expect(negative).toContain('手持剧情道具');
expect(negative).toContain('现代运动鞋');
expect(negative).toContain('不要改变角色胎记');
expect(negative).toContain('身份错误:');
expect(negative).toContain('摄影与材质错误:');
expect(CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS.every((term) => !negative.includes(term))).toBe(true);
});
it('uses a dedicated structural branch for multi-head non-human characters', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile: {
name: '九幽鬼将',
roleType: '召唤实体',
genderLabel: '非人形男性战将意象',
ageGroup: '古老亡灵',
identityDesc: '三头六臂的中国古战场亡灵战将',
appearanceDesc: '百丈体量,腐朽古代重甲',
faceDesc: '中首为主身份头部,左右副首关系固定',
bodyDesc: '三头六臂,六臂关节清晰',
costumeRules: '腐朽中国古代重甲'
},
referenceImageCount: 0,
liveAction: true
});
expect(prompt).toContain('主身份头部超清特写');
expect(prompt).toContain('头部数量、肢体数量、关节结构');
expect(prompt).toContain('原创、稳定、可复用的非人角色资产');
expect(prompt).toContain('16:9 横版角色');
expect(prompt).toContain('photorealistic CGI creature or supernatural character asset');
expect(prompt).not.toContain('同一位原创虚构数字演员的同一套角色定妆');
expect(isCurrentCharacterTurnaroundPrompt(prompt)).toBe(true);
});
it('rejects old versions and face-suppressing conflicts as current templates', () => {
const current = buildCharacterTurnaroundPublicPrompt({
profile,
referenceImageCount: 0,
liveAction: true
});
expect(isCurrentCharacterTurnaroundPrompt(current)).toBe(true);
expect(isCurrentCharacterTurnaroundPrompt(current.replace(CHARACTER_TURNAROUND_TEMPLATE_VERSION, 'character_turnaround_public_v3_s_plus'))).toBe(false);
expect(isCurrentCharacterTurnaroundPrompt(`${current}\n不要真人照片级清晰正脸`)).toBe(false);
});
});
@@ -0,0 +1,185 @@
export const CHARACTER_TURNAROUND_TEMPLATE_VERSION =
'character_turnaround_single_sheet_v5_s_plus_lossless';
export const CHARACTER_TURNAROUND_S_PLUS_THRESHOLD = 96;
export const CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS = [
'不要真人照片级清晰正脸',
'不要证件照式正面大头照',
'不要真实演员肖像照',
'不要可识别真人脸特写',
'不要高精真人脸摄影棚写真'
] as const;
export interface CharacterTurnaroundProfile {
name: string;
descriptionOverride?: string | null;
roleType?: string | null;
genderLabel?: string | null;
ageGroup?: string | null;
identityDesc?: string | null;
appearanceDesc?: string | null;
faceDesc?: string | null;
hairDesc?: string | null;
eyeDesc?: string | null;
bodyDesc?: string | null;
costumeRules?: string | null;
}
export interface CharacterTurnaroundPromptInput {
profile: CharacterTurnaroundProfile;
referenceImageCount: number | null;
liveAction?: boolean;
}
export const CHARACTER_TURNAROUND_HARD_FAILURES = [
'左侧身份特写与右侧任一视图不是同一角色',
'缺少、重复或错置正面、严格90度侧面、严格180度背面中的任一全身视图',
'使用45度、三分之二侧身或回头姿势冒充严格侧面或背面',
'任一全身视图的头顶、手、脚、鞋履或主要服装轮廓被裁切',
'跨视图的年龄、体型、发型、胡须、服装拓扑、配饰或鞋履明显漂移',
'皮肤、眼睛、毛发或材质呈现明显塑料CG、涂抹、过曝或伪细节',
'古代或特定时代角色出现现代运动鞋、白色橡胶中底或时代错误鞋型',
'存在严重人体结构错误、多余肢体、重影、文字、Logo或水印'
] as const;
export const CHARACTER_TURNAROUND_SCORE_DIMENSIONS = [
'同一角色身份、脸部骨相、年龄与状态一致性:25分',
'正面、严格90度侧面、严格180度背面几何:20分',
'服装裁剪拓扑、发型、配饰与鞋履对应:15分',
'皮肤或表面、眼睛、毛发与真实年龄微细节:15分',
'织物、刺绣、皮革、金属与旧化材质可信度:10分',
'人体结构、全身完整、统一比例与基线:10分',
'无缝背景、中性棚拍光线与视频母版可用性:5分'
] as const;
function clean(value: string | null | undefined) {
return value?.replace(/\s+/g, ' ').trim() || '';
}
function profileLine(label: string, value: string | null | undefined) {
const normalized = clean(value);
return normalized ? `${label}${normalized}` : null;
}
function isNonHumanProfile(profile: CharacterTurnaroundProfile) {
const source = [
profile.roleType,
profile.genderLabel,
profile.ageGroup,
profile.identityDesc,
profile.appearanceDesc,
profile.faceDesc,
profile.bodyDesc
].map(clean).join(' ');
return /(?:非人|亡灵|鬼将|神兽|妖兽|魔物|怪物|多头|多臂|三头|六臂|机械体|机器人|异形)/.test(source);
}
export function characterTurnaroundPromptConflicts(prompt: string) {
return CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS.filter((term) => prompt.includes(term));
}
export function isCurrentCharacterTurnaroundPrompt(prompt: string) {
return prompt.includes(CHARACTER_TURNAROUND_TEMPLATE_VERSION)
&& characterTurnaroundPromptConflicts(prompt).length === 0;
}
export function buildCharacterTurnaroundPublicPrompt(input: CharacterTurnaroundPromptInput) {
const profile = input.profile;
const nonHuman = isNonHumanProfile(profile);
const descriptionOverride = clean(profile.descriptionOverride);
const hasReference = input.referenceImageCount === null
? null
: input.referenceImageCount > 0;
const profileLines = descriptionOverride
? [
profileLine('角色名', profile.name),
profileLine('本次唯一角色描述', descriptionOverride)
].filter((line): line is string => Boolean(line))
: [
profileLine('角色名', profile.name),
profileLine('角色身份', profile.identityDesc || profile.roleType),
profileLine('性别', profile.genderLabel),
profileLine('真实年龄或年龄段', profile.ageGroup),
profileLine('整体气质', profile.appearanceDesc),
profileLine('脸型、五官与稳定识别点', profile.faceDesc),
profileLine('发型、发际线与胡须', profile.hairDesc),
profileLine('眼睛与眼神', profile.eyeDesc),
profileLine('身高、体型与姿态', profile.bodyDesc),
profileLine('完整基础服装、配饰与鞋履', profile.costumeRules)
].filter((line): line is string => Boolean(line));
const sourceRules = descriptionOverride
? [
'DESCRIPTION_OVERRIDE_MODE:仅使用“本次唯一角色描述”建立角色;历史角色描述、旧锚点、旧 Prompt 和角色专属经验全部不参与。'
]
: hasReference === true
? [
`REFERENCE_LOCK_MODE:已附带 ${input.referenceImageCount} 张角色参考图。参考图是身份、脸部骨相、年龄、发型、体型和基础服装的最高视觉真值;文字只补足参考图不可见的侧面和背面结构。`
]
: hasReference === false
? [
'DIRECT_DESIGN_MODE:本次不使用参考图,仅根据下列角色资料在同一张图内建立一个唯一、原创、可重复调用的角色身份。'
]
: [
'REFERENCE_OPTION_MODE:有参考图时以参考图锁定身份;没有参考图时仅依据角色资料建立唯一原创身份。'
];
return [
`S+ 影视角色单张整板母版 / ${CHARACTER_TURNAROUND_TEMPLATE_VERSION}`,
'直接生成一张完整的 16:9 横版角色连续性定妆板。这是后续关键帧、图生视频和视频角色元素的身份母版,不是海报、插画或氛围图。角色身份、严格视图和可用微细节优先。',
'',
'【身份来源】',
...sourceRules,
'',
'【角色唯一设定】',
...profileLines,
'',
'【单张整板版式】',
'一张连续的 16:9 横图,无后期拼接感。纯白或极浅中性灰无缝摄影棚背景,不得出现边框、分割线、标题、标签、尺寸线或文字。',
nonHuman
? '左侧约38%为同一角色的主身份头部特写;右侧约62%依次排列同一角色的严格正面全身、严格90度右向侧面全身、严格180度背面全身。'
: '左侧约38%为同一角色的超清正面头肩身份特写;右侧约62%依次排列同一角色的严格正面全身、严格90度右向侧面全身、严格180度背面全身。',
'右侧只允许三个全身视图;三者等高、同基线、同尺度、同一中性站姿,头顶、手、脚、鞋履和衣摆完整入画。严格侧面不能是45度或三分之二侧身;背面不回头、不露侧脸。',
'',
'【同一角色硬锁定】',
nonHuman
? '左侧特写与右侧三视图必须是同一个角色资产:头部数量、肢体数量、头部结构、眼位、身体比例、表面材质、甲胄拓扑和损伤识别点完全一致。'
: '左侧特写与右侧三视图必须像同一位原创虚构演员在同一次影视服化定妆棚拍中拍摄:颅骨、脸型、五官比例、耳朵、肤色、年龄、发际线、发型、胡须或妆容、疤痕和识别点完全一致。',
'身高、头身比、肩宽、腰线、四肢长度、手脚大小和姿态完全统一。基础服装的衣领、肩线、袖口、内外叠层、腰带、绑带、刺绣、缝线、扣件、配饰、衣摆和鞋履必须在正侧背逐一对应,不换装、不增删、不改款。',
'默认中性站姿、双手空置,只保留不可从身体或基础服装分离的常驻穿戴物;不加入剧情道具、武器、扇子、书卷、手机或法器。鞋履必须符合角色的时代、身份和服装设定;禁止现代运动鞋、休闲鞋、白色橡胶中底、厚底潮鞋和时代错误鞋型。',
'',
'【真实微细节与材质】',
nonHuman
? '使用高预算影视怪物实物特效、服化制作与数字材质扫描的真实质感,结构、腐蚀、破损、内光和材质边界清晰,不糊成烟雾或随机怪脸。'
: '整体是高预算真人影视服化造型部门的角色连续性定妆摄影(live-action costume and character continuity photography),不是游戏CG渲染、蜡像、塑料数字人、概念插画或二次元。',
nonHuman
? '羽毛、毛发、皮肤、鳞片、布料、皮革、木材、金属、腐蚀和破损分别呈现各自真实的微结构与光照反应,不用噪点或过度锐化伪造细节。'
: '脸部保留与年龄匹配的真实皮肤微结构:毛孔尺度自然,细纹、眼周、法令纹、唇纹、肤色起伏与自然不完美可读,眼白、虹膜、睫毛、眉毛、发根、碎发和胡须根部清楚;不磨皮、不美颜、不网红化、不用假毛孔和噪点伪造清晰度。',
'服装材质必须是可放大检查的实物微细节:织物经纬与纱线密度、缝线、锁边、刺绣针脚、金属边缘、皮革纹理、木材纹理、旧化和自然折痕分层清楚,不能用平滑色块或简单线条代替。',
'浅色或白色服装必须保留明度层次与局部微对比:外袍、内衬、缘边、织纹、针脚、刺绣和褶皱不得过曝成纯白平面。不做柔焦、降噪涂抹、插值光滑、过度锐化、高反差边缘或廉价CG材质。',
'',
'【摄影标准】',
'中性全画幅摄影棚质感,85至105mm长焦等效、低透视畸变、相机与人物中心高度对齐;柔和均匀的大面积柔光,中性白平衡,高光不剪切,轮廓边缘与白背景清晰分离,脚下只保留很淡的真实接触阴影。无景深虚化、无戏剧性彩光、无仙气光晕、无烟雾粒子。',
'',
'【最终自检】',
'画面只能包含:左侧一张身份特写 + 右侧严格正面、严格90度侧面、严格180度背面三个完整全身视图。',
nonHuman
? '同一身份、同一头部与肢体数量、同一体型、同一材质与同一甲胄拓扑;严格正侧背;零文字、零Logo、零水印。'
: '同脸、同年龄、同发型、同体型、同服装拓扑和同鞋履;严格正侧背;不换脸、不年轻化、不现代鞋履、不塑料CG化;零文字、零Logo、零水印。',
'使用当前模型与 API 参数允许的最高原生质量。结构稳定和真实有效细节优先于装饰、氛围和锐化。'
].filter(Boolean).join('\n');
}
export function buildCharacterTurnaroundNegativePrompt(extraRules?: string | null) {
const core = [
'不同角色、换脸、年龄漂移、发型漂移、服装换款、体型或比例漂移',
'缺少或重复视图、45度或三分之二侧身、背面回头、头脚或衣摆裁切、人物不等高',
'塑料皮肤、蜡像、游戏CG渲染、插画、动漫、柔焦、降噪涂抹、过曝、噪点伪细节、过度锐化',
'平滑色块布料、画线式刺绣、缝线与织纹消失、现代运动鞋、白色橡胶中底、时代错误鞋履',
'剧情道具、武器、手持物、复杂背景、海报装饰、法阵、光环、烟雾、粒子、边框、分割线、标题、标签、文字、Logo、水印'
];
const extra = clean(extraRules);
return [...core, extra].filter(Boolean).join('');
}
+10
View File
@@ -0,0 +1,10 @@
export const PRODUCTION_ASPECT_RATIO = '16:9' as const;
export const PRODUCTION_VIDEO_WIDTH = 1920;
export const PRODUCTION_VIDEO_HEIGHT = 1080;
export const PRODUCTION_KEYFRAME_WIDTH = 2560;
export const PRODUCTION_KEYFRAME_HEIGHT = 1440;
export const PRODUCTION_VIDEO_RESOLUTION = '1080p' as const;
export const PRODUCTION_FORMAT_LABEL = '16:9 horizontal cinematic video';
export const PRODUCTION_FORMAT_LABEL_ZH = '16:9横屏,1920×1080,电影级横向构图';
+10
View File
@@ -2,6 +2,16 @@ import type { EpisodeStatus } from './episode.types';
export class GenerateEpisodePlanDto {
target_episode_count?: number;
episode_count_mode?: 'fixed' | 'ai_recommend';
force?: boolean;
provider_code?: string;
min_quality_score?: number;
prompt_overrides?: {
overview?: string;
batch?: string;
reconcile?: string;
};
request_params_override?: Record<string, unknown>;
}
export class UpdateEpisodeDto {
@@ -19,6 +19,15 @@ export class EpisodesController {
return this.episodesService.generatePlan(user, projectId, dto);
}
@Post('projects/:projectId/episodes/plan-request-preview')
previewPlanRequest(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: GenerateEpisodePlanDto
) {
return this.episodesService.previewPlanRequest(user, projectId, dto);
}
@Get('projects/:projectId/episodes')
listEpisodes(
@CurrentUser() user: AuthRequestUser,
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { EpisodesController } from './episodes.controller';
import { EpisodesService } from './episodes.service';
@Module({
imports: [AuthModule, PrismaModule],
imports: [AuthModule, PrismaModule, ProvidersModule],
controllers: [EpisodesController],
providers: [EpisodesService],
exports: [EpisodesService]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { GenerationPlanService } from './generation-plan.service';
@Module({
imports: [PrismaModule],
providers: [GenerationPlanService],
exports: [GenerationPlanService]
})
export class GenerationPlanModule {}
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PrismaService } from '../prisma/prisma.service';
import { GenerationPlanService } from './generation-plan.service';
describe('GenerationPlanService', () => {
let service: GenerationPlanService;
let plans: any[];
let activePlanId: bigint | null;
let tx: any;
let prisma: any;
beforeEach(() => {
plans = [];
activePlanId = null;
tx = {
storyboardShot: {
findUnique: vi.fn().mockImplementation(async () => ({
id: 30n,
project_id: 10n,
episode_id: 20n,
active_generation_plan_id: activePlanId
})),
update: vi.fn().mockImplementation(async ({ data }: any) => {
activePlanId = data.active_generation_plan_id;
return { id: 30n, active_generation_plan_id: activePlanId };
})
},
shotGenerationPlan: {
findUnique: vi.fn().mockImplementation(async ({ where }: any) =>
plans.find((plan) => plan.id === where.id) ?? null
),
findFirst: vi.fn().mockImplementation(async ({ where }: any) =>
plans.find((plan) => plan.shot_id === where.shot_id && plan.plan_hash === where.plan_hash) ?? null
),
aggregate: vi.fn().mockImplementation(async () => ({
_max: { revision: plans.length ? Math.max(...plans.map((plan) => plan.revision)) : null }
})),
create: vi.fn().mockImplementation(async ({ data }: any) => {
const plan = {
id: BigInt(plans.length + 1),
...data,
frozen_at: data.frozen_at ?? new Date(),
created_at: new Date()
};
plans.push(plan);
return plan;
})
}
};
prisma = {
$transaction: vi.fn().mockImplementation(async (callback: any) => callback(tx)),
shotGenerationPlan: {
findMany: vi.fn().mockImplementation(async ({ where }: any) => {
const ids = new Set<bigint>(where?.id?.in ?? []);
return plans.filter((plan) =>
plan.project_id === where.project_id &&
plan.episode_id === where.episode_id &&
(!ids.size || ids.has(plan.id))
);
})
}
};
service = new GenerationPlanService(prisma as unknown as PrismaService);
});
const input = (resolution: string) => ({
projectId: 10n,
episodeId: 20n,
shotId: 30n,
sourceEngineVersion: 'splus_v1',
providerCode: 'kling-v3-omni-native-audio-1080p-video',
providerId: 100n,
capabilityRegistryVersionId: 501n,
parameterSchemaVersionId: 502n,
pricingVersionId: 503n,
modelName: 'kling-v3-omni',
endpoint: '/v1/videos/omni-video',
capabilityVersion: 'kling_video_capabilities_2026-07-15',
routeTier: 'premium',
effectiveMode: 'pro',
resolutionRecommendation: resolution,
effectiveGenerationResolution: resolution,
aspectRatio: '16:9',
duration: 6,
soundEnabled: true,
multiShot: false,
native4kCandidate: resolution === '4k',
projectNative4kEnabled: false,
requiredAssets: { character_elements: ['char_1'] },
elementPlan: { selected_assets: [101] },
voicePlan: { speakers: [{ speaker_name: '陈渡', voice: 'voice_1' }] },
keyframePlan: { image_provider_code: 'openai-image' },
videoRequest: { resolution },
routerDecision: { provider_code: 'kling-v3-omni-native-audio-1080p-video' },
qualityPolicy: { target: 'S+', minimum_score: 90 },
retryPolicy: { max_retries: 2 },
fallbackChain: ['kling-v3-native-audio-video'],
costPolicy: { estimated_cost: 2.52 },
providerSnapshot: { price_version: '2026-07-15' },
promptSnapshot: { video_prompt: 'test' },
frozenByUserId: 1n
});
it('creates a new immutable revision when execution inputs change', async () => {
const first = await service.freezeShotPlan(input('1080p'));
const firstHash = first.plan_hash;
const firstResolution = first.effective_generation_resolution;
const second = await service.freezeShotPlan(input('4k'));
expect(first.revision).toBe(1);
expect(second.revision).toBe(2);
expect(second.id).not.toBe(first.id);
expect(plans).toHaveLength(2);
expect(first.plan_hash).toBe(firstHash);
expect(first.effective_generation_resolution).toBe(firstResolution);
expect(activePlanId).toBe(second.id);
});
it('reuses the active frozen revision when the canonical plan is unchanged', async () => {
const first = await service.freezeShotPlan(input('1080p'));
const second = await service.freezeShotPlan(input('1080p'));
expect(second.id).toBe(first.id);
expect(plans).toHaveLength(1);
expect(tx.shotGenerationPlan.create).toHaveBeenCalledTimes(1);
});
it('returns a field-level comparison for two revisions of the same shot', async () => {
const first = await service.freezeShotPlan(input('1080p'));
const second = await service.freezeShotPlan(input('4k'));
const report = await service.compareForEpisode(10n, 20n, first.id, second.id);
expect(report.changed_count).toBeGreaterThan(0);
expect(report.changes).toEqual(expect.arrayContaining([
expect.objectContaining({
path: 'effective_generation_resolution',
category: 'parameters',
before: '1080p',
after: '4k'
})
]));
});
});
@@ -0,0 +1,352 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, type ShotGenerationPlan } from '@prisma/client';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import {
SHOT_GENERATION_PLAN_VERSION,
type FreezeShotGenerationPlanInput,
toSafeShotGenerationPlan
} from './generation-plan.types';
@Injectable()
export class GenerationPlanService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async freezeShotPlan(input: FreezeShotGenerationPlanInput): Promise<ShotGenerationPlan> {
const payload = this.canonicalPayload(input);
const planHash = createHash('sha256').update(this.stableStringify(payload)).digest('hex');
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await this.prisma.$transaction(async (tx) => {
const shot = await tx.storyboardShot.findUnique({
where: { id: input.shotId },
select: {
id: true,
project_id: true,
episode_id: true,
active_generation_plan_id: true
}
});
if (!shot || shot.project_id !== input.projectId || shot.episode_id !== input.episodeId) {
throw new NotFoundException('SHOT_GENERATION_PLAN_SOURCE_SHOT_NOT_FOUND');
}
if (shot.active_generation_plan_id) {
const active = await tx.shotGenerationPlan.findUnique({
where: { id: shot.active_generation_plan_id }
});
if (active?.plan_hash === planHash) {
return active;
}
}
const identical = await tx.shotGenerationPlan.findFirst({
where: { shot_id: input.shotId, plan_hash: planHash }
});
if (identical) {
await tx.storyboardShot.update({
where: { id: input.shotId },
data: { active_generation_plan_id: identical.id }
});
return identical;
}
const latest = await tx.shotGenerationPlan.aggregate({
where: { shot_id: input.shotId },
_max: { revision: true }
});
const plan = await tx.shotGenerationPlan.create({
data: {
project_id: input.projectId,
episode_id: input.episodeId,
shot_id: input.shotId,
revision: (latest._max.revision ?? 0) + 1,
plan_version: SHOT_GENERATION_PLAN_VERSION,
plan_hash: planHash,
status: 'frozen',
source_engine_version: input.sourceEngineVersion,
provider_code: input.providerCode,
provider_id: input.providerId ?? null,
capability_registry_version_id: input.capabilityRegistryVersionId ?? null,
parameter_schema_version_id: input.parameterSchemaVersionId ?? null,
pricing_version_id: input.pricingVersionId ?? null,
model_name: input.modelName ?? null,
endpoint: input.endpoint ?? null,
capability_version: input.capabilityVersion ?? null,
route_tier: input.routeTier ?? null,
effective_mode: input.effectiveMode ?? null,
resolution_recommendation: input.resolutionRecommendation ?? null,
effective_generation_resolution: input.effectiveGenerationResolution ?? null,
aspect_ratio: input.aspectRatio ?? null,
duration: input.duration ?? null,
sound_enabled: input.soundEnabled,
multi_shot: input.multiShot,
native_4k_candidate: input.native4kCandidate,
project_native_4k_enabled: input.projectNative4kEnabled,
required_assets_json: input.requiredAssets,
element_plan_json: input.elementPlan,
voice_plan_json: input.voicePlan,
keyframe_plan_json: input.keyframePlan,
video_request_json: input.videoRequest,
router_decision_json: input.routerDecision,
quality_policy_json: input.qualityPolicy,
retry_policy_json: input.retryPolicy,
fallback_chain_json: input.fallbackChain,
cost_policy_json: input.costPolicy,
provider_snapshot_json: input.providerSnapshot,
prompt_snapshot_json: input.promptSnapshot,
frozen_by_user_id: input.frozenByUserId ?? null,
frozen_at: new Date()
}
});
await tx.storyboardShot.update({
where: { id: input.shotId },
data: { active_generation_plan_id: plan.id }
});
return plan;
});
} catch (error) {
const revisionConflict =
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002';
if (!revisionConflict || attempt === 2) throw error;
}
}
throw new BadRequestException('SHOT_GENERATION_PLAN_FREEZE_CONFLICT');
}
async activeForShot(shotId: bigint) {
const shot = await this.prisma.storyboardShot.findUnique({
where: { id: shotId },
select: { active_generation_plan_id: true }
});
if (!shot?.active_generation_plan_id) return null;
return this.prisma.shotGenerationPlan.findUnique({
where: { id: shot.active_generation_plan_id }
});
}
async requireActiveForShot(shotId: bigint) {
const plan = await this.activeForShot(shotId);
if (!plan || plan.status !== 'frozen') {
throw new BadRequestException('SPLUS_GENERATION_PLAN_REQUIRED');
}
return plan;
}
async listForEpisode(projectId: bigint, episodeId: bigint) {
const [plans, shots] = await Promise.all([
this.prisma.shotGenerationPlan.findMany({
where: { project_id: projectId, episode_id: episodeId },
orderBy: [{ shot_id: 'asc' }, { revision: 'desc' }]
}),
this.prisma.storyboardShot.findMany({
where: { project_id: projectId, episode_id: episodeId },
select: { id: true, shot_no: true, scene_name: true, active_generation_plan_id: true }
})
]);
const shotById = new Map(shots.map((shot) => [shot.id.toString(), shot]));
return plans.map((plan) => {
const shot = shotById.get(plan.shot_id.toString());
return {
...toSafeShotGenerationPlan(plan),
shot_no: shot?.shot_no ?? null,
scene_name: shot?.scene_name ?? null,
is_active: shot?.active_generation_plan_id === plan.id
};
});
}
async compareForEpisode(projectId: bigint, episodeId: bigint, basePlanId: bigint, targetPlanId: bigint) {
if (basePlanId === targetPlanId) {
throw new BadRequestException('GENERATION_PLAN_COMPARE_REQUIRES_TWO_REVISIONS');
}
const plans = await this.prisma.shotGenerationPlan.findMany({
where: {
project_id: projectId,
episode_id: episodeId,
id: { in: [basePlanId, targetPlanId] }
}
});
const base = plans.find((plan) => plan.id === basePlanId);
const target = plans.find((plan) => plan.id === targetPlanId);
if (!base || !target) throw new NotFoundException('GENERATION_PLAN_COMPARE_REVISION_NOT_FOUND');
if (base.shot_id !== target.shot_id) {
throw new BadRequestException('GENERATION_PLAN_COMPARE_REQUIRES_SAME_SHOT');
}
const changes: Array<{
path: string;
category: string;
before: unknown;
after: unknown;
}> = [];
this.collectDiff(this.comparableSnapshot(base), this.comparableSnapshot(target), '', changes);
return {
shot_id: base.shot_id.toString(),
base: toSafeShotGenerationPlan(base),
target: toSafeShotGenerationPlan(target),
changed_count: changes.length,
changes
};
}
snapshot(plan: ShotGenerationPlan): Prisma.InputJsonObject {
return {
id: plan.id.toString(),
revision: plan.revision,
plan_version: plan.plan_version,
plan_hash: plan.plan_hash,
status: plan.status,
source_engine_version: plan.source_engine_version,
provider_code: plan.provider_code,
provider_id: plan.provider_id?.toString() ?? '',
capability_registry_version_id: plan.capability_registry_version_id?.toString() ?? '',
parameter_schema_version_id: plan.parameter_schema_version_id?.toString() ?? '',
pricing_version_id: plan.pricing_version_id?.toString() ?? '',
model_name: plan.model_name ?? '',
endpoint: plan.endpoint ?? '',
capability_version: plan.capability_version ?? '',
route_tier: plan.route_tier ?? '',
effective_mode: plan.effective_mode ?? '',
resolution_recommendation: plan.resolution_recommendation ?? '',
effective_generation_resolution: plan.effective_generation_resolution ?? '',
aspect_ratio: plan.aspect_ratio ?? '',
duration: plan.duration?.toString() ?? '',
sound_enabled: plan.sound_enabled,
multi_shot: plan.multi_shot,
native_4k_candidate: plan.native_4k_candidate,
project_native_4k_enabled: plan.project_native_4k_enabled,
required_assets: plan.required_assets_json ?? {},
element_plan: plan.element_plan_json ?? {},
voice_plan: plan.voice_plan_json ?? {},
keyframe_plan: plan.keyframe_plan_json ?? {},
video_request: plan.video_request_json ?? {},
router_decision: plan.router_decision_json,
quality_policy: plan.quality_policy_json,
retry_policy: plan.retry_policy_json,
fallback_chain: plan.fallback_chain_json,
cost_policy: plan.cost_policy_json ?? {},
provider_snapshot: plan.provider_snapshot_json ?? {},
prompt_snapshot: plan.prompt_snapshot_json ?? {},
frozen_at: plan.frozen_at.toISOString()
};
}
private canonicalPayload(input: FreezeShotGenerationPlanInput) {
return {
plan_version: SHOT_GENERATION_PLAN_VERSION,
project_id: input.projectId.toString(),
episode_id: input.episodeId.toString(),
shot_id: input.shotId.toString(),
source_engine_version: input.sourceEngineVersion,
provider_code: input.providerCode,
provider_id: input.providerId?.toString() ?? null,
capability_registry_version_id: input.capabilityRegistryVersionId?.toString() ?? null,
parameter_schema_version_id: input.parameterSchemaVersionId?.toString() ?? null,
pricing_version_id: input.pricingVersionId?.toString() ?? null,
model_name: input.modelName ?? null,
endpoint: input.endpoint ?? null,
capability_version: input.capabilityVersion ?? null,
route_tier: input.routeTier ?? null,
effective_mode: input.effectiveMode ?? null,
resolution_recommendation: input.resolutionRecommendation ?? null,
effective_generation_resolution: input.effectiveGenerationResolution ?? null,
aspect_ratio: input.aspectRatio ?? null,
duration: input.duration ?? null,
sound_enabled: input.soundEnabled,
multi_shot: input.multiShot,
native_4k_candidate: input.native4kCandidate,
project_native_4k_enabled: input.projectNative4kEnabled,
required_assets: input.requiredAssets ?? null,
element_plan: input.elementPlan ?? null,
voice_plan: input.voicePlan ?? null,
keyframe_plan: input.keyframePlan ?? null,
video_request: input.videoRequest ?? null,
router_decision: input.routerDecision,
quality_policy: input.qualityPolicy,
retry_policy: input.retryPolicy,
fallback_chain: input.fallbackChain,
cost_policy: input.costPolicy ?? null,
provider_snapshot: input.providerSnapshot ?? null,
prompt_snapshot: input.promptSnapshot ?? null
};
}
private collectDiff(
before: unknown,
after: unknown,
path: string,
changes: Array<{ path: string; category: string; before: unknown; after: unknown }>
) {
if (this.stableStringify(before) === this.stableStringify(after)) return;
if (this.isRecord(before) && this.isRecord(after)) {
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
for (const key of keys) {
this.collectDiff(before[key], after[key], path ? `${path}.${key}` : key, changes);
}
return;
}
changes.push({
path: path || 'plan',
category: this.diffCategory(path),
before: before ?? null,
after: after ?? null
});
}
private comparableSnapshot(plan: ShotGenerationPlan) {
const snapshot = this.snapshot(plan) as Record<string, unknown>;
const { id: _id, revision: _revision, plan_hash: _hash, frozen_at: _frozenAt, ...comparable } = snapshot;
return comparable;
}
private diffCategory(path: string) {
if (/pricing_version|cost_policy|estimated_cost|price/i.test(path)) return 'pricing';
if (/parameter_schema|video_request|mode|resolution|duration|aspect_ratio|sound|multi_shot/i.test(path)) return 'parameters';
if (/capability/i.test(path)) return 'capability';
if (/provider|model|endpoint|route|fallback/i.test(path)) return 'model_route';
if (/required_assets|element_plan|actor_lock/i.test(path)) return 'assets';
if (/voice|lip_sync/i.test(path)) return 'voice';
if (/keyframe/i.test(path)) return 'keyframe';
if (/prompt/i.test(path)) return 'prompt';
if (/quality/i.test(path)) return 'quality';
if (/retry/i.test(path)) return 'retry';
return 'other';
}
private isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}
private stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => this.stableStringify(item)).join(',')}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${this.stableStringify(item)}`).join(',')}}`;
}
}
@@ -0,0 +1,57 @@
import type { Prisma, ShotGenerationPlan } from '@prisma/client';
export const SHOT_GENERATION_PLAN_VERSION = 'shot_generation_plan_v1';
export type FreezeShotGenerationPlanInput = {
projectId: bigint;
episodeId: bigint;
shotId: bigint;
sourceEngineVersion: string;
providerCode: string;
providerId?: bigint | null;
capabilityRegistryVersionId?: bigint | null;
parameterSchemaVersionId?: bigint | null;
pricingVersionId?: bigint | null;
modelName?: string | null;
endpoint?: string | null;
capabilityVersion?: string | null;
routeTier?: string | null;
effectiveMode?: string | null;
resolutionRecommendation?: string | null;
effectiveGenerationResolution?: string | null;
aspectRatio?: string | null;
duration?: number | null;
soundEnabled: boolean;
multiShot: boolean;
native4kCandidate: boolean;
projectNative4kEnabled: boolean;
requiredAssets?: Prisma.InputJsonValue;
elementPlan?: Prisma.InputJsonValue;
voicePlan?: Prisma.InputJsonValue;
keyframePlan?: Prisma.InputJsonValue;
videoRequest?: Prisma.InputJsonValue;
routerDecision: Prisma.InputJsonValue;
qualityPolicy: Prisma.InputJsonValue;
retryPolicy: Prisma.InputJsonValue;
fallbackChain: Prisma.InputJsonValue;
costPolicy?: Prisma.InputJsonValue;
providerSnapshot?: Prisma.InputJsonValue;
promptSnapshot?: Prisma.InputJsonValue;
frozenByUserId?: bigint | null;
};
export function toSafeShotGenerationPlan(plan: ShotGenerationPlan) {
return {
...plan,
id: plan.id.toString(),
project_id: plan.project_id.toString(),
episode_id: plan.episode_id.toString(),
shot_id: plan.shot_id.toString(),
provider_id: plan.provider_id?.toString() ?? null,
capability_registry_version_id: plan.capability_registry_version_id?.toString() ?? null,
parameter_schema_version_id: plan.parameter_schema_version_id?.toString() ?? null,
pricing_version_id: plan.pricing_version_id?.toString() ?? null,
frozen_by_user_id: plan.frozen_by_user_id?.toString() ?? null,
duration: plan.duration?.toString() ?? null
};
}
@@ -0,0 +1,230 @@
import { Prisma } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthRequestUser } from '../auth/auth.types';
import { CharacterTurnaroundMasterService } from './character-turnaround-master.service';
const user: AuthRequestUser = { id: '1', email: 'user@example.com', role: 'user' };
function panel(imageType: string, id: number, assetId: number) {
return {
id: String(id),
project_id: '22',
character_id: '92',
asset_id: String(assetId),
image_type: imageType,
prompt_text: 'prompt',
negative_prompt: 'negative',
is_anchor: false,
prompt_quality_score: 99,
quality_score: 97,
status: 'quality_passed',
created_at: '2026-07-16T00:00:00.000Z',
visual_review: {
id: String(id + 1000),
score: 97,
grade: 'S+',
threshold_score: 96,
passed: true,
status: 'passed',
strengths: [],
issues: [],
improvement_rules: [],
quality_gate: {},
provider_code: 'openai-responses-text',
model_name: 'gpt-5',
error_message: null,
created_at: '2026-07-16T00:00:00.000Z'
}
};
}
describe('CharacterTurnaroundMasterService', () => {
let prisma: any;
let images: any;
let service: CharacterTurnaroundMasterService;
beforeEach(() => {
prisma = {
character: {
findUnique: vi.fn().mockResolvedValue({
id: 92n,
project_id: 22n,
name: '诸葛亮',
status: 'locked',
anchor_asset_id: null
})
},
project: {
findUnique: vi.fn().mockResolvedValue({ id: 22n, user_id: 1n })
},
characterImage: {
findFirst: vi.fn().mockResolvedValue({
id: 147n,
asset_id: 980n,
image_type: 'turnaround_reference',
quality_score: new Prisma.Decimal(87)
}),
update: vi.fn()
},
characterImageQualityReview: {
findMany: vi.fn().mockResolvedValue([])
},
providerLog: {
findMany: vi.fn().mockResolvedValue([])
},
renderTask: {
findMany: vi.fn().mockResolvedValue([])
}
};
images = {
generateCharacterImages: vi.fn()
};
const storage = {};
service = new CharacterTurnaroundMasterService(prisma, storage as any, images);
});
it('generates identity, front, strict side and back with cumulative identity evidence', async () => {
const outputs = [
panel('turnaround_identity_panel', 201, 1001),
panel('turnaround_front_panel', 202, 1002),
panel('turnaround_side_panel', 203, 1003),
panel('turnaround_back_panel', 204, 1004)
];
images.generateCharacterImages
.mockResolvedValueOnce({ images: [outputs[0]] })
.mockResolvedValueOnce({ images: [outputs[1]] })
.mockResolvedValueOnce({ images: [outputs[2]] })
.mockResolvedValueOnce({ images: [outputs[3]] });
vi.spyOn(service as any, 'composeMaster').mockResolvedValue(
panel('turnaround_reference', 205, 1005)
);
const result = await service.generate(user, '92', {
provider_code: 'openai-image',
output_size: '3840x2160',
enable_visual_quality_review: true
});
expect(images.generateCharacterImages).toHaveBeenCalledTimes(4);
const calls = images.generateCharacterImages.mock.calls;
expect(calls[0][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_identity_panel'],
reference_asset_ids: ['980'],
reference_crop_mode: 'turnaround_face_panel'
}));
expect(calls[1][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_front_panel'],
reference_asset_ids: ['1001', '980']
}));
expect(calls[2][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_side_panel'],
reference_asset_ids: ['1001', '1002', '980']
}));
expect(calls[3][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_back_panel'],
reference_asset_ids: ['1001', '1002', '1003', '980']
}));
expect(calls.every((call: any[]) => call[3]?.approveSplitPanelSystemTemplate === true)).toBe(true);
expect(result.quality_gate.approved_for_s_plus_video).toBe(true);
expect(result.identity_reference).toEqual({
assetIds: ['980'],
cropMode: 'turnaround_face_panel',
source: 'best_existing_turnaround_candidate'
});
});
it('resumes from reusable panels without paying to regenerate completed work', async () => {
const identity = panel('turnaround_identity_panel', 201, 1001);
const front = panel('turnaround_front_panel', 202, 1002);
const side = panel('turnaround_side_panel', 203, 1003);
const back = panel('turnaround_back_panel', 204, 1004);
images.listCharacterImages = vi.fn().mockResolvedValue([front, identity]);
images.generateCharacterImages
.mockResolvedValueOnce({ images: [side] })
.mockResolvedValueOnce({ images: [back] });
vi.spyOn(service as any, 'composeMaster').mockResolvedValue(
panel('turnaround_reference', 205, 1005)
);
const result = await service.generate(user, '92', {
provider_code: 'openai-image',
output_size: '3840x2160',
enable_visual_quality_review: true,
force: false
});
expect(images.generateCharacterImages).toHaveBeenCalledTimes(2);
expect(images.generateCharacterImages.mock.calls[0][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_side_panel'],
reference_asset_ids: ['1001', '1002', '980']
}));
expect(images.generateCharacterImages.mock.calls[1][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_back_panel'],
reference_asset_ids: ['1001', '1002', '1003', '980']
}));
expect(result.panel_images.map((image: any) => image.asset_id)).toEqual([
'1001',
'1002',
'1003',
'1004'
]);
});
it('regenerates only explicitly selected failed panels', async () => {
const identity = panel('turnaround_identity_panel', 201, 1001);
const front = panel('turnaround_front_panel', 202, 1002);
const oldSide = panel('turnaround_side_panel', 203, 1003);
const back = panel('turnaround_back_panel', 204, 1004);
const newSide = panel('turnaround_side_panel', 206, 1006);
images.listCharacterImages = vi.fn().mockResolvedValue([back, oldSide, front, identity]);
images.generateCharacterImages.mockResolvedValueOnce({ images: [newSide] });
vi.spyOn(service as any, 'composeMaster').mockResolvedValue(
panel('turnaround_reference', 205, 1005)
);
const result = await service.generate(user, '92', {
provider_code: 'openai-image',
output_size: '3840x2160',
enable_visual_quality_review: true,
force: false,
regenerate_image_types: ['turnaround_side_panel']
});
expect(images.generateCharacterImages).toHaveBeenCalledTimes(1);
expect(images.generateCharacterImages).toHaveBeenCalledWith(
user,
'92',
expect.objectContaining({
image_types: ['turnaround_side_panel'],
force: true,
reference_asset_ids: ['1001', '1002', '980']
}),
expect.any(Object)
);
expect(result.panel_images.map((image: any) => image.asset_id)).toEqual([
'1001',
'1002',
'1006',
'1004'
]);
});
it('composes four independent image buffers into one deterministic master', async () => {
const colors = ['#dbeafe', '#dcfce7', '#fef3c7', '#fee2e2'];
const sources = colors.map((color) => ({
asset: { mime_type: 'image/svg+xml' },
buffer: Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="${color}"/></svg>`
)
}));
const result = await (service as any).composePanelBuffers(
sources,
[200, 120, 120, 120],
315
);
expect(result.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(result.length).toBeGreaterThan(1000);
});
});
@@ -0,0 +1,558 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { execFile } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { readFile, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { extname, join } from 'node:path';
import { promisify } from 'node:util';
import type { AuthRequestUser } from '../auth/auth.types';
import { StorageService } from '../assets/storage.service';
import {
CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
CHARACTER_TURNAROUND_PANEL_TYPES,
characterTurnaroundPanelLabel,
type CharacterTurnaroundPanelType
} from '../common/character-turnaround-panel-template';
import { PrismaService } from '../prisma/prisma.service';
import type { GenerateCharacterTurnaroundMasterDto } from './image.dto';
import type { SafeCharacterImage } from './image.types';
import { ImagesService } from './images.service';
const execFileAsync = promisify(execFile);
const MASTER_TEMPLATE_VERSION = 'character_turnaround_programmatic_master_v1_0';
@Injectable()
export class CharacterTurnaroundMasterService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(StorageService) private readonly storage: StorageService,
@Inject(ImagesService) private readonly imagesService: ImagesService
) {}
async generate(
user: AuthRequestUser,
characterId: string,
dto: GenerateCharacterTurnaroundMasterDto
) {
const characterIdValue = this.parseId(characterId, 'Invalid character id');
const character = await this.prisma.character.findUnique({ where: { id: characterIdValue } });
if (!character) throw new NotFoundException('Character not found');
if (character.status !== 'locked') {
throw new BadRequestException('Locked character is required before image generation');
}
const project = await this.prisma.project.findUnique({ where: { id: character.project_id } });
if (!project) throw new NotFoundException('Project not found');
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project access denied');
}
const outputSize = this.normalizeOutputSize(dto.output_size);
const descriptionOverride = this.normalizeDescriptionOverride(dto.character_description_override);
const baseReference = descriptionOverride
? { assetIds: [] as string[], cropMode: 'full' as const, source: 'description_override' }
: await this.resolveIdentityReference(character.id, character.anchor_asset_id, dto.reference_asset_ids, project.id, user);
const panelImages: SafeCharacterImage[] = [];
const panelReferenceMap: Record<string, string[]> = {};
const regenerateTypes = this.normalizeRegenerateTypes(dto.regenerate_image_types);
const existingImages = dto.force === false
? await this.imagesService.listCharacterImages(user, characterId)
: [];
for (const panelType of CHARACTER_TURNAROUND_PANEL_TYPES) {
const reusablePanel = [...existingImages]
.reverse()
.find((image) => image.image_type === panelType
&& image.asset_id
&& image.status !== 'deleted'
&& !regenerateTypes.has(panelType));
if (reusablePanel) {
panelImages.push(reusablePanel);
panelReferenceMap[panelType] = this.panelReferenceAssetIds(
panelType,
panelImages.slice(0, -1),
baseReference.assetIds
);
continue;
}
const referenceAssetIds = this.panelReferenceAssetIds(
panelType,
panelImages,
baseReference.assetIds
);
panelReferenceMap[panelType] = referenceAssetIds;
const response = await this.imagesService.generateCharacterImages(
user,
characterId,
{
image_types: [panelType],
count_per_type: 1,
force: dto.force !== false || regenerateTypes.has(panelType),
set_first_as_anchor: false,
use_reference_images: referenceAssetIds.length > 0,
reference_asset_ids: referenceAssetIds,
reference_crop_mode: panelType === 'turnaround_identity_panel'
? baseReference.cropMode
: 'full',
character_description_override: descriptionOverride || undefined,
output_size: outputSize,
enable_visual_quality_review: dto.enable_visual_quality_review !== false,
provider_code: dto.provider_code,
prompt_review_provider_code: dto.prompt_review_provider_code
},
{ approveSplitPanelSystemTemplate: true }
);
const panel = response.images[0] as SafeCharacterImage | undefined;
if (!panel?.asset_id) {
throw new BadRequestException(`${characterTurnaroundPanelLabel(panelType)}生成后没有可用素材`);
}
panelImages.push(panel);
}
const master = await this.composeMaster({
user,
projectId: project.id,
characterId: character.id,
characterName: character.name,
descriptionOverride,
outputSize,
panelImages,
panelReferenceMap,
providerCode: dto.prompt_review_provider_code,
enableVisualReview: dto.enable_visual_quality_review !== false
});
const panelGatePassed = panelImages.every((image) => image.visual_review?.passed === true);
const masterGatePassed = master.visual_review?.passed === true;
const approved = panelGatePassed && masterGatePassed;
if (!approved && master.status === 'quality_passed') {
await this.prisma.characterImage.update({
where: { id: BigInt(master.id) },
data: { status: 'needs_optimization' }
});
master.status = 'needs_optimization';
}
const assetIds = [...panelImages, master]
.map((image) => image.asset_id)
.filter((assetId): assetId is string => Boolean(assetId));
const tasks = await this.prisma.renderTask.findMany({
where: { output_asset_id: { in: assetIds.map((assetId) => BigInt(assetId)) } },
orderBy: { created_at: 'asc' }
});
const totalCost = tasks.reduce(
(sum, task) => sum + Number(task.cost_actual?.toString() ?? 0),
0
);
const imageIds = [...panelImages, master].map((image) => BigInt(image.id));
const reviewRows = await this.prisma.characterImageQualityReview.findMany({
where: {
character_image_id: { in: imageIds },
provider_log_id: { not: null }
},
select: { provider_log_id: true }
});
const reviewProviderLogIds = Array.from(new Set(
reviewRows
.map((review) => review.provider_log_id?.toString())
.filter((id): id is string => Boolean(id))
)).map((id) => BigInt(id));
const reviewProviderLogs = reviewProviderLogIds.length
? await this.prisma.providerLog.findMany({
where: { id: { in: reviewProviderLogIds } },
select: { id: true, cost_actual: true }
})
: [];
const visualQcCost = reviewProviderLogs.reduce(
(sum, log) => sum + Number(log.cost_actual?.toString() ?? 0),
0
);
const overallCost = totalCost + visualQcCost;
return {
workflow: 'split_panels_then_programmatic_master',
template_version: CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
master_template_version: MASTER_TEMPLATE_VERSION,
character_id: character.id.toString(),
output_size: outputSize,
identity_reference: baseReference,
panel_reference_map: panelReferenceMap,
panel_images: panelImages,
master_image: master,
quality_gate: {
s_plus_threshold: 96,
panel_gate_passed: panelGatePassed,
master_gate_passed: masterGatePassed,
approved_for_s_plus_video: approved,
panel_scores: panelImages.map((image) => ({
image_type: image.image_type,
score: image.visual_review?.score ?? image.quality_score,
passed: image.visual_review?.passed ?? false
})),
master_score: master.visual_review?.score ?? master.quality_score
},
execution_evidence: {
task_ids: tasks.map((task) => task.id.toString()),
visual_qc_provider_log_ids: reviewProviderLogs.map((log) => log.id.toString()),
source_asset_ids: panelImages.map((image) => image.asset_id),
master_asset_id: master.asset_id,
image_generation_cost_actual: Math.round(totalCost * 1_000_000) / 1_000_000,
visual_qc_cost_actual: Math.round(visualQcCost * 1_000_000) / 1_000_000,
programmatic_compose_cost_actual: 0,
total_cost_actual: Math.round(overallCost * 1_000_000) / 1_000_000
},
next_step: approved
? 'approved_for_video_identity_lock'
: 'review_failed_panels_and_regenerate_only_failed_panel'
};
}
private async resolveIdentityReference(
characterId: bigint,
anchorAssetId: bigint | null,
requestedAssetIds: string[] | undefined,
projectId: bigint,
user: AuthRequestUser
) {
const requested = this.uniqueIds(requestedAssetIds);
if (requested.length) {
await this.assertProjectAssets(requested, projectId, user);
return {
assetIds: requested,
cropMode: await this.referenceNeedsFaceCrop(requested[0], characterId)
? 'turnaround_face_panel' as const
: 'full' as const,
source: 'explicit_reference'
};
}
if (anchorAssetId) {
return {
assetIds: [anchorAssetId.toString()],
cropMode: 'full' as const,
source: 'character_anchor'
};
}
const bestTurnaround = await this.prisma.characterImage.findFirst({
where: {
character_id: characterId,
image_type: 'turnaround_reference',
asset_id: { not: null },
status: { not: 'deleted' }
},
orderBy: [{ quality_score: 'desc' }, { created_at: 'desc' }]
});
return bestTurnaround?.asset_id
? {
assetIds: [bestTurnaround.asset_id.toString()],
cropMode: 'turnaround_face_panel' as const,
source: 'best_existing_turnaround_candidate'
}
: { assetIds: [], cropMode: 'full' as const, source: 'character_profile_only' };
}
private panelReferenceAssetIds(
panelType: CharacterTurnaroundPanelType,
panels: SafeCharacterImage[],
baseReferenceAssetIds: string[]
) {
const generated = Object.fromEntries(
panels.map((image) => [image.image_type, image.asset_id]).filter((entry) => Boolean(entry[1]))
) as Record<string, string>;
if (panelType === 'turnaround_identity_panel') return baseReferenceAssetIds.slice(0, 1);
if (panelType === 'turnaround_front_panel') {
return this.uniqueIds([generated.turnaround_identity_panel, ...baseReferenceAssetIds]).slice(0, 3);
}
if (panelType === 'turnaround_side_panel') {
return this.uniqueIds([
generated.turnaround_identity_panel,
generated.turnaround_front_panel,
...baseReferenceAssetIds
]).slice(0, 4);
}
return this.uniqueIds([
generated.turnaround_identity_panel,
generated.turnaround_front_panel,
generated.turnaround_side_panel,
...baseReferenceAssetIds
]).slice(0, 4);
}
private async composeMaster(input: {
user: AuthRequestUser;
projectId: bigint;
characterId: bigint;
characterName: string;
descriptionOverride: string | null;
outputSize: '2560x1440' | '3840x2160';
panelImages: SafeCharacterImage[];
panelReferenceMap: Record<string, string[]>;
providerCode?: string;
enableVisualReview: boolean;
}) {
const [width, height] = input.outputSize.split('x').map(Number);
const identityWidth = Math.round(width * 0.3125);
const remaining = width - identityWidth;
const frontWidth = Math.floor(remaining / 3);
const sideWidth = Math.floor(remaining / 3);
const backWidth = remaining - frontWidth - sideWidth;
const sourceAssetIds = input.panelImages.map((image) => image.asset_id as string);
const composition = {
layout: 'identity_front_side_back',
output_size: input.outputSize,
widths: [identityWidth, frontWidth, sideWidth, backWidth],
source_asset_ids: sourceAssetIds,
source_image_ids: input.panelImages.map((image) => image.id),
source_scores: input.panelImages.map((image) => image.visual_review?.score ?? image.quality_score),
panel_reference_map: input.panelReferenceMap
} as Prisma.InputJsonObject;
const taskInput = {
target_type: 'character_turnaround_master',
character_id: input.characterId.toString(),
composition,
template_version: MASTER_TEMPLATE_VERSION
} as Prisma.InputJsonObject;
const inputHash = createHash('sha256').update(JSON.stringify(taskInput)).digest('hex');
const task = await this.prisma.renderTask.create({
data: {
project_id: input.projectId,
task_type: 'character_turnaround_compose',
status: 'running',
input_json: taskInput,
input_hash: inputHash,
idempotency_key: `character_turnaround_compose:${input.projectId.toString()}:${input.characterId.toString()}:${inputHash}:${Date.now()}`,
retry_count: 0,
max_retry: 0,
started_at: new Date()
}
});
try {
const buffers = [];
for (const assetId of sourceAssetIds) {
const asset = await this.prisma.asset.findUnique({ where: { id: BigInt(assetId) } });
if (!asset) throw new NotFoundException(`Source asset ${assetId} not found`);
buffers.push({ asset, buffer: await this.storage.readPrivateFile(asset.file_path) });
}
const outputBuffer = await this.composePanelBuffers(
buffers,
[identityWidth, frontWidth, sideWidth, backWidth],
height
);
const file = {
originalname: `${input.characterName}-turnaround-master-${randomUUID()}.png`,
mimetype: 'image/png',
size: outputBuffer.length,
buffer: outputBuffer
} as Express.Multer.File;
const stored = await this.storage.storePrivateFile(file, 'character-turnaround-masters');
const asset = await this.prisma.asset.create({
data: {
user_id: BigInt(input.user.id),
project_id: input.projectId,
asset_type: 'image',
file_path: stored.file_path,
file_url: null,
mime_type: 'image/png',
width,
height,
size: stored.size,
hash: stored.hash,
display_name: `${input.characterName} S+四面板人物母版`,
selection_status: 'candidate',
metadata_json: {
...composition,
workflow: 'split_panels_then_programmatic_master',
template_version: MASTER_TEMPLATE_VERSION
} as Prisma.InputJsonObject,
visibility: 'private',
status: 'active'
}
});
const promptText = [
MASTER_TEMPLATE_VERSION,
input.descriptionOverride
? `DESCRIPTION_OVERRIDE_MODE${input.descriptionOverride}`
: `角色:${input.characterName}`,
'程序合成母版:左侧身份特写,右侧严格正面、严格90度侧面、严格180度背面;四栏来自独立生成并独立质检的源图。',
`源素材:${sourceAssetIds.join(', ')}`
].join('\n');
const image = await this.prisma.characterImage.create({
data: {
project_id: input.projectId,
character_id: input.characterId,
asset_id: asset.id,
image_type: 'turnaround_reference',
prompt_text: promptText,
negative_prompt: null,
is_anchor: false,
prompt_quality_score: new Prisma.Decimal(100),
quality_score: null,
status: 'generated'
}
});
await this.prisma.renderTask.update({
where: { id: task.id },
data: { status: 'success', output_asset_id: asset.id, finished_at: new Date() }
});
if (input.enableVisualReview) {
return this.imagesService.reviewCharacterImage(
input.user,
input.characterId.toString(),
image.id.toString(),
{ provider_code: input.providerCode }
);
}
const allImages = await this.imagesService.listCharacterImages(
input.user,
input.characterId.toString()
);
return allImages.find((item) => item.id === image.id.toString()) as SafeCharacterImage;
} catch (error) {
await this.prisma.renderTask.update({
where: { id: task.id },
data: {
status: 'failed',
error_code: 'TURNAROUND_MASTER_COMPOSE_FAILED',
error_message: error instanceof Error ? error.message : String(error),
finished_at: new Date()
}
});
throw error;
}
}
private async composePanelBuffers(
sources: Array<{ asset: { mime_type: string | null }; buffer: Buffer }>,
widths: number[],
height: number
) {
const token = randomUUID();
const inputPaths = sources.map((source, index) => join(
tmpdir(),
`turnaround-panel-${token}-${index}${this.extensionForMime(source.asset.mime_type)}`
));
const outputPath = join(tmpdir(), `turnaround-master-${token}.png`);
try {
await Promise.all(sources.map((source, index) => writeFile(inputPaths[index], source.buffer)));
const filters = widths.map((panelWidth, index) =>
`[${index}:v]scale=${widths.reduce((sum, value) => sum + value, 0)}:${height}:force_original_aspect_ratio=increase,` +
`crop=${panelWidth}:${height}:(iw-${panelWidth})/2:(ih-${height})/2,setsar=1[p${index}]`
);
filters.push('[p0][p1][p2][p3]hstack=inputs=4,format=rgb24[out]');
const args = ['-y', '-hide_banner', '-loglevel', 'error'];
for (const inputPath of inputPaths) args.push('-i', inputPath);
args.push(
'-filter_complex',
filters.join(';'),
'-map',
'[out]',
'-frames:v',
'1',
outputPath
);
await execFileAsync('ffmpeg', args, { timeout: 120_000, maxBuffer: 4 * 1024 * 1024 });
const result = await readFile(outputPath);
if (!result.length) throw new Error('Turnaround master output is empty');
return result;
} finally {
await Promise.all([...inputPaths, outputPath].map((path) => unlink(path).catch(() => undefined)));
}
}
private async assertProjectAssets(assetIds: string[], projectId: bigint, user: AuthRequestUser) {
for (const assetId of assetIds) {
const asset = await this.prisma.asset.findUnique({ where: { id: BigInt(assetId) } });
if (!asset || asset.status !== 'active') throw new NotFoundException(`Reference asset ${assetId} not found`);
if (asset.project_id && asset.project_id !== projectId) {
throw new ForbiddenException(`Reference asset ${assetId} belongs to another project`);
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException(`Reference asset ${assetId} is private`);
}
if (asset.asset_type !== 'image' || !asset.mime_type?.startsWith('image/')) {
throw new BadRequestException(`Reference asset ${assetId} is not an image`);
}
}
}
private async referenceNeedsFaceCrop(assetId: string, characterId: bigint) {
const image = await this.prisma.characterImage.findFirst({
where: { character_id: characterId, asset_id: BigInt(assetId) }
});
return image?.image_type === 'turnaround_reference';
}
private normalizeOutputSize(value: unknown): '2560x1440' | '3840x2160' {
const normalized = String(value || '3840x2160').trim().toLowerCase();
if (normalized !== '2560x1440' && normalized !== '3840x2160') {
throw new BadRequestException('output_size must be 2560x1440 or 3840x2160');
}
return normalized;
}
private normalizeDescriptionOverride(value: unknown) {
const normalized = typeof value === 'string' ? value.replace(/\r\n/g, '\n').trim() : '';
if (normalized.length > 5000) {
throw new BadRequestException('character_description_override must be at most 5000 characters');
}
return normalized || null;
}
private normalizeRegenerateTypes(values: string[] | undefined) {
const normalized = new Set<CharacterTurnaroundPanelType>();
for (const value of values || []) {
if (!(CHARACTER_TURNAROUND_PANEL_TYPES as readonly string[]).includes(value)) {
throw new BadRequestException(`Unsupported turnaround panel type: ${value}`);
}
normalized.add(value as CharacterTurnaroundPanelType);
}
return normalized;
}
private uniqueIds(values: Array<string | null | undefined> | undefined) {
return Array.from(new Set((values || [])
.map((value) => String(value || '').trim())
.filter((value) => /^\d+$/.test(value) && BigInt(value) > 0n)));
}
private extensionForMime(mimeType: string | null) {
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return '.jpg';
if (mimeType === 'image/webp') return '.webp';
if (mimeType === 'image/svg+xml') return '.svg';
return extname(mimeType || '') || '.png';
}
private parseId(value: string, message: string) {
try {
const id = BigInt(value);
if (id <= 0n) throw new Error('ID must be positive');
return id;
} catch {
throw new BadRequestException(message);
}
}
}
+39
View File
@@ -3,6 +3,29 @@ export class GenerateCharacterImagesDto {
count_per_type?: number;
force?: boolean;
set_first_as_anchor?: boolean;
use_reference_images?: boolean;
reference_asset_ids?: string[];
reference_crop_mode?: string;
character_description_override?: string;
output_size?: string;
enable_visual_quality_review?: boolean;
provider_code?: string;
prompt_review_provider_code?: string;
}
export class ReviewCharacterImageDto {
provider_code?: string;
}
export class GenerateCharacterTurnaroundMasterDto {
provider_code?: string;
prompt_review_provider_code?: string;
reference_asset_ids?: string[];
character_description_override?: string;
output_size?: string;
enable_visual_quality_review?: boolean;
force?: boolean;
regenerate_image_types?: string[];
}
export class SetCharacterAnchorDto {
@@ -10,13 +33,29 @@ export class SetCharacterAnchorDto {
asset_id?: string;
}
export class ImportCharacterImageDto {
asset_id?: string;
image_type?: string;
set_as_anchor?: boolean | string;
prompt_text?: string;
}
export class SetCharacterFacePackDto {
asset_ids?: string[];
consent_confirmed?: boolean | string;
set_first_as_anchor?: boolean | string;
notes?: string;
}
export class GenerateShotImageDto {
image_type?: string;
force?: boolean;
provider_code?: string;
}
export class GenerateEpisodeShotImagesDto {
image_type?: string;
only_missing?: boolean;
limit?: number;
provider_code?: string;
}
+28
View File
@@ -1,13 +1,19 @@
import type { Asset, CharacterImage, ShotImage } from '@prisma/client';
import { toSafeAsset, type SafeAsset } from '../assets/asset.types';
import { CHARACTER_TURNAROUND_PANEL_TYPES } from '../common/character-turnaround-panel-template';
export const CHARACTER_IMAGE_TYPES = [
'front_reference',
'side_reference',
'expression_pack',
'costume_default',
'costume_special',
'turnaround_reference',
...CHARACTER_TURNAROUND_PANEL_TYPES,
'prop_anchor',
'anchor',
'face_reference',
'scene_variant'
] as const;
@@ -16,6 +22,23 @@ export const SHOT_IMAGE_TYPES = ['preview', 'final'] as const;
export type CharacterImageType = (typeof CHARACTER_IMAGE_TYPES)[number];
export type ShotImageType = (typeof SHOT_IMAGE_TYPES)[number];
export interface SafeCharacterImageQualityReview {
id: string;
score: number | null;
grade: string | null;
threshold_score: number;
passed: boolean;
status: string;
strengths: string[];
issues: string[];
improvement_rules: string[];
quality_gate: unknown;
provider_code: string | null;
model_name: string | null;
error_message: string | null;
created_at: string;
}
export interface SafeCharacterImage {
id: string;
project_id: string;
@@ -25,10 +48,12 @@ export interface SafeCharacterImage {
prompt_text: string | null;
negative_prompt: string | null;
is_anchor: boolean;
prompt_quality_score: number | null;
quality_score: number | null;
status: string;
created_at: string;
asset?: SafeAsset | null;
visual_review?: SafeCharacterImageQualityReview | null;
}
export interface SafeShotImage {
@@ -58,6 +83,9 @@ export function toSafeCharacterImage(
prompt_text: image.prompt_text,
negative_prompt: image.negative_prompt,
is_anchor: image.is_anchor,
prompt_quality_score: image.prompt_quality_score
? Number(image.prompt_quality_score.toString())
: null,
quality_score: image.quality_score ? Number(image.quality_score.toString()) : null,
status: image.status,
created_at: image.created_at.toISOString(),
+48 -2
View File
@@ -12,16 +12,25 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import {
GenerateCharacterImagesDto,
GenerateCharacterTurnaroundMasterDto,
GenerateEpisodeShotImagesDto,
GenerateShotImageDto,
SetCharacterAnchorDto
ImportCharacterImageDto,
ReviewCharacterImageDto,
SetCharacterAnchorDto,
SetCharacterFacePackDto
} from './image.dto';
import { CharacterTurnaroundMasterService } from './character-turnaround-master.service';
import { ImagesService } from './images.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class ImagesController {
constructor(@Inject(ImagesService) private readonly imagesService: ImagesService) {}
constructor(
@Inject(ImagesService) private readonly imagesService: ImagesService,
@Inject(CharacterTurnaroundMasterService)
private readonly turnaroundMasterService: CharacterTurnaroundMasterService
) {}
@Post('characters/:characterId/generate-images')
generateCharacterImages(
@@ -32,6 +41,15 @@ export class ImagesController {
return this.imagesService.generateCharacterImages(user, characterId, dto);
}
@Post('characters/:characterId/turnaround-master/generate')
generateCharacterTurnaroundMaster(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: GenerateCharacterTurnaroundMasterDto
) {
return this.turnaroundMasterService.generate(user, characterId, dto);
}
@Get('characters/:characterId/images')
listCharacterImages(
@CurrentUser() user: AuthRequestUser,
@@ -40,6 +58,16 @@ export class ImagesController {
return this.imagesService.listCharacterImages(user, characterId);
}
@Post('characters/:characterId/images/:imageId/review-visual')
reviewCharacterImage(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Param('imageId') imageId: string,
@Body() dto: ReviewCharacterImageDto
) {
return this.imagesService.reviewCharacterImage(user, characterId, imageId, dto);
}
@Post('characters/:characterId/set-anchor')
setCharacterAnchor(
@CurrentUser() user: AuthRequestUser,
@@ -49,6 +77,24 @@ export class ImagesController {
return this.imagesService.setCharacterAnchor(user, characterId, dto);
}
@Post('characters/:characterId/import-image')
importCharacterImage(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: ImportCharacterImageDto
) {
return this.imagesService.importCharacterImage(user, characterId, dto);
}
@Post('characters/:characterId/face-pack')
setCharacterFacePack(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: SetCharacterFacePackDto
) {
return this.imagesService.setCharacterFacePack(user, characterId, dto);
}
@Post('storyboard-shots/:shotId/images/generate')
generateShotImage(
@CurrentUser() user: AuthRequestUser,
+2 -1
View File
@@ -4,12 +4,13 @@ import { AssetsModule } from '../assets/assets.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { ImagesController } from './images.controller';
import { CharacterTurnaroundMasterService } from './character-turnaround-master.service';
import { ImagesService } from './images.service';
@Module({
imports: [AuthModule, AssetsModule, PrismaModule, ProvidersModule],
controllers: [ImagesController],
providers: [ImagesService],
providers: [ImagesService, CharacterTurnaroundMasterService],
exports: [ImagesService]
})
export class ImagesModule {}
+613 -6
View File
@@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
Asset,
ActorProfile,
Character,
CharacterImage,
Episode,
@@ -15,6 +16,7 @@ import type { AuthRequestUser } from '../auth/auth.types';
import type { StorageService } from '../assets/storage.service';
import type { PrismaService } from '../prisma/prisma.service';
import type { ProvidersService } from '../providers/providers.service';
import { CHARACTER_TURNAROUND_TEMPLATE_VERSION } from '../common/character-turnaround-template';
import { ImagesService } from './images.service';
const user: AuthRequestUser = {
@@ -177,6 +179,7 @@ function createCharacterImage(overrides: Partial<CharacterImage> = {}): Characte
prompt_text: 'prompt',
negative_prompt: 'negative',
is_anchor: false,
prompt_quality_score: new Prisma.Decimal(98),
quality_score: new Prisma.Decimal(92),
status: 'generated',
created_at: now,
@@ -184,6 +187,25 @@ function createCharacterImage(overrides: Partial<CharacterImage> = {}): Characte
};
}
function createActorProfile(overrides: Partial<ActorProfile> = {}): ActorProfile {
return {
id: 90n,
project_id: 10n,
character_id: 20n,
actor_desc: '眼神坚定,气质冷静',
appearance_rules: '精致鹅蛋脸;深色中长发',
wardrobe_rules: '现代都市通勤装',
performance_style: null,
voice_style: null,
reference_asset_ids: ['50'],
anchor_asset_id: 50n,
status: 'locked',
created_at: now,
updated_at: now,
...overrides
};
}
function createShotImage(overrides: Partial<ShotImage> = {}): ShotImage {
return {
id: 70n,
@@ -238,11 +260,17 @@ describe('ImagesService', () => {
beforeEach(() => {
tx = {
characterImage: {
findFirst: vi.fn().mockResolvedValue(null),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
update: vi.fn().mockResolvedValue(createCharacterImage({ is_anchor: true, status: 'selected' }))
update: vi.fn().mockResolvedValue(createCharacterImage({ is_anchor: true, status: 'selected' })),
create: vi.fn().mockResolvedValue(createCharacterImage({ image_type: 'face_reference' }))
},
character: {
update: vi.fn().mockResolvedValue(createCharacter({ anchor_asset_id: 50n }))
},
actorProfile: {
findUnique: vi.fn().mockResolvedValue(createActorProfile()),
upsert: vi.fn().mockResolvedValue(createActorProfile())
}
};
prisma = {
@@ -258,7 +286,33 @@ describe('ImagesService', () => {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createCharacterImage()),
findMany: vi.fn().mockResolvedValue([createCharacterImage()]),
create: vi.fn().mockResolvedValue(createCharacterImage())
create: vi.fn().mockResolvedValue(createCharacterImage()),
update: vi.fn().mockResolvedValue(createCharacterImage())
},
characterImageQualityReview: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn()
},
characterPromptVersion: {
findFirst: vi.fn().mockResolvedValue({
id: 101n,
prompt_text: 'FACE_IDENTITY_LOCK professional character reference image,自然美颜但不能换脸',
negative_prompt: 'identity drift, excessive beauty filter',
source_type: 'external_web',
source_label: 'test',
layer_code: 'main_anchor',
channel: 'chatgpt_web',
quality_score: new Prisma.Decimal(98)
})
},
characterPromptOptimizationLesson: {
findMany: vi.fn().mockResolvedValue([])
},
characterPromptReview: {
create: vi.fn().mockResolvedValue({ id: 102n })
},
actorProfile: {
findUnique: vi.fn().mockResolvedValue(null)
},
storyboardShot: {
findUnique: vi.fn().mockResolvedValue(createShot()),
@@ -278,12 +332,15 @@ describe('ImagesService', () => {
update: vi.fn().mockResolvedValue(createTask({ status: 'success', output_asset_id: 50n }))
},
asset: {
create: vi.fn().mockResolvedValue(createAsset()),
findUnique: vi.fn().mockResolvedValue(createAsset())
create: vi.fn().mockImplementation(({ data }: any) => createAsset({ status: data.status })),
findUnique: vi.fn().mockResolvedValue(createAsset()),
findFirst: vi.fn().mockResolvedValue(createAsset())
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
storage = {
readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('face-reference-image')),
createTemporaryPublicUrl: vi.fn().mockReturnValue('https://example.com/temporary-image.png'),
storePrivateFile: vi.fn().mockResolvedValue({
file_path: 'local://generated-images/mock.svg',
size: 1024n,
@@ -313,6 +370,266 @@ describe('ImagesService', () => {
);
});
it('uses provider-compatible flagship landscape specs for character turnarounds', () => {
const renderSpec = (service as any).characterImageRenderSpec.bind(service);
const isFlagshipPrompt = (service as any).isFlagshipCharacterTurnaroundPrompt.bind(service);
expect(renderSpec('turnaround_reference', 'openai-image')).toEqual({
width: 2560,
height: 1440,
size: '2560x1440',
aspectRatio: '16:9',
quality: 'high'
});
expect(renderSpec('turnaround_reference', undefined)).toEqual({
width: 2560,
height: 1440,
size: '2560x1440',
aspectRatio: '16:9',
quality: 'high'
});
expect(renderSpec('turnaround_reference', 'openai-image', '3840x2160')).toEqual({
width: 3840,
height: 2160,
size: '3840x2160',
aspectRatio: '16:9',
quality: 'high'
});
expect(renderSpec('turnaround_reference', 'volcengine-seedream-50-image')).toEqual({
width: 2560,
height: 1440,
size: '2560x1440',
aspectRatio: '16:9',
quality: 'high'
});
expect(isFlagshipPrompt(
'横版纯白背景,左侧脸部特写,右侧严格正面、90度侧面、背面三个全身视图。'
)).toBe(true);
expect(isFlagshipPrompt(
'横版纯白背景,左侧脸部特写;第一行四个全身视图,第二行面部三视图,包含正面、侧面、背面。'
)).toBe(false);
expect(isFlagshipPrompt('白底普通人物三视图。')).toBe(false);
const directPrompt = (service as any).buildCharacterTurnaroundPrompt(
createProject({ output_mode: 'live_action_ai' }),
createCharacter(),
'turnaround_reference',
0,
0
);
expect(directPrompt).toContain('本次不使用参考图');
expect(directPrompt).toContain('DIRECT_DESIGN_MODE');
expect(directPrompt).toContain('左侧约38%');
expect(directPrompt).toContain('严格90度右向侧面全身');
expect(directPrompt).toContain('服装拓扑');
expect(directPrompt).toContain('默认中性站姿、双手空置');
expect(directPrompt).toContain('角色连续性定妆摄影');
expect(directPrompt).toContain('禁止现代运动鞋');
expect(directPrompt).not.toContain('第一行:四个全身');
expect(directPrompt).not.toContain('基于已上传并确认的主锚点图严格生成');
const overridePrompt = (service as any).buildCharacterTurnaroundPrompt(
createProject({ output_mode: 'live_action_ai' }),
createCharacter(),
'turnaround_reference',
0,
0,
'62岁女性边关统帅,银灰短发,左眉旧伤,深红鳞甲与黑色战靴。'
);
expect(overridePrompt).toContain('DESCRIPTION_OVERRIDE_MODE');
expect(overridePrompt).toContain('本次唯一角色描述:62岁女性边关统帅');
expect(overridePrompt).not.toContain('精致鹅蛋脸');
expect(overridePrompt).not.toContain('现代都市通勤装');
const reviewPrompt = (service as any).buildCharacterImagePromptReviewPrompt({
character: createCharacter(),
imageType: 'turnaround_reference',
draftPrompt: overridePrompt,
currentPrompt: overridePrompt,
negativePrompt: '无文字,无水印',
referenceImageCount: 0,
round: 1,
lessons: [],
characterDescriptionOverride: '62岁女性边关统帅,银灰短发,左眉旧伤,深红鳞甲与黑色战靴。'
});
expect(reviewPrompt).toContain('DESCRIPTION_OVERRIDE_MODE');
expect(reviewPrompt).toContain('本次唯一角色描述=62岁女性边关统帅');
expect(reviewPrompt).not.toContain('face=精致鹅蛋脸');
expect(reviewPrompt).not.toContain('costume=现代都市通勤装');
expect(reviewPrompt).not.toContain('单独负面约束:');
expect(reviewPrompt).not.toContain('无文字,无水印');
const referenceReviewPrompt = (service as any).buildCharacterImagePromptReviewPrompt({
character: createCharacter(),
imageType: 'turnaround_reference',
draftPrompt: directPrompt,
currentPrompt: directPrompt,
negativePrompt: 'unique-negative-sentinel',
referenceImageCount: 1,
round: 1,
lessons: [],
characterDescriptionOverride: null
});
expect(referenceReviewPrompt).toContain('只采用 REFERENCE_LOCK_MODE');
expect(referenceReviewPrompt).not.toContain('unique-negative-sentinel');
});
it('treats critical visual defects as S+ hard failures', () => {
const hardFailures = (service as any).characterImageVisualHardFailures({
hard_failures: ['严格侧面缺失'],
issues: [
{
category: '身份一致性',
severity: 'critical',
region: '背面视图',
evidence: '发型和体型变成另一人',
fix: '锁定同一角色身份'
},
{
category: '材质',
severity: 'minor',
evidence: '布料略软'
},
{
category: '鞋履时代适配',
severity: 'major',
evidence: '侧面出现现代厚底靴轮廓'
}
]
});
expect(hardFailures).toContain('严格侧面缺失');
expect(hardFailures.some((item: string) => item.includes('背面视图'))).toBe(true);
expect(hardFailures.some((item: string) => item.includes('鞋履时代错误'))).toBe(true);
expect(hardFailures.some((item: string) => item.includes('布料略软'))).toBe(false);
});
it('keeps turnaround microdetail rules free from the old no-pore wrapper', () => {
const ensureQuality = (service as any).ensureCharacterTurnaroundQualityPrompt.bind(service);
const prompt = ensureQuality('公共三视图角色母版');
expect(prompt).toContain('当前模型与 API 参数允许的最高原生质量');
expect(prompt).toContain('适合后续4K放大与视频锁定');
expect(prompt).toContain('皮肤或表面');
expect(prompt).not.toContain('非真实毛孔结构');
});
it('normalizes legacy 8K marketing claims into executable quality language', () => {
const normalize = (service as any).normalizeSavedPromptForImageGeneration.bind(service);
const normalized = normalize([
'8K超高清画质 / 4K可用细节的角色资产。',
'同一8K CG数字角色保持一致。',
'8K / 4K-detail semi-realistic CGI character.'
].join('\n'));
expect(normalized).toContain('当前模型最高原生质量与后续4K放大准备');
expect(normalized).toContain('最高原生质量的原创虚构CG数字角色');
expect(normalized).toContain('highest-native-quality, 4K-upscaling-ready');
expect(normalized).not.toContain('8K');
});
it('rejects a high-scoring legacy turnaround prompt and only trusts the current clean template', () => {
const shouldTrust = (service as any).shouldTrustActiveCharacterImagePrompt.bind(service);
const base = {
version_id: '103',
negativePrompt: 'identity drift, modern shoes',
source_type: 'external_web',
source_label: 'turnaround',
layer_code: 'turnaround_reference',
channel: 'chatgpt_web',
quality_score: 98
};
const layout = 'DIRECT_DESIGN_MODE,横版16:9纯白背景,左侧面部特写,右侧依次为正面全身、严格90度侧面全身、背面全身。';
expect(shouldTrust({ ...base, prompt: layout }, 'turnaround_reference')).toBe(false);
expect(shouldTrust({
...base,
prompt: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\n${layout}`
}, 'turnaround_reference')).toBe(true);
expect(shouldTrust({
...base,
prompt: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\n${layout}\n不要真人照片级清晰正脸`
}, 'turnaround_reference')).toBe(false);
});
it('filters project-specific, portrait-suppressing and inapplicable turnaround lessons', () => {
const isReusable = (service as any).isReusableCharacterPromptLesson.bind(service);
const human = createCharacter({
name: '测试军师',
age_group: '约48岁,中年',
identity_desc: '三国时期古代军师',
costume_rules: '白色交领长袍、浅青内层、深色布靴'
});
const nonHuman = createCharacter({
name: '九幽鬼将',
gender_label: '非人形男性战将意象',
age_group: '古老亡灵',
identity_desc: '三头六臂的亡灵鬼将',
face_desc: '中首为主身份头部',
body_desc: '三头六臂,关节结构清楚',
costume_rules: '腐朽古代重甲'
});
expect(isReusable(
'正面、严格90度侧面、背面必须同尺度、同基线、同焦距。',
'turnaround_anchor',
human,
'global'
)).toBe(true);
expect(isReusable(
'测试军师的羽扇必须在全部视图保持同一只手持握。',
'turnaround_anchor',
human,
'global'
)).toBe(false);
expect(isReusable(
'9:16竖屏,不要真人照片级清晰正脸。',
'turnaround_anchor',
human,
'global'
)).toBe(false);
expect(isReusable(
'中老年角色必须保留法令纹、眼袋与胡须根部。',
'turnaround_anchor',
nonHuman,
'global'
)).toBe(false);
expect(isReusable(
'多头角色必须锁定头部与肢体数量,禁止随机增减肢体。',
'turnaround_anchor',
nonHuman,
'global'
)).toBe(true);
const filterNegative = (service as any).filterCharacterTurnaroundNegativeRules.bind(service);
const filtered = filterNegative(
'不要换脸,复制诸葛亮或司马懿面孔,9:16竖屏,不要真人照片级清晰正脸'
);
expect(filtered).toContain('复制其他角色的面孔、脸型或五官');
expect(filtered).not.toContain('诸葛亮');
expect(filtered).not.toContain('司马懿');
expect(filtered).not.toContain('9:16');
expect(filtered).not.toContain('不要真人照片级清晰正脸');
});
it('reuses a trusted 98-point prompt even after visual lessons are recorded', async () => {
prisma.characterPromptOptimizationLesson.findMany.mockResolvedValue([
{ rule_text: '全身小脸必须继承主特写年龄纹理。' }
]);
await service.generateCharacterImages(user, '20', {
image_types: ['front_reference'],
set_first_as_anchor: false,
enable_visual_quality_review: false
});
expect(providers.executeProvider).toHaveBeenCalledTimes(1);
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
provider_type: 'ImageProvider'
}));
});
it('generates locked character reference images through ImageProvider', async () => {
const result = await service.generateCharacterImages(user, '20', {
image_types: ['front_reference'],
@@ -325,8 +642,9 @@ describe('ImagesService', () => {
task_id: '80',
allow_fallback: false,
input_json: expect.objectContaining({
width: 1080,
height: 1920
width: 1920,
height: 1080,
aspect_ratio: '16:9'
})
})
);
@@ -350,7 +668,213 @@ describe('ImagesService', () => {
expect(result.next_step).toBe('shot_image_generate');
});
it('does not promote a turnaround sheet to the character main anchor', async () => {
prisma.characterPromptVersion.findFirst.mockResolvedValueOnce({
id: 103n,
prompt_text: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\nDIRECT_DESIGN_MODE,横版16:9纯白背景,左侧面部特写,右侧依次为正面全身、严格90度侧面全身、背面全身。`,
negative_prompt: 'identity drift, modern shoes',
source_type: 'external_web',
source_label: 'turnaround-v4',
layer_code: 'turnaround_reference',
channel: 'chatgpt_web',
quality_score: new Prisma.Decimal(98)
}).mockResolvedValueOnce(null);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
const result = await service.generateCharacterImages(user, '20', {
image_types: ['turnaround_reference'],
force: true,
use_reference_images: true,
enable_visual_quality_review: false
});
expect(result.anchor).toBeNull();
expect(tx.character.update).not.toHaveBeenCalled();
});
it('passes an explicit 4K turnaround size to GPT Image 2', async () => {
prisma.characterPromptVersion.findFirst.mockResolvedValueOnce({
id: 103n,
prompt_text: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\nDIRECT_DESIGN_MODE,横版16:9纯白背景,左侧面部特写,右侧依次为正面全身、严格90度侧面全身、背面全身。`,
negative_prompt: 'identity drift, modern shoes',
source_type: 'external_web',
source_label: 'turnaround-v4',
layer_code: 'turnaround_reference',
channel: 'chatgpt_web',
quality_score: new Prisma.Decimal(98)
}).mockResolvedValueOnce(null);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
await service.generateCharacterImages(user, '20', {
image_types: ['turnaround_reference'],
force: true,
set_first_as_anchor: false,
use_reference_images: true,
enable_visual_quality_review: false,
provider_code: 'openai-image',
output_size: '3840x2160'
});
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
preferred_provider_code: 'openai-image',
input_json: expect.objectContaining({
width: 3840,
height: 2160,
size: '3840x2160',
aspect_ratio: '16:9',
quality: 'high',
output_format: 'png'
})
}));
});
it('uses explicit prior candidates first when refining a character turnaround', async () => {
const cropSpy = vi.spyOn(service as any, 'cropTurnaroundFacePanel').mockResolvedValue(
Buffer.from('cropped-face-panel')
);
vi.spyOn(service as any, 'reviewAndOptimizeCharacterImagePrompt').mockImplementation(
async (input: any) => ({
status: 'approved',
review_id: '104',
prompt_version_id: null,
draft_prompt: input.draftPrompt,
approved_prompt: input.draftPrompt,
score: 98,
threshold: 98,
passed: true,
issues: [],
suggestions: [],
reusable_rules: [],
quality_gate: null,
provider_log_id: null,
provider_code: 'openai-responses-text',
model_name: 'gpt-5',
requested_provider_code: null,
raw_text: null,
error_message: null
})
);
prisma.asset.findUnique.mockImplementation(async ({ where }: { where: { id: bigint } }) =>
createAsset({
id: where.id,
file_path: `local://turnaround/${where.id.toString()}.jpg`,
mime_type: 'image/jpeg',
width: 2560,
height: 1440
})
);
prisma.characterImage.findMany.mockResolvedValueOnce([]);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
await service.generateCharacterImages(user, '20', {
image_types: ['turnaround_reference'],
force: true,
set_first_as_anchor: false,
use_reference_images: true,
reference_asset_ids: ['51'],
reference_crop_mode: 'turnaround_face_panel',
enable_visual_quality_review: false,
provider_code: 'volcengine-seedream-50-image'
});
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
input_json: expect.objectContaining({
prompt: expect.stringContaining('REFERENCE_LOCK_MODE'),
reference_images: [expect.stringContaining('data:image/png;base64,')]
})
}));
expect(cropSpy).toHaveBeenCalledOnce();
expect(prisma.renderTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
input_json: expect.objectContaining({
reference_mode: 'explicit_iteration_reference',
explicit_reference_asset_ids: ['51'],
reference_crop_mode: 'turnaround_face_panel',
face_reference_asset_ids: ['51']
})
})
});
});
it('passes uploaded face pack references when generating a live-action character anchor', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ output_mode: 'live_action_ai' }));
prisma.characterImage.findMany.mockResolvedValueOnce([
createCharacterImage({
id: 61n,
image_type: 'face_reference',
asset_id: 51n,
is_anchor: false
})
]);
prisma.actorProfile.findUnique.mockResolvedValueOnce(createActorProfile({
reference_asset_ids: ['52'],
anchor_asset_id: null
}));
prisma.asset.findUnique.mockImplementation(async ({ where }: { where: { id: bigint } }) =>
createAsset({
id: where.id,
file_path: `local://face-pack/${where.id.toString()}.jpg`,
mime_type: 'image/jpeg',
file_url: null
})
);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'anchor'
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'anchor'
}));
await service.generateCharacterImages(user, '20', {
image_types: ['anchor'],
count_per_type: 1,
force: true,
provider_code: 'volcengine-seedream-50-image'
});
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
preferred_provider_code: 'volcengine-seedream-50-image',
input_json: expect.objectContaining({
prompt: expect.stringContaining('FACE_IDENTITY_LOCK'),
reference_images: [
expect.stringContaining('data:image/jpeg;base64,'),
expect.stringContaining('data:image/jpeg;base64,')
]
})
}));
const request = providers.executeProvider.mock.calls[0][0];
expect(request.input_json.prompt).toContain('自然美颜');
expect(request.input_json.prompt).toContain('不能换脸');
expect(request.input_json.negative_prompt).toContain('excessive beauty filter');
expect(storage.createTemporaryPublicUrl).not.toHaveBeenCalled();
});
it('sets a character anchor image and updates the character anchor asset', async () => {
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'anchor'
}));
const result = await service.setCharacterAnchor(user, '20', {
character_image_id: '60'
});
@@ -373,6 +897,89 @@ describe('ImagesService', () => {
expect(result.anchor_asset_id).toBe('50');
});
it('rejects setting uploaded face references as character anchors', async () => {
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'face_reference'
}));
await expect(service.setCharacterAnchor(user, '20', {
character_image_id: '60'
})).rejects.toBeInstanceOf(BadRequestException);
expect(tx.character.update).not.toHaveBeenCalled();
});
it('attaches a consented face pack and syncs the actor profile references', async () => {
prisma.asset.findUnique
.mockResolvedValueOnce(createAsset({
id: 51n,
file_path: 'local://uploads/face.png',
file_url: '/assets/face.png',
mime_type: 'image/png'
}))
.mockResolvedValueOnce(createAsset({
id: 51n,
file_path: 'local://uploads/face.png',
file_url: '/assets/face.png',
mime_type: 'image/png'
}));
tx.characterImage.create.mockResolvedValueOnce(createCharacterImage({
id: 61n,
asset_id: 51n,
image_type: 'face_reference',
is_anchor: false,
status: 'generated'
}));
tx.character.update.mockResolvedValueOnce(createCharacter({ anchor_asset_id: 51n }));
tx.actorProfile.findUnique.mockResolvedValueOnce(createActorProfile({
reference_asset_ids: ['50'],
anchor_asset_id: 50n
}));
tx.actorProfile.upsert.mockResolvedValueOnce(createActorProfile({
reference_asset_ids: ['50', '51'],
anchor_asset_id: 50n
}));
const result = await service.setCharacterFacePack(user, '20', {
asset_ids: ['51'],
consent_confirmed: true
});
expect(tx.characterImage.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
character_id: 20n,
asset_id: 51n,
image_type: 'face_reference',
is_anchor: false,
status: 'generated'
})
});
expect(tx.character.update).not.toHaveBeenCalled();
expect(tx.actorProfile.upsert).toHaveBeenCalledWith({
where: {
project_id_character_id: {
project_id: 10n,
character_id: 20n
}
},
update: expect.objectContaining({
reference_asset_ids: ['50', '51'],
anchor_asset_id: 50n,
status: 'locked'
}),
create: expect.objectContaining({
project_id: 10n,
character_id: 20n,
reference_asset_ids: ['50', '51'],
anchor_asset_id: 50n,
status: 'locked'
})
});
expect(result.face_reference_count).toBe(1);
expect(result.anchor_asset_id).toBeNull();
expect(result.next_step).toBe('generate_character_anchor');
});
it('generates a preview image for a confirmed storyboard shot', async () => {
const result = await service.generateShotImage(user, '40', {
image_type: 'preview'
File diff suppressed because it is too large Load Diff
@@ -277,6 +277,17 @@ async function runProviderAcceptance(input: {
force: input.config.forceRegenerate,
max_cost_per_clip: input.config.maxCostPerClip
});
if (!generated.video_clip) {
return finishRow(
baseRow,
'skipped',
generated.pending_task
? `Provider task is still running: ${generated.pending_task.id}`
: 'Provider task is still running'
);
}
baseRow.clip_id = generated.video_clip.id;
baseRow.output_asset_id = generated.video_clip.output_asset_id;
baseRow.cost_actual = generated.video_clip.cost_actual;
@@ -8,7 +8,9 @@ import {
LiveActionGenerateDto,
LiveActionManualReviewDto,
LiveActionPreflightQueryDto,
LiveActionQualityCheckDto
LiveActionQualityCheckDto,
LiveActionShotAssetPlanQueryDto,
LiveActionUpdateShotPromptDto
} from './live-action.dto';
import { LiveActionService } from './live-action.service';
@@ -36,6 +38,33 @@ export class LiveActionController {
return this.liveActionService.listLiveActionShots(user, episodeId);
}
@Get('episodes/:episodeId/live-action/shot-asset-plans')
planLiveActionShotAssets(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Query() query: LiveActionShotAssetPlanQueryDto
) {
return this.liveActionService.planLiveActionShotAssets(user, episodeId, query);
}
@Get('episodes/:episodeId/live-action/generation-plans')
listGenerationPlans(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string
) {
return this.liveActionService.listGenerationPlans(user, episodeId);
}
@Get('episodes/:episodeId/live-action/generation-plans/compare')
compareGenerationPlans(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Query('base_plan_id') basePlanId: string,
@Query('target_plan_id') targetPlanId: string
) {
return this.liveActionService.compareGenerationPlans(user, episodeId, basePlanId, targetPlanId);
}
@Post('episodes/:episodeId/live-action/shots/prepare')
prepareLiveActionShots(
@CurrentUser() user: AuthRequestUser,
@@ -92,6 +121,46 @@ export class LiveActionController {
return this.liveActionService.attachShotKeyframe(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/keyframe/generate')
generateShotKeyframe(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateShotKeyframe(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/keyframe/approve')
approveShotKeyframe(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.approveShotKeyframe(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/generation-plan/freeze')
freezeShotGenerationPlan(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.freezeShotGenerationPlan(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/prompt')
updateShotPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionUpdateShotPromptDto
) {
return this.liveActionService.updateShotPrompt(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/video-clip/generate')
generateShotVideoClip(
@CurrentUser() user: AuthRequestUser,
@@ -151,4 +220,13 @@ export class LiveActionController {
) {
return this.liveActionService.renderLiveActionEpisode(user, episodeId, dto);
}
@Post('episodes/:episodeId/live-action/music/generate')
generateOriginalMusicPackage(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateOriginalMusicPackage(user, episodeId, dto);
}
}
+35 -1
View File
@@ -2,32 +2,52 @@ export class LiveActionGenerateDto {
force?: boolean;
only_missing?: boolean;
provider_code?: string;
image_provider_code?: string;
image_quality?: 'low' | 'medium' | 'high' | string;
text_provider_code?: string;
render_title?: string;
resolution?: string;
aspect_ratio?: string;
confirm_real_video?: boolean;
allow_seedance_real_person_test?: boolean | string;
allow_character_reference_fallback?: boolean | string;
generate_audio?: boolean | string;
max_cost_per_clip?: number | string | null;
candidate_count?: number | string | null;
shot_id?: string;
include_source_audio?: boolean | string;
include_audio?: boolean;
include_subtitle?: boolean;
include_bgm?: boolean;
include_sfx?: boolean;
include_ambient_sfx?: boolean | string;
fallback_sfx_when_no_source_audio?: boolean | string;
include_lip_sync?: boolean;
audio_text_mode?: 'auto' | 'dialogue' | 'title';
lip_sync_max_seconds?: number | string | null;
voice?: string;
voice_provider_code?: string;
lip_sync_provider_code?: string;
subtitle_mode?: 'dialogue' | 'shot';
music_provider_code?: string;
subtitle_mode?: 'auto' | 'all' | 'dialogue' | 'shot' | 'title';
max_chars_per_line?: number | string | null;
bgm_asset_id?: string;
bgm_volume?: number | string | null;
sfx_volume?: number | string | null;
action_beat_mode?: boolean | string;
action_beat_count?: number | string | null;
reference_image_limit?: number | string | null;
soft_stitch?: boolean | string;
previous_shot_tail_reference?: boolean | string;
chain_previous_shot_tail?: boolean | string;
}
export class LiveActionQualityCheckDto {
auto_repair?: boolean;
min_quality_score?: number | string | null;
confirm_real_video?: boolean;
allow_seedance_real_person_test?: boolean | string;
allow_character_reference_fallback?: boolean | string;
max_cost_per_clip?: number | string | null;
}
@@ -35,9 +55,18 @@ export class LiveActionCostEstimateQueryDto {
provider_code?: string;
}
export class LiveActionShotAssetPlanQueryDto {
provider_code?: string;
}
export class LiveActionPreflightQueryDto {
provider_code?: string;
resolution?: string;
aspect_ratio?: string;
confirm_real_video?: boolean | string;
allow_seedance_real_person_test?: boolean | string;
allow_character_reference_fallback?: boolean | string;
generate_audio?: boolean | string;
max_cost_per_clip?: number | string | null;
shot_id?: string;
action_beat_mode?: boolean | string;
@@ -48,6 +77,11 @@ export class LiveActionAttachKeyframeDto {
asset_id?: string;
}
export class LiveActionUpdateShotPromptDto {
live_action_desc?: string;
video_prompt?: string;
}
export class LiveActionManualReviewDto {
result_status?: string;
reason?: string;
@@ -2,6 +2,8 @@ import { forwardRef, Module } from '@nestjs/common';
import { AiRouterModule } from '../ai-router/ai-router.module';
import { AuthModule } from '../auth/auth.module';
import { AssetsModule } from '../assets/assets.module';
import { GenerationPlanModule } from '../generation-plans/generation-plan.module';
import { ModelRegistryModule } from '../model-registry/model-registry.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { QueuesModule } from '../queues/queues.module';
@@ -10,7 +12,7 @@ import { LiveActionService } from './live-action.service';
import { LiveActionPromptBuilderService } from './prompt-builder.service';
@Module({
imports: [AiRouterModule, AuthModule, AssetsModule, PrismaModule, ProvidersModule, forwardRef(() => QueuesModule)],
imports: [AiRouterModule, AuthModule, AssetsModule, GenerationPlanModule, ModelRegistryModule, PrismaModule, ProvidersModule, forwardRef(() => QueuesModule)],
controllers: [LiveActionController],
providers: [LiveActionService, LiveActionPromptBuilderService],
exports: [LiveActionService, LiveActionPromptBuilderService]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+68 -1
View File
@@ -1,4 +1,4 @@
import type { ActorProfile, Prisma, StoryboardShot, VideoClip } from '@prisma/client';
import type { ActorProfile, Prisma, ProviderConfig, StoryboardShot, VideoClip } from '@prisma/client';
export interface SafeActorProfile {
id: string;
@@ -21,16 +21,22 @@ export interface SafeLiveActionShot {
project_id: string;
episode_id: string;
shot_no: number;
active_generation_plan_id: string | null;
scene_name: string | null;
live_action_desc: string | null;
actor_action: string | null;
camera_instruction: string | null;
performance_instruction: string | null;
transition_to_next: string | null;
transition_to_next_duration: string | null;
transition_to_next_reason: string | null;
scene_type: string | null;
importance_score: number | null;
emotion_score: number | null;
action_score: number | null;
route_tier: string | null;
generation_strategy_mode: 'text_only' | 'first_frame' | 'first_last_frame' | 'multi_reference' | null;
scene_geography_version_id: string | null;
video_prompt: string | null;
keyframe_asset_id: string | null;
video_clip_asset_id: string | null;
@@ -45,17 +51,28 @@ export interface SafeVideoClip {
project_id: string;
episode_id: string;
shot_id: string;
generation_plan_id: string | null;
provider_id: string | null;
provider_code: string | null;
provider_name: string | null;
model_name: string | null;
input_asset_id: string | null;
output_asset_id: string | null;
duration: string | null;
prompt_text: string | null;
status: string;
cost_estimate: number | null;
cost_currency: string | null;
cost_actual: number | null;
retry_count: number;
quality_status: string | null;
quality_score: number | null;
quality_issues: Prisma.JsonValue | null;
engine_version: string | null;
asset_lock_json: Prisma.JsonValue | null;
motion_control_json: Prisma.JsonValue | null;
camera_control_json: Prisma.JsonValue | null;
composer_usage_json: Prisma.JsonValue | null;
created_at: string;
updated_at: string;
}
@@ -79,21 +96,51 @@ export function toSafeActorProfile(profile: ActorProfile): SafeActorProfile {
}
export function toSafeLiveActionShot(shot: StoryboardShot): SafeLiveActionShot {
let generationStrategyMode: SafeLiveActionShot['generation_strategy_mode'] = null;
let sceneGeographyVersionId: string | null = null;
if (shot.prompt_text) {
try {
const parsed = JSON.parse(shot.prompt_text) as Record<string, unknown>;
const spec = parsed.shot_execution_spec && typeof parsed.shot_execution_spec === 'object'
? parsed.shot_execution_spec as Record<string, unknown>
: null;
const strategy = spec?.generation_strategy && typeof spec.generation_strategy === 'object'
? spec.generation_strategy as Record<string, unknown>
: null;
const mode = typeof strategy?.mode === 'string' ? strategy.mode : '';
if (['text_only', 'first_frame', 'first_last_frame', 'multi_reference'].includes(mode)) {
generationStrategyMode = mode as SafeLiveActionShot['generation_strategy_mode'];
}
sceneGeographyVersionId = typeof spec?.scene_geography_version_id === 'string'
? spec.scene_geography_version_id
: null;
} catch {
generationStrategyMode = null;
sceneGeographyVersionId = null;
}
}
return {
id: shot.id.toString(),
project_id: shot.project_id.toString(),
episode_id: shot.episode_id.toString(),
shot_no: shot.shot_no,
active_generation_plan_id: shot.active_generation_plan_id?.toString() ?? null,
scene_name: shot.scene_name,
live_action_desc: shot.live_action_desc,
actor_action: shot.actor_action,
camera_instruction: shot.camera_instruction,
performance_instruction: shot.performance_instruction,
transition_to_next: shot.transition_to_next,
transition_to_next_duration: shot.transition_to_next_duration?.toString() ?? null,
transition_to_next_reason: shot.transition_to_next_reason,
scene_type: shot.scene_type,
importance_score: shot.importance_score,
emotion_score: shot.emotion_score,
action_score: shot.action_score,
route_tier: shot.route_tier,
generation_strategy_mode: generationStrategyMode,
scene_geography_version_id: sceneGeographyVersionId,
video_prompt: shot.video_prompt,
keyframe_asset_id: shot.keyframe_asset_id?.toString() ?? null,
video_clip_asset_id: shot.video_clip_asset_id?.toString() ?? null,
@@ -105,22 +152,42 @@ export function toSafeLiveActionShot(shot: StoryboardShot): SafeLiveActionShot {
}
export function toSafeVideoClip(clip: VideoClip): SafeVideoClip {
return toSafeVideoClipWithMetadata(clip, null, null, null);
}
export function toSafeVideoClipWithMetadata(
clip: VideoClip,
provider?: Pick<ProviderConfig, 'provider_code' | 'display_name' | 'model_name'> | null,
costEstimate?: number | null,
costCurrency?: string | null
): SafeVideoClip {
return {
id: clip.id.toString(),
project_id: clip.project_id.toString(),
episode_id: clip.episode_id.toString(),
shot_id: clip.shot_id.toString(),
generation_plan_id: clip.generation_plan_id?.toString() ?? null,
provider_id: clip.provider_id?.toString() ?? null,
provider_code: provider?.provider_code ?? null,
provider_name: provider?.display_name ?? null,
model_name: provider?.model_name ?? null,
input_asset_id: clip.input_asset_id?.toString() ?? null,
output_asset_id: clip.output_asset_id?.toString() ?? null,
duration: clip.duration?.toString() ?? null,
prompt_text: clip.prompt_text,
status: clip.status,
cost_estimate: costEstimate ?? null,
cost_currency: costCurrency ?? null,
cost_actual: clip.cost_actual ? Number(clip.cost_actual.toString()) : null,
retry_count: clip.retry_count,
quality_status: clip.quality_status,
quality_score: clip.quality_score ? Number(clip.quality_score.toString()) : null,
quality_issues: clip.quality_issues,
engine_version: clip.engine_version,
asset_lock_json: clip.asset_lock_json,
motion_control_json: clip.motion_control_json,
camera_control_json: clip.camera_control_json,
composer_usage_json: clip.composer_usage_json,
created_at: clip.created_at.toISOString(),
updated_at: clip.updated_at.toISOString()
};
@@ -48,23 +48,57 @@ describe('LiveActionPromptBuilderService', () => {
});
expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('[推进]');
expect(result.prompt).toContain('导演分镜');
expect(result.prompt).toContain('剪辑目的');
expect(result.prompt).toContain('后期音效提示');
expect(result.prompt.length).toBeLessThanOrEqual(1800);
expect(result.prompt).toContain('精品短剧图生视频模板');
expect(result.prompt).toContain('核心动作');
expect(result.prompt).toContain('硬性禁止');
expect(result.prompt).not.toContain('导演分镜');
expect(result.prompt).not.toContain('剪辑目的');
expect(result.prompt.length).toBeLessThanOrEqual(1200);
expect(result.components.sound_cue).toContain('digital shimmer');
expect(result.components).toEqual(
expect.objectContaining({
prompt_version: 'live-action-prompt-engine-v1',
prompt_version: 'live-action-prompt-engine-v5-performance-chain',
provider_profile: 'hailuo',
scene_type: 'dimensional_break',
template_ids: expect.arrayContaining(['scene:dimensional_break:v2']),
rules_applied: expect.arrayContaining([
'course_rule:one_main_action_per_clip',
'provider_rule:hailuo_single_action_simple_camera'
]),
route_tier: 'premium',
camera_tag: '[推进]'
})
);
expect(result.prompt_version).toBe('live-action-prompt-engine-v5-performance-chain');
expect(result.template_ids).toContain('scene:dimensional_break:v2');
expect(result.rules_applied).toContain('motion_rule:split_or_simplify_complex_action');
expect(result.negative_prompt).toContain('anime style');
});
it('maps course urban short-drama scenes into premium scene templates and audit rules', () => {
const result = builder.buildLiveActionVideoPrompt({
providerCode: 'minimax_hailuo_23_fast',
sceneType: 'rich_arrival',
routeTier: 'premium',
durationSeconds: 6,
characters: '顾辰,黑色西装,保持同一张脸',
location: '雨夜酒店门口,一辆黑色迈巴赫停在门前',
action: '管家打开车门,顾辰冷静下车,所有人转头看向他',
scores: {
importance_score: 9,
emotion_score: 7,
action_score: 3,
route_tier: 'premium'
}
});
expect(result.components.scene_type).toBe('rich_arrival');
expect(result.components.template_ids).toContain('scene:rich_arrival:v2');
expect(result.components.rules_applied).toContain('urban_short_drama_rule:premium_asset_or_reveal_beat');
expect(result.prompt).toContain('迈巴赫');
expect(result.negative_prompt).toContain('cheap luxury prop');
});
it('keeps high-risk dialogue away from frontal mouth close-ups when lip-sync falls back to TTS subtitles', () => {
const result = builder.buildLiveActionVideoPrompt({
providerCode: 'kling_21',
@@ -85,8 +119,7 @@ describe('LiveActionPromptBuilderService', () => {
});
expect(result.provider_profile).toBe('kling');
expect(result.prompt).toContain('medium shot, three-quarter angle');
expect(result.prompt).toContain('post_tts_subtitle_light_mouth');
expect(result.prompt).toContain('不要正面嘴部大特写');
expect(result.negative_prompt).toContain('frontal mouth close-up');
});
@@ -109,12 +142,10 @@ describe('LiveActionPromptBuilderService', () => {
});
expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('动作导演');
expect(result.prompt).toContain('时间节奏');
expect(result.prompt).toContain('结印手法必须清楚');
expect(result.prompt).toContain('食指中指并拢');
expect(result.prompt).toContain('lotus seal');
expect(result.prompt).toContain('紫色光球');
expect(result.prompt).toContain('核心动作');
expect(result.prompt).toContain('硬性禁止');
expect(result.components.motion_director?.vfx_timing).toContain('紫色光球');
expect(result.negative_prompt).toContain('random hand waving');
expect(result.components.motion_director).toEqual(
expect.objectContaining({
@@ -138,10 +169,9 @@ describe('LiveActionPromptBuilderService', () => {
effectType: '法相天地 千臂法身 重低音轰鸣 碎石粉化'
});
expect(result.prompt).toContain('Motion director');
expect(result.prompt).toContain('One action only');
expect(result.prompt).toContain('千臂法身');
expect(result.prompt).toContain('巨手依次结出不同仙印');
expect(result.prompt).toContain('贴地低角度仰拍');
expect(result.prompt).toContain('Hard avoid');
expect(result.negative_prompt).toContain('tiny dharma body');
expect(result.components.motion_director?.time_beats.join(' ')).toContain('千只巨手');
});
@@ -165,15 +195,51 @@ describe('LiveActionPromptBuilderService', () => {
});
expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('10秒');
expect(result.prompt).toContain('一镜到底动作');
expect(result.prompt).toContain('0.0-2.0s');
expect(result.prompt).toContain('3.0-5.0s');
expect(result.prompt).toContain('8.0-10.0s');
expect(result.prompt).toContain('双手在胸前清晰结印');
expect(result.prompt).toContain('千臂法身完全展开');
expect(result.prompt.length).toBeLessThanOrEqual(2400);
expect(result.components.duration_seconds).toBe(10);
expect(result.prompt).toContain('核心动作');
expect(result.prompt).toContain('千臂法身升起');
expect(result.prompt.length).toBeLessThanOrEqual(1600);
expect(result.negative_prompt).toContain('multi-shot montage');
expect(result.negative_prompt).toContain('character identity drift');
});
it('keeps Seedance native-audio prompts scoped to the current shot without old template pollution', () => {
const result = builder.buildLiveActionVideoPrompt({
projectTitle: '草船借箭:永远差一箭',
episodeNo: 1,
episodeTitle: '系统绑定',
shotNo: 1,
providerCode: 'volcengine_seedance_20_mini',
sceneType: 'dialog',
routeTier: 'premium',
durationSeconds: 10,
characters: '周瑜、诸葛亮',
actorConsistencyRules: '诸葛亮保持羽扇纶巾、白袍、同一张脸;周瑜保持红黑将军甲、同一张脸',
location: '东吴军帐,木案、令箭、烛火、帐帘和古代军事地图',
action: '周瑜拍桌下军令状;诸葛亮听到系统绑定提示后看向画面中上方留白位置,眼神一亮,强行压住笑意,用羽扇半遮嘴角,最后淡定回应周瑜',
visualDescription: '轻喜剧三国短剧,系统弹窗和进度条全部后期合成,画面只留干净构图空区',
cameraMotion: '中景稳定推进到诸葛亮压笑反应',
performanceInstruction: '周瑜杀气腾腾,诸葛亮先愣一下再强行装淡定,眼神发亮但嘴角憋笑',
dialogueText: '周瑜:诸葛亮!三天十万支箭,少一支,军法处置! 系统电子提示:叮!拼夕夕借箭系统绑定成功! 诸葛亮:都督放心,三天太久,今晚足矣。',
effectType: '后期系统弹窗,轻喜剧提示音',
nativeAudioDialogue: true
});
expect(result.provider_profile).toBe('seedance');
expect(result.prompt).toContain('豆包 Seedance 2.0 Mini 图生视频');
expect(result.prompt).toContain('原生对白顺序');
expect(result.prompt).toContain('周瑜:');
expect(result.prompt).toContain('系统电子提示:');
expect(result.prompt).toContain('诸葛亮:');
expect(result.prompt).toContain('不要合并到同一个角色');
expect(result.prompt).toContain('中文普通话对白');
expect(result.prompt).toContain('干净构图空区');
expect(result.prompt).not.toContain('Seedance 2.0 Pro');
expect(result.prompt).not.toContain('直播灯');
expect(result.prompt).not.toContain('宴会现场空间感');
expect(result.prompt).not.toContain('宴会厅');
expect(result.prompt).not.toContain('手机震动');
expect(result.prompt).not.toContain('medium shot');
expect(result.prompt).not.toContain('over-the-shoulder');
});
});
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,7 @@
export class GenerateEpisodeAudioDto {
voice?: string;
narration_voice?: string;
voice_provider_code?: string;
dialogue_mode?: 'mixed' | 'narration';
max_segments?: number;
force?: boolean;
@@ -10,6 +11,7 @@ export class RetryEpisodeAudioSegmentDto {
voice?: string;
voice_style?: string;
speech_speed?: number | string;
voice_provider_code?: string;
}
export class GenerateEpisodeSubtitleDto {
+34 -10
View File
@@ -19,6 +19,7 @@ import type {
import { Prisma as PrismaNamespace } from '@prisma/client';
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -26,6 +27,7 @@ import { promisify } from 'node:util';
import type { AuthRequestUser } from '../auth/auth.types';
import { StorageService } from '../assets/storage.service';
import { toSafeAsset } from '../assets/asset.types';
import { PRODUCTION_VIDEO_HEIGHT, PRODUCTION_VIDEO_WIDTH } from '../common/production-format';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
import { ProvidersService } from '../providers/providers.service';
@@ -40,8 +42,15 @@ import { toSafeMediaTaskResult, type SrtCue } from './media.types';
const execFileAsync = promisify(execFile);
const DEFAULT_AUDIO_VOICE = 'coral';
const VIDEO_WIDTH = 1080;
const VIDEO_HEIGHT = 1920;
const VIDEO_WIDTH = PRODUCTION_VIDEO_WIDTH;
const VIDEO_HEIGHT = PRODUCTION_VIDEO_HEIGHT;
const SUBTITLE_FONT_DIR_CANDIDATES = [
process.env.SUBTITLE_FONT_DIR,
'/usr/share/fonts/google-noto-cjk',
'/usr/share/fonts/opentype/noto',
'/usr/share/fonts/truetype/noto',
'/usr/share/fonts'
].filter((value): value is string => Boolean(value));
interface AudioDialogueSegment {
index: number;
@@ -186,6 +195,7 @@ export class MediaService {
},
[
{
preferred_provider_code: dto.voice_provider_code?.trim() || undefined,
purpose: `episode-${episode.id.toString()}-tts`,
input_json: this.createAudioSegmentProviderInput(narrationSegment)
}
@@ -472,7 +482,8 @@ export class MediaService {
...targetSegment,
voice: dto.voice?.trim() || targetSegment.voice,
voice_style: dto.voice_style?.trim() || targetSegment.voice_style,
speech_speed: this.normalizeSpeechSpeed(dto.speech_speed, targetSegment.speech_speed)
speech_speed: this.normalizeSpeechSpeed(dto.speech_speed, targetSegment.speech_speed),
voice_provider_code: dto.voice_provider_code?.trim() || targetSegment.voice_provider_code
};
const task = await this.createRenderTask(
project.id,
@@ -733,7 +744,16 @@ export class MediaService {
const tasks = await this.prisma.renderTask.findMany({
where: {
episode_id: episode.id,
task_type: { in: ['audio_generate', 'subtitle_generate', 'video_render'] },
task_type: {
in: [
'audio_generate',
'subtitle_generate',
'video_render',
'live_action_audio_generate',
'live_action_subtitle_generate',
'live_action_video_render'
]
},
output_asset_id: { not: null }
},
orderBy: { created_at: 'desc' }
@@ -761,7 +781,7 @@ export class MediaService {
}
private async createMediaTimeline(task: RenderTask, asset: Asset) {
if (task.task_type === 'audio_generate') {
if (task.task_type === 'audio_generate' || task.task_type === 'live_action_audio_generate') {
const input = this.jsonObject(task.input_json);
return {
@@ -771,7 +791,7 @@ export class MediaService {
};
}
if (task.task_type === 'subtitle_generate') {
if (task.task_type === 'subtitle_generate' || task.task_type === 'live_action_subtitle_generate') {
return {
type: 'subtitle',
cues: await this.readSubtitleCuesFromAsset(asset)
@@ -784,7 +804,7 @@ export class MediaService {
private createMediaTaskStats(task: RenderTask, asset: Asset) {
const input = this.jsonObject(task.input_json);
if (task.task_type === 'audio_generate') {
if (task.task_type === 'audio_generate' || task.task_type === 'live_action_audio_generate') {
const segments = this.readTaskAudioSegments(input);
const totalCharacters =
this.numberFromUnknown(input.estimated_tts_characters) ||
@@ -811,7 +831,7 @@ export class MediaService {
};
}
if (task.task_type === 'subtitle_generate') {
if (task.task_type === 'subtitle_generate' || task.task_type === 'live_action_subtitle_generate') {
return {
subtitle_mode: this.stringifyText(input.subtitle_mode) || 'shot',
cue_count: this.numberFromUnknown(input.cue_count),
@@ -1343,7 +1363,7 @@ export class MediaService {
speaker_name: speakerName,
text: cleanedText.slice(0, 800),
voice,
voice_provider_code: character?.voice_provider_code ?? null,
voice_provider_code: (dto.voice_provider_code?.trim() || character?.voice_provider_code) ?? null,
voice_model: character?.voice_model ?? null,
voice_style: voiceStyle,
speech_speed: speechSpeed,
@@ -2510,10 +2530,14 @@ export class MediaService {
private subtitleFilter(subtitlePath: string) {
return [
`subtitles=${this.escapeFfmpegFilterPath(subtitlePath)}`,
'fontsdir=/usr/share/fonts/google-noto-cjk'
`fontsdir=${this.escapeFfmpegFilterPath(this.subtitleFontDir())}`
].join(':');
}
private subtitleFontDir() {
return SUBTITLE_FONT_DIR_CANDIDATES.find((dir) => existsSync(dir)) ?? '/usr/share/fonts';
}
private async writeAssSubtitleForFfmpeg(tempDir: string, subtitlePath: string) {
const content = await readFile(subtitlePath, 'utf8');
const cues = this.parseSrtContent(content);
+9 -1
View File
@@ -48,7 +48,7 @@ export class MemoriesController {
@Param('projectId') projectId: string,
@Body() dto: GeneratePlotMemoriesDto
) {
return this.memoriesService.generatePlotMemories(user, projectId, dto);
return this.memoriesService.submitPlotMemoryGeneration(user, projectId, dto);
}
@Post('projects/:projectId/plot-memories')
@@ -86,6 +86,14 @@ export class MemoriesController {
return this.memoriesService.listCharacterMemories(user, characterId);
}
@Get('projects/:projectId/character-memories')
listProjectCharacterMemories(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.memoriesService.listProjectCharacterMemories(user, projectId);
}
@Get('projects/:projectId/plot-threads')
listPlotThreads(
@CurrentUser() user: AuthRequestUser,
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { MemoriesController } from './memories.controller';
import { MemoriesService } from './memories.service';
@Module({
imports: [AuthModule, PrismaModule],
imports: [AuthModule, PrismaModule, ProvidersModule],
controllers: [MemoriesController],
providers: [MemoriesService],
exports: [MemoriesService]
File diff suppressed because it is too large Load Diff
+3
View File
@@ -8,6 +8,9 @@ import type {
export class GeneratePlotMemoriesDto {
episode_id?: string;
chapter_id?: string;
provider_code?: string;
replace_existing?: boolean;
min_quality_score?: number | string;
}
export class CreatePlotMemoryDto {
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Inject, Post, Query, UseGuards } from '@nestjs/common';
import type { AuthRequestUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ModelRegistryService } from './model-registry.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class ModelRegistryController {
constructor(@Inject(ModelRegistryService) private readonly registry: ModelRegistryService) {}
@Get('model-registry')
list(@CurrentUser() user: AuthRequestUser, @Query('provider_code') providerCode?: string) {
return this.registry.listVersions(user, providerCode);
}
@Post('admin/model-registry/sync')
sync(
@CurrentUser() user: AuthRequestUser,
@Body() body: { provider_code?: string }
) {
return this.registry.syncProviders(user, body?.provider_code);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ModelRegistryController } from './model-registry.controller';
import { ModelRegistryService } from './model-registry.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [ModelRegistryController],
providers: [ModelRegistryService],
exports: [ModelRegistryService]
})
export class ModelRegistryModule {}
@@ -0,0 +1,156 @@
import { type ProviderConfig } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PrismaService } from '../prisma/prisma.service';
import { ModelRegistryService } from './model-registry.service';
const now = new Date('2026-07-15T00:00:00.000Z');
function provider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
return {
id: 100n,
provider_type: 'VideoProvider',
provider_code: 'kling-v3-omni-test',
display_name: 'Kling Omni Test',
mode: 'real',
model_name: 'kling-v3-omni',
config_json: {
driver: 'kling_omni_video',
allowed_modes: ['std', 'pro', '4k'],
allowed_resolutions: ['720p', '1080p', '4k'],
allowed_durations: [3, 5, 10, 15],
max_prompt_length: 2500,
max_image_inputs: 7,
max_image_inputs_with_video: 4,
max_video_inputs: 1,
supports_audio: true,
supports_4k: true,
official_doc_url: 'https://kling.ai/document-api/api/video/3-0-omni/video-omni'
},
fallback_provider_id: null,
is_enabled: true,
priority: 100,
rate_limit_json: {},
cost_rule_json: { currency: 'CNY', unit: 'second', price_per_second: 0.25 },
created_at: now,
updated_at: now,
...overrides
};
}
function versionDelegate(jsonField: string) {
const rows: any[] = [];
return {
rows,
findFirst: vi.fn(async ({ where }: any) => rows.find((row) =>
row.provider_type === where.provider_type &&
row.provider_code === where.provider_code &&
row.content_hash === where.content_hash
) ?? null),
aggregate: vi.fn(async () => ({
_max: { revision: rows.length ? Math.max(...rows.map((row) => row.revision)) : null }
})),
create: vi.fn(async ({ data }: any) => {
const row = {
id: BigInt(rows.length + 1),
status: 'published',
effective_at: now,
created_at: now,
...data,
[jsonField]: data[jsonField]
};
rows.push(row);
return row;
}),
findMany: vi.fn(async () => rows)
};
}
describe('ModelRegistryService', () => {
let service: ModelRegistryService;
let capability: ReturnType<typeof versionDelegate>;
let schema: ReturnType<typeof versionDelegate>;
let pricing: ReturnType<typeof versionDelegate>;
beforeEach(() => {
capability = versionDelegate('capability_json');
schema = versionDelegate('schema_json');
pricing = versionDelegate('pricing_json');
const prisma = {
modelCapabilityVersion: capability,
modelParameterSchemaVersion: schema,
modelPricingVersion: pricing
};
service = new ModelRegistryService(prisma as unknown as PrismaService);
});
it('publishes immutable registry versions once and reuses identical content', async () => {
const first = await service.resolveOrPublish(provider(), 1n);
const second = await service.resolveOrPublish(provider(), 1n);
expect(first.capability.id).toBe(second.capability.id);
expect(first.parameterSchema.id).toBe(second.parameterSchema.id);
expect(first.pricing.id).toBe(second.pricing.id);
expect(capability.create).toHaveBeenCalledTimes(1);
expect(schema.create).toHaveBeenCalledTimes(1);
expect(pricing.create).toHaveBeenCalledTimes(1);
expect((first.parameterSchema.schema_json as any).properties.image_list.maxItems).toBe(7);
});
it('rejects Omni reference-video requests that keep native sound on', async () => {
const registry = await service.resolveOrPublish(provider(), 1n);
const result = service.validateRequest(registry.parameterSchema.schema_json, {
prompt: '镜头测试',
mode: 'pro',
duration: 5,
sound: 'on',
video_list: [{ video_url: 'https://example.com/reference.mp4' }],
image_list: []
});
expect(result.valid).toBe(false);
expect(result.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'VIDEO_REFERENCE_REQUIRES_SOUND_OFF' })
]));
});
it('accepts a compatible 4K image-reference request', async () => {
const registry = await service.resolveOrPublish(provider(), 1n);
const result = service.validateRequest(registry.parameterSchema.schema_json, {
prompt: '史诗人物特写',
mode: '4k',
resolution: '4k',
duration: 10,
sound: 'on',
image_list: [{ image_url: 'https://example.com/one.png' }]
});
expect(result).toEqual({ valid: true, issues: [] });
});
it('versions GPT Image 2 4K sizes and quality parameters', async () => {
const registry = await service.resolveOrPublish(provider({
provider_type: 'ImageProvider',
provider_code: 'openai-image',
model_name: 'gpt-image-2',
config_json: {
driver: 'openai_image_generation',
allowed_sizes: ['2560x1440', '3840x2160'],
allowed_qualities: ['low', 'medium', 'high', 'auto'],
supports_4k: true,
max_width: 3840,
max_height: 3840
}
}), 1n);
const result = service.validateRequest(registry.parameterSchema.schema_json, {
prompt: '电影级人物三视图',
size: '3840x2160',
quality: 'high'
});
expect(result).toEqual({ valid: true, issues: [] });
expect((registry.parameterSchema.schema_json as any).properties.size.enum).toEqual([
'2560x1440',
'3840x2160'
]);
});
});
@@ -0,0 +1,512 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
Prisma,
type ModelCapabilityVersion,
type ModelParameterSchemaVersion,
type ModelPricingVersion,
type ProviderConfig
} from '@prisma/client';
import { createHash } from 'node:crypto';
import type { AuthRequestUser } from '../auth/auth.types';
import { assertPermission } from '../auth/rbac';
import { PrismaService } from '../prisma/prisma.service';
import {
MODEL_CAPABILITY_SCHEMA_VERSION,
MODEL_PARAMETER_SCHEMA_VERSION,
MODEL_PRICING_SCHEMA_VERSION,
type ModelConflictRule,
type ModelParameterProperty,
type ModelParameterSchema,
type ModelParameterValidationIssue,
type ModelParameterValidationResult,
type ModelRegistryBundle,
jsonRecord,
toSafeCapabilityVersion,
toSafeParameterSchemaVersion,
toSafePricingVersion
} from './model-registry.types';
@Injectable()
export class ModelRegistryService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async resolveOrPublish(provider: ProviderConfig, createdByUserId?: bigint | null): Promise<ModelRegistryBundle> {
const [capability, parameterSchema, pricing] = await Promise.all([
this.publishCapability(provider, createdByUserId),
this.publishParameterSchema(provider, createdByUserId),
this.publishPricing(provider, createdByUserId)
]);
return { capability, parameterSchema, pricing };
}
async syncProviders(user: AuthRequestUser, providerCode?: string) {
assertPermission(user, 'providers:write');
const normalizedCode = this.normalizeProviderCode(providerCode);
const providers = await this.prisma.providerConfig.findMany({
where: normalizedCode ? { provider_code: normalizedCode } : {},
orderBy: [{ provider_type: 'asc' }, { priority: 'desc' }, { provider_code: 'asc' }]
});
if (normalizedCode && providers.length === 0) {
throw new NotFoundException('MODEL_REGISTRY_PROVIDER_NOT_FOUND');
}
const createdBy = this.toBigIntOrNull(user.id);
const bundles = [];
for (const provider of providers) {
const bundle = await this.resolveOrPublish(provider, createdBy);
bundles.push(this.safeBundle(bundle));
}
return {
provider_count: providers.length,
versions: bundles
};
}
async listVersions(user: AuthRequestUser, providerCode?: string) {
assertPermission(user, 'providers:read');
const normalizedCode = this.normalizeProviderCode(providerCode);
const where = normalizedCode ? { provider_code: normalizedCode } : {};
const [capabilities, parameterSchemas, pricing] = await Promise.all([
this.prisma.modelCapabilityVersion.findMany({ where, orderBy: [{ provider_code: 'asc' }, { revision: 'desc' }] }),
this.prisma.modelParameterSchemaVersion.findMany({ where, orderBy: [{ provider_code: 'asc' }, { revision: 'desc' }] }),
this.prisma.modelPricingVersion.findMany({ where, orderBy: [{ provider_code: 'asc' }, { revision: 'desc' }] })
]);
return {
capabilities: capabilities.map(toSafeCapabilityVersion),
parameter_schemas: parameterSchemas.map(toSafeParameterSchemaVersion),
pricing: pricing.map(toSafePricingVersion)
};
}
validateRequest(
schemaValue: Prisma.JsonValue | null | undefined,
request: Record<string, unknown>
): ModelParameterValidationResult {
const schema = jsonRecord(schemaValue);
const properties = jsonRecord(schema.properties as Prisma.JsonValue | undefined);
const required = Array.isArray(schema.required)
? schema.required.filter((item): item is string => typeof item === 'string')
: [];
const issues: ModelParameterValidationIssue[] = [];
for (const path of required) {
const value = this.valueAtPath(request, path);
if (value === undefined || value === null || value === '') {
issues.push({ path, code: 'REQUIRED', message: `${path} is required` });
}
}
for (const [path, rawProperty] of Object.entries(properties)) {
const value = this.valueAtPath(request, path);
if (value === undefined || value === null) continue;
this.validateProperty(path, value, jsonRecord(rawProperty as Prisma.JsonValue), issues);
}
const conflictRules = Array.isArray(schema.x_conflict_rules)
? schema.x_conflict_rules.filter((item): item is ModelConflictRule => Boolean(item && typeof item === 'object'))
: [];
for (const rule of conflictRules) {
this.validateConflictRule(rule, request, issues);
}
return { valid: issues.length === 0, issues };
}
assertRequestCompatible(
schemaValue: Prisma.JsonValue | null | undefined,
request: Record<string, unknown>
) {
const result = this.validateRequest(schemaValue, request);
if (!result.valid) {
throw new BadRequestException({
code: 'MODEL_PARAMETER_SCHEMA_VALIDATION_FAILED',
message: 'Generation request does not match the frozen model parameter schema',
issues: result.issues
});
}
return result;
}
safeBundle(bundle: ModelRegistryBundle) {
return {
capability: toSafeCapabilityVersion(bundle.capability),
parameter_schema: toSafeParameterSchemaVersion(bundle.parameterSchema),
pricing: toSafePricingVersion(bundle.pricing)
};
}
private async publishCapability(provider: ProviderConfig, createdByUserId?: bigint | null) {
const payload = this.buildCapability(provider);
const hash = this.hash(payload);
const existing = await this.prisma.modelCapabilityVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (existing) return existing;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const latest = await this.prisma.modelCapabilityVersion.aggregate({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code },
_max: { revision: true }
});
const revision = (latest._max.revision ?? 0) + 1;
return await this.prisma.modelCapabilityVersion.create({
data: {
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name,
revision,
version_key: this.versionKey(provider.provider_code, 'capability', revision, hash),
content_hash: hash,
capability_json: this.toJson(payload),
source_json: this.sourceSnapshot(provider),
created_by_user_id: createdByUserId ?? null
}
});
} catch (error) {
if (!this.isUniqueConflict(error) || attempt === 2) throw error;
const row = await this.prisma.modelCapabilityVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (row) return row;
}
}
throw new BadRequestException('MODEL_CAPABILITY_VERSION_PUBLISH_CONFLICT');
}
private async publishParameterSchema(provider: ProviderConfig, createdByUserId?: bigint | null) {
const payload = this.buildParameterSchema(provider);
const hash = this.hash(payload);
const existing = await this.prisma.modelParameterSchemaVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (existing) return existing;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const latest = await this.prisma.modelParameterSchemaVersion.aggregate({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code },
_max: { revision: true }
});
const revision = (latest._max.revision ?? 0) + 1;
return await this.prisma.modelParameterSchemaVersion.create({
data: {
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name,
revision,
version_key: this.versionKey(provider.provider_code, 'schema', revision, hash),
content_hash: hash,
schema_json: this.toJson(payload),
source_json: this.sourceSnapshot(provider),
created_by_user_id: createdByUserId ?? null
}
});
} catch (error) {
if (!this.isUniqueConflict(error) || attempt === 2) throw error;
const row = await this.prisma.modelParameterSchemaVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (row) return row;
}
}
throw new BadRequestException('MODEL_PARAMETER_SCHEMA_VERSION_PUBLISH_CONFLICT');
}
private async publishPricing(provider: ProviderConfig, createdByUserId?: bigint | null) {
const payload = this.buildPricing(provider);
const hash = this.hash(payload);
const existing = await this.prisma.modelPricingVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (existing) return existing;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const latest = await this.prisma.modelPricingVersion.aggregate({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code },
_max: { revision: true }
});
const revision = (latest._max.revision ?? 0) + 1;
return await this.prisma.modelPricingVersion.create({
data: {
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name,
revision,
version_key: this.versionKey(provider.provider_code, 'pricing', revision, hash),
content_hash: hash,
pricing_json: this.toJson(payload),
source_json: this.sourceSnapshot(provider),
created_by_user_id: createdByUserId ?? null
}
});
} catch (error) {
if (!this.isUniqueConflict(error) || attempt === 2) throw error;
const row = await this.prisma.modelPricingVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (row) return row;
}
}
throw new BadRequestException('MODEL_PRICING_VERSION_PUBLISH_CONFLICT');
}
private buildCapability(provider: ProviderConfig) {
const config = jsonRecord(provider.config_json);
const selected: Record<string, unknown> = {};
const exactKeys = new Set([
'driver', 'capability_version', 'official_doc_url', 'formal_splus_model', 'resolution',
'duration', 'aspect_ratio', 'mode', 'video_modes', 'reference_image_limit'
]);
const prefixes = ['supports_', 'max_', 'allowed_', 'input_', 'video_input_'];
for (const [key, value] of Object.entries(config)) {
if (exactKeys.has(key) || prefixes.some((prefix) => key.startsWith(prefix))) {
selected[key] = value;
}
}
return {
schema_version: MODEL_CAPABILITY_SCHEMA_VERSION,
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name ?? '',
capabilities: selected
};
}
private buildParameterSchema(provider: ProviderConfig): ModelParameterSchema {
const config = jsonRecord(provider.config_json);
const properties: Record<string, ModelParameterProperty> = {
prompt: { type: 'string', maxLength: this.positiveInt(config.max_prompt_length) ?? 12000 },
negative_prompt: { type: 'string', maxLength: this.positiveInt(config.max_prompt_length) ?? 12000 },
duration: { type: 'number', minimum: 1, maximum: 60 },
aspect_ratio: { type: 'string' },
size: { type: 'string' },
width: { type: 'number', minimum: 16 },
height: { type: 'number', minimum: 16 },
quality: { type: 'string' },
output_format: { type: 'string' },
mode: { type: 'string' },
resolution: { type: 'string' },
sound: { type: 'string', enum: ['on', 'off'] },
multi_shot: { type: 'boolean' },
image: { type: 'string' },
image_tail: { type: 'string' },
image_list: { type: 'array', items: { type: 'object' } },
video_list: { type: 'array', items: { type: 'object' } },
element_list: { type: 'array', items: { type: 'object' } },
voice_list: { type: 'array', items: { type: 'object' } },
multi_prompt: { type: 'array', items: { type: 'object' } }
};
this.applyEnum(properties.duration, config.allowed_durations);
this.applyEnum(properties.size, config.allowed_sizes);
this.applyEnum(properties.quality, config.allowed_qualities);
this.applyEnum(properties.output_format, config.allowed_output_formats);
this.applyEnum(properties.mode, config.allowed_modes);
this.applyEnum(properties.resolution, config.allowed_resolutions);
this.applyEnum(properties.aspect_ratio, config.allowed_aspect_ratios);
this.applyMaxItems(properties.image_list, config.max_image_inputs);
this.applyMaxItems(properties.video_list, config.max_video_inputs);
this.applyMaxItems(
properties.element_list,
config.max_elements ?? config.max_elements_with_start_end_frames
);
this.applyMaxItems(properties.voice_list, config.max_voices);
this.applyMaxItems(properties.multi_prompt, config.max_multi_shots);
const rules: ModelConflictRule[] = [];
if (this.positiveInt(config.max_video_inputs)) {
rules.push({
code: 'VIDEO_REFERENCE_REQUIRES_SOUND_OFF',
message: 'Reference video requests must disable native sound',
when: { field: 'video_list', operator: 'non_empty' },
require: { field: 'sound', operator: 'equals', value: 'off' }
});
}
const imageLimitWithVideo = this.positiveInt(config.max_image_inputs_with_video);
if (imageLimitWithVideo) {
rules.push({
code: 'VIDEO_REFERENCE_IMAGE_LIMIT',
message: `Reference video requests allow at most ${imageLimitWithVideo} images`,
when: { field: 'video_list', operator: 'non_empty' },
require: { field: 'image_list', operator: 'max_items', value: imageLimitWithVideo }
});
}
return {
schema_version: MODEL_PARAMETER_SCHEMA_VERSION,
type: 'object',
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name ?? '',
required: provider.provider_type === 'VideoProvider' ? ['prompt'] : [],
properties,
x_conflict_rules: rules
};
}
private buildPricing(provider: ProviderConfig) {
const rule = jsonRecord(provider.cost_rule_json);
return {
schema_version: MODEL_PRICING_SCHEMA_VERSION,
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name ?? '',
currency: typeof rule.currency === 'string' ? rule.currency : 'UNSPECIFIED',
unit: typeof rule.unit === 'string' ? rule.unit : 'unspecified',
pricing_rule: rule
};
}
private validateProperty(
path: string,
value: unknown,
property: Record<string, unknown>,
issues: ModelParameterValidationIssue[]
) {
const expectedType = typeof property.type === 'string' ? property.type : '';
const actualType = Array.isArray(value) ? 'array' : typeof value;
if (expectedType && actualType !== expectedType) {
issues.push({ path, code: 'TYPE', message: `${path} must be ${expectedType}`, expected: expectedType, actual: actualType });
return;
}
const allowed = Array.isArray(property.enum) ? property.enum : null;
if (allowed && !allowed.some((item) => item === value)) {
issues.push({ path, code: 'ENUM', message: `${path} is not an allowed value`, expected: allowed, actual: value });
}
if (typeof value === 'number') {
const minimum = this.finiteNumber(property.minimum);
const maximum = this.finiteNumber(property.maximum);
if (minimum !== null && value < minimum) issues.push({ path, code: 'MINIMUM', message: `${path} is below minimum`, expected: minimum, actual: value });
if (maximum !== null && value > maximum) issues.push({ path, code: 'MAXIMUM', message: `${path} exceeds maximum`, expected: maximum, actual: value });
}
if (typeof value === 'string') {
const maxLength = this.positiveInt(property.maxLength);
if (maxLength && value.length > maxLength) issues.push({ path, code: 'MAX_LENGTH', message: `${path} is too long`, expected: maxLength, actual: value.length });
}
if (Array.isArray(value)) {
const maxItems = this.positiveInt(property.maxItems);
if (maxItems && value.length > maxItems) issues.push({ path, code: 'MAX_ITEMS', message: `${path} has too many items`, expected: maxItems, actual: value.length });
}
}
private validateConflictRule(
rule: ModelConflictRule,
request: Record<string, unknown>,
issues: ModelParameterValidationIssue[]
) {
if (!rule?.when || !rule?.require) return;
const whenValue = this.valueAtPath(request, rule.when.field);
if (!this.matchesOperator(whenValue, rule.when.operator, rule.when.value)) return;
const requiredValue = this.valueAtPath(request, rule.require.field);
let valid = true;
if (rule.require.operator === 'equals') valid = requiredValue === rule.require.value;
if (rule.require.operator === 'absent') valid = requiredValue === undefined || requiredValue === null;
if (rule.require.operator === 'max_items') {
const maximum = this.positiveInt(rule.require.value) ?? 0;
valid = !Array.isArray(requiredValue) || requiredValue.length <= maximum;
}
if (!valid) {
issues.push({
path: rule.require.field,
code: rule.code,
message: rule.message,
expected: rule.require.value,
actual: requiredValue
});
}
}
private matchesOperator(value: unknown, operator: ModelConflictRule['when']['operator'], expected: unknown) {
if (operator === 'equals') return value === expected;
if (operator === 'present') return value !== undefined && value !== null;
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== '';
}
private valueAtPath(value: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce<unknown>((current, key) => {
if (!current || Array.isArray(current) || typeof current !== 'object') return undefined;
return (current as Record<string, unknown>)[key];
}, value);
}
private applyEnum(property: ModelParameterProperty, value: unknown) {
if (!Array.isArray(value)) return;
const allowed = value.filter((item): item is string | number | boolean =>
typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean'
);
if (allowed.length) property.enum = allowed;
}
private applyMaxItems(property: ModelParameterProperty, value: unknown) {
const maximum = this.positiveInt(value);
if (maximum) property.maxItems = maximum;
}
private sourceSnapshot(provider: ProviderConfig): Prisma.InputJsonObject {
const config = jsonRecord(provider.config_json);
return {
provider_config_id: provider.id.toString(),
provider_updated_at: provider.updated_at.toISOString(),
source_capability_version: typeof config.capability_version === 'string' ? config.capability_version : ''
};
}
private versionKey(providerCode: string, kind: string, revision: number, hash: string) {
return `${providerCode}:${kind}:r${revision}:${hash.slice(0, 12)}`;
}
private hash(value: unknown) {
return createHash('sha256').update(this.stableStringify(value)).digest('hex');
}
private stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => this.stableStringify(item)).join(',')}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${this.stableStringify(item)}`).join(',')}}`;
}
private toJson(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
private isUniqueConflict(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
private positiveInt(value: unknown) {
const numberValue = this.finiteNumber(value);
return numberValue !== null && numberValue > 0 ? Math.floor(numberValue) : null;
}
private finiteNumber(value: unknown) {
const numberValue = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
return Number.isFinite(numberValue) ? numberValue : null;
}
private normalizeProviderCode(value?: string) {
const normalized = value?.trim();
if (!normalized) return undefined;
if (normalized.length > 100) throw new BadRequestException('INVALID_PROVIDER_CODE');
return normalized;
}
private toBigIntOrNull(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
}
@@ -0,0 +1,94 @@
import type {
ModelCapabilityVersion,
ModelParameterSchemaVersion,
ModelPricingVersion,
Prisma
} from '@prisma/client';
export const MODEL_CAPABILITY_SCHEMA_VERSION = 'model_capability_v1';
export const MODEL_PARAMETER_SCHEMA_VERSION = 'model_parameter_schema_v1';
export const MODEL_PRICING_SCHEMA_VERSION = 'model_pricing_v1';
export type ModelParameterProperty = {
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
enum?: Array<string | number | boolean>;
minimum?: number;
maximum?: number;
maxLength?: number;
maxItems?: number;
items?: { type: 'string' | 'number' | 'boolean' | 'object' };
};
export type ModelConflictRule = {
code: string;
message: string;
when: {
field: string;
operator: 'present' | 'non_empty' | 'equals';
value?: unknown;
};
require: {
field: string;
operator: 'equals' | 'absent' | 'max_items';
value?: unknown;
};
};
export type ModelParameterSchema = {
schema_version: string;
type: 'object';
provider_type: string;
provider_code: string;
model_name: string;
required: string[];
properties: Record<string, ModelParameterProperty>;
x_conflict_rules: ModelConflictRule[];
};
export type ModelRegistryBundle = {
capability: ModelCapabilityVersion;
parameterSchema: ModelParameterSchemaVersion;
pricing: ModelPricingVersion;
};
export type ModelParameterValidationIssue = {
path: string;
code: string;
message: string;
expected?: unknown;
actual?: unknown;
};
export type ModelParameterValidationResult = {
valid: boolean;
issues: ModelParameterValidationIssue[];
};
export function toSafeCapabilityVersion(row: ModelCapabilityVersion) {
return {
...row,
id: row.id.toString(),
created_by_user_id: row.created_by_user_id?.toString() ?? null
};
}
export function toSafeParameterSchemaVersion(row: ModelParameterSchemaVersion) {
return {
...row,
id: row.id.toString(),
created_by_user_id: row.created_by_user_id?.toString() ?? null
};
}
export function toSafePricingVersion(row: ModelPricingVersion) {
return {
...row,
id: row.id.toString(),
created_by_user_id: row.created_by_user_id?.toString() ?? null
};
}
export function jsonRecord(value: Prisma.JsonValue | null | undefined): Record<string, unknown> {
if (!value || Array.isArray(value) || typeof value !== 'object') return {};
return value as Record<string, unknown>;
}
+616
View File
@@ -0,0 +1,616 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { AgentPrompt, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { ProvidersService } from '../providers/providers.service';
import { PROVIDER_TYPES, type ProviderType } from '../providers/provider.types';
import {
type NovelAgentRunResult,
type RunNovelAgentInput,
toSafeAgentPrompt,
toSafeAgentRun
} from './novel-agent.types';
@Injectable()
export class NovelAgentService {
constructor(
@Inject(PrismaService)
private readonly prisma: PrismaService,
@Inject(ProvidersService)
private readonly providersService: ProvidersService
) {}
async listPrompts(agentName?: string) {
const prompts = await this.prisma.agentPrompt.findMany({
where: agentName ? { agent_name: agentName } : undefined,
orderBy: [
{ agent_name: 'asc' },
{ version: 'desc' }
]
});
return prompts.map(toSafeAgentPrompt);
}
async getActivePrompt(agentName: string, version?: number) {
const name = this.normalizeRequiredText(agentName, 'agent_name is required');
const prompt = version
? await this.prisma.agentPrompt.findFirst({
where: {
agent_name: name,
version,
is_active: true
}
})
: await this.prisma.agentPrompt.findFirst({
where: {
agent_name: name,
is_active: true
},
orderBy: { version: 'desc' }
});
if (!prompt) {
throw new NotFoundException(`Active agent prompt not found: ${name}`);
}
return prompt;
}
async runAgent(input: RunNovelAgentInput): Promise<NovelAgentRunResult> {
const agentName = this.normalizeRequiredText(input.agent_name, 'agent_name is required');
const prompt = await this.getActivePrompt(agentName, input.prompt_version);
const variables = this.readObject(input.variables);
const providerType = this.resolveProviderType(input.provider_type ?? prompt.provider_type);
const requestedProviderCode = this.normalizeOptionalText(input.provider_code) ??
this.normalizeOptionalText(prompt.default_provider_code) ??
this.defaultProviderCodeForAgent(agentName, providerType);
const renderedPrompt = this.enrichRenderedPrompt(
agentName,
this.renderPrompt(prompt.user_prompt_template, variables),
variables
);
const providerCode = await this.resolveCompatibleProviderCode(
providerType,
requestedProviderCode,
input.allow_fallback !== false
);
const projectId = this.parseOptionalId(input.project_id, 'Invalid project_id');
const novelSourceId = this.parseOptionalId(input.novel_source_id, 'Invalid novel_source_id');
const outputMode = input.output_mode ?? 'both';
const providerInput = this.createProviderInput(prompt, renderedPrompt, variables, input.input_json);
const startedAt = new Date();
const agentRun = await this.prisma.agentRun.create({
data: {
project_id: projectId,
novel_source_id: novelSourceId,
chapter_no: input.chapter_no ?? null,
agent_name: agentName,
prompt_version: prompt.version,
input_json: providerInput,
status: 'running',
started_at: startedAt
}
});
try {
const providerResult = await this.providersService.executeProvider({
provider_type: providerType,
preferred_provider_code: providerCode,
purpose: input.purpose ?? `novel_agent_${agentName}`,
project_id: projectId?.toString(),
input_json: providerInput,
allow_fallback: input.allow_fallback !== false,
return_binary: false
});
const resultObject = this.readObject(providerResult.result);
const outputText = this.extractProviderText(providerResult.result);
const parsedJson =
outputMode === 'text'
? null
: this.parseProviderJson(outputText) ?? resultObject;
const storedOutputJson = parsedJson ? this.toJsonValue(parsedJson) : undefined;
const qualityScore = this.extractQualityScore(parsedJson);
const safeProvider = this.readObject(providerResult.provider);
const safeProviderLog = this.readObject(providerResult.provider_log);
const updatedRun = await this.prisma.agentRun.update({
where: { id: agentRun.id },
data: {
provider_code: this.readString(safeProvider.provider_code) ??
this.readString(safeProviderLog.provider_code) ??
providerCode ??
null,
model_name: this.readString(safeProvider.model_name) ??
this.readString(safeProviderLog.model_name) ??
null,
provider_log_id: this.parseOptionalId(safeProviderLog.id, 'Invalid provider_log_id'),
output_json: storedOutputJson,
output_text: outputText || null,
status: 'success',
quality_score: qualityScore,
error_code: null,
error_message: null,
finished_at: new Date()
}
});
return {
run: toSafeAgentRun(updatedRun),
prompt: toSafeAgentPrompt(prompt),
output_text: outputText,
output_json: updatedRun.output_json,
provider: providerResult.provider,
provider_log: providerResult.provider_log
};
} catch (error) {
const normalizedError = this.toError(error);
const failedRun = await this.prisma.agentRun.update({
where: { id: agentRun.id },
data: {
status: 'failed',
error_code: this.errorCodeFromError(normalizedError),
error_message: normalizedError.message,
finished_at: new Date()
}
});
if (input.provider_code) {
throw new BadRequestException({
message: `选定模型执行 ${agentName} 失败:${normalizedError.message}`,
agent_run: toSafeAgentRun(failedRun)
});
}
throw normalizedError;
}
}
private async resolveCompatibleProviderCode(
providerType: ProviderType,
providerCode: string | undefined,
allowFallback: boolean
) {
if (!providerCode) {
return undefined;
}
const exactProvider = await this.prisma.providerConfig.findUnique({
where: {
provider_type_provider_code: {
provider_type: providerType,
provider_code: providerCode
}
}
});
if (exactProvider?.is_enabled) {
return providerCode;
}
const mappedProvider = await this.findCompatibleProvider(providerType, providerCode);
if (mappedProvider) {
return mappedProvider.provider_code;
}
return allowFallback ? undefined : providerCode;
}
private async findCompatibleProvider(providerType: ProviderType, providerCode: string) {
const candidateCodes = this.compatibleProviderCodeCandidates(providerType, providerCode);
for (const candidateCode of candidateCodes) {
const provider = await this.prisma.providerConfig.findUnique({
where: {
provider_type_provider_code: {
provider_type: providerType,
provider_code: candidateCode
}
}
});
if (provider?.is_enabled) {
return provider;
}
}
return this.findCompatibleProviderBySharedApiKeyEnv(providerType, providerCode);
}
private compatibleProviderCodeCandidates(providerType: ProviderType, providerCode: string) {
const candidates: string[] = [];
const push = (value: string | undefined) => {
if (value && value !== providerCode && !candidates.includes(value)) {
candidates.push(value);
}
};
if (providerType === 'NovelProvider') {
push(providerCode.replace(/-text$/, '-novel'));
push(providerCode.replace(/_text$/, '_novel'));
const specialMap: Record<string, string[]> = {
'volcengine-doubao-seed20-mini-text': [
'volcengine-doubao-seed20-lite-novel',
'volcengine-doubao-seed20-pro-novel',
'volcengine-doubao-novel'
],
'openai-gpt54-nano-text': [
'openai-gpt54-mini-novel',
'openai-gpt54-novel',
'openai-responses-novel'
],
'openai-gpt41-mini-text': [
'openai-gpt41-novel',
'openai-responses-novel'
]
};
for (const item of specialMap[providerCode] ?? []) {
push(item);
}
}
if (providerType === 'TextProvider') {
push(providerCode.replace(/-novel$/, '-text'));
push(providerCode.replace(/_novel$/, '_text'));
}
return candidates;
}
private async findCompatibleProviderBySharedApiKeyEnv(providerType: ProviderType, providerCode: string) {
const sourceProviders = await this.prisma.providerConfig.findMany({
where: { provider_code: providerCode }
});
const apiKeyEnvs = new Set(
sourceProviders
.map((provider) => this.readString(this.readObject(provider.config_json).api_key_env))
.filter((value): value is string => Boolean(value))
);
if (apiKeyEnvs.size === 0) {
return null;
}
const candidates = await this.prisma.providerConfig.findMany({
where: {
provider_type: providerType,
is_enabled: true
},
orderBy: [
{ priority: 'desc' },
{ id: 'asc' }
]
});
return candidates.find((provider) =>
apiKeyEnvs.has(this.readString(this.readObject(provider.config_json).api_key_env) ?? '')
) ?? null;
}
renderPrompt(template: string, variables: Record<string, unknown>) {
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, path: string) => {
const value = this.readPath(variables, path);
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value, null, 2);
});
}
extractProviderText(result: unknown) {
const object = this.readObject(result);
return (
this.readString(object.text) ??
this.readString(object.chapter_text) ??
this.readString(object.raw_text) ??
this.readString(object.content) ??
''
);
}
parseProviderJson(text: string): Record<string, unknown> | null {
const cleaned = text
.replace(/^```(?:json)?/i, '')
.replace(/```$/i, '')
.trim();
const jsonText = cleaned.startsWith('{')
? cleaned
: cleaned.match(/\{[\s\S]*\}/)?.[0] ?? '';
if (!jsonText) return null;
try {
const parsed = JSON.parse(jsonText) as unknown;
return this.readObject(parsed);
} catch {
return this.parsePartialProposalsJson(jsonText);
}
}
private parsePartialProposalsJson(text: string): Record<string, unknown> | null {
const marker = text.search(/"proposals"\s*:\s*\[/);
if (marker < 0) return null;
const arrayStart = text.indexOf('[', marker);
if (arrayStart < 0) return null;
const proposals = this.extractClosedJsonObjects(text.slice(arrayStart + 1));
return proposals.length > 0 ? { proposals } : null;
}
private extractClosedJsonObjects(text: string): Record<string, unknown>[] {
const objects: Record<string, unknown>[] = [];
let depth = 0;
let startIndex = -1;
let inString = false;
let escaped = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === '{') {
if (depth === 0) startIndex = index;
depth += 1;
continue;
}
if (char !== '}') {
continue;
}
depth -= 1;
if (depth !== 0 || startIndex < 0) {
continue;
}
try {
const parsed = JSON.parse(text.slice(startIndex, index + 1)) as unknown;
const object = this.readObject(parsed);
if (Object.keys(object).length > 0) {
objects.push(object);
}
} catch {
// Keep any earlier valid proposal cards even when a later one is truncated.
}
startIndex = -1;
}
return objects;
}
private createProviderInput(
prompt: AgentPrompt,
renderedPrompt: string,
variables: Record<string, unknown>,
extraInput?: Record<string, unknown>
): Prisma.InputJsonObject {
const temperature = prompt.temperature ? Number(prompt.temperature.toString()) : undefined;
const object = {
...(extraInput ?? {}),
prompt: renderedPrompt,
instructions: prompt.system_prompt,
agent_name: prompt.agent_name,
prompt_version: prompt.version,
variables,
output_schema_json: prompt.output_schema_json ?? undefined,
temperature,
max_output_tokens: prompt.max_output_tokens ?? undefined
};
return this.toJsonValue(object) as Prisma.InputJsonObject;
}
private defaultProviderCodeForAgent(agentName: string, providerType: ProviderType) {
if (providerType === 'TextProvider' && agentName === 'NovelIdeaCoachAgent') {
return 'volcengine-doubao-seed20-pro-text';
}
if (providerType !== 'NovelProvider') {
return undefined;
}
const strategy: Record<string, string> = {
IPBibleAgent: 'openai-responses-novel',
ChapterCardAgent: 'openai-responses-novel',
ContinuityCheckAgent: 'openai-responses-novel',
QualityCheckAgent: 'openai-responses-novel',
NovelWriterAgent: 'anthropic-claude-novel',
NovelPolishAgent: 'anthropic-claude-novel',
RepairAgent: 'anthropic-claude-novel',
MemoryUpdateAgent: 'volcengine-doubao-seed20-pro-novel'
};
return strategy[agentName];
}
private enrichRenderedPrompt(
agentName: string,
renderedPrompt: string,
variables: Record<string, unknown>
) {
const contextSnapshot = this.readString(variables.context_snapshot);
const canonicalFacts = this.readString(variables.canonical_facts);
if (!contextSnapshot || renderedPrompt.includes('上下文快照')) {
return renderedPrompt;
}
if (![
'ChapterCardAgent',
'NovelWriterAgent',
'NovelPolishAgent',
'ContinuityCheckAgent',
'QualityCheckAgent',
'RepairAgent',
'MemoryUpdateAgent'
].includes(agentName)) {
return renderedPrompt;
}
const parts = [
renderedPrompt,
this.agentRuntimeInstruction(agentName),
'【上下文快照,优先级高于最近章节摘要】',
contextSnapshot
].filter(Boolean);
if (canonicalFacts) {
parts.push('【硬性连续性事实,不得改名、改日期、改地点、改核心设定】', canonicalFacts);
}
return parts.join('\n\n');
}
private agentRuntimeInstruction(agentName: string) {
const instructions: Record<string, string> = {
ChapterCardAgent: [
'【章节卡要求】本章必须像真实网文章节一样有明确开场钩子、场景推进、人物选择、信息反转和章尾悬念。',
'scenes 不能只是概念,必须包含时间/地点/冲突/人物动作/本场释放的信息;ending_hook 必须能自然引出下一章。',
'必须提前保留听书/短剧改编字段,标明关键场景、关键对白、可视化道具和不宜改动的伏笔。'
].join('\n'),
NovelWriterAgent: [
'【正文质量要求】只写可直接发布的小说正文,不要写“场景1/音效/镜头提示/Markdown标题”。',
'开头 300 字内必须进入冲突或异常;每一场都要推动剧情/人物/伏笔,禁止解释性堆设定。',
'保持人物姓名、日期、公司/地点、核心规则与上下文快照一致;若最近摘要和硬设定冲突,优先硬设定。',
'章尾必须留下清晰追更钩子,但不能强行断句或写半句话。'
].join('\n'),
NovelPolishAgent: [
'【润色要求】只增强节奏、对白、画面感和可读性,不得改人物姓名、日期、地点、组织名、核心设定。',
'删除脚本化标记、音效括号和过度机械的校验语言,让正文更像成熟网文。'
].join('\n'),
ContinuityCheckAgent: [
'【连续性红线】只要出现人物姓名变化、日期变化、公司/地点名变化、核心规则冲突、章节中断、未完成句子,has_conflict 必须为 true。',
'conflicts/timeline_errors/setting_errors 必须写清楚“原设定是什么、正文哪里违背、怎么修”。'
].join('\n'),
QualityCheckAgent: [
'【评分硬规则】total_score 必须使用 0-100 分制,不允许 0-10 分制。',
'如果存在姓名/日期/公司/地点/核心设定冲突、章节中断、未完成、降智或违背 IP 圣经,pass 必须为 falserewrite_required 必须为 true。',
'评分不要客气,低于可发布水准必须给出 must_fix 和 rewrite_strategy。'
].join('\n'),
RepairAgent: [
'【修复要求】优先修复 must_fix 和连续性冲突。必须输出完整章节正文,不要解释修了什么。',
'修复后必须保持原章节目标、人物硬设定、章尾钩子,不得新增无关设定。'
].join('\n'),
MemoryUpdateAgent: [
'【记忆更新要求】只记录正文已经发生的事实。character_updates 必须包含本章出现或状态变化的人物。',
'new_foreshadows/updated_foreshadows/resolved_foreshadows 和 forbidden_to_forget 要尽量结构化,方便下一章读取。'
].join('\n')
};
return instructions[agentName] ?? '';
}
private resolveProviderType(value: string | undefined): ProviderType {
if (PROVIDER_TYPES.includes(value as ProviderType)) {
return value as ProviderType;
}
throw new BadRequestException(`Unsupported provider_type: ${value ?? ''}`);
}
private extractQualityScore(value: Record<string, unknown> | null) {
if (!value) return null;
const raw =
value.total_score ??
value.quality_score ??
this.readObject(value.quality).total_score ??
this.readObject(value.quality).novel_score;
const score = Number(raw);
return Number.isFinite(score) ? score : null;
}
private readPath(object: Record<string, unknown>, path: string) {
return path.split('.').reduce<unknown>((current, key) => {
if (!current || typeof current !== 'object') {
return undefined;
}
return (current as Record<string, unknown>)[key];
}, object);
}
private parseOptionalId(value: unknown, message: string) {
if (value === undefined || value === null || value === '') return null;
if (!['string', 'number', 'bigint'].includes(typeof value)) {
throw new BadRequestException(message);
}
try {
const id = typeof value === 'bigint' ? value : BigInt(String(value));
if (id <= 0n) throw new Error(message);
return id;
} catch {
throw new BadRequestException(message);
}
}
private normalizeRequiredText(value: unknown, message: string) {
const text = typeof value === 'string' ? value.trim() : '';
if (!text) {
throw new BadRequestException(message);
}
return text;
}
private normalizeOptionalText(value: unknown) {
const text = typeof value === 'string' ? value.trim() : '';
return text || undefined;
}
private readObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
private readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
private toJsonValue(value: unknown): Prisma.InputJsonValue {
return JSON.parse(
JSON.stringify(value, (_key, nestedValue) =>
typeof nestedValue === 'bigint' ? nestedValue.toString() : nestedValue
)
) as Prisma.InputJsonValue;
}
private toError(error: unknown) {
return error instanceof Error ? error : new Error(String(error));
}
private errorCodeFromError(error: Error) {
return error.message
.split(':')[0]
.replace(/[^A-Z0-9_]/gi, '_')
.toUpperCase()
.slice(0, 100);
}
}
+122
View File
@@ -0,0 +1,122 @@
import type { AgentPrompt, AgentRun, Prisma } from '@prisma/client';
import type { ProviderType } from '../providers/provider.types';
export const NOVEL_AGENT_NAMES = [
'NovelIdeaCoachAgent',
'IPBibleAgent',
'ChapterCardAgent',
'NovelWriterAgent',
'NovelPolishAgent',
'ContinuityCheckAgent',
'QualityCheckAgent',
'RepairAgent',
'MemoryUpdateAgent',
'AdaptationPackageAgent',
'AudioScriptAgent',
'DramaScriptAgent'
] as const;
export type NovelAgentName = (typeof NOVEL_AGENT_NAMES)[number] | string;
export type NovelAgentOutputMode = 'text' | 'json' | 'both';
export interface RunNovelAgentInput {
agent_name: NovelAgentName;
project_id?: string | bigint | null;
novel_source_id?: string | bigint | null;
chapter_no?: number | null;
prompt_version?: number;
provider_type?: ProviderType;
provider_code?: string;
purpose?: string;
variables?: Record<string, unknown>;
input_json?: Record<string, unknown>;
allow_fallback?: boolean;
output_mode?: NovelAgentOutputMode;
}
export interface SafeAgentPrompt {
id: string;
agent_name: string;
version: number;
provider_type: string;
default_provider_code: string | null;
system_prompt: string;
user_prompt_template: string;
output_schema_json: Prisma.JsonValue | null;
temperature: number | null;
max_output_tokens: number | null;
is_active: boolean;
created_at: string;
}
export interface SafeAgentRun {
id: string;
project_id: string | null;
novel_source_id: string | null;
chapter_no: number | null;
agent_name: string;
prompt_version: number | null;
provider_code: string | null;
model_name: string | null;
provider_log_id: string | null;
input_json: Prisma.JsonValue | null;
output_json: Prisma.JsonValue | null;
output_text: string | null;
status: string;
quality_score: number | null;
error_code: string | null;
error_message: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
}
export interface NovelAgentRunResult {
run: SafeAgentRun;
prompt: SafeAgentPrompt;
output_text: string;
output_json: Prisma.JsonValue | null;
provider: unknown;
provider_log: unknown;
}
export function toSafeAgentPrompt(prompt: AgentPrompt): SafeAgentPrompt {
return {
id: prompt.id.toString(),
agent_name: prompt.agent_name,
version: prompt.version,
provider_type: prompt.provider_type,
default_provider_code: prompt.default_provider_code,
system_prompt: prompt.system_prompt,
user_prompt_template: prompt.user_prompt_template,
output_schema_json: prompt.output_schema_json,
temperature: prompt.temperature ? Number(prompt.temperature.toString()) : null,
max_output_tokens: prompt.max_output_tokens,
is_active: prompt.is_active,
created_at: prompt.created_at.toISOString()
};
}
export function toSafeAgentRun(run: AgentRun): SafeAgentRun {
return {
id: run.id.toString(),
project_id: run.project_id?.toString() ?? null,
novel_source_id: run.novel_source_id?.toString() ?? null,
chapter_no: run.chapter_no,
agent_name: run.agent_name,
prompt_version: run.prompt_version,
provider_code: run.provider_code,
model_name: run.model_name,
provider_log_id: run.provider_log_id?.toString() ?? null,
input_json: run.input_json,
output_json: run.output_json,
output_text: run.output_text,
status: run.status,
quality_score: run.quality_score ? Number(run.quality_score.toString()) : null,
error_code: run.error_code,
error_message: run.error_message,
started_at: run.started_at?.toISOString() ?? null,
finished_at: run.finished_at?.toISOString() ?? null,
created_at: run.created_at.toISOString()
};
}
@@ -0,0 +1,615 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
Character,
NovelChapter,
NovelContextMemory,
NovelGenerationPlan,
NovelSource,
Prisma,
Project,
StoryBible,
WorldBible
} from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface NovelChapterContext {
project: Project;
plan: NovelGenerationPlan;
source: NovelSource;
chapter_no: number;
ip_bible: Prisma.JsonValue | null;
ip_bible_summary: string;
volume_outline: Prisma.JsonValue | null;
source_design: Record<string, unknown>;
source_volume_plan: Prisma.JsonValue | null;
planned_chapter_outline: Prisma.JsonValue | null;
style_rules: string;
forbidden_rules: string;
recent_chapters: NovelChapter[];
recent_summaries: string[];
characters: Character[];
character_profiles: Array<Record<string, unknown>>;
character_states: Array<Record<string, unknown>>;
memory_character_states: Array<Record<string, unknown>>;
memory_timeline: Array<Record<string, unknown>>;
next_chapter_must_continue: unknown[];
forbidden_to_forget: unknown[];
canonical_facts: string[];
context_snapshot: Record<string, unknown>;
memories: NovelContextMemory[];
active_foreshadows: unknown[];
previous_chapter: NovelChapter | null;
story_bible: StoryBible | null;
world_bible: WorldBible | null;
}
@Injectable()
export class NovelContextBuilderService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async buildChapterContext(input: {
project: Project;
plan: NovelGenerationPlan;
source: NovelSource;
chapter_no: number;
}): Promise<NovelChapterContext> {
const [storyBible, worldBible, recentChapters, characters, memories] = await Promise.all([
this.prisma.storyBible.findFirst({
where: { project_id: input.project.id },
orderBy: [
{ version: 'desc' },
{ created_at: 'desc' }
]
}),
this.prisma.worldBible.findFirst({
where: { project_id: input.project.id },
orderBy: { created_at: 'desc' }
}),
this.prisma.novelChapter.findMany({
where: {
novel_source_id: input.source.id,
chapter_no: {
lt: input.chapter_no
}
},
orderBy: { chapter_no: 'desc' },
take: this.recentChapterLimit(input.plan.novel_scale)
}),
this.prisma.character.findMany({
where: {
project_id: input.project.id,
status: { not: 'deleted' }
},
orderBy: [
{ importance_level: 'desc' },
{ id: 'asc' }
],
take: 20
}),
this.prisma.novelContextMemory.findMany({
where: {
project_id: input.project.id,
status: 'active',
OR: [
{ novel_source_id: input.source.id },
{ novel_source_id: null }
]
},
orderBy: [
{ importance_level: 'desc' },
{ created_at: 'desc' }
],
take: 80
})
]);
const orderedRecentChapters = [...recentChapters].reverse();
const orderedMemories = this.orderMemoriesForContext(memories);
const ipBible = input.plan.ip_bible_json ??
this.sourceIpBible(input.source) ??
this.ipBibleFromLegacy(storyBible, worldBible);
const sourceDesign = this.readObject(input.source.design_json);
const sourceVolumePlan =
input.source.volume_plan_json ??
(this.readArray(sourceDesign.volume_plan).length
? this.readArray(sourceDesign.volume_plan) as Prisma.JsonValue
: null);
const plannedChapterOutline = this.chapterOutlineForNo(sourceDesign, input.chapter_no);
const canonicalCharacterNames = this.canonicalCharacterNameSet(ipBible);
const memoryCharacterStates = this.memoryCharacterStates(orderedMemories, canonicalCharacterNames);
const characterProfiles = this.mergeNamedRecords([
...characters.map((character) => this.characterProfile(character)),
...this.ipBibleCharacterProfiles(ipBible),
...memoryCharacterStates
]);
const characterStates = this.mergeNamedRecords([
...characters.map((character) => this.characterState(character, orderedMemories)),
...memoryCharacterStates
]);
const memoryTimeline = this.memoryTimeline(orderedMemories);
const nextChapterMustContinue = this.nextChapterMustContinue(orderedMemories);
const forbiddenToForget = this.forbiddenToForget(orderedMemories);
const activeForeshadows = this.extractForeshadows(ipBible, orderedMemories);
const canonicalFacts = this.canonicalFacts(
ipBible,
storyBible,
worldBible,
characterProfiles,
memoryTimeline,
forbiddenToForget
);
const contextSnapshot = this.contextSnapshot({
chapter_no: input.chapter_no,
ip_bible_summary: this.buildIpBibleSummary(ipBible, storyBible, worldBible),
canonical_facts: canonicalFacts,
character_profiles: characterProfiles,
character_states: characterStates,
recent_summaries: orderedRecentChapters.map((chapter) => this.chapterSummary(chapter)),
memory_timeline: memoryTimeline,
source_design: sourceDesign,
source_volume_plan: sourceVolumePlan,
planned_chapter_outline: plannedChapterOutline,
active_foreshadows: activeForeshadows,
next_chapter_must_continue: nextChapterMustContinue,
forbidden_to_forget: forbiddenToForget,
previous_chapter: orderedRecentChapters.at(-1) ?? null
});
return {
project: input.project,
plan: input.plan,
source: input.source,
chapter_no: input.chapter_no,
ip_bible: ipBible,
ip_bible_summary: this.buildIpBibleSummary(ipBible, storyBible, worldBible),
volume_outline: input.plan.volume_plan_json ?? sourceVolumePlan ?? this.readObject(ipBible).volume_structure ?? null,
source_design: sourceDesign,
source_volume_plan: sourceVolumePlan,
planned_chapter_outline: plannedChapterOutline,
style_rules: this.buildStyleRules(input.plan, ipBible, storyBible),
forbidden_rules: this.buildForbiddenRules(input.plan, ipBible, storyBible, worldBible),
recent_chapters: orderedRecentChapters,
recent_summaries: orderedRecentChapters.map((chapter) => this.chapterSummary(chapter)),
characters,
character_profiles: characterProfiles,
character_states: characterStates,
memory_character_states: memoryCharacterStates,
memory_timeline: memoryTimeline,
next_chapter_must_continue: nextChapterMustContinue,
forbidden_to_forget: forbiddenToForget,
canonical_facts: canonicalFacts,
context_snapshot: contextSnapshot,
memories: orderedMemories,
active_foreshadows: activeForeshadows,
previous_chapter: orderedRecentChapters.at(-1) ?? null,
story_bible: storyBible,
world_bible: worldBible
};
}
private recentChapterLimit(scale: string) {
if (scale === 'short') return 8;
if (scale === 'long') return 5;
return 6;
}
private sourceIpBible(source: NovelSource) {
const report = this.readObject(source.parse_report);
const ipBible = this.readObject(report.ip_bible_json);
return Object.keys(ipBible).length ? ipBible as Prisma.JsonValue : null;
}
private ipBibleFromLegacy(storyBible: StoryBible | null, worldBible: WorldBible | null) {
if (!storyBible && !worldBible) return null;
const writingRules = [storyBible?.tone, worldBible?.visual_rules]
.filter((item): item is string => Boolean(item));
const forbiddenRules = [storyBible?.taboo_rules, worldBible?.forbidden_rules]
.filter((item): item is string => Boolean(item));
return {
core_logline: storyBible?.logline ?? storyBible?.main_plot ?? '',
theme: storyBible?.tone ?? '',
worldbuilding: {
world_summary: storyBible?.world_summary ?? '',
setting_text: worldBible?.setting_text ?? '',
rules_text: worldBible?.rules_text ?? '',
power_system: worldBible?.power_system ?? '',
visual_rules: worldBible?.visual_rules ?? ''
},
writing_rules: writingRules,
forbidden_rules: forbiddenRules
} satisfies Prisma.JsonObject;
}
private buildIpBibleSummary(
ipBible: Prisma.JsonValue | null,
storyBible: StoryBible | null,
worldBible: WorldBible | null
) {
const object = this.readObject(ipBible);
const parts = [
this.stringifyCompact(object.core_logline),
this.stringifyCompact(object.theme),
this.stringifyCompact(object.worldbuilding),
storyBible?.main_plot,
storyBible?.core_conflict,
worldBible?.setting_text,
worldBible?.rules_text
].filter(Boolean);
return parts.join('\n').slice(0, 6000);
}
private buildStyleRules(
plan: NovelGenerationPlan,
ipBible: Prisma.JsonValue | null,
storyBible: StoryBible | null
) {
const object = this.readObject(ipBible);
const rules = [
plan.style_code ? `风格代码:${plan.style_code}` : null,
storyBible?.tone ? `故事基调:${storyBible.tone}` : null,
this.stringifyCompact(object.writing_rules),
this.stringifyCompact(object.adaptation_rules)
].filter(Boolean);
return rules.join('\n') || '画面感强,情绪克制,节奏清晰,对白符合人物身份。';
}
private buildForbiddenRules(
_plan: NovelGenerationPlan,
ipBible: Prisma.JsonValue | null,
storyBible: StoryBible | null,
worldBible: WorldBible | null
) {
const object = this.readObject(ipBible);
const rules = [
storyBible?.taboo_rules,
worldBible?.forbidden_rules,
this.stringifyCompact(object.forbidden_rules)
].filter(Boolean);
return rules.join('\n') || '不得违背已确认人设、时间线、世界观和已埋伏笔。';
}
private chapterSummary(chapter: NovelChapter) {
return [
`${chapter.chapter_no}${chapter.title ?? ''}`,
chapter.summary ?? chapter.visual_summary ?? this.compact(chapter.content, 260)
].filter(Boolean).join('');
}
private orderMemoriesForContext(memories: NovelContextMemory[]) {
return [...memories].sort((a, b) => {
const chapterDiff = (b.chapter_no ?? 0) - (a.chapter_no ?? 0);
if (chapterDiff !== 0) return chapterDiff;
return b.created_at.getTime() - a.created_at.getTime();
});
}
private characterProfile(character: Character): Record<string, unknown> {
return {
id: character.id.toString(),
name: character.name,
role_type: character.role_type,
gender_label: character.gender_label,
age_group: character.age_group,
identity_desc: character.identity_desc,
appearance_desc: character.appearance_desc,
face_desc: character.face_desc,
costume_rules: character.costume_rules,
special_props: character.special_props,
personality_desc: character.personality_desc,
speech_style: character.speech_style,
relationship_desc: character.relationship_desc,
character_arc: character.character_arc,
negative_rules: character.negative_rules,
voice_style: character.voice_style,
performance_style: character.performance_style
};
}
private characterState(
character: Character,
memories: NovelContextMemory[]
): Record<string, unknown> {
const relatedMemories = memories
.filter((memory) => {
const text = `${memory.memory_text ?? ''} ${JSON.stringify(memory.memory_json ?? {})}`;
return text.includes(character.name);
})
.slice(0, 5);
return {
character_id: character.id.toString(),
name: character.name,
current_status: character.status,
recent_memory: relatedMemories.map((memory) => ({
memory_type: memory.memory_type,
memory_text: memory.memory_text,
memory_json: memory.memory_json
}))
};
}
private ipBibleCharacterProfiles(ipBible: Prisma.JsonValue | null) {
const object = this.readObject(ipBible);
const characters = [
...this.readArray(object.main_characters),
...this.readArray(object.supporting_characters)
];
return characters
.map((item) => this.readObject(item))
.filter((item) => this.readString(item.name))
.map((item) => ({
source: 'ip_bible',
canonical_status: 'canonical',
name: this.readString(item.name),
core_identity: this.readString(item.core_identity) ?? this.readString(item.identity_desc),
core_drive: this.readString(item.core_drive),
personality_traits: this.stringifyCompact(item.personality_traits),
character_arc: this.readString(item.character_arc),
speech_style: this.readString(item.speech_style),
forbidden_rules: this.stringifyCompact(item.forbidden_rules)
}));
}
private memoryCharacterStates(memories: NovelContextMemory[], canonicalCharacterNames: Set<string>) {
const states: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
for (const memory of memories) {
if (memory.memory_type !== 'character_state') continue;
for (const item of this.readArray(memory.memory_json)) {
const object = this.readObject(item);
const name = this.readString(object.name);
if (!name || seen.has(name)) continue;
seen.add(name);
states.push({
source: 'memory',
canonical_status: canonicalCharacterNames.has(name)
? 'canonical'
: canonicalCharacterNames.size === 0
? 'unverified'
: 'memory_only_check_before_use',
source_chapter_no: memory.chapter_no,
name,
current_status: this.readString(object.status) ??
this.readString(object.current_status) ??
this.stringifyCompact(object),
recent_memory: this.compact(this.stringifyCompact(object), 900)
});
}
}
return states.slice(0, 30);
}
private canonicalCharacterNameSet(ipBible: Prisma.JsonValue | null) {
const object = this.readObject(ipBible);
const names = [
...this.readArray(object.main_characters),
...this.readArray(object.supporting_characters)
]
.map((item) => this.readString(this.readObject(item).name))
.filter((name): name is string => Boolean(name));
return new Set(names);
}
private memoryTimeline(memories: NovelContextMemory[]) {
return memories
.filter((memory) => memory.memory_type === 'timeline_update')
.map((memory) => {
const object = this.readObject(memory.memory_json);
return {
chapter_no: memory.chapter_no,
timeline_update: this.readString(object.timeline_update) ?? memory.memory_text,
location_update: this.readString(object.location_update),
next_chapter_must_continue: this.readArray(object.next_chapter_must_continue)
};
})
.filter((item) => item.timeline_update || item.next_chapter_must_continue.length > 0)
.slice(0, 12);
}
private nextChapterMustContinue(memories: NovelContextMemory[]) {
const output: unknown[] = [];
for (const memory of memories) {
const object = this.readObject(memory.memory_json);
output.push(...this.readArray(object.next_chapter_must_continue));
if (output.length >= 30) break;
}
return output.slice(0, 30);
}
private forbiddenToForget(memories: NovelContextMemory[]) {
const output: unknown[] = [];
for (const memory of memories) {
const object = this.readObject(memory.memory_json);
output.push(...this.readArray(object.forbidden_to_forget));
if (memory.memory_type === 'continuity_rule') {
output.push(...this.readArray(memory.memory_json));
}
if (output.length >= 30) break;
}
return output.slice(0, 30);
}
private extractForeshadows(ipBible: Prisma.JsonValue | null, memories: NovelContextMemory[]) {
const object = this.readObject(ipBible);
const planned = this.flattenContextItems(object.foreshadow_plan);
const memoryForeshadows = memories.flatMap((memory) => {
const memoryObject = this.readObject(memory.memory_json);
const items = [
...this.flattenContextItems(memoryObject.new_foreshadows),
...this.flattenContextItems(memoryObject.updated_foreshadows),
...this.flattenContextItems(memoryObject.resolved_foreshadows)
];
if (memory.memory_type.includes('foreshadow')) {
items.push(...this.flattenContextItems(memory.memory_json ?? memory.memory_text));
}
return items;
}).filter(Boolean);
return [...planned, ...memoryForeshadows].slice(0, 40);
}
private canonicalFacts(
ipBible: Prisma.JsonValue | null,
storyBible: StoryBible | null,
worldBible: WorldBible | null,
characterProfiles: Array<Record<string, unknown>>,
memoryTimeline: Array<Record<string, unknown>>,
forbiddenToForget: unknown[]
) {
const object = this.readObject(ipBible);
const worldbuilding = this.readObject(object.worldbuilding);
const facts: string[] = [];
const push = (label: string, value: unknown) => {
const text = this.stringifyCompact(value);
if (text) facts.push(`${label}${this.compact(text, 500)}`);
};
push('核心主线', object.core_logline ?? storyBible?.main_plot);
push('主题', object.theme ?? storyBible?.tone);
push('世界规则', worldbuilding.time_loop_rule ?? worldbuilding.rules_text ?? worldBible?.rules_text);
push('关键地点/世界观', worldbuilding.world_summary ?? worldbuilding.setting_text ?? worldBible?.setting_text);
push('禁止规则', object.forbidden_rules ?? storyBible?.taboo_rules ?? worldBible?.forbidden_rules);
for (const profile of characterProfiles.slice(0, 12)) {
const name = this.readString(profile.name);
if (!name) continue;
push(`人物:${name}`, {
identity: profile.core_identity ?? profile.identity_desc,
status: profile.current_status,
drive: profile.core_drive,
arc: profile.character_arc
});
}
for (const item of memoryTimeline.slice(0, 4)) {
push(`最新时间线:${item.chapter_no ?? ''}`, item.timeline_update);
}
push('绝对不能忘记', forbiddenToForget);
return Array.from(new Set(facts)).slice(0, 50);
}
private contextSnapshot(input: {
chapter_no: number;
ip_bible_summary: string;
canonical_facts: string[];
character_profiles: Array<Record<string, unknown>>;
character_states: Array<Record<string, unknown>>;
recent_summaries: string[];
memory_timeline: Array<Record<string, unknown>>;
source_design: Record<string, unknown>;
source_volume_plan: Prisma.JsonValue | null;
planned_chapter_outline: Prisma.JsonValue | null;
active_foreshadows: unknown[];
next_chapter_must_continue: unknown[];
forbidden_to_forget: unknown[];
previous_chapter: NovelChapter | null;
}) {
return {
chapter_no: input.chapter_no,
context_rule: '以本快照为准。若最近章节摘要与人物/世界硬设定冲突,必须优先遵守 canonical_facts 和 forbidden_to_forget。',
canonical_facts: input.canonical_facts,
character_profiles: input.character_profiles.slice(0, 20),
character_states: input.character_states.slice(0, 20),
previous_chapter: input.previous_chapter
? {
chapter_no: input.previous_chapter.chapter_no,
title: input.previous_chapter.title,
summary: input.previous_chapter.summary ?? this.compact(input.previous_chapter.content, 300)
}
: null,
source_design: this.compact(this.stringifyCompact(input.source_design), 1600),
source_volume_plan: input.source_volume_plan,
planned_chapter_outline: input.planned_chapter_outline,
recent_summaries: input.recent_summaries.slice(-6),
memory_timeline: input.memory_timeline.slice(0, 10),
active_foreshadows: input.active_foreshadows.slice(0, 30),
next_chapter_must_continue: input.next_chapter_must_continue.slice(0, 20),
forbidden_to_forget: input.forbidden_to_forget.slice(0, 20)
};
}
private mergeNamedRecords(records: Array<Record<string, unknown>>) {
const merged = new Map<string, Record<string, unknown>>();
for (const record of records) {
const name = this.readString(record.name);
const key = name ? `name:${name}` : this.readString(record.character_id);
if (!key) continue;
merged.set(key, {
...(merged.get(key) ?? {}),
...record
});
}
return Array.from(merged.values()).slice(0, 40);
}
private flattenContextItems(value: unknown): unknown[] {
if (value === undefined || value === null || value === '') return [];
if (Array.isArray(value)) {
return value.flatMap((item) => this.flattenContextItems(item));
}
if (typeof value === 'object') {
const object = this.readObject(value);
const directText = this.readString(object.title) ??
this.readString(object.name) ??
this.readString(object.text) ??
this.readString(object.summary);
if (directText) return [object];
return Object.values(object).flatMap((item) => this.flattenContextItems(item));
}
return [value];
}
private readArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
private readObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
private chapterOutlineForNo(sourceDesign: Record<string, unknown>, chapterNo: number): Prisma.JsonValue | null {
const outlines = this.readArray(sourceDesign.chapter_outlines);
const matched = outlines
.map((item) => this.readObject(item))
.find((item) => Number(item.chapter_no) === chapterNo);
return Object.keys(matched ?? {}).length ? matched as Prisma.JsonValue : null;
}
private readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
private stringifyCompact(value: unknown) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'string') return value;
return JSON.stringify(value);
}
private compact(value: string, length: number) {
return value.replace(/\s+/g, ' ').trim().slice(0, length);
}
}
@@ -0,0 +1,58 @@
import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
AcceptNovelWizardProposalDto,
GenerateNovelWizardProposalsDto
} from './novel-creation-wizard.dto';
import { NovelCreationWizardService } from './novel-creation-wizard.service';
@Controller('admin/novel-creation-wizard')
@UseGuards(JwtAuthGuard)
export class NovelCreationWizardController {
constructor(
@Inject(NovelCreationWizardService)
private readonly wizard: NovelCreationWizardService
) {}
@Post('proposals')
generateProposals(
@CurrentUser() user: AuthRequestUser,
@Body() dto: GenerateNovelWizardProposalsDto
) {
return this.wizard.generateProposals(user, dto);
}
@Post('proposals/jobs')
createProposalJob(
@CurrentUser() user: AuthRequestUser,
@Body() dto: GenerateNovelWizardProposalsDto
) {
return this.wizard.createProposalJob(user, dto);
}
@Get('proposals/jobs/:jobId')
getProposalJob(
@CurrentUser() user: AuthRequestUser,
@Param('jobId') jobId: string
) {
return this.wizard.getProposalJob(user, jobId);
}
@Get('idea-logs')
listIdeaLogs(
@CurrentUser() user: AuthRequestUser,
@Query('limit') limit?: string
) {
return this.wizard.listIdeaLogs(user, limit);
}
@Post('accept')
acceptProposal(
@CurrentUser() user: AuthRequestUser,
@Body() dto: AcceptNovelWizardProposalDto
) {
return this.wizard.acceptProposal(user, dto);
}
}
@@ -0,0 +1,22 @@
export class GenerateNovelWizardProposalsDto {
provider_code?: string;
allow_fallback?: boolean;
novel_scale?: 'short' | 'medium' | 'long';
target_audience?: string;
genre?: string;
style_code?: string;
target_words?: number;
target_chapters?: number;
adaptation_targets?: string[];
inspiration_text?: string;
must_have_text?: string;
avoid_text?: string;
reference_titles?: string;
}
export class AcceptNovelWizardProposalDto {
provider_code?: string;
allow_fallback?: boolean;
generate_ip_bible?: boolean;
proposal?: unknown;
}
@@ -0,0 +1,889 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import { assertPermission } from '../auth/rbac';
import { PrismaService } from '../prisma/prisma.service';
import { toSafeProject } from '../projects/project.types';
import { toSafeNovelGenerationPlan } from './novel-generation.types';
import { NovelAgentService } from './novel-agent.service';
import { NovelGenerationWorkflowService } from './novel-generation-workflow.service';
import { toSafeNovelSource } from './novel.types';
import {
AcceptNovelWizardProposalDto,
GenerateNovelWizardProposalsDto
} from './novel-creation-wizard.dto';
type NovelWizardProposal = {
id: string;
title: string;
genre: string;
novel_scale: 'short' | 'medium' | 'long';
target_audience: string;
target_words: number | null;
target_chapters: number | null;
style_code: string | null;
logline: string;
opening_hook: string;
main_character: string;
core_conflict: string;
selling_points: string[];
risk_notes: string[];
adaptation_targets: string[];
volume_plan: unknown[];
chapter_blueprint: unknown[];
writing_rules: string[];
forbidden_rules: string[];
score: number;
operator_reason: string;
};
const DEFAULT_ADAPTATION_TARGETS = ['听书', '短剧'];
const DEFAULT_WIZARD_PROVIDER_CODE = 'volcengine-doubao-seed20-mini-text';
const DEFAULT_TARGET_CHAPTERS_BY_SCALE = {
short: 12,
medium: 60,
long: 120
} as const;
const NOVEL_WIZARD_IDEA_LOG_ACTION = 'admin_novel_wizard_generate_proposals';
const NOVEL_WIZARD_PROPOSAL_JOB_ACTION = 'admin_novel_wizard_generate_proposals_job';
const SLOW_WIZARD_PROVIDER_CODES = new Set([
'volcengine-doubao-seed20-lite-text',
'volcengine-doubao-seed20-pro-text'
]);
@Injectable()
export class NovelCreationWizardService {
constructor(
@Inject(PrismaService)
private readonly prisma: PrismaService,
@Inject(NovelAgentService)
private readonly agents: NovelAgentService,
@Inject(NovelGenerationWorkflowService)
private readonly workflow: NovelGenerationWorkflowService
) {}
async createProposalJob(user: AuthRequestUser, dto: GenerateNovelWizardProposalsDto) {
assertPermission(user, 'projects:write');
const userId = this.parseId(user.id, 'Invalid user id');
const metadata = this.createProposalJobMetadata(dto, {
status: 'pending',
submitted_at: new Date().toISOString()
});
const job = await this.prisma.operationLog.create({
data: {
user_id: userId,
operator_role: user.role,
action: NOVEL_WIZARD_PROPOSAL_JOB_ACTION,
target_type: 'novel_wizard_proposal_job',
metadata_json: this.toJsonValue(metadata)
}
});
void this.runProposalJob(job.id, user, dto).catch((error) => {
void this.updateProposalJob(job.id, {
status: 'failed',
finished_at: new Date().toISOString(),
agent_error: this.errorMessage(error)
});
});
return {
job: this.toSafeProposalJob(job),
polling: {
interval_ms: 3000,
endpoint: `/api/admin/novel-creation-wizard/proposals/jobs/${job.id.toString()}`
}
};
}
async getProposalJob(user: AuthRequestUser, jobId: string) {
assertPermission(user, 'projects:write');
const id = this.parseId(jobId, 'Invalid job id');
const userId = this.parseId(user.id, 'Invalid user id');
const job = await this.prisma.operationLog.findFirst({
where: {
id,
user_id: userId,
action: NOVEL_WIZARD_PROPOSAL_JOB_ACTION
}
});
if (!job) {
throw new BadRequestException('小说方案任务不存在或无权访问');
}
return {
job: this.toSafeProposalJob(job)
};
}
async generateProposals(user: AuthRequestUser, dto: GenerateNovelWizardProposalsDto) {
assertPermission(user, 'projects:write');
const startedAt = Date.now();
const variables = this.buildWizardVariables(dto);
let agentResult: Awaited<ReturnType<NovelAgentService['runAgent']>> | null = null;
let agentError: string | null = null;
try {
agentResult = await this.agents.runAgent({
agent_name: 'NovelIdeaCoachAgent',
provider_type: 'TextProvider',
provider_code: this.resolveWizardProviderCode(dto.provider_code),
allow_fallback: false,
variables,
input_json: {
max_tokens: 1200
},
output_mode: 'json',
purpose: 'novel_creation_wizard_proposals'
});
} catch (error) {
agentError = this.errorMessage(error);
}
const proposals = agentResult
? this.normalizeProposals(agentResult.output_json, dto)
: this.fallbackProposals(dto);
const usedFallback = Boolean(agentError) || this.isFallbackProposalSet(proposals);
const ideaLog = await this.recordIdeaLog(user, {
dto,
proposals,
agentResult,
usedFallback,
agentError,
durationMs: Date.now() - startedAt
});
return {
proposals,
agent_run: agentResult?.run ?? null,
idea_log: ideaLog,
used_fallback: usedFallback,
agent_error: agentError ?? (usedFallback ? 'AI 没有返回标准 proposals JSON,已使用本地兜底方案。' : null),
guide: {
title: '选择一个你最想点进去看的方案',
tips: [
'优先选开篇钩子强、主角目标清晰、冲突能连续升级的方案。',
'分数只是辅助,运营可以按市场感觉选择。',
'选中后系统会自动创建小说项目、生成计划和 IP 圣经。'
],
next_step: 'accept_proposal'
}
};
}
private async runProposalJob(
jobId: bigint,
user: AuthRequestUser,
dto: GenerateNovelWizardProposalsDto
) {
const startedAt = Date.now();
await this.updateProposalJob(jobId, {
status: 'running',
started_at: new Date().toISOString()
});
const result = await this.generateProposals(user, dto);
await this.updateProposalJob(jobId, {
status: result.used_fallback ? 'fallback' : 'success',
finished_at: new Date().toISOString(),
duration_ms: Date.now() - startedAt,
result
});
}
async listIdeaLogs(user: AuthRequestUser, limitValue?: string) {
assertPermission(user, 'projects:write');
const limit = this.normalizeListLimit(limitValue);
const logs = await this.prisma.operationLog.findMany({
where: { action: NOVEL_WIZARD_IDEA_LOG_ACTION },
orderBy: { created_at: 'desc' },
take: limit
});
return {
logs: logs.map((log) => this.toSafeIdeaLog(log)),
total: logs.length,
limit
};
}
async acceptProposal(user: AuthRequestUser, dto: AcceptNovelWizardProposalDto) {
assertPermission(user, 'projects:write');
const proposal = this.normalizeAcceptedProposal(dto.proposal);
const userId = this.parseId(user.id, 'Invalid user id');
const project = await this.prisma.project.create({
data: {
user_id: userId,
title: proposal.title,
input_mode: 'ai_original',
genre: proposal.genre,
style_code: proposal.style_code ?? this.defaultStyleCode(proposal.genre),
output_type: 'novel_ip',
output_mode: 'live_action_ai',
visual_mode: 'live_action',
video_generation_level: 'standard',
target_episode_count: null,
episode_duration: null,
status: 'novel_ip_planning',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'ip_factory',
is_long_series: proposal.novel_scale !== 'short'
}
});
const source = await this.prisma.novelSource.create({
data: {
project_id: project.id,
source_type: 'ai_original',
title: proposal.title,
author_name: 'AI 小说工厂',
raw_text: '',
clean_text: this.createProposalCleanText(proposal),
word_count: 0,
chapter_count: 0,
parse_status: 'idea_selected',
ai_provider_code: this.normalizeOptionalText(dto.provider_code) ?? null,
parse_report: this.toJsonValue({
created_by: 'novel_creation_wizard',
hook_text: proposal.opening_hook,
intro_text: proposal.logline,
target_chapters: proposal.target_chapters,
target_words: proposal.target_words,
selected_proposal: proposal,
accepted_at: new Date().toISOString()
}),
design_json: this.toJsonValue({
source: 'novel_creation_wizard',
raw_proposal: proposal,
novel_title: proposal.title,
total_chapters: proposal.target_chapters,
total_words: proposal.target_words,
genre: proposal.genre,
target_audience: proposal.target_audience,
logline: proposal.logline,
opening_hook: proposal.opening_hook,
main_character: proposal.main_character,
core_conflict: proposal.core_conflict,
selling_points: proposal.selling_points,
risk_notes: proposal.risk_notes,
adaptation_targets: proposal.adaptation_targets,
volume_plan: proposal.volume_plan,
chapter_outlines: proposal.chapter_blueprint,
writing_rules: proposal.writing_rules,
forbidden_rules: proposal.forbidden_rules,
parsed_at: new Date().toISOString()
}),
volume_plan_json: this.toJsonValue({
source: 'novel_creation_wizard',
volume_plan: proposal.volume_plan,
chapter_blueprint: proposal.chapter_blueprint
})
}
});
const planResult = await this.workflow.createPlan(user, project.id.toString(), {
source_id: source.id.toString(),
title: proposal.title,
novel_scale: proposal.novel_scale,
target_words: proposal.target_words ?? undefined,
target_chapters: proposal.target_chapters ?? this.defaultTargetChapters(proposal.novel_scale),
genre: proposal.genre,
style_code: proposal.style_code ?? undefined,
automation_level: 'L1',
brief: this.toJsonValue({
title: proposal.title,
target_audience: proposal.target_audience,
logline: proposal.logline,
opening_hook: proposal.opening_hook,
main_character: proposal.main_character,
core_conflict: proposal.core_conflict,
selling_points: proposal.selling_points,
risk_notes: proposal.risk_notes,
adaptation_targets: proposal.adaptation_targets,
writing_rules: proposal.writing_rules,
forbidden_rules: proposal.forbidden_rules,
operator_reason: proposal.operator_reason
}),
pipeline_config: this.toJsonValue({
wizard: true,
creation_flow: ['proposal', 'ip_bible', 'chapter_directory', 'chapter_workflow'],
operator_mode: 'guided'
}),
quality_threshold: {
pass_score: 88,
repair_score: 80,
max_repair_attempts: 1
}
});
const planId = this.parseId(planResult.plan.id, 'Invalid plan id');
await this.prisma.novelGenerationPlan.update({
where: { id: planId },
data: {
volume_plan_json: this.toJsonValue({
volume_plan: proposal.volume_plan,
chapter_blueprint: proposal.chapter_blueprint
})
}
});
await this.prisma.operationLog.create({
data: {
user_id: userId,
operator_role: user.role,
action: 'admin_novel_wizard_accept_proposal',
target_type: 'novel_generation_plan',
target_id: planId,
metadata_json: this.toJsonValue({
project_id: project.id.toString(),
source_id: source.id.toString(),
proposal_id: proposal.id,
title: proposal.title
})
}
}).catch(() => undefined);
let ipBibleResult: Awaited<ReturnType<NovelGenerationWorkflowService['generateIpBible']>> | null = null;
let ipBibleError: string | null = null;
if (dto.generate_ip_bible !== false) {
try {
ipBibleResult = await this.workflow.generateIpBible(user, project.id.toString(), planId.toString(), {
provider_code: this.normalizeOptionalText(dto.provider_code) ?? undefined,
allow_fallback: dto.allow_fallback !== false,
variables: {
brief: proposal
}
});
} catch (error) {
ipBibleError = error instanceof Error ? error.message : String(error);
await this.prisma.novelSource.update({
where: { id: source.id },
data: {
parse_status: 'ip_bible_failed',
parse_report: this.toJsonValue({
created_by: 'novel_creation_wizard',
hook_text: proposal.opening_hook,
intro_text: proposal.logline,
target_chapters: proposal.target_chapters,
target_words: proposal.target_words,
selected_proposal: proposal,
ip_bible_error: ipBibleError,
failed_at: new Date().toISOString()
})
}
}).catch(() => undefined);
}
}
const refreshedPlan = await this.prisma.novelGenerationPlan.findUniqueOrThrow({
where: { id: planId }
});
const refreshedSource = await this.prisma.novelSource.findUniqueOrThrow({
where: { id: source.id }
});
return {
project: toSafeProject(project),
source: toSafeNovelSource(refreshedSource),
plan: toSafeNovelGenerationPlan(refreshedPlan),
proposal,
ip_bible: ipBibleResult?.ip_bible ?? null,
ip_bible_error: ipBibleError,
agent_run: ipBibleResult?.agent_run ?? null,
next_step: ipBibleError ? 'retry_ip_bible' : 'generate_first_chapter'
};
}
private buildWizardVariables(dto: GenerateNovelWizardProposalsDto) {
const scale = this.resolveNovelScale(dto.novel_scale);
return {
novel_scale: scale,
target_audience: this.normalizeOptionalText(dto.target_audience) ?? '大众读者',
genre: this.normalizeOptionalText(dto.genre) ?? '都市爽文',
style_code: this.normalizeOptionalText(dto.style_code) ?? '',
target_words: this.normalizeOptionalPositiveInt(dto.target_words),
target_chapters: this.normalizeOptionalPositiveInt(dto.target_chapters),
adaptation_targets: this.normalizeStringList(dto.adaptation_targets).join('、') || DEFAULT_ADAPTATION_TARGETS.join('、'),
inspiration_text: this.normalizeOptionalText(dto.inspiration_text) ?? '运营暂未提供详细灵感,请你主动设计高商业化方向。',
must_have_text: this.normalizeOptionalText(dto.must_have_text) ?? '强钩子、强冲突、连续爽点、人设稳定。',
avoid_text: this.normalizeOptionalText(dto.avoid_text) ?? '避免流水账、设定混乱、人设崩塌、低俗擦边。',
reference_titles: this.normalizeOptionalText(dto.reference_titles) ?? ''
};
}
private normalizeProposals(value: unknown, dto: GenerateNovelWizardProposalsDto): NovelWizardProposal[] {
const object = this.readObject(value);
const rawProposals = Array.isArray(object.proposals)
? object.proposals
: Array.isArray(object.items)
? object.items
: [];
const proposals = rawProposals
.map((item, index) => this.normalizeProposalObject(item, index, dto))
.filter((item): item is NovelWizardProposal => Boolean(item))
.slice(0, 5);
if (proposals.length > 0) {
return proposals;
}
return this.fallbackProposals(dto);
}
private resolveWizardProviderCode(value: unknown) {
const providerCode = this.normalizeOptionalText(value);
if (!providerCode || SLOW_WIZARD_PROVIDER_CODES.has(providerCode)) {
return DEFAULT_WIZARD_PROVIDER_CODE;
}
return providerCode;
}
private async recordIdeaLog(
user: AuthRequestUser,
input: {
dto: GenerateNovelWizardProposalsDto;
proposals: NovelWizardProposal[];
agentResult: Awaited<ReturnType<NovelAgentService['runAgent']>> | null;
usedFallback: boolean;
agentError: string | null;
durationMs: number;
}
) {
try {
const userId = this.parseId(user.id, 'Invalid user id');
const targetId = this.safeBigInt(input.agentResult?.run?.id);
const metadata = this.buildIdeaLogMetadata(input);
const log = await this.prisma.operationLog.create({
data: {
user_id: userId,
operator_role: user.role,
action: NOVEL_WIZARD_IDEA_LOG_ACTION,
target_type: targetId ? 'agent_run' : 'novel_wizard',
target_id: targetId,
metadata_json: this.toJsonValue(metadata)
}
});
return this.toSafeIdeaLog(log);
} catch {
return null;
}
}
private buildIdeaLogMetadata(input: {
dto: GenerateNovelWizardProposalsDto;
proposals: NovelWizardProposal[];
agentResult: Awaited<ReturnType<NovelAgentService['runAgent']>> | null;
usedFallback: boolean;
agentError: string | null;
durationMs: number;
}) {
const proposalSummaries = input.proposals.map((proposal) => ({
id: proposal.id,
title: proposal.title,
score: proposal.score,
genre: proposal.genre,
logline: proposal.logline,
opening_hook: proposal.opening_hook
}));
return {
source: 'novel_creation_wizard',
recorded_for: 'operator_prompt_optimization',
submitted_at: new Date().toISOString(),
duration_ms: input.durationMs,
provider_code: this.normalizeOptionalText(input.dto.provider_code) ?? null,
selected_provider_code: input.agentResult?.run.provider_code ?? null,
selected_model_name: input.agentResult?.run.model_name ?? null,
agent_run_id: input.agentResult?.run.id ?? null,
used_fallback: input.usedFallback,
agent_error: input.agentError,
operator_input: {
novel_scale: this.resolveNovelScale(input.dto.novel_scale),
target_audience: this.normalizeOptionalText(input.dto.target_audience) ?? null,
genre: this.normalizeOptionalText(input.dto.genre) ?? null,
style_code: this.normalizeOptionalText(input.dto.style_code) ?? null,
target_words: this.normalizeOptionalPositiveInt(input.dto.target_words),
target_chapters: this.normalizeOptionalPositiveInt(input.dto.target_chapters),
adaptation_targets: this.normalizeStringList(input.dto.adaptation_targets),
inspiration_text: this.normalizeOptionalText(input.dto.inspiration_text) ?? null,
must_have_text: this.normalizeOptionalText(input.dto.must_have_text) ?? null,
avoid_text: this.normalizeOptionalText(input.dto.avoid_text) ?? null,
reference_titles: this.normalizeOptionalText(input.dto.reference_titles) ?? null
},
proposal_count: proposalSummaries.length,
proposals: proposalSummaries
};
}
private createProposalJobMetadata(
dto: GenerateNovelWizardProposalsDto,
patch: Record<string, unknown>
) {
return {
source: 'novel_creation_wizard',
mode: 'async_polling',
provider_code: this.normalizeOptionalText(dto.provider_code) ?? null,
resolved_provider_code: this.resolveWizardProviderCode(dto.provider_code),
operator_input: {
novel_scale: this.resolveNovelScale(dto.novel_scale),
target_audience: this.normalizeOptionalText(dto.target_audience) ?? null,
genre: this.normalizeOptionalText(dto.genre) ?? null,
style_code: this.normalizeOptionalText(dto.style_code) ?? null,
target_words: this.normalizeOptionalPositiveInt(dto.target_words),
target_chapters: this.normalizeOptionalPositiveInt(dto.target_chapters),
adaptation_targets: this.normalizeStringList(dto.adaptation_targets),
inspiration_text: this.normalizeOptionalText(dto.inspiration_text) ?? null,
must_have_text: this.normalizeOptionalText(dto.must_have_text) ?? null,
avoid_text: this.normalizeOptionalText(dto.avoid_text) ?? null,
reference_titles: this.normalizeOptionalText(dto.reference_titles) ?? null
},
...patch
};
}
private async updateProposalJob(jobId: bigint, patch: Record<string, unknown>) {
const job = await this.prisma.operationLog.findUnique({ where: { id: jobId } });
const metadata = {
...this.readObject(job?.metadata_json),
...patch
};
return this.prisma.operationLog.update({
where: { id: jobId },
data: {
metadata_json: this.toJsonValue(metadata)
}
});
}
private toSafeProposalJob(log: {
id: bigint;
user_id: bigint | null;
operator_role: string | null;
target_type: string | null;
target_id: bigint | null;
metadata_json: Prisma.JsonValue | null;
created_at: Date;
}) {
const metadata = this.readObject(log.metadata_json);
const result = this.readObject(metadata.result);
return {
id: log.id.toString(),
user_id: log.user_id?.toString() ?? null,
operator_role: log.operator_role,
target_type: log.target_type,
target_id: log.target_id?.toString() ?? null,
status: this.readString(metadata.status) ?? 'pending',
provider_code: this.readString(metadata.provider_code),
resolved_provider_code: this.readString(metadata.resolved_provider_code),
submitted_at: this.readString(metadata.submitted_at),
started_at: this.readString(metadata.started_at),
finished_at: this.readString(metadata.finished_at),
duration_ms: this.normalizeOptionalPositiveInt(metadata.duration_ms),
agent_error: this.readString(metadata.agent_error) ?? this.readString(result.agent_error),
used_fallback: result.used_fallback === true,
proposals: Array.isArray(result.proposals) ? result.proposals : [],
agent_run: result.agent_run ?? null,
idea_log: result.idea_log ?? null,
guide: result.guide ?? null,
created_at: log.created_at.toISOString()
};
}
private toSafeIdeaLog(log: {
id: bigint;
user_id: bigint | null;
operator_role: string | null;
target_type: string | null;
target_id: bigint | null;
metadata_json: Prisma.JsonValue | null;
created_at: Date;
}) {
const metadata = this.readObject(log.metadata_json);
const operatorInput = this.readObject(metadata.operator_input);
const proposals = this.readArray(metadata.proposals)
.map((item) => this.readObject(item))
.map((proposal) => ({
id: this.readString(proposal.id),
title: this.readString(proposal.title),
score: this.normalizeScore(proposal.score),
genre: this.readString(proposal.genre),
logline: this.readString(proposal.logline),
opening_hook: this.readString(proposal.opening_hook)
}));
return {
id: log.id.toString(),
user_id: log.user_id?.toString() ?? null,
operator_role: log.operator_role,
target_type: log.target_type,
target_id: log.target_id?.toString() ?? null,
created_at: log.created_at.toISOString(),
provider_code: this.readString(metadata.provider_code),
selected_provider_code: this.readString(metadata.selected_provider_code),
selected_model_name: this.readString(metadata.selected_model_name),
used_fallback: metadata.used_fallback === true,
agent_error: this.readString(metadata.agent_error),
duration_ms: this.normalizeOptionalPositiveInt(metadata.duration_ms),
proposal_count: this.normalizeOptionalPositiveInt(metadata.proposal_count) ?? proposals.length,
operator_input: {
novel_scale: this.readString(operatorInput.novel_scale),
target_audience: this.readString(operatorInput.target_audience),
genre: this.readString(operatorInput.genre),
style_code: this.readString(operatorInput.style_code),
target_words: this.normalizeOptionalPositiveInt(operatorInput.target_words),
target_chapters: this.normalizeOptionalPositiveInt(operatorInput.target_chapters),
adaptation_targets: this.readStringArray(operatorInput.adaptation_targets),
inspiration_text: this.readString(operatorInput.inspiration_text),
must_have_text: this.readString(operatorInput.must_have_text),
avoid_text: this.readString(operatorInput.avoid_text),
reference_titles: this.readString(operatorInput.reference_titles)
},
proposals
};
}
private isFallbackProposalSet(proposals: NovelWizardProposal[]) {
return proposals.length > 0 && proposals.every((proposal) => proposal.id.startsWith('fallback-'));
}
private normalizeAcceptedProposal(value: unknown): NovelWizardProposal {
const proposal = this.normalizeProposalObject(value, 0, {});
if (!proposal) {
throw new BadRequestException('请选择一个有效的小说方案');
}
return proposal;
}
private normalizeProposalObject(
value: unknown,
index: number,
dto: Pick<GenerateNovelWizardProposalsDto, 'novel_scale' | 'genre' | 'target_audience' | 'target_words' | 'target_chapters' | 'style_code' | 'adaptation_targets'>
): NovelWizardProposal | null {
const object = this.readObject(value);
const title = this.readString(object.title) ?? this.readString(object.book_title);
const logline = this.readString(object.logline) ?? this.readString(object.core_logline);
if (!title && !logline) {
return null;
}
const genre = this.readString(object.genre) ?? this.normalizeOptionalText(dto.genre) ?? '都市爽文';
const scale = this.resolveNovelScale(this.readString(object.novel_scale) ?? dto.novel_scale);
const targetWords = this.normalizeOptionalPositiveInt(object.target_words) ?? this.normalizeOptionalPositiveInt(dto.target_words);
const targetChapters = this.normalizeOptionalPositiveInt(object.target_chapters) ??
this.normalizeOptionalPositiveInt(dto.target_chapters) ??
this.defaultTargetChapters(scale);
return {
id: this.readString(object.id) ?? `proposal-${index + 1}`,
title: title ?? `${genre}小说方案 ${index + 1}`,
genre,
novel_scale: scale,
target_audience: this.readString(object.target_audience) ?? this.normalizeOptionalText(dto.target_audience) ?? '大众读者',
target_words: targetWords,
target_chapters: targetChapters,
style_code: this.readString(object.style_code) ?? this.normalizeOptionalText(dto.style_code) ?? this.defaultStyleCode(genre),
logline: logline ?? '主角在极限压迫下反击命运,完成身份、关系和事业的多线逆袭。',
opening_hook: this.readString(object.opening_hook) ?? this.readString(object.hook) ?? '开局必须在 800 字内出现强冲突和反转。',
main_character: this.readString(object.main_character) ?? this.stringifyCompact(object.protagonist) ?? '高共情主角,目标明确,行动力强。',
core_conflict: this.readString(object.core_conflict) ?? '主角目标与强势对手、亲密关系、阶层规则发生持续冲突。',
selling_points: this.readStringArray(object.selling_points),
risk_notes: this.readStringArray(object.risk_notes),
adaptation_targets: this.readStringArray(object.adaptation_targets).length
? this.readStringArray(object.adaptation_targets)
: this.normalizeStringList(dto.adaptation_targets, DEFAULT_ADAPTATION_TARGETS),
volume_plan: this.readArray(object.volume_plan ?? object.volume_structure),
chapter_blueprint: this.readArray(object.chapter_blueprint ?? object.chapter_directory),
writing_rules: this.readStringArray(object.writing_rules),
forbidden_rules: this.readStringArray(object.forbidden_rules),
score: this.normalizeScore(object.score ?? object.market_score),
operator_reason: this.readString(object.operator_reason) ?? this.readString(object.recommend_reason) ?? '商业化潜力较稳,适合继续扩写。'
};
}
private fallbackProposals(dto: GenerateNovelWizardProposalsDto): NovelWizardProposal[] {
const genre = this.normalizeOptionalText(dto.genre) ?? '都市爽文';
const scale = this.resolveNovelScale(dto.novel_scale);
const audience = this.normalizeOptionalText(dto.target_audience) ?? '大众读者';
const targets = this.normalizeStringList(dto.adaptation_targets, DEFAULT_ADAPTATION_TARGETS);
const inspiration = this.normalizeOptionalText(dto.inspiration_text) ?? '主角在低谷中反击命运';
const base = [
['逆风翻盘型', '被羞辱的主角在关键场合拿出证据,反手撕开多年骗局。'],
['身份反转型', '所有人都以为主角无依无靠,直到隐藏身份被迫曝光。'],
['情感复仇型', '亲密关系背叛后,主角用冷静布局夺回人生主动权。']
];
return base.map(([suffix, hook], index) => ({
id: `fallback-${index + 1}`,
title: `${genre}${suffix}`,
genre,
novel_scale: scale,
target_audience: audience,
target_words: this.normalizeOptionalPositiveInt(dto.target_words) ?? null,
target_chapters: this.normalizeOptionalPositiveInt(dto.target_chapters) ?? this.defaultTargetChapters(scale),
style_code: this.normalizeOptionalText(dto.style_code) ?? this.defaultStyleCode(genre),
logline: `${inspiration},在连续冲突中完成成长、复仇与关系重塑。`,
opening_hook: hook,
main_character: '外柔内韧、目标明确、能主动行动的主角。',
core_conflict: '主角与旧关系、权力结构、利益集团持续对抗,每卷升级一次敌人层级。',
selling_points: ['开局强钩子', '身份反转', '连续打脸', '感情拉扯', '适合短剧切片'],
risk_notes: ['需要避免只靠误会推进', '每章必须有新增信息或关系变化'],
adaptation_targets: targets,
volume_plan: [
{ volume_no: 1, title: '低谷反击', goal: '建立主角困境、第一轮反击和核心敌人。' },
{ volume_no: 2, title: '身份升级', goal: '扩大关系网和敌人层级,埋下终局伏笔。' },
{ volume_no: 3, title: '终局清算', goal: '集中收束伏笔,完成情感和事业胜利。' }
],
chapter_blueprint: [
{ chapter_no: 1, title: '开局羞辱', hook: '主角被逼到台前,必须当场做选择。' },
{ chapter_no: 2, title: '证据初现', hook: '第一份证据出现,但更大的秘密被压住。' },
{ chapter_no: 3, title: '反手一击', hook: '对手以为赢了,主角公开反击。' }
],
writing_rules: ['每章必须有冲突升级', '主角必须主动行动', '结尾留下下一章钩子'],
forbidden_rules: ['不得水剧情', '不得让主角长期被动挨打', '不得忘记已埋伏笔'],
score: 86 - index,
operator_reason: '这是兜底方案,可用于模型暂时没有返回标准 JSON 时继续测试流程。'
}));
}
private resolveNovelScale(value: unknown): 'short' | 'medium' | 'long' {
const text = this.normalizeOptionalText(value);
if (text === 'short' || text === 'medium' || text === 'long') return text;
return 'medium';
}
private defaultTargetChapters(scale: 'short' | 'medium' | 'long') {
return DEFAULT_TARGET_CHAPTERS_BY_SCALE[scale];
}
private createProposalCleanText(proposal: NovelWizardProposal) {
return [
`${proposal.title}`,
'',
`开场钩子:${proposal.opening_hook}`,
'',
`小说简介:${proposal.logline}`
].join('\n');
}
private normalizeOptionalPositiveInt(value: unknown) {
if (value === undefined || value === null || value === '') return null;
const numberValue = Number(value);
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : null;
}
private normalizeScore(value: unknown) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) return 80;
return Math.max(0, Math.min(100, Math.round(numberValue)));
}
private normalizeStringList(value: unknown, fallback: string[] = []) {
if (Array.isArray(value)) {
return value.map((item) => this.normalizeOptionalText(item)).filter((item): item is string => Boolean(item));
}
const text = this.normalizeOptionalText(value);
if (!text) return fallback;
return text.split(/[,\n,、]/).map((item) => item.trim()).filter(Boolean);
}
private readStringArray(value: unknown) {
if (Array.isArray(value)) {
return value
.map((item) => this.normalizeOptionalText(item) ?? this.stringifyCompact(item))
.filter((item): item is string => Boolean(item));
}
const text = this.normalizeOptionalText(value);
return text ? [text] : [];
}
private readArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
private readObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
private readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
private normalizeOptionalText(value: unknown) {
const text = this.readString(value);
return text ? text.slice(0, 5000) : null;
}
private stringifyCompact(value: unknown) {
if (value === undefined || value === null) return null;
if (typeof value === 'string') return value.trim() || null;
return JSON.stringify(value).slice(0, 2000);
}
private errorMessage(error: unknown) {
const object = this.readObject(error);
const response = this.readObject(object.response);
const message = this.readString(response.message) ?? this.readString(object.message);
if (message) return message;
if (error instanceof Error && error.message) {
return error.message;
}
return String(error);
}
private defaultStyleCode(genre: string) {
if (genre.includes('玄幻') || genre.includes('仙')) return 'fantasy_cinematic';
if (genre.includes('都市') || genre.includes('豪门')) return 'urban_revenge';
if (genre.includes('悬疑')) return 'suspense_hook';
return 'commercial_serial_novel';
}
private parseId(id: string, message: string) {
try {
const value = BigInt(id);
if (value <= 0n) throw new Error(message);
return value;
} catch {
throw new BadRequestException(message);
}
}
private safeBigInt(value: unknown) {
try {
if (value === undefined || value === null || value === '') return null;
const parsed = BigInt(String(value));
return parsed > 0n ? parsed : null;
} catch {
return null;
}
}
private normalizeListLimit(value: unknown) {
const numberValue = Number(value);
return Number.isInteger(numberValue) && numberValue > 0
? Math.min(numberValue, 100)
: 20;
}
private toJsonValue(value: unknown): Prisma.InputJsonValue {
if (value === undefined || value === null) return {};
return value as Prisma.InputJsonValue;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
CreateNovelGenerationPlanDto,
GenerateNovelIpBibleDto,
RunNovelChapterBatchWorkflowDto,
RunNovelChapterWorkflowDto,
UpdateNovelGenerationPlanDto,
UpdateNovelIpBibleDto
} from './novel-generation.dto';
import { NovelGenerationWorkflowService } from './novel-generation-workflow.service';
@Controller('projects/:projectId/novel-generation')
@UseGuards(JwtAuthGuard)
export class NovelGenerationController {
constructor(
@Inject(NovelGenerationWorkflowService)
private readonly workflow: NovelGenerationWorkflowService
) {}
@Post('plans')
createPlan(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateNovelGenerationPlanDto
) {
return this.workflow.createPlan(user, projectId, dto);
}
@Get('plans')
listPlans(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.workflow.listPlans(user, projectId);
}
@Get('plans/:planId')
getPlan(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string
) {
return this.workflow.getPlan(user, projectId, planId);
}
@Patch('plans/:planId')
updatePlan(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Body() dto: UpdateNovelGenerationPlanDto
) {
return this.workflow.updatePlan(user, projectId, planId, dto);
}
@Delete('plans/:planId')
deletePlan(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string
) {
return this.workflow.deletePlan(user, projectId, planId);
}
@Post('plans/:planId/ip-bible')
generateIpBible(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Body() dto: GenerateNovelIpBibleDto
) {
return this.workflow.generateIpBible(user, projectId, planId, dto);
}
@Post('plans/:planId/ip-bible/jobs')
createIpBibleJob(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Body() dto: GenerateNovelIpBibleDto
) {
return this.workflow.createIpBibleJob(user, projectId, planId, dto);
}
@Get('plans/:planId/ip-bible-jobs/:jobId')
getIpBibleJob(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Param('jobId') jobId: string
) {
return this.workflow.getIpBibleJob(user, projectId, planId, jobId);
}
@Patch('plans/:planId/ip-bible')
updateIpBible(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Body() dto: UpdateNovelIpBibleDto
) {
return this.workflow.updateIpBible(user, projectId, planId, dto);
}
@Post('plans/:planId/chapters/:chapterNo/run')
runSingleChapter(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Param('chapterNo') chapterNo: string,
@Body() dto: RunNovelChapterWorkflowDto
) {
return this.workflow.runSingleChapter(user, projectId, planId, chapterNo, dto);
}
@Post('plans/:planId/chapters/:chapterNo/run/jobs')
createChapterWorkflowJob(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Param('chapterNo') chapterNo: string,
@Body() dto: RunNovelChapterWorkflowDto
) {
return this.workflow.createChapterWorkflowJob(user, projectId, planId, chapterNo, dto);
}
@Get('plans/:planId/chapter-jobs/:jobId')
getChapterWorkflowJob(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Param('jobId') jobId: string
) {
return this.workflow.getChapterWorkflowJob(user, projectId, planId, jobId);
}
@Post('plans/:planId/chapters/batch/jobs')
createChapterBatchWorkflowJob(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Body() dto: RunNovelChapterBatchWorkflowDto
) {
return this.workflow.createChapterBatchWorkflowJob(user, projectId, planId, dto);
}
@Get('plans/:planId/chapter-batch-jobs')
listChapterBatchWorkflowJobs(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string
) {
return this.workflow.listChapterBatchWorkflowJobs(user, projectId, planId);
}
@Get('plans/:planId/chapter-batch-jobs/:jobId')
getChapterBatchWorkflowJob(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Param('jobId') jobId: string
) {
return this.workflow.getChapterBatchWorkflowJob(user, projectId, planId, jobId);
}
@Post('plans/:planId/chapter-batch-jobs/:jobId/pause')
pauseChapterBatchWorkflowJob(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Param('jobId') jobId: string
) {
return this.workflow.pauseChapterBatchWorkflowJob(user, projectId, planId, jobId);
}
@Post('plans/:planId/chapter-batch-jobs/:jobId/resume')
resumeChapterBatchWorkflowJob(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('planId') planId: string,
@Param('jobId') jobId: string
) {
return this.workflow.resumeChapterBatchWorkflowJob(user, projectId, planId, jobId);
}
}
@@ -0,0 +1,51 @@
export class CreateNovelGenerationPlanDto {
source_id?: string;
title?: string;
novel_scale?: 'short' | 'medium' | 'long';
target_words?: number;
target_chapters?: number;
genre?: string;
style_code?: string;
automation_level?: 'L1' | 'L2' | 'L3';
brief?: unknown;
pipeline_config?: unknown;
quality_threshold?: unknown;
}
export class UpdateNovelGenerationPlanDto {
novel_scale?: 'short' | 'medium' | 'long';
target_words?: number | null;
target_chapters?: number | null;
genre?: string | null;
style_code?: string | null;
automation_level?: 'L1' | 'L2' | 'L3';
brief?: unknown;
pipeline_config?: unknown;
quality_threshold?: unknown;
status?: string;
}
export class GenerateNovelIpBibleDto {
provider_code?: string;
allow_fallback?: boolean;
variables?: Record<string, unknown>;
}
export class UpdateNovelIpBibleDto {
ip_bible?: unknown;
}
export class RunNovelChapterWorkflowDto {
provider_code?: string;
allow_fallback?: boolean;
target_words?: number;
max_repair_attempts?: number;
force_repair?: boolean;
variables?: Record<string, unknown>;
}
export class RunNovelChapterBatchWorkflowDto extends RunNovelChapterWorkflowDto {
start_chapter_no?: number;
end_chapter_no?: number;
failure_strategy?: 'pause_on_failure' | 'continue_on_failure';
}
@@ -0,0 +1,260 @@
import type {
AgentRun,
NovelChapterVersion,
NovelContextMemory,
NovelDerivativeJob,
NovelGenerationPlan,
NovelQualityReport,
NovelVersionSnapshot,
Prisma
} from '@prisma/client';
import { toSafeAgentRun, type SafeAgentRun } from './novel-agent.types';
export interface SafeNovelGenerationPlan {
id: string;
project_id: string;
novel_source_id: string | null;
user_id: string;
novel_scale: string;
target_words: number | null;
target_chapters: number | null;
genre: string | null;
style_code: string | null;
brief_json: Prisma.JsonValue | null;
ip_bible_json: Prisma.JsonValue | null;
volume_plan_json: Prisma.JsonValue | null;
pipeline_config_json: Prisma.JsonValue | null;
quality_threshold_json: Prisma.JsonValue | null;
automation_level: string;
status: string;
current_chapter_no: number;
created_at: string;
updated_at: string;
}
export interface SafeNovelChapterVersion {
id: string;
project_id: string;
novel_source_id: string | null;
novel_chapter_id: string | null;
chapter_no: number;
version_no: number;
version_type: string;
title: string | null;
content_text: string | null;
content_json: Prisma.JsonValue | null;
source_agent: string | null;
provider_code: string | null;
model_name: string | null;
quality_score: number | null;
status: string;
created_at: string;
}
export interface SafeNovelContextMemory {
id: string;
project_id: string;
novel_source_id: string | null;
chapter_no: number | null;
memory_type: string;
memory_text: string | null;
memory_json: Prisma.JsonValue | null;
importance_level: number;
status: string;
created_at: string;
}
export interface SafeNovelQualityReport {
id: string;
project_id: string;
novel_source_id: string | null;
novel_chapter_id: string | null;
chapter_no: number | null;
report_type: string;
total_score: number | null;
score_json: Prisma.JsonValue | null;
problems_json: Prisma.JsonValue | null;
suggestions_json: Prisma.JsonValue | null;
pass_status: boolean;
rewrite_required: boolean;
source_agent: string | null;
provider_code: string | null;
model_name: string | null;
created_at: string;
}
export interface SafeNovelVersionSnapshot {
id: string;
project_id: string;
novel_source_id: string;
version_no: number;
title: string | null;
snapshot_scope: string;
chapter_start: number | null;
chapter_end: number | null;
source_hash: string | null;
snapshot_text: string | null;
snapshot_json: Prisma.JsonValue | null;
created_for: string | null;
created_by_user_id: string | null;
created_at: string;
}
export interface SafeNovelDerivativeJob {
id: string;
project_id: string;
novel_source_id: string;
snapshot_id: string | null;
user_id: string;
derivative_type: string;
target_project_id: string | null;
target_ref_id: string | null;
chapter_start: number | null;
chapter_end: number | null;
config_json: Prisma.JsonValue | null;
status: string;
progress_json: Prisma.JsonValue | null;
error_message: string | null;
created_at: string;
updated_at: string;
}
export interface NovelWorkflowStepRuns {
ip_bible?: SafeAgentRun;
chapter_card?: SafeAgentRun;
draft?: SafeAgentRun;
polish?: SafeAgentRun;
continuity?: SafeAgentRun;
quality?: SafeAgentRun;
repair?: SafeAgentRun[];
memory?: SafeAgentRun;
}
export function toSafeNovelGenerationPlan(plan: NovelGenerationPlan): SafeNovelGenerationPlan {
return {
id: plan.id.toString(),
project_id: plan.project_id.toString(),
novel_source_id: plan.novel_source_id?.toString() ?? null,
user_id: plan.user_id.toString(),
novel_scale: plan.novel_scale,
target_words: plan.target_words,
target_chapters: plan.target_chapters,
genre: plan.genre,
style_code: plan.style_code,
brief_json: plan.brief_json,
ip_bible_json: plan.ip_bible_json,
volume_plan_json: plan.volume_plan_json,
pipeline_config_json: plan.pipeline_config_json,
quality_threshold_json: plan.quality_threshold_json,
automation_level: plan.automation_level,
status: plan.status,
current_chapter_no: plan.current_chapter_no,
created_at: plan.created_at.toISOString(),
updated_at: plan.updated_at.toISOString()
};
}
export function toSafeNovelChapterVersion(
version: NovelChapterVersion
): SafeNovelChapterVersion {
return {
id: version.id.toString(),
project_id: version.project_id.toString(),
novel_source_id: version.novel_source_id?.toString() ?? null,
novel_chapter_id: version.novel_chapter_id?.toString() ?? null,
chapter_no: version.chapter_no,
version_no: version.version_no,
version_type: version.version_type,
title: version.title,
content_text: version.content_text,
content_json: version.content_json,
source_agent: version.source_agent,
provider_code: version.provider_code,
model_name: version.model_name,
quality_score: version.quality_score ? Number(version.quality_score.toString()) : null,
status: version.status,
created_at: version.created_at.toISOString()
};
}
export function toSafeNovelContextMemory(memory: NovelContextMemory): SafeNovelContextMemory {
return {
id: memory.id.toString(),
project_id: memory.project_id.toString(),
novel_source_id: memory.novel_source_id?.toString() ?? null,
chapter_no: memory.chapter_no,
memory_type: memory.memory_type,
memory_text: memory.memory_text,
memory_json: memory.memory_json,
importance_level: memory.importance_level,
status: memory.status,
created_at: memory.created_at.toISOString()
};
}
export function toSafeNovelQualityReport(report: NovelQualityReport): SafeNovelQualityReport {
return {
id: report.id.toString(),
project_id: report.project_id.toString(),
novel_source_id: report.novel_source_id?.toString() ?? null,
novel_chapter_id: report.novel_chapter_id?.toString() ?? null,
chapter_no: report.chapter_no,
report_type: report.report_type,
total_score: report.total_score ? Number(report.total_score.toString()) : null,
score_json: report.score_json,
problems_json: report.problems_json,
suggestions_json: report.suggestions_json,
pass_status: report.pass_status,
rewrite_required: report.rewrite_required,
source_agent: report.source_agent,
provider_code: report.provider_code,
model_name: report.model_name,
created_at: report.created_at.toISOString()
};
}
export function toSafeNovelVersionSnapshot(
snapshot: NovelVersionSnapshot
): SafeNovelVersionSnapshot {
return {
id: snapshot.id.toString(),
project_id: snapshot.project_id.toString(),
novel_source_id: snapshot.novel_source_id.toString(),
version_no: snapshot.version_no,
title: snapshot.title,
snapshot_scope: snapshot.snapshot_scope,
chapter_start: snapshot.chapter_start,
chapter_end: snapshot.chapter_end,
source_hash: snapshot.source_hash,
snapshot_text: snapshot.snapshot_text,
snapshot_json: snapshot.snapshot_json,
created_for: snapshot.created_for,
created_by_user_id: snapshot.created_by_user_id?.toString() ?? null,
created_at: snapshot.created_at.toISOString()
};
}
export function toSafeNovelDerivativeJob(job: NovelDerivativeJob): SafeNovelDerivativeJob {
return {
id: job.id.toString(),
project_id: job.project_id.toString(),
novel_source_id: job.novel_source_id.toString(),
snapshot_id: job.snapshot_id?.toString() ?? null,
user_id: job.user_id.toString(),
derivative_type: job.derivative_type,
target_project_id: job.target_project_id?.toString() ?? null,
target_ref_id: job.target_ref_id?.toString() ?? null,
chapter_start: job.chapter_start,
chapter_end: job.chapter_end,
config_json: job.config_json,
status: job.status,
progress_json: job.progress_json,
error_message: job.error_message,
created_at: job.created_at.toISOString(),
updated_at: job.updated_at.toISOString()
};
}
export function toSafeAgentRuns(runs: AgentRun[]) {
return runs.map(toSafeAgentRun);
}
@@ -33,6 +33,49 @@ https://example.com
expect(parsed.chapters[1].content).toContain('完整方案');
});
it('splits GPT pasted chapters by standalone divider headings', () => {
const parsed = service.parseText(`
610
6m 51s
===== 6 =====
###===7===###
`);
expect(parsed.chapter_count).toBe(2);
expect(parsed.parse_report.strategy).toBe('heading');
expect(parsed.chapters[0].title).toBe('第6章:直播里的耳光');
expect(parsed.chapters[0].content).not.toContain('已思考');
expect(parsed.chapters[1].title).toBe('第7章:她把证据甩上桌');
});
it('binds chapter headings to the current volume heading', () => {
const parsed = service.parseText(`
1
2
`);
expect(parsed.chapter_count).toBe(2);
expect(parsed.chapters[0]).toMatchObject({
volume_no: 1,
volume_title: '第一卷:离婚夜',
title: '第1章:她签了'
});
expect(parsed.chapters[1]).toMatchObject({
volume_no: 2,
volume_title: '第二卷:反击',
title: '第2章:她反击'
});
});
it('falls back to chunk splitting when headings are missing', () => {
const parsed = service.parseText(
'她醒来时,窗外正在下雨。她意识到命运已经重新开始,于是把所有证据重新整理,准备迎接第一场反击。'
+112 -6
View File
@@ -13,6 +13,8 @@ export interface ExtractedText {
}
export interface ParsedChapterDraft {
volume_no: number | null;
volume_title: string | null;
chapter_no: number;
title: string;
content: string;
@@ -84,6 +86,8 @@ export class NovelParserService {
const splitResult = this.splitChapters(cleaned.cleanText);
const chapters = splitResult.chapters.map((chapter, index) => ({
volume_no: chapter.volume_no,
volume_title: chapter.volume_title,
chapter_no: index + 1,
title: chapter.title || `${index + 1}`,
content: chapter.content,
@@ -155,7 +159,9 @@ export class NovelParserService {
private splitChapters(cleanText: string) {
const lines = cleanText.split('\n');
const chapters: Array<{ title: string; content: string }> = [];
const chapters: Array<{ volume_no: number | null; volume_title: string | null; title: string; content: string }> = [];
let currentVolumeNo: number | null = null;
let currentVolumeTitle: string | null = null;
let currentTitle = '';
let currentLines: string[] = [];
let foundHeading = false;
@@ -164,6 +170,8 @@ export class NovelParserService {
const content = currentLines.join('\n').trim();
if (content) {
chapters.push({
volume_no: currentVolumeNo,
volume_title: currentVolumeTitle,
title: currentTitle,
content
});
@@ -172,11 +180,26 @@ export class NovelParserService {
};
for (const line of lines) {
const volume = this.matchVolumeHeading(line);
if (volume) {
if (foundHeading && currentLines.join('\n').trim()) {
flush();
} else {
currentLines = [];
}
currentVolumeNo = volume.volume_no;
currentVolumeTitle = volume.volume_title;
continue;
}
const heading = this.matchChapterHeading(line);
if (heading) {
if (foundHeading || currentLines.join('').trim()) {
if (foundHeading) {
flush();
} else {
currentLines = [];
}
currentTitle = heading;
foundHeading = true;
@@ -203,13 +226,15 @@ export class NovelParserService {
private splitByLength(cleanText: string) {
const paragraphs = cleanText.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean);
const chapters: Array<{ title: string; content: string }> = [];
const chapters: Array<{ volume_no: number | null; volume_title: string | null; title: string; content: string }> = [];
let chunk: string[] = [];
let chunkLength = 0;
for (const paragraph of paragraphs) {
if (chunkLength > 0 && chunkLength + paragraph.length > MAX_CHAPTER_CHARS) {
chapters.push({
volume_no: null,
volume_title: null,
title: `${chapters.length + 1}`,
content: chunk.join('\n\n')
});
@@ -223,6 +248,8 @@ export class NovelParserService {
if (chunk.length > 0) {
chapters.push({
volume_no: null,
volume_title: null,
title: `${chapters.length + 1}`,
content: chunk.join('\n\n')
});
@@ -230,18 +257,40 @@ export class NovelParserService {
return chapters.length > 0
? chapters
: [{ title: '第1段', content: cleanText }];
: [{ volume_no: null, volume_title: null, title: '第1段', content: cleanText }];
}
private matchVolumeHeading(line: string) {
const trimmed = this.normalizeChapterHeadingLine(line);
if (!trimmed || trimmed.length > 80) {
return null;
}
const match = /^第\s*([零一二三四五六七八九十百千万两\d]+)\s*卷[\s::、.-]*(.+)?$/.exec(trimmed) ??
/^(序卷|楔子卷|番外卷)[\s::、.-]*(.+)?$/.exec(trimmed);
if (!match) {
return null;
}
const volumeNo = match[1] ? this.cnNumberToInt(match[1]) : null;
return {
volume_no: volumeNo,
volume_title: trimmed
};
}
private matchChapterHeading(line: string) {
const trimmed = line.trim();
const trimmed = this.normalizeChapterHeadingLine(line);
if (!trimmed || trimmed.length > 80) {
return null;
}
const patterns = [
/^第[零一二三四五六七八九十百千万两\d]+[章节回集部篇][\s::、.-]*(.+)?$/,
/^第[零一二三四五六七八九十百千万两\d]+[章节回集部篇][\s::、.-]*(.+)?$/,
/^chapter\s*\d+[\s::、.-]*(.+)?$/i,
/^\d{1,4}[\s、._-]+(.+)$/,
/^(序章|楔子|前言|正文|番外(?:篇|外)?(?:\s*\d+)?|尾声|后记)$/
@@ -250,6 +299,63 @@ export class NovelParserService {
return patterns.some((pattern) => pattern.test(trimmed)) ? trimmed : null;
}
private normalizeChapterHeadingLine(line: string) {
return line
.trim()
.replace(/^#{1,6}\s*/, '')
.replace(/\s*#{1,6}$/, '')
.replace(/^[=]{3,}\s*/, '')
.replace(/\s*[=]{3,}$/, '')
.replace(/^#{1,6}\s*/, '')
.replace(/\s*#{1,6}$/, '')
.replace(/^\*\*\s*(.*?)\s*\*\*$/, '$1')
.trim();
}
private cnNumberToInt(value: string) {
const text = value.trim();
const numeric = Number(text);
if (Number.isInteger(numeric) && numeric > 0) {
return numeric;
}
const digits: Record<string, number> = {
: 0,
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9
};
let total = 0;
let section = 0;
let number = 0;
const units: Record<string, number> = { : 10, : 100, : 1000 };
for (const char of text) {
if (char in digits) {
number = digits[char];
} else if (char in units) {
section += (number || 1) * units[char];
number = 0;
} else if (char === '万') {
total += (section + number || 1) * 10000;
section = 0;
number = 0;
}
}
const result = total + section + number;
return result > 0 ? result : null;
}
private buildSummary(content: string) {
return this.compact(content).slice(0, 180);
}
@@ -0,0 +1,179 @@
import { Injectable } from '@nestjs/common';
import type { NovelGenerationPlan, Project } from '@prisma/client';
import type { NovelChapterContext } from './novel-context-builder.service';
@Injectable()
export class NovelPromptBuilderService {
buildIpBibleVariables(input: {
project: Project;
plan: NovelGenerationPlan;
extra?: Record<string, unknown>;
}) {
const brief = this.readObject(input.plan.brief_json);
return {
brief: this.stringifyPretty(input.extra?.brief ?? brief),
novel_scale: input.plan.novel_scale,
genre: input.plan.genre ?? input.project.genre ?? this.readString(brief.genre) ?? '通用题材',
style: input.plan.style_code ?? input.project.style_code ?? this.readString(brief.style) ?? '高质量中文小说',
avoid: this.stringifyPretty(input.extra?.avoid ?? brief.avoid ?? brief.forbidden_rules ?? []),
project_title: input.project.title ?? this.readString(brief.title_working) ?? '未命名小说',
target_words: input.plan.target_words,
target_chapters: input.plan.target_chapters,
...(input.extra ?? {})
};
}
buildChapterCardVariables(context: NovelChapterContext, extra?: Record<string, unknown>) {
return {
chapter_no: context.chapter_no,
ip_bible_summary: context.ip_bible_summary,
volume_outline: this.stringifyPretty(context.volume_outline),
source_design: this.stringifyPretty(context.source_design),
planned_chapter_outline: this.stringifyPretty(context.planned_chapter_outline),
recent_summaries: context.recent_summaries.join('\n'),
context_snapshot: this.stringifyPretty(context.context_snapshot),
canonical_facts: context.canonical_facts.join('\n'),
character_states: this.stringifyPretty(context.character_states),
active_foreshadows: this.stringifyPretty(context.active_foreshadows),
next_chapter_must_continue: this.stringifyPretty(context.next_chapter_must_continue),
forbidden_rules: context.forbidden_rules,
...(extra ?? {})
};
}
buildNovelWriterVariables(
context: NovelChapterContext,
chapterCard: unknown,
targetWords: number,
extra?: Record<string, unknown>
) {
return {
chapter_no: context.chapter_no,
target_words: targetWords,
style_rules: context.style_rules,
context_snapshot: this.stringifyPretty(context.context_snapshot),
planned_chapter_outline: this.stringifyPretty(context.planned_chapter_outline),
canonical_facts: context.canonical_facts.join('\n'),
ip_bible_summary: context.ip_bible_summary,
character_profiles: this.stringifyPretty(context.character_profiles),
character_states: this.stringifyPretty(context.character_states),
recent_summaries: context.recent_summaries.join('\n'),
active_foreshadows: this.stringifyPretty(context.active_foreshadows),
next_chapter_must_continue: this.stringifyPretty(context.next_chapter_must_continue),
forbidden_to_forget: this.stringifyPretty(context.forbidden_to_forget),
chapter_card: this.stringifyPretty(chapterCard),
forbidden_rules: context.forbidden_rules,
...(extra ?? {})
};
}
buildPolishVariables(
context: NovelChapterContext,
chapterCard: unknown,
draftText: string,
extra?: Record<string, unknown>
) {
return {
style_rules: context.style_rules,
context_snapshot: this.stringifyPretty(context.context_snapshot),
planned_chapter_outline: this.stringifyPretty(context.planned_chapter_outline),
canonical_facts: context.canonical_facts.join('\n'),
chapter_card: this.stringifyPretty(chapterCard),
forbidden_rules: context.forbidden_rules,
draft_text: draftText,
...(extra ?? {})
};
}
buildContinuityCheckVariables(
context: NovelChapterContext,
chapterCard: unknown,
chapterText: string,
extra?: Record<string, unknown>
) {
return {
ip_bible: this.stringifyPretty(context.ip_bible),
context_snapshot: this.stringifyPretty(context.context_snapshot),
planned_chapter_outline: this.stringifyPretty(context.planned_chapter_outline),
canonical_facts: context.canonical_facts.join('\n'),
character_states: this.stringifyPretty(context.character_states),
active_foreshadows: this.stringifyPretty(context.active_foreshadows),
chapter_card: this.stringifyPretty(chapterCard),
chapter_text: chapterText,
...(extra ?? {})
};
}
buildQualityCheckVariables(
context: NovelChapterContext,
chapterCard: unknown,
continuityCheck: unknown,
chapterText: string,
extra?: Record<string, unknown>
) {
return {
ip_bible: this.stringifyPretty(context.ip_bible),
context_snapshot: this.stringifyPretty(context.context_snapshot),
planned_chapter_outline: this.stringifyPretty(context.planned_chapter_outline),
canonical_facts: context.canonical_facts.join('\n'),
chapter_card: this.stringifyPretty(chapterCard),
continuity_check: this.stringifyPretty(continuityCheck),
chapter_text: chapterText,
...(extra ?? {})
};
}
buildRepairVariables(
context: NovelChapterContext,
chapterCard: unknown,
chapterText: string,
qualityReport: unknown,
continuityCheck: unknown,
extra?: Record<string, unknown>
) {
return {
ip_bible_summary: context.ip_bible_summary,
context_snapshot: this.stringifyPretty(context.context_snapshot),
planned_chapter_outline: this.stringifyPretty(context.planned_chapter_outline),
canonical_facts: context.canonical_facts.join('\n'),
chapter_card: this.stringifyPretty(chapterCard),
chapter_text: chapterText,
quality_report: this.stringifyPretty(qualityReport),
continuity_check: this.stringifyPretty(continuityCheck),
...(extra ?? {})
};
}
buildMemoryUpdateVariables(
context: NovelChapterContext,
chapterText: string,
extra?: Record<string, unknown>
) {
return {
character_states: this.stringifyPretty(context.character_states),
active_foreshadows: this.stringifyPretty(context.active_foreshadows),
context_snapshot: this.stringifyPretty(context.context_snapshot),
planned_chapter_outline: this.stringifyPretty(context.planned_chapter_outline),
canonical_facts: context.canonical_facts.join('\n'),
chapter_text: chapterText,
...(extra ?? {})
};
}
private stringifyPretty(value: unknown) {
if (value === undefined || value === null) return '';
if (typeof value === 'string') return value;
return JSON.stringify(value, null, 2);
}
private readObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
private readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
}
+2
View File
@@ -19,6 +19,8 @@ export class ParseNovelDto {
}
export class UpdateNovelChapterDto {
volume_no?: number | string | null;
volume_title?: string | null;
title?: string;
content?: string;
summary?: string;
+39
View File
@@ -20,6 +20,13 @@ export interface SafeNovelSource {
chapter_count: number | null;
parse_status: string;
parse_report: Prisma.JsonValue | null;
ip_bible_json: Prisma.JsonValue | null;
design_json: Prisma.JsonValue | null;
volume_plan_json: Prisma.JsonValue | null;
ai_provider_code: string | null;
ai_model_name: string | null;
ai_cost_estimate: number | null;
ai_cost_actual: number | null;
created_at: string;
}
@@ -27,13 +34,21 @@ export interface SafeNovelChapter {
id: string;
project_id: string;
novel_source_id: string | null;
volume_no: number | null;
volume_title: string | null;
chapter_no: number;
title: string | null;
content: string;
summary: string | null;
visual_summary: string | null;
outline_json: Prisma.JsonValue | null;
analysis_json: Prisma.JsonValue | null;
word_count: number | null;
status: string;
ai_provider_code: string | null;
ai_model_name: string | null;
ai_cost_estimate: number | null;
ai_cost_actual: number | null;
created_at: string;
}
@@ -60,22 +75,46 @@ export function toSafeNovelSource(source: NovelSource): SafeNovelSource {
chapter_count: source.chapter_count,
parse_status: source.parse_status,
parse_report: source.parse_report,
ip_bible_json: sourceIpBibleFromReport(source.parse_report),
design_json: source.design_json,
volume_plan_json: source.volume_plan_json,
ai_provider_code: source.ai_provider_code,
ai_model_name: source.ai_model_name,
ai_cost_estimate: source.ai_cost_estimate ? Number(source.ai_cost_estimate.toString()) : null,
ai_cost_actual: source.ai_cost_actual ? Number(source.ai_cost_actual.toString()) : null,
created_at: source.created_at.toISOString()
};
}
function sourceIpBibleFromReport(value: Prisma.JsonValue | null): Prisma.JsonValue | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const ipBible = (value as Record<string, unknown>).ip_bible_json;
return ipBible && typeof ipBible === 'object' && !Array.isArray(ipBible)
? ipBible as Prisma.JsonValue
: null;
}
export function toSafeNovelChapter(chapter: NovelChapter): SafeNovelChapter {
return {
id: chapter.id.toString(),
project_id: chapter.project_id.toString(),
novel_source_id: chapter.novel_source_id?.toString() ?? null,
volume_no: chapter.volume_no,
volume_title: chapter.volume_title,
chapter_no: chapter.chapter_no,
title: chapter.title,
content: chapter.content,
summary: chapter.summary,
visual_summary: chapter.visual_summary,
outline_json: chapter.outline_json,
analysis_json: chapter.analysis_json,
word_count: chapter.word_count,
status: chapter.status,
ai_provider_code: chapter.ai_provider_code,
ai_model_name: chapter.ai_model_name,
ai_cost_estimate: chapter.ai_cost_estimate ? Number(chapter.ai_cost_estimate.toString()) : null,
ai_cost_actual: chapter.ai_cost_actual ? Number(chapter.ai_cost_actual.toString()) : null,
created_at: chapter.created_at.toISOString()
};
}
+35 -4
View File
@@ -1,16 +1,47 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AssetsModule } from '../assets/assets.module';
import { ProvidersModule } from '../providers/providers.module';
import { NovelGenerationController } from './novel-generation.controller';
import { NovelGenerationWorkflowService } from './novel-generation-workflow.service';
import { NovelCreationWizardController } from './novel-creation-wizard.controller';
import { NovelCreationWizardService } from './novel-creation-wizard.service';
import { NovelContextBuilderService } from './novel-context-builder.service';
import { NovelPromptBuilderService } from './novel-prompt-builder.service';
import { NovelsController } from './novels.controller';
import { NovelParserService } from './novel-parser.service';
import { NovelAgentService } from './novel-agent.service';
import { NovelsService } from './novels.service';
import { OriginalNovelMockService } from './original-novel-mock.service';
import { OriginalNovelsController } from './original-novels.controller';
@Module({
imports: [AuthModule, AssetsModule],
controllers: [NovelsController, OriginalNovelsController],
providers: [NovelParserService, NovelsService, OriginalNovelMockService],
exports: [NovelParserService, NovelsService, OriginalNovelMockService]
imports: [AuthModule, AssetsModule, ProvidersModule],
controllers: [
NovelsController,
OriginalNovelsController,
NovelGenerationController,
NovelCreationWizardController
],
providers: [
NovelParserService,
NovelAgentService,
NovelContextBuilderService,
NovelPromptBuilderService,
NovelGenerationWorkflowService,
NovelCreationWizardService,
NovelsService,
OriginalNovelMockService
],
exports: [
NovelParserService,
NovelAgentService,
NovelContextBuilderService,
NovelPromptBuilderService,
NovelGenerationWorkflowService,
NovelCreationWizardService,
NovelsService,
OriginalNovelMockService
]
})
export class NovelsModule {}
@@ -63,6 +63,8 @@ function createChapter(overrides: Partial<NovelChapter> = {}): NovelChapter {
id: 30n,
project_id: 10n,
novel_source_id: 20n,
volume_no: null,
volume_title: null,
chapter_no: 1,
title: '第1章 重生',
content: '她醒来后开始反击。',
@@ -116,6 +118,8 @@ const parsedText: ParsedNovelText = {
chapter_count: 1,
chapters: [
{
volume_no: null,
volume_title: null,
chapter_no: 1,
title: '第1章 重生',
content: '她醒来后开始反击。',
+25 -1
View File
@@ -173,9 +173,17 @@ export class NovelsService {
}
await this.findProjectForUser(chapter.project_id.toString(), user);
const data: Partial<NovelChapter> = {};
const data: Prisma.NovelChapterUncheckedUpdateInput = {};
let edited = false;
if ('volume_no' in dto) {
data.volume_no = this.normalizeOptionalPositiveInt(dto.volume_no, 'volume_no') ?? null;
edited = true;
}
if ('volume_title' in dto) {
data.volume_title = this.normalizeOptionalText(dto.volume_title ?? undefined) ?? null;
edited = true;
}
if ('title' in dto) {
data.title = this.normalizeOptionalText(dto.title) ?? null;
edited = true;
@@ -302,6 +310,8 @@ export class NovelsService {
data: parsed.chapters.map((chapter) => ({
project_id: project.id,
novel_source_id: source.id,
volume_no: chapter.volume_no,
volume_title: chapter.volume_title,
chapter_no: chapter.chapter_no,
title: chapter.title,
content: chapter.content,
@@ -468,6 +478,20 @@ export class NovelsService {
return normalized || undefined;
}
private normalizeOptionalPositiveInt(value: number | string | null | undefined, field: string) {
if (value === undefined || value === null || value === '') {
return undefined;
}
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < 1 || numberValue > 10000) {
throw new BadRequestException(`${field} must be a positive integer`);
}
return numberValue;
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
@@ -3,11 +3,13 @@ import {
ForbiddenException,
Inject,
Injectable,
NotFoundException
NotFoundException,
Optional
} from '@nestjs/common';
import type { NovelChapter, NovelSource, Prisma, Project } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { ProvidersService } from '../providers/providers.service';
import { NovelParserService } from './novel-parser.service';
import {
GenerateOriginalChaptersDto,
@@ -32,19 +34,22 @@ export class OriginalNovelMockService {
@Inject(PrismaService)
private readonly prisma: PrismaService,
@Inject(NovelParserService)
private readonly parser: NovelParserService
private readonly parser: NovelParserService,
@Optional() @Inject(ProvidersService) private readonly providersService?: ProvidersService
) {}
async generateIdea(user: AuthRequestUser, projectId: string, dto: GenerateOriginalIdeaDto) {
const project = await this.findProjectForUser(projectId, user);
this.assertOriginalProject(project);
const idea = this.buildIdea(project, dto);
const fallbackIdea = this.buildIdea(project, dto);
const idea =
(await this.tryGenerateIdeaWithProvider(project, dto, fallbackIdea)) ?? fallbackIdea;
const report: OriginalNovelReport = {
provider: 'mock_novel_provider',
provider: dto.provider_code?.trim() || 'smart_novel_provider',
mode: 'ai_original',
stage: 'idea',
idea,
warnings: ['阶段 07 使用 deterministic mock,不调用真实 AI Provider。']
warnings: dto.provider_code?.trim() ? [] : ['自动模式失败时会回落到本地结构化草稿。']
};
const source = await this.prisma.novelSource.create({
data: {
@@ -84,7 +89,10 @@ export class OriginalNovelMockService {
}
const chapterCount = this.resolveChapterCount(dto.target_chapter_count, project);
const outline = this.buildOutline(report.idea, chapterCount);
const fallbackOutline = this.buildOutline(report.idea, chapterCount);
const outline =
(await this.tryGenerateOutlineWithProvider(project, report.idea, chapterCount, fallbackOutline, dto.provider_code)) ??
fallbackOutline;
const nextReport: OriginalNovelReport = {
...report,
stage: 'outline',
@@ -124,9 +132,12 @@ export class OriginalNovelMockService {
const outline =
report.outline ??
this.buildOutline(report.idea, this.resolveChapterCount(dto.target_chapter_count, project));
const chapters = outline.chapters.map((chapter) =>
const fallbackChapters = outline.chapters.map((chapter) =>
this.buildChapter(report.idea as OriginalIdea, chapter)
);
const chapters =
(await this.tryGenerateChaptersWithProvider(project, report.idea as OriginalIdea, outline, fallbackChapters, dto.provider_code)) ??
fallbackChapters;
const rawText = chapters
.map((chapter) => `${chapter.title}\n${chapter.content}`)
.join('\n\n');
@@ -250,6 +261,182 @@ export class OriginalNovelMockService {
};
}
private async tryGenerateIdeaWithProvider(
project: Project,
dto: GenerateOriginalIdeaDto,
fallbackIdea: OriginalIdea
): Promise<OriginalIdea | null> {
if (!this.providersService) return null;
const selectedProvider = this.normalizeProviderCode(dto.provider_code);
const prompt = [
'你是网文短剧原创策划。请严格输出 JSON,不要 Markdown。',
'JSON 字段:title, genre, target_audience, protagonist_name, protagonist_setting, story_mood, selling_points, world_setting, logline, core_conflict, visual_hooks。',
'selling_points 和 visual_hooks 必须是字符串数组。',
`用户输入:${JSON.stringify(dto)}`,
`项目:${project.title ?? '未命名'},题材:${project.genre ?? '通用'}`,
`参考草稿:${JSON.stringify(fallbackIdea)}`
].join('\n\n');
try {
const output = await this.providersService.executeProvider({
provider_type: 'NovelProvider',
preferred_provider_code: selectedProvider,
purpose: 'original_idea_generate',
project_id: project.id.toString(),
input_json: { prompt },
allow_fallback: !selectedProvider,
return_binary: false
});
const parsed = this.parseProviderJson(this.extractProviderText(output.result));
const idea = this.ideaFromObject(parsed);
return idea ?? (selectedProvider ? this.throwInvalidProviderJson('原创选题') : null);
} catch (error) {
if (selectedProvider) {
throw new BadRequestException(`选定小说模型生成选题失败:${this.errorMessage(error)}`);
}
return null;
}
}
private async tryGenerateOutlineWithProvider(
project: Project,
idea: OriginalIdea,
chapterCount: number,
fallbackOutline: OriginalOutline,
providerCode?: string
): Promise<OriginalOutline | null> {
if (!this.providersService) return null;
const selectedProvider = this.normalizeProviderCode(providerCode);
const prompt = [
'你是网文大纲策划。请严格输出 JSON,不要 Markdown。',
'JSON 字段:main_plot, chapter_count, chapters。chapters 每项包含 chapter_no, title, goal, conflict, turning_point, ending_hook。',
`必须生成 ${chapterCount} 章,适合后续改编短视频分集。`,
`选题:${JSON.stringify(idea)}`,
`参考草稿:${JSON.stringify(fallbackOutline)}`
].join('\n\n');
try {
const output = await this.providersService.executeProvider({
provider_type: 'NovelProvider',
preferred_provider_code: selectedProvider,
purpose: 'original_outline_generate',
project_id: project.id.toString(),
input_json: { prompt },
allow_fallback: !selectedProvider,
return_binary: false
});
const parsed = this.parseProviderJson(this.extractProviderText(output.result));
const outline = this.outlineFromObject(parsed, chapterCount);
return outline ?? (selectedProvider ? this.throwInvalidProviderJson('原创大纲') : null);
} catch (error) {
if (selectedProvider) {
throw new BadRequestException(`选定小说模型生成大纲失败:${this.errorMessage(error)}`);
}
return null;
}
}
private async tryGenerateChaptersWithProvider(
project: Project,
idea: OriginalIdea,
outline: OriginalOutline,
fallbackChapters: ReturnType<OriginalNovelMockService['buildChapter']>[],
providerCode?: string
): Promise<ReturnType<OriginalNovelMockService['buildChapter']>[] | null> {
if (!this.providersService) return null;
const selectedProvider = this.normalizeProviderCode(providerCode);
const prompt = [
'你是网文正文作者。请严格输出 JSON,不要 Markdown。',
'JSON 格式:{"chapters":[{"chapter_no":1,"title":"","content":"","summary":"","visual_summary":""}]}',
'要求:每章有冲突、转折、结尾钩子;内容适合后续短剧/漫画改编。',
`选题:${JSON.stringify(idea)}`,
`大纲:${JSON.stringify(outline)}`,
`参考草稿:${JSON.stringify(fallbackChapters)}`
].join('\n\n');
try {
const output = await this.providersService.executeProvider({
provider_type: 'NovelProvider',
preferred_provider_code: selectedProvider,
purpose: 'original_chapters_generate',
project_id: project.id.toString(),
input_json: { prompt },
allow_fallback: !selectedProvider,
return_binary: false
});
const parsed = this.parseProviderJson(this.extractProviderText(output.result));
const chapters = this.chaptersFromObject(parsed, fallbackChapters);
return chapters ?? (selectedProvider ? this.throwInvalidProviderJson('原创正文') : null);
} catch (error) {
if (selectedProvider) {
throw new BadRequestException(`选定小说模型生成正文失败:${this.errorMessage(error)}`);
}
return null;
}
}
private ideaFromObject(value: Record<string, unknown> | null): OriginalIdea | null {
if (!value) return null;
const idea: OriginalIdea = {
title: this.readString(value.title) ?? '',
genre: this.readString(value.genre) ?? 'urban_rebirth',
target_audience: this.readString(value.target_audience) ?? '',
protagonist_name: this.readString(value.protagonist_name) ?? '',
protagonist_setting: this.readString(value.protagonist_setting) ?? '',
story_mood: this.readString(value.story_mood) ?? '',
selling_points: this.readStringArray(value.selling_points),
world_setting: this.readString(value.world_setting) ?? '',
logline: this.readString(value.logline) ?? '',
core_conflict: this.readString(value.core_conflict) ?? '',
visual_hooks: this.readStringArray(value.visual_hooks)
};
return idea.title && idea.protagonist_name && idea.logline ? idea : null;
}
private outlineFromObject(value: Record<string, unknown> | null, chapterCount: number): OriginalOutline | null {
const chapters = Array.isArray(value?.chapters) ? value.chapters : [];
if (!value || chapters.length === 0) return null;
return {
main_plot: this.readString(value.main_plot) ?? '',
chapter_count: chapterCount,
chapters: chapters.slice(0, chapterCount).map((item, index) => {
const object = this.readObject(item);
return {
chapter_no: index + 1,
title: this.readString(object.title) ?? `${index + 1}`,
goal: this.readString(object.goal) ?? '',
conflict: this.readString(object.conflict) ?? '',
turning_point: this.readString(object.turning_point) ?? '',
ending_hook: this.readString(object.ending_hook) ?? ''
};
})
};
}
private chaptersFromObject(
value: Record<string, unknown> | null,
fallbackChapters: ReturnType<OriginalNovelMockService['buildChapter']>[]
) {
const items = Array.isArray(value?.chapters) ? value.chapters : [];
if (items.length === 0) return null;
return items.slice(0, fallbackChapters.length).map((item, index) => {
const object = this.readObject(item);
const fallback = fallbackChapters[index];
const content = this.readString(object.content) ?? fallback.content;
return {
chapter_no: index + 1,
title: this.readString(object.title) ?? fallback.title,
content,
summary: this.readString(object.summary) ?? fallback.summary,
visual_summary: this.readString(object.visual_summary) ?? fallback.visual_summary,
word_count: this.parser.countWords(content)
};
});
}
private buildIdea(project: Project, dto: GenerateOriginalIdeaDto): OriginalIdea {
const genre = this.normalizeOptionalText(dto.genre) ?? project.genre ?? 'urban_rebirth';
const title = this.normalizeOptionalText(dto.title) ?? project.title ?? this.titleForGenre(genre);
@@ -502,6 +689,59 @@ export class OriginalNovelMockService {
return normalized || undefined;
}
private readObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
private readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
private readStringArray(value: unknown) {
return Array.isArray(value)
? value.map((item) => String(item).trim()).filter(Boolean)
: [];
}
private extractProviderText(result: unknown) {
const object = this.readObject(result);
return (
this.readString(object.text) ??
this.readString(object.chapter_text) ??
this.readString(object.raw_text) ??
''
);
}
private parseProviderJson(text: string): Record<string, unknown> | null {
const cleaned = text.replace(/^```(?:json)?/i, '').replace(/```$/i, '').trim();
const jsonText = cleaned.startsWith('{') ? cleaned : cleaned.match(/\{[\s\S]*\}/)?.[0] ?? '';
if (!jsonText) return null;
try {
const parsed = JSON.parse(jsonText) as unknown;
return this.readObject(parsed);
} catch {
return null;
}
}
private normalizeProviderCode(value: string | undefined) {
const normalized = value?.trim();
return normalized || undefined;
}
private throwInvalidProviderJson(stage: string): never {
throw new BadRequestException(`选定小说模型没有返回可用的${stage} JSON`);
}
private errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
+3
View File
@@ -9,16 +9,19 @@ export class GenerateOriginalIdeaDto {
world_setting?: string;
taboo_rules?: string;
target_chapter_count?: number;
provider_code?: string;
}
export class GenerateOriginalOutlineDto {
source_id?: string;
target_chapter_count?: number;
provider_code?: string;
}
export class GenerateOriginalChaptersDto {
source_id?: string;
target_chapter_count?: number;
provider_code?: string;
}
export class OriginalSelfCheckDto {
+1 -1
View File
@@ -38,7 +38,7 @@ export interface OriginalSelfCheckResult {
}
export interface OriginalNovelReport {
provider: 'mock_novel_provider';
provider: string;
mode: 'ai_original';
stage: 'idea' | 'outline' | 'chapters' | 'self_check';
idea?: OriginalIdea;
@@ -0,0 +1,654 @@
export const PRODUCTION_CONTRACT_VERSIONS = {
sourceAnalysis: 'source_analysis_v1',
adaptationBible: 'adaptation_bible_v1',
episodePlan: 'episode_plan_v1',
sceneScript: 'scene_script_v1',
assetPlan: 'asset_plan_v1',
sceneGeography: 'scene_geography_v1',
shotExecution: 'shot_execution_v1',
stageQuality: 'stage_quality_v1',
stageReviewDelta: 'stage_review_delta_v1',
shotQcDelta: 'shot_qc_delta_v1',
promptRule: 'prompt_rule_v1'
} as const;
export type ProductionContractVersion =
(typeof PRODUCTION_CONTRACT_VERSIONS)[keyof typeof PRODUCTION_CONTRACT_VERSIONS];
export interface SourceRefV1 {
source_type: 'novel_snapshot' | 'chapter' | 'story_bible' | 'memory' | 'approved_decision';
source_id: string;
locator?: string;
quote?: string;
}
export interface SourceFactRefV1 {
fact: string;
source_refs: SourceRefV1[];
}
export interface SourceCharacterFactV1 {
character_id?: string;
name: string;
role: string;
goal: string;
fear?: string;
secrets: string[];
state: string;
source_refs: SourceRefV1[];
}
export interface SourceEventV1 {
id: string;
order: number;
event: string;
participants: string[];
consequence: string;
source_refs: SourceRefV1[];
}
export interface SourceAnalysisSpecV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.sourceAnalysis;
source_snapshot_id: string;
premise: string;
genre_promises: string[];
timeline: SourceEventV1[];
characters: SourceCharacterFactV1[];
relationships: SourceFactRefV1[];
world_rules: SourceFactRefV1[];
conflicts: SourceFactRefV1[];
mysteries: SourceFactRefV1[];
emotional_assets: SourceFactRefV1[];
visual_assets: SourceFactRefV1[];
immutable_facts: SourceFactRefV1[];
uncertain_facts: SourceFactRefV1[];
source_refs: SourceRefV1[];
}
export interface AdaptationDecisionV1 {
id: string;
operation: 'keep' | 'compress' | 'merge' | 'reorder' | 'remove' | 'add';
source_refs: SourceRefV1[];
target: string;
reason: string;
impact: string[];
continuity_risk: string;
approval_status: 'draft' | 'approved' | 'rejected';
}
export interface HookPolicyV1 {
mode: 'aggressive_0_3s' | 'suspense_0_5s' | 'cinematic_0_8s';
max_setup_ms: number;
require_visible_question: boolean;
require_episode_payoff: boolean;
max_repeated_hook_type: number;
}
export interface DialoguePolicyV1 {
language: string;
target_chars_per_second: number;
preserve_confirmed_lines: boolean;
require_listener_reaction: boolean;
}
export interface AdaptationBibleSpecV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.adaptationBible;
source_analysis_version_id: string;
format: {
orientation: '16:9';
target_episode_seconds: number;
episode_count_range: [number, number];
audience: string;
};
genre_contract: string;
tone_contract: string;
protagonist_contract: string;
central_dramatic_question: string;
main_arc: string;
character_arcs: Array<{ character_id: string; arc: string }>;
season_beats: Array<{ id: string; beat: string; consequence: string }>;
adaptation_decisions: AdaptationDecisionV1[];
hook_policy: HookPolicyV1;
dialogue_policy: DialoguePolicyV1;
visual_policy: string[];
sound_policy: string[];
prohibited_changes: string[];
source_refs: SourceRefV1[];
}
export type HookTypeV1 =
| 'crisis'
| 'result_first'
| 'identity_contrast'
| 'relationship_break'
| 'impossible_event'
| 'secret_exposure'
| 'countdown'
| 'forced_choice'
| 'high_value_promise'
| 'previous_hook_payoff';
export interface HookTimelineBeatV1 {
start_ms: number;
end_ms: number;
visual: string;
audio: string;
information_gain: string;
}
export interface OpeningHookSpecV1 {
type: HookTypeV1;
secondary_type?: HookTypeV1;
viewer_question: string;
character_at_risk: string[];
stakes: string;
visual_event: string;
audio_event: string;
timeline: HookTimelineBeatV1[];
relation_to_episode_conflict: string;
payoff_beat_id: string;
source_refs: SourceRefV1[];
anti_clickbait_check: string;
}
export interface EndingHookSpecV1 {
hook: string;
new_information: string;
next_episode_question: string;
state_change: string;
source_refs: SourceRefV1[];
}
export interface StoryStateRefV1 {
character_states: Record<string, string>;
active_conflicts: string[];
known_information: string[];
location_state?: string;
}
export interface EpisodeBeatV1 {
id: string;
type: 'setup' | 'escalation' | 'reversal' | 'choice' | 'climax' | 'aftermath' | 'hook';
beat: string;
character_action: string;
consequence: string;
source_refs: SourceRefV1[];
}
export interface EpisodePlanSpecV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.episodePlan;
adaptation_bible_version_id: string;
episode_number: number;
target_duration_ms: number;
hook_policy: HookPolicyV1;
opening_hook: OpeningHookSpecV1;
protagonist_goal: string;
obstacle: string;
stakes: string;
dramatic_question: string;
beats: EpisodeBeatV1[];
midpoint_change: string;
climax_choice: string;
irreversible_change: string;
ending_hook: EndingHookSpecV1;
entry_state: StoryStateRefV1;
exit_state: StoryStateRefV1;
source_refs: SourceRefV1[];
adaptation_decision_refs: string[];
}
export interface DialogueBeatV1 {
id: string;
character_id: string;
line: string;
intention: string;
subtext: string;
reaction_target?: string;
estimated_duration_ms: number;
}
export interface ActionBeatV1 {
id: string;
character_id?: string;
action: string;
trigger: string;
result: string;
estimated_duration_ms: number;
}
export interface SceneSpecV1 {
id: string;
location_asset_ref: string;
time_of_day: string;
entry_state: StoryStateRefV1;
scene_goal: string;
active_characters: string[];
character_objectives: Array<{ character_id: string; objective: string }>;
obstacle: string;
tactics: Array<{ character_id: string; tactic: string }>;
action_beats: ActionBeatV1[];
dialogue_beats: DialogueBeatV1[];
subtext: string;
turn: string;
exit_state: StoryStateRefV1;
estimated_duration_ms: number;
source_refs: SourceRefV1[];
}
export interface SceneScriptSpecV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.sceneScript;
episode_plan_version_id: string;
episode_number: number;
target_duration_ms: number;
scenes: SceneSpecV1[];
opening_hook_scene_id: string;
climax_scene_id: string;
ending_hook_scene_id: string;
dialogue_duration_estimate_ms: number;
total_duration_estimate_ms: number;
character_voice_checks: Array<{ character_id: string; check?: string; passed: boolean; issue?: string }>;
continuity_checks: Array<{ check?: string; passed: boolean; issue?: string }>;
}
export type AssetRequirementKindV1 =
| 'character_identity'
| 'character_state'
| 'crowd'
| 'location'
| 'prop'
| 'vfx';
export type AssetEntityTypeV1 =
| 'project_character'
| 'character_state'
| 'project_visual_asset'
| 'global_character'
| 'asset';
export interface AssetEntityRefV1 {
entity_type: AssetEntityTypeV1;
id: string;
}
export interface AssetCandidateRefV1 extends AssetEntityRefV1 {
reason: string;
approval_status: 'manual_review_required' | 'approved' | 'rejected';
}
export interface AssetRequirementV1 {
id: string;
kind: AssetRequirementKindV1;
name: string;
source_character_id?: string;
source_location_ref?: string;
reuse_decision: 'reuse' | 'create' | 'upgrade' | 'manual_review';
existing_ref: AssetEntityRefV1 | null;
candidate_refs: AssetCandidateRefV1[];
applies_to_scene_ids: string[];
priority: 'blocking' | 'high' | 'normal';
visual_brief: string;
continuity_locks: string[];
deliverables: string[];
acceptance_criteria: string[];
dependencies: string[];
source_refs: SourceRefV1[];
}
export interface AssetSceneBindingV1 {
scene_id: string;
location_requirement_ref: string;
character_requirement_refs: string[];
crowd_requirement_refs: string[];
prop_requirement_refs: string[];
vfx_requirement_refs: string[];
}
export interface AssetPlanSpecV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.assetPlan;
scene_script_version_id: string;
episode_number: number;
format: {
orientation: '16:9';
keyframe_resolution: '2560x1440';
video_resolution: '1920x1080';
};
inventory_snapshot: {
project_character_ids: string[];
project_visual_asset_ids: string[];
matching_global_character_ids: string[];
legacy_candidate_asset_ids: string[];
};
requirements: AssetRequirementV1[];
scene_bindings: AssetSceneBindingV1[];
creation_order: string[];
blocking_requirement_refs: string[];
quality_checks: Array<{ check: string; passed: boolean; issue?: string }>;
source_refs: SourceRefV1[];
}
export interface SpatialVectorV1 {
x: number;
y: number;
z: number;
}
export interface SceneGeographyZoneV1 {
id: string;
name: string;
purpose: string;
center: SpatialVectorV1;
bounds: string;
adjacent_zone_ids: string[];
}
export interface SceneGeographyPlacementV1 {
subject_ref: string;
subject_type: 'character' | 'faction' | 'crowd' | 'prop' | 'vfx_origin' | 'vfx_target';
faction_ref?: string;
zone_id: string;
world_position: SpatialVectorV1;
facing_vector: SpatialVectorV1;
screen_side: 'left' | 'center' | 'right' | 'background' | 'foreground';
eyeline_target_ref?: string;
vertical_relation: 'ground' | 'below' | 'above' | 'overhead';
}
export interface SceneGeographyAxisV1 {
id: string;
endpoint_a_ref: string;
endpoint_b_ref: string;
description: string;
screen_left_ref: string;
screen_right_ref: string;
safe_camera_side: string;
forbidden_camera_side: string;
crossing_policy: 'forbidden' | 'neutral_bridge_required' | 'motivated_crossing_allowed';
}
export interface SceneGeographyCameraPositionV1 {
id: string;
name: string;
world_position: SpatialVectorV1;
viewing_direction: SpatialVectorV1;
axis_side: 'safe' | 'on_axis' | 'forbidden';
allowed: boolean;
purpose: string;
preserves_screen_relationship: string;
}
export interface SceneGeographyActionVectorV1 {
id: string;
source_ref: string;
target_ref: string;
origin_zone_id: string;
target_zone_id: string;
world_direction: SpatialVectorV1;
screen_direction: string;
trajectory: string;
must_keep_origin_and_target_visible: boolean;
}
export interface SceneGeographyBlockingBeatV1 {
id: string;
trigger: string;
actor_ref: string;
start_zone_id: string;
end_zone_id: string;
facing_target_ref: string;
eyeline_target_ref: string;
action_vector_ref?: string;
continuity_result: string;
}
export interface SceneGeographySceneV1 {
scene_id: string;
location_requirement_ref: string;
world_layout: string;
coordinate_system: {
origin: string;
x_axis: string;
y_axis: string;
z_axis: string;
};
zones: SceneGeographyZoneV1[];
placements: SceneGeographyPlacementV1[];
primary_axis: SceneGeographyAxisV1;
camera_positions: SceneGeographyCameraPositionV1[];
action_vectors: SceneGeographyActionVectorV1[];
blocking_beats: SceneGeographyBlockingBeatV1[];
continuity_locks: string[];
forbidden_outcomes: string[];
required_spatial_anchor_refs: string[];
acceptance_criteria: string[];
source_refs: SourceRefV1[];
}
export interface SceneGeographySpecV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.sceneGeography;
asset_plan_version_id: string;
scene_script_version_id: string;
episode_number: number;
format: {
orientation: '16:9';
world_unit: 'meter';
coordinate_handedness: 'right_handed';
};
scene_geographies: SceneGeographySceneV1[];
global_continuity_locks: string[];
global_forbidden_outcomes: string[];
quality_checks: Array<{ check: string; passed: boolean; issue?: string }>;
source_refs: SourceRefV1[];
}
export interface FrameStateV1 {
composition: string;
character_positions: string;
eyelines: string;
action_state: string;
emotional_state: string;
camera_axis: string;
screen_direction: string;
lighting_state: string;
}
export interface ContinuityAnchorV1 {
type: 'character' | 'action' | 'eyeline' | 'camera_axis' | 'screen_direction' | 'sound' | 'prop' | 'light';
value: string;
}
export interface PerformanceBeatV1 {
character_id: string;
start_ms: number;
end_ms: number;
start_state: string;
trigger: string;
visible_action: string;
end_state: string;
}
export interface DialogueTimingV1 {
character_id: string;
start_ms: number;
end_ms: number;
spoken_text: string;
delivery: string;
listener_reaction?: string;
}
export interface ShotExecutionSpecV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.shotExecution;
scene_script_version_id: string;
scene_geography_version_id: string;
scene_id: string;
shot_number: number;
dramatic_purpose: string;
generation_unit: 'single_shot' | 'continuous_shot' | 'montage_unit';
target_duration_ms: number;
assets: {
character_state_refs: string[];
location_asset_ref: string;
prop_asset_refs: string[];
wardrobe_refs: string[];
vfx_asset_refs: string[];
};
blocking: {
focus: string;
pressure_source: string;
reaction_receiver: string;
positions: string;
movement_path: string;
};
geography_binding: {
axis_id: string;
camera_position_id: string;
placement_subject_refs: string[];
action_vector_refs: string[];
zone_ids: string[];
};
performance_timeline: PerformanceBeatV1[];
dialogue_timeline: DialogueTimingV1[];
camera: {
shot_size: string;
angle: string;
lens_intent: string;
movement: string;
movement_motivation: string;
camera_axis: string;
screen_direction: string;
landing_point: string;
};
lighting: {
key_light: string;
contrast: string;
dynamic_change: string;
};
vfx: Array<{
effect: string;
trigger: string;
interaction: string;
intensity_arc: string;
}>;
sound: {
ambience: string[];
dialogue_source: 'native_audio' | 'post_tts' | 'none';
sfx_hits: Array<{ at_ms: number; cue: string }>;
music_cue?: string;
sound_bridge?: string;
};
start_state: FrameStateV1;
end_state: FrameStateV1;
continuity_from_previous: ContinuityAnchorV1[];
continuity_to_next: ContinuityAnchorV1[];
edit_relation_to_previous:
| 'new_scene_cut'
| 'motivated_hard_cut'
| 'reaction_cut'
| 'match_cut'
| 'sound_bridge'
| 'continuous_motion';
cut_motivation: string;
generation_strategy: {
mode: 'text_only' | 'first_frame' | 'first_last_frame' | 'multi_reference';
reference_assets: Array<{
asset_id: string;
responsibility:
| 'character_identity'
| 'wardrobe_state'
| 'location_layout'
| 'prop_identity'
| 'start_frame'
| 'end_frame'
| 'action_pose'
| 'style_reference';
priority: number;
}>;
};
provider_requirements: {
native_audio_required: boolean;
dialogue_required: boolean;
max_reference_images?: number;
allowed_duration_ms?: number[];
};
acceptance_criteria: Array<{ field_path: string; expected: string; severity: 'minor' | 'major' | 'fatal' }>;
forbidden_outcomes: string[];
}
export interface QualityIssueV1 {
code: string;
field_path: string;
message: string;
severity: 'minor' | 'major' | 'fatal';
}
export interface StageQualityResultV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.stageQuality;
hard_gate_status: 'pass' | 'blocked';
hard_gate_issues: QualityIssueV1[];
dimension_scores: Record<string, number>;
weighted_score: number;
confidence: number;
fatal_issues: QualityIssueV1[];
repair_instructions: Array<{ field_path: string; instruction: string }>;
rubric_version: string;
}
export interface StageReviewDeltaV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.stageReviewDelta;
contract_type: 'source_analysis' | 'adaptation_bible' | 'episode_plan' | 'scene_script' | 'asset_plan' | 'scene_geography';
expected_contract_id: string;
reviewer_version: string;
verdict: 'pass' | 'repair_required';
issues: QualityIssueV1[];
repairs: Array<{
field_path: string;
instruction: string;
evidence_refs: SourceRefV1[];
}>;
reviewer_notes: string[];
}
export interface ShotQcDeltaV1 {
schema_version: typeof PRODUCTION_CONTRACT_VERSIONS.shotQcDelta;
expected_spec_version: string;
observed_asset_id: string;
matched: string[];
mismatches: Array<{
field_path: string;
expected: string;
observed: string;
severity: 'minor' | 'major' | 'fatal';
}>;
repair_scope: 'prompt' | 'reference' | 'duration' | 'route' | 'script';
repair_instructions: string[];
}
export type PromptRuleStageV1 =
| 'source'
| 'adaptation'
| 'episode'
| 'script'
| 'assets'
| 'geography'
| 'shot'
| 'qc'
| 'timeline';
export interface PromptRuleCandidateV1 {
id: string;
source_prompt_refs: string[];
stage: PromptRuleStageV1;
scope: 'global' | 'genre' | 'provider' | 'project';
genre_tags: string[];
provider_codes: string[];
original_text: string;
normalized_rule: string;
activation_condition: string;
expected_improvement: string;
evidence_refs: string[];
positive_examples: string[];
negative_examples: string[];
conflicts_with: string[];
status: 'candidate' | 'testing' | 'approved' | 'deprecated';
reviewer_id?: string;
rule_version: typeof PRODUCTION_CONTRACT_VERSIONS.promptRule;
}
@@ -0,0 +1,512 @@
import {
PRODUCTION_CONTRACT_VERSIONS,
type AdaptationBibleSpecV1,
type AssetPlanSpecV1,
type EpisodePlanSpecV1,
type SceneGeographySpecV1,
type SceneScriptSpecV1,
type ShotExecutionSpecV1,
type SourceAnalysisSpecV1,
type SourceRefV1
} from '../contracts/production-contracts';
export const NEUTRAL_SOURCE_REF_V1: SourceRefV1 = {
source_type: 'chapter',
source_id: 'chapter-neutral-1',
locator: 'paragraph-1'
};
export const VALID_SOURCE_ANALYSIS_FIXTURE_V1: SourceAnalysisSpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.sourceAnalysis,
source_snapshot_id: '1',
premise: '一名调查者必须在身份暴露前找回能证明旧案真相的证据。',
genre_promises: ['调查推进', '证据反转', '身份压力'],
timeline: [{
id: 'event-neutral-1',
order: 1,
event: '调查者发现关键证据被拆分保存。',
participants: ['CHAR_A'],
consequence: '原定的一次取证变成持续追查。',
source_refs: [NEUTRAL_SOURCE_REF_V1]
}],
characters: [{
character_id: 'CHAR_A',
name: '调查者',
role: 'protagonist',
goal: '找回完整证据并证明旧案真相。',
fear: '身份提前暴露。',
secrets: ['掌握一半证据的位置。'],
state: '身份尚未暴露。',
source_refs: [NEUTRAL_SOURCE_REF_V1]
}],
relationships: [{ fact: 'CHAR_A 与保管证据的人互不信任。', source_refs: [NEUTRAL_SOURCE_REF_V1] }],
world_rules: [{ fact: '封存资料必须双人授权开启。', source_refs: [NEUTRAL_SOURCE_REF_V1] }],
conflicts: [{ fact: '取证速度与身份隐藏冲突。', source_refs: [NEUTRAL_SOURCE_REF_V1] }],
mysteries: [{ fact: '另一半证据被谁拿走。', source_refs: [NEUTRAL_SOURCE_REF_V1] }],
emotional_assets: [{ fact: '调查者对旧案受害者的承诺。', source_refs: [NEUTRAL_SOURCE_REF_V1] }],
visual_assets: [{ fact: '封存柜、警示灯和被拆开的证据袋。', source_refs: [NEUTRAL_SOURCE_REF_V1] }],
immutable_facts: [{ fact: '证据被拆成两部分。', source_refs: [NEUTRAL_SOURCE_REF_V1] }],
uncertain_facts: [{ fact: '保管者可能故意留下线索。', source_refs: [NEUTRAL_SOURCE_REF_V1] }],
source_refs: [NEUTRAL_SOURCE_REF_V1]
};
export const VALID_ADAPTATION_BIBLE_FIXTURE_V1: AdaptationBibleSpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.adaptationBible,
source_analysis_version_id: '100',
format: {
orientation: '16:9',
target_episode_seconds: 60,
episode_count_range: [3, 6],
audience: '喜欢悬疑反转与人物选择的短剧观众'
},
genre_contract: '证据调查悬疑,每集必须推进事实或改变人物关系。',
tone_contract: '克制、紧张,反转来自信息和行动结果。',
protagonist_contract: 'CHAR_A 主动调查并承担每次选择的后果。',
central_dramatic_question: 'CHAR_A 能否在身份完全暴露前拼回证据?',
main_arc: '从秘密取证到公开对抗,再到揭示旧案责任人。',
character_arcs: [{ character_id: 'CHAR_A', arc: '从独自承担转为愿意信任同盟。' }],
season_beats: [
{ id: 'season-neutral-1', beat: '取得第一半证据。', consequence: '对手确认调查已经开始。' },
{ id: 'season-neutral-2', beat: '找到另一半证据。', consequence: '旧案责任人被迫公开行动。' }
],
adaptation_decisions: [{
id: 'decision-neutral-1',
operation: 'compress',
source_refs: [NEUTRAL_SOURCE_REF_V1],
target: '把多次外围查证压缩为一次封存室行动。',
reason: '保持短剧单集动作目标清晰。',
impact: ['调查节奏加快', '身份风险提前'],
continuity_risk: '必须保留证据被拆分的原著事实。',
approval_status: 'approved'
}],
hook_policy: {
mode: 'aggressive_0_3s',
max_setup_ms: 3_000,
require_visible_question: true,
require_episode_payoff: true,
max_repeated_hook_type: 2
},
dialogue_policy: {
language: 'zh-CN',
target_chars_per_second: 4.2,
preserve_confirmed_lines: true,
require_listener_reaction: true
},
visual_policy: ['证据状态必须可见', '空间关系必须连续'],
sound_policy: ['关键证据变化必须有声音提示', '声音桥接服务于跨镜衔接'],
prohibited_changes: ['不得把推测改成原著事实', '不得删除证据拆分设定'],
source_refs: [NEUTRAL_SOURCE_REF_V1]
};
const neutralState = (state: string) => ({
character_states: { CHAR_A: state },
active_conflicts: ['CHAR_A must protect the evidence'],
known_information: ['CHAR_A knows the evidence is incomplete'],
location_state: 'LOCATION_A'
});
export const VALID_EPISODE_PLAN_FIXTURE_V1: EpisodePlanSpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.episodePlan,
adaptation_bible_version_id: 'adaptation-neutral-v1',
episode_number: 1,
target_duration_ms: 60_000,
hook_policy: {
mode: 'aggressive_0_3s',
max_setup_ms: 3_000,
require_visible_question: true,
require_episode_payoff: true,
max_repeated_hook_type: 2
},
opening_hook: {
type: 'secret_exposure',
viewer_question: 'CHAR_A 能否在对手发现前取回证据?',
character_at_risk: ['CHAR_A'],
stakes: '证据一旦暴露,CHAR_A 将失去唯一翻案机会。',
visual_event: '封存柜的警示灯突然亮起。',
audio_event: '门外传来钥匙插入锁孔的声音。',
timeline: [
{
start_ms: 0,
end_ms: 1_200,
visual: 'CHAR_A 的手停在封存柜前。',
audio: '锁孔轻响。',
information_gain: '有人正在进入。'
},
{
start_ms: 1_200,
end_ms: 2_800,
visual: '柜门缝里露出证据袋一角。',
audio: '脚步逼近。',
information_gain: 'CHAR_A 距离证据只差一步。'
}
],
relation_to_episode_conflict: '直接启动本集争夺证据的主冲突。',
payoff_beat_id: 'beat-payoff',
source_refs: [NEUTRAL_SOURCE_REF_V1],
anti_clickbait_check: '证据争夺在本集中完成第一次结果。'
},
protagonist_goal: '在封存室关闭前取出证据。',
obstacle: 'CHAR_B 已经发现异常并封锁出口。',
stakes: '失败会失去翻案机会并暴露身份。',
dramatic_question: 'CHAR_A 愿意付出什么代价带走证据?',
beats: [
{
id: 'beat-setup',
type: 'setup',
beat: 'CHAR_A 潜入封存室。',
character_action: '确认柜门与出口。',
consequence: '发现撤离时间比计划更短。',
source_refs: [NEUTRAL_SOURCE_REF_V1]
},
{
id: 'beat-payoff',
type: 'reversal',
beat: '证据袋内只有一半材料。',
character_action: 'CHAR_A 决定继续寻找另一半。',
consequence: '原定撤离计划失效。',
source_refs: [NEUTRAL_SOURCE_REF_V1]
},
{
id: 'beat-climax',
type: 'climax',
beat: 'CHAR_A 用身份暴露换取带走证据。',
character_action: '主动打开备用出口引开追兵。',
consequence: '证据保住,但对手确认了 CHAR_A 的身份。',
source_refs: [NEUTRAL_SOURCE_REF_V1]
}
],
midpoint_change: '证据不完整,行动目标从撤离变为继续追查。',
climax_choice: '在隐藏身份和保住证据之间选择证据。',
irreversible_change: 'CHAR_B 确认 CHAR_A 正在调查旧案。',
ending_hook: {
hook: '另一半证据出现在 CHAR_B 手里。',
new_information: 'CHAR_B 早已知道证据被拆分。',
next_episode_question: 'CHAR_B 为什么故意留下这一半?',
state_change: '双方从暗中调查转为彼此确认。',
source_refs: [NEUTRAL_SOURCE_REF_V1]
},
entry_state: neutralState('hidden'),
exit_state: neutralState('identity_exposed'),
source_refs: [NEUTRAL_SOURCE_REF_V1],
adaptation_decision_refs: ['decision-neutral-1']
};
export const VALID_SCENE_SCRIPT_FIXTURE_V1: SceneScriptSpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.sceneScript,
episode_plan_version_id: 'episode-plan-neutral-v1',
episode_number: 1,
target_duration_ms: 12_000,
scenes: [
{
id: 'scene-neutral-1',
location_asset_ref: 'LOCATION_A:v1',
time_of_day: 'night',
entry_state: neutralState('hidden'),
scene_goal: '在来人开门前取得证据。',
active_characters: ['CHAR_A'],
character_objectives: [{ character_id: 'CHAR_A', objective: '取出证据并保持身份隐藏。' }],
obstacle: '柜门上锁,门外脚步逼近。',
tactics: [{ character_id: 'CHAR_A', tactic: '先制造远处声响,再快速开锁。' }],
action_beats: [
{
id: 'action-neutral-1',
character_id: 'CHAR_A',
action: '停手听脚步,再转向备用锁。',
trigger: '钥匙插入外门。',
result: 'CHAR_A 改变开锁策略。',
estimated_duration_ms: 4_000
}
],
dialogue_beats: [],
subtext: 'CHAR_A 表面冷静,实际已决定冒险。',
turn: '证据袋里只有一半材料。',
exit_state: neutralState('evidence_incomplete'),
estimated_duration_ms: 12_000,
source_refs: [NEUTRAL_SOURCE_REF_V1]
}
],
opening_hook_scene_id: 'scene-neutral-1',
climax_scene_id: 'scene-neutral-1',
ending_hook_scene_id: 'scene-neutral-1',
dialogue_duration_estimate_ms: 0,
total_duration_estimate_ms: 12_000,
character_voice_checks: [{ character_id: 'CHAR_A', passed: true }],
continuity_checks: [{ passed: true }]
};
export const VALID_ASSET_PLAN_FIXTURE_V1: AssetPlanSpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.assetPlan,
scene_script_version_id: 'scene-script-neutral-v1',
episode_number: 1,
format: {
orientation: '16:9',
keyframe_resolution: '2560x1440',
video_resolution: '1920x1080'
},
inventory_snapshot: {
project_character_ids: [],
project_visual_asset_ids: [],
matching_global_character_ids: [],
legacy_candidate_asset_ids: []
},
requirements: [
{
id: 'REQ_CHARACTER_001',
kind: 'character_identity',
name: '调查者身份母版',
source_character_id: 'CHAR_A',
reuse_decision: 'create',
existing_ref: null,
candidate_refs: [],
applies_to_scene_ids: ['scene-neutral-1'],
priority: 'blocking',
visual_brief: '锁定调查者真实演员式面孔、年龄、身形与基础妆发。',
continuity_locks: ['同一张脸', '同一年龄', '同一身形'],
deliverables: ['16:9影视角色母版', '正侧背三视图和面部特写'],
acceptance_criteria: ['身份在全部视图一致', '可用于视频参考'],
dependencies: [],
source_refs: [NEUTRAL_SOURCE_REF_V1]
},
{
id: 'REQ_LOCATION_001',
kind: 'location',
name: '封存室空间母版',
source_location_ref: 'LOCATION_A:v1',
reuse_decision: 'create',
existing_ref: null,
candidate_refs: [],
applies_to_scene_ids: ['scene-neutral-1'],
priority: 'blocking',
visual_brief: '锁定封存柜、入口、备用出口、警示灯和光源方向。',
continuity_locks: ['入口出口位置固定', '封存柜位置固定', '主光方向固定'],
deliverables: ['主视角', '反打', '侧向空间关系', '俯视布局'],
acceptance_criteria: ['四个方向空间关系可互相推导'],
dependencies: [],
source_refs: [NEUTRAL_SOURCE_REF_V1]
}
],
scene_bindings: [{
scene_id: 'scene-neutral-1',
location_requirement_ref: 'REQ_LOCATION_001',
character_requirement_refs: ['REQ_CHARACTER_001'],
crowd_requirement_refs: [],
prop_requirement_refs: [],
vfx_requirement_refs: []
}],
creation_order: ['REQ_CHARACTER_001', 'REQ_LOCATION_001'],
blocking_requirement_refs: ['REQ_CHARACTER_001', 'REQ_LOCATION_001'],
quality_checks: [
{ check: '库存已核对', passed: true },
{ check: '场景与角色覆盖完整', passed: true }
],
source_refs: [NEUTRAL_SOURCE_REF_V1]
};
export const VALID_SCENE_GEOGRAPHY_FIXTURE_V1: SceneGeographySpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.sceneGeography,
asset_plan_version_id: 'asset-plan-neutral-v1',
scene_script_version_id: 'scene-script-neutral-v1',
episode_number: 1,
format: {
orientation: '16:9',
world_unit: 'meter',
coordinate_handedness: 'right_handed'
},
scene_geographies: [{
scene_id: 'scene-neutral-1',
location_requirement_ref: 'REQ_LOCATION_001',
world_layout: '封存柜位于西侧,调查者在东侧操作区,入口位于南侧。',
coordinate_system: {
origin: '封存室地面中心',
x_axis: '向东为正',
y_axis: '向北为正',
z_axis: '垂直向上为正'
},
zones: [{
id: 'zone-character',
name: '调查者操作区',
purpose: '承载调查者的开锁动作',
center: { x: 2, y: 0, z: 0 },
bounds: 'x=1至3米,y=-1至1米',
adjacent_zone_ids: ['zone-cabinet']
}, {
id: 'zone-cabinet',
name: '封存柜区',
purpose: '承载主锁、备用锁和证据',
center: { x: -2, y: 0, z: 0 },
bounds: 'x=-3至-1米,y=-1至1米',
adjacent_zone_ids: ['zone-character']
}],
placements: [{
subject_ref: 'CHAR_A',
subject_type: 'character',
zone_id: 'zone-character',
world_position: { x: 2, y: 0, z: 0 },
facing_vector: { x: -1, y: 0, z: 0 },
screen_side: 'right',
eyeline_target_ref: 'CABINET_A',
vertical_relation: 'ground'
}, {
subject_ref: 'CABINET_A',
subject_type: 'prop',
zone_id: 'zone-cabinet',
world_position: { x: -2, y: 0, z: 0 },
facing_vector: { x: 1, y: 0, z: 0 },
screen_side: 'left',
eyeline_target_ref: 'CHAR_A',
vertical_relation: 'ground'
}],
primary_axis: {
id: 'axis-character-cabinet',
endpoint_a_ref: 'CHAR_A',
endpoint_b_ref: 'CABINET_A',
description: '调查者与封存柜之间的东西向关系轴',
screen_left_ref: 'CABINET_A',
screen_right_ref: 'CHAR_A',
safe_camera_side: '关系轴南侧',
forbidden_camera_side: '关系轴北侧',
crossing_policy: 'neutral_bridge_required'
},
camera_positions: [{
id: 'camera-south-medium',
name: '南侧中近景机位',
world_position: { x: 0, y: -4, z: 1.6 },
viewing_direction: { x: 0, y: 1, z: 0 },
axis_side: 'safe',
allowed: true,
purpose: '同时看清调查者和封存柜',
preserves_screen_relationship: '封存柜固定画面左侧,调查者固定画面右侧'
}],
action_vectors: [],
blocking_beats: [{
id: 'blocking-unlock',
trigger: '门外钥匙声逼近',
actor_ref: 'CHAR_A',
start_zone_id: 'zone-character',
end_zone_id: 'zone-character',
facing_target_ref: 'CABINET_A',
eyeline_target_ref: 'CABINET_A',
continuity_result: '调查者仍在右侧操作区,手转向备用锁'
}],
continuity_locks: ['封存柜始终在画面左侧', '调查者始终在画面右侧'],
forbidden_outcomes: ['调查者与封存柜左右互换', '未经桥接从北侧反拍'],
required_spatial_anchor_refs: ['REQ_LOCATION_001'],
acceptance_criteria: ['主锁和备用锁的位置在连续镜头中可推导', '人物左右关系不变'],
source_refs: [NEUTRAL_SOURCE_REF_V1]
}],
global_continuity_locks: ['16:9横屏', '同一场景空间关系稳定'],
global_forbidden_outcomes: ['无动机越轴', '空间左右漂移'],
quality_checks: [
{ check: '空间区、关系轴和安全机位完整', passed: true },
{ check: '活动角色已有明确站位', passed: true }
],
source_refs: [NEUTRAL_SOURCE_REF_V1]
};
export const VALID_SHOT_EXECUTION_FIXTURE_V1: ShotExecutionSpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.shotExecution,
scene_script_version_id: 'scene-script-neutral-v1',
scene_geography_version_id: 'scene-geography-neutral-v1',
scene_id: 'scene-neutral-1',
shot_number: 1,
dramatic_purpose: '建立 CHAR_A 被迫改变开锁策略的瞬间。',
generation_unit: 'single_shot',
target_duration_ms: 5_000,
assets: {
character_state_refs: ['CHAR_A:hidden:v1'],
location_asset_ref: 'LOCATION_A:v1',
prop_asset_refs: ['PROP_A:v1'],
wardrobe_refs: ['CHAR_A:wardrobe:v1'],
vfx_asset_refs: []
},
blocking: {
focus: 'CHAR_A 的手与眼神',
pressure_source: '画外逼近的脚步',
reaction_receiver: 'CHAR_A',
positions: 'CHAR_A 位于前景右侧,封存柜占据画面左侧。',
movement_path: '手从主锁移向备用锁,身体位置不变。'
},
geography_binding: {
axis_id: 'axis-character-cabinet',
camera_position_id: 'camera-south-medium',
placement_subject_refs: ['CHAR_A', 'CABINET_A'],
action_vector_refs: [],
zone_ids: ['zone-character', 'zone-cabinet']
},
performance_timeline: [
{
character_id: 'CHAR_A',
start_ms: 0,
end_ms: 5_000,
start_state: '专注主锁。',
trigger: '听见门外钥匙声。',
visible_action: '手指停住,视线转向门口,再移向备用锁。',
end_state: '呼吸压低,决定冒险。'
}
],
dialogue_timeline: [],
camera: {
shot_size: 'medium close-up',
angle: 'eye level',
lens_intent: '同时看清手部决定和眼神压力。',
movement: '短距离跟随手部横移。',
movement_motivation: '跟随策略变化。',
camera_axis: 'axis-character-cabinet',
screen_direction: '手部从画面右侧移向左侧。',
landing_point: '备用锁与收紧的指节。'
},
lighting: {
key_light: '柜内冷光从左侧照亮手部。',
contrast: '背景压暗,保留门缝微光。',
dynamic_change: '外门开启时门缝光略微增强。'
},
vfx: [],
sound: {
ambience: ['封存室低频底噪', '远处脚步回声'],
dialogue_source: 'none',
sfx_hits: [{ at_ms: 900, cue: '钥匙插入锁孔的轻响' }],
sound_bridge: '脚步声延续到下一镜'
},
start_state: {
composition: 'CHAR_A 和主锁构成稳定中近景。',
character_positions: 'CHAR_A 前景右侧。',
eyelines: '视线落在主锁。',
action_state: '手指正在试探主锁。',
emotional_state: '克制专注。',
camera_axis: '横向轴线 A。',
screen_direction: '右向左。',
lighting_state: '左冷右暗。'
},
end_state: {
composition: '构图不变,焦点落在备用锁。',
character_positions: 'CHAR_A 前景右侧。',
eyelines: '视线转向备用锁。',
action_state: '手指停在备用锁上。',
emotional_state: '已作出冒险决定。',
camera_axis: '横向轴线 A。',
screen_direction: '右向左。',
lighting_state: '左冷右暗,门缝光增强。'
},
continuity_from_previous: [],
continuity_to_next: [{ type: 'sound', value: '脚步声继续逼近' }],
edit_relation_to_previous: 'new_scene_cut',
cut_motivation: '动作落在备用锁,下一镜切证据袋。',
generation_strategy: {
mode: 'multi_reference',
reference_assets: [
{ asset_id: 'asset-neutral-character', responsibility: 'character_identity', priority: 1 },
{ asset_id: 'asset-neutral-location', responsibility: 'location_layout', priority: 2 }
]
},
provider_requirements: {
native_audio_required: false,
dialogue_required: false,
max_reference_images: 4,
allowed_duration_ms: [5_000]
},
acceptance_criteria: [
{ field_path: 'performance_timeline[0].visible_action', expected: '手部停顿后改向备用锁', severity: 'fatal' },
{ field_path: 'camera.screen_direction', expected: '右向左', severity: 'major' }
],
forbidden_outcomes: ['新增第二名角色', '切换到其他房间', '生成可读文件文字']
};
+4
View File
@@ -0,0 +1,4 @@
export * from './contracts/production-contracts';
export * from './prompts/prompt-contamination';
export * from './prompts/prompt-rule-registry';
export * from './validators/production-contract-validators';
@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const backendSource = (relativePath: string) =>
readFileSync(resolve(process.cwd(), 'src', relativePath), 'utf8');
describe('legacy production behavior guard', () => {
it('does not restore mechanical filler shots or automatic dialogue splitting', () => {
const source = backendSource('scripts/scripts.service.ts');
expect(source).not.toContain('导演补拍节奏点');
expect(source).not.toContain('系统对白拆分');
});
it('keeps historical project facts out of the cleaned public script and episode prompts', () => {
const publicSource = [
backendSource('scripts/scripts.service.ts'),
backendSource('episodes/episodes.service.ts')
].join('\n');
const historicalProjectTerms = [
'沈知夏',
'顾砚衡',
'许听棠',
'周曼仪',
'云顶酒店',
'白塔地下七层',
'第七张底牌',
'孟晚'
];
for (const term of historicalProjectTerms) {
expect(publicSource).not.toContain(term);
}
});
});
@@ -0,0 +1,149 @@
import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
AiReviewProductionContractDto,
CheckEpisodeContinuityDto,
CompileFlowTestGenerationPlansDto,
ConfirmProductionContractDto,
CreateProductionSourceSnapshotDto,
GenerateProductionStageDto,
MaterializeProductionExecutionDto,
PreviewProductionStagePromptDto,
ReviewProductionContractDto,
SaveProductionContractDto
} from './production-kernel.dto';
import { ProductionKernelService } from './production-kernel.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class ProductionKernelController {
constructor(@Inject(ProductionKernelService) private readonly service: ProductionKernelService) {}
@Post('projects/:projectId/production/source-snapshots')
createSourceSnapshot(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateProductionSourceSnapshotDto
) {
return this.service.createSourceSnapshot(user, projectId, dto);
}
@Get('projects/:projectId/production/source-snapshots')
listSourceSnapshots(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.service.listSourceSnapshots(user, projectId);
}
@Post('projects/:projectId/production/contracts/:contractType')
saveContract(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('contractType') contractType: string,
@Body() dto: SaveProductionContractDto
) {
return this.service.saveContract(user, projectId, contractType, dto);
}
@Get('projects/:projectId/production/contracts')
listContracts(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('contract_type') contractType?: string
) {
return this.service.listContracts(user, projectId, contractType);
}
@Get('production/contracts/:contractId')
getContract(
@CurrentUser() user: AuthRequestUser,
@Param('contractId') contractId: string
) {
return this.service.getContract(user, contractId);
}
@Post('production/contracts/:contractId/review')
reviewContract(
@CurrentUser() user: AuthRequestUser,
@Param('contractId') contractId: string,
@Body() dto: ReviewProductionContractDto
) {
return this.service.reviewContract(user, contractId, dto);
}
@Post('production/contracts/:contractId/ai-review')
aiReviewContract(
@CurrentUser() user: AuthRequestUser,
@Param('contractId') contractId: string,
@Body() dto: AiReviewProductionContractDto = {}
) {
return this.service.aiReviewSceneScript(user, contractId, dto);
}
@Post('production/contracts/:contractId/confirm')
confirmContract(
@CurrentUser() user: AuthRequestUser,
@Param('contractId') contractId: string,
@Body() dto: ConfirmProductionContractDto = {}
) {
return this.service.confirmContract(user, contractId, dto);
}
@Post('production/contracts/:contractId/release-to-script')
releaseEpisodePlan(
@CurrentUser() user: AuthRequestUser,
@Param('contractId') contractId: string
) {
return this.service.releaseEpisodePlan(user, contractId);
}
@Post('production/contracts/:contractId/materialize-execution-draft')
materializeExecutionDraft(
@CurrentUser() user: AuthRequestUser,
@Param('contractId') contractId: string,
@Body() dto: MaterializeProductionExecutionDto
) {
return this.service.materializeExecutionDraft(user, contractId, dto);
}
@Post('production/contracts/:contractId/compile-flow-test-generation-plans')
compileFlowTestGenerationPlans(
@CurrentUser() user: AuthRequestUser,
@Param('contractId') contractId: string,
@Body() dto: CompileFlowTestGenerationPlansDto
) {
return this.service.compileFlowTestGenerationPlans(user, contractId, dto);
}
@Post('projects/:projectId/production/prompts/:stage/preview')
previewStagePrompt(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('stage') stage: string,
@Body() dto: PreviewProductionStagePromptDto
) {
return this.service.previewStagePrompt(user, projectId, stage, dto);
}
@Post('projects/:projectId/production/stages/:stage/generate')
generateStageContract(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('stage') stage: string,
@Body() dto: GenerateProductionStageDto
) {
return this.service.generateStageContract(user, projectId, stage, dto);
}
@Post('projects/:projectId/production/episode-continuity/check')
checkEpisodeContinuity(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CheckEpisodeContinuityDto
) {
return this.service.checkEpisodeContinuity(user, projectId, dto);
}
}
@@ -0,0 +1,61 @@
export type ProductionContractTypeDto =
| 'source_analysis'
| 'adaptation_bible'
| 'episode_plan'
| 'scene_script'
| 'asset_plan'
| 'scene_geography';
export type ProductionStageDto = 'source' | 'adaptation' | 'episode' | 'script' | 'assets' | 'geography';
export class ActivateSplusProjectDto {
confirm_new_kernel?: boolean;
}
export class CreateProductionSourceSnapshotDto {
novel_source_id?: string;
chapter_ids?: string[];
}
export class SaveProductionContractDto {
payload?: Record<string, unknown>;
parent_contract_id?: string;
source_snapshot_id?: string;
}
export class PreviewProductionStagePromptDto {
source_snapshot_id?: string;
upstream_contract_id?: string;
episode_number?: number;
}
export class GenerateProductionStageDto extends PreviewProductionStagePromptDto {
provider_code?: string;
request_params_override?: Record<string, unknown>;
}
export class ReviewProductionContractDto {
reviewer_notes?: string[];
}
export class AiReviewProductionContractDto extends ReviewProductionContractDto {
provider_code?: string;
request_params_override?: Record<string, unknown>;
}
export class ConfirmProductionContractDto {
confirm_splus_director_override?: boolean;
override_reason?: string;
}
export class CheckEpisodeContinuityDto {
contract_ids?: string[];
}
export class MaterializeProductionExecutionDto {
shot_specs?: Record<string, unknown>[];
}
export class CompileFlowTestGenerationPlansDto {
confirm_flow_test_waiver?: boolean;
waiver_reason?: string;
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { GenerationPlanModule } from '../generation-plans/generation-plan.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { ProductionKernelController } from './production-kernel.controller';
import { ProductionKernelService } from './production-kernel.service';
import { ProductionStagePromptBuilderService } from './production-stage-prompt-builder.service';
@Module({
imports: [AuthModule, PrismaModule, ProvidersModule, GenerationPlanModule],
controllers: [ProductionKernelController],
providers: [ProductionKernelService, ProductionStagePromptBuilderService],
exports: [ProductionKernelService, ProductionStagePromptBuilderService]
})
export class ProductionKernelModule {}
@@ -0,0 +1,642 @@
import { ForbiddenException } from '@nestjs/common';
import type { ProductionContract, ProductionSourceSnapshot, Project } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import type { ProvidersService } from '../providers/providers.service';
import {
VALID_ADAPTATION_BIBLE_FIXTURE_V1,
VALID_ASSET_PLAN_FIXTURE_V1,
VALID_EPISODE_PLAN_FIXTURE_V1,
VALID_SCENE_GEOGRAPHY_FIXTURE_V1,
VALID_SCENE_SCRIPT_FIXTURE_V1,
VALID_SHOT_EXECUTION_FIXTURE_V1,
VALID_SOURCE_ANALYSIS_FIXTURE_V1
} from './fixtures/neutral-production-fixtures';
import { ProductionKernelService } from './production-kernel.service';
import { ProductionStagePromptBuilderService } from './production-stage-prompt-builder.service';
const user: AuthRequestUser = { id: '1', email: 'user@example.com', role: 'user' };
function project(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '中性调查故事',
input_mode: 'upload',
genre: 'suspense',
style_code: 'cinematic',
output_type: 'short_video',
output_mode: 'live_action_ai',
visual_mode: 'live_action',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'novel_uploaded',
copyright_status: 'confirmed',
payment_status: 'unpaid',
quality_level: 'splus',
is_long_series: false,
engine_version: 'splus_v1',
production_lifecycle: 'source_snapshotted',
created_at: new Date('2026-07-15T00:00:00.000Z'),
updated_at: new Date('2026-07-15T00:00:00.000Z'),
completed_at: null,
...overrides
};
}
function snapshot(overrides: Partial<ProductionSourceSnapshot> = {}): ProductionSourceSnapshot {
return {
id: 1n,
project_id: 10n,
novel_source_id: 20n,
snapshot_version: 1,
source_type: 'upload',
title: '中性调查故事',
content_hash: 'a'.repeat(64),
content_text: '调查者发现证据被拆分保存。',
chapter_manifest_json: [{ chapter_id: 'chapter-neutral-1', chapter_no: 1 }],
metadata_json: null,
created_by_user_id: 1n,
created_at: new Date('2026-07-15T00:00:00.000Z'),
...overrides
};
}
function contract(overrides: Partial<ProductionContract> = {}): ProductionContract {
return {
id: 100n,
project_id: 10n,
contract_type: 'source_analysis',
schema_version: 'source_analysis_v1',
version: 1,
scope_key: 'project',
parent_contract_id: null,
source_snapshot_id: '1',
input_hash: 'b'.repeat(64),
input_snapshot_json: {},
payload_json: VALID_SOURCE_ANALYSIS_FIXTURE_V1,
source_refs_json: [],
quality_result_json: null,
reviewer_delta_json: null,
status: 'confirmed',
downstream_status: 'blocked',
created_by_user_id: 1n,
confirmed_at: new Date('2026-07-15T00:00:00.000Z'),
released_at: null,
created_at: new Date('2026-07-15T00:00:00.000Z'),
updated_at: new Date('2026-07-15T00:00:00.000Z'),
...overrides
} as ProductionContract;
}
describe('ProductionKernelService', () => {
let prisma: any;
let providers: { executeProvider: ReturnType<typeof vi.fn> };
let service: ProductionKernelService;
beforeEach(() => {
prisma = {
project: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), update: vi.fn() },
character: { findMany: vi.fn(), findFirst: vi.fn() },
characterState: { findFirst: vi.fn() },
projectVisualAsset: { findMany: vi.fn(), findFirst: vi.fn() },
globalCharacter: { findMany: vi.fn(), findFirst: vi.fn() },
asset: { findFirst: vi.fn() },
novelSource: { findFirst: vi.fn() },
novelChapter: { findMany: vi.fn() },
productionSourceSnapshot: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() },
productionContract: {
findFirst: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn()
},
productionContractReview: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() },
episode: { findUnique: vi.fn(), upsert: vi.fn() },
episodeScript: { findFirst: vi.fn(), create: vi.fn(), updateMany: vi.fn() },
storyboardShot: { count: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn(), findMany: vi.fn() },
shotGenerationPlan: { count: vi.fn(), create: vi.fn() },
$transaction: vi.fn(async (callback: (tx: any) => unknown) => callback(prisma))
};
providers = { executeProvider: vi.fn() };
service = new ProductionKernelService(
prisma as PrismaService,
providers as unknown as ProvidersService,
new ProductionStagePromptBuilderService()
);
});
it('keeps legacy projects read-only for the new production kernel', async () => {
prisma.project.findFirst.mockResolvedValue(project({ engine_version: 'legacy_v1' }));
await expect(service.listContracts(user, '10')).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects a source snapshot assembled from non-contiguous chapters', async () => {
prisma.project.findFirst.mockResolvedValue(project());
prisma.novelSource.findFirst.mockResolvedValue({
id: 20n,
project_id: 10n,
source_type: 'upload',
title: '中性调查故事'
});
prisma.novelChapter.findMany.mockResolvedValue([
{ id: 31n, chapter_no: 1, title: '第一章', content: '第一段事实。' },
{ id: 33n, chapter_no: 3, title: '第三章', content: '第三段事实。' }
]);
await expect(service.createSourceSnapshot(user, '10', {
novel_source_id: '20',
chapter_ids: ['31', '33']
})).rejects.toThrow('原著分析只允许选择章节号连续的故事段');
expect(prisma.productionSourceSnapshot.create).not.toHaveBeenCalled();
});
it('writes a traceable source analysis as a validated version', async () => {
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionSourceSnapshot.findFirst.mockResolvedValue(snapshot());
prisma.productionContract.findFirst.mockResolvedValue(null);
prisma.productionContract.create.mockImplementation(({ data }: any) => contract({
...data,
id: 101n,
created_at: new Date('2026-07-15T00:00:00.000Z'),
updated_at: new Date('2026-07-15T00:00:00.000Z'),
confirmed_at: null,
released_at: null
}));
prisma.project.update.mockResolvedValue(project());
const result = await service.saveContract(user, '10', 'source_analysis', {
source_snapshot_id: '1',
payload: structuredClone(VALID_SOURCE_ANALYSIS_FIXTURE_V1) as unknown as Record<string, unknown>
});
expect(result.quality.hard_gate_status).toBe('pass');
expect(result.contract.status).toBe('validated');
expect(prisma.productionContract.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ contract_type: 'source_analysis', source_snapshot_id: '1' })
}));
});
it('blocks an episode plan that cites an unapproved adaptation decision', async () => {
const adaptationPayload = structuredClone(VALID_ADAPTATION_BIBLE_FIXTURE_V1);
adaptationPayload.source_analysis_version_id = '100';
const adaptation = contract({
id: 200n,
contract_type: 'adaptation_bible',
schema_version: 'adaptation_bible_v1',
parent_contract_id: 100n,
payload_json: adaptationPayload,
status: 'confirmed'
});
const plan = structuredClone(VALID_EPISODE_PLAN_FIXTURE_V1);
plan.adaptation_bible_version_id = '200';
plan.adaptation_decision_refs = ['decision-not-approved'];
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionContract.findFirst
.mockResolvedValueOnce(adaptation)
.mockResolvedValueOnce(null);
prisma.productionContract.findMany.mockResolvedValue([]);
prisma.productionContract.create.mockImplementation(({ data }: any) => contract({
...data,
id: 201n,
created_at: new Date('2026-07-15T00:00:00.000Z'),
updated_at: new Date('2026-07-15T00:00:00.000Z'),
confirmed_at: null,
released_at: null
}));
prisma.project.update.mockResolvedValue(project());
const result = await service.saveContract(user, '10', 'episode_plan', {
parent_contract_id: '200',
payload: plan as unknown as Record<string, unknown>
});
expect(result.quality.hard_gate_status).toBe('blocked');
expect(result.quality.hard_gate_issues.some((item) => item.code === 'unapproved_adaptation_decision')).toBe(true);
expect(result.contract.status).toBe('draft');
});
it('blocks three episodes when an entry state does not inherit the previous exit state', async () => {
const first = structuredClone(VALID_EPISODE_PLAN_FIXTURE_V1);
first.adaptation_bible_version_id = '200';
const second = structuredClone(first);
second.episode_number = 2;
second.entry_state = structuredClone(first.exit_state);
second.exit_state = { ...structuredClone(first.exit_state), location_state: 'LOCATION_B' };
const third = structuredClone(second);
third.episode_number = 3;
third.entry_state = { ...structuredClone(second.exit_state), location_state: 'WRONG_LOCATION' };
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionContract.findMany.mockResolvedValue([
contract({ id: 301n, contract_type: 'episode_plan', scope_key: 'episode:1', payload_json: first, status: 'confirmed' }),
contract({ id: 302n, contract_type: 'episode_plan', scope_key: 'episode:2', payload_json: second, status: 'confirmed' }),
contract({ id: 303n, contract_type: 'episode_plan', scope_key: 'episode:3', payload_json: third, status: 'confirmed' })
]);
prisma.productionContractReview.findFirst.mockResolvedValue(null);
prisma.productionContractReview.create.mockResolvedValue({});
prisma.productionContract.update.mockResolvedValue({});
prisma.project.update.mockResolvedValue(project());
const result = await service.checkEpisodeContinuity(user, '10', {
contract_ids: ['301', '302', '303']
});
expect(result.passed).toBe(false);
expect(result.quality.hard_gate_issues.some((item) => item.code === 'episode_state_discontinuity')).toBe(true);
expect(prisma.productionContract.update).toHaveBeenCalledWith(expect.objectContaining({
data: { downstream_status: 'blocked' }
}));
});
it('does not generate a scene script before its episode plan is released', async () => {
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionContract.findFirst.mockResolvedValue(contract({
id: 400n,
contract_type: 'episode_plan',
schema_version: 'episode_plan_v1',
parent_contract_id: 200n,
payload_json: VALID_EPISODE_PLAN_FIXTURE_V1 as any,
status: 'confirmed',
downstream_status: 'eligible'
}));
await expect(service.previewStagePrompt(user, '10', 'script', {
upstream_contract_id: '400'
})).rejects.toThrow('分集计划尚未放行到场景剧本阶段');
});
it('builds a scene script prompt from the released plan, bible and source excerpt', async () => {
const episodePlan = structuredClone(VALID_EPISODE_PLAN_FIXTURE_V1);
episodePlan.adaptation_bible_version_id = '200';
const episode = contract({
id: 400n,
contract_type: 'episode_plan',
schema_version: 'episode_plan_v1',
parent_contract_id: 200n,
source_snapshot_id: '1',
payload_json: episodePlan as any,
status: 'confirmed',
downstream_status: 'released'
});
const adaptationPayload = structuredClone(VALID_ADAPTATION_BIBLE_FIXTURE_V1);
adaptationPayload.source_analysis_version_id = '100';
const adaptation = contract({
id: 200n,
contract_type: 'adaptation_bible',
schema_version: 'adaptation_bible_v1',
parent_contract_id: 100n,
source_snapshot_id: '1',
payload_json: adaptationPayload as any,
status: 'confirmed'
});
const analysis = contract({ id: 100n, payload_json: VALID_SOURCE_ANALYSIS_FIXTURE_V1 as any });
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionContract.findFirst
.mockResolvedValueOnce(episode)
.mockResolvedValueOnce(adaptation)
.mockResolvedValueOnce(analysis);
prisma.productionSourceSnapshot.findFirst.mockResolvedValue(snapshot({
content_text: '[[chapter:chapter-neutral-1|no:1|title:测试]]\n调查者说:“证据被拆开了。”'
}));
const preview = await service.previewStagePrompt(user, '10', 'script', {
upstream_contract_id: '400'
});
expect(preview.schema_version).toBe('scene_script_v1');
expect(preview.parent_contract_id).toBe('400');
expect(preview.prompt).toContain('调查者说:“证据被拆开了。”');
expect(preview.prompt).toContain('script.dialogue_integrity_and_reaction');
});
it('runs a strict S+ AI scene script review and persists the provider trace', async () => {
const sceneScript = structuredClone(VALID_SCENE_SCRIPT_FIXTURE_V1);
sceneScript.episode_plan_version_id = '400';
const episodePlan = structuredClone(VALID_EPISODE_PLAN_FIXTURE_V1);
episodePlan.target_duration_ms = sceneScript.target_duration_ms;
episodePlan.entry_state = structuredClone(sceneScript.scenes[0].entry_state);
episodePlan.exit_state = structuredClone(sceneScript.scenes.at(-1)!.exit_state);
const script = contract({
id: 500n,
contract_type: 'scene_script',
schema_version: 'scene_script_v1',
parent_contract_id: 400n,
source_snapshot_id: '1',
payload_json: sceneScript as any,
status: 'validated'
});
const episode = contract({
id: 400n,
contract_type: 'episode_plan',
schema_version: 'episode_plan_v1',
parent_contract_id: 200n,
source_snapshot_id: '1',
payload_json: episodePlan as any,
status: 'confirmed',
downstream_status: 'released'
});
const adaptation = contract({
id: 200n,
contract_type: 'adaptation_bible',
schema_version: 'adaptation_bible_v1',
parent_contract_id: 100n,
source_snapshot_id: '1',
payload_json: VALID_ADAPTATION_BIBLE_FIXTURE_V1 as any,
status: 'confirmed'
});
const analysis = contract({ id: 100n, payload_json: VALID_SOURCE_ANALYSIS_FIXTURE_V1 as any });
const report = {
schema_version: 'splus_scene_script_review_v1',
contract_id: '500',
reviewer_version: 'scene_script_splus_ai_reviewer_v1',
verdict: 'pass',
overall_score: 99,
dimension_scores: {
source_fidelity: 99,
dramatic_structure: 99,
opening_hook: 99,
pacing: 99,
character_consistency: 99,
dialogue_performance: 99,
visual_executability: 99,
duration_feasibility: 99,
continuity: 99,
production_readiness: 99
},
strengths: ['动作因果清晰。'],
issues: [],
scene_reviews: [{ scene_id: 'scene-neutral-1', score: 99, strengths: ['场面可执行。'], issues: [] }],
rewrite_priorities: [],
reviewer_summary: '达到S+场景剧本放行标准。'
};
prisma.productionContract.findUnique.mockResolvedValue(script);
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionSourceSnapshot.findFirst.mockResolvedValue(snapshot());
prisma.productionContract.findFirst
.mockResolvedValueOnce(episode)
.mockResolvedValueOnce(adaptation)
.mockResolvedValueOnce(analysis);
providers.executeProvider.mockResolvedValue({
provider: { provider_code: 'openai-responses-text' },
provider_log: { id: '990' },
result: { output_text: JSON.stringify(report) }
});
prisma.productionContractReview.findFirst.mockResolvedValue(null);
prisma.productionContractReview.create.mockImplementation(({ data }: any) => ({
id: 901n,
...data,
quality_score: { toString: () => String(data.quality_score) },
created_at: new Date('2026-07-15T01:00:00.000Z')
}));
const result = await service.aiReviewSceneScript(user, '500', {});
expect(result.report.splus_gate.passed).toBe(true);
expect(result.report.overall_score).toBe(99);
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
purpose: 'splus_scene_script_ai_review',
provider_type: 'TextProvider'
}));
expect(prisma.productionContractReview.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
reviewer_type: 'scene_script_splus_ai_reviewer_v1',
provider_log_id: 990n,
passed: true
})
}));
});
it('does not confirm a scene script before a passing S+ AI review exists', async () => {
const sceneScript = structuredClone(VALID_SCENE_SCRIPT_FIXTURE_V1);
sceneScript.episode_plan_version_id = '400';
const episodePlan = structuredClone(VALID_EPISODE_PLAN_FIXTURE_V1);
episodePlan.target_duration_ms = sceneScript.target_duration_ms;
episodePlan.entry_state = structuredClone(sceneScript.scenes[0].entry_state);
episodePlan.exit_state = structuredClone(sceneScript.scenes.at(-1)!.exit_state);
const script = contract({
id: 500n,
contract_type: 'scene_script',
schema_version: 'scene_script_v1',
parent_contract_id: 400n,
source_snapshot_id: '1',
payload_json: sceneScript as any,
status: 'validated'
});
const episode = contract({
id: 400n,
contract_type: 'episode_plan',
schema_version: 'episode_plan_v1',
payload_json: episodePlan as any,
status: 'confirmed',
downstream_status: 'released'
});
prisma.productionContract.findUnique.mockImplementation(({ where }: any) => (
String(where.id) === '500' ? script : episode
));
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionSourceSnapshot.findFirst.mockResolvedValue(snapshot());
prisma.productionContract.findFirst.mockResolvedValue(episode);
prisma.productionContractReview.findFirst.mockResolvedValue(null);
prisma.productionContractReview.create.mockResolvedValue({
id: 902n,
project_id: 10n,
contract_id: 500n,
reviewer_type: 'scene_script_reviewer_v1',
reviewer_version: 'scene_script_reviewer_v1',
review_no: 1,
passed: true,
quality_score: { toString: () => '100' },
issues_json: [],
delta_json: {},
provider_log_id: null,
created_by_user_id: 1n,
created_at: new Date('2026-07-15T01:00:00.000Z')
});
prisma.productionContract.update.mockResolvedValue(script);
await expect(service.confirmContract(user, '500')).rejects.toThrow('场景剧本必须先通过 98+ 的 S+ AI终审');
});
it('builds an asset plan prompt from the confirmed scene script and read-only inventory', async () => {
const sceneScript = structuredClone(VALID_SCENE_SCRIPT_FIXTURE_V1);
sceneScript.episode_plan_version_id = '400';
const script = contract({
id: 500n,
contract_type: 'scene_script',
schema_version: 'scene_script_v1',
parent_contract_id: 400n,
source_snapshot_id: '1',
payload_json: sceneScript as any,
status: 'confirmed'
});
const analysis = contract({ id: 100n, payload_json: VALID_SOURCE_ANALYSIS_FIXTURE_V1 as any });
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionContract.findFirst
.mockResolvedValueOnce(script)
.mockResolvedValueOnce(analysis);
prisma.character.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{
id: 91n,
project_id: 9n,
name: '调查者',
anchor_asset_id: 901n,
appearance_desc: '历史测试造型',
costume_rules: '历史测试服装',
status: 'locked'
}]);
prisma.projectVisualAsset.findMany.mockResolvedValue([]);
prisma.globalCharacter.findMany.mockResolvedValue([]);
prisma.project.findMany.mockResolvedValue([{ id: 9n }]);
const preview = await service.previewStagePrompt(user, '10', 'assets', {
upstream_contract_id: '500'
});
expect(preview.schema_version).toBe('asset_plan_v1');
expect(preview.parent_contract_id).toBe('500');
expect(preview.prompt).toContain('2560x1440');
expect(preview.prompt).toContain('manual_review_required');
expect(preview.prompt).toContain('"anchor_asset_id":"901"');
expect(preview.prompt).toContain('同一原创虚构角色资产');
expect(preview.prompt).toContain('candidate_refs 只用于人工比对');
expect(preview.prompt).not.toContain('同一真实演员式角色身份');
});
it('builds scene geography only from a confirmed asset plan and its scene script', async () => {
const assetPlanContract = contract({
id: 600n,
contract_type: 'asset_plan',
schema_version: 'asset_plan_v1',
parent_contract_id: 500n,
payload_json: structuredClone(VALID_ASSET_PLAN_FIXTURE_V1) as any,
status: 'confirmed'
});
const sceneScriptContract = contract({
id: 500n,
contract_type: 'scene_script',
schema_version: 'scene_script_v1',
payload_json: structuredClone(VALID_SCENE_SCRIPT_FIXTURE_V1) as any,
status: 'confirmed'
});
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionContract.findFirst
.mockResolvedValueOnce(assetPlanContract)
.mockResolvedValueOnce(sceneScriptContract);
const preview = await service.previewStagePrompt(user, '10', 'geography', {
upstream_contract_id: '600'
});
expect(preview.schema_version).toBe('scene_geography_v1');
expect(preview.parent_contract_id).toBe('600');
expect(preview.prompt).toContain('米制右手世界坐标');
expect(preview.prompt).toContain('action_vectors');
expect(preview.prompt).toContain('禁止看起来攻击自己');
});
it('materializes a validated execution draft without freezing generation plans', async () => {
const sceneScript = structuredClone(VALID_SCENE_SCRIPT_FIXTURE_V1);
sceneScript.target_duration_ms = 5_000;
sceneScript.total_duration_estimate_ms = 5_000;
sceneScript.scenes[0].estimated_duration_ms = 5_000;
const assetPlan = structuredClone(VALID_ASSET_PLAN_FIXTURE_V1);
assetPlan.scene_script_version_id = '500';
const assetPlanContract = contract({
id: 600n,
contract_type: 'asset_plan',
schema_version: 'asset_plan_v1',
parent_contract_id: 500n,
payload_json: assetPlan as any,
status: 'confirmed'
});
const sceneGeography = structuredClone(VALID_SCENE_GEOGRAPHY_FIXTURE_V1);
sceneGeography.asset_plan_version_id = '600';
sceneGeography.scene_script_version_id = '500';
const sceneGeographyContract = contract({
id: 650n,
contract_type: 'scene_geography',
schema_version: 'scene_geography_v1',
parent_contract_id: 600n,
payload_json: sceneGeography as any,
status: 'confirmed'
});
const sceneScriptContract = contract({
id: 500n,
contract_type: 'scene_script',
schema_version: 'scene_script_v1',
payload_json: sceneScript as any,
status: 'confirmed'
});
const shot = structuredClone(VALID_SHOT_EXECUTION_FIXTURE_V1);
shot.scene_script_version_id = '500';
shot.scene_geography_version_id = '650';
shot.assets = {
character_state_refs: ['REQ_CHARACTER_001'],
location_asset_ref: 'REQ_LOCATION_001',
prop_asset_refs: [],
wardrobe_refs: [],
vfx_asset_refs: []
};
shot.generation_strategy.reference_assets = [
{ asset_id: 'REQ_CHARACTER_001', responsibility: 'character_identity', priority: 1 },
{ asset_id: 'REQ_LOCATION_001', responsibility: 'location_layout', priority: 2 }
];
prisma.productionContract.findUnique.mockResolvedValue(sceneGeographyContract);
prisma.project.findFirst.mockResolvedValue(project());
prisma.productionContract.findFirst
.mockResolvedValueOnce(assetPlanContract)
.mockResolvedValueOnce(sceneScriptContract);
prisma.episode.findUnique.mockResolvedValue(null);
prisma.episode.upsert.mockResolvedValue({ id: 700n, project_id: 10n, episode_no: 1 });
prisma.episodeScript.findFirst.mockResolvedValue(null);
prisma.episodeScript.create.mockResolvedValue({ id: 701n, project_id: 10n, episode_id: 700n, version: 1 });
prisma.storyboardShot.createMany.mockResolvedValue({ count: 1 });
prisma.storyboardShot.findMany.mockResolvedValue([{
id: 702n,
shot_no: 1,
scene_name: 'scene-neutral-1',
duration: { toString: () => '5' },
video_status: 'external_assets_pending',
status: 'generated'
}]);
const result = await service.materializeExecutionDraft(user, '650', {
shot_specs: [shot as unknown as Record<string, unknown>]
});
expect(result).toMatchObject({
shot_count: 1,
scene_geography_contract_id: '650',
total_duration_ms: 5_000,
execution_status: 'external_assets_pending',
generation_plan_frozen: false
});
expect(prisma.storyboardShot.createMany).toHaveBeenCalledWith(expect.objectContaining({
data: [expect.objectContaining({
video_status: 'external_assets_pending',
video_prompt: null
})]
}));
expect(prisma.shotGenerationPlan.create).not.toHaveBeenCalled();
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: {
status: 'waiting_external_assets',
production_lifecycle: 'shot_execution_draft_materialized'
}
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
import { describe, expect, it } from 'vitest';
import {
PRODUCTION_CONTRACT_VERSIONS,
type AssetPlanSpecV1,
type PromptRuleCandidateV1,
type SceneGeographySpecV1,
type SceneScriptSpecV1,
type ShotExecutionSpecV1
} from './contracts/production-contracts';
import {
VALID_EPISODE_PLAN_FIXTURE_V1,
VALID_ADAPTATION_BIBLE_FIXTURE_V1,
VALID_ASSET_PLAN_FIXTURE_V1,
VALID_SCENE_GEOGRAPHY_FIXTURE_V1,
VALID_SCENE_SCRIPT_FIXTURE_V1,
VALID_SHOT_EXECUTION_FIXTURE_V1,
VALID_SOURCE_ANALYSIS_FIXTURE_V1
} from './fixtures/neutral-production-fixtures';
import { assertPromptIsCleanV1, scanPromptContaminationV1 } from './prompts/prompt-contamination';
import { PROMPT_RULE_REGISTRY_V1, activePromptRulesV1, promptRuleLinesV1 } from './prompts/prompt-rule-registry';
import {
estimateChineseSpeechDurationMsV1,
validateAdaptationBibleSpecV1,
validateAssetPlanSpecV1,
validateEpisodePlanSpecV1,
validatePromptRuleCandidateV1,
validateSceneGeographySpecV1,
validateSceneScriptSpecV1,
validateShotExecutionSpecV1,
validateSourceAnalysisSpecV1
} from './validators/production-contract-validators';
const LEGACY_PROJECT_TERMS = [
'沈知夏',
'顾砚衡',
'许听棠',
'周曼仪',
'云顶酒店',
'白塔地下七层',
'第七张底牌'
];
describe('production kernel v1', () => {
it('passes traceable source analysis and blocks an untraceable adaptation decision', () => {
expect(validateSourceAnalysisSpecV1(VALID_SOURCE_ANALYSIS_FIXTURE_V1)).toEqual({ passed: true, issues: [] });
expect(validateAdaptationBibleSpecV1(VALID_ADAPTATION_BIBLE_FIXTURE_V1)).toEqual({ passed: true, issues: [] });
const invalid = structuredClone(VALID_ADAPTATION_BIBLE_FIXTURE_V1);
invalid.adaptation_decisions[0].source_refs = [];
const validation = validateAdaptationBibleSpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.some((item) => item.code === 'source_ref_required')).toBe(true);
});
it('rejects indirect source refs and non-contract aliases in source analysis', () => {
const invalid = structuredClone(VALID_SOURCE_ANALYSIS_FIXTURE_V1) as any;
invalid.premise = { logline: '错误对象' };
invalid.timeline[0].source_refs = ['ref_chapter_1'];
invalid.world_rules = [{ rule: '错误别名', evidence: '错误字段', source_refs: ['ref_chapter_1'] }];
invalid.visual_assets = [{ asset: '错误别名', description: '错误字段', source_refs: ['ref_chapter_1'] }];
const validation = validateSourceAnalysisSpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.map((item) => item.code)).toEqual(expect.arrayContaining([
'required_text',
'invalid_source_ref',
'required_text'
]));
expect(validation.issues.some((item) => item.field_path === 'world_rules[0].fact')).toBe(true);
expect(validation.issues.some((item) => item.field_path === 'visual_assets[0].fact')).toBe(true);
});
it('rejects malformed adaptation format, duplicate decisions and loose policy arrays', () => {
const invalid = structuredClone(VALID_ADAPTATION_BIBLE_FIXTURE_V1) as any;
invalid.format.orientation = '9:16';
invalid.adaptation_decisions.push(structuredClone(invalid.adaptation_decisions[0]));
invalid.visual_policy = '错误的字符串';
const validation = validateAdaptationBibleSpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.map((item) => item.code)).toEqual(expect.arrayContaining([
'invalid_orientation',
'duplicate_adaptation_decision_id',
'string_array_required'
]));
});
it('keeps the approved prompt rule registry free of legacy project facts', () => {
const publicRules = PROMPT_RULE_REGISTRY_V1.filter((rule) => rule.scope !== 'project');
const publicText = publicRules.map((rule) => `${rule.original_text}\n${rule.normalized_rule}`).join('\n');
expect(scanPromptContaminationV1(publicText, LEGACY_PROJECT_TERMS)).toEqual([]);
expect(publicRules.every((rule) => validatePromptRuleCandidateV1(rule).passed)).toBe(true);
});
it('activates global rules and matching genre rules without deprecated project rules', () => {
const generic = activePromptRulesV1({ stage: 'shot' });
const suspense = activePromptRulesV1({ stage: 'shot', genre_tags: ['suspense'] });
expect(generic.some((rule) => rule.id === 'shot.performance_arc')).toBe(true);
expect(generic.some((rule) => rule.id === 'shot.suspense_sound_pressure')).toBe(false);
expect(suspense.some((rule) => rule.id === 'shot.suspense_sound_pressure')).toBe(true);
expect(suspense.some((rule) => rule.status === 'deprecated')).toBe(false);
expect(promptRuleLinesV1({ stage: 'shot' })[0]).toContain('@prompt_rule_v1');
});
it('detects embedded project facts, ids, asset references and temporary urls', () => {
const text = '沿云顶酒店外墙推进,project_id=18,使用参考图 #922https://example.com/a?token=secret';
const matches = scanPromptContaminationV1(text, LEGACY_PROJECT_TERMS);
expect(matches.map((match) => match.type)).toEqual(expect.arrayContaining([
'forbidden_term',
'database_id',
'embedded_asset_reference',
'temporary_url'
]));
expect(() => assertPromptIsCleanV1(text, LEGACY_PROJECT_TERMS)).toThrow('Prompt contamination detected');
});
it('passes a valid episode plan and blocks a hook without an episode payoff', () => {
expect(validateEpisodePlanSpecV1(VALID_EPISODE_PLAN_FIXTURE_V1)).toEqual({ passed: true, issues: [] });
const invalid = structuredClone(VALID_EPISODE_PLAN_FIXTURE_V1);
invalid.opening_hook.payoff_beat_id = 'missing-beat';
const validation = validateEpisodePlanSpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.some((item) => item.code === 'hook_payoff_missing')).toBe(true);
});
it('blocks overlapping hook timing and incomplete episode state', () => {
const invalid = structuredClone(VALID_EPISODE_PLAN_FIXTURE_V1) as any;
invalid.opening_hook.timeline.push({
start_ms: 1_000,
end_ms: 2_000,
visual: '重叠画面',
audio: '重叠声音',
information_gain: '重叠信息'
});
invalid.exit_state.character_states = {};
const validation = validateEpisodePlanSpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.map((item) => item.code)).toEqual(expect.arrayContaining([
'overlapping_hook_timing',
'character_states_required'
]));
});
it('budgets Chinese dialogue before generation and blocks truncated timing', () => {
const requiredMs = estimateChineseSpeechDurationMsV1('这句话必须完整说完。');
expect(requiredMs).toBeGreaterThan(1_000);
const invalid: SceneScriptSpecV1 = structuredClone(VALID_SCENE_SCRIPT_FIXTURE_V1);
invalid.scenes[0].dialogue_beats = [{
id: 'dialogue-1',
character_id: 'CHAR_A',
line: '这句话必须完整说完。',
intention: '阻止对方离开。',
subtext: 'CHAR_A 已经没有退路。',
estimated_duration_ms: 500
}];
invalid.dialogue_duration_estimate_ms = requiredMs;
const validation = validateSceneScriptSpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.some((item) => item.code === 'dialogue_duration_insufficient')).toBe(true);
});
it('passes a traceable scene script and blocks duplicated scenes or broken state handoff', () => {
expect(validateSceneScriptSpecV1(VALID_SCENE_SCRIPT_FIXTURE_V1)).toEqual({ passed: true, issues: [] });
const invalid: SceneScriptSpecV1 = structuredClone(VALID_SCENE_SCRIPT_FIXTURE_V1);
const duplicated = structuredClone(invalid.scenes[0]);
duplicated.entry_state = {
...structuredClone(invalid.scenes[0].exit_state),
location_state: 'BROKEN_HANDOFF'
};
invalid.scenes.push(duplicated);
invalid.total_duration_estimate_ms = 24_000;
invalid.target_duration_ms = 24_000;
const validation = validateSceneScriptSpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.some((item) => item.code === 'duplicate_scene_id')).toBe(true);
});
it('passes a complete asset plan and blocks fake reuse without an existing asset', () => {
expect(validateAssetPlanSpecV1(VALID_ASSET_PLAN_FIXTURE_V1)).toEqual({ passed: true, issues: [] });
const invalid: AssetPlanSpecV1 = structuredClone(VALID_ASSET_PLAN_FIXTURE_V1);
invalid.requirements[0].reuse_decision = 'reuse';
invalid.requirements[0].existing_ref = null;
invalid.scene_bindings[0].location_requirement_ref = 'REQ_MISSING';
const validation = validateAssetPlanSpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.map((item) => item.code)).toEqual(expect.arrayContaining([
'existing_asset_ref_required',
'asset_requirement_ref_missing'
]));
});
it('passes a scene geography contract and blocks self-targeting action vectors or unsafe cameras', () => {
expect(validateSceneGeographySpecV1(VALID_SCENE_GEOGRAPHY_FIXTURE_V1)).toEqual({ passed: true, issues: [] });
const invalid: SceneGeographySpecV1 = structuredClone(VALID_SCENE_GEOGRAPHY_FIXTURE_V1);
invalid.scene_geographies[0].camera_positions[0].axis_side = 'forbidden';
invalid.scene_geographies[0].action_vectors = [{
id: 'vector-self-hit',
source_ref: 'CHAR_A',
target_ref: 'CHAR_A',
origin_zone_id: 'zone-character',
target_zone_id: 'zone-character',
world_direction: { x: 0, y: 0, z: -1 },
screen_direction: '向下',
trajectory: '回到自身',
must_keep_origin_and_target_visible: true
}];
const validation = validateSceneGeographySpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.map((item) => item.code)).toEqual(expect.arrayContaining([
'safe_camera_position_required',
'forbidden_camera_marked_allowed',
'action_vector_self_target'
]));
});
it('passes a valid shot and blocks broken first-last-frame continuity', () => {
expect(validateShotExecutionSpecV1(VALID_SHOT_EXECUTION_FIXTURE_V1)).toEqual({ passed: true, issues: [] });
const invalid: ShotExecutionSpecV1 = structuredClone(VALID_SHOT_EXECUTION_FIXTURE_V1);
invalid.generation_strategy.mode = 'first_last_frame';
invalid.generation_strategy.reference_assets = [
{ asset_id: 'asset-start', responsibility: 'start_frame', priority: 1 },
{ asset_id: 'asset-end', responsibility: 'end_frame', priority: 2 }
];
invalid.end_state.camera_axis = 'opposite axis';
invalid.end_state.screen_direction = 'left to right';
const validation = validateShotExecutionSpecV1(invalid);
expect(validation.passed).toBe(false);
expect(validation.issues.map((item) => item.code)).toEqual(expect.arrayContaining([
'first_last_axis_mismatch',
'first_last_direction_mismatch'
]));
});
it('enforces mutually exclusive temporal-frame rules for every generation strategy', () => {
const textOnly: ShotExecutionSpecV1 = structuredClone(VALID_SHOT_EXECUTION_FIXTURE_V1);
textOnly.generation_strategy.mode = 'text_only';
expect(validateShotExecutionSpecV1(textOnly).issues.map((item) => item.code)).toContain(
'text_only_references_forbidden'
);
const plannedFirstFrame: ShotExecutionSpecV1 = structuredClone(VALID_SHOT_EXECUTION_FIXTURE_V1);
plannedFirstFrame.generation_strategy.mode = 'first_frame';
plannedFirstFrame.generation_strategy.reference_assets = [];
expect(validateShotExecutionSpecV1(plannedFirstFrame)).toEqual({ passed: true, issues: [] });
const plannedFirstLastFrame: ShotExecutionSpecV1 = structuredClone(VALID_SHOT_EXECUTION_FIXTURE_V1);
plannedFirstLastFrame.generation_strategy.mode = 'first_last_frame';
plannedFirstLastFrame.generation_strategy.reference_assets = [];
expect(validateShotExecutionSpecV1(plannedFirstLastFrame)).toEqual({ passed: true, issues: [] });
const emptyMultiReference: ShotExecutionSpecV1 = structuredClone(VALID_SHOT_EXECUTION_FIXTURE_V1);
emptyMultiReference.generation_strategy.mode = 'multi_reference';
emptyMultiReference.generation_strategy.reference_assets = [];
expect(validateShotExecutionSpecV1(emptyMultiReference).issues.map((item) => item.code)).toContain(
'multi_reference_assets_required'
);
const temporalMultiReference: ShotExecutionSpecV1 = structuredClone(VALID_SHOT_EXECUTION_FIXTURE_V1);
temporalMultiReference.generation_strategy.mode = 'multi_reference';
temporalMultiReference.generation_strategy.reference_assets = [
{ asset_id: 'asset-start', responsibility: 'start_frame', priority: 1 }
];
expect(validateShotExecutionSpecV1(temporalMultiReference).issues.map((item) => item.code)).toContain(
'multi_reference_temporal_frames_forbidden'
);
});
it('rejects a project-scoped rule pretending to be public and approved', () => {
const invalidRule: PromptRuleCandidateV1 = {
id: 'bad.project.rule',
source_prompt_refs: ['legacy:1'],
stage: 'shot',
scope: 'project',
genre_tags: [],
provider_codes: [],
original_text: 'specific project fact',
normalized_rule: 'specific project fact',
activation_condition: 'always',
expected_improvement: 'none',
evidence_refs: ['legacy:1'],
positive_examples: [],
negative_examples: [],
conflicts_with: [],
status: 'approved',
rule_version: PRODUCTION_CONTRACT_VERSIONS.promptRule
};
const validation = validatePromptRuleCandidateV1(invalidRule);
expect(validation.passed).toBe(false);
expect(validation.issues.some((item) => item.code === 'project_rule_not_public')).toBe(true);
});
});
@@ -0,0 +1,944 @@
import { Injectable } from '@nestjs/common';
import type { Project } from '@prisma/client';
import {
PRODUCTION_CONTRACT_VERSIONS,
assertPromptIsCleanV1,
estimateChineseSpeechDurationMsV1,
promptRuleLinesV1,
type AdaptationBibleSpecV1,
type AssetPlanSpecV1,
type EpisodePlanSpecV1,
type SceneGeographySpecV1,
type SceneScriptSpecV1,
type SourceAnalysisSpecV1,
type StageQualityResultV1
} from './index';
interface SourceSnapshotPromptInput {
id: string;
title: string | null;
content_hash: string;
content_text: string;
chapter_manifest: unknown;
}
interface SceneScriptPromptContext {
episode_contract_id: string;
episode_plan: EpisodePlanSpecV1;
adaptation_contract_id: string;
adaptation_bible: AdaptationBibleSpecV1;
source_analysis_contract_id?: string;
source_analysis?: SourceAnalysisSpecV1;
source_excerpt?: string;
}
interface AssetPlanPromptContext {
scene_script_contract_id: string;
scene_script: SceneScriptSpecV1;
source_analysis?: SourceAnalysisSpecV1;
inventory: {
project_characters: Array<{
id: string;
name: string;
anchor_asset_id: string | null;
global_character_id: string | null;
status: string;
}>;
project_visual_assets: Array<{
id: string;
asset_kind: string;
asset_type: string;
name: string;
asset_id: string | null;
is_primary: boolean;
status: string;
}>;
matching_global_characters: Array<{
id: string;
name: string;
anchor_asset_id: string | null;
status: string;
}>;
legacy_character_candidates: Array<{
character_id: string;
source_character_id: string;
name: string;
source_project_id: string;
anchor_asset_id: string;
appearance_desc: string | null;
costume_rules: string | null;
status: string;
}>;
};
}
interface SceneGeographyPromptContext {
asset_plan_contract_id: string;
asset_plan: AssetPlanSpecV1;
scene_script_contract_id: string;
scene_script: SceneScriptSpecV1;
}
interface SplusSceneScriptReviewerPromptContext {
contract_id: string;
scene_script: SceneScriptSpecV1;
episode_plan_contract_id: string;
episode_plan: EpisodePlanSpecV1;
adaptation_contract_id: string;
adaptation_bible: AdaptationBibleSpecV1;
source_analysis_contract_id?: string;
source_analysis?: SourceAnalysisSpecV1;
source_excerpt?: string;
deterministic_quality: StageQualityResultV1;
reviewer_notes: string[];
}
const JSON_ONLY = [
'只输出一个合法 JSON 对象,不要 Markdown 代码块,不要解释文字。',
'禁止省略必填字段,禁止用“同上”“略”等占位词。',
'事实、事件、人物、世界规则和改编决定必须携带 source_refs。',
'来源不确定时写入 uncertain_facts,禁止把推测写成 immutable_facts。'
];
@Injectable()
export class ProductionStagePromptBuilderService {
buildSourceAnalystPrompt(project: Project, snapshot: SourceSnapshotPromptInput) {
const chapterEntry = Array.isArray(snapshot.chapter_manifest)
? snapshot.chapter_manifest.find((entry) => typeof entry === 'object' && entry !== null)
: null;
const chapterId = chapterEntry && ('chapter_id' in chapterEntry || 'id' in chapterEntry)
? String(('chapter_id' in chapterEntry ? chapterEntry.chapter_id : chapterEntry.id) ?? '')
: '';
const factSourceRef = chapterId
? { source_type: 'chapter', source_id: chapterId, locator: `chapter:${chapterId}` }
: { source_type: 'novel_snapshot', source_id: snapshot.id, locator: `snapshot:${snapshot.id}` };
const exactSchema = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.sourceAnalysis,
source_snapshot_id: snapshot.id,
premise: '<单行文本>',
genre_promises: ['<文本>'],
timeline: [{
id: 'event-1',
order: 1,
event: '<文本>',
participants: ['<角色名>'],
consequence: '<文本>',
source_refs: [factSourceRef]
}],
characters: [{
character_id: 'CHAR_001',
name: '<人物名>',
role: '<角色职能>',
goal: '<文本>',
fear: '<可选文本>',
secrets: ['<文本;没有则空数组>'],
state: '<当前状态>',
source_refs: [factSourceRef]
}],
relationships: [{ fact: '<文本>', source_refs: [factSourceRef] }],
world_rules: [{ fact: '<文本>', source_refs: [factSourceRef] }],
conflicts: [{ fact: '<文本>', source_refs: [factSourceRef] }],
mysteries: [{ fact: '<文本>', source_refs: [factSourceRef] }],
emotional_assets: [{ fact: '<文本>', source_refs: [factSourceRef] }],
visual_assets: [{ fact: '<文本>', source_refs: [factSourceRef] }],
immutable_facts: [{ fact: '<文本>', source_refs: [factSourceRef] }],
uncertain_facts: [{ fact: '<文本>', source_refs: [factSourceRef] }],
source_refs: [{ source_type: 'novel_snapshot', source_id: snapshot.id, locator: `snapshot:${snapshot.id}` }]
};
const template = [
'你是 S+ 短剧生产内核的 Source Analyst,只负责忠实分析原著,不负责改编或续写。',
...JSON_ONLY,
...promptRuleLinesV1({ stage: 'source', genre_tags: project.genre ? [project.genre] : [] }),
`schema_version 必须为 ${PRODUCTION_CONTRACT_VERSIONS.sourceAnalysis}`,
'必须严格按下方 JSON 结构和字段名输出,不得自创字段,不得把字段改成对象或别名。',
'premise 必须是字符串;genre_promises 必须是字符串数组;secrets 必须是字符串数组,没有内容时输出空数组。',
'relationships、world_rules、conflicts、mysteries、emotional_assets、visual_assets、immutable_facts、uncertain_facts 的每一项只能使用 fact 与 source_refs。',
'禁止在上述事实项中使用 rule、evidence、asset、description、claim、reason、from、to、type 等替代字段。',
'每一处 source_refs 都必须直接嵌入完整来源对象数组,禁止输出 ref_id,禁止输出字符串引用,禁止使用类似 ref_chapter_123 的间接索引。',
'source_refs.source_type 只能使用 novel_snapshot 或 chaptersource_id 必须使用下方输入中的真实快照 ID 或真实章节 ID,不能编造名称。',
'timeline 中每个事件必须有 id、order、event、participants、consequence、source_refs;人物必须有 name、role、goal、secrets、state、source_refs。',
'结构示例中的尖括号文本只是类型占位,输出时必须全部替换为对原文的真实分析,不得原样保留。',
'【精确 JSON 结构】',
JSON.stringify(exactSchema)
].join('\n');
assertPromptIsCleanV1(template);
return [
template,
'',
'【项目元数据】',
JSON.stringify({ title: project.title, genre: project.genre, output_mode: project.output_mode }),
'【不可变原文快照】',
JSON.stringify({
snapshot_id: snapshot.id,
title: snapshot.title,
content_hash: snapshot.content_hash,
chapter_manifest: snapshot.chapter_manifest
}),
snapshot.content_text
].join('\n');
}
buildAdaptationShowrunnerPrompt(project: Project, sourceContractId: string, source: SourceAnalysisSpecV1) {
const sourceRefExample = source.source_refs.find((ref) => ref.source_type === 'chapter')
?? source.timeline.flatMap((event) => event.source_refs).find((ref) => ref.source_type === 'chapter')
?? source.source_refs[0];
const targetEpisodeSeconds = project.episode_duration ?? 60;
const targetEpisodeCount = project.target_episode_count ?? 3;
const exactSchema = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.adaptationBible,
source_analysis_version_id: sourceContractId,
format: {
orientation: '16:9',
target_episode_seconds: targetEpisodeSeconds,
episode_count_range: [targetEpisodeCount, targetEpisodeCount],
audience: '<目标观众文本>'
},
genre_contract: '<文本>',
tone_contract: '<文本>',
protagonist_contract: '<文本>',
central_dramatic_question: '<文本>',
main_arc: '<文本>',
character_arcs: [{ character_id: '<原著分析中的角色ID>', arc: '<文本>' }],
season_beats: [{ id: 'season-beat-1', beat: '<文本>', consequence: '<文本>' }],
adaptation_decisions: [{
id: 'decision-1',
operation: 'compress',
source_refs: sourceRefExample ? [sourceRefExample] : [],
target: '<改编对象或范围>',
reason: '<文本>',
impact: ['<文本>'],
continuity_risk: '<文本>',
approval_status: 'approved'
}],
hook_policy: {
mode: 'cinematic_0_8s',
max_setup_ms: 8_000,
require_visible_question: true,
require_episode_payoff: true,
max_repeated_hook_type: 1
},
dialogue_policy: {
language: 'zh-CN',
target_chars_per_second: 4.2,
preserve_confirmed_lines: true,
require_listener_reaction: true
},
visual_policy: ['<文本>'],
sound_policy: ['<文本>'],
prohibited_changes: ['<文本>'],
source_refs: source.source_refs
};
const template = [
'你是 S+ 短剧 Adaptation Showrunner。只能根据已确认的原著分析制定改编圣经和决策账本。',
...JSON_ONLY,
...promptRuleLinesV1({ stage: 'adaptation', genre_tags: project.genre ? [project.genre] : [] }),
`schema_version 必须为 ${PRODUCTION_CONTRACT_VERSIONS.adaptationBible}`,
'source_analysis_version_id 必须原样使用给定合同 ID。',
'每项 adaptation_decisions 必须有唯一 id、operation、target、reason、impact、continuity_risk、approval_status。',
'keep/compress/merge/reorder/remove 必须引用原著;add 必须说明其服务的戏剧目标且不得违反 immutable_facts。',
'operation 只能是 keep、compress、merge、reorder、remove、addapproval_status 必须为 approved,合同确认即代表本批改编决定获准。',
'本项目固定为 16:9episode_count_range 的上下限必须都等于目标集数,不得自行改成单集长片或更多集数。',
'source_refs 必须直接嵌入完整来源对象,不得输出字符串引用或 ref_id;角色弧必须使用原著分析中已有 character_id。',
'不得把未批准的改编决定伪装成原著事实,不得改变 immutable_facts,不得提前解释 uncertain_facts。',
'必须严格按下方 JSON 结构和字段名输出,不得自创字段或更改嵌套层级。',
'结构示例中的尖括号文本只是类型占位,输出时必须全部替换为本项目真实内容。',
'【精确 JSON 结构】',
JSON.stringify(exactSchema)
].join('\n');
assertPromptIsCleanV1(template);
return [
template,
'',
'【项目目标】',
JSON.stringify({
title: project.title,
genre: project.genre,
orientation: '16:9',
target_episode_seconds: targetEpisodeSeconds,
target_episode_count: targetEpisodeCount
}),
'【已确认原著分析】',
JSON.stringify({ contract_id: sourceContractId, payload: source })
].join('\n');
}
buildEpisodePlannerPrompt(
project: Project,
adaptationContractId: string,
adaptation: AdaptationBibleSpecV1,
episodeNumber: number,
previousPlans: EpisodePlanSpecV1[]
) {
const previousPlan = previousPlans.at(-1);
const sourceRefExample = adaptation.adaptation_decisions
.flatMap((decision) => decision.source_refs)
.find((ref) => ref.source_type === 'chapter')
?? adaptation.source_refs[0];
const characterStates = Object.fromEntries(
adaptation.character_arcs.map((arc) => [arc.character_id, '<本集入口状态>'])
);
const entryState = previousPlan?.exit_state ?? {
character_states: characterStates,
active_conflicts: ['<本集开始时仍在进行的冲突>'],
known_information: ['<本集开始时角色已知信息>'],
location_state: '<本集入口地点状态>'
};
const approvedDecisionId = adaptation.adaptation_decisions
.find((decision) => decision.approval_status === 'approved')?.id
?? '<本集实际使用的已批准决策ID>';
const exactSchema = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.episodePlan,
adaptation_bible_version_id: adaptationContractId,
episode_number: episodeNumber,
target_duration_ms: (project.episode_duration ?? adaptation.format.target_episode_seconds) * 1000,
hook_policy: adaptation.hook_policy,
opening_hook: {
type: 'crisis',
viewer_question: '<文本>',
character_at_risk: ['<角色ID>'],
stakes: '<文本>',
visual_event: '<可见画面事件>',
audio_event: '<可听声音事件>',
timeline: [{
start_ms: 0,
end_ms: Math.min(2_000, adaptation.hook_policy.max_setup_ms),
visual: '<文本>',
audio: '<文本>',
information_gain: '<观众新获得的信息>'
}],
relation_to_episode_conflict: '<文本>',
payoff_beat_id: 'beat-payoff',
source_refs: sourceRefExample ? [sourceRefExample] : adaptation.source_refs,
anti_clickbait_check: '<说明本集如何兑现>'
},
protagonist_goal: '<文本>',
obstacle: '<文本>',
stakes: '<文本>',
dramatic_question: '<文本>',
beats: [{
id: 'beat-payoff',
type: 'setup',
beat: '<文本>',
character_action: '<角色可执行动作>',
consequence: '<不可省略的结果>',
source_refs: sourceRefExample ? [sourceRefExample] : adaptation.source_refs
}, {
id: 'beat-climax',
type: 'climax',
beat: '<文本>',
character_action: '<角色关键选择或行动>',
consequence: '<不可逆结果>',
source_refs: sourceRefExample ? [sourceRefExample] : adaptation.source_refs
}, {
id: 'beat-hook',
type: 'hook',
beat: '<集尾新增信息>',
character_action: '<文本>',
consequence: '<文本>',
source_refs: sourceRefExample ? [sourceRefExample] : adaptation.source_refs
}],
midpoint_change: '<文本>',
climax_choice: '<文本>',
irreversible_change: '<文本>',
ending_hook: {
hook: '<文本>',
new_information: '<本集结尾新增信息>',
next_episode_question: '<下一集追看问题>',
state_change: '<本集结束时发生的状态变化>',
source_refs: sourceRefExample ? [sourceRefExample] : adaptation.source_refs
},
entry_state: entryState,
exit_state: {
character_states: characterStates,
active_conflicts: ['<本集结束后仍在进行的冲突>'],
known_information: ['<本集结束时角色已知信息>'],
location_state: '<本集出口地点状态>'
},
source_refs: sourceRefExample ? [sourceRefExample] : adaptation.source_refs,
adaptation_decision_refs: [approvedDecisionId]
};
const template = [
'你是 S+ 短剧 Episode Planner。把已确认改编圣经写成单集结构计划,不写完整对白,不拆分镜。',
...JSON_ONLY,
...promptRuleLinesV1({ stage: 'episode', genre_tags: project.genre ? [project.genre] : [] }),
`schema_version 必须为 ${PRODUCTION_CONTRACT_VERSIONS.episodePlan}`,
'adaptation_bible_version_id 必须原样使用给定合同 ID。',
'开场钩子必须从 0ms 开始,必须由画面事件和声音事件共同成立,并在本集 beats 中兑现。',
'本集必须有角色目标、阻力、代价、升级、转折、高潮选择、不可逆变化和集尾新增信息。',
'entry_state 必须严格继承上一集 exit_state;没有上一集时从改编圣经建立初始状态。',
'adaptation_decision_refs 只能引用 approval_status=approved 且本集实际使用的改编决策,禁止把全部 ID 无差别填入。',
'opening_hook.type 只能是 crisis、result_first、identity_contrast、relationship_break、impossible_event、secret_exposure、countdown、forced_choice、high_value_promise、previous_hook_payoff。',
'beats.type 只能是 setup、escalation、reversal、choice、climax、aftermath、hook;至少三项且必须包含 climax 或 choice。',
'opening_hook.timeline 必须从 0ms 开始、时间不重叠、结束时间不超过 hook_policy.max_setup_mspayoff_beat_id 必须指向本集真实 beat。',
'所有 source_refs 必须直接嵌入完整来源对象,不能使用字符串引用或 ref_id。',
'必须严格按下方 JSON 结构和字段名输出,不得自创字段或更改嵌套层级。',
previousPlan
? `${episodeNumber} 集 entry_state 必须逐字逐字段复制下方精确结构中的 entry_state,不得概括、增删或改名。`
: '第 1 集 entry_state 必须准确描述故事开场状态,不得写入尚未发生的信息。',
'结构示例中的尖括号文本只是类型占位,输出时必须全部替换为本集真实内容。',
'【精确 JSON 结构】',
JSON.stringify(exactSchema)
].join('\n');
assertPromptIsCleanV1(template);
return [
template,
'',
'【本次规划参数】',
JSON.stringify({
episode_number: episodeNumber,
target_duration_ms: (project.episode_duration ?? adaptation.format.target_episode_seconds) * 1000,
project_genre: project.genre
}),
'【已确认改编圣经】',
JSON.stringify({ contract_id: adaptationContractId, payload: adaptation }),
'【前序分集状态,只允许继承,不得改写】',
JSON.stringify(previousPlans.map((plan) => ({
episode_number: plan.episode_number,
exit_state: plan.exit_state,
ending_hook: plan.ending_hook,
opening_hook_type: plan.opening_hook.type
})))
].join('\n');
}
buildEpisodeScreenwriterPrompt(project: Project, context: SceneScriptPromptContext) {
const plan = context.episode_plan;
const adaptation = context.adaptation_bible;
const targetDuration = plan.target_duration_ms;
const sourceRefs = plan.source_refs.length > 0
? plan.source_refs
: adaptation.source_refs;
const characterIds = [...new Set([
...Object.keys(plan.entry_state.character_states ?? {}),
...Object.keys(plan.exit_state.character_states ?? {}),
...(plan.opening_hook.character_at_risk ?? []),
...adaptation.character_arcs.map((arc) => arc.character_id)
])].filter(Boolean);
const leadCharacter = characterIds[0] ?? 'CHAR_001';
const counterpart = characterIds[1] ?? leadCharacter;
const openingDuration = Math.min(
Math.max(4_000, Math.round(targetDuration * 0.13)),
adaptation.hook_policy.max_setup_ms,
targetDuration
);
const endingDuration = Math.max(4_000, Math.round(targetDuration * 0.17));
const middleOneDuration = Math.round((targetDuration - openingDuration - endingDuration) * 0.42);
const middleTwoDuration = targetDuration - openingDuration - endingDuration - middleOneDuration;
const sceneTemplate = (
id: string,
entryState: EpisodePlanSpecV1['entry_state'],
exitState: EpisodePlanSpecV1['exit_state'],
duration: number
) => ({
id,
location_asset_ref: 'LOCATION_REQUIREMENT_001:V1_REQUIRED',
time_of_day: '<明确时间>',
entry_state: entryState,
scene_goal: '<本场人物可执行目标>',
active_characters: characterIds,
character_objectives: characterIds.map((characterId) => ({
character_id: characterId,
objective: '<该角色本场具体目标>'
})),
obstacle: '<阻止目标达成的具体力量>',
tactics: characterIds.map((characterId) => ({
character_id: characterId,
tactic: '<该角色为目标采取的可见策略>'
})),
action_beats: [{
id: `${id}-action-1`,
character_id: leadCharacter,
action: '<可拍摄动作>',
trigger: '<触发动作的可见或可听事件>',
result: '<动作造成的状态变化>',
estimated_duration_ms: Math.max(1_000, Math.round(duration * 0.35))
}],
dialogue_beats: [{
id: `${id}-dialogue-1`,
character_id: counterpart,
line: '<该角色在本场实际说出的完整中文台词;无台词场景可输出空数组>',
intention: '<说这句话想改变什么>',
subtext: '<没有明说的真实意思>',
reaction_target: leadCharacter,
estimated_duration_ms: Math.max(1_000, Math.round(duration * 0.2))
}],
subtext: '<本场整体潜台词>',
turn: '<本场结尾不可省略的转折或新增信息>',
exit_state: exitState,
estimated_duration_ms: duration,
source_refs: sourceRefs
});
const unchangedEntry = structuredClone(plan.entry_state);
const exactSchema: SceneScriptSpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.sceneScript,
episode_plan_version_id: context.episode_contract_id,
episode_number: plan.episode_number,
target_duration_ms: targetDuration,
scenes: [
sceneTemplate('scene-opening-hook', plan.entry_state, unchangedEntry, openingDuration),
sceneTemplate('scene-escalation-1', unchangedEntry, unchangedEntry, middleOneDuration),
sceneTemplate('scene-climax', unchangedEntry, unchangedEntry, middleTwoDuration),
sceneTemplate('scene-ending-hook', unchangedEntry, plan.exit_state, endingDuration)
],
opening_hook_scene_id: 'scene-opening-hook',
climax_scene_id: 'scene-climax',
ending_hook_scene_id: 'scene-ending-hook',
dialogue_duration_estimate_ms: 0,
total_duration_estimate_ms: targetDuration,
character_voice_checks: characterIds.map((characterId) => ({
character_id: characterId,
check: '<检查该角色全部台词是否符合身份、目标、处境和时代语感>',
passed: true
})),
continuity_checks: [{
check: '<检查首场入口、相邻场接力、末场出口和信息知情边界>',
passed: true
}]
};
const template = [
'你是 S+ 短剧 Scene Screenwriter。把已放行的单集计划写成可拍、可计时、可继续拆镜的场景剧本合同;此阶段不写镜头号、运镜、模型提示词或后期字幕。',
...JSON_ONLY,
...promptRuleLinesV1({ stage: 'script', genre_tags: project.genre ? [project.genre] : [] }),
`schema_version 必须为 ${PRODUCTION_CONTRACT_VERSIONS.sceneScript}`,
`episode_plan_version_id 必须原样使用 ${context.episode_contract_id}episode_number 必须为 ${plan.episode_number}target_duration_ms 必须为 ${targetDuration}`,
'只允许展开已确认分集计划及其获准改编决定,不得擅自提前下一集事件,不得改变不可变事实。',
'优先逐字保留原文中已有且属于本集的对白;没有原句时才可补写符合角色身份、处境、目标和时代语感的短句。禁止现代网络口吻,除非改编圣经明确要求。',
`对白时长按每秒 ${adaptation.dialogue_policy.target_chars_per_second} 个有效字估算,并叠加标点停顿;每句 estimated_duration_ms 必须足够完整说完。`,
'dialogue_duration_estimate_ms 必须等于所有 dialogue_beats.estimated_duration_ms 的整数合计;total_duration_estimate_ms 必须等于所有 scenes.estimated_duration_ms 的整数合计,且必须严格等于 target_duration_ms。',
'本集使用 3 至 6 个戏剧场景。同一地点、同一冲突阶段的连续行动不要机械拆场;每场必须有目标、阻力、策略、动作结果和转折。',
'首场 entry_state 必须逐字段等于分集 entry_state;末场 exit_state 必须逐字段等于分集 exit_state;相邻两场的 exit_state 与 entry_state 必须逐字段完全一致。',
'result_first 冷开场只作为非线性视觉承诺时,不得把未来事件写进人物当前已知信息;冷开场结束后世界状态保持分集入口不变,再回到当前时间推进。',
'opening_hook_scene_id 必须指向从 0ms 开始兑现 opening_hook 的场景;climax_scene_id 必须承载 climax_choiceending_hook_scene_id 必须真正产生 ending_hook.new_information。',
'active_characters、character_objectives、tactics、action_beats 和 dialogue_beats 必须只包含本场实际参与者;无对白场景的 dialogue_beats 输出空数组,不得保留示例占位台词。',
'location_asset_ref 尚无已批准场景资产时,使用稳定的需求引用,例如 LOCATION_WUZHANGYUAN_BATTLEFIELD:V1_REQUIRED;不得伪造数据库资产 ID。',
'character_voice_checks 必须覆盖本集所有说话角色;发现声线或措辞不符合角色时 passed=false 并写 issue,不能谎报通过。continuity_checks 必须逐项记录入口、场间接力、出口和信息知情边界检查。',
'所有 scenes.source_refs 必须直接嵌入分集已有的完整来源对象;禁止字符串引用、ref_id 和不存在的来源。',
'必须严格按下方 JSON 字段名和嵌套层级输出。示例中的尖括号内容必须替换为真实剧本;scenes 数组可按剧情职责增减到 3 至 6 项,但不得省略任何场景字段。',
'【精确 JSON 结构】',
JSON.stringify(exactSchema)
].join('\n');
assertPromptIsCleanV1(template);
return [
template,
'',
'【已放行分集计划】',
JSON.stringify({ contract_id: context.episode_contract_id, payload: plan }),
'【已确认改编圣经】',
JSON.stringify({ contract_id: context.adaptation_contract_id, payload: adaptation }),
'【已确认原著分析】',
JSON.stringify({
contract_id: context.source_analysis_contract_id ?? null,
payload: context.source_analysis ?? null
}),
'【本集可引用原文】',
context.source_excerpt?.trim() || '未提供原文摘录,只能使用上游合同中的已确认事实和台词。'
].join('\n');
}
buildAssetPlannerPrompt(project: Project, context: AssetPlanPromptContext) {
const script = context.scene_script;
const sourceRefs = script.scenes.flatMap((scene) => scene.source_refs);
const uniqueSourceRefs = sourceRefs.filter((ref, index, refs) =>
refs.findIndex((candidate) => candidate.source_type === ref.source_type && candidate.source_id === ref.source_id) === index
);
const characterNames = new Map(
(context.source_analysis?.characters ?? [])
.filter((character) => character.character_id)
.map((character) => [character.character_id!, character.name])
);
const activeCharacterIds = [...new Set(script.scenes.flatMap((scene) => scene.active_characters))];
const locationRefs = [...new Set(script.scenes.map((scene) => scene.location_asset_ref))];
const projectCharacterIds = context.inventory.project_characters.map((item) => item.id);
const projectVisualAssetIds = context.inventory.project_visual_assets.map((item) => item.id);
const matchingGlobalCharacterIds = context.inventory.matching_global_characters.map((item) => item.id);
const legacyCandidateAssetIds = [...new Set(context.inventory.legacy_character_candidates.map((item) => item.anchor_asset_id))];
const firstSourceRef = uniqueSourceRefs[0];
const requirementSourceRefs = firstSourceRef ? [firstSourceRef] : uniqueSourceRefs;
const characterRequirements = activeCharacterIds.map((characterId, index) => ({
id: `REQ_CHARACTER_${String(index + 1).padStart(3, '0')}`,
kind: 'character_identity' as const,
name: `${characterNames.get(characterId) ?? characterId}身份母版`,
source_character_id: characterId,
reuse_decision: 'create' as const,
existing_ref: null,
candidate_refs: context.inventory.legacy_character_candidates
.filter((candidate) => candidate.source_character_id === characterId)
.map((candidate) => ({
entity_type: 'asset' as const,
id: candidate.anchor_asset_id,
reason: `历史项目“${candidate.name}”锚点,只可人工比对脸型、年龄和服装是否符合本项目史诗设定`,
approval_status: 'manual_review_required' as const
})),
applies_to_scene_ids: script.scenes.filter((scene) => scene.active_characters.includes(characterId)).map((scene) => scene.id),
priority: 'blocking' as const,
visual_brief: '<同一原创虚构角色资产;人类角色锁定数字演员的年龄、脸型、身形、妆发和基础气质,非人角色锁定主头部、肢体数量、形体、表面材质和甲胄拓扑;不得复制现实演员或既有角色,不得把剧情状态混入身份母版>',
continuity_locks: ['<身份锁>', '<年龄锁>', '<脸型锁>', '<身形锁>', '<妆发基线锁>'],
deliverables: ['16:9影视级角色母版', '面部特写+正面全身+严格侧面全身+背面全身', '中性背景身份参考'],
acceptance_criteria: ['同一角色身份与年龄或形态状态', '全身比例、解剖和服装或甲胄结构一致', '可用于后续视频角色元素源视频'],
dependencies: [],
source_refs: requirementSourceRefs
}));
const locationRequirements = locationRefs.map((locationRef, index) => ({
id: `REQ_LOCATION_${String(index + 1).padStart(3, '0')}`,
kind: 'location' as const,
name: `${locationRef}场景空间母版`,
source_location_ref: locationRef,
reuse_decision: 'create' as const,
existing_ref: null,
candidate_refs: [],
applies_to_scene_ids: script.scenes.filter((scene) => scene.location_asset_ref === locationRef).map((scene) => scene.id),
priority: 'blocking' as const,
visual_brief: '<固定空间结构、蜀魏方位、地貌、入口出口、标志物、光源方向、天气和可延续破坏状态>',
continuity_locks: ['<空间拓扑锁>', '<阵营方位锁>', '<主光方向锁>', '<地貌材质锁>', '<固定标志物锁>'],
deliverables: ['2560x1440主视角', '2560x1440反打方向', '2560x1440侧向空间关系', '俯视布局图'],
acceptance_criteria: ['四张图空间关系互相可推导', '蜀魏方向固定', '后续VFX有明确落点与尺度参照'],
dependencies: [],
source_refs: requirementSourceRefs
}));
const exactSchema: AssetPlanSpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.assetPlan,
scene_script_version_id: context.scene_script_contract_id,
episode_number: script.episode_number,
format: {
orientation: '16:9',
keyframe_resolution: '2560x1440',
video_resolution: '1920x1080'
},
inventory_snapshot: {
project_character_ids: projectCharacterIds,
project_visual_asset_ids: projectVisualAssetIds,
matching_global_character_ids: matchingGlobalCharacterIds,
legacy_candidate_asset_ids: legacyCandidateAssetIds
},
requirements: [...characterRequirements, ...locationRequirements],
scene_bindings: script.scenes.map((scene) => ({
scene_id: scene.id,
location_requirement_ref: locationRequirements.find((requirement) => requirement.source_location_ref === scene.location_asset_ref)?.id ?? '',
character_requirement_refs: characterRequirements
.filter((requirement) => scene.active_characters.includes(requirement.source_character_id))
.map((requirement) => requirement.id),
crowd_requirement_refs: [],
prop_requirement_refs: [],
vfx_requirement_refs: []
})),
creation_order: [...characterRequirements, ...locationRequirements].map((requirement) => requirement.id),
blocking_requirement_refs: [...characterRequirements, ...locationRequirements].map((requirement) => requirement.id),
quality_checks: [
{ check: '<项目角色、项目视觉资产、全局角色和历史候选库存已逐项核对>', passed: true },
{ check: '<每个场景、每个活动角色、关键道具和VFX均有可追踪需求或明确判定为不需要>', passed: true },
{ check: '<所有reuse和upgrade均绑定真实现有引用,历史项目候选没有被冒充正式资产>', passed: true }
],
source_refs: uniqueSourceRefs
};
const template = [
'你是 S+ 短剧 Asset Planner。根据已确认场景剧本和只读库存,生成资产需求计划;此阶段不得拆镜、不得写视频 Prompt、不得创建图片、不得把历史候选自动批准为正式资产。',
...JSON_ONLY,
...promptRuleLinesV1({ stage: 'assets', genre_tags: project.genre ? [project.genre] : [] }),
`schema_version 必须为 ${PRODUCTION_CONTRACT_VERSIONS.assetPlan}scene_script_version_id 必须原样使用 ${context.scene_script_contract_id}`,
'format 必须固定为16:9、关键帧2560x1440、视频1920x1080,禁止输出9:16或其他画幅。',
'先盘点再决定:只有当前项目已绑定资产或已审批全局角色才能标记reuse/upgrade并填写existing_ref;历史项目角色锚点只能写入candidate_refs且approval_status=manual_review_required。',
'项目当前没有对应正式资产时必须标记createexisting_ref必须为null;禁止编造数据库ID、元素ID、资产版本或质检分。',
'requirements 必须覆盖每场实际活动角色的身份母版和必要剧情状态、每个location_asset_ref、会影响动作或证据的关键道具,以及剧情明确出现的VFX。群众、乌鸦、阴兵、巨型法相等根据可复用性判断为角色、道具或VFX,但必须说明连续性锁和交互尺度。',
'角色身份与角色状态必须拆开:身份母版锁脸、年龄、体型、基础妆发和气质;状态资产锁本集服装、伤势、污渍、持有物和情绪阶段。',
'角色身份母版必须写成原创虚构数字角色资产:人类可要求高写实电影VFX数字演员质感,但不得复制现实演员、公众人物或既有影视版本;非人、多头、多臂角色必须改用主头部、肢体数量、关节、表面材质和甲胄拓扑锁定,不得套用人类年龄纹理。',
'candidate_refs 只用于人工比对,不得把候选图的人脸、服装、风格、项目名称或历史 Prompt 写入 visual_brief、continuity_locks 和 acceptance_criteria;未人工批准时一律按 create 处理。',
'场景母版不是宣传海报,必须要求主视角、反打、侧向空间关系和俯视布局,并锁定阵营方位、入口出口、固定物件、地貌、主光方向和可延续破坏状态。',
'VFX需求必须说明起始形态、发展阶段、空间落点、人物/环境交互、光照反馈、尺度参照和结束残留,禁止只写“高级特效”。',
'每项需求都必须写清visual_brief、continuity_locks、deliverables、acceptance_criteria、dependencies、applies_to_scene_ids和source_refs。',
'scene_bindings 必须一场一项,引用真实存在的需求ID;creation_order必须按依赖顺序,blocking_requirement_refs必须列出付费拆镜前必须解决的缺口。',
'inventory_snapshot 四个数组必须逐字复制只读库存快照,不能增删或改写。',
'必须严格使用下方字段和嵌套结构。示例只预置身份与场景需求;你必须根据真实场景动作补齐状态、道具、群众和VFX需求。',
'【精确 JSON 结构】',
JSON.stringify(exactSchema)
].join('\n');
assertPromptIsCleanV1(template);
return [
template,
'',
'【已确认场景剧本】',
JSON.stringify({ contract_id: context.scene_script_contract_id, payload: script }),
'【角色ID与姓名】',
JSON.stringify(Object.fromEntries(activeCharacterIds.map((id) => [id, characterNames.get(id) ?? id]))),
'【只读资产库存快照】',
JSON.stringify(context.inventory)
].join('\n');
}
buildSceneGeographyPrompt(project: Project, context: SceneGeographyPromptContext) {
const assetPlan = context.asset_plan;
const script = context.scene_script;
const requirementById = new Map(assetPlan.requirements.map((requirement) => [requirement.id, requirement]));
const sceneGeographies = script.scenes.map((scene, sceneIndex) => {
const binding = assetPlan.scene_bindings.find((item) => item.scene_id === scene.id);
const locationRef = binding?.location_requirement_ref ?? '';
const subjects = scene.active_characters.length > 0
? scene.active_characters
: [`SCENE_SPACE_${String(sceneIndex + 1).padStart(3, '0')}`];
const endpointA = subjects[0] ?? 'SUBJECT_A';
const endpointB = subjects[1] ?? `ENVIRONMENT_TARGET_${String(sceneIndex + 1).padStart(3, '0')}`;
const sourceRefs = scene.source_refs;
return {
scene_id: scene.id,
location_requirement_ref: locationRef,
world_layout: `<根据场景动作和${requirementById.get(locationRef)?.name ?? locationRef}描述可推导的空间拓扑,不写镜头语言>`,
coordinate_system: {
origin: '<明确固定原点,例如中央无人区中心或主桌中心>',
x_axis: '<明确东西/左右正方向及戏剧含义>',
y_axis: '<明确南北/纵深正方向及戏剧含义>',
z_axis: '垂直向上为正'
},
zones: [{
id: `${scene.id}-zone-a`,
name: '<主体A固定区>',
purpose: '<该区承担的剧情与调度功能>',
center: { x: -10, y: 0, z: 0 },
bounds: '<米制边界和不可侵入边界>',
adjacent_zone_ids: [`${scene.id}-zone-b`]
}, {
id: `${scene.id}-zone-b`,
name: '<主体B或目标固定区>',
purpose: '<该区承担的剧情与调度功能>',
center: { x: 10, y: 0, z: 0 },
bounds: '<米制边界和不可侵入边界>',
adjacent_zone_ids: [`${scene.id}-zone-a`]
}],
placements: [{
subject_ref: endpointA,
subject_type: 'character' as const,
zone_id: `${scene.id}-zone-a`,
world_position: { x: -10, y: 0, z: 0 },
facing_vector: { x: 1, y: 0, z: 0 },
screen_side: 'left' as const,
eyeline_target_ref: endpointB,
vertical_relation: 'ground' as const
}, {
subject_ref: endpointB,
subject_type: subjects[1] ? 'character' as const : 'vfx_target' as const,
zone_id: `${scene.id}-zone-b`,
world_position: { x: 10, y: 0, z: 0 },
facing_vector: { x: -1, y: 0, z: 0 },
screen_side: 'right' as const,
eyeline_target_ref: endpointA,
vertical_relation: 'ground' as const
}],
primary_axis: {
id: `${scene.id}-axis-a`,
endpoint_a_ref: endpointA,
endpoint_b_ref: endpointB,
description: '<两主体关系轴及其世界坐标方向>',
screen_left_ref: endpointA,
screen_right_ref: endpointB,
safe_camera_side: '<明确轴线哪一侧为主拍摄半平面>',
forbidden_camera_side: '<明确未经桥接不得进入的半平面>',
crossing_policy: 'neutral_bridge_required' as const
},
camera_positions: [{
id: `${scene.id}-camera-establishing`,
name: '<中立建立机位>',
world_position: { x: 0, y: -18, z: 2 },
viewing_direction: { x: 0, y: 1, z: 0 },
axis_side: 'safe' as const,
allowed: true,
purpose: '<建立站位、朝向、纵深和攻击归属>',
preserves_screen_relationship: `<${endpointA}>固定在画面左侧,<${endpointB}>固定在画面右侧`
}],
action_vectors: [],
blocking_beats: [{
id: `${scene.id}-blocking-1`,
trigger: '<来自场景剧本的可见或可听触发>',
actor_ref: endpointA,
start_zone_id: `${scene.id}-zone-a`,
end_zone_id: `${scene.id}-zone-a`,
facing_target_ref: endpointB,
eyeline_target_ref: endpointB,
continuity_result: '<本节拍结束后必须由下一镜继承的站位、朝向和状态>'
}],
continuity_locks: ['<阵营或人物左右锁>', '<视线锁>', '<主光与地标锁>'],
forbidden_outcomes: ['<主体左右互换>', '<无动机越过180度轴线>', '<攻击返回施法者自身阵地>'],
required_spatial_anchor_refs: [locationRef],
acceptance_criteria: ['<在同一画面或连续镜头中可推导起点、目标和方向>', '<所有机位保持批准的左右关系>'],
source_refs: sourceRefs
};
});
const sourceRefs = script.scenes.flatMap((scene) => scene.source_refs).filter((ref, index, refs) =>
refs.findIndex((candidate) => candidate.source_type === ref.source_type && candidate.source_id === ref.source_id) === index
);
const exactSchema: SceneGeographySpecV1 = {
schema_version: PRODUCTION_CONTRACT_VERSIONS.sceneGeography,
asset_plan_version_id: context.asset_plan_contract_id,
scene_script_version_id: context.scene_script_contract_id,
episode_number: script.episode_number,
format: {
orientation: '16:9',
world_unit: 'meter',
coordinate_handedness: 'right_handed'
},
scene_geographies: sceneGeographies,
global_continuity_locks: ['<跨场景不允许漂移的阵营、人物、地标、光线和尺度关系>'],
global_forbidden_outcomes: ['<阵营互换>', '<攻击方向反转>', '<VFX起点与召唤者脱离>', '<无桥接越轴>'],
quality_checks: [
{ check: '<每个场景均建立米制右手坐标系、至少两个空间区和一条主关系轴>', passed: true },
{ check: '<每个可见攻击、追逐或递交动作均有明确起点、目标、世界方向与屏幕方向>', passed: true },
{ check: '<每个允许机位都能保持人物左右、视线和阵营归属>', passed: true }
],
source_refs: sourceRefs
};
const template = [
'你是 S+ 短剧 Production Designer 与 Blocking Director。根据已确认场景剧本和资产计划,建立可被分镜、关键帧、视频生成和视觉质检共同读取的场景地理与人物调度合同。此阶段不拆镜、不写视频Prompt、不生成图片。',
...JSON_ONLY,
...promptRuleLinesV1({ stage: 'geography', genre_tags: project.genre ? [project.genre] : [] }),
`schema_version 必须为 ${PRODUCTION_CONTRACT_VERSIONS.sceneGeography}asset_plan_version_id 和 scene_script_version_id 必须原样使用给定合同ID。`,
'固定16:9、米制右手世界坐标。每场必须先定义世界原点和X/Y/Z方向,再定义空间区、主体站位、关系轴、允许机位、动作向量与调度节拍。',
'人物、阵营、召唤物、道具和目标都必须使用稳定subject_ref;同一主体不能在同场无理由改变zone、screen_side、facing_vector或vertical_relation。',
'主关系轴必须由真实站位主体构成,并明确画面左、画面右、安全拍摄半平面、禁用半平面和越轴政策。默认禁止越轴;确需越轴只能通过中立正轴镜头、明确移动穿轴或可读桥接完成。',
'所有攻击、追逐、递交、抛掷和视效冲击必须写入action_vectors:起点主体、目标主体、起点区、目标区、世界方向、屏幕方向、轨迹,以及是否必须同框保留起点和目标。禁止“朝前”“拍下去”等不可验证描述。',
'大型召唤物必须绑定召唤阵营上方或后方的明确zone与vertical_relation;其攻击向量必须从召唤方空间出发并落向敌方目标区,禁止看起来攻击自己。',
'camera_positions只定义可用观察位置和左右关系,不写推拉摇移。标记axis_side=forbidden的机位必须allowed=false。',
'required_spatial_anchor_refs只能引用资产计划中的真实需求ID;每场至少需要主建立机位,复杂攻击还需要能同时证明起点和目标的方向锚点。',
'scene_geographies必须逐一覆盖场景剧本全部scene_id,不得新增、遗漏或合并场景;location_requirement_ref必须对应资产计划scene_bindings。',
'所有source_refs必须继承已确认场景剧本,不得编造剧情事实。占位内容必须替换为本集真实空间关系。',
'【精确 JSON 结构】',
JSON.stringify(exactSchema)
].join('\n');
assertPromptIsCleanV1(template);
return [
template,
'',
'【已确认场景剧本】',
JSON.stringify({ contract_id: context.scene_script_contract_id, payload: script }),
'【已确认资产需求计划】',
JSON.stringify({ contract_id: context.asset_plan_contract_id, payload: assetPlan })
].join('\n');
}
buildSplusSceneScriptReviewerPrompt(project: Project, context: SplusSceneScriptReviewerPromptContext) {
const dimensions = {
source_fidelity: 0,
dramatic_structure: 0,
opening_hook: 0,
pacing: 0,
character_consistency: 0,
dialogue_performance: 0,
visual_executability: 0,
duration_feasibility: 0,
continuity: 0,
production_readiness: 0
};
const sceneIds = context.scene_script.scenes.map((scene) => scene.id);
const exactSchema = {
schema_version: 'splus_scene_script_review_v1',
contract_id: context.contract_id,
reviewer_version: 'scene_script_splus_ai_reviewer_v1',
verdict: 'revise',
overall_score: 0,
dimension_scores: dimensions,
strengths: ['<有证据的优点>'],
issues: [{
code: 'dialogue_timing_example',
severity: 'major',
field_path: 'scenes[0].dialogue_beats[0]',
scene_id: sceneIds[0] ?? null,
message: '<具体问题>',
evidence: '<引用剧本中的动作、台词或时长作为证据>',
repair_instruction: '<只写修复目标和边界,不代写整场>'
}],
scene_reviews: sceneIds.map((sceneId) => ({
scene_id: sceneId,
score: 0,
strengths: ['<本场优点;没有则空数组>'],
issues: ['<本场问题;没有则空数组>']
})),
rewrite_priorities: [{
priority: 1,
field_path: 'scenes[0]',
instruction: '<最高优先级修订指令>'
}],
reviewer_summary: '<面向导演和编剧的简明结论>'
};
const dialogueTimingFacts = context.scene_script.scenes.flatMap((scene) =>
scene.dialogue_beats.map((beat, dialogueIndex) => ({
scene_id: scene.id,
field_path: `scenes[${context.scene_script.scenes.indexOf(scene)}].dialogue_beats[${dialogueIndex}]`,
spoken_text: beat.line,
assigned_duration_ms: beat.estimated_duration_ms,
deterministic_minimum_ms: estimateChineseSpeechDurationMsV1(beat.line, 4.2)
}))
);
const template = [
'你是独立的 S+ 真人短剧剧本终审,不参与创作,不替原模型辩护,也不直接重写剧本。',
'只输出一个合法 JSON 对象,不要 Markdown,不要解释文字,不得省略字段。',
'审核对象是场景级剧本,不是分镜提示词。必须逐场阅读动作、对白、反应、时长、入口状态、出口状态和转折。',
'评分必须严格、有区分度,不得因为结构合法就给高分。90分代表可拍但仍需明显优化,95分代表精品候选,98分以上才是S+放行候选。',
'必须分别检查:原著忠实度、戏剧结构、0-8秒开场钩子、节奏、人物一致性、对白可演性、画面可执行性、时长可行性、连续性、生产就绪度。',
'对白审核必须检查说话者归属、语气、潜台词、听者反应、完整说完所需时长,以及是否出现解释剧情式台词。',
'对白最低可懂时长以“确定性对白时长事实”为唯一计算口径:该值已按每秒4.2个口播单位并计入中文标点停顿。不得自行重算字数,不得建议低于 deterministic_minimum_ms 的时长。若 assigned_duration_ms 不低于该下限,只能基于明确的戏剧节奏证据评价留白,不能再报“说得过慢”或“4.2字/秒不符”。',
'画面审核必须检查动作是否可拍、空间是否明确、转折是否可见、是否依赖无法实现的抽象心理描写。',
'场景剧本只需明确可见动作、反应对象、空间关系和戏剧结果;具体景别、镜头时长、镜头切换、运镜与构图属于后续分镜执行阶段。不得因场景动作未指定近景、特写或其他镜头参数而报错或扣分。',
'禁止新增原著事实,禁止建议越过已批准改编决定,禁止把后续分镜、特效或后期问题错误归罪于本阶段。',
'人工补充要求中明确标记为导演已确认、已批准或不可回退的纯视觉母版覆盖,应视为当前改编执行事实。只要角色身份、剧情功能、能力边界和事件因果未改变,不得再用原著外观措辞否决该母版或扣减原著忠实度。',
'每个问题必须给出精确 field_path、scene_id、剧本证据和有边界的修复指令。severity 只能是 minor、major、fatal。',
'overall_score 必须是0到100整数;十个 dimension_scores 必须全部填写0到100整数。verdict 只能是 pass、revise、reject。',
`contract_id 必须原样输出 ${context.contract_id}reviewer_version 必须为 scene_script_splus_ai_reviewer_v1。`,
'scene_reviews 必须逐一覆盖所有真实场景ID,不得虚构或遗漏;没有问题时 issues 输出空数组。',
'不要输出 splus_gate,服务端会根据分数与问题严重性独立计算放行结果。',
'【精确 JSON 结构】',
JSON.stringify(exactSchema)
].join('\n');
assertPromptIsCleanV1(template);
return [
template,
'',
'【项目目标】',
JSON.stringify({
title: project.title,
genre: project.genre,
orientation: '16:9',
target_episode_seconds: project.episode_duration,
quality_level: project.quality_level
}),
'【确定性结构硬门结果】',
JSON.stringify(context.deterministic_quality),
'【确定性对白时长事实】',
JSON.stringify(dialogueTimingFacts),
'【已确认改编圣经】',
JSON.stringify({ contract_id: context.adaptation_contract_id, payload: context.adaptation_bible }),
'【已放行分集计划】',
JSON.stringify({ contract_id: context.episode_plan_contract_id, payload: context.episode_plan }),
'【待审场景剧本】',
JSON.stringify({ contract_id: context.contract_id, payload: context.scene_script }),
'【原著分析与本集原文】',
JSON.stringify({
contract_id: context.source_analysis_contract_id ?? null,
source_analysis: context.source_analysis ?? null,
source_excerpt: context.source_excerpt ?? null
}),
'【人工补充审稿要求】',
JSON.stringify(context.reviewer_notes)
].join('\n');
}
buildReviewerPrompt(contractId: string, contractType: string, payload: unknown, hardGateIssues: unknown[]) {
const template = [
'你是版本化 Production Contract Reviewer。只审核,不重写合同正文。',
'只输出结构化问题与修复指令;每条修复必须定位 field_path,并说明应如何修复。',
'不得增加新剧情、不得替作者决定未批准的改编、不得删除来源引用。',
`reviewer_version=production_contract_reviewer_v1contract_type=${contractType}`
].join('\n');
assertPromptIsCleanV1(template);
return [
template,
`expected_contract_id=${contractId}`,
'【合同】',
JSON.stringify(payload),
'【确定性硬门结果】',
JSON.stringify(hardGateIssues)
].join('\n');
}
}
@@ -0,0 +1,57 @@
export interface PromptContaminationMatchV1 {
type: 'forbidden_term' | 'database_id' | 'temporary_url' | 'embedded_asset_reference';
value: string;
index: number;
}
const DATABASE_ID_PATTERN = /\b(?:project|episode|shot|asset|character)[-_ ]?id\s*[:=]\s*["']?\d+/gi;
const TEMPORARY_URL_PATTERN = /https?:\/\/[^\s"']+(?:signature|token|expires|x-amz-|temp)[^\s"']*/gi;
const EMBEDDED_ASSET_REFERENCE_PATTERN = /(?:素材|资产|锚点|参考图)\s*#?\d{2,}/g;
function collectRegexMatches(
text: string,
type: PromptContaminationMatchV1['type'],
pattern: RegExp
) {
const matches: PromptContaminationMatchV1[] = [];
const copy = new RegExp(pattern.source, pattern.flags);
let match: RegExpExecArray | null;
while ((match = copy.exec(text)) !== null) {
matches.push({ type, value: match[0], index: match.index });
if (match[0].length === 0) copy.lastIndex += 1;
}
return matches;
}
export function scanPromptContaminationV1(text: string, forbiddenTerms: string[] = []) {
const matches: PromptContaminationMatchV1[] = [];
for (const term of [...new Set(forbiddenTerms.map((value) => value.trim()).filter(Boolean))]) {
let offset = 0;
while (offset < text.length) {
const index = text.indexOf(term, offset);
if (index < 0) break;
matches.push({ type: 'forbidden_term', value: term, index });
offset = index + term.length;
}
}
matches.push(...collectRegexMatches(text, 'database_id', DATABASE_ID_PATTERN));
matches.push(...collectRegexMatches(text, 'temporary_url', TEMPORARY_URL_PATTERN));
matches.push(...collectRegexMatches(text, 'embedded_asset_reference', EMBEDDED_ASSET_REFERENCE_PATTERN));
return matches.sort((left, right) => left.index - right.index || left.value.localeCompare(right.value));
}
export function assertPromptIsCleanV1(text: string, forbiddenTerms: string[] = []) {
const matches = scanPromptContaminationV1(text, forbiddenTerms);
if (matches.length === 0) return;
const summary = matches
.slice(0, 8)
.map((match) => `${match.type}:${match.value}`)
.join(', ');
throw new Error(`Prompt contamination detected: ${summary}`);
}
@@ -0,0 +1,396 @@
import {
PRODUCTION_CONTRACT_VERSIONS,
type PromptRuleCandidateV1,
type PromptRuleStageV1
} from '../contracts/production-contracts';
export interface PromptRuleActivationContextV1 {
stage: PromptRuleStageV1;
genre_tags?: string[];
provider_code?: string;
include_testing?: boolean;
}
const baseRule = (
rule: Omit<PromptRuleCandidateV1, 'rule_version' | 'source_prompt_refs' | 'evidence_refs' | 'positive_examples' | 'negative_examples' | 'conflicts_with'> &
Partial<Pick<PromptRuleCandidateV1, 'source_prompt_refs' | 'evidence_refs' | 'positive_examples' | 'negative_examples' | 'conflicts_with'>>
): PromptRuleCandidateV1 => ({
...rule,
source_prompt_refs: rule.source_prompt_refs ?? [],
evidence_refs: rule.evidence_refs ?? [],
positive_examples: rule.positive_examples ?? [],
negative_examples: rule.negative_examples ?? [],
conflicts_with: rule.conflicts_with ?? [],
rule_version: PRODUCTION_CONTRACT_VERSIONS.promptRule
});
export const PROMPT_RULE_REGISTRY_V1: readonly PromptRuleCandidateV1[] = [
baseRule({
id: 'shot.performance_arc',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '开头状态、触发变化、尾帧余韵、微表演。',
normalized_rule: '每个角色表演必须明确起点、触发、可见反应和余韵;情绪变化落在眼神、呼吸、手部、肩颈、站姿和停顿上。',
activation_condition: '镜头中出现可见角色',
expected_improvement: '减少站桩、面瘫和只有对白没有表演过程的问题',
evidence_refs: ['legacy:scripts:performance-chain', 'production-lessons:character-performance'],
status: 'approved'
}),
baseRule({
id: 'shot.dialogue_reaction_timing',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '开口前有准备,说话后留听者反应。',
normalized_rule: '对白必须嵌入表演时间线:开口前预留视线或呼吸准备,说话时口型和意图清楚,句尾预留听者反应或沉默落点。',
activation_condition: '镜头包含对白',
expected_improvement: '避免台词未说完、镜头突然切断和对白块机械拼接',
evidence_refs: ['legacy:scripts:dialogue-performance', 'production-lessons:dialogue-duration'],
status: 'approved'
}),
baseRule({
id: 'shot.motivated_camera',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '起点、运动方式、跟随或揭示、落点。',
normalized_rule: '运镜必须说明起点、速度、跟随或揭示对象、叙事动机和落点;禁止随机甩镜、装饰性推拉和无意义大运动。',
activation_condition: '镜头包含摄影机运动',
expected_improvement: '让运镜服务信息、情绪和剪辑,而不是只增加表面动态',
evidence_refs: ['legacy:prompt-builder:camera-arc'],
status: 'approved'
}),
baseRule({
id: 'shot.first_last_frame_continuity',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '首尾帧保持同空间、同光源、同轴线和同运动方向。',
normalized_rule: '首尾帧必须是同一镜头内动作的两个状态,保持空间、光源、镜头轴线和运动方向一致;尾帧只能是本镜动作落点。',
activation_condition: 'generation_strategy.mode=first_last_frame',
expected_improvement: '减少首尾帧之间换场、换机位、换角色和硬插值',
evidence_refs: ['legacy:scripts:first-last-frame'],
status: 'approved'
}),
baseRule({
id: 'shot.cross_space_boundary',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '跨物理空间优先拆镜并在可剪切遮挡处落点。',
normalized_rule: '跨越不同物理空间时,默认拆成过渡桥和新空间建立镜头;前镜落在门、玻璃、暗部、光变或声音桥等可剪切点,后镜用场景资产重新建立空间。',
activation_condition: '镜头动作跨越两个独立物理空间',
expected_improvement: '减少模型穿墙后随机生成新空间和前后场景不一致',
evidence_refs: ['legacy:scripts:cross-space-opening', 'production-lessons:scene-anchor'],
status: 'approved'
}),
baseRule({
id: 'shot.scene_anchor_lock',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '连续场次共享环境锚点,只允许机位、景别和人物走位变化。',
normalized_rule: '连续场次必须继承同一场景母版的空间结构、入口出口、固定物件、光源方向、色调和材质;只允许当前镜头明确要求的机位、景别、走位和局部状态变化。',
activation_condition: '镜头与前后镜属于同一连续场次',
expected_improvement: '减少同场景换空间、换装潢、换光源和关键物件漂移',
evidence_refs: ['legacy:scripts:master-environment-anchor', 'production-lessons:scene-continuity'],
status: 'approved'
}),
baseRule({
id: 'shot.character_state_lock',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '角色主锚点锁身份,当前状态锚点锁服装、年龄和伤势。',
normalized_rule: '角色身份、脸型和基础年龄由身份母版锁定;本场服装、妆发、伤势、污渍和剧情阶段由当前状态资产锁定。不得用身份母版覆盖已批准的场次状态。',
activation_condition: '镜头中出现可辨认角色',
expected_improvement: '减少换脸、换装、伤势消失和前后剧情状态混用',
evidence_refs: ['legacy:scripts:character-state-lock', 'production-lessons:wardrobe-continuity'],
status: 'approved'
}),
baseRule({
id: 'shot.asset_visibility_boundary',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '检索标签不等于画面必须出现。',
normalized_rule: '只有镜头视觉、动作、起止状态明确要求的角色、道具和设备才允许入画;检索标签、资产名称、下一镜内容、声音说明和内部元数据不得被模型画进当前镜头。',
activation_condition: '镜头包含资产引用或自动选图标签',
expected_improvement: '减少模型把资产清单、未来剧情和内部说明误当画面内容',
evidence_refs: ['legacy:scripts:asset-boundary', 'production-lessons:reference-pollution'],
status: 'approved'
}),
baseRule({
id: 'shot.axis_and_screen_direction',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '明确 camera_axis 和 screen_direction。',
normalized_rule: '连续动作链必须记录镜头轴线和画面运动方向;下一镜继承方向,只有明确反打时才改变。',
activation_condition: '镜头与前后镜共享人物、动作或空间',
expected_improvement: '减少人物方向跳变、反打混乱和合并时的空间断裂',
evidence_refs: ['legacy:scripts:axis-direction'],
status: 'approved'
}),
baseRule({
id: 'shot.state_handoff',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '上一镜 end_state 是下一镜 start_state 的原因。',
normalized_rule: '同段镜头必须用动作、视线、道具、情绪、声音或光线中的至少一个可观察状态完成接力;新段落必须声明切换依据,不得只写“自然衔接”。',
activation_condition: '镜头不是场景首镜或孤立插入镜头',
expected_improvement: '减少随机拼图感并让首尾状态可供自动合并验证',
evidence_refs: ['legacy:scripts:continuity-chain', 'production-lessons:shot-handoff'],
status: 'approved'
}),
baseRule({
id: 'shot.dialogue_integrity',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '对白按剧本顺序,优先原句,不提前、不漏句。',
normalized_rule: '对白必须保持已批准剧本的说话人、语义和先后顺序;时长不足时返回剧本或调整镜头,不得自动截断、换人、提前借用下一镜台词或依靠字幕补完。',
activation_condition: '镜头包含对白',
expected_improvement: '减少漏词、错人、英语或乱码替代中文、台词跨镜抢跑',
evidence_refs: ['legacy:scripts:dialogue-order', 'production-lessons:spoken-line-completeness'],
status: 'approved'
}),
baseRule({
id: 'shot.sound_is_story_action',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '每镜包含环境声、拟音、对白或声音桥。',
normalized_rule: '声音必须对应当前空间、动作、心理压力或剪辑点;明确环境底噪、动作拟音、对白来源和声音桥,禁止无关旁白、随机对白和每镜独立配乐。',
activation_condition: '所有正式视频镜头',
expected_improvement: '提升现场感、情绪连续性和剪辑动机',
evidence_refs: ['legacy:scripts:sound-review', 'production-lessons:native-audio'],
status: 'approved'
}),
baseRule({
id: 'shot.transition_responsibility',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '视频 Prompt 负责镜内动作,transition 字段负责镜间合并。',
normalized_rule: '视频生成请求只描述当前镜头内部表演、摄影和可切落点;淡入淡出、匹配剪辑、声音桥和下一镜内容由时间线/合并层执行,禁止写进当前镜头让模型自行换场。',
activation_condition: '镜头存在后续镜头或 transition 指令',
expected_improvement: '减少单镜内部突然换场、提前出现下一镜内容和转场重复执行',
evidence_refs: ['legacy:scripts:transition-boundary', 'production-lessons:composer-boundary'],
status: 'approved'
}),
baseRule({
id: 'shot.suspense_sound_pressure',
stage: 'shot',
scope: 'genre',
genre_tags: ['suspense', 'mystery'],
provider_codes: [],
original_text: '悬疑声音按情节制造不安。',
normalized_rule: '悬疑声音必须绑定当前证据、动作或心理压力,可使用低频脉冲、空间底噪、短促静默、呼吸、纸张、脚步、金属或电子声,但不得无动机堆叠。',
activation_condition: '项目题材包含 suspense 或 mystery',
expected_improvement: '用声音推动悬疑情绪,避免普通环境声或廉价惊吓音效',
evidence_refs: ['legacy:scripts:suspense-audio'],
status: 'approved'
}),
baseRule({
id: 'shot.suspense_inner_monologue',
stage: 'shot',
scope: 'genre',
genre_tags: ['suspense', 'mystery'],
provider_codes: [],
original_text: '悬疑内心独白只用于关键判断,不解释剧情。',
normalized_rule: '悬疑内心独白只在识破矛盾、软肋受威胁、证据改变或反击决策时使用;保持短促、可听、可读,并与角色可见反应同步,禁止逐镜解释剧情。',
activation_condition: '悬疑或 mystery 项目且当前镜头存在关键心理转折',
expected_improvement: '增强主观悬疑体验,同时避免旁白淹没表演和证据',
evidence_refs: ['legacy:scripts:suspense-inner-voice', 'production-lessons:psychological-voiceover'],
status: 'approved'
}),
baseRule({
id: 'shot.ui_text_post_composite',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '系统 UI、弹窗、进度条和字幕后期合成。',
normalized_rule: '需要精确文字、系统 UI、进度条或字幕时,视频只预留干净构图、人物视线和反应时长;可读文字与框体由结构化后期模板合成。',
activation_condition: '镜头包含精确 UI 或可读文字',
expected_improvement: '避免乱码、错误文字和 UI 过早出现',
evidence_refs: ['legacy:prompt-builder:text-rule', 'production-lessons:ui-overlay'],
status: 'approved'
}),
baseRule({
id: 'shot.ui_reaction_placeholder',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: 'UI 占位是人物视线、停顿和反应时长,不是在画面里生成空框。',
normalized_rule: '精确 UI 由后期合成时,视频镜头必须提供干净可放置区域、明确视线目标、出现前触发动作、可读停顿和出现后反应;不得让视频模型预生成发光框、伪文字或错误进度条。',
activation_condition: '镜头需要后期 UI、系统提示、进度条或二维码',
expected_improvement: '让后期 UI 与表演真正发生关系,并避免框体丑陋和出现时机错误',
evidence_refs: ['legacy:scripts:ui-placeholder', 'production-lessons:system-overlay-timing'],
status: 'approved'
}),
baseRule({
id: 'shot.reference_image_responsibility',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '参考图按角色、场景、道具、首尾帧分配职责。',
normalized_rule: '每张参考图必须有唯一主职责和优先级;不得上传若干图片后让模型自行猜测人物、空间和动作关系。',
activation_condition: 'generation_strategy 包含参考图片',
expected_improvement: '提高角色、服装、空间和道具控制的可解释性',
evidence_refs: ['legacy:scripts:required-assets', 'production-lessons:multi-reference'],
status: 'approved'
}),
baseRule({
id: 'shot.one_primary_action',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '单镜围绕一个主要动作或情绪转折。',
normalized_rule: '单个生成片段只承担一个主要动作链或一个情绪转折;多个独立事件必须拆分或改为明确连续长镜头设计。',
activation_condition: '所有视频生成片段',
expected_improvement: '减少动作漏失、顺序错乱和模型自行改剧情',
evidence_refs: ['legacy:prompt-builder:one-main-action'],
status: 'approved'
}),
baseRule({
id: 'shot.no_mechanical_filler',
stage: 'shot',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '不为满足镜头数量补空镜或反应镜。',
normalized_rule: '镜头数量由戏剧职责决定;禁止仅为达到目标数量自动补反应、证据、空镜或慢推镜头。',
activation_condition: '分镜规划和镜头数量调整',
expected_improvement: '减少平平无奇的填充镜头和对白拼接感',
evidence_refs: ['work-review:v1.1:no-filler'],
status: 'approved'
}),
baseRule({
id: 'episode.hook_truth_and_payoff',
stage: 'episode',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '开场钩子来自当前冲突,并在本集兑现。',
normalized_rule: '开场钩子必须来自已批准来源或改编决策,明确观众问题、风险、可见事件和本集兑现节拍;不得把中后段反转提前冒充开场,也不得只制造悬念不兑现。',
activation_condition: '生成或审核分集计划',
expected_improvement: '减少虚假钩子、剧透式开场和开场与本集主线脱节',
evidence_refs: ['legacy:episodes:hook-payoff', 'work-review:v1.1:opening-hook-contract'],
status: 'approved'
}),
baseRule({
id: 'script.scene_objective_and_turn',
stage: 'script',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '每场戏都要有目标、阻力、策略和转折。',
normalized_rule: '每个场景必须由人物目标驱动,明确阻力、人物采取的策略、可见行动结果与场尾转折;禁止只有气氛、说明或对白交换而没有状态变化。',
activation_condition: '生成或审核场景剧本',
expected_improvement: '减少平铺直叙、站桩对白和没有戏剧结果的填充场次',
evidence_refs: ['work-review:v1.1:scene-contract', 'legacy:scripts:dramatic-turn'],
status: 'approved'
}),
baseRule({
id: 'script.dialogue_integrity_and_reaction',
stage: 'script',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '原台词优先,对白必须说完,并给听者反应。',
normalized_rule: '对白优先保留原文已确认语句及说话人;每句必须按目标语速分配完整发声时长,并在动作或下一句前留下必要的呼吸、停顿和听者反应,不得靠字幕补完被截断的语音。',
activation_condition: '场景包含对白',
expected_improvement: '减少漏句、错人、台词未说完和对白与表演脱节',
evidence_refs: ['production-lessons:dialogue-duration', 'legacy:scripts:spoken-line-completeness'],
status: 'approved'
}),
baseRule({
id: 'script.state_handoff',
stage: 'script',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '场景出口状态必须成为下一场入口状态。',
normalized_rule: '首场入口必须继承分集入口,末场出口必须兑现分集出口;相邻场景通过人物状态、冲突、已知信息与地点状态逐字段接力,不得跳过关键行动或让信息凭空出现。',
activation_condition: '场景剧本包含两个或更多场景',
expected_improvement: '减少场次之间剧情断裂、瞬移和人物认知跳变',
evidence_refs: ['work-review:v1.1:state-contract', 'production-lessons:continuity'],
status: 'approved'
}),
baseRule({
id: 'script.no_mechanical_filler',
stage: 'script',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '场景数量由戏剧职责决定,不机械补段。',
normalized_rule: '场景数量由冲突阶段、地点变化和不可逆转折决定;同一地点的连续行动优先保持完整,不得为了凑数量拆成重复反应、空镜说明或同义对白。',
activation_condition: '生成场景剧本',
expected_improvement: '减少碎片化切场和成片拼接生硬',
evidence_refs: ['work-review:v1.1:no-filler', 'production-lessons:scene-cohesion'],
status: 'approved'
}),
baseRule({
id: 'timeline.subtitle_spoken_content_only',
stage: 'timeline',
scope: 'global',
genre_tags: [],
provider_codes: [],
original_text: '字幕只输出真正说出的话,从语音开始后出现。',
normalized_rule: '对白字幕只能包含实际可听语音,不包含角色名、动作括号、斜杠或脚本标记;入点跟随可听语音或嘴型开始,出点不得早于句尾。',
activation_condition: '时间线包含对白字幕',
expected_improvement: '减少角色名和动作混入字幕、尾部斜杠以及字幕抢跑',
evidence_refs: ['production-lessons:subtitle-cleanup', 'legacy:composer:subtitle-timing'],
status: 'approved'
}),
baseRule({
id: 'deprecated.project_specific_examples',
stage: 'shot',
scope: 'project',
genre_tags: [],
provider_codes: [],
original_text: '在公共模板中写入具体角色、酒店、文件和终局剧情。',
normalized_rule: '项目专属角色、场景、道具和剧情只能来自当前项目圣经与镜头合同。',
activation_condition: 'never',
expected_improvement: '阻止旧项目内容污染新项目',
evidence_refs: ['code-audit:2026-07-15'],
status: 'deprecated'
})
] as const;
export function activePromptRulesV1(context: PromptRuleActivationContextV1) {
const genreTags = new Set((context.genre_tags ?? []).map((tag) => tag.toLowerCase()));
return PROMPT_RULE_REGISTRY_V1.filter((rule) => {
if (rule.stage !== context.stage) return false;
if (rule.status !== 'approved' && !(context.include_testing && rule.status === 'testing')) return false;
if (rule.scope === 'project') return false;
if (rule.scope === 'genre' && !rule.genre_tags.some((tag) => genreTags.has(tag.toLowerCase()))) return false;
if (rule.scope === 'provider' && !rule.provider_codes.includes(context.provider_code ?? '')) return false;
return true;
});
}
export function promptRuleLinesV1(context: PromptRuleActivationContextV1) {
return activePromptRulesV1(context).map((rule) => `[${rule.id}@${rule.rule_version}] ${rule.normalized_rule}`);
}
File diff suppressed because it is too large Load Diff
+64
View File
@@ -14,6 +14,7 @@ export class CreateProjectDto {
quality_level?: string;
is_long_series?: boolean;
creative_pattern_ids?: Array<string | number>;
source_novel_id?: string;
}
export class UpdateProjectDto {
@@ -36,6 +37,69 @@ export class ListCreativePatternsQueryDto {
limit?: string;
}
export class ListProjectAssetsQueryDto {
asset_type?: string;
selection_status?: 'all' | 'candidate' | 'selected' | 'rejected';
q?: string;
shot_no?: string;
shot_start?: string;
shot_end?: string;
page?: string;
limit?: string;
}
export class NovelSearchQueryDto {
q?: string;
page?: string;
limit?: string;
}
export class UpdateNovelReadingProgressDto {
chapter_id?: string;
page_index?: number | string;
page_count?: number | string;
}
export class CreateNovelBookmarkDto {
chapter_id?: string;
page_index?: number | string;
title?: string;
note_text?: string;
}
export class CreateNovelAnnotationDto {
chapter_id?: string;
page_index?: number | string;
selected_text?: string;
note_text?: string;
color?: string;
}
export class UpdateNovelAnnotationDto {
note_text?: string;
color?: string;
}
export class UpdateProjectCreativePatternsDto {
creative_pattern_ids?: Array<string | number>;
}
export class UpdateProjectPipelineConfigDto {
scene_composer_enabled?: boolean | string;
original_music_enabled?: boolean | string;
}
export class CreatePromptStoryboardDto {
title?: string;
prompt_text?: string;
negative_prompt?: string;
genre?: string;
output_mode?: OutputMode;
visual_mode?: string;
style_code?: string;
target_shot_count?: number | string;
shot_duration?: number | string;
auto_confirm?: boolean;
creative_pattern_ids?: Array<string | number>;
global_character_id?: string;
}
+40 -1
View File
@@ -1,4 +1,4 @@
import type { CreativePattern, Project, ProjectCreativePattern } from '@prisma/client';
import type { CreativePattern, Prisma, Project, ProjectCreativePattern, ProjectPipelineConfig } from '@prisma/client';
export const INPUT_MODES = ['ai_original', 'upload', 'admin_import'] as const;
export const OUTPUT_MODES = ['image_manga', 'motion_comic', 'live_action_ai'] as const;
@@ -29,6 +29,7 @@ export const PROJECT_STATUSES = [
'storyboard_generating',
'waiting_storyboard_confirm',
'storyboard_confirmed',
'waiting_external_assets',
'character_image_generated',
'preview_images_generated',
'final_images_generated',
@@ -68,11 +69,29 @@ export interface SafeProject {
payment_status: string;
quality_level: string | null;
is_long_series: boolean;
engine_version: string;
production_lifecycle: string;
created_at: string;
updated_at: string;
completed_at: string | null;
}
export interface SafeProjectPipelineConfig {
id: string;
project_id: string;
ip_isolation_enabled: boolean;
video_engine_enabled: boolean;
scene_composer_enabled: boolean;
default_generation_mode: string;
video_engine_version: string;
scene_composer_version: string;
ip_rules_json: Prisma.JsonValue | null;
video_engine_config_json: Prisma.JsonValue | null;
scene_composer_config_json: Prisma.JsonValue | null;
created_at: string;
updated_at: string;
}
export function toSafeProject(project: Project): SafeProject {
return {
id: project.id.toString(),
@@ -92,12 +111,32 @@ export function toSafeProject(project: Project): SafeProject {
payment_status: project.payment_status,
quality_level: project.quality_level,
is_long_series: project.is_long_series,
engine_version: project.engine_version,
production_lifecycle: project.production_lifecycle,
created_at: project.created_at.toISOString(),
updated_at: project.updated_at.toISOString(),
completed_at: project.completed_at?.toISOString() ?? null
};
}
export function toSafeProjectPipelineConfig(config: ProjectPipelineConfig): SafeProjectPipelineConfig {
return {
id: config.id.toString(),
project_id: config.project_id.toString(),
ip_isolation_enabled: config.ip_isolation_enabled,
video_engine_enabled: config.video_engine_enabled,
scene_composer_enabled: config.scene_composer_enabled,
default_generation_mode: config.default_generation_mode,
video_engine_version: config.video_engine_version,
scene_composer_version: config.scene_composer_version,
ip_rules_json: config.ip_rules_json,
video_engine_config_json: config.video_engine_config_json,
scene_composer_config_json: config.scene_composer_config_json,
created_at: config.created_at.toISOString(),
updated_at: config.updated_at.toISOString()
};
}
export function toSafeCreativePattern(pattern: CreativePattern) {
return {
id: pattern.id.toString(),

Some files were not shown because too many files have changed in this diff Show More