Initial AI manga platform
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import type { ScriptStatus, StoryboardStatus } from './script.types';
|
||||
|
||||
export class UpdateEpisodeScriptDto {
|
||||
script_text?: string;
|
||||
narration_text?: string;
|
||||
dialogue_json?: unknown;
|
||||
status?: ScriptStatus;
|
||||
}
|
||||
|
||||
export class UpdateStoryboardShotDto {
|
||||
shot_no?: number;
|
||||
scene_name?: string;
|
||||
location_desc?: string;
|
||||
characters_json?: unknown;
|
||||
visual_desc?: string;
|
||||
action_desc?: string;
|
||||
dialogue_text?: string;
|
||||
narration_text?: string;
|
||||
camera_motion?: string;
|
||||
effect_type?: string;
|
||||
duration?: number;
|
||||
prompt_text?: string;
|
||||
negative_prompt?: string;
|
||||
status?: StoryboardStatus;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { EpisodeScript, Prisma, StoryboardShot } from '@prisma/client';
|
||||
|
||||
export const SCRIPT_STATUSES = ['draft', 'generated', 'edited', 'confirmed', 'superseded'] as const;
|
||||
export const STORYBOARD_STATUSES = ['draft', 'generated', 'edited', 'confirmed'] as const;
|
||||
|
||||
export type ScriptStatus = (typeof SCRIPT_STATUSES)[number];
|
||||
export type StoryboardStatus = (typeof STORYBOARD_STATUSES)[number];
|
||||
|
||||
export interface SafeEpisodeScript {
|
||||
id: string;
|
||||
project_id: string;
|
||||
episode_id: string;
|
||||
script_text: string | null;
|
||||
narration_text: string | null;
|
||||
dialogue_json: Prisma.JsonValue | null;
|
||||
version: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SafeStoryboardShot {
|
||||
id: string;
|
||||
project_id: string;
|
||||
episode_id: string;
|
||||
shot_no: number;
|
||||
scene_name: string | null;
|
||||
location_desc: string | null;
|
||||
characters_json: Prisma.JsonValue | null;
|
||||
visual_desc: string | null;
|
||||
action_desc: string | null;
|
||||
dialogue_text: string | null;
|
||||
narration_text: string | null;
|
||||
camera_motion: string | null;
|
||||
effect_type: string | null;
|
||||
duration: number | null;
|
||||
prompt_text: string | null;
|
||||
negative_prompt: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function toSafeEpisodeScript(script: EpisodeScript): SafeEpisodeScript {
|
||||
return {
|
||||
id: script.id.toString(),
|
||||
project_id: script.project_id.toString(),
|
||||
episode_id: script.episode_id.toString(),
|
||||
script_text: script.script_text,
|
||||
narration_text: script.narration_text,
|
||||
dialogue_json: script.dialogue_json,
|
||||
version: script.version,
|
||||
status: script.status,
|
||||
created_at: script.created_at.toISOString(),
|
||||
updated_at: script.updated_at.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function toSafeStoryboardShot(shot: StoryboardShot): SafeStoryboardShot {
|
||||
return {
|
||||
id: shot.id.toString(),
|
||||
project_id: shot.project_id.toString(),
|
||||
episode_id: shot.episode_id.toString(),
|
||||
shot_no: shot.shot_no,
|
||||
scene_name: shot.scene_name,
|
||||
location_desc: shot.location_desc,
|
||||
characters_json: shot.characters_json,
|
||||
visual_desc: shot.visual_desc,
|
||||
action_desc: shot.action_desc,
|
||||
dialogue_text: shot.dialogue_text,
|
||||
narration_text: shot.narration_text,
|
||||
camera_motion: shot.camera_motion,
|
||||
effect_type: shot.effect_type,
|
||||
duration: shot.duration ? Number(shot.duration.toString()) : null,
|
||||
prompt_text: shot.prompt_text,
|
||||
negative_prompt: shot.negative_prompt,
|
||||
status: shot.status,
|
||||
created_at: shot.created_at.toISOString(),
|
||||
updated_at: shot.updated_at.toISOString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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 { UpdateEpisodeScriptDto, UpdateStoryboardShotDto } from './script.dto';
|
||||
import { ScriptsService } from './scripts.service';
|
||||
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ScriptsController {
|
||||
constructor(@Inject(ScriptsService) private readonly scriptsService: ScriptsService) {}
|
||||
|
||||
@Post('episodes/:episodeId/script/generate')
|
||||
generateScript(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('episodeId') episodeId: string
|
||||
) {
|
||||
return this.scriptsService.generateScript(user, episodeId);
|
||||
}
|
||||
|
||||
@Get('episodes/:episodeId/script')
|
||||
getScript(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('episodeId') episodeId: string
|
||||
) {
|
||||
return this.scriptsService.getScript(user, episodeId);
|
||||
}
|
||||
|
||||
@Patch('episodes/:episodeId/script')
|
||||
updateScript(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('episodeId') episodeId: string,
|
||||
@Body() dto: UpdateEpisodeScriptDto
|
||||
) {
|
||||
return this.scriptsService.updateScript(user, episodeId, dto);
|
||||
}
|
||||
|
||||
@Post('episodes/:episodeId/script/confirm')
|
||||
confirmScript(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('episodeId') episodeId: string
|
||||
) {
|
||||
return this.scriptsService.confirmScript(user, episodeId);
|
||||
}
|
||||
|
||||
@Post('episodes/:episodeId/storyboard/generate')
|
||||
generateStoryboard(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('episodeId') episodeId: string
|
||||
) {
|
||||
return this.scriptsService.generateStoryboard(user, episodeId);
|
||||
}
|
||||
|
||||
@Get('episodes/:episodeId/storyboard')
|
||||
getStoryboard(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('episodeId') episodeId: string
|
||||
) {
|
||||
return this.scriptsService.getStoryboard(user, episodeId);
|
||||
}
|
||||
|
||||
@Patch('storyboard-shots/:shotId')
|
||||
updateStoryboardShot(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('shotId') shotId: string,
|
||||
@Body() dto: UpdateStoryboardShotDto
|
||||
) {
|
||||
return this.scriptsService.updateStoryboardShot(user, shotId, dto);
|
||||
}
|
||||
|
||||
@Delete('storyboard-shots/:shotId')
|
||||
deleteStoryboardShot(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('shotId') shotId: string
|
||||
) {
|
||||
return this.scriptsService.deleteStoryboardShot(user, shotId);
|
||||
}
|
||||
|
||||
@Post('episodes/:episodeId/storyboard/confirm')
|
||||
confirmStoryboard(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('episodeId') episodeId: string
|
||||
) {
|
||||
return this.scriptsService.confirmStoryboard(user, episodeId);
|
||||
}
|
||||
|
||||
@Post('storyboard-shots/:shotId/regenerate-prompt')
|
||||
regenerateShotPrompt(
|
||||
@CurrentUser() user: AuthRequestUser,
|
||||
@Param('shotId') shotId: string
|
||||
) {
|
||||
return this.scriptsService.regenerateShotPrompt(user, shotId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { ScriptsController } from './scripts.controller';
|
||||
import { ScriptsService } from './scripts.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
controllers: [ScriptsController],
|
||||
providers: [ScriptsService],
|
||||
exports: [ScriptsService]
|
||||
})
|
||||
export class ScriptsModule {}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
Character,
|
||||
Episode,
|
||||
EpisodeScript,
|
||||
PlotMemory,
|
||||
Project,
|
||||
StoryBible,
|
||||
StoryboardShot
|
||||
} from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { AuthRequestUser } from '../auth/auth.types';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { ScriptsService } from './scripts.service';
|
||||
|
||||
const user: AuthRequestUser = {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
role: 'user'
|
||||
};
|
||||
|
||||
const now = new Date('2026-05-31T00:00:00.000Z');
|
||||
|
||||
function createProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: 10n,
|
||||
user_id: 1n,
|
||||
title: '重生归来,我只搞事业',
|
||||
input_mode: 'ai_original',
|
||||
genre: 'urban_rebirth',
|
||||
style_code: 'korean_comic',
|
||||
output_type: 'short_video',
|
||||
output_mode: 'image_manga',
|
||||
visual_mode: 'korean_manga',
|
||||
video_generation_level: 'standard',
|
||||
target_episode_count: 3,
|
||||
episode_duration: 60,
|
||||
status: 'episode_confirmed',
|
||||
copyright_status: 'ai_original',
|
||||
payment_status: 'unpaid',
|
||||
quality_level: 'mvp',
|
||||
is_long_series: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
completed_at: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createEpisode(overrides: Partial<Episode> = {}): Episode {
|
||||
return {
|
||||
id: 20n,
|
||||
project_id: 10n,
|
||||
episode_no: 1,
|
||||
source_chapter_ids: ['30'],
|
||||
title: '第1集 暴雨开局',
|
||||
summary: '林晚用录音证据逼近真相。',
|
||||
opening_hook: '林晚睁眼时,会议室大屏已经开始播放她的录音。',
|
||||
middle_conflict: '周启试图转移责任。',
|
||||
ending_hook: '幕后投资人的车停在楼下。',
|
||||
target_duration: 60,
|
||||
status: 'confirmed',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryBible(overrides: Partial<StoryBible> = {}): StoryBible {
|
||||
return {
|
||||
id: 40n,
|
||||
project_id: 10n,
|
||||
title: '重生归来,我只搞事业',
|
||||
logline: '林晚重回命运转折点,用证据夺回项目。',
|
||||
main_plot: '林晚夺回原创项目控制权,周启持续制造阻碍。',
|
||||
core_conflict: '林晚必须在资本压力中守住原创项目。',
|
||||
selling_points: '重生归来\n证据反杀',
|
||||
tone: '克制、锋利、连续反转',
|
||||
world_summary: '现代都市内容公司',
|
||||
ending_direction: '幕后真相继续推进。',
|
||||
taboo_rules: '不得改变主角姓名。',
|
||||
version: 1,
|
||||
status: 'confirmed',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createCharacter(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 50n,
|
||||
project_id: 10n,
|
||||
global_character_id: null,
|
||||
name: '林晚',
|
||||
alias_names: [],
|
||||
role_type: 'protagonist',
|
||||
gender_label: '女',
|
||||
age_group: '青年',
|
||||
identity_desc: '故事主角',
|
||||
appearance_desc: '眼神坚定',
|
||||
face_desc: '精致鹅蛋脸',
|
||||
hair_desc: '深色中长发',
|
||||
eye_desc: '深色眼睛',
|
||||
body_desc: '身形修长',
|
||||
costume_rules: '现代都市通勤装',
|
||||
special_props: '手机、录音证据',
|
||||
personality_desc: '冷静克制',
|
||||
speech_style: '短句明确',
|
||||
relationship_desc: '与周启围绕项目控制权对抗',
|
||||
character_arc: '从被动到主动',
|
||||
negative_rules: '不得改名',
|
||||
anchor_asset_id: null,
|
||||
wardrobe_variant: null,
|
||||
voice_provider_code: null,
|
||||
voice_model: null,
|
||||
voice_id: null,
|
||||
voice_style: null,
|
||||
performance_style: null,
|
||||
importance_level: 100,
|
||||
status: 'locked',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createPlotMemory(overrides: Partial<PlotMemory> = {}): PlotMemory {
|
||||
return {
|
||||
id: 60n,
|
||||
project_id: 10n,
|
||||
episode_id: null,
|
||||
chapter_id: 30n,
|
||||
memory_type: 'foreshadowing',
|
||||
content: '录音证据会在后续揭开幕后真相。',
|
||||
importance_level: 90,
|
||||
status: 'active',
|
||||
created_at: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createScript(overrides: Partial<EpisodeScript> = {}): EpisodeScript {
|
||||
return {
|
||||
id: 70n,
|
||||
project_id: 10n,
|
||||
episode_id: 20n,
|
||||
script_text: '【第1集】林晚在会议室反击。',
|
||||
narration_text: '林晚压住情绪,拿出录音证据。',
|
||||
dialogue_json: [
|
||||
{ speaker: '林晚', line: '这一回,我不会再退。' },
|
||||
{ speaker: '周启', line: '你以为这样就够了吗?' }
|
||||
],
|
||||
version: 1,
|
||||
status: 'generated',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createShot(overrides: Partial<StoryboardShot> = {}): StoryboardShot {
|
||||
return {
|
||||
id: 80n,
|
||||
project_id: 10n,
|
||||
episode_id: 20n,
|
||||
shot_no: 1,
|
||||
scene_name: '开局压迫',
|
||||
location_desc: '会议室,竖屏构图',
|
||||
characters_json: [{ id: '50', name: '林晚' }],
|
||||
visual_desc: '林晚处于画面中心,眼神冷静。',
|
||||
action_desc: '林晚抬眼看向镜头。',
|
||||
dialogue_text: '这一回,我不会再退。',
|
||||
narration_text: '会议室大屏开始播放录音。',
|
||||
camera_motion: '近景缓慢推进',
|
||||
effect_type: 'subtle_zoom',
|
||||
duration: new Prisma.Decimal(4),
|
||||
scene_type: null,
|
||||
importance_score: null,
|
||||
emotion_score: null,
|
||||
action_score: null,
|
||||
route_tier: null,
|
||||
prompt_text: '高质量韩漫风,林晚会议室反击。',
|
||||
negative_prompt: '低清晰度,多人混脸。',
|
||||
live_action_desc: null,
|
||||
actor_action: null,
|
||||
camera_instruction: null,
|
||||
performance_instruction: null,
|
||||
video_prompt: null,
|
||||
keyframe_asset_id: null,
|
||||
video_clip_asset_id: null,
|
||||
video_status: null,
|
||||
status: 'generated',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createCreativePattern(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 100n,
|
||||
source_case_id: null,
|
||||
pattern_type: 'opening_hook',
|
||||
title: '退婚开场钩子',
|
||||
genre: 'urban_rebirth',
|
||||
language: 'zh-CN',
|
||||
description: '前 8 秒建立关系破裂和证据反击。',
|
||||
structure_json: {},
|
||||
prompt_template: '写一个退婚现场开场钩子。',
|
||||
negative_prompt: '拖慢铺垫',
|
||||
tags_json: ['退婚', '打脸'],
|
||||
usage_count: 0,
|
||||
effectiveness_score: null,
|
||||
status: 'active',
|
||||
created_by_user_id: 1n,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createProjectCreativePattern(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 101n,
|
||||
project_id: 10n,
|
||||
creative_pattern_id: 100n,
|
||||
source: 'user_selected',
|
||||
snapshot_json: {},
|
||||
sort_order: 1,
|
||||
created_at: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('ScriptsService', () => {
|
||||
let prisma: any;
|
||||
let tx: any;
|
||||
let service: ScriptsService;
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
episodeScript: {
|
||||
create: vi.fn().mockResolvedValue(createScript()),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
update: vi.fn().mockResolvedValue(createScript({ status: 'confirmed' }))
|
||||
},
|
||||
storyboardShot: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 10 }),
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
createShot(),
|
||||
createShot({ id: 81n, shot_no: 2, scene_name: '证据出现' })
|
||||
]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 2 })
|
||||
},
|
||||
project: {
|
||||
update: vi.fn().mockResolvedValue(createProject())
|
||||
}
|
||||
};
|
||||
prisma = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(createProject()),
|
||||
update: vi.fn().mockResolvedValue(createProject())
|
||||
},
|
||||
episode: {
|
||||
findUnique: vi.fn().mockResolvedValue(createEpisode())
|
||||
},
|
||||
storyBible: {
|
||||
findFirst: vi.fn().mockResolvedValue(createStoryBible())
|
||||
},
|
||||
character: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
createCharacter(),
|
||||
createCharacter({
|
||||
id: 51n,
|
||||
name: '周启',
|
||||
role_type: 'antagonist',
|
||||
importance_level: 80
|
||||
})
|
||||
])
|
||||
},
|
||||
plotMemory: {
|
||||
findMany: vi.fn().mockResolvedValue([createPlotMemory()])
|
||||
},
|
||||
projectCreativePattern: {
|
||||
findMany: vi.fn().mockResolvedValue([])
|
||||
},
|
||||
creativePattern: {
|
||||
findMany: vi.fn().mockResolvedValue([])
|
||||
},
|
||||
episodeScript: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([createScript()]),
|
||||
update: vi.fn().mockResolvedValue(createScript({ status: 'edited' })),
|
||||
updateMany: vi.fn()
|
||||
},
|
||||
storyboardShot: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
findMany: vi.fn().mockResolvedValue([createShot()]),
|
||||
findUnique: vi.fn().mockResolvedValue(createShot()),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update: vi.fn().mockResolvedValue(createShot({ status: 'edited' })),
|
||||
delete: vi.fn().mockResolvedValue(createShot())
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
|
||||
};
|
||||
service = new ScriptsService(prisma as PrismaService);
|
||||
});
|
||||
|
||||
it('generates a script for a confirmed episode', async () => {
|
||||
const result = await service.generateScript(user, '20');
|
||||
|
||||
expect(prisma.project.update).toHaveBeenCalledWith({
|
||||
where: { id: 10n },
|
||||
data: { status: 'script_generating' }
|
||||
});
|
||||
expect(tx.episodeScript.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
project_id: 10n,
|
||||
episode_id: 20n,
|
||||
version: 1,
|
||||
status: 'generated'
|
||||
})
|
||||
});
|
||||
expect(tx.project.update).toHaveBeenCalledWith({
|
||||
where: { id: 10n },
|
||||
data: { status: 'waiting_script_confirm' }
|
||||
});
|
||||
expect(result.next_step).toBe('script_confirm');
|
||||
});
|
||||
|
||||
it('injects selected creative patterns into script and storyboard prompts', async () => {
|
||||
prisma.projectCreativePattern.findMany.mockResolvedValue([
|
||||
createProjectCreativePattern(),
|
||||
createProjectCreativePattern({ id: 103n, creative_pattern_id: 102n, sort_order: 2 })
|
||||
]);
|
||||
prisma.creativePattern.findMany.mockResolvedValue([
|
||||
createCreativePattern(),
|
||||
createCreativePattern({
|
||||
id: 102n,
|
||||
pattern_type: 'visual_prompt',
|
||||
title: '会议室权力构图',
|
||||
prompt_template: '竖版会议室强对峙,证据特写后切人物反应。',
|
||||
negative_prompt: '站桩闲聊'
|
||||
})
|
||||
]);
|
||||
prisma.episodeScript.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(createScript({ status: 'confirmed' }));
|
||||
|
||||
await service.generateScript(user, '20');
|
||||
await service.generateStoryboard(user, '20');
|
||||
|
||||
expect(tx.episodeScript.create.mock.calls[0][0].data.script_text).toContain('【题材套路库】');
|
||||
expect(tx.episodeScript.create.mock.calls[0][0].data.script_text).toContain('退婚开场钩子');
|
||||
expect(tx.storyboardShot.createMany.mock.calls[0][0].data[0].prompt_text).toContain('题材套路/视觉Prompt参考');
|
||||
expect(tx.storyboardShot.createMany.mock.calls[0][0].data[0].negative_prompt).toContain('站桩闲聊');
|
||||
});
|
||||
|
||||
it('requires confirmed episode before script generation', async () => {
|
||||
prisma.episode.findUnique.mockResolvedValue(createEpisode({ status: 'generated' }));
|
||||
|
||||
await expect(service.generateScript(user, '20')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('updates and confirms a script', async () => {
|
||||
prisma.episodeScript.findFirst.mockResolvedValue(createScript());
|
||||
|
||||
const updated = await service.updateScript(user, '20', {
|
||||
narration_text: '新的旁白。'
|
||||
});
|
||||
const confirmed = await service.confirmScript(user, '20');
|
||||
|
||||
expect(prisma.episodeScript.update).toHaveBeenCalledWith({
|
||||
where: { id: 70n },
|
||||
data: expect.objectContaining({
|
||||
narration_text: '新的旁白。',
|
||||
status: 'edited'
|
||||
})
|
||||
});
|
||||
expect(tx.episodeScript.update).toHaveBeenCalledWith({
|
||||
where: { id: 70n },
|
||||
data: { status: 'confirmed' }
|
||||
});
|
||||
expect(tx.project.update).toHaveBeenCalledWith({
|
||||
where: { id: 10n },
|
||||
data: { status: 'script_confirmed' }
|
||||
});
|
||||
expect(updated.status).toBe('edited');
|
||||
expect(confirmed.next_step).toBe('storyboard_generate');
|
||||
});
|
||||
|
||||
it('generates storyboard shots from a confirmed script', async () => {
|
||||
prisma.episodeScript.findFirst.mockResolvedValue(createScript({ status: 'confirmed' }));
|
||||
|
||||
const result = await service.generateStoryboard(user, '20');
|
||||
|
||||
expect(prisma.project.update).toHaveBeenCalledWith({
|
||||
where: { id: 10n },
|
||||
data: { status: 'storyboard_generating' }
|
||||
});
|
||||
expect(tx.storyboardShot.createMany.mock.calls[0][0].data).toHaveLength(10);
|
||||
expect(tx.storyboardShot.createMany.mock.calls[0][0].data[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
project_id: 10n,
|
||||
episode_id: 20n,
|
||||
shot_no: 1,
|
||||
status: 'generated'
|
||||
})
|
||||
);
|
||||
expect(result.next_step).toBe('storyboard_confirm');
|
||||
});
|
||||
|
||||
it('updates, regenerates prompt, and deletes an unconfirmed shot', async () => {
|
||||
const updated = await service.updateStoryboardShot(user, '80', {
|
||||
visual_desc: '林晚站在会议桌前,表情更坚定。',
|
||||
duration: 5
|
||||
});
|
||||
const regenerated = await service.regenerateShotPrompt(user, '80');
|
||||
const deleted = await service.deleteStoryboardShot(user, '80');
|
||||
|
||||
expect(prisma.storyboardShot.update).toHaveBeenCalledWith({
|
||||
where: { id: 80n },
|
||||
data: expect.objectContaining({
|
||||
visual_desc: '林晚站在会议桌前,表情更坚定。',
|
||||
duration: 5,
|
||||
status: 'edited'
|
||||
})
|
||||
});
|
||||
expect(regenerated.prompt_text).toContain('高质量韩漫风');
|
||||
expect(deleted.id).toBe('80');
|
||||
expect(updated.status).toBe('edited');
|
||||
});
|
||||
|
||||
it('confirms storyboard shots', async () => {
|
||||
prisma.storyboardShot.findMany.mockResolvedValue([
|
||||
createShot(),
|
||||
createShot({ id: 81n, shot_no: 2 })
|
||||
]);
|
||||
|
||||
const result = await service.confirmStoryboard(user, '20');
|
||||
|
||||
expect(tx.storyboardShot.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
episode_id: 20n,
|
||||
status: { in: ['draft', 'generated', 'edited'] }
|
||||
},
|
||||
data: { status: 'confirmed' }
|
||||
});
|
||||
expect(tx.project.update).toHaveBeenCalledWith({
|
||||
where: { id: 10n },
|
||||
data: { status: 'storyboard_confirmed' }
|
||||
});
|
||||
expect(result.next_step).toBe('image_generation');
|
||||
});
|
||||
|
||||
it('blocks editing confirmed scripts and shots', async () => {
|
||||
prisma.episodeScript.findFirst.mockResolvedValue(createScript({ status: 'confirmed' }));
|
||||
prisma.storyboardShot.findUnique.mockResolvedValue(createShot({ status: 'confirmed' }));
|
||||
|
||||
await expect(service.updateScript(user, '20', { script_text: '改不了' })).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
await expect(
|
||||
service.updateStoryboardShot(user, '80', { visual_desc: '改不了' })
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects access to another user project', async () => {
|
||||
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
|
||||
|
||||
await expect(service.getScript(user, '20')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user