Reorganize skills package structure
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
MV 国风动画舞剑分镜生成器。使用 Midjourney V7 生成风格统一的武侠图片集,
|
||||
通过 Seedance 2.0 多图参考合成角色入场景,最终合成分镜视频。
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
name: sword-dance
|
||||
display-name-zh: 武侠舞剑
|
||||
summary-cn: 一键生成国风武侠舞剑短片
|
||||
summary-en: Generate Chinese sword dance video
|
||||
description: |
|
||||
MV 国风动画舞剑分镜生成器。使用 Midjourney V7 生成风格统一的武侠图片集,
|
||||
通过 Seedance 2.0 多图参考合成角色入场景,最终合成分镜视频。
|
||||
version: 0.3.5
|
||||
tags: [Video, Dance, Wuxia]
|
||||
tags-cn: [视频, 舞蹈, 武侠]
|
||||
exported-by: MiniMax-hub
|
||||
---
|
||||
|
||||
你是 MV 国风动画舞剑分镜生成助手。你的任务是生成一组风格统一的中国武侠风格 AI 图片集,并合成为分镜视频。
|
||||
|
||||
## 流程概览
|
||||
|
||||
整体分两个阶段:先生成素材图,再合成视频。
|
||||
|
||||
**Phase 1 - 素材生成(交互式选图)**
|
||||
1. 人设图:Midjourney 生成 → 四宫格裁切 → 选 1 张 → 可用参考照片换脸
|
||||
2. 舞剑图:Midjourney 生成 → 四宫格裁切 → 融入人设 → 选 1 张
|
||||
3. 空镜图:从模板库 + AI 生成中选 2 张(不够则循环补生成)
|
||||
|
||||
**Phase 2 - 视频合成**
|
||||
- 4 张素材(人设 + 舞剑 + 2 张空镜)传给 Seedance 2.0 多模态参考,生成 15 秒带音频的舞剑视频
|
||||
|
||||
每一步都会弹出浏览器预览页,供你选图和提交反馈。不满意可循环重新生成。
|
||||
|
||||
## 全局约定
|
||||
|
||||
### 语言适配
|
||||
|
||||
根据用户的语言回复。用户说中文就用中文,说英文就用英文,以此类推。Preview HTML 仅支持中文/英文,非中文用户调用预览脚本时传 `--lang en`。
|
||||
|
||||
### 画风后缀
|
||||
|
||||
编写新提示词时,以下风格描述作为统一参考模板:
|
||||
|
||||
```
|
||||
opulent and ethereal mood, classical Chinese aesthetic, ink wash painting style mixed with photorealistic rendering, rich saturated palette of deep cinnabar red and burnished gold against velvety darkness, shallow depth of field, radiant warm highlights with sumptuous shadow depth, 8k resolution
|
||||
```
|
||||
|
||||
> 注意:`references/prompts.md` 中的每条提示词已内置各自的风格描述变体,无需额外追加。
|
||||
|
||||
### 默认模型
|
||||
|
||||
- **图片生成**:`midjourney_image_generation`(Midjourney V7)
|
||||
- **图片编辑/修改**:`all_in_one_image_generation`(model_name: `all_in_one_pro`)/ `nano_banana_image_generation`(model_name: `nano_banana_2`)
|
||||
- 所有 Midjourney 提示词末尾必须包含 `--p qgbmkdn`(情绪版 LoRA)
|
||||
|
||||
### Midjourney 四宫格裁切
|
||||
|
||||
只有 `midjourney_image_generation` 输出为 2×2 四宫格拼图,需要用 `ffmpeg` MCP 工具裁切为 4 张独立图片(1 次 MJ 调用 = 4 张图产出)。裁切方法:先用 `ffprobe` 获取宽高,再用 `crop` 滤镜分别截取左上、右上、左下、右下四个象限。
|
||||
|
||||
若使用其他模型(如 `all_in_one_image_generation`、`nano_banana_image_generation`)代替 MJ,这些模型每次只输出单张图片,无需裁切,但必须并行调用 4 次以产出 4 张候选图,保证与 MJ 相同的 4 选 1 流程。
|
||||
|
||||
### 图片选择预览
|
||||
|
||||
裁切后的候选图通过 HTML 预览页面展示,供用户点击选择。调用 MCP 工具 `preview_and_collect_feedback`:
|
||||
- **script**: `.opencode/skills/sword-dance/scripts/render_image_select.py`
|
||||
- **args**: `["--title", "页面标题", "--images", "cell_1.png", "cell_2.png", "cell_3.png", "cell_4.png"]`
|
||||
- **feedback_path**: `.sword-dance/feedback.md`
|
||||
|
||||
工具会启动预览服务器、打开浏览器,等待用户提交选择后直接返回反馈内容:
|
||||
- 以 `LGTM` 开头 → 用户确认选定,解析 `选图:{filename}` 获取选中的文件名
|
||||
- 含 `修改建议:` → 用户有修改意见,按建议处理后重新生成
|
||||
|
||||
### 提示词来源
|
||||
|
||||
所有提示词定义在 `references/prompts.md`,运行时读取。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:素材生成
|
||||
|
||||
### Step 1:人设图
|
||||
|
||||
1. 读取 `references/prompts.md` 中的 `CHAR-01` 提示词,调用 `midjourney_image_generation` 生成人设图。
|
||||
2. 四宫格裁切 → 4 张候选图保存到 `.sword-dance/char_1.png` ~ `char_4.png`。
|
||||
3. 调用 `preview_and_collect_feedback`(`--title "人设图选择"`),用户选择一张。
|
||||
4. 解析反馈内容:
|
||||
- `LGTM` → 将选中图复制为 `.sword-dance/char.png`,进入人设图修改流程
|
||||
- 含修改建议 → 根据建议调整提示词,重新生成(回到步骤 1)
|
||||
|
||||
#### 人设图修改流程
|
||||
|
||||
通过 `AskUserQuestion` 询问用户是否需要用参考照片修改人设:
|
||||
- 不需要 → 直接进入 Step 2
|
||||
- 需要 → 用户提供参考照片路径,继续以下流程
|
||||
|
||||
人设图**仅支持参考图修改**(用户提供人物参考照片进行换脸),不支持文字描述修改。
|
||||
|
||||
调用 `all_in_one_batch_image_generation`(model_name: `all_in_one_pro`)或 `nano_banana_batch_image_generation_v2`(model_name: `nano_banana_2`)一次生成 4 张候选人设图,每张使用相同参数:
|
||||
- **image_paths**:按顺序传入两张图:
|
||||
1. 当前人设图 `.sword-dance/char.png`
|
||||
2. 用户提供的参考照片
|
||||
- **prompt**:
|
||||
```
|
||||
Replace the face in image 1 with the face from image 2. Keep image 1's composition, hairstyle, pose and environment completely unchanged. Add very fine light reddish-brown contour lines to the character. Render the character in flat coloring style. The character should have no facial expression.
|
||||
```
|
||||
- **aspect_ratio**:`1:1`
|
||||
|
||||
4 张候选图保存到 `.sword-dance/char_candidate_1.png` ~ `char_candidate_4.png`,调用 `preview_and_collect_feedback` 展示给用户选择。选定前先将原始人设图备份为 `.sword-dance/char_original.png`(若已存在则跳过),再将选定图保存为 `.sword-dance/char.png`。
|
||||
|
||||
### Step 2:舞剑图
|
||||
|
||||
1. 读取 `references/prompts.md` 中的 `SWORD-01` 提示词,调用 `midjourney_image_generation` 生成舞剑图。
|
||||
2. 四宫格裁切 → 4 张候选图保存到 `.sword-dance/sword_1.png` ~ `sword_4.png`。
|
||||
3. 将 Step 1 选定的人设图融入每张舞剑候选图:对 4 张候选图分别调用 `all_in_one_image_generation`(model_name: `all_in_one_2`)或 `nano_banana_image_generation`(model_name: `nano_banana_2_flash`):
|
||||
- **image_paths**:`[sword_N.png, char.png]`
|
||||
- **prompt**:
|
||||
```
|
||||
Transfer the character appearance from image 2 into image 1. Keep image 1's composition, pose, sword, action and environment completely unchanged. Add very fine light reddish-brown contour lines to the character. Render the character in flat coloring style. The character should have no facial expression. Fix any hand or finger artifacts.
|
||||
```
|
||||
- 生成结果覆盖对应的 `sword_N.png`
|
||||
4. 调用 `preview_and_collect_feedback`(`--title "舞剑图选择"`),用户选择一张。
|
||||
5. 解析反馈内容:
|
||||
- `LGTM` → 将选中图复制为 `.sword-dance/sword.png`,进入 Step 3
|
||||
- 含修改建议 → 根据建议调整提示词,重新生成(回到步骤 1)
|
||||
|
||||
### Step 3:空镜图(循环直到选定 2 张)
|
||||
|
||||
运行 MCP 工具 `preview_and_collect_feedback` 启动本地预览服务器:
|
||||
- **script**: `.opencode/skills/sword-dance/scripts/render_scene_select.py`
|
||||
- **args**: `[]`
|
||||
- **feedback_path**: `.sword-dance/feedback.md`
|
||||
|
||||
工具会加载 3 个内置预设场景(首次自动从 CDN 下载到 `.sword-dance/templates_cache/`)和 `.sword-dance/generated/` 下 AI 生成的场景,注入 HTML 模板后在浏览器中打开。用户选择 0~2 张模板图,未选满的部分需输入场景描述。工具返回反馈内容后解析。
|
||||
|
||||
读取反馈内容,feedback 格式:
|
||||
- `模板1:/path/a.png` — 从模板池选取的图
|
||||
- `AI生成1:月下竹林` — 需要 AI 生成的场景(描述为空则由 AI 自由发挥)
|
||||
|
||||
**如果 feedback 中全部是模板选择(无 AI 生成项),将 2 张图片路径写入 `.sword-dance/selected.md`(每行一个路径),进入 Phase 2。**
|
||||
|
||||
**如果 feedback 中包含 AI 生成项**:
|
||||
|
||||
1. 将 feedback 中已选的模板路径写入 `.sword-dance/selected.md`(保存中间状态,下次预览页会自动预选这些图)。
|
||||
2. 将场景描述扩写为详细英文提示词(参考 `references/prompts.md` 的写法风格),末尾包含画风后缀和 `--p qgbmkdn`。描述为空时自行构思不重复的古风空镜场景。
|
||||
3. 调用 `midjourney_image_generation` 生成空镜图。
|
||||
4. **四宫格裁切**,将 4 张独立图片保存到 `.sword-dance/generated/` 临时目录(命名规则 `scene-{04,05,06,...}/cell_1.png` ~ `cell_4.png`,避开预设的 scene-01~03)。
|
||||
5. **回到 Step 3 开头**重新调用 `preview_and_collect_feedback`——此时 AI 生成的图已出现在模板池中,上次选定的模板图自动预选,用户从全部图中重新选择。
|
||||
|
||||
循环执行,直到用户选定 2 张空镜图(feedback 中无 AI 生成项)。
|
||||
|
||||
`.sword-dance/` 目录结构(项目根目录下):
|
||||
```
|
||||
.sword-dance/
|
||||
├── char.png # 选定的人设图
|
||||
├── char_1~4.png # 人设图四宫格裁切候选
|
||||
├── sword.png # 选定的舞剑图
|
||||
├── sword_1~4.png # 舞剑图四宫格裁切候选
|
||||
├── feedback.md # 每次预览页提交的反馈(会被覆盖)
|
||||
├── selected.md # 已确认选定的空镜图路径(每行一个)
|
||||
├── templates_cache/ # 预设场景 CDN 缓存(首次预览自动下载)
|
||||
│ └── scene-01/ ~ scene-03/
|
||||
└── generated/ # AI 生成的空镜图(四宫格裁切后)
|
||||
└── scene-04/
|
||||
├── cell_1.png
|
||||
├── cell_2.png
|
||||
├── cell_3.png
|
||||
└── cell_4.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:视频生成(Seedance 2.0)
|
||||
|
||||
使用 Seedance 2.0 多模态参考能力,根据 Phase 1 的素材生成舞剑视频。
|
||||
|
||||
读取 `.sword-dance/selected.md` 获取 2 张已选定的空镜图路径,加上 Phase 1 的人设图和舞剑图。
|
||||
|
||||
调用 `seedance_multimodal_video` 生成视频:
|
||||
|
||||
- **reference_image_paths**:按顺序传入 4 张图:
|
||||
1. 人设图(`.sword-dance/char.png`)
|
||||
2. 舞剑图(`.sword-dance/sword.png`)
|
||||
3. 空镜图 1
|
||||
4. 空镜图 2
|
||||
- **prompt**:使用 `<<image_N>>` 内联引用参考图,模板:
|
||||
```
|
||||
<<image_1>> An ancient Chinese girl with eyes closed, butterflies flying past her <<image_4>>, then the girl gracefully dances with a sword <<image_2>>, <<image_3>> flower petals fluttering in the air
|
||||
```
|
||||
> 根据实际选定的空镜内容调整对 image_3/image_4 的描述,使其与空镜图内容匹配。
|
||||
- **model_name**:`seedance2.0`
|
||||
- **duration**:`15`(秒,最大时长)
|
||||
- **ratio**:`16:9`
|
||||
- **resolution**:`720p`
|
||||
- **generate_audio**:`true`
|
||||
|
||||
展示最终视频路径,告知用户生成完毕。如果用户不满意,可调整 prompt 或更换参考图后重新生成。
|
||||
@@ -0,0 +1,240 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>舞剑分镜 - 图片选择</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f0f0f; --card: #1a1a1a; --border: #2a2a2a;
|
||||
--text: #e0e0e0; --text-dim: #888;
|
||||
--accent: #f0c040; --green: #4ade80;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
line-height: 1.6; padding: 2rem 2rem 12rem;
|
||||
max-width: 1100px; margin: 0 auto;
|
||||
}
|
||||
|
||||
.header { margin-bottom: 2rem; text-align: center; }
|
||||
.header h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.3rem; }
|
||||
.header .sub { font-size: 0.85rem; color: var(--text-dim); }
|
||||
|
||||
.candidates-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px; max-width: 900px; margin: 0 auto;
|
||||
}
|
||||
|
||||
.candidate-item {
|
||||
position: relative; border: 3px solid var(--border); border-radius: 10px;
|
||||
overflow: hidden; cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
.candidate-item:hover { border-color: #555; transform: translateY(-2px); }
|
||||
.candidate-item.selected {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 1px var(--green), 0 0 16px rgba(74,222,128,0.15);
|
||||
}
|
||||
.candidate-item img { width: 100%; height: auto; display: block; }
|
||||
.candidate-label {
|
||||
position: absolute; top: 8px; left: 8px;
|
||||
background: rgba(0,0,0,0.75); backdrop-filter: blur(4px);
|
||||
padding: 3px 10px; border-radius: 4px; font-size: 0.8rem; color: #ddd;
|
||||
}
|
||||
.selected-badge {
|
||||
position: absolute; top: 8px; right: 8px;
|
||||
background: var(--green); color: #111; padding: 3px 10px; border-radius: 4px;
|
||||
font-size: 0.7rem; font-weight: 600; display: none;
|
||||
}
|
||||
.candidate-item.selected .selected-badge { display: block; }
|
||||
|
||||
/* --- 底部提交区 --- */
|
||||
.submit-area {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
background: rgba(15,15,15,0.95); backdrop-filter: blur(8px);
|
||||
border-top: 1px solid var(--border); padding: 1rem 2rem;
|
||||
}
|
||||
.submit-row { max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 10px; }
|
||||
.feedback-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.feedback-textarea {
|
||||
flex: 1; min-height: 40px; max-height: 100px; padding: 8px 12px; resize: vertical;
|
||||
background: #222; border: 1px solid #444; border-radius: 6px;
|
||||
color: var(--text); font-size: 0.85rem; font-family: inherit;
|
||||
}
|
||||
.feedback-textarea::placeholder { color: #555; }
|
||||
.feedback-textarea:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.submit-top { display: flex; align-items: center; gap: 12px; }
|
||||
.submit-info { font-size: 0.85rem; color: var(--text-dim); }
|
||||
.submit-info .sel { color: var(--green); font-weight: 600; }
|
||||
.submit-btn {
|
||||
padding: 8px 24px; background: var(--accent); color: #1a1a1a; border: none;
|
||||
border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.submit-btn:hover { background: #e0b030; }
|
||||
.submit-btn:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
#status-msg { font-size: 0.85rem; color: var(--green); margin-left: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1 id="page-title">图片选择</h1>
|
||||
<p class="sub">点击选择最满意的一张 · 不满意可在下方输入修改建议重新生成</p>
|
||||
</div>
|
||||
|
||||
<div class="candidates-grid" id="grid"></div>
|
||||
|
||||
<div class="submit-area">
|
||||
<div class="submit-row">
|
||||
<div class="feedback-row">
|
||||
<textarea class="feedback-textarea" id="feedback-input"
|
||||
placeholder="修改建议(选填):如「换成蓝色衣服」「背景改为竹林」,留空 = 确认选定当前图片"></textarea>
|
||||
</div>
|
||||
<div class="submit-top">
|
||||
<span class="submit-info">已选:<span class="sel" id="sel-label">未选择</span></span>
|
||||
<button class="submit-btn" id="submit-btn" onclick="submitSelection()">提交</button>
|
||||
<span id="status-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PREVIEW_LANG = null; /* __PREVIEW_LANG__ */
|
||||
const LANG = PREVIEW_LANG || (navigator.language.startsWith('zh') ? 'zh' : 'en');
|
||||
const I18N = {
|
||||
zh: {
|
||||
defaultTitle: '图片选择',
|
||||
pageSub: '点击选择最满意的一张 · 不满意可在下方输入修改建议重新生成',
|
||||
selectedBadge: '已选定',
|
||||
feedbackPlaceholder: '修改建议(选填):如「换成蓝色衣服」「背景改为竹林」,留空 = 确认选定当前图片',
|
||||
selectedLabel: '已选:',
|
||||
notSelected: '未选择',
|
||||
submitBtn: '提交',
|
||||
submitFailed: '提交失败,请重试',
|
||||
confirmNext: '已提交,Hub 将继续下一步',
|
||||
pageClosing: '页面将自动关闭...',
|
||||
canClose: '可以关闭此页面了',
|
||||
selectPrefix: '选图:',
|
||||
feedbackPrefix: '修改建议:',
|
||||
},
|
||||
en: {
|
||||
defaultTitle: 'Image Selection',
|
||||
pageSub: 'Click to select your favorite · Enter feedback below to regenerate',
|
||||
selectedBadge: 'Selected',
|
||||
feedbackPlaceholder: 'Feedback (optional): e.g. "change to blue outfit" "change background to bamboo forest", leave empty = confirm current selection',
|
||||
selectedLabel: 'Selected: ',
|
||||
notSelected: 'None',
|
||||
submitBtn: 'Submit',
|
||||
submitFailed: 'Submit failed, please retry',
|
||||
confirmNext: 'Submitted. Hub will proceed to next step.',
|
||||
pageClosing: 'Page will close automatically...',
|
||||
canClose: 'You can close this page now.',
|
||||
selectPrefix: 'Select: ',
|
||||
feedbackPrefix: 'Feedback: ',
|
||||
}
|
||||
};
|
||||
const t = (key) => (I18N[LANG] || I18N.en)[key] || key;
|
||||
document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en';
|
||||
|
||||
const PREVIEW_DATA = null; /* __PREVIEW_DATA__ */
|
||||
|
||||
let selected = 0;
|
||||
|
||||
function init() {
|
||||
const data = typeof PREVIEW_DATA === 'string' ? JSON.parse(PREVIEW_DATA) : PREVIEW_DATA;
|
||||
if (!data || !data.candidates) return;
|
||||
|
||||
document.getElementById('page-title').textContent = data.title || t('defaultTitle');
|
||||
document.querySelector('.header .sub').textContent = t('pageSub');
|
||||
document.getElementById('feedback-input').placeholder = t('feedbackPlaceholder');
|
||||
document.querySelector('.submit-info').innerHTML = t('selectedLabel') + '<span class="sel" id="sel-label">' + t('notSelected') + '</span>';
|
||||
document.getElementById('submit-btn').textContent = t('submitBtn');
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
data.candidates.forEach((cand, idx) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'candidate-item' + (idx === 0 ? ' selected' : '');
|
||||
div.id = 'cand-' + idx;
|
||||
div.onclick = () => selectItem(idx);
|
||||
div.innerHTML = `
|
||||
<span class="candidate-label">${esc(cand.label)}</span>
|
||||
<span class="selected-badge">${t('selectedBadge')}</span>
|
||||
<img src="${cand.url}" alt="${esc(cand.label)}" loading="lazy">
|
||||
`;
|
||||
grid.appendChild(div);
|
||||
});
|
||||
|
||||
updateLabel();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function selectItem(idx) {
|
||||
selected = idx;
|
||||
document.querySelectorAll('.candidate-item').forEach((el, i) => {
|
||||
el.classList.toggle('selected', i === idx);
|
||||
});
|
||||
updateLabel();
|
||||
}
|
||||
|
||||
function updateLabel() {
|
||||
const data = typeof PREVIEW_DATA === 'string' ? JSON.parse(PREVIEW_DATA) : PREVIEW_DATA;
|
||||
document.getElementById('sel-label').textContent = data.candidates[selected].label;
|
||||
}
|
||||
|
||||
async function submitSelection() {
|
||||
const data = typeof PREVIEW_DATA === 'string' ? JSON.parse(PREVIEW_DATA) : PREVIEW_DATA;
|
||||
const cand = data.candidates[selected];
|
||||
const feedbackText = document.getElementById('feedback-input').value.trim();
|
||||
|
||||
let feedback = `${t('selectPrefix')}${cand.filename}\n`;
|
||||
if (feedbackText) {
|
||||
feedback += `\n${t('feedbackPrefix')}${feedbackText}\n`;
|
||||
} else {
|
||||
feedback = 'LGTM\n' + feedback;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/feedback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ feedback: feedback.trim() })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.ok) {
|
||||
showDone(t('confirmNext'));
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('status-msg').textContent = t('submitFailed');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
function showDone(msg) {
|
||||
document.body.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:#0f0f0f;">
|
||||
<div style="text-align:center;color:#e0e0e0;font-family:-apple-system,sans-serif;">
|
||||
<div style="font-size:3rem;color:#4ade80;margin-bottom:1rem;">✓</div>
|
||||
<div style="font-size:1.2rem;font-weight:600;margin-bottom:0.5rem;">${msg}</div>
|
||||
<div class="done-sub" style="font-size:0.85rem;color:#888;">${t('pageClosing')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
setTimeout(() => {
|
||||
const sub = document.querySelector('.done-sub');
|
||||
if (sub) sub.textContent = t('canClose');
|
||||
}, 2000);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,366 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>国风动画舞剑 - 空镜选择</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--card: #1a1a1a;
|
||||
--border: #2a2a2a;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--accent: #f0c040;
|
||||
--accent-dim: #a08020;
|
||||
--green: #4ade80;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
line-height: 1.6; padding: 2rem 2rem 6rem;
|
||||
max-width: 1400px; margin: 0 auto;
|
||||
}
|
||||
|
||||
.header { margin-bottom: 2.5rem; text-align: center; }
|
||||
.header h1 { font-size: 1.6rem; font-weight: 600; margin-bottom: 0.4rem; }
|
||||
.header .sub { font-size: 0.85rem; color: var(--text-dim); }
|
||||
.header .hint { font-size: 0.8rem; color: #666; margin-top: 0.3rem; }
|
||||
|
||||
/* --- 场景分区 --- */
|
||||
.scene-section {
|
||||
margin-bottom: 2.5rem; padding: 1.5rem;
|
||||
border: 1px solid var(--border); border-radius: 12px;
|
||||
background: var(--card);
|
||||
}
|
||||
.scene-title {
|
||||
font-size: 1.1rem; font-weight: 600; color: var(--accent);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
position: relative; cursor: pointer;
|
||||
border: 2px solid transparent; border-radius: 8px;
|
||||
overflow: hidden; transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.image-card:hover { border-color: #444; }
|
||||
.image-card.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent), 0 0 16px rgba(240,192,64,0.15);
|
||||
}
|
||||
.image-card img {
|
||||
width: 100%; height: auto; display: block;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.image-card:hover img { transform: scale(1.02); }
|
||||
|
||||
.check-badge {
|
||||
position: absolute; top: 8px; right: 8px;
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: rgba(0,0,0,0.5); border: 2px solid #555;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.image-card.selected .check-badge {
|
||||
background: var(--accent); border-color: var(--accent);
|
||||
}
|
||||
.check-badge::after {
|
||||
content: ''; display: none;
|
||||
width: 6px; height: 12px;
|
||||
border: solid #1a1a1a; border-width: 0 2.5px 2.5px 0;
|
||||
transform: rotate(45deg); margin-top: -2px;
|
||||
}
|
||||
.image-card.selected .check-badge::after { display: block; }
|
||||
|
||||
.image-label {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
padding: 4px 8px; background: rgba(0,0,0,0.6);
|
||||
font-size: 0.75rem; color: #ccc; text-align: center;
|
||||
}
|
||||
|
||||
/* --- AI 生成区 --- */
|
||||
.ai-section {
|
||||
margin-bottom: 2.5rem; padding: 1.5rem;
|
||||
border: 1px solid var(--border); border-radius: 12px;
|
||||
background: var(--card);
|
||||
}
|
||||
.ai-section-title {
|
||||
font-size: 1.1rem; font-weight: 600; color: var(--green);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.ai-section-hint {
|
||||
font-size: 0.8rem; color: var(--text-dim); margin-bottom: 1rem;
|
||||
}
|
||||
.ai-slots { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ai-slot {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.ai-slot-label {
|
||||
font-size: 0.85rem; color: var(--accent); font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-slot input {
|
||||
flex: 1; padding: 10px 14px;
|
||||
background: #111; border: 1px solid #333; border-radius: 6px;
|
||||
color: var(--text); font-size: 0.9rem; outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.ai-slot input:focus { border-color: var(--accent); }
|
||||
.ai-slot input::placeholder { color: #555; }
|
||||
|
||||
/* --- 提交区 --- */
|
||||
.submit-area {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
background: rgba(15,15,15,0.95); backdrop-filter: blur(8px);
|
||||
border-top: 1px solid var(--border); padding: 1rem 2rem;
|
||||
display: flex; align-items: center; justify-content: center; gap: 16px;
|
||||
}
|
||||
.counter {
|
||||
font-size: 0.9rem; color: var(--text-dim);
|
||||
}
|
||||
.counter .num { color: var(--accent); font-weight: 600; }
|
||||
.submit-btn {
|
||||
padding: 10px 32px; background: var(--accent); color: #1a1a1a;
|
||||
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
|
||||
cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.submit-btn:hover { background: #e0b030; }
|
||||
.submit-btn:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
#status-msg { font-size: 0.85rem; color: var(--green); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>空镜场景选择</h1>
|
||||
<p class="sub">共需 2 张空镜图,可从模板选取,也可交给 AI 生成</p>
|
||||
<p class="hint">模板可选 0~2 张,未选满的部分由 AI 根据描述自动生成</p>
|
||||
</div>
|
||||
|
||||
<div id="scenes-container"></div>
|
||||
|
||||
<div class="ai-section" id="ai-section">
|
||||
<div class="ai-section-title">AI 生成空镜</div>
|
||||
<div class="ai-section-hint">描述你想要的场景(可选,留空则由 AI 自由发挥)</div>
|
||||
<div class="ai-slots" id="ai-slots"></div>
|
||||
</div>
|
||||
|
||||
<div class="submit-area">
|
||||
<span class="counter">模板 <span class="num" id="count-display">0</span> 张 + AI <span class="num" id="ai-count-display">2</span> 张</span>
|
||||
<button class="submit-btn" id="submit-btn" onclick="submitChoice()">确认选择</button>
|
||||
<span id="status-msg"></span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PREVIEW_LANG = null; /* __PREVIEW_LANG__ */
|
||||
const LANG = PREVIEW_LANG || (navigator.language.startsWith('zh') ? 'zh' : 'en');
|
||||
const I18N = {
|
||||
zh: {
|
||||
pageTitle: '空镜场景选择',
|
||||
pageSub: '共需 2 张空镜图,可从模板选取,也可交给 AI 生成',
|
||||
pageHint: '模板可选 0~2 张,未选满的部分由 AI 根据描述自动生成',
|
||||
aiSectionTitle: 'AI 生成空镜',
|
||||
aiSectionHint: '描述你想要的场景(可选,留空则由 AI 自由发挥)',
|
||||
aiSlotLabel: '空镜',
|
||||
aiPlaceholder: '例:月下竹林、雪中古桥、山顶云海…',
|
||||
templateLabel: '模板',
|
||||
templateUnit: '张',
|
||||
aiLabel: 'AI',
|
||||
confirmBtn: '确认选择',
|
||||
submitFailed: '提交失败,请重试',
|
||||
confirmNext: '已提交,Hub 将继续下一步',
|
||||
pageClosing: '页面将自动关闭...',
|
||||
canClose: '可以关闭此页面了',
|
||||
feedbackTemplate: '模板',
|
||||
feedbackAi: 'AI生成',
|
||||
feedbackFreeplay: '(自由发挥)',
|
||||
},
|
||||
en: {
|
||||
pageTitle: 'Scene Selection',
|
||||
pageSub: '2 scene images needed — pick from templates or let AI generate',
|
||||
pageHint: 'Select 0-2 templates; remaining slots will be AI-generated from your description',
|
||||
aiSectionTitle: 'AI Generated Scenes',
|
||||
aiSectionHint: 'Describe the scene you want (optional, leave empty for AI to improvise)',
|
||||
aiSlotLabel: 'Scene',
|
||||
aiPlaceholder: 'e.g. moonlit bamboo forest, snowy ancient bridge, mountain peak above clouds…',
|
||||
templateLabel: 'Templates',
|
||||
templateUnit: '',
|
||||
aiLabel: 'AI',
|
||||
confirmBtn: 'Confirm Selection',
|
||||
submitFailed: 'Submit failed, please retry',
|
||||
confirmNext: 'Submitted. Hub will proceed to next step.',
|
||||
pageClosing: 'Page will close automatically...',
|
||||
canClose: 'You can close this page now.',
|
||||
feedbackTemplate: 'Template',
|
||||
feedbackAi: 'AI Generate',
|
||||
feedbackFreeplay: '(improvise)',
|
||||
}
|
||||
};
|
||||
const t = (key) => (I18N[LANG] || I18N.en)[key] || key;
|
||||
document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en';
|
||||
|
||||
const PREVIEW_DATA = null; /* __PREVIEW_DATA__ */
|
||||
const PRESELECT = []; /* __PRESELECT__ */
|
||||
|
||||
const TOTAL = 2;
|
||||
let selected = [];
|
||||
|
||||
function init() {
|
||||
const data = typeof PREVIEW_DATA === 'string' ? JSON.parse(PREVIEW_DATA) : PREVIEW_DATA;
|
||||
if (!data || !data.scenes) return;
|
||||
|
||||
// i18n: update static HTML text
|
||||
document.querySelector('.header h1').textContent = t('pageTitle');
|
||||
document.querySelector('.header .sub').textContent = t('pageSub');
|
||||
document.querySelector('.header .hint').textContent = t('pageHint');
|
||||
document.querySelector('.ai-section-title').textContent = t('aiSectionTitle');
|
||||
document.querySelector('.ai-section-hint').textContent = t('aiSectionHint');
|
||||
document.getElementById('submit-btn').textContent = t('confirmBtn');
|
||||
|
||||
const container = document.getElementById('scenes-container');
|
||||
|
||||
Object.keys(data.scenes).forEach(sceneId => {
|
||||
const scene = data.scenes[sceneId];
|
||||
const section = document.createElement('div');
|
||||
section.className = 'scene-section';
|
||||
|
||||
let imagesHtml = '';
|
||||
scene.images.forEach(img => {
|
||||
imagesHtml += `
|
||||
<div class="image-card" data-path="${img.path}" onclick="toggleSelect(this)">
|
||||
<img src="${img.url}" alt="${img.filename}" loading="lazy">
|
||||
<div class="check-badge"></div>
|
||||
<div class="image-label">${img.filename}</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
section.innerHTML = `
|
||||
<div class="scene-title">${scene.name}</div>
|
||||
<div class="image-grid">${imagesHtml}</div>
|
||||
`;
|
||||
container.appendChild(section);
|
||||
});
|
||||
|
||||
updateAiSlots();
|
||||
|
||||
// 恢复上一轮的选择
|
||||
if (PRESELECT && PRESELECT.length > 0) {
|
||||
document.querySelectorAll('.image-card').forEach(card => {
|
||||
if (PRESELECT.includes(card.dataset.path) && selected.length < TOTAL) {
|
||||
selected.push(card.dataset.path);
|
||||
card.classList.add('selected');
|
||||
}
|
||||
});
|
||||
updateAiSlots();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(card) {
|
||||
const path = card.dataset.path;
|
||||
const idx = selected.indexOf(path);
|
||||
|
||||
if (idx > -1) {
|
||||
selected.splice(idx, 1);
|
||||
card.classList.remove('selected');
|
||||
} else {
|
||||
if (selected.length >= TOTAL) return;
|
||||
selected.push(path);
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
updateAiSlots();
|
||||
}
|
||||
|
||||
function updateAiSlots() {
|
||||
const aiCount = TOTAL - selected.length;
|
||||
document.getElementById('count-display').textContent = selected.length;
|
||||
document.getElementById('ai-count-display').textContent = aiCount;
|
||||
|
||||
const slotsEl = document.getElementById('ai-slots');
|
||||
const sectionEl = document.getElementById('ai-section');
|
||||
|
||||
if (aiCount <= 0) {
|
||||
sectionEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
sectionEl.style.display = '';
|
||||
|
||||
// 保留已输入的值
|
||||
const existing = [];
|
||||
slotsEl.querySelectorAll('input').forEach(inp => existing.push(inp.value));
|
||||
|
||||
slotsEl.innerHTML = '';
|
||||
for (let i = 0; i < aiCount; i++) {
|
||||
const slot = document.createElement('div');
|
||||
slot.className = 'ai-slot';
|
||||
slot.innerHTML = `
|
||||
<span class="ai-slot-label">${t('aiSlotLabel')} ${selected.length + i + 1}</span>
|
||||
<input type="text" id="ai-desc-${i}" placeholder="${t('aiPlaceholder')}" value="${existing[i] || ''}">
|
||||
`;
|
||||
slotsEl.appendChild(slot);
|
||||
}
|
||||
|
||||
// Update counter text
|
||||
const counterEl = document.querySelector('.counter');
|
||||
const unitStr = t('templateUnit') ? ' ' + t('templateUnit') : '';
|
||||
counterEl.innerHTML = `${t('templateLabel')} <span class="num" id="count-display">${selected.length}</span>${unitStr} + ${t('aiLabel')} <span class="num" id="ai-count-display">${aiCount}</span>${unitStr}`;
|
||||
}
|
||||
|
||||
async function submitChoice() {
|
||||
const aiCount = TOTAL - selected.length;
|
||||
const aiDescs = [];
|
||||
for (let i = 0; i < aiCount; i++) {
|
||||
const inp = document.getElementById(`ai-desc-${i}`);
|
||||
aiDescs.push(inp ? inp.value.trim() : '');
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
const colon = LANG === 'zh' ? '\uFF1A' : ': ';
|
||||
selected.forEach((p, i) => lines.push(`${t('feedbackTemplate')}${i + 1}${colon}${p}`));
|
||||
aiDescs.forEach((d, i) => lines.push(`${t('feedbackAi')}${i + 1}${colon}${d || t('feedbackFreeplay')}`));
|
||||
const feedback = lines.join('\n');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/feedback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ feedback })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.ok) {
|
||||
showDone(t('confirmNext'));
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('status-msg').textContent = t('submitFailed');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
function showDone(msg) {
|
||||
document.body.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:#0f0f0f;">
|
||||
<div style="text-align:center;color:#e0e0e0;font-family:-apple-system,sans-serif;">
|
||||
<div style="font-size:3rem;color:#4ade80;margin-bottom:1rem;">✓</div>
|
||||
<div style="font-size:1.2rem;font-weight:600;margin-bottom:0.5rem;">${msg}</div>
|
||||
<div class="done-sub" style="font-size:0.85rem;color:#888;">${t('pageClosing')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
setTimeout(() => {
|
||||
const sub = document.querySelector('.done-sub');
|
||||
if (sub) sub.textContent = t('canClose');
|
||||
}, 2000);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
## 画风后缀(新增提示词参考模板)
|
||||
|
||||
编写新提示词时参考以下风格模板,确保整体画风统一:
|
||||
|
||||
```
|
||||
opulent and ethereal mood, classical Chinese aesthetic, ink wash painting style mixed with photorealistic rendering, rich saturated palette of deep cinnabar red and burnished gold against velvety darkness, shallow depth of field, radiant warm highlights with sumptuous shadow depth, 8k resolution
|
||||
```
|
||||
|
||||
> 注意:下方每条提示词已内置各自的风格描述变体,直接使用即可,无需额外追加。
|
||||
> 所有提示词末尾的 `--p qgbmkdn` 为 Midjourney 情绪版 LoRA 参数。
|
||||
|
||||
---
|
||||
|
||||
## 固定提示词
|
||||
|
||||
### CHAR-01: 人设图(全身像)
|
||||
|
||||
An ancient Chinese girl in simple everyday vermillion red hanfu robes, her hair styled in a sleek center-parted low chignon hair perfectly smooth and flat against her head with zero loose strands or flyaways, secured with a single delicate jade hairpin, the severe elegant updo accentuating the clean lines of her neck and profile, hyper-detailed ink diffusion, atmospheric and ethereal mood, classical Chinese aesthetic, ink wash painting style mixed with photorealistic rendering, shallow depth of field, muted tones with deep blacks, vermillion red and pearl whites, 8k resolution,Full-body portrait --p qgbmkdn
|
||||
|
||||
### SWORD-01: 舞剑图(拔剑起舞)
|
||||
|
||||
An ancient Chinese girl in flowing vibrant hanfu robes, drawing a gleaming silver sword from an ornate jade-inlaid scabbard in one fluid sweeping motion, the blade catching light and casting brilliant arcs of radiance as she spins into an elegant sword dance, silk sleeves and ribbon sashes trailing behind her like living calligraphy strokes in the air, her robes fanning outward in a dramatic spiral of saturated crimson and deep violet, the razor-sharp blade slicing through falling cherry blossom petals suspended mid-flight, each severed petal splitting in hyper-detailed slow motion, sparks of reflected light scattering from the polished steel like scattered stars, her hair breaking free from golden hairpins with dark strands swirling in dynamic motion, cinematic composition, macro close-up on the blade edge cutting through petals with light refracting along the steel surface, hyper-detailed metallic reflections and fabric dynamics, magnificent and ethereal mood, classical Chinese wuxia aesthetic, ink wash painting style mixed with photorealistic rendering, rich saturated palette of imperial crimson and molten gold against deep indigo twilight, shallow depth of field, explosive contrast of gleaming highlights and velvety shadows, 8k resolution --p qgbmkdn
|
||||
|
||||
---
|
||||
|
||||
## 空镜提示词池
|
||||
|
||||
<!-- 添加新空镜:复制一个 SCENE-XX 段落,递增编号即可。末尾必须包含 --p qgbmkdn -->
|
||||
|
||||
### SCENE-01: 落花飞舞
|
||||
|
||||
A breathtaking sky filled with infinite swirling flower petals cascading from heaven, thousands of cherry blossoms, peonies and plum blossoms in vivid magenta, coral pink, soft lavender and golden yellow tumbling through a dramatic twilight sky, rich gradient heavens shifting from deep violet and royal indigo at the zenith to blazing amber and rose gold at the horizon, petals catching the golden hour light and glowing translucent like stained glass, clusters of petals forming ethereal rivers and spiraling galaxies across the sky, shimmering iridescent pollen drifting like liquid gold dust, volumetric god rays piercing through the petal storm, cinematic wide-angle composition, hyper-detailed petal texture and translucent veins, atmospheric and opulent mood, classical Chinese aesthetic meets vivid fantasy, photorealistic rendering with ink wash painting undertones, ultra-high saturation, rich jewel-toned palette of fuchsia, imperial gold, sapphire and pearl, shallow depth of field, 8k resolution --p qgbmkdn
|
||||
|
||||
### SCENE-02: 蝴蝶
|
||||
|
||||
A breathtaking butterfly with iridescent wings soaring gracefully through the air, its delicate wing scales catching and refracting golden light into prismatic rainbows, luminous dust particles trailing behind each wingbeat like scattered stardust, wings adorned with intricate patterns resembling ancient Chinese embroidery and silk brocade, translucent veins glowing with inner light, the butterfly drifting through a cascade of falling cherry blossoms and shimmering golden pollen, rich jewel-toned background of deep indigo and imperial jade, baroque-meets-classical Chinese aesthetic, cinematic composition, macro close-up on the wing texture and light diffusion, hyper-detailed iridescent scales and gossamer membranes, atmospheric and opulent mood, shallow depth of field, muted yet rich tones with lustrous golds, peacock blues and pearl whites, ink wash painting style mixed with photorealistic rendering, 8k resolution --p qgbmkdn
|
||||
|
||||
### SCENE-03: 古风书房
|
||||
|
||||
Empty ancient Chinese study chamber interior with no people, tall lattice wooden window screens spanning the entire background as the central subject, beyond the carved geometric lattice panels a vast misty mountain landscape of layered peaks dissolving into hazy ink-wash atmospheric tones, soft diffused daylight filtering through translucent rice paper panes gently glowing with warm amber luminance, the paper screens catching subtle refractions of jade green and celadon light from the bamboo grove visible outside, silk curtains in deep vermillion hanging from carved wooden beams swaying softly in a quiet breeze, a polished rosewood writing desk standing in the middle ground with a few scattered peach blossom petals resting on its lacquered surface beside an abandoned calligraphy brush and an ink stone with wet black ink still glistening, a bronze incense burner on a carved wooden stand with a thin wisp of smoke curling upward in a delicate spiral, an antique celadon vase on a side table holding a single branch of plum blossoms in vivid crimson, the clean polished stone floor mirroring the entire quiet scene in perfect symmetry below, pale mountain glow and silk curtain ripples reflected in the dark stone like a still lake, soft pools of warm golden daylight spreading across the immaculate desk surface, dust particles and incense smoke drifting slowly through the shafts of lattice-filtered light like suspended time, the empty chamber breathing with ancient solitude and atmospheric stillness, cinematic wide composition, the vast lattice window wall as the dominant frame with hyper-detailed carved wood texture and stone floor reflections and mountain atmospheric haze, opulent and ethereal mood, classical Chinese aesthetic, ink wash painting style mixed with photorealistic rendering, rich saturated palette of deep cinnabar red and burnished gold with jade green and celadon accents against misty pearl grey mountain tones, deep depth of field capturing both the still interior and the distant dissolving peaks, radiant warm highlights with sumptuous cool shadow depth, 8k resolution --p qgbmkdn
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""下载 sword-dance 内置预设场景图到本地缓存。
|
||||
|
||||
用法:
|
||||
python3 download_scene_templates.py <cache_dir>
|
||||
python3 download_scene_templates.py <cache_dir> --scene scene-01
|
||||
|
||||
也可作为模块导入,调用 ensure_scene_templates(cache_dir) 获取本地路径。
|
||||
|
||||
CDN 源仅在本脚本内部使用,不对外暴露。下载到本地后,所有调用方
|
||||
(render_scene_select.py / Phase 2 reference_image_paths)都使用本地路径。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# Internal CDN source — do not reference these URLs from markdown or other code.
|
||||
_CDN_BASE = "https://cdn.hailuoai.com/hailuo-video-web/public_assets"
|
||||
_PRESETS = {
|
||||
"scene-01": ["cell_1.png", "cell_2.png", "cell_3.png", "cell_4.png"],
|
||||
"scene-02": ["cell_1.png", "cell_2.png", "cell_3.png", "cell_4.png"],
|
||||
"scene-03": ["cell_1.png", "cell_2.png", "cell_3.png", "cell_4.png"],
|
||||
}
|
||||
|
||||
PRESET_SCENES: tuple[str, ...] = tuple(_PRESETS.keys())
|
||||
|
||||
|
||||
def _fetch_one(scene_id: str, filename: str, cache_dir: Path) -> Path | None:
|
||||
"""Download a single preset image; skip if already cached.
|
||||
|
||||
Returns the local path on success (cached or freshly downloaded), or None
|
||||
if the download failed.
|
||||
"""
|
||||
out_dir = cache_dir / scene_id
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
local = out_dir / filename
|
||||
if local.exists():
|
||||
return local
|
||||
url = f"{_CDN_BASE}/{scene_id}/{filename}"
|
||||
try:
|
||||
urllib.request.urlretrieve(url, local)
|
||||
except Exception as exc: # noqa: BLE001 — log and continue
|
||||
print(f"WARN: failed to download {url}: {exc}", file=sys.stderr)
|
||||
return None
|
||||
return local
|
||||
|
||||
|
||||
def ensure_scene_templates(
|
||||
cache_dir: Path,
|
||||
scenes: tuple[str, ...] | None = None,
|
||||
) -> dict[str, list[Path]]:
|
||||
"""Ensure preset scene images exist under cache_dir; download missing ones.
|
||||
|
||||
Args:
|
||||
cache_dir: Directory to cache preset images. Created if absent.
|
||||
scenes: Optional subset of scene ids to ensure. Defaults to all known.
|
||||
|
||||
Returns:
|
||||
{scene_id: [local_path, ...]} — only includes scenes with at least one
|
||||
successfully cached image.
|
||||
"""
|
||||
targets = scenes or PRESET_SCENES
|
||||
result: dict[str, list[Path]] = {}
|
||||
for scene_id in targets:
|
||||
filenames = _PRESETS.get(scene_id)
|
||||
if not filenames:
|
||||
print(f"WARN: unknown scene id: {scene_id}", file=sys.stderr)
|
||||
continue
|
||||
paths: list[Path] = []
|
||||
for fn in filenames:
|
||||
local = _fetch_one(scene_id, fn, cache_dir)
|
||||
if local is not None:
|
||||
paths.append(local)
|
||||
if paths:
|
||||
result[scene_id] = paths
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Download sword-dance built-in scene templates")
|
||||
parser.add_argument("cache_dir", type=Path, help="Local cache directory")
|
||||
parser.add_argument(
|
||||
"--scene",
|
||||
action="append",
|
||||
choices=PRESET_SCENES,
|
||||
help="Only download specific scene(s); may be passed multiple times",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cache_dir: Path = args.cache_dir.resolve()
|
||||
selected = tuple(args.scene) if args.scene else None
|
||||
result = ensure_scene_templates(cache_dir, scenes=selected)
|
||||
|
||||
if not result:
|
||||
print("ERROR: no scene templates cached", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for scene_id, paths in result.items():
|
||||
print(f"{scene_id}: {len(paths)} files")
|
||||
for p in paths:
|
||||
print(f" {p}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""启动图片选择预览服务器。
|
||||
|
||||
用法:python3 render_image_select.py --title "人设图选择" --images a.png b.png c.png d.png
|
||||
|
||||
展示候选图片供用户点击选择,选择结果写入 feedback.md,服务器自动退出。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
TEMPLATE_DIR = SKILL_DIR / "html"
|
||||
PROJECT_ROOT = Path.cwd() # MCP tool spawns with cwd = user project dir
|
||||
WORK_DIR = PROJECT_ROOT / ".sword-dance"
|
||||
|
||||
|
||||
def to_url(abs_path: str) -> str:
|
||||
encoded = base64.urlsafe_b64encode(abs_path.encode()).decode()
|
||||
try:
|
||||
mtime = int(os.path.getmtime(abs_path))
|
||||
except OSError:
|
||||
mtime = 0
|
||||
return f"/images/{encoded}?t={mtime}"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
html_content: str = ""
|
||||
feedback_path: Path = Path("feedback.md")
|
||||
server_ref: HTTPServer | None = None
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/images/"):
|
||||
self._serve_image()
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(self.html_content.encode("utf-8"))
|
||||
|
||||
def _serve_image(self):
|
||||
raw = self.path[len("/images/"):]
|
||||
encoded = raw.split("?")[0] # strip cache-buster query
|
||||
try:
|
||||
img_path = Path(base64.urlsafe_b64decode(encoded).decode("utf-8"))
|
||||
except Exception:
|
||||
self.send_error(400)
|
||||
return
|
||||
if not img_path.exists():
|
||||
self.send_error(404)
|
||||
return
|
||||
mime = mimetypes.guess_type(str(img_path))[0] or "image/png"
|
||||
data = img_path.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mime)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/feedback":
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
feedback = body.get("feedback", "").strip()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
if feedback:
|
||||
self.feedback_path.write_text(feedback, encoding="utf-8")
|
||||
self.wfile.write(json.dumps({"ok": True}).encode())
|
||||
print(f"FEEDBACK_FILE={self.feedback_path}")
|
||||
threading.Timer(0.5, self._shutdown).start()
|
||||
else:
|
||||
self.wfile.write(json.dumps({"ok": False}).encode())
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def _shutdown(self):
|
||||
if self.server_ref:
|
||||
self.server_ref.shutdown()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--title", required=True, help="页面标题")
|
||||
parser.add_argument("--images", nargs="+", required=True, help="候选图片路径列表")
|
||||
parser.add_argument("--feedback", default=str(WORK_DIR / "feedback.md"),
|
||||
help="反馈文件路径")
|
||||
parser.add_argument("--lang", default="", help="UI language: zh/en")
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
|
||||
template_path = TEMPLATE_DIR / "preview-image_select.html"
|
||||
if not template_path.exists():
|
||||
print(f"ERROR: 模板不存在: {template_path}")
|
||||
sys.exit(1)
|
||||
|
||||
feedback_path = Path(args.feedback)
|
||||
feedback_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build candidate data
|
||||
candidates = []
|
||||
for i, img_path in enumerate(args.images):
|
||||
p = Path(img_path).resolve()
|
||||
if not p.exists():
|
||||
print(f"WARNING: 图片不存在: {p}")
|
||||
continue
|
||||
candidates.append({
|
||||
"url": to_url(str(p)),
|
||||
"label": f"Candidate {i + 1}" if args.lang == "en" else f"候选 {i + 1}",
|
||||
"filename": p.name,
|
||||
})
|
||||
|
||||
if not candidates:
|
||||
print("ERROR: 没有有效的候选图片")
|
||||
sys.exit(1)
|
||||
|
||||
data = {"title": args.title, "candidates": candidates}
|
||||
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
injected = template.replace(
|
||||
"const PREVIEW_DATA = null; /* __PREVIEW_DATA__ */",
|
||||
f"const PREVIEW_DATA = {json.dumps(json.dumps(data, ensure_ascii=False))};",
|
||||
)
|
||||
injected = injected.replace(
|
||||
"const PREVIEW_LANG = null; /* __PREVIEW_LANG__ */",
|
||||
f"const PREVIEW_LANG = {json.dumps(args.lang or None)};",
|
||||
)
|
||||
|
||||
server = HTTPServer(("127.0.0.1", args.port), Handler)
|
||||
port = server.server_address[1]
|
||||
|
||||
Handler.html_content = injected
|
||||
Handler.feedback_path = feedback_path
|
||||
Handler.server_ref = server
|
||||
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
print(f"PREVIEW_URL={url}")
|
||||
print("等待用户选择...")
|
||||
|
||||
webbrowser.open(url)
|
||||
server.serve_forever()
|
||||
print("服务器已停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""启动空镜选择预览服务器。
|
||||
|
||||
用法:python3 render_scene_select.py [--port PORT]
|
||||
|
||||
加载内置预设场景(本地 templates/ 优先,否则由 download_scene_templates
|
||||
脚本下载到 templates_cache/)和 .sword-dance/generated/ 下 AI 生成的场景,
|
||||
注入 HTML 模板并在浏览器中打开。
|
||||
用户选择 2 张空镜后,结果写入 feedback.md,服务器自动退出。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
# Allow importing sibling script as module when invoked directly
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from download_scene_templates import PRESET_SCENES, ensure_scene_templates # noqa: E402
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
TEMPLATE_DIR = SKILL_DIR / "html"
|
||||
TEMPLATES_DIR = SKILL_DIR / "templates"
|
||||
PROJECT_ROOT = Path.cwd() # MCP tool spawns with cwd = user project dir
|
||||
WORK_DIR = PROJECT_ROOT / ".sword-dance"
|
||||
EXTRA_SCENES_DIR = WORK_DIR / "generated"
|
||||
TEMPLATES_CACHE_DIR = WORK_DIR / "templates_cache"
|
||||
FEEDBACK_PATH = WORK_DIR / "feedback.md"
|
||||
SELECTED_PATH = WORK_DIR / "selected.md"
|
||||
|
||||
IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
|
||||
SCENE_META = {
|
||||
"scene-01": {"zh": "落花飞舞", "en": "Falling Petals"},
|
||||
"scene-02": {"zh": "蝴蝶", "en": "Butterfly"},
|
||||
"scene-03": {"zh": "古风书房", "en": "Ancient Study"},
|
||||
}
|
||||
|
||||
|
||||
def to_url(abs_path: str) -> str:
|
||||
encoded = base64.urlsafe_b64encode(abs_path.encode()).decode()
|
||||
try:
|
||||
mtime = int(os.path.getmtime(abs_path))
|
||||
except OSError:
|
||||
mtime = 0
|
||||
return f"/images/{encoded}?t={mtime}"
|
||||
|
||||
|
||||
def scene_name(scene_id: str, lang: str) -> str:
|
||||
meta = SCENE_META.get(scene_id)
|
||||
if isinstance(meta, dict):
|
||||
return meta.get(lang, meta.get("en", scene_id))
|
||||
return meta or scene_id
|
||||
|
||||
|
||||
def list_scene_images(scene_dir: Path) -> list[Path]:
|
||||
return sorted(
|
||||
p for p in scene_dir.iterdir() if p.suffix.lower() in IMG_EXTS
|
||||
)
|
||||
|
||||
|
||||
def fetch_cdn_preset(scene_id: str) -> list[Path]:
|
||||
"""通过 download_scene_templates 模块下载该场景的预设图到本地缓存。"""
|
||||
cached = ensure_scene_templates(TEMPLATES_CACHE_DIR, scenes=(scene_id,))
|
||||
return cached.get(scene_id, [])
|
||||
|
||||
|
||||
def make_scene_entry(scene_id: str, image_paths: list[Path], lang: str) -> dict:
|
||||
return {
|
||||
"name": scene_name(scene_id, lang),
|
||||
"images": [
|
||||
{
|
||||
"url": to_url(str(p.resolve())),
|
||||
"path": str(p.resolve()),
|
||||
"filename": p.name,
|
||||
}
|
||||
for p in image_paths
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_scene_data(lang: str = "zh") -> dict:
|
||||
scenes: dict[str, dict] = {}
|
||||
|
||||
# Step 1: 预设池 — 本地 templates/ 优先,缺失则从 CDN 下载到 templates_cache/
|
||||
for scene_id in PRESET_SCENES:
|
||||
local_dir = TEMPLATES_DIR / scene_id
|
||||
if local_dir.is_dir():
|
||||
images = list_scene_images(local_dir)
|
||||
if images:
|
||||
scenes[scene_id] = make_scene_entry(scene_id, images, lang)
|
||||
continue
|
||||
cached = fetch_cdn_preset(scene_id)
|
||||
if cached:
|
||||
scenes[scene_id] = make_scene_entry(scene_id, cached, lang)
|
||||
|
||||
# Step 2: AI 生成场景叠加(同名覆盖,但预设用 scene-01~03,生成用 scene-04+)
|
||||
if EXTRA_SCENES_DIR.exists():
|
||||
for d in sorted(EXTRA_SCENES_DIR.iterdir()):
|
||||
if not d.is_dir() or not d.name.startswith("scene-"):
|
||||
continue
|
||||
images = list_scene_images(d)
|
||||
if images:
|
||||
scenes[d.name] = make_scene_entry(d.name, images, lang)
|
||||
|
||||
return {"scenes": scenes}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
html_content: str = ""
|
||||
feedback_path: Path = Path("feedback.md")
|
||||
server_ref: HTTPServer | None = None
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/images/"):
|
||||
self._serve_image()
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(self.html_content.encode("utf-8"))
|
||||
|
||||
def _serve_image(self):
|
||||
raw = self.path[len("/images/"):]
|
||||
encoded = raw.split("?")[0] # strip cache-buster query
|
||||
try:
|
||||
img_path = Path(base64.urlsafe_b64decode(encoded).decode("utf-8"))
|
||||
except Exception:
|
||||
self.send_error(400)
|
||||
return
|
||||
if not img_path.exists():
|
||||
self.send_error(404)
|
||||
return
|
||||
mime = mimetypes.guess_type(str(img_path))[0] or "image/png"
|
||||
data = img_path.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mime)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/feedback":
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
feedback = body.get("feedback", "").strip()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
if feedback:
|
||||
self.feedback_path.write_text(feedback, encoding="utf-8")
|
||||
self.wfile.write(json.dumps({"ok": True}).encode())
|
||||
print(f"FEEDBACK_FILE={self.feedback_path}")
|
||||
threading.Timer(0.5, self._shutdown).start()
|
||||
else:
|
||||
self.wfile.write(json.dumps({"ok": False}).encode())
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def _shutdown(self):
|
||||
if self.server_ref:
|
||||
self.server_ref.shutdown()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
|
||||
def load_preselect(selected_path: Path) -> list[str]:
|
||||
if not selected_path.exists():
|
||||
return []
|
||||
paths = []
|
||||
for line in selected_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
paths.append(line)
|
||||
return paths
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
parser.add_argument("--lang", default="", help="UI language: zh/en")
|
||||
args = parser.parse_args()
|
||||
|
||||
template_path = TEMPLATE_DIR / "preview-scene_select.html"
|
||||
if not template_path.exists():
|
||||
print(f"ERROR: 模板不存在: {template_path}")
|
||||
sys.exit(1)
|
||||
|
||||
feedback_path = FEEDBACK_PATH
|
||||
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
preselect = load_preselect(SELECTED_PATH)
|
||||
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
data = build_scene_data(lang=args.lang or "zh")
|
||||
content = json.dumps(data, ensure_ascii=False)
|
||||
|
||||
injected = template.replace(
|
||||
"const PREVIEW_DATA = null; /* __PREVIEW_DATA__ */",
|
||||
f"const PREVIEW_DATA = {json.dumps(content)};",
|
||||
)
|
||||
injected = injected.replace(
|
||||
"const PRESELECT = []; /* __PRESELECT__ */",
|
||||
f"const PRESELECT = {json.dumps(preselect)};",
|
||||
)
|
||||
injected = injected.replace(
|
||||
"const PREVIEW_LANG = null; /* __PREVIEW_LANG__ */",
|
||||
f"const PREVIEW_LANG = {json.dumps(args.lang or None)};",
|
||||
)
|
||||
|
||||
server = HTTPServer(("127.0.0.1", args.port), Handler)
|
||||
port = server.server_address[1]
|
||||
|
||||
Handler.html_content = injected
|
||||
Handler.feedback_path = feedback_path
|
||||
Handler.server_ref = server
|
||||
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
print(f"PREVIEW_URL={url}")
|
||||
print("等待用户选择...")
|
||||
|
||||
webbrowser.open(url)
|
||||
server.serve_forever()
|
||||
print("服务器已停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Reference in New Issue
Block a user