Publish photo-to-shadow-puppet skill with shared linked documentation
PROMPT Docker CI and deployment / verify-and-deploy (push) Successful in 15m54s

This commit is contained in:
2026-10-23 17:26:46 +08:00
parent 228e3dcf94
commit 83e19d31ca
11 changed files with 595 additions and 16 deletions
+6
View File
@@ -122,3 +122,9 @@ PROMPT_IMAGE=prompt:local PROMPT_TEST_IMAGE=prompt:tests bash scripts/integratio
工具栏首项为技能;素材库与分类管理页面已移除,已有素材、标签、导入和数据 API 保留。技能使用瀑布卡片,首页也混排展示。
交给 agent 的入口为 `/skills/street-people/SKILL.md`,后续子文档为 `style.md`、`upload.md`、`checks.md`;纯文本只通过链接引用图片、文档和 API。人类阅读入口 `/tools/skills/street-people` 和对应子页面使用相同文档数据。右侧资源面板分类收录全部文档、图片和 API,支持预览与打开;不会执行 POST,也不展示与技能流程无关的测试图包。旧 JSON API 和 `?format=md` 兼容保留,后者返回相同入口文档。
## 照片入皮影
技能目录和首页新增「照片入皮影」,入口网页 `/tools/skills/shadow-puppet`,Agent 入口纯文本 `/skills/shadow-puppet/SKILL.md`。网页与纯文本共用 `src/content/skills/shadow-puppet.ts` 的五份文档:入口、制作流程、画面语法、完整提示词、验收与上传。侧栏包含五份文档、用户提供的白鹭/舞者两张完成示例,以及通用图片上传和媒体读取两个 API。示例被明确标记为风格参考,不当作原始照片重新嵌套。
每张原片独立输出不透明 3:4 海报,上下高度严格 1:1:上半保真摄影,下半是一幕一偶的暖灯宣纸皮影。Agent 使用自己的图像工具逐张制作与验收,完成后调用 `POST /api/upload`;不使用街头小人的透明 PNG 接口。资源面板只展示 API 说明,不执行上传或直接请求未替换参数的媒体地址模板。阅读器和纯文本文档路由按技能注册表读取,后续可继续增加技能。
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["playwright-core"],
async rewrites(){return [{source:"/agent",destination:"/api/agent-index"},{source:"/agent/index.md",destination:"/api/agent-index"},{source:"/skills/street-people/:doc.md",destination:"/api/skill-docs/street-people/:doc"}]},
async rewrites(){return [{source:"/agent",destination:"/api/agent-index"},{source:"/agent/index.md",destination:"/api/agent-index"},{source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"}]},
output: "standalone",
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
poweredByHeader: false,
Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+5 -7
View File
@@ -1,16 +1,14 @@
import Head from 'next/head';
import Link from 'next/link';
import {useState,useEffect,useRef} from 'react';
import {useRouter} from 'next/router';
import {streetPeople} from "@/lib/street-people";
import {X,PanelRight,Copy,ExternalLink} from 'lucide-react';
import {Shell} from './Shell';
import {skillDocuments,skillResources,documentMarkdown,type Resource} from '@/content/skills/catalog';
export function SkillDocs({documentId='index'}:{documentId?:string}){
const router=useRouter();const doc=skillDocuments.find(d=>d.id===documentId)!;const [open,setOpen]=useState(false);const[selected,setSelected]=useState<Resource|null>(null);const[copied,setCopied]=useState(false);const panel=useRef<HTMLElement>(null);
import {getSkill,skillDocumentMarkdown,type Resource} from '@/content/skills/catalog';
export function SkillDocs({documentId='index',skillId='street-people'}:{documentId?:string;skillId?:string}){
const skill=getSkill(skillId)!;const skillResources=skill.resources;const doc=skill.documents.find(d=>d.id===documentId)!;const [open,setOpen]=useState(false);const[selected,setSelected]=useState<Resource|null>(null);const[copied,setCopied]=useState(false);const panel=useRef<HTMLElement>(null);
useEffect(()=>{setOpen(window.innerWidth>760)},[]);
useEffect(()=>{setSelected(null);setCopied(false)},[documentId]);
useEffect(()=>{setSelected(null);setCopied(false)},[skillId,documentId]);
useEffect(()=>{if(!open)return;const handler=(e:KeyboardEvent)=>{if(e.key==='Escape')setOpen(false)};window.addEventListener('keydown',handler);return()=>window.removeEventListener('keydown',handler)},[open]);
const resource=(id:string)=>skillResources.find(r=>r.id===id)!;
return <Shell><Head><title>{`${doc.title} · 技能文档 · PROMPT`}</title></Head><div className="docs-top"><Link href="/tools/skills">← 全部技能</Link><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}><PanelRight size={16}/>文档与来源 · {skillResources.length}</button></div><div className={`docs-layout ${open?'with-sources':''}`}><article className="docs-body"><div className="docs-eyebrow">街头小人 / SKILL DOCUMENTS</div><h1>{doc.title}</h1><div className="docs-actions"><button onClick={async()=>{try{const value=location.origin+'/skills/street-people/SKILL.md';if(navigator.clipboard)await navigator.clipboard.writeText(value);else{const input=document.createElement('textarea');input.value=value;document.body.appendChild(input);input.select();const ok=document.execCommand('copy');input.remove();if(!ok)throw Error('Copy unavailable');}setCopied(true)}catch{setCopied(false)}}}><Copy size={14}/>{copied?'已复制入口文档':'复制入口文档给 Agent'}</button><a href={resource(documentId).href} target="_blank" rel="noreferrer">纯文本版本 ↗</a></div><p className="docs-hint">网页与 Agent 纯文本版本来自同一份文档。参考图、子文档与接口均通过链接连接。</p>{doc.blocks.map((b,i)=>b.type==='heading'?<h2 key={i}>{b.text}</h2>:b.type==='code'?<pre key={i}>{b.text}</pre>:b.type==='links'?<div className="docs-inline-links" key={i}>{b.refs!.map(id=>{const r=resource(id);return <a href={r.human||r.href} key={id} onClick={e=>{e.preventDefault();setSelected(r);setOpen(true);panel.current?.focus()}}>{r.kind==='document'?'文档':r.kind==='image'?'图片':r.method} · {r.title}<ExternalLink size={12}/></a>})}</div>:<p key={i}>{b.text}</p>)}</article>{open&&<aside className="docs-sources" ref={panel} tabIndex={-1} aria-label="技能所有文档与来源"><header><div><b>文档与来源</b><small>当前技能的完整资源索引</small></div><button aria-label="关闭文档与来源" onClick={()=>setOpen(false)}><X size={18}/></button></header>{(['document','image','api'] as const).map(kind=><section key={kind}><h3>{kind==='document'?'文档':kind==='image'?'参考与示意图':'API 接口'}</h3>{skillResources.filter(r=>r.kind===kind).map(r=><button key={r.id} className={selected?.id===r.id?'selected':''} onClick={()=>setSelected(r)}><span>{r.method|| (r.kind==='image'?'IMG':'MD')}</span><div>{r.title}<small>{r.href}</small></div></button>)}</section>)}{selected&&<div className="docs-resource-preview"><h3>{selected.title}</h3>{selected.kind==='image'?<img src={selected.href} alt={selected.title}/>:selected.kind==='document'?<pre>{documentMarkdown(selected.id)}</pre>:<><code>{selected.method} {selected.href}</code><pre>{JSON.stringify(selected.method==='POST'?streetPeople.upload:streetPeople,null,2)}</pre><p>{selected.method==='POST'?'上传接口。阅读上传文档后,由 Agent 使用 multipart/form-data 提交文件。此处仅展示说明,不会发送请求。':'返回 JSON 规范。入口文档负责引导流程,JSON 用于查阅接口结构。'}</p></>}<div className="docs-resource-actions">{selected.human&&<Link href={selected.human}>打开文档网页 ↗</Link>}{selected.method!=='POST'&&<a href={selected.href} target="_blank" rel="noreferrer">{selected.kind==='document'?'打开纯文本':'打开资源'} ↗</a>}</div></div>}</aside>}</div></Shell>;
return <Shell><Head><title>{`${doc.title} · 技能文档 · PROMPT`}</title></Head><div className="docs-top"><Link href="/tools/skills">← 全部技能</Link><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}><PanelRight size={16}/>文档与来源 · {skillResources.length}</button></div><div className={`docs-layout ${open?'with-sources':''}`}><article className="docs-body"><div className="docs-eyebrow">{skill.title} / SKILL DOCUMENTS</div><h1>{doc.title}</h1><div className="docs-actions"><button onClick={async()=>{try{const value=new URL(skillResources.find(r=>r.id==='index')!.href,location.origin).href;if(navigator.clipboard)await navigator.clipboard.writeText(value);else{const input=document.createElement('textarea');input.value=value;document.body.appendChild(input);input.select();const ok=document.execCommand('copy');input.remove();if(!ok)throw Error('Copy unavailable');}setCopied(true)}catch{setCopied(false)}}}><Copy size={14}/>{copied?'已复制入口文档':'复制入口文档给 Agent'}</button><a href={resource(documentId).href} target="_blank" rel="noreferrer">纯文本版本 ↗</a></div><p className="docs-hint">网页与 Agent 纯文本版本来自同一份文档。参考图、子文档与接口均通过链接连接。</p>{doc.blocks.map((b,i)=>b.type==='heading'?<h2 key={i}>{b.text}</h2>:b.type==='code'?<pre key={i}>{b.text}</pre>:b.type==='links'?<div className="docs-inline-links" key={i}>{b.refs!.map(id=>{const r=resource(id);return <a href={r.human||r.href} key={id} onClick={e=>{e.preventDefault();setSelected(r);setOpen(true);panel.current?.focus()}}>{r.kind==='document'?'文档':r.kind==='image'?'图片':r.method} · {r.title}<ExternalLink size={12}/></a>})}</div>:<p key={i}>{b.text}</p>)}</article>{open&&<aside className="docs-sources" ref={panel} tabIndex={-1} aria-label="技能所有文档与来源"><header><div><b>文档与来源</b><small>当前技能的完整资源索引</small></div><button aria-label="关闭文档与来源" onClick={()=>setOpen(false)}><X size={18}/></button></header>{(['document','image','api'] as const).map(kind=><section key={kind}><h3>{kind==='document'?'文档':kind==='image'?'参考与示意图':'API 接口'}</h3>{skillResources.filter(r=>r.kind===kind).map(r=><button key={r.id} className={selected?.id===r.id?'selected':''} onClick={()=>setSelected(r)}><span>{r.method|| (r.kind==='image'?'IMG':'MD')}</span><div>{r.title}<small>{r.href}</small></div></button>)}</section>)}{selected&&<div className="docs-resource-preview"><h3>{selected.title}</h3>{selected.kind==='image'?<img src={selected.href} alt={selected.title}/>:selected.kind==='document'?<pre>{skillDocumentMarkdown(skillId,selected.id)}</pre>:<><code>{selected.method} {selected.href}</code><pre>{JSON.stringify(selected.detail||{method:selected.method,path:selected.href},null,2)}</pre><p>{selected.description}</p></>}<div className="docs-resource-actions">{selected.human&&<Link href={selected.human}>打开文档网页 ↗</Link>}{selected.method!=='POST'&&!selected.template&&<a href={selected.href} target="_blank" rel="noreferrer">{selected.kind==='document'?'打开纯文本':'打开资源'} ↗</a>}</div></div>}</aside>}</div></Shell>;
}
+14 -5
View File
@@ -1,5 +1,6 @@
import {shadowPuppetResources,shadowPuppetDocuments} from "./shadow-puppet";
import {streetPeople} from "@/lib/street-people";
export type Resource={id:string;kind:'document'|'image'|'api';title:string;href:string;human?:string;method?:string};
export type Resource={id:string;kind:'document'|'image'|'api';title:string;href:string;human?:string;method?:string;description?:string;detail?:unknown;template?:boolean};
export type Block={type:'paragraph'|'heading'|'code'|'links';text?:string;refs?:string[]};
export type SkillDocument={id:string;title:string;blocks:Block[]};
export const skillResources:Resource[]=[
@@ -28,8 +29,16 @@ export const skillDocuments:SkillDocument[]=[
{id:'upload',title:'上传与返回结果',blocks:[{type:'paragraph',text:'先由 agent 生成 PNG,再以 multipart/form-data 提交。API 不负责绘图。'}, {type:'links',refs:['submit','spec']},{type:'code',text:`POST /api/skills/street-people\nContent-Type: multipart/form-data\n\n${Object.entries(streetPeople.upload.fields).map(([k,v])=>k+': '+v).join('\n')}\n\n201: { id, slug, url, assets }\n400: 文件或表单不符合规范\n409: slug 已存在\n413: 超出上传限制\n500: 服务器或存储错误`},{type:'heading',text:'调用示例'},{type:'code',text:`curl -X POST "$BASE/api/skills/street-people" \\\n -F 'title=街头小人 · 冬日片段' \\\n -F 'slug=winter-street-people' \\\n -F 'description=人物动作、提示词与生成来源' \\\n -F 'files=@person.png;type=image/png'`},{type:'paragraph',text:'最多 16 张,单文件 250 MB,总计 1 GB,每张最多 4000 万像素。重复 slug 返回 409,不会覆盖已有笔记。使用返回的 url 查看结果。'},{type:'links',refs:['checks','index']}]},
{id:'checks',title:'交付检查清单',blocks:[{type:'paragraph',text:'逐张检查完整人物、纸片边缘、衣着与动作。外部需要真实透明像素 alpha=0,内部需要接近不透明像素 alpha≥250。仅增加 alpha 通道而没有透明背景的 PNG 会被拒绝。'}, {type:'paragraph',text:'检查无截肢、无额外人物、无文字、无背景和外部阴影。记录生成来源与提示词;来源不同的图包分开提交。服务器的像素检查不能代替 agent 对画风和内容的检查。'}, {type:'links',refs:['style','sample','upload','index']}]},
];
export const skillCatalog=[{id:'street-people',title:'街头小人',category:'图片生成 · 透明 PNG',description:'松散的线条,冬天的衣着。把街上的一个瞬间,剪成一张小贴纸。',image:'/skills/street-people/sample.png',alt:'手绘街头小人,透明剪纸素材',href:'/tools/skills/street-people',created_at:'2026-10-22T17:29:47Z'}];
export function documentMarkdown(id:string,origin='http://8.211.154.134:18131'){
const doc=skillDocuments.find(d=>d.id===id);if(!doc)return null;
return `# ${doc.title}\n\n`+doc.blocks.map(b=>b.type==='heading'?`## ${b.text}`:b.type==='code'?`\x60\x60\x60\n${b.text}\n\x60\x60\x60`:b.type==='links'?b.refs!.map(id=>{const r=skillResources.find(r=>r.id===id)!;return `- [${r.title}](${new URL(r.href,origin).href})${r.method?' — '+r.method:''}`}).join('\n'):b.text).join('\n\n')+'\n';
export const skillCatalog=[{id:'street-people',title:'街头小人',category:'图片生成 · 透明 PNG',description:'松散的线条,冬天的衣着。把街上的一个瞬间,剪成一张小贴纸。',image:'/skills/street-people/sample.png',alt:'手绘街头小人,透明剪纸素材',href:'/tools/skills/street-people',created_at:'2026-10-22T17:29:47Z'},{id:'shadow-puppet',title:'照片入皮影',category:'照片转译 · 3:4 海报',description:'把照片里的主角请上幕布,让它用一个姿态演完一整出戏。',image:'/skills/shadow-puppet/egret-example.png',alt:'白鹭照片与暖灯宣纸皮影的上下等分海报示例',href:'/tools/skills/shadow-puppet',created_at:'2026-10-23T03:45:00Z'}];
export type SkillDefinition={id:string;title:string;documents:SkillDocument[];resources:Resource[]};
const definitions:Record<string,SkillDefinition>={
'street-people':{id:'street-people',title:'街头小人',documents:skillDocuments,resources:skillResources.map(r=>r.kind==='api'?{...r,detail:r.method==='POST'?streetPeople.upload:streetPeople,description:r.method==='POST'?'上传接口。阅读上传文档后,由 Agent 使用 multipart/form-data 提交文件。此处仅展示说明,不会发送请求。':'返回 JSON 规范。入口文档负责引导流程,JSON 用于查阅接口结构。'}:r)},
'shadow-puppet':{id:'shadow-puppet',title:'照片入皮影',documents:shadowPuppetDocuments,resources:shadowPuppetResources},
};
export function getSkill(id:string):SkillDefinition|undefined{return Object.hasOwn(definitions,id)?definitions[id]:undefined;}
export function skillDocumentMarkdown(skillId:string,id:string,origin='http://8.211.154.134:18131'){
const skill=getSkill(skillId);const doc=skill?.documents.find(d=>d.id===id);if(!skill||!doc)return null;
return `# ${doc.title}\n\n`+doc.blocks.map(b=>b.type==='heading'?`## ${b.text}`:b.type==='code'?`\x60\x60\x60\n${b.text}\n\x60\x60\x60`:b.type==='links'?b.refs!.map(id=>{const r=skill.resources.find(r=>r.id===id)!;return `- [${r.title}](${new URL(r.href,origin).href})${r.method?' — '+r.method:''}`}).join('\n'):b.text).join('\n\n')+'\n';
}
// Preserve the existing street-people document API.
export function documentMarkdown(id:string,origin?:string){return skillDocumentMarkdown('street-people',id,origin);}
+516
View File
@@ -0,0 +1,516 @@
import type {Resource,SkillDocument} from "./catalog";
export const shadowPuppetResources:Resource[] = [
{
"id": "index",
"kind": "document",
"title": "入口文档 · SKILL.md",
"href": "/skills/shadow-puppet/SKILL.md",
"human": "/tools/skills/shadow-puppet"
},
{
"id": "workflow",
"kind": "document",
"title": "制作流程",
"href": "/skills/shadow-puppet/workflow.md",
"human": "/tools/skills/shadow-puppet/workflow"
},
{
"id": "visual-grammar",
"kind": "document",
"title": "画面与皮影语法",
"href": "/skills/shadow-puppet/visual-grammar.md",
"human": "/tools/skills/shadow-puppet/visual-grammar"
},
{
"id": "prompt",
"kind": "document",
"title": "完整提示词",
"href": "/skills/shadow-puppet/prompt.md",
"human": "/tools/skills/shadow-puppet/prompt"
},
{
"id": "delivery",
"kind": "document",
"title": "验收与上传",
"href": "/skills/shadow-puppet/delivery.md",
"human": "/tools/skills/shadow-puppet/delivery"
},
{
"id": "egret-example",
"kind": "image",
"title": "白鹭成品示例",
"href": "/skills/shadow-puppet/egret-example.png"
},
{
"id": "dancer-example",
"kind": "image",
"title": "舞者成品示例",
"href": "/skills/shadow-puppet/dancer-example.png"
},
{
"id": "upload-api",
"kind": "api",
"title": "上传独立海报",
"href": "/api/upload",
"method": "POST",
"description": "Agent 完成图片生成与视觉验收后,再使用此接口上传;接口本身不绘图。",
"detail": {
"encoding": "multipart/form-data",
"fields": {
"kind": "image",
"title": "必填,海报或系列标题",
"files": "1–16 个独立 PNG/JPG 成品",
"description": "原片与提示词记录(可选)",
"tags": "已有分类 UUID 数组 JSON(可选)",
"slug": "唯一网址标识(可选)"
},
"response": {
"status": 201,
"body": {
"id": "post UUID",
"slug": "稳定标识",
"url": "/p/<slug>",
"assets": [
"asset UUID"
]
}
},
"limits": {
"files": 16,
"bytesPerFile": 262144000,
"totalBytes": 1073741824
},
"validation": "API 检查上传格式;3:4、上下等分与视觉质量由 Agent 检查"
}
},
{
"id": "media-api",
"kind": "api",
"title": "读取成品原图",
"href": "/api/media/{asset-id}",
"method": "GET",
"template": true,
"description": "将 {asset-id} 替换为上传返回的素材 UUID,再请求此地址;模板不是可以直接请求的成品地址。",
"detail": {
"pathParameter": {
"asset-id": "上传返回的素材 UUID"
},
"response": "原始图片字节;保留原图格式",
"supports": [
"GET",
"HEAD",
"Range"
]
}
}
];
export const shadowPuppetDocuments:SkillDocument[] = [
{
"id": "index",
"title": "照片入皮影",
"blocks": [
{
"type": "paragraph",
"text": "把照片里的主角请上幕布,让它用一个姿态演完一整出戏。"
},
{
"type": "paragraph",
"text": "将每张原始照片分别制作成一张 3:4 竖版海报。上半张保留摄影,下半张把同一个主角转译成中国传统皮影戏偶;上下区域严格等高。每张照片独立输出,不将多张照片拼接。"
},
{
"type": "heading",
"text": "何时使用"
},
{
"type": "paragraph",
"text": "用于人物、动物、建筑、植物、器物与风景照片的皮影转译。创作由 coding agent 自带的图像生成/编辑工具执行,网站提供文档、参考图与成品上传 API,不执行服务器端模型推理。"
},
{
"type": "heading",
"text": "输入与参考图"
},
{
"type": "paragraph",
"text": "先区分原始照片与风格参考。只有原始照片决定主角的身份、结构、姿态和叙事关系。"
},
{
"type": "paragraph",
"text": "- 白鹭成品示例:参考弯颈、抬足与皮影关节的对应方式,以及暖灯透光效果。"
},
{
"type": "links",
"refs": [
"egret-example"
]
},
{
"type": "paragraph",
"text": "- 舞者成品示例:参考扬袖姿态如何转译成铆钉连接、镂刻纹样和操纵竹签。"
},
{
"type": "links",
"refs": [
"dancer-example"
]
},
{
"type": "paragraph",
"text": "这两张是完成海报,已经含有上下两个区域;不能当成新的原始照片套入上半区,也不能再把它们整张拼进海报。示例用于理解技法;构图比例、留白与主体占比以文字规范为准。"
},
{
"type": "paragraph",
"text": "如果用户只给了完成示例而没有原始照片,可以整理规范与提示词;需要正式生成时,请用户提供原始照片,不编造照片里的主角。只有明确获准时才从示例中截取上半区作为输入。"
},
{
"type": "heading",
"text": "执行顺序"
},
{
"type": "paragraph",
"text": "1. 阅读制作流程,逐张识别主角、姿态与配色。"
},
{
"type": "links",
"refs": [
"workflow"
]
},
{
"type": "paragraph",
"text": "2. 阅读画面与皮影语法,锁定 3:4、上下 1:1 和一幕一偶。"
},
{
"type": "links",
"refs": [
"visual-grammar"
]
},
{
"type": "paragraph",
"text": "3. 使用完整提示词,为每张照片单独调用图像编辑工具。"
},
{
"type": "links",
"refs": [
"prompt"
]
},
{
"type": "paragraph",
"text": "4. 按验收与上传检查成品,再将独立图片发布到网站。"
},
{
"type": "links",
"refs": [
"delivery"
]
},
{
"type": "paragraph",
"text": "完整资源索引包含本技能的全部子文档、参考图片和 API。参考图片以链接提供,执行时先区分风格参考与原始照片。"
},
{
"type": "heading",
"text": "完整资源索引"
},
{
"type": "links",
"refs": [
"index",
"workflow",
"visual-grammar",
"prompt",
"delivery",
"egret-example",
"dancer-example",
"upload-api",
"media-api"
]
}
]
},
{
"id": "workflow",
"title": "制作流程",
"blocks": [
{
"type": "heading",
"text": "1. 先建立逐张任务"
},
{
"type": "paragraph",
"text": "把每张原始照片分配独立编号。多张原片可以连续处理,但每次图像工具调用只对应一张原片,最终每张输出一个独立海报文件。不生成九宫格、联系表或多照片拼贴。"
},
{
"type": "paragraph",
"text": "记录原片文件名、主体识别点、最具表情的姿态、主叙事关系及 2–3 个配色。将成品示例仅作为风格参考,明确区别 edit target 与 style reference。"
},
{
"type": "heading",
"text": "2. 锁定画板"
},
{
"type": "paragraph",
"text": "选择工具支持的精确 3:4 尺寸,优先 1536 × 2048 或等比规格;中线严格为 H/2。若工具无法保证精确尺寸和等分,必须在验收时标出并修正,不能把“约 3:4”称为合格。"
},
{
"type": "paragraph",
"text": "先确定上半区如何容纳原始照片。仅在必要时自然扩展环境背景,保持主体身份、结构与姿态;避免以放大裁切牺牲脚、翅膀、手臂、建筑尖顶或重要物件。"
},
{
"type": "heading",
"text": "3. 为同一主角设计一具戏偶"
},
{
"type": "paragraph",
"text": "不要机械复制照片纹理。先找最能辨认的轮廓和姿态,再简化成少量活动部件:例如白鹭的弯颈与抬足、舞者的扬袖与重心、建筑的檐线与层叠关系。关节、铆钉、雕纹与竹签都围绕这些特点组织。"
},
{
"type": "heading",
"text": "4. 独立调用图像工具"
},
{
"type": "paragraph",
"text": "使用完整提示词,标明原片与示例的不同角色。选择不透明的完整海报背景;本技能不输出透明贴纸。每张原片独立调用并保存。"
},
{
"type": "links",
"refs": [
"prompt"
]
},
{
"type": "paragraph",
"text": "只对失败点做针对性修改:例如上半区身份偏移、分界偏离中线、戏偶过大、关节不合理、缺少竹签、灯光像聚光舞台或出现多余人物。修改时再次锁定其他正确内容。"
},
{
"type": "heading",
"text": "5. 验收并发布"
},
{
"type": "paragraph",
"text": "按照验收与上传确认尺寸、等分、主体保真与皮影语法。先检查,再上传。记录实际使用的提示词、工具和原片映射,供以后复现与重做。"
},
{
"type": "links",
"refs": [
"delivery"
]
},
{
"type": "links",
"refs": [
"index"
]
}
]
},
{
"id": "visual-grammar",
"title": "画面与皮影语法",
"blocks": [
{
"type": "heading",
"text": "尺寸是硬约束"
},
{
"type": "paragraph",
"text": "完整海报宽高比为 3:4,竖版。上下区域高度严格 1:1,各占全图 50%。例如 1536 × 2048 像素,上半区 y=0–1023,下半区 y=1024–2047;分界线位于 y=1024。不得因为题字、边框或留白改变两区高度。两个区块横向铺满画幅,不在外层额外增加标题栏或页脚。"
},
{
"type": "heading",
"text": "上半区:原始照片"
},
{
"type": "paragraph",
"text": "保留主体身份、结构、姿态、真实材质、自然光影与原有色彩氛围。仅进行轻微高级调色,呈现艺术摄影与东方展览图像质感。适配宽幅半区时,优先自然扩展周围环境,保持主体完整;不得拉伸、扭曲、改脸、改变肢体或裁掉最具识别度的动作。禁止把摄影区也做成皮影、插画或 3D。"
},
{
"type": "heading",
"text": "下半区:被老手艺人重新理解的角色"
},
{
"type": "paragraph",
"text": "从照片提取最具识别性的主体、轮廓、姿态与叙事关系。无论原图是人物、动物、建筑、植物、器物还是风景,都理解为可被操纵的一个角色。"
},
{
"type": "paragraph",
"text": "- 用少量平面关节部件概括,以可见铆钉相连。关节服务于原图动作,不能任意拼接。"
},
{
"type": "paragraph",
"text": "- 侧面化、平面化;使用装饰性镂空、云纹与几何雕刻纹样,以及局部半透明彩染。"
},
{
"type": "paragraph",
"text": "- 一两根细长操纵竹签从下边缘伸入并合理连接。不要额外加入操偶人。"
},
{
"type": "paragraph",
"text": "- 一幕一偶与大量留白。主角约占幕布画面 25%–35%,其余空间保持安静;可偏心或靠黄金分割位置。"
},
{
"type": "paragraph",
"text": "- 对建筑、植物和风景,选择照片最具识别性的整体轮廓及关系,把它概括为一具能够活动的戏偶;不要加入无关的人物或堆满一套风景舞台。"
},
{
"type": "paragraph",
"text": "- 照片里有多个主体时,先确定主叙事;若无法在一偶中保留关键关系,先确认创作取舍,不擅自删掉关系核心。"
},
{
"type": "heading",
"text": "灯、纸与颜色"
},
{
"type": "paragraph",
"text": "下半区是在暖光照亮的半透明宣纸幕布上演出,不是摆在台面上的皮影工艺品。幕布呈米黄或浅琥珀光场,四周自然暗下。后方一盏暖灯照透镂空处,形成温暖光点;皮影边缘具有柔和投影虚边和轻微光晕。允许极轻微双影或动态虚影,像缓慢移动中的一瞬间。"
},
{
"type": "paragraph",
"text": "从原照片提取 2–3 种最具情绪价值的颜色,转译成朱红、靛青、橄榄绿、琥珀黄体系内协调的半透明彩染;不必强行集齐全部颜色。阴影使用深褐与暖墨色,避免冷黑。"
},
{
"type": "heading",
"text": "文字可选"
},
{
"type": "paragraph",
"text": "可在幕布一角用少量安静的竖排小字写戏名或短句,像手写节目单。文字不是必需。用户没有指定文案时,宁可无字,也不生成无意义书法、乱码或大标题。"
},
{
"type": "heading",
"text": "避免"
},
{
"type": "paragraph",
"text": "完整写实的下半区、卡通化、廉价国潮、模板化海报、塑料质感、3D 渲染、满场景堆砌、观众、舞台全景、复杂道具。摄影与皮影都必须来自同一张原始照片,不混入另一张照片的主体。"
},
{
"type": "links",
"refs": [
"index"
]
}
]
},
{
"id": "prompt",
"title": "完整提示词",
"blocks": [
{
"type": "paragraph",
"text": "以下提示词每次只对应一张原始照片。将方括号字段替换为当前照片信息,生成前删除不使用的可选题字。"
},
{
"type": "code",
"text": "任务:将本次提供的这一张原始照片制作成一张独立的高级设计海报,不多图拼接。输出 3:4 竖版,尺寸 [1536×2048 或工具支持的精确等比尺寸]。上下两个区域高度严格 1:1,各占全图 50%;水平分界位于画面高度的 50%。两个区域横向铺满,外层没有额外页眉、页脚或改变等分的边框。\n\n输入角色:\n- 原始照片(编辑目标):[原片文件名或链接]。本海报中的所有主体与叙事来自这一张照片。\n- 风格参考(如提供):[白鹭/舞者成品示例链接]。它们已经是完成的海报,只参考皮影技法和光感,不复制主体,不将示例整图放进上半区。\n\n原片观察:\n- 主体与不可改变的识别点:[身份、结构、轮廓与关键物件]\n- 最有表情的瞬间:[姿态、朝向、重心与动作]\n- 核心叙事关系:[主体之间或主体与环境的关系]\n- 2–3 种情绪色:[从本张照片中提取]\n\n上半部分:保留原始照片,保持主体身份、结构、姿态、真实质感、自然光影与原有色彩氛围。仅轻微高级调色,具有艺术摄影与东方展览图像质感。适配画幅时可自然扩展环境背景,禁止拉伸、扭曲、改脸、增减肢体或改变主体。保持关键动作完整;上半区仍然是照片。\n\n下半部分:提取同一原片最具识别性的主体、轮廓、姿态与叙事关系,重构为一具中国传统皮影戏偶(shadow puppet),正在一面暖光照亮的半透明宣纸幕布上演出。无论原图是人物、动物、建筑、植物、器物还是风景,都将它理解为可被操纵的一个角色。主体拆分为少量合理关节部件,以铆钉相连,姿态取自照片最有表情的瞬间;一两根细长的操纵竹签自画面下方伸入并连接戏偶。\n\n用皮影的造型语法重新概括,而非写实复制或卡通化:侧面化、平面化、装饰性镂空、云纹与几何雕刻纹样、局部半透明彩染。保留原物最具识别度的特征,使人一眼认出,同时像被老手艺人重新理解和雕刻过。\n\n光影:幕布后方一盏暖色灯光,镂空处透出温暖光点。照片中的情绪色转译为半透明的朱红、靛青、橄榄绿或琥珀黄,不必使用全部颜色。边缘带柔和投影虚边与轻微光晕,可有极轻微双影或动态虚影,像缓慢演出中的一瞬间。阴影用深褐与暖墨,不用冷黑。\n\n下半区构图:一幕一偶、大量留白。米黄或浅琥珀色暖光场,四周自然暗下。戏偶主体约占幕布画面的 25%–35%,可偏心或位于黄金分割位置,其余空间安静,如戏开场前的停顿。不出现观众、操偶人、舞台全景或复杂道具。\n\n文字:[可选:在幕布一角竖排少量小字“用户指定戏名或短句”]。未指定文案则无字,不添加主标题、广告文字、乱码或无意义书法。\n\n最终呈现:皮影镂刻、半透明彩染、暖灯幕布、柔和投影与东方留白共同构成光影视觉。核心原则:把照片里的主角请上幕布,让它用一个姿态演完一整出戏。\n\n禁止:多照片拼贴、上下比例偏移、主体身份改变、下半区完整写实、卡通、廉价国潮、塑料质感、3D 渲染、满场景堆砌、模板化海报。每张原始照片单独输出一个完整海报文件,画布背景不透明。"
},
{
"type": "links",
"refs": [
"index"
]
}
]
},
{
"id": "delivery",
"title": "验收与上传",
"blocks": [
{
"type": "heading",
"text": "画面验收"
},
{
"type": "paragraph",
"text": "- 文件为独立 3:4 竖版海报。可检查 width × 4 === height × 3,且 height 为偶数。"
},
{
"type": "paragraph",
"text": "- 摄影与皮影区的分界位于 height / 2。仅有正确文件尺寸不足以证明上下等分,仍需查看画面。"
},
{
"type": "paragraph",
"text": "- 上半区保留同一原片主体身份、结构、姿态、自然质感和配色;没有被重新画成插画。"
},
{
"type": "paragraph",
"text": "- 下半区是同一主角的一具皮影,轮廓与动作可辨,有合理的关节铆钉及一两根从下缘伸入的竹签。"
},
{
"type": "paragraph",
"text": "- 镂空透暖光、彩染半透明、纸幕材质明确、投影柔软,留白充足;没有观众、复杂舞台、3D 或卡通。"
},
{
"type": "paragraph",
"text": "- 每张原片对应一个独立成品,没有多照片拼贴。题字若有,必须可读且符合指定内容。"
},
{
"type": "paragraph",
"text": "本技能的整张海报背景不透明。不要使用「街头小人」的透明 PNG 专用接口;该接口针对另一种交付要求。"
},
{
"type": "heading",
"text": "上传 API"
},
{
"type": "paragraph",
"text": "使用网站通用接口 POST /api/upload,编码为 multipart/form-data。请求字段:"
},
{
"type": "paragraph",
"text": "- kind=image。"
},
{
"type": "paragraph",
"text": "- title:必填,当前海报或系列标题。"
},
{
"type": "paragraph",
"text": "- files:独立海报文件。PNG 或 JPG;一张照片输出一个文件。"
},
{
"type": "paragraph",
"text": "- description:可选,记录原片标识、提示词、生成工具与制作说明。"
},
{
"type": "paragraph",
"text": "- tags:可选,已有分类 UUID 数组的 JSON;没有分类时使用 [] 或不传。"
},
{
"type": "paragraph",
"text": "- slug:可选,唯一的小写英文、数字和连字符标识。"
},
{
"type": "paragraph",
"text": "默认每张照片一份独立海报;如果用户要求组成系列,可以把多个独立海报文件作为同一 post 的多图上传,文件仍必须逐张独立。不要为凑系列把它们拼在同一张图片里。"
},
{
"type": "paragraph",
"text": "单次最多 16 个文件,单个不超过 250 MB,总计不超过 1 GB。上传接口校验文件格式;3:4、上下等分与视觉质量由 Agent 在上传前验收,不宣称接口自动识别构图。"
},
{
"type": "code",
"text": "# BASE 为当前 PROMPT 网站的根地址\ncurl -X POST \"$BASE/api/upload\" \\\n -F 'kind=image' \\\n -F 'title=照片入皮影 · 戏名' \\\n -F 'description=原片:photo-01;每张单独制作;已检查3:4与上下等分。' \\\n -F 'files=@photo-01-shadow-puppet.png;type=image/png'"
},
{
"type": "paragraph",
"text": "成功返回 201,包含 id、slug、url 和 assets。用返回的 url 打开 post;assets 为可复用素材 ID。引用或下载原图使用 GET /api/media/<asset-id>。"
},
{
"type": "paragraph",
"text": "400 表示表单或文件格式不符合要求,409 表示 slug 已存在,413 表示文件数量或大小超限,500 表示服务或存储异常。失败时不重复盲传;先检查错误。给用户回报真实的文件、post 地址与已完成的检查,不把推送成功当成生产上传成功。"
},
{
"type": "links",
"refs": [
"upload-api",
"media-api",
"index"
]
}
]
}
];
+11
View File
@@ -0,0 +1,11 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {skillDocumentMarkdown} from '@/content/skills/catalog';
export default function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Allow','GET, HEAD');
if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();
const name=String(req.query.doc).replace(/\.md$/,'');
const text=skillDocumentMarkdown(String(req.query.skill),name==='SKILL'?'index':name);
if(!text)return res.status(404).end();
res.setHeader('Content-Type','text/markdown; charset=utf-8');res.setHeader('Cache-Control','no-cache');
return req.method==='HEAD'?res.status(200).end():res.status(200).send(text);
}
@@ -0,0 +1,11 @@
import type {GetServerSideProps} from 'next';
import {SkillDocs} from '@/components/SkillDocs';
import {getSkill} from '@/content/skills/catalog';
export default SkillDocs;
export const getServerSideProps:GetServerSideProps=async({params})=>{
const skillId=String(params?.skill);const parts=params?.doc;
if(parts!==undefined&&(!Array.isArray(parts)||parts.length!==1||parts[0]==='index'))return {notFound:true};
const documentId=Array.isArray(parts)?parts[0]:'index';
const skill=getSkill(skillId);
return skill?.documents.some(d=>d.id===documentId)?{props:{skillId,documentId}}:{notFound:true};
};
+10 -2
View File
@@ -23,7 +23,7 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video
const request=async(path:string,body?:unknown)=>{const res=await fetch(base+path,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:undefined);assert.ok(res.ok,`${path}: ${res.status} ${res.ok?'':await res.text()}`);return res.json();};
try{
let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready);
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people'])assert.equal((await fetch(base+route)).status,200,route);
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route);
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white',parentId:root.id});
await assert.rejects(db().query('UPDATE tags SET parent_id=$1 WHERE id=$2',[child.id,root.id]),/cycle/);
const a=await sharp({create:{width:160,height:220,channels:3,background:'#b8c9af'}}).png().toBuffer();
@@ -69,7 +69,15 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video
let after;for(let i=0;i<80;i++){after=await request(`/api/posts/${published.published[0]}/snapshots`);if(after.job?.status==='ready'&&after.pages[2].rendered_at!==before.pages[2].rendered_at)break;await setTimeout(500)}
assert.notEqual(after.pages[2].rendered_at,before.pages[2].rendered_at);assert.equal(after.pages[1].rendered_at,before.pages[1].rendered_at,'Single-page refresh preserves other snapshots');
for(const doc of ['SKILL','style','upload','checks']){const r=await fetch(base+`/skills/street-people/${doc}.md`);assert.equal(r.status,200);assert.ok(r.headers.get('content-type')?.includes('text/markdown'));const text=await r.text();assert.ok(text.startsWith('# '));assert.ok(!text.includes('<html'));}
const home=await fetch(base+'/').then(r=>r.text());assert.ok(home.includes('查看文档与 Agent 入口'));
for(const doc of ['SKILL','workflow','visual-grammar','prompt','delivery']){
const path=`/skills/shadow-puppet/${doc}.md`;const response=await fetch(base+path);assert.equal(response.status,200,path);assert.ok(response.headers.get('content-type')?.includes('text/markdown'));assert.ok((await response.text()).startsWith('# '));
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'])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);
const home=await fetch(base+'/').then(r=>r.text());assert.ok(home.includes('查看文档与 Agent 入口'));assert.ok(home.includes('照片入皮影'));
const spec=await request('/api/skills/street-people');assert.equal(spec.generation.executor,'coding-agent');
assert.equal((await fetch(base+'/api/skills/street-people?format=md')).headers.get('content-type'),'text/markdown; charset=utf-8');
+21 -1
View File
@@ -1,8 +1,28 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {skillDocuments,skillResources,documentMarkdown} from '../src/content/skills/catalog';
import {skillDocuments,skillResources,documentMarkdown,getSkill,skillDocumentMarkdown,skillCatalog} from '../src/content/skills/catalog';
test('human and agent documents share a complete linked resource registry',()=>{
for(const doc of skillDocuments){const md=documentMarkdown(doc.id)!;assert.ok(md.startsWith('# '+doc.title));for(const block of doc.blocks){if(block.text)assert.ok(md.includes(block.text));for(const id of block.refs||[])assert.ok(skillResources.some(r=>r.id===id))}assert.ok(!md.includes('/p/street-people-'));}
assert.equal(documentMarkdown('missing'),null);assert.equal(new Set(skillResources.map(r=>r.id)).size,skillResources.length);
for(const doc of skillDocuments)assert.ok(skillResources.some(r=>r.id===doc.id&&r.human&&r.href.endsWith('.md')));
});
test('each registered skill resolves its own entry, documents and API resources without leaking another skill',()=>{
for(const card of skillCatalog){
const skill=getSkill(card.id)!;assert.ok(skill);assert.equal(skill.id,card.id);
assert.equal(new Set(skill.resources.map(r=>r.id)).size,skill.resources.length);
for(const doc of skill.documents){
const md=skillDocumentMarkdown(card.id,doc.id)!;assert.ok(md.startsWith('# '+doc.title));
assert.ok(skill.resources.some(r=>r.id===doc.id&&r.kind==='document'&&r.human));
for(const block of doc.blocks){if(block.text)assert.ok(md.includes(block.text));for(const id of block.refs||[])assert.ok(skill.resources.some(r=>r.id===id),card.id+': '+id);}
}
}
assert.equal(getSkill('__proto__'),undefined);assert.equal(skillDocumentMarkdown('missing','index'),null);assert.equal(skillDocumentMarkdown('shadow-puppet','missing'),null);
const shadow=getSkill('shadow-puppet')!;
assert.equal(shadow.resources.filter(r=>r.kind==='document').length,5);
assert.equal(shadow.resources.filter(r=>r.kind==='image').length,2);
assert.equal(shadow.resources.find(r=>r.id==='upload-api')?.href,'/api/upload');
assert.equal(shadow.resources.find(r=>r.id==='media-api')?.template,true);
assert.ok(!skillDocumentMarkdown('shadow-puppet','index')!.includes('/skills/street-people/'));
assert.ok(!documentMarkdown('index')!.includes('/skills/shadow-puppet/'));
});