diff --git a/.gitea/workflows/studio.yml b/.gitea/workflows/studio.yml index 8325218..75ebd5c 100644 --- a/.gitea/workflows/studio.yml +++ b/.gitea/workflows/studio.yml @@ -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,6 +35,12 @@ 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 diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index 4ca5637..c1e0eb4 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -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 diff --git a/apps/studio/fixtures/autumn-journal.json b/apps/studio/fixtures/autumn-journal.json new file mode 100644 index 0000000..9d02696 --- /dev/null +++ b/apps/studio/fixtures/autumn-journal.json @@ -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 + } + } + } + } + ] + } +} diff --git a/apps/studio/scripts/smoke.mjs b/apps/studio/scripts/smoke.mjs index c374cab..d5aaadb 100644 --- a/apps/studio/scripts/smoke.mjs +++ b/apps/studio/scripts/smoke.mjs @@ -1,9 +1,13 @@ /** 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');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'}}}))}); +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); @@ -13,6 +17,9 @@ const headers={cookie:'session=smoke-test'}; 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(//)[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(//)[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); diff --git a/apps/studio/src/components/NativeResult.tsx b/apps/studio/src/components/NativeResult.tsx index e03f080..9f300a0 100644 --- a/apps/studio/src/components/NativeResult.tsx +++ b/apps/studio/src/components/NativeResult.tsx @@ -6,23 +6,23 @@ import {decorationKind,defaultText,emptyParameters,imageShape,paperColor,slotPos /** A saved result contains artwork only: no editing state, controls or event handlers. */ export function NativeResult({layout,design}:{layout:Layout;design:Design|null}){ + return
{design?.name||layout.name} · Prompt
; +} +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
- {design?.name||layout.name} · Prompt -
+ return
{['image','decoration','text'].flatMap(kind=>elements.filter(s=>s.kind===kind).map(s=>{ const t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id],ls=params.layers?.[s.id]||{}; if(ls.hidden||(kind==='decoration'&&d?.hidden))return null; const text=t?.value??defaultText(layout,s); const position:CSSProperties={...slotPosition(layout,{...s,box:[ls.x??s.box[0],ls.y??s.box[1],ls.width??s.box[2],ls.height??s.box[3]]}),transform:`rotate(${ls.rotation||0}deg)`,opacity:ls.opacity??1,borderRadius:ls.radius===undefined?undefined:`${ls.radius/layout.width*100}cqw`,zIndex:ls.z??(kind==='text'?3:kind==='image'?1:2)}; - const textStyle:CSSProperties={fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily==='serif'?'Georgia,serif':t?.fontFamily==='mono'?'monospace':t?.fontFamily==='sans'?'Arial,sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'}; + const textStyle:CSSProperties={fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily==='serif'?'Georgia,"Noto Serif CJK SC","Songti SC",serif':t?.fontFamily==='mono'?'"Noto Sans Mono CJK SC",monospace':t?.fontFamily==='sans'?'Arial,"Noto Sans CJK SC","PingFang SC","Microsoft YaHei",sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'}; return
{kind==='text'?(s.direction==='curved'?:
{text}
):kind==='image'?
- {im?.assetId&&'/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})`}}/>} + {im?.assetId&&'/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})`}}/>}
:
{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=>)}{decorationKind(s)==='vertical-lines'&&<>}
}
; }))} -
-
; + ; } diff --git a/apps/studio/src/components/NativeSeries.tsx b/apps/studio/src/components/NativeSeries.tsx new file mode 100644 index 0000000..414fcb9 --- /dev/null +++ b/apps/studio/src/components/NativeSeries.tsx @@ -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
+ {design.name} · {doc.pages.length}页 + {doc.pages.map((p,index)=>{if(page!==null&&page!==index+1)return null;const layout=[standard,...fixed].find(l=>l.id===p.layoutId)!;return
{!quality&&

{String(index+1).padStart(2,'0')} / {doc.pages.length}

}
})} +
; +} diff --git a/apps/studio/src/lib/design-server.ts b/apps/studio/src/lib/design-server.ts index bd5daa5..9510dd9 100644 --- a/apps/studio/src/lib/design-server.ts +++ b/apps/studio/src/lib/design-server.ts @@ -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):Promisel.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)}; } diff --git a/apps/studio/src/lib/native-layout.ts b/apps/studio/src/lib/native-layout.ts index 9422cdf..558297b 100644 --- a/apps/studio/src/lib/native-layout.ts +++ b/apps/studio/src/lib/native-layout.ts @@ -1,7 +1,7 @@ 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:{}}); diff --git a/apps/studio/src/lib/series.ts b/apps/studio/src/lib/series.ts new file mode 100644 index 0000000..214d425 --- /dev/null +++ b/apps/studio/src/lib/series.ts @@ -0,0 +1,42 @@ +import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters'; +export type Typography=Required>; +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;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}; +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)||vmax)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(); + 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={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; +} diff --git a/apps/studio/src/pages/api/index.ts b/apps/studio/src/pages/api/index.ts index 7677ab7..6c0b54b 100644 --- a/apps/studio/src/pages/api/index.ts +++ b/apps/studio/src/pages/api/index.ts @@ -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'));} diff --git a/apps/studio/src/pages/api/layouts/index.ts b/apps/studio/src/pages/api/layouts/index.ts index 9904afc..cb7cd0a 100644 --- a/apps/studio/src/pages/api/layouts/index.ts +++ b/apps/studio/src/pages/api/layouts/index.ts @@ -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'}); } diff --git a/apps/studio/src/pages/template-layout.tsx b/apps/studio/src/pages/template-layout.tsx index 02ea6be..21f5e72 100644 --- a/apps/studio/src/pages/template-layout.tsx +++ b/apps/studio/src/pages/template-layout.tsx @@ -1,4 +1,5 @@ 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'; @@ -6,13 +7,15 @@ 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}; -export default function TemplateLayout(props:Props){return } +type Props={layout:Layout;design:Design;page:number|null;quality:boolean}; +export default function TemplateLayout(props:Props){return props.design.document?:} export const getServerSideProps:GetServerSideProps=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}; - return {props:{layout:nativeLayout(layout),design}}; + 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'}}; }; diff --git a/apps/studio/src/styles/globals.css b/apps/studio/src/styles/globals.css index 943c137..2dbdb52 100644 --- a/apps/studio/src/styles/globals.css +++ b/apps/studio/src/styles/globals.css @@ -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}} diff --git a/apps/studio/tests/series.test.mjs b/apps/studio/tests/series.test.mjs new file mode 100644 index 0000000..f565f95 --- /dev/null +++ b/apps/studio/tests/series.test.mjs @@ -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'))); diff --git a/apps/waterfall-wiki/db/013_studio_series.sql b/apps/waterfall-wiki/db/013_studio_series.sql new file mode 100644 index 0000000..12a1446 --- /dev/null +++ b/apps/waterfall-wiki/db/013_studio_series.sql @@ -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; diff --git a/apps/waterfall-wiki/fixtures/autumn-journal.json b/apps/waterfall-wiki/fixtures/autumn-journal.json new file mode 100644 index 0000000..9d02696 --- /dev/null +++ b/apps/waterfall-wiki/fixtures/autumn-journal.json @@ -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 + } + } + } + } + ] + } +} diff --git a/apps/waterfall-wiki/src/lib/api-documentation.ts b/apps/waterfall-wiki/src/lib/api-documentation.ts index e5096d4..342bf7e 100644 --- a/apps/waterfall-wiki/src/lib/api-documentation.ts +++ b/apps/waterfall-wiki/src/lib/api-documentation.ts @@ -68,7 +68,7 @@ export const apiDocuments:Record={ "/api/preview": [ "轻量图片预览", "GET, HEAD", - "查询参数:source(必填,本站静态栅格图片路径,或 /api/media/,可带 ?variant=poster;作为查询值时必须 URL 编码);width(可选,240 / 480 / 960 / 1280,默认 480)。不接受任意远程图片地址。支持 If-None-Match。", + "查询参数:source(必填,本站静态栅格图片路径,或 /api/media/,可带 ?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&width=480", "400:参数不合法;404:图片不存在;503:预览暂不可用。" diff --git a/apps/waterfall-wiki/src/lib/preview.ts b/apps/waterfall-wiki/src/lib/preview.ts index 801195e..5eb8322 100644 --- a/apps/waterfall-wiki/src/lib/preview.ts +++ b/apps/waterfall-wiki/src/lib/preview.ts @@ -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(work:()=>Promise){if(active>=3){if(waiting.length>=24)throw Error('Preview busy');await new Promise(done=>waiting.push(done));}else active++;try{return await work()}finally{const next=waiting.shift();if(next)next();else active--;}} const pending=new Map>();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){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,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)};} diff --git a/apps/waterfall-wiki/src/lib/series.ts b/apps/waterfall-wiki/src/lib/series.ts new file mode 100644 index 0000000..214d425 --- /dev/null +++ b/apps/waterfall-wiki/src/lib/series.ts @@ -0,0 +1,42 @@ +import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters'; +export type Typography=Required>; +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;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}; +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)||vmax)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(); + 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={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; +} diff --git a/apps/waterfall-wiki/src/lib/studio-design-documentation.ts b/apps/waterfall-wiki/src/lib/studio-design-documentation.ts index 60b492a..71dd773 100644 --- a/apps/waterfall-wiki/src/lib/studio-design-documentation.ts +++ b/apps/waterfall-wiki/src/lib/studio-design-documentation.ts @@ -1,5 +1,5 @@ import type {NextApiRequest,NextApiResponse} from 'next'; -// Agent documentation mirrors the canonical Studio guide. -export const designDocs="# Studio 排版 API · 标准图文与 27 套固定构图\n\n把本篇与图片生成文档一起交给 coding agent。agent 自行选择图片、配色、字体、构图与参数,通过 HTTP 保存渲染描述,得到可持续更新的原生 HTML/CSS 页面。数据库只保存模板关联、文字、素材引用和渲染参数,不保存截图或任意 HTML。\n\n[可选:查看模板示例](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19)\n\n## agent 的交付流程\n\n1. 按下文读取模板 API,选择 layoutId,并获取该模板实际的 T/I/D 元素 ID。\n2. 上传图片取得 assetId,把图片、文字和参数提交给创建 API。\n3. 从创建响应读取 `design.id` 与 `design.url`,把完整结果链接交给用户,例如 `http://8.211.154.134:18131/studio/template-layout?id=实际返回的UUID`。\n4. 用户直接打开该链接看结果;修改内容继续调用 PATCH,同一 ID 与结果链接保持不变。\n\n不要编造 ID,也不要把模板预览链接当作最终结果。API 保存成功并返回真实 UUID 才算完成。以下文档足以通过 HTTP 完成流程,无需使用编辑器。\n\n## 两类排版,同一内容模型\n\n- **标准排版**:`standard-01`,120×160 单位。沿用原 Studio 图文页的标题、正文、栏目结构,增加主题图片与分隔线。T01 主标题、T02 正文、T03 栏目、I01 图片、D01 分隔线。所有内容和位置可设置,也能增加原生元素。\n- **固定排版**:`ref-01` 至 `ref-27`,保留各自画板、出血、圆形、斜切、竖题和字体构图。先读取模板,按实际 T/I/D ID 填入内容。不能假定各模板拥有相同元素。\n\n两类都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。调用方无需打开 `/studio` 或操作编辑器。模板由 agent 经 API 选择,图片和文字由 API 保存,用户只打开结果链接。现有独立工具箱功能保持可用。\n\n## 1. 由 agent 通过 API 发现模板\n\n以下 curl 使用指定服务器。`PROMPT_TOKEN` 由用户通过现有登录方式提供,只保存在进程环境,不放 URL 或日志。也可使用已登录浏览器的同源 Cookie;浏览器写请求需要同源 Origin。未登录 API 返回 401。\n\n```sh\nBASE=http://8.211.154.134:18131\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/standard-01\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/ref-17\"\n```\n\n目录返回 `version, layouts, saveApi, catalogUrl, documentationUrl`。每项包含 `id,name,family,url,api`,family 为 standard/fixed。模板返回尺寸、元素 ID/类型/role/box/direction/text、默认 parameters、workspaceSchema 与页面 URL。`?agent_mode=True` 返回纯文本文档,业务请求移除此参数。\n\n## 2. 生成图片,上传一次,显示压缩衍生图\n\n先让 coding agent 按生成图片文档产出文件;Studio 不暗中调用生成模型。图片可以来自用户素材或生成结果。上传走现有素材 API:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n -F kind=image -F assetOnly=true -F files=@hero.png \"$BASE/api/upload\"\n```\n\n返回 `assets` UUID 数组及对应 `files` 元数据。取 `assets[0]` 作为 assetId。上传不单独发帖;设计创建时才自动关联 Wiki post。不要把 Base64、任意远程 URL、HTML 或 SVG 塞入参数。\n\n原素材保留在素材库,**页面展示只请求压缩 WebP**。以 assetId 引用素材,不必自行再上传缩略图:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n \"$BASE/api/preview?source=%2Fapi%2Fmedia%2FASSET_UUID&width=960\" -o hero-preview.webp\n```\n\nwidth 只允许 240/480/960/1280;小图预算 100KB,大图预算 220KB,复杂图片会降低尺寸/质量。原生页面按槽位显示宽度和像素密度选择 srcset 衍生图,保留 fit/cropX/cropY/zoom。目录使用原生文字与 CSS 色块,不加载参考原图;旧工具箱参考图也使用压缩预览。`/api/media/{id}` 是素材原件读取接口,不应用作展示图片的 src。\n\n## 3. 创建:POST /api/studio/designs\n\n创建和更新共用内容字段 `name,parameters`;`schemaVersion` 可省略,当前仅支持 1。创建额外传 `layoutId`,不传 revision 或 id。\n\n```sh\ncurl -fsS -X POST \"$BASE/api/studio/designs\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"schemaVersion\":1,\"layoutId\":\"standard-01\",\"name\":\"观察日常\",\"parameters\":{\"paper\":\"#edf0ef\",\"texts\":{\"T01\":{\"value\":\"观察日常\\n记录灵感\",\"fontFamily\":\"serif\",\"color\":\"#23424b\"},\"T02\":{\"value\":\"让每一页只讲一件事。\",\"lineHeight\":1.4}},\"images\":{\"I01\":{\"color\":\"#73929a\",\"fit\":\"cover\",\"cropX\":50,\"cropY\":45,\"zoom\":1}},\"decorations\":{\"D01\":{\"color\":\"#73929a\"}}}}'\n```\n\n有图片时在 I01 加 `assetId:\"上传返回的UUID\"`。固定排版只需改为所选 `layoutId`,并按 schema 选择元素 ID。例如 ref-01 支持 `texts.T01`、`texts.T02` 与 `images.I01`。\n\n201 返回 `{design}`。design 包含:\n\n| 字段 | 含义 |\n| --- | --- |\n| id / layoutId / family | 持久化 UUID、不可变模板 ID、standard 或 fixed |\n| schemaVersion / revision | 参数契约版本(1)、记录修订号(从1递增) |\n| name / parameters | 名称与规范化内容;省略的分组返回空对象 |\n| url / renderUrl | 同一个原生 HTML/CSS 页面 URL |\n| schemaUrl | 当前模板参数地址 |\n| postId / postUrl | 自动创建并在后续更新中复用的 Wiki post |\n| archived / archivedAt | 可恢复归档状态与时间 |\n| createdAt / updatedAt | 创建与最后修改时间 |\n| legacySvgUrl | 固定排版的旧 SVG 导出地址,标准排版为 null |\n\n创建设计与 Wiki post 在同一数据库事务内完成,失败整体回滚。更新同一设计复用同一 post;手工编辑过的 Wiki 正文、标题、标签、封面不会被参数更新覆盖。\n\n## 4. 读取与打开\n\n```sh\nID=创建返回的UUID\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs/$ID\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs?layoutId=standard-01&limit=20\"\n```\n\n读取返回 `{design}`,没有副作用。列表默认只列未归档记录;`archived=true` 只列归档记录;limit 为1–50,默认20;`nextCursor` 作为下一页 before。layoutId 可省略。\n\n直接打开创建响应中的 `design.url`:`/studio/template-layout?id={UUID}`。这个 UUID 对应数据库里 API 保存的模板、图片与文字,服务端按记录渲染最终 HTML/CSS。链接中无需携带 layoutId,无需打开 `/studio` 选择模板,也没有编辑按钮、双击编辑或右键编辑。`standard-01` 与 `ref-17` 是模板 ID,不是保存记录的 UUID。\n\n`/studio/designs/{UUID}` 与旧 `/studio/layouts/{layoutId}?id={UUID}` 兼容跳转到同一个结果页。缺失、非法、不存在或已归档的 ID 返回404,不会显示空模板或编辑器;旧链接模板与记录不匹配也返回404。归档设计仍能经 API 读取以恢复。\n\n## 5. 更新:PATCH /api/studio/designs/{id}\n\n先 GET,保留所有未改字段,再整体替换 parameters;不是深度合并。以下假设刚读到 revision=1:\n\n```sh\ncurl -fsS -X PATCH \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"name\":\"观察日常 · 修订\",\"revision\":1,\"parameters\":{\"texts\":{\"T01\":{\"value\":\"新的标题\"}},\"images\":{},\"decorations\":{}}}'\n```\n\n200 返回新 design,revision 增加。过期 revision 返回409,重新 GET 并合并后再提交;不要盲目重试。不接受 layoutId、id、url、postId 等返回字段;不能直接把整个 design 作为 body。另存副本使用 POST 创建新 UUID。\n\n## 6. 删除与恢复\n\nDELETE 采用可恢复归档,不物理删除参数、素材或 Wiki post。和更新一样必须提供最新 revision;自动同步关联 post 的归档状态。\n\n```sh\ncurl -fsS -X DELETE \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":2}'\n# 删除后读取新 revision,再恢复。此例删除后为3:\ncurl -fsS -X POST \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":3,\"archived\":false}'\n```\n\n两者200返回 `{design}`,revision 递增。归档记录 PATCH 返回409,先恢复。重复写入仍需最新 revision;409不会修改数据。独立归档 Wiki post 只影响笔记发现状态,不等于删除设计。旧设计缺 Wiki 入口可调用 `POST /api/studio/designs/{id}/post`,body `{}`,首次201、已有200;不改变 revision。\n\n## 参数清单(两类共用)\n\nparameters 顶层允许 texts/images/decorations/paper/layers/added;全部可省略,不能添加未知字段。最大请求64KB。颜色都为 `#RRGGBB`。坐标与字号使用模板单位,不是 CSS 像素。\n\n| 分组 | 字段与约束 |\n| --- | --- |\n| texts.{文字ID} | value 必填,可空,最多500字/8行;color;scale 0.25–2;align start/middle/end;fontFamily sans/serif/mono;fontSize 0.1–1000;fontWeight 100–900;lineHeight 0.5–4;letterSpacing -20–100 |\n| images.{图片ID} | assetId(已存在的图片 UUID);color;fit cover/contain;cropX/cropY 0–100(默认50);zoom 1–5(默认1) |\n| decorations.{装饰ID} | color;hidden 布尔值 |\n| paper | 纸张颜色 |\n| layers.{元素ID} | x/y -10000–10000;width/height 0.1–10000;rotation -360–360;opacity 0–1;radius 0–5000;z -1000–1000;hidden/locked 布尔值 |\n| added | 最多100项:id 为 user- 加1–60位字母/数字/连字符,唯一;kind text/image/decoration;role 最多80字;box 为 [x,y,width,height];direction 必须 horizontal;text 为文字或null;reading 必须 custom;装饰可带 shape rectangle/ellipse |\n\n新增元素先在 added 声明,然后在 texts/images/decorations/layers 用该 ID 配置。locked 控制工作区交互,不禁止 agent 更新。数据是封闭结构,无脚本执行。未来扩展新增契约版本;v1 不接受未知字段/版本。现有省略 schemaVersion 的请求继续按v1处理,旧记录无需重建,布局 ID 和页面 UUID 保持不变。默认视觉可随模板美化变化;要求固定外观时显式设置配色、字体和图层参数。\n\n错误:400 参数/版本/构件类型/图片 ID 不合法;401 未登录;404 模板或设计不存在;409 修订冲突或已归档;405 方法不支持;413 请求超限;500 服务失败。压缩预览失败可能返回503与 Retry-After。\n\n## 现有工具箱与兼容导出\n\n工具功能保留:[ref-17 同一母版四张试版](/studio/ideas/learning-skills-template)、[ref-01/07/26 三种构图试版](/studio/ideas/layout-selection)、[最初图文页](/studio/ideas/first-idea)。旧封面工具文档已合并到本篇,旧文档 URL 保留为归档说明,避免重复顶级入口。\n\n旧无状态 API:`/studio/api/export/learning-skills-template` 和 `/studio/api/export/layout-selection` 支持 GET/POST,不保存设计。前者参数 slide=0..3、heading(1–6字)、body(最多360字/3行)、color/accent、masthead(2项);后者 layoutId=ref-01/ref-07/ref-26、fields(对应T ID,1–120字/4行)、color/accent。GET 默认返回方案 JSON;GET format=svg 导出初始图;POST format=svg(默认)导出 SVG,format=json 返回参数,最大32KB。first-idea 仅支持 GET 方案/Markdown。\n\n```sh\ncurl -fsS -X POST \"$BASE/studio/api/export/learning-skills-template?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"slide\":1,\"heading\":\"笔记复习\",\"color\":\"#7a8b7e\"}' -o cover.svg\ncurl -fsS -X POST \"$BASE/studio/api/export/layout-selection?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"layoutId\":\"ref-01\",\"fields\":{\"T01\":\"STUDY\"},\"color\":\"#83918a\"}' -o selection.svg\n```\n\n固定排版 `/studio/api/layouts/ref-01` 的 GET format=svg、POST 仍提供旧分层 SVG;POST format=json 只验证和返回规范化参数。**旧 SVG 不反映 added、layers、扩展字体和裁切参数**,不能用来证明原生效果。standard-01 不支持 SVG。原生页面应直接打开返回 URL,或用浏览器打印。代码可扩展渲染能力,但每次是否使用、选择哪些参数均由 coding agent 决定。\n\n## 固定排版索引\n\n读取 /studio/api/layouts 获取全部27套构图。\n"; +// Generated from content/studio-tools/layouts-api.md; keep the canonical Wiki guide identical. +export const designDocs="# Studio 排版 API · 标准图文与 27 套固定构图\n\n把本篇与图片生成文档一起交给 coding agent。agent 自行选择图片、配色、字体、构图与参数,通过 HTTP 保存渲染描述,得到可持续更新的原生 HTML/CSS 页面。数据库只保存模板关联、文字、素材引用和渲染参数,不保存截图或任意 HTML。\n\n[可选:查看模板示例](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19)\n\n## agent 的交付流程\n\n1. 按下文读取模板 API,选择 layoutId,并获取该模板实际的 T/I/D 元素 ID。\n2. 上传图片取得 assetId,把图片、文字和参数提交给创建 API。\n3. 从创建响应读取 `design.id` 与 `design.url`,把完整结果链接交给用户,例如 `http://8.211.154.134:18131/studio/template-layout?id=实际返回的UUID`。\n4. 用户直接打开该链接看结果;修改内容继续调用 PATCH,同一 ID 与结果链接保持不变。\n\n不要编造 ID,也不要把模板预览链接当作最终结果。API 保存成功并返回真实 UUID 才算完成。以下文档足以通过 HTTP 完成流程,无需使用编辑器。\n\n## 多页作品:从创作 brief 到可发布文件(schemaVersion 2)\n\n多页作品由 coding agent 完成创作决策与图像生成,网站负责封闭参数验证、事务保存与原生渲染。无需模型服务、模型密钥或编辑器。先形成主题、读者、意图、逐页分镜,再生成需要的图片,最后通过 HTTP 保存整组作品。封面、正文、结尾共用一个真实 UUID、修订号和 Wiki post。\n\n### 完整内容与视觉模型\n\n创建 `POST /api/studio/series`,body 为 `{schemaVersion:2,name,document}`。读取/整体修订使用 `GET/PATCH /api/studio/series/{id}`;PATCH 额外提供最新 revision。最大512KB,未知字段会拒绝。document:\n\n- `version:1`:多页文档结构版本。\n- `brief`:topic、audience、intent、designIntent、imageStrategy,均为必填非空文字,最多2000字符。分别说明主题、读者、内容目标、完整设计描述和配图策略。\n- `style.version`:agent 指定的样式版本标识(最多80字符),如 `autumn-journal/1`。它用于记录创作版本;不锁定未来网站渲染器,精确交付请同时保留导出文件和 manifest。\n- `style.palette`:paper、ink、muted、accent,均为 #RRGGBB。\n- `style.typography`:title、body、caption 三种层级,每项包含 fontFamily(sans/serif/mono)、fontSize(0.1–40)、fontWeight(100–900)、lineHeight(0.5–4)、letterSpacing(-5–10)。字号和字距以120宽参考画板为单位,固定模板按画板宽度同比换算。\n- `style.spacing`:margin(4–16)、gap(1–10),并要求 `2*margin+4*gap<=48`。标准排版据此计算标题、图片、正文和分隔线的位置;固定模板保留原构图,用 layers 显式调整位置。\n- `sources`:最多100项,每项 assetId、author、license、url、prompt、tool 全部必填,文字最多4000字符。url 是 http(s) 来源地址或 `generated`。用户照片记录来源与授权;生成图记录完整提示词和工具,不把 AI 图写成摄影原作。所有被页面引用的图片必须有来源记录。\n- `pages`:本工作流支持3–18页(这是本服务的约束,不宣称是平台实时上限)。每页 key 唯一(小写字母/数字/连字符,最多60位)、role(首项 cover,中间 body,末项 ending)、layoutId、intent(最多1000字符)、bindings、parameters。parameters 继续使用下文封闭的单页参数契约。\n- `pages[].bindings`:把实际文字元素 ID 映射到 title/body/caption,例如 `{T01:\"title\",T02:\"body\",T03:\"caption\"}`。被绑定元素必须提供文案。共享字体、墨色和纸色会参与真实渲染;单元素显式值优先覆盖。所有可见文字必须提供或明确隐藏,避免默认占位文案泄漏。图片默认色与装饰默认色取 accent。\n\n标准排版可直接沿用 T01/T02/T03/I01/D01;固定排版先读对应模板 API,元素不能混用。agent 必须控制标题长度和正文密度,不能把“已保存”当成“已验收”。同组图片建议统一比例;导出器遇到混合比例会拒绝,agent 应改用同一画板并调整内容布局。\n\n### 一次保存,一条直接结果链接\n\n```sh\n# series.json 包含完整 document,示例文件见仓库 content/studio-examples/autumn-journal.json\ncurl -fsS -X POST \"$BASE/api/studio/series\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n --data-binary @series.json\n# 返回 {design}:schemaVersion=2、document、id、revision、url、postId 等\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/series/$ID\"\n```\n\n创建响应的 `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。\n\n归档 `DELETE /api/studio/series/{id}` body `{revision}`;恢复 `POST` 同一路径 body `{revision,archived:false}`。归档后结果页和导出计划返回404,API 仍能读取并恢复;参数、素材和 Wiki post 保留。更新复用原 Wiki post,手工编辑过的正文不被覆盖。\n\n### 高质量导出与验收\n\n`GET /api/studio/series/{id}/export` 返回固定顺序的导出计划:id、revision、每页 key/role/number、filename、width/height、原生渲染 url。计划本身不生成位图。示例统一为3:4、1080×1440 PNG;其他固定模板保留自己的画板比例,不拉伸。该尺寸是本流程的交付规格;发布前还须在小红书当前发布界面复核裁切及文件限制,本站不自动发布。\n\n导出 URL 带 `&page=N&export=1`,画布在1080 CSS像素下重新布局原生文字,不把860宽或手机截图放大。图片使用 `/api/preview?source=...&width=2160&profile=export`:WebP质量95,不为常规缩略图预算降低尺寸,不放大小原件。普通展示仍使用240/480/960/1280及100KB/220KB预算。\n\n在用户已确认的 **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),不在命令行或文件中存令牌。不得在用户本机启动编译、容器构建或预览服务器。\n\n脚本逐页检查字体和图片加载、文字溢出、图片原生分辨率与裁切缩放、相同画板比例、页序、最终PNG像素、导出前后revision一致;记录实际字体、SHA256及manifest。额外生成桌面1200宽和手机390宽整组截图供视觉检查。自动检查通过后,agent 仍须逐页看PNG,核对中文缺字、主体裁切、阅读顺序、留白和系列一致性。检查失败修订 document,重新导出;不能把失败文件当成可发布结果。\n\n最终交付:真实设计结果链接、按01/02/03排序的PNG、完整design.json、manifest.json、素材来源与提示词、简短发布文案。生成图、保存成功、导出成功、人工视觉通过、PR待合并、线上部署验收应分别如实报告。未合并代码的能力不可写成线上已可用。\n\n### 可复现三页示例\n\n仓库 `content/studio-examples/autumn-journal.json` 是完整的三页内容与样式文档:封面“把秋天装进一页”、正文“先看见再记录”、结尾“留一点白给下一次”。它不含虚构素材 UUID,空图片位仅用于契约测试。真实制作时先生成银杏母图,再上传取得assetId,在每页 I01 加入该 UUID,并在 sources 记录完整来源。不要把无图夹具当作已完成作品。\n\n推荐在 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/;已成功导出的修订会核对哈希后复用,后续修订使用新目录,避免覆盖或混入旧页。\n\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 在远程执行,上传素材并填写所有页面引用后,保存真实作品,写出请求、响应和状态文件。重复运行会读取已保存状态,防止在成功后重复创建;出现网络结果不明时先核对素材/设计列表,不盲目重试。脚本仅在传入文件后写入作品,不自动发布到小红书。\n\n\n## 两类排版,同一内容模型\n\n- **标准排版**:`standard-01`,120×160 单位。沿用原 Studio 图文页的标题、正文、栏目结构,增加主题图片与分隔线。T01 主标题、T02 正文、T03 栏目、I01 图片、D01 分隔线。所有内容和位置可设置,也能增加原生元素。\n- **固定排版**:`ref-01` 至 `ref-27`,保留各自画板、出血、圆形、斜切、竖题和字体构图。先读取模板,按实际 T/I/D ID 填入内容。不能假定各模板拥有相同元素。\n\n两类单页都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。调用方无需打开 `/studio` 或操作编辑器。模板由 agent 经 API 选择,图片和文字由 API 保存,用户只打开结果链接。现有独立工具箱功能保持可用。\n\n## 1. 由 agent 通过 API 发现模板\n\n以下 curl 使用指定服务器。`PROMPT_TOKEN` 由用户通过现有登录方式提供,只保存在进程环境,不放 URL 或日志。也可使用已登录浏览器的同源 Cookie;浏览器写请求需要同源 Origin。未登录 API 返回 401。\n\n```sh\nBASE=http://8.211.154.134:18131\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/standard-01\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/ref-17\"\n```\n\n目录返回 `version, layouts, saveApi, catalogUrl, documentationUrl`。每项包含 `id,name,family,url,api`,family 为 standard/fixed。模板返回尺寸、元素 ID/类型/role/box/direction/text、默认 parameters、workspaceSchema 与页面 URL。`?agent_mode=True` 返回纯文本文档,业务请求移除此参数。\n\n## 2. 生成图片,上传一次,显示压缩衍生图\n\n先让 coding agent 按生成图片文档产出文件;Studio 不暗中调用生成模型。图片可以来自用户素材或生成结果。上传走现有素材 API:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n -F kind=image -F assetOnly=true -F files=@hero.png \"$BASE/api/upload\"\n```\n\n返回 `assets` UUID 数组及对应 `files` 元数据。取 `assets[0]` 作为 assetId。上传不单独发帖;设计创建时才自动关联 Wiki post。不要把 Base64、任意远程 URL、HTML 或 SVG 塞入参数。\n\n原素材保留在素材库,**页面展示只请求压缩 WebP**。以 assetId 引用素材,不必自行再上传缩略图:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n \"$BASE/api/preview?source=%2Fapi%2Fmedia%2FASSET_UUID&width=960\" -o hero-preview.webp\n```\n\nwidth 只允许 240/480/960/1280;小图预算 100KB,大图预算 220KB,复杂图片会降低尺寸/质量。原生页面按槽位显示宽度和像素密度选择 srcset 衍生图,保留 fit/cropX/cropY/zoom。目录使用原生文字与 CSS 色块,不加载参考原图;旧工具箱参考图也使用压缩预览。`/api/media/{id}` 是素材原件读取接口,不应用作展示图片的 src。\n\n## 3. 创建:POST /api/studio/designs\n\n创建和更新共用内容字段 `name,parameters`;`schemaVersion` 可省略,单页当前支持 1(多页使用上述独立 series API 的2)。创建额外传 `layoutId`,不传 revision 或 id。\n\n```sh\ncurl -fsS -X POST \"$BASE/api/studio/designs\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"schemaVersion\":1,\"layoutId\":\"standard-01\",\"name\":\"观察日常\",\"parameters\":{\"paper\":\"#edf0ef\",\"texts\":{\"T01\":{\"value\":\"观察日常\\n记录灵感\",\"fontFamily\":\"serif\",\"color\":\"#23424b\"},\"T02\":{\"value\":\"让每一页只讲一件事。\",\"lineHeight\":1.4}},\"images\":{\"I01\":{\"color\":\"#73929a\",\"fit\":\"cover\",\"cropX\":50,\"cropY\":45,\"zoom\":1}},\"decorations\":{\"D01\":{\"color\":\"#73929a\"}}}}'\n```\n\n有图片时在 I01 加 `assetId:\"上传返回的UUID\"`。固定排版只需改为所选 `layoutId`,并按 schema 选择元素 ID。例如 ref-01 支持 `texts.T01`、`texts.T02` 与 `images.I01`。\n\n201 返回 `{design}`。design 包含:\n\n| 字段 | 含义 |\n| --- | --- |\n| id / layoutId / family | 持久化 UUID、不可变模板 ID、standard 或 fixed |\n| schemaVersion / revision | 参数契约版本(1)、记录修订号(从1递增) |\n| name / parameters | 名称与规范化内容;省略的分组返回空对象 |\n| url / renderUrl | 同一个原生 HTML/CSS 页面 URL |\n| schemaUrl | 当前模板参数地址 |\n| postId / postUrl | 自动创建并在后续更新中复用的 Wiki post |\n| archived / archivedAt | 可恢复归档状态与时间 |\n| createdAt / updatedAt | 创建与最后修改时间 |\n| legacySvgUrl | 固定排版的旧 SVG 导出地址,标准排版为 null |\n\n创建设计与 Wiki post 在同一数据库事务内完成,失败整体回滚。更新同一设计复用同一 post;手工编辑过的 Wiki 正文、标题、标签、封面不会被参数更新覆盖。\n\n## 4. 读取与打开\n\n```sh\nID=创建返回的UUID\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs/$ID\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs?layoutId=standard-01&limit=20\"\n```\n\n读取返回 `{design}`,没有副作用。列表默认只列未归档记录;`archived=true` 只列归档记录;limit 为1–50,默认20;`nextCursor` 作为下一页 before。layoutId 可省略。\n\n直接打开创建响应中的 `design.url`:`/studio/template-layout?id={UUID}`。这个 UUID 对应数据库里 API 保存的模板、图片与文字,服务端按记录渲染最终 HTML/CSS。链接中无需携带 layoutId,无需打开 `/studio` 选择模板,也没有编辑按钮、双击编辑或右键编辑。`standard-01` 与 `ref-17` 是模板 ID,不是保存记录的 UUID。\n\n`/studio/designs/{UUID}` 与旧 `/studio/layouts/{layoutId}?id={UUID}` 兼容跳转到同一个结果页。缺失、非法、不存在或已归档的 ID 返回404,不会显示空模板或编辑器;旧链接模板与记录不匹配也返回404。归档设计仍能经 API 读取以恢复。\n\n## 5. 更新:PATCH /api/studio/designs/{id}\n\n先 GET,保留所有未改字段,再整体替换 parameters;不是深度合并。以下假设刚读到 revision=1:\n\n```sh\ncurl -fsS -X PATCH \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"name\":\"观察日常 · 修订\",\"revision\":1,\"parameters\":{\"texts\":{\"T01\":{\"value\":\"新的标题\"}},\"images\":{},\"decorations\":{}}}'\n```\n\n200 返回新 design,revision 增加。过期 revision 返回409,重新 GET 并合并后再提交;不要盲目重试。不接受 layoutId、id、url、postId 等返回字段;不能直接把整个 design 作为 body。另存副本使用 POST 创建新 UUID。\n\n## 6. 删除与恢复\n\nDELETE 采用可恢复归档,不物理删除参数、素材或 Wiki post。和更新一样必须提供最新 revision;自动同步关联 post 的归档状态。\n\n```sh\ncurl -fsS -X DELETE \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":2}'\n# 删除后读取新 revision,再恢复。此例删除后为3:\ncurl -fsS -X POST \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":3,\"archived\":false}'\n```\n\n两者200返回 `{design}`,revision 递增。归档记录 PATCH 返回409,先恢复。重复写入仍需最新 revision;409不会修改数据。独立归档 Wiki post 只影响笔记发现状态,不等于删除设计。旧设计缺 Wiki 入口可调用 `POST /api/studio/designs/{id}/post`,body `{}`,首次201、已有200;不改变 revision。\n\n## 参数清单(两类共用)\n\nparameters 顶层允许 texts/images/decorations/paper/layers/added;全部可省略,不能添加未知字段。最大请求64KB。颜色都为 `#RRGGBB`。坐标与字号使用模板单位,不是 CSS 像素。\n\n| 分组 | 字段与约束 |\n| --- | --- |\n| texts.{文字ID} | value 必填,可空,最多500字/8行;color;scale 0.25–2;align start/middle/end;fontFamily sans/serif/mono;fontSize 0.1–1000;fontWeight 100–900;lineHeight 0.5–4;letterSpacing -20–100 |\n| images.{图片ID} | assetId(已存在的图片 UUID);color;fit cover/contain;cropX/cropY 0–100(默认50);zoom 1–5(默认1) |\n| decorations.{装饰ID} | color;hidden 布尔值 |\n| paper | 纸张颜色 |\n| layers.{元素ID} | x/y -10000–10000;width/height 0.1–10000;rotation -360–360;opacity 0–1;radius 0–5000;z -1000–1000;hidden/locked 布尔值 |\n| added | 最多100项:id 为 user- 加1–60位字母/数字/连字符,唯一;kind text/image/decoration;role 最多80字;box 为 [x,y,width,height];direction 必须 horizontal;text 为文字或null;reading 必须 custom;装饰可带 shape rectangle/ellipse |\n\n新增元素先在 added 声明,然后在 texts/images/decorations/layers 用该 ID 配置。locked 控制工作区交互,不禁止 agent 更新。数据是封闭结构,无脚本执行。未来扩展新增契约版本;v1 不接受未知字段/版本。现有省略 schemaVersion 的请求继续按v1处理,旧记录无需重建,布局 ID 和页面 UUID 保持不变。默认视觉可随模板美化变化;要求固定外观时显式设置配色、字体和图层参数。\n\n错误:400 参数/版本/构件类型/图片 ID 不合法;401 未登录;404 模板或设计不存在;409 修订冲突或已归档;405 方法不支持;413 请求超限;500 服务失败。压缩预览失败可能返回503与 Retry-After。\n\n## 现有工具箱与兼容导出\n\n工具功能保留:[ref-17 同一母版四张试版](/studio/ideas/learning-skills-template)、[ref-01/07/26 三种构图试版](/studio/ideas/layout-selection)、[最初图文页](/studio/ideas/first-idea)。旧封面工具文档已合并到本篇,旧文档 URL 保留为归档说明,避免重复顶级入口。\n\n旧无状态 API:`/studio/api/export/learning-skills-template` 和 `/studio/api/export/layout-selection` 支持 GET/POST,不保存设计。前者参数 slide=0..3、heading(1–6字)、body(最多360字/3行)、color/accent、masthead(2项);后者 layoutId=ref-01/ref-07/ref-26、fields(对应T ID,1–120字/4行)、color/accent。GET 默认返回方案 JSON;GET format=svg 导出初始图;POST format=svg(默认)导出 SVG,format=json 返回参数,最大32KB。first-idea 仅支持 GET 方案/Markdown。\n\n```sh\ncurl -fsS -X POST \"$BASE/studio/api/export/learning-skills-template?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"slide\":1,\"heading\":\"笔记复习\",\"color\":\"#7a8b7e\"}' -o cover.svg\ncurl -fsS -X POST \"$BASE/studio/api/export/layout-selection?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"layoutId\":\"ref-01\",\"fields\":{\"T01\":\"STUDY\"},\"color\":\"#83918a\"}' -o selection.svg\n```\n\n固定排版 `/studio/api/layouts/ref-01` 的 GET format=svg、POST 仍提供旧分层 SVG;POST format=json 只验证和返回规范化参数。**旧 SVG 不反映 added、layers、扩展字体和裁切参数**,不能用来证明原生效果。standard-01 不支持 SVG。原生页面应直接打开返回 URL,;多页可按上文通过导出器生成PNG。代码可扩展渲染能力,但每次是否使用、选择哪些参数均由 coding agent 决定。\n\n## 固定排版索引\n\n读取 /studio/api/layouts 获取全部27套构图。\n"; export function studioDesignDocumentation(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||'')||String(req.query.agent_mode).toLowerCase()!=='true')return false;res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')res.status(200).end();else res.status(200).send(designDocs);return true} diff --git a/apps/waterfall-wiki/src/lib/studio-design-posts.ts b/apps/waterfall-wiki/src/lib/studio-design-posts.ts index f998c00..a9c5167 100644 --- a/apps/waterfall-wiki/src/lib/studio-design-posts.ts +++ b/apps/waterfall-wiki/src/lib/studio-design-posts.ts @@ -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 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)=>`/studio/template-layout?id=${design.id}`; -function postDescription(design:DesignRow){return `已保存的 ${design.layout_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{ 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此笔记关联 API 保存的模板、图片与文字;直接打开链接即可看到结果,无需选择排版或进入编辑器。`; + 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]); diff --git a/apps/waterfall-wiki/src/lib/studio-designs.ts b/apps/waterfall-wiki/src/lib/studio-designs.ts index 4a0e90d..6a7a5d5 100644 --- a/apps/waterfall-wiki/src/lib/studio-designs.ts +++ b/apps/waterfall-wiki/src/lib/studio-designs.ts @@ -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){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){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){ const p=await validateDesign(input.layoutId,input.parameters); @@ -23,6 +23,7 @@ export async function updateDesign(id:string,input:z.infer{ 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); diff --git a/apps/waterfall-wiki/src/lib/studio-series.ts b/apps/waterfall-wiki/src/lib/studio-series.ts new file mode 100644 index 0000000..1045509 --- /dev/null +++ b/apps/waterfall-wiki/src/lib/studio-series.ts @@ -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,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} diff --git a/apps/waterfall-wiki/src/pages/api/index.ts b/apps/waterfall-wiki/src/pages/api/index.ts index 1e378cf..e7332db 100644 --- a/apps/waterfall-wiki/src/pages/api/index.ts +++ b/apps/waterfall-wiki/src/pages/api/index.ts @@ -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'}, diff --git a/apps/waterfall-wiki/src/pages/api/preview.ts b/apps/waterfall-wiki/src/pages/api/preview.ts index d82da4e..ddb0d03 100644 --- a/apps/waterfall-wiki/src/pages/api/preview.ts +++ b/apps/waterfall-wiki/src/pages/api/preview.ts @@ -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};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){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};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){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}}; diff --git a/apps/waterfall-wiki/src/pages/api/studio/series/[id].ts b/apps/waterfall-wiki/src/pages/api/studio/series/[id].ts new file mode 100644 index 0000000..764a53e --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/studio/series/[id].ts @@ -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'}}}; diff --git a/apps/waterfall-wiki/src/pages/api/studio/series/[id]/export.ts b/apps/waterfall-wiki/src/pages/api/studio/series/[id]/export.ts new file mode 100644 index 0000000..b4fc4f6 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/studio/series/[id]/export.ts @@ -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)} +} diff --git a/apps/waterfall-wiki/src/pages/api/studio/series/index.ts b/apps/waterfall-wiki/src/pages/api/studio/series/index.ts new file mode 100644 index 0000000..89b50da --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/studio/series/index.ts @@ -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'}}}; diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index 8dbf77d..717abf7 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -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]){ @@ -60,6 +63,20 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi 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/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]); diff --git a/apps/waterfall-wiki/tests/preview.test.ts b/apps/waterfall-wiki/tests/preview.test.ts index 482cb30..5830574 100644 --- a/apps/waterfall-wiki/tests/preview.test.ts +++ b/apps/waterfall-wiki/tests/preview.test.ts @@ -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)}); diff --git a/content/studio-examples/autumn-journal.json b/content/studio-examples/autumn-journal.json new file mode 100644 index 0000000..9d02696 --- /dev/null +++ b/content/studio-examples/autumn-journal.json @@ -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 + } + } + } + } + ] + } +} diff --git a/content/studio-tools/layouts-api.md b/content/studio-tools/layouts-api.md index 91493b8..da7140d 100644 --- a/content/studio-tools/layouts-api.md +++ b/content/studio-tools/layouts-api.md @@ -13,12 +13,68 @@ 不要编造 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 错套到新模板。调用方无需打开 `/studio` 或操作编辑器。模板由 agent 经 API 选择,图片和文字由 API 保存,用户只打开结果链接。现有独立工具箱功能保持可用。 +两类单页都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。调用方无需打开 `/studio` 或操作编辑器。模板由 agent 经 API 选择,图片和文字由 API 保存,用户只打开结果链接。现有独立工具箱功能保持可用。 ## 1. 由 agent 通过 API 发现模板 @@ -55,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" \ @@ -153,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 决定。 ## 固定排版索引 diff --git a/scripts/check-studio-contracts.mjs b/scripts/check-studio-contracts.mjs new file mode 100644 index 0000000..73e75fb --- /dev/null +++ b/scripts/check-studio-contracts.mjs @@ -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'); diff --git a/scripts/deliver-studio-series.sh b/scripts/deliver-studio-series.sh new file mode 100644 index 0000000..d36ab04 --- /dev/null +++ b/scripts/deliver-studio-series.sh @@ -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 diff --git a/scripts/export-studio-series.cjs b/scripts/export-studio-series.cjs new file mode 100644 index 0000000..32e51f6 --- /dev/null +++ b/scripts/export-studio-series.cjs @@ -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)}); diff --git a/scripts/publish-studio-guide.sh b/scripts/publish-studio-guide.sh new file mode 100644 index 0000000..3ece921 --- /dev/null +++ b/scripts/publish-studio-guide.sh @@ -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 diff --git a/scripts/publish-studio-library.mjs b/scripts/publish-studio-library.mjs index f8422ea..e76c4b3 100644 --- a/scripts/publish-studio-library.mjs +++ b/scripts/publish-studio-library.mjs @@ -9,6 +9,8 @@ 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. @@ -27,7 +29,7 @@ if(current.editableMarkdown.includes(appendixMarker)){ } const tagIds=entry=>[...new Set([...(entry.systemTags||[]).map(t=>t.id),...entry.categories.map(name=>tags.find(t=>t.name===name)?.id).filter(Boolean)])]; const body=(entry,text,title=entry.title)=>({title,description:entry.description,markdown:text,tags:tagIds(entry),assets:entry.assets,coverAssetId:entry.coverAssetId,revision:entry.revision}); -await api('/api/notes/'+canonical,{...body(current,markdown,'Studio 排版 API · 标准图文与27套固定构图'),description:'coding agent 经 HTTP 选择标准图文或27套固定构图,上传压缩展示素材,创建、读取、更新、归档与恢复稳定 ID 原生页面;含兼容工具箱。'},'PUT'); +await api('/api/notes/'+canonical,{...body(current,markdown,'Studio 图文 API · 多页作品与统一视觉设计'),description:'coding agent 制定主题分镜与统一视觉,生成配图,经 HTTP 保存可修订多页作品;凭稳定 ID 直接打开原生结果并在远程导出高清图片。'},'PUT'); const redirectText=`# Studio 文档已合并\n\n[前往 Studio 排版 API 统一入口](/explore/${canonical})\n\n[可视化 API 库](/studio) · [原 ref-17 工具](/studio/ideas/learning-skills-template) · [原三种构图工具](/studio/ideas/layout-selection)\n\n完整原说明与代码示例已保留在统一入口的兼容工具章节。此记录仅可恢复归档,未删除工具、素材或设计。`; await api('/api/notes/'+legacy,body(old,redirectText),'PUT'); await api('/api/posts/'+legacy+'/archive',{archived:true}); diff --git a/scripts/save-studio-series.mjs b/scripts/save-studio-series.mjs new file mode 100644 index 0000000..ef0897a --- /dev/null +++ b/scripts/save-studio-series.mjs @@ -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}));