// web/screens/shoots.jsx — экран «Мои съёмки», по дизайну _design/.../shoots.jsx.
// Стадии конвейера, в порядке отображения. Соответствуют значениям
// shoot.Stage из pkg/pipeline/orchestrator.go.
const STAGES = [
{ id: "recognizing", short: "распз", label: "Распознавание" },
{ id: "plan", short: "план", label: "План вариативности" },
{ id: "analyze", short: "анализ", label: "Анализ" },
{ id: "review1", short: "р1", label: "Ревью 1" },
{ id: "generate", short: "тексты", label: "Тексты" },
{ id: "review2", short: "р2", label: "Ревью 2" },
{ id: "csv", short: "csv", label: "csv" },
{ id: "done", short: "exif", label: "EXIF · watcher" },
];
// stageInfo(stage, shoot?) — возвращает { idx, state, statusText } для shoot.
// idx — индекс текущей стадии в STAGES
// state — pending | running | waiting | done | failed
// statusText — короткая русская подпись для пилюли
//
// shoot опционален: если передан, мы учитываем фактический прогресс
// (Recognized/Photos), чтобы не показывать spinner когда все фотки готовы,
// но автоматический переход stage не случился (правка 2026-06-05).
function stageInfo(stage, shoot) {
if (stage === "new") return { idx: 0, state: "pending", statusText: "В очереди на распознавание" };
if (stage === "failed") return { idx: 0, state: "failed", statusText: "Сбой обработки (детали в логах)" };
if (stage === "done") return { idx: 7, state: "done", statusText: "Готово" };
if (stage === "review1") return { idx: 3, state: "waiting", statusText: "Ждёт ревью 1" };
if (stage === "review2") return { idx: 5, state: "waiting", statusText: "Ждёт ревью 2" };
// Если recognize фактически завершён (все фотки в recognized) — показываем
// как «готов к анализу», даём кнопку «Сделать анализ →».
if (stage === "recognizing" && shoot && shoot.Photos > 0 && shoot.Recognized >= shoot.Photos) {
return { idx: 0, state: "waiting", statusText: "Готов к анализу" };
}
const idx = STAGES.findIndex((s) => s.id === stage);
if (idx >= 0) return { idx, state: "running", statusText: STAGES[idx].label };
return { idx: 0, state: "pending", statusText: stage || "—" };
}
const MEDIUM_META = {
photo: { label: "Фото", icon: "image" },
video: { label: "Видео", icon: "film" },
vector: { label: "Vector", icon: "layers" },
};
function KindPills({ medium, editorial }) {
const m = MEDIUM_META[medium];
return (
<>
{medium !== "photo" && m && (
{m.label}
)}
{editorial && (
Editorial
)}
>
);
}
function StagePipeline({ info }) {
return (
{STAGES.map((s, i) => {
let state = "pending";
if (info.state === "done") state = "done";
else if (i < info.idx) state = "done";
else if (i === info.idx) state = info.state;
return (
{s.label}
);
})}
);
}
function ShootStatusPill({ info }) {
let cls = "pill";
if (info.state === "done") cls = "pill ok";
else if (info.state === "waiting") cls = "pill accent";
else if (info.state === "running") cls = "pill accent";
else if (info.state === "failed") cls = "pill warn";
else if (info.state === "pending" && info.idx === 0) cls = "pill warn";
return (
{info.statusText}
);
}
// shootDateISO — дата съёмки серии из префикса Title «ГГММДД …» (задаётся при
// импорте по EXIF, см. pkg/ingest). Возвращает "YYYY-MM-DD" или null, если
// префикса нет. По этой дате в БД датируются релизы (ключ release.releaseDate).
function shootDateISO(title) {
const m = /^(\d{2})(\d{2})(\d{2})(?:\D|$)/.exec(title || "");
if (!m) return null;
return "20" + m[1] + "-" + m[2] + "-" + m[3];
}
// ShootReleases — блок «Релизы» в карточке серии (T16, arch.md §16.1): готовые
// релизы моделей на дату этой съёмки (матчинг shootDateISO == release.releaseDate)
// как чипы + вход в создание релиза прямо из серии (дата фиксирована = дата серии).
function ShootReleases({ dateISO, setRoute }) {
const [, toast] = useToasts();
const [rels, setRels] = React.useState(null);
const [creating, setCreating] = React.useState(false);
const load = React.useCallback(() => {
api.release.ListByDate(dateISO)
.then((r) => setRels(Array.isArray(r) ? r : []))
.catch(() => setRels([]));
}, [dateISO]);
React.useEffect(() => { load(); }, [load]);
return (
дата: {dateISO} · выбери модели (одно описание на всех)
{models.map((m) => (
))}
setDesc((d) => (d ? d + " " : "") + t)} title="Надиктовать (можно по-русски)"/>
EN напрямую или RU → «Перевести»
);
}
function ShootsScreen({ setRoute, me }) {
const canRelease = !!(me && (me.isAdmin || (me.rights || []).includes("release.access")));
const [shoots, reloadShoots] = useAsync(() => api.feed.shoots("false"), []);
const [archiveCount] = useAsync(async () => {
try { const a = await api.feed.shoots("true"); return Array.isArray(a) ? a.length : 0; }
catch { return 0; }
}, []);
const [yandex] = useAsync(() => api.yandex.Status(), []);
const [_, toast] = useToasts();
// Тихий refresh списка без обнуления. useAsync.reload() ставит data=null →
// список схлопывается в спиннер → страница прыгает вверх. После действий по
// серии (Распознать / Анализ / Архив / Заново) обновляем именно так, оставаясь
// на том же месте (правка 2026-06-08).
const [liveShoots, setLiveShoots] = React.useState(null);
React.useEffect(() => { setLiveShoots(null); }, [shoots.data]);
const refreshShootsLive = React.useCallback(() => {
api.feed.shoots("false").then((r) => { if (Array.isArray(r)) setLiveShoots(r); }).catch(() => {});
}, []);
const [typeFilter, setTypeFilter] = React.useState("all");
const [stateFilter, setStateFilter] = React.useState("all"); // all | active | done
const [sortBy, setSortBy] = React.useState("date");
const [query, setQuery] = React.useState("");
const list = (liveShoots || shoots.data || []).map((s) => ({ ...s, _info: stageInfo(s.Stage, s) }));
const byType = (s) => {
if (typeFilter === "all") return true;
if (typeFilter === "editorial") return s.IsEditorial;
return s.ContentType === typeFilter;
};
const byState = (s) => {
if (stateFilter === "all") return true;
if (stateFilter === "done") return s._info.state === "done";
return s._info.state !== "done";
};
const byQuery = (s) => {
if (!query) return true;
const q = query.toLowerCase();
return (s.Title || "").toLowerCase().includes(q);
};
const sortFn = (a, b) => {
if (sortBy === "date") return (a.CreatedAt < b.CreatedAt) ? 1 : -1;
if (sortBy === "count") return b.Photos - a.Photos;
if (sortBy === "status") {
// По этапам конвейера: распознавание → план → анализ → ревью1 → тексты →
// ревью2 → csv → готово. _info.idx — позиция в STAGES (правка 2026-06-08,
// раньше сортировало по абстрактному state, не по ходу конвейера).
const d = (a._info.idx ?? 99) - (b._info.idx ?? 99);
if (d !== 0) return d;
return (a.CreatedAt < b.CreatedAt) ? 1 : -1; // в пределах этапа — новые выше
}
return 0;
};
const filtered = list.filter((s) => byType(s) && byState(s) && byQuery(s)).slice().sort(sortFn);
const countByType = list.reduce((acc, s) => {
acc[s.ContentType] = (acc[s.ContentType] || 0) + 1;
if (s.IsEditorial) acc.editorial = (acc.editorial || 0) + 1;
acc.all = (acc.all || 0) + 1;
return acc;
}, {});
const types = [
{ id: "all", label: "Все" },
{ id: "photo", label: "Фото", icon: "image" },
{ id: "video", label: "Видео", icon: "film" },
{ id: "vector", label: "Vector", icon: "layers" },
{ id: "editorial", label: "Editorial", icon: "edit" },
];
// Действия по серии. На MVP без OpenClaw полноценный конвейер не работает —
// показываем подсказку через title-tooltip, но кнопку оставляем кликабельной
// (Recognize отвалится с понятной серверной ошибкой, если pipeline не настроен).
const action = (s) => {
const info = s._info;
// Серия отправлена в очередь анализа и ждёт воркера (анализатор крутит
// другую серию). Стадия ещё «Готов к анализу», поэтому отдельная метка —
// иначе непонятно, что серия уже в очереди (правка 2026-06-22 по запросу Лены).
if (s.AnalyseQueued) {
return (
в очереди на анализ…
);
}
if (info.state === "pending" && info.idx === 0) {
return (
);
}
if (info.state === "running") {
// Для stage=csv серия уже отработала анализ и тексты — CSV выложен,
// ждём watcher. Лена может зайти в Ревью 2 и **отредактировать** итог
// (правка 2026-06-05 по запросу). Если внесёт правки — снова жмёт
// «Принять серию», CSV перезаливается и watcher переписывает EXIF.
if (info.idx === 6 /* csv */) {
return (
EXIF пишется…
);
}
return
в работе…
;
}
// Распознавание фактически готово (все фотки recognized), но анализ
// ещё не запускали — Лена жмёт «Сделать анализ» прямо с главной.
if (info.statusText === "Готов к анализу") {
return (
{/* Если в серии есть кадры с ошибкой — кнопка перераспознать их прямо
из списка, перед анализом. Оранжевая, как надпись «N/M · ⚠ E»
(правка 2026-06-08). */}
{s.Errored > 0 && (
)}
{/* Явная кнопка на этап «посмотреть/поправить распознавание» — открывает
страницу серии. Без неё неочевидно, что для правок надо просто
кликнуть по серии (правка 2026-06-22 по запросу Лены). */}
);
}
if (info.state === "waiting" && info.idx <= 3) {
return ;
}
if (info.state === "waiting") {
return ;
}
if (info.state === "done") {
// Готовая серия — кроме архивации даём открыть Ревью 2 для правок
// (правка 2026-06-05 по запросу Лены). Если правки внесены и нажмёшь
// «Принять серию» — CSV перезаливается, watcher переписывает EXIF.
return (
);
}
if (info.state === "failed") {
// recognizeErrored (а не RecognizeFailed) — ловит и Stage=failed, и
// «битые» recognized (текст-ошибка / нет GIST). Старый RecognizeFailed
// на упавшей серии находил 0 фоток и молча ничего не делал — кнопка
// «не срабатывала» (правка 2026-06-08).
return (
);
}
return null;
};
return (
Мои съёмки
{list.length} активных серий
{archiveCount.data ? ` · ${archiveCount.data} в архиве` : ""}
{" · рабочая папка "} _Pikasrv/
{yandex.data && !yandex.data.connected && " · Я.Диск не подключён"}