feat: expand novel IP and production workflows
This commit is contained in:
@@ -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(',')}}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user