Spaces:
Sleeping
Sleeping
| /** | |
| * Amazon MEC (Media Entertainment Core) XML metadata parser. | |
| * Parses mdmec:CoreMetadata XML and extracts text fields as QC lines | |
| * so they can be fed through the AI QC pipeline for spelling/grammar checks. | |
| */ | |
| import type { QCLine } from "./types"; | |
| interface MECField { | |
| fieldName: string; | |
| language: string; | |
| value: string; | |
| } | |
| /** | |
| * Detect if a buffer is an Amazon MEC XML file. | |
| */ | |
| export function isMECFile(buffer: Buffer, filename: string): boolean { | |
| if (!filename.match(/\.(xml|mec)$/i)) return false; | |
| const head = buffer.subarray(0, Math.min(500, buffer.length)).toString("utf-8"); | |
| return head.includes("mdmec:CoreMetadata") || head.includes("movielabs.com/schema/mdmec"); | |
| } | |
| /** | |
| * Parse MEC XML and extract all text fields as QCLines. | |
| * Each field becomes a QCLine with the field path as context. | |
| */ | |
| export function parseMECToLines(buffer: Buffer, targetLanguage?: string): { | |
| lines: QCLine[]; | |
| metadata: MECMetadata; | |
| } { | |
| const xml = buffer.toString("utf-8"); | |
| const fields = extractMECFields(xml); | |
| const metadata = extractMECMetadata(xml); | |
| // Filter by language if specified | |
| const filtered = targetLanguage | |
| ? fields.filter(f => { | |
| // Match language prefix (e.g. "en" matches "en-US", "fil" matches "fil-PH") | |
| const langPrefix = targetLanguage.toLowerCase().split("-")[0]; | |
| const fieldLangPrefix = f.language.toLowerCase().split("-")[0]; | |
| return fieldLangPrefix === langPrefix || f.language === "" || f.language === "all"; | |
| }) | |
| : fields; | |
| const lines: QCLine[] = filtered.map((f, i) => ({ | |
| id: i + 1, | |
| text: f.value, | |
| tcIn: undefined, | |
| tcOut: undefined, | |
| // Store field context in a way the QC prompts can use | |
| _fieldName: f.fieldName, | |
| _language: f.language, | |
| } as QCLine & { _fieldName: string; _language: string })); | |
| return { lines, metadata }; | |
| } | |
| export interface MECMetadata { | |
| contentId: string; | |
| title: string; | |
| originalTitle: string; | |
| workType: string; | |
| releaseYear: string; | |
| releaseDate: string; | |
| languages: string[]; | |
| country: string; | |
| genres: string[]; | |
| castCount: number; | |
| crewCount: number; | |
| } | |
| function extractMECMetadata(xml: string): MECMetadata { | |
| const get = (tag: string): string => { | |
| const m = xml.match(new RegExp(`<[^>]*${tag}[^>]*>([^<]*)<`)); | |
| return m ? m[1].trim() : ""; | |
| }; | |
| const getAttr = (tag: string, attr: string): string => { | |
| const m = xml.match(new RegExp(`<[^>]*${tag}[^>]*${attr}="([^"]*)"`)); | |
| return m ? m[1].trim() : ""; | |
| }; | |
| const languages = new Set<string>(); | |
| const langMatches = xml.matchAll(/language="([^"]+)"/g); | |
| for (const m of langMatches) languages.add(m[1]); | |
| const genresSet = new Set<string>(); | |
| const genreMatches = xml.matchAll(/Genre\s+id="([^"]+)"/g); | |
| for (const m of genreMatches) genresSet.add(m[1]); | |
| const castCount = (xml.match(/Actor<\/md:JobFunction>/g) || []).length; | |
| const crewCount = (xml.match(/<md:JobFunction>(?!Actor)/g) || []).length; | |
| return { | |
| contentId: getAttr("Basic", "ContentID"), | |
| title: get("TitleDisplayUnlimited"), | |
| originalTitle: get("OriginalTitle"), | |
| workType: get("WorkType"), | |
| releaseYear: get("ReleaseYear"), | |
| releaseDate: get("ReleaseDate"), | |
| languages: [...languages], | |
| country: get("country"), | |
| genres: [...genresSet], | |
| castCount: castCount / 2, // dedupe fil-PH + en-US | |
| crewCount: crewCount / 2, | |
| }; | |
| } | |
| function extractMECFields(xml: string): MECField[] { | |
| const fields: MECField[] = []; | |
| // Extract all LocalizedInfo blocks | |
| const localizedBlocks = xml.matchAll( | |
| /<md:LocalizedInfo\s+language="([^"]*)"[^>]*>([\s\S]*?)<\/md:LocalizedInfo>/g | |
| ); | |
| for (const block of localizedBlocks) { | |
| const lang = block[1]; | |
| const content = block[2]; | |
| // Title | |
| const title = content.match(/<md:TitleDisplayUnlimited>([^<]*)<\/md:TitleDisplayUnlimited>/); | |
| if (title?.[1]?.trim()) { | |
| fields.push({ fieldName: `Title (${lang})`, language: lang, value: title[1].trim() }); | |
| } | |
| // Original Title | |
| const origTitle = content.match(/<md:OriginalTitle>([^<]*)<\/md:OriginalTitle>/); | |
| if (origTitle?.[1]?.trim()) { | |
| fields.push({ fieldName: `OriginalTitle (${lang})`, language: lang, value: origTitle[1].trim() }); | |
| } | |
| // Summary190 | |
| const s190 = content.match(/<md:Summary190>([^<]*)<\/md:Summary190>/); | |
| if (s190?.[1]?.trim()) { | |
| fields.push({ fieldName: `Summary190 (${lang})`, language: lang, value: s190[1].trim() }); | |
| } | |
| // Summary400 | |
| const s400 = content.match(/<md:Summary400>([\s\S]*?)<\/md:Summary400>/); | |
| if (s400?.[1]?.trim()) { | |
| fields.push({ fieldName: `Summary400 (${lang})`, language: lang, value: s400[1].trim() }); | |
| } | |
| // Summary4000 | |
| const s4000 = content.match(/<md:Summary4000>([\s\S]*?)<\/md:Summary4000>/); | |
| if (s4000?.[1]?.trim()) { | |
| fields.push({ fieldName: `Summary4000 (${lang})`, language: lang, value: s4000[1].trim() }); | |
| } | |
| } | |
| // Extract People names + characters | |
| const people = xml.matchAll( | |
| /<md:People>([\s\S]*?)<\/md:People>/g | |
| ); | |
| const seenPeople = new Set<string>(); | |
| for (const p of people) { | |
| const content = p[1]; | |
| const job = content.match(/<md:JobFunction>([^<]*)<\/md:JobFunction>/)?.[1]?.trim(); | |
| const name = content.match(/<md:DisplayName\s+language="([^"]*)"[^>]*>([^<]*)<\/md:DisplayName>/); | |
| const character = content.match(/<md:Character>([^<]*)<\/md:Character>/)?.[1]?.trim(); | |
| if (name?.[2]?.trim()) { | |
| const lang = name[1]; | |
| const nameVal = name[2].trim(); | |
| const key = `${job}:${nameVal}:${lang}`; | |
| if (!seenPeople.has(key)) { | |
| seenPeople.add(key); | |
| const label = character | |
| ? `${job}: ${nameVal} as "${character}" (${lang})` | |
| : `${job}: ${nameVal} (${lang})`; | |
| fields.push({ fieldName: label, language: lang, value: nameVal }); | |
| if (character) { | |
| fields.push({ fieldName: `Character name (${lang})`, language: lang, value: character }); | |
| } | |
| } | |
| } | |
| } | |
| // CompanyDisplayCredit | |
| const company = xml.match(/<md:DisplayString>([^<]*)<\/md:DisplayString>/); | |
| if (company?.[1]?.trim()) { | |
| fields.push({ fieldName: "CompanyDisplayCredit", language: "all", value: company[1].trim() }); | |
| } | |
| return fields; | |
| } | |
| /** | |
| * Format MEC lines for QC prompt — includes field context. | |
| */ | |
| export function formatMECLinesForPrompt(lines: (QCLine & { _fieldName?: string; _language?: string })[]): string { | |
| return lines.map(l => { | |
| const field = l._fieldName || `Field ${l.id}`; | |
| return `[LINE ${l.id}] [${field}] ${l.text}`; | |
| }).join("\n"); | |
| } | |