import type { PublishMediaInfo, PublishMetadataArtifact, PublishPlatformProfile, PublishValidationIssue, PublishValidationResult, } from './types.js'; export function validatePublishPackageInput(input: { profile: PublishPlatformProfile; metadata: PublishMetadataArtifact; media: PublishMediaInfo; captionsPath?: string; thumbnailPath?: string; }): PublishValidationResult { const issues: PublishValidationIssue[] = []; validateMedia(input.profile, input.media, issues); validateMetadata(input.profile, input.metadata, issues); validateSupportFiles(input.profile, input.captionsPath, input.thumbnailPath, issues); const blockers = issues.filter((issue) => issue.severity === 'blocker'); const warnings = issues.filter((issue) => issue.severity === 'warning'); return { ok: blockers.length === 0, blockers, warnings }; } function validateSupportFiles( profile: PublishPlatformProfile, captionsPath: string | undefined, thumbnailPath: string | undefined, issues: PublishValidationIssue[], ): void { if (captionsPath) { const extension = fileExtension(captionsPath); if (!profile.metadata.captionFormats.includes(extension)) { issues.push(blocker( 'caption_format_unsupported', `${profile.displayName} package captions must use a supported format.`, extension || null, profile.metadata.captionFormats, )); } } if (thumbnailPath) { const extension = fileExtension(thumbnailPath); if (!profile.metadata.thumbnailFormats.includes(extension)) { issues.push(blocker( 'thumbnail_format_unsupported', `${profile.displayName} package thumbnail must use a supported format.`, extension || null, profile.metadata.thumbnailFormats, )); } } } function fileExtension(path: string): string { const name = path.replaceAll('\\', '/').split('/').pop() ?? ''; const dot = name.lastIndexOf('.'); return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; } function validateMedia( profile: PublishPlatformProfile, media: PublishMediaInfo, issues: PublishValidationIssue[], ): void { const limits = profile.media; const container = normalizeCodec(media.container); const videoCodec = normalizeCodec(media.videoCodec); const audioCodec = normalizeCodec(media.audioCodec); if (!limits.containers.includes(container)) { issues.push(blocker('container_unsupported', `${profile.displayName} does not support ${media.container} containers.`, media.container, limits.containers)); } if (!videoCodec || !limits.videoCodecs.includes(videoCodec)) { issues.push(blocker('video_codec_unsupported', `${profile.displayName} does not support this video codec.`, media.videoCodec ?? null, limits.videoCodecs)); } if (limits.audioRequired && !media.audioPresent) { issues.push(blocker('audio_missing', `${profile.displayName} requires an audio track.`)); } if (media.audioPresent && limits.audioCodecs && audioCodec && !limits.audioCodecs.includes(audioCodec)) { issues.push(blocker('audio_codec_unsupported', `${profile.displayName} does not support this audio codec.`, media.audioCodec, limits.audioCodecs)); } if (media.audioPresent && limits.audioCodecs && !audioCodec) { issues.push(blocker('media_probe_incomplete', 'Audio codec could not be probed; validation cannot prove platform compatibility.')); } if (limits.progressiveRequired && media.progressive !== true) { issues.push(blocker('progressive_required', `${profile.displayName} requires verified progressive video.`)); } if (typeof media.durationSeconds !== 'number') { const required = limits.minDurationSeconds !== undefined || limits.maxDurationSeconds !== undefined; issues.push((required ? blocker : warning)('media_probe_incomplete', 'Media duration could not be probed; validation is incomplete.')); } else { if (limits.minDurationSeconds !== undefined && media.durationSeconds < limits.minDurationSeconds) { issues.push(blocker('duration_too_short', `Duration is below ${profile.displayName} minimum.`, media.durationSeconds, limits.minDurationSeconds)); } if (limits.maxDurationSeconds !== undefined && media.durationSeconds > limits.maxDurationSeconds) { issues.push(blocker('duration_too_long', `Duration exceeds ${profile.displayName} maximum.`, media.durationSeconds, limits.maxDurationSeconds)); } } if (limits.maxSizeBytes !== undefined && typeof media.sizeBytes === 'number' && media.sizeBytes > limits.maxSizeBytes) { issues.push(blocker('file_too_large', `File size exceeds ${profile.displayName} maximum.`, media.sizeBytes, limits.maxSizeBytes)); } if (limits.maxSizeBytes !== undefined && typeof media.sizeBytes !== 'number') { issues.push(blocker('media_probe_incomplete', 'Media file size could not be probed; validation cannot enforce the platform maximum.')); } if (typeof media.width !== 'number' || typeof media.height !== 'number') { const required = limits.minWidth !== undefined || limits.maxWidth !== undefined || limits.minHeight !== undefined || limits.maxHeight !== undefined || limits.aspectRatios !== undefined || limits.minAspectRatio !== undefined || limits.maxAspectRatio !== undefined; issues.push((required ? blocker : warning)('media_probe_incomplete', 'Media dimensions could not be probed; aspect validation is incomplete.')); } else { if (limits.minWidth !== undefined && media.width < limits.minWidth) { issues.push(blocker('width_too_small', `Width is below ${profile.displayName} minimum.`, media.width, limits.minWidth)); } if (limits.maxWidth !== undefined && media.width > limits.maxWidth) { issues.push(blocker('width_too_large', `Width exceeds ${profile.displayName} maximum.`, media.width, limits.maxWidth)); } if (limits.minHeight !== undefined && media.height < limits.minHeight) { issues.push(blocker('height_too_small', `Height is below ${profile.displayName} minimum.`, media.height, limits.minHeight)); } if (limits.maxHeight !== undefined && media.height > limits.maxHeight) { issues.push(blocker('height_too_large', `Height exceeds ${profile.displayName} maximum.`, media.height, limits.maxHeight)); } validateAspectRatio(profile, media.width / media.height, issues); } if (typeof media.frameRate === 'number') { if (limits.minFrameRate !== undefined && media.frameRate < limits.minFrameRate) { issues.push(blocker('frame_rate_too_low', `Frame rate is below ${profile.displayName} minimum.`, media.frameRate, limits.minFrameRate)); } if (limits.maxFrameRate !== undefined && media.frameRate > limits.maxFrameRate) { issues.push(blocker('frame_rate_too_high', `Frame rate exceeds ${profile.displayName} maximum.`, media.frameRate, limits.maxFrameRate)); } } if ((limits.minFrameRate !== undefined || limits.maxFrameRate !== undefined) && typeof media.frameRate !== 'number') { issues.push(blocker('media_probe_incomplete', 'Frame rate could not be probed; validation cannot enforce the platform range.')); } if (limits.maxVideoBitrateMbps !== undefined && typeof media.videoBitrateMbps === 'number' && media.videoBitrateMbps > limits.maxVideoBitrateMbps) { issues.push(blocker('bitrate_too_high', `Video bitrate exceeds ${profile.displayName} maximum.`, media.videoBitrateMbps, limits.maxVideoBitrateMbps)); } if (limits.maxVideoBitrateMbps !== undefined && typeof media.videoBitrateMbps !== 'number') { issues.push(blocker('media_probe_incomplete', 'Video bitrate could not be probed; validation cannot enforce the platform maximum.')); } if (limits.maxAudioSampleRateHz !== undefined && typeof media.audioSampleRate === 'number' && media.audioSampleRate > limits.maxAudioSampleRateHz) { issues.push(blocker('audio_sample_rate_too_high', `Audio sample rate exceeds ${profile.displayName} maximum.`, media.audioSampleRate, limits.maxAudioSampleRateHz)); } if (media.audioPresent && limits.maxAudioSampleRateHz !== undefined && typeof media.audioSampleRate !== 'number') { issues.push(blocker('media_probe_incomplete', 'Audio sample rate could not be probed; validation cannot enforce the platform maximum.')); } if (limits.maxAudioChannels !== undefined && typeof media.audioChannels === 'number' && media.audioChannels > limits.maxAudioChannels) { issues.push(blocker('audio_channels_too_high', `Audio channel count exceeds ${profile.displayName} maximum.`, media.audioChannels, limits.maxAudioChannels)); } if (media.audioPresent && limits.maxAudioChannels !== undefined && typeof media.audioChannels !== 'number') { issues.push(blocker('media_probe_incomplete', 'Audio channel count could not be probed; validation cannot enforce the platform maximum.')); } } function validateAspectRatio( profile: PublishPlatformProfile, observed: number, issues: PublishValidationIssue[], ): void { const limits = profile.media; if (limits.aspectRatios) { const matched = limits.aspectRatios.some((ratio) => Math.abs(observed - ratio.ratio) <= (ratio.tolerance ?? 0.01)); if (!matched) { issues.push(blocker('aspect_ratio_unsupported', `Aspect ratio is not supported by ${profile.displayName}.`, observed, limits.aspectRatios.map((ratio) => ratio.label))); } } if (limits.minAspectRatio !== undefined && observed < limits.minAspectRatio) { issues.push(blocker('aspect_ratio_unsupported', `Aspect ratio is below ${profile.displayName} minimum.`, observed, limits.minAspectRatio)); } if (limits.maxAspectRatio !== undefined && observed > limits.maxAspectRatio) { issues.push(blocker('aspect_ratio_unsupported', `Aspect ratio exceeds ${profile.displayName} maximum.`, observed, limits.maxAspectRatio)); } } function validateMetadata( profile: PublishPlatformProfile, metadata: PublishMetadataArtifact, issues: PublishValidationIssue[], ): void { if (!metadata.title.trim()) { issues.push(blocker('title_missing', 'Publish metadata title is required.')); } if (metadata.title.length > profile.metadata.maxTitleCharacters) { issues.push(blocker('title_too_long', `Title exceeds ${profile.displayName} limit.`, metadata.title.length, profile.metadata.maxTitleCharacters)); } if ( profile.metadata.maxDescriptionCharacters !== undefined && (metadata.description?.length ?? 0) > profile.metadata.maxDescriptionCharacters ) { issues.push(blocker('description_too_long', `Description exceeds ${profile.displayName} limit.`, metadata.description?.length ?? 0, profile.metadata.maxDescriptionCharacters)); } if (profile.metadata.maxHashtags !== undefined && (metadata.tags?.length ?? 0) > profile.metadata.maxHashtags) { issues.push(blocker('hashtags_too_many', `Too many tags for ${profile.displayName}.`, metadata.tags?.length ?? 0, profile.metadata.maxHashtags)); } if (!profile.metadata.supportedVisibility.includes(metadata.visibility)) { issues.push(blocker('visibility_unsupported', `${profile.displayName} does not support requested visibility.`, metadata.visibility, profile.metadata.supportedVisibility)); } if (profile.metadata.syntheticDisclosureRequired && metadata.syntheticMedia === undefined) { issues.push(blocker('synthetic_disclosure_missing', `${profile.displayName} requires an explicit synthetic-media disclosure field.`)); } } function normalizeCodec(value: string | undefined): string { return (value ?? '') .trim() .toLowerCase() .replace(/^avc1.*$/, 'h264') .replace(/^h\.264$/, 'h264') .replace(/^h\.265$/, 'h265') .replace(/^hevc$/, 'hevc') .replace(/^mpeg4$/, 'mp4'); } function blocker( code: PublishValidationIssue['code'], message: string, observed?: unknown, limit?: unknown, ): PublishValidationIssue { return { code, severity: 'blocker', message, ...(observed !== undefined ? { observed } : {}), ...(limit !== undefined ? { limit } : {}) }; } function warning( code: PublishValidationIssue['code'], message: string, observed?: unknown, limit?: unknown, ): PublishValidationIssue { return { code, severity: 'warning', message, ...(observed !== undefined ? { observed } : {}), ...(limit !== undefined ? { limit } : {}) }; }