Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3e9f5a3fb | |||
| 922c7bd0d0 | |||
| 3f8bd11a17 | |||
| e2bc4ae6bd | |||
| ef66f687bd | |||
| f5422b1541 | |||
| 19bba57561 | |||
| 906c6985cb |
@@ -10,3 +10,11 @@ dist/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
|
||||
# Content images belong in S3; metadata lives in managed-media.json.
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.webp
|
||||
*.gif
|
||||
*.avif
|
||||
|
||||
@@ -34,6 +34,7 @@ COPY --from=build --chown=node:node /app/db/ ./db/
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
|
||||
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
||||
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,4 +1,74 @@
|
||||
// Run against an already started Studio. BASE_URL is the origin, including port.
|
||||
// Supply authorized browser state for the server. Local acceptance was explicitly authorized for this revision.
|
||||
const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto((process.env.BASE_URL||'http://localhost:3018')+'/studio/infinite_canvas');await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'添加笔记',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByLabel('节点名称').fill('Smoke note');await page.getByLabel('笔记内容').fill('A saved idea');await page.reload();await page.getByText('Smoke note',{exact:true}).first().waitFor();assert.equal(await page.locator('article').count(),4);await page.getByText('Smoke note',{exact:true}).first().click();await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByRole('button',{name:'适应画布'}).click();const card=page.locator('article').last();const before=await card.getAttribute('style');const box=await card.locator('div').first().boundingBox();await page.mouse.move(box.x+70,box.y+20);await page.mouse.down();await page.mouse.move(box.x+120,box.y+45,{steps:5});await page.mouse.up();assert.notEqual(await card.getAttribute('style'),before);const download=page.waitForEvent('download');await page.getByRole('button',{name:'导出项目'}).click();assert.equal((await download).suggestedFilename(),'studio-canvas.json');if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-desktop.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png',fullPage:true});assert.deepEqual(errors,[]);console.log('Canvas browser smoke passed: create, edit, restore, delete, undo, fit, export, mobile.')}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
const fs=require('node:fs/promises');
|
||||
(async()=>{
|
||||
const base=process.env.BASE_URL;
|
||||
assert.ok(['http://8.211.154.134:18131','http://127.0.0.1:3024'].includes(base),'Use the designated server or the explicitly authorized local preview');
|
||||
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true}:{channel:'chrome',headless:true});
|
||||
try{
|
||||
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
|
||||
const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto(base+'/studio/infinite_canvas');
|
||||
assert.equal(new URL(page.url()).pathname,'/studio/infinite_canvas','Login required; authenticate without weakening protection');
|
||||
await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();
|
||||
// A fresh context is used so this test cannot overwrite a human browser draft.
|
||||
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
|
||||
const node=id=>page.locator(`[data-node-id="${id}"]`);
|
||||
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
|
||||
async function drag(handle,dx,dy){const box=await handle.boundingBox();assert.ok(box);await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();await page.mouse.move(box.x+box.width/2+dx,box.y+box.height/2+dy,{steps:12});await page.mouse.up()}
|
||||
for(const scale of [0,1]){
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
if(scale)await page.getByRole('button',{name:'放大',exact:true}).click();
|
||||
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
|
||||
for(const id of ['brief','reference']){
|
||||
const before=(await snapshot()).cards.find(c=>c.id===id);
|
||||
await drag(node(id).locator('[title="拖动手柄 · 移动工具"]'),45,25);
|
||||
const after=(await snapshot()).cards.find(c=>c.id===id);close(after.x-before.x,45/z);close(after.y-before.y,25/z);
|
||||
}
|
||||
}
|
||||
const before=(await snapshot()).cards.find(c=>c.id==='brief');
|
||||
await node('brief').locator('textarea').fill('真实工具内编辑 · 输入不能移动节点');
|
||||
const edited=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(edited.x,before.x);assert.equal(edited.y,before.y);
|
||||
const resize=node('brief').getByRole('button',{name:/调整/});await drag(resize,60,40);
|
||||
assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320);
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(c=>c.id==='brief').width,undefined);
|
||||
await page.getByRole('button',{name:'重做',exact:true}).click();assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320);
|
||||
// Each SVG endpoint must agree with the actual transformed node edge, after resize and pan.
|
||||
async function wires(){const mismatch=await page.evaluate(()=>{
|
||||
return [...document.querySelectorAll('svg path')].filter(p=>p.getAttribute('d')?.startsWith('M ')).slice(0,1).some(p=>{const d=p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),world=p.closest('div').getBoundingClientRect();const matrix=p.getScreenCTM();const pt=new DOMPoint(d[0],d[1]).matrixTransform(matrix);const card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(pt.x-card.right)>3||Math.abs(pt.y-(card.top+card.height/2))>3});
|
||||
});assert.equal(mismatch,false)}
|
||||
// The first link starts at brief; check it specifically.
|
||||
await wires();
|
||||
await page.getByRole('button',{name:'移动画布',exact:true}).click();
|
||||
const viewport=page.locator('[class*="viewport"]');const box=await viewport.boundingBox();await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+100,box.y+80,{steps:8});await page.mouse.up();
|
||||
await wires();
|
||||
const saved=await snapshot(),view=await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view'));
|
||||
await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.deepEqual(await snapshot(),saved);assert.equal(await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view')),view);
|
||||
// Zoom must preserve the world coordinate beneath the pointer.
|
||||
const vp=await viewport.boundingBox();assert.equal(vp.x,0);assert.equal(vp.y,0);assert.equal(vp.width,1600);assert.equal(vp.height,1100);
|
||||
const anchor={x:vp.x+vp.width*.65,y:vp.y+vp.height*.25};
|
||||
const worldAt=()=>page.locator('[class*=world]').evaluate((el,p)=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(p.x-m.e)/m.a,y:(p.y-m.f)/m.d}},anchor);
|
||||
const point=await worldAt();await page.mouse.move(anchor.x,anchor.y);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
|
||||
await page.waitForFunction(old=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1.view')).zoom!==JSON.parse(old).zoom,view);
|
||||
const pointAfter=await worldAt();close(point.x,pointAfter.x);close(point.y,pointAfter.y);
|
||||
await page.getByRole('button',{name:'选择工具',exact:true}).click();
|
||||
await page.getByRole('button',{name:'添加笔记',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.length,saved.cards.length+1);
|
||||
await page.getByRole('button',{name:/操作$/}).last().click();
|
||||
await page.getByRole('button',{name:'复制节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2);
|
||||
await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1);
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2);
|
||||
await page.getByRole('button',{name:'重做',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1);
|
||||
if(await page.getByRole('button',{name:'关闭画布操作',exact:true}).isVisible())await page.getByRole('button',{name:'关闭画布操作',exact:true}).click();
|
||||
// Return to the saved three-node document through undo.
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).click();
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();
|
||||
const dock=await page.getByRole('navigation',{name:'画布工具'}).boundingBox();assert.ok(dock.y>950);assert.ok(dock.x>500);
|
||||
await page.getByRole('button',{name:'切换明暗主题'}).click();assert.equal(await page.locator('main').getAttribute('data-theme'),'light');await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light');
|
||||
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-light.png'});
|
||||
await page.getByRole('button',{name:'切换明暗主题'}).click();
|
||||
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-rebuild.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));const mobile=await viewport.boundingBox();assert.equal(mobile.width,390);assert.equal(mobile.height,844);await page.getByRole('button',{name:'适应画布',exact:true}).click();await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png'});}
|
||||
assert.deepEqual(errors,[]);console.log('Passed: two-node drag at two scales, inline input, resize, undo/redo, wires, pan, reload, pointer zoom, CRUD and full-screen desktop/mobile');
|
||||
}finally{await browser.close()}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
|
||||
@@ -6,13 +6,13 @@ const standardId='00000000-0000-4000-8000-000000000003';
|
||||
const seriesId='00000000-0000-4000-8000-000000000004';
|
||||
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
|
||||
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
|
||||
let reads=0;
|
||||
const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(req.url.endsWith(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'}}}))});
|
||||
let reads=0,mediaReads=0;
|
||||
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;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,[process.env.STUDIO_STANDALONE_SERVER || '.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 entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
|
||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||
assert.equal(reads,0,'Default pages never load a saved design');
|
||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
|
||||
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));
|
||||
|
||||
@@ -4,6 +4,22 @@ import type {Parameters} from './layout-parameters';
|
||||
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
|
||||
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
|
||||
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
for(const id of ids){const url='http://waterfall-wiki:3000/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
|
||||
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** Export a self-contained SVG without storing image bytes in template JSON. */
|
||||
export async function embedSvgMedia(req:NextApiRequest,svg:string){
|
||||
const paths=[...new Set(svg.match(/\/api\/media\/[0-9a-f-]{36}/g)||[])];
|
||||
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
let total=0;
|
||||
for(const path of paths){
|
||||
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
|
||||
const mime=response.headers.get('content-type')?.split(';')[0];
|
||||
if(!response.ok||!mime||!['image/png','image/jpeg','image/webp'].includes(mime))throw Error('模板图片不可访问');
|
||||
const chunks:Uint8Array[]=[];const reader=response.body!.getReader();
|
||||
try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('模板图片总计超限');chunks.push(value);}}finally{await reader.cancel();}
|
||||
svg=svg.replaceAll(path,'data:'+mime+';base64,'+Buffer.concat(chunks).toString('base64'));
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {embedSvgMedia} from '../../../lib/layout-images';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {serveApiDocumentation} from '../../../lib/api-documentation';
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
@@ -5,7 +6,7 @@ import {ideas,ideaMarkdown} from '../../../content/ideas';
|
||||
import {selections} from '../../../components/SourceCover';
|
||||
import {renderCover} from '../../../lib/render-cover';
|
||||
import {tutorialPost} from '../../../content/tool-links';
|
||||
export default function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;
|
||||
if(!['GET','POST'].includes(req.method||'')){res.setHeader('Allow','GET, POST');return res.status(405).end();}
|
||||
const slug=String(req.query.slug||''),idea=ideas.find(i=>i.slug===slug);if(!idea)return res.status(404).end();
|
||||
@@ -17,7 +18,7 @@ export default function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
const result=renderCover(slug,input);res.setHeader('Cache-Control','no-store');
|
||||
res.setHeader('Content-Disposition',`attachment; filename="${slug}.${req.query.format==='json'?'json':'svg'}"`);
|
||||
if(req.query.format==='json')return res.json(result.data);
|
||||
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(result.node));
|
||||
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(result.node)));
|
||||
}catch(error){return res.status(400).json({error:error instanceof Error?error.message:'无效参数'});}
|
||||
}
|
||||
if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send(ideaMarkdown(idea)+'\n\n使用教程:'+tutorialPost+(idea.tool?'\n\n```json\n'+JSON.stringify(selections,null,2)+'\n```':''));}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {embedSvgMedia} from '../../../lib/layout-images';
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {embedLayoutImages} from '../../../lib/layout-images';
|
||||
import {createElement} from 'react';
|
||||
@@ -25,6 +26,6 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
||||
}
|
||||
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}const layout=layouts.find(l=>l.id===req.query.id);if(!layout)return res.status(404).json({error:'排版不存在'});
|
||||
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)})))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
||||
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)}))))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
||||
}
|
||||
export const config={api:{bodyParser:{sizeLimit:'64kb'}}};
|
||||
|
||||
@@ -1,34 +1,41 @@
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight} from 'lucide-react';
|
||||
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight,Sun,Moon} from 'lucide-react';
|
||||
import s from '../../styles/infinite-canvas.module.css';
|
||||
type Card={id:string;kind:'text'|'image'|'prompt';title:string;text:string;x:number;y:number;image?:string};
|
||||
type Card={id:string;kind:'text'|'image'|'prompt';title:string;text:string;x:number;y:number;image?:string;width?:number;height?:number};
|
||||
type Doc={version:1;cards:Card[];links:[string,string][]};
|
||||
const initial:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
|
||||
const key='studio.infinite-canvas.v1';
|
||||
function valid(v:unknown):v is Doc{const d=v as Doc;return !!d&&d.version===1&&Array.isArray(d.cards)&&d.cards.length<=80&&d.cards.every(c=>c&&typeof c.id==='string'&&c.id.length<100&&['text','image','prompt'].includes(c.kind)&&typeof c.title==='string'&&c.title.length<=200&&typeof c.text==='string'&&c.text.length<=10000&&Number.isFinite(c.x)&&Number.isFinite(c.y)&&Math.abs(c.x)<100000&&Math.abs(c.y)<100000&&(!c.image||(/^data:image\/(png|jpeg|webp);base64,/.test(c.image)&&c.image.length<3000000)))&&new Set(d.cards.map(c=>c.id)).size===d.cards.length&&Array.isArray(d.links)&&d.links.length<=200&&d.links.every(l=>Array.isArray(l)&&l.length===2&&l[0]!==l[1]&&l.every(id=>d.cards.some(c=>c.id===id)));}
|
||||
function valid(v:unknown):v is Doc{const d=v as Doc;return !!d&&d.version===1&&Array.isArray(d.cards)&&d.cards.length<=80&&d.cards.every(c=>c&&typeof c.id==='string'&&c.id.length<100&&['text','image','prompt'].includes(c.kind)&&typeof c.title==='string'&&c.title.length<=200&&typeof c.text==='string'&&c.text.length<=10000&&Number.isFinite(c.x)&&Number.isFinite(c.y)&&Math.abs(c.x)<100000&&Math.abs(c.y)<100000&& (c.width===undefined||(Number.isFinite(c.width)&&c.width>=260&&c.width<=1200))&&(c.height===undefined||(Number.isFinite(c.height)&&c.height>=300&&c.height<=1200))&&(!c.image||(/^data:image\/(png|jpeg|webp);base64,/.test(c.image)&&c.image.length<3000000)))&&new Set(d.cards.map(c=>c.id)).size===d.cards.length&&Array.isArray(d.links)&&d.links.length<=200&&d.links.every(l=>Array.isArray(l)&&l.length===2&&l[0]!==l[1]&&l.every(id=>d.cards.some(c=>c.id===id)));}
|
||||
export default function InfiniteCanvas(){
|
||||
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState('');
|
||||
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc}|null>(null);
|
||||
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[theme,setTheme]=useState<'light'|'dark'>('dark');
|
||||
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc;resize?:boolean}|null>(null);
|
||||
const active=doc.cards.find(c=>c.id===selected);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=JSON.parse(raw);if(valid(d)){setDoc(d);setStatus('已恢复本机草稿')}}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=JSON.parse(raw);if(valid(d)){setDoc(d);setStatus('已恢复本机草稿')}}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}try{const t=localStorage.getItem(key+'.theme');setTheme(t==='light'?'light':'dark')}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
|
||||
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
|
||||
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]);
|
||||
useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]);
|
||||
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
|
||||
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.theme',theme)}catch{}},[theme,ready]);
|
||||
function scaleAt(next:number,cx?:number,cy?:number){const el=viewport.current;if(!el)return;const r=el.getBoundingClientRect(),x=cx===undefined?r.width/2:cx-r.left,y=cy===undefined?r.height/2:cy-r.top;const z=Math.max(.2,Math.min(2,next));setPan({x:x-(x-pan.x)*z/zoom,y:y-(y-pan.y)*z/zoom});setZoom(z)}
|
||||
function duplicate(){if(!active||doc.cards.length>=80)return;const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');commit({...doc,cards:[...doc.cards,{...active,id,title:active.title.slice(0,197)+' 副本',x:active.x+40,y:active.y+40}]});select(id)}
|
||||
function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)}
|
||||
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'参考素材':'生成提示词',text:'',x:((rect?.width||700)/2-pan.x)/zoom-160,y:(160-pan.y)/zoom,image}]});select(id)}
|
||||
function change(field:'title'|'text',value:string){commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,[field]:value}:c)})}
|
||||
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'参考素材':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)}
|
||||
function change(field:'title'|'text',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...c,[field]:value}:c)}))}
|
||||
function beginEdit(){setPast(p=>[...p.slice(-29),doc]);setFuture([])}
|
||||
function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1))}
|
||||
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1))}
|
||||
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+320))-minX,h=Math.max(...doc.cards.map(c=>c.y+350))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
|
||||
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...doc.cards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
|
||||
function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
return <main className={s.app}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
<header className={s.header}><a href="/studio" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/studio" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
|
||||
return <main className={s.app} data-theme={theme}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
<header className={s.header}><a href="/studio" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/studio" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><button aria-label="切换明暗主题" title={theme==='dark'?'切换浅色模式':'切换深色模式'} onClick={()=>setTheme(t=>t==='dark'?'light':'dark')}>{theme==='dark'?<Sun size={17}/>:<Moon size={17}/>}</button><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
|
||||
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
|
||||
<section className={s.canvasArea}><div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
|
||||
<div className={s.viewport} ref={viewport} onWheel={e=>{if(e.ctrlKey||e.metaKey){setZoom(z=>Math.max(.2,Math.min(2,z-e.deltaY*.002)))}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{if(e.target===e.currentTarget||tool==='hand'){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}:c)})}} onPointerUp={()=>{const d=drag.current;if(d&&d.id!=='pan'){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{drag.current=null}}>
|
||||
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+320} ${from.y+150} C ${from.x+365} ${from.y+150},${to.x-45} ${to.y+150},${to.x} ${to.y+150}`}/>})}</svg>
|
||||
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} style={{left:c.x,top:c.y}} onClick={()=>select(c.id)}><div className={s.cardHead} onPointerDown={e=>{if(tool==='hand')return;e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}}><span>{c.kind==='text'?<Type size={15}/>:c.kind==='image'?<ImagePlus size={15}/>:<Sparkles size={15}/>} {c.title}</span><span>⠿</span></div>{c.kind==='image'?<div className={s.image} onClick={()=>{select(c.id);file.current?.click()}}>{c.image?<img src={c.image} alt={c.title}/>:<><div className={s.landscape}><span>REF.</span><b>光的形状</b><small>LIGHT / SPACE / EMOTION</small></div><span className={s.sample}>示例占位 · 点击上传参考图</span></>}</div>:<div className={s.cardBody}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><p>{c.text||'选择节点,在右侧写下你的想法。'}</p></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<span className={s.port}/></footer></article>)}
|
||||
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.2,z-.1))}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
|
||||
<aside className={s.inspector}><div className={s.panelHeading}><Workflow size={17}/><strong>节点属性</strong><span>{doc.cards.length} 个节点</span></div>{active?<><span className={s.eyebrow}>INSPECTOR</span><h2>{active.kind==='prompt'?'把想法描述清楚':active.kind==='image'?'让参考彼此连接':'记录灵感的起点'}</h2><label>节点名称<input maxLength={200} value={active.title} onChange={e=>change('title',e.target.value)}/></label><label>{active.kind==='prompt'?'画面描述':'笔记内容'}<textarea maxLength={10000} value={active.text} onChange={e=>change('text',e.target.value)} placeholder="写下你的创作想法…"/></label>{active.kind==='image'&&<button className={s.wide} onClick={()=>file.current?.click()}><ImagePlus size={16}/>选择参考图片</button>}{active.kind==='prompt'&&<div className={s.notice}><Sparkles size={18}/><strong>为下一次创作做好准备</strong><p>提示词可保存在项目中。当前没有连接模型服务,暂不支持 AI 图片或视频生成。</p></div>}<label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button className={s.wide} disabled={!link} onClick={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[selected,link]]});setLink('')}}><Link2 size={16}/>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button className={s.connection} key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>↗ {doc.cards.find(c=>c.id===(a===selected?b:a))?.title} <span>断开 ×</span></button>)}<button className={s.delete} onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('')}}><Trash2 size={15}/>删除节点</button></>:<><h2>给灵感留一点空间</h2><p>选择画布中的节点来编辑,或从左侧添加新内容。</p><button className={s.wide} onClick={()=>add('text')}><Plus size={16}/>添加笔记</button></>}<div className={s.help}>拖动节点标题移动节点<br/>滚轮平移 · Ctrl / ⌘ + 滚轮缩放<br/>草稿仅存于当前浏览器,请及时导出。</div></aside></div>
|
||||
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
|
||||
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
|
||||
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} data-node-id={c.id} style={{left:c.x,top:c.y,width:c.width||320,height:c.height||350}} onClick={()=>select(c.id)}><div className={s.cardHead} title="拖动手柄 · 移动工具" aria-label={`移动 ${c.title}`} onPointerDown={e=>{if(tool==='hand'||e.button!==0)return;e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}}><span>{c.kind==='text'?<Type size={15}/>:c.kind==='image'?<ImagePlus size={15}/>:<Sparkles size={15}/>} {c.title}</span><span>⠿</span></div>{c.kind==='image'?<div className={s.image} onClick={()=>{if(tool==='hand')return;select(c.id);file.current?.click()}}>{c.image?<img src={c.image} alt={c.title}/>:<><div className={s.landscape}><span>REF.</span><b>光的形状</b><small>LIGHT / SPACE / EMOTION</small></div><span className={s.sample}>示例占位 · 点击上传参考图</span></>}</div>:<div className={s.cardBody}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><input aria-label={`${c.title} 名称`} value={c.title} maxLength={200} onFocus={beginEdit} onChange={e=>change('title',e.target.value,c.id)}/><textarea aria-label={`${c.title} 内容`} value={c.text} maxLength={10000} placeholder="在工具内直接编辑…" onFocus={beginEdit} onChange={e=>change('text',e.target.value,c.id)}/></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<button aria-label={`${c.title} 操作`} onClick={e=>{e.stopPropagation();select(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}}>•••</button></footer><span className={s.inputPort}/><span className={s.outputPort}/><button className={s.resize} aria-label={`调整 ${c.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}}>↘</button></article>)}
|
||||
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>scaleAt(zoom-.1)}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>scaleAt(zoom+.1)}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
|
||||
{menu&&<div className={s.contextMenu} role="dialog" aria-label="画布操作" style={{left:Math.max(8,menu.x),top:Math.max(8,menu.y)}}><div className={s.menuTitle}>{active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{active?<><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('');setMenu(null)}}>删除节点</button><label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button disabled={!link} onClick={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[selected,link]]});setLink('')}}>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>断开:{doc.cards.find(c=>c.id===(a===selected?b:a))?.title}</button>)}</>:<><button onClick={()=>{add('text');setMenu(null)}}>添加笔记</button><button onClick={()=>{add('image');setMenu(null)}}>添加图片</button><button onClick={()=>{add('prompt');setMenu(null)}}>添加提示词</button></>}</div>}</div>
|
||||
<input hidden ref={file} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(!['image/png','image/jpeg','image/webp'].includes(f.type)||f.size>2000000){setStatus('请选择 2MB 以内的 PNG、JPEG 或 WebP');return}const reader=new FileReader();reader.onload=()=>{const image=String(reader.result);if(active?.kind==='image')commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,image}:c)});else add('image',image)};reader.readAsDataURL(f)}}/>
|
||||
<input hidden ref={json} type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=JSON.parse(await f.text());if(!valid(d))throw Error();commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
|
||||
</main>;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {embedSvgMedia} from '../src/lib/layout-images.ts';
|
||||
const id='a05dc841-240a-5a3a-b01b-d589fc21cd36';
|
||||
test('SVG export fetches each internal media once with the caller authentication',async()=>{
|
||||
const original=globalThis.fetch;let calls=0;
|
||||
try{
|
||||
globalThis.fetch=async(url,options)=>{calls++;assert.equal(url,'http://waterfall-wiki:3000/api/media/'+id);assert.equal(options.headers.cookie,'session=fixture');assert.equal(options.redirect,'error');return new Response(Buffer.from('fixture'),{headers:{'content-type':'image/png'}});};
|
||||
const svg=await embedSvgMedia({headers:{cookie:'session=fixture'}},`<svg><image href="/api/media/${id}"/><image href="/api/media/${id}"/></svg>`);
|
||||
assert.equal(calls,1);assert.equal((svg.match(/data:image\/png;base64,Zml4dHVyZQ==/g)||[]).length,2);assert.ok(!svg.includes('/api/media/'));
|
||||
}finally{globalThis.fetch=original;}
|
||||
});
|
||||
test('SVG export rejects unavailable or non-image objects',async()=>{
|
||||
const original=globalThis.fetch;
|
||||
try{for(const response of [new Response('missing',{status:404}),new Response('<svg/>',{headers:{'content-type':'image/svg+xml'}})]){
|
||||
globalThis.fetch=async()=>response;await assert.rejects(()=>embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`),/模板图片不可访问/);
|
||||
}}finally{globalThis.fetch=original;}
|
||||
});
|
||||
test('SVG exports without managed images do not access storage',async()=>{
|
||||
assert.equal(await embedSvgMedia({headers:{}},'<svg><text>Title</text></svg>'),'<svg><text>Title</text></svg>');
|
||||
});
|
||||
|
||||
test('SVG export uses the configured isolated Wiki origin',async()=>{
|
||||
const original=globalThis.fetch,previous=process.env.STUDIO_WIKI_ORIGIN;
|
||||
try{process.env.STUDIO_WIKI_ORIGIN='http://127.0.0.1:19000';globalThis.fetch=async url=>{assert.equal(url,'http://127.0.0.1:19000/api/media/'+id);return new Response('fixture',{headers:{'content-type':'image/png'}})};assert.ok((await embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`)).includes('data:image/png;base64,'));}
|
||||
finally{globalThis.fetch=original;if(previous===undefined)delete process.env.STUDIO_WIKI_ORIGIN;else process.env.STUDIO_WIKI_ORIGIN=previous;}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
|
||||
import {parameters} from '../src/lib/layout-parameters.ts';
|
||||
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
|
||||
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^data:image\/png;base64,/);if(s.kind==='image')assert.match(s.shape,/^data:image\/png;base64,/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
|
||||
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
|
||||
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
|
||||
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
|
||||
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
|
||||
|
||||
@@ -32,6 +32,7 @@ COPY --from=build --chown=node:node /app/db/ ./db/
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
|
||||
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
||||
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
import managedMedia from "./src/content/managed-media.json";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["playwright-core"],
|
||||
async rewrites(){return [
|
||||
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/waterfall-wiki/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/waterfall-wiki/public/".length),destination:"/api/media/"+asset.id})),
|
||||
{source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"},
|
||||
{source:"/index.md",destination:"/api/agent-index"},
|
||||
{source:"/skills/index.md",destination:"/api/guidance/skills"},
|
||||
|
||||
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
@@ -1,17 +1,19 @@
|
||||
import {readFile,readdir} from 'node:fs/promises';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {join,dirname} from 'node:path';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {requiredPackImages} from './verify-character-pack.mjs';
|
||||
const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters');
|
||||
const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000';
|
||||
const media=JSON.parse(await readFile(join(root,'..','..','src','content','managed-media.json'),'utf8'));
|
||||
const packFiles=pack=>Object.entries(media).filter(([name])=>name.startsWith(`apps/waterfall-wiki/seed/characters/${pack.directory}/`)).sort(([a],[b])=>a.localeCompare(b));
|
||||
const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8'));
|
||||
async function verify(pack){
|
||||
let cursor='',post;const seen=new Set();
|
||||
do{const response=await fetch(base+'/api/feed?'+new URLSearchParams({section:'images',...(cursor?{cursor}:{})}));if(!response.ok)throw Error('Cannot verify character feed: '+response.status);const page=await response.json();post=page.posts.find(p=>p.slug===pack.slug);cursor=page.nextCursor||'';if(cursor&&seen.has(cursor))throw Error('Character feed cursor did not advance');seen.add(cursor);}while(!post&&cursor);
|
||||
if(!post)throw Error('Character pack missing: '+pack.slug);
|
||||
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
|
||||
const hashes=await Promise.all(files.map(async file=>createHash('sha256').update(await readFile(join(root,pack.directory,file))).digest('hex')));
|
||||
const files=packFiles(pack);if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
|
||||
const hashes=files.map(([,asset])=>asset.sha256);
|
||||
const originals=requiredPackImages(hashes,post.assets.filter(a=>a.kind==='image'));
|
||||
for(const image of originals){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw Error('Original PNG unavailable');}
|
||||
console.log(`Verified: /p/${pack.slug} (${originals.length} original PNGs preserved; editorial covers allowed)`);
|
||||
@@ -20,10 +22,14 @@ for(const pack of packs){
|
||||
const existing=await fetch(`${base}/p/${pack.slug}`);
|
||||
if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;}
|
||||
if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`);
|
||||
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();
|
||||
const files=packFiles(pack);
|
||||
if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`);
|
||||
const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description);
|
||||
for(const file of files)data.append('files',new Blob([await readFile(join(root,pack.directory,file))],{type:'image/png'}),file);
|
||||
for(const [name,asset] of files){
|
||||
const response=await fetch(`${base}/api/media/${asset.id}`);if(!response.ok)throw Error('Cannot read migrated original: '+name);
|
||||
const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length!==asset.bytes||createHash('sha256').update(bytes).digest('hex')!==asset.sha256)throw Error('Original checksum mismatch: '+name);
|
||||
data.append('files',new Blob([bytes],{type:'image/png'}),name.split('/').pop());
|
||||
}
|
||||
const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data});
|
||||
if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`);
|
||||
if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {writeFile,unlink} from 'node:fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { spawn } from "node:child_process";
|
||||
import assert from "node:assert/strict";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
@@ -15,6 +17,8 @@ async function availablePort() {
|
||||
|
||||
const port = process.env.SMOKE_PORT || await availablePort();
|
||||
const release = "smoke-test";
|
||||
const previewFixture='.next/standalone/public/__smoke-preview.png';
|
||||
await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer());
|
||||
const child = spawn(process.execPath, [".next/standalone/server.js"], {
|
||||
// This loopback-only child checks retained resources without production credentials.
|
||||
// Production keeps failing closed; the bypass requires both explicit test settings.
|
||||
@@ -42,10 +46,11 @@ try {
|
||||
const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id);
|
||||
const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`);
|
||||
assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);const agentText=await agent.text();assert.match(agentText,/^# /);assert.doesNotMatch(agentText,/^\s*(?:<!doctype html|<html)/i);
|
||||
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/skills/shadow-puppet/egret-example.png')}&width=480`);
|
||||
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/__smoke-preview.png')}&width=480`);
|
||||
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
|
||||
console.log("Production smoke test passed");
|
||||
} finally {
|
||||
await unlink(previewFixture);
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const exited = once(child, "exit");
|
||||
child.kill("SIGTERM");
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 769 KiB |
|
Before Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 700 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 854 KiB |
|
Before Width: | Height: | Size: 884 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 1020 KiB |
|
Before Width: | Height: | Size: 866 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
@@ -1,4 +1,6 @@
|
||||
import sharp from 'sharp';
|
||||
import managedMedia from '../content/managed-media.json';
|
||||
import {getObject} from './storage';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {mkdir,readFile,writeFile,rename,stat,readdir,unlink,realpath} from 'node:fs/promises';
|
||||
import {resolve,sep,join} from 'node:path';
|
||||
@@ -11,4 +13,7 @@ async function limited<T>(work:()=>Promise<T>){if(active>=3){if(waiting.length>=
|
||||
const pending=new Map<string,Promise<Buffer>>();const cache=join(tmpdir(),'prompt-previews-v1');let writes=0;
|
||||
export async function compressPreview(input:Buffer,width:number){const max=width<=480?100_000:220_000;let size=width,quality=72;let output:Buffer;do{output=await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width:size,withoutEnlargement:true}).webp({quality,effort:3}).toBuffer();if(output.length<=max)return output;size=Math.max(240,Math.floor(size*.8));quality=Math.max(30,quality-10);}while(size>240||quality>30);return sharp(input,{limitInputPixels:40_000_000}).rotate().resize({width:240,withoutEnlargement:true}).webp({quality:30}).toBuffer();}
|
||||
export async function cachedPreview(identity:string,width:number,load:()=>Promise<Buffer>,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)};}
|
||||
export async function loadStaticPreview(source:string){
|
||||
const asset=(managedMedia as Record<string,{key:string;sha256:string;bytes:number}>)["apps/waterfall-wiki/public"+source];
|
||||
if(asset){return {identity:asset.key,load:async()=>{const object=await getObject(asset.key);if(asset.bytes>64*1024*1024)throw Error("Image too large");const bytes=Buffer.from(await object.Body!.transformToByteArray());if(bytes.length!==asset.bytes||createHash("sha256").update(bytes).digest("hex")!==asset.sha256)throw Error("Stored image checksum mismatch");return bytes;}};}
|
||||
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)};}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import managedMedia from "../content/managed-media.json";
|
||||
import {guidanceDocuments} from "../content/skills/guidance";
|
||||
import {wikiPages} from "../content/wiki";
|
||||
import { chapters } from "../content/learning-skills/data";
|
||||
import { transaction } from "./db";
|
||||
import { putFile } from "./storage";
|
||||
import { getObject } from "./storage";
|
||||
|
||||
function stableId(name: string) {
|
||||
const bytes=createHash("sha256").update(`prompt:learning-series:${name}`).digest().subarray(0,16);
|
||||
@@ -13,12 +13,13 @@ function stableId(name: string) {
|
||||
}
|
||||
// Explicit editorial publication, never part of the generic storage bootstrap.
|
||||
// Re-running updates these three authored posts without duplicating posts or media.
|
||||
export async function publishLearningSeries() {
|
||||
const path="public/learning-skills/cover.png";
|
||||
const cover=await readFile(path);const digest=createHash("sha256").update(cover).digest("hex");
|
||||
const storageKey=`editorial/learning-skills/${digest}.png`;
|
||||
const coverId=stableId("cover");
|
||||
await putFile(storageKey,path,"image/png");
|
||||
export async function publishLearningSeries(asset=managedMedia["apps/waterfall-wiki/public/learning-skills/cover.png"]) {
|
||||
const object=await getObject(asset.key);
|
||||
const cover=Buffer.from(await object.Body!.transformToByteArray());
|
||||
const digest=createHash("sha256").update(cover).digest("hex");
|
||||
if(digest!==asset.sha256||cover.length!==asset.bytes)throw Error("Learning cover checksum mismatch");
|
||||
const storageKey=asset.key;
|
||||
const coverId=asset.id;
|
||||
return transaction(async client=>{
|
||||
await client.query("SELECT pg_advisory_xact_lock(18131002)");
|
||||
await client.query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height)
|
||||
|
||||
@@ -7,7 +7,9 @@ import {setTimeout} from 'node:timers/promises';
|
||||
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
import {putFile} from '../src/lib/storage';
|
||||
import managedMedia from '../src/content/managed-media.json';
|
||||
import sharp from 'sharp';
|
||||
import {db} from '../src/lib/db';
|
||||
import {probe,run} from '../src/lib/probe';
|
||||
@@ -134,7 +136,14 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
for(const body of [{layoutId:'ref-28',name:'bad',parameters:{}},{layoutId:'ref-01',name:'bad',parameters:{texts:{I01:{value:'wrong kind'}}}},{layoutId:'ref-01',name:'bad',parameters:{images:{I01:{assetId:randomUUID()}}}}])assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).status,400);
|
||||
assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID())).status,404);
|
||||
assert.ok((await fetch(base+'/api/studio/designs?agent_mode=True').then(r=>r.text())).includes('revision'));
|
||||
const published=await publishLearningSeries();await publishLearningSeries();
|
||||
// Isolated S3 uses synthetic fixtures, never credentials or production objects.
|
||||
const testCoverFile=join(dir,'learning-cover.png');
|
||||
const testCoverBytes=await sharp({create:{width:1080,height:1440,channels:3,background:'#e8eadc'}}).png().toBuffer();
|
||||
await writeFile(testCoverFile,testCoverBytes);
|
||||
const testCover={...managedMedia['apps/waterfall-wiki/public/learning-skills/cover.png'],id:randomUUID(),key:'integration/learning-cover.png',sha256:createHash('sha256').update(testCoverBytes).digest('hex'),bytes:testCoverBytes.length};
|
||||
await putFile(testCover.key,testCoverFile,'image/png');
|
||||
const publishTestSeries=()=>publishLearningSeries(testCover);
|
||||
const published=await publishTestSeries();await publishTestSeries();
|
||||
const articles=(await request('/api/feed?section=articles&path=learning-skills')).posts;
|
||||
assert.equal(articles.length,3,'Repeated publication must not duplicate posts');
|
||||
assert.equal(new Set(articles.map((p:{cover_asset_id:string})=>p.cover_asset_id)).size,1,'Three posts share one independent image asset');
|
||||
@@ -173,7 +182,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
assert.equal((await request('/api/notes/'+captionReuse.id)).entry.editableMarkdown,'可以复用的独立文字','Editing one note must not mutate shared text');
|
||||
const authored=(await request('/api/feed?section=articles')).posts.find((p:{slug:string})=>p.slug===published.published[0]);const authoredState=await request('/api/notes/'+authored.id);
|
||||
assert.equal((await edit(authored.id,{title:'保留用户修改',description:'部署不能覆盖',markdown:'手动修改的正文',tags:[newTag.id],revision:authoredState.entry.revision})).status,200);
|
||||
await publishLearningSeries();const retained=await request('/api/notes/'+authored.id);assert.equal(retained.entry.title,'保留用户修改');assert.equal(retained.entry.editableMarkdown,'手动修改的正文');
|
||||
await publishTestSeries();const retained=await request('/api/notes/'+authored.id);assert.equal(retained.entry.title,'保留用户修改');assert.equal(retained.entry.editableMarkdown,'手动修改的正文');
|
||||
assert.equal((await request('/api/notes/'+note.id)).entry.title,payload.title,'Static edits outlive content publication');
|
||||
assert.equal((await fetch(base+'/api/notes/missing')).status,404);
|
||||
assert.equal((await fetch(base+'/api/notes/'+note.id,{method:'DELETE'})).status,405);
|
||||
@@ -208,6 +217,11 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
const head=await fetch(base+path,{method:'HEAD'});assert.equal(head.status,200);assert.equal(await head.text(),'');
|
||||
if(doc!=='SKILL')assert.equal((await fetch(base+'/tools/skills/shadow-puppet/'+doc)).status,200);
|
||||
}
|
||||
for(const image of ['egret-example','dancer-example']){
|
||||
const asset=managedMedia[`apps/waterfall-wiki/public/skills/shadow-puppet/${image}.png` as keyof typeof managedMedia];
|
||||
const key='integration/legacy-'+asset.id+'.png';await putFile(key,testCoverFile,'image/png');
|
||||
await db().query("INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256) VALUES($1,'image',$2,$3,'image/png',$4,$5)",[asset.id,image+'.png',key,testCoverBytes.length,testCover.sha256]);
|
||||
}
|
||||
for(const image of ['egret-example','dancer-example'])assert.equal((await fetch(base+`/skills/shadow-puppet/${image}.png`,{method:'HEAD'})).status,200);
|
||||
for(const path of ['/skills/missing/SKILL.md','/skills/shadow-puppet/missing.md','/tools/skills/shadow-puppet/missing'])assert.equal((await fetch(base+path)).status,404,path);
|
||||
assert.equal((await fetch(base+'/api/skill-docs/shadow-puppet/SKILL',{method:'POST'})).status,405);
|
||||
|
||||
|
Before Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 182 B |
|
Before Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 322 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 234 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 759 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 917 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 238 B |
|
Before Width: | Height: | Size: 775 B |
|
Before Width: | Height: | Size: 151 B |
|
Before Width: | Height: | Size: 835 B |
|
Before Width: | Height: | Size: 170 B |
|
Before Width: | Height: | Size: 893 B |
|
Before Width: | Height: | Size: 191 B |
|
Before Width: | Height: | Size: 900 B |
|
Before Width: | Height: | Size: 147 B |
|
Before Width: | Height: | Size: 766 B |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 187 B |
|
Before Width: | Height: | Size: 183 B |
|
Before Width: | Height: | Size: 262 B |
|
Before Width: | Height: | Size: 249 B |
|
Before Width: | Height: | Size: 185 B |
|
Before Width: | Height: | Size: 193 B |
|
Before Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 842 B |
|
Before Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 243 B |
|
Before Width: | Height: | Size: 231 B |
|
Before Width: | Height: | Size: 193 B |
|
Before Width: | Height: | Size: 298 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 175 B |