Сохранять каждый запуск датасета в отдельную папку и показывать прогресс генерации.
Файлы пишутся в sonar_dataset/дата-время-модель без удаления прошлых запусков; кнопка «Генерация…» заполняется по мере записи сцен через NDJSON-стрим. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -289,7 +289,7 @@ export const api = {
|
||||
});
|
||||
triggerDownload(blob, safeName || suggested);
|
||||
},
|
||||
datasetGenerate: ({
|
||||
datasetGenerate: async ({
|
||||
count = 5,
|
||||
seed = 42,
|
||||
outputDir = "sonar_dataset",
|
||||
@@ -298,6 +298,7 @@ export const api = {
|
||||
beamCount = 45,
|
||||
lengthCount = 45,
|
||||
modelFile,
|
||||
onProgress,
|
||||
} = {}) => {
|
||||
if (!modelFile) {
|
||||
return Promise.reject(new Error("Выберите файл модели .obj"));
|
||||
@@ -311,10 +312,74 @@ export const api = {
|
||||
formData.append("beamCount", String(beamCount ?? 45));
|
||||
formData.append("lengthCount", String(lengthCount ?? 45));
|
||||
formData.append("model", modelFile, modelFile.name || "model.obj");
|
||||
return request("/api/dataset/generate", {
|
||||
|
||||
const response = await fetch("/api/dataset/generate", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(formatApiError(payload.detail, `Request failed: /api/dataset/generate`));
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("Пустой ответ генерации датасета");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let result = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(trimmed);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (event.type === "error") {
|
||||
throw new Error(event.detail || "Ошибка генерации датасета");
|
||||
}
|
||||
if (event.type === "start" || event.type === "progress") {
|
||||
if (typeof onProgress === "function") onProgress(event);
|
||||
}
|
||||
if (event.type === "done") {
|
||||
result = event.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(buffer.trim());
|
||||
if (event.type === "error") {
|
||||
throw new Error(event.detail || "Ошибка генерации датасета");
|
||||
}
|
||||
if (event.type === "done") result = event.result;
|
||||
if ((event.type === "start" || event.type === "progress") && typeof onProgress === "function") {
|
||||
onProgress(event);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
/* ignore trailing junk */
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Генерация завершилась без результата");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
datasetPreview: ({ stem, outputDir = "sonar_dataset", maxPoints = 25000 } = {}) =>
|
||||
request("/api/dataset/preview", {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const useDatasetStore = defineStore("dataset", {
|
||||
objectScaleIsMax: false,
|
||||
beamCount: 45,
|
||||
lengthCount: 45,
|
||||
generateProgress: 0,
|
||||
lastResult: null,
|
||||
selectedStem: null,
|
||||
previewPoints: [],
|
||||
@@ -116,6 +117,7 @@ export const useDatasetStore = defineStore("dataset", {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.generateProgress = 0;
|
||||
this.statusText = "Генерация датасета…";
|
||||
this.pushLog(
|
||||
`Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}${this.objectScaleIsMax ? " (макс.)" : ""}, dir=${this.outputDir}, model=${this.modelFileName}`,
|
||||
@@ -130,7 +132,35 @@ export const useDatasetStore = defineStore("dataset", {
|
||||
beamCount: Number(this.beamCount) || 45,
|
||||
lengthCount: Number(this.lengthCount) || 45,
|
||||
modelFile: this.modelFile,
|
||||
onProgress: (event) => {
|
||||
if (event.type === "start") {
|
||||
this.generateProgress = 0;
|
||||
if (event.outputDir) {
|
||||
this.resolvedOutputDir = event.outputDir;
|
||||
this.pushLog(`Каталог запуска: ${event.outputDir}`);
|
||||
}
|
||||
this.statusText = `Генерация… 0/${event.total || "?"}`;
|
||||
return;
|
||||
}
|
||||
if (event.type === "progress") {
|
||||
const total = Number(event.total) || 1;
|
||||
const current = Number(event.current) || 0;
|
||||
this.generateProgress = Math.min(100, Math.round((100 * current) / total));
|
||||
this.statusText = `Генерация… ${current}/${total}`;
|
||||
const entry = event.entry;
|
||||
if (entry?.stem) {
|
||||
const scalePart =
|
||||
entry.objectScale != null
|
||||
? `, scale=${Number(entry.objectScale).toFixed(3)}`
|
||||
: "";
|
||||
this.pushLog(
|
||||
`${entry.stem}: pts=${entry.pointCount}, object=${entry.objectPointCount}, ${entry.visibility}${scalePart}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
this.generateProgress = 100;
|
||||
this.lastResult = result;
|
||||
this.resolvedOutputDir = result?.outputDir || null;
|
||||
const s = result?.stats || {};
|
||||
@@ -142,18 +172,11 @@ export const useDatasetStore = defineStore("dataset", {
|
||||
`Модель: ${result.objectName || this.modelFileName} (${result.objectVertexCount || "?"} вершин), ${scaleNote}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`,
|
||||
);
|
||||
this.pushLog(
|
||||
`Записано ${result.count} сцен. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
||||
`Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
||||
);
|
||||
this.pushLog(
|
||||
`Видимость: nearly_hidden=${s.nearly_hidden || 0}, partial=${s.partial || 0}, visible=${s.visible || 0}, absent=${s.absent || 0}.`,
|
||||
);
|
||||
for (const item of result.written || []) {
|
||||
const scalePart =
|
||||
item.objectScale != null ? `, scale=${Number(item.objectScale).toFixed(3)}` : "";
|
||||
this.pushLog(
|
||||
`${item.stem}: pts=${item.pointCount}, object=${item.objectPointCount}, ${item.visibility}${scalePart}`,
|
||||
);
|
||||
}
|
||||
|
||||
const initialStem =
|
||||
result?.preview?.stem ||
|
||||
@@ -190,6 +213,7 @@ export const useDatasetStore = defineStore("dataset", {
|
||||
throw error;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.generateProgress = 0;
|
||||
}
|
||||
},
|
||||
async selectScene(stem) {
|
||||
|
||||
@@ -193,11 +193,18 @@ function onHighlightClassChange(event) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
class="primary generate-btn"
|
||||
:class="{ busy: store.busy }"
|
||||
:disabled="!store.canGenerate"
|
||||
@click="onGenerate"
|
||||
>
|
||||
{{ store.busy ? "Генерация…" : "Сгенерировать" }}
|
||||
<span
|
||||
class="generate-btn-fill"
|
||||
:style="{ width: `${store.busy ? store.generateProgress : 0}%` }"
|
||||
/>
|
||||
<span class="generate-btn-label">
|
||||
{{ store.busy ? "Генерация…" : "Сгенерировать" }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p class="status">{{ store.statusText }}</p>
|
||||
@@ -400,10 +407,41 @@ function onHighlightClassChange(event) {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.generate-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
.generate-btn-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
background: color-mix(in srgb, var(--primary-button-text, #142033) 22%, transparent);
|
||||
transition: width 0.18s ease;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.generate-btn.busy .generate-btn-fill {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, #fff 35%, var(--primary-button-bg, #ffb020)),
|
||||
color-mix(in srgb, #fff 12%, var(--primary-button-bg, #ffb020))
|
||||
);
|
||||
}
|
||||
.generate-btn-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.generate-btn.busy:disabled {
|
||||
opacity: 1;
|
||||
cursor: wait;
|
||||
}
|
||||
.status {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
Reference in New Issue
Block a user