@bitmagic/cli 0.1.64-dev.1 → 0.1.64-dev.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -5
- package/assets/blender-level/README.md +25 -0
- package/assets/blender-level/audit.py +232 -0
- package/assets/blender-level/bmlevel/__init__.py +294 -0
- package/assets/blender-level/bmlevel/core.py +438 -0
- package/assets/blender-level/bmlevel/scene_bpy.py +186 -0
- package/assets/blender-level/bmlevel/scene_null.py +44 -0
- package/assets/blender-level/example_level.py +80 -0
- package/assets/blender-level/find_blender.py +62 -0
- package/assets/blender-level/review.py +117 -0
- package/assets/blender-level/sync_world.py +85 -0
- package/assets/blender-level/tests/test_core.py +189 -0
- package/dist/assets/keep-glb.d.ts +20 -0
- package/dist/assets/keep-glb.js +45 -0
- package/dist/assets/keep-glb.js.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/assets.d.ts +3 -0
- package/dist/commands/assets.js +33 -3
- package/dist/commands/assets.js.map +1 -1
- package/dist/commands/cover.js +7 -1
- package/dist/commands/cover.js.map +1 -1
- package/dist/commands/forge.d.ts +2 -0
- package/dist/commands/forge.js +76 -12
- package/dist/commands/forge.js.map +1 -1
- package/dist/commands/generate.d.ts +88 -1
- package/dist/commands/generate.js +400 -21
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +33 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/judge.d.ts +0 -11
- package/dist/commands/judge.js +15 -2
- package/dist/commands/judge.js.map +1 -1
- package/dist/commands/levels.d.ts +1 -1
- package/dist/commands/levels.js +14 -3
- package/dist/commands/levels.js.map +1 -1
- package/dist/commands/reference.js +5 -1
- package/dist/commands/reference.js.map +1 -1
- package/dist/commands/reset-account.js +2 -11
- package/dist/commands/reset-account.js.map +1 -1
- package/dist/commands/tools.d.ts +1 -0
- package/dist/commands/tools.js +73 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/commands/trailer.js +18 -4
- package/dist/commands/trailer.js.map +1 -1
- package/dist/editor/asset-detail-panel.d.ts +8 -3
- package/dist/editor/asset-detail-panel.js +11 -1
- package/dist/editor/asset-detail-panel.js.map +1 -1
- package/dist/editor/hud-replay-page.js +317 -22
- package/dist/editor/hud-replay-page.js.map +1 -1
- package/dist/editor/shell-page.js +117 -7
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/forge/apply-modifications.js +70 -2
- package/dist/forge/apply-modifications.js.map +1 -1
- package/dist/forge/progress.d.ts +10 -0
- package/dist/forge/progress.js +7 -6
- package/dist/forge/progress.js.map +1 -1
- package/dist/forge/run-pipeline.d.ts +8 -0
- package/dist/forge/run-pipeline.js +37 -2
- package/dist/forge/run-pipeline.js.map +1 -1
- package/dist/forge/stream.d.ts +7 -1
- package/dist/forge/stream.js +5 -2
- package/dist/forge/stream.js.map +1 -1
- package/dist/forge/two-d-gate.d.ts +82 -0
- package/dist/forge/two-d-gate.js +232 -0
- package/dist/forge/two-d-gate.js.map +1 -0
- package/dist/generate/animation-from-video.d.ts +11 -3
- package/dist/generate/animation-from-video.js +12 -2
- package/dist/generate/animation-from-video.js.map +1 -1
- package/dist/generate/model.d.ts +11 -0
- package/dist/generate/model.js +41 -1
- package/dist/generate/model.js.map +1 -1
- package/dist/generate/prop.d.ts +9 -0
- package/dist/generate/prop.js +59 -9
- package/dist/generate/prop.js.map +1 -1
- package/dist/generate/video-review.d.ts +28 -0
- package/dist/generate/video-review.js +120 -0
- package/dist/generate/video-review.js.map +1 -0
- package/dist/project/art-style.d.ts +34 -0
- package/dist/project/art-style.js +63 -0
- package/dist/project/art-style.js.map +1 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +36 -0
- package/dist/prompt.js.map +1 -0
- package/dist/publish/bundle.d.ts +12 -0
- package/dist/publish/bundle.js +32 -0
- package/dist/publish/bundle.js.map +1 -1
- package/dist/render/qr-png.d.ts +6 -3
- package/dist/render/qr-png.js +8 -8
- package/dist/render/qr-png.js.map +1 -1
- package/dist/scaffold/blender-level-skill-files.d.ts +26 -0
- package/dist/scaffold/blender-level-skill-files.js +570 -0
- package/dist/scaffold/blender-level-skill-files.js.map +1 -0
- package/dist/scaffold/claude-settings.js +2 -0
- package/dist/scaffold/claude-settings.js.map +1 -1
- package/dist/scaffold/project-files.d.ts +2 -1
- package/dist/scaffold/project-files.js +125 -42
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.d.ts +9 -0
- package/dist/scaffold/project.js +29 -3
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/reference-guidance.d.ts +1 -1
- package/dist/scaffold/reference-guidance.js +2 -1
- package/dist/scaffold/reference-guidance.js.map +1 -1
- package/dist/scaffold/skill-files.js +3 -1
- package/dist/scaffold/skill-files.js.map +1 -1
- package/dist/scaffold/unity-skill-files.js +1 -2
- package/dist/scaffold/unity-skill-files.js.map +1 -1
- package/dist/tools/bundles.d.ts +21 -0
- package/dist/tools/bundles.js +67 -0
- package/dist/tools/bundles.js.map +1 -0
- package/dist/trailer/ffmpeg.d.ts +10 -0
- package/dist/trailer/ffmpeg.js +51 -6
- package/dist/trailer/ffmpeg.js.map +1 -1
- package/dist/trailer/hud-raster.d.ts +12 -12
- package/dist/trailer/hud-raster.js +85 -32
- package/dist/trailer/hud-raster.js.map +1 -1
- package/dist/trailer/hud-state.d.ts +75 -4
- package/dist/trailer/hud-state.js +132 -17
- package/dist/trailer/hud-state.js.map +1 -1
- package/dist/trailer/io.d.ts +2 -0
- package/dist/trailer/io.js +6 -1
- package/dist/trailer/io.js.map +1 -1
- package/dist/trailer/link.d.ts +1 -0
- package/dist/trailer/link.js +15 -0
- package/dist/trailer/link.js.map +1 -0
- package/dist/trailer/make.d.ts +2 -0
- package/dist/trailer/make.js +1 -0
- package/dist/trailer/make.js.map +1 -1
- package/dist/trailer/merge.js +32 -11
- package/dist/trailer/merge.js.map +1 -1
- package/dist/trailer/music-beats.js +4 -39
- package/dist/trailer/music-beats.js.map +1 -1
- package/dist/trailer/render.d.ts +2 -0
- package/dist/trailer/render.js +94 -48
- package/dist/trailer/render.js.map +1 -1
- package/dist/trailer/timeline-types.d.ts +19 -0
- package/dist/trailer/timeline-types.js +42 -2
- package/dist/trailer/timeline-types.js.map +1 -1
- package/dist/trailer/title-raster.js +2 -1
- package/dist/trailer/title-raster.js.map +1 -1
- package/dist/trailer/titles.js +6 -1
- package/dist/trailer/titles.js.map +1 -1
- package/dist/trailer/video-chain.d.ts +96 -8
- package/dist/trailer/video-chain.js +183 -0
- package/dist/trailer/video-chain.js.map +1 -1
- package/dist/verify/browser.js +4 -1
- package/dist/verify/browser.js.map +1 -1
- package/package.json +4 -4
|
@@ -9,7 +9,8 @@ import { CliError, errnoCode } from '../errors.js';
|
|
|
9
9
|
import { parseNumberFlag } from './number-flag.js';
|
|
10
10
|
import { createOutput } from '../output.js';
|
|
11
11
|
import { commandRunDeps, decoratedError } from './run-deps.js';
|
|
12
|
-
import { loadProjectContext } from '../project/context.js';
|
|
12
|
+
import { findProjectRootOrNull, loadProjectContext } from '../project/context.js';
|
|
13
|
+
import { readArtStyleDeclaration, resolveArtStyle } from '../project/art-style.js';
|
|
13
14
|
import { withJobRecord } from '../project/jobs.js';
|
|
14
15
|
import { describeAppliedReference, NO_REFERENCE_FLAG, REFERENCE_FILE_FLAG, REFERENCE_IMAGE_FLAG, } from '../project/reference.js';
|
|
15
16
|
import { resolveReferenceForCall } from '../project/reference-file.js';
|
|
@@ -26,8 +27,11 @@ import { downloadMusicFile } from '../generate/music.js';
|
|
|
26
27
|
import { MUSIC_MAX_SECONDS, MUSIC_MIN_SECONDS, VIDEO_MAX_SECONDS, VIDEO_MIN_SECONDS } from '@bitmagic/asset-core';
|
|
27
28
|
import { generateAndInstallAnimationFromVideo, isLocomotionState, isVideoToMotionModel, } from '../generate/animation-from-video.js';
|
|
28
29
|
import { generateAndInstallVideo, IMAGE_EXTENSIONS, } from '../generate/video.js';
|
|
30
|
+
import { reviewGeneratedClip } from '../generate/video-review.js';
|
|
31
|
+
import { askOnStdin, isAffirmative, isInteractive } from '../prompt.js';
|
|
32
|
+
import { renderInlineImage } from '../render/inline-image.js';
|
|
29
33
|
import { VIDEO_EXTENSIONS } from '../assets/file-kind.js';
|
|
30
|
-
import { LOCOMOTION_STATES, VIDEO_TO_MOTION_MODELS } from '@bitmagic/asset-core';
|
|
34
|
+
import { LOCOMOTION_STATES, VIDEO_TO_MOTION_MODELS, MOTION_SOURCE_DEFAULT_SECONDS, MOTION_SOURCE_MIN_SECONDS, buildMotionSourcePrompt, } from '@bitmagic/asset-core';
|
|
31
35
|
import { classifyAssetMaterialsQuietly } from '../assets/materials.js';
|
|
32
36
|
import { DEFAULT_MAX_VOXEL_SIZE, DEFAULT_MIN_VOXEL_SIZE, SOLID_FILL_MAX_HEIGHT_M } from './assets.js';
|
|
33
37
|
import { creatorPromptField, ORIGINAL_PROMPT_FLAG, withParent } from '../telemetry/creator-prompt.js';
|
|
@@ -102,6 +106,18 @@ export function buildImageBody(args) {
|
|
|
102
106
|
referenceImageUrl: args.referenceImageUrl,
|
|
103
107
|
});
|
|
104
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The character default a project's art style implies: a low-poly game keeps the textured rig.
|
|
111
|
+
* Read from the cwd's project like every other generate command; outside a project the answer
|
|
112
|
+
* is "declares nothing", so the generator's own default (voxelize) stands and `loadProjectContext`
|
|
113
|
+
* reports the missing project on the very next line.
|
|
114
|
+
*/
|
|
115
|
+
function defaultVoxelizeForProject() {
|
|
116
|
+
const root = findProjectRootOrNull();
|
|
117
|
+
if (root === null)
|
|
118
|
+
return undefined;
|
|
119
|
+
return readArtStyleDeclaration(root).artStyle === 'low-poly' ? false : undefined;
|
|
120
|
+
}
|
|
105
121
|
export function buildCharacterBody(args) {
|
|
106
122
|
return withoutAbsentFlags({
|
|
107
123
|
name: args.name,
|
|
@@ -109,6 +125,8 @@ export function buildCharacterBody(args) {
|
|
|
109
125
|
applyToPlayer: args.applyToPlayer,
|
|
110
126
|
headScale: parseNumberFlag('head-scale', args.headScale, { min: 50, max: 300 }),
|
|
111
127
|
useLibrary: args.library,
|
|
128
|
+
voxelize: args.voxelize,
|
|
129
|
+
meshModel: args.meshModel,
|
|
112
130
|
});
|
|
113
131
|
}
|
|
114
132
|
// ── animation ───────────────────────────────────────────────────────────────
|
|
@@ -253,6 +271,44 @@ export function buildAnimationBody(args, readSpec) {
|
|
|
253
271
|
spec,
|
|
254
272
|
});
|
|
255
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* `--video-prompt` is `--video` with the clip generated instead of filmed, so it
|
|
276
|
+
* shares every downstream flag and refuses the same combinations. The extra
|
|
277
|
+
* refusal is the duration floor: `generate video` will happily make a 1 s clip,
|
|
278
|
+
* and Uthana will then reject it, which would bill the creator for footage that
|
|
279
|
+
* never had a chance.
|
|
280
|
+
*/
|
|
281
|
+
export function buildAnimationFromPromptRun(args) {
|
|
282
|
+
if (args.spec !== undefined || args.family !== undefined) {
|
|
283
|
+
throw new CliError('--video-prompt cannot be combined with --spec or --family: a captured clip is not authored from a spec.');
|
|
284
|
+
}
|
|
285
|
+
if (args.video !== undefined) {
|
|
286
|
+
throw new CliError('--video and --video-prompt cannot be combined: pass a clip you have, or describe one to generate.');
|
|
287
|
+
}
|
|
288
|
+
if (args.videoPrompt.trim() === '') {
|
|
289
|
+
throw new CliError('--video-prompt needs a description of the motion, e.g. "a person waving both arms overhead".');
|
|
290
|
+
}
|
|
291
|
+
const durationSeconds = args.videoDuration ?? MOTION_SOURCE_DEFAULT_SECONDS;
|
|
292
|
+
if (!Number.isFinite(durationSeconds)
|
|
293
|
+
|| durationSeconds < MOTION_SOURCE_MIN_SECONDS || durationSeconds > VIDEO_MAX_SECONDS) {
|
|
294
|
+
throw new CliError(`--video-duration must be ${MOTION_SOURCE_MIN_SECONDS}-${VIDEO_MAX_SECONDS} seconds; `
|
|
295
|
+
+ `got ${JSON.stringify(String(args.videoDuration))}. Video-to-motion needs at least `
|
|
296
|
+
+ `${MOTION_SOURCE_MIN_SECONDS}s of footage to read.`);
|
|
297
|
+
}
|
|
298
|
+
// The clip half is validated by the same rules --video uses. No videoPath and
|
|
299
|
+
// no videoUrl yet: the first leg fills videoUrl in once it has produced one.
|
|
300
|
+
return { description: args.videoPrompt.trim(), durationSeconds, run: buildClipFlags(args) };
|
|
301
|
+
}
|
|
302
|
+
/** `--video` takes either a local file or an already-public clip URL. */
|
|
303
|
+
export function isHttpUrl(value) {
|
|
304
|
+
try {
|
|
305
|
+
const { protocol } = new URL(value);
|
|
306
|
+
return protocol === 'http:' || protocol === 'https:';
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
256
312
|
/**
|
|
257
313
|
* `--video` is a different generator from the authored spec: the clip comes from a person, not
|
|
258
314
|
* a parameter file, so the two flag sets cannot be combined and the spec flags are refused here
|
|
@@ -262,6 +318,20 @@ export function buildAnimationVideoRun(args) {
|
|
|
262
318
|
if (args.spec !== undefined || args.family !== undefined) {
|
|
263
319
|
throw new CliError('--video cannot be combined with --spec or --family: a video clip is not authored from a spec.');
|
|
264
320
|
}
|
|
321
|
+
if (args.videoPrompt !== undefined) {
|
|
322
|
+
throw new CliError('--video and --video-prompt cannot be combined: pass a clip you have, or describe one to generate.');
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
// A URL is handed straight to the job; only a local file needs uploading.
|
|
326
|
+
...(isHttpUrl(args.video) ? { videoUrl: args.video } : { videoPath: args.video }),
|
|
327
|
+
...buildClipFlags(args),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The flags that describe the CLIP rather than where it came from, shared by
|
|
332
|
+
* `--video` and `--video-prompt` so the two lanes cannot drift apart.
|
|
333
|
+
*/
|
|
334
|
+
function buildClipFlags(args) {
|
|
265
335
|
if (args.slot !== undefined && !isLocomotionState(args.slot)) {
|
|
266
336
|
throw new CliError(`--slot must be one of ${LOCOMOTION_STATES.join(', ')}; got ${JSON.stringify(args.slot)}.`);
|
|
267
337
|
}
|
|
@@ -283,7 +353,6 @@ export function buildAnimationVideoRun(args) {
|
|
|
283
353
|
}
|
|
284
354
|
}
|
|
285
355
|
return {
|
|
286
|
-
videoPath: args.video,
|
|
287
356
|
...(args.name !== undefined ? { name: args.name } : {}),
|
|
288
357
|
...(args.model !== undefined ? { model: args.model } : {}),
|
|
289
358
|
...(args.loop !== undefined ? { loop: args.loop } : {}),
|
|
@@ -294,7 +363,14 @@ export function buildAnimationVideoRun(args) {
|
|
|
294
363
|
...(args.assetId !== undefined ? { assetId: args.assetId } : {}),
|
|
295
364
|
};
|
|
296
365
|
}
|
|
297
|
-
const defaultDeps = (json) =>
|
|
366
|
+
const defaultDeps = (json) => ({
|
|
367
|
+
...commandRunDeps(json),
|
|
368
|
+
interactive: isInteractive,
|
|
369
|
+
ask: askOnStdin,
|
|
370
|
+
// Same seam the cover lane uses: draws only where the terminal speaks the protocol,
|
|
371
|
+
// and the sheet's path is printed either way.
|
|
372
|
+
renderImage: (bytes) => renderInlineImage(bytes),
|
|
373
|
+
});
|
|
298
374
|
/** Said once every long-form generation lands, so the creator knows not to reload by hand. */
|
|
299
375
|
const DEV_VIEW_RELOADS = 'A running `bitmagic dev` reloads the browser by itself to show it.';
|
|
300
376
|
/**
|
|
@@ -623,6 +699,37 @@ const MATERIALS_FLAG = {
|
|
|
623
699
|
description: 'Decide what the asset is made of after the bake, so metal shines and glass '
|
|
624
700
|
+ 'reflects. Pass --no-materials to leave it plain; `bitmagic assets materials` edits it later.',
|
|
625
701
|
};
|
|
702
|
+
/**
|
|
703
|
+
* The mesh-KEEPING pair, `generate prop|model`'s answer to `assets add --keep-glb` / `--voxelize`.
|
|
704
|
+
* Neither is needed in the common case: a low-poly project (game.json `artStyle`) keeps, a voxel
|
|
705
|
+
* project bakes. They exist for the one prop that goes the other way.
|
|
706
|
+
*/
|
|
707
|
+
const KEEP_GLB_FLAG = {
|
|
708
|
+
type: 'boolean',
|
|
709
|
+
description: 'Keep the generated mesh as a polygon-mesh asset instead of voxelizing it '
|
|
710
|
+
+ '(the default when game.json artStyle is low-poly). Implies --mesh; no browser is needed.',
|
|
711
|
+
};
|
|
712
|
+
const VOXELIZE_FLAG = {
|
|
713
|
+
type: 'boolean',
|
|
714
|
+
description: 'Voxelize even in a low-poly project.',
|
|
715
|
+
};
|
|
716
|
+
/**
|
|
717
|
+
* Whether a generated mesh is kept: the flags when given, else the project's art style. Says so
|
|
718
|
+
* only when the style decided, so a voxel project's transcript is unchanged.
|
|
719
|
+
*/
|
|
720
|
+
function resolveKeepGlb(root, options, log) {
|
|
721
|
+
if (options.keepGlb === true && options.voxelize === true) {
|
|
722
|
+
throw new CliError('Pass either --keep-glb or --voxelize, not both. world.json is unchanged.');
|
|
723
|
+
}
|
|
724
|
+
if (options.keepGlb === true)
|
|
725
|
+
return true;
|
|
726
|
+
if (options.voxelize === true)
|
|
727
|
+
return false;
|
|
728
|
+
if (resolveArtStyle(root, log) !== 'low-poly')
|
|
729
|
+
return false;
|
|
730
|
+
log('Keeping the mesh: game.json artStyle is low-poly (pass --voxelize to bake it instead).');
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
626
733
|
/** The voxel path's `--glb-url`: a paid-for master to bake, instead of buying another. */
|
|
627
734
|
const MASTER_URL_FLAG = {
|
|
628
735
|
type: 'string',
|
|
@@ -646,9 +753,10 @@ function classifyGeneratedAsset(input) {
|
|
|
646
753
|
* or an asset with no `fitBox` costs nothing.
|
|
647
754
|
*/
|
|
648
755
|
export async function runPropGeneration(options) {
|
|
649
|
-
assertVoxelGridAllowed(options.voxelGrid, options.mesh === true);
|
|
756
|
+
assertVoxelGridAllowed(options.voxelGrid, options.mesh === true || options.keepGlb === true);
|
|
650
757
|
assertOneRetrySource(options.glbUrl, options.masterUrl);
|
|
651
758
|
const { output, runDeps, context, environment, token } = await resolveRun(options.json, options.deps);
|
|
759
|
+
const keepGlb = resolveKeepGlb(context.root, options, runDeps.log);
|
|
652
760
|
const result = await withJobRecord({
|
|
653
761
|
root: context.root,
|
|
654
762
|
kind: 'prop',
|
|
@@ -670,6 +778,8 @@ export async function runPropGeneration(options) {
|
|
|
670
778
|
...(options.masterUrl ? { masterUrl: options.masterUrl } : {}),
|
|
671
779
|
...(options.mesh === true ? { mesh: true } : {}),
|
|
672
780
|
...(options.voxelGrid !== undefined ? { voxelGrid: options.voxelGrid } : {}),
|
|
781
|
+
...(keepGlb ? { keepGlb: true } : {}),
|
|
782
|
+
...(runDeps.keepGlbUpload !== undefined ? { keepGlbUpload: runDeps.keepGlbUpload } : {}),
|
|
673
783
|
fetchImpl: runDeps.fetch,
|
|
674
784
|
log,
|
|
675
785
|
});
|
|
@@ -680,12 +790,13 @@ export async function runPropGeneration(options) {
|
|
|
680
790
|
});
|
|
681
791
|
runDeps.log(`Upgraded "${result.assetName}" — ${result.instanceCount} placed instance`
|
|
682
792
|
+ `${result.instanceCount === 1 ? '' : 's'} now use the generated version.`);
|
|
683
|
-
|
|
793
|
+
// Materials name what each colour group of a VOXEL asset is made of; a kept mesh has its own.
|
|
794
|
+
const materials = options.materials === false || keepGlb
|
|
684
795
|
? null
|
|
685
796
|
: await classifyGeneratedAsset({ context, environment, token, assetId: result.assetId, runDeps });
|
|
686
797
|
// `result` already carries glbUrl, so a retry after a later failure can find the paid-for mesh
|
|
687
798
|
// in the JSON document rather than only in the error text.
|
|
688
|
-
output.result({ ok: true, kind: 'prop', ...result, ...(materials === null ? {} : { materials }) });
|
|
799
|
+
output.result({ ok: true, kind: 'prop', ...result, ...(keepGlb ? { keptMesh: true } : {}), ...(materials === null ? {} : { materials }) });
|
|
689
800
|
runDeps.log(DEV_VIEW_RELOADS);
|
|
690
801
|
return result;
|
|
691
802
|
}
|
|
@@ -699,6 +810,14 @@ export async function runPropGeneration(options) {
|
|
|
699
810
|
* prompt nor a preset, which is refused here before anything is spent.
|
|
700
811
|
*/
|
|
701
812
|
export async function runVehicleGeneration(options) {
|
|
813
|
+
{
|
|
814
|
+
// A vehicle is voxel-native (its builder and finishes assume six face normals), so a low-poly
|
|
815
|
+
// project gets one anyway — said once, so the mismatch is a known choice rather than a surprise.
|
|
816
|
+
const root = findProjectRootOrNull();
|
|
817
|
+
if (root !== null && resolveArtStyle(root) === 'low-poly') {
|
|
818
|
+
process.stderr.write('note: vehicles are always voxelized; this low-poly project (game.json artStyle) will get a voxel car.\n');
|
|
819
|
+
}
|
|
820
|
+
}
|
|
702
821
|
if (options.prompt === undefined && options.presetName === undefined && options.glbUrl === undefined) {
|
|
703
822
|
throw new CliError('Provide --prompt to design a vehicle, or --preset to build a standard one. '
|
|
704
823
|
+ 'Run `bitmagic generate vehicle --preset ?` to see the preset names.');
|
|
@@ -759,9 +878,10 @@ export async function runVehicleGeneration(options) {
|
|
|
759
878
|
* has no instances yet and scales to `--height` (or the mesh's own size) instead.
|
|
760
879
|
*/
|
|
761
880
|
export async function runModelGeneration(options) {
|
|
762
|
-
assertVoxelGridAllowed(options.voxelGrid, options.mesh === true);
|
|
881
|
+
assertVoxelGridAllowed(options.voxelGrid, options.mesh === true || options.keepGlb === true);
|
|
763
882
|
assertOneRetrySource(options.glbUrl, options.masterUrl);
|
|
764
883
|
const { output, runDeps, context, environment, token } = await resolveRun(options.json, options.deps);
|
|
884
|
+
const keepGlb = resolveKeepGlb(context.root, options, runDeps.log);
|
|
765
885
|
const result = await withJobRecord({
|
|
766
886
|
root: context.root,
|
|
767
887
|
kind: 'model',
|
|
@@ -790,6 +910,8 @@ export async function runModelGeneration(options) {
|
|
|
790
910
|
},
|
|
791
911
|
fetchImpl: runDeps.fetch,
|
|
792
912
|
...(runDeps.voxelize !== undefined ? { voxelize: runDeps.voxelize } : {}),
|
|
913
|
+
...(keepGlb ? { keepGlb: true } : {}),
|
|
914
|
+
...(runDeps.keepGlbUpload !== undefined ? { keepGlbUpload: runDeps.keepGlbUpload } : {}),
|
|
793
915
|
log,
|
|
794
916
|
});
|
|
795
917
|
}
|
|
@@ -798,9 +920,11 @@ export async function runModelGeneration(options) {
|
|
|
798
920
|
}
|
|
799
921
|
});
|
|
800
922
|
runDeps.log(`Added "${result.assetName}" (id ${result.assetId})`
|
|
801
|
-
+ `${result.voxelCount !== undefined ? `, ${result.voxelCount} voxels` : ''}
|
|
923
|
+
+ `${result.voxelCount !== undefined ? `, ${result.voxelCount} voxels` : ''}`
|
|
924
|
+
+ `${result.keptMesh === true ? ', kept as a polygon mesh' : ''}. Place it from game `
|
|
802
925
|
+ 'code, or drag it in from the dev view\'s Add-object palette.');
|
|
803
|
-
|
|
926
|
+
// Materials name what each colour group of a VOXEL asset is made of; a kept mesh has its own.
|
|
927
|
+
const materials = options.materials === false || keepGlb
|
|
804
928
|
? null
|
|
805
929
|
: await classifyGeneratedAsset({ context, environment, token, assetId: result.assetId, runDeps });
|
|
806
930
|
// `result` carries whichever source was paid for — glbUrl or masterUrl — so a retry after a
|
|
@@ -1100,6 +1224,15 @@ const characterCommand = defineCommand({
|
|
|
1100
1224
|
type: 'boolean',
|
|
1101
1225
|
negativeDescription: 'Always make a new character; do not use a ready-made one from the library.',
|
|
1102
1226
|
},
|
|
1227
|
+
voxelize: {
|
|
1228
|
+
type: 'boolean',
|
|
1229
|
+
description: 'Voxelize the rigged body. Omit to follow game.json artStyle: voxel games voxelize, low-poly games keep the textured rig.',
|
|
1230
|
+
negativeDescription: 'Keep the textured low-poly rig instead of voxelizing it.',
|
|
1231
|
+
},
|
|
1232
|
+
'mesh-model': {
|
|
1233
|
+
type: 'string',
|
|
1234
|
+
description: "Image-to-mesh model. Low-poly characters already default to 'runware-meshy-6' (~$0.80, ~6 min); pass 'unified-trellis' to put one back on the cheap lane. Voxel characters ignore the default and use the Forger's.",
|
|
1235
|
+
},
|
|
1103
1236
|
'original-prompt': ORIGINAL_PROMPT_FLAG,
|
|
1104
1237
|
json: JSON_FLAG,
|
|
1105
1238
|
},
|
|
@@ -1110,6 +1243,8 @@ const characterCommand = defineCommand({
|
|
|
1110
1243
|
applyToPlayer: args['apply-to-player'],
|
|
1111
1244
|
headScale: args['head-scale'],
|
|
1112
1245
|
library: args.library,
|
|
1246
|
+
voxelize: args.voxelize ?? defaultVoxelizeForProject(),
|
|
1247
|
+
meshModel: args['mesh-model'],
|
|
1113
1248
|
});
|
|
1114
1249
|
await runAssetGeneration('character', 'character', withParent(body), args.json === true);
|
|
1115
1250
|
},
|
|
@@ -1164,25 +1299,53 @@ const animationCommand = defineCommand({
|
|
|
1164
1299
|
video: {
|
|
1165
1300
|
type: 'string',
|
|
1166
1301
|
description: `Make the clip from a video of one person instead of a spec (Uthana video-to-motion, costs sparks). `
|
|
1167
|
-
+
|
|
1302
|
+
+ `A local ${VIDEO_EXTENSIONS.map((e) => `.${e}`).join('/')} file, or an http(s) URL of one already uploaded. `
|
|
1303
|
+
+ `2-60 s, full body in frame. Cannot be combined with --spec/--family/--video-prompt.`,
|
|
1304
|
+
},
|
|
1305
|
+
'video-prompt': {
|
|
1306
|
+
type: 'string',
|
|
1307
|
+
description: 'Describe a motion and have the source clip generated instead of filmed '
|
|
1308
|
+
+ '(costs sparks for both the clip and the motion). The clip is framed automatically for '
|
|
1309
|
+
+ 'capture: one person, whole body in shot, static camera. Cannot be combined with '
|
|
1310
|
+
+ '--video/--spec/--family.',
|
|
1311
|
+
},
|
|
1312
|
+
'contact-sheet': {
|
|
1313
|
+
type: 'boolean',
|
|
1314
|
+
default: true,
|
|
1315
|
+
description: 'Grab 8 frames of the generated source clip into a PNG under .bitmagic/videos/ '
|
|
1316
|
+
+ 'so it can be reviewed before the capture leg spends (the default; with --video-prompt). '
|
|
1317
|
+
+ 'Pass --no-contact-sheet to skip the download; needs ffmpeg.',
|
|
1318
|
+
},
|
|
1319
|
+
yes: {
|
|
1320
|
+
type: 'boolean',
|
|
1321
|
+
description: 'Skip the clip review with --video-prompt and capture motion from whatever the '
|
|
1322
|
+
+ 'first leg produced. The review only happens at a terminal anyway, so agents and CI are '
|
|
1323
|
+
+ 'unaffected either way.',
|
|
1324
|
+
},
|
|
1325
|
+
'video-duration': {
|
|
1326
|
+
type: 'string',
|
|
1327
|
+
description: `Length of the generated source clip in seconds, ${MOTION_SOURCE_MIN_SECONDS}-${VIDEO_MAX_SECONDS} `
|
|
1328
|
+
+ `(default ${MOTION_SOURCE_DEFAULT_SECONDS}); with --video-prompt. Billed per second. Not --duration, which is `
|
|
1329
|
+
+ 'the authored-spec clip length.',
|
|
1168
1330
|
},
|
|
1169
1331
|
model: {
|
|
1170
1332
|
type: 'string',
|
|
1171
|
-
description: `Video-to-motion model (with --video): ${VIDEO_TO_MOTION_MODELS.join(' | ')}.`,
|
|
1333
|
+
description: `Video-to-motion model (with --video or --video-prompt): ${VIDEO_TO_MOTION_MODELS.join(' | ')}.`,
|
|
1172
1334
|
},
|
|
1173
1335
|
loop: {
|
|
1174
1336
|
type: 'boolean',
|
|
1175
|
-
description: 'Mark the clip as looping (with --video). Idle, walk and run clips should loop.',
|
|
1337
|
+
description: 'Mark the clip as looping (with --video/--video-prompt). Idle, walk and run clips should loop.',
|
|
1176
1338
|
},
|
|
1177
1339
|
slot: {
|
|
1178
1340
|
type: 'string',
|
|
1179
|
-
description: `Pin the clip to a locomotion state for the player and every NPC (with --video): ${LOCOMOTION_STATES.join(' | ')}. `
|
|
1341
|
+
description: `Pin the clip to a locomotion state for the player and every NPC (with --video/--video-prompt): ${LOCOMOTION_STATES.join(' | ')}. `
|
|
1180
1342
|
+ 'Omit for a one-shot clip played with playCustomAnimation().',
|
|
1181
1343
|
},
|
|
1182
1344
|
'auto-trim': {
|
|
1183
1345
|
type: 'boolean',
|
|
1184
1346
|
description: 'Cut "walking to the camera" frames off both ends of the video before the motion is made (with --video). '
|
|
1185
|
-
+ 'Useful for clips recorded webcam-style; the dev-view recorder instead confirms an explicit window.'
|
|
1347
|
+
+ 'Useful for clips recorded webcam-style; the dev-view recorder instead confirms an explicit window. '
|
|
1348
|
+
+ 'A generated --video-prompt clip has no such frames, so it rarely needs this.',
|
|
1186
1349
|
},
|
|
1187
1350
|
'trim-start': {
|
|
1188
1351
|
type: 'string',
|
|
@@ -1197,6 +1360,25 @@ const animationCommand = defineCommand({
|
|
|
1197
1360
|
json: JSON_FLAG,
|
|
1198
1361
|
},
|
|
1199
1362
|
async run({ args }) {
|
|
1363
|
+
if (args['video-prompt'] !== undefined) {
|
|
1364
|
+
const run = buildAnimationFromPromptRun({
|
|
1365
|
+
videoPrompt: args['video-prompt'],
|
|
1366
|
+
videoDuration: args['video-duration'] !== undefined ? Number(args['video-duration']) : undefined,
|
|
1367
|
+
video: args.video,
|
|
1368
|
+
name: args.name,
|
|
1369
|
+
model: args.model,
|
|
1370
|
+
loop: args.loop === true ? true : undefined,
|
|
1371
|
+
slot: args.slot,
|
|
1372
|
+
autoTrim: args['auto-trim'] === true ? true : undefined,
|
|
1373
|
+
trimStart: args['trim-start'] !== undefined ? Number(args['trim-start']) : undefined,
|
|
1374
|
+
trimEnd: args['trim-end'] !== undefined ? Number(args['trim-end']) : undefined,
|
|
1375
|
+
assetId: args['asset-id'],
|
|
1376
|
+
spec: args.spec,
|
|
1377
|
+
family: args.family,
|
|
1378
|
+
});
|
|
1379
|
+
await runAnimationFromPrompt(run, args.json === true, undefined, creatorPromptField(args).creatorPrompt, { yes: args.yes === true, contactSheet: args['contact-sheet'] !== false });
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1200
1382
|
if (args.video !== undefined) {
|
|
1201
1383
|
const run = buildAnimationVideoRun({
|
|
1202
1384
|
video: args.video,
|
|
@@ -1236,7 +1418,7 @@ const animationCommand = defineCommand({
|
|
|
1236
1418
|
*/
|
|
1237
1419
|
export async function runAnimationFromVideo(run, json, deps) {
|
|
1238
1420
|
const { output, runDeps, context, environment, token } = await resolveRun(json, deps);
|
|
1239
|
-
const label = run.name ?? path.basename(run.videoPath);
|
|
1421
|
+
const label = run.name ?? (run.videoPath !== undefined ? path.basename(run.videoPath) : 'Recorded motion');
|
|
1240
1422
|
const result = await withJobRecord({
|
|
1241
1423
|
root: context.root, kind: 'animation-video', label, log: runDeps.log,
|
|
1242
1424
|
}, async (log) => {
|
|
@@ -1272,6 +1454,162 @@ export async function runAnimationFromVideo(run, json, deps) {
|
|
|
1272
1454
|
});
|
|
1273
1455
|
runDeps.log(DEV_VIEW_RELOADS);
|
|
1274
1456
|
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Look at the generated clip before the capture leg spends on it.
|
|
1459
|
+
*
|
|
1460
|
+
* The clip costs a few sparks and the Uthana leg costs fifteen more, so the expensive half is the
|
|
1461
|
+
* one worth gating. What decides "is this usable" is visible in stills — is it one person, are they
|
|
1462
|
+
* whole and centred, did the model cut to a close-up halfway — so the contact sheet
|
|
1463
|
+
* `generate video` already writes is enough to judge it, and the clip is a real asset by now, so a
|
|
1464
|
+
* creator who wants to watch it move can do that in the dev view while this waits.
|
|
1465
|
+
*
|
|
1466
|
+
* Returns whether to go on. Everything except the question is best-effort: a missing ffmpeg or a
|
|
1467
|
+
* failed download costs a note, never the generation that has already been paid for.
|
|
1468
|
+
*/
|
|
1469
|
+
export async function confirmSourceClip(options) {
|
|
1470
|
+
const { deps } = options;
|
|
1471
|
+
const review = !options.contactSheet ? null : await reviewGeneratedClip({
|
|
1472
|
+
root: options.root,
|
|
1473
|
+
assetId: options.assetId,
|
|
1474
|
+
videoUrl: options.videoUrl,
|
|
1475
|
+
durationSeconds: options.durationSeconds,
|
|
1476
|
+
fetch: deps.fetch,
|
|
1477
|
+
});
|
|
1478
|
+
if (review !== null && review.sheetPath !== null) {
|
|
1479
|
+
deps.log(`Frames: ${review.sheetPath}`);
|
|
1480
|
+
// Additional, never instead of: the path is printed above whether or not the
|
|
1481
|
+
// terminal can draw, exactly as the cover lane does it.
|
|
1482
|
+
if (deps.renderImage !== undefined) {
|
|
1483
|
+
try {
|
|
1484
|
+
deps.renderImage(fs.readFileSync(review.sheetPath));
|
|
1485
|
+
}
|
|
1486
|
+
catch {
|
|
1487
|
+
// A sheet that cannot be re-read is not worth failing a paid generation over.
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
for (const note of review?.notes ?? [])
|
|
1492
|
+
deps.log(` ${note}`);
|
|
1493
|
+
deps.log(`The clip is already an asset — watch it play in the dev view (${options.assetId}).`);
|
|
1494
|
+
if (options.skipPrompt)
|
|
1495
|
+
return true;
|
|
1496
|
+
const ask = deps.ask ?? askOnStdin;
|
|
1497
|
+
return isAffirmative(await ask('Capture motion from this clip? [y/N] '));
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* The `--video-prompt` lane: text -> clip -> motion, the two existing job lanes run back to back.
|
|
1501
|
+
*
|
|
1502
|
+
* There is no chained endpoint behind this — asset-forger has one route that makes a video and
|
|
1503
|
+
* another that reads one, and this is the seam between them. Both legs bill under their own
|
|
1504
|
+
* existing key (`generate-video` per second, `generate-animation-from-video` flat), the way
|
|
1505
|
+
* design-vehicle prices its chained image sub-generations rather than minting a composite.
|
|
1506
|
+
*
|
|
1507
|
+
* Both asset rows are kept. The clip is a real `type: 'video'` asset the creator can play back
|
|
1508
|
+
* or reuse as a cutscene, and keeping it is also what makes a leg-2 failure recoverable: the
|
|
1509
|
+
* footage is already paid for, so the error hands back a `--video <url>` retry instead of asking
|
|
1510
|
+
* for the whole thing again.
|
|
1511
|
+
*/
|
|
1512
|
+
export async function runAnimationFromPrompt(prompt, json, deps, creatorPrompt, options = {}) {
|
|
1513
|
+
const { output, runDeps, context, environment, token } = await resolveRun(json, deps);
|
|
1514
|
+
const label = prompt.run.name ?? prompt.description;
|
|
1515
|
+
const video = await withJobRecord({
|
|
1516
|
+
root: context.root, kind: 'video', label: `${label} (motion source)`, log: runDeps.log,
|
|
1517
|
+
}, async (log) => {
|
|
1518
|
+
try {
|
|
1519
|
+
return await generateAndInstallVideo({
|
|
1520
|
+
context, environment, token,
|
|
1521
|
+
run: {
|
|
1522
|
+
prompt: buildMotionSourcePrompt(prompt.description),
|
|
1523
|
+
durationSeconds: prompt.durationSeconds,
|
|
1524
|
+
name: `${label} (motion source)`,
|
|
1525
|
+
...(creatorPrompt !== undefined ? { creatorPrompt } : {}),
|
|
1526
|
+
},
|
|
1527
|
+
deps: { fetch: runDeps.fetch, sleep: runDeps.sleep, log },
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
catch (error) {
|
|
1531
|
+
throw decoratedError(error, (message) => message);
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
runDeps.log(`Source clip generated (${video.durationSeconds.toFixed(1)}s): ${video.videoUrl}`);
|
|
1535
|
+
/*
|
|
1536
|
+
* Nobody to ask means go on, rather than refuse: this lane is run by agents and by CI, and
|
|
1537
|
+
* blocking there would hang on a stdin that never answers. `--json` is the declared agent
|
|
1538
|
+
* signal, and its stdout carries one document, so it is not a place to hold a conversation.
|
|
1539
|
+
*/
|
|
1540
|
+
const interactive = (runDeps.interactive ?? isInteractive)();
|
|
1541
|
+
const skipPrompt = options.yes === true || json || !interactive;
|
|
1542
|
+
const proceed = await confirmSourceClip({
|
|
1543
|
+
root: context.root,
|
|
1544
|
+
assetId: video.assetId,
|
|
1545
|
+
videoUrl: video.videoUrl,
|
|
1546
|
+
durationSeconds: video.durationSeconds,
|
|
1547
|
+
skipPrompt,
|
|
1548
|
+
contactSheet: options.contactSheet !== false,
|
|
1549
|
+
deps: runDeps,
|
|
1550
|
+
});
|
|
1551
|
+
if (!proceed) {
|
|
1552
|
+
// Declining is a decision, not a failure: the clip was made, billed and kept, so this
|
|
1553
|
+
// exits clean and says what it costs to change your mind.
|
|
1554
|
+
runDeps.log('Stopped before capturing motion — the video-to-motion charge was not made.');
|
|
1555
|
+
runDeps.log(`The clip is kept as asset "${video.assetId}". Capture from it later with:`);
|
|
1556
|
+
runDeps.log(` bitmagic generate animation --video ${video.videoUrl} --name ${JSON.stringify(label)}`);
|
|
1557
|
+
output.result({
|
|
1558
|
+
ok: true,
|
|
1559
|
+
kind: 'video',
|
|
1560
|
+
reviewed: 'declined',
|
|
1561
|
+
assetId: video.assetId,
|
|
1562
|
+
videoUrl: video.videoUrl,
|
|
1563
|
+
durationSeconds: video.durationSeconds,
|
|
1564
|
+
sparksCharged: video.sparksCharged,
|
|
1565
|
+
patchesApplied: video.patchesApplied,
|
|
1566
|
+
});
|
|
1567
|
+
runDeps.log(DEV_VIEW_RELOADS);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
const motion = await withJobRecord({
|
|
1571
|
+
root: context.root, kind: 'animation-video', label, log: runDeps.log,
|
|
1572
|
+
}, async (log) => {
|
|
1573
|
+
try {
|
|
1574
|
+
return await generateAndInstallAnimationFromVideo({
|
|
1575
|
+
context, environment, token,
|
|
1576
|
+
run: { ...prompt.run, videoUrl: video.videoUrl, name: label },
|
|
1577
|
+
deps: { fetch: runDeps.fetch, sleep: runDeps.sleep, log },
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
catch (error) {
|
|
1581
|
+
// The clip survived and is already an asset, so say what it costs to pick
|
|
1582
|
+
// up from here rather than letting the creator re-buy the footage.
|
|
1583
|
+
throw decoratedError(error, (message) => `${message}\n`
|
|
1584
|
+
+ `The source clip was kept as asset "${video.assetId}". Retry the motion without `
|
|
1585
|
+
+ `regenerating it:\n bitmagic generate animation --video ${video.videoUrl} --name ${JSON.stringify(label)}`);
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
const sparksCharged = video.sparksCharged + motion.sparksCharged;
|
|
1589
|
+
const patchesApplied = video.patchesApplied + motion.patchesApplied;
|
|
1590
|
+
runDeps.log(`Generated animation "${label}" from a generated clip (${motion.durationSeconds.toFixed(1)}s)${describeAutoTrim(motion)}: ${motion.animationUrl}`);
|
|
1591
|
+
runDeps.log(` - motionId ${motion.motionId}${motion.locomotionState ? ` — pinned to ${motion.locomotionState}` : ''}`);
|
|
1592
|
+
runDeps.log(` - source clip asset ${video.assetId}`);
|
|
1593
|
+
runDeps.log(`world.json updated (${patchesApplied} patch${patchesApplied === 1 ? '' : 'es'}).`);
|
|
1594
|
+
if (!motion.locomotionState) {
|
|
1595
|
+
runDeps.log(`Play it with playCustomAnimation("${motion.motionId}"), or re-run with --slot idle|walk|run to make it locomotion.`);
|
|
1596
|
+
}
|
|
1597
|
+
output.result({
|
|
1598
|
+
ok: true,
|
|
1599
|
+
kind: 'animation',
|
|
1600
|
+
motionId: motion.motionId,
|
|
1601
|
+
animationUrl: motion.animationUrl,
|
|
1602
|
+
durationSeconds: motion.durationSeconds,
|
|
1603
|
+
sourceVideoAssetId: video.assetId,
|
|
1604
|
+
sourceVideoUrl: video.videoUrl,
|
|
1605
|
+
...(motion.trimmedStartSeconds !== undefined ? { trimmedStartSeconds: motion.trimmedStartSeconds } : {}),
|
|
1606
|
+
...(motion.trimmedEndSeconds !== undefined ? { trimmedEndSeconds: motion.trimmedEndSeconds } : {}),
|
|
1607
|
+
...(motion.locomotionState ? { locomotionState: motion.locomotionState } : {}),
|
|
1608
|
+
sparksCharged,
|
|
1609
|
+
patchesApplied,
|
|
1610
|
+
});
|
|
1611
|
+
runDeps.log(DEV_VIEW_RELOADS);
|
|
1612
|
+
}
|
|
1275
1613
|
const videoCommand = defineCommand({
|
|
1276
1614
|
meta: {
|
|
1277
1615
|
name: 'video',
|
|
@@ -1304,6 +1642,12 @@ const videoCommand = defineCommand({
|
|
|
1304
1642
|
type: 'string',
|
|
1305
1643
|
description: 'Pre-minted asset_… id to store the clip under, so code already referencing it resolves.',
|
|
1306
1644
|
},
|
|
1645
|
+
'contact-sheet': {
|
|
1646
|
+
type: 'boolean',
|
|
1647
|
+
default: true,
|
|
1648
|
+
description: 'Grab 8 frames into a PNG under .bitmagic/videos/ so the clip can be reviewed (the default). '
|
|
1649
|
+
+ 'Pass --no-contact-sheet to skip the download; needs ffmpeg.',
|
|
1650
|
+
},
|
|
1307
1651
|
'original-prompt': ORIGINAL_PROMPT_FLAG,
|
|
1308
1652
|
json: JSON_FLAG,
|
|
1309
1653
|
},
|
|
@@ -1321,15 +1665,15 @@ const videoCommand = defineCommand({
|
|
|
1321
1665
|
...(args['asset-id'] !== undefined ? { assetId: args['asset-id'] } : {}),
|
|
1322
1666
|
...creatorPromptField(args),
|
|
1323
1667
|
};
|
|
1324
|
-
await runVideoGeneration(run, args.json === true);
|
|
1668
|
+
await runVideoGeneration(run, args.json === true, { contactSheet: args['contact-sheet'] !== false });
|
|
1325
1669
|
},
|
|
1326
1670
|
});
|
|
1327
1671
|
/**
|
|
1328
1672
|
* Job-shaped rather than streamed (see generate/video.ts), so it cannot share `runAssetGeneration`;
|
|
1329
1673
|
* it does share the job record, so the dev view shows it beside every other generation.
|
|
1330
1674
|
*/
|
|
1331
|
-
export async function runVideoGeneration(run, json,
|
|
1332
|
-
const { output, runDeps, context, environment, token } = await resolveRun(json, deps);
|
|
1675
|
+
export async function runVideoGeneration(run, json, options = {}) {
|
|
1676
|
+
const { output, runDeps, context, environment, token } = await resolveRun(json, options.deps);
|
|
1333
1677
|
const label = run.name ?? run.prompt;
|
|
1334
1678
|
const result = await withJobRecord({
|
|
1335
1679
|
root: context.root, kind: 'video', label, log: runDeps.log,
|
|
@@ -1349,7 +1693,32 @@ export async function runVideoGeneration(run, json, deps) {
|
|
|
1349
1693
|
: '';
|
|
1350
1694
|
runDeps.log(`Generated video "${label}" (${result.durationSeconds.toFixed(1)}s${shape}): ${result.videoUrl}`);
|
|
1351
1695
|
runDeps.log(`world.json updated (${result.patchesApplied} patch${result.patchesApplied === 1 ? '' : 'es'}).`);
|
|
1352
|
-
|
|
1696
|
+
const review = options.contactSheet === false
|
|
1697
|
+
? null
|
|
1698
|
+
: await reviewGeneratedClip({
|
|
1699
|
+
root: context.root,
|
|
1700
|
+
assetId: result.assetId,
|
|
1701
|
+
videoUrl: result.videoUrl,
|
|
1702
|
+
durationSeconds: result.durationSeconds,
|
|
1703
|
+
fetch: runDeps.fetch,
|
|
1704
|
+
});
|
|
1705
|
+
if (review !== null) {
|
|
1706
|
+
if (review.sheetPath !== null)
|
|
1707
|
+
runDeps.log(`Frames: ${review.sheetPath}`);
|
|
1708
|
+
// Silence is what decides whether the cutscene needs a music bed under it, and it is
|
|
1709
|
+
// invisible in the frames — so it is said in words either way, never left to be assumed.
|
|
1710
|
+
if (review.hasAudioTrack === false) {
|
|
1711
|
+
runDeps.log('The clip has NO audio track — play a music bed under it if the cutscene needs sound.');
|
|
1712
|
+
}
|
|
1713
|
+
else if (review.hasAudioTrack === true) {
|
|
1714
|
+
runDeps.log('The clip carries its own audio track.');
|
|
1715
|
+
}
|
|
1716
|
+
for (const note of review.notes)
|
|
1717
|
+
runDeps.log(` ${note}`);
|
|
1718
|
+
}
|
|
1719
|
+
runDeps.log(`Play it with engine.playVideo("${result.assetId}") — from the Play click or later, `
|
|
1720
|
+
+ 'never before: without a prior user gesture the browser blocks autoplay and the clip silently '
|
|
1721
|
+
+ 'does not run. See video-cutscenes.md.');
|
|
1353
1722
|
output.result({
|
|
1354
1723
|
ok: true,
|
|
1355
1724
|
kind: 'video',
|
|
@@ -1361,6 +1730,8 @@ export async function runVideoGeneration(run, json, deps) {
|
|
|
1361
1730
|
...(result.height !== undefined ? { height: result.height } : {}),
|
|
1362
1731
|
sparksCharged: result.sparksCharged,
|
|
1363
1732
|
patchesApplied: result.patchesApplied,
|
|
1733
|
+
...(review?.sheetPath != null ? { contactSheetPath: review.sheetPath } : {}),
|
|
1734
|
+
...(review?.hasAudioTrack != null ? { hasAudioTrack: review.hasAudioTrack } : {}),
|
|
1364
1735
|
});
|
|
1365
1736
|
runDeps.log(DEV_VIEW_RELOADS);
|
|
1366
1737
|
}
|
|
@@ -1386,6 +1757,8 @@ const propCommand = defineCommand({
|
|
|
1386
1757
|
},
|
|
1387
1758
|
'master-url': MASTER_URL_FLAG,
|
|
1388
1759
|
mesh: MESH_FLAG,
|
|
1760
|
+
'keep-glb': KEEP_GLB_FLAG,
|
|
1761
|
+
voxelize: VOXELIZE_FLAG,
|
|
1389
1762
|
'direct-voxels': DIRECT_VOXELS_FLAG,
|
|
1390
1763
|
'voxel-grid': VOXEL_GRID_FLAG,
|
|
1391
1764
|
materials: MATERIALS_FLAG,
|
|
@@ -1399,6 +1772,8 @@ const propCommand = defineCommand({
|
|
|
1399
1772
|
...(args['glb-url'] ? { glbUrl: args['glb-url'] } : {}),
|
|
1400
1773
|
...(args['master-url'] ? { masterUrl: args['master-url'] } : {}),
|
|
1401
1774
|
...(args.mesh === true ? { mesh: true } : {}),
|
|
1775
|
+
...(args['keep-glb'] === true ? { keepGlb: true } : {}),
|
|
1776
|
+
...(args.voxelize === true ? { voxelize: true } : {}),
|
|
1402
1777
|
...voxelGridField(args['voxel-grid']),
|
|
1403
1778
|
...(args.materials === false ? { materials: false } : {}),
|
|
1404
1779
|
json: args.json === true,
|
|
@@ -1475,7 +1850,7 @@ const modelCommand = defineCommand({
|
|
|
1475
1850
|
},
|
|
1476
1851
|
height: {
|
|
1477
1852
|
type: 'string',
|
|
1478
|
-
description: 'Scale the model to this height in metres. Omit to keep the generated mesh\'s own size.',
|
|
1853
|
+
description: 'Scale the model to this height in metres. Omit to keep the generated mesh\'s own size (2 m when the mesh is kept).',
|
|
1479
1854
|
},
|
|
1480
1855
|
hollow: {
|
|
1481
1856
|
type: 'boolean',
|
|
@@ -1491,6 +1866,8 @@ const modelCommand = defineCommand({
|
|
|
1491
1866
|
},
|
|
1492
1867
|
'master-url': MASTER_URL_FLAG,
|
|
1493
1868
|
mesh: MESH_FLAG,
|
|
1869
|
+
'keep-glb': KEEP_GLB_FLAG,
|
|
1870
|
+
voxelize: VOXELIZE_FLAG,
|
|
1494
1871
|
'direct-voxels': DIRECT_VOXELS_FLAG,
|
|
1495
1872
|
'voxel-grid': VOXEL_GRID_FLAG,
|
|
1496
1873
|
materials: MATERIALS_FLAG,
|
|
@@ -1523,6 +1900,8 @@ const modelCommand = defineCommand({
|
|
|
1523
1900
|
...(args['glb-url'] !== undefined ? { glbUrl: args['glb-url'] } : {}),
|
|
1524
1901
|
...(args['master-url'] !== undefined ? { masterUrl: args['master-url'] } : {}),
|
|
1525
1902
|
...(args.mesh === true ? { mesh: true } : {}),
|
|
1903
|
+
...(args['keep-glb'] === true ? { keepGlb: true } : {}),
|
|
1904
|
+
...(args.voxelize === true ? { voxelize: true } : {}),
|
|
1526
1905
|
...voxelGridField(args['voxel-grid']),
|
|
1527
1906
|
...(args.materials === false ? { materials: false } : {}),
|
|
1528
1907
|
json: args.json === true,
|