Распознавание не удалось{photo.Recognition ? " — модель вернула ошибку вместо описания" : ""}.
Опиши кадр вручную в поле ниже: этот текст пойдёт в план и анализ вместо распознавания
(перераспознать обычно бесполезно — снова переполнит контекст модели).
{photo.Recognition && (
показать ответ модели
{photo.Recognition}
)}
)}
{isWorking && (
распознаётся…
)}
{isQueued && (
в очереди на распознавание
)}
{showFix && (
)}
);
}
function SeriesScreen({ shootID, setRoute }) {
const [shootState, reloadShoot] = useAsync(async () => {
const list = await api.feed.shoots();
return (list || []).find((s) => s.ID === shootID) || null;
}, [shootID]);
const [photosState, reloadPhotos] = useAsync(() => api.feed.photos(shootID), [shootID]);
const [commentsState, reloadComments] = useAsync(() => api.review.ListComments(shootID), [shootID]);
const [selected, setSelected] = React.useState(null);
const [confirmDelShoot, setConfirmDelShoot] = React.useState(false);
const [confirmDelPhoto, setConfirmDelPhoto] = React.useState(null);
// «Сделать анализ» блокирует до конца анализа (очередь ждёт результат, ~минуты).
// analysing — чтобы сразу показать «анализ идёт…» и не дать нажать повторно
// (правка 2026-06-08: раньше кнопка не менялась, было непонятно, запустилось ли).
const [analysing, setAnalysing] = React.useState(false);
const [_, toast] = useToasts();
// Локальные правки заголовка/типа — отправляются на blur/change.
const saveMeta = async (patch) => {
try {
await api.feed.updateShoot(shootID, patch);
reloadShoot();
} catch (e) {
toast("Не удалось сохранить: " + e.message, "error");
}
};
const deleteShoot = async () => {
try {
await api.feed.deleteShoot(shootID);
toast("Серия удалена", "info");
setRoute("shoots");
} catch (e) {
toast("Не удалось удалить: " + e.message, "error");
}
};
const deletePhoto = async (photo) => {
try {
await api.feed.deletePhoto(photo.ID);
toast(`«${photo.Filename}» удалена`, "info");
setConfirmDelPhoto(null);
if (selected?.ID === photo.ID) setSelected(null);
reloadPhotos();
} catch (e) {
toast("Не удалось удалить: " + e.message, "error");
}
};
const shoot = shootState.data;
// Живой список фоток. useAsync.reload() обнуляет data (мигание спиннером на
// всю страницу), поэтому для фонового поллинга статусов держим отдельный
// снимок. Свежий ответ useAsync (ручной reload серии) сбрасывает его, чтобы
// не залипнуть на устаревшем (правка 2026-06-07).
const [livePhotos, setLivePhotos] = React.useState(null);
React.useEffect(() => { setLivePhotos(null); }, [photosState.data]);
// Стабильная сортировка по имени файла. Бэкенд /v1/photos отдаёт фото без
// гарантированного порядка, и он меняется после перераспознавания (меняется
// updatedAt) — из-за чего строки прыгали и казалось, что распознанная фотка
// «ушла в очередь». Сортируем по Filename (там номер кадра _DSCxxxxx) —
// порядок фиксированный и совпадает с нумерацией камеры (правка 2026-06-07).
const photos = (livePhotos || photosState.data || []).slice().sort(
(a, b) => (a.Filename || "").localeCompare(b.Filename || "", undefined, { numeric: true }),
);
// Тихий refresh статусов без мигания — дёргаем строки после «Перераспознать».
const refreshPhotosLive = React.useCallback(() => {
api.feed.photos(shootID)
.then((d) => { if (Array.isArray(d)) setLivePhotos(d); })
.catch(() => {});
}, [shootID]);
// Пока есть кадры в очереди/в процессе на стадии распознавания — тихо
// опрашиваем статусы каждые 3 сек. Распознавание идёт ПО ОДНОМУ кадру (один
// воркер очереди), поэтому Лена видит, как статусы меняются последовательно:
// queued → распознаётся → распознано/не удалось (правка 2026-06-07).
const inFlightCount = photos.filter(
(p) => p.Stage === "queued" || p.Stage === "recognizing",
).length;
React.useEffect(() => {
if (!shoot) return;
if (stageInfo(shoot.Stage, shoot).idx !== 0) return;
if (inFlightCount === 0) return;
const t = setInterval(refreshPhotosLive, 3000);
return () => clearInterval(t);
}, [shoot, inFlightCount, refreshPhotosLive]);
// Серийная «Поправка распознавания» — общий fix-коммент (photoID=0/null),
// учитывается в плане и анализе для ВСЕХ кадров (как seriesFix в Ревью 1).
// Подтягиваем из БД, но не затираем непустой локальный ввод (правка 2026-06-08).
const [seriesFix, setSeriesFix] = React.useState("");
React.useEffect(() => {
const list = commentsState.data || [];
const ser = list.find((c) => c.commentType === "fix" && (!c.photoId || c.photoId === 0));
const server = ser ? ser.body : "";
if (server !== seriesFix && server !== "") setSeriesFix(server);
}, [commentsState.data]);
const saveSeriesFix = async () => {
try {
await api.feed.upsertComment(shootID, 0, "fix", seriesFix);
reloadComments();
} catch (e) { toast("Не удалось сохранить: " + e.message, "error"); }
};
// Голосовой ввод серийной поправки — дописать + сразу сохранить.
const appendSeriesFix = (t) => {
setSeriesFix((prev) => {
const next = (prev.trim() ? prev.trim() + " " : "") + t;
api.feed.upsertComment(shootID, 0, "fix", next)
.then(() => reloadComments())
.catch(() => toast("Не удалось сохранить", "error"));
return next;
});
};
// Подсчёты для верхней статистики — по точным стадиям.
const cnt = photos.reduce((acc, p) => {
if (p.Stage === "queued") acc.pending++;
else if (p.Stage === "recognizing") acc.working++;
// Битое распознавание (Stage=recognized, но текст-ошибка / нет GIST) считаем
// как «не распозналось», а не «распознано» — иначе счётчик врал «34 распознано»
// при том что каждая строка красная (правка 2026-06-07).
else if (p.Stage === "failed" || recoLooksFailed(p)) acc.failed++;
else if (p.Stage === "recognized") acc.ok++;
else if (p.Recognition) acc.ok++; // fallback для исторических данных
else acc.pending++;
if (p.BannerType && p.BannerType !== "none") acc.banner++;
return acc;
}, { ok: 0, working: 0, pending: 0, failed: 0, banner: 0 });
if (shootState.loading || photosState.loading) {
return
загружаем серию…
;
}
if (!shoot) {
return (
Серия #{shootID} не найдена.
);
}
// Передаём весь shoot (с Recognized/Photos) — stageInfo использует прогресс
// чтобы не показывать «recognizing» когда все фотки уже готовы.
const info = stageInfo(shoot.Stage, shoot);
const stageLabel = STAGES[info.idx]?.label || shoot.Stage;
// Стадия распознавания (до анализа): idx===0 = new / recognizing / распознано-
// ждёт-анализа / failed. Тут показываем построчный список вместо грида, чтобы
// Лена правила распознавание до отправки на «Сделать анализ» (правка 2026-06-07).
const recognitionPhase = info.idx === 0 && photos.length > 0;
// Кадр пригоден для анализа, если он recognized ИЛИ failed, но Лена вписала
// ручное описание в «Поправку распознавания» (commentType="fix"). Нужно для
// кнопки «Сделать анализ»: серия из одних битых failed-кадров с описаниями
// тоже должна запускаться (правка 2026-06-07).
const comments = commentsState.data || [];
const hasManualFix = (photoID) =>
comments.some((c) => c.photoId === photoID && c.commentType === "fix" && (c.body || "").trim());
const analysableCount = photos.filter(
(p) => p.Stage === "recognized" || (p.Stage === "failed" && hasManualFix(p.ID)),
).length;
// Распознавание устаканилось — нет кадров в очереди/в процессе.
const recognitionSettled =
photos.length > 0 && photos.every((p) => p.Stage === "recognized" || p.Stage === "failed");
// Кадры «с ошибкой» — failed ИЛИ битое распознавание (см. recoLooksFailed):
// для кнопки «Перераспознать все с ошибкой» (правка 2026-06-07).
const erroredCount = photos.filter((p) => p.Stage === "failed" || recoLooksFailed(p)).length;
return (
{/* Header */}
{/* Заголовок — кликабельный для переименования */}
e.currentTarget.style.borderBottomColor = "var(--accent)"}
onBlur={(e) => {
e.currentTarget.style.borderBottomColor = "transparent";
const v = e.currentTarget.textContent.trim();
const old = shoot.Title || "";
if (v !== old) saveMeta({ Title: v });
}}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); }
if (e.key === "Escape") {
e.currentTarget.textContent = shoot.Title || `Серия ${shoot.ID}`;
e.currentTarget.blur();
}
}}
>{shoot.Title || `Серия ${shoot.ID}`}
{/* Тип контента — селект, и чекбокс editorial */}
{new Date(shoot.CreatedAt).toLocaleString("ru-RU", {
day: "2-digit", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit",
})} · {photos.length} файлов
{/* «Сбросить анализ» — удаляет ревью + анализ + тексты, сохраняет
recognition. Стадия серии откатывается на plan. Полезно когда
результат после правок «не идёт» и проще начать с чистого анализа. */}
{/* «Сбросить распознавание + анализ» — полная перезагрузка: всё
что наработала модель. Фотки откатываются на queued, шут — на new.
Нужно потом запустить «Запустить распознавание» заново. */}
{/* «В архив» доступен на ЛЮБОЙ стадии (не только csv/done) — серию в
работе можно убрать из активного списка в один клик. Вернуть — из
Архива кнопкой «Вернуть» (правка 2026-06-18). */}
{info.state === "waiting" && info.idx === 3 && (
)}
{info.state === "waiting" && info.idx === 5 && (
)}
{/* Stage csv/done — серия отработала, но Лена может зайти в Ревью 2
чтобы перепроверить или дополнить (правка 2026-06-05 по запросу).
Если внесёт правки — снова жмёт «Принять серию», pikasrv перезаливает
CSV, watcher перепрописывает EXIF. */}
{(shoot.Stage === "csv" || shoot.Stage === "done") && (
)}
{info.state === "pending" && info.idx === 0 && (
)}
{/* «Сделать анализ»: показываем когда распознавание устаканилось (нет
queued / recognizing — всё либо recognized, либо failed), а серия ещё
не дошла до review1. failed-кадры с ручным описанием проанализируются,
остальные failed просто пропустятся (правка 2026-06-07). Запускает
RunPlanAndAnalyse — 1 plan-вызов + N analyse. */}
{recognitionSettled && analysableCount > 0 && info.idx < 3 && (
)}
{/* «Перераспознать все с ошибкой» — failed + битые (overflow / без GIST).
Перераспознаёт ровно ошибочные кадры, не трогая нормальные и кадры
в очереди/процессе. Статусы обновятся вживую (правка 2026-06-07). */}
{erroredCount > 0 && (
)}
{/* «Перезапустить распознавание всей серии» — сбрасывает все фотки
и запускает с нуля. Удобно когда модель странно описала большую
часть кадров. */}
{photos.length > 0 && (
)}
{/* Стадия распознавания — построчный список как в Ревью 1 (правка 2026-06-07). */}
{recognitionPhase && (
{/* Серийная «Поправка распознавания» — общий fix-коммент на всю серию.
Учтётся для ВСЕХ кадров и в плане вариативности, и в анализе
(как «общий комментарий» в Ревью 1). Правка 2026-06-08. */}
{photos.map((p, i) => (
))}
)}
{/* Grid + boкс. панель — для стадий после распознавания (просмотр результата). */}
{!recognitionPhase && (