M0 playable slice: lanes integrated, machine chain staged, hidden-tab hardening
Integration: blob+paint+machines composed in game.ts; cannons staged along the racing line (muzzle 26 — default 16 undershoots course distances); plate threshold above clean-blob mass so only painted blobs trip it (paint=weight= machine access); belt delivers riders under the bucket (shallow catch timed to teeter drift); arch moved to a detour lane. Cross-lane fixes found at integration, all one class — gameplay logic living on render-frame time, which stalls in hidden tabs: - telegraph windup -> fixed step (gates forces/dumps) - bucket tip/pour -> fixed step (gates the splat) - blob group sync -> fixed step in controller (paint proximity gate reads it) world.ts grew tick()/renderOnce() + a hidden-tab watchdog interval for this. Assets: mesh wave 1 (4 GLBs via farm: blobbo-base, toaster, spring boot, paint bucket) + mesh_pipeline.py with outdir scp fallback for the farm's unregistered-remote-outputs bug. Verified headless end-to-end via tick(): move/jump/squash, cannon hits on a moving blob, coverage->buffs (BURN 1.6x, super at 70%), mass 1.0->1.67 with paint, plate clean-reject + painted-trip, boot launch (peak y 3.9), belt-> bucket dump (23.6% purple), arch cleanse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BIN
assets/meshes/blobbo-base.glb
Normal file
BIN
assets/meshes/prop-paint-bucket.glb
Normal file
BIN
assets/meshes/prop-spring-boot.glb
Normal file
BIN
assets/meshes/prop-toaster-launcher.glb
Normal file
BIN
assets/meshes/src/blobbo-base-cut.png
Normal file
|
After Width: | Height: | Size: 499 KiB |
BIN
assets/meshes/src/blobbo-base.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
assets/meshes/src/prop-paint-bucket-cut.png
Normal file
|
After Width: | Height: | Size: 801 KiB |
BIN
assets/meshes/src/prop-paint-bucket.png
Normal file
|
After Width: | Height: | Size: 712 KiB |
BIN
assets/meshes/src/prop-spring-boot-cut.png
Normal file
|
After Width: | Height: | Size: 635 KiB |
BIN
assets/meshes/src/prop-spring-boot.png
Normal file
|
After Width: | Height: | Size: 566 KiB |
BIN
assets/meshes/src/prop-toaster-launcher-cut.png
Normal file
|
After Width: | Height: | Size: 668 KiB |
BIN
assets/meshes/src/prop-toaster-launcher.png
Normal file
|
After Width: | Height: | Size: 587 KiB |
@ -79,6 +79,11 @@ export function createBlobController(
|
|||||||
|
|
||||||
// ---- grounded check: short ray straight down, excluding self ----
|
// ---- grounded check: short ray straight down, excluding self ----
|
||||||
const t = body.translation()
|
const t = body.translation()
|
||||||
|
// Sync the visual root to the body in the FIXED step: gameplay reads this
|
||||||
|
// transform (paint proximity gating), so it must track the sim even when
|
||||||
|
// render frames stall (hidden tab). The feel layer re-sets it per frame
|
||||||
|
// with wobble on top — that stays visual-only.
|
||||||
|
blob.group.position.set(t.x, t.y, t.z)
|
||||||
const ray = new rapier.Ray({ x: t.x, y: t.y, z: t.z }, { x: 0, y: -1, z: 0 })
|
const ray = new rapier.Ray({ x: t.x, y: t.y, z: t.z }, { x: 0, y: -1, z: 0 })
|
||||||
const hit = physics.castRay(ray, r + 0.18, true, undefined, undefined, blob.collider, body)
|
const hit = physics.castRay(ray, r + 0.18, true, undefined, undefined, blob.collider, body)
|
||||||
const nowGrounded = hit !== null
|
const nowGrounded = hit !== null
|
||||||
|
|||||||
@ -82,6 +82,10 @@ export interface World {
|
|||||||
addSystem(s: System): void
|
addSystem(s: System): void
|
||||||
/** Per-render-frame hooks (visual-only work: cameras, jiggle, particles). */
|
/** Per-render-frame hooks (visual-only work: cameras, jiggle, particles). */
|
||||||
onFrame(fn: (dt: number) => void): void
|
onFrame(fn: (dt: number) => void): void
|
||||||
|
/** Manually advance the fixed-step sim (tests, headless verification). */
|
||||||
|
tick(steps?: number): void
|
||||||
|
/** Run frame hooks + render one frame (screenshots while backgrounded). */
|
||||||
|
renderOnce(): void
|
||||||
start(): void
|
start(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
src/game.ts
@ -1,56 +1,117 @@
|
|||||||
/**
|
/**
|
||||||
* INTEGRATION SEAM — owned by integration, not by lanes.
|
* INTEGRATION SEAM — owned by integration, not by lanes.
|
||||||
* Composes lane modules into the playable slice. While lanes are in flight this
|
* The playable slice: Lane A blob + course, Lane B paint + buffs, and (when
|
||||||
* boots a placeholder ball on a flat floor so `npm run dev` always shows life.
|
* lane-c merges) the machine chain on the course's machine area.
|
||||||
*/
|
*/
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import type { World } from './contracts'
|
import type { World } from './contracts'
|
||||||
|
import { buildGreybox } from './course/greybox'
|
||||||
|
import { createBlob } from './blob/createBlob'
|
||||||
|
import { createBlobController } from './blob/controller'
|
||||||
|
import { createBlobFeel } from './blob/feel'
|
||||||
|
import { createFollowCamera } from './blob/camera'
|
||||||
|
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
|
||||||
|
import { createPressurePlate, createSpringBoot, createBucketDump, createBubbleArch, createConveyorBelt } from './machine/index'
|
||||||
|
|
||||||
export function installGame(world: World) {
|
export function installGame(world: World) {
|
||||||
const { scene, physics, rapier } = world
|
const course = buildGreybox(world)
|
||||||
|
|
||||||
// floor
|
// ---- blob (Lane A) ----
|
||||||
const floor = new THREE.Mesh(
|
const blob = createBlob(world, { position: course.spawn })
|
||||||
new THREE.BoxGeometry(60, 1, 60),
|
blob.paint = new PaintSkin(blob.mesh, { size: 256 })
|
||||||
new THREE.MeshStandardMaterial({ color: '#e8d9b8' }))
|
world.blob = blob
|
||||||
floor.position.y = -0.5
|
|
||||||
floor.receiveShadow = true
|
|
||||||
scene.add(floor)
|
|
||||||
physics.createCollider(rapier.ColliderDesc.cuboid(30, 0.5, 30)
|
|
||||||
.setTranslation(0, -0.5, 0))
|
|
||||||
|
|
||||||
// placeholder blob-ball (replaced by lane A's createBlob at integration)
|
world.addSystem(createBlobController(world, blob))
|
||||||
const ball = new THREE.Mesh(
|
|
||||||
new THREE.SphereGeometry(0.6, 32, 24),
|
|
||||||
new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.35 }))
|
|
||||||
ball.castShadow = true
|
|
||||||
scene.add(ball)
|
|
||||||
const body = physics.createRigidBody(
|
|
||||||
rapier.RigidBodyDesc.dynamic().setTranslation(0, 3, 0))
|
|
||||||
physics.createCollider(
|
|
||||||
rapier.ColliderDesc.ball(0.6).setRestitution(0.4).setFriction(1.0), body)
|
|
||||||
|
|
||||||
const keys = new Set<string>()
|
const feel = createBlobFeel(blob)
|
||||||
addEventListener('keydown', (e) => keys.add(e.code))
|
world.events.on('blob:jumped', () => feel.onJump())
|
||||||
addEventListener('keyup', (e) => keys.delete(e.code))
|
world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact))
|
||||||
|
world.onFrame((dt) => feel.update(dt))
|
||||||
|
|
||||||
world.addSystem({
|
const camera = createFollowCamera(world, blob)
|
||||||
update() {
|
world.onFrame((dt) => camera.update(dt))
|
||||||
const f = 18
|
|
||||||
const impulse = { x: 0, y: 0, z: 0 }
|
// ---- paint (Lane B): cannons staged along the racing line ----
|
||||||
if (keys.has('KeyW') || keys.has('ArrowUp')) impulse.z -= f
|
const skin = blob.paint as PaintSkin
|
||||||
if (keys.has('KeyS') || keys.has('ArrowDown')) impulse.z += f
|
const cannonSpecs = [
|
||||||
if (keys.has('KeyA') || keys.has('ArrowLeft')) impulse.x -= f
|
{ color: 'red' as const, position: new THREE.Vector3(-9, 2.5, 14), triggerId: 'cannon-red' },
|
||||||
if (keys.has('KeyD') || keys.has('ArrowRight')) impulse.x += f
|
{ color: 'green' as const, position: new THREE.Vector3(9, 4.5, -6), triggerId: 'cannon-green' },
|
||||||
body.applyImpulse({ x: impulse.x / 60, y: 0, z: impulse.z / 60 }, true)
|
{ color: 'blue' as const, position: new THREE.Vector3(-9, 2.0, -30), triggerId: 'cannon-blue' },
|
||||||
const p = body.translation()
|
]
|
||||||
ball.position.set(p.x, p.y, p.z)
|
for (const spec of cannonSpecs) {
|
||||||
const q = body.rotation()
|
world.addSystem(new PaintCannon({
|
||||||
ball.quaternion.set(q.x, q.y, q.z, q.w)
|
world,
|
||||||
},
|
position: spec.position,
|
||||||
|
color: spec.color,
|
||||||
|
target: blob.mesh,
|
||||||
|
paint: skin,
|
||||||
|
targetRadius: blob.radius,
|
||||||
|
triggerId: spec.triggerId,
|
||||||
|
interval: 2.2,
|
||||||
|
muzzleSpeed: 26, // default 16 caps ballistic range at ~18u — course distances need ~26
|
||||||
|
splatRadius: 0.45,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- machine chain (Lane C) on the course's machine area ----
|
||||||
|
// The pressure plate needs MORE than a clean blob's mass: paint = weight =
|
||||||
|
// machine access (GDD §5.2/§5.4). Clean blobs roll over it; painted trip it.
|
||||||
|
const baseMass = blob.body.mass()
|
||||||
|
createPressurePlate(world, {
|
||||||
|
id: 'plate-1', position: [0, 0.24, -40],
|
||||||
|
massThreshold: baseMass * 1.25, emits: 'boot-1', size: [4, 3],
|
||||||
|
})
|
||||||
|
createSpringBoot(world, {
|
||||||
|
id: 'boot-1', position: [0, 0.2, -42.5], // 0.5s telegraph ≈ 3u of roll past the plate
|
||||||
|
impulse: baseMass * 18, // sized for a plate-tripping (painted, heavy) blob to reach the bucket
|
||||||
|
direction: [0, 1, -0.55],
|
||||||
|
onSignal: 'boot-1', strikeSize: [2.4, 1.4, 3],
|
||||||
|
})
|
||||||
|
// Bucket hangs over the conveyor: the belt delivers you underneath at 4 u/s,
|
||||||
|
// the catch trigger arms the teeter, and the pour lands at the spout point
|
||||||
|
// (bucket.x + 1.4) — which is the belt's centerline. Linger and get soaked.
|
||||||
|
createBucketDump(world, {
|
||||||
|
// Timed empirically: catch trips at z≈-46, teeter ≈0.8s, belt 3 u/s →
|
||||||
|
// blob reaches the spout (bucket.z) as the pour lands. Miss was 1.73u
|
||||||
|
// with bucket at -50 / belt 4.
|
||||||
|
id: 'bucket-1', position: [4.6, 6, -46.5],
|
||||||
|
color: 'purple', radius: 0.8,
|
||||||
|
// Shallow catch just upstream of the spout: observed teeter drift is only
|
||||||
|
// 0.4-2u depending on speed, so trip near the spout, not a belt-length away.
|
||||||
|
trigger: { size: [3, 2, 1.6], offset: [1.4, -5.2, 0.75] },
|
||||||
|
})
|
||||||
|
createBubbleArch(world, {
|
||||||
|
id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6, // far-side lane: cleansing is a detour you choose
|
||||||
|
})
|
||||||
|
createConveyorBelt(world, {
|
||||||
|
id: 'belt-1', position: [6, 0.25, -50],
|
||||||
|
size: [3, 0.5, 10], velocity: [0, 0, -3],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
|
||||||
|
installPaint(world, { mesh: blob.mesh, paint: skin })
|
||||||
|
|
||||||
|
const hud = new PaintHUD()
|
||||||
world.onFrame(() => {
|
world.onFrame(() => {
|
||||||
world.camera.lookAt(ball.position)
|
hud.update(skin.coverage(), blob.modifiers) // coverage() self-caches at 250ms
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- fall respawn + debug ----
|
||||||
|
const respawn = () => {
|
||||||
|
blob.body.setTranslation({ x: course.spawn.x, y: course.spawn.y, z: course.spawn.z }, true)
|
||||||
|
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
|
||||||
|
world.events.emit('paint:request-cleanse', { fraction: 1 })
|
||||||
|
}
|
||||||
|
world.onFrame(() => {
|
||||||
|
if (blob.body.translation().y < -25) respawn()
|
||||||
|
})
|
||||||
|
addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'KeyR') respawn()
|
||||||
|
if (e.code === 'KeyC') world.events.emit('paint:request-cleanse', { fraction: 0.5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// debug handle (dev builds are the only builds right now)
|
||||||
|
;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, course }
|
||||||
|
|
||||||
|
return { course, blob }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -437,8 +437,14 @@ export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePa
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Visual-only sloshers stay on the render frame…
|
||||||
world.onFrame((dt) => {
|
world.onFrame((dt) => {
|
||||||
for (const s of [...sloshers]) s(dt)
|
for (const s of [...sloshers]) s(dt)
|
||||||
|
})
|
||||||
|
// …but the tip drives the SPLAT — gameplay, so fixed-step (onFrame stalls in
|
||||||
|
// hidden tabs and would silence every dump; found at integration).
|
||||||
|
world.addSystem({
|
||||||
|
update(dt) {
|
||||||
tip = lerp(tip, tipTarget, Math.min(1, dt * 4))
|
tip = lerp(tip, tipTarget, Math.min(1, dt * 4))
|
||||||
group.rotation.z = -tip
|
group.rotation.z = -tip
|
||||||
|
|
||||||
@ -464,6 +470,7 @@ export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePa
|
|||||||
// right the bucket back once poured, then re-arm
|
// right the bucket back once poured, then re-arm
|
||||||
if (dumping && tip > Math.PI * 0.8) tipTarget = 0
|
if (dumping && tip > Math.PI * 0.8) tipTarget = 0
|
||||||
if (dumping && tipTarget === 0 && tip < 0.05) dumping = false
|
if (dumping && tipTarget === 0 && tip < 0.05) dumping = false
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return { id: cfg.id, group }
|
return { id: cfg.id, group }
|
||||||
|
|||||||
@ -127,7 +127,10 @@ export function telegraph(
|
|||||||
if (!list) {
|
if (!list) {
|
||||||
list = []
|
list = []
|
||||||
perWorld.set(world, list)
|
perWorld.set(world, list)
|
||||||
world.onFrame((dt) => tick(list!, dt))
|
// Fixed-step, not onFrame: the windup GATES gameplay (forces, paint dumps),
|
||||||
|
// so it must advance with the sim — onFrame stalls in hidden tabs, which
|
||||||
|
// would freeze every contraption mid-telegraph (found at integration).
|
||||||
|
world.addSystem({ update: (dt) => tick(list!, dt) })
|
||||||
}
|
}
|
||||||
const o = { ...DEFAULTS, ...opts }
|
const o = { ...DEFAULTS, ...opts }
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
|
|||||||
28
src/world.ts
@ -49,27 +49,47 @@ export async function createWorld(container: HTMLElement): Promise<World> {
|
|||||||
const frameHooks: Array<(dt: number) => void> = []
|
const frameHooks: Array<(dt: number) => void> = []
|
||||||
const events = makeEvents()
|
const events = makeEvents()
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
physics.step()
|
||||||
|
for (const s of systems) s.update(FIXED_DT)
|
||||||
|
}
|
||||||
|
|
||||||
const world: World = {
|
const world: World = {
|
||||||
scene, camera, renderer, physics, rapier: RAPIER, events,
|
scene, camera, renderer, physics, rapier: RAPIER, events,
|
||||||
addSystem: (s) => void systems.push(s),
|
addSystem: (s) => void systems.push(s),
|
||||||
onFrame: (fn) => void frameHooks.push(fn),
|
onFrame: (fn) => void frameHooks.push(fn),
|
||||||
|
tick(steps = 1) {
|
||||||
|
for (let i = 0; i < steps; i++) step()
|
||||||
|
},
|
||||||
|
renderOnce() {
|
||||||
|
for (const fn of frameHooks) fn(FIXED_DT)
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
},
|
||||||
start() {
|
start() {
|
||||||
let last = performance.now()
|
let last = performance.now()
|
||||||
let acc = 0
|
let acc = 0
|
||||||
const loop = (now: number) => {
|
const advance = (now: number) => {
|
||||||
requestAnimationFrame(loop)
|
|
||||||
const dt = Math.min((now - last) / 1000, 0.1)
|
const dt = Math.min((now - last) / 1000, 0.1)
|
||||||
last = now
|
last = now
|
||||||
acc += dt
|
acc += dt
|
||||||
while (acc >= FIXED_DT) {
|
while (acc >= FIXED_DT) {
|
||||||
physics.step()
|
step()
|
||||||
for (const s of systems) s.update(FIXED_DT)
|
|
||||||
acc -= FIXED_DT
|
acc -= FIXED_DT
|
||||||
}
|
}
|
||||||
|
return dt
|
||||||
|
}
|
||||||
|
const loop = (now: number) => {
|
||||||
|
requestAnimationFrame(loop)
|
||||||
|
const dt = advance(now)
|
||||||
for (const fn of frameHooks) fn(dt)
|
for (const fn of frameHooks) fn(dt)
|
||||||
renderer.render(scene, camera)
|
renderer.render(scene, camera)
|
||||||
}
|
}
|
||||||
requestAnimationFrame(loop)
|
requestAnimationFrame(loop)
|
||||||
|
// Hidden tabs suspend rAF entirely — keep the sim alive on a coarse
|
||||||
|
// interval so the game (and headless verification) survives backgrounding.
|
||||||
|
setInterval(() => {
|
||||||
|
if (performance.now() - last > 500) advance(performance.now())
|
||||||
|
}, 250)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
tools/mesh-wave-1.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "blobbo-base",
|
||||||
|
"prompt": "Single friendly white blob mascot creature standing in a relaxed T-pose, arms slightly out, soft rounded michelin-man-like body, big cute glossy black eyes, tiny smile, stubby legs, three-quarter front view, full body centered, clean flat white background, no shadow, 3D game character render style"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prop-toaster-launcher",
|
||||||
|
"prompt": "Single cartoon chrome toaster as a game prop, chunky friendly toy-like design, oversized launch lever on the side, slightly tilted three-quarter view, whole object centered and fully visible, clean flat white background, no shadow, 3D game asset render style"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prop-spring-boot",
|
||||||
|
"prompt": "Single cartoon giant red rubber boot mounted on a big metal coil spring, chunky toy-like game prop design, three-quarter view, whole object centered and fully visible, clean flat white background, no shadow, 3D game asset render style"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prop-paint-bucket",
|
||||||
|
"prompt": "Single cartoon metal paint bucket brimming with glossy bright red paint, one thick drip over the rim, chunky toy-like game prop design, three-quarter view, whole object centered and fully visible, clean flat white background, no shadow, 3D game asset render style"
|
||||||
|
}
|
||||||
|
]
|
||||||
99
tools/mesh_pipeline.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""mesh_pipeline.py — flux image -> bg_remove -> image->GLB, per spec entry.
|
||||||
|
|
||||||
|
Usage: mesh_pipeline.py tools/mesh-wave-1.json assets/meshes/
|
||||||
|
Retries each farm job up to 3x (the m4 node's broken mflux venv 126s any flux
|
||||||
|
job routed to it — resubmission re-rolls the load-balancer). Skips any entry
|
||||||
|
whose .glb already exists, so reruns are cheap.
|
||||||
|
"""
|
||||||
|
import json, os, sys, time
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import mb
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def run_job(operator, params=None, asset_id=None, label=''):
|
||||||
|
for attempt in range(1, RETRIES + 1):
|
||||||
|
jid = None
|
||||||
|
while jid is None:
|
||||||
|
jid = mb.submit(operator, params, asset_id=asset_id)
|
||||||
|
if jid is None:
|
||||||
|
print(f'[pipe] {label}: throttled, 15s', flush=True)
|
||||||
|
time.sleep(15)
|
||||||
|
st = mb.wait(jid, f'{label} ({operator} try {attempt})', interval=10)
|
||||||
|
if st == 'done':
|
||||||
|
return jid
|
||||||
|
time.sleep(5)
|
||||||
|
raise RuntimeError(f'{label}: {operator} failed {RETRIES}x')
|
||||||
|
|
||||||
|
|
||||||
|
PRIMARY = 'm3ultra@100.89.131.57'
|
||||||
|
|
||||||
|
|
||||||
|
def scp_from_outdir(jid, out, suffix='.glb'):
|
||||||
|
"""Fallback for a farm bug (2026-07-17): hunyuan jobs run on remote workers
|
||||||
|
ship outputs back to the primary's job outdir but never register them as
|
||||||
|
assets. Pull the textured mesh (not *_shape.glb) straight from outdir."""
|
||||||
|
import subprocess
|
||||||
|
j = mb.req(f'/api/jobs/{jid}')
|
||||||
|
outdir = j.get('outdir')
|
||||||
|
if not outdir:
|
||||||
|
return False
|
||||||
|
ls = subprocess.run(['ssh', '-o', 'ConnectTimeout=8', '-o', 'BatchMode=yes',
|
||||||
|
PRIMARY, f'ls {outdir}'], capture_output=True, text=True)
|
||||||
|
names = [n for n in ls.stdout.split()
|
||||||
|
if n.endswith(suffix) and not n.endswith('_shape.glb')]
|
||||||
|
if not names:
|
||||||
|
return False
|
||||||
|
r = subprocess.run(['scp', '-o', 'ConnectTimeout=8', '-o', 'BatchMode=yes',
|
||||||
|
f'{PRIMARY}:{outdir}/{names[0]}', out], capture_output=True)
|
||||||
|
return r.returncode == 0 and os.path.exists(out)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_output(jid, out, suffix=None):
|
||||||
|
outs = mb.outputs_for(jid, suffix)
|
||||||
|
if outs:
|
||||||
|
n = mb.fetch(outs[0]['id'], out)
|
||||||
|
print(f'[pipe] wrote {out} ({n//1024}KB)', flush=True)
|
||||||
|
return
|
||||||
|
if suffix and scp_from_outdir(jid, out, suffix):
|
||||||
|
print(f'[pipe] wrote {out} ({os.path.getsize(out)//1024}KB, outdir fallback)', flush=True)
|
||||||
|
return
|
||||||
|
raise RuntimeError(f'no output asset for {jid} (api + outdir fallback both empty)')
|
||||||
|
|
||||||
|
|
||||||
|
def main(spec_path, outdir):
|
||||||
|
specs = json.load(open(spec_path))
|
||||||
|
srcdir = os.path.join(outdir, 'src')
|
||||||
|
os.makedirs(srcdir, exist_ok=True)
|
||||||
|
failed = []
|
||||||
|
for s in specs:
|
||||||
|
name = s['name']
|
||||||
|
glb = os.path.join(outdir, f'{name}.glb')
|
||||||
|
if os.path.exists(glb):
|
||||||
|
print(f'[pipe] {name}: glb exists, skip', flush=True)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
img = os.path.join(srcdir, f'{name}.png')
|
||||||
|
if not os.path.exists(img):
|
||||||
|
jid = run_job('flux_local', {'prompt': s['prompt']}, label=name)
|
||||||
|
fetch_output(jid, img)
|
||||||
|
cut = os.path.join(srcdir, f'{name}-cut.png')
|
||||||
|
if not os.path.exists(cut):
|
||||||
|
aid = mb.upload(img)
|
||||||
|
jid = run_job('bg_remove_local', {}, asset_id=aid, label=name)
|
||||||
|
fetch_output(jid, cut)
|
||||||
|
aid = mb.upload(cut)
|
||||||
|
jid = run_job('hunyuan3d_mlx', {}, asset_id=aid, label=name)
|
||||||
|
fetch_output(jid, glb, suffix='.glb')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[pipe] {name}: FAILED — {e}', flush=True)
|
||||||
|
failed.append(name)
|
||||||
|
ok = len(specs) - len(failed)
|
||||||
|
print(f'[pipe] mesh wave complete: {ok}/{len(specs)} ok'
|
||||||
|
+ (f' — failed: {failed}' if failed else ''), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main(sys.argv[1], sys.argv[2])
|
||||||