Preserve exported revisions and provide a remote delivery command
studio CI and deployment / verify-and-deploy (pull_request) Has been cancelled
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled

This commit is contained in:
2026-10-28 23:51:01 +08:00
parent 04df551f6a
commit 6a7d924436
6 changed files with 40 additions and 6 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
node scripts/check-studio-contracts.mjs
node --check scripts/export-studio-series.cjs
node --check scripts/save-studio-series.mjs
bash -n scripts/publish-studio-guide.sh
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
- name: Build and verify studio
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
- name: Release studio
+2 -2
View File
@@ -11,13 +11,13 @@ export function NativeResult({layout,design}:{layout:Layout;design:Design|null})
export function NativeArtwork({layout,design,quality=false}:{layout:Layout;design:Design|null;quality?:boolean}){
const params:Parameters=design?.parameters||emptyParameters();
const elements=[...layout.elements,...(params.added||[])];
return <article className={'native-layout '+layout.id} aria-label={design?.name||layout.name} data-layout-id={layout.id} data-design-id={design?.id} style={{aspectRatio:`${layout.width}/${layout.height}`,backgroundColor:params.paper||paperColor(layout.id)}}>
return <article className={'native-layout '+layout.id} aria-label={design?.name||layout.name} data-layout-id={layout.id} data-design-id={design?.id} style={{aspectRatio:`${layout.width}/${layout.height}`,height:quality?Math.round(1080*layout.height/layout.width):undefined,backgroundColor:params.paper||paperColor(layout.id)}}>
{['image','decoration','text'].flatMap(kind=>elements.filter(s=>s.kind===kind).map(s=>{
const t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id],ls=params.layers?.[s.id]||{};
if(ls.hidden||(kind==='decoration'&&d?.hidden))return null;
const text=t?.value??defaultText(layout,s);
const position:CSSProperties={...slotPosition(layout,{...s,box:[ls.x??s.box[0],ls.y??s.box[1],ls.width??s.box[2],ls.height??s.box[3]]}),transform:`rotate(${ls.rotation||0}deg)`,opacity:ls.opacity??1,borderRadius:ls.radius===undefined?undefined:`${ls.radius/layout.width*100}cqw`,zIndex:ls.z??(kind==='text'?3:kind==='image'?1:2)};
const textStyle:CSSProperties={fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily==='serif'?'Georgia,serif':t?.fontFamily==='mono'?'monospace':t?.fontFamily==='sans'?'Arial,sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'};
const textStyle:CSSProperties={fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily==='serif'?'Georgia,"Noto Serif CJK SC","Songti SC",serif':t?.fontFamily==='mono'?'"Noto Sans Mono CJK SC",monospace':t?.fontFamily==='sans'?'Arial,"Noto Sans CJK SC","PingFang SC","Microsoft YaHei",sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'};
return <div key={s.id} className={'native-element native-'+kind} data-element={s.id} aria-label={s.role} style={position}>
{kind==='text'?(s.direction==='curved'?<CurvedCopy layout={layout} slot={s} value={text} spacing={t?.letterSpacing} style={textStyle}/>:<div className={'native-copy direction-'+s.direction} style={textStyle}>{text}</div>):kind==='image'?<div className="native-image-fill" style={{...imageShape(layout,s),...(im?.color?{backgroundColor:im.color}:{}),...(ls.radius!==undefined?{borderRadius:`${ls.radius/layout.width*100}cqw`}:{})}}>
{im?.assetId&&<img src={'/api/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId,width:quality?'2160':'960',...(quality?{profile:'export'}:{})})} srcSet={quality?undefined:[240,480,960,1280].map(width=>'/api/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId!,width:String(width)})+' '+width+'w').join(',')} sizes={`(max-width: 860px) ${Math.min(100,(ls.width??s.box[2])/layout.width*100)}vw, ${Math.min(860,(ls.width??s.box[2])/layout.width*860)}px`} alt={s.role} style={{objectFit:im.fit||'cover',objectPosition:`${im.cropX??50}% ${im.cropY??50}%`,transform:`scale(${im.zoom??1})`}}/>}
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -64,7 +64,9 @@ curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/api/studio/series/$ID"
仓库 `content/studio-examples/autumn-journal.json` 是完整的三页内容与样式文档:封面“把秋天装进一页”、正文“先看见再记录”、结尾“留一点白给下一次”。它不含虚构素材 UUID,空图片位仅用于契约测试。真实制作时先生成银杏母图,再上传取得assetId,在每页 I01 加入该 UUID,并在 sources 记录完整来源。不要把无图夹具当作已完成作品。
`node /app/auth-cli.cjs run /delivery/scripts/save-studio-series.mjs /absolute/path/series.json /absolute/path/ginkgo-master.png /absolute/path/provenance.json /absolute/path/output-directory` 经既有 auth-cli run 在远程执行,上传素材并填写所有页面引用后,保存真实作品,写出请求、响应和状态文件。重复运行会读取已保存状态,防止在成功后重复创建;出现网络结果不明时先核对素材/设计列表,不盲目重试。脚本仅在传入文件后写入作品,不自动发布到小红书。
推荐在 ces-control 的仓库目录直接执行 `bash scripts/deliver-studio-series.sh /absolute/path/series.json /absolute/path/ginkgo-master.png /absolute/path/provenance.json /absolute/path/output-directory`:该脚本复用已部署认证卷和匹配的CI镜像,依次保存、导出并留下证据,不启动构建或部署。成品位于 output-directory/revision-N/;已成功导出的修订会核对哈希后复用,后续修订使用新目录,避免覆盖或混入旧页。
底层保存命令 `node /app/auth-cli.cjs run /delivery/scripts/save-studio-series.mjs /absolute/path/series.json /absolute/path/ginkgo-master.png /absolute/path/provenance.json /absolute/path/output-directory` 经既有 auth-cli run 在远程执行,上传素材并填写所有页面引用后,保存真实作品,写出请求、响应和状态文件。重复运行会读取已保存状态,防止在成功后重复创建;出现网络结果不明时先核对素材/设计列表,不盲目重试。脚本仅在传入文件后写入作品,不自动发布到小红书。
## 两类排版,同一内容模型
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Execute on the confirmed ces-control runner, after user-merged code is deployed.
# Uses the existing deployed auth volume and matching CI image; does not build or deploy.
set -Eeuo pipefail
[[ $# -eq 4 ]] || { echo 'Usage: bash scripts/deliver-studio-series.sh series.json image.png provenance.json output-directory'; exit 2; }
ROOT=$(git rev-parse --show-toplevel)
INPUT=$(realpath "$1")
HERO=$(realpath "$2")
PROVENANCE=$(realpath "$3")
mkdir -p "$4"
OUT=$(realpath "$4")
IMAGE=$(docker inspect --format '{{.Config.Image}}' prompt-waterfall-wiki-1)
[[ "$IMAGE" =~ ^waterfall-wiki:[0-9a-f]{40}$ ]] || exit 1
TEST_IMAGE="waterfall-wiki-tests:${IMAGE#waterfall-wiki:}"
docker image inspect "$TEST_IMAGE" >/dev/null
chown 1000:1000 "$OUT"
COMMON=(--rm --volumes-from prompt-waterfall-wiki-1:ro
--mount "type=bind,src=$ROOT,dst=/delivery,readonly"
--mount "type=bind,src=$OUT,dst=/output"
-e AUTH_CONFIG_FILE=/auth/config.json -e AUTH_CHECK_BASE_URL=http://8.211.154.134:18131)
docker run "${COMMON[@]}" \
--mount "type=bind,src=$INPUT,dst=/input/series.json,readonly" \
--mount "type=bind,src=$HERO,dst=/input/hero.png,readonly" \
--mount "type=bind,src=$PROVENANCE,dst=/input/provenance.json,readonly" \
--entrypoint node "$TEST_IMAGE" dist/auth-cli.cjs run /delivery/scripts/save-studio-series.mjs /input/series.json /input/hero.png /input/provenance.json /output
read -r ID REVISION < <(docker run --rm --mount "type=bind,src=$OUT,dst=/output,readonly" --entrypoint node "$TEST_IMAGE" -e "const d=JSON.parse(require('fs').readFileSync('/output/design.json','utf8'));process.stdout.write(d.id+' '+d.revision+'\n')")
[[ "$ID" =~ ^[0-9a-f-]{36}$ && "$REVISION" =~ ^[1-9][0-9]*$ ]] || exit 1
docker run "${COMMON[@]}" -e NODE_PATH=/app/node_modules -e "STUDIO_DESIGN_ID=$ID" -e "STUDIO_EXPORT_DIR=/output/revision-$REVISION" \
--entrypoint node "$TEST_IMAGE" /delivery/scripts/export-studio-series.cjs
+4 -1
View File
@@ -12,6 +12,9 @@ const assert=require('node:assert/strict');
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');
// A successful revision is immutable on disk. Reuse it only after checking every hash.
let previous;try{previous=JSON.parse(await readFile(dir+'/manifest.json','utf8'))}catch(e){if(e.code!=='ENOENT')throw e}
if(previous){assert.equal(previous.id,id);assert.equal(previous.revision,plan.revision);assert.equal(previous.files.length,plan.pages.length);for(const [index,file] of previous.files.entries()){assert.equal(file.filename,plan.pages[index].filename);assert.equal(createHash('sha256').update(await readFile(dir+'/'+file.filename)).digest('hex'),file.sha256)}console.log(JSON.stringify({id,revision:plan.revision,existing:true,dir}));return}
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{
@@ -25,7 +28,7 @@ const assert=require('node:assert/strict');
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 text=await cdp.send('DOM.getOuterHTML',{nodeId});if(!text.outerHTML.replace(/<[^>]*>/g,'').trim())continue;const f=await cdp.send('CSS.getPlatformFontsForNode',{nodeId});fonts.push(f.fonts);assert.ok(f.fonts.length,'No rendered fonts')}
for(const nodeId of nodes.nodeIds){const text=await cdp.send('DOM.getOuterHTML',{nodeId});if(!text.outerHTML.replace(/<[^>]*>/g,'').trim())continue;const f=await cdp.send('CSS.getPlatformFontsForNode',{nodeId});fonts.push(f.fonts);assert.ok(f.fonts.length,'No rendered fonts');if(/[\u3400-\u9fff]/.test(text.outerHTML))assert.ok(f.fonts.some(font=>/Noto.*CJK.*SC|Source Han|PingFang|Songti|Microsoft YaHei/.test(font.familyName)),'Simplified Chinese font unavailable')}
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,[]);