Add versioned multi-page Studio artwork documents and native exports
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/** Run only on the confirmed ces-control runner against the designated deployed site. */
|
||||
const {readFile,writeFile,mkdir}=require('node:fs/promises');
|
||||
const {createHmac,randomBytes,createHash}=require('node:crypto');
|
||||
const {chromium}=require('playwright-core');
|
||||
const sharp=require('sharp');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const base='http://8.211.154.134:18131',id=process.env.STUDIO_DESIGN_ID,dir=process.env.STUDIO_EXPORT_DIR;
|
||||
assert.match(id||'',/^[0-9a-f-]{36}$/i);assert.ok(dir,'Set STUDIO_EXPORT_DIR to a persistent output directory');await mkdir(dir,{recursive:true});
|
||||
let token=process.env.PROMPT_TOKEN;
|
||||
if(!token){const config=JSON.parse(await readFile(process.env.AUTH_CONFIG_FILE,'utf8')),now=Math.floor(Date.now()/1000),part=o=>Buffer.from(JSON.stringify(o)).toString('base64url');const payload=part({alg:'HS256',typ:'JWT'})+'.'+part({iss:'prompt',aud:'prompt-web',sub:'shared',iat:now,exp:now+1200,jti:randomBytes(16).toString('hex')});token=payload+'.'+createHmac('sha256',Buffer.from(config.jwtKey,'hex')).update(payload).digest('base64url')}
|
||||
const api=async path=>{const r=await fetch(base+path,{headers:{Authorization:'Bearer '+token},redirect:'error'});assert.equal(r.status,200,path);return r.json()};
|
||||
const plan=await api('/api/studio/series/'+id+'/export'),{design}=await api('/api/studio/series/'+id);
|
||||
assert.equal(plan.revision,design.revision);assert.ok(plan.pages.length>=3);assert.equal(new Set(plan.pages.map(p=>p.width+':'+p.height)).size,1,'Use one aspect ratio throughout the series');
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
const evidence={id,revision:plan.revision,url:base+design.url,styleVersion:design.document.style.version,files:[],viewports:[],errors:[]};
|
||||
try{
|
||||
const context=await browser.newContext({viewport:{width:1080,height:1600},deviceScaleFactor:1});
|
||||
await context.route('**/*',route=>{const u=new URL(route.request().url());return u.origin===base?route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}}):route.abort()});
|
||||
const page=await context.newPage();page.on('pageerror',e=>evidence.errors.push(e.message));
|
||||
async function ready(){await page.evaluate(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(i=>i.decode()))});assert.equal(await page.locator('button,input,textarea,select,[contenteditable]').count(),0)}
|
||||
async function metrics(){return page.locator('article.native-layout').evaluateAll(arts=>arts.map(el=>({width:el.clientWidth,height:el.clientHeight,text:[...el.querySelectorAll('.native-copy')].map(e=>({id:e.closest('[data-element]').dataset.element,overflow:e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2,font:getComputedStyle(e).fontFamily,value:e.textContent})),images:[...el.querySelectorAll('img')].map(i=>({src:i.currentSrc,width:i.naturalWidth,height:i.naturalHeight,displayWidth:i.clientWidth,displayHeight:i.clientHeight,zoom:Number((i.style.transform.match(/scale\(([^)]+)\)/)||[])[1]||1)}))}))) }
|
||||
for(const viewport of [{width:1200,height:1000},{width:390,height:844}]){await page.setViewportSize(viewport);await page.goto(base+design.url,{waitUntil:'networkidle'});await ready();assert.equal(await page.locator('.series-page').count(),plan.pages.length);assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));const m=await metrics();assert.ok(m.every(a=>a.text.every(t=>!t.overflow)),'Text overflow in '+viewport.width);await page.screenshot({path:dir+'/review-'+viewport.width+'.png',fullPage:true});evidence.viewports.push({viewport,metrics:m})}
|
||||
for(const p of plan.pages){await page.setViewportSize({width:p.width,height:p.height});await page.goto(base+p.url,{waitUntil:'networkidle'});await ready();const [m]=await metrics();assert.equal(m.width,p.width);assert.ok(Math.abs(m.height-p.height)<=1);assert.ok(m.text.every(t=>!t.overflow),'Export text overflow');
|
||||
for(const i of m.images){assert.equal(new URL(i.src).pathname,'/api/preview');assert.equal(new URL(i.src).searchParams.get('profile'),'export');assert.ok(i.width>=i.displayWidth*i.zoom&&i.height>=i.displayHeight*i.zoom,'Source image too small for crop/zoom')}
|
||||
// Record actual glyph-rendering fonts, including the CJK fallback used by Chromium.
|
||||
const cdp=await context.newCDPSession(page);await cdp.send('DOM.enable');await cdp.send('CSS.enable');const root=await cdp.send('DOM.getDocument'),nodes=await cdp.send('DOM.querySelectorAll',{nodeId:root.root.nodeId,selector:'.native-copy'}),fonts=[];
|
||||
for(const nodeId of nodes.nodeIds){const f=await cdp.send('CSS.getPlatformFontsForNode',{nodeId});fonts.push(f.fonts);assert.ok(f.fonts.length,'No rendered fonts')}
|
||||
await cdp.detach();const bytes=await page.locator('article.native-layout').screenshot({path:dir+'/'+p.filename,animations:'disabled'}),meta=await sharp(bytes).metadata();assert.equal(meta.width,p.width);assert.equal(meta.height,p.height);evidence.files.push({...p,bytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex'),fonts,metrics:m});
|
||||
}
|
||||
assert.equal((await api('/api/studio/series/'+id)).design.revision,plan.revision,'Revision changed during export');assert.deepEqual(evidence.errors,[]);
|
||||
await writeFile(dir+'/manifest.json',JSON.stringify(evidence,null,2));await writeFile(dir+'/design.json',JSON.stringify(design,null,2));console.log(JSON.stringify({id,revision:plan.revision,pages:evidence.files.length,dir}));
|
||||
}finally{await browser.close()}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on ces-control only after both services have deployed this revision.
|
||||
set -Eeuo pipefail
|
||||
ROOT=$(git rev-parse --show-toplevel)
|
||||
IMAGE=$(docker inspect --format '{{.Config.Image}}' prompt-waterfall-wiki-1)
|
||||
[[ "$IMAGE" =~ ^waterfall-wiki:[0-9a-f]{40}$ ]] || exit 1
|
||||
OUT="$ROOT/artifacts/studio-guide"
|
||||
mkdir -p "$OUT"
|
||||
chown 1000:1000 "$OUT"
|
||||
docker run --rm --volumes-from prompt-waterfall-wiki-1:ro \
|
||||
--mount "type=bind,src=$ROOT,dst=/delivery,readonly" \
|
||||
--mount "type=bind,src=$OUT,dst=/tmp/studio-library" \
|
||||
-e AUTH_CONFIG_FILE=/auth/config.json -e AUTH_CHECK_BASE_URL=http://8.211.154.134:18131 \
|
||||
--entrypoint node "waterfall-wiki-tests:${IMAGE#waterfall-wiki:}" dist/auth-cli.cjs run /delivery/scripts/publish-studio-library.mjs
|
||||
@@ -9,6 +9,7 @@ async function api(path,body,method=body?'POST':'GET'){
|
||||
if(!response.ok)throw Error(path+' status '+response.status);
|
||||
return response.json();
|
||||
}
|
||||
const capability=await fetch(base+'/api/studio/series?agent_mode=True');if(!capability.ok||!(await capability.text()).includes('schemaVersion 2'))throw Error('Series API is not deployed; refusing to publish future documentation');
|
||||
const canonical='8eb8c76500580f7a4b56ba3c',legacy='9ebd8f78d54b4b9db741b4e8';
|
||||
const {entry:current,tags}=await api('/api/notes/'+canonical),{entry:old}=await api('/api/notes/'+legacy);
|
||||
// Snapshot only non-secret editorial state. Retain the first backup across repeat publication.
|
||||
@@ -27,7 +28,7 @@ if(current.editableMarkdown.includes(appendixMarker)){
|
||||
}
|
||||
const tagIds=entry=>[...new Set([...(entry.systemTags||[]).map(t=>t.id),...entry.categories.map(name=>tags.find(t=>t.name===name)?.id).filter(Boolean)])];
|
||||
const body=(entry,text,title=entry.title)=>({title,description:entry.description,markdown:text,tags:tagIds(entry),assets:entry.assets,coverAssetId:entry.coverAssetId,revision:entry.revision});
|
||||
await api('/api/notes/'+canonical,{...body(current,markdown,'Studio 排版 API · 标准图文与27套固定构图'),description:'coding agent 经 HTTP 选择标准图文或27套固定构图,上传压缩展示素材,创建、读取、更新、归档与恢复稳定 ID 原生页面;含兼容工具箱。'},'PUT');
|
||||
await api('/api/notes/'+canonical,{...body(current,markdown,'Studio 图文 API · 多页作品与统一视觉设计'),description:'coding agent 制定主题分镜与统一视觉,生成配图,经 HTTP 保存可修订多页作品;凭稳定 ID 直接打开原生结果并在远程导出高清图片。'},'PUT');
|
||||
const redirectText=`# Studio 文档已合并\n\n[前往 Studio 排版 API 统一入口](/explore/${canonical})\n\n[可视化 API 库](/studio) · [原 ref-17 工具](/studio/ideas/learning-skills-template) · [原三种构图工具](/studio/ideas/layout-selection)\n\n完整原说明与代码示例已保留在统一入口的兼容工具章节。此记录仅可恢复归档,未删除工具、素材或设计。`;
|
||||
await api('/api/notes/'+legacy,body(old,redirectText),'PUT');
|
||||
await api('/api/posts/'+legacy+'/archive',{archived:true});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Invoke via the deployed auth-cli run on ces-control; authorization stays in memory. */
|
||||
import {readFile,writeFile,mkdir} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
const [input,image,provenance,out]=process.argv.slice(4); // auth-cli.cjs run SCRIPT ARGS
|
||||
if(!input||!image||!provenance||!out)throw Error('Supply input.json image.png provenance.json output-directory');
|
||||
const dir=resolve(out);await mkdir(dir,{recursive:true});
|
||||
const statePath=dir+'/save-state.json';let state={};try{state=JSON.parse(await readFile(statePath,'utf8'))}catch(e){if(e.code!=='ENOENT')throw e}
|
||||
const base='http://8.211.154.134:18131';
|
||||
if(state.designId){const r=await fetch(base+'/api/studio/series/'+state.designId);if(!r.ok)throw Error('Saved state cannot be verified');const result=await r.json();await writeFile(dir+'/design.json',JSON.stringify(result.design,null,2));console.log(JSON.stringify({existing:true,id:state.designId,url:base+result.design.url}));process.exit(0)}
|
||||
// An in-flight operation is deliberately not retried: reconcile uncertain outcomes through the API.
|
||||
if(state.pending)throw Error('Previous request outcome unknown; reconcile '+state.pending+' before retrying');
|
||||
const save=()=>writeFile(statePath,JSON.stringify(state,null,2));
|
||||
if(!state.assetId){state.pending='upload';await save();const form=new FormData();form.set('kind','image');form.set('assetOnly','true');form.append('files',new Blob([await readFile(image)],{type:'image/png'}),'ginkgo-master.png');const r=await fetch(base+'/api/upload',{method:'POST',body:form});if(!r.ok)throw Error('Upload failed: '+r.status);const uploaded=await r.json();state.assetId=uploaded.assets[0];delete state.pending;await save()}
|
||||
const body=JSON.parse(await readFile(input,'utf8')),source=JSON.parse(await readFile(provenance,'utf8'));
|
||||
body.document.sources=[{assetId:state.assetId,...source}];for(const page of body.document.pages)page.parameters.images.I01={...page.parameters.images.I01,assetId:state.assetId};
|
||||
await writeFile(dir+'/series-request.json',JSON.stringify(body,null,2));state.pending='create';await save();
|
||||
const r=await fetch(base+'/api/studio/series',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(!r.ok)throw Error('Create failed: '+r.status+' '+await r.text());const {design}=await r.json();state.designId=design.id;delete state.pending;await save();await writeFile(dir+'/design.json',JSON.stringify(design,null,2));console.log(JSON.stringify({id:design.id,url:base+design.url,revision:design.revision,pages:design.document.pages.length}));
|
||||
Reference in New Issue
Block a user