feat(ui): add environment media playlists
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user