/** Deterministic segmentation + reviewed semantic regions; no generated imagery. */ import sharp from 'sharp'; import {readFile,writeFile,mkdir,rm} from 'node:fs/promises'; import path from 'node:path'; import {createHash} from 'node:crypto'; import annotations from '../contentasset:annotations.json'; import {textMergeTarget,unionBoxes} from '../contentasset:group-text'; import ocr from '../contentasset:ocr-evidence.json'; const root=path.resolve(import.meta.dirname,'..'),target=path.resolve(process.env.MEDIA_AUTHORING_ROOT||'/tmp/prompt-media-authoring'); if(target===root||target.startsWith(root+path.sep))throw Error('MEDIA_AUTHORING_ROOT must be outside repository'); type Box=[number,number,number,number]; type Element={id:string;kind:'text'|'image'|'decoration';role:string;box:Box;text:string|null;direction:string;reading:'verified'|'unreadable'|'not-text';asset:string;mask?:string;underlay?:string;pixels:number}; function components(mask:Uint8Array,w:number,h:number){ const seen=new Uint8Array(mask.length),items:{box:Box;pixels:number[]}[]=[]; for(let i=0;i=0&&kMath.abs(data[i*3+k]-c)))>19?1:0; const found=components(mask,W,H).filter(({box:[,,w,h]})=>w>85&&w<125&&h>145&&h<190).map(({box:[x,y,w,h]})=>({x,y,w,h})); if(found.length!==27)throw Error(`Expected 27 panels; found ${found.length}`); const rows:number[]=[],cols:number[]=[]; for(const p of [...found].sort((a,b)=>a.y-b.y))if(!rows.some(y=>Math.abs(y-p.y)<8))rows.push(p.y); for(const p of [...found].sort((a,b)=>a.x-b.x))if(!cols.some(x=>Math.abs(x-p.x)<8))cols.push(p.x); const panels=found.map(p=>({...p,row:rows.findIndex(y=>Math.abs(y-p.y)<8)+1,column:cols.findIndex(x=>Math.abs(x-p.x)<8)+1})).sort((a,b)=>a.row-b.row||a.x-b.x); const manifests=[];let totalPixels=0; for(const [index,p]of panels.entries()){ const id='ref-'+String(index+1).padStart(2,'0'),spec=annotations[index],dir=path.join(target,id);await rm(dir,{recursive:true,force:true});await mkdir(dir,{recursive:true}); const crop=await sharp(source).extract({left:p.x,top:p.y,width:p.w,height:p.h}).removeAlpha().raw().toBuffer(),w=p.w,h=p.h; await sharp(crop,{raw:{width:w,height:h,channels:3}}).png().toFile(path.join(dir,'source.png')); const owner=new Int16Array(w*h).fill(-1),elements:Element[]=[]; function entry(kind:Element['kind'],role:string,box:number[],text:string|null=null,direction='horizontal'){ const prefix={text:'T',image:'I',decoration:'D'}[kind],n=elements.filter(e=>e.kind===kind).length+1; let[x,y,bw,bh]=box.map(Math.round);x=Math.max(0,Math.min(w-1,x));y=Math.max(0,Math.min(h-1,y)); const e:Element={id:prefix+String(n).padStart(2,'0'),kind,role,box:[x,y,Math.max(1,Math.min(w-x,bw)),Math.max(1,Math.min(h-y,bh))],text,direction,reading:kind==='text'?(text===null?'unreadable':'verified'):'not-text',asset:'',pixels:0};elements.push(e);return e; } function region(box:Box,fn:(i:number)=>boolean){const[x,y,bw,bh]=box;for(let yy=y;yy(); for(let y=Math.max(1,sy-7);y220||Math.min(...rgb)<65)continue;const key=rgb.map(v=>Math.round(v/8)).join(','),c=hist.get(key);if(c)c.count++;else hist.set(key,{rgb,count:1})} const color=[...hist.values()].sort((a,b)=>b.count-a.count)[0]?.rgb;if(!color)throw Error(id+': no fill color');return color; } const imageColors=spec.images.map(image=>fillColor(image.seed)); for(const d of spec.decor.filter(d=>d.box[3]<=2&&d.box[2]>8)){const e=entry('decoration',d.role,d.box);region(e.box,i=>Math.max(...crop.subarray(i*3,i*3+3))<225)} for(const t of spec.texts){const e=entry('text',t.role,t.box,t.text,t.direction); const[x,y,bw,bh]=e.box,hist=new Map();for(let yy=y;yyb[1]-a[1])[0][0]; region(e.box,i=>{ const rgb=[...crop.subarray(i*3,i*3+3)],lum=(rgb[0]+rgb[1]+rgb[2])/3; if(t.tone==='light'||t.tone==='muted-light')return lum>mode+7; if(t.tone==='muted-dark')return lum{const[a,b,c,d]=spec.images[n].box,x=i%w,y=Math.floor(i/w);return x>=a&&x=b&&yrgb[k]-v))<16}); }) } for(const[n,image]of spec.images.entries()){ const e=entry('image',image.role,image.box),color=imageColors[n],candidate=new Uint8Array(w*h),[a,b,c,d]=image.box; for(let y=Math.max(1,b-12);ycrop[i*3+k]-v))<=36)candidate[i]=1} const[sx,sy]=image.seed,options=components(candidate,w,h).filter(c=>c.pixels.length>=12).sort((u,v)=>{ const distance=(part:{box:Box})=>{const[x,y,bw,bh]=part.box;return Math.max(x-sx,sx-x-bw,0)**2+Math.max(y-sy,sy-y-bh,0)**2};return distance(u)-distance(v)||v.pixels.length-u.pixels.length; }); const selected=options[0];if(!selected)throw Error(id+': no connected image region '+image.role); for(const i of selected.pixels)if(owner[i]===-1)owner[i]=elements.length-1; } for(const d of spec.decor.filter(d=>!(d.box[3]<=2&&d.box[2]>8))){const e=entry('decoration',d.role,d.box);region(e.box,i=>id==='ref-04'?Math.min(...crop.subarray(i*3,i*3+3))>218:Math.max(...crop.subarray(i*3,i*3+3))<240)} const residual=new Uint8Array(w*h);for(let y=4;y=7&&bh<=2)||(bh>=7&&bw<=2)){entry('decoration','自动识别细线',c.box);for(const i of c.pixels)owner[i]=elements.length-1}else tiny.push(...c.pixels)} if(tiny.length){ const joined=new Uint8Array(w*h);for(const i of tiny){const x=i%w,y=Math.floor(i/w);for(let dy=-1;dy<=1;dy++)for(let dx=-2;dx<=2;dx++)if(x+dx>=4&&x+dx=4&&y+dyset.has(i));if(!ink.length)continue;const candidates=elements.filter(e=>e.kind==='text'),match=textMergeTarget(c.box,candidates);if(match>=0){const existing=candidates[match],n=elements.indexOf(existing);existing.box=unionBoxes(existing.box,c.box);for(const i of ink)owner[i]=n}else{entry('text','未辨识细字(内容待核对)',c.box);for(const i of ink)owner[i]=elements.length-1}} } for(const[n,e]of elements.entries()){ let x0=w,y0=h,x1=-1,y1=-1;for(let i=0;i=0)e.box=[x0,y0,x1-x0+1,y1-y0+1]; } const result=Buffer.alloc(w*h*3),background=Buffer.alloc(w*h*4);let assigned=0; for(let i=0;i=w||yy+dy<0||yy+dy>=h)continue; const k=(yy+dy)*w+xx+dx;if(elements[owner[k]]?.kind==='text')continue;samples.push([...crop.subarray(k*3,k*3+3)]); } for(let c=0;c<3;c++)underlay[j+c]=samples.length?Math.round(samples.reduce((sum,rgb)=>sum+rgb[c],0)/samples.length):245;underlay[j+3]=255;underlay.copy(background,i*4,j,j+4); } e.underlay=`asset:${id}/${e.id}-underlay.png`; await sharp(underlay,{raw:{width:bw,height:bh,channels:4}}).png().toFile(path.join(dir,e.id+'-underlay.png')); } if(e.kind==='image'){ const outside=new Uint8Array(binary.length),q:number[]=[];for(let i=0;i=bw*(bh-1)||i%bw===0||i%bw===bw-1)){q.push(i);outside[i]=1} for(let c=0;c=0&&je.pixels>0),colors={text:'#2c64d8',image:'#e66d22',decoration:'#08875d'}; const overlay=`${kept.map(e=>{const[x,y,bw,bh]=e.box;return `${e.id}`}).join('')}`; await sharp(crop,{raw:{width:w,height:h,channels:3}}).resize(w*6,h*6,{kernel:'nearest'}).composite([{input:Buffer.from(overlay)}]).png().toFile(path.join(dir,'annotated.png')); const manifest={id,name:spec.name,source:{url:'asset:source.png',sha256:createHash('sha256').update(source).digest('hex'),box:[p.x,p.y,w,h],row:p.row,column:p.column},width:w,height:h,reference:`asset:${id}/source.png`,annotated:`asset:${id}/annotated.png`,background:`asset:${id}/background.png`,elements:kept,ocr:ocr.find(r=>r.file===id+'.png')?.lines||[],verification:{pixelIdentical:true,pixels:w*h,unreadableText:kept.filter(e=>e.reading==='unreadable').length}}; await writeFile(path.join(dir,'manifest.json'),JSON.stringify(manifest,null,2)+'\n');manifests.push(manifest);totalPixels+=w*h; } const overlay=`${manifests.map(m=>{const[x,y,w,h]=m.source.box;return `${m.id}`}).join('')}`; await sharp(source).composite([{input:Buffer.from(overlay)}]).png().toFile(path.join(target,'numbered-source.png')); await writeFile(path.join(root,'contentasset:manifest.json'),JSON.stringify(manifests,null,2)+'\n'); await writeFile(path.join(target,'index.json'),JSON.stringify({version:2,sourceSha256:manifests[0].source.sha256,numbering:'row-major; exclude left title banner',layouts:manifests.map(m=>({id:m.id,name:m.name,source:m.source,manifest:`asset:${m.id}/manifest.json`}))},null,2)+'\n'); console.log(`Detected ${panels.length} panels; ${manifests.reduce((n,m)=>n+m.elements.length,0)} typed components; ${totalPixels} pixels reconstructed exactly.`); } main().catch(e=>{console.error(e);process.exitCode=1});