import { createHash } from 'node:crypto'; import type { ArollSegment, EditorialPlan, EditorialPlanReceipt, EditorialStatus, TalkingHeadProject, TranscriptAnalysis, WordRange } from './contracts.ts'; // Canonical object keys allow JSON clients to round-trip receipts without relying on key order. function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (value !== null && typeof value === 'object') return `{${Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`; return JSON.stringify(value); } export const editorialHash = (value: unknown) => createHash('sha256').update(canonical(value)).digest('hex'); function nonempty(value: unknown, label: string): void { if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be nonempty`); } function unique(values: string[], label: string): void { if (values.some((id) => !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(id)) || new Set(values).size !== values.length) throw new Error(`${label} require unique stable IDs`); } export function validateEditorialPlan(plan: EditorialPlan, aroll: ArollSegment[], analysis: TranscriptAnalysis): EditorialStatus { if (!plan || !['full', 'pauses-only'].includes(plan.scope)) throw new Error('Editorial scope must be full or pauses-only'); if (plan.scope === 'pauses-only') nonempty(plan.scopeReason, 'Explicit user restriction for pauses-only scope'); if (!Array.isArray(plan.reviewedSentenceIndexes) || new Set(plan.reviewedSentenceIndexes).size !== analysis.sentences.length || plan.reviewedSentenceIndexes.length !== analysis.sentences.length || plan.reviewedSentenceIndexes.some((index) => !Number.isInteger(index) || index < 0 || index >= analysis.sentences.length)) { throw new Error('Editorial plan must record review of every ASR sentence, not only candidates'); } if (!plan.sourceReview || !['text-and-visual', 'listened'].includes(plan.sourceReview.method)) throw new Error('Source review method is required'); nonempty(plan.sourceReview.notes, 'Source review notes'); for (const field of ['decisions', 'uniqueInformation', 'segmentReasons', 'unresolvedIssues'] as const) { if (!Array.isArray(plan[field])) throw new Error(`Editorial plan requires ${field}`); } unique(aroll.map((segment) => segment.id), 'A-roll segments'); if (!aroll.length || aroll.some((segment) => !Number.isFinite(segment.sourceStartMs) || !Number.isFinite(segment.sourceEndMs) || segment.sourceStartMs < 0 || segment.sourceEndMs <= segment.sourceStartMs)) throw new Error('Invalid planned A-roll ranges'); for (const segment of aroll) for (const word of analysis.words) { if ((segment.sourceStartMs > word.beginMs && segment.sourceStartMs < word.endMs) || (segment.sourceEndMs > word.beginMs && segment.sourceEndMs < word.endMs)) throw new Error('Editorial A-roll cuts must not split a word'); } const kept = analysis.words.map((word) => aroll.some((segment) => segment.sourceStartMs <= word.beginMs && segment.sourceEndMs >= word.endMs)); const validateRange = (range: WordRange) => { if (!Number.isInteger(range.startWordIndex) || !Number.isInteger(range.endWordIndex) || range.startWordIndex < 0 || range.endWordIndex < range.startWordIndex || range.endWordIndex >= analysis.words.length) throw new Error('Editorial word range is outside the transcript'); }; unique(plan.decisions.map((decision) => decision.id), 'Editorial decisions'); const requiredCandidates = [ ...analysis.candidates, ...(plan.scope === 'full' ? [...analysis.fillers, ...analysis.repetitions, ...analysis.contentCandidates] : []), ]; const validCandidateIds = new Set(requiredCandidates.map((candidate) => candidate.id)); const coveredCandidates = new Set(); const explainedRemovals = new Set(); let pending = plan.unresolvedIssues.length > 0; for (const issue of plan.unresolvedIssues) nonempty(issue, 'Unresolved issue'); for (const decision of plan.decisions) { if (!['retake', 'false-start', 'filler', 'pause', 'other'].includes(decision.kind) || !['edit', 'keep', 'review'].includes(decision.action)) throw new Error('Invalid editorial decision kind or action'); nonempty(decision.reason, 'Decision reason'); if (!Array.isArray(decision.candidateIds) || !Array.isArray(decision.ranges)) throw new Error('Editorial decision requires candidateIds and ranges'); if (!decision.candidateIds.length && !decision.ranges.length) throw new Error('Editorial decision must reference a candidate or word range'); if (decision.action === 'review') pending = true; for (const id of decision.candidateIds) { if (!validCandidateIds.has(id)) throw new Error(`Unknown or out-of-scope editorial candidate: ${id}`); coveredCandidates.add(id); } for (const range of decision.ranges) { validateRange(range); if (!['keep', 'remove', 'review'].includes(range.disposition)) throw new Error('Invalid word disposition'); if (range.disposition === 'review') pending = true; if (decision.action === 'keep' && range.disposition !== 'keep') throw new Error('Keep decision contradicts word disposition'); for (let index = range.startWordIndex; index <= range.endWordIndex; index++) { if (range.disposition === 'keep' && !kept[index]) throw new Error(`Kept editorial word ${index} is missing from A-roll`); if (range.disposition === 'remove' && kept[index]) throw new Error(`Removed editorial word ${index} is still present in A-roll`); if (range.disposition !== 'keep') explainedRemovals.add(index); } } } for (const id of validCandidateIds) if (!coveredCandidates.has(id)) throw new Error(`Editorial candidate has no decision: ${id}`); for (let i = 0; i < kept.length; i++) if (!kept[i]) { if (plan.scope === 'pauses-only') throw new Error('Pauses-only scope cannot delete spoken words'); if (!explainedRemovals.has(i)) throw new Error(`Deleted word ${i} has no editorial disposition`); } unique(plan.uniqueInformation.map((item) => item.id), 'Unique information'); for (const item of plan.uniqueInformation) { validateRange(item); nonempty(item.reason, 'Unique information reason'); for (let i = item.startWordIndex; i <= item.endWordIndex; i++) if (!kept[i]) throw new Error(`Unique information ${item.id} was lost at word ${i}`); } unique(plan.segmentReasons.map((item) => item.segmentId), 'Segment reasons'); if (plan.segmentReasons.length !== aroll.length || aroll.some((segment) => !plan.segmentReasons.some((item) => item.segmentId === segment.id))) throw new Error('Every output segment requires an editorial reason'); for (const item of plan.segmentReasons) nonempty(item.reason, 'Segment reason'); return pending ? 'needs-review' : plan.scope === 'full' ? 'content-reviewed' : 'pauses-only-reviewed'; } export function createEditorialPlanReceipt(project: TalkingHeadProject, revision: number, aroll: ArollSegment[], analysis: TranscriptAnalysis, plan: EditorialPlan): { editorialPlanReceipt: EditorialPlanReceipt; editorialStatus: EditorialStatus; listeningStatus: 'pending' } { if (project.currentRevision !== revision) throw new Error('Editorial plan revision conflict; re-read the project'); if (project.sourceDurationMs !== undefined && aroll.some((segment) => segment.sourceEndMs > project.sourceDurationMs!)) throw new Error('Editorial A-roll exceeds the source duration'); const editorialStatus = validateEditorialPlan(plan, aroll, analysis); const payload = { schemaVersion: 1 as const, projectId: project.projectId, revision, sourceSha256: project.source.sha256, transcriptSha256: project.transcript.sha256, arollSha256: editorialHash(aroll), plan: structuredClone(plan) }; return { editorialPlanReceipt: { ...payload, planSha256: editorialHash(payload) }, editorialStatus, listeningStatus: 'pending' }; } export function verifyEditorialPlanReceipt(project: TalkingHeadProject, revision: number, aroll: ArollSegment[], analysis: TranscriptAnalysis, receipt: EditorialPlanReceipt): EditorialStatus { const expected = createEditorialPlanReceipt(project, revision, aroll, analysis, receipt.plan); if (editorialHash(expected.editorialPlanReceipt) !== editorialHash(receipt)) throw new Error('Editorial plan receipt changed or belongs to different sources, revision, or A-roll; plan again'); return expected.editorialStatus; }