feat: add Gaussian simulation workspace
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
export type SimulationSceneType = "interior" | "outdoor" | "object";
|
||||
export type SimulationProjectStatus =
|
||||
| "uploading"
|
||||
| "queued"
|
||||
| "processing"
|
||||
| "importing"
|
||||
| "ready"
|
||||
| "failed";
|
||||
|
||||
export interface SimulationSourceFile {
|
||||
fileId: string;
|
||||
logicalPath: string;
|
||||
byteLength: number;
|
||||
uploadedBytes: number;
|
||||
sha256: string | null;
|
||||
}
|
||||
|
||||
export interface SimulationWorldManifest {
|
||||
schemaVersion: "missioncore.simulation-world-manifest/v1";
|
||||
projectId: string;
|
||||
visual: {
|
||||
previewSogUrl: string | null;
|
||||
streamedSogUrl: string | null;
|
||||
};
|
||||
collision: {
|
||||
meshUrl: string | null;
|
||||
available: boolean;
|
||||
};
|
||||
transforms: {
|
||||
worldFromVisual: number[];
|
||||
worldFromCollision: number[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SimulationProject {
|
||||
schemaVersion: "missioncore.simulation-project/v1";
|
||||
projectId: string;
|
||||
name: string;
|
||||
sceneType: SimulationSceneType;
|
||||
status: SimulationProjectStatus;
|
||||
source: {
|
||||
kind: "archive" | "folder";
|
||||
totalByteLength: number;
|
||||
uploadedByteLength: number;
|
||||
files: SimulationSourceFile[];
|
||||
bundleSha256: string | null;
|
||||
};
|
||||
provider: {
|
||||
providerId: "gaussian-pipeline";
|
||||
jobId: string | null;
|
||||
state: string | null;
|
||||
progress: { completed_steps?: number; total_steps?: number; stage?: string } | null;
|
||||
runtime: { source_revision?: string; image_digest?: string } | null;
|
||||
};
|
||||
artifacts: Array<{
|
||||
role: string;
|
||||
logicalPath: string;
|
||||
mediaType: string;
|
||||
sha256: string;
|
||||
byteLength: number;
|
||||
}>;
|
||||
worldManifest: SimulationWorldManifest | null;
|
||||
error: string | null;
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
}
|
||||
|
||||
export interface SimulationUploadCandidate {
|
||||
file: File;
|
||||
logicalPath: string;
|
||||
}
|
||||
|
||||
export interface SimulationUploadProgress {
|
||||
uploadedBytes: number;
|
||||
totalBytes: number;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
const API_ROOT = "/api/v1/simulation-worlds/projects";
|
||||
const CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_UPLOAD_ATTEMPTS = 3;
|
||||
|
||||
export async function fetchSimulationProjects(signal?: AbortSignal): Promise<SimulationProject[]> {
|
||||
const response = await fetch(API_ROOT, { signal, cache: "no-store" });
|
||||
const document = await jsonResponse(response);
|
||||
const record = objectValue(document, "simulation project page");
|
||||
exactKeys(record, ["schema_version", "projects"], "simulation project page");
|
||||
if (record.schema_version !== "missioncore.simulation-project-page/v1" || !Array.isArray(record.projects)) {
|
||||
throw new Error("Каталог симуляций вернул неподдерживаемый контракт.");
|
||||
}
|
||||
return record.projects.map(parseProject);
|
||||
}
|
||||
|
||||
export async function createAndUploadSimulationProject(
|
||||
name: string,
|
||||
sceneType: SimulationSceneType,
|
||||
candidates: SimulationUploadCandidate[],
|
||||
onProgress: (progress: SimulationUploadProgress) => void,
|
||||
): Promise<SimulationProject> {
|
||||
const sourceKind = sourceKindFor(candidates);
|
||||
const createdResponse = await fetch(API_ROOT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: "missioncore.simulation-project-create/v1",
|
||||
name,
|
||||
scene_type: sceneType,
|
||||
source_kind: sourceKind,
|
||||
files: candidates.map((candidate) => ({
|
||||
logical_path: candidate.logicalPath,
|
||||
byte_length: candidate.file.size,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
let project = parseProject(await jsonResponse(createdResponse));
|
||||
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;
|
||||
for (const sourceFile of project.source.files) {
|
||||
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);
|
||||
confirmedBytes += offset;
|
||||
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
|
||||
while (offset < file.size) {
|
||||
const nextOffset = Math.min(file.size, offset + CHUNK_BYTES);
|
||||
const confirmedOffset = await uploadChunk(uploadUrl, file, offset, nextOffset);
|
||||
confirmedBytes += confirmedOffset - offset;
|
||||
offset = confirmedOffset;
|
||||
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
|
||||
}
|
||||
}
|
||||
const buildResponse = await fetch(`${API_ROOT}/${encodeURIComponent(project.projectId)}/build`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
project = parseProject(await jsonResponse(buildResponse));
|
||||
return project;
|
||||
}
|
||||
|
||||
async function readUploadOffset(uploadUrl: string, fileSize: number): Promise<number> {
|
||||
const head = await fetch(uploadUrl, { method: "HEAD", cache: "no-store" });
|
||||
if (!head.ok) await jsonResponse(head);
|
||||
const value = head.headers.get("Upload-Offset");
|
||||
if (!value || !/^\d+$/.test(value)) {
|
||||
throw new Error("Сервер не вернул смещение resumable-загрузки.");
|
||||
}
|
||||
const offset = Number(value);
|
||||
if (!Number.isSafeInteger(offset) || offset < 0 || offset > fileSize) {
|
||||
throw new Error("Сервер вернул некорректное смещение загрузки.");
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
async function uploadChunk(
|
||||
uploadUrl: string,
|
||||
file: File,
|
||||
offset: number,
|
||||
nextOffset: number,
|
||||
): Promise<number> {
|
||||
let lastFailure: unknown = null;
|
||||
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) {
|
||||
let response: Response | null = null;
|
||||
try {
|
||||
response = await fetch(uploadUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/offset+octet-stream",
|
||||
"Upload-Offset": String(offset),
|
||||
},
|
||||
body: file.slice(offset, nextOffset),
|
||||
});
|
||||
} catch (caught) {
|
||||
lastFailure = caught;
|
||||
}
|
||||
if (response?.ok) {
|
||||
const confirmed = response.headers.get("Upload-Offset");
|
||||
if (confirmed && Number(confirmed) === nextOffset) return nextOffset;
|
||||
throw new Error("Сервер не подтвердил непрерывность загрузки.");
|
||||
}
|
||||
if (
|
||||
response &&
|
||||
((response.status < 500 && response.status !== 409) ||
|
||||
(response.status >= 500 && attempt === MAX_UPLOAD_ATTEMPTS))
|
||||
) {
|
||||
await jsonResponse(response);
|
||||
throw new Error("Mission Core отклонил блок загрузки.");
|
||||
}
|
||||
if (response) {
|
||||
lastFailure = new Error(`Mission Core временно вернул HTTP ${response.status}.`);
|
||||
}
|
||||
try {
|
||||
const confirmed = await readUploadOffset(uploadUrl, file.size);
|
||||
if (confirmed === nextOffset) return confirmed;
|
||||
if (confirmed !== offset) {
|
||||
throw new Error("Сервер подтвердил неожиданное смещение загрузки.");
|
||||
}
|
||||
} catch (caught) {
|
||||
lastFailure = caught;
|
||||
}
|
||||
}
|
||||
throw lastFailure instanceof Error
|
||||
? lastFailure
|
||||
: new Error("Не удалось продолжить resumable-загрузку.");
|
||||
}
|
||||
|
||||
export async function updateSimulationProject(
|
||||
projectId: string,
|
||||
name: string,
|
||||
sceneType: SimulationSceneType,
|
||||
): Promise<SimulationProject> {
|
||||
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: "missioncore.simulation-project-update/v1",
|
||||
name,
|
||||
scene_type: sceneType,
|
||||
}),
|
||||
});
|
||||
return parseProject(await jsonResponse(response));
|
||||
}
|
||||
|
||||
export async function deleteSimulationProject(projectId: string): Promise<void> {
|
||||
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
||||
if (!response.ok) await jsonResponse(response);
|
||||
}
|
||||
|
||||
function sourceKindFor(candidates: SimulationUploadCandidate[]): "archive" | "folder" {
|
||||
if (candidates.length === 1 && /\.(zip|rar|7z)$/i.test(candidates[0]?.logicalPath ?? "")) {
|
||||
return "archive";
|
||||
}
|
||||
if (candidates.some((candidate) => /\.(zip|rar|7z)$/i.test(candidate.logicalPath))) {
|
||||
throw new Error("Архив загружается отдельно; нельзя смешивать его с файлами папки.");
|
||||
}
|
||||
return "folder";
|
||||
}
|
||||
|
||||
function parseProject(value: unknown): SimulationProject {
|
||||
const record = objectValue(value, "simulation project");
|
||||
exactKeys(record, [
|
||||
"schema_version", "project_id", "name", "scene_type", "status", "source", "provider",
|
||||
"artifacts", "world_manifest", "error", "created_at_utc", "updated_at_utc",
|
||||
], "simulation project");
|
||||
if (record.schema_version !== "missioncore.simulation-project/v1") {
|
||||
throw new Error("Проект симуляции вернул неподдерживаемую версию.");
|
||||
}
|
||||
const source = objectValue(record.source, "simulation source");
|
||||
const provider = objectValue(record.provider, "simulation provider");
|
||||
if (!Array.isArray(source.files) || !Array.isArray(record.artifacts)) {
|
||||
throw new Error("Проект симуляции вернул некорректный состав файлов.");
|
||||
}
|
||||
return {
|
||||
schemaVersion: "missioncore.simulation-project/v1",
|
||||
projectId: stringValue(record.project_id, "project id"),
|
||||
name: stringValue(record.name, "project name"),
|
||||
sceneType: sceneTypeValue(record.scene_type),
|
||||
status: statusValue(record.status),
|
||||
source: {
|
||||
kind: source.kind === "archive" ? "archive" : source.kind === "folder" ? "folder" : invalid("source kind"),
|
||||
totalByteLength: numberValue(source.total_byte_length, "source bytes"),
|
||||
uploadedByteLength: numberValue(source.uploaded_byte_length, "uploaded bytes"),
|
||||
files: source.files.map((item) => {
|
||||
const file = objectValue(item, "source file");
|
||||
return {
|
||||
fileId: stringValue(file.file_id, "source file id"),
|
||||
logicalPath: stringValue(file.logical_path, "source logical path"),
|
||||
byteLength: numberValue(file.byte_length, "source file bytes"),
|
||||
uploadedBytes: numberValue(file.uploaded_bytes, "source uploaded bytes"),
|
||||
sha256: nullableString(file.sha256, "source sha256"),
|
||||
};
|
||||
}),
|
||||
bundleSha256: nullableString(source.bundle_sha256, "bundle sha256"),
|
||||
},
|
||||
provider: {
|
||||
providerId: provider.provider_id === "gaussian-pipeline" ? "gaussian-pipeline" : invalid("provider id"),
|
||||
jobId: nullableString(provider.job_id, "provider job id"),
|
||||
state: nullableString(provider.state, "provider state"),
|
||||
progress: provider.progress === null ? null : objectValue(provider.progress, "provider progress"),
|
||||
runtime: provider.runtime === null ? null : objectValue(provider.runtime, "provider runtime"),
|
||||
},
|
||||
artifacts: record.artifacts.map((item) => {
|
||||
const artifact = objectValue(item, "simulation artifact");
|
||||
return {
|
||||
role: stringValue(artifact.role, "artifact role"),
|
||||
logicalPath: stringValue(artifact.logical_path, "artifact path"),
|
||||
mediaType: stringValue(artifact.media_type, "artifact media type"),
|
||||
sha256: stringValue(artifact.sha256, "artifact sha256"),
|
||||
byteLength: numberValue(artifact.byte_length, "artifact bytes"),
|
||||
};
|
||||
}),
|
||||
worldManifest: record.world_manifest === null ? null : parseWorldManifest(record.world_manifest),
|
||||
error: nullableString(record.error, "project error"),
|
||||
createdAtUtc: stringValue(record.created_at_utc, "created at"),
|
||||
updatedAtUtc: stringValue(record.updated_at_utc, "updated at"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorldManifest(value: unknown): SimulationWorldManifest {
|
||||
const record = objectValue(value, "world manifest");
|
||||
const visual = objectValue(record.visual, "visual manifest");
|
||||
const collision = objectValue(record.collision, "collision manifest");
|
||||
const transforms = objectValue(record.transforms, "world transforms");
|
||||
if (record.schema_version !== "missioncore.simulation-world-manifest/v1") {
|
||||
throw new Error("World manifest вернул неподдерживаемую версию.");
|
||||
}
|
||||
return {
|
||||
schemaVersion: "missioncore.simulation-world-manifest/v1",
|
||||
projectId: stringValue(record.project_id, "manifest project id"),
|
||||
visual: {
|
||||
previewSogUrl: nullableString(visual.preview_sog_url, "preview URL"),
|
||||
streamedSogUrl: nullableString(visual.streamed_sog_url, "streamed URL"),
|
||||
},
|
||||
collision: {
|
||||
meshUrl: nullableString(collision.mesh_url, "collision URL"),
|
||||
available: booleanValue(collision.available, "collision availability"),
|
||||
},
|
||||
transforms: {
|
||||
worldFromVisual: matrixValue(transforms.world_from_visual, "visual transform"),
|
||||
worldFromCollision: matrixValue(transforms.world_from_collision, "collision transform"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function jsonResponse(response: Response): Promise<unknown> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await response.json();
|
||||
} catch {
|
||||
if (response.ok) return {};
|
||||
throw new Error(`Mission Core вернул HTTP ${response.status}.`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = objectValue(value, "error response").detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `Mission Core вернул HTTP ${response.status}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, any> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
return value as Record<string, any>;
|
||||
}
|
||||
|
||||
function exactKeys(record: Record<string, any>, keys: string[], label: string): void {
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
||||
throw new Error(`Некорректные поля ${label}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value) throw new Error(`Некорректный ${label}.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`Некорректный ${label}.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function matrixValue(value: unknown, label: string): number[] {
|
||||
if (!Array.isArray(value) || value.length !== 16 || value.some((item) => typeof item !== "number")) {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
return [...value];
|
||||
}
|
||||
|
||||
function sceneTypeValue(value: unknown): SimulationSceneType {
|
||||
if (value === "interior" || value === "outdoor" || value === "object") return value;
|
||||
return invalid("scene type");
|
||||
}
|
||||
|
||||
function statusValue(value: unknown): SimulationProjectStatus {
|
||||
if (["uploading", "queued", "processing", "importing", "ready", "failed"].includes(String(value))) {
|
||||
return value as SimulationProjectStatus;
|
||||
}
|
||||
return invalid("project status");
|
||||
}
|
||||
|
||||
function invalid(label: string): never {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
Reference in New Issue
Block a user