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 {
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user