Compare commits

...

12 Commits

Author SHA1 Message Date
codex-bot 4b24745eb9 Document and verify the high-quality preview HTTP contract
studio CI and deployment / verify-and-deploy (pull_request) Successful in 1m8s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Successful in 18m56s
2026-10-29 00:09:22 +08:00
codex-bot 0360a5165b Measure export image resolution using the selected object fit
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
2026-10-29 00:03:40 +08:00
codex-bot dbc575ced7 Exercise multi-page server rendering and transactional asset validation
studio CI and deployment / verify-and-deploy (pull_request) Successful in 6m12s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 23:53:16 +08:00
codex-bot 6a7d924436 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
2026-10-28 23:51:01 +08:00
codex-bot 04df551f6a Harden series validation and synchronize agent delivery contracts
studio CI and deployment / verify-and-deploy (pull_request) Successful in 6m43s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 23:42:18 +08:00
codex-bot 174a3ef266 Add versioned multi-page Studio artwork documents and native exports
studio CI and deployment / verify-and-deploy (pull_request) Failing after 3m51s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 2m58s
2026-10-28 23:36:54 +08:00
Zayden-Jung d12f9a8dc3 Merge pull request 'Render Studio saved results directly by ID' (#4) from codex/studio-result-page into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Successful in 5m38s
studio CI and deployment / verify-and-deploy (push) Successful in 4m30s
Reviewed-on: #4
2026-10-28 13:43:24 +00:00
codex-bot b550c151bd Preserve the Studio agent documentation response handler
studio CI and deployment / verify-and-deploy (pull_request) Successful in 1m5s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Successful in 17m44s
2026-10-28 21:17:04 +08:00
codex-bot eac2dd4cde Expect ID-only result links for both layout families
studio CI and deployment / verify-and-deploy (pull_request) Successful in 5m43s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 1m39s
2026-10-28 21:07:53 +08:00
codex-bot df831a15ba Distinguish historical toolbox examples from current result flow
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
studio CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 21:05:04 +08:00
codex-bot de30ee4ec8 Render saved Studio results directly by ID without an editor
studio CI and deployment / verify-and-deploy (pull_request) Failing after 3m5s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 21:02:21 +08:00
Zayden-Jung efffcdb1fe Merge pull request 'Fit serif headlines inside vertical slots' (#3) from codex/studio-render-api into main
studio CI and deployment / verify-and-deploy (push) Successful in 3m39s
Reviewed-on: #3
2026-10-28 11:16:27 +00:00
43 changed files with 1012 additions and 45 deletions
+12 -3
View File
@@ -9,6 +9,9 @@ on:
- 'scripts/verify-studio-library.mjs'
- 'scripts/verify-studio-visual.cjs'
- '.gitea/workflows/studio.yml'
- 'scripts/*studio*'
- 'content/studio-examples/**'
- 'content/studio-tools/layouts-api.md'
- 'pnpm-workspace.yaml'
pull_request:
paths:
@@ -18,6 +21,9 @@ on:
- 'scripts/verify-studio-library.mjs'
- 'scripts/verify-studio-visual.cjs'
- '.gitea/workflows/studio.yml'
- 'scripts/*studio*'
- 'content/studio-examples/**'
- 'content/studio-tools/layouts-api.md'
- 'pnpm-workspace.yaml'
workflow_dispatch:
@@ -29,14 +35,17 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check shared content and documentation contracts
run: |
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 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
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
- name: Capture deployed baseline diagnostics (does not validate PR code)
if: gitea.event_name == 'pull_request'
run: bash scripts/verify-studio-production.sh --diagnose
- name: Verify the deployed Studio API and native pages
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
run: bash scripts/verify-studio-production.sh
+1
View File
@@ -10,6 +10,7 @@ RUN pnpm install --offline --frozen-lockfile
COPY apps/studio/ .
# Compare the two public parameter validators inside the isolated CI build.
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /waterfall-wiki/src/lib/layout-parameters.ts
COPY apps/waterfall-wiki/src/lib/series.ts /waterfall-wiki/src/lib/series.ts
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM node:24-bookworm-slim
WORKDIR /app
+163
View File
@@ -0,0 +1,163 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
+16 -4
View File
@@ -1,17 +1,29 @@
/** CI-only isolated runtime checks, never a local preview workflow. */
import {readFileSync} from 'node:fs';
import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises';
const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002';
const standardId='00000000-0000-4000-8000-000000000003';
const seriesId='00000000-0000-4000-8000-000000000004';
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
let reads=0;
const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:designId,name:'已保存的实际页面',layoutId:'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,['.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const library=await fetch(base+'/studio').then(r=>r.text());assert.ok(library.includes('standard-01'));assert.ok(library.includes('ref-27'));const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('&lt;'));}
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('&lt;'));}
assert.equal(reads,0,'Default pages never load a saved design');
const headers={cookie:'session=smoke-test'};
const saved=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+designId+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));
for(const id of [designId,standardId]){
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
}
const complete=await fetch(base+'/studio/template-layout?id='+seriesId,{headers});assert.equal(complete.status,200);const completeHtml=await complete.text(),artwork=completeHtml.match(/<main[\s\S]*?<\/main>/)[0];assert.equal((artwork.match(/data-page-key=/g)||[]).length,3);assert.ok(artwork.includes('data-style-version="autumn-journal/1"'));assert.ok(artwork.includes('把秋天'));assert.ok(artwork.includes('先看见'));assert.ok(artwork.includes('留一点白'));assert.ok(!/button|contenteditable|workbench-top/.test(artwork));
const second=await fetch(base+'/studio/template-layout?id='+seriesId+'&page=2&export=1',{headers});assert.equal(second.status,200);const secondHtml=(await second.text()).match(/<main[\s\S]*?<\/main>/)[0];assert.equal((secondHtml.match(/data-page-key=/g)||[]).length,1);assert.ok(secondHtml.includes('data-page="2"'));assert.ok(secondHtml.includes('height:1440px'));assert.ok(secondHtml.includes('width=2160'));assert.ok(secondHtml.includes('profile=export'));assert.ok(secondHtml.includes('Noto Serif CJK SC'));
for(const suffix of ['0','4','bad'])assert.equal((await fetch(base+'/studio/template-layout?id='+seriesId+'&page='+suffix,{headers})).status,404);
for(const path of ['/studio/template-layout','/studio/template-layout?id=invalid','/studio/template-layout?id='+missingId,'/studio/template-layout?id='+designId+'&id='+standardId])assert.equal((await fetch(base+path,{headers})).status,404);
assert.equal((await fetch(base+'/studio/layouts/ref-02?id='+designId,{headers})).status,404);
assert.equal((await fetch(base+'/studio/layouts/ref-01?id='+missingId,{headers})).status,404);
const beforeInvalid=reads;assert.equal((await fetch(base+'/studio/layouts/ref-01?id=invalid',{headers})).status,404);assert.equal(reads,beforeInvalid);
const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/layouts/ref-01?id='+designId);
const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/template-layout?id='+designId);
const old=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers,redirect:'manual'});assert.equal(old.status,307);assert.equal(old.headers.get('location'),'/studio/template-layout?id='+designId);
assert.equal((await fetch(base+'/studio/layouts/ref-28')).status,404);assert.equal((await fetch(base+'/studio/api/layouts/ref-01',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{I01:{value:'bad'}}})})).status,400);console.log('All 27 native pages, server-loaded saved IDs, legacy redirects and render APIs passed');}finally{child.kill('SIGTERM');await once(child,'exit');await new Promise(r=>wiki.close(r))}
@@ -0,0 +1,28 @@
import Head from 'next/head';
import type {CSSProperties} from 'react';
import type {AddedSlot,Layout,Parameters} from '../lib/layout-parameters';
import {CurvedCopy} from './NativeCurvedCopy';
import {decorationKind,defaultText,emptyParameters,imageShape,paperColor,slotPosition,textColor,textSize,type Design} from '../lib/native-layout';
/** A saved result contains artwork only: no editing state, controls or event handlers. */
export function NativeResult({layout,design}:{layout:Layout;design:Design|null}){
return <main className="native-layout-page"><Head><title>{design?.name||layout.name} · Prompt</title></Head><NativeArtwork layout={layout} design={design}/></main>;
}
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}`,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,"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})`}}/>}
</div>:<div className={'native-decoration decoration-'+((s as AddedSlot).shape?'solid':decorationKind(s))} style={{borderRadius:ls.radius!==undefined?`${ls.radius/layout.width*100}cqw`:(s as AddedSlot).shape==='ellipse'?'50%':undefined,color:d?.color||'#83908a',backgroundColor:(s as AddedSlot).shape?d?.color||'#83908a':decorationKind(s)==='band'?d?.color||'#dce1d8':undefined}}>{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=><i key={i}/>)}{decorationKind(s)==='vertical-lines'&&<><i/><i/><i/></>}</div>}
</div>;
}))}
</article>;
}
@@ -0,0 +1,13 @@
import Head from 'next/head';
import fixed from '../content/templates/layouts.json';
import standard from '../content/standard-layout.json';
import {pageParameters} from '../lib/series';
import type {Design} from '../lib/native-layout';
import {NativeArtwork} from './NativeResult';
export function NativeSeries({design,page,quality}:{design:Design;page:number|null;quality:boolean}){
const doc=design.document!;
return <main className={'native-layout-page native-series'+(quality?' native-export':'')} data-style-version={doc.style.version} data-revision={design.revision}>
<Head><title>{design.name} · {doc.pages.length}页</title><meta name="description" content={doc.brief.intent}/></Head>
{doc.pages.map((p,index)=>{if(page!==null&&page!==index+1)return null;const layout=[standard,...fixed].find(l=>l.id===p.layoutId)!;return <div className="series-page" key={p.key} data-page={index+1} data-page-key={p.key} data-page-role={p.role}><NativeArtwork layout={layout} quality={quality} design={{...design,name:design.name+' · '+(index+1),layoutId:p.layoutId,parameters:pageParameters(doc,p,layout)}}/>{!quality&&<p className="series-page-number">{String(index+1).padStart(2,'0')} / {doc.pages.length}</p>}</div>})}
</main>;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type {IncomingMessage} from 'node:http';
import {series} from './series';
import {parameters,uuid} from './layout-parameters';
import fixedLayouts from '../content/templates/layouts.json';
import standardLayout from '../content/standard-layout.json';
@@ -19,5 +20,5 @@ export async function readDesign(id:unknown,req:IncomingMessage):Promise<Design|
const layout=layouts.find(l=>l.id===design.layoutId);
if(!layout||design.id!==id)throw new Error('Invalid saved layout');
if((design as Design&{archived?:boolean}).archived)return null;
return {...design,parameters:parameters(layout,design.parameters)};
return {...design,...(design.document?{document:series(design.document,layouts)}:{}),parameters:parameters(layout,design.parameters)};
}
+2 -2
View File
@@ -1,11 +1,11 @@
import type {CSSProperties} from 'react';
import type {Layout,Parameters,Slot} from './layout-parameters';
export type Design={id:string;name:string;layoutId:string;parameters:Parameters;revision:number;url:string};
export type Design={document?:import('./series').Series;id:string;name:string;layoutId:string;parameters:Parameters;revision:number;url:string};
// getRandomValues is available on the deployment's HTTP origin; randomUUID is not.
export const newElementId=()=> 'user-'+Array.from(globalThis.crypto.getRandomValues(new Uint32Array(4)),n=>n.toString(16).padStart(8,'0')).join('');
export const emptyParameters=():Parameters=>({texts:{},images:{},decorations:{}});
export const designUrl=(layoutId:string,id:string)=>`/studio/layouts/${layoutId}?id=${id}`;
export const designUrl=(_layoutId:string,id:string)=>`/studio/template-layout?id=${id}`;
export function nativeLayout(layout:Layout):Layout {
return {id:layout.id,name:layout.name,width:layout.width,height:layout.height,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading}))};
}
+42
View File
@@ -0,0 +1,42 @@
import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters';
export type Typography=Required<Pick<TextValue,'fontFamily'|'fontSize'|'fontWeight'|'lineHeight'|'letterSpacing'>>;
export type Series={version:1;brief:{topic:string;audience:string;intent:string;designIntent:string;imageStrategy:string};style:{version:string;palette:{paper:string;ink:string;muted:string;accent:string};typography:Record<'title'|'body'|'caption',Typography>;spacing:{margin:number;gap:number}};sources:{assetId:string;author:string;license:string;url:string;prompt:string;tool:string}[];pages:{key:string;role:'cover'|'body'|'ending';layoutId:string;intent:string;bindings:Record<string,'title'|'body'|'caption'>;parameters:Parameters}[]};
function fail(message:string):never{throw new LayoutParameterError(message)}
const obj=(v:unknown)=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail('作品字段必须是对象');return v as Record<string,unknown>};
function exact(v:unknown,allowed:string[]){const o=obj(v);if(Object.keys(o).some(k=>!allowed.includes(k)))fail('作品包含未知字段');return o}
function str(v:unknown,max=1000){if(typeof v!=='string'||!v.trim()||v.length>max)fail('作品文字缺失或超长');return v as string}
function num(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail('作品数值超出范围');return v as number}
/** Closed, bounded content document. The same validator runs in persistence and SSR. */
export function series(input:unknown,layouts:Layout[]):Series{
const raw=exact(input,['version','brief','style','sources','pages']);if(raw.version!==1)fail('不支持的作品版本');
const brief=exact(raw.brief,['topic','audience','intent','designIntent','imageStrategy']);for(const k of ['topic','audience','intent','designIntent','imageStrategy'])str(brief[k],2000);
const style=exact(raw.style,['version','palette','typography','spacing']);str(style.version,80);
const palette=exact(style.palette,['paper','ink','muted','accent']);for(const k of ['paper','ink','muted','accent'])if(!/^#[a-f\d]{6}$/i.test(String(palette[k])))fail('作品颜色使用 #RRGGBB');
const typography=exact(style.typography,['title','body','caption']);
for(const role of ['title','body','caption']){const t=exact(typography[role],['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(!['sans','serif','mono'].includes(String(t.fontFamily)))fail('无效作品字体');num(t.fontSize,.1,40);num(t.fontWeight,100,900);num(t.lineHeight,.5,4);num(t.letterSpacing,-5,10)}
const spacing=exact(style.spacing,['margin','gap']);num(spacing.margin,4,16);num(spacing.gap,1,10);if(Number(spacing.margin)*2+Number(spacing.gap)*4>48)fail('页边距与间距组合过大');
if(!Array.isArray(raw.sources)||raw.sources.length>100)fail('素材来源最多100条');
const sources=raw.sources.map(v=>{const s=exact(v,['assetId','author','license','url','prompt','tool']);if(!uuid(str(s.assetId,36)))fail('来源需素材 UUID');for(const k of ['author','license','url','prompt','tool'])str(s[k],4000);if(s.url!=='generated'&&!/^https?:\/\//.test(String(s.url)))fail('来源使用 http(s) 地址或 generated');return s as Series['sources'][number]});
if(!Array.isArray(raw.pages)||raw.pages.length<3||raw.pages.length>18)fail('作品需要3–18页');
const seen=new Set<string>();
const pages=raw.pages.map((entry,index)=>{const p=exact(entry,['key','role','layoutId','intent','bindings','parameters']);const key=str(p.key,60);if(!/^[a-z0-9-]+$/.test(key)||seen.has(key))fail('页面 key 必须唯一');seen.add(key);
const role=index===0?'cover':index===(raw.pages as unknown[]).length-1?'ending':'body';if(p.role!==role)fail('页序须为封面、正文、结尾');str(p.intent,1000);const layout=layouts.find(l=>l.id===p.layoutId);if(!layout)fail('作品模板不存在');const resolvedLayout=layout!;const params=parameters(resolvedLayout,p.parameters);const bindings=obj(p.bindings);const slots=[...resolvedLayout.elements,...(params.added||[])];for(const [id,token] of Object.entries(bindings)){if(!slots.some(s=>s.id===id&&s.kind==='text')||!['title','body','caption'].includes(String(token)))fail('字体绑定无效');if(!params.texts[id])fail('绑定文字必须提供文案')}
// Every visible text is authored; template placeholder copy must never leak into a finished series.
for(const s of slots)if(s.kind==='text'&&!params.layers?.[s.id]?.hidden&&!params.texts[s.id])fail('请填写或隐藏所有文字元素');
for(const image of Object.values(params.images))if(image.assetId&&!sources.some(s=>s.assetId===image.assetId))fail('每张图片需记录作者、许可和生成过程');
return {...p,parameters:params,bindings} as Series['pages'][number]});
return {version:1,brief:brief as Series['brief'],style:style as Series['style'],sources,pages};
}
/** Style units use a 120-wide reference canvas; explicit element values always win. */
export function pageParameters(doc:Series,page:Series['pages'][number],layout:Layout):Parameters{
const p=page.parameters,s=doc.style,k=layout.width/120;
const result:Parameters={...p,paper:p.paper||s.palette.paper,texts:{...p.texts},images:{...p.images},decorations:{...p.decorations},layers:{...p.layers}};
for(const [id,token] of Object.entries(page.bindings)){const t=s.typography[token];result.texts[id]={...t,fontSize:t.fontSize*k,letterSpacing:t.letterSpacing*k,color:token==='caption'?s.palette.muted:s.palette.ink,...p.texts[id]}}
for(const slot of [...layout.elements,...(p.added||[])]){if(slot.kind==='image')result.images[slot.id]={color:s.palette.accent,...p.images[slot.id]};if(slot.kind==='decoration')result.decorations[slot.id]={color:s.palette.accent,...p.decorations[slot.id]}}
if(layout.id==='standard-01'){
const m=s.spacing.margin,g=s.spacing.gap,w=120-2*m;
const boxes:Record<string,number[]>={T03:[m,m,w,4],T01:[m,m+4+g,w,28],I01:[m,m+32+2*g,w,50],T02:[m,m+82+3*g,w,27],D01:[m,160-m,w,.4]};
for(const [id,[x,y,width,height]] of Object.entries(boxes))result.layers![id]={x,y,width,height,...p.layers?.[id]};
}
return result;
}
+1 -1
View File
@@ -1,3 +1,3 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {ideas} from '../../content/ideas';
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
+1 -1
View File
@@ -15,7 +15,7 @@ const workspaceSchema={
};
export default async function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Cache-Control','no-store');
if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 扩展编辑参数\n${JSON.stringify(workspaceSchema,null,2)}\n\n# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 standard-01(标准图文)或 ref-01 .. ref-27(固定构图)。standard-01 仅返回原生模板参数,不提供兼容 SVG。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。带 ?id={designId} 在服务端加载已保存页面;ID 与排版不匹配或不存在返回404。右键或编辑页面可调整元素。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开 /studio/layouts/{layoutId}?id={id}(原生页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)}
if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 扩展编辑参数\n${JSON.stringify(workspaceSchema,null,2)}\n\n# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 standard-01(标准图文)或 ref-01 .. ref-27(固定构图)。standard-01 仅返回原生模板参数,不提供兼容 SVG。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。保存结果直接打开 /studio/template-layout?id={designId},服务端根据 ID 读取模板、图片和文字,无需选版或编辑器;缺失或不存在的 ID 返回404。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开返回的 /studio/template-layout?id={id}(纯结果页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)}
if(req.query.id==='standard-01'){
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}
try{const p=parameters(standardLayout,req.method==='POST'?req.body:{});
+2 -2
View File
@@ -5,6 +5,6 @@ export default function handler(req:NextApiRequest,res:NextApiResponse){
if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end()}
const entries=[standard,...fixed].map(l=>({id:l.id,name:l.name,family:l.id==='standard-01'?'standard':'fixed',url:'/studio/layouts/'+l.id,api:'/studio/api/layouts/'+l.id}));
res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')return res.status(200).end();
if(String(req.query.agent_mode).toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');return res.send('# Studio 可视化排版 API 库\n\n'+entries.map(e=>`${e.family} / ${e.id} · ${e.name}\n页面:${e.url}\n参数:${e.api}?agent_mode=True`).join('\n\n')+'\n\nCRUD:/api/studio/designs?agent_mode=True\n统一文档:/explore/8eb8c76500580f7a4b56ba3c')}
return res.json({version:1,layouts:entries,saveApi:'/api/studio/designs',catalogUrl:'/studio',documentationUrl:'/explore/8eb8c76500580f7a4b56ba3c'});
if(String(req.query.agent_mode).toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');return res.send('# Studio 可视化排版 API 库\n\n'+entries.map(e=>`${e.family} / ${e.id} · ${e.name}\n页面:${e.url}\n参数:${e.api}?agent_mode=True`).join('\n\n')+'\n\n多页作品:/api/studio/series?agent_mode=True\n单页 CRUD:/api/studio/designs?agent_mode=True\n统一文档:/explore/8eb8c76500580f7a4b56ba3c')}
return res.json({version:1,seriesDocumentVersion:1,seriesApi:'/api/studio/series',layouts:entries,saveApi:'/api/studio/designs',catalogUrl:'/studio',documentationUrl:'/explore/8eb8c76500580f7a4b56ba3c'});
}
+7 -6
View File
@@ -1,19 +1,20 @@
import type {GetServerSideProps} from 'next';
import {LayoutWorkbench,type LayoutPageProps} from '../../components/LayoutWorkbench';
import {NativeResult} from '../../components/NativeResult';
import type {Layout} from '../../lib/layout-parameters';
import fixedLayouts from '../../content/templates/layouts.json';
import standardLayout from '../../content/standard-layout.json';
const layouts=[...fixedLayouts,standardLayout];
import {nativeLayout} from '../../lib/native-layout';
import {nativeLayout,designUrl} from '../../lib/native-layout';
import {readDesign} from '../../lib/design-server';
export default function LayoutPage(props:LayoutPageProps){return <LayoutWorkbench key={props.design?.id||props.layout.id} {...props}/>}
export const getServerSideProps:GetServerSideProps<LayoutPageProps>=async({params,query,req,res})=>{
export default function LayoutPage({layout}:{layout:Layout}){return <NativeResult layout={layout} design={null}/>}
export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({params,query,req,res})=>{
res.setHeader('Cache-Control','private, no-store');
const layout=layouts.find(l=>l.id===params?.layoutId);
if(!layout)return {notFound:true};
if(query.id!==undefined){
const design=await readDesign(query.id,req);
if(!design||design.layoutId!==layout.id)return {notFound:true};
return {props:{layout:nativeLayout(layout),design}};
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
}
return {props:{layout:nativeLayout(layout),design:null}};
return {props:{layout:nativeLayout(layout)}};
};
+21
View File
@@ -0,0 +1,21 @@
import type {GetServerSideProps} from 'next';
import {NativeSeries} from '../components/NativeSeries';
import {NativeResult} from '../components/NativeResult';
import fixedLayouts from '../content/templates/layouts.json';
import standardLayout from '../content/standard-layout.json';
import {readDesign} from '../lib/design-server';
import {nativeLayout,type Design} from '../lib/native-layout';
import type {Layout} from '../lib/layout-parameters';
type Props={layout:Layout;design:Design;page:number|null;quality:boolean};
export default function TemplateLayout(props:Props){return props.design.document?<NativeSeries design={props.design} page={props.page} quality={props.quality}/>:<NativeResult {...props}/>}
export const getServerSideProps:GetServerSideProps<Props>=async({query,req,res})=>{
res.setHeader('Cache-Control','private, no-store');
const design=await readDesign(query.id,req);
if(!design)return {notFound:true};
const layout=[standardLayout,...fixedLayouts].find(l=>l.id===design.layoutId);
if(!layout)return {notFound:true};
const page=query.page===undefined?null:Number(query.page);
if(page!==null&&(!Number.isInteger(page)||page<1||page>(design.document?.pages.length||1)))return {notFound:true};
return {props:{layout:nativeLayout(layout),design,page,quality:query.export==='1'}};
};
+5
View File
@@ -86,3 +86,8 @@
.native-copy.direction-vertical,.native-copy.direction-rotated{line-height:1}
.ref-19 .native-text:is([data-element=T01],[data-element=T02],[data-element=T03],[data-element=T04]) .native-copy{font-weight:700;letter-spacing:-.06em}
/* Saved series contain artwork and page order only. Export lays out at final pixels. */
.native-series{padding-top:24px}.series-page{margin:0 auto 32px}.series-page-number{text-align:center;font-size:12px;letter-spacing:.14em;color:#68716b;margin:14px 0 0}
.native-export{padding:0;background:transparent;min-height:0}.native-export .series-page{margin:0}.native-export .native-layout{width:1080px;max-width:none;margin:0}
@media print{.native-series{padding:0}.series-page{margin:0;break-after:page}.series-page:last-child{break-after:auto}.series-page-number{display:none}}
+2 -1
View File
@@ -12,7 +12,8 @@ test('geometric templates retain their distinct silhouettes without bitmap masks
assert.equal(imageShape(layouts[4],layouts[4].elements.find(s=>s.id==='I03')).borderRadius,'50%');
assert.ok(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath.startsWith('polygon('));
assert.notEqual(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath,imageShape(layouts[26],layouts[26].elements.find(s=>s.id==='I02')).clipPath);
assert.equal(designUrl('ref-01','abc'),'/studio/layouts/ref-01?id=abc');
assert.equal(designUrl('ref-01','abc'),'/studio/template-layout?id=abc');
assert.equal(designUrl('standard-01','abc'),'/studio/template-layout?id=abc');
});
test('element IDs work on HTTP origins without secure-context randomUUID',()=>{const saved=Object.getOwnPropertyDescriptor(globalThis,'crypto');let calls=0;Object.defineProperty(globalThis,'crypto',{configurable:true,value:{getRandomValues(array){for(let i=0;i<array.length;i++)array[i]=++calls;return array}}});try{const first=newElementId(),second=newElementId();assert.match(first,/^user-[a-f0-9]{32}$/);assert.notEqual(first,second)}finally{Object.defineProperty(globalThis,'crypto',saved)}});
+12
View File
@@ -0,0 +1,12 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {registerHooks} from 'node:module';
registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}});
const {series,pageParameters}=await import('../src/lib/series.ts');
const layout=JSON.parse(readFileSync(new URL('../src/content/standard-layout.json',import.meta.url)));
// Fixture copied into the CI image; no build is performed by these tests.
const input=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url)));
test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')});
test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}});
test('persistence and renderer use byte-identical series contracts',()=>assert.equal(readFileSync(new URL('../src/lib/series.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/series.ts',import.meta.url),'utf8')));
@@ -0,0 +1,5 @@
BEGIN;
-- The cover continues to use the existing design/post identity and archival lifecycle.
ALTER TABLE studio_designs ADD COLUMN IF NOT EXISTS document jsonb CHECK(document IS NULL OR jsonb_typeof(document)='object');
INSERT INTO schema_migrations(version) VALUES('013_studio_series') ON CONFLICT DO NOTHING;
COMMIT;
@@ -0,0 +1,163 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
@@ -68,7 +68,7 @@ export const apiDocuments:Record<string,readonly string[]>={
"/api/preview": [
"轻量图片预览",
"GET, HEAD",
"查询参数:source(必填,本站静态栅格图片路径,或 /api/media/<UUID>,可带 ?variant=poster;作为查询值时必须 URL 编码);width(可选,240 / 480 / 960 / 1280,默认 480)。不接受任意远程图片地址。支持 If-None-Match。",
"查询参数:source(必填,本站静态栅格图片路径,或 /api/media/<UUID>,可带 ?variant=poster;作为查询值时必须 URL 编码);width(可选,240 / 480 / 960 / 1280,默认 480)。高清导出使用 profile=export 且 width=2160:WebP 质量95,不按常规缩略图预算缩小,不放大小原件;其他 profile 或尺寸组合返回400。不接受任意远程图片地址。支持 If-None-Match。",
"WebP 图片;包含 ETag;缓存命中可返回 304;HEAD 无正文。",
"GET /api/preview?source=%2Fapi%2Fmedia%2F<UUID>&width=480",
"400:参数不合法;404:图片不存在;503:预览暂不可用。"
+2 -2
View File
@@ -4,11 +4,11 @@ import {mkdir,readFile,writeFile,rename,stat,readdir,unlink,realpath} from 'node
import {resolve,sep,join} from 'node:path';
import {tmpdir} from 'node:os';
export const previewWidths=[240,480,960,1280];
export function previewWidth(value:unknown){const n=Number(value||480);return previewWidths.includes(n)?n:null;}
export function previewWidth(value:unknown,profile?:unknown){const n=Number(value||480);return profile==='export'?(n===2160?n:null):profile===undefined&&previewWidths.includes(n)?n:null;}
export function staticPreviewPath(source:string,root:string){if(!source.startsWith('/')||source.startsWith('//')||source.includes('\\')||!/^\/(?:[^?]+)\.(png|jpe?g|webp)$/i.test(source))throw Error('Invalid source');const file=resolve(root,'.'+source);if(!file.startsWith(resolve(root)+sep))throw Error('Invalid source');return file;}
let active=0;const waiting:(()=>void)[]=[];
async function limited<T>(work:()=>Promise<T>){if(active>=3){if(waiting.length>=24)throw Error('Preview busy');await new Promise<void>(done=>waiting.push(done));}else active++;try{return await work()}finally{const next=waiting.shift();if(next)next();else active--;}}
const pending=new Map<string,Promise<Buffer>>();const cache=join(tmpdir(),'prompt-previews-v1');let writes=0;
export async function compressPreview(input:Buffer,width:number){const max=width<=480?100_000:220_000;let size=width,quality=72;let output:Buffer;do{output=await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width:size,withoutEnlargement:true}).webp({quality,effort:3}).toBuffer();if(output.length<=max)return output;size=Math.max(240,Math.floor(size*.8));quality=Math.max(30,quality-10);}while(size>240||quality>30);return sharp(input,{limitInputPixels:40_000_000}).rotate().resize({width:240,withoutEnlargement:true}).webp({quality:30}).toBuffer();}
export async function cachedPreview(identity:string,width:number,load:()=>Promise<Buffer>){const key=createHash('sha256').update(identity+':'+width+':webp-v1').digest('hex');const file=join(cache,key+'.webp');try{return {bytes:await readFile(file),etag:'"'+key+'"'};}catch{/* cache miss */}let task=pending.get(key);if(!task){task=limited(async()=>{await mkdir(cache,{recursive:true});const bytes=await compressPreview(await load(),width);const temp=file+'.'+process.pid+'.tmp';await writeFile(temp,bytes);await rename(temp,file);if(++writes%32===0){const files=await readdir(cache);if(files.length>1024){const dates=await Promise.all(files.map(async name=>({name,time:(await stat(join(cache,name)).catch(()=>({mtimeMs:0}))).mtimeMs})));await Promise.all(dates.sort((a,b)=>a.time-b.time).slice(0,files.length-1024).map(f=>unlink(join(cache,f.name)).catch(()=>{})));}}return bytes;});pending.set(key,task);}try{return {bytes:await task,etag:'"'+key+'"'};}finally{pending.delete(key);}}
export async function cachedPreview(identity:string,width:number,load:()=>Promise<Buffer>,quality=false){const key=createHash('sha256').update(identity+':'+width+(quality?':export-v1':':webp-v1')).digest('hex');const file=join(cache,key+'.webp');try{return {bytes:await readFile(file),etag:'"'+key+'"'};}catch{/* cache miss */}let task=pending.get(key);if(!task){task=limited(async()=>{await mkdir(cache,{recursive:true});const input=await load();const bytes=quality?await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width,withoutEnlargement:true}).webp({quality:95,effort:4}).toBuffer():await compressPreview(input,width);const temp=file+'.'+process.pid+'.tmp';await writeFile(temp,bytes);await rename(temp,file);if(++writes%32===0){const files=await readdir(cache);if(files.length>1024){const dates=await Promise.all(files.map(async name=>({name,time:(await stat(join(cache,name)).catch(()=>({mtimeMs:0}))).mtimeMs})));await Promise.all(dates.sort((a,b)=>a.time-b.time).slice(0,files.length-1024).map(f=>unlink(join(cache,f.name)).catch(()=>{})));}}return bytes;});pending.set(key,task);}try{return {bytes:await task,etag:'"'+key+'"'};}finally{pending.delete(key);}}
export async function loadStaticPreview(source:string){const root=await realpath(join(process.cwd(),'public'));const file=await realpath(staticPreviewPath(source,root));if(!file.startsWith(root+sep))throw Error('Invalid source');const info=await stat(file);if(info.size>64*1024*1024)throw Error('Image too large');return {identity:file+':'+info.mtimeMs,load:()=>readFile(file)};}
+42
View File
@@ -0,0 +1,42 @@
import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters';
export type Typography=Required<Pick<TextValue,'fontFamily'|'fontSize'|'fontWeight'|'lineHeight'|'letterSpacing'>>;
export type Series={version:1;brief:{topic:string;audience:string;intent:string;designIntent:string;imageStrategy:string};style:{version:string;palette:{paper:string;ink:string;muted:string;accent:string};typography:Record<'title'|'body'|'caption',Typography>;spacing:{margin:number;gap:number}};sources:{assetId:string;author:string;license:string;url:string;prompt:string;tool:string}[];pages:{key:string;role:'cover'|'body'|'ending';layoutId:string;intent:string;bindings:Record<string,'title'|'body'|'caption'>;parameters:Parameters}[]};
function fail(message:string):never{throw new LayoutParameterError(message)}
const obj=(v:unknown)=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail('作品字段必须是对象');return v as Record<string,unknown>};
function exact(v:unknown,allowed:string[]){const o=obj(v);if(Object.keys(o).some(k=>!allowed.includes(k)))fail('作品包含未知字段');return o}
function str(v:unknown,max=1000){if(typeof v!=='string'||!v.trim()||v.length>max)fail('作品文字缺失或超长');return v as string}
function num(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail('作品数值超出范围');return v as number}
/** Closed, bounded content document. The same validator runs in persistence and SSR. */
export function series(input:unknown,layouts:Layout[]):Series{
const raw=exact(input,['version','brief','style','sources','pages']);if(raw.version!==1)fail('不支持的作品版本');
const brief=exact(raw.brief,['topic','audience','intent','designIntent','imageStrategy']);for(const k of ['topic','audience','intent','designIntent','imageStrategy'])str(brief[k],2000);
const style=exact(raw.style,['version','palette','typography','spacing']);str(style.version,80);
const palette=exact(style.palette,['paper','ink','muted','accent']);for(const k of ['paper','ink','muted','accent'])if(!/^#[a-f\d]{6}$/i.test(String(palette[k])))fail('作品颜色使用 #RRGGBB');
const typography=exact(style.typography,['title','body','caption']);
for(const role of ['title','body','caption']){const t=exact(typography[role],['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(!['sans','serif','mono'].includes(String(t.fontFamily)))fail('无效作品字体');num(t.fontSize,.1,40);num(t.fontWeight,100,900);num(t.lineHeight,.5,4);num(t.letterSpacing,-5,10)}
const spacing=exact(style.spacing,['margin','gap']);num(spacing.margin,4,16);num(spacing.gap,1,10);if(Number(spacing.margin)*2+Number(spacing.gap)*4>48)fail('页边距与间距组合过大');
if(!Array.isArray(raw.sources)||raw.sources.length>100)fail('素材来源最多100条');
const sources=raw.sources.map(v=>{const s=exact(v,['assetId','author','license','url','prompt','tool']);if(!uuid(str(s.assetId,36)))fail('来源需素材 UUID');for(const k of ['author','license','url','prompt','tool'])str(s[k],4000);if(s.url!=='generated'&&!/^https?:\/\//.test(String(s.url)))fail('来源使用 http(s) 地址或 generated');return s as Series['sources'][number]});
if(!Array.isArray(raw.pages)||raw.pages.length<3||raw.pages.length>18)fail('作品需要3–18页');
const seen=new Set<string>();
const pages=raw.pages.map((entry,index)=>{const p=exact(entry,['key','role','layoutId','intent','bindings','parameters']);const key=str(p.key,60);if(!/^[a-z0-9-]+$/.test(key)||seen.has(key))fail('页面 key 必须唯一');seen.add(key);
const role=index===0?'cover':index===(raw.pages as unknown[]).length-1?'ending':'body';if(p.role!==role)fail('页序须为封面、正文、结尾');str(p.intent,1000);const layout=layouts.find(l=>l.id===p.layoutId);if(!layout)fail('作品模板不存在');const resolvedLayout=layout!;const params=parameters(resolvedLayout,p.parameters);const bindings=obj(p.bindings);const slots=[...resolvedLayout.elements,...(params.added||[])];for(const [id,token] of Object.entries(bindings)){if(!slots.some(s=>s.id===id&&s.kind==='text')||!['title','body','caption'].includes(String(token)))fail('字体绑定无效');if(!params.texts[id])fail('绑定文字必须提供文案')}
// Every visible text is authored; template placeholder copy must never leak into a finished series.
for(const s of slots)if(s.kind==='text'&&!params.layers?.[s.id]?.hidden&&!params.texts[s.id])fail('请填写或隐藏所有文字元素');
for(const image of Object.values(params.images))if(image.assetId&&!sources.some(s=>s.assetId===image.assetId))fail('每张图片需记录作者、许可和生成过程');
return {...p,parameters:params,bindings} as Series['pages'][number]});
return {version:1,brief:brief as Series['brief'],style:style as Series['style'],sources,pages};
}
/** Style units use a 120-wide reference canvas; explicit element values always win. */
export function pageParameters(doc:Series,page:Series['pages'][number],layout:Layout):Parameters{
const p=page.parameters,s=doc.style,k=layout.width/120;
const result:Parameters={...p,paper:p.paper||s.palette.paper,texts:{...p.texts},images:{...p.images},decorations:{...p.decorations},layers:{...p.layers}};
for(const [id,token] of Object.entries(page.bindings)){const t=s.typography[token];result.texts[id]={...t,fontSize:t.fontSize*k,letterSpacing:t.letterSpacing*k,color:token==='caption'?s.palette.muted:s.palette.ink,...p.texts[id]}}
for(const slot of [...layout.elements,...(p.added||[])]){if(slot.kind==='image')result.images[slot.id]={color:s.palette.accent,...p.images[slot.id]};if(slot.kind==='decoration')result.decorations[slot.id]={color:s.palette.accent,...p.decorations[slot.id]}}
if(layout.id==='standard-01'){
const m=s.spacing.margin,g=s.spacing.gap,w=120-2*m;
const boxes:Record<string,number[]>={T03:[m,m,w,4],T01:[m,m+4+g,w,28],I01:[m,m+32+2*g,w,50],T02:[m,m+82+3*g,w,27],D01:[m,160-m,w,.4]};
for(const [id,[x,y,width,height]] of Object.entries(boxes))result.layers![id]={x,y,width,height,...p.layers?.[id]};
}
return result;
}
File diff suppressed because one or more lines are too long
@@ -2,9 +2,9 @@ import {randomUUID} from 'node:crypto';
import type {PoolClient} from 'pg';
import type {Parameters} from './layout-parameters';
export type DesignRow={id:string;layout_id:string;name:string;parameters:Parameters;revision:number;post_id:string|null;archived?:boolean;created_at:Date;updated_at:Date};
export const studioPageUrl=(design:Pick<DesignRow,'id'|'layout_id'>)=>`/studio/layouts/${design.layout_id}?id=${design.id}`;
function postDescription(design:DesignRow){return `已保存的 ${design.layout_id} 排版页面,可直接打开并继续编辑。`}
export type DesignRow={document?:import('./series').Series;id:string;layout_id:string;name:string;parameters:Parameters;revision:number;post_id:string|null;archived?:boolean;created_at:Date;updated_at:Date};
export const studioPageUrl=(design:Pick<DesignRow,'id'|'layout_id'>)=>`/studio/template-layout?id=${design.id}`;
function postDescription(design:DesignRow){if(design.document)return `${design.document.pages.length}页图文作品 · ${design.document.brief.intent}`;return `已保存的 ${design.layout_id} 排版结果,打开链接即显示图片与文字。`}
/** Caller holds the design row lock. All writes share the design's transaction. */
export async function ensureDesignPost(client:PoolClient,design:DesignRow):Promise<DesignRow>{
if(design.post_id){
@@ -12,9 +12,9 @@ export async function ensureDesignPost(client:PoolClient,design:DesignRow):Promi
await client.query('UPDATE posts SET title=$2,description=$3,updated_at=now() WHERE id=$1 AND edited_at IS NULL',[design.post_id,design.name,postDescription(design)]);
return design;
}
const postId=randomUUID(),textId=randomUUID(),imageIds=[...new Set(Object.values(design.parameters.images||{}).flatMap(image=>image.assetId?[image.assetId]:[]))];
const postId=randomUUID(),textId=randomUUID(),imageIds=[...new Set((design.document?design.document.pages.flatMap(p=>Object.values(p.parameters.images)):Object.values(design.parameters.images||{})).flatMap(image=>image.assetId?[image.assetId]:[]))];
const images=imageIds.length?(await client.query("SELECT id FROM assets WHERE id=ANY($1::uuid[]) AND kind='image'",[imageIds])).rows.map(row=>row.id as string):[];
const body=`[打开页面并继续编辑](${studioPageUrl(design)})\n\n排版:${design.layout_id}\n\n页面 ID:\`${design.id}\`\n\n此笔记关联独立保存的页面;页面内容以链接中的保存记录为准。`;
const body=(design.document?`作品:${design.document.brief.topic}\n\n设计:${design.document.brief.designIntent}\n\n${design.document.pages.map((p,i)=>`${i+1}. ${p.role} · ${p.intent}`).join('\n')}\n\n`:'')+`[打开排版结果](${studioPageUrl(design)})\n\n排版:${design.layout_id}\n\n页面 ID:\`${design.id}\`\n\n此笔记关联 API 保存的模板、图片与文字;直接打开链接即可看到结果,无需选择排版或进入编辑器。`;
await client.query("INSERT INTO posts(id,slug,kind,title,description,cover_asset_id) VALUES($1,$2,'article',$3,$4,$5)",[postId,'studio-design-'+design.id,design.name,postDescription(design),images[0]||null]);
await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[textId,body]);
await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,0,'caption')",[postId,textId]);
@@ -10,7 +10,7 @@ import {ensureDesignPost,studioPageUrl,type DesignRow} from './studio-design-pos
export const designInput=z.object({schemaVersion:z.literal(1).optional(),layoutId:z.string(),name:z.string().trim().min(1).max(120),parameters:z.unknown()}).strict();
export const updateDesignInput=designInput.omit({layoutId:true}).extend({revision:z.number().int().positive()}).strict();
export class DesignConflict extends Error{}
export function designOutput(r:Record<string,unknown>){return {id:r.id,layoutId:r.layout_id,name:r.name,parameters:r.parameters,revision:r.revision,schemaVersion:1,family:r.layout_id==='standard-01'?'standard':'fixed',archived:r.archived||false,archivedAt:r.archived_at||null,createdAt:r.created_at,updatedAt:r.updated_at,url:studioPageUrl(r as DesignRow),schemaUrl:'/studio/api/layouts/'+r.layout_id,postId:r.post_id||null,postUrl:r.post_id?'/explore/'+r.post_id:null,renderUrl:studioPageUrl(r as DesignRow),legacySvgUrl:r.layout_id==='standard-01'?null:'/studio/api/layouts/'+r.layout_id+'?format=svg'};}
export function designOutput(r:Record<string,unknown>){return {id:r.id,layoutId:r.layout_id,name:r.name,parameters:r.parameters,revision:r.revision,schemaVersion:r.document?2:1,...(r.document?{document:r.document}:{}),family:r.layout_id==='standard-01'?'standard':'fixed',archived:r.archived||false,archivedAt:r.archived_at||null,createdAt:r.created_at,updatedAt:r.updated_at,url:studioPageUrl(r as DesignRow),schemaUrl:'/studio/api/layouts/'+r.layout_id,postId:r.post_id||null,postUrl:r.post_id?'/explore/'+r.post_id:null,renderUrl:studioPageUrl(r as DesignRow),legacySvgUrl:r.layout_id==='standard-01'?null:'/studio/api/layouts/'+r.layout_id+'?format=svg'};}
export async function validateDesign(layoutId:string,raw:unknown,client?:PoolClient){const layout=layouts.find(l=>l.id===layoutId);if(!layout)throw new LayoutParameterError('排版不存在');const p=parameters(layout,raw),images=[...new Set(Object.values(p.images).flatMap(i=>i.assetId?[i.assetId]:[]))];if(images.length){const found=await (client||db()).query("SELECT id FROM assets WHERE kind='image' AND id=ANY($1::uuid[])",[images]);if(found.rowCount!==images.length)throw new LayoutParameterError('引用的图片素材不存在')}return p;}
export async function createDesign(input:z.infer<typeof designInput>){
const p=await validateDesign(input.layoutId,input.parameters);
@@ -23,6 +23,7 @@ export async function updateDesign(id:string,input:z.infer<typeof updateDesignIn
return transaction(async client=>{
const old=(await client.query('SELECT * FROM studio_designs WHERE id=$1 FOR UPDATE',[id])).rows[0] as DesignRow|undefined;
if(!old)return null;
if((old as DesignRow&{document?:unknown}).document)throw new DesignConflict('多页作品请使用 /api/studio/series/{id} 更新');
if(old.archived)throw new DesignConflict('页面已归档,请先恢复');
if(old.revision!==input.revision)throw new DesignConflict('效果已被修改,请重新读取后再保存');
const p=await validateDesign(old.layout_id,input.parameters,client);
@@ -0,0 +1,25 @@
import {randomUUID} from 'node:crypto';
import {z} from 'zod';
import {db,transaction} from './db';
import {series,pageParameters} from './series';
import layouts from '../content/studio-layouts.json';
import standard from '../content/standard-layout.json';
import {validateDesign,designOutput,DesignConflict} from './studio-designs';
import {ensureDesignPost} from './studio-design-posts';
const all=[...layouts,standard];
export const seriesInput=z.object({schemaVersion:z.literal(2),name:z.string().trim().min(1).max(120),document:z.unknown()}).strict();
export const seriesUpdate=seriesInput.extend({revision:z.number().int().positive()});
export async function saveSeries(input:z.infer<typeof seriesInput>,id?:string,revision?:number){
const document=series(input.document,all);
return transaction(async client=>{
const old=id?(await client.query('SELECT * FROM studio_designs WHERE id=$1 FOR UPDATE',[id])).rows[0]:null;
if(id&&!old)return null;
if(old&&(!old.document||old.archived||old.revision!==revision))throw new DesignConflict('作品类型、归档状态或修订号冲突');
for(const page of document.pages)await validateDesign(page.layoutId,page.parameters,client);
const cover=document.pages[0],layout=all.find(l=>l.id===cover.layoutId)!;
const p=pageParameters(document,cover,layout);
const row=id?(await client.query('UPDATE studio_designs SET name=$2,layout_id=$3,parameters=$4,document=$5,revision=revision+1,updated_at=now() WHERE id=$1 RETURNING *',[id,input.name,cover.layoutId,JSON.stringify(p),JSON.stringify(document)])).rows[0]:(await client.query('INSERT INTO studio_designs(id,name,layout_id,parameters,document) VALUES($1,$2,$3,$4,$5) RETURNING *',[randomUUID(),input.name,cover.layoutId,JSON.stringify(p),JSON.stringify(document)])).rows[0];
return designOutput(await ensureDesignPost(client,row));
});
}
export async function readSeries(id:string){const row=(await db().query('SELECT * FROM studio_designs WHERE id=$1 AND document IS NOT NULL',[id])).rows[0];return row?designOutput(row):null}
@@ -6,6 +6,9 @@ export default function handler(req:NextApiRequest,res:NextApiResponse){
{path:'/studio/api/export/{slug}',title:'封面渲染与方案导出',methods:'GET, POST',documentation:'/studio/api/export/{slug}?agent_mode=True'},
{path:'/studio/api/layouts',title:'标准与27套固定排版目录',methods:'GET',documentation:'/studio/api/layouts?agent_mode=True'},
{path:'/studio/api/layouts/{id}',title:'排版参数与 SVG 渲染',methods:'GET, POST',documentation:'/studio/api/layouts/{id}?agent_mode=True'},
{path:'/api/studio/series',title:'原子保存多页图文作品',methods:'POST',documentation:'/api/studio/series?agent_mode=True'},
{path:'/api/studio/series/{id}/export',title:'多页原生高清导出计划',methods:'GET',documentation:'/api/studio/series/{id}/export?agent_mode=True'},
{path:'/api/studio/series/{id}',title:'读取、修订与归档多页作品',methods:'GET, PATCH, DELETE, POST',documentation:'/api/studio/series/{id}?agent_mode=True'},
{path:'/api/studio/designs',title:'保存与列举 Studio 效果',methods:'GET, POST',documentation:'/api/studio/designs?agent_mode=True'},
{path:'/api/studio/designs/{id}',title:'读取、更新与可恢复删除页面',methods:'GET, PATCH, DELETE, POST',documentation:'/api/studio/designs/{id}?agent_mode=True'},
{path:'/api/studio/designs/{id}/post',title:'为旧页面补建 Wiki 入口',methods:'POST',documentation:'/api/studio/designs/{id}/post?agent_mode=True'},
+1 -1
View File
@@ -4,5 +4,5 @@ import {db} from '@/lib/db';
import {getObject} from '@/lib/storage';
import {previewWidth,cachedPreview,loadStaticPreview} from '@/lib/preview';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/preview'))return;if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','private, no-store');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
if(serveApiDocumentation(req,res,'/api/preview'))return;if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width,req.query.profile);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load,req.query.profile==='export');res.setHeader('ETag',etag);res.setHeader('Cache-Control','private, no-store');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
export const config={api:{responseLimit:false}};
@@ -0,0 +1,21 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {z} from 'zod';
import {sameOrigin,apiError} from '@/lib/api';
import {seriesUpdate,saveSeries,readSeries} from '@/lib/studio-series';
import {archiveDesign,archiveDesignInput,DesignConflict} from '@/lib/studio-designs';
import {LayoutParameterError} from '@/lib/layout-parameters';
import {studioDesignDocumentation} from '@/lib/studio-design-documentation';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(studioDesignDocumentation(req,res))return;res.setHeader('Cache-Control','no-store');
try{
const id=z.uuid().parse(req.query.id);if(!['GET','PATCH','DELETE','POST'].includes(req.method||'')){res.setHeader('Allow','GET, PATCH, DELETE, POST');return res.status(405).end()}
if(!await readSeries(id))return res.status(404).json({error:'作品不存在'});
if(req.method==='GET')return res.json({design:await readSeries(id)});
sameOrigin(req);
if(req.method==='PATCH'){const input=seriesUpdate.parse(req.body);return res.json({design:await saveSeries(input,id,input.revision)})}
const input=(req.method==='DELETE'?archiveDesignInput.omit({archived:true}):archiveDesignInput.required()).parse(req.body);
if(req.method==='POST'&&(!('archived' in input)||input.archived!==false))return res.status(400).json({error:'恢复需 archived:false'});
return res.json({design:await archiveDesign(id,input.revision,req.method==='DELETE')});
}catch(e){if(e instanceof LayoutParameterError)return res.status(400).json({error:e.message});if(e instanceof DesignConflict)return res.status(409).json({error:e.message});return apiError(res,e)}
}
export const config={api:{bodyParser:{sizeLimit:'512kb'}}};
@@ -0,0 +1,13 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {z} from 'zod';
import {apiError} from '@/lib/api';
import {readSeries} from '@/lib/studio-series';
import type {Series} from '@/lib/series';
import fixed from '@/content/studio-layouts.json';
import standard from '@/content/standard-layout.json';
import {studioDesignDocumentation} from '@/lib/studio-design-documentation';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(studioDesignDocumentation(req,res))return;res.setHeader('Cache-Control','no-store');
if(req.method!=='GET'){res.setHeader('Allow','GET');return res.status(405).end()}
try{const id=z.uuid().parse(req.query.id),d=await readSeries(id);if(!d||d.archived)return res.status(404).json({error:'作品不存在'});const doc=d.document as Series;return res.json({version:1,id,revision:d.revision,name:d.name,format:'png',pages:doc.pages.map((p,i)=>{const l=[standard,...fixed].find(l=>l.id===p.layoutId)!;return {number:i+1,key:p.key,role:p.role,width:1080,height:Math.round(1080*l.height/l.width),url:d.url+'&page='+(i+1)+'&export=1',filename:String(i+1).padStart(2,'0')+'-'+p.key+'.png'}})})}catch(e){return apiError(res,e)}
}
@@ -0,0 +1,11 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {sameOrigin,apiError} from '@/lib/api';
import {seriesInput,saveSeries} from '@/lib/studio-series';
import {LayoutParameterError} from '@/lib/layout-parameters';
import {studioDesignDocumentation} from '@/lib/studio-design-documentation';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(studioDesignDocumentation(req,res))return;res.setHeader('Cache-Control','no-store');
if(req.method!=='POST'){res.setHeader('Allow','POST');return res.status(405).end()}
try{sameOrigin(req);return res.status(201).json({design:await saveSeries(seriesInput.parse(req.body))})}catch(e){if(e instanceof LayoutParameterError)return res.status(400).json({error:e.message});return apiError(res,e)}
}
export const config={api:{bodyParser:{sizeLimit:'512kb'}}};
+18 -1
View File
@@ -11,6 +11,7 @@ import {randomUUID} from 'node:crypto';
import sharp from 'sharp';
import {db} from '../src/lib/db';
import {probe,run} from '../src/lib/probe';
import seriesExample from '../fixtures/autumn-journal.json';
import {verifyFlatNoteMigration} from './flat-note-migration';
import {staticEntries} from '../src/lib/explore';
import {publishLearningSeries} from '../src/lib/publish-learning-series';
@@ -41,6 +42,8 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
await db().query("INSERT INTO contribution_events(id,kind,subject_type,amount) VALUES($1,'test.import','asset',2),($2,'test.edit','post',3)",[randomUUID(),randomUUID()]);assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5);
const img=await fetch(base+'/api/media/'+first.assets[0],{headers:{Range:'bytes=0-9'}});assert.equal(img.status,206);assert.equal((await img.arrayBuffer()).byteLength,10);
const lightweight=await fetch(base+'/api/preview?source='+encodeURIComponent('/api/media/'+first.assets[0])+'&width=480');assert.equal(lightweight.status,200);assert.match(lightweight.headers.get('content-type')||'',/image\/webp/);assert.ok((await lightweight.arrayBuffer()).byteLength<=100000);
const highQuality=await fetch(base+'/api/preview?source='+encodeURIComponent('/api/media/'+first.assets[0])+'&width=2160&profile=export');assert.equal(highQuality.status,200);assert.match(highQuality.headers.get('content-type')||'',/image\/webp/);const exportMeta=await sharp(Buffer.from(await highQuality.arrayBuffer())).metadata();assert.equal(exportMeta.width,160);assert.equal(exportMeta.height,220);
assert.equal((await fetch(base+'/api/preview?source='+encodeURIComponent('/api/media/'+first.assets[0])+'&width=2160')).status,400);
const textEntry=await fetch(base+'/explore/'+reused.id+'?agent_mode=True');assert.equal(textEntry.status,200);assert.match(textEntry.headers.get('content-type')||'',/text\/plain/);assert.ok((await textEntry.text()).includes(first.assets[0]));
assert.equal((await fetch(base+'/images/lines/missing')).status,404);assert.equal((await fetch(base+'/p/'+reused.slug)).status,200);
for(const sound of [true,false]){
@@ -59,7 +62,21 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
for(const removed of ['/library/assets','/library/tags'])assert.equal((await fetch(base+removed)).status,404);
assert.equal((await db().query("SELECT 1 FROM schema_migrations WHERE version='009_studio_designs'")).rowCount,1,'Studio migration must be recorded for repeat deployments');
// Studio owns no second database: designs use this same isolated PostgreSQL.
for(let i=1;i<=27;i++){const layoutId='ref-'+String(i).padStart(2,'0'),saved=await request('/api/studio/designs',{layoutId,name:'布局 '+i,parameters:{}});assert.equal(saved.design.layoutId,layoutId);assert.equal(saved.design.revision,1);assert.ok(saved.design.postId);assert.equal(saved.design.postUrl,'/explore/'+saved.design.postId);assert.equal(saved.design.url,'/studio/layouts/'+layoutId+'?id='+saved.design.id);const wiki=await request('/api/notes/'+saved.design.postId);assert.ok(wiki.entry.editableMarkdown.includes(saved.design.url));const read=await request('/api/studio/designs/'+saved.design.id);assert.deepEqual(read.design.parameters,{texts:{},images:{},decorations:{}});}
for(let i=1;i<=27;i++){const layoutId='ref-'+String(i).padStart(2,'0'),saved=await request('/api/studio/designs',{layoutId,name:'布局 '+i,parameters:{}});assert.equal(saved.design.layoutId,layoutId);assert.equal(saved.design.revision,1);assert.ok(saved.design.postId);assert.equal(saved.design.postUrl,'/explore/'+saved.design.postId);assert.equal(saved.design.url,'/studio/template-layout?id='+saved.design.id);const wiki=await request('/api/notes/'+saved.design.postId);assert.ok(wiki.entry.editableMarkdown.includes(saved.design.url));const read=await request('/api/studio/designs/'+saved.design.id);assert.deepEqual(read.design.parameters,{texts:{},images:{},decorations:{}});}
// Exercise actual HTTP + PostgreSQL transactions, not a mocked persistence layer.
const seriesBody=structuredClone(seriesExample);
const group=await request('/api/studio/series',seriesBody);assert.equal(group.design.schemaVersion,2);assert.equal(group.design.document.pages.length,3);assert.ok(group.design.postId);
const seriesId=group.design.id;
const exportPlan=await request('/api/studio/series/'+seriesId+'/export');assert.equal(exportPlan.pages.length,3);assert.equal(exportPlan.pages[0].height,1440);
const patchSeries={...seriesBody,revision:1,name:'Updated series'};
const edits=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/series/'+seriesId,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patchSeries)})));assert.deepEqual(edits.map(r=>r.status).sort(),[200,409]);
const badSeries=structuredClone(seriesBody);const absentAsset=randomUUID();Object.assign(badSeries.document.pages[1].parameters.images.I01,{assetId:absentAsset});(badSeries.document.sources as unknown[]).push({assetId:absentAsset,author:'CI',license:'test',url:'generated',prompt:'fixture',tool:'test'});
const countBefore=(await db().query('SELECT count(*) FROM studio_designs')).rows[0].count;
assert.equal((await fetch(base+'/api/studio/series',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(badSeries)})).status,400);assert.equal((await db().query('SELECT count(*) FROM studio_designs')).rows[0].count,countBefore);
assert.equal((await fetch(base+'/api/studio/designs/'+seriesId,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'wrong API',revision:2,parameters:{}})})).status,409);
assert.equal((await fetch(base+'/api/studio/series/'+seriesId,{method:'DELETE',headers:{'Content-Type':'application/json'},body:'{"revision":2}'})).status,200);
assert.equal((await fetch(base+'/api/studio/series/'+seriesId+'/export')).status,404);
assert.equal((await request('/api/studio/series/'+seriesId,{revision:3,archived:false})).design.revision,4);
const design=await request('/api/studio/designs',{layoutId:'ref-01',name:'可修改效果',parameters:{texts:{T01:{value:'新的标题'}},images:{I01:{assetId:first.assets[0],fit:'contain'}}}});
const designEdit={name:'更新效果',parameters:{texts:{T01:{value:'修改后'}}},revision:design.design.revision};
const patches=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/designs/'+design.design.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(designEdit)})));assert.deepEqual(patches.map(r=>r.status).sort(),[200,409]);
@@ -5,3 +5,4 @@ import {compressPreview,previewWidth,staticPreviewPath,cachedPreview} from '../s
test('preview input validation rejects traversal and arbitrary sizes',()=>{for(const value of [1,10000,'bad'])assert.equal(previewWidth(value),null);assert.equal(previewWidth(480),480);for(const source of ['https://example.com/x.png','/../secret.png','//remote/x.png','/x.svg','/x.png?foo=bar'])assert.throws(()=>staticPreviewPath(source,'/public'));assert.equal(staticPreviewPath('/images/a.png','/public'),'/public/images/a.png');});
test('large PNG becomes a bounded WebP with no upscaling',async()=>{const original=await sharp({create:{width:2200,height:3000,channels:3,background:'#f0e2d2'}}).png().toBuffer();const preview=await compressPreview(original,480);const info=await sharp(preview).metadata();assert.equal(info.format,'webp');assert.equal(info.width,480);assert.ok(preview.length<=100000);});
test('identical concurrent requests share one transform and reuse the disk result',async()=>{let loads=0;const input=await sharp({create:{width:40,height:40,channels:3,background:'#fff'}}).png().toBuffer();const identity='test-'+Date.now();const load=async()=>{loads++;return input};const [a,b]=await Promise.all([cachedPreview(identity,480,load),cachedPreview(identity,480,load)]);assert.deepEqual(a.bytes,b.bytes);assert.equal(loads,1);await cachedPreview(identity,480,load);assert.equal(loads,1);});
test('export derivatives keep resolution and use an independent cache identity',async()=>{assert.equal(previewWidth(2160,'export'),2160);assert.equal(previewWidth(2160),null);assert.equal(previewWidth(480,'export'),null);const input=await sharp({create:{width:1800,height:1200,channels:3,background:'#eddfbd'}}).png().toBuffer();const identity='export-'+Date.now();const result=await cachedPreview(identity,2160,async()=>input,true);const meta=await sharp(result.bytes).metadata();assert.equal(meta.width,1800);assert.equal(meta.height,1200);const normal=await cachedPreview(identity,480,async()=>input);assert.notEqual(result.etag,normal.etag)});
+163
View File
@@ -0,0 +1,163 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
+73 -6
View File
@@ -2,16 +2,81 @@
把本篇与图片生成文档一起交给 coding agent。agent 自行选择图片、配色、字体、构图与参数,通过 HTTP 保存渲染描述,得到可持续更新的原生 HTML/CSS 页面。数据库只保存模板关联、文字、素材引用和渲染参数,不保存截图或任意 HTML。
[打开可视化 API 库](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19)
[可选:查看模板示例](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19)
## agent 的交付流程
1. 按下文读取模板 API,选择 layoutId,并获取该模板实际的 T/I/D 元素 ID。
2. 上传图片取得 assetId,把图片、文字和参数提交给创建 API。
3. 从创建响应读取 `design.id` 与 `design.url`,把完整结果链接交给用户,例如 `http://8.211.154.134:18131/studio/template-layout?id=实际返回的UUID`。
4. 用户直接打开该链接看结果;修改内容继续调用 PATCH,同一 ID 与结果链接保持不变。
不要编造 ID,也不要把模板预览链接当作最终结果。API 保存成功并返回真实 UUID 才算完成。以下文档足以通过 HTTP 完成流程,无需使用编辑器。
## 多页作品:从创作 brief 到可发布文件(schemaVersion 2)
多页作品由 coding agent 完成创作决策与图像生成,网站负责封闭参数验证、事务保存与原生渲染。无需模型服务、模型密钥或编辑器。先形成主题、读者、意图、逐页分镜,再生成需要的图片,最后通过 HTTP 保存整组作品。封面、正文、结尾共用一个真实 UUID、修订号和 Wiki post。
### 完整内容与视觉模型
创建 `POST /api/studio/series`,body 为 `{schemaVersion:2,name,document}`。读取/整体修订使用 `GET/PATCH /api/studio/series/{id}`;PATCH 额外提供最新 revision。最大512KB,未知字段会拒绝。document:
- `version:1`:多页文档结构版本。
- `brief`:topic、audience、intent、designIntent、imageStrategy,均为必填非空文字,最多2000字符。分别说明主题、读者、内容目标、完整设计描述和配图策略。
- `style.version`:agent 指定的样式版本标识(最多80字符),如 `autumn-journal/1`。它用于记录创作版本;不锁定未来网站渲染器,精确交付请同时保留导出文件和 manifest。
- `style.palette`:paper、ink、muted、accent,均为 #RRGGBB。
- `style.typography`:title、body、caption 三种层级,每项包含 fontFamily(sans/serif/mono)、fontSize(0.1–40)、fontWeight(100–900)、lineHeight(0.5–4)、letterSpacing(-5–10)。字号和字距以120宽参考画板为单位,固定模板按画板宽度同比换算。
- `style.spacing`:margin(4–16)、gap(1–10),并要求 `2*margin+4*gap<=48`。标准排版据此计算标题、图片、正文和分隔线的位置;固定模板保留原构图,用 layers 显式调整位置。
- `sources`:最多100项,每项 assetId、author、license、url、prompt、tool 全部必填,文字最多4000字符。url 是 http(s) 来源地址或 `generated`。用户照片记录来源与授权;生成图记录完整提示词和工具,不把 AI 图写成摄影原作。所有被页面引用的图片必须有来源记录。
- `pages`:本工作流支持3–18页(这是本服务的约束,不宣称是平台实时上限)。每页 key 唯一(小写字母/数字/连字符,最多60位)、role(首项 cover,中间 body,末项 ending)、layoutId、intent(最多1000字符)、bindings、parameters。parameters 继续使用下文封闭的单页参数契约。
- `pages[].bindings`:把实际文字元素 ID 映射到 title/body/caption,例如 `{T01:"title",T02:"body",T03:"caption"}`。被绑定元素必须提供文案。共享字体、墨色和纸色会参与真实渲染;单元素显式值优先覆盖。所有可见文字必须提供或明确隐藏,避免默认占位文案泄漏。图片默认色与装饰默认色取 accent。
标准排版可直接沿用 T01/T02/T03/I01/D01;固定排版先读对应模板 API,元素不能混用。agent 必须控制标题长度和正文密度,不能把“已保存”当成“已验收”。同组图片建议统一比例;导出器遇到混合比例会拒绝,agent 应改用同一画板并调整内容布局。
### 一次保存,一条直接结果链接
```sh
# series.json 包含完整 document,示例文件见仓库 content/studio-examples/autumn-journal.json
curl -fsS -X POST "$BASE/api/studio/series" \
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
--data-binary @series.json
# 返回 {design}:schemaVersion=2、document、id、revision、url、postId 等
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/api/studio/series/$ID"
```
创建响应的 `design.url` 仍是 `/studio/template-layout?id={UUID}`;直接展示全部页,无需选模板。`&page=2` 只显示第2页,非法页码返回404。页面是服务端原生文字与 CSS,图片走 /api/preview。创建与全部页验证、Wiki post 处于同一事务,任一页无效整组不保存。PATCH 整体替换 document,revision 冲突返回409;先重新读取并合并。单页 PATCH 接口会拒绝修改多页作品,避免只改封面导致组内不一致。可从 designs 列表发现作品,schemaVersion 区分1与2。
归档 `DELETE /api/studio/series/{id}` body `{revision}`;恢复 `POST` 同一路径 body `{revision,archived:false}`。归档后结果页和导出计划返回404,API 仍能读取并恢复;参数、素材和 Wiki post 保留。更新复用原 Wiki post,手工编辑过的正文不被覆盖。
### 高质量导出与验收
`GET /api/studio/series/{id}/export` 返回固定顺序的导出计划:id、revision、每页 key/role/number、filename、width/height、原生渲染 url。计划本身不生成位图。示例统一为3:4、1080×1440 PNG;其他固定模板保留自己的画板比例,不拉伸。该尺寸是本流程的交付规格;发布前还须在小红书当前发布界面复核裁切及文件限制,本站不自动发布。
导出 URL 带 `&page=N&export=1`,画布在1080 CSS像素下重新布局原生文字,不把860宽或手机截图放大。图片使用 `/api/preview?source=...&width=2160&profile=export`:WebP质量95,不为常规缩略图预算降低尺寸,不放大小原件。普通展示仍使用240/480/960/1280及100KB/220KB预算。
在用户已确认的 **ces-control / aliyun-ces-control** 上运行仓库 `scripts/export-studio-series.cjs`,使用既有测试镜像的 playwright-core、sharp、Chromium 和中文字体。只访问 `http://8.211.154.134:18131`,认证保留进程内。设置 STUDIO_DESIGN_ID、STUDIO_EXPORT_DIR、AUTH_CONFIG_FILE(或由现有登录方式提供的 PROMPT_TOKEN),不在命令行或文件中存令牌。不得在用户本机启动编译、容器构建或预览服务器。
脚本逐页检查字体和图片加载、文字溢出、图片原生分辨率与裁切缩放、相同画板比例、页序、最终PNG像素、导出前后revision一致;记录实际字体、SHA256及manifest。额外生成桌面1200宽和手机390宽整组截图供视觉检查。自动检查通过后,agent 仍须逐页看PNG,核对中文缺字、主体裁切、阅读顺序、留白和系列一致性。检查失败修订 document,重新导出;不能把失败文件当成可发布结果。
最终交付:真实设计结果链接、按01/02/03排序的PNG、完整design.json、manifest.json、素材来源与提示词、简短发布文案。生成图、保存成功、导出成功、人工视觉通过、PR待合并、线上部署验收应分别如实报告。未合并代码的能力不可写成线上已可用。
### 可复现三页示例
仓库 `content/studio-examples/autumn-journal.json` 是完整的三页内容与样式文档:封面“把秋天装进一页”、正文“先看见再记录”、结尾“留一点白给下一次”。它不含虚构素材 UUID,空图片位仅用于契约测试。真实制作时先生成银杏母图,再上传取得assetId,在每页 I01 加入该 UUID,并在 sources 记录完整来源。不要把无图夹具当作已完成作品。
推荐在 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 在远程执行,上传素材并填写所有页面引用后,保存真实作品,写出请求、响应和状态文件。重复运行会读取已保存状态,防止在成功后重复创建;出现网络结果不明时先核对素材/设计列表,不盲目重试。脚本仅在传入文件后写入作品,不自动发布到小红书。
## 两类排版,同一内容模型
- **标准排版**:`standard-01`,120×160 单位。沿用原 Studio 图文页的标题、正文、栏目结构,增加主题图片与分隔线。T01 主标题、T02 正文、T03 栏目、I01 图片、D01 分隔线。所有内容和位置可设置,也能增加原生元素。
- **固定排版**:`ref-01` 至 `ref-27`,保留各自画板、出血、圆形、斜切、竖题和字体构图。先读取模板,按实际 T/I/D ID 填入内容。不能假定各模板拥有相同元素。
两类都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。用户可按需进入现有编辑工作区,工具箱功能保持可用。
两类单页都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。调用方无需打开 `/studio` 或操作编辑器。模板由 agent 经 API 选择,图片和文字由 API 保存,用户只打开结果链接。现有独立工具箱功能保持可用。
## 1. 发现能力并选择模板
## 1. 由 agent 通过 API 发现模板
以下 curl 使用指定服务器。`PROMPT_TOKEN` 由用户通过现有登录方式提供,只保存在进程环境,不放 URL 或日志。也可使用已登录浏览器的同源 Cookie;浏览器写请求需要同源 Origin。未登录 API 返回 401。
@@ -46,7 +111,7 @@ width 只允许 240/480/960/1280;小图预算 100KB,大图预算 220KB,复
## 3. 创建:POST /api/studio/designs
创建和更新共用内容字段 `name,parameters`;`schemaVersion` 可省略,当前仅支持 1。创建额外传 `layoutId`,不传 revision 或 id。
创建和更新共用内容字段 `name,parameters`;`schemaVersion` 可省略,单页当前支持 1(多页使用上述独立 series API 的2)。创建额外传 `layoutId`,不传 revision 或 id。
```sh
curl -fsS -X POST "$BASE/api/studio/designs" \
@@ -82,7 +147,9 @@ curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/api/studio/designs?lay
读取返回 `{design}`,没有副作用。列表默认只列未归档记录;`archived=true` 只列归档记录;limit 为1–50,默认20;`nextCursor` 作为下一页 before。layoutId 可省略。
直接打开返回的 url:`/studio/layouts/standard-01?id={UUID}` 或 `/studio/layouts/ref-17?id={UUID}`,默认看到原生页面,点击“编辑页面”才进入工作区。`/studio/designs/{UUID}` 是兼容跳转。ID 与模板不匹配返回404。归档设计仍能经 API 读取以恢复,渲染页返回404。
直接打开创建响应中的 `design.url`:`/studio/template-layout?id={UUID}`。这个 UUID 对应数据库里 API 保存的模板、图片与文字,服务端按记录渲染最终 HTML/CSS。链接中无需携带 layoutId,无需打开 `/studio` 选择模板,也没有编辑按钮、双击编辑或右键编辑。`standard-01` 与 `ref-17` 是模板 ID,不是保存记录的 UUID。
`/studio/designs/{UUID}` 与旧 `/studio/layouts/{layoutId}?id={UUID}` 兼容跳转到同一个结果页。缺失、非法、不存在或已归档的 ID 返回404,不会显示空模板或编辑器;旧链接模板与记录不匹配也返回404。归档设计仍能经 API 读取以恢复。
## 5. 更新:PATCH /api/studio/designs/{id}
@@ -142,7 +209,7 @@ curl -fsS -X POST "$BASE/studio/api/export/layout-selection?format=svg" \
-d '{"layoutId":"ref-01","fields":{"T01":"STUDY"},"color":"#83918a"}' -o selection.svg
```
固定排版 `/studio/api/layouts/ref-01` 的 GET format=svg、POST 仍提供旧分层 SVG;POST format=json 只验证和返回规范化参数。**旧 SVG 不反映 added、layers、扩展字体和裁切参数**,不能用来证明原生效果。standard-01 不支持 SVG。原生页面应直接打开返回 URL,或用浏览器打印。代码可扩展渲染能力,但每次是否使用、选择哪些参数均由 coding agent 决定。
固定排版 `/studio/api/layouts/ref-01` 的 GET format=svg、POST 仍提供旧分层 SVG;POST format=json 只验证和返回规范化参数。**旧 SVG 不反映 added、layers、扩展字体和裁切参数**,不能用来证明原生效果。standard-01 不支持 SVG。原生页面应直接打开返回 URL,;多页可按上文通过导出器生成PNG。代码可扩展渲染能力,但每次是否使用、选择哪些参数均由 coding agent 决定。
## 固定排版索引
+9
View File
@@ -0,0 +1,9 @@
/** Source-only parity and syntax checks; no compilation or generated output. */
import {readFile} from 'node:fs/promises';
import assert from 'node:assert/strict';
const root=new URL('../',import.meta.url),read=path=>readFile(new URL(path,root),'utf8');
for(const file of ['layout-parameters.ts','series.ts'])assert.equal(await read('apps/studio/src/lib/'+file),await read('apps/waterfall-wiki/src/lib/'+file),file+' contract drift');
const canonical=await read('content/studio-examples/autumn-journal.json');for(const app of ['studio','waterfall-wiki'])assert.equal(canonical,await read('apps/'+app+'/fixtures/autumn-journal.json'),'Example fixture drift');
const doc=await read('apps/waterfall-wiki/src/lib/studio-design-documentation.ts'),encoded=doc.match(/export const designDocs=(.*);\n/);
assert.ok(encoded);assert.equal(JSON.parse(encoded[1]),(await read('content/studio-tools/layouts-api.md')).replace('{{LAYOUTS}}','读取 /studio/api/layouts 获取全部27套构图。'),'Wiki/agent documentation drift');
console.log('Studio content, validation and documentation contracts agree');
+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
+37
View File
@@ -0,0 +1,37 @@
/** 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');
// 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{
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,fit:getComputedStyle(i).objectFit,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');const fitScale=(i.fit==='contain'?Math.min:Math.max)(i.displayWidth/i.width,i.displayHeight/i.height)*i.zoom;assert.ok(fitScale<=1.001,'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');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,[]);
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)});
+14
View File
@@ -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
+14 -4
View File
@@ -9,17 +9,27 @@ 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 studioCapability=await api('/studio/api/layouts');if(studioCapability.seriesDocumentVersion!==1)throw Error('Studio renderer 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.
for(const [id,entry] of [[canonical,current],[legacy,old]]){try{await readFile(dir+'/'+id+'-before.json')}catch{await writeFile(dir+'/'+id+'-before.json',JSON.stringify(entry),{mode:0o600})}}
const catalogue=await api('/studio/api/layouts');
let markdown=(await readFile(dir+'/layouts-api.md','utf8')).replace('{{LAYOUTS}}',catalogue.layouts.filter(l=>l.family==='fixed').map(l=>`- [${l.id} · ${l.name}](${l.url}) · [参数](${l.api})`).join('\n'));
const legacyOriginal=JSON.parse(await readFile(dir+'/'+legacy+'-before.json','utf8')).editableMarkdown;
markdown+='\n\n## 兼容工具原说明与代码示例\n\n以下保留原工具的说明与 HTML/React/Next.js 调用示例;它们是无状态兼容接口,持久化页面请使用上文 CRUD。\n\n'+legacyOriginal;
let markdown=(await readFile(new URL('../content/studio-tools/layouts-api.md',import.meta.url),'utf8')).replace('{{LAYOUTS}}',catalogue.layouts.filter(l=>l.family==='fixed').map(l=>`- [${l.id} · ${l.name}](${l.url}) · [参数](${l.api})`).join('\n'));
const appendixMarker='## 兼容工具原说明与代码示例';
const historicalNotice='> 以下保留历史工具示例,不代表当前 API 保存与打开流程。历史文字中关于首页、编辑器和旧结果地址的描述已过时:当前由 API 创建 ID,直接打开 `/studio/template-layout?id={UUID}`,无需选版或编辑器。当前契约以上文为准。';
if(current.editableMarkdown.includes(appendixMarker)){
const appendix=current.editableMarkdown.slice(current.editableMarkdown.indexOf(appendixMarker));
markdown+='\n\n'+(appendix.includes(historicalNotice)?appendix:appendix.replace(appendixMarker,appendixMarker+'\n\n'+historicalNotice));
}else{
if(old.archived)throw Error('Original toolbox appendix is missing; refusing to replace it with the archived redirect');
const legacyOriginal=JSON.parse(await readFile(dir+'/'+legacy+'-before.json','utf8')).editableMarkdown;
markdown+='\n\n'+appendixMarker+'\n\n'+historicalNotice+'\n\n以下保留原工具的说明与 HTML/React/Next.js 调用示例;它们是无状态兼容接口,持久化页面请使用上文 CRUD。\n\n'+legacyOriginal;
}
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});
+17
View File
@@ -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=[{...source,assetId:state.assetId}];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}));
+3 -1
View File
@@ -19,6 +19,7 @@ if(process.argv.includes('--finalize')){
const final={canonical:base+'/explore/'+canonical,documentEntries:category?.total??null,legacyCompatible:!documentFailure,testRecordsArchived:results.length};
await writeFile('/tmp/studio-library/final-verification.json',JSON.stringify(final,null,2));console.log(JSON.stringify(final));if(documentFailure)throw documentFailure;
}else{
for(const path of ['/studio/template-layout','/studio/template-layout?id=invalid'])assert.equal((await fetch(base+path)).status,404);
const catalog=await api('/studio/api/layouts');assert.equal(catalog.layouts.length,28);
const note=(await api('/api/notes/8eb8c76500580f7a4b56ba3c')).entry;
const asset=note.coverAssetId||note.assets.find(a=>a.kind==='image').id;
@@ -29,10 +30,11 @@ for(const layoutId of ['standard-01','ref-17']){
const p={texts:{T01:{value:'API 验收 · 原生页面',fontFamily:'serif',fontWeight:600}},images:{I01:{assetId:asset,fit:'cover',cropX:35,cropY:65,zoom:1.2}},added:[{id:'user-api-check',kind:'text',role:'扩展正文',box:[10,120,60,9],direction:'horizontal',text:'通过 HTTP 保存并恢复',reading:'custom'}],layers:{'user-api-check':{z:20,rotation:2}}};
let d=(await api('/api/studio/designs',{schemaVersion:1,layoutId,name:'API 验收 '+layoutId,parameters:p},'POST',201)).design;
const id=d.id,postId=d.postId;
assert.equal(d.url,'/studio/template-layout?id='+id);assert.equal(d.renderUrl,d.url);
results.push({layoutId,id,url:d.url,revision:d.revision,postId,parameters:p});
await writeFile('/tmp/studio-library/verification.json',JSON.stringify({results,preview:{asset,bytes,type:'image/webp'},pages:0},null,2));
assert.deepEqual((await api('/api/studio/designs/'+id)).design.parameters,d.parameters);
const html=await fetch(base+d.url).then(r=>r.text());assert.ok(html.includes('API 验收'));assert.ok(html.includes('user-api-check'));assert.ok(html.includes('/api/preview?'));assert.ok(!html.includes('src="/api/media/'));
const html=await fetch(base+d.url).then(r=>r.text());assert.ok(html.includes('API 验收'));assert.ok(html.includes('user-api-check'));assert.ok(html.includes('/api/preview?'));assert.ok(!html.includes('src="/api/media/'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(html));
d=(await api('/api/studio/designs/'+id,{name:'API 验收修订 '+layoutId,parameters:p,revision:d.revision},'PATCH')).design;
assert.equal(d.postId,postId);assert.equal(d.revision,2);
await api('/api/studio/designs/'+id,{name:'过期写入',parameters:{},revision:1},'PATCH',409);
+7 -1
View File
@@ -26,7 +26,13 @@ const assert=require('node:assert/strict');
await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.equal(await page.locator('.library-item').count(),27);await page.screenshot({path:dir+'/catalog-desktop.png',fullPage:true});
const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8'));
for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&&current.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png',style:'.page-tools{visibility:hidden}'});
await page.getByRole('button',{name:'编辑页面',exact:true}).click();assert.equal(await page.locator('.workbench-top').count(),1);await page.screenshot({path:dir+'/'+d.layoutId+'-editor.png'});
assert.equal(new URL(page.url()).pathname,'/studio/template-layout');
assert.equal(await page.locator('button,input,textarea,select,[contenteditable],.page-tools,.workbench-top').count(),0);
await page.locator('.native-copy').first().dblclick();await page.locator('.native-copy').first().dispatchEvent('contextmenu');
assert.equal(await page.locator('button,input,textarea,select,[contenteditable],.workbench-top').count(),0);
const plain=await browser.newContext({viewport:{width:1100,height:1000},javaScriptEnabled:false});
await plain.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin===origin)return route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}});return route.abort()});
const direct=await plain.newPage();await direct.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await direct.locator('article[data-design-id="'+d.id+'"]').count(),1);assert.ok((await direct.locator('article').innerText()).includes('API 验收'));await plain.close();
}
for(const slug of ['learning-skills-template','layout-selection']){await page.goto(base+'/studio/ideas/'+slug,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})}
await page.setViewportSize({width:390,height:844});await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/catalog-mobile.png',fullPage:true});