tileflow 0.1.0-alpha.11 → 0.1.0-alpha.12
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 +17 -0
- package/dist/index.js +339 -8
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -151,6 +151,23 @@ on that route. Capture does not start the server or discover ports. It accepts l
|
|
|
151
151
|
selects exactly one target, verifies the concrete theme, and waits for the component's idle marker.
|
|
152
152
|
Do not run a separate `tileflow preview` server beside the application.
|
|
153
153
|
|
|
154
|
+
## Render a timed shot
|
|
155
|
+
|
|
156
|
+
Commit a [capture shot](https://github.com/tileflow/tileflow-sdk/blob/main/packages/core/docs/capture-shots.md)
|
|
157
|
+
under `shots` on the same map: a concrete theme, a viewport, and two or more center-camera
|
|
158
|
+
keyframes, optionally with a theme blend. Then render it:
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
npx tileflow capture --shot dusk-flyover --json
|
|
162
|
+
npx tileflow capture --shot dusk-flyover --format frames --out-dir evidence --json
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The first command writes `.tileflow/captures/shots/dusk-flyover.webm`; the second writes
|
|
166
|
+
`evidence/dusk-flyover/frame-0000.png` and the following frames. Each output has a sibling
|
|
167
|
+
`.receipt.json` shot receipt. Repeat `--shot` or use `--all-shots` to render several shots; `--out`
|
|
168
|
+
names the `.webm` file or frame directory of a single shot. Every frame waits for the map to be
|
|
169
|
+
ready, so a slow machine renders more slowly without dropping frames.
|
|
170
|
+
|
|
154
171
|
## Review pixels without accepting changes
|
|
155
172
|
|
|
156
173
|
After reviewing a render, create its baseline. Compare subsequent renders separately:
|
package/dist/index.js
CHANGED
|
@@ -649,6 +649,7 @@ import { isAbsolute, relative as relative2, resolve as resolve3, sep as sep2 } f
|
|
|
649
649
|
import {
|
|
650
650
|
createTileflowCaptureSession,
|
|
651
651
|
serializeTileflowCaptureReceipt,
|
|
652
|
+
serializeTileflowShotReceipt,
|
|
652
653
|
setupTileflowCaptureBrowser,
|
|
653
654
|
TileflowCaptureError,
|
|
654
655
|
tileflowSyntheticAssetOrigin,
|
|
@@ -658,7 +659,8 @@ import {
|
|
|
658
659
|
compareCodeUnits as compareCodeUnits3,
|
|
659
660
|
normalizeTileflowCaptureScene,
|
|
660
661
|
tileflowCaptureSceneNameSchema,
|
|
661
|
-
tileflowCaptureSceneSchema
|
|
662
|
+
tileflowCaptureSceneSchema,
|
|
663
|
+
tileflowCaptureShotNameSchema
|
|
662
664
|
} from "@tileflow/core";
|
|
663
665
|
import {
|
|
664
666
|
createTileflowArtifactDiagnostics,
|
|
@@ -670,8 +672,8 @@ import { TileflowStyleValidationError } from "@tileflow/dev/validation";
|
|
|
670
672
|
|
|
671
673
|
// src/capture-output.ts
|
|
672
674
|
import { randomUUID } from "crypto";
|
|
673
|
-
import { lstat, mkdir as mkdir2, open as open2, readFile as readFile2, rename as rename2, unlink as unlink2 } from "fs/promises";
|
|
674
|
-
import { basename, dirname as dirname3, relative, resolve as resolve2, sep } from "path";
|
|
675
|
+
import { lstat, mkdir as mkdir2, open as open2, readdir, readFile as readFile2, rename as rename2, rm, unlink as unlink2 } from "fs/promises";
|
|
676
|
+
import { basename, dirname as dirname3, join, relative, resolve as resolve2, sep } from "path";
|
|
675
677
|
var maximumAtomicArtifactBytes = 256 * 1024 * 1024;
|
|
676
678
|
async function writeCapturePair(input) {
|
|
677
679
|
const outputPath = resolve2(input.outputPath);
|
|
@@ -815,6 +817,119 @@ async function writeAtomicFile(input) {
|
|
|
815
817
|
await removeOptional(temporary);
|
|
816
818
|
}
|
|
817
819
|
}
|
|
820
|
+
var shotFrameFilePattern = /^frame-\d{4}\.png$/u;
|
|
821
|
+
function shotFrameFileName(index) {
|
|
822
|
+
return `frame-${String(index).padStart(4, "0")}.png`;
|
|
823
|
+
}
|
|
824
|
+
async function stageShotFrameDirectory(input) {
|
|
825
|
+
const boundaryPath = resolve2(input.boundaryPath);
|
|
826
|
+
const directory = resolve2(input.directory);
|
|
827
|
+
await assertNoSymlinkComponents(dirname3(directory), boundaryPath);
|
|
828
|
+
await listShotFrameDirectory(directory);
|
|
829
|
+
await mkdir2(dirname3(directory), { recursive: true });
|
|
830
|
+
await assertNoSymlinkComponents(dirname3(directory), boundaryPath);
|
|
831
|
+
const staging = temporarySibling(directory, `${process.pid}.${randomUUID()}`, "frames");
|
|
832
|
+
await mkdir2(staging);
|
|
833
|
+
return {
|
|
834
|
+
boundaryPath,
|
|
835
|
+
directory,
|
|
836
|
+
staging,
|
|
837
|
+
write: (index, png) => writeSyncedExclusive(join(staging, shotFrameFileName(index)), png),
|
|
838
|
+
discard: () => rm(staging, { force: true, recursive: true })
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
async function commitShotFrameDirectory(input) {
|
|
842
|
+
const { stage } = input;
|
|
843
|
+
const receiptPath = resolve2(input.receiptPath);
|
|
844
|
+
const receipt = new TextEncoder().encode(input.receipt);
|
|
845
|
+
await assertNoSymlinkComponents(dirname3(stage.directory), stage.boundaryPath);
|
|
846
|
+
await assertNoSymlinkComponents(dirname3(receiptPath), stage.boundaryPath);
|
|
847
|
+
await assertNotSymlink(receiptPath);
|
|
848
|
+
const existingFrames = await listShotFrameDirectory(stage.directory);
|
|
849
|
+
const existingReceipt = await readOptional(receiptPath);
|
|
850
|
+
if (existingFrames && existingReceipt && Buffer.from(existingReceipt).equals(Buffer.from(receipt)) && await sameShotFrames(stage.directory, existingFrames, stage.staging)) {
|
|
851
|
+
await stage.discard();
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
if ((existingFrames || existingReceipt) && !input.managed && !input.force) {
|
|
855
|
+
throw new Error(
|
|
856
|
+
"Shot output already exists with different contents. Use --force to replace it."
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
const transaction = `${process.pid}.${randomUUID()}`;
|
|
860
|
+
const receiptTemporary = temporarySibling(receiptPath, transaction, "new");
|
|
861
|
+
const directoryBackup = temporarySibling(stage.directory, transaction, "previous");
|
|
862
|
+
const receiptBackup = temporarySibling(receiptPath, transaction, "previous");
|
|
863
|
+
const state = {
|
|
864
|
+
directoryBackedUp: false,
|
|
865
|
+
directoryInstalled: false,
|
|
866
|
+
receiptBackedUp: false,
|
|
867
|
+
receiptInstalled: false
|
|
868
|
+
};
|
|
869
|
+
try {
|
|
870
|
+
await writeSyncedExclusive(receiptTemporary, receipt);
|
|
871
|
+
await syncDirectory2(stage.staging);
|
|
872
|
+
if (existingFrames) {
|
|
873
|
+
await rename2(stage.directory, directoryBackup);
|
|
874
|
+
state.directoryBackedUp = true;
|
|
875
|
+
}
|
|
876
|
+
if (existingReceipt) {
|
|
877
|
+
await rename2(receiptPath, receiptBackup);
|
|
878
|
+
state.receiptBackedUp = true;
|
|
879
|
+
}
|
|
880
|
+
await rename2(stage.staging, stage.directory);
|
|
881
|
+
state.directoryInstalled = true;
|
|
882
|
+
await rename2(receiptTemporary, receiptPath);
|
|
883
|
+
state.receiptInstalled = true;
|
|
884
|
+
await syncDirectory2(dirname3(stage.directory));
|
|
885
|
+
if (dirname3(receiptPath) !== dirname3(stage.directory))
|
|
886
|
+
await syncDirectory2(dirname3(receiptPath));
|
|
887
|
+
} catch (error) {
|
|
888
|
+
if (state.receiptInstalled) await removeOptional(receiptPath).catch(() => void 0);
|
|
889
|
+
if (state.receiptBackedUp) await rename2(receiptBackup, receiptPath).catch(() => void 0);
|
|
890
|
+
if (state.directoryInstalled) {
|
|
891
|
+
await rename2(stage.directory, stage.staging).catch(() => void 0);
|
|
892
|
+
}
|
|
893
|
+
if (state.directoryBackedUp) {
|
|
894
|
+
await rename2(directoryBackup, stage.directory).catch(() => void 0);
|
|
895
|
+
}
|
|
896
|
+
throw error;
|
|
897
|
+
} finally {
|
|
898
|
+
await removeOptional(receiptTemporary).catch(() => void 0);
|
|
899
|
+
}
|
|
900
|
+
await Promise.allSettled([
|
|
901
|
+
rm(directoryBackup, { force: true, recursive: true }),
|
|
902
|
+
removeOptional(receiptBackup)
|
|
903
|
+
]);
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
async function listShotFrameDirectory(directory) {
|
|
907
|
+
const info = await lstatOptional(directory);
|
|
908
|
+
if (!info) return void 0;
|
|
909
|
+
if (info.isSymbolicLink()) throw new Error("Capture output cannot be a symbolic link.");
|
|
910
|
+
if (!info.isDirectory()) throw new Error("Shot frame output must be a directory.");
|
|
911
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
912
|
+
if (entries.some((entry) => !entry.isFile() || !shotFrameFilePattern.test(entry.name))) {
|
|
913
|
+
throw new Error(
|
|
914
|
+
"Shot frame output contains files that are not Tileflow frames. Choose an empty or Tileflow-owned directory."
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
return entries.map((entry) => entry.name).sort();
|
|
918
|
+
}
|
|
919
|
+
async function sameShotFrames(directory, names, staging) {
|
|
920
|
+
const staged = (await readdir(staging)).sort();
|
|
921
|
+
if (staged.length !== names.length || staged.some((name, index) => name !== names[index])) {
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
for (const name of names) {
|
|
925
|
+
const [existing, next] = await Promise.all([
|
|
926
|
+
readOptional(join(directory, name)),
|
|
927
|
+
readOptional(join(staging, name))
|
|
928
|
+
]);
|
|
929
|
+
if (!existing || !next || !Buffer.from(existing).equals(Buffer.from(next))) return false;
|
|
930
|
+
}
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
818
933
|
function captureReceiptPath(outputPath) {
|
|
819
934
|
return outputPath.toLowerCase().endsWith(".png") ? `${outputPath.slice(0, -4)}.receipt.json` : `${outputPath}.receipt.json`;
|
|
820
935
|
}
|
|
@@ -955,7 +1070,14 @@ function registerCaptureCommands(program2, dependencies) {
|
|
|
955
1070
|
controller.close();
|
|
956
1071
|
}
|
|
957
1072
|
});
|
|
958
|
-
program2.command("capture").description(
|
|
1073
|
+
program2.command("capture").description(
|
|
1074
|
+
"Render Tileflow scenes to PNG, or timed shots to video, with a pinned headless browser"
|
|
1075
|
+
).argument("[scenes...]", "named committed capture scenes").option("-c, --config <path>", "config path", dependencies.defaultConfigPath).option("--all", "capture every configured scene").option(
|
|
1076
|
+
"--shot <name>",
|
|
1077
|
+
"render a committed timed shot; repeat for more shots",
|
|
1078
|
+
collectOption,
|
|
1079
|
+
[]
|
|
1080
|
+
).option("--all-shots", "render every committed timed shot").option("--format <format>", "shot output: webm (default) or frames").option("--map <name>", "capture one map with an uncommitted camera").option("--theme <name>", "required concrete theme for exploratory --map capture").option("--center <lng,lat>", "exploratory center camera").option("--bounds <west,south,east,north>", "exploratory bounds camera").option("--zoom <number>", "exploratory center zoom").option("--bearing <number>", "exploratory camera bearing").option("--pitch <number>", "exploratory camera pitch").option("--width <pixels>", "exploratory viewport width").option("--height <pixels>", "exploratory viewport height").option("--dpr <ratio>", "exploratory device pixel ratio: 1 or 2").option("--app-origin <origin>", "loopback origin for committed application scenes").option("--url <url>", "one-off full loopback URL for exactly one named scene").option("--selector <selector>", "one-off application target selector").option("--frame <frame>", "application capture frame: map or viewport").option("--out <path>", "PNG path for one scene, or .webm/frame directory for one shot").option("--out-dir <path>", "directory for one or more captures").option("--force", "replace different explicit output files").option("--json", "print deterministic schema-version-1 JSON").option("--watch", "keep running and recapture after each valid local edit").option(
|
|
959
1081
|
"--no-browser-install",
|
|
960
1082
|
"require the pinned browser to be prepared; never use system Chrome"
|
|
961
1083
|
).addHelpText(
|
|
@@ -965,11 +1087,205 @@ function registerCaptureCommands(program2, dependencies) {
|
|
|
965
1087
|
try {
|
|
966
1088
|
await runCaptureCommand(scenes ?? [], options);
|
|
967
1089
|
} catch (error) {
|
|
968
|
-
printCaptureError(error, {
|
|
1090
|
+
printCaptureError(error, {
|
|
1091
|
+
command: isShotSelection(options) ? "capture.shot" : "capture",
|
|
1092
|
+
json: options.json
|
|
1093
|
+
});
|
|
969
1094
|
process.exitCode = 1;
|
|
970
1095
|
}
|
|
971
1096
|
});
|
|
972
1097
|
}
|
|
1098
|
+
function collectOption(value, previous) {
|
|
1099
|
+
return [...previous, value];
|
|
1100
|
+
}
|
|
1101
|
+
function isShotSelection(options) {
|
|
1102
|
+
return Boolean(options.allShots) || (options.shot?.length ?? 0) > 0;
|
|
1103
|
+
}
|
|
1104
|
+
function createTileflowShotCaptureJson(shots, cwd) {
|
|
1105
|
+
return {
|
|
1106
|
+
schemaVersion: 1,
|
|
1107
|
+
command: "capture.shot",
|
|
1108
|
+
shots: [...shots].sort((left, right) => compareCodeUnits3(left.capture.shot, right.capture.shot)).map(({ capture, outputPath, receiptPath }) => ({
|
|
1109
|
+
shot: capture.shot,
|
|
1110
|
+
map: capture.map,
|
|
1111
|
+
theme: capture.theme,
|
|
1112
|
+
status: "captured",
|
|
1113
|
+
format: capture.format,
|
|
1114
|
+
...capture.codec ? { codec: capture.codec } : {},
|
|
1115
|
+
outputPath: relativePath(cwd, outputPath),
|
|
1116
|
+
receiptPath: relativePath(cwd, receiptPath),
|
|
1117
|
+
sha256: capture.sha256,
|
|
1118
|
+
framesSha256: capture.framesSha256,
|
|
1119
|
+
frameCount: capture.frameCount,
|
|
1120
|
+
fps: capture.fps,
|
|
1121
|
+
durationMs: capture.durationMs,
|
|
1122
|
+
width: capture.width,
|
|
1123
|
+
height: capture.height,
|
|
1124
|
+
dpr: capture.dpr,
|
|
1125
|
+
renderer: capture.renderer,
|
|
1126
|
+
networkDependent: capture.networkDependent,
|
|
1127
|
+
warnings: [...capture.warnings].sort(compareCodeUnits3)
|
|
1128
|
+
}))
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
function serializeTileflowShotCaptureJson(value) {
|
|
1132
|
+
return `${JSON.stringify(value, null, 2)}
|
|
1133
|
+
`;
|
|
1134
|
+
}
|
|
1135
|
+
function shotReceiptPath(outputPath) {
|
|
1136
|
+
return outputPath.toLowerCase().endsWith(".webm") ? `${outputPath.slice(0, -5)}.receipt.json` : `${outputPath}.receipt.json`;
|
|
1137
|
+
}
|
|
1138
|
+
async function runCaptureShotCommand(options) {
|
|
1139
|
+
const cwd = process.cwd();
|
|
1140
|
+
const format = options.format === "frames" ? "frames" : "webm";
|
|
1141
|
+
const controller = installSignalAbortController();
|
|
1142
|
+
const session = createTileflowCaptureSession({
|
|
1143
|
+
allowBrowserInstall: options.browserInstall,
|
|
1144
|
+
config: options.config,
|
|
1145
|
+
cwd,
|
|
1146
|
+
onBrowserInstallProgress: options.json ? void 0 : printInstallProgress,
|
|
1147
|
+
signal: controller.signal
|
|
1148
|
+
});
|
|
1149
|
+
const stages = /* @__PURE__ */ new Map();
|
|
1150
|
+
try {
|
|
1151
|
+
const onFrame = format === "frames" ? async (frame) => {
|
|
1152
|
+
let stage = stages.get(frame.shot);
|
|
1153
|
+
if (!stage) {
|
|
1154
|
+
const target = resolveShotOutputTarget(frame.shot, options, format, cwd);
|
|
1155
|
+
stage = await stageShotFrameDirectory({
|
|
1156
|
+
boundaryPath: target.boundaryPath,
|
|
1157
|
+
directory: target.outputPath
|
|
1158
|
+
});
|
|
1159
|
+
stages.set(frame.shot, stage);
|
|
1160
|
+
}
|
|
1161
|
+
await stage.write(frame.index, frame.png);
|
|
1162
|
+
} : void 0;
|
|
1163
|
+
const shotOptions = { format, ...onFrame ? { onFrame } : {}, signal: controller.signal };
|
|
1164
|
+
const result = options.allShots ? await session.captureAllShots(shotOptions) : await session.captureShots(options.shot ?? [], shotOptions);
|
|
1165
|
+
const written = [];
|
|
1166
|
+
for (const capture of result.shots) {
|
|
1167
|
+
const target = resolveShotOutputTarget(capture.shot, options, format, cwd);
|
|
1168
|
+
const receipt = serializeTileflowShotReceipt(capture.receipt);
|
|
1169
|
+
if (capture.video) {
|
|
1170
|
+
await writeAtomicFileSet({
|
|
1171
|
+
boundaryPath: target.boundaryPath,
|
|
1172
|
+
files: [
|
|
1173
|
+
{ path: target.outputPath, source: capture.video },
|
|
1174
|
+
{ path: target.receiptPath, source: receipt }
|
|
1175
|
+
],
|
|
1176
|
+
force: Boolean(options.force),
|
|
1177
|
+
label: "Shot output",
|
|
1178
|
+
managed: target.managed
|
|
1179
|
+
});
|
|
1180
|
+
} else {
|
|
1181
|
+
const stage = stages.get(capture.shot);
|
|
1182
|
+
if (!stage) throw new Error(`Shot ${capture.shot} produced no frames.`);
|
|
1183
|
+
await commitShotFrameDirectory({
|
|
1184
|
+
force: Boolean(options.force),
|
|
1185
|
+
managed: target.managed,
|
|
1186
|
+
receipt,
|
|
1187
|
+
receiptPath: target.receiptPath,
|
|
1188
|
+
stage
|
|
1189
|
+
});
|
|
1190
|
+
stages.delete(capture.shot);
|
|
1191
|
+
}
|
|
1192
|
+
written.push({ capture, outputPath: target.outputPath, receiptPath: target.receiptPath });
|
|
1193
|
+
}
|
|
1194
|
+
if (!options.out && !options.outDir) await warnIfManagedCapturesAreNotIgnored(cwd);
|
|
1195
|
+
const document = createTileflowShotCaptureJson(written, cwd);
|
|
1196
|
+
if (options.json) {
|
|
1197
|
+
process.stdout.write(serializeTileflowShotCaptureJson(document));
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
for (const entry of document.shots) {
|
|
1201
|
+
console.log(
|
|
1202
|
+
`Rendered ${entry.shot} -> ${entry.outputPath} (${entry.frameCount} frames, ${entry.sha256.slice(0, 12)}).`
|
|
1203
|
+
);
|
|
1204
|
+
if (entry.networkDependent) {
|
|
1205
|
+
console.error(`Shot ${entry.shot} used remote map resources.`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
} finally {
|
|
1209
|
+
await Promise.allSettled([...stages.values()].map((stage) => stage.discard()));
|
|
1210
|
+
await session.close();
|
|
1211
|
+
controller.close();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
function resolveShotOutputTarget(shot, options, format, cwd) {
|
|
1215
|
+
const managed = !options.out && !options.outDir;
|
|
1216
|
+
const directory = resolve3(cwd, options.outDir ?? ".tileflow/captures/shots");
|
|
1217
|
+
const outputPath = options.out ? resolve3(cwd, options.out) : resolve3(directory, format === "webm" ? `${shot}.webm` : shot);
|
|
1218
|
+
const receiptPath = shotReceiptPath(outputPath);
|
|
1219
|
+
if (managed) {
|
|
1220
|
+
const relativeToRoot = relative2(directory, outputPath);
|
|
1221
|
+
if (relativeToRoot.startsWith("..") || relativeToRoot.includes(`..${sep2}`)) {
|
|
1222
|
+
throw new Error("Managed shot output escaped .tileflow/captures/shots.");
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
relativePath(cwd, outputPath);
|
|
1226
|
+
relativePath(cwd, receiptPath);
|
|
1227
|
+
return {
|
|
1228
|
+
boundaryPath: managed ? cwd : options.outDir ? directory : resolve3(outputPath, ".."),
|
|
1229
|
+
managed,
|
|
1230
|
+
outputPath,
|
|
1231
|
+
receiptPath
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
function validateShotSelectionOptions(positionalScenes, options) {
|
|
1235
|
+
const shots = options.shot ?? [];
|
|
1236
|
+
if (options.allShots && shots.length > 0) {
|
|
1237
|
+
throw new Error("Use either --shot or --all-shots, not both.");
|
|
1238
|
+
}
|
|
1239
|
+
if (positionalScenes.length > 0 || options.all || options.map) {
|
|
1240
|
+
throw new Error("Render shots separately from scenes; --shot and --all-shots take no scenes.");
|
|
1241
|
+
}
|
|
1242
|
+
if (options.watch) {
|
|
1243
|
+
throw new Error("--watch captures scenes; it does not render shots.");
|
|
1244
|
+
}
|
|
1245
|
+
const sceneOnlyOptions = [
|
|
1246
|
+
options.center,
|
|
1247
|
+
options.bounds,
|
|
1248
|
+
options.zoom,
|
|
1249
|
+
options.bearing,
|
|
1250
|
+
options.pitch,
|
|
1251
|
+
options.width,
|
|
1252
|
+
options.height,
|
|
1253
|
+
options.dpr,
|
|
1254
|
+
options.theme,
|
|
1255
|
+
options.url,
|
|
1256
|
+
options.appOrigin,
|
|
1257
|
+
options.selector,
|
|
1258
|
+
options.frame
|
|
1259
|
+
];
|
|
1260
|
+
if (sceneOnlyOptions.some((value) => value !== void 0)) {
|
|
1261
|
+
throw new Error(
|
|
1262
|
+
"Shots render committed cameras and themes; camera, viewport, theme, and application flags apply to scenes."
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
for (const shot of shots) {
|
|
1266
|
+
if (!tileflowCaptureShotNameSchema.safeParse(shot).success) {
|
|
1267
|
+
throw new Error("--shot expects a portable Tileflow shot name.");
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (options.format !== void 0 && options.format !== "webm" && options.format !== "frames") {
|
|
1271
|
+
throw new Error("--format expects webm or frames.");
|
|
1272
|
+
}
|
|
1273
|
+
if (options.out && options.outDir) {
|
|
1274
|
+
throw new Error("Use either --out or --out-dir, not both.");
|
|
1275
|
+
}
|
|
1276
|
+
if (options.out) {
|
|
1277
|
+
if (options.allShots || new Set(shots).size !== 1) {
|
|
1278
|
+
throw new Error("--out can be used only when rendering one shot. Use --out-dir instead.");
|
|
1279
|
+
}
|
|
1280
|
+
const isVideoPath = options.out.toLowerCase().endsWith(".webm");
|
|
1281
|
+
if ((options.format ?? "webm") === "webm" && !isVideoPath) {
|
|
1282
|
+
throw new Error("--out must name a .webm file.");
|
|
1283
|
+
}
|
|
1284
|
+
if (options.format === "frames" && (isVideoPath || /\.(?:json|png)$/iu.test(options.out))) {
|
|
1285
|
+
throw new Error("--out must name a directory for --format frames.");
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
973
1289
|
function createTileflowCaptureJson(captures, cwd) {
|
|
974
1290
|
return {
|
|
975
1291
|
schemaVersion: 1,
|
|
@@ -1019,6 +1335,10 @@ function serializeTileflowCaptureFailureJson(value) {
|
|
|
1019
1335
|
async function runCaptureCommand(positionalScenes, options) {
|
|
1020
1336
|
validateSelectionOptions(positionalScenes, options);
|
|
1021
1337
|
await withTileflowConfigSecretsHidden(async () => {
|
|
1338
|
+
if (isShotSelection(options)) {
|
|
1339
|
+
await runCaptureShotCommand(options);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1022
1342
|
if (options.watch) {
|
|
1023
1343
|
await runCaptureWatchCommand(positionalScenes, options);
|
|
1024
1344
|
return;
|
|
@@ -1267,6 +1587,13 @@ function waitForAbort(signal) {
|
|
|
1267
1587
|
);
|
|
1268
1588
|
}
|
|
1269
1589
|
function validateSelectionOptions(positionalScenes, options) {
|
|
1590
|
+
if (isShotSelection(options)) {
|
|
1591
|
+
validateShotSelectionOptions(positionalScenes, options);
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
if (options.format !== void 0) {
|
|
1595
|
+
throw new Error("--format applies to --shot and --all-shots.");
|
|
1596
|
+
}
|
|
1270
1597
|
const exploratoryOptions = [
|
|
1271
1598
|
options.center,
|
|
1272
1599
|
options.bounds,
|
|
@@ -1325,7 +1652,9 @@ function validateSelectionOptions(positionalScenes, options) {
|
|
|
1325
1652
|
throw new Error("Exploratory camera, viewport, and theme flags require --map.");
|
|
1326
1653
|
}
|
|
1327
1654
|
if (!options.all && positionalScenes.length === 0) {
|
|
1328
|
-
throw new Error(
|
|
1655
|
+
throw new Error(
|
|
1656
|
+
"Select at least one scene, use --all, use --map for exploration, or select shots with --shot."
|
|
1657
|
+
);
|
|
1329
1658
|
}
|
|
1330
1659
|
}
|
|
1331
1660
|
function createExploratoryScene(options) {
|
|
@@ -1467,6 +1796,8 @@ function captureFailurePhase(error, code) {
|
|
|
1467
1796
|
if (code === "MAP_LOAD_FAILED") return "map-load";
|
|
1468
1797
|
if (code === "SCREENSHOT_FAILED" || code === "INVALID_PNG") return "screenshot";
|
|
1469
1798
|
if (code === "SCENE_NOT_FOUND" || code.startsWith("APPLICATION_")) return "scene-resolution";
|
|
1799
|
+
if (code === "SHOT_NOT_FOUND") return "shot-resolution";
|
|
1800
|
+
if (code.startsWith("VIDEO_")) return "video-encode";
|
|
1470
1801
|
if (code === "ABORTED") return "capture";
|
|
1471
1802
|
return "input-validation";
|
|
1472
1803
|
}
|
|
@@ -3137,7 +3468,7 @@ import {
|
|
|
3137
3468
|
// src/icon-diff-report.ts
|
|
3138
3469
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
3139
3470
|
import { mkdir as mkdir3, readFile as readFile3, rename as rename3, unlink as unlink3, writeFile } from "fs/promises";
|
|
3140
|
-
import { basename as basename2, dirname as dirname6, join } from "path";
|
|
3471
|
+
import { basename as basename2, dirname as dirname6, join as join2 } from "path";
|
|
3141
3472
|
import {
|
|
3142
3473
|
compareCodeUnits as compareCodeUnits5,
|
|
3143
3474
|
sha256Hex,
|
|
@@ -3604,7 +3935,7 @@ async function writeAtomicReport(path, html, force) {
|
|
|
3604
3935
|
if (!isMissingFile3(error)) throw error;
|
|
3605
3936
|
}
|
|
3606
3937
|
await mkdir3(dirname6(path), { recursive: true });
|
|
3607
|
-
const temporaryPath =
|
|
3938
|
+
const temporaryPath = join2(
|
|
3608
3939
|
dirname6(path),
|
|
3609
3940
|
`.${basename2(path)}.${process.pid}.${randomUUID2()}.tmp`
|
|
3610
3941
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tileflow",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.12",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "CLI-only tools for Tileflow config, capture, visual testing, and deploy.",
|
|
6
6
|
"type": "module",
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@hono/node-server": "2.1.3",
|
|
39
|
-
"@tileflow/capture": ">=0.1.0-alpha.
|
|
39
|
+
"@tileflow/capture": ">=0.1.0-alpha.30 <0.1.0-beta.0",
|
|
40
40
|
"@tileflow/coordinates": ">=0.1.0-alpha.2 <0.1.0-beta.0",
|
|
41
41
|
"@tileflow/coordinates-runtime": ">=0.1.0-alpha.5 <0.1.0-beta.0",
|
|
42
|
-
"@tileflow/core": ">=0.1.0-alpha.
|
|
43
|
-
"@tileflow/dev": ">=0.1.0-alpha.
|
|
42
|
+
"@tileflow/core": ">=0.1.0-alpha.34 <0.1.0-beta.0",
|
|
43
|
+
"@tileflow/dev": ">=0.1.0-alpha.31 <0.1.0-beta.0",
|
|
44
44
|
"@tileflow/maps": ">=0.1.0-alpha.8 <0.1.0-beta.0",
|
|
45
45
|
"commander": "^15.0.0",
|
|
46
46
|
"hono": "4.13.7",
|