import { type ProjectDocument, projectDocumentSchema } from "./schema"; import { clipDurationUs, MIN_CLIP_DURATION_US, MIN_TEXT_DURATION_US, projectContentEndUs, textDurationUs, } from "./project"; export type ProjectValidationIssue = { code: | "duplicate_id" | "invalid_boundary" | "invalid_order" | "invalid_reference" | "overlap" | "schema"; message: string; path: string; }; export type ProjectValidationResult = | { success: true; data: ProjectDocument; issues: [] } | { success: false; issues: ProjectValidationIssue[] }; export class ProjectValidationError extends Error { readonly issues: ProjectValidationIssue[]; constructor(issues: ProjectValidationIssue[]) { super(issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")); this.name = "ProjectValidationError"; this.issues = issues; } } function duplicateIssues( values: ReadonlyArray<{ id: string }>, path: string, ): ProjectValidationIssue[] { const seen = new Set(); const issues: ProjectValidationIssue[] = []; for (const [index, value] of values.entries()) { if (seen.has(value.id)) { issues.push({ code: "duplicate_id", path: `${path}.${index}.id`, message: `ID "${value.id}" 重复`, }); } seen.add(value.id); } return issues; } function trackOrderIssues(project: ProjectDocument): ProjectValidationIssue[] { const expectedOrders = new Set(project.tracks.map((_track, index) => index)); const seen = new Set(); const issues: ProjectValidationIssue[] = []; for (const [index, track] of project.tracks.entries()) { if (!expectedOrders.has(track.order) || seen.has(track.order)) { issues.push({ code: "invalid_order", path: `tracks.${index}.order`, message: "轨道 order 必须归一化为 0..N-1", }); } seen.add(track.order); } return issues; } export function validateProjectInvariants( project: ProjectDocument, ): ProjectValidationIssue[] { const issues = [ ...duplicateIssues(project.assets, "assets"), ...duplicateIssues(project.tracks, "tracks"), ...duplicateIssues(project.clips, "clips"), ...duplicateIssues(project.texts, "texts"), ...trackOrderIssues(project), ]; const assets = new Map(project.assets.map((asset) => [asset.id, asset])); const tracks = new Map(project.tracks.map((track) => [track.id, track])); if (project.timeline.durationUs < projectContentEndUs(project)) { issues.push({ code: "invalid_boundary", path: "timeline.durationUs", message: "时间线总时长不能小于最长内容末尾", }); } for (const [index, clip] of project.clips.entries()) { const path = `clips.${index}`; const asset = assets.get(clip.assetId); const track = tracks.get(clip.trackId); if (!asset) { issues.push({ code: "invalid_reference", path: `${path}.assetId`, message: `素材 "${clip.assetId}" 不存在`, }); } if (!track || track.kind === "text") { issues.push({ code: "invalid_reference", path: `${path}.trackId`, message: `轨道 "${clip.trackId}" 不存在或不是媒体轨`, }); } if (track?.kind === "audio" && asset && !asset.hasAudio) { issues.push({ code: "invalid_reference", path: `${path}.assetId`, message: "音频轨片段必须引用包含音频的素材", }); } if (clip.sourceStartUs >= clip.sourceEndUs) { issues.push({ code: "invalid_boundary", path, message: "sourceStartUs 必须小于 sourceEndUs", }); } if ( clip.sourceStartUs < clip.sourceEndUs && clipDurationUs(clip) < MIN_CLIP_DURATION_US ) { issues.push({ code: "invalid_boundary", path, message: `片段时长不能短于 ${MIN_CLIP_DURATION_US} 微秒`, }); } if (asset && clip.sourceEndUs > asset.durationUs) { issues.push({ code: "invalid_boundary", path: `${path}.sourceEndUs`, message: "片段源结束时间超出素材时长", }); } } for (const [index, text] of project.texts.entries()) { const path = `texts.${index}`; const track = tracks.get(text.trackId); if (!track || track.kind !== "text") { issues.push({ code: "invalid_reference", path: `${path}.trackId`, message: `轨道 "${text.trackId}" 不存在或不是文字轨`, }); } if (text.startUs >= text.endUs) { issues.push({ code: "invalid_boundary", path, message: "文字开始时间必须小于结束时间", }); } if ( text.startUs < text.endUs && textDurationUs(text) < MIN_TEXT_DURATION_US ) { issues.push({ code: "invalid_boundary", path, message: `文字时长不能短于 ${MIN_TEXT_DURATION_US} 微秒`, }); } if (!Number.isFinite(text.fontSize) || text.fontSize <= 0) { issues.push({ code: "invalid_boundary", path: `${path}.fontSize`, message: "文字字号必须大于 0", }); } if (!Number.isFinite(text.strokeWidth) || text.strokeWidth < 0) { issues.push({ code: "invalid_boundary", path: `${path}.strokeWidth`, message: "文字描边宽度不能小于 0", }); } if ( !Number.isFinite(text.backgroundOpacity) || text.backgroundOpacity < 0 || text.backgroundOpacity > 1 ) { issues.push({ code: "invalid_boundary", path: `${path}.backgroundOpacity`, message: "文字背景透明度必须在 0 到 1 之间", }); } } const clipsByTrack = new Map(); for (const clip of project.clips) { const track = tracks.get(clip.trackId); if (!track || track.kind === "text") { continue; } const clips = clipsByTrack.get(clip.trackId) ?? []; clips.push(clip); clipsByTrack.set(clip.trackId, clips); } for (const clips of clipsByTrack.values()) { const sorted = [...clips].sort( (left, right) => left.timelineStartUs - right.timelineStartUs, ); for (let index = 1; index < sorted.length; index += 1) { const previous = sorted[index - 1]; const current = sorted[index]; if ( previous && current && previous.timelineStartUs + (previous.sourceEndUs - previous.sourceStartUs) > current.timelineStartUs ) { issues.push({ code: "overlap", path: `clips.${current.id}`, message: `片段 "${previous.id}" 与 "${current.id}" 重叠`, }); } } } const textsByTrack = new Map(); for (const text of project.texts) { const track = tracks.get(text.trackId); if (!track || track.kind !== "text") { continue; } const texts = textsByTrack.get(text.trackId) ?? []; texts.push(text); textsByTrack.set(text.trackId, texts); } for (const texts of textsByTrack.values()) { const sorted = [...texts].sort( (left, right) => left.startUs - right.startUs, ); for (let index = 1; index < sorted.length; index += 1) { const previous = sorted[index - 1]; const current = sorted[index]; if (previous && current && previous.endUs > current.startUs) { issues.push({ code: "overlap", path: `texts.${current.id}`, message: `文字 "${previous.id}" 与 "${current.id}" 重叠`, }); } } } return issues; } export function validateProjectDocument( input: unknown, ): ProjectValidationResult { const parsed = projectDocumentSchema.safeParse(input); if (!parsed.success) { return { success: false, issues: parsed.error.issues.map((issue) => ({ code: "schema", path: issue.path.join("."), message: issue.message, })), }; } const issues = validateProjectInvariants(parsed.data); return issues.length === 0 ? { success: true, data: parsed.data, issues: [] } : { success: false, issues }; } export function assertValidProjectDocument( input: unknown, ): asserts input is ProjectDocument { const result = validateProjectDocument(input); if (!result.success) { throw new ProjectValidationError(result.issues); } }