Add reproducible shadow cover layout and API publication tools

This commit is contained in:
2026-10-27 21:58:35 +08:00
parent e57f7b7518
commit ee4d818eed
2 changed files with 84 additions and 0 deletions
+38
View File
@@ -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'}));
+46
View File
@@ -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='<!-- shadow-cover-2026-10-27:'+item.key+' -->';
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}));