diff --git a/README.md b/README.md index b1f1082..d4d6af4 100644 --- a/README.md +++ b/README.md @@ -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 说明,不执行上传或直接请求未替换参数的媒体地址模板。阅读器和纯文本文档路由按技能注册表读取,后续可继续增加技能。 diff --git a/next.config.ts b/next.config.ts index b8200eb..4608c3e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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, diff --git a/public/skills/shadow-puppet/dancer-example.png b/public/skills/shadow-puppet/dancer-example.png new file mode 100644 index 0000000..0eff652 Binary files /dev/null and b/public/skills/shadow-puppet/dancer-example.png differ diff --git a/public/skills/shadow-puppet/egret-example.png b/public/skills/shadow-puppet/egret-example.png new file mode 100644 index 0000000..6612e7c Binary files /dev/null and b/public/skills/shadow-puppet/egret-example.png differ diff --git a/src/components/SkillDocs.tsx b/src/components/SkillDocs.tsx index 91aedf3..a1b3398 100644 --- a/src/components/SkillDocs.tsx +++ b/src/components/SkillDocs.tsx @@ -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(null);const[copied,setCopied]=useState(false);const panel=useRef(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(null);const[copied,setCopied]=useState(false);const panel=useRef(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 {`${doc.title} · 技能文档 · PROMPT`}
← 全部技能
街头小人 / SKILL DOCUMENTS

{doc.title}

纯文本版本 ↗

网页与 Agent 纯文本版本来自同一份文档。参考图、子文档与接口均通过链接连接。

{doc.blocks.map((b,i)=>b.type==='heading'?

{b.text}

:b.type==='code'?
{b.text}
:b.type==='links'?:

{b.text}

)}
{open&&}
; + return {`${doc.title} · 技能文档 · PROMPT`}
← 全部技能
{skill.title} / SKILL DOCUMENTS

{doc.title}

纯文本版本 ↗

网页与 Agent 纯文本版本来自同一份文档。参考图、子文档与接口均通过链接连接。

{doc.blocks.map((b,i)=>b.type==='heading'?

{b.text}

:b.type==='code'?
{b.text}
:b.type==='links'?:

{b.text}

)}
{open&&}
; } diff --git a/src/content/skills/catalog.ts b/src/content/skills/catalog.ts index ada15a0..f44cf24 100644 --- a/src/content/skills/catalog.ts +++ b/src/content/skills/catalog.ts @@ -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={ + '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);} diff --git a/src/content/skills/shadow-puppet.ts b/src/content/skills/shadow-puppet.ts new file mode 100644 index 0000000..36c9ab9 --- /dev/null +++ b/src/content/skills/shadow-puppet.ts @@ -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/", + "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/。" + }, + { + "type": "paragraph", + "text": "400 表示表单或文件格式不符合要求,409 表示 slug 已存在,413 表示文件数量或大小超限,500 表示服务或存储异常。失败时不重复盲传;先检查错误。给用户回报真实的文件、post 地址与已完成的检查,不把推送成功当成生产上传成功。" + }, + { + "type": "links", + "refs": [ + "upload-api", + "media-api", + "index" + ] + } + ] + } +]; diff --git a/src/pages/api/skill-docs/[skill]/[doc].ts b/src/pages/api/skill-docs/[skill]/[doc].ts new file mode 100644 index 0000000..758230f --- /dev/null +++ b/src/pages/api/skill-docs/[skill]/[doc].ts @@ -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); +} diff --git a/src/pages/tools/skills/[skill]/[[...doc]].tsx b/src/pages/tools/skills/[skill]/[[...doc]].tsx new file mode 100644 index 0000000..21dcc14 --- /dev/null +++ b/src/pages/tools/skills/[skill]/[[...doc]].tsx @@ -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}; +}; diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 4b394b3..436c525 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -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('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'); diff --git a/tests/skill-docs.test.ts b/tests/skill-docs.test.ts index 4ab0498..9182b2e 100644 --- a/tests/skill-docs.test.ts +++ b/tests/skill-docs.test.ts @@ -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/')); +});