@bitmagic/cli 0.1.64-dev.3 → 0.1.64-dev.5

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.
Files changed (66) hide show
  1. package/README.md +56 -3
  2. package/dist/assets/keep-glb.d.ts +20 -0
  3. package/dist/assets/keep-glb.js +45 -0
  4. package/dist/assets/keep-glb.js.map +1 -0
  5. package/dist/cli.d.ts +5 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands/assets.d.ts +3 -0
  9. package/dist/commands/assets.js +33 -3
  10. package/dist/commands/assets.js.map +1 -1
  11. package/dist/commands/cover.js +7 -1
  12. package/dist/commands/cover.js.map +1 -1
  13. package/dist/commands/generate.d.ts +54 -1
  14. package/dist/commands/generate.js +294 -20
  15. package/dist/commands/generate.js.map +1 -1
  16. package/dist/commands/init.d.ts +11 -0
  17. package/dist/commands/init.js +33 -1
  18. package/dist/commands/init.js.map +1 -1
  19. package/dist/commands/judge.d.ts +0 -11
  20. package/dist/commands/judge.js +15 -2
  21. package/dist/commands/judge.js.map +1 -1
  22. package/dist/commands/reference.js +5 -1
  23. package/dist/commands/reference.js.map +1 -1
  24. package/dist/commands/tools.d.ts +1 -0
  25. package/dist/commands/tools.js +73 -0
  26. package/dist/commands/tools.js.map +1 -0
  27. package/dist/generate/animation-from-video.d.ts +11 -3
  28. package/dist/generate/animation-from-video.js +12 -2
  29. package/dist/generate/animation-from-video.js.map +1 -1
  30. package/dist/generate/model.d.ts +11 -0
  31. package/dist/generate/model.js +41 -1
  32. package/dist/generate/model.js.map +1 -1
  33. package/dist/generate/prop.d.ts +9 -0
  34. package/dist/generate/prop.js +59 -9
  35. package/dist/generate/prop.js.map +1 -1
  36. package/dist/generate/video-review.d.ts +28 -0
  37. package/dist/generate/video-review.js +120 -0
  38. package/dist/generate/video-review.js.map +1 -0
  39. package/dist/project/art-style.d.ts +34 -0
  40. package/dist/project/art-style.js +63 -0
  41. package/dist/project/art-style.js.map +1 -0
  42. package/dist/scaffold/blender-level-skill-files.js +25 -3
  43. package/dist/scaffold/blender-level-skill-files.js.map +1 -1
  44. package/dist/scaffold/claude-settings.js +2 -0
  45. package/dist/scaffold/claude-settings.js.map +1 -1
  46. package/dist/scaffold/project-files.d.ts +2 -1
  47. package/dist/scaffold/project-files.js +46 -35
  48. package/dist/scaffold/project-files.js.map +1 -1
  49. package/dist/scaffold/project.d.ts +9 -0
  50. package/dist/scaffold/project.js +23 -3
  51. package/dist/scaffold/project.js.map +1 -1
  52. package/dist/scaffold/reference-guidance.d.ts +1 -1
  53. package/dist/scaffold/reference-guidance.js +2 -1
  54. package/dist/scaffold/reference-guidance.js.map +1 -1
  55. package/dist/scaffold/skill-files.js +1 -1
  56. package/dist/scaffold/unity-skill-files.js +1 -2
  57. package/dist/scaffold/unity-skill-files.js.map +1 -1
  58. package/dist/tools/bundles.d.ts +21 -0
  59. package/dist/tools/bundles.js +67 -0
  60. package/dist/tools/bundles.js.map +1 -0
  61. package/dist/trailer/ffmpeg.d.ts +10 -0
  62. package/dist/trailer/ffmpeg.js +51 -6
  63. package/dist/trailer/ffmpeg.js.map +1 -1
  64. package/dist/trailer/music-beats.js +4 -39
  65. package/dist/trailer/music-beats.js.map +1 -1
  66. package/package.json +4 -4
@@ -6,6 +6,7 @@ import { type GenerateModelResult } from '../generate/model.js';
6
6
  import { type AnimationFromVideoRun } from '../generate/animation-from-video.js';
7
7
  import { type VideoRun } from '../generate/video.js';
8
8
  import type { VoxelizeGlb } from '../assets/voxelize-glb.js';
