feat: 新增高铁游与车宿徒步游技能及相关基础设施
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
/* ============================================================
|
||||
* 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) })
|
||||
Reference in New Issue
Block a user