From ee4d818eed6ecc40a80f7ff197da6d3c189037b6 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 27 Oct 2026 21:58:35 +0800 Subject: [PATCH] Add reproducible shadow cover layout and API publication tools --- scripts/layout-shadow-covers.mjs | 38 +++++++++++++++++++++++++ scripts/publish-shadow-covers.mjs | 46 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 scripts/layout-shadow-covers.mjs create mode 100644 scripts/publish-shadow-covers.mjs diff --git a/scripts/layout-shadow-covers.mjs b/scripts/layout-shadow-covers.mjs new file mode 100644 index 0000000..df29cd9 --- /dev/null +++ b/scripts/layout-shadow-covers.mjs @@ -0,0 +1,38 @@ +/** Precise layout authorized by the user; all photographic/puppet/paper content comes from original photos or imagegen. */ +import {createRequire} from 'node:module'; +import {writeFile} from 'node:fs/promises'; +import {resolve,join} from 'node:path'; +const require=createRequire(new URL('../apps/waterfall-wiki/package.json',import.meta.url)),sharp=require('sharp'); +const root=resolve(process.argv[2]||'../output/shadow-puppet-covers'),W=1536,H=2048,HALF=1024; +const specs=[ + {key:'lotus',draft:'lotus-shadow-puppet.png',seam:724}, + {key:'pavilion',draft:'pavilion-shadow-puppet.png',seam:727}, + {key:'crane',draft:'crane-draft.png',seam:648,cutoutHeight:880}, + {key:'lion',draft:'lion-draft.png',seam:724,cutoutHeight:880,generatedPhoto:true}, + {key:'peach',draft:'peach-draft.png',seam:645,cutoutHeight:900} +]; +const checks=[]; +for(const spec of specs){ + const path=join(root,spec.draft),meta=await sharp(path).metadata();let lower; + if(spec.cutoutHeight){ + const cutout=join(root,spec.key+'-cutout.png'),alpha=await sharp(cutout).stats(); + if(!alpha.channels[3]||alpha.channels[3].min!==0)throw Error('Cutout lacks real transparent background: '+spec.key); + const resizedSubject=await sharp(cutout).trim({background:'#00000000',threshold:1}).resize({width:1050,height:spec.cutoutHeight,fit:'inside'}).png().toBuffer(); + const sized=await sharp(resizedSubject).metadata(); + // Control rods continue beyond the frame rather than terminating on the paper. + const subject=await sharp(resizedSubject).extract({left:0,top:0,width:sized.width,height:sized.height-36}).png().toBuffer(); + const subjectInfo=await sharp(subject).metadata(); + const paper=await sharp(join(root,'paper-stage.png')).resize(W,HALF,{fit:'cover'}).png().toBuffer(); + lower=await sharp(paper).composite([{input:subject,left:Math.round((W-subjectInfo.width)/2),top:HALF-subjectInfo.height}]).png().toBuffer(); + }else{ + lower=await sharp(path).extract({left:0,top:spec.seam,width:meta.width,height:meta.height-spec.seam}).resize(W,HALF,{fit:'cover'}).png().toBuffer(); + } + const upper=spec.generatedPhoto?await sharp(path).extract({left:0,top:0,width:meta.width,height:spec.seam}).resize(W,HALF,{fit:'cover'}).png().toBuffer():await sharp(join(root,'sources',spec.key+'.jpg')).resize(W,HALF,{fit:'cover',position:'centre'}).png().toBuffer(); + const output=join(root,spec.key+'-cover.png'); + const composed=await sharp({create:{width:W,height:H,channels:3,background:'#f3d39c'}}).composite([{input:upper,left:0,top:0},{input:lower,left:0,top:HALF}]).png().toBuffer(); + await sharp(composed).flatten({background:'#f3d39c'}).removeAlpha().png().toFile(output); + const final=await sharp(output).metadata();if(final.width*4!==final.height*3||final.height%2||final.hasAlpha)throw Error('Invalid cover geometry: '+spec.key); + checks.push({key:spec.key,path:output,width:final.width,height:final.height,photographRows:[0,HALF-1],puppetRows:[HALF,H-1],sourcePanelSeam:spec.seam,sourcePhoto:spec.generatedPhoto?'imagegen photographic outpaint of original':'original photograph',cutout:!!spec.cutoutHeight}); +} +await writeFile(join(root,'layout-checks.json'),JSON.stringify(checks,null,2)); +console.log(JSON.stringify({covers:checks.length,width:W,height:H,seam:HALF,checks:'exact 3:4, equal halves, opaque'})); diff --git a/scripts/publish-shadow-covers.mjs b/scripts/publish-shadow-covers.mjs new file mode 100644 index 0000000..099b79c --- /dev/null +++ b/scripts/publish-shadow-covers.mjs @@ -0,0 +1,46 @@ +/** Run with the production auth CLI after staging reviewed covers and manifest. Never stores auth tokens. */ +import {readFile,writeFile} from 'node:fs/promises'; +import {join,resolve} from 'node:path'; +import {createHash} from 'node:crypto'; +const root=resolve(process.env.SHADOW_COVERS_DIR||'/tmp/shadow-covers'); +const base=process.env.AUTH_CHECK_BASE_URL||'http://127.0.0.1:3000'; +const manifest=JSON.parse(await readFile(join(root,'manifest.json'),'utf8')); +const backup=JSON.parse(await readFile(join(root,'notes-before.json'),'utf8')); +let state;try{state=JSON.parse(await readFile(join(root,'publication.json'),'utf8'))}catch{state={covers:{},archived:[],beforeArchive:[]}} +const persist=()=>writeFile(join(root,'publication.json'),JSON.stringify(state,null,2)); +async function api(path,body,method=body?'POST':'GET'){const r=await fetch(base+path,{method,...(body?{headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(path+' '+r.status+' '+JSON.stringify(data));return data} +async function upload(key,file){if(state.covers[key]?.assetId)return state.covers[key].assetId;const bytes=await readFile(join(root,file)),form=new FormData();form.set('kind','image');form.set('assetOnly','true');form.set('files',new Blob([bytes],{type:'image/png'}),file);const r=await fetch(base+'/api/upload',{method:'POST',body:form});const data=await r.json();if(!r.ok)throw Error('Upload '+r.status+JSON.stringify(data));state.covers[key]={assetId:data.assets[0],sha256:createHash('sha256').update(bytes).digest('hex')};await persist();return data.assets[0]} +for(const item of manifest){ + const assetId=await upload(item.key,item.outputFile),{entry:e,tags}=await api('/api/notes/'+item.postId); + const marker=''; + if(e.coverAssetId!==assetId){ + const ids=[...(e.systemTags||[]).map(t=>t.id),...e.categories.map(name=>{const t=tags.find(t=>t.kind==='normal'&&t.name===name);if(!t)throw Error('Missing tag '+name);return t.id})]; + const attribution=`\n\n${marker}\n## 本篇封面:照片入皮影\n\n摄影/馆藏来源:[原始照片](${item.source});作者:${item.author}。\n\n封面图许可:[${item.license}](${item.licenseUrl})(仅适用于本封面及其改编,不改变正文原有许可)。照片经裁切,皮影由 AI 根据原照生成;上下分区经代码精确排版为 1:1。${item.key==='lion'?'上半部亦使用 AI 对原照扩图。':''}\n`; + const markdown=e.editableMarkdown.includes(marker)?e.editableMarkdown:e.editableMarkdown+attribution; + const assets=(e.assets||[]).map(({id,role})=>({id,role})); + if(!e.postSlug){ + // Retain the original skill's two style examples, already stored by its companion notes. + assets.push({id:'102a1975-5343-4da9-91aa-c6914db50d5e',role:'content'},{id:'0a7a631d-0a4a-4a66-bae9-71d824469c50',role:'content'}); + } + if(!assets.some(a=>a.id===assetId))assets.push({id:assetId,role:'content'}); + const input={title:e.title,description:e.description,tags:[...new Set(ids)],assets,coverAssetId:assetId}; + if(e.postSlug)await api('/api/notes/'+e.id,{...input,markdown,revision:e.revision},'PUT'); + else await api('/api/posts',{...input,kind:'article',publicId:e.id,slug:'guide-shadow-puppet',text:markdown}); + } + const after=(await api('/api/notes/'+e.id)).entry;if(after.coverAssetId!==assetId)throw Error('Cover mismatch'); + const media=await fetch(base+'/api/media/'+assetId);if(!media.ok||createHash('sha256').update(Buffer.from(await media.arrayBuffer())).digest('hex')!==state.covers[item.key].sha256)throw Error('Media checksum mismatch'); + state.covers[item.key].postId=e.id;state.covers[item.key].verified=true;await persist();console.log(JSON.stringify({cover:item.key,postId:e.id,verified:true})); +} +if(backup.oldRefs.length!==27)throw Error('Expected exact 27-entry archive allowlist'); +for(const item of backup.oldRefs){ + const e=(await api('/api/notes/'+item.id)).entry; + if(e.title!==item.title||!/^ref-(0[1-9]|1[0-9]|2[0-7]) · /.test(e.title)||!e.postSlug)throw Error('Archive allowlist mismatch '+item.id); + if(!state.beforeArchive.some(n=>n.id===e.id)){state.beforeArchive.push(e);await persist()} + await api('/api/posts/'+item.id+'/archive',{archived:true}); + const after=(await api('/api/notes/'+item.id)).entry; + if(!after.archived||after.editableMarkdown!==e.editableMarkdown||JSON.stringify(after.assets)!==JSON.stringify(e.assets))throw Error('Archive preservation failed'); + if(!state.archived.includes(e.id))state.archived.push(e.id);await persist(); +} +for(const category of ['照片入皮影','高级感排版']){let cursor=null,entries=[];do{const d=await api('/api/explore-feed?category='+encodeURIComponent(category)+(cursor?'&cursor='+encodeURIComponent(cursor):''));entries.push(...d.entries);cursor=d.nextCursor}while(cursor);if(category==='照片入皮影'&&!manifest.every(m=>entries.some(e=>e.id===m.postId)))throw Error('Missing cover post');if(entries.some(e=>state.archived.includes(e.id)))throw Error('Archived article still discoverable');state[category]=entries.map(e=>({id:e.id,title:e.title}));} +const saved=(await api('/api/notes/38a7a8ea-6b3b-46fd-902f-431d2f351cf4')).entry;if(saved.archived)throw Error('User design incorrectly archived'); +state.completedAt=new Date().toISOString();await persist();console.log(JSON.stringify({covers:manifest.length,archived:state.archived.length,userDesignPreserved:true}));