9
+ import type { KeepGlb } from '../assets/keep-glb.js';
9
10
  export interface SkyboxArgs {
10
11
  description: string;
11
12
  referenceImageUrl?: string;
@@ -61,6 +62,8 @@ export interface CharacterArgs {
61
62
  headScale?: string;
62
63
  /** citty's negative boolean: `--no-library` arrives as `false`; the flag has no positive form. */
63
64
  library?: boolean;
65
+ /** Absent = the generator's default (true); the command fills it from the project's art style. */
66
+ voxelize?: boolean;
64
67
  }
65
68
  export declare function buildCharacterBody(args: CharacterArgs): Record<string, unknown>;
66
69
  export interface AnimationArgs {
@@ -102,7 +105,32 @@ export interface AnimationVideoArgs {
102
105
  /** Any of the authored-spec flags, to refuse the two forms being mixed. */
103
106
  spec?: string;
104
107
  family?: string;
108
+ /** The prompt lane, to refuse a clip and a description being given at once. */
109
+ videoPrompt?: string;
105
110
  }
111
+ export interface AnimationPromptArgs extends Omit<AnimationVideoArgs, 'video'> {
112
+ videoPrompt: string;
113
+ videoDuration?: number;
114
+ video?: string;
115
+ }
116
+ /** A `--video-prompt` run: everything `--video` carries, plus the clip to generate first. */
117
+ export interface AnimationFromPromptRun {
118
+ /** What the source clip should show, in the creator's words. */
119
+ description: string;
120
+ /** Source clip length in seconds; billed per second by the video leg. */
121
+ durationSeconds: number;
122
+ run: AnimationFromVideoRun;
123
+ }
124
+ /**
125
+ * `--video-prompt` is `--video` with the clip generated instead of filmed, so it
126
+ * shares every downstream flag and refuses the same combinations. The extra
127
+ * refusal is the duration floor: `generate video` will happily make a 1 s clip,
128
+ * and Uthana will then reject it, which would bill the creator for footage that
129
+ * never had a chance.
130
+ */
131
+ export declare function buildAnimationFromPromptRun(args: AnimationPromptArgs): AnimationFromPromptRun;
132
+ /** `--video` takes either a local file or an already-public clip URL. */
133
+ export declare function isHttpUrl(value: string): boolean;
106
134
  /**
107
135
  * `--video` is a different generator from the authored spec: the clip comes from a person, not
108
136
  * a parameter file, so the two flag sets cannot be combined and the spec flags are refused here
@@ -112,6 +140,8 @@ export declare function buildAnimationVideoRun(args: AnimationVideoArgs): Animat
112
140
  export interface GenerateRunDeps extends CommandRunDeps {
113
141
  /** Test-only override for the headless bake, so the lane runs without Chrome. */
114
142
  voxelize?: VoxelizeGlb;
143
+ /** Injected in tests so the kept-mesh lane (`--keep-glb` / low-poly projects) runs without a network. */
144
+ keepGlbUpload?: KeepGlb;
115
145
  }
116
146
  /**
117
147
  * Runs one `bitmagic generate <subcommand>`: requests the asset, then applies the returned
@@ -222,6 +252,9 @@ export declare function runPropGeneration(options: {
222
252
  voxelGrid?: number;
223
253
  /** `--no-materials`: leave the asset matte rather than classifying it after the bake. */
224
254
  materials?: boolean;
255
+ /** `--keep-glb` / `--voxelize`; absent follows game.json artStyle. */
256
+ keepGlb?: boolean;
257
+ voxelize?: boolean;
225
258
  json: boolean;
226
259
  deps?: GenerateRunDeps;
227
260
  }): Promise<PropVoxelizeResult>;
@@ -271,6 +304,9 @@ export declare function runModelGeneration(options: {
271
304
  voxelGrid?: number;
272
305
  /** `--no-materials`: leave the asset matte rather than classifying it after the bake. */
273
306
  materials?: boolean;
307
+ /** `--keep-glb` / `--voxelize`; absent follows game.json artStyle. */
308
+ keepGlb?: boolean;
309
+ voxelize?: boolean;
274
310
  json: boolean;
275
311
  deps?: GenerateRunDeps;
276
312
  }): Promise<GenerateModelResult>;
@@ -280,9 +316,26 @@ export declare function runModelGeneration(options: {
280
316
  * job record, so the dev view shows it beside every other generation.
281
317
  */
282
318
  export declare function runAnimationFromVideo(run: AnimationFromVideoRun, json: boolean, deps?: GenerateRunDeps): Promise<void>;
319
+ /**
320
+ * The `--video-prompt` lane: text -> clip -> motion, the two existing job lanes run back to back.
321
+ *
322
+ * There is no chained endpoint behind this — asset-forger has one route that makes a video and
323
+ * another that reads one, and this is the seam between them. Both legs bill under their own
324
+ * existing key (`generate-video` per second, `generate-animation-from-video` flat), the way
325
+ * design-vehicle prices its chained image sub-generations rather than minting a composite.
326
+ *
327
+ * Both asset rows are kept. The clip is a real `type: 'video'` asset the creator can play back
328
+ * or reuse as a cutscene, and keeping it is also what makes a leg-2 failure recoverable: the
329
+ * footage is already paid for, so the error hands back a `--video <url>` retry instead of asking
330
+ * for the whole thing again.
331
+ */
332
+ export declare function runAnimationFromPrompt(prompt: AnimationFromPromptRun, json: boolean, deps?: GenerateRunDeps, creatorPrompt?: string): Promise<void>;
283
333
  /**
284
334
  * Job-shaped rather than streamed (see generate/video.ts), so it cannot share `runAssetGeneration`;
285
335
  * it does share the job record, so the dev view shows it beside every other generation.
286
336
  */
287
- export declare function runVideoGeneration(run: VideoRun, json: boolean, deps?: GenerateRunDeps): Promise<void>;
337
+ export declare function runVideoGeneration(run: VideoRun, json: boolean, options?: {
338
+ deps?: GenerateRunDeps;
339
+ contactSheet?: boolean;
340
+ }): Promise<void>;
288
341
  export declare const generateCommand: import("citty").CommandDef<import("citty").ArgsDef>;
@@ -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,9 @@ 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';
29
31
  import { VIDEO_EXTENSIONS } from '../assets/file-kind.js';
30
- import { LOCOMOTION_STATES, VIDEO_TO_MOTION_MODELS } from '@bitmagic/asset-core';
32
+ import { LOCOMOTION_STATES, VIDEO_TO_MOTION_MODELS, MOTION_SOURCE_DEFAULT_SECONDS, MOTION_SOURCE_MIN_SECONDS, buildMotionSourcePrompt, } from '@bitmagic/asset-core';
31
33
  import { classifyAssetMaterialsQuietly } from '../assets/materials.js';
32
34
  import { DEFAULT_MAX_VOXEL_SIZE, DEFAULT_MIN_VOXEL_SIZE, SOLID_FILL_MAX_HEIGHT_M } from './assets.js';
33
35
  import { creatorPromptField, ORIGINAL_PROMPT_FLAG, withParent } from '../telemetry/creator-prompt.js';
@@ -102,6 +104,18 @@ export function buildImageBody(args) {
102
104
  referenceImageUrl: args.referenceImageUrl,
103
105
  });
104
106
  }
107
+ /**
108
+ * The character default a project's art style implies: a low-poly game keeps the textured rig.
109
+ * Read from the cwd's project like every other generate command; outside a project the answer
110
+ * is "declares nothing", so the generator's own default (voxelize) stands and `loadProjectContext`
111
+ * reports the missing project on the very next line.
112
+ */
113
+ function defaultVoxelizeForProject() {
114
+ const root = findProjectRootOrNull();
115
+ if (root === null)
116
+ return undefined;
117
+ return readArtStyleDeclaration(root).artStyle === 'low-poly' ? false : undefined;
118
+ }
105
119
  export function buildCharacterBody(args) {
106
120
  return withoutAbsentFlags({
107
121
  name: args.name,
@@ -109,6 +123,7 @@ export function buildCharacterBody(args) {
109
123
  applyToPlayer: args.applyToPlayer,
110
124
  headScale: parseNumberFlag('head-scale', args.headScale, { min: 50, max: 300 }),
111
125
  useLibrary: args.library,
126
+ voxelize: args.voxelize,
112
127
  });
113
128
  }
114
129
  // ── animation ───────────────────────────────────────────────────────────────
@@ -253,6 +268,44 @@ export function buildAnimationBody(args, readSpec) {
253
268
  spec,
254
269
  });
255
270
  }
271
+ /**
272
+ * `--video-prompt` is `--video` with the clip generated instead of filmed, so it
273
+ * shares every downstream flag and refuses the same combinations. The extra
274
+ * refusal is the duration floor: `generate video` will happily make a 1 s clip,
275
+ * and Uthana will then reject it, which would bill the creator for footage that
276
+ * never had a chance.
277
+ */
278
+ export function buildAnimationFromPromptRun(args) {
279
+ if (args.spec !== undefined || args.family !== undefined) {
280
+ throw new CliError('--video-prompt cannot be combined with --spec or --family: a captured clip is not authored from a spec.');
281
+ }
282
+ if (args.video !== undefined) {
283
+ throw new CliError('--video and --video-prompt cannot be combined: pass a clip you have, or describe one to generate.');
284
+ }
285
+ if (args.videoPrompt.trim() === '') {
286
+ throw new CliError('--video-prompt needs a description of the motion, e.g. "a person waving both arms overhead".');
287
+ }
288
+ const durationSeconds = args.videoDuration ?? MOTION_SOURCE_DEFAULT_SECONDS;
289
+ if (!Number.isFinite(durationSeconds)
290
+ || durationSeconds < MOTION_SOURCE_MIN_SECONDS || durationSeconds > VIDEO_MAX_SECONDS) {
291
+ throw new CliError(`--video-duration must be ${MOTION_SOURCE_MIN_SECONDS}-${VIDEO_MAX_SECONDS} seconds; `
292
+ + `got ${JSON.stringify(String(args.videoDuration))}. Video-to-motion needs at least `
293
+ + `${MOTION_SOURCE_MIN_SECONDS}s of footage to read.`);
294
+ }
295
+ // The clip half is validated by the same rules --video uses. No videoPath and
296
+ // no videoUrl yet: the first leg fills videoUrl in once it has produced one.
297
+ return { description: args.videoPrompt.trim(), durationSeconds, run: buildClipFlags(args) };
298
+ }
299
+ /** `--video` takes either a local file or an already-public clip URL. */
300
+ export function isHttpUrl(value) {
301
+ try {
302
+ const { protocol } = new URL(value);
303
+ return protocol === 'http:' || protocol === 'https:';
304
+ }
305
+ catch {
306
+ return false;
307
+ }
308
+ }
256
309
  /**
257
310
  * `--video` is a different generator from the authored spec: the clip comes from a person, not
258
311
  * a parameter file, so the two flag sets cannot be combined and the spec flags are refused here
@@ -262,6 +315,20 @@ export function buildAnimationVideoRun(args) {
262
315
  if (args.spec !== undefined || args.family !== undefined) {
263
316
  throw new CliError('--video cannot be combined with --spec or --family: a video clip is not authored from a spec.');
264
317
  }
318
+ if (args.videoPrompt !== undefined) {
319
+ throw new CliError('--video and --video-prompt cannot be combined: pass a clip you have, or describe one to generate.');
320
+ }
321
+ return {
322
+ // A URL is handed straight to the job; only a local file needs uploading.
323
+ ...(isHttpUrl(args.video) ? { videoUrl: args.video } : { videoPath: args.video }),
324
+ ...buildClipFlags(args),
325
+ };
326
+ }
327
+ /**
328
+ * The flags that describe the CLIP rather than where it came from, shared by
329
+ * `--video` and `--video-prompt` so the two lanes cannot drift apart.
330
+ */
331
+ function buildClipFlags(args) {
265
332
  if (args.slot !== undefined && !isLocomotionState(args.slot)) {
266
333
  throw new CliError(`--slot must be one of ${LOCOMOTION_STATES.join(', ')}; got ${JSON.stringify(args.slot)}.`);
267
334
  }
@@ -283,7 +350,6 @@ export function buildAnimationVideoRun(args) {
283
350
  }
284
351
  }
285
352
  return {
286
- videoPath: args.video,
287
353
  ...(args.name !== undefined ? { name: args.name } : {}),
288
354
  ...(args.model !== undefined ? { model: args.model } : {}),
289
355
  ...(args.loop !== undefined ? { loop: args.loop } : {}),
@@ -623,6 +689,37 @@ const MATERIALS_FLAG = {
623
689
  description: 'Decide what the asset is made of after the bake, so metal shines and glass '
624
690
  + 'reflects. Pass --no-materials to leave it plain; `bitmagic assets materials` edits it later.',
625
691
  };
692
+ /**
693
+ * The mesh-KEEPING pair, `generate prop|model`'s answer to `assets add --keep-glb` / `--voxelize`.
694
+ * Neither is needed in the common case: a low-poly project (game.json `artStyle`) keeps, a voxel
695
+ * project bakes. They exist for the one prop that goes the other way.
696
+ */
697
+ const KEEP_GLB_FLAG = {
698
+ type: 'boolean',
699
+ description: 'Keep the generated mesh as a polygon-mesh asset instead of voxelizing it '
700
+ + '(the default when game.json artStyle is low-poly). Implies --mesh; no browser is needed.',
701
+ };
702
+ const VOXELIZE_FLAG = {
703
+ type: 'boolean',
704
+ description: 'Voxelize even in a low-poly project.',
705
+ };
706
+ /**
707
+ * Whether a generated mesh is kept: the flags when given, else the project's art style. Says so
708
+ * only when the style decided, so a voxel project's transcript is unchanged.
709
+ */
710
+ function resolveKeepGlb(root, options, log) {
711
+ if (options.keepGlb === true && options.voxelize === true) {
712
+ throw new CliError('Pass either --keep-glb or --voxelize, not both. world.json is unchanged.');
713
+ }
714
+ if (options.keepGlb === true)
715
+ return true;
716
+ if (options.voxelize === true)
717
+ return false;
718
+ if (resolveArtStyle(root, log) !== 'low-poly')
719
+ return false;
720
+ log('Keeping the mesh: game.json artStyle is low-poly (pass --voxelize to bake it instead).');
721
+ return true;
722
+ }
626
723
  /** The voxel path's `--glb-url`: a paid-for master to bake, instead of buying another. */
627
724
  const MASTER_URL_FLAG = {
628
725
  type: 'string',
@@ -646,9 +743,10 @@ function classifyGeneratedAsset(input) {
646
743
  * or an asset with no `fitBox` costs nothing.
647
744
  */
648
745
  export async function runPropGeneration(options) {
649
- assertVoxelGridAllowed(options.voxelGrid, options.mesh === true);
746
+ assertVoxelGridAllowed(options.voxelGrid, options.mesh === true || options.keepGlb === true);
650
747
  assertOneRetrySource(options.glbUrl, options.masterUrl);
651
748
  const { output, runDeps, context, environment, token } = await resolveRun(options.json, options.deps);
749
+ const keepGlb = resolveKeepGlb(context.root, options, runDeps.log);
652
750
  const result = await withJobRecord({
653
751
  root: context.root,
654
752
  kind: 'prop',
@@ -670,6 +768,8 @@ export async function runPropGeneration(options) {
670
768
  ...(options.masterUrl ? { masterUrl: options.masterUrl } : {}),
671
769
  ...(options.mesh === true ? { mesh: true } : {}),
672
770
  ...(options.voxelGrid !== undefined ? { voxelGrid: options.voxelGrid } : {}),
771
+ ...(keepGlb ? { keepGlb: true } : {}),
772
+ ...(runDeps.keepGlbUpload !== undefined ? { keepGlbUpload: runDeps.keepGlbUpload } : {}),
673
773
  fetchImpl: runDeps.fetch,
674
774
  log,
675
775
  });
@@ -680,12 +780,13 @@ export async function runPropGeneration(options) {
680
780
  });
681
781
  runDeps.log(`Upgraded "${result.assetName}" — ${result.instanceCount} placed instance`
682
782
  + `${result.instanceCount === 1 ? '' : 's'} now use the generated version.`);
683
- const materials = options.materials === false
783
+ // Materials name what each colour group of a VOXEL asset is made of; a kept mesh has its own.
784
+ const materials = options.materials === false || keepGlb
684
785
  ? null
685
786
  : await classifyGeneratedAsset({ context, environment, token, assetId: result.assetId, runDeps });
686
787
  // `result` already carries glbUrl, so a retry after a later failure can find the paid-for mesh
687
788
  // in the JSON document rather than only in the error text.
688
- output.result({ ok: true, kind: 'prop', ...result, ...(materials === null ? {} : { materials }) });
789
+ output.result({ ok: true, kind: 'prop', ...result, ...(keepGlb ? { keptMesh: true } : {}), ...(materials === null ? {} : { materials }) });
689
790
  runDeps.log(DEV_VIEW_RELOADS);
690
791
  return result;
691
792
  }
@@ -699,6 +800,14 @@ export async function runPropGeneration(options) {
699
800
  * prompt nor a preset, which is refused here before anything is spent.
700
801
  */
701
802
  export async function runVehicleGeneration(options) {
803
+ {
804
+ // A vehicle is voxel-native (its builder and finishes assume six face normals), so a low-poly
805
+ // project gets one anyway — said once, so the mismatch is a known choice rather than a surprise.
806
+ const root = findProjectRootOrNull();
807
+ if (root !== null && resolveArtStyle(root) === 'low-poly') {
808
+ process.stderr.write('note: vehicles are always voxelized; this low-poly project (game.json artStyle) will get a voxel car.\n');
809
+ }
810
+ }
702
811
  if (options.prompt === undefined && options.presetName === undefined && options.glbUrl === undefined) {
703
812
  throw new CliError('Provide --prompt to design a vehicle, or --preset to build a standard one. '
704
813
  + 'Run `bitmagic generate vehicle --preset ?` to see the preset names.');
@@ -759,9 +868,10 @@ export async function runVehicleGeneration(options) {
759
868
  * has no instances yet and scales to `--height` (or the mesh's own size) instead.
760
869
  */
761
870
  export async function runModelGeneration(options) {
762
- assertVoxelGridAllowed(options.voxelGrid, options.mesh === true);
871
+ assertVoxelGridAllowed(options.voxelGrid, options.mesh === true || options.keepGlb === true);
763
872
  assertOneRetrySource(options.glbUrl, options.masterUrl);
764
873
  const { output, runDeps, context, environment, token } = await resolveRun(options.json, options.deps);
874
+ const keepGlb = resolveKeepGlb(context.root, options, runDeps.log);
765
875
  const result = await withJobRecord({
766
876
  root: context.root,
767
877
  kind: 'model',
@@ -790,6 +900,8 @@ export async function runModelGeneration(options) {
790
900
  },
791
901
  fetchImpl: runDeps.fetch,
792
902
  ...(runDeps.voxelize !== undefined ? { voxelize: runDeps.voxelize } : {}),
903
+ ...(keepGlb ? { keepGlb: true } : {}),
904
+ ...(runDeps.keepGlbUpload !== undefined ? { keepGlbUpload: runDeps.keepGlbUpload } : {}),
793
905
  log,
794
906
  });
795
907
  }
@@ -798,9 +910,11 @@ export async function runModelGeneration(options) {
798
910
  }
799
911
  });
800
912
  runDeps.log(`Added "${result.assetName}" (id ${result.assetId})`
801
- + `${result.voxelCount !== undefined ? `, ${result.voxelCount} voxels` : ''}. Place it from game `
913
+ + `${result.voxelCount !== undefined ? `, ${result.voxelCount} voxels` : ''}`
914
+ + `${result.keptMesh === true ? ', kept as a polygon mesh' : ''}. Place it from game `
802
915
  + 'code, or drag it in from the dev view\'s Add-object palette.');
803
- const materials = options.materials === false
916
+ // Materials name what each colour group of a VOXEL asset is made of; a kept mesh has its own.
917
+ const materials = options.materials === false || keepGlb
804
918
  ? null
805
919
  : await classifyGeneratedAsset({ context, environment, token, assetId: result.assetId, runDeps });
806
920
  // `result` carries whichever source was paid for — glbUrl or masterUrl — so a retry after a
@@ -1100,6 +1214,11 @@ const characterCommand = defineCommand({
1100
1214
  type: 'boolean',
1101
1215
  negativeDescription: 'Always make a new character; do not use a ready-made one from the library.',
1102
1216
  },
1217
+ voxelize: {
1218
+ type: 'boolean',
1219
+ description: 'Voxelize the rigged body. Omit to follow game.json artStyle: voxel games voxelize, low-poly games keep the textured rig.',
1220
+ negativeDescription: 'Keep the textured low-poly rig instead of voxelizing it.',
1221
+ },
1103
1222
  'original-prompt': ORIGINAL_PROMPT_FLAG,
1104
1223
  json: JSON_FLAG,
1105
1224
  },
@@ -1110,6 +1229,7 @@ const characterCommand = defineCommand({
1110
1229
  applyToPlayer: args['apply-to-player'],
1111
1230
  headScale: args['head-scale'],
1112
1231
  library: args.library,
1232
+ voxelize: args.voxelize ?? defaultVoxelizeForProject(),
1113
1233
  });
1114
1234
  await runAssetGeneration('character', 'character', withParent(body), args.json === true);
1115
1235
  },
@@ -1164,25 +1284,40 @@ const animationCommand = defineCommand({
1164
1284
  video: {
1165
1285
  type: 'string',
1166
1286
  description: `Make the clip from a video of one person instead of a spec (Uthana video-to-motion, costs sparks). `
1167
- + `${VIDEO_EXTENSIONS.map((e) => `.${e}`).join('/')}, 2-60 s, full body in frame. Cannot be combined with --spec/--family.`,
1287
+ + `A local ${VIDEO_EXTENSIONS.map((e) => `.${e}`).join('/')} file, or an http(s) URL of one already uploaded. `
1288
+ + `2-60 s, full body in frame. Cannot be combined with --spec/--family/--video-prompt.`,
1289
+ },
1290
+ 'video-prompt': {
1291
+ type: 'string',
1292
+ description: 'Describe a motion and have the source clip generated instead of filmed '
1293
+ + '(costs sparks for both the clip and the motion). The clip is framed automatically for '
1294
+ + 'capture: one person, whole body in shot, static camera. Cannot be combined with '
1295
+ + '--video/--spec/--family.',
1296
+ },
1297
+ 'video-duration': {
1298
+ type: 'string',
1299
+ description: `Length of the generated source clip in seconds, ${MOTION_SOURCE_MIN_SECONDS}-${VIDEO_MAX_SECONDS} `
1300
+ + `(default ${MOTION_SOURCE_DEFAULT_SECONDS}); with --video-prompt. Billed per second. Not --duration, which is `
1301
+ + 'the authored-spec clip length.',
1168
1302
  },
1169
1303
  model: {
1170
1304
  type: 'string',
1171
- description: `Video-to-motion model (with --video): ${VIDEO_TO_MOTION_MODELS.join(' | ')}.`,
1305
+ description: `Video-to-motion model (with --video or --video-prompt): ${VIDEO_TO_MOTION_MODELS.join(' | ')}.`,
1172
1306
  },
1173
1307
  loop: {
1174
1308
  type: 'boolean',
1175
- description: 'Mark the clip as looping (with --video). Idle, walk and run clips should loop.',
1309
+ description: 'Mark the clip as looping (with --video/--video-prompt). Idle, walk and run clips should loop.',
1176
1310
  },
1177
1311
  slot: {
1178
1312
  type: 'string',
1179
- description: `Pin the clip to a locomotion state for the player and every NPC (with --video): ${LOCOMOTION_STATES.join(' | ')}. `
1313
+ description: `Pin the clip to a locomotion state for the player and every NPC (with --video/--video-prompt): ${LOCOMOTION_STATES.join(' | ')}. `
1180
1314
  + 'Omit for a one-shot clip played with playCustomAnimation().',
1181
1315
  },
1182
1316
  'auto-trim': {
1183
1317
  type: 'boolean',
1184
1318
  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.',
1319
+ + 'Useful for clips recorded webcam-style; the dev-view recorder instead confirms an explicit window. '
1320
+ + 'A generated --video-prompt clip has no such frames, so it rarely needs this.',
1186
1321
  },
1187
1322
  'trim-start': {
1188
1323
  type: 'string',
@@ -1197,6 +1332,25 @@ const animationCommand = defineCommand({
1197
1332
  json: JSON_FLAG,
1198
1333
  },
1199
1334
  async run({ args }) {
1335
+ if (args['video-prompt'] !== undefined) {
1336
+ const run = buildAnimationFromPromptRun({
1337
+ videoPrompt: args['video-prompt'],
1338
+ videoDuration: args['video-duration'] !== undefined ? Number(args['video-duration']) : undefined,
1339
+ video: args.video,
1340
+ name: args.name,
1341
+ model: args.model,
1342
+ loop: args.loop === true ? true : undefined,
1343
+ slot: args.slot,
1344
+ autoTrim: args['auto-trim'] === true ? true : undefined,
1345
+ trimStart: args['trim-start'] !== undefined ? Number(args['trim-start']) : undefined,
1346
+ trimEnd: args['trim-end'] !== undefined ? Number(args['trim-end']) : undefined,
1347
+ assetId: args['asset-id'],
1348
+ spec: args.spec,
1349
+ family: args.family,
1350
+ });
1351
+ await runAnimationFromPrompt(run, args.json === true, undefined, creatorPromptField(args).creatorPrompt);
1352
+ return;
1353
+ }
1200
1354
  if (args.video !== undefined) {
1201
1355
  const run = buildAnimationVideoRun({
1202
1356
  video: args.video,
@@ -1236,7 +1390,7 @@ const animationCommand = defineCommand({
1236
1390
  */
1237
1391
  export async function runAnimationFromVideo(run, json, deps) {
1238
1392
  const { output, runDeps, context, environment, token } = await resolveRun(json, deps);
1239
- const label = run.name ?? path.basename(run.videoPath);
1393
+ const label = run.name ?? (run.videoPath !== undefined ? path.basename(run.videoPath) : 'Recorded motion');
1240
1394
  const result = await withJobRecord({
1241
1395
  root: context.root, kind: 'animation-video', label, log: runDeps.log,
1242
1396
  }, async (log) => {
@@ -1272,6 +1426,85 @@ export async function runAnimationFromVideo(run, json, deps) {
1272
1426
  });
1273
1427
  runDeps.log(DEV_VIEW_RELOADS);
1274
1428
  }
1429
+ /**
1430
+ * The `--video-prompt` lane: text -> clip -> motion, the two existing job lanes run back to back.
1431
+ *
1432
+ * There is no chained endpoint behind this — asset-forger has one route that makes a video and
1433
+ * another that reads one, and this is the seam between them. Both legs bill under their own
1434
+ * existing key (`generate-video` per second, `generate-animation-from-video` flat), the way
1435
+ * design-vehicle prices its chained image sub-generations rather than minting a composite.
1436
+ *
1437
+ * Both asset rows are kept. The clip is a real `type: 'video'` asset the creator can play back
1438
+ * or reuse as a cutscene, and keeping it is also what makes a leg-2 failure recoverable: the
1439
+ * footage is already paid for, so the error hands back a `--video <url>` retry instead of asking
1440
+ * for the whole thing again.
1441
+ */
1442
+ export async function runAnimationFromPrompt(prompt, json, deps, creatorPrompt) {
1443
+ const { output, runDeps, context, environment, token } = await resolveRun(json, deps);
1444
+ const label = prompt.run.name ?? prompt.description;
1445
+ const video = await withJobRecord({
1446
+ root: context.root, kind: 'video', label: `${label} (motion source)`, log: runDeps.log,
1447
+ }, async (log) => {
1448
+ try {
1449
+ return await generateAndInstallVideo({
1450
+ context, environment, token,
1451
+ run: {
1452
+ prompt: buildMotionSourcePrompt(prompt.description),
1453
+ durationSeconds: prompt.durationSeconds,
1454
+ name: `${label} (motion source)`,
1455
+ ...(creatorPrompt !== undefined ? { creatorPrompt } : {}),
1456
+ },
1457
+ deps: { fetch: runDeps.fetch, sleep: runDeps.sleep, log },
1458
+ });
1459
+ }
1460
+ catch (error) {
1461
+ throw decoratedError(error, (message) => message);
1462
+ }
1463
+ });
1464
+ runDeps.log(`Source clip generated (${video.durationSeconds.toFixed(1)}s): ${video.videoUrl}`);
1465
+ const motion = await withJobRecord({
1466
+ root: context.root, kind: 'animation-video', label, log: runDeps.log,
1467
+ }, async (log) => {
1468
+ try {
1469
+ return await generateAndInstallAnimationFromVideo({
1470
+ context, environment, token,
1471
+ run: { ...prompt.run, videoUrl: video.videoUrl, name: label },
1472
+ deps: { fetch: runDeps.fetch, sleep: runDeps.sleep, log },
1473
+ });
1474
+ }
1475
+ catch (error) {
1476
+ // The clip survived and is already an asset, so say what it costs to pick
1477
+ // up from here rather than letting the creator re-buy the footage.
1478
+ throw decoratedError(error, (message) => `${message}\n`
1479
+ + `The source clip was kept as asset "${video.assetId}". Retry the motion without `
1480
+ + `regenerating it:\n bitmagic generate animation --video ${video.videoUrl} --name ${JSON.stringify(label)}`);
1481
+ }
1482
+ });
1483
+ const sparksCharged = video.sparksCharged + motion.sparksCharged;
1484
+ const patchesApplied = video.patchesApplied + motion.patchesApplied;
1485
+ runDeps.log(`Generated animation "${label}" from a generated clip (${motion.durationSeconds.toFixed(1)}s)${describeAutoTrim(motion)}: ${motion.animationUrl}`);
1486
+ runDeps.log(` - motionId ${motion.motionId}${motion.locomotionState ? ` — pinned to ${motion.locomotionState}` : ''}`);
1487
+ runDeps.log(` - source clip asset ${video.assetId}`);
1488
+ runDeps.log(`world.json updated (${patchesApplied} patch${patchesApplied === 1 ? '' : 'es'}).`);
1489
+ if (!motion.locomotionState) {
1490
+ runDeps.log(`Play it with playCustomAnimation("${motion.motionId}"), or re-run with --slot idle|walk|run to make it locomotion.`);
1491
+ }
1492
+ output.result({
1493
+ ok: true,
1494
+ kind: 'animation',
1495
+ motionId: motion.motionId,
1496
+ animationUrl: motion.animationUrl,
1497
+ durationSeconds: motion.durationSeconds,
1498
+ sourceVideoAssetId: video.assetId,
1499
+ sourceVideoUrl: video.videoUrl,
1500
+ ...(motion.trimmedStartSeconds !== undefined ? { trimmedStartSeconds: motion.trimmedStartSeconds } : {}),
1501
+ ...(motion.trimmedEndSeconds !== undefined ? { trimmedEndSeconds: motion.trimmedEndSeconds } : {}),
1502
+ ...(motion.locomotionState ? { locomotionState: motion.locomotionState } : {}),
1503
+ sparksCharged,
1504
+ patchesApplied,
1505
+ });
1506
+ runDeps.log(DEV_VIEW_RELOADS);
1507
+ }
1275
1508
  const videoCommand = defineCommand({
1276
1509
  meta: {
1277
1510
  name: 'video',
@@ -1304,6 +1537,12 @@ const videoCommand = defineCommand({
1304
1537
  type: 'string',
1305
1538
  description: 'Pre-minted asset_… id to store the clip under, so code already referencing it resolves.',
1306
1539
  },
1540
+ 'contact-sheet': {
1541
+ type: 'boolean',
1542
+ default: true,
1543
+ description: 'Grab 8 frames into a PNG under .bitmagic/videos/ so the clip can be reviewed (the default). '
1544
+ + 'Pass --no-contact-sheet to skip the download; needs ffmpeg.',
1545
+ },
1307
1546
  'original-prompt': ORIGINAL_PROMPT_FLAG,
1308
1547
  json: JSON_FLAG,
1309
1548
  },
@@ -1321,15 +1560,15 @@ const videoCommand = defineCommand({
1321
1560
  ...(args['asset-id'] !== undefined ? { assetId: args['asset-id'] } : {}),
1322
1561
  ...creatorPromptField(args),
1323
1562
  };
1324
- await runVideoGeneration(run, args.json === true);
1563
+ await runVideoGeneration(run, args.json === true, { contactSheet: args['contact-sheet'] !== false });
1325
1564
  },
1326
1565
  });
1327
1566
  /**
1328
1567
  * Job-shaped rather than streamed (see generate/video.ts), so it cannot share `runAssetGeneration`;
1329
1568
  * it does share the job record, so the dev view shows it beside every other generation.
1330
1569
  */
1331
- export async function runVideoGeneration(run, json, deps) {
1332
- const { output, runDeps, context, environment, token } = await resolveRun(json, deps);
1570
+ export async function runVideoGeneration(run, json, options = {}) {
1571
+ const { output, runDeps, context, environment, token } = await resolveRun(json, options.deps);
1333
1572
  const label = run.name ?? run.prompt;
1334
1573
  const result = await withJobRecord({
1335
1574
  root: context.root, kind: 'video', label, log: runDeps.log,
@@ -1349,7 +1588,32 @@ export async function runVideoGeneration(run, json, deps) {
1349
1588
  : '';
1350
1589
  runDeps.log(`Generated video "${label}" (${result.durationSeconds.toFixed(1)}s${shape}): ${result.videoUrl}`);
1351
1590
  runDeps.log(`world.json updated (${result.patchesApplied} patch${result.patchesApplied === 1 ? '' : 'es'}).`);
1352
- runDeps.log(`Play it with engine.playVideo("${result.assetId}").`);
1591
+ const review = options.contactSheet === false
1592
+ ? null
1593
+ : await reviewGeneratedClip({
1594
+ root: context.root,
1595
+ assetId: result.assetId,
1596
+ videoUrl: result.videoUrl,
1597
+ durationSeconds: result.durationSeconds,
1598
+ fetch: runDeps.fetch,
1599
+ });
1600
+ if (review !== null) {
1601
+ if (review.sheetPath !== null)
1602
+ runDeps.log(`Frames: ${review.sheetPath}`);
1603
+ // Silence is what decides whether the cutscene needs a music bed under it, and it is
1604
+ // invisible in the frames — so it is said in words either way, never left to be assumed.
1605
+ if (review.hasAudioTrack === false) {
1606
+ runDeps.log('The clip has NO audio track — play a music bed under it if the cutscene needs sound.');
1607
+ }
1608
+ else if (review.hasAudioTrack === true) {
1609
+ runDeps.log('The clip carries its own audio track.');
1610
+ }
1611
+ for (const note of review.notes)
1612
+ runDeps.log(` ${note}`);
1613
+ }
1614
+ runDeps.log(`Play it with engine.playVideo("${result.assetId}") — from the Play click or later, `
1615
+ + 'never before: without a prior user gesture the browser blocks autoplay and the clip silently '
1616
+ + 'does not run. See video-cutscenes.md.');
1353
1617
  output.result({
1354
1618
  ok: true,
1355
1619
  kind: 'video',
@@ -1361,6 +1625,8 @@ export async function runVideoGeneration(run, json, deps) {
1361
1625
  ...(result.height !== undefined ? { height: result.height } : {}),
1362
1626
  sparksCharged: result.sparksCharged,
1363
1627
  patchesApplied: result.patchesApplied,
1628
+ ...(review?.sheetPath != null ? { contactSheetPath: review.sheetPath } : {}),
1629
+ ...(review?.hasAudioTrack != null ? { hasAudioTrack: review.hasAudioTrack } : {}),
1364
1630
  });
1365
1631
  runDeps.log(DEV_VIEW_RELOADS);
1366
1632
  }
@@ -1386,6 +1652,8 @@ const propCommand = defineCommand({
1386
1652
  },
1387
1653
  'master-url': MASTER_URL_FLAG,
1388
1654
  mesh: MESH_FLAG,
1655
+ 'keep-glb': KEEP_GLB_FLAG,
1656
+ voxelize: VOXELIZE_FLAG,
1389
1657
  'direct-voxels': DIRECT_VOXELS_FLAG,
1390
1658
  'voxel-grid': VOXEL_GRID_FLAG,
1391
1659
  materials: MATERIALS_FLAG,
@@ -1399,6 +1667,8 @@ const propCommand = defineCommand({
1399
1667
  ...(args['glb-url'] ? { glbUrl: args['glb-url'] } : {}),
1400
1668
  ...(args['master-url'] ? { masterUrl: args['master-url'] } : {}),
1401
1669
  ...(args.mesh === true ? { mesh: true } : {}),
1670
+ ...(args['keep-glb'] === true ? { keepGlb: true } : {}),
1671
+ ...(args.voxelize === true ? { voxelize: true } : {}),
1402
1672
  ...voxelGridField(args['voxel-grid']),
1403
1673
  ...(args.materials === false ? { materials: false } : {}),
1404
1674
  json: args.json === true,
@@ -1475,7 +1745,7 @@ const modelCommand = defineCommand({
1475
1745
  },
1476
1746
  height: {
1477
1747
  type: 'string',
1478
- description: 'Scale the model to this height in metres. Omit to keep the generated mesh\'s own size.',
1748
+ 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
1749
  },
1480
1750
  hollow: {
1481
1751
  type: 'boolean',
@@ -1491,6 +1761,8 @@ const modelCommand = defineCommand({
1491
1761
  },
1492
1762
  'master-url': MASTER_URL_FLAG,
1493
1763
  mesh: MESH_FLAG,
1764
+ 'keep-glb': KEEP_GLB_FLAG,
1765
+ voxelize: VOXELIZE_FLAG,
1494
1766
  'direct-voxels': DIRECT_VOXELS_FLAG,
1495
1767
  'voxel-grid': VOXEL_GRID_FLAG,
1496
1768
  materials: MATERIALS_FLAG,
@@ -1523,6 +1795,8 @@ const modelCommand = defineCommand({
1523
1795
  ...(args['glb-url'] !== undefined ? { glbUrl: args['glb-url'] } : {}),
1524
1796
  ...(args['master-url'] !== undefined ? { masterUrl: args['master-url'] } : {}),
1525
1797
  ...(args.mesh === true ? { mesh: true } : {}),
1798
+ ...(args['keep-glb'] === true ? { keepGlb: true } : {}),
1799
+ ...(args.voxelize === true ? { voxelize: true } : {}),
1526
1800
  ...voxelGridField(args['voxel-grid']),
1527
1801
  ...(args.materials === false ? { materials: false } : {}),
1528
1802
  json: args.json === true,