feat: add task workflow and asset downloads
This commit is contained in:
708
lib/content/seedance-starter/catalog.json
Normal file
708
lib/content/seedance-starter/catalog.json
Normal file
@@ -0,0 +1,708 @@
|
||||
{
|
||||
"generatedAt": "2026-05-03T13:52:55.451Z",
|
||||
"purpose": "Startup reference content for Seedance-based creation modes. The UI should present these as selectable examples before custom upload.",
|
||||
"sourceAttribution": {
|
||||
"repository": "EvoLinkAI/awesome-seedance-2-guide",
|
||||
"repositoryUrl": "https://github.com/EvoLinkAI/awesome-seedance-2-guide",
|
||||
"importedPages": [
|
||||
"https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md",
|
||||
"https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md"
|
||||
]
|
||||
},
|
||||
"downloadFailures": [],
|
||||
"cases": [
|
||||
{
|
||||
"id": "promo-storefront-sample-1",
|
||||
"slug": "promo-storefront-sample-1",
|
||||
"mode": "storefront_avatar_storyboard",
|
||||
"modeLabel": "宣传片制作",
|
||||
"guideId": "local-promo-sample",
|
||||
"title": "宣传片制作",
|
||||
"inputSummary": "本地样片参考 + 店铺分镜素材",
|
||||
"prompt": "参考@视频1的真实质感、空间氛围和转场节奏,结合用户上传素材,生成干净自然的通用宣传片。",
|
||||
"promptPattern": {
|
||||
"primaryReference": "local_storefront_reference_video",
|
||||
"userControlledInputs": [
|
||||
"project_name",
|
||||
"uploaded_materials",
|
||||
"final_prompt"
|
||||
],
|
||||
"seedanceInstruction": "将参考视频的真实质感、转场节奏和环境氛围迁移到用户上传素材上。"
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "storyboard_cards",
|
||||
"defaultUserAction": "选择参考样片后,逐段上传店铺分镜素材并修改口播。",
|
||||
"visibleControls": [
|
||||
"分镜素材",
|
||||
"口播",
|
||||
"画面辅助",
|
||||
"镜头辅助"
|
||||
],
|
||||
"customUploadSecondary": false
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": false,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "又见乌江名宿宣传片样片",
|
||||
"page": "local-desktop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "promo-digital-human-host-1",
|
||||
"slug": "promo-digital-human-host-1",
|
||||
"mode": "storefront_avatar_storyboard",
|
||||
"modeLabel": "宣传片制作",
|
||||
"guideId": "local-digital-human-host",
|
||||
"title": "达人模式",
|
||||
"inputSummary": "固定达人形象 + 店铺分镜素材",
|
||||
"prompt": "固定@图片1作为达人出镜形象,结合用户上传的店铺分镜素材,生成达人自然出镜讲解的本地商铺介绍视频。",
|
||||
"promptPattern": {
|
||||
"primaryReference": "fixed_digital_human_host",
|
||||
"userControlledInputs": [
|
||||
"storefront_images",
|
||||
"host_lines",
|
||||
"scene_order"
|
||||
],
|
||||
"seedanceInstruction": "保持@图片1达人形象稳定,让达人出镜讲解与店铺画面自然穿插,口播、口型和声音同步。"
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "storyboard_cards",
|
||||
"defaultUserAction": "选择达人模式后,逐段上传店铺分镜素材并修改口播。",
|
||||
"visibleControls": [
|
||||
"固定达人",
|
||||
"分镜素材",
|
||||
"口播",
|
||||
"画面辅助",
|
||||
"镜头辅助"
|
||||
],
|
||||
"customUploadSecondary": false
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": false,
|
||||
"hasResultVideo": false,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "本地达人形象",
|
||||
"page": "bundled-starter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-9-1",
|
||||
"slug": "2-3-9-1",
|
||||
"mode": "music_sync_ad",
|
||||
"modeLabel": "音乐卡点广告片",
|
||||
"guideId": "09-music-sync",
|
||||
"title": "时尚换装卡点",
|
||||
"inputSummary": "4张图 + 1个参考视频(节奏)",
|
||||
"prompt": "海报中的女生在不停的换装,服装参考@图片1@图片2的样式,手中提着@图片3的包,\n视频节奏参考@视频",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_rhythm",
|
||||
"userControlledInputs": [
|
||||
"ordered_images",
|
||||
"style_intensity",
|
||||
"scene_crop_freedom"
|
||||
],
|
||||
"seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。",
|
||||
"reusablePromptFragments": [
|
||||
"视频节奏参考@视频1",
|
||||
"根据@视频1中的画面关键帧的位置和整体节奏进行卡点",
|
||||
"可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "rhythm_timeline",
|
||||
"defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。",
|
||||
"visibleControls": [
|
||||
"参考节奏",
|
||||
"素材出现顺序",
|
||||
"卡点强度",
|
||||
"景别自由度",
|
||||
"整体风格"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "音乐卡点",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-9-2",
|
||||
"slug": "2-3-9-2",
|
||||
"mode": "music_sync_ad",
|
||||
"modeLabel": "音乐卡点广告片",
|
||||
"guideId": "09-music-sync",
|
||||
"title": "多风格图片卡点混剪",
|
||||
"inputSummary": "6张风格图 + 1个参考视频(节奏)",
|
||||
"prompt": "@图片1@图片2@图片3@图片4@图片5@图片6@图片7中的图片根据@视频中的画面关键帧的位置\n和整体节奏进行卡点,画面中的人物更有动感,整体画面风格更梦幻,画面张力强,可根据\n音乐及画面需求自行改变参考图的景别,及补充画面的光影变化",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_rhythm",
|
||||
"userControlledInputs": [
|
||||
"ordered_images",
|
||||
"style_intensity",
|
||||
"scene_crop_freedom"
|
||||
],
|
||||
"seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。",
|
||||
"reusablePromptFragments": [
|
||||
"视频节奏参考@视频1",
|
||||
"根据@视频1中的画面关键帧的位置和整体节奏进行卡点",
|
||||
"可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "rhythm_timeline",
|
||||
"defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。",
|
||||
"visibleControls": [
|
||||
"参考节奏",
|
||||
"素材出现顺序",
|
||||
"卡点强度",
|
||||
"景别自由度",
|
||||
"整体风格"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "音乐卡点",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-9-3",
|
||||
"slug": "2-3-9-3",
|
||||
"mode": "music_sync_ad",
|
||||
"modeLabel": "音乐卡点广告片",
|
||||
"guideId": "09-music-sync",
|
||||
"title": "风光大片卡点转场",
|
||||
"inputSummary": "6张风景图 + 1个参考视频(节奏)",
|
||||
"prompt": "@图片1@图片2@图片3@图片4@图片5@图片6的风光场景图,参考@视频中的画面节奏,\n转场间画面风格及音乐节奏进行卡点",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_rhythm",
|
||||
"userControlledInputs": [
|
||||
"ordered_images",
|
||||
"style_intensity",
|
||||
"scene_crop_freedom"
|
||||
],
|
||||
"seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。",
|
||||
"reusablePromptFragments": [
|
||||
"视频节奏参考@视频1",
|
||||
"根据@视频1中的画面关键帧的位置和整体节奏进行卡点",
|
||||
"可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "rhythm_timeline",
|
||||
"defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。",
|
||||
"visibleControls": [
|
||||
"参考节奏",
|
||||
"素材出现顺序",
|
||||
"卡点强度",
|
||||
"景别自由度",
|
||||
"整体风格"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "音乐卡点",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-9-4",
|
||||
"slug": "2-3-9-4",
|
||||
"mode": "music_sync_ad",
|
||||
"modeLabel": "音乐卡点广告片",
|
||||
"guideId": "09-music-sync",
|
||||
"title": "动漫分镜 + 战斗卡点",
|
||||
"inputSummary": "纯文本(详细分镜脚本)",
|
||||
"prompt": "8秒智性博弈式战斗动漫片段,贴合复仇主题。\n0-3秒:女主转身坐下,转镜头,女主下了一步棋子,并说\"你输了\"。\n3-4秒:快速摇镜头,转向对面男人面部特写,男人咬牙切齿,对结果很不满。\n4-6秒:切镜头,俯拍,女人下了一步棋,对面的人们惊叹。\n6-8秒:镜头迅速向下摇,画面黑屏转场,后画面渐亮,昏暗室内,女人看着窗外月色静静地说\n\"我们走着瞧\"。",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_rhythm",
|
||||
"userControlledInputs": [
|
||||
"ordered_images",
|
||||
"style_intensity",
|
||||
"scene_crop_freedom"
|
||||
],
|
||||
"seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。",
|
||||
"reusablePromptFragments": [
|
||||
"视频节奏参考@视频1",
|
||||
"根据@视频1中的画面关键帧的位置和整体节奏进行卡点",
|
||||
"可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "rhythm_timeline",
|
||||
"defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。",
|
||||
"visibleControls": [
|
||||
"参考节奏",
|
||||
"素材出现顺序",
|
||||
"卡点强度",
|
||||
"景别自由度",
|
||||
"整体风格"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": false,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": false
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/4",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "音乐卡点",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-3-1",
|
||||
"slug": "2-3-3-1",
|
||||
"mode": "creative_remix",
|
||||
"modeLabel": "创意视频复刻",
|
||||
"guideId": "03-creative-effects",
|
||||
"title": "科幻眼镜穿越多世界",
|
||||
"inputSummary": "4张场景图 + 1个参考视频",
|
||||
"prompt": "将@视频1的人物换成@图片1,@图片1为首帧,人物带上虚拟科幻眼镜,参考@视频1的运镜,\n及近的环绕镜头,从第三人称视角变成人物的主观视角,在AI虚拟眼镜中穿梭,来到@图片2\n的深邃的蓝色宇宙,出现几架飞<E69EB6><E9A39E><EFBFBD>穿梭向远方,镜头跟随飞船穿梭到@图片3的像素世界,\n镜头低空飞过像素的山林世界,里面的树木生长形式出现,随后视角仰拍,急速穿梭到\n@图片4的浅绿色纹理的星球,镜头穿梭并掠过星球表面",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_effect_template",
|
||||
"userControlledInputs": [
|
||||
"replacement_subject",
|
||||
"replacement_scene",
|
||||
"text_or_logo_replacement",
|
||||
"preserve_motion",
|
||||
"preserve_effect",
|
||||
"preserve_rhythm"
|
||||
],
|
||||
"seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。",
|
||||
"reusablePromptFragments": [
|
||||
"完全参考@视频1的特效和动作",
|
||||
"参考@视频1的运镜",
|
||||
"将@视频1的首帧人物替换成@图片1",
|
||||
"文字替换成用户提供的品牌文案或Logo"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "reference_mapping",
|
||||
"defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。",
|
||||
"visibleControls": [
|
||||
"保留运镜",
|
||||
"保留动作",
|
||||
"保留特效",
|
||||
"保留节奏",
|
||||
"替换主体",
|
||||
"替换场景",
|
||||
"替换文字/Logo"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "创意模板 / 复杂特效精准复刻",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-3-2",
|
||||
"slug": "2-3-3-2",
|
||||
"mode": "creative_remix",
|
||||
"modeLabel": "创意视频复刻",
|
||||
"guideId": "03-creative-effects",
|
||||
"title": "鱼眼换装闪切",
|
||||
"inputSummary": "6张图(人物+服装)+ 1个参考视频",
|
||||
"prompt": "参考第一张图片里模特的五官长相。模特分别穿着第2-6张参考图里的服装凑近镜头,\n做出调皮、冷酷、可爱、惊讶、耍帅的造型,每一个造型穿着不同服装,每次更换,\n画面伴随会切镜,参考视频的里鱼眼镜头效果、重影闪烁的炫影画面效果,参考@视频1",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_effect_template",
|
||||
"userControlledInputs": [
|
||||
"replacement_subject",
|
||||
"replacement_scene",
|
||||
"text_or_logo_replacement",
|
||||
"preserve_motion",
|
||||
"preserve_effect",
|
||||
"preserve_rhythm"
|
||||
],
|
||||
"seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。",
|
||||
"reusablePromptFragments": [
|
||||
"完全参考@视频1的特效和动作",
|
||||
"参考@视频1的运镜",
|
||||
"将@视频1的首帧人物替换成@图片1",
|
||||
"文字替换成用户提供的品牌文案或Logo"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "reference_mapping",
|
||||
"defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。",
|
||||
"visibleControls": [
|
||||
"保留运镜",
|
||||
"保留动作",
|
||||
"保留特效",
|
||||
"保留节奏",
|
||||
"替换主体",
|
||||
"替换场景",
|
||||
"替换文字/Logo"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "创意模板 / 复杂特效精准复刻",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-3-3",
|
||||
"slug": "2-3-3-3",
|
||||
"mode": "creative_remix",
|
||||
"modeLabel": "创意视频复刻",
|
||||
"guideId": "03-creative-effects",
|
||||
"title": "羽绒服广告创意复刻",
|
||||
"inputSummary": "3张图 + 1个参考视频",
|
||||
"prompt": "参考视频的广告创意,用提供的羽绒服图片,并参考鹅绒图片、天鹅图片,搭配以下广告词\n\"这是根鹅绒,这是暖天鹅,这是能穿的极地天鹅绒羽绒服,新年穿得暖,生活过得暖\",\n生成新的羽绒服广告视频。",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_effect_template",
|
||||
"userControlledInputs": [
|
||||
"replacement_subject",
|
||||
"replacement_scene",
|
||||
"text_or_logo_replacement",
|
||||
"preserve_motion",
|
||||
"preserve_effect",
|
||||
"preserve_rhythm"
|
||||
],
|
||||
"seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。",
|
||||
"reusablePromptFragments": [
|
||||
"完全参考@视频1的特效和动作",
|
||||
"参考@视频1的运镜",
|
||||
"将@视频1的首帧人物替换成@图片1",
|
||||
"文字替换成用户提供的品牌文案或Logo"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "reference_mapping",
|
||||
"defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。",
|
||||
"visibleControls": [
|
||||
"保留运镜",
|
||||
"保留动作",
|
||||
"保留特效",
|
||||
"保留节奏",
|
||||
"替换主体",
|
||||
"替换场景",
|
||||
"替换文字/Logo"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "创意模板 / 复杂特效精准复刻",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-3-4",
|
||||
"slug": "2-3-3-4",
|
||||
"mode": "creative_remix",
|
||||
"modeLabel": "创意视频复刻",
|
||||
"guideId": "03-creative-effects",
|
||||
"title": "水墨太极功夫",
|
||||
"inputSummary": "1张人物图 + 1个参考视频",
|
||||
"prompt": "黑白水墨风格,@图片1的人物参考@视频1的特效和动作,上演一段水墨太极功夫",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_effect_template",
|
||||
"userControlledInputs": [
|
||||
"replacement_subject",
|
||||
"replacement_scene",
|
||||
"text_or_logo_replacement",
|
||||
"preserve_motion",
|
||||
"preserve_effect",
|
||||
"preserve_rhythm"
|
||||
],
|
||||
"seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。",
|
||||
"reusablePromptFragments": [
|
||||
"完全参考@视频1的特效和动作",
|
||||
"参考@视频1的运镜",
|
||||
"将@视频1的首帧人物替换成@图片1",
|
||||
"文字替换成用户提供的品牌文案或Logo"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "reference_mapping",
|
||||
"defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。",
|
||||
"visibleControls": [
|
||||
"保留运镜",
|
||||
"保留动作",
|
||||
"保留特效",
|
||||
"保留节奏",
|
||||
"替换主体",
|
||||
"替换场景",
|
||||
"替换文字/Logo"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/4",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "创意模板 / 复杂特效精准复刻",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-3-5",
|
||||
"slug": "2-3-3-5",
|
||||
"mode": "creative_remix",
|
||||
"modeLabel": "创意视频复刻",
|
||||
"guideId": "03-creative-effects",
|
||||
"title": "角色变装特效(玫瑰蔓延)",
|
||||
"inputSummary": "2张人物图 + 1个参考视频",
|
||||
"prompt": "将@视频1的首帧人物替换成@图片1,完全@参考视频1的特效和动作,手里的花蕊长出玫瑰\n花瓣,裂纹在脸部向上延伸,逐渐被杂草覆盖,人物双手拂过脸部,杂草变成粒子消散,\n最后变成@图片2的长相",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_effect_template",
|
||||
"userControlledInputs": [
|
||||
"replacement_subject",
|
||||
"replacement_scene",
|
||||
"text_or_logo_replacement",
|
||||
"preserve_motion",
|
||||
"preserve_effect",
|
||||
"preserve_rhythm"
|
||||
],
|
||||
"seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。",
|
||||
"reusablePromptFragments": [
|
||||
"完全参考@视频1的特效和动作",
|
||||
"参考@视频1的运镜",
|
||||
"将@视频1的首帧人物替换成@图片1",
|
||||
"文字替换成用户提供的品牌文案或Logo"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "reference_mapping",
|
||||
"defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。",
|
||||
"visibleControls": [
|
||||
"保留运镜",
|
||||
"保留动作",
|
||||
"保留特效",
|
||||
"保留节奏",
|
||||
"替换主体",
|
||||
"替换场景",
|
||||
"替换文字/Logo"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/5",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "创意模板 / 复杂特效精准复刻",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-3-6",
|
||||
"slug": "2-3-3-6",
|
||||
"mode": "creative_remix",
|
||||
"modeLabel": "创意视频复刻",
|
||||
"guideId": "03-creative-effects",
|
||||
"title": "拼图破碎转场 + 文字替换",
|
||||
"inputSummary": "2张图 + 1个参考视频",
|
||||
"prompt": "由@图片1的天花板开始,参考@视频1的拼图破碎效果进行转场,\"BELIEVE\"字体替换成\n\"Seedance\",参考@图2的字体",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_effect_template",
|
||||
"userControlledInputs": [
|
||||
"replacement_subject",
|
||||
"replacement_scene",
|
||||
"text_or_logo_replacement",
|
||||
"preserve_motion",
|
||||
"preserve_effect",
|
||||
"preserve_rhythm"
|
||||
],
|
||||
"seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。",
|
||||
"reusablePromptFragments": [
|
||||
"完全参考@视频1的特效和动作",
|
||||
"参考@视频1的运镜",
|
||||
"将@视频1的首帧人物替换成@图片1",
|
||||
"文字替换成用户提供的品牌文案或Logo"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "reference_mapping",
|
||||
"defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。",
|
||||
"visibleControls": [
|
||||
"保留运镜",
|
||||
"保留动作",
|
||||
"保留特效",
|
||||
"保留节奏",
|
||||
"替换主体",
|
||||
"替换场景",
|
||||
"替换文字/Logo"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/6",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "创意模板 / 复杂特效精准复刻",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-3-7",
|
||||
"slug": "2-3-3-7",
|
||||
"mode": "creative_remix",
|
||||
"modeLabel": "创意视频复刻",
|
||||
"guideId": "03-creative-effects",
|
||||
"title": "金色粒子片头",
|
||||
"inputSummary": "1张文字/Logo图 + 1个参考视频",
|
||||
"prompt": "以黑幕开场,参考视频1的粒子特效和材质,金色鎏金材质的沙砾从画面左边飘出并向右覆盖,\n参考@视频1的粒子吹散效果,@图片1的字体逐渐出现在画面中心",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_effect_template",
|
||||
"userControlledInputs": [
|
||||
"replacement_subject",
|
||||
"replacement_scene",
|
||||
"text_or_logo_replacement",
|
||||
"preserve_motion",
|
||||
"preserve_effect",
|
||||
"preserve_rhythm"
|
||||
],
|
||||
"seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。",
|
||||
"reusablePromptFragments": [
|
||||
"完全参考@视频1的特效和动作",
|
||||
"参考@视频1的运镜",
|
||||
"将@视频1的首帧人物替换成@图片1",
|
||||
"文字替换成用户提供的品牌文案或Logo"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "reference_mapping",
|
||||
"defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。",
|
||||
"visibleControls": [
|
||||
"保留运镜",
|
||||
"保留动作",
|
||||
"保留特效",
|
||||
"保留节奏",
|
||||
"替换主体",
|
||||
"替换场景",
|
||||
"替换文字/Logo"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/7",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "创意模板 / 复杂特效精准复刻",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2-3-3-8",
|
||||
"slug": "2-3-3-8",
|
||||
"mode": "creative_remix",
|
||||
"modeLabel": "创意视频复刻",
|
||||
"guideId": "03-creative-effects",
|
||||
"title": "吃泡面抽象行为艺术",
|
||||
"inputSummary": "1张人物图 + 1个参考视频",
|
||||
"prompt": "@图片1的人物参考@视频1中的动作和表情变化,展示吃泡面的抽象行为",
|
||||
"promptPattern": {
|
||||
"primaryReference": "reference_video_as_effect_template",
|
||||
"userControlledInputs": [
|
||||
"replacement_subject",
|
||||
"replacement_scene",
|
||||
"text_or_logo_replacement",
|
||||
"preserve_motion",
|
||||
"preserve_effect",
|
||||
"preserve_rhythm"
|
||||
],
|
||||
"seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。",
|
||||
"reusablePromptFragments": [
|
||||
"完全参考@视频1的特效和动作",
|
||||
"参考@视频1的运镜",
|
||||
"将@视频1的首帧人物替换成@图片1",
|
||||
"文字替换成用户提供的品牌文案或Logo"
|
||||
]
|
||||
},
|
||||
"interactionHooks": {
|
||||
"editorType": "reference_mapping",
|
||||
"defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。",
|
||||
"visibleControls": [
|
||||
"保留运镜",
|
||||
"保留动作",
|
||||
"保留特效",
|
||||
"保留节奏",
|
||||
"替换主体",
|
||||
"替换场景",
|
||||
"替换文字/Logo"
|
||||
],
|
||||
"customUploadSecondary": true
|
||||
},
|
||||
"display": {
|
||||
"hasReferenceVideo": true,
|
||||
"hasResultVideo": true,
|
||||
"selectableAsReferenceTemplate": true
|
||||
},
|
||||
"assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/8",
|
||||
"assets": [],
|
||||
"source": {
|
||||
"title": "创意模板 / 复杂特效精准复刻",
|
||||
"page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import catalog from "@/runtime/nianxx-play/content/seedance-starter/catalog.json";
|
||||
import catalog from "@/lib/content/seedance-starter/catalog.json";
|
||||
|
||||
type LegacyCatalog = typeof catalog;
|
||||
type LegacyCase = LegacyCatalog["cases"][number];
|
||||
type SeedanceCatalog = typeof catalog;
|
||||
type SeedanceCase = SeedanceCatalog["cases"][number];
|
||||
|
||||
export type VideoTemplate = {
|
||||
id: string;
|
||||
@@ -24,26 +24,16 @@ export type VideoTemplate = {
|
||||
};
|
||||
|
||||
export function getVideoTemplates(): VideoTemplate[] {
|
||||
return (catalog.cases as LegacyCase[]).map((item) => ({
|
||||
return (catalog.cases as SeedanceCase[]).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
mode: item.mode,
|
||||
modeLabel: item.modeLabel,
|
||||
prompt: item.prompt,
|
||||
seedanceInstruction: typeof item.promptPattern?.seedanceInstruction === "string" ? item.promptPattern.seedanceInstruction : undefined,
|
||||
coverUrl: rewriteLegacyUrl(item.display?.coverPublicUrl),
|
||||
referenceVideoUrl: rewriteLegacyUrl(item.display?.referenceVideoPublicUrl),
|
||||
resultVideoUrl: rewriteLegacyUrl(item.display?.resultVideoPublicUrl),
|
||||
selectable: Boolean(item.display?.selectableAsReferenceTemplate),
|
||||
controls: item.interactionHooks?.visibleControls || [],
|
||||
materials: (item.assets || [])
|
||||
.map((asset) => ({
|
||||
role: asset.role,
|
||||
type: asset.role.includes("video") ? "video" as const : asset.role.includes("audio") ? "audio" as const : "image" as const,
|
||||
url: rewriteLegacyUrl(asset.publicUrl) || "",
|
||||
label: "promptLabel" in asset ? asset.promptLabel : undefined
|
||||
}))
|
||||
.filter((asset) => asset.url)
|
||||
materials: []
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -51,10 +41,3 @@ export function getTemplateById(id?: string): VideoTemplate | undefined {
|
||||
if (!id) return undefined;
|
||||
return getVideoTemplates().find((template) => template.id === id);
|
||||
}
|
||||
|
||||
function rewriteLegacyUrl(url?: string | null): string | undefined {
|
||||
if (!url) return undefined;
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
if (url.startsWith("/seedance-starter-assets/") || url.startsWith("/starter/") || url.startsWith("/planning-cases/")) return url;
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { AppState, Asset, GenerationJob, Project, UsageEvent } from "@/lib/types";
|
||||
import type { AppState, Asset, GenerationCapability, GenerationJob, GenerationStatus, Project, UsageEvent } from "@/lib/types";
|
||||
import { createId } from "@/lib/server/ids";
|
||||
import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime";
|
||||
|
||||
@@ -12,6 +12,21 @@ type AssetInput = Omit<Asset, "id" | "createdAt" | "updatedAt"> & Partial<Pick<A
|
||||
type JobInput = Omit<GenerationJob, "id" | "createdAt" | "updatedAt"> & Partial<Pick<GenerationJob, "id" | "createdAt" | "updatedAt">>;
|
||||
type UsageInput = Omit<UsageEvent, "id" | "createdAt"> & Partial<Pick<UsageEvent, "id" | "createdAt">>;
|
||||
|
||||
export type GenerationJobListFilters = {
|
||||
ownerId?: string;
|
||||
externalClientId?: string;
|
||||
status?: GenerationStatus;
|
||||
capability?: GenerationCapability;
|
||||
limit?: number;
|
||||
before?: string;
|
||||
};
|
||||
|
||||
export type ClaimGenerationJobsInput = {
|
||||
workerId: string;
|
||||
limit?: number;
|
||||
lockTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export async function listAssets(ownerId = DEFAULT_OWNER_ID): Promise<Asset[]> {
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
@@ -82,19 +97,37 @@ export async function deleteAsset(id: string): Promise<Asset | null> {
|
||||
}
|
||||
|
||||
export async function listGenerationJobs(ownerId = DEFAULT_OWNER_ID, limit = 200): Promise<GenerationJob[]> {
|
||||
return listGenerationJobsFiltered({ ownerId, limit });
|
||||
}
|
||||
|
||||
export async function listGenerationJobsFiltered(filters: GenerationJobListFilters = {}): Promise<GenerationJob[]> {
|
||||
const ownerId = filters.ownerId || DEFAULT_OWNER_ID;
|
||||
const limit = filters.limit || 200;
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase
|
||||
let query = supabase
|
||||
.from("generation_jobs")
|
||||
.select("*")
|
||||
.eq("owner_id", ownerId)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(limit);
|
||||
if (filters.externalClientId) query = query.eq("external_client_id", filters.externalClientId);
|
||||
if (filters.status) query = query.eq("status", filters.status);
|
||||
if (filters.capability) query = query.eq("capability", filters.capability);
|
||||
if (filters.before) query = query.lt("created_at", filters.before);
|
||||
const { data, error } = await query;
|
||||
if (error) throw new Error(error.message);
|
||||
return (data || []).map(jobFromRow);
|
||||
}
|
||||
const state = await readState();
|
||||
return state.generationJobs.filter((job) => job.ownerId === ownerId).sort(sortNewest).slice(0, limit);
|
||||
return state.generationJobs
|
||||
.filter((job) => job.ownerId === ownerId)
|
||||
.filter((job) => !filters.externalClientId || job.externalClientId === filters.externalClientId)
|
||||
.filter((job) => !filters.status || job.status === filters.status)
|
||||
.filter((job) => !filters.capability || job.capability === filters.capability)
|
||||
.filter((job) => !filters.before || job.createdAt < filters.before)
|
||||
.sort(sortNewest)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function getGenerationJob(id: string): Promise<GenerationJob | null> {
|
||||
@@ -118,6 +151,11 @@ export async function createGenerationJob(input: JobInput): Promise<GenerationJo
|
||||
inputUrls: input.inputUrls || [],
|
||||
outputAssetIds: input.outputAssetIds || [],
|
||||
requestPayload: input.requestPayload || {},
|
||||
priority: input.priority ?? 0,
|
||||
attempts: input.attempts ?? 0,
|
||||
maxAttempts: input.maxAttempts ?? 3,
|
||||
scheduledAt: input.scheduledAt || now,
|
||||
webhookAttempts: input.webhookAttempts ?? 0,
|
||||
createdAt: input.createdAt || now,
|
||||
updatedAt: input.updatedAt || now
|
||||
};
|
||||
@@ -133,6 +171,100 @@ export async function createGenerationJob(input: JobInput): Promise<GenerationJo
|
||||
});
|
||||
}
|
||||
|
||||
export async function findGenerationJobByIdempotency(
|
||||
externalClientId: string,
|
||||
idempotencyKey: string,
|
||||
ownerId = DEFAULT_OWNER_ID
|
||||
): Promise<GenerationJob | null> {
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from("generation_jobs")
|
||||
.select("*")
|
||||
.eq("owner_id", ownerId)
|
||||
.eq("external_client_id", externalClientId)
|
||||
.eq("idempotency_key", idempotencyKey)
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(error.message);
|
||||
return data ? jobFromRow(data) : null;
|
||||
}
|
||||
const state = await readState();
|
||||
return state.generationJobs.find((job) => (
|
||||
job.ownerId === ownerId &&
|
||||
job.externalClientId === externalClientId &&
|
||||
job.idempotencyKey === idempotencyKey
|
||||
)) || null;
|
||||
}
|
||||
|
||||
export async function claimGenerationJobs(input: ClaimGenerationJobsInput): Promise<GenerationJob[]> {
|
||||
const limit = Math.max(1, Math.min(input.limit || 1, 20));
|
||||
const lockTimeoutMs = input.lockTimeoutMs ?? 5 * 60 * 1000;
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase.rpc("claim_generation_jobs", {
|
||||
p_worker_id: input.workerId,
|
||||
p_limit: limit,
|
||||
p_lock_timeout_seconds: Math.ceil(lockTimeoutMs / 1000)
|
||||
});
|
||||
if (error) throw new Error(`claim_generation_jobs failed: ${error.message}`);
|
||||
return (Array.isArray(data) ? data : []).map(jobFromRow);
|
||||
}
|
||||
|
||||
return mutateLocalState((state) => {
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const staleBefore = new Date(now.getTime() - lockTimeoutMs).toISOString();
|
||||
const selected = state.generationJobs
|
||||
.filter((job) => isClaimableJob(job, nowIso, staleBefore))
|
||||
.sort(sortClaimableJobs)
|
||||
.slice(0, limit);
|
||||
for (const job of selected) {
|
||||
job.lockedAt = nowIso;
|
||||
job.lockedBy = input.workerId;
|
||||
if (!job.startedAt) job.startedAt = nowIso;
|
||||
job.updatedAt = nowIso;
|
||||
}
|
||||
return selected.map((job) => ({ ...job }));
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearGenerationJobLock(
|
||||
id: string,
|
||||
patch: Partial<GenerationJob> = {},
|
||||
options: { clearProviderTaskId?: boolean } = {}
|
||||
): Promise<GenerationJob> {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from("generation_jobs")
|
||||
.update({
|
||||
...jobToRow({ ...patch, updatedAt } as GenerationJob),
|
||||
locked_at: null,
|
||||
locked_by: null,
|
||||
...(options.clearProviderTaskId ? { provider_task_id: null } : {})
|
||||
})
|
||||
.eq("id", id)
|
||||
.select("*")
|
||||
.single();
|
||||
if (error) throw new Error(error.message);
|
||||
return jobFromRow(data);
|
||||
}
|
||||
return mutateLocalState((state) => {
|
||||
const index = state.generationJobs.findIndex((job) => job.id === id);
|
||||
if (index === -1) throw new Error(`Generation job not found: ${id}`);
|
||||
state.generationJobs[index] = {
|
||||
...state.generationJobs[index],
|
||||
...patch,
|
||||
lockedAt: undefined,
|
||||
lockedBy: undefined,
|
||||
...(options.clearProviderTaskId ? { providerTaskId: undefined } : {}),
|
||||
updatedAt
|
||||
};
|
||||
return state.generationJobs[index];
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateGenerationJob(id: string, patch: Partial<GenerationJob>): Promise<GenerationJob> {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const supabase = getSupabaseAdmin();
|
||||
@@ -252,6 +384,20 @@ function sortNewest<T extends { createdAt: string }>(a: T, b: T): number {
|
||||
return b.createdAt.localeCompare(a.createdAt);
|
||||
}
|
||||
|
||||
function isClaimableJob(job: GenerationJob, nowIso: string, staleBefore: string): boolean {
|
||||
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return false;
|
||||
if ((job.scheduledAt || job.createdAt) > nowIso) return false;
|
||||
return !job.lockedAt || job.lockedAt < staleBefore;
|
||||
}
|
||||
|
||||
function sortClaimableJobs(a: GenerationJob, b: GenerationJob): number {
|
||||
const priority = (b.priority || 0) - (a.priority || 0);
|
||||
if (priority !== 0) return priority;
|
||||
const scheduled = (a.scheduledAt || a.createdAt).localeCompare(b.scheduledAt || b.createdAt);
|
||||
if (scheduled !== 0) return scheduled;
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
|
||||
function assetToRow(asset: Partial<Asset>) {
|
||||
return {
|
||||
id: asset.id,
|
||||
@@ -288,6 +434,7 @@ function jobToRow(job: Partial<GenerationJob>) {
|
||||
const row: Record<string, unknown> = {};
|
||||
if (job.id !== undefined) row.id = job.id;
|
||||
if (job.ownerId !== undefined) row.owner_id = job.ownerId;
|
||||
if (job.externalClientId !== undefined) row.external_client_id = job.externalClientId;
|
||||
if (job.capability !== undefined) row.capability = job.capability;
|
||||
if (job.provider !== undefined) row.provider = job.provider;
|
||||
if (job.reqKey !== undefined) row.req_key = job.reqKey;
|
||||
@@ -301,6 +448,19 @@ function jobToRow(job: Partial<GenerationJob>) {
|
||||
if (job.responsePayload !== undefined) row.response_payload = job.responsePayload;
|
||||
if (job.error !== undefined) row.error = job.error;
|
||||
if (job.retryOf !== undefined) row.retry_of = job.retryOf;
|
||||
if (job.idempotencyKey !== undefined) row.idempotency_key = job.idempotencyKey;
|
||||
if (job.idempotencyFingerprint !== undefined) row.idempotency_fingerprint = job.idempotencyFingerprint;
|
||||
if (job.priority !== undefined) row.priority = job.priority;
|
||||
if (job.attempts !== undefined) row.attempts = job.attempts;
|
||||
if (job.maxAttempts !== undefined) row.max_attempts = job.maxAttempts;
|
||||
if (job.scheduledAt !== undefined) row.scheduled_at = job.scheduledAt;
|
||||
if (job.lockedAt !== undefined) row.locked_at = job.lockedAt;
|
||||
if (job.lockedBy !== undefined) row.locked_by = job.lockedBy;
|
||||
if (job.startedAt !== undefined) row.started_at = job.startedAt;
|
||||
if (job.completedAt !== undefined) row.completed_at = job.completedAt;
|
||||
if (job.webhookUrl !== undefined) row.webhook_url = job.webhookUrl;
|
||||
if (job.webhookAttempts !== undefined) row.webhook_attempts = job.webhookAttempts;
|
||||
if (job.webhookLastStatus !== undefined) row.webhook_last_status = job.webhookLastStatus;
|
||||
if (job.createdAt !== undefined) row.created_at = job.createdAt;
|
||||
if (job.updatedAt !== undefined) row.updated_at = job.updatedAt;
|
||||
return row;
|
||||
@@ -310,6 +470,7 @@ function jobFromRow(row: Record<string, unknown>): GenerationJob {
|
||||
return {
|
||||
id: String(row.id),
|
||||
ownerId: String(row.owner_id),
|
||||
externalClientId: optionalString(row.external_client_id),
|
||||
capability: row.capability as GenerationJob["capability"],
|
||||
provider: row.provider as GenerationJob["provider"],
|
||||
reqKey: String(row.req_key),
|
||||
@@ -323,6 +484,27 @@ function jobFromRow(row: Record<string, unknown>): GenerationJob {
|
||||
responsePayload: isRecord(row.response_payload) ? row.response_payload : undefined,
|
||||
error: isRecord(row.error) ? { message: String(row.error.message || "Unknown error"), code: row.error.code as string | number | undefined, retryable: Boolean(row.error.retryable) } : undefined,
|
||||
retryOf: row.retry_of ? String(row.retry_of) : undefined,
|
||||
idempotencyKey: optionalString(row.idempotency_key),
|
||||
idempotencyFingerprint: optionalString(row.idempotency_fingerprint),
|
||||
priority: optionalNumber(row.priority),
|
||||
attempts: optionalNumber(row.attempts),
|
||||
maxAttempts: optionalNumber(row.max_attempts),
|
||||
scheduledAt: optionalString(row.scheduled_at),
|
||||
lockedAt: optionalString(row.locked_at),
|
||||
lockedBy: optionalString(row.locked_by),
|
||||
startedAt: optionalString(row.started_at),
|
||||
completedAt: optionalString(row.completed_at),
|
||||
webhookUrl: optionalString(row.webhook_url),
|
||||
webhookAttempts: optionalNumber(row.webhook_attempts),
|
||||
webhookLastStatus: isRecord(row.webhook_last_status)
|
||||
? {
|
||||
ok: Boolean(row.webhook_last_status.ok),
|
||||
status: optionalNumber(row.webhook_last_status.status),
|
||||
error: optionalString(row.webhook_last_status.error),
|
||||
attemptedAt: String(row.webhook_last_status.attemptedAt || row.webhook_last_status.attempted_at || ""),
|
||||
nextAttemptAt: optionalString(row.webhook_last_status.nextAttemptAt || row.webhook_last_status.next_attempt_at)
|
||||
}
|
||||
: undefined,
|
||||
createdAt: String(row.created_at),
|
||||
updatedAt: String(row.updated_at)
|
||||
};
|
||||
@@ -355,3 +537,15 @@ function usageFromRow(row: Record<string, unknown>): UsageEvent {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { queryVisualTask, shouldMockVisualApi, submitVisualTask } from "@/lib/vo
|
||||
|
||||
export type SubmitImageJobInput = {
|
||||
ownerId?: string;
|
||||
externalClientId?: string;
|
||||
capability: EnabledImageCapability;
|
||||
prompt?: string;
|
||||
imageUrls?: string[];
|
||||
@@ -43,6 +44,11 @@ export type SubmitImageJobInput = {
|
||||
resolution?: "4k" | "8k";
|
||||
seed?: number;
|
||||
retryOf?: string;
|
||||
idempotencyKey?: string;
|
||||
idempotencyFingerprint?: string;
|
||||
priority?: number;
|
||||
maxAttempts?: number;
|
||||
webhookUrl?: string;
|
||||
};
|
||||
|
||||
export async function submitImageJob(input: SubmitImageJobInput, origin: string): Promise<GenerationJob> {
|
||||
@@ -57,6 +63,7 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string)
|
||||
const reqKey = engine === "evolink" ? getEvolinkImageSettings().model : capability.reqKey;
|
||||
let job = await createGenerationJob({
|
||||
ownerId,
|
||||
externalClientId: input.externalClientId,
|
||||
capability: input.capability,
|
||||
provider: mock ? "mock" : engine === "evolink" ? "evolink" : "volcengine-visual",
|
||||
reqKey,
|
||||
@@ -70,15 +77,30 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string)
|
||||
input,
|
||||
providerPayload
|
||||
},
|
||||
retryOf: input.retryOf
|
||||
retryOf: input.retryOf,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
idempotencyFingerprint: input.idempotencyFingerprint,
|
||||
priority: input.priority,
|
||||
maxAttempts: input.maxAttempts,
|
||||
webhookUrl: input.webhookUrl
|
||||
});
|
||||
|
||||
if (mock) {
|
||||
return completeMockJob(job, origin);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function advanceImageJob(jobId: string, origin: string): Promise<GenerationJob> {
|
||||
const job = await getGenerationJob(jobId);
|
||||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||||
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job;
|
||||
if (job.provider === "mock") return completeMockJob(job, origin);
|
||||
if (!job.providerTaskId) return dispatchImageJob(job);
|
||||
return syncImageJob(job.id, origin);
|
||||
}
|
||||
|
||||
async function dispatchImageJob(job: GenerationJob): Promise<GenerationJob> {
|
||||
const providerPayload = asRecord(job.requestPayload.providerPayload);
|
||||
try {
|
||||
if (engine === "evolink") {
|
||||
if (job.provider === "evolink") {
|
||||
const response = await submitEvolinkImageTask(providerPayload);
|
||||
const taskId = getEvolinkTaskId(response);
|
||||
if (!taskId) {
|
||||
@@ -132,7 +154,7 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string)
|
||||
export async function syncImageJob(jobId: string, origin: string): Promise<GenerationJob> {
|
||||
const job = await getGenerationJob(jobId);
|
||||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||||
if (["succeeded", "failed", "expired"].includes(job.status)) return job;
|
||||
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job;
|
||||
if (job.provider === "mock") return completeMockJob(job, origin);
|
||||
if (!job.providerTaskId) return job;
|
||||
|
||||
@@ -353,3 +375,9 @@ function sourceForCapability(capability: string) {
|
||||
if (capability === "image.upscale") return "upscaled";
|
||||
return "generated";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
69
lib/server/public-api-auth.ts
Normal file
69
lib/server/public-api-auth.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
export type PublicApiClient = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export class PublicApiAuthError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status = 401) {
|
||||
super(message);
|
||||
this.name = "PublicApiAuthError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPublicApiClients(): PublicApiClient[] {
|
||||
const configured = process.env.ZHINIAN_API_KEYS?.trim();
|
||||
if (!configured) return [];
|
||||
return configured
|
||||
.split(/[\n,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const separator = entry.indexOf(":");
|
||||
if (separator === -1) return { id: "default", key: entry };
|
||||
return {
|
||||
id: entry.slice(0, separator).trim(),
|
||||
key: entry.slice(separator + 1).trim()
|
||||
};
|
||||
})
|
||||
.filter((client) => client.id && client.key);
|
||||
}
|
||||
|
||||
export function authenticatePublicApiRequest(request: Request): PublicApiClient {
|
||||
const presented = getPresentedApiKey(request);
|
||||
if (!presented) throw new PublicApiAuthError("Missing API key.");
|
||||
const client = getPublicApiClients().find((candidate) => safeEqual(candidate.key, presented));
|
||||
if (!client) throw new PublicApiAuthError("Invalid API key.");
|
||||
return client;
|
||||
}
|
||||
|
||||
export function assertInternalWorkerToken(request: Request) {
|
||||
const expected = process.env.ZHINIAN_INTERNAL_WORKER_TOKEN?.trim();
|
||||
if (!expected && process.env.NODE_ENV !== "production") return;
|
||||
if (!expected) throw new PublicApiAuthError("Worker token is not configured.", 500);
|
||||
const presented = request.headers.get("x-zhinian-worker-token") || bearerToken(request);
|
||||
if (!presented || !safeEqual(expected, presented)) {
|
||||
throw new PublicApiAuthError("Invalid worker token.", 401);
|
||||
}
|
||||
}
|
||||
|
||||
function getPresentedApiKey(request: Request): string | undefined {
|
||||
return bearerToken(request) || request.headers.get("x-zhinian-api-key") || undefined;
|
||||
}
|
||||
|
||||
function bearerToken(request: Request): string | undefined {
|
||||
const authorization = request.headers.get("authorization") || "";
|
||||
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function safeEqual(expected: string, presented: string): boolean {
|
||||
const left = Buffer.from(expected);
|
||||
const right = Buffer.from(presented);
|
||||
if (left.length !== right.length) return false;
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
149
lib/server/public-api-jobs.ts
Normal file
149
lib/server/public-api-jobs.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler";
|
||||
import { findGenerationJobByIdempotency } from "@/lib/server/data-store";
|
||||
import { submitImageJob, type SubmitImageJobInput } from "@/lib/server/generation-service";
|
||||
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
|
||||
import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service";
|
||||
import type { PublicApiClient } from "@/lib/server/public-api-auth";
|
||||
import type { EnabledImageCapability, GenerationCapability, GenerationJob } from "@/lib/types";
|
||||
|
||||
export class PublicApiConflictError extends Error {
|
||||
status = 409;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PublicApiConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export type PublicJobCreateBody = {
|
||||
capability?: GenerationCapability;
|
||||
prompt?: string;
|
||||
inputUrls?: string[];
|
||||
imageUrls?: string[];
|
||||
inputAssetIds?: string[];
|
||||
materials?: PromptMaterial[];
|
||||
promptAssembly?: PromptAssemblyInput;
|
||||
settings?: SubmitVideoJobInput["settings"];
|
||||
priority?: number;
|
||||
webhookUrl?: string;
|
||||
idempotencyKey?: string;
|
||||
scale?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
min_ratio?: number;
|
||||
max_ratio?: number;
|
||||
force_single?: boolean;
|
||||
resolution?: "4k" | "8k";
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
export async function createPublicGenerationJob(input: {
|
||||
client: PublicApiClient;
|
||||
body: PublicJobCreateBody;
|
||||
request: Request;
|
||||
origin: string;
|
||||
}): Promise<{ job: GenerationJob; reused: boolean }> {
|
||||
const capability = input.body.capability || "image.generate";
|
||||
const idempotencyKey = input.request.headers.get("idempotency-key") || input.body.idempotencyKey;
|
||||
const fingerprint = idempotencyKey ? fingerprintBody(input.body) : undefined;
|
||||
if (idempotencyKey && fingerprint) {
|
||||
const existing = await findGenerationJobByIdempotency(input.client.id, idempotencyKey);
|
||||
if (existing) {
|
||||
if (existing.idempotencyFingerprint !== fingerprint) {
|
||||
throw new PublicApiConflictError("Idempotency key was already used with a different request body.");
|
||||
}
|
||||
return { job: existing, reused: true };
|
||||
}
|
||||
}
|
||||
|
||||
const common = {
|
||||
ownerId: DEFAULT_OWNER_ID,
|
||||
externalClientId: input.client.id,
|
||||
idempotencyKey,
|
||||
idempotencyFingerprint: fingerprint,
|
||||
priority: normalizePriority(input.body.priority),
|
||||
webhookUrl: normalizeWebhookUrl(input.body.webhookUrl),
|
||||
maxAttempts: 3
|
||||
};
|
||||
|
||||
if (capability === "video.generate") {
|
||||
const job = await submitVideoJob({
|
||||
...input.body,
|
||||
...common,
|
||||
mode: "video",
|
||||
materials: input.body.materials || input.body.promptAssembly?.materials || []
|
||||
} as SubmitVideoJobInput, input.origin);
|
||||
return { job, reused: false };
|
||||
}
|
||||
|
||||
const imageCapability = normalizeImageCapability(capability);
|
||||
const assembled = input.body.promptAssembly
|
||||
? assemblePrompt({
|
||||
...input.body.promptAssembly,
|
||||
mode: "image",
|
||||
materials: input.body.materials || input.body.promptAssembly.materials || []
|
||||
})
|
||||
: undefined;
|
||||
const materialImages = (input.body.materials || assembled?.materials || [])
|
||||
.filter((material) => material.type === "image")
|
||||
.map((material) => material.url);
|
||||
const job = await submitImageJob({
|
||||
...common,
|
||||
capability: imageCapability,
|
||||
prompt: input.body.prompt || assembled?.prompt,
|
||||
imageUrls: input.body.imageUrls || input.body.inputUrls || materialImages,
|
||||
inputAssetIds: input.body.inputAssetIds || (input.body.materials || []).map((material) => material.id).filter(Boolean) as string[],
|
||||
scale: asNumber(input.body.scale),
|
||||
width: asNumber(input.body.width),
|
||||
height: asNumber(input.body.height),
|
||||
min_ratio: asNumber(input.body.min_ratio),
|
||||
max_ratio: asNumber(input.body.max_ratio),
|
||||
force_single: Boolean(input.body.force_single),
|
||||
resolution: input.body.resolution,
|
||||
seed: asNumber(input.body.seed)
|
||||
} satisfies SubmitImageJobInput, input.origin);
|
||||
return { job, reused: false };
|
||||
}
|
||||
|
||||
function normalizeImageCapability(capability: GenerationCapability): EnabledImageCapability {
|
||||
if (capability === "image.generate" || capability === "image.inpaint" || capability === "image.upscale") {
|
||||
return capability;
|
||||
}
|
||||
throw new Error(`Unsupported image capability: ${capability}`);
|
||||
}
|
||||
|
||||
function normalizePriority(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
return Math.max(-100, Math.min(100, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
function normalizeWebhookUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || !value.trim()) return undefined;
|
||||
const url = new URL(value.trim());
|
||||
if (!["http:", "https:"].includes(url.protocol)) throw new Error("webhookUrl must be an HTTP or HTTPS URL.");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function fingerprintBody(body: PublicJobCreateBody): string {
|
||||
const { idempotencyKey: _idempotencyKey, ...fingerprintSource } = body;
|
||||
return createHash("sha256").update(stableStringify(fingerprintSource)).digest("hex");
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
9
lib/server/public-api-response.ts
Normal file
9
lib/server/public-api-response.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { jsonError } from "@/lib/server/api";
|
||||
import { PublicApiAuthError } from "@/lib/server/public-api-auth";
|
||||
import { PublicApiConflictError } from "@/lib/server/public-api-jobs";
|
||||
|
||||
export function publicApiError(error: unknown) {
|
||||
if (error instanceof PublicApiAuthError) return jsonError(error.message, error.status);
|
||||
if (error instanceof PublicApiConflictError) return jsonError(error.message, error.status);
|
||||
return jsonError(error);
|
||||
}
|
||||
@@ -8,19 +8,19 @@ export function rootDir(): string {
|
||||
}
|
||||
|
||||
export function runtimeDir(): string {
|
||||
return process.env.NIANXXPLAY_RUNTIME_DIR || join(rootDir(), ".runtime");
|
||||
return process.env.ZHINIAN_RUNTIME_DIR || join(rootDir(), ".runtime");
|
||||
}
|
||||
|
||||
export function dataDir(): string {
|
||||
return process.env.NIANXXPLAY_DATA_DIR || join(runtimeDir(), "data");
|
||||
return process.env.ZHINIAN_DATA_DIR || join(runtimeDir(), "data");
|
||||
}
|
||||
|
||||
export function uploadDir(): string {
|
||||
return process.env.NIANXXPLAY_UPLOAD_DIR || join(runtimeDir(), "uploads");
|
||||
return process.env.ZHINIAN_UPLOAD_DIR || join(runtimeDir(), "uploads");
|
||||
}
|
||||
|
||||
export function resultDir(): string {
|
||||
return process.env.NIANXXPLAY_RESULT_DIR || join(runtimeDir(), "generated-results");
|
||||
return process.env.ZHINIAN_RESULT_DIR || join(runtimeDir(), "generated-results");
|
||||
}
|
||||
|
||||
export async function ensureRuntimeDirs(): Promise<void> {
|
||||
@@ -36,7 +36,7 @@ export function localRuntimePath(...parts: string[]): string {
|
||||
}
|
||||
|
||||
export function requestOrigin(request: Request): string {
|
||||
const configured = process.env.NEXT_PUBLIC_APP_URL || process.env.NIANXXPLAY_PUBLIC_BASE_URL;
|
||||
const configured = process.env.NEXT_PUBLIC_APP_URL || process.env.ZHINIAN_PUBLIC_BASE_URL;
|
||||
if (configured) return normalizePublicOrigin(configured);
|
||||
return normalizePublicOrigin(new URL(request.url).origin);
|
||||
}
|
||||
|
||||
@@ -181,19 +181,20 @@ export async function readLocalServedFile(area: "uploads" | "generated-results",
|
||||
}
|
||||
}
|
||||
|
||||
export async function readLegacyPublicFile(pathParts: string[]): Promise<{
|
||||
export async function readAssetForDownload(asset: Asset): Promise<{
|
||||
bytes: Buffer;
|
||||
contentType: string;
|
||||
} | null> {
|
||||
const filePath = join(process.cwd(), "runtime", "nianxx-play", "public", ...pathParts);
|
||||
try {
|
||||
return {
|
||||
bytes: await readFile(filePath),
|
||||
contentType: contentTypeForPath(filePath)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const local = await readLocalAsset(asset);
|
||||
if (local) return local;
|
||||
|
||||
if (!/^https?:\/\//i.test(asset.url)) return null;
|
||||
const response = await fetch(asset.url);
|
||||
if (!response.ok) return null;
|
||||
return {
|
||||
bytes: Buffer.from(await response.arrayBuffer()),
|
||||
contentType: response.headers.get("content-type") || contentTypeForPath(new URL(asset.url).pathname)
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteStoredAsset(asset: Asset): Promise<void> {
|
||||
@@ -218,6 +219,30 @@ export async function deleteStoredAsset(asset: Asset): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocalAsset(asset: Asset): Promise<{
|
||||
bytes: Buffer;
|
||||
contentType: string;
|
||||
} | null> {
|
||||
const localPath = localServedPathParts(asset.storagePath) || localServedPathParts(asset.url);
|
||||
if (!localPath) return null;
|
||||
return readLocalServedFile(localPath.area, localPath.pathParts);
|
||||
}
|
||||
|
||||
function localServedPathParts(value?: string): {
|
||||
area: "uploads" | "generated-results";
|
||||
pathParts: string[];
|
||||
} | null {
|
||||
if (!value) return null;
|
||||
const clean = value.replace(/^\/+/, "");
|
||||
if (clean.startsWith("uploads/")) {
|
||||
return { area: "uploads", pathParts: clean.slice("uploads/".length).split("/").filter(Boolean) };
|
||||
}
|
||||
if (clean.startsWith("generated-results/")) {
|
||||
return { area: "generated-results", pathParts: clean.slice("generated-results/".length).split("/").filter(Boolean) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function storeBuffer(input: {
|
||||
bytes: Buffer;
|
||||
fileName: string;
|
||||
|
||||
151
lib/server/task-manager.ts
Normal file
151
lib/server/task-manager.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
claimGenerationJobs,
|
||||
clearGenerationJobLock,
|
||||
getGenerationJob,
|
||||
updateGenerationJob
|
||||
} from "@/lib/server/data-store";
|
||||
import { advanceImageJob } from "@/lib/server/generation-service";
|
||||
import { advanceVideoJob } from "@/lib/server/video-generation-service";
|
||||
import { requestOrigin } from "@/lib/server/runtime";
|
||||
import { deliverJobWebhook } from "@/lib/server/webhook";
|
||||
import type { GenerationJob, GenerationStatus } from "@/lib/types";
|
||||
|
||||
export type WorkerTickResult = {
|
||||
workerId: string;
|
||||
claimed: number;
|
||||
jobs: Array<{
|
||||
id: string;
|
||||
status: GenerationStatus;
|
||||
action: "processed" | "retry_scheduled" | "released" | "failed";
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const TERMINAL_STATUSES = new Set<GenerationStatus>(["succeeded", "failed", "expired", "cancelled"]);
|
||||
|
||||
export async function runWorkerTick(input: {
|
||||
request?: Request;
|
||||
origin?: string;
|
||||
workerId?: string;
|
||||
limit?: number;
|
||||
} = {}): Promise<WorkerTickResult> {
|
||||
const workerId = input.workerId || `worker-${randomUUID()}`;
|
||||
const origin = input.origin || (input.request ? requestOrigin(input.request) : workerOrigin());
|
||||
const jobs = await claimGenerationJobs({
|
||||
workerId,
|
||||
limit: input.limit || workerBatchSize(),
|
||||
lockTimeoutMs: workerLockTimeoutMs()
|
||||
});
|
||||
const result: WorkerTickResult = {
|
||||
workerId,
|
||||
claimed: jobs.length,
|
||||
jobs: []
|
||||
};
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const advanced = await advanceClaimedJob(job, origin);
|
||||
const settled = await settleAdvancedJob(advanced);
|
||||
result.jobs.push({
|
||||
id: settled.job.id,
|
||||
status: settled.job.status,
|
||||
action: settled.action
|
||||
});
|
||||
} catch (error) {
|
||||
const failed = await updateGenerationJob(job.id, {
|
||||
status: "failed",
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
retryable: true
|
||||
}
|
||||
});
|
||||
const settled = await settleAdvancedJob(failed);
|
||||
result.jobs.push({
|
||||
id: settled.job.id,
|
||||
status: settled.job.status,
|
||||
action: "failed",
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function advanceClaimedJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
|
||||
if (job.capability === "video.generate") return advanceVideoJob(job.id, origin);
|
||||
return advanceImageJob(job.id, origin);
|
||||
}
|
||||
|
||||
async function settleAdvancedJob(job: GenerationJob): Promise<{
|
||||
job: GenerationJob;
|
||||
action: WorkerTickResult["jobs"][number]["action"];
|
||||
}> {
|
||||
const current = await getGenerationJob(job.id) || job;
|
||||
const now = new Date();
|
||||
|
||||
if (current.status === "failed" && canRetry(current)) {
|
||||
const attempts = (current.attempts || 0) + 1;
|
||||
const scheduledAt = new Date(now.getTime() + retryDelayMs(attempts)).toISOString();
|
||||
const retryJob = await clearGenerationJobLock(current.id, {
|
||||
status: "queued",
|
||||
attempts,
|
||||
scheduledAt
|
||||
}, { clearProviderTaskId: true });
|
||||
return { job: retryJob, action: "retry_scheduled" };
|
||||
}
|
||||
|
||||
if (TERMINAL_STATUSES.has(current.status)) {
|
||||
const terminalJob = await clearGenerationJobLock(current.id, {
|
||||
attempts: current.status === "failed" ? (current.attempts || 0) + 1 : current.attempts,
|
||||
completedAt: current.completedAt || now.toISOString()
|
||||
});
|
||||
const webhook = await deliverJobWebhook(terminalJob);
|
||||
if (webhook.lastStatus) {
|
||||
const withWebhook = await updateGenerationJob(terminalJob.id, {
|
||||
webhookAttempts: webhook.attempts,
|
||||
webhookLastStatus: webhook.lastStatus
|
||||
});
|
||||
return { job: withWebhook, action: "processed" };
|
||||
}
|
||||
return { job: terminalJob, action: "processed" };
|
||||
}
|
||||
|
||||
const scheduledAt = new Date(now.getTime() + workerPollIntervalMs()).toISOString();
|
||||
const released = await clearGenerationJobLock(current.id, { scheduledAt });
|
||||
return { job: released, action: "released" };
|
||||
}
|
||||
|
||||
function canRetry(job: GenerationJob): boolean {
|
||||
const attempts = job.attempts || 0;
|
||||
const maxAttempts = job.maxAttempts || 3;
|
||||
return Boolean(job.error?.retryable) && attempts < maxAttempts;
|
||||
}
|
||||
|
||||
function retryDelayMs(attempts: number): number {
|
||||
const base = readPositiveInt("ZHINIAN_WORKER_RETRY_BASE_MS", 10_000);
|
||||
const max = readPositiveInt("ZHINIAN_WORKER_RETRY_MAX_MS", 5 * 60 * 1000);
|
||||
return Math.min(max, base * 2 ** Math.max(0, attempts - 1));
|
||||
}
|
||||
|
||||
function workerPollIntervalMs(): number {
|
||||
return readPositiveInt("ZHINIAN_WORKER_POLL_INTERVAL_MS", 5_000);
|
||||
}
|
||||
|
||||
function workerLockTimeoutMs(): number {
|
||||
return readPositiveInt("ZHINIAN_WORKER_LOCK_TIMEOUT_MS", 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
function workerBatchSize(): number {
|
||||
return Math.max(1, Math.min(readPositiveInt("ZHINIAN_WORKER_BATCH_SIZE", 3), 20));
|
||||
}
|
||||
|
||||
function workerOrigin(): string {
|
||||
return (process.env.NEXT_PUBLIC_APP_URL || process.env.ZHINIAN_PUBLIC_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function readPositiveInt(name: string, fallback: number): number {
|
||||
const parsed = Number(process.env[name]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
||||
}
|
||||
@@ -14,10 +14,16 @@ import { normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution }
|
||||
|
||||
export type SubmitVideoJobInput = PromptAssemblyInput & {
|
||||
ownerId?: string;
|
||||
externalClientId?: string;
|
||||
prompt?: string;
|
||||
settings?: SeedanceSettings;
|
||||
materials?: PromptMaterial[];
|
||||
retryOf?: string;
|
||||
idempotencyKey?: string;
|
||||
idempotencyFingerprint?: string;
|
||||
priority?: number;
|
||||
maxAttempts?: number;
|
||||
webhookUrl?: string;
|
||||
};
|
||||
|
||||
export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise<GenerationJob> {
|
||||
@@ -36,6 +42,7 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string)
|
||||
const mock = shouldMockSeedance();
|
||||
let job = await createGenerationJob({
|
||||
ownerId,
|
||||
externalClientId: input.externalClientId,
|
||||
capability: "video.generate",
|
||||
provider: mock ? "mock" : "seedance",
|
||||
reqKey: config.model,
|
||||
@@ -49,16 +56,38 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string)
|
||||
assembled,
|
||||
settings
|
||||
},
|
||||
retryOf: input.retryOf
|
||||
retryOf: input.retryOf,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
idempotencyFingerprint: input.idempotencyFingerprint,
|
||||
priority: input.priority,
|
||||
maxAttempts: input.maxAttempts,
|
||||
webhookUrl: input.webhookUrl
|
||||
});
|
||||
|
||||
if (mock) return completeMockVideoJob(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function advanceVideoJob(jobId: string, origin: string): Promise<GenerationJob> {
|
||||
const job = await getGenerationJob(jobId);
|
||||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||||
if (["succeeded", "failed", "cancelled", "expired"].includes(job.status)) return job;
|
||||
if (job.provider === "mock") return completeMockVideoJob(job);
|
||||
if (!job.providerTaskId) return dispatchVideoJob(job, origin);
|
||||
return syncVideoJob(job.id, origin);
|
||||
}
|
||||
|
||||
async function dispatchVideoJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
|
||||
try {
|
||||
const input = asRecord(job.requestPayload.input) as SubmitVideoJobInput;
|
||||
const assembled = asRecord(job.requestPayload.assembled);
|
||||
const settings = asRecord(job.requestPayload.settings) as SeedanceSettings;
|
||||
const materials = Array.isArray(assembled.materials)
|
||||
? assembled.materials as PromptMaterial[]
|
||||
: input.materials || [];
|
||||
const response = await createSeedanceTask({
|
||||
prompt: finalPrompt,
|
||||
prompt: job.prompt || "",
|
||||
settings,
|
||||
materials: assembled.materials,
|
||||
materials,
|
||||
origin
|
||||
});
|
||||
return updateGenerationJob(job.id, {
|
||||
@@ -140,7 +169,7 @@ async function completeMockVideoJob(job: GenerationJob): Promise<GenerationJob>
|
||||
ownerId: job.ownerId,
|
||||
kind: "video",
|
||||
name: `mock-video-${job.id}.mp4`,
|
||||
url: "/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4",
|
||||
url: "/mock/seedance-mock.mp4",
|
||||
source: "generated",
|
||||
tags: ["video.generate", "mock"],
|
||||
metadata: {
|
||||
@@ -168,3 +197,9 @@ async function completeMockVideoJob(job: GenerationJob): Promise<GenerationJob>
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
71
lib/server/webhook.ts
Normal file
71
lib/server/webhook.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import type { GenerationJob, WebhookLastStatus } from "@/lib/types";
|
||||
|
||||
export type JobWebhookPayload = {
|
||||
jobId: string;
|
||||
status: GenerationJob["status"];
|
||||
capability: GenerationJob["capability"];
|
||||
outputAssetIds: string[];
|
||||
error?: GenerationJob["error"];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const MAX_WEBHOOK_ATTEMPTS = 3;
|
||||
|
||||
export function buildJobWebhookPayload(job: GenerationJob): JobWebhookPayload {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
capability: job.capability,
|
||||
outputAssetIds: job.outputAssetIds,
|
||||
error: job.error,
|
||||
updatedAt: job.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
export function signWebhookBody(body: string, secret = process.env.ZHINIAN_WEBHOOK_SECRET): string | undefined {
|
||||
if (!secret?.trim()) return undefined;
|
||||
return `sha256=${createHmac("sha256", secret.trim()).update(body).digest("hex")}`;
|
||||
}
|
||||
|
||||
export async function deliverJobWebhook(job: GenerationJob): Promise<{
|
||||
attempts: number;
|
||||
lastStatus?: WebhookLastStatus;
|
||||
}> {
|
||||
if (!job.webhookUrl) return { attempts: job.webhookAttempts || 0 };
|
||||
let attempts = job.webhookAttempts || 0;
|
||||
let lastStatus: WebhookLastStatus | undefined = job.webhookLastStatus;
|
||||
const payload = buildJobWebhookPayload(job);
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = signWebhookBody(body);
|
||||
|
||||
while (attempts < MAX_WEBHOOK_ATTEMPTS) {
|
||||
attempts += 1;
|
||||
const attemptedAt = new Date().toISOString();
|
||||
try {
|
||||
const response = await fetch(job.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "zhinian-aigc-webhook/1.0",
|
||||
...(signature ? { "X-Zhinian-Signature": signature } : {})
|
||||
},
|
||||
body
|
||||
});
|
||||
lastStatus = {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
attemptedAt
|
||||
};
|
||||
if (response.ok) break;
|
||||
} catch (error) {
|
||||
lastStatus = {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
attemptedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { attempts, lastStatus };
|
||||
}
|
||||
22
lib/types.ts
22
lib/types.ts
@@ -19,6 +19,14 @@ export type GenerationStatus =
|
||||
| "expired"
|
||||
| "cancelled";
|
||||
|
||||
export type WebhookLastStatus = {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
attemptedAt: string;
|
||||
nextAttemptAt?: string;
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
@@ -36,6 +44,7 @@ export type Asset = {
|
||||
export type GenerationJob = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
externalClientId?: string;
|
||||
capability: GenerationCapability;
|
||||
provider: "volcengine-visual" | "evolink" | "seedance" | "mock";
|
||||
reqKey: string;
|
||||
@@ -53,6 +62,19 @@ export type GenerationJob = {
|
||||
retryable?: boolean;
|
||||
};
|
||||
retryOf?: string;
|
||||
idempotencyKey?: string;
|
||||
idempotencyFingerprint?: string;
|
||||
priority?: number;
|
||||
attempts?: number;
|
||||
maxAttempts?: number;
|
||||
scheduledAt?: string;
|
||||
lockedAt?: string;
|
||||
lockedBy?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
webhookUrl?: string;
|
||||
webhookAttempts?: number;
|
||||
webhookLastStatus?: WebhookLastStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user