Files
Toolbox/dev_test_scripts/tools/tool_gen_travel_citytour_photos.js
T

202 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* tool_gen_travel_citytour_photos.js —— 用百炼生成「游山玩水」样例的 AI 示意图
*
* 背景:Trae 自带的 text_to_image 接口(coresg)对任意 prompt 只返回同一张固定图,
* 无法逐节点出图;改用百炼(DashScope)文生图。
* 实测可用两条通道:
* A) multimodal-generation + qwen-image 系列 —— 直接返回图片 URL,无需轮询
* B) text2image/image-synthesis + wanx/wan2.x —— 异步任务,需轮询
* 脚本按优先级依次尝试,谁通用谁,避免单点故障。
*
* 产物:public/tools/thought_lab/labs/travel_citytour/assets/photos/*.jpg
* 运行:node dev_test_scripts/tools/tool_gen_travel_citytour_photos.js
* 说明:密钥来自本机私有凭据文件,不写入仓库;本脚本只读密钥、只写图片。
* ============================================================ */
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
if (!line || /^\s*#/.test(line)) continue
const i = line.indexOf('=')
if (i < 0) continue
const k = line.slice(0, i).trim()
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
if (k && !(k in process.env)) process.env[k] = v
}
}
const KEY = process.env.DASHSCOPE_API_KEY || ''
const BASE = 'https://dashscope.aliyuncs.com'
const OUT_DIR = path.join(
process.cwd(), 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'assets', 'photos'
)
fs.mkdirSync(OUT_DIR, { recursive: true })
// 优先级:先试直接返回的 qwen-image 系列,再退到异步的 wanx 系列
const MODEL_CHAIN = [
{ id: 'qwen-image-3.0-pro', kind: 'multimodal' },
{ id: 'qwen-image-3.0', kind: 'multimodal' },
{ id: 'qwen-image-max', kind: 'multimodal' },
{ id: 'qwen-image-plus', kind: 'multimodal' },
{ id: 'wan2.2-t2i-plus', kind: 'synthesis', size: '1280*720' },
{ id: 'wanx2.1-t2i-plus', kind: 'synthesis', size: '1280*720' }
]
// 尺寸候选:页面卡片里只显示 150px 高,用不到 1664×928,
// 按「够用且省体积」的顺序试;qwen-image 对尺寸有白名单,故逐个降级尝试。
const MULTIMODAL_SIZES = [
process.env.TC_IMG_SIZE || '',
'1024*576',
'1280*720',
'1328*1328'
].filter(Boolean)
const STYLE = 'realistic travel photograph, natural daylight, no text, no watermark, no logo'
const JOBS = [
{ file: 'rudong_cover.jpg', prompt: 'coastal highway along the Yellow Sea tidal flat in Jiangsu China, wind turbines in the distance, autumn afternoon, wide landscape' },
{ file: 'rudong_hike1_embankment.jpg', prompt: 'long concrete sea embankment levee path above a mudflat, fishing nets and small boats below, overcast coastal sky, empty path' },
{ file: 'rudong_bengcha.jpg', prompt: 'old Chinese town street with grey brick houses, stone slab lane, red lanterns, quiet morning light, low-rise traditional buildings' },
{ file: 'rudong_lunch_local.jpg', prompt: 'small local seafood restaurant table in a Jiangsu coastal town, clam soup, steamed fish, plain ceramic bowls, simple interior' },
{ file: 'rudong_bathhouse.jpg', prompt: 'outdoor hot spring pool in a resort garden at dusk, steam rising from the water, wooden deck, greenery around' },
{ file: 'rudong_carsleep_park.jpg', prompt: 'quiet parking lot beside a city park in a small Chinese town at night, warm street lamps, one car parked alone, trees' },
{ file: 'rudong_hike2_forest.jpg', prompt: 'coastal shelterbelt forest dirt trail, tall trees on both sides, dappled sunlight through leaves, green tunnel path' }
]
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function genMultimodal(model, prompt, size) {
const r = await fetch(BASE + '/api/v1/services/aigc/multimodal-generation/generation', {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
input: { messages: [{ role: 'user', content: [{ text: prompt + ', ' + STYLE }] }] },
parameters: { size, n: 1, prompt_extend: true }
}),
signal: AbortSignal.timeout(120000)
})
const t = await r.text()
let j = null
try { j = JSON.parse(t) } catch {}
if (r.status !== 200) throw new Error('HTTP ' + r.status + ' ' + (j && j.message ? j.message : t.slice(0, 140)))
const content = j && j.output && j.output.choices && j.output.choices[0] &&
j.output.choices[0].message && j.output.choices[0].message.content
const url = Array.isArray(content) ? (content.find((c) => c.image) || {}).image : null
if (!url) throw new Error('未返回图片:' + t.slice(0, 200))
return url
}
async function genSynthesis(model, prompt, size) {
const r = await fetch(BASE + '/api/v1/services/aigc/text2image/image-synthesis', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + KEY,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable'
},
body: JSON.stringify({
model,
input: { prompt: prompt + ', ' + STYLE },
parameters: { size, n: 1 }
}),
signal: AbortSignal.timeout(60000)
})
const t = await r.text()
let j = null
try { j = JSON.parse(t) } catch {}
if (r.status !== 200 || !j || !j.output || !j.output.task_id) {
throw new Error('HTTP ' + r.status + ' ' + (j && j.message ? j.message : t.slice(0, 140)))
}
const taskId = j.output.task_id
for (let i = 0; i < 40; i++) {
await sleep(3000)
const pr = await fetch(BASE + '/api/v1/tasks/' + taskId, { headers: { Authorization: 'Bearer ' + KEY } })
const pj = JSON.parse(await pr.text())
const st = pj && pj.output && pj.output.task_status
if (st === 'SUCCEEDED') {
const url = ((pj.output.results || [])[0] || {}).url
if (!url) throw new Error('任务成功但无图片 URL')
return url
}
if (st === 'FAILED') throw new Error('任务失败:' + (pj.output.message || pj.message || ''))
}
throw new Error('任务超时')
}
async function download(url, file) {
const r = await fetch(url, { signal: AbortSignal.timeout(120000) })
if (!r.ok) throw new Error('下载失败 HTTP ' + r.status)
const buf = Buffer.from(await r.arrayBuffer())
if (buf.length < 10000) throw new Error('文件过小,疑似异常:' + buf.length)
const full = path.join(OUT_DIR, file)
fs.writeFileSync(full, buf)
return { size: buf.length, md5: crypto.createHash('md5').update(buf).digest('hex') }
}
async function main() {
if (!KEY) { console.log('缺少 DASHSCOPE_API_KEY'); process.exit(1) }
// 先做一次连通性预检,确定这次用哪条通道、哪个尺寸
let chosen = null
for (const m of MODEL_CHAIN) {
if (chosen) break
if (m.kind === 'multimodal') {
for (const size of MULTIMODAL_SIZES) {
process.stdout.write('[preflight] ' + m.id + ' size=' + size + ' … ')
try {
await genMultimodal(m.id, 'a simple red apple on a wooden table', size)
console.log('可用(直接返回)')
chosen = { id: m.id, kind: 'multimodal', size }
break
} catch (e) {
console.log('不可用:' + e.message)
}
}
} else {
process.stdout.write('[preflight] ' + m.id + ' size=' + m.size + ' … ')
try {
// 异步模型预检只验证能否建任务,避免多花钱
const r = await fetch(BASE + '/api/v1/services/aigc/text2image/image-synthesis', {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json', 'X-DashScope-Async': 'enable' },
body: JSON.stringify({ model: m.id, input: { prompt: 'test' }, parameters: { size: m.size, n: 1 } })
})
const j = JSON.parse(await r.text())
if (!(j && j.output && j.output.task_id)) throw new Error(j && j.message ? j.message : 'HTTP ' + r.status)
console.log('可用(异步任务)')
chosen = m
} catch (e) {
console.log('不可用:' + e.message)
}
}
}
if (!chosen) { console.log('没有可用模型/尺寸组合'); process.exit(1) }
console.log('[chosen] ' + chosen.id + ' size=' + chosen.size + ' → 输出目录 ' + OUT_DIR)
const results = []
for (const job of JOBS) {
process.stdout.write('[gen] ' + job.file + ' … ')
try {
const url = chosen.kind === 'multimodal'
? await genMultimodal(chosen.id, job.prompt, chosen.size)
: await genSynthesis(chosen.id, job.prompt, chosen.size)
const d = await download(url, job.file)
results.push({ file: job.file, ...d })
console.log('OK ' + d.size + ' bytes ' + d.md5.slice(0, 12))
} catch (e) {
console.log('FAIL ' + e.message)
}
}
console.log('\n===== 汇总 =====')
results.forEach((r) => console.log(r.file + ' ' + r.size + ' ' + r.md5))
const uniq = new Set(results.map((r) => r.md5))
console.log('生成 ' + results.length + '/' + JOBS.length + ' 张,去重后 ' + uniq.size + ' 张')
console.log(uniq.size === results.length ? '✓ 每张都不同,可逐节点使用' : '❌ 存在重复图,不可用')
}
main().catch((e) => { console.error('FATAL', e && e.message); process.exit(1) })