@bitmagic/cli 0.1.24 → 0.1.26
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 +27 -0
- package/dist/commands/dev.js +4 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/forge.js +32 -21
- package/dist/commands/forge.js.map +1 -1
- package/dist/commands/generate.js +117 -56
- package/dist/commands/generate.js.map +1 -1
- package/dist/editor/previews.d.ts +23 -0
- package/dist/editor/previews.js +108 -0
- package/dist/editor/previews.js.map +1 -0
- package/dist/editor/server.js +111 -3
- package/dist/editor/server.js.map +1 -1
- package/dist/editor/shell-page.js +500 -5
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/project/jobs.d.ts +67 -0
- package/dist/project/jobs.js +295 -0
- package/dist/project/jobs.js.map +1 -0
- package/dist/scaffold/project-files.js +9 -0
- package/dist/scaffold/project-files.js.map +1 -1
- package/package.json +1 -1
|
@@ -100,6 +100,7 @@ export function renderEditorShell(options) {
|
|
|
100
100
|
button { background: #26262e; color: #e6e6ea; border: 1px solid #34343f; border-radius: 5px;
|
|
101
101
|
padding: 4px 10px; font: inherit; cursor: pointer; }
|
|
102
102
|
button:hover { background: #30303a; }
|
|
103
|
+
button:disabled { opacity: .5; cursor: default; background: #26262e; }
|
|
103
104
|
#tabs .tab:hover { background: #22222a; }
|
|
104
105
|
#tabs .tab.active:hover { background: #3352c4; }
|
|
105
106
|
.banner { display: none; align-items: center; gap: 12px; flex: none; padding: 8px 12px;
|
|
@@ -107,7 +108,41 @@ export function renderEditorShell(options) {
|
|
|
107
108
|
.banner.show { display: flex; }
|
|
108
109
|
#terrain-banner, #build-banner { background: #3d1f1c; border-bottom-color: #6b2f28; color: #f3c3bd; }
|
|
109
110
|
#build-banner code { font-family: ui-monospace, monospace; }
|
|
110
|
-
|
|
111
|
+
/* The game and the panel share one row, so the panel takes width from the game rather than
|
|
112
|
+
overlaying it — an overlay would cover the thing you opened the panel to watch. */
|
|
113
|
+
#stage { flex: 1; display: flex; min-height: 0; }
|
|
114
|
+
#frame { display: block; flex: 1; border: 0; min-width: 0; height: 100%; }
|
|
115
|
+
#assets { width: 264px; flex: none; overflow-y: auto; background: #131318;
|
|
116
|
+
border-left: 1px solid #26262e; }
|
|
117
|
+
body.assets-closed #assets { display: none; }
|
|
118
|
+
#assets-head { position: sticky; top: 0; display: flex; align-items: center; gap: 8px;
|
|
119
|
+
padding: 8px 10px; background: #17171d; border-bottom: 1px solid #26262e;
|
|
120
|
+
font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8a8a99; }
|
|
121
|
+
#assets-head .count { margin-left: auto; color: #6a6a7a; letter-spacing: 0; }
|
|
122
|
+
#assets-empty { padding: 14px 10px; color: #6a6a7a; }
|
|
123
|
+
.row { display: flex; gap: 9px; padding: 8px 10px; border-bottom: 1px solid #1e1e26; }
|
|
124
|
+
.row .thumb { width: 46px; height: 46px; flex: none; border-radius: 4px; background: #1e1e26;
|
|
125
|
+
display: flex; align-items: center; justify-content: center; font-size: 18px;
|
|
126
|
+
color: #55556a; overflow: hidden; }
|
|
127
|
+
.row .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
128
|
+
.row .body { min-width: 0; flex: 1; }
|
|
129
|
+
.row .name { color: #e6e6ea; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
130
|
+
.row .meta { color: #6a6a7a; font-size: 11px; overflow: hidden; text-overflow: ellipsis;
|
|
131
|
+
white-space: nowrap; }
|
|
132
|
+
.row .chip { display: inline-block; margin-left: 5px; padding: 0 5px; border-radius: 3px;
|
|
133
|
+
font-size: 10px; background: #2a2a34; color: #9a9aab; vertical-align: 1px; }
|
|
134
|
+
.row .chip.hq { background: #1f3a2a; color: #8fd0a4; }
|
|
135
|
+
.row .chip.placeholder { background: #3a3320; color: #d9c08a; }
|
|
136
|
+
/* A new asset during a forge is the whole show, so it announces itself and then settles. */
|
|
137
|
+
.row.fresh { animation: landed 2.4s ease-out; }
|
|
138
|
+
@keyframes landed { from { background: #22314f; } to { background: transparent; } }
|
|
139
|
+
.row.job .thumb { color: #d9a441; }
|
|
140
|
+
.row.job .message { color: #9a9aab; font-size: 11px; overflow: hidden; text-overflow: ellipsis;
|
|
141
|
+
white-space: nowrap; }
|
|
142
|
+
.row.job.failed .thumb { color: #e0564f; }
|
|
143
|
+
.row.job.failed .message { color: #e0938e; white-space: normal; }
|
|
144
|
+
.spin { display: inline-block; animation: spin 1.4s linear infinite; }
|
|
145
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
111
146
|
#hq-overlay { display: none; position: fixed; inset: 0; background: rgba(8,8,12,.72);
|
|
112
147
|
align-items: center; justify-content: center; z-index: 10; }
|
|
113
148
|
#hq-overlay.show { display: flex; }
|
|
@@ -138,6 +173,8 @@ export function renderEditorShell(options) {
|
|
|
138
173
|
<input id="auto-toggle" type="checkbox" checked> Auto-reload
|
|
139
174
|
</label>
|
|
140
175
|
<span id="status"><span id="dot"></span><span id="status-text">Loading…</span></span>
|
|
176
|
+
<button id="assets-toggle" type="button" title="Show the assets in this project and what is being generated">Assets</button>
|
|
177
|
+
<button id="shot" type="button" title="Copy the current frame to the clipboard">Screenshot</button>
|
|
141
178
|
<button id="reload" type="button">Reload</button>
|
|
142
179
|
</div>
|
|
143
180
|
<div id="build-banner" class="banner">
|
|
@@ -154,7 +191,15 @@ export function renderEditorShell(options) {
|
|
|
154
191
|
voxel changes will be lost. Discard them, or press Cancel in the terrain toolbar.</span>
|
|
155
192
|
<button id="terrain-discard" type="button">Discard terrain edits</button>
|
|
156
193
|
</div>
|
|
157
|
-
<
|
|
194
|
+
<div id="stage">
|
|
195
|
+
<iframe id="frame" allow="autoplay; fullscreen; xr-spatial-tracking; clipboard-write"></iframe>
|
|
196
|
+
<aside id="assets">
|
|
197
|
+
<div id="assets-head">Assets<span class="count" id="assets-count"></span></div>
|
|
198
|
+
<div id="assets-jobs"></div>
|
|
199
|
+
<div id="assets-list"></div>
|
|
200
|
+
<div id="assets-empty">Nothing yet. Assets appear here as they are generated.</div>
|
|
201
|
+
</aside>
|
|
202
|
+
</div>
|
|
158
203
|
<div id="hq-overlay">
|
|
159
204
|
<div id="hq-dialog">
|
|
160
205
|
<h2>Generate a high-quality <span id="hq-name"></span></h2>
|
|
@@ -179,6 +224,7 @@ export function renderEditorShell(options) {
|
|
|
179
224
|
var ACTIVE_EDIT_MS = ${ACTIVE_EDIT_WINDOW_MS};
|
|
180
225
|
var TAB_KEY = 'bitmagic.dev.tab';
|
|
181
226
|
var AUTO_KEY = 'bitmagic.dev.autoReload';
|
|
227
|
+
var ASSETS_KEY = 'bitmagic.dev.assets';
|
|
182
228
|
|
|
183
229
|
var frame = document.getElementById('frame');
|
|
184
230
|
var dot = document.getElementById('dot');
|
|
@@ -280,9 +326,8 @@ export function renderEditorShell(options) {
|
|
|
280
326
|
// Re-read rather than serving the copy captured at boot: an agent may have generated an
|
|
281
327
|
// asset since this page loaded, and a stale list is an empty asset palette with no
|
|
282
328
|
// explanation.
|
|
283
|
-
fetch('/api/
|
|
284
|
-
|
|
285
|
-
postFlat({ type: 'ASSETS_RESPONSE', assets: (data && data.assets) || [] });
|
|
329
|
+
fetch('/api/assets').then(function (r) { return r.json(); }).then(function (payload) {
|
|
330
|
+
postFlat({ type: 'ASSETS_RESPONSE', assets: withThumbnails(payload.assets || [], payload.previews || []) });
|
|
286
331
|
}).catch(function () {
|
|
287
332
|
postFlat({ type: 'ASSETS_RESPONSE', assets: [] });
|
|
288
333
|
});
|
|
@@ -515,6 +560,9 @@ export function renderEditorShell(options) {
|
|
|
515
560
|
loaded = false;
|
|
516
561
|
markersDirty = false;
|
|
517
562
|
gameState = '';
|
|
563
|
+
// A fresh iframe is a fresh engine with no memory of what was glowing, so the next push must
|
|
564
|
+
// not be suppressed as a repeat of what the PREVIOUS one was told.
|
|
565
|
+
lastGlowing = '';
|
|
518
566
|
banner.classList.remove('show');
|
|
519
567
|
terrainBanner.classList.remove('show');
|
|
520
568
|
setStatus('', 'Loading…');
|
|
@@ -557,6 +605,10 @@ export function renderEditorShell(options) {
|
|
|
557
605
|
|
|
558
606
|
loaded = true;
|
|
559
607
|
applyTab(activeTab);
|
|
608
|
+
// A boot almost always follows a world.json change, so the list has moved with it. Deliberately
|
|
609
|
+
// not resetting the seen-ids set: the asset that caused the reload is exactly the one worth
|
|
610
|
+
// flashing.
|
|
611
|
+
void refreshAssets();
|
|
560
612
|
}
|
|
561
613
|
|
|
562
614
|
async function flush() {
|
|
@@ -658,6 +710,438 @@ export function renderEditorShell(options) {
|
|
|
658
710
|
start();
|
|
659
711
|
}
|
|
660
712
|
|
|
713
|
+
/**
|
|
714
|
+
* The assets panel: what this project HAS, and what is being made for it right now.
|
|
715
|
+
*
|
|
716
|
+
* The second half is the reason it exists. Generating a game is mostly waiting — a forge bakes a
|
|
717
|
+
* level over twenty minutes, a prop takes a few — and during all of it the world on screen has
|
|
718
|
+
* not changed yet, so the dev view used to look identical to an idle one. The jobs come from
|
|
719
|
+
* '.bitmagic/jobs/', which every generate command writes, so a 'bitmagic generate' running in the
|
|
720
|
+
* creator's OTHER window shows up here with its live progress line.
|
|
721
|
+
*
|
|
722
|
+
* Fed by the sidecar's 'assets' SSE event (a bare "the list moved" ping — the panel re-fetches
|
|
723
|
+
* rather than the server diffing for it) with a slow poll behind it, because elapsed times have
|
|
724
|
+
* to tick even when nothing changed.
|
|
725
|
+
*/
|
|
726
|
+
var assetsOpen = true;
|
|
727
|
+
var assetsList = document.getElementById('assets-list');
|
|
728
|
+
var assetsJobs = document.getElementById('assets-jobs');
|
|
729
|
+
/** This page's own origin, for the absolute URL the game's asset palette needs. */
|
|
730
|
+
var SHELL_ORIGIN = window.location.origin;
|
|
731
|
+
var assetsEmpty = document.getElementById('assets-empty');
|
|
732
|
+
var assetsCount = document.getElementById('assets-count');
|
|
733
|
+
var assetsToggle = document.getElementById('assets-toggle');
|
|
734
|
+
// Ids rendered on the previous pass, so an asset that has just landed can announce itself. Null
|
|
735
|
+
// until the first render: on a cold open EVERY asset is new, and flashing all of them says
|
|
736
|
+
// nothing.
|
|
737
|
+
var seenAssetIds = null;
|
|
738
|
+
var lastJobs = [];
|
|
739
|
+
var assetsTimer = null;
|
|
740
|
+
|
|
741
|
+
var TYPE_GLYPHS = {
|
|
742
|
+
image: '🖼', skybox: '🖼',
|
|
743
|
+
glb: '🧊', gltf: '🧊', 'polygon-mesh': '🧊', vxl: '🧊', voxels: '🧊',
|
|
744
|
+
animation: '🏃', audio: '🔊', sound: '🔊', 'sound-effect': '🔊',
|
|
745
|
+
vwld: '🗺', spz: '✨'
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
function glyphFor(type) {
|
|
749
|
+
return TYPE_GLYPHS[String(type || '').toLowerCase()] || '📦';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function formatSize(bytes) {
|
|
753
|
+
if (typeof bytes !== 'number' || !isFinite(bytes) || bytes <= 0) return '';
|
|
754
|
+
if (bytes < 1024) return bytes + ' B';
|
|
755
|
+
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
|
|
756
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function formatElapsed(since) {
|
|
760
|
+
var ms = Date.now() - Date.parse(since);
|
|
761
|
+
if (!isFinite(ms) || ms < 0) return '';
|
|
762
|
+
var seconds = Math.floor(ms / 1000);
|
|
763
|
+
if (seconds < 60) return seconds + 's';
|
|
764
|
+
return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** When an asset was made, or 0 for the ones that came with the template. */
|
|
768
|
+
function madeAt(asset) {
|
|
769
|
+
var stamp = asset.hqGeneratedAt || (asset.production && asset.production.at);
|
|
770
|
+
var parsed = typeof stamp === 'string' ? Date.parse(stamp) : NaN;
|
|
771
|
+
return isFinite(parsed) ? parsed : 0;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Newest first, but only among assets that know when they were made.
|
|
776
|
+
*
|
|
777
|
+
* A forged world arrives with hundreds of undated assets and no meaningful order beyond the one
|
|
778
|
+
* world.json already gives them, so they keep it. The handful generated during this session are
|
|
779
|
+
* the ones the creator is looking for, and they go on top.
|
|
780
|
+
*/
|
|
781
|
+
function sortAssets(assets) {
|
|
782
|
+
var dated = [];
|
|
783
|
+
var undated = [];
|
|
784
|
+
for (var i = 0; i < assets.length; i++) {
|
|
785
|
+
(madeAt(assets[i]) > 0 ? dated : undated).push(assets[i]);
|
|
786
|
+
}
|
|
787
|
+
dated.sort(function (a, b) { return madeAt(b) - madeAt(a); });
|
|
788
|
+
return dated.concat(undated);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function el(tag, className, text) {
|
|
792
|
+
var node = document.createElement(tag);
|
|
793
|
+
if (className) node.className = className;
|
|
794
|
+
// textContent throughout: names, prompts and error strings are creator and model input, and
|
|
795
|
+
// this panel is not a place to start interpreting markup found in them.
|
|
796
|
+
if (text !== undefined) node.textContent = text;
|
|
797
|
+
return node;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function jobRow(job) {
|
|
801
|
+
var row = el('div', 'row job' + (job.status === 'failed' ? ' failed' : ''));
|
|
802
|
+
var thumb = el('div', 'thumb');
|
|
803
|
+
thumb.appendChild(el('span', job.status === 'failed' ? '' : 'spin', job.status === 'failed' ? '!' : '⟳'));
|
|
804
|
+
row.appendChild(thumb);
|
|
805
|
+
var body = el('div', 'body');
|
|
806
|
+
body.appendChild(el('div', 'name', job.label || job.kind));
|
|
807
|
+
var meta = el('div', 'meta', job.kind + (job.status === 'failed' ? '' : ' · ' + formatElapsed(job.startedAt)));
|
|
808
|
+
body.appendChild(meta);
|
|
809
|
+
body.appendChild(el('div', 'message', job.status === 'failed' ? (job.error || 'Failed') : (job.message || '')));
|
|
810
|
+
row.appendChild(body);
|
|
811
|
+
return row;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* The URL the engine should draw a thumbnail from, or null when there is nothing to draw.
|
|
816
|
+
*
|
|
817
|
+
* 'sourceGlbUrl' before 'url' because most CLI-lane meshes are voxelized: 'url' is then a .vxl,
|
|
818
|
+
* which the GLB preview renderer cannot read, while 'sourceGlbUrl' is the original the forger or
|
|
819
|
+
* the generator started from. An asset with neither — a sound, an animation, a level — keeps its
|
|
820
|
+
* glyph, which is the honest answer for something with no appearance.
|
|
821
|
+
*/
|
|
822
|
+
function previewSourceFor(asset) {
|
|
823
|
+
var url = asset.sourceGlbUrl || asset.url;
|
|
824
|
+
if (typeof url !== 'string' || url === '') return null;
|
|
825
|
+
// Double-escaped because this whole page is a template literal: a single backslash is consumed
|
|
826
|
+
// by the literal, and the browser would receive the invalid regex /.(glb|gltf)(?|$)/.
|
|
827
|
+
return /\\.(glb|gltf)(\\?|$)/i.test(url) ? url : null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function assetRow(asset, isFresh, hasPreview) {
|
|
831
|
+
var row = el('div', 'row' + (isFresh ? ' fresh' : ''));
|
|
832
|
+
var thumb = el('div', 'thumb');
|
|
833
|
+
var type = String(asset.type || '').toLowerCase();
|
|
834
|
+
if (type === 'image' && typeof asset.url === 'string' && asset.url !== '') {
|
|
835
|
+
// An image asset IS its own thumbnail — skyboxes and cover art included. Nothing to render.
|
|
836
|
+
var direct = document.createElement('img');
|
|
837
|
+
direct.src = asset.url;
|
|
838
|
+
direct.alt = '';
|
|
839
|
+
thumb.appendChild(direct);
|
|
840
|
+
} else if (hasPreview) {
|
|
841
|
+
var cached = document.createElement('img');
|
|
842
|
+
cached.src = '/api/assets/preview/' + encodeURIComponent(asset.id) + '.png';
|
|
843
|
+
cached.alt = '';
|
|
844
|
+
// A cached file that has since been deleted falls back rather than showing a broken image.
|
|
845
|
+
cached.onerror = function () { thumb.textContent = glyphFor(asset.type); };
|
|
846
|
+
thumb.appendChild(cached);
|
|
847
|
+
} else {
|
|
848
|
+
thumb.appendChild(el('span', '', glyphFor(asset.type)));
|
|
849
|
+
if (previewSourceFor(asset) !== null) queuePreview(asset);
|
|
850
|
+
}
|
|
851
|
+
row.appendChild(thumb);
|
|
852
|
+
|
|
853
|
+
var body = el('div', 'body');
|
|
854
|
+
var name = el('div', 'name', asset.name || asset.id || 'unnamed');
|
|
855
|
+
if (asset.hqGeneratedAt) name.appendChild(el('span', 'chip hq', 'HQ'));
|
|
856
|
+
else if (asset.placeholder === true) name.appendChild(el('span', 'chip placeholder', 'placeholder'));
|
|
857
|
+
body.appendChild(name);
|
|
858
|
+
var bits = [asset.type || 'unknown'];
|
|
859
|
+
var size = formatSize(asset.size);
|
|
860
|
+
if (size) bits.push(size);
|
|
861
|
+
body.appendChild(el('div', 'meta', bits.join(' · ')));
|
|
862
|
+
row.appendChild(body);
|
|
863
|
+
return row;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Jobs and assets render separately, and that split is not cosmetic.
|
|
868
|
+
*
|
|
869
|
+
* The job rows carry an elapsed time, so they have to be redrawn every second whether or not
|
|
870
|
+
* anything changed. The asset rows must NOT be: they hold <img> elements, a forged world has
|
|
871
|
+
* hundreds of them, and rebuilding that list once a second would refetch and visibly flicker
|
|
872
|
+
* every thumbnail for as long as a generation runs — which is precisely when the creator is
|
|
873
|
+
* looking at it. So the asset list is rebuilt only when the set it is showing actually moved.
|
|
874
|
+
*/
|
|
875
|
+
var lastAssetSignature = null;
|
|
876
|
+
|
|
877
|
+
function renderAssets(assets, jobs, previews) {
|
|
878
|
+
var sorted = sortAssets(assets);
|
|
879
|
+
var cached = {};
|
|
880
|
+
for (var p = 0; p < previews.length; p++) cached[previews[p]] = true;
|
|
881
|
+
|
|
882
|
+
assetsJobs.textContent = '';
|
|
883
|
+
for (var j = 0; j < jobs.length; j++) assetsJobs.appendChild(jobRow(jobs[j]));
|
|
884
|
+
|
|
885
|
+
var ids = {};
|
|
886
|
+
var signature = [];
|
|
887
|
+
for (var i = 0; i < sorted.length; i++) {
|
|
888
|
+
ids[sorted[i].id] = true;
|
|
889
|
+
// The preview state is part of the signature: a thumbnail landing is a change worth redrawing
|
|
890
|
+
// for, and it is the only one that alters a row without altering the asset set.
|
|
891
|
+
signature.push(sorted[i].id + (cached[sorted[i].id] ? '*' : ''));
|
|
892
|
+
}
|
|
893
|
+
var joined = signature.join(',');
|
|
894
|
+
|
|
895
|
+
if (joined !== lastAssetSignature) {
|
|
896
|
+
assetsList.textContent = '';
|
|
897
|
+
for (var k = 0; k < sorted.length; k++) {
|
|
898
|
+
var fresh = seenAssetIds !== null && !seenAssetIds[sorted[k].id];
|
|
899
|
+
assetsList.appendChild(assetRow(sorted[k], fresh, cached[sorted[k].id] === true));
|
|
900
|
+
}
|
|
901
|
+
seenAssetIds = ids;
|
|
902
|
+
lastAssetSignature = joined;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
lastJobs = jobs;
|
|
906
|
+
if (loaded) pushGeneratingAssets(jobs);
|
|
907
|
+
assetsCount.textContent = String(sorted.length);
|
|
908
|
+
assetsEmpty.style.display = (sorted.length === 0 && jobs.length === 0) ? 'block' : 'none';
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Ask the game to draw the thumbnails the cache is missing — one at a time, and never twice for
|
|
913
|
+
* the same asset in one session.
|
|
914
|
+
*
|
|
915
|
+
* Serial on purpose. Each render is a GLB fetch and a draw in the SAME renderer the creator is
|
|
916
|
+
* playing in, and a forged city has hundreds of assets: firing them all at once would stutter the
|
|
917
|
+
* game to decorate a side panel. 'attempted' covers the failures too, so an asset whose GLB 404s
|
|
918
|
+
* is asked for once rather than on every render pass forever.
|
|
919
|
+
*/
|
|
920
|
+
var previewQueue = [];
|
|
921
|
+
var previewAttempted = {};
|
|
922
|
+
var previewRunning = false;
|
|
923
|
+
var previewRequestId = 0;
|
|
924
|
+
|
|
925
|
+
function queuePreview(asset) {
|
|
926
|
+
if (previewAttempted[asset.id]) return;
|
|
927
|
+
previewAttempted[asset.id] = true;
|
|
928
|
+
previewQueue.push(asset);
|
|
929
|
+
void drainPreviewQueue();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function drainPreviewQueue() {
|
|
933
|
+
if (previewRunning) return;
|
|
934
|
+
previewRunning = true;
|
|
935
|
+
var stored = 0;
|
|
936
|
+
try {
|
|
937
|
+
while (previewQueue.length > 0) {
|
|
938
|
+
// The engine can only draw once it exists. Anything queued before then waits for the next
|
|
939
|
+
// pass rather than being dropped — refreshAssets re-queues on every render.
|
|
940
|
+
if (!loaded) return;
|
|
941
|
+
var asset = previewQueue.shift();
|
|
942
|
+
var source = previewSourceFor(asset);
|
|
943
|
+
if (source === null) continue;
|
|
944
|
+
|
|
945
|
+
previewRequestId += 1;
|
|
946
|
+
var requestId = 'preview-' + previewRequestId;
|
|
947
|
+
postFlat({ type: 'GENERATE_GLB_PREVIEW', requestId: requestId, glbUrl: source });
|
|
948
|
+
var response = await await_('GLB_PREVIEW_RESPONSE', 15000);
|
|
949
|
+
// Verified rather than assumed: a late answer from a request that already timed out would
|
|
950
|
+
// otherwise be cached under the NEXT asset's id, which is a wrong picture rather than a
|
|
951
|
+
// missing one — much the worse failure.
|
|
952
|
+
if (!response || response.requestId !== requestId) continue;
|
|
953
|
+
if (!response.success || !response.previewBase64) continue;
|
|
954
|
+
|
|
955
|
+
var saved = await (await fetch('/api/assets/preview', {
|
|
956
|
+
method: 'POST',
|
|
957
|
+
headers: { 'Content-Type': 'application/json' },
|
|
958
|
+
body: JSON.stringify({ assetId: asset.id, image: response.previewBase64 })
|
|
959
|
+
})).json();
|
|
960
|
+
if (saved.stored === true) stored += 1;
|
|
961
|
+
}
|
|
962
|
+
} catch (error) {
|
|
963
|
+
// A thumbnail is a decoration; losing the queue must not take the panel with it.
|
|
964
|
+
} finally {
|
|
965
|
+
previewRunning = false;
|
|
966
|
+
}
|
|
967
|
+
// Only when something actually landed. refreshAssets kicks this queue in turn, so an
|
|
968
|
+
// unconditional re-render here would be an endless render/drain ping-pong over an empty queue.
|
|
969
|
+
if (stored > 0) void refreshAssets();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function refreshAssets() {
|
|
973
|
+
if (!assetsOpen) return;
|
|
974
|
+
try {
|
|
975
|
+
var payload = await (await fetch('/api/assets')).json();
|
|
976
|
+
renderAssets(payload.assets || [], payload.jobs || [], payload.previews || []);
|
|
977
|
+
// Rendering is what discovers the missing thumbnails, and anything queued while the game was
|
|
978
|
+
// still booting has been sitting there since. Guarded on both sides, so this is a no-op
|
|
979
|
+
// whenever there is nothing to draw.
|
|
980
|
+
void drainPreviewQueue();
|
|
981
|
+
} catch (error) {
|
|
982
|
+
// The sidecar is this page's own server. If it is gone the reload button is the only useful
|
|
983
|
+
// action left, and the status bar already says so.
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* A slow tick behind the SSE push, for the two things an event cannot cover: elapsed times, which
|
|
989
|
+
* change with no write anywhere, and a missed event on a stream that dropped.
|
|
990
|
+
*/
|
|
991
|
+
function startAssetsTicker() {
|
|
992
|
+
if (assetsTimer) clearInterval(assetsTimer);
|
|
993
|
+
assetsTimer = setInterval(function () {
|
|
994
|
+
if (!assetsOpen) return;
|
|
995
|
+
// Cheap while idle: with nothing generating there is nothing whose label changes on its own.
|
|
996
|
+
if (lastJobs.length === 0) return;
|
|
997
|
+
void refreshAssets();
|
|
998
|
+
}, 1000);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Lend the cached thumbnails to the engine's own asset palette.
|
|
1003
|
+
*
|
|
1004
|
+
* Right-click in the editor offers "Add object", and that grid renders 'asset.screenshotUrl'
|
|
1005
|
+
* (SceneEditor.buildAssetGrid) — a field only the web Creator ever writes, so in a CLI project
|
|
1006
|
+
* every tile there reads "No Preview". The panel has just rendered those images anyway, so
|
|
1007
|
+
* handing them over on the way past costs nothing and needs no change under game/.
|
|
1008
|
+
*
|
|
1009
|
+
* Copies rather than mutating: this list is served straight out of world.json, and putting a
|
|
1010
|
+
* local URL onto the records the save path also reads would be a field the CLI invented leaking
|
|
1011
|
+
* into the creator's file.
|
|
1012
|
+
*/
|
|
1013
|
+
function withThumbnails(assets, previews) {
|
|
1014
|
+
var cached = {};
|
|
1015
|
+
for (var i = 0; i < previews.length; i++) cached[previews[i]] = true;
|
|
1016
|
+
return assets.map(function (asset) {
|
|
1017
|
+
if (asset.screenshotUrl || !cached[asset.id]) return asset;
|
|
1018
|
+
var copy = {};
|
|
1019
|
+
for (var key in asset) if (Object.prototype.hasOwnProperty.call(asset, key)) copy[key] = asset[key];
|
|
1020
|
+
copy.screenshotUrl = SHELL_ORIGIN + '/api/assets/preview/' + encodeURIComponent(asset.id) + '.png';
|
|
1021
|
+
return copy;
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Make the meshes being regenerated glow in the world itself.
|
|
1027
|
+
*
|
|
1028
|
+
* The engine has handled this message since before the CLI lane existed — HqGeneratingHandler
|
|
1029
|
+
* maps asset ids to their placed instances and drives HqGenerationGlow over them — and the web
|
|
1030
|
+
* Creator has always sent it. Nothing in the CLI ever did, so a 'generate prop' upgrading a
|
|
1031
|
+
* building gave no sign in the scene. The job records now carry the asset id, so it is free.
|
|
1032
|
+
*/
|
|
1033
|
+
var lastGlowing = '';
|
|
1034
|
+
function pushGeneratingAssets(jobs) {
|
|
1035
|
+
var ids = [];
|
|
1036
|
+
for (var i = 0; i < jobs.length; i++) {
|
|
1037
|
+
if (jobs[i].status === 'running' && jobs[i].assetId) ids.push(jobs[i].assetId);
|
|
1038
|
+
}
|
|
1039
|
+
// The engine walks every mesh on a change, so it is worth not repeating ourselves — its own
|
|
1040
|
+
// setGeneratingHqAssetIds returns early on an unchanged set, and this saves even the message.
|
|
1041
|
+
var key = ids.join(',');
|
|
1042
|
+
if (key === lastGlowing) return;
|
|
1043
|
+
lastGlowing = key;
|
|
1044
|
+
postFlat({ type: 'HQ_ASSET_GENERATING', assetIds: ids });
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function setAssetsOpen(open) {
|
|
1048
|
+
assetsOpen = open;
|
|
1049
|
+
document.body.classList.toggle('assets-closed', !open);
|
|
1050
|
+
writePref(ASSETS_KEY, open ? '1' : '0');
|
|
1051
|
+
if (open) void refreshAssets();
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Screenshot → clipboard.
|
|
1056
|
+
*
|
|
1057
|
+
* The shell cannot read the game's pixels itself: the iframe is a different origin, so its
|
|
1058
|
+
* canvas is off limits. The engine has to take the shot, and it already can — 'CAPTURE_SCREENSHOT'
|
|
1059
|
+
* answers with 'SCREENSHOT_RESPONSE {screenshot}', handling preserveDrawingBuffer and the 2D
|
|
1060
|
+
* genres' separate overlay canvas ('GameTemplate.captureScreenshot'). This is the Creator's own
|
|
1061
|
+
* capture path, borrowed whole.
|
|
1062
|
+
*
|
|
1063
|
+
* Two things stand between that data URL and the clipboard:
|
|
1064
|
+
*
|
|
1065
|
+
* 1. **The engine hands back JPEG; the clipboard only takes PNG.** Chrome rejects
|
|
1066
|
+
* 'image/jpeg' from 'clipboard.write' outright, so the frame is decoded and re-encoded here.
|
|
1067
|
+
* 2. **The write has to start inside the click.** Clipboard writes need transient user
|
|
1068
|
+
* activation, and the round-trip to the engine outlives it. 'ClipboardItem' accepts a
|
|
1069
|
+
* *promise* for exactly this case, so the write is issued synchronously and the blob lands
|
|
1070
|
+
* later — which is why 'captureGamePng()' is called before 'clipboard.write', not awaited
|
|
1071
|
+
* into it.
|
|
1072
|
+
*/
|
|
1073
|
+
var capturing = false;
|
|
1074
|
+
var shotButton = document.getElementById('shot');
|
|
1075
|
+
|
|
1076
|
+
async function captureGamePng() {
|
|
1077
|
+
post('CAPTURE_SCREENSHOT', { mode: 'immediate' });
|
|
1078
|
+
var response = await await_('SCREENSHOT_RESPONSE', 15000);
|
|
1079
|
+
if (!response) throw new Error('the game did not answer');
|
|
1080
|
+
// The engine reports its own failures as a null screenshot rather than staying silent.
|
|
1081
|
+
if (!response.screenshot) throw new Error('the engine could not capture a frame');
|
|
1082
|
+
|
|
1083
|
+
var image = new Image();
|
|
1084
|
+
await new Promise(function (resolve, reject) {
|
|
1085
|
+
image.onload = resolve;
|
|
1086
|
+
image.onerror = function () { reject(new Error('the captured frame could not be decoded')); };
|
|
1087
|
+
image.src = response.screenshot;
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
var canvas = document.createElement('canvas');
|
|
1091
|
+
canvas.width = image.naturalWidth;
|
|
1092
|
+
canvas.height = image.naturalHeight;
|
|
1093
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
1094
|
+
return await new Promise(function (resolve, reject) {
|
|
1095
|
+
canvas.toBlob(function (blob) {
|
|
1096
|
+
if (blob) resolve(blob);
|
|
1097
|
+
else reject(new Error('the frame could not be re-encoded as PNG'));
|
|
1098
|
+
}, 'image/png');
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** The fallback when the clipboard refuses. A file beats a button that did nothing. */
|
|
1103
|
+
function downloadPng(blob) {
|
|
1104
|
+
var url = URL.createObjectURL(blob);
|
|
1105
|
+
var link = document.createElement('a');
|
|
1106
|
+
link.href = url;
|
|
1107
|
+
link.download = 'bitmagic-' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
|
|
1108
|
+
link.click();
|
|
1109
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function finishShot(kind, text) {
|
|
1113
|
+
capturing = false;
|
|
1114
|
+
shotButton.disabled = false;
|
|
1115
|
+
setStatus(kind, text);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function takeScreenshot() {
|
|
1119
|
+
if (!loaded) { setStatus('error', 'Nothing to capture yet'); return; }
|
|
1120
|
+
if (capturing) return;
|
|
1121
|
+
capturing = true;
|
|
1122
|
+
shotButton.disabled = true;
|
|
1123
|
+
setStatus('saving', 'Capturing…');
|
|
1124
|
+
|
|
1125
|
+
var png = captureGamePng();
|
|
1126
|
+
// Not awaited before the write — see note 2 above.
|
|
1127
|
+
var canWrite = typeof ClipboardItem !== 'undefined' && navigator.clipboard && navigator.clipboard.write;
|
|
1128
|
+
var written = canWrite
|
|
1129
|
+
? navigator.clipboard.write([new ClipboardItem({ 'image/png': png })])
|
|
1130
|
+
: Promise.reject(new Error('this browser cannot put images on the clipboard'));
|
|
1131
|
+
|
|
1132
|
+
written.then(function () {
|
|
1133
|
+
finishShot('saved', 'Screenshot copied');
|
|
1134
|
+
}).catch(function (error) {
|
|
1135
|
+
// Firefox has no promise-valued ClipboardItem, and any browser may refuse the write.
|
|
1136
|
+
png.then(downloadPng).then(function () {
|
|
1137
|
+
finishShot('saved', 'Clipboard refused it — saved to your downloads');
|
|
1138
|
+
}).catch(function (captureError) {
|
|
1139
|
+
// The capture itself failed, so there was never an image; report that, not the clipboard.
|
|
1140
|
+
finishShot('error', 'Screenshot failed: ' + (captureError.message || error.message));
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
661
1145
|
/**
|
|
662
1146
|
* Retire the running poll.
|
|
663
1147
|
*
|
|
@@ -709,6 +1193,12 @@ export function renderEditorShell(options) {
|
|
|
709
1193
|
try { payload = JSON.parse(event.data); } catch (error) { /* reason is a label */ }
|
|
710
1194
|
onChangeSignal(payload.reason || 'change');
|
|
711
1195
|
});
|
|
1196
|
+
// A bare "the list moved" ping: a generation started, made progress, failed, or world.json was
|
|
1197
|
+
// rewritten. The panel re-reads rather than the server sending a diff, which keeps the whole
|
|
1198
|
+
// question of what changed on the side that is about to render it.
|
|
1199
|
+
events.addEventListener('assets', function () {
|
|
1200
|
+
void refreshAssets();
|
|
1201
|
+
});
|
|
712
1202
|
events.addEventListener('build', function (event) {
|
|
713
1203
|
var payload = {};
|
|
714
1204
|
try { payload = JSON.parse(event.data); } catch (error) { /* see above */ }
|
|
@@ -723,6 +1213,7 @@ export function renderEditorShell(options) {
|
|
|
723
1213
|
});
|
|
724
1214
|
}
|
|
725
1215
|
|
|
1216
|
+
shotButton.addEventListener('click', takeScreenshot);
|
|
726
1217
|
document.getElementById('reload').addEventListener('click', function () { start(); });
|
|
727
1218
|
document.getElementById('banner-reload').addEventListener('click', function () { start(); });
|
|
728
1219
|
document.getElementById('terrain-discard').addEventListener('click', function () {
|
|
@@ -741,8 +1232,12 @@ export function renderEditorShell(options) {
|
|
|
741
1232
|
if (autoReload) banner.classList.remove('show');
|
|
742
1233
|
});
|
|
743
1234
|
|
|
1235
|
+
assetsToggle.addEventListener('click', function () { setAssetsOpen(!assetsOpen); });
|
|
1236
|
+
|
|
744
1237
|
autoReload = readPref(AUTO_KEY, '1') !== '0';
|
|
745
1238
|
autoToggle.checked = autoReload;
|
|
1239
|
+
setAssetsOpen(readPref(ASSETS_KEY, '1') !== '0');
|
|
1240
|
+
startAssetsTicker();
|
|
746
1241
|
// Applied through selectTab so the button states and the stored preference cannot disagree with
|
|
747
1242
|
// the variable. 'loaded' is false here, so it only sets up the UI — boot() applies the mode.
|
|
748
1243
|
activeTab = readPref(TAB_KEY, 'game') === 'editor' ? 'editor' : 'game';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;GAMG;AACH,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAEnC,kGAAkG;AAClG,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC3B,CAAC;AASD,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,OAAO,GAAG,oBAAoB,OAAO,CAAC,QAAQ,2BAA2B,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACpH,OAAO;;;;wBAIe,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC
|
|
1
|
+
{"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;GAMG;AACH,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAEnC,kGAAkG;AAClG,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC3B,CAAC;AASD,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,OAAO,GAAG,oBAAoB,OAAO,CAAC,QAAQ,2BAA2B,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACpH,OAAO;;;;wBAIe,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBA2F3B,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBAwD9B,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;yBACT,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmgC7C,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Which command is running. Shown as-is in the dev view, so these are creator-facing words. */
|
|
2
|
+
export type JobKind = 'skybox' | 'sound' | 'image' | 'character' | 'animation' | 'prop' | 'vehicle' | 'forge' | 'cover' | 'hq';
|
|
3
|
+
export interface JobRecord {
|
|
4
|
+
jobId: string;
|
|
5
|
+
kind: JobKind;
|
|
6
|
+
/** What the creator asked for — a prompt or a description. Trimmed for display, not parsed. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Known up front only for the kinds that upgrade an asset in place (prop, vehicle, hq). */
|
|
9
|
+
assetId?: string;
|
|
10
|
+
status: 'running' | 'failed';
|
|
11
|
+
/** The generation's most recent progress line. See `withJobRecord`. */
|
|
12
|
+
message: string;
|
|
13
|
+
pid: number;
|
|
14
|
+
startedAt: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const JOBS_DIR: string;
|
|
19
|
+
export declare function jobsDirPath(root: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Every job this project knows about, newest first, with dead `running` records reported as
|
|
22
|
+
* failed rather than left spinning in the dev view forever.
|
|
23
|
+
*
|
|
24
|
+
* Unreadable and unparseable files are skipped, not thrown: a record being written right now is a
|
|
25
|
+
* normal thing for a directory that several processes append to, and losing one tick of one job is
|
|
26
|
+
* not worth failing a read that also carries five healthy ones.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readJobs(root: string): JobRecord[];
|
|
29
|
+
/**
|
|
30
|
+
* Drop failed records older than a day. Called by `dev` at startup rather than on a timer: this is
|
|
31
|
+
* housekeeping, and the moment someone opens the dev view is the moment a day-old failure stops
|
|
32
|
+
* being worth showing.
|
|
33
|
+
*/
|
|
34
|
+
export declare function pruneJobs(root: string, now?: number): number;
|
|
35
|
+
export interface JobHandle {
|
|
36
|
+
readonly jobId: string;
|
|
37
|
+
/** Record the latest progress line. */
|
|
38
|
+
update(message: string): void;
|
|
39
|
+
}
|
|
40
|
+
export interface StartJobOptions {
|
|
41
|
+
root: string;
|
|
42
|
+
kind: JobKind;
|
|
43
|
+
label: string;
|
|
44
|
+
assetId?: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function startJob(options: StartJobOptions): JobHandle;
|
|
47
|
+
/**
|
|
48
|
+
* Run `body` with its progress recorded for anyone watching.
|
|
49
|
+
*
|
|
50
|
+
* The `log` handed to the body is the caller's own logger with a tap on it, so every line a command
|
|
51
|
+
* already prints also becomes the job's current message. That is deliberate reuse rather than a
|
|
52
|
+
* second progress vocabulary to keep in step — the dev sidecar's in-process HQ job has worked this
|
|
53
|
+
* way since it shipped, and it means a command gains live progress in the dev view without a single
|
|
54
|
+
* new call site inside it.
|
|
55
|
+
*
|
|
56
|
+
* **Never fails the generation it is watching.** Bookkeeping that breaks a paid, minutes-long
|
|
57
|
+
* generation would be a far worse bug than the missing progress line it is trying to provide, so
|
|
58
|
+
* every filesystem error here is swallowed. The body's own errors propagate untouched, after being
|
|
59
|
+
* recorded.
|
|
60
|
+
*/
|
|
61
|
+
export declare function withJobRecord<T>(options: StartJobOptions & {
|
|
62
|
+
log: (message: string) => void;
|
|
63
|
+
}, body: (log: (message: string) => void) => Promise<T>): Promise<T>;
|
|
64
|
+
/** Success: the asset is in world.json, and that is the record. */
|
|
65
|
+
export declare function removeJob(root: string, jobId: string): void;
|
|
66
|
+
/** Failure: nothing else records it, so the record stays and carries the reason. */
|
|
67
|
+
export declare function failJob(root: string, jobId: string, error: string): void;
|