353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
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(',')}}`;
|
|
}
|
|
}
|