/* ============================================================ * 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) })