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 { 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; 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 { 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) .filter(([, item]) => item !== undefined) .sort(([left], [right]) => left.localeCompare(right)); return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${this.stableStringify(item)}`).join(',')}}`; } }