feat(simulation): add Gaussian UGV runtime pipeline
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
export type SimulationSceneType = "interior" | "outdoor" | "object";
|
||||
export type SimulationTransformAxis = "x" | "y" | "z";
|
||||
export type SimulationViewerQuality = "low" | "medium" | "high" | "ultra" | "maximum";
|
||||
export interface SimulationEulerRotation {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
export type SimulationProjectStatus =
|
||||
| "uploading"
|
||||
| "queued"
|
||||
@@ -35,13 +39,38 @@ export interface SimulationWorldManifest {
|
||||
}
|
||||
|
||||
export interface SimulationViewerSettings {
|
||||
schemaVersion: "missioncore.simulation-viewer-settings/v1";
|
||||
schemaVersion: "missioncore.simulation-viewer-settings/v3";
|
||||
quality: SimulationViewerQuality;
|
||||
visual: { inverted: boolean; axis: SimulationTransformAxis };
|
||||
collision: { inverted: boolean; axis: SimulationTransformAxis };
|
||||
visual: { rotationDegrees: SimulationEulerRotation };
|
||||
collision: { rotationDegrees: SimulationEulerRotation };
|
||||
camera: { invertHorizontal: boolean; invertVertical: boolean };
|
||||
ugv: SimulationUgvPreset;
|
||||
}
|
||||
|
||||
export interface SimulationUgvPreset {
|
||||
presetName: string;
|
||||
massKg: number;
|
||||
dimensionsMeters: {
|
||||
length: number;
|
||||
width: number;
|
||||
height: number;
|
||||
groundClearance: number;
|
||||
};
|
||||
maxSpeedMetersPerSecond: number;
|
||||
maxTurnRateDegrees: number;
|
||||
invertSteering: boolean;
|
||||
}
|
||||
|
||||
export const SIMULATION_UGV_EXACT_VALUE_LIMITS = {
|
||||
massKg: { min: 0.01, max: 1_000_000_000 },
|
||||
length: { min: 0.01, max: 1_000_000_000 },
|
||||
width: { min: 0.01, max: 1_000_000_000 },
|
||||
height: { min: 0.07, max: 1_000_000_000 },
|
||||
groundClearance: { min: 0.01, max: 1_000_000_000 },
|
||||
maxSpeedMetersPerSecond: { min: 0, max: 1_000_000_000 },
|
||||
maxTurnRateDegrees: { min: 0, max: 1_000_000_000 },
|
||||
} as const;
|
||||
|
||||
export interface SimulationProject {
|
||||
schemaVersion: "missioncore.simulation-project/v1";
|
||||
projectId: string;
|
||||
@@ -58,7 +87,9 @@ export interface SimulationProject {
|
||||
provider: {
|
||||
providerId: "gaussian-pipeline";
|
||||
jobId: string | null;
|
||||
jobCreatedAtUtc: string | null;
|
||||
state: string | null;
|
||||
stateStartedAtUtc: string | null;
|
||||
progress: { completed_steps?: number; total_steps?: number; stage?: string } | null;
|
||||
runtime: { source_revision?: string; image_digest?: string } | null;
|
||||
};
|
||||
@@ -85,11 +116,60 @@ export interface SimulationUploadProgress {
|
||||
uploadedBytes: number;
|
||||
totalBytes: number;
|
||||
currentPath: string;
|
||||
bytesPerSecond: number | null;
|
||||
estimatedSecondsRemaining: number | null;
|
||||
}
|
||||
|
||||
const API_ROOT = "/api/v1/simulation-worlds/projects";
|
||||
const CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_UPLOAD_ATTEMPTS = 3;
|
||||
const REFERENCE_SOURCE_BYTES = 4_100_808_854;
|
||||
const REFERENCE_PREPARATION_SECONDS = 187;
|
||||
const REFERENCE_STREAM_SECONDS = 2_317;
|
||||
|
||||
export interface SimulationProcessingEstimate {
|
||||
percent: number;
|
||||
estimatedSecondsRemaining: number | null;
|
||||
}
|
||||
|
||||
export function estimateSimulationProcessing(
|
||||
project: SimulationProject,
|
||||
nowMilliseconds = Date.now(),
|
||||
): SimulationProcessingEstimate | null {
|
||||
if (project.status !== "processing") return null;
|
||||
const jobStarted = Date.parse(project.provider.jobCreatedAtUtc ?? "");
|
||||
if (!Number.isFinite(jobStarted)) return null;
|
||||
const elapsedSeconds = Math.max(0, (nowMilliseconds - jobStarted) / 1_000);
|
||||
const sourceScale = clamp(
|
||||
project.source.totalByteLength / REFERENCE_SOURCE_BYTES,
|
||||
0.1,
|
||||
4,
|
||||
);
|
||||
let expectedSeconds = (
|
||||
REFERENCE_PREPARATION_SECONDS + REFERENCE_STREAM_SECONDS
|
||||
) * sourceScale;
|
||||
if (project.provider.state === "building_streamed_sog") {
|
||||
const streamedStarted = Date.parse(project.provider.stateStartedAtUtc ?? "");
|
||||
if (Number.isFinite(streamedStarted) && streamedStarted >= jobStarted) {
|
||||
const preparationSeconds = Math.max(1, (streamedStarted - jobStarted) / 1_000);
|
||||
const observedLoad = clamp(
|
||||
preparationSeconds / (REFERENCE_PREPARATION_SECONDS * sourceScale),
|
||||
0.5,
|
||||
2,
|
||||
);
|
||||
expectedSeconds = preparationSeconds
|
||||
+ REFERENCE_STREAM_SECONDS * sourceScale * observedLoad;
|
||||
}
|
||||
}
|
||||
const rawPercent = expectedSeconds > 0 ? elapsedSeconds / expectedSeconds * 100 : 0;
|
||||
if (rawPercent >= 100) {
|
||||
return { percent: 99, estimatedSecondsRemaining: null };
|
||||
}
|
||||
return {
|
||||
percent: Math.max(1, Math.floor(rawPercent)),
|
||||
estimatedSecondsRemaining: Math.max(0, Math.ceil(expectedSeconds - elapsedSeconds)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSimulationProjects(signal?: AbortSignal): Promise<SimulationProject[]> {
|
||||
const response = await fetch(API_ROOT, { signal, cache: "no-store" });
|
||||
@@ -107,6 +187,15 @@ export async function createAndUploadSimulationProject(
|
||||
sceneType: SimulationSceneType,
|
||||
candidates: SimulationUploadCandidate[],
|
||||
onProgress: (progress: SimulationUploadProgress) => void,
|
||||
): Promise<SimulationProject> {
|
||||
const project = await createSimulationProject(name, sceneType, candidates);
|
||||
return uploadSimulationProjectSource(project, candidates, onProgress);
|
||||
}
|
||||
|
||||
export async function createSimulationProject(
|
||||
name: string,
|
||||
sceneType: SimulationSceneType,
|
||||
candidates: SimulationUploadCandidate[],
|
||||
): Promise<SimulationProject> {
|
||||
const sourceKind = sourceKindFor(candidates);
|
||||
const createdResponse = await fetch(API_ROOT, {
|
||||
@@ -123,36 +212,85 @@ export async function createAndUploadSimulationProject(
|
||||
})),
|
||||
}),
|
||||
});
|
||||
let project = parseProject(await jsonResponse(createdResponse));
|
||||
return parseProject(await jsonResponse(createdResponse));
|
||||
}
|
||||
|
||||
export async function uploadSimulationProjectSource(
|
||||
project: SimulationProject,
|
||||
candidates: SimulationUploadCandidate[],
|
||||
onProgress: (progress: SimulationUploadProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SimulationProject> {
|
||||
const byPath = new Map(candidates.map((candidate) => [candidate.logicalPath, candidate.file]));
|
||||
const totalBytes = candidates.reduce((total, candidate) => total + candidate.file.size, 0);
|
||||
let confirmedBytes = 0;
|
||||
let sampleBytes = 0;
|
||||
let sampleAt = performance.now();
|
||||
let smoothedBytesPerSecond: number | null = null;
|
||||
const reportProgress = (currentPath: string) => {
|
||||
const remainingBytes = Math.max(0, totalBytes - confirmedBytes);
|
||||
onProgress({
|
||||
uploadedBytes: confirmedBytes,
|
||||
totalBytes,
|
||||
currentPath,
|
||||
bytesPerSecond: smoothedBytesPerSecond,
|
||||
estimatedSecondsRemaining: smoothedBytesPerSecond && remainingBytes > 0
|
||||
? Math.ceil(remainingBytes / smoothedBytesPerSecond)
|
||||
: remainingBytes === 0 ? 0 : null,
|
||||
});
|
||||
};
|
||||
for (const sourceFile of project.source.files) {
|
||||
signal?.throwIfAborted();
|
||||
const file = byPath.get(sourceFile.logicalPath);
|
||||
if (!file) throw new Error(`Сервер изменил состав источника: ${sourceFile.logicalPath}`);
|
||||
const uploadUrl = `${API_ROOT}/${encodeURIComponent(project.projectId)}/source/${encodeURIComponent(sourceFile.fileId)}`;
|
||||
let offset = await readUploadOffset(uploadUrl, file.size);
|
||||
let offset = await readUploadOffset(uploadUrl, file.size, signal);
|
||||
confirmedBytes += offset;
|
||||
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
|
||||
sampleBytes = confirmedBytes;
|
||||
sampleAt = performance.now();
|
||||
reportProgress(sourceFile.logicalPath);
|
||||
while (offset < file.size) {
|
||||
signal?.throwIfAborted();
|
||||
const nextOffset = Math.min(file.size, offset + CHUNK_BYTES);
|
||||
const confirmedOffset = await uploadChunk(uploadUrl, file, offset, nextOffset);
|
||||
const confirmedOffset = await uploadChunk(uploadUrl, file, offset, nextOffset, signal);
|
||||
confirmedBytes += confirmedOffset - offset;
|
||||
offset = confirmedOffset;
|
||||
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
|
||||
const now = performance.now();
|
||||
const elapsedSeconds = (now - sampleAt) / 1_000;
|
||||
const transferredBytes = confirmedBytes - sampleBytes;
|
||||
if (elapsedSeconds > 0 && transferredBytes > 0) {
|
||||
const currentBytesPerSecond = transferredBytes / elapsedSeconds;
|
||||
smoothedBytesPerSecond = smoothedBytesPerSecond === null
|
||||
? currentBytesPerSecond
|
||||
: smoothedBytesPerSecond * 0.7 + currentBytesPerSecond * 0.3;
|
||||
}
|
||||
sampleBytes = confirmedBytes;
|
||||
sampleAt = now;
|
||||
reportProgress(sourceFile.logicalPath);
|
||||
}
|
||||
}
|
||||
const buildResponse = await fetch(`${API_ROOT}/${encodeURIComponent(project.projectId)}/build`, {
|
||||
return startSimulationProjectBuild(project.projectId, signal);
|
||||
}
|
||||
|
||||
export async function startSimulationProjectBuild(
|
||||
projectId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SimulationProject> {
|
||||
const buildResponse = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}/build`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal,
|
||||
});
|
||||
project = parseProject(await jsonResponse(buildResponse));
|
||||
return project;
|
||||
return parseProject(await jsonResponse(buildResponse));
|
||||
}
|
||||
|
||||
async function readUploadOffset(uploadUrl: string, fileSize: number): Promise<number> {
|
||||
const head = await fetch(uploadUrl, { method: "HEAD", cache: "no-store" });
|
||||
async function readUploadOffset(
|
||||
uploadUrl: string,
|
||||
fileSize: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
const head = await fetch(uploadUrl, { method: "HEAD", cache: "no-store", signal });
|
||||
if (!head.ok) await jsonResponse(head);
|
||||
const value = head.headers.get("Upload-Offset");
|
||||
if (!value || !/^\d+$/.test(value)) {
|
||||
@@ -170,11 +308,13 @@ async function uploadChunk(
|
||||
file: File,
|
||||
offset: number,
|
||||
nextOffset: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
let lastFailure: unknown = null;
|
||||
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) {
|
||||
let response: Response | null = null;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
response = await fetch(uploadUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -182,8 +322,10 @@ async function uploadChunk(
|
||||
"Upload-Offset": String(offset),
|
||||
},
|
||||
body: file.slice(offset, nextOffset),
|
||||
signal,
|
||||
});
|
||||
} catch (caught) {
|
||||
if (signal?.aborted) throw caught;
|
||||
lastFailure = caught;
|
||||
}
|
||||
if (response?.ok) {
|
||||
@@ -203,7 +345,7 @@ async function uploadChunk(
|
||||
lastFailure = new Error(`Mission Core временно вернул HTTP ${response.status}.`);
|
||||
}
|
||||
try {
|
||||
const confirmed = await readUploadOffset(uploadUrl, file.size);
|
||||
const confirmed = await readUploadOffset(uploadUrl, file.size, signal);
|
||||
if (confirmed === nextOffset) return confirmed;
|
||||
if (confirmed !== offset) {
|
||||
throw new Error("Сервер подтвердил неожиданное смещение загрузки.");
|
||||
@@ -246,26 +388,32 @@ export async function saveSimulationViewerSettings(
|
||||
body: JSON.stringify({
|
||||
schema_version: settings.schemaVersion,
|
||||
quality: settings.quality,
|
||||
visual: settings.visual,
|
||||
collision: settings.collision,
|
||||
visual: { rotation_degrees: settings.visual.rotationDegrees },
|
||||
collision: { rotation_degrees: settings.collision.rotationDegrees },
|
||||
camera: {
|
||||
invert_horizontal: settings.camera.invertHorizontal,
|
||||
invert_vertical: settings.camera.invertVertical,
|
||||
},
|
||||
ugv: {
|
||||
preset_name: settings.ugv.presetName,
|
||||
mass_kg: settings.ugv.massKg,
|
||||
dimensions_m: {
|
||||
length: settings.ugv.dimensionsMeters.length,
|
||||
width: settings.ugv.dimensionsMeters.width,
|
||||
height: settings.ugv.dimensionsMeters.height,
|
||||
ground_clearance: settings.ugv.dimensionsMeters.groundClearance,
|
||||
},
|
||||
max_speed_mps: settings.ugv.maxSpeedMetersPerSecond,
|
||||
max_turn_rate_degrees: settings.ugv.maxTurnRateDegrees,
|
||||
invert_steering: settings.ugv.invertSteering,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
return parseProject(await jsonResponse(response));
|
||||
}
|
||||
|
||||
export async function retrySimulationProject(projectId: string): Promise<SimulationProject> {
|
||||
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}/build`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
return parseProject(await jsonResponse(response));
|
||||
}
|
||||
export const retrySimulationProject = startSimulationProjectBuild;
|
||||
|
||||
export async function deleteSimulationProject(projectId: string): Promise<void> {
|
||||
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
||||
@@ -321,7 +469,9 @@ function parseProject(value: unknown): SimulationProject {
|
||||
provider: {
|
||||
providerId: provider.provider_id === "gaussian-pipeline" ? "gaussian-pipeline" : invalid("provider id"),
|
||||
jobId: nullableString(provider.job_id, "provider job id"),
|
||||
jobCreatedAtUtc: nullableString(provider.job_created_at_utc ?? null, "provider job creation"),
|
||||
state: nullableString(provider.state, "provider state"),
|
||||
stateStartedAtUtc: nullableString(provider.state_started_at_utc ?? null, "provider state start"),
|
||||
progress: provider.progress === null ? null : objectValue(provider.progress, "provider progress"),
|
||||
runtime: provider.runtime === null ? null : objectValue(provider.runtime, "provider runtime"),
|
||||
},
|
||||
@@ -345,16 +495,17 @@ function parseProject(value: unknown): SimulationProject {
|
||||
|
||||
function parseViewerSettings(value: unknown): SimulationViewerSettings {
|
||||
const record = objectValue(value, "viewer settings");
|
||||
exactKeys(record, ["schema_version", "quality", "visual", "collision", "camera"], "viewer settings");
|
||||
if (record.schema_version !== "missioncore.simulation-viewer-settings/v1") {
|
||||
exactKeys(record, ["schema_version", "quality", "visual", "collision", "camera", "ugv"], "viewer settings");
|
||||
if (record.schema_version !== "missioncore.simulation-viewer-settings/v3") {
|
||||
throw new Error("Настройки сцены вернули неподдерживаемую версию.");
|
||||
}
|
||||
const visual = parseLayerViewerSettings(record.visual, "visual settings");
|
||||
const collision = parseLayerViewerSettings(record.collision, "collision settings");
|
||||
const camera = objectValue(record.camera, "camera settings");
|
||||
exactKeys(camera, ["invert_horizontal", "invert_vertical"], "camera settings");
|
||||
const ugv = parseUgvPreset(record.ugv);
|
||||
return {
|
||||
schemaVersion: "missioncore.simulation-viewer-settings/v1",
|
||||
schemaVersion: "missioncore.simulation-viewer-settings/v3",
|
||||
quality: qualityValue(record.quality),
|
||||
visual,
|
||||
collision,
|
||||
@@ -362,18 +513,88 @@ function parseViewerSettings(value: unknown): SimulationViewerSettings {
|
||||
invertHorizontal: booleanValue(camera.invert_horizontal, "horizontal camera inversion"),
|
||||
invertVertical: booleanValue(camera.invert_vertical, "vertical camera inversion"),
|
||||
},
|
||||
ugv,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUgvPreset(value: unknown): SimulationUgvPreset {
|
||||
const record = objectValue(value, "UGV settings");
|
||||
exactKeys(
|
||||
record,
|
||||
["preset_name", "mass_kg", "dimensions_m", "max_speed_mps", "max_turn_rate_degrees", "invert_steering"],
|
||||
"UGV settings",
|
||||
);
|
||||
const dimensions = objectValue(record.dimensions_m, "UGV dimensions");
|
||||
exactKeys(dimensions, ["length", "width", "height", "ground_clearance"], "UGV dimensions");
|
||||
const presetName = stringValue(record.preset_name, "UGV preset name");
|
||||
if (presetName.length > 80) return invalid("UGV preset name");
|
||||
const height = finiteNumberValue(
|
||||
dimensions.height,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.height.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.height.max,
|
||||
"UGV height",
|
||||
);
|
||||
const groundClearance = finiteNumberValue(
|
||||
dimensions.ground_clearance,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.groundClearance.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.groundClearance.max,
|
||||
"UGV ground clearance",
|
||||
);
|
||||
if (groundClearance >= height - 0.05) return invalid("UGV ground clearance");
|
||||
return {
|
||||
presetName,
|
||||
massKg: finiteNumberValue(
|
||||
record.mass_kg,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.massKg.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.massKg.max,
|
||||
"UGV mass",
|
||||
),
|
||||
dimensionsMeters: {
|
||||
length: finiteNumberValue(
|
||||
dimensions.length,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.length.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.length.max,
|
||||
"UGV length",
|
||||
),
|
||||
width: finiteNumberValue(
|
||||
dimensions.width,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.width.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.width.max,
|
||||
"UGV width",
|
||||
),
|
||||
height,
|
||||
groundClearance,
|
||||
},
|
||||
maxSpeedMetersPerSecond: finiteNumberValue(
|
||||
record.max_speed_mps,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.maxSpeedMetersPerSecond.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.maxSpeedMetersPerSecond.max,
|
||||
"UGV maximum speed",
|
||||
),
|
||||
maxTurnRateDegrees: finiteNumberValue(
|
||||
record.max_turn_rate_degrees,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.maxTurnRateDegrees.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.maxTurnRateDegrees.max,
|
||||
"UGV maximum turn rate",
|
||||
),
|
||||
invertSteering: booleanValue(record.invert_steering, "UGV steering inversion"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLayerViewerSettings(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): { inverted: boolean; axis: SimulationTransformAxis } {
|
||||
): { rotationDegrees: SimulationEulerRotation } {
|
||||
const record = objectValue(value, label);
|
||||
exactKeys(record, ["inverted", "axis"], label);
|
||||
exactKeys(record, ["rotation_degrees"], label);
|
||||
const rotation = objectValue(record.rotation_degrees, `${label} rotation`);
|
||||
exactKeys(rotation, ["x", "y", "z"], `${label} rotation`);
|
||||
return {
|
||||
inverted: booleanValue(record.inverted, `${label} inversion`),
|
||||
axis: axisValue(record.axis),
|
||||
rotationDegrees: {
|
||||
x: rotationValue(rotation.x, `${label} X rotation`),
|
||||
y: rotationValue(rotation.y, `${label} Y rotation`),
|
||||
z: rotationValue(rotation.z, `${label} Z rotation`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -466,9 +687,18 @@ function sceneTypeValue(value: unknown): SimulationSceneType {
|
||||
return invalid("scene type");
|
||||
}
|
||||
|
||||
function axisValue(value: unknown): SimulationTransformAxis {
|
||||
if (value === "x" || value === "y" || value === "z") return value;
|
||||
return invalid("transform axis");
|
||||
function rotationValue(value: unknown, label: string): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= -360 && value <= 360) {
|
||||
return value;
|
||||
}
|
||||
return invalid(label);
|
||||
}
|
||||
|
||||
function finiteNumberValue(value: unknown, minimum: number, maximum: number, label: string): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= minimum && value <= maximum) {
|
||||
return value;
|
||||
}
|
||||
return invalid(label);
|
||||
}
|
||||
|
||||
function qualityValue(value: unknown): SimulationViewerQuality {
|
||||
@@ -488,3 +718,7 @@ function statusValue(value: unknown): SimulationProjectStatus {
|
||||
function invalid(label: string): never {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { SimulationUploadCandidate } from "./projects";
|
||||
|
||||
const ARCHIVE_PATTERN = /\.(zip|rar|7z)$/i;
|
||||
export const MAX_SIMULATION_SOURCE_BYTES = 16 * 1024 ** 3;
|
||||
|
||||
interface LegacyFileEntry {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file?: (callback: (file: File) => void, error?: (error: DOMException) => void) => void;
|
||||
createReader?: () => LegacyDirectoryReader;
|
||||
}
|
||||
|
||||
interface LegacyDirectoryReader {
|
||||
readEntries: (
|
||||
callback: (entries: LegacyFileEntry[]) => void,
|
||||
error?: (error: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export async function candidatesFromDrop(
|
||||
transfer: DataTransfer,
|
||||
): Promise<SimulationUploadCandidate[]> {
|
||||
const candidates: SimulationUploadCandidate[] = [];
|
||||
for (const item of Array.from(transfer.items)) {
|
||||
const entry = (item as DataTransferItem & {
|
||||
webkitGetAsEntry?: () => LegacyFileEntry | null;
|
||||
}).webkitGetAsEntry?.();
|
||||
if (entry?.isDirectory) {
|
||||
await collectEntry(entry, candidates);
|
||||
continue;
|
||||
}
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
candidates.push({ file, logicalPath: file.name });
|
||||
continue;
|
||||
}
|
||||
if (entry) await collectEntry(entry, candidates);
|
||||
}
|
||||
if (!candidates.length) {
|
||||
for (const file of Array.from(transfer.files)) {
|
||||
candidates.push({ file, logicalPath: file.name });
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export async function archivesFromDrop(transfer: DataTransfer): Promise<File[]> {
|
||||
const candidates = await candidatesFromDrop(transfer);
|
||||
const invalid = candidates.filter((candidate) => !ARCHIVE_PATTERN.test(candidate.logicalPath));
|
||||
if (invalid.length) {
|
||||
throw new Error("В каталог можно перетащить только отдельные архивы ZIP, RAR или 7z.");
|
||||
}
|
||||
if (!candidates.length) {
|
||||
throw new Error("В перетаскивании не найдено архивов ZIP, RAR или 7z.");
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.logicalPath.includes("/")) {
|
||||
throw new Error("Перетащите сами архивы, а не содержащую их папку.");
|
||||
}
|
||||
if (candidate.file.size <= 0) {
|
||||
throw new Error(`Пустой архив: ${candidate.file.name}`);
|
||||
}
|
||||
if (candidate.file.size > MAX_SIMULATION_SOURCE_BYTES) {
|
||||
throw new Error(`Архив ${candidate.file.name} превышает лимит 16 ГБ.`);
|
||||
}
|
||||
}
|
||||
return candidates.map((candidate) => candidate.file);
|
||||
}
|
||||
|
||||
export function validateSimulationCandidates(candidates: SimulationUploadCandidate[]): void {
|
||||
if (!candidates.length) throw new Error("Выберите архив или папку с результатом LCC/LCC2.");
|
||||
const archives = candidates.filter((candidate) => ARCHIVE_PATTERN.test(candidate.logicalPath));
|
||||
if (archives.length) {
|
||||
if (candidates.length !== 1) throw new Error("Архив нужно загружать одним файлом.");
|
||||
if (archives[0]!.file.size <= 0) throw new Error(`Пустой файл: ${archives[0]!.logicalPath}`);
|
||||
if (archives[0]!.file.size > MAX_SIMULATION_SOURCE_BYTES) {
|
||||
throw new Error(`Архив ${archives[0]!.logicalPath} превышает лимит 16 ГБ.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const descriptors = candidates.filter((candidate) => /\.lcc2?$/i.test(candidate.logicalPath));
|
||||
if (descriptors.length !== 1) {
|
||||
throw new Error("В папке должна быть ровно одна сцена .lcc или .lcc2.");
|
||||
}
|
||||
const paths = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
for (const candidate of candidates) {
|
||||
const parts = candidate.logicalPath.split("/");
|
||||
if (
|
||||
!candidate.logicalPath
|
||||
|| candidate.logicalPath.startsWith("/")
|
||||
|| candidate.logicalPath.includes("\\")
|
||||
|| candidate.logicalPath.includes("\0")
|
||||
|| parts.some((part) => part === "" || part === "." || part === "..")
|
||||
) {
|
||||
throw new Error("Папка содержит небезопасный путь.");
|
||||
}
|
||||
if (candidate.file.size <= 0) throw new Error(`Пустой файл: ${candidate.logicalPath}`);
|
||||
totalBytes += candidate.file.size;
|
||||
if (paths.has(candidate.logicalPath)) throw new Error(`Повторяющийся путь: ${candidate.logicalPath}`);
|
||||
paths.add(candidate.logicalPath);
|
||||
}
|
||||
if (totalBytes > MAX_SIMULATION_SOURCE_BYTES) {
|
||||
throw new Error("Папка с исходниками превышает лимит 16 ГБ.");
|
||||
}
|
||||
}
|
||||
|
||||
export function hasDroppedFiles(transfer: DataTransfer): boolean {
|
||||
return transfer.files.length > 0
|
||||
|| Array.from(transfer.items).some((item) => item.kind === "file")
|
||||
|| Array.from(transfer.types).some((type) => type.toLowerCase() === "files");
|
||||
}
|
||||
|
||||
export function archiveProjectName(fileName: string): string {
|
||||
const name = fileName.replace(ARCHIVE_PATTERN, "").trim();
|
||||
return name || "Новая сцена";
|
||||
}
|
||||
|
||||
function collectEntry(
|
||||
entry: LegacyFileEntry,
|
||||
target: SimulationUploadCandidate[],
|
||||
): Promise<void> {
|
||||
if (entry.isFile && entry.file) {
|
||||
return new Promise<File>((resolve, reject) => entry.file?.(resolve, reject))
|
||||
.then((file) => {
|
||||
target.push({ file, logicalPath: entry.fullPath.replace(/^\//, "") || file.name });
|
||||
});
|
||||
}
|
||||
if (!entry.isDirectory || !entry.createReader) return Promise.resolve();
|
||||
return collectDirectory(entry.createReader(), target);
|
||||
}
|
||||
|
||||
async function collectDirectory(
|
||||
reader: LegacyDirectoryReader,
|
||||
target: SimulationUploadCandidate[],
|
||||
): Promise<void> {
|
||||
while (true) {
|
||||
const batch = await new Promise<LegacyFileEntry[]>((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
if (!batch.length) return;
|
||||
for (const child of batch) await collectEntry(child, target);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user