@bitmagic/cli 0.1.64-dev.0 → 0.1.64-dev.10
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.js +70 -11
- package/dist/commands/forge.js.map +1 -1
- package/dist/commands/generate.d.ts +93 -0
- package/dist/commands/generate.js +491 -18
- 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 +72 -0
- package/dist/forge/two-d-gate.js +198 -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/generate/video.d.ts +52 -0
- package/dist/generate/video.js +177 -0
- package/dist/generate/video.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/project/jobs.d.ts +1 -1
- package/dist/project/jobs.js.map +1 -1
- 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 +135 -43
- 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';
|
|
@@ -23,10 +24,14 @@ import { DEFAULT_MASTER_GRID, generateAndInstallProp } from '../generate/prop.js
|
|
|
23
24
|
import { generateAndInstallVehicle } from '../generate/vehicle.js';
|
|
24
25
|
import { generateAndInstallModel } from '../generate/model.js';
|
|
25
26
|
import { downloadMusicFile } from '../generate/music.js';
|
|
26
|
-
import { MUSIC_MAX_SECONDS, MUSIC_MIN_SECONDS } from '@bitmagic/asset-core';
|
|
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';
|
|
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';
|
|
28
33
|
import { VIDEO_EXTENSIONS } from '../assets/file-kind.js';
|
|
29
|
-
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';
|
|
30
35
|
import { classifyAssetMaterialsQuietly } from '../assets/materials.js';
|
|
31
36
|
import { DEFAULT_MAX_VOXEL_SIZE, DEFAULT_MIN_VOXEL_SIZE, SOLID_FILL_MAX_HEIGHT_M } from './assets.js';
|
|
32
37
|
import { creatorPromptField, ORIGINAL_PROMPT_FLAG, withParent } from '../telemetry/creator-prompt.js';
|
|
@@ -101,6 +106,18 @@ export function buildImageBody(args) {
|
|
|
101
106
|
referenceImageUrl: args.referenceImageUrl,
|
|
102
107
|
});
|
|
103
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
|
+
}
|
|
104
121
|
export function buildCharacterBody(args) {
|
|
105
122
|
return withoutAbsentFlags({
|
|
106
123
|
name: args.name,
|
|
@@ -108,6 +125,8 @@ export function buildCharacterBody(args) {
|
|
|
108
125
|
applyToPlayer: args.applyToPlayer,
|
|
109
126
|
headScale: parseNumberFlag('head-scale', args.headScale, { min: 50, max: 300 }),
|
|
110
127
|
useLibrary: args.library,
|
|
128
|
+
voxelize: args.voxelize,
|
|
129
|
+
meshModel: args.meshModel,
|
|
111
130
|
});
|
|
112
131
|
}
|
|
113
132
|
// ── animation ───────────────────────────────────────────────────────────────
|
|
@@ -252,6 +271,44 @@ export function buildAnimationBody(args, readSpec) {
|
|
|
252
271
|
spec,
|
|
253
272
|
});
|
|
254
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
|
+
}
|
|
255
312
|
/**
|
|
256
313
|
* `--video` is a different generator from the authored spec: the clip comes from a person, not
|
|
257
314
|
* a parameter file, so the two flag sets cannot be combined and the spec flags are refused here
|
|
@@ -261,6 +318,20 @@ export function buildAnimationVideoRun(args) {
|
|
|
261
318
|
if (args.spec !== undefined || args.family !== undefined) {
|
|
262
319
|
throw new CliError('--video cannot be combined with --spec or --family: a video clip is not authored from a spec.');
|
|
263
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) {
|
|
264
335
|
if (args.slot !== undefined && !isLocomotionState(args.slot)) {
|
|
265
336
|
throw new CliError(`--slot must be one of ${LOCOMOTION_STATES.join(', ')}; got ${JSON.stringify(args.slot)}.`);
|
|
266
337
|
}
|
|
@@ -282,7 +353,6 @@ export function buildAnimationVideoRun(args) {
|
|
|
282
353
|
}
|
|
283
354
|
}
|
|
284
355
|
return {
|
|
285
|
-
videoPath: args.video,
|
|
286
356
|
...(args.name !== undefined ? { name: args.name } : {}),
|
|
287
357
|
...(args.model !== undefined ? { model: args.model } : {}),
|
|
288
358
|
...(args.loop !== undefined ? { loop: args.loop } : {}),
|
|
@@ -293,7 +363,14 @@ export function buildAnimationVideoRun(args) {
|
|
|
293
363
|
...(args.assetId !== undefined ? { assetId: args.assetId } : {}),
|
|
294
364
|
};
|
|
295
365
|
}
|
|
296
|
-
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
|
+
});
|
|
297
374
|
/** Said once every long-form generation lands, so the creator knows not to reload by hand. */
|
|
298
375
|
const DEV_VIEW_RELOADS = 'A running `bitmagic dev` reloads the browser by itself to show it.';
|
|
299
376
|
/**
|
|
@@ -622,6 +699,37 @@ const MATERIALS_FLAG = {
|
|
|
622
699
|
description: 'Decide what the asset is made of after the bake, so metal shines and glass '
|
|
623
700
|
+ 'reflects. Pass --no-materials to leave it plain; `bitmagic assets materials` edits it later.',
|
|
624
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
|
+
}
|
|
625
733
|
/** The voxel path's `--glb-url`: a paid-for master to bake, instead of buying another. */
|
|
626
734
|
const MASTER_URL_FLAG = {
|
|
627
735
|
type: 'string',
|
|
@@ -645,9 +753,10 @@ function classifyGeneratedAsset(input) {
|
|
|
645
753
|
* or an asset with no `fitBox` costs nothing.
|
|
646
754
|
*/
|
|
647
755
|
export async function runPropGeneration(options) {
|
|
648
|
-
assertVoxelGridAllowed(options.voxelGrid, options.mesh === true);
|
|
756
|
+
assertVoxelGridAllowed(options.voxelGrid, options.mesh === true || options.keepGlb === true);
|
|
649
757
|
assertOneRetrySource(options.glbUrl, options.masterUrl);
|
|
650
758
|
const { output, runDeps, context, environment, token } = await resolveRun(options.json, options.deps);
|
|
759
|
+
const keepGlb = resolveKeepGlb(context.root, options, runDeps.log);
|
|
651
760
|
const result = await withJobRecord({
|
|
652
761
|
root: context.root,
|
|
653
762
|
kind: 'prop',
|
|
@@ -669,6 +778,8 @@ export async function runPropGeneration(options) {
|
|
|
669
778
|
...(options.masterUrl ? { masterUrl: options.masterUrl } : {}),
|
|
670
779
|
...(options.mesh === true ? { mesh: true } : {}),
|
|
671
780
|
...(options.voxelGrid !== undefined ? { voxelGrid: options.voxelGrid } : {}),
|
|
781
|
+
...(keepGlb ? { keepGlb: true } : {}),
|
|
782
|
+
...(runDeps.keepGlbUpload !== undefined ? { keepGlbUpload: runDeps.keepGlbUpload } : {}),
|
|
672
783
|
fetchImpl: runDeps.fetch,
|
|
673
784
|
log,
|
|
674
785
|
});
|
|
@@ -679,12 +790,13 @@ export async function runPropGeneration(options) {
|
|
|
679
790
|
});
|
|
680
791
|
runDeps.log(`Upgraded "${result.assetName}" — ${result.instanceCount} placed instance`
|
|
681
792
|
+ `${result.instanceCount === 1 ? '' : 's'} now use the generated version.`);
|
|
682
|
-
|
|
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
|
|
683
795
|
? null
|
|
684
796
|
: await classifyGeneratedAsset({ context, environment, token, assetId: result.assetId, runDeps });
|
|
685
797
|
// `result` already carries glbUrl, so a retry after a later failure can find the paid-for mesh
|
|
686
798
|
// in the JSON document rather than only in the error text.
|
|
687
|
-
output.result({ ok: true, kind: 'prop', ...result, ...(materials === null ? {} : { materials }) });
|
|
799
|
+
output.result({ ok: true, kind: 'prop', ...result, ...(keepGlb ? { keptMesh: true } : {}), ...(materials === null ? {} : { materials }) });
|
|
688
800
|
runDeps.log(DEV_VIEW_RELOADS);
|
|
689
801
|
return result;
|
|
690
802
|
}
|
|
@@ -698,6 +810,14 @@ export async function runPropGeneration(options) {
|
|
|
698
810
|
* prompt nor a preset, which is refused here before anything is spent.
|
|
699
811
|
*/
|
|
700
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
|
+
}
|
|
701
821
|
if (options.prompt === undefined && options.presetName === undefined && options.glbUrl === undefined) {
|
|
702
822
|
throw new CliError('Provide --prompt to design a vehicle, or --preset to build a standard one. '
|
|
703
823
|
+ 'Run `bitmagic generate vehicle --preset ?` to see the preset names.');
|
|
@@ -758,9 +878,10 @@ export async function runVehicleGeneration(options) {
|
|
|
758
878
|
* has no instances yet and scales to `--height` (or the mesh's own size) instead.
|
|
759
879
|
*/
|
|
760
880
|
export async function runModelGeneration(options) {
|
|
761
|
-
assertVoxelGridAllowed(options.voxelGrid, options.mesh === true);
|
|
881
|
+
assertVoxelGridAllowed(options.voxelGrid, options.mesh === true || options.keepGlb === true);
|
|
762
882
|
assertOneRetrySource(options.glbUrl, options.masterUrl);
|
|
763
883
|
const { output, runDeps, context, environment, token } = await resolveRun(options.json, options.deps);
|
|
884
|
+
const keepGlb = resolveKeepGlb(context.root, options, runDeps.log);
|
|
764
885
|
const result = await withJobRecord({
|
|
765
886
|
root: context.root,
|
|
766
887
|
kind: 'model',
|
|
@@ -789,6 +910,8 @@ export async function runModelGeneration(options) {
|
|
|
789
910
|
},
|
|
790
911
|
fetchImpl: runDeps.fetch,
|
|
791
912
|
...(runDeps.voxelize !== undefined ? { voxelize: runDeps.voxelize } : {}),
|
|
913
|
+
...(keepGlb ? { keepGlb: true } : {}),
|
|
914
|
+
...(runDeps.keepGlbUpload !== undefined ? { keepGlbUpload: runDeps.keepGlbUpload } : {}),
|
|
792
915
|
log,
|
|
793
916
|
});
|
|
794
917
|
}
|
|
@@ -797,9 +920,11 @@ export async function runModelGeneration(options) {
|
|
|
797
920
|
}
|
|
798
921
|
});
|
|
799
922
|
runDeps.log(`Added "${result.assetName}" (id ${result.assetId})`
|
|
800
|
-
+ `${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 `
|
|
801
925
|
+ 'code, or drag it in from the dev view\'s Add-object palette.');
|
|
802
|
-
|
|
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
|
|
803
928
|
? null
|
|
804
929
|
: await classifyGeneratedAsset({ context, environment, token, assetId: result.assetId, runDeps });
|
|
805
930
|
// `result` carries whichever source was paid for — glbUrl or masterUrl — so a retry after a
|
|
@@ -1099,6 +1224,15 @@ const characterCommand = defineCommand({
|
|
|
1099
1224
|
type: 'boolean',
|
|
1100
1225
|
negativeDescription: 'Always make a new character; do not use a ready-made one from the library.',
|
|
1101
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
|
+
},
|
|
1102
1236
|
'original-prompt': ORIGINAL_PROMPT_FLAG,
|
|
1103
1237
|
json: JSON_FLAG,
|
|
1104
1238
|
},
|
|
@@ -1109,6 +1243,8 @@ const characterCommand = defineCommand({
|
|
|
1109
1243
|
applyToPlayer: args['apply-to-player'],
|
|
1110
1244
|
headScale: args['head-scale'],
|
|
1111
1245
|
library: args.library,
|
|
1246
|
+
voxelize: args.voxelize ?? defaultVoxelizeForProject(),
|
|
1247
|
+
meshModel: args['mesh-model'],
|
|
1112
1248
|
});
|
|
1113
1249
|
await runAssetGeneration('character', 'character', withParent(body), args.json === true);
|
|
1114
1250
|
},
|
|
@@ -1163,25 +1299,53 @@ const animationCommand = defineCommand({
|
|
|
1163
1299
|
video: {
|
|
1164
1300
|
type: 'string',
|
|
1165
1301
|
description: `Make the clip from a video of one person instead of a spec (Uthana video-to-motion, costs sparks). `
|
|
1166
|
-
+
|
|
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.',
|
|
1167
1330
|
},
|
|
1168
1331
|
model: {
|
|
1169
1332
|
type: 'string',
|
|
1170
|
-
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(' | ')}.`,
|
|
1171
1334
|
},
|
|
1172
1335
|
loop: {
|
|
1173
1336
|
type: 'boolean',
|
|
1174
|
-
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.',
|
|
1175
1338
|
},
|
|
1176
1339
|
slot: {
|
|
1177
1340
|
type: 'string',
|
|
1178
|
-
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(' | ')}. `
|
|
1179
1342
|
+ 'Omit for a one-shot clip played with playCustomAnimation().',
|
|
1180
1343
|
},
|
|
1181
1344
|
'auto-trim': {
|
|
1182
1345
|
type: 'boolean',
|
|
1183
1346
|
description: 'Cut "walking to the camera" frames off both ends of the video before the motion is made (with --video). '
|
|
1184
|
-
+ '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.',
|
|
1185
1349
|
},
|
|
1186
1350
|
'trim-start': {
|
|
1187
1351
|
type: 'string',
|
|
@@ -1196,6 +1360,25 @@ const animationCommand = defineCommand({
|
|
|
1196
1360
|
json: JSON_FLAG,
|
|
1197
1361
|
},
|
|
1198
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
|
+
}
|
|
1199
1382
|
if (args.video !== undefined) {
|
|
1200
1383
|
const run = buildAnimationVideoRun({
|
|
1201
1384
|
video: args.video,
|
|
@@ -1235,7 +1418,7 @@ const animationCommand = defineCommand({
|
|
|
1235
1418
|
*/
|
|
1236
1419
|
export async function runAnimationFromVideo(run, json, deps) {
|
|
1237
1420
|
const { output, runDeps, context, environment, token } = await resolveRun(json, deps);
|
|
1238
|
-
const label = run.name ?? path.basename(run.videoPath);
|
|
1421
|
+
const label = run.name ?? (run.videoPath !== undefined ? path.basename(run.videoPath) : 'Recorded motion');
|
|
1239
1422
|
const result = await withJobRecord({
|
|
1240
1423
|
root: context.root, kind: 'animation-video', label, log: runDeps.log,
|
|
1241
1424
|
}, async (log) => {
|
|
@@ -1271,6 +1454,287 @@ export async function runAnimationFromVideo(run, json, deps) {
|
|
|
1271
1454
|
});
|
|
1272
1455
|
runDeps.log(DEV_VIEW_RELOADS);
|
|
1273
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
|
+
}
|
|
1613
|
+
const videoCommand = defineCommand({
|
|
1614
|
+
meta: {
|
|
1615
|
+
name: 'video',
|
|
1616
|
+
description: 'Generate a short video clip and add it to world.json assets, playable with engine.playVideo().',
|
|
1617
|
+
},
|
|
1618
|
+
args: {
|
|
1619
|
+
prompt: {
|
|
1620
|
+
type: 'string',
|
|
1621
|
+
required: true,
|
|
1622
|
+
description: 'What happens in the clip: subject, motion, camera, mood ("the castle gates swing open, dust drifting").',
|
|
1623
|
+
},
|
|
1624
|
+
name: {
|
|
1625
|
+
type: 'string',
|
|
1626
|
+
description: 'Asset name, and what engine.playVideo() is passed. Defaults to the prompt.',
|
|
1627
|
+
},
|
|
1628
|
+
duration: {
|
|
1629
|
+
type: 'string',
|
|
1630
|
+
description: `Clip length in seconds, ${VIDEO_MIN_SECONDS}-${VIDEO_MAX_SECONDS} (default 5). Billed per second.`,
|
|
1631
|
+
},
|
|
1632
|
+
image: {
|
|
1633
|
+
type: 'string',
|
|
1634
|
+
description: `First frame to animate (${IMAGE_EXTENSIONS.map((e) => `.${e}`).join(', ')}) — a cover or reference image. `
|
|
1635
|
+
+ 'Much slower than a prompt alone, and the clip takes its shape from the image, not the 16:9 default.',
|
|
1636
|
+
},
|
|
1637
|
+
seed: {
|
|
1638
|
+
type: 'string',
|
|
1639
|
+
description: 'Fixed seed, to re-roll the same clip.',
|
|
1640
|
+
},
|
|
1641
|
+
'asset-id': {
|
|
1642
|
+
type: 'string',
|
|
1643
|
+
description: 'Pre-minted asset_… id to store the clip under, so code already referencing it resolves.',
|
|
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
|
+
},
|
|
1651
|
+
'original-prompt': ORIGINAL_PROMPT_FLAG,
|
|
1652
|
+
json: JSON_FLAG,
|
|
1653
|
+
},
|
|
1654
|
+
async run({ args }) {
|
|
1655
|
+
const durationSeconds = parseNumberFlag('duration', args.duration, {
|
|
1656
|
+
min: VIDEO_MIN_SECONDS, max: VIDEO_MAX_SECONDS,
|
|
1657
|
+
});
|
|
1658
|
+
const seed = parseNumberFlag('seed', args.seed, { integer: true });
|
|
1659
|
+
const run = {
|
|
1660
|
+
prompt: args.prompt,
|
|
1661
|
+
...(args.name !== undefined ? { name: args.name } : {}),
|
|
1662
|
+
...(durationSeconds !== undefined ? { durationSeconds } : {}),
|
|
1663
|
+
...(args.image !== undefined ? { imagePath: args.image } : {}),
|
|
1664
|
+
...(seed !== undefined ? { seed } : {}),
|
|
1665
|
+
...(args['asset-id'] !== undefined ? { assetId: args['asset-id'] } : {}),
|
|
1666
|
+
...creatorPromptField(args),
|
|
1667
|
+
};
|
|
1668
|
+
await runVideoGeneration(run, args.json === true, { contactSheet: args['contact-sheet'] !== false });
|
|
1669
|
+
},
|
|
1670
|
+
});
|
|
1671
|
+
/**
|
|
1672
|
+
* Job-shaped rather than streamed (see generate/video.ts), so it cannot share `runAssetGeneration`;
|
|
1673
|
+
* it does share the job record, so the dev view shows it beside every other generation.
|
|
1674
|
+
*/
|
|
1675
|
+
export async function runVideoGeneration(run, json, options = {}) {
|
|
1676
|
+
const { output, runDeps, context, environment, token } = await resolveRun(json, options.deps);
|
|
1677
|
+
const label = run.name ?? run.prompt;
|
|
1678
|
+
const result = await withJobRecord({
|
|
1679
|
+
root: context.root, kind: 'video', label, log: runDeps.log,
|
|
1680
|
+
}, async (log) => {
|
|
1681
|
+
try {
|
|
1682
|
+
return await generateAndInstallVideo({
|
|
1683
|
+
context, environment, token, run,
|
|
1684
|
+
deps: { fetch: runDeps.fetch, sleep: runDeps.sleep, log },
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
catch (error) {
|
|
1688
|
+
throw decoratedError(error, (message) => message);
|
|
1689
|
+
}
|
|
1690
|
+
});
|
|
1691
|
+
const shape = result.width !== undefined && result.height !== undefined
|
|
1692
|
+
? `, ${result.width}x${result.height}`
|
|
1693
|
+
: '';
|
|
1694
|
+
runDeps.log(`Generated video "${label}" (${result.durationSeconds.toFixed(1)}s${shape}): ${result.videoUrl}`);
|
|
1695
|
+
runDeps.log(`world.json updated (${result.patchesApplied} patch${result.patchesApplied === 1 ? '' : 'es'}).`);
|
|
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.');
|
|
1722
|
+
output.result({
|
|
1723
|
+
ok: true,
|
|
1724
|
+
kind: 'video',
|
|
1725
|
+
assetId: result.assetId,
|
|
1726
|
+
videoUrl: result.videoUrl,
|
|
1727
|
+
...(result.webmUrl !== undefined ? { webmUrl: result.webmUrl } : {}),
|
|
1728
|
+
durationSeconds: result.durationSeconds,
|
|
1729
|
+
...(result.width !== undefined ? { width: result.width } : {}),
|
|
1730
|
+
...(result.height !== undefined ? { height: result.height } : {}),
|
|
1731
|
+
sparksCharged: result.sparksCharged,
|
|
1732
|
+
patchesApplied: result.patchesApplied,
|
|
1733
|
+
...(review?.sheetPath != null ? { contactSheetPath: review.sheetPath } : {}),
|
|
1734
|
+
...(review?.hasAudioTrack != null ? { hasAudioTrack: review.hasAudioTrack } : {}),
|
|
1735
|
+
});
|
|
1736
|
+
runDeps.log(DEV_VIEW_RELOADS);
|
|
1737
|
+
}
|
|
1274
1738
|
const propCommand = defineCommand({
|
|
1275
1739
|
meta: {
|
|
1276
1740
|
name: 'prop',
|
|
@@ -1293,6 +1757,8 @@ const propCommand = defineCommand({
|
|
|
1293
1757
|
},
|
|
1294
1758
|
'master-url': MASTER_URL_FLAG,
|
|
1295
1759
|
mesh: MESH_FLAG,
|
|
1760
|
+
'keep-glb': KEEP_GLB_FLAG,
|
|
1761
|
+
voxelize: VOXELIZE_FLAG,
|
|
1296
1762
|
'direct-voxels': DIRECT_VOXELS_FLAG,
|
|
1297
1763
|
'voxel-grid': VOXEL_GRID_FLAG,
|
|
1298
1764
|
materials: MATERIALS_FLAG,
|
|
@@ -1306,6 +1772,8 @@ const propCommand = defineCommand({
|
|
|
1306
1772
|
...(args['glb-url'] ? { glbUrl: args['glb-url'] } : {}),
|
|
1307
1773
|
...(args['master-url'] ? { masterUrl: args['master-url'] } : {}),
|
|
1308
1774
|
...(args.mesh === true ? { mesh: true } : {}),
|
|
1775
|
+
...(args['keep-glb'] === true ? { keepGlb: true } : {}),
|
|
1776
|
+
...(args.voxelize === true ? { voxelize: true } : {}),
|
|
1309
1777
|
...voxelGridField(args['voxel-grid']),
|
|
1310
1778
|
...(args.materials === false ? { materials: false } : {}),
|
|
1311
1779
|
json: args.json === true,
|
|
@@ -1382,7 +1850,7 @@ const modelCommand = defineCommand({
|
|
|
1382
1850
|
},
|
|
1383
1851
|
height: {
|
|
1384
1852
|
type: 'string',
|
|
1385
|
-
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).',
|
|
1386
1854
|
},
|
|
1387
1855
|
hollow: {
|
|
1388
1856
|
type: 'boolean',
|
|
@@ -1398,6 +1866,8 @@ const modelCommand = defineCommand({
|
|
|
1398
1866
|
},
|
|
1399
1867
|
'master-url': MASTER_URL_FLAG,
|
|
1400
1868
|
mesh: MESH_FLAG,
|
|
1869
|
+
'keep-glb': KEEP_GLB_FLAG,
|
|
1870
|
+
voxelize: VOXELIZE_FLAG,
|
|
1401
1871
|
'direct-voxels': DIRECT_VOXELS_FLAG,
|
|
1402
1872
|
'voxel-grid': VOXEL_GRID_FLAG,
|
|
1403
1873
|
materials: MATERIALS_FLAG,
|
|
@@ -1430,6 +1900,8 @@ const modelCommand = defineCommand({
|
|
|
1430
1900
|
...(args['glb-url'] !== undefined ? { glbUrl: args['glb-url'] } : {}),
|
|
1431
1901
|
...(args['master-url'] !== undefined ? { masterUrl: args['master-url'] } : {}),
|
|
1432
1902
|
...(args.mesh === true ? { mesh: true } : {}),
|
|
1903
|
+
...(args['keep-glb'] === true ? { keepGlb: true } : {}),
|
|
1904
|
+
...(args.voxelize === true ? { voxelize: true } : {}),
|
|
1433
1905
|
...voxelGridField(args['voxel-grid']),
|
|
1434
1906
|
...(args.materials === false ? { materials: false } : {}),
|
|
1435
1907
|
json: args.json === true,
|
|
@@ -1450,6 +1922,7 @@ export const generateCommand = defineCommand({
|
|
|
1450
1922
|
image: imageCommand,
|
|
1451
1923
|
character: characterCommand,
|
|
1452
1924
|
animation: animationCommand,
|
|
1925
|
+
video: videoCommand,
|
|
1453
1926
|
prop: propCommand,
|
|
1454
1927
|
model: modelCommand,
|
|
1455
1928
|
vehicle: vehicleCommand,
|