1010 lines
34 KiB
TypeScript
1010 lines
34 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ForbiddenException,
|
||
Inject,
|
||
Injectable,
|
||
NotFoundException
|
||
} from '@nestjs/common';
|
||
import type {
|
||
Character,
|
||
CreativePattern,
|
||
Episode,
|
||
EpisodeScript,
|
||
PlotMemory,
|
||
Prisma,
|
||
Project,
|
||
StoryBible,
|
||
StoryboardShot
|
||
} from '@prisma/client';
|
||
import type { AuthRequestUser } from '../auth/auth.types';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
import { UpdateEpisodeScriptDto, UpdateStoryboardShotDto } from './script.dto';
|
||
import {
|
||
SCRIPT_STATUSES,
|
||
STORYBOARD_STATUSES,
|
||
toSafeEpisodeScript,
|
||
toSafeStoryboardShot,
|
||
type ScriptStatus,
|
||
type StoryboardStatus
|
||
} from './script.types';
|
||
|
||
const MIN_SHOT_DURATION = 2;
|
||
const MAX_SHOT_DURATION = 5;
|
||
const DEFAULT_SHOT_COUNT = 10;
|
||
|
||
interface ScriptContext {
|
||
episode: Episode;
|
||
project: Project;
|
||
storyBible: StoryBible;
|
||
characters: Character[];
|
||
plotMemories: PlotMemory[];
|
||
creativePatterns: CreativePattern[];
|
||
}
|
||
|
||
interface ScriptDraft {
|
||
script_text: string;
|
||
narration_text: string;
|
||
dialogue_json: Prisma.InputJsonValue;
|
||
}
|
||
|
||
interface StoryboardDraft {
|
||
shot_no: number;
|
||
scene_name: string;
|
||
location_desc: string;
|
||
characters_json: Prisma.InputJsonValue;
|
||
visual_desc: string;
|
||
action_desc: string;
|
||
dialogue_text: string | null;
|
||
narration_text: string | null;
|
||
camera_motion: string;
|
||
effect_type: string;
|
||
duration: number;
|
||
prompt_text: string;
|
||
negative_prompt: string;
|
||
status: StoryboardStatus;
|
||
}
|
||
|
||
@Injectable()
|
||
export class ScriptsService {
|
||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||
|
||
async generateScript(user: AuthRequestUser, episodeId: string) {
|
||
const context = await this.loadScriptContext(episodeId, user);
|
||
|
||
if (context.episode.status !== 'confirmed') {
|
||
throw new BadRequestException('Confirmed episode is required before script generation');
|
||
}
|
||
|
||
const nextVersion = await this.nextScriptVersion(context.episode.id);
|
||
const draft = this.buildScriptDraft(context);
|
||
|
||
await this.prisma.project.update({
|
||
where: { id: context.project.id },
|
||
data: { status: 'script_generating' }
|
||
});
|
||
|
||
const script = await this.prisma.$transaction(async (tx) => {
|
||
const created = await tx.episodeScript.create({
|
||
data: {
|
||
project_id: context.project.id,
|
||
episode_id: context.episode.id,
|
||
...draft,
|
||
version: nextVersion,
|
||
status: 'generated'
|
||
}
|
||
});
|
||
await tx.project.update({
|
||
where: { id: context.project.id },
|
||
data: { status: 'waiting_script_confirm' }
|
||
});
|
||
return created;
|
||
});
|
||
|
||
return {
|
||
script: toSafeEpisodeScript(script),
|
||
next_step: 'script_confirm'
|
||
};
|
||
}
|
||
|
||
async getScript(user: AuthRequestUser, episodeId: string) {
|
||
const { episode } = await this.loadEpisodeForUser(episodeId, user);
|
||
const [latest, versions] = await Promise.all([
|
||
this.findLatestScript(episode.id),
|
||
this.prisma.episodeScript.findMany({
|
||
where: { episode_id: episode.id },
|
||
orderBy: { version: 'desc' }
|
||
})
|
||
]);
|
||
|
||
return {
|
||
script: latest ? toSafeEpisodeScript(latest) : null,
|
||
versions: versions.map((script) => ({
|
||
id: script.id.toString(),
|
||
version: script.version,
|
||
status: script.status,
|
||
updated_at: script.updated_at.toISOString()
|
||
}))
|
||
};
|
||
}
|
||
|
||
async updateScript(user: AuthRequestUser, episodeId: string, dto: UpdateEpisodeScriptDto) {
|
||
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
|
||
const script = await this.findLatestScript(episode.id);
|
||
|
||
if (!script) {
|
||
throw new NotFoundException('Episode script not found');
|
||
}
|
||
|
||
if (script.status === 'confirmed') {
|
||
throw new BadRequestException('Confirmed scripts cannot be edited');
|
||
}
|
||
|
||
const data = this.createScriptUpdateData(dto);
|
||
|
||
if (Object.keys(data).length === 0) {
|
||
throw new BadRequestException('No script fields to update');
|
||
}
|
||
|
||
if (data.status !== 'confirmed') {
|
||
data.status = data.status ?? 'edited';
|
||
}
|
||
|
||
const updated = await this.prisma.episodeScript.update({
|
||
where: { id: script.id },
|
||
data
|
||
});
|
||
|
||
await this.prisma.project.update({
|
||
where: { id: project.id },
|
||
data: { status: 'waiting_script_confirm' }
|
||
});
|
||
|
||
return toSafeEpisodeScript(updated);
|
||
}
|
||
|
||
async confirmScript(user: AuthRequestUser, episodeId: string) {
|
||
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
|
||
const script = await this.findLatestScript(episode.id);
|
||
|
||
if (!script) {
|
||
throw new NotFoundException('Episode script not found');
|
||
}
|
||
|
||
this.assertScriptReady(script);
|
||
|
||
const confirmed = await this.prisma.$transaction(async (tx) => {
|
||
await tx.episodeScript.updateMany({
|
||
where: {
|
||
episode_id: episode.id,
|
||
status: 'confirmed',
|
||
id: { not: script.id }
|
||
},
|
||
data: { status: 'superseded' }
|
||
});
|
||
const updated = await tx.episodeScript.update({
|
||
where: { id: script.id },
|
||
data: { status: 'confirmed' }
|
||
});
|
||
await tx.project.update({
|
||
where: { id: project.id },
|
||
data: { status: 'script_confirmed' }
|
||
});
|
||
return updated;
|
||
});
|
||
|
||
return {
|
||
script: toSafeEpisodeScript(confirmed),
|
||
next_step: 'storyboard_generate'
|
||
};
|
||
}
|
||
|
||
async generateStoryboard(user: AuthRequestUser, episodeId: string) {
|
||
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
|
||
const script = await this.findConfirmedScript(episode.id);
|
||
|
||
if (!script) {
|
||
throw new BadRequestException('Confirmed episode script is required before storyboard generation');
|
||
}
|
||
|
||
const confirmedShotCount = await this.prisma.storyboardShot.count({
|
||
where: {
|
||
episode_id: episode.id,
|
||
status: 'confirmed'
|
||
}
|
||
});
|
||
|
||
if (confirmedShotCount > 0) {
|
||
throw new BadRequestException('Confirmed storyboard cannot be regenerated');
|
||
}
|
||
|
||
const characters = await this.prisma.character.findMany({
|
||
where: {
|
||
project_id: project.id,
|
||
status: 'locked'
|
||
},
|
||
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
|
||
});
|
||
const creativePatterns = await this.loadProjectCreativePatterns(project.id);
|
||
|
||
if (characters.length === 0) {
|
||
throw new BadRequestException('Locked characters are required before storyboard generation');
|
||
}
|
||
|
||
const drafts = this.buildStoryboardDrafts(project, episode, script, characters, creativePatterns);
|
||
|
||
await this.prisma.project.update({
|
||
where: { id: project.id },
|
||
data: { status: 'storyboard_generating' }
|
||
});
|
||
|
||
const shots = await this.prisma.$transaction(async (tx) => {
|
||
await tx.storyboardShot.deleteMany({
|
||
where: { episode_id: episode.id }
|
||
});
|
||
await tx.storyboardShot.createMany({
|
||
data: drafts.map((draft) => ({
|
||
project_id: project.id,
|
||
episode_id: episode.id,
|
||
...draft
|
||
}))
|
||
});
|
||
const saved = await tx.storyboardShot.findMany({
|
||
where: { episode_id: episode.id },
|
||
orderBy: { shot_no: 'asc' }
|
||
});
|
||
await tx.project.update({
|
||
where: { id: project.id },
|
||
data: { status: 'waiting_storyboard_confirm' }
|
||
});
|
||
return saved;
|
||
});
|
||
|
||
return {
|
||
shots: shots.map(toSafeStoryboardShot),
|
||
next_step: 'storyboard_confirm'
|
||
};
|
||
}
|
||
|
||
async getStoryboard(user: AuthRequestUser, episodeId: string) {
|
||
const { episode } = await this.loadEpisodeForUser(episodeId, user);
|
||
const shots = await this.prisma.storyboardShot.findMany({
|
||
where: { episode_id: episode.id },
|
||
orderBy: { shot_no: 'asc' }
|
||
});
|
||
|
||
return shots.map(toSafeStoryboardShot);
|
||
}
|
||
|
||
async updateStoryboardShot(
|
||
user: AuthRequestUser,
|
||
shotId: string,
|
||
dto: UpdateStoryboardShotDto
|
||
) {
|
||
const shot = await this.findShotForUser(shotId, user);
|
||
|
||
if (shot.status === 'confirmed') {
|
||
throw new BadRequestException('Confirmed storyboard shots cannot be edited');
|
||
}
|
||
|
||
const data = await this.createShotUpdateData(shot, dto);
|
||
|
||
if (Object.keys(data).length === 0) {
|
||
throw new BadRequestException('No storyboard shot fields to update');
|
||
}
|
||
|
||
if (data.status !== 'confirmed') {
|
||
data.status = data.status ?? 'edited';
|
||
}
|
||
|
||
const updated = await this.prisma.storyboardShot.update({
|
||
where: { id: shot.id },
|
||
data
|
||
});
|
||
|
||
await this.prisma.project.update({
|
||
where: { id: shot.project_id },
|
||
data: { status: 'waiting_storyboard_confirm' }
|
||
});
|
||
|
||
return toSafeStoryboardShot(updated);
|
||
}
|
||
|
||
async deleteStoryboardShot(user: AuthRequestUser, shotId: string) {
|
||
const shot = await this.findShotForUser(shotId, user);
|
||
|
||
if (shot.status === 'confirmed') {
|
||
throw new BadRequestException('Confirmed storyboard shots cannot be deleted');
|
||
}
|
||
|
||
const deleted = await this.prisma.storyboardShot.delete({
|
||
where: { id: shot.id }
|
||
});
|
||
|
||
await this.prisma.project.update({
|
||
where: { id: shot.project_id },
|
||
data: { status: 'waiting_storyboard_confirm' }
|
||
});
|
||
|
||
return toSafeStoryboardShot(deleted);
|
||
}
|
||
|
||
async regenerateShotPrompt(user: AuthRequestUser, shotId: string) {
|
||
const shot = await this.findShotForUser(shotId, user);
|
||
|
||
if (shot.status === 'confirmed') {
|
||
throw new BadRequestException('Confirmed storyboard shots cannot regenerate prompt');
|
||
}
|
||
|
||
const characters = await this.prisma.character.findMany({
|
||
where: {
|
||
project_id: shot.project_id,
|
||
status: 'locked'
|
||
},
|
||
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
|
||
});
|
||
const creativePatterns = await this.loadProjectCreativePatterns(shot.project_id);
|
||
const promptContext = this.createStoryboardPatternPromptContext(creativePatterns);
|
||
const updated = await this.prisma.storyboardShot.update({
|
||
where: { id: shot.id },
|
||
data: {
|
||
prompt_text: this.buildPromptText(shot, characters, promptContext.promptSuffix),
|
||
negative_prompt: this.buildNegativePrompt(characters, promptContext.negativeSuffix),
|
||
status: 'edited'
|
||
}
|
||
});
|
||
|
||
return toSafeStoryboardShot(updated);
|
||
}
|
||
|
||
async confirmStoryboard(user: AuthRequestUser, episodeId: string) {
|
||
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
|
||
const shots = await this.prisma.storyboardShot.findMany({
|
||
where: { episode_id: episode.id },
|
||
orderBy: { shot_no: 'asc' }
|
||
});
|
||
|
||
this.assertStoryboardReady(shots);
|
||
|
||
const confirmed = await this.prisma.$transaction(async (tx) => {
|
||
await tx.storyboardShot.updateMany({
|
||
where: {
|
||
episode_id: episode.id,
|
||
status: { in: ['draft', 'generated', 'edited'] }
|
||
},
|
||
data: { status: 'confirmed' }
|
||
});
|
||
const saved = await tx.storyboardShot.findMany({
|
||
where: { episode_id: episode.id },
|
||
orderBy: { shot_no: 'asc' }
|
||
});
|
||
await tx.project.update({
|
||
where: { id: project.id },
|
||
data: { status: 'storyboard_confirmed' }
|
||
});
|
||
return saved;
|
||
});
|
||
|
||
return {
|
||
shots: confirmed.map(toSafeStoryboardShot),
|
||
next_step: 'image_generation'
|
||
};
|
||
}
|
||
|
||
private async loadScriptContext(episodeId: string, user: AuthRequestUser): Promise<ScriptContext> {
|
||
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
|
||
const [storyBible, characters, plotMemories, creativePatterns] = await Promise.all([
|
||
this.prisma.storyBible.findFirst({
|
||
where: {
|
||
project_id: project.id,
|
||
status: 'confirmed'
|
||
},
|
||
orderBy: { version: 'desc' }
|
||
}),
|
||
this.prisma.character.findMany({
|
||
where: {
|
||
project_id: project.id,
|
||
status: 'locked'
|
||
},
|
||
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
|
||
}),
|
||
this.prisma.plotMemory.findMany({
|
||
where: {
|
||
project_id: project.id,
|
||
status: 'active'
|
||
},
|
||
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }],
|
||
take: 20
|
||
}),
|
||
this.loadProjectCreativePatterns(project.id)
|
||
]);
|
||
|
||
if (!storyBible) {
|
||
throw new BadRequestException('Confirmed story bible is required before script generation');
|
||
}
|
||
if (characters.length === 0) {
|
||
throw new BadRequestException('Locked characters are required before script generation');
|
||
}
|
||
|
||
return {
|
||
episode,
|
||
project,
|
||
storyBible,
|
||
characters,
|
||
plotMemories,
|
||
creativePatterns
|
||
};
|
||
}
|
||
|
||
private async loadEpisodeForUser(episodeId: string, user: AuthRequestUser) {
|
||
const episode = await this.prisma.episode.findUnique({
|
||
where: { id: this.parseId(episodeId, 'Invalid episode id') }
|
||
});
|
||
|
||
if (!episode) {
|
||
throw new NotFoundException('Episode not found');
|
||
}
|
||
|
||
const project = await this.prisma.project.findUnique({
|
||
where: { id: episode.project_id }
|
||
});
|
||
|
||
if (!project) {
|
||
throw new NotFoundException('Project not found');
|
||
}
|
||
|
||
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
|
||
throw new ForbiddenException('Project is private');
|
||
}
|
||
|
||
return { episode, project };
|
||
}
|
||
|
||
private buildScriptDraft(context: ScriptContext): ScriptDraft {
|
||
const protagonist =
|
||
context.characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ??
|
||
context.characters[0];
|
||
const antagonist = context.characters.find((character) => character.role_type === 'antagonist');
|
||
const helper = context.characters.find((character) => character.role_type === 'supporting');
|
||
const foreshadowing = context.plotMemories.find((memory) => memory.memory_type === 'foreshadowing');
|
||
const conflict = context.episode.middle_conflict || context.storyBible.core_conflict || '核心冲突升级。';
|
||
const patternGuide = this.createScriptPatternGuide(context.creativePatterns);
|
||
const dialogue = [
|
||
{
|
||
beat: 'opening',
|
||
speaker: protagonist.name,
|
||
line: '这一回,我不会再让你们拿走我的东西。'
|
||
},
|
||
{
|
||
beat: 'conflict',
|
||
speaker: antagonist?.name ?? '对手',
|
||
line: '你以为一份证据就能改变结果吗?'
|
||
},
|
||
{
|
||
beat: 'turning',
|
||
speaker: helper?.name ?? protagonist.name,
|
||
line: helper ? '备份还在,我已经找到了新的时间戳。' : '真正的证据,还没有公开。'
|
||
},
|
||
{
|
||
beat: 'ending',
|
||
speaker: protagonist.name,
|
||
line: '下一次见面,该轮到我提条件了。'
|
||
}
|
||
];
|
||
const narration = [
|
||
context.episode.opening_hook,
|
||
`${protagonist.name}把所有情绪压回眼底,只留下一个明确目标。`,
|
||
conflict,
|
||
patternGuide.narrationHint,
|
||
foreshadowing?.content,
|
||
context.episode.ending_hook
|
||
].filter(Boolean).join('\n');
|
||
const scriptText = [
|
||
`【第${context.episode.episode_no}集】${context.episode.title ?? '未命名分集'}`,
|
||
`【本集摘要】${context.episode.summary ?? '待补充分集摘要'}`,
|
||
`【开头钩子】${context.episode.opening_hook ?? '主角进入高压场景。'}`,
|
||
`【中段冲突】${conflict}`,
|
||
patternGuide.scriptBlock,
|
||
`【脚本】`,
|
||
`1. ${protagonist.name}进入画面中心,场景压力直接压到观众面前。`,
|
||
`2. ${antagonist?.name ?? '主要对手'}试图用规则和舆论压制局面。`,
|
||
`3. ${helper?.name ?? protagonist.name}抛出关键线索,推动局势反转。`,
|
||
`4. ${protagonist.name}用短句完成反击,保留下一集悬念。`,
|
||
`【结尾悬念】${context.episode.ending_hook ?? context.storyBible.ending_direction ?? '幕后真相继续推进。'}`
|
||
].join('\n');
|
||
|
||
return {
|
||
script_text: scriptText,
|
||
narration_text: narration,
|
||
dialogue_json: dialogue
|
||
};
|
||
}
|
||
|
||
private buildStoryboardDrafts(
|
||
project: Project,
|
||
episode: Episode,
|
||
script: EpisodeScript,
|
||
characters: Character[],
|
||
creativePatterns: CreativePattern[]
|
||
): StoryboardDraft[] {
|
||
const protagonist =
|
||
characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ??
|
||
characters[0];
|
||
const antagonist = characters.find((character) => character.role_type === 'antagonist');
|
||
const helper = characters.find((character) => character.role_type === 'supporting');
|
||
const duration = Math.min(
|
||
MAX_SHOT_DURATION,
|
||
Math.max(MIN_SHOT_DURATION, Math.floor((episode.target_duration ?? 50) / DEFAULT_SHOT_COUNT))
|
||
);
|
||
const patternPromptContext = this.createStoryboardPatternPromptContext(creativePatterns);
|
||
const rhythmHint = this.patternPromptForType(creativePatterns, 'episode_rhythm');
|
||
const openingHint = this.patternPromptForType(creativePatterns, 'opening_hook');
|
||
const shotSeeds = [
|
||
{
|
||
scene: '开局压迫',
|
||
location: '会议室或雨夜室内,竖屏构图',
|
||
chars: [protagonist],
|
||
visual: `${protagonist.name}处于画面中心,眼神冷静,背景压暗。${openingHint ? ` 套路参考:${openingHint}` : ''}`,
|
||
action: '主角抬眼看向镜头,情绪从压抑转为坚定。',
|
||
dialogue: this.pickDialogue(script, protagonist.name) ?? '这一回,我不会再退。',
|
||
narration: episode.opening_hook,
|
||
camera: '近景缓慢推进',
|
||
effect: 'subtle_zoom'
|
||
},
|
||
{
|
||
scene: '证据出现',
|
||
location: '桌面、手机屏幕或投屏前',
|
||
chars: [protagonist],
|
||
visual: '手机录音、合同或关键证据占据画面前景。',
|
||
action: `${protagonist.name}把证据推到众人面前。`,
|
||
dialogue: null,
|
||
narration: '关键证据第一次进入画面。',
|
||
camera: '俯拍定格',
|
||
effect: 'flash_cut'
|
||
},
|
||
{
|
||
scene: '反派压制',
|
||
location: '会议桌对面',
|
||
chars: [antagonist ?? protagonist],
|
||
visual: `${antagonist?.name ?? '主要对手'}面部特写,表情克制但带压迫感。`,
|
||
action: '对手身体前倾,用冷静语气施压。',
|
||
dialogue: this.pickDialogue(script, antagonist?.name ?? '对手') ?? '你以为这样就够了吗?',
|
||
narration: null,
|
||
camera: '面部特写',
|
||
effect: 'speed_line'
|
||
},
|
||
{
|
||
scene: '主角反击',
|
||
location: '会议室中心',
|
||
chars: [protagonist, antagonist ?? protagonist],
|
||
visual: `${protagonist.name}与${antagonist?.name ?? '对手'}分立画面两侧,形成强对抗。`,
|
||
action: `${protagonist.name}说出关键台词,对手表情第一次动摇。`,
|
||
dialogue: this.pickDialogue(script, protagonist.name) ?? '真正的证据,还没有公开。',
|
||
narration: episode.middle_conflict,
|
||
camera: '过肩对峙镜头',
|
||
effect: 'comic_impact'
|
||
},
|
||
{
|
||
scene: '支援入场',
|
||
location: '门口或走廊',
|
||
chars: [helper ?? protagonist],
|
||
visual: `${helper?.name ?? protagonist.name}拿着资料袋或手机快步进入。`,
|
||
action: '支援角色带来新线索,打断现场僵局。',
|
||
dialogue: this.pickDialogue(script, helper?.name ?? protagonist.name) ?? '备份还在。',
|
||
narration: null,
|
||
camera: '中景横移',
|
||
effect: 'quick_pan'
|
||
},
|
||
{
|
||
scene: '线索放大',
|
||
location: '投屏画面前',
|
||
chars: [protagonist],
|
||
visual: '屏幕上的时间戳、照片或合同细节被放大。',
|
||
action: `${protagonist.name}指向关键细节,现场气氛冻结。`,
|
||
dialogue: null,
|
||
narration: '伏笔被推进,但真相还未完全揭开。',
|
||
camera: '特写切入',
|
||
effect: 'freeze_frame'
|
||
},
|
||
{
|
||
scene: '反派震惊',
|
||
location: '对手席位',
|
||
chars: [antagonist ?? protagonist],
|
||
visual: `${antagonist?.name ?? '对手'}眼睛睁大,背景速度线,表情失控一瞬。`,
|
||
action: '对手短暂停顿,暴露破绽。',
|
||
dialogue: null,
|
||
narration: '局势第一次倒向主角。',
|
||
camera: '极近特写',
|
||
effect: 'shock_line'
|
||
},
|
||
{
|
||
scene: '主角掌控',
|
||
location: '画面中心',
|
||
chars: [protagonist],
|
||
visual: `${protagonist.name}站稳,服装和发型保持角色圣经规则。`,
|
||
action: '主角收起证据,语气平静地提出条件。',
|
||
dialogue: this.pickDialogue(script, protagonist.name) ?? '现在,轮到我提条件。',
|
||
narration: null,
|
||
camera: '低角度中近景',
|
||
effect: 'hero_light'
|
||
},
|
||
{
|
||
scene: '悬念前奏',
|
||
location: '走廊阴影或窗边',
|
||
chars: [helper ?? protagonist],
|
||
visual: '新消息弹出,屏幕只露出半句关键内容。',
|
||
action: '角色低头看手机,表情突然凝住。',
|
||
dialogue: null,
|
||
narration: episode.ending_hook,
|
||
camera: '手机屏幕特写',
|
||
effect: 'suspense_blink'
|
||
},
|
||
{
|
||
scene: '结尾钩子',
|
||
location: '阴影中的门口或车内',
|
||
chars: [protagonist],
|
||
visual: '神秘人物只露出半张脸或一只手,画面留白强。',
|
||
action: `关键人物或道具在最后一秒出现。${rhythmHint ? ` 节奏参考:${rhythmHint}` : ''}`,
|
||
dialogue: null,
|
||
narration: episode.ending_hook ?? '幕后真相继续推进。',
|
||
camera: '远景定格',
|
||
effect: 'cliffhanger'
|
||
}
|
||
];
|
||
|
||
return shotSeeds.map((seed, index) => {
|
||
const characterPayload = this.toCharacterPayload(seed.chars.filter(Boolean) as Character[]);
|
||
const baseShot = {
|
||
id: 0n,
|
||
project_id: project.id,
|
||
episode_id: episode.id,
|
||
shot_no: index + 1,
|
||
scene_name: seed.scene,
|
||
location_desc: seed.location,
|
||
characters_json: characterPayload,
|
||
visual_desc: seed.visual,
|
||
action_desc: seed.action,
|
||
dialogue_text: seed.dialogue,
|
||
narration_text: seed.narration ?? null,
|
||
camera_motion: seed.camera,
|
||
effect_type: seed.effect,
|
||
duration: { toString: () => String(duration) },
|
||
prompt_text: null,
|
||
negative_prompt: null,
|
||
status: 'generated',
|
||
created_at: new Date(),
|
||
updated_at: new Date()
|
||
} as unknown as StoryboardShot;
|
||
|
||
return {
|
||
shot_no: index + 1,
|
||
scene_name: seed.scene,
|
||
location_desc: seed.location,
|
||
characters_json: characterPayload,
|
||
visual_desc: seed.visual,
|
||
action_desc: seed.action,
|
||
dialogue_text: seed.dialogue,
|
||
narration_text: seed.narration ?? null,
|
||
camera_motion: seed.camera,
|
||
effect_type: seed.effect,
|
||
duration,
|
||
prompt_text: this.buildPromptText(baseShot, characters, patternPromptContext.promptSuffix),
|
||
negative_prompt: this.buildNegativePrompt(characters, patternPromptContext.negativeSuffix),
|
||
status: 'generated'
|
||
};
|
||
});
|
||
}
|
||
|
||
private createScriptUpdateData(dto: UpdateEpisodeScriptDto): Prisma.EpisodeScriptUncheckedUpdateInput {
|
||
const data: Prisma.EpisodeScriptUncheckedUpdateInput = {};
|
||
|
||
if ('script_text' in dto) data.script_text = this.optionalText(dto.script_text);
|
||
if ('narration_text' in dto) data.narration_text = this.optionalText(dto.narration_text);
|
||
if ('dialogue_json' in dto) data.dialogue_json = this.normalizeJson(dto.dialogue_json);
|
||
if ('status' in dto) data.status = this.validateScriptStatus(dto.status);
|
||
|
||
return data;
|
||
}
|
||
|
||
private async createShotUpdateData(
|
||
shot: StoryboardShot,
|
||
dto: UpdateStoryboardShotDto
|
||
): Promise<Prisma.StoryboardShotUncheckedUpdateInput> {
|
||
const data: Prisma.StoryboardShotUncheckedUpdateInput = {};
|
||
|
||
if ('shot_no' in dto) data.shot_no = await this.validateShotNoForUpdate(shot, dto.shot_no);
|
||
if ('scene_name' in dto) data.scene_name = this.optionalText(dto.scene_name);
|
||
if ('location_desc' in dto) data.location_desc = this.optionalText(dto.location_desc);
|
||
if ('characters_json' in dto) data.characters_json = this.normalizeJson(dto.characters_json);
|
||
if ('visual_desc' in dto) data.visual_desc = this.optionalText(dto.visual_desc);
|
||
if ('action_desc' in dto) data.action_desc = this.optionalText(dto.action_desc);
|
||
if ('dialogue_text' in dto) data.dialogue_text = this.optionalText(dto.dialogue_text);
|
||
if ('narration_text' in dto) data.narration_text = this.optionalText(dto.narration_text);
|
||
if ('camera_motion' in dto) data.camera_motion = this.optionalText(dto.camera_motion);
|
||
if ('effect_type' in dto) data.effect_type = this.optionalText(dto.effect_type);
|
||
if ('duration' in dto) data.duration = this.validateShotDuration(dto.duration);
|
||
if ('prompt_text' in dto) data.prompt_text = this.optionalText(dto.prompt_text);
|
||
if ('negative_prompt' in dto) data.negative_prompt = this.optionalText(dto.negative_prompt);
|
||
if ('status' in dto) data.status = this.validateStoryboardStatus(dto.status);
|
||
|
||
return data;
|
||
}
|
||
|
||
private assertScriptReady(script: EpisodeScript) {
|
||
if (!script.script_text || !script.narration_text || !script.dialogue_json) {
|
||
throw new BadRequestException('Script text, narration, and dialogue are required before confirmation');
|
||
}
|
||
}
|
||
|
||
private assertStoryboardReady(shots: StoryboardShot[]) {
|
||
if (shots.length === 0) {
|
||
throw new BadRequestException('Storyboard shots are required before confirmation');
|
||
}
|
||
|
||
for (const shot of shots) {
|
||
if (
|
||
!shot.visual_desc ||
|
||
!shot.action_desc ||
|
||
!shot.duration ||
|
||
!shot.prompt_text ||
|
||
!shot.negative_prompt
|
||
) {
|
||
throw new BadRequestException('Each storyboard shot must include visual, action, duration, prompt, and negative prompt');
|
||
}
|
||
|
||
const duration = Number(shot.duration.toString());
|
||
if (duration < MIN_SHOT_DURATION || duration > MAX_SHOT_DURATION) {
|
||
throw new BadRequestException('Each storyboard shot duration must be between 2 and 5 seconds');
|
||
}
|
||
}
|
||
}
|
||
|
||
private buildPromptText(shot: StoryboardShot, characters: Character[], patternPromptSuffix = '') {
|
||
const shotCharacters = this.readShotCharacterNames(shot);
|
||
const fixedCharacterText = characters
|
||
.filter((character) => shotCharacters.length === 0 || shotCharacters.includes(character.name))
|
||
.slice(0, 3)
|
||
.map((character) =>
|
||
`${character.name}:${character.global_character_id ? `全局角色#${character.global_character_id.toString()},` : ''}${character.age_group ?? '固定年龄段'},${character.face_desc ?? character.appearance_desc ?? '固定脸型'},${character.hair_desc ?? '固定发型'},${character.costume_rules ?? '固定服装范围'}${character.wardrobe_variant ? `,本项目服装=${character.wardrobe_variant}` : ''}${character.performance_style ? `,表演=${character.performance_style}` : ''}`
|
||
)
|
||
.join(';');
|
||
|
||
return [
|
||
'高质量韩漫风,竖屏9:16,电影光影,人物五官精致,背景清晰',
|
||
shot.location_desc,
|
||
shot.visual_desc,
|
||
shot.action_desc,
|
||
shot.camera_motion ? `镜头:${shot.camera_motion}` : null,
|
||
shot.effect_type ? `效果:${shot.effect_type}` : null,
|
||
fixedCharacterText ? `角色固定设定:${fixedCharacterText}` : null,
|
||
patternPromptSuffix ? `题材套路/视觉Prompt参考:${patternPromptSuffix}` : null,
|
||
'一个画面中心,一个主要动作,一个清晰情绪点'
|
||
].filter(Boolean).join(',');
|
||
}
|
||
|
||
private buildNegativePrompt(characters: Character[], patternNegativeSuffix = '') {
|
||
const names = characters.map((character) => character.name).join('、');
|
||
|
||
return [
|
||
'低清晰度,崩坏手指,五官扭曲,多余肢体,文字水印,画面模糊',
|
||
'多人混脸,年龄漂移,发色无原因变化,服装完全跑偏',
|
||
names ? `禁止把${names}混合成同一个角色` : null,
|
||
patternNegativeSuffix ? `题材套路禁区:${patternNegativeSuffix}` : null,
|
||
'一个镜头超过4个主要人物,同镜头同时打斗,复杂手部互动'
|
||
].filter(Boolean).join(',');
|
||
}
|
||
|
||
private async loadProjectCreativePatterns(projectId: bigint) {
|
||
const bindings = await this.prisma.projectCreativePattern.findMany({
|
||
where: { project_id: projectId },
|
||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }]
|
||
});
|
||
|
||
if (bindings.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const patterns = await this.prisma.creativePattern.findMany({
|
||
where: {
|
||
id: { in: bindings.map((binding) => binding.creative_pattern_id) },
|
||
status: 'active'
|
||
}
|
||
});
|
||
const patternMap = new Map(patterns.map((pattern) => [pattern.id.toString(), pattern]));
|
||
|
||
return bindings
|
||
.map((binding) => patternMap.get(binding.creative_pattern_id.toString()) ?? null)
|
||
.filter((pattern): pattern is CreativePattern => Boolean(pattern));
|
||
}
|
||
|
||
private createScriptPatternGuide(patterns: CreativePattern[]) {
|
||
if (patterns.length === 0) {
|
||
return {
|
||
scriptBlock: '',
|
||
narrationHint: ''
|
||
};
|
||
}
|
||
|
||
const items = patterns
|
||
.slice(0, 5)
|
||
.map((pattern) => `- ${pattern.title}(${pattern.pattern_type}):${pattern.prompt_template ?? pattern.description ?? '复用该模式。'}`)
|
||
.join('\n');
|
||
const narrationHint = patterns
|
||
.map((pattern) => pattern.description ?? pattern.prompt_template)
|
||
.filter((value): value is string => Boolean(value))
|
||
.slice(0, 2)
|
||
.join('\n');
|
||
|
||
return {
|
||
scriptBlock: `【题材套路库】\n${items}`,
|
||
narrationHint
|
||
};
|
||
}
|
||
|
||
private createStoryboardPatternPromptContext(patterns: CreativePattern[]) {
|
||
const promptSuffix = patterns
|
||
.filter((pattern) => ['visual_prompt', 'opening_hook', 'episode_rhythm'].includes(pattern.pattern_type))
|
||
.map((pattern) => pattern.prompt_template ?? pattern.description)
|
||
.filter((value): value is string => Boolean(value))
|
||
.slice(0, 3)
|
||
.join(';');
|
||
const negativeSuffix = patterns
|
||
.map((pattern) => pattern.negative_prompt)
|
||
.filter((value): value is string => Boolean(value))
|
||
.slice(0, 3)
|
||
.join(';');
|
||
|
||
return { promptSuffix, negativeSuffix };
|
||
}
|
||
|
||
private patternPromptForType(patterns: CreativePattern[], patternType: string) {
|
||
const pattern = patterns.find((item) => item.pattern_type === patternType);
|
||
|
||
return pattern?.prompt_template ?? pattern?.description ?? '';
|
||
}
|
||
|
||
private toCharacterPayload(characters: Character[]) {
|
||
return characters.slice(0, 3).map((character) => ({
|
||
id: character.id.toString(),
|
||
name: character.name,
|
||
role_type: character.role_type,
|
||
fixed_desc: character.appearance_desc,
|
||
costume_rules: character.costume_rules
|
||
}));
|
||
}
|
||
|
||
private pickDialogue(script: EpisodeScript, speaker: string) {
|
||
const dialogue = Array.isArray(script.dialogue_json) ? script.dialogue_json : [];
|
||
const item = dialogue.find((entry) =>
|
||
typeof entry === 'object' &&
|
||
entry !== null &&
|
||
'speaker' in entry &&
|
||
String(entry.speaker) === speaker &&
|
||
'line' in entry
|
||
);
|
||
|
||
return item && typeof item === 'object' && 'line' in item ? String(item.line) : null;
|
||
}
|
||
|
||
private readShotCharacterNames(shot: StoryboardShot) {
|
||
const value = shot.characters_json;
|
||
|
||
if (!Array.isArray(value)) {
|
||
return [];
|
||
}
|
||
|
||
return value
|
||
.map((item) =>
|
||
typeof item === 'object' && item !== null && 'name' in item ? String(item.name) : ''
|
||
)
|
||
.filter(Boolean);
|
||
}
|
||
|
||
private async findShotForUser(shotId: string, user: AuthRequestUser) {
|
||
const shot = await this.prisma.storyboardShot.findUnique({
|
||
where: { id: this.parseId(shotId, 'Invalid storyboard shot id') }
|
||
});
|
||
|
||
if (!shot) {
|
||
throw new NotFoundException('Storyboard shot not found');
|
||
}
|
||
|
||
await this.loadEpisodeForUser(shot.episode_id.toString(), user);
|
||
return shot;
|
||
}
|
||
|
||
private async findLatestScript(episodeId: bigint) {
|
||
return this.prisma.episodeScript.findFirst({
|
||
where: { episode_id: episodeId },
|
||
orderBy: { version: 'desc' }
|
||
});
|
||
}
|
||
|
||
private async findConfirmedScript(episodeId: bigint) {
|
||
return this.prisma.episodeScript.findFirst({
|
||
where: {
|
||
episode_id: episodeId,
|
||
status: 'confirmed'
|
||
},
|
||
orderBy: { version: 'desc' }
|
||
});
|
||
}
|
||
|
||
private async nextScriptVersion(episodeId: bigint) {
|
||
const latest = await this.findLatestScript(episodeId);
|
||
return (latest?.version ?? 0) + 1;
|
||
}
|
||
|
||
private async validateShotNoForUpdate(shot: StoryboardShot, value: number | undefined) {
|
||
const shotNo = this.validatePositiveInt(value, 'shot_no', 1, 500);
|
||
|
||
if (shotNo === shot.shot_no) {
|
||
return shotNo;
|
||
}
|
||
|
||
const existing = await this.prisma.storyboardShot.findFirst({
|
||
where: {
|
||
episode_id: shot.episode_id,
|
||
shot_no: shotNo,
|
||
id: { not: shot.id }
|
||
}
|
||
});
|
||
|
||
if (existing) {
|
||
throw new BadRequestException('shot_no already exists in this episode');
|
||
}
|
||
|
||
return shotNo;
|
||
}
|
||
|
||
private validateShotDuration(value: number | undefined) {
|
||
return this.validatePositiveInt(value, 'duration', MIN_SHOT_DURATION, MAX_SHOT_DURATION);
|
||
}
|
||
|
||
private validatePositiveInt(value: unknown, field: string, min: number, max: number) {
|
||
const numberValue = Number(value);
|
||
|
||
if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) {
|
||
throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`);
|
||
}
|
||
|
||
return numberValue;
|
||
}
|
||
|
||
private validateScriptStatus(value: string | undefined): ScriptStatus {
|
||
if (!value || !SCRIPT_STATUSES.includes(value as never)) {
|
||
throw new BadRequestException('script status is invalid');
|
||
}
|
||
|
||
return value as ScriptStatus;
|
||
}
|
||
|
||
private validateStoryboardStatus(value: string | undefined): StoryboardStatus {
|
||
if (!value || !STORYBOARD_STATUSES.includes(value as never)) {
|
||
throw new BadRequestException('storyboard status is invalid');
|
||
}
|
||
|
||
return value as StoryboardStatus;
|
||
}
|
||
|
||
private normalizeJson(value: unknown): Prisma.InputJsonValue {
|
||
if (value === undefined) {
|
||
throw new BadRequestException('json value is required');
|
||
}
|
||
|
||
return value as Prisma.InputJsonValue;
|
||
}
|
||
|
||
private optionalText(value: string | undefined) {
|
||
const normalized = value?.trim();
|
||
return normalized || null;
|
||
}
|
||
|
||
private parseId(id: string, message: string) {
|
||
try {
|
||
return BigInt(id);
|
||
} catch {
|
||
throw new BadRequestException(message);
|
||
}
|
||
}
|
||
}
|