202 lines
9.3 KiB
JavaScript
202 lines
9.3 KiB
JavaScript
/* ============================================================
|
||
* 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) }) |