feat(ui): add environment media playlists

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 14:20:19 +03:00
parent c455301764
commit 8b1f109b09
11 changed files with 1076 additions and 138 deletions
@@ -0,0 +1,92 @@
import { useEffect, useMemo, useState } from "react";
import type {
EnvironmentBackground,
EnvironmentMediaItem,
} from "../core/environment/environmentSettings";
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
url: string;
mediaKind: "image" | "video";
};
function isReadyMediaItem(
item: EnvironmentMediaItem,
): item is ReadyEnvironmentMediaItem {
return Boolean(item.url && item.mediaKind);
}
export function EnvironmentBackgroundMedia({
background,
}: {
background: EnvironmentBackground;
}) {
const items = useMemo(
() => background.items.filter(isReadyMediaItem),
[background.items],
);
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
const [activeIndex, setActiveIndex] = useState(0);
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
const playableItems = items.filter((item) => !failedIds.has(item.id));
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
useEffect(() => {
setActiveIndex(0);
setFailedIds(new Set());
}, [playlistIdentity]);
useEffect(() => {
if (
!background.enabled
|| !activeItem
|| activeItem.mediaKind !== "image"
|| playableItems.length < 2
) {
return;
}
const timer = window.setTimeout(() => {
setActiveIndex((current) => (current + 1) % playableItems.length);
}, background.imageDurationSeconds * 1_000);
return () => window.clearTimeout(timer);
}, [
activeItem,
background.enabled,
background.imageDurationSeconds,
playableItems.length,
]);
if (!background.enabled || !activeItem) return null;
return (
<div className="landing-stage__media" aria-hidden="true">
{activeItem.mediaKind === "video" ? (
<video
key={activeItem.url}
src={activeItem.url}
autoPlay
muted
loop={playableItems.length === 1}
playsInline
onEnded={() => setActiveIndex((current) => (
(current + 1) % playableItems.length
))}
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
) : (
<img
key={activeItem.url}
src={activeItem.url}
alt=""
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,257 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Icon,
IconButton,
MediaSourceField,
RangeControl,
SortableList,
} from "@nodedc/ui-react";
import {
createEnvironmentMediaItem,
inferEnvironmentMediaKind,
maxEnvironmentMediaItems,
type EnvironmentBackground,
type EnvironmentMediaItem,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
interface EnvironmentMediaPlaylistEditorProps {
surfaceId: EnvironmentSurfaceId;
background: EnvironmentBackground;
disabled: boolean;
error: string | null;
onChange: (background: EnvironmentBackground) => void;
onBusyChange: (busy: boolean) => void;
onUpload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
const acceptedEnvironmentMedia = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"video/quicktime",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".avif",
".mp4",
".webm",
".mov",
].join(",");
function patchItem(
background: EnvironmentBackground,
itemId: string,
patch: Partial<EnvironmentMediaItem>,
): EnvironmentBackground {
return {
...background,
items: background.items.map((item) => (
item.id === itemId ? { ...item, ...patch } : item
)),
};
}
export function EnvironmentMediaPlaylistEditor({
surfaceId,
background,
disabled,
error,
onChange,
onBusyChange,
onUpload,
}: EnvironmentMediaPlaylistEditorProps) {
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const backgroundRef = useRef(background);
backgroundRef.current = background;
const displayedItems = useMemo(
() => [...background.items].reverse(),
[background.items],
);
useEffect(() => {
onBusyChange(uploadingIds.size > 0);
}, [onBusyChange, uploadingIds.size]);
useEffect(() => () => onBusyChange(false), [onBusyChange]);
const setItemError = (itemId: string, message?: string) => {
setItemErrors((current) => {
const next = { ...current };
if (message) next[itemId] = message;
else delete next[itemId];
return next;
});
};
const uploadFile = async (itemId: string, file?: File) => {
if (!file) return;
setUploadingIds((current) => new Set(current).add(itemId));
setItemError(itemId);
try {
const uploaded = await onUpload(surfaceId, itemId, file);
onChange(patchItem(backgroundRef.current, itemId, {
source: "file",
url: uploaded.url,
mediaKind: uploaded.mediaKind,
fileName: uploaded.fileName,
}));
} catch (reason) {
setItemError(
itemId,
reason instanceof Error
? reason.message
: "Не удалось загрузить медиаконтент.",
);
} finally {
setUploadingIds((current) => {
const next = new Set(current);
next.delete(itemId);
return next;
});
}
};
return (
<div className="environment-media-playlist">
<div className="environment-media-playlist__head">
<div>
<span>Видео / картинка</span>
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
</div>
<IconButton
label="Добавить медиаконтент"
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
onClick={() => onChange({
...background,
items: [...background.items, createEnvironmentMediaItem()],
})}
>
<Icon name="plus" />
</IconButton>
</div>
{displayedItems.length ? (
<SortableList
items={displayedItems}
getId={(item) => item.id}
className="environment-media-playlist__items"
onReorder={(items) => onChange({
...background,
items: [...items].reverse(),
})}
>
{(item, { handle }) => {
const playbackIndex = background.items.findIndex(
(candidate) => candidate.id === item.id,
);
return (
<div className="environment-media-playlist__item">
<MediaSourceField
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
kindLabel={item.mediaKind ?? "media"}
source={item.source}
url={item.url ?? ""}
fileName={item.fileName}
uploading={uploadingIds.has(item.id)}
previewSrc={item.url}
previewKind={item.mediaKind}
accept={acceptedEnvironmentMedia}
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
error={itemErrors[item.id] ?? (
playbackIndex === background.items.length - 1 ? error : null
)}
onSourceChange={(source) => {
if (source === item.source) return;
setItemError(item.id);
onChange(patchItem(background, item.id, {
source,
url: null,
mediaKind: null,
fileName: null,
}));
}}
onUrlChange={(url) => {
setItemError(item.id);
onChange(patchItem(background, item.id, {
source: "url",
url: url || null,
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
fileName: null,
}));
}}
onFileChange={(file) => void uploadFile(item.id, file)}
/>
<div className="environment-media-playlist__item-actions">
<IconButton
label={`Удалить медиаконтент ${playbackIndex + 1}`}
disabled={disabled || uploadingIds.has(item.id)}
onClick={() => {
setItemError(item.id);
const items = background.items.filter(
(candidate) => candidate.id !== item.id,
);
onChange({
...background,
enabled: items.length ? background.enabled : false,
items,
});
}}
>
<Icon name="trash" />
</IconButton>
{handle}
</div>
</div>
);
}}
</SortableList>
) : (
<>
<p className="environment-media-playlist__empty">
Добавьте первый файл или прямую ссылку на медиаконтент.
</p>
{error ? (
<p className="environment-media-playlist__error" role="alert">
{error}
</p>
) : null}
</>
)}
<div className="environment-media-playlist__timing">
<RangeControl
label="Показывать изображение"
value={background.imageDurationSeconds}
min={1}
max={60}
step={1}
disabled={disabled}
formatValue={(value) => `${value} с`}
onChange={(imageDurationSeconds) => onChange({
...background,
imageDurationSeconds,
})}
/>
<span>
Новые элементы появляются сверху. Воспроизведение начинается снизу;
перетаскивание меняет порядок.
</span>
</div>
</div>
);
}
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react";
import {
Button,
FeatureSettingsWindow,
MediaSourceField,
Select,
SettingsCard,
Switch,
@@ -14,12 +13,12 @@ import {
import {
cloneEnvironmentSettings,
type EnvironmentBackground,
type EnvironmentMediaKind,
type EnvironmentPage,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
import {
roots,
workspaces,
@@ -35,14 +34,11 @@ interface EnvironmentSettingsWindowProps {
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
onUpload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
function inferMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
function patchPage(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
@@ -108,8 +104,6 @@ export function EnvironmentSettingsWindow({
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
})),
], [draft.pages]);
const previewKind = selectedBackground.mediaKind
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null);
const quickActionWorkspaces = useMemo(
() => surfaceId === "home"
? workspaces.filter((workspace) => !workspace.internalOnly)
@@ -138,28 +132,6 @@ export function EnvironmentSettingsWindow({
setDraft((current) => patchPage(current, surfaceId, patch));
};
const uploadFile = async (file?: File) => {
if (!file) return;
setUploading(true);
setLocalError(null);
try {
const uploaded = await onUpload(surfaceId, file);
setDraft((current) => patchBackground(current, surfaceId, {
enabled: true,
source: "file",
url: uploaded.url,
mediaKind: uploaded.mediaKind,
fileName: uploaded.fileName,
}));
} catch (reason) {
setLocalError(reason instanceof Error
? reason.message
: "Не удалось загрузить фон окружения.");
} finally {
setUploading(false);
}
};
const save = async () => {
const invalid = Object.entries(draft.pages).find(([, page]) => (
!page.headerLabel.trim()
@@ -178,6 +150,26 @@ export function EnvironmentSettingsWindow({
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
return;
}
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
(page.background.enabled && !page.background.items.length)
|| page.background.items.some((item) => {
if (!item.url || !item.mediaKind) return true;
if (item.source !== "url") return false;
try {
const parsed = new URL(item.url);
return !["http:", "https:"].includes(parsed.protocol);
} catch {
return true;
}
})
));
if (invalidMedia) {
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
setLocalError(
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
);
return;
}
setLocalError(null);
try {
await onSave({
@@ -253,8 +245,8 @@ export function EnvironmentSettingsWindow({
checked={selectedBackground.enabled}
label="Показывать фон"
onChange={(enabled) => {
if (enabled && !selectedBackground.url) {
setLocalError("Сначала загрузите файл или укажите URL.");
if (enabled && !selectedBackground.items.length) {
setLocalError("Сначала добавьте медиаконтент.");
return;
}
setLocalError(null);
@@ -345,29 +337,18 @@ export function EnvironmentSettingsWindow({
</div>
</div>
<MediaSourceField
label="Видео / картинка"
kindLabel={previewKind ?? "media"}
source={selectedBackground.source}
url={selectedBackground.url ?? ""}
fileName={selectedBackground.fileName}
uploading={uploading}
previewSrc={selectedBackground.url}
previewKind={previewKind}
accept="image/png,image/jpeg,image/gif,image/webp,image/avif,video/mp4,video/webm,video/quicktime,.png,.jpg,.jpeg,.gif,.webp,.avif,.mp4,.webm,.mov"
path={`${surfaceId}.background → server environment media`}
hint="Файл сохраняется в Mission Core data root. URL должен быть доступен браузеру по HTTP(S)."
<EnvironmentMediaPlaylistEditor
surfaceId={surfaceId}
background={selectedBackground}
disabled={busy}
error={localError ?? error}
onSourceChange={(source) => setDraft((current) =>
patchBackground(current, surfaceId, { source }))}
onUrlChange={(url) => setDraft((current) =>
patchBackground(current, surfaceId, {
source: "url",
url: url || null,
mediaKind: url ? inferMediaKind(url) : null,
fileName: null,
}))}
onFileChange={uploadFile}
onBusyChange={setUploading}
onChange={(background) => {
setLocalError(null);
setDraft((current) =>
patchBackground(current, surfaceId, background));
}}
onUpload={onUpload}
/>
</div>
</SettingsCard>
@@ -4,6 +4,7 @@ import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { EnvironmentPage } from "../core/environment/environmentSettings";
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
export interface LandingStageProps {
root: RootDefinition | null;
@@ -25,28 +26,16 @@ export function LandingStage({
onOpenWorkspace,
}: LandingStageProps) {
const { background } = page;
const hasMedia = background.enabled && background.items.some(
(item) => item.url && item.mediaKind,
);
return (
<section
className="landing-stage"
data-root={root?.id ?? "home"}
data-has-media={background.enabled && background.url ? "true" : undefined}
data-has-media={hasMedia ? "true" : undefined}
>
{background.enabled && background.url ? (
<div className="landing-stage__media" aria-hidden="true">
{background.mediaKind === "video" ? (
<video
key={background.url}
src={background.url}
autoPlay
muted
loop
playsInline
/>
) : (
<img src={background.url} alt="" />
)}
</div>
) : null}
<EnvironmentBackgroundMedia background={background} />
<div className="landing-stage__shade" aria-hidden="true" />
<div className="landing-stage__copy">
<span className="section-eyebrow">{page.eyebrow}</span>
@@ -4,14 +4,20 @@ export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video";
export type EnvironmentMediaSource = "file" | "url";
export interface EnvironmentBackground {
enabled: boolean;
export interface EnvironmentMediaItem {
id: string;
source: EnvironmentMediaSource;
url: string | null;
mediaKind: EnvironmentMediaKind | null;
fileName: string | null;
}
export interface EnvironmentBackground {
enabled: boolean;
imageDurationSeconds: number;
items: EnvironmentMediaItem[];
}
export interface EnvironmentPage {
headerLabel: string;
eyebrow: string;
@@ -29,6 +35,7 @@ export interface EnvironmentSettings {
export interface UploadedEnvironmentMedia {
surfaceId: EnvironmentSurfaceId;
itemId: string;
url: string;
fileName: string;
mediaKind: EnvironmentMediaKind;
@@ -47,9 +54,20 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"polygon",
];
export const defaultEnvironmentImageDurationSeconds = 10;
export const maxEnvironmentMediaItems = 24;
function emptyBackground(): EnvironmentBackground {
return {
enabled: false,
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
items: [],
};
}
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
return {
id: `media-${crypto.randomUUID()}`,
source: "file",
url: null,
mediaKind: null,
@@ -57,6 +75,10 @@ function emptyBackground(): EnvironmentBackground {
};
}
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
const defaultQuickActions: Record<
EnvironmentSurfaceId,
readonly [string | null, string | null]
@@ -110,26 +132,57 @@ function requireString(
return value;
}
function decodeMediaItem(value: unknown, path: string): EnvironmentMediaItem {
const record = requireRecord(value, path);
if (
typeof record.id !== "string"
|| !/^media-[a-z0-9-]{1,58}$/.test(record.id)
) {
throw new Error(`${path}.id не поддерживается.`);
}
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error(`${path}.media_kind не поддерживается.`);
}
return {
id: record.id,
source: record.source as EnvironmentMediaSource,
url: requireString(record.url, `${path}.url`),
mediaKind: record.media_kind as EnvironmentMediaKind,
fileName: requireString(record.file_name, `${path}.file_name`, true),
};
}
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
const record = requireRecord(value, path);
if (typeof record.enabled !== "boolean") {
throw new Error(`${path}.enabled должен быть boolean.`);
}
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
if (
typeof record.image_duration_seconds !== "number"
|| !Number.isSafeInteger(record.image_duration_seconds)
|| record.image_duration_seconds < 1
|| record.image_duration_seconds > 300
) {
throw new Error(`${path}.image_duration_seconds некорректен.`);
}
if (
record.media_kind !== null
&& !["image", "video"].includes(String(record.media_kind))
!Array.isArray(record.items)
|| record.items.length > maxEnvironmentMediaItems
) {
throw new Error(`${path}.media_kind не поддерживается.`);
throw new Error(`${path}.items некорректен.`);
}
const items = record.items.map((item, index) =>
decodeMediaItem(item, `${path}.items[${index}]`));
if (new Set(items.map((item) => item.id)).size !== items.length) {
throw new Error(`${path}.items содержит повторяющиеся id.`);
}
return {
enabled: record.enabled,
source: record.source as EnvironmentMediaSource,
url: requireString(record.url, `${path}.url`, true),
mediaKind: record.media_kind as EnvironmentMediaKind | null,
fileName: requireString(record.file_name, `${path}.file_name`, true),
imageDurationSeconds: record.image_duration_seconds,
items,
};
}
@@ -156,7 +209,7 @@ function decodePage(value: unknown, path: string): EnvironmentPage {
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
const record = requireRecord(value, "environment");
if (record.schema_version !== "missioncore.operator-environment/v2") {
if (record.schema_version !== "missioncore.operator-environment/v3") {
throw new Error("Версия настроек окружения не поддерживается.");
}
if (
@@ -190,10 +243,14 @@ export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknow
secondary_workspace_id: page.secondaryWorkspaceId,
background: {
enabled: page.background.enabled,
source: page.background.source,
url: page.background.url,
media_kind: page.background.mediaKind,
file_name: page.background.fileName,
image_duration_seconds: page.background.imageDurationSeconds,
items: page.background.items.map((item) => ({
id: item.id,
source: item.source,
url: item.url,
media_kind: item.mediaKind,
file_name: item.fileName,
})),
},
}];
})),
@@ -204,12 +261,18 @@ export function decodeUploadedEnvironmentMedia(
value: unknown,
): UploadedEnvironmentMedia {
const record = requireRecord(value, "environment media");
if (record.schema_version !== "missioncore.operator-environment-media/v1") {
if (record.schema_version !== "missioncore.operator-environment-media/v2") {
throw new Error("Версия загруженного media не поддерживается.");
}
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
throw new Error("Экран загруженного media не поддерживается.");
}
if (
typeof record.item_id !== "string"
|| !/^media-[a-z0-9-]{1,58}$/.test(record.item_id)
) {
throw new Error("Идентификатор загруженного media не поддерживается.");
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error("Тип загруженного media не поддерживается.");
}
@@ -222,6 +285,7 @@ export function decodeUploadedEnvironmentMedia(
}
return {
surfaceId: record.surface_id as EnvironmentSurfaceId,
itemId: record.item_id,
url: requireString(record.url, "environment media.url")!,
fileName: requireString(record.file_name, "environment media.file_name")!,
mediaKind: record.media_kind as EnvironmentMediaKind,
@@ -240,7 +304,12 @@ export function cloneEnvironmentSettings(
surfaceId,
{
...settings.pages[surfaceId],
background: { ...settings.pages[surfaceId].background },
background: {
...settings.pages[surfaceId].background,
items: settings.pages[surfaceId].background.items.map((item) => ({
...item,
})),
},
},
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
};
@@ -17,6 +17,7 @@ interface EnvironmentSettingsController {
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
upload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
@@ -101,17 +102,21 @@ export function useEnvironmentSettings(): EnvironmentSettingsController {
const upload = useCallback(async (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => {
const response = await fetch(`/api/v1/environment/media/${surfaceId}`, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
const response = await fetch(
`/api/v1/environment/media/${surfaceId}/${itemId}`,
{
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
},
body: file,
},
body: file,
});
);
if (!response.ok) {
throw new Error(await responseError(
response,
@@ -54,6 +54,72 @@
width: 100%;
}
.environment-media-playlist {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.environment-media-playlist__head {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.environment-media-playlist__head > div {
display: grid;
min-width: 0;
gap: 0.22rem;
}
.environment-media-playlist__head span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.environment-media-playlist__head p,
.environment-media-playlist__empty,
.environment-media-playlist__timing > span,
.environment-media-playlist__error {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.4;
}
.environment-media-playlist__error {
color: rgb(var(--nodedc-danger-rgb));
}
.environment-media-playlist__items {
display: grid;
min-width: 0;
gap: 0.7rem;
}
.environment-media-playlist__item {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.4rem;
}
.environment-media-playlist__item-actions {
display: flex;
align-items: center;
gap: 0.1rem;
padding-top: 1.65rem;
}
.environment-media-playlist__timing {
display: grid;
gap: 0.42rem;
}
@media (max-width: 760px) {
.environment-settings__copy,
.environment-settings__quick-actions {
@@ -63,4 +129,13 @@
.environment-settings__surface {
grid-template-columns: 1fr;
}
.environment-media-playlist__item {
grid-template-columns: minmax(0, 1fr);
}
.environment-media-playlist__item-actions {
justify-self: end;
padding-top: 0;
}
}
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createServer } from "vite";
let server;
let backgroundMedia;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
backgroundMedia = await server.ssrLoadModule(
"/src/components/EnvironmentBackgroundMedia.tsx",
);
});
after(async () => {
await server?.close();
});
test("background playlist starts with its oldest media item", () => {
const markup = renderToStaticMarkup(
React.createElement(backgroundMedia.EnvironmentBackgroundMedia, {
background: {
enabled: true,
imageDurationSeconds: 10,
items: [
{
id: "media-oldest",
source: "url",
url: "https://example.test/oldest.png",
mediaKind: "image",
fileName: null,
},
{
id: "media-newest",
source: "url",
url: "https://example.test/newest.mp4",
mediaKind: "video",
fileName: null,
},
],
},
}),
);
assert.match(markup, /oldest\.png/);
assert.doesNotMatch(markup, /newest\.mp4/);
});
test("disabled background renders no media", () => {
const markup = renderToStaticMarkup(
React.createElement(backgroundMedia.EnvironmentBackgroundMedia, {
background: {
enabled: false,
imageDurationSeconds: 10,
items: [
{
id: "media-hidden",
source: "url",
url: "https://example.test/hidden.png",
mediaKind: "image",
fileName: null,
},
],
},
}),
);
assert.equal(markup, "");
});
@@ -24,7 +24,7 @@ after(async () => {
function serverDocument(overrides = {}) {
const defaults = environment.defaultEnvironmentSettings();
return {
schema_version: "missioncore.operator-environment/v2",
schema_version: "missioncore.operator-environment/v3",
revision: defaults.revision,
pages: Object.fromEntries(
Object.entries(defaults.pages).map(([surfaceId, page]) => [
@@ -38,10 +38,14 @@ function serverDocument(overrides = {}) {
secondary_workspace_id: page.secondaryWorkspaceId,
background: {
enabled: page.background.enabled,
source: page.background.source,
url: page.background.url,
media_kind: page.background.mediaKind,
file_name: page.background.fileName,
image_duration_seconds: page.background.imageDurationSeconds,
items: page.background.items.map((item) => ({
id: item.id,
source: item.source,
url: item.url,
media_kind: item.mediaKind,
file_name: item.fileName,
})),
},
},
]),
@@ -81,10 +85,23 @@ test("server document decodes and re-encodes the complete page contract", () =>
primary_workspace_id: "contour-health",
background: {
enabled: true,
source: "file",
url: `/api/v1/environment/media/fleet?generation=${"a".repeat(64)}`,
media_kind: "video",
file_name: "stage.mp4",
image_duration_seconds: 12,
items: [
{
id: "media-11111111-1111-4111-8111-111111111111",
source: "file",
url: `/api/v1/environment/media/fleet?generation=${"a".repeat(64)}`,
media_kind: "video",
file_name: "stage.mp4",
},
{
id: "media-22222222-2222-4222-8222-222222222222",
source: "url",
url: "https://example.test/background.png",
media_kind: "image",
file_name: null,
},
],
},
},
},
@@ -95,8 +112,11 @@ test("server document decodes and re-encodes the complete page contract", () =>
assert.equal(decoded.revision, 4);
assert.equal(decoded.pages.observation.headerLabel, "Контроль");
assert.equal(decoded.pages.observation.title, "Контроль пространства");
assert.equal(decoded.pages.fleet.background.mediaKind, "video");
assert.equal(encoded.pages.fleet.background.media_kind, "video");
assert.equal(decoded.pages.fleet.background.items.length, 2);
assert.equal(decoded.pages.fleet.background.items[0].mediaKind, "video");
assert.equal(decoded.pages.fleet.background.imageDurationSeconds, 12);
assert.equal(encoded.pages.fleet.background.items[1].media_kind, "image");
assert.equal(encoded.pages.fleet.background.image_duration_seconds, 12);
assert.equal(encoded.pages.fleet.primary_workspace_id, "contour-health");
assert.equal("schema_version" in encoded, false);
});
@@ -115,7 +135,11 @@ test("editing a cloned page cannot mutate accepted settings", () => {
const draft = environment.cloneEnvironmentSettings(accepted);
draft.pages.observation.headerLabel = "Изменено";
draft.pages.fleet.background.enabled = true;
draft.pages.fleet.background.items.push(
environment.createEnvironmentMediaItem(),
);
assert.equal(accepted.pages.observation.headerLabel, "Наблюдение");
assert.equal(accepted.pages.fleet.background.enabled, false);
assert.equal(accepted.pages.fleet.background.items.length, 0);
});
+289 -24
View File
@@ -11,6 +11,7 @@ from typing import Annotated, Literal
from urllib.parse import unquote, urlsplit
from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi import Path as ApiPath
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -23,17 +24,32 @@ EnvironmentSurfaceId = Literal[
"system",
"polygon",
]
ENVIRONMENT_SURFACE_IDS: tuple[EnvironmentSurfaceId, ...] = (
"home",
"fleet",
"observation",
"missions",
"data",
"system",
"polygon",
)
EnvironmentMediaKind = Literal["image", "video"]
EnvironmentMediaSource = Literal["file", "url"]
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v2"] = (
"missioncore.operator-environment/v2"
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v3"] = (
"missioncore.operator-environment/v3"
)
ENVIRONMENT_MEDIA_SCHEMA_VERSION: Literal[
"missioncore.operator-environment-media/v1"
] = "missioncore.operator-environment-media/v1"
ENVIRONMENT_PLAYLIST_MEDIA_SCHEMA_VERSION: Literal[
"missioncore.operator-environment-media/v2"
] = "missioncore.operator-environment-media/v2"
MAX_ENVIRONMENT_MEDIA_BYTES = 256 * 1024 * 1024
MAX_ENVIRONMENT_MEDIA_ITEMS = 24
DEFAULT_IMAGE_DURATION_SECONDS = 10
SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9А-Яа-яЁё._ -]+")
SAFE_MEDIA_ITEM_ID = re.compile(r"^media-[a-z0-9-]{1,58}$")
SUPPORTED_MEDIA_TYPES: dict[str, tuple[EnvironmentMediaKind, str]] = {
"image/avif": ("image", ".avif"),
"image/gif": ("image", ".gif"),
@@ -50,25 +66,43 @@ class StrictApiModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class EnvironmentBackground(StrictApiModel):
enabled: bool = False
class EnvironmentMediaItem(StrictApiModel):
id: str = Field(pattern=r"^media-[a-z0-9-]{1,58}$")
source: EnvironmentMediaSource = "file"
url: str | None = Field(default=None, max_length=2048)
media_kind: EnvironmentMediaKind | None = None
url: str = Field(max_length=2048)
media_kind: EnvironmentMediaKind
file_name: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_source(self) -> EnvironmentBackground:
if not self.enabled:
return self
if not self.url or self.media_kind is None:
raise ValueError("enabled background requires url and media kind")
def validate_source(self) -> EnvironmentMediaItem:
if self.source == "url":
parsed = urlsplit(self.url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("external background URL must use HTTP or HTTPS")
raise ValueError("external media URL must use HTTP or HTTPS")
elif not self.url.startswith("/api/v1/environment/media/"):
raise ValueError("file background must use the Mission Core media endpoint")
raise ValueError("file media must use the Mission Core media endpoint")
return self
class EnvironmentBackground(StrictApiModel):
enabled: bool = False
image_duration_seconds: int = Field(
default=DEFAULT_IMAGE_DURATION_SECONDS,
ge=1,
le=300,
)
items: list[EnvironmentMediaItem] = Field(
default_factory=list,
max_length=MAX_ENVIRONMENT_MEDIA_ITEMS,
)
@model_validator(mode="after")
def validate_playlist(self) -> EnvironmentBackground:
ids = [item.id for item in self.items]
if len(ids) != len(set(ids)):
raise ValueError("background media ids must be unique")
if self.enabled and not self.items:
raise ValueError("enabled background requires at least one media item")
return self
@@ -115,7 +149,7 @@ class EnvironmentSettingsPut(StrictApiModel):
class EnvironmentSettingsDocument(EnvironmentSettingsPut):
schema_version: Literal["missioncore.operator-environment/v2"] = ENVIRONMENT_SCHEMA_VERSION
schema_version: Literal["missioncore.operator-environment/v3"] = ENVIRONMENT_SCHEMA_VERSION
class EnvironmentMediaDocument(StrictApiModel):
@@ -131,6 +165,20 @@ class EnvironmentMediaDocument(StrictApiModel):
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
class EnvironmentPlaylistMediaDocument(StrictApiModel):
schema_version: Literal["missioncore.operator-environment-media/v2"] = (
ENVIRONMENT_PLAYLIST_MEDIA_SCHEMA_VERSION
)
surface_id: EnvironmentSurfaceId
item_id: str = Field(pattern=r"^media-[a-z0-9-]{1,58}$")
url: str
file_name: str
media_kind: EnvironmentMediaKind
media_type: str
byte_length: int = Field(ge=1, le=MAX_ENVIRONMENT_MEDIA_BYTES)
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
def default_environment_settings() -> EnvironmentSettingsDocument:
background = EnvironmentBackground()
return EnvironmentSettingsDocument(
@@ -221,11 +269,48 @@ def default_environment_settings() -> EnvironmentSettingsDocument:
)
def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
if not isinstance(payload, dict):
raise ValueError("environment document must be an object")
if payload.get("schema_version") != "missioncore.operator-environment/v1":
return EnvironmentSettingsDocument.model_validate(payload)
def _upgrade_legacy_background(
surface_id: str,
value: object,
) -> EnvironmentBackground:
if not isinstance(value, dict):
raise ValueError(f"legacy background {surface_id} must be an object")
enabled = value.get("enabled")
source = value.get("source")
url = value.get("url")
media_kind = value.get("media_kind")
file_name = value.get("file_name")
if not isinstance(enabled, bool):
raise ValueError(f"legacy background {surface_id} is incomplete")
if url is None and media_kind is None:
return EnvironmentBackground(enabled=False)
if source == "file":
source_value: EnvironmentMediaSource = "file"
elif source == "url":
source_value = "url"
else:
raise ValueError(f"legacy background {surface_id} has invalid source")
if not isinstance(url, str):
raise ValueError(f"legacy background {surface_id} has invalid URL")
if media_kind == "image":
media_kind_value: EnvironmentMediaKind = "image"
elif media_kind == "video":
media_kind_value = "video"
else:
raise ValueError(f"legacy background {surface_id} has invalid media kind")
if file_name is not None and not isinstance(file_name, str):
raise ValueError(f"legacy background {surface_id} has invalid file name")
item = EnvironmentMediaItem(
id=f"media-legacy-{surface_id}",
source=source_value,
url=url,
media_kind=media_kind_value,
file_name=file_name,
)
return EnvironmentBackground(enabled=enabled, items=[item])
def _upgrade_v1_environment(payload: dict[str, object]) -> EnvironmentSettingsDocument:
revision = payload.get("revision")
labels = payload.get("header_labels")
backgrounds = payload.get("backgrounds")
@@ -238,7 +323,6 @@ def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
pages: dict[str, EnvironmentPage] = {}
for surface_id, default_page in defaults.pages:
legacy_label = labels.get(surface_id)
legacy_background = backgrounds.get(surface_id)
pages[surface_id] = default_page.model_copy(
update={
"header_label": (
@@ -246,8 +330,9 @@ def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
if isinstance(legacy_label, str) and legacy_label.strip()
else default_page.header_label
),
"background": EnvironmentBackground.model_validate(
legacy_background
"background": _upgrade_legacy_background(
surface_id,
backgrounds.get(surface_id),
),
},
)
@@ -257,6 +342,42 @@ def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
)
def _upgrade_v2_environment(payload: dict[str, object]) -> EnvironmentSettingsDocument:
revision = payload.get("revision")
raw_pages = payload.get("pages")
if not isinstance(revision, int) or not isinstance(raw_pages, dict):
raise ValueError("v2 environment document is incomplete")
pages: dict[str, EnvironmentPage] = {}
for surface_id in ENVIRONMENT_SURFACE_IDS:
raw_page = raw_pages.get(surface_id)
if not isinstance(raw_page, dict):
raise ValueError(f"v2 environment page {surface_id} is incomplete")
pages[surface_id] = EnvironmentPage.model_validate(
{
**raw_page,
"background": _upgrade_legacy_background(
surface_id,
raw_page.get("background"),
),
}
)
return EnvironmentSettingsDocument(
revision=revision,
pages=EnvironmentPages.model_validate(pages),
)
def _upgrade_environment(payload: object) -> EnvironmentSettingsDocument:
if not isinstance(payload, dict):
raise ValueError("environment document must be an object")
schema_version = payload.get("schema_version")
if schema_version == "missioncore.operator-environment/v1":
return _upgrade_v1_environment(payload)
if schema_version == "missioncore.operator-environment/v2":
return _upgrade_v2_environment(payload)
return EnvironmentSettingsDocument.model_validate(payload)
class EnvironmentSettingsStore:
def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve()
@@ -270,7 +391,7 @@ class EnvironmentSettingsStore:
return default_environment_settings()
try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
return _upgrade_v1_environment(payload)
return _upgrade_environment(payload)
except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc
@@ -302,7 +423,7 @@ class EnvironmentSettingsStore:
return default_environment_settings()
try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
return _upgrade_v1_environment(payload)
return _upgrade_environment(payload)
except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc
@@ -368,6 +489,87 @@ class EnvironmentSettingsStore:
os.replace(metadata_temporary, metadata_path)
return document
def playlist_media_root(self, surface_id: EnvironmentSurfaceId) -> Path:
return self.media_root / surface_id
def playlist_media_metadata_path(
self,
surface_id: EnvironmentSurfaceId,
item_id: str,
) -> Path:
if not SAFE_MEDIA_ITEM_ID.fullmatch(item_id):
raise ValueError("invalid media item id")
return self.playlist_media_root(surface_id) / f"{item_id}.json"
def read_playlist_media(
self,
surface_id: EnvironmentSurfaceId,
item_id: str,
generation: str,
) -> tuple[Path, EnvironmentPlaylistMediaDocument]:
with self._lock:
metadata_path = self.playlist_media_metadata_path(surface_id, item_id)
try:
metadata = EnvironmentPlaylistMediaDocument.model_validate_json(
metadata_path.read_text(encoding="utf-8")
)
except (OSError, ValueError) as exc:
raise FileNotFoundError(item_id) from exc
if generation != metadata.sha256:
raise PermissionError("media generation changed")
extension = SUPPORTED_MEDIA_TYPES[metadata.media_type][1]
path = self.playlist_media_root(surface_id) / f"{item_id}{extension}"
if not path.is_file() or path.stat().st_size != metadata.byte_length:
raise FileNotFoundError(item_id)
return path, metadata
def finalize_playlist_media(
self,
*,
surface_id: EnvironmentSurfaceId,
item_id: str,
temporary_path: Path,
file_name: str,
media_type: str,
byte_length: int,
sha256: str,
) -> EnvironmentPlaylistMediaDocument:
if not SAFE_MEDIA_ITEM_ID.fullmatch(item_id):
raise ValueError("invalid media item id")
media_kind, extension = SUPPORTED_MEDIA_TYPES[media_type]
with self._lock:
item_root = self.playlist_media_root(surface_id)
item_root.mkdir(mode=0o700, parents=True, exist_ok=True)
destination = item_root / f"{item_id}{extension}"
metadata_path = self.playlist_media_metadata_path(surface_id, item_id)
for stale_path in item_root.glob(f"{item_id}.*"):
if stale_path == metadata_path:
continue
if stale_path != destination and stale_path.is_file():
stale_path.unlink()
os.replace(temporary_path, destination)
document = EnvironmentPlaylistMediaDocument(
surface_id=surface_id,
item_id=item_id,
url=(
f"/api/v1/environment/media/{surface_id}/{item_id}"
f"?generation={sha256}"
),
file_name=file_name,
media_kind=media_kind,
media_type=media_type,
byte_length=byte_length,
sha256=sha256,
)
metadata_temporary = metadata_path.with_suffix(".json.tmp")
metadata_temporary.write_text(
json.dumps(document.model_dump(mode="json"), ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
os.replace(metadata_temporary, metadata_path)
return document
async def _write_upload(
request: Request,
@@ -491,4 +693,67 @@ def build_environment_router(
},
)
@router.put("/media/{surface_id}/{item_id}")
async def upload_environment_playlist_media(
surface_id: EnvironmentSurfaceId,
item_id: Annotated[str, ApiPath(pattern=r"^media-[a-z0-9-]{1,58}$")],
request: Request,
file_name: Annotated[str | None, Header(alias="X-NODEDC-File-Name")] = None,
) -> EnvironmentPlaylistMediaDocument:
media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if media_type not in SUPPORTED_MEDIA_TYPES:
raise HTTPException(
status_code=415,
detail="Поддерживаются PNG, JPEG, WebP, GIF, AVIF, MP4, WebM и MOV.",
)
content_length = request.headers.get("content-length")
if content_length is not None:
try:
if int(content_length) > MAX_ENVIRONMENT_MEDIA_BYTES:
raise HTTPException(status_code=413, detail="Файл превышает лимит 256 МБ.")
except ValueError as exc:
raise HTTPException(status_code=422, detail="Некорректный размер файла.") from exc
environment_store = store()
item_root = environment_store.playlist_media_root(surface_id)
item_root.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary_path = item_root / f".{item_id}.{os.getpid()}.upload"
temporary_path.unlink(missing_ok=True)
byte_length, sha256 = await _write_upload(request, temporary_path)
extension = SUPPORTED_MEDIA_TYPES[media_type][1]
return environment_store.finalize_playlist_media(
surface_id=surface_id,
item_id=item_id,
temporary_path=temporary_path,
file_name=_safe_file_name(file_name, extension),
media_type=media_type,
byte_length=byte_length,
sha256=sha256,
)
@router.get("/media/{surface_id}/{item_id}")
def get_environment_playlist_media(
surface_id: EnvironmentSurfaceId,
item_id: Annotated[str, ApiPath(pattern=r"^media-[a-z0-9-]{1,58}$")],
generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
) -> FileResponse:
try:
path, metadata = store().read_playlist_media(
surface_id,
item_id,
generation,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Медиафайл окружения не найден.") from exc
except PermissionError as exc:
raise HTTPException(status_code=412, detail="Медиафайл окружения был заменён.") from exc
return FileResponse(
path,
media_type=metadata.media_type,
headers={
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
"ETag": f'"{metadata.sha256}"',
"X-Content-Type-Options": "nosniff",
},
)
return router
+111 -6
View File
@@ -15,6 +15,8 @@ from pydantic import ValidationError
from k1link.web.environment_api import (
EnvironmentBackground,
EnvironmentMediaDocument,
EnvironmentMediaItem,
EnvironmentPlaylistMediaDocument,
EnvironmentSettingsPut,
EnvironmentSettingsStore,
build_environment_router,
@@ -124,21 +126,24 @@ def test_environment_settings_reject_stale_revision(tmp_path: Path) -> None:
def test_environment_background_rejects_untrusted_enabled_source() -> None:
with pytest.raises(ValidationError):
EnvironmentBackground(
enabled=True,
EnvironmentMediaItem(
id="media-invalid-url",
source="url",
url="file:///tmp/background.mp4",
media_kind="video",
)
with pytest.raises(ValidationError):
EnvironmentBackground(
enabled=True,
EnvironmentMediaItem(
id="media-invalid-file",
source="file",
url="/private/operator/background.mp4",
media_kind="video",
)
with pytest.raises(ValidationError):
EnvironmentBackground(enabled=True, items=[])
def test_environment_media_is_generation_bound_and_stored_outside_git(
tmp_path: Path,
@@ -192,6 +197,65 @@ def test_environment_media_upload_route_streams_and_publishes_safe_metadata(
assert str(tmp_path) not in document.model_dump_json()
def test_playlist_media_is_item_scoped_and_generation_bound(tmp_path: Path) -> None:
root = tmp_path / "mission-data" / "ui-environment"
store = EnvironmentSettingsStore(root)
item_id = "media-11111111-1111-4111-8111-111111111111"
item_root = store.playlist_media_root("home")
item_root.mkdir(parents=True)
payload = b"\x89PNG\r\n\x1a\nplaylist-redacted"
temporary = item_root / ".playlist.upload"
temporary.write_bytes(payload)
digest = hashlib.sha256(payload).hexdigest()
document = store.finalize_playlist_media(
surface_id="home",
item_id=item_id,
temporary_path=temporary,
file_name="playlist.png",
media_type="image/png",
byte_length=len(payload),
sha256=digest,
)
path, restored = store.read_playlist_media("home", item_id, digest)
assert isinstance(document, EnvironmentPlaylistMediaDocument)
assert path == item_root / f"{item_id}.png"
assert path.read_bytes() == payload
assert restored == document
assert document.url == (
f"/api/v1/environment/media/home/{item_id}?generation={digest}"
)
with pytest.raises(PermissionError):
store.read_playlist_media("home", item_id, "0" * 64)
def test_playlist_media_upload_route_preserves_item_identity(tmp_path: Path) -> None:
root = tmp_path / "mission-data" / "ui-environment"
router = build_environment_router(lambda: root)
payload = b"\x89PNG\r\n\x1a\nplaylist-route-redacted"
item_id = "media-22222222-2222-4222-8222-222222222222"
upload = _endpoint(
router,
"/api/v1/environment/media/{surface_id}/{item_id}",
"PUT",
)
document = asyncio.run(
upload(
surface_id="home",
item_id=item_id,
request=_streaming_request(payload, "image/png"),
file_name="playlist.png",
)
)
assert isinstance(document, EnvironmentPlaylistMediaDocument)
assert document.item_id == item_id
assert document.media_kind == "image"
assert (root / "media" / "home" / f"{item_id}.png").read_bytes() == payload
def test_default_environment_has_every_product_surface() -> None:
document = default_environment_settings()
assert set(document.pages.model_dump()) == {
@@ -203,7 +267,7 @@ def test_default_environment_has_every_product_surface() -> None:
"system",
"polygon",
}
assert document.schema_version == "missioncore.operator-environment/v2"
assert document.schema_version == "missioncore.operator-environment/v3"
assert document.pages.fleet.primary_workspace_id == "contour-health"
@@ -264,6 +328,47 @@ def test_legacy_v1_environment_is_migrated_without_losing_operator_media(
assert migrated.revision == 10
assert migrated.pages.observation.header_label == "Контроль"
assert migrated.pages.fleet.background.file_name == "park.png"
assert migrated.pages.fleet.background.items[0].file_name == "park.png"
assert migrated.pages.fleet.background.image_duration_seconds == 10
assert migrated.pages.fleet.primary_workspace_id == "contour-health"
assert "center" not in migrated.pages.model_dump()
def test_v2_environment_is_migrated_to_ordered_playlist(tmp_path: Path) -> None:
store = EnvironmentSettingsStore(tmp_path / "operator-environment")
defaults = default_environment_settings()
store.root.mkdir(parents=True)
pages = defaults.pages.model_dump(mode="json")
for surface_id, page in pages.items():
page["background"] = {
"enabled": surface_id == "fleet",
"source": "file",
"url": (
f"/api/v1/environment/media/{surface_id}?generation="
+ "a" * 64
if surface_id == "fleet"
else None
),
"media_kind": "video" if surface_id == "fleet" else None,
"file_name": "legacy.mp4" if surface_id == "fleet" else None,
}
store.settings_path.write_text(
json.dumps(
{
"schema_version": "missioncore.operator-environment/v2",
"revision": 35,
"pages": pages,
}
),
encoding="utf-8",
)
migrated = store.read()
assert migrated.schema_version == "missioncore.operator-environment/v3"
assert migrated.revision == 35
assert migrated.pages.fleet.background.enabled is True
assert [item.id for item in migrated.pages.fleet.background.items] == [
"media-legacy-fleet"
]
assert migrated.pages.fleet.background.items[0].media_kind == "video"