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
@@ -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(',')}}`;
}
}