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 { import {
Button, Button,
FeatureSettingsWindow, FeatureSettingsWindow,
MediaSourceField,
Select, Select,
SettingsCard, SettingsCard,
Switch, Switch,
@@ -14,12 +13,12 @@ import {
import { import {
cloneEnvironmentSettings, cloneEnvironmentSettings,
type EnvironmentBackground, type EnvironmentBackground,
type EnvironmentMediaKind,
type EnvironmentPage, type EnvironmentPage,
type EnvironmentSettings, type EnvironmentSettings,
type EnvironmentSurfaceId, type EnvironmentSurfaceId,
type UploadedEnvironmentMedia, type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings"; } from "../core/environment/environmentSettings";
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
import { import {
roots, roots,
workspaces, workspaces,
@@ -35,14 +34,11 @@ interface EnvironmentSettingsWindowProps {
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>; onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
onUpload: ( onUpload: (
surfaceId: EnvironmentSurfaceId, surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File, file: File,
) => Promise<UploadedEnvironmentMedia>; ) => Promise<UploadedEnvironmentMedia>;
} }
function inferMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
function patchPage( function patchPage(
draft: EnvironmentSettings, draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId, surfaceId: EnvironmentSurfaceId,
@@ -108,8 +104,6 @@ export function EnvironmentSettingsWindow({
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`, description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
})), })),
], [draft.pages]); ], [draft.pages]);
const previewKind = selectedBackground.mediaKind
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null);
const quickActionWorkspaces = useMemo( const quickActionWorkspaces = useMemo(
() => surfaceId === "home" () => surfaceId === "home"
? workspaces.filter((workspace) => !workspace.internalOnly) ? workspaces.filter((workspace) => !workspace.internalOnly)
@@ -138,28 +132,6 @@ export function EnvironmentSettingsWindow({
setDraft((current) => patchPage(current, surfaceId, patch)); 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 save = async () => {
const invalid = Object.entries(draft.pages).find(([, page]) => ( const invalid = Object.entries(draft.pages).find(([, page]) => (
!page.headerLabel.trim() !page.headerLabel.trim()
@@ -178,6 +150,26 @@ export function EnvironmentSettingsWindow({
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности."); setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
return; 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); setLocalError(null);
try { try {
await onSave({ await onSave({
@@ -253,8 +245,8 @@ export function EnvironmentSettingsWindow({
checked={selectedBackground.enabled} checked={selectedBackground.enabled}
label="Показывать фон" label="Показывать фон"
onChange={(enabled) => { onChange={(enabled) => {
if (enabled && !selectedBackground.url) { if (enabled && !selectedBackground.items.length) {
setLocalError("Сначала загрузите файл или укажите URL."); setLocalError("Сначала добавьте медиаконтент.");
return; return;
} }
setLocalError(null); setLocalError(null);
@@ -345,29 +337,18 @@ export function EnvironmentSettingsWindow({
</div> </div>
</div> </div>
<MediaSourceField <EnvironmentMediaPlaylistEditor
label="Видео / картинка" surfaceId={surfaceId}
kindLabel={previewKind ?? "media"} background={selectedBackground}
source={selectedBackground.source} disabled={busy}
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)."
error={localError ?? error} error={localError ?? error}
onSourceChange={(source) => setDraft((current) => onBusyChange={setUploading}
patchBackground(current, surfaceId, { source }))} onChange={(background) => {
onUrlChange={(url) => setDraft((current) => setLocalError(null);
patchBackground(current, surfaceId, { setDraft((current) =>
source: "url", patchBackground(current, surfaceId, background));
url: url || null, }}
mediaKind: url ? inferMediaKind(url) : null, onUpload={onUpload}
fileName: null,
}))}
onFileChange={uploadFile}
/> />
</div> </div>
</SettingsCard> </SettingsCard>
@@ -4,6 +4,7 @@ import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { EnvironmentPage } from "../core/environment/environmentSettings"; import type { EnvironmentPage } from "../core/environment/environmentSettings";
import type { RootDefinition, WorkspaceDefinition } from "../productModel"; import type { RootDefinition, WorkspaceDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation"; import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
export interface LandingStageProps { export interface LandingStageProps {
root: RootDefinition | null; root: RootDefinition | null;
@@ -25,28 +26,16 @@ export function LandingStage({
onOpenWorkspace, onOpenWorkspace,
}: LandingStageProps) { }: LandingStageProps) {
const { background } = page; const { background } = page;
const hasMedia = background.enabled && background.items.some(
(item) => item.url && item.mediaKind,
);
return ( return (
<section <section
className="landing-stage" className="landing-stage"
data-root={root?.id ?? "home"} data-root={root?.id ?? "home"}
data-has-media={background.enabled && background.url ? "true" : undefined} data-has-media={hasMedia ? "true" : undefined}
> >
{background.enabled && background.url ? ( <EnvironmentBackgroundMedia background={background} />
<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}
<div className="landing-stage__shade" aria-hidden="true" /> <div className="landing-stage__shade" aria-hidden="true" />
<div className="landing-stage__copy"> <div className="landing-stage__copy">
<span className="section-eyebrow">{page.eyebrow}</span> <span className="section-eyebrow">{page.eyebrow}</span>
@@ -4,14 +4,20 @@ export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video"; export type EnvironmentMediaKind = "image" | "video";
export type EnvironmentMediaSource = "file" | "url"; export type EnvironmentMediaSource = "file" | "url";
export interface EnvironmentBackground { export interface EnvironmentMediaItem {
enabled: boolean; id: string;
source: EnvironmentMediaSource; source: EnvironmentMediaSource;
url: string | null; url: string | null;
mediaKind: EnvironmentMediaKind | null; mediaKind: EnvironmentMediaKind | null;
fileName: string | null; fileName: string | null;
} }
export interface EnvironmentBackground {
enabled: boolean;
imageDurationSeconds: number;
items: EnvironmentMediaItem[];
}
export interface EnvironmentPage { export interface EnvironmentPage {
headerLabel: string; headerLabel: string;
eyebrow: string; eyebrow: string;
@@ -29,6 +35,7 @@ export interface EnvironmentSettings {
export interface UploadedEnvironmentMedia { export interface UploadedEnvironmentMedia {
surfaceId: EnvironmentSurfaceId; surfaceId: EnvironmentSurfaceId;
itemId: string;
url: string; url: string;
fileName: string; fileName: string;
mediaKind: EnvironmentMediaKind; mediaKind: EnvironmentMediaKind;
@@ -47,9 +54,20 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"polygon", "polygon",
]; ];
export const defaultEnvironmentImageDurationSeconds = 10;
export const maxEnvironmentMediaItems = 24;
function emptyBackground(): EnvironmentBackground { function emptyBackground(): EnvironmentBackground {
return { return {
enabled: false, enabled: false,
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
items: [],
};
}
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
return {
id: `media-${crypto.randomUUID()}`,
source: "file", source: "file",
url: null, url: null,
mediaKind: 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< const defaultQuickActions: Record<
EnvironmentSurfaceId, EnvironmentSurfaceId,
readonly [string | null, string | null] readonly [string | null, string | null]
@@ -110,26 +132,57 @@ function requireString(
return value; 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 { function decodeBackground(value: unknown, path: string): EnvironmentBackground {
const record = requireRecord(value, path); const record = requireRecord(value, path);
if (typeof record.enabled !== "boolean") { if (typeof record.enabled !== "boolean") {
throw new Error(`${path}.enabled должен быть boolean.`); throw new Error(`${path}.enabled должен быть boolean.`);
} }
if (!["file", "url"].includes(String(record.source))) { if (
throw new Error(`${path}.source не поддерживается.`); 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 ( if (
record.media_kind !== null !Array.isArray(record.items)
&& !["image", "video"].includes(String(record.media_kind)) || 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 { return {
enabled: record.enabled, enabled: record.enabled,
source: record.source as EnvironmentMediaSource, imageDurationSeconds: record.image_duration_seconds,
url: requireString(record.url, `${path}.url`, true), items,
mediaKind: record.media_kind as EnvironmentMediaKind | null,
fileName: requireString(record.file_name, `${path}.file_name`, true),
}; };
} }
@@ -156,7 +209,7 @@ function decodePage(value: unknown, path: string): EnvironmentPage {
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings { export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
const record = requireRecord(value, "environment"); const record = requireRecord(value, "environment");
if (record.schema_version !== "missioncore.operator-environment/v2") { if (record.schema_version !== "missioncore.operator-environment/v3") {
throw new Error("Версия настроек окружения не поддерживается."); throw new Error("Версия настроек окружения не поддерживается.");
} }
if ( if (
@@ -190,10 +243,14 @@ export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknow
secondary_workspace_id: page.secondaryWorkspaceId, secondary_workspace_id: page.secondaryWorkspaceId,
background: { background: {
enabled: page.background.enabled, enabled: page.background.enabled,
source: page.background.source, image_duration_seconds: page.background.imageDurationSeconds,
url: page.background.url, items: page.background.items.map((item) => ({
media_kind: page.background.mediaKind, id: item.id,
file_name: page.background.fileName, source: item.source,
url: item.url,
media_kind: item.mediaKind,
file_name: item.fileName,
})),
}, },
}]; }];
})), })),
@@ -204,12 +261,18 @@ export function decodeUploadedEnvironmentMedia(
value: unknown, value: unknown,
): UploadedEnvironmentMedia { ): UploadedEnvironmentMedia {
const record = requireRecord(value, "environment media"); 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 не поддерживается."); throw new Error("Версия загруженного media не поддерживается.");
} }
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) { if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
throw new Error("Экран загруженного media не поддерживается."); 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))) { if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error("Тип загруженного media не поддерживается."); throw new Error("Тип загруженного media не поддерживается.");
} }
@@ -222,6 +285,7 @@ export function decodeUploadedEnvironmentMedia(
} }
return { return {
surfaceId: record.surface_id as EnvironmentSurfaceId, surfaceId: record.surface_id as EnvironmentSurfaceId,
itemId: record.item_id,
url: requireString(record.url, "environment media.url")!, url: requireString(record.url, "environment media.url")!,
fileName: requireString(record.file_name, "environment media.file_name")!, fileName: requireString(record.file_name, "environment media.file_name")!,
mediaKind: record.media_kind as EnvironmentMediaKind, mediaKind: record.media_kind as EnvironmentMediaKind,
@@ -240,7 +304,12 @@ export function cloneEnvironmentSettings(
surfaceId, surfaceId,
{ {
...settings.pages[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>, ])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
}; };
@@ -17,6 +17,7 @@ interface EnvironmentSettingsController {
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>; save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
upload: ( upload: (
surfaceId: EnvironmentSurfaceId, surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File, file: File,
) => Promise<UploadedEnvironmentMedia>; ) => Promise<UploadedEnvironmentMedia>;
} }
@@ -101,17 +102,21 @@ export function useEnvironmentSettings(): EnvironmentSettingsController {
const upload = useCallback(async ( const upload = useCallback(async (
surfaceId: EnvironmentSurfaceId, surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File, file: File,
) => { ) => {
const response = await fetch(`/api/v1/environment/media/${surfaceId}`, { const response = await fetch(
method: "PUT", `/api/v1/environment/media/${surfaceId}/${itemId}`,
headers: { {
Accept: "application/json", method: "PUT",
"Content-Type": file.type || "application/octet-stream", headers: {
"X-NODEDC-File-Name": encodeURIComponent(file.name), Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
},
body: file,
}, },
body: file, );
});
if (!response.ok) { if (!response.ok) {
throw new Error(await responseError( throw new Error(await responseError(
response, response,
@@ -54,6 +54,72 @@
width: 100%; 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) { @media (max-width: 760px) {
.environment-settings__copy, .environment-settings__copy,
.environment-settings__quick-actions { .environment-settings__quick-actions {
@@ -63,4 +129,13 @@
.environment-settings__surface { .environment-settings__surface {
grid-template-columns: 1fr; 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 = {}) { function serverDocument(overrides = {}) {
const defaults = environment.defaultEnvironmentSettings(); const defaults = environment.defaultEnvironmentSettings();
return { return {
schema_version: "missioncore.operator-environment/v2", schema_version: "missioncore.operator-environment/v3",
revision: defaults.revision, revision: defaults.revision,
pages: Object.fromEntries( pages: Object.fromEntries(
Object.entries(defaults.pages).map(([surfaceId, page]) => [ Object.entries(defaults.pages).map(([surfaceId, page]) => [
@@ -38,10 +38,14 @@ function serverDocument(overrides = {}) {
secondary_workspace_id: page.secondaryWorkspaceId, secondary_workspace_id: page.secondaryWorkspaceId,
background: { background: {
enabled: page.background.enabled, enabled: page.background.enabled,
source: page.background.source, image_duration_seconds: page.background.imageDurationSeconds,
url: page.background.url, items: page.background.items.map((item) => ({
media_kind: page.background.mediaKind, id: item.id,
file_name: page.background.fileName, 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", primary_workspace_id: "contour-health",
background: { background: {
enabled: true, enabled: true,
source: "file", image_duration_seconds: 12,
url: `/api/v1/environment/media/fleet?generation=${"a".repeat(64)}`, items: [
media_kind: "video", {
file_name: "stage.mp4", 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.revision, 4);
assert.equal(decoded.pages.observation.headerLabel, "Контроль"); assert.equal(decoded.pages.observation.headerLabel, "Контроль");
assert.equal(decoded.pages.observation.title, "Контроль пространства"); assert.equal(decoded.pages.observation.title, "Контроль пространства");
assert.equal(decoded.pages.fleet.background.mediaKind, "video"); assert.equal(decoded.pages.fleet.background.items.length, 2);
assert.equal(encoded.pages.fleet.background.media_kind, "video"); 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(encoded.pages.fleet.primary_workspace_id, "contour-health");
assert.equal("schema_version" in encoded, false); 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); const draft = environment.cloneEnvironmentSettings(accepted);
draft.pages.observation.headerLabel = "Изменено"; draft.pages.observation.headerLabel = "Изменено";
draft.pages.fleet.background.enabled = true; draft.pages.fleet.background.enabled = true;
draft.pages.fleet.background.items.push(
environment.createEnvironmentMediaItem(),
);
assert.equal(accepted.pages.observation.headerLabel, "Наблюдение"); assert.equal(accepted.pages.observation.headerLabel, "Наблюдение");
assert.equal(accepted.pages.fleet.background.enabled, false); 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 urllib.parse import unquote, urlsplit
from fastapi import APIRouter, Header, HTTPException, Query, Request from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi import Path as ApiPath
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -23,17 +24,32 @@ EnvironmentSurfaceId = Literal[
"system", "system",
"polygon", "polygon",
] ]
ENVIRONMENT_SURFACE_IDS: tuple[EnvironmentSurfaceId, ...] = (
"home",
"fleet",
"observation",
"missions",
"data",
"system",
"polygon",
)
EnvironmentMediaKind = Literal["image", "video"] EnvironmentMediaKind = Literal["image", "video"]
EnvironmentMediaSource = Literal["file", "url"] EnvironmentMediaSource = Literal["file", "url"]
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v2"] = ( ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v3"] = (
"missioncore.operator-environment/v2" "missioncore.operator-environment/v3"
) )
ENVIRONMENT_MEDIA_SCHEMA_VERSION: Literal[ ENVIRONMENT_MEDIA_SCHEMA_VERSION: Literal[
"missioncore.operator-environment-media/v1" "missioncore.operator-environment-media/v1"
] = "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_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_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]] = { SUPPORTED_MEDIA_TYPES: dict[str, tuple[EnvironmentMediaKind, str]] = {
"image/avif": ("image", ".avif"), "image/avif": ("image", ".avif"),
"image/gif": ("image", ".gif"), "image/gif": ("image", ".gif"),
@@ -50,25 +66,43 @@ class StrictApiModel(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
class EnvironmentBackground(StrictApiModel): class EnvironmentMediaItem(StrictApiModel):
enabled: bool = False id: str = Field(pattern=r"^media-[a-z0-9-]{1,58}$")
source: EnvironmentMediaSource = "file" source: EnvironmentMediaSource = "file"
url: str | None = Field(default=None, max_length=2048) url: str = Field(max_length=2048)
media_kind: EnvironmentMediaKind | None = None media_kind: EnvironmentMediaKind
file_name: str | None = Field(default=None, max_length=255) file_name: str | None = Field(default=None, max_length=255)
@model_validator(mode="after") @model_validator(mode="after")
def validate_source(self) -> EnvironmentBackground: def validate_source(self) -> EnvironmentMediaItem:
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")
if self.source == "url": if self.source == "url":
parsed = urlsplit(self.url) parsed = urlsplit(self.url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc: 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/"): 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 return self
@@ -115,7 +149,7 @@ class EnvironmentSettingsPut(StrictApiModel):
class EnvironmentSettingsDocument(EnvironmentSettingsPut): 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): class EnvironmentMediaDocument(StrictApiModel):
@@ -131,6 +165,20 @@ class EnvironmentMediaDocument(StrictApiModel):
sha256: str = Field(pattern=r"^[a-f0-9]{64}$") 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: def default_environment_settings() -> EnvironmentSettingsDocument:
background = EnvironmentBackground() background = EnvironmentBackground()
return EnvironmentSettingsDocument( return EnvironmentSettingsDocument(
@@ -221,11 +269,48 @@ def default_environment_settings() -> EnvironmentSettingsDocument:
) )
def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument: def _upgrade_legacy_background(
if not isinstance(payload, dict): surface_id: str,
raise ValueError("environment document must be an object") value: object,
if payload.get("schema_version") != "missioncore.operator-environment/v1": ) -> EnvironmentBackground:
return EnvironmentSettingsDocument.model_validate(payload) 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") revision = payload.get("revision")
labels = payload.get("header_labels") labels = payload.get("header_labels")
backgrounds = payload.get("backgrounds") backgrounds = payload.get("backgrounds")
@@ -238,7 +323,6 @@ def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
pages: dict[str, EnvironmentPage] = {} pages: dict[str, EnvironmentPage] = {}
for surface_id, default_page in defaults.pages: for surface_id, default_page in defaults.pages:
legacy_label = labels.get(surface_id) legacy_label = labels.get(surface_id)
legacy_background = backgrounds.get(surface_id)
pages[surface_id] = default_page.model_copy( pages[surface_id] = default_page.model_copy(
update={ update={
"header_label": ( "header_label": (
@@ -246,8 +330,9 @@ def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
if isinstance(legacy_label, str) and legacy_label.strip() if isinstance(legacy_label, str) and legacy_label.strip()
else default_page.header_label else default_page.header_label
), ),
"background": EnvironmentBackground.model_validate( "background": _upgrade_legacy_background(
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: class EnvironmentSettingsStore:
def __init__(self, root: Path) -> None: def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve() self.root = root.expanduser().resolve()
@@ -270,7 +391,7 @@ class EnvironmentSettingsStore:
return default_environment_settings() return default_environment_settings()
try: try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8")) 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: except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc raise RuntimeError("operator environment settings are corrupt") from exc
@@ -302,7 +423,7 @@ class EnvironmentSettingsStore:
return default_environment_settings() return default_environment_settings()
try: try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8")) 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: except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc raise RuntimeError("operator environment settings are corrupt") from exc
@@ -368,6 +489,87 @@ class EnvironmentSettingsStore:
os.replace(metadata_temporary, metadata_path) os.replace(metadata_temporary, metadata_path)
return document 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( async def _write_upload(
request: Request, 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 return router
+111 -6
View File
@@ -15,6 +15,8 @@ from pydantic import ValidationError
from k1link.web.environment_api import ( from k1link.web.environment_api import (
EnvironmentBackground, EnvironmentBackground,
EnvironmentMediaDocument, EnvironmentMediaDocument,
EnvironmentMediaItem,
EnvironmentPlaylistMediaDocument,
EnvironmentSettingsPut, EnvironmentSettingsPut,
EnvironmentSettingsStore, EnvironmentSettingsStore,
build_environment_router, 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: def test_environment_background_rejects_untrusted_enabled_source() -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
EnvironmentBackground( EnvironmentMediaItem(
enabled=True, id="media-invalid-url",
source="url", source="url",
url="file:///tmp/background.mp4", url="file:///tmp/background.mp4",
media_kind="video", media_kind="video",
) )
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
EnvironmentBackground( EnvironmentMediaItem(
enabled=True, id="media-invalid-file",
source="file", source="file",
url="/private/operator/background.mp4", url="/private/operator/background.mp4",
media_kind="video", media_kind="video",
) )
with pytest.raises(ValidationError):
EnvironmentBackground(enabled=True, items=[])
def test_environment_media_is_generation_bound_and_stored_outside_git( def test_environment_media_is_generation_bound_and_stored_outside_git(
tmp_path: Path, 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() 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: def test_default_environment_has_every_product_surface() -> None:
document = default_environment_settings() document = default_environment_settings()
assert set(document.pages.model_dump()) == { assert set(document.pages.model_dump()) == {
@@ -203,7 +267,7 @@ def test_default_environment_has_every_product_surface() -> None:
"system", "system",
"polygon", "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" 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.revision == 10
assert migrated.pages.observation.header_label == "Контроль" 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 migrated.pages.fleet.primary_workspace_id == "contour-health"
assert "center" not in migrated.pages.model_dump() 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"