feat(web): FFmpeg profile, filler preset, watermark editors (#143)

Three new SPA list+editor surfaces, closing the Blazor-only gap found in the
#91 phase-(b) recon:

- FFmpeg Profiles (System group): list with resolution/video/audio summary +
  add/copy/delete; full General/Video/Audio editor. Per-hwaccel video-format
  and profile rules ported from FFmpegProfileEditViewModelValidator; video
  preset list ported from FFmpegLibraryHelper.PresetsForFFmpegProfile /
  AvailablePresets.ForAccelAndFormat (cited in code). Resolutions from
  /api/settings/resolutions, hwaccel kinds from the new lookup endpoint.
  Copy = client-side clone into the create form (no server copy endpoint).
- Filler Presets (Media group): list + editor with the Blazor VM interlocks
  (Fallback/Tail force mode None; non-MidRoll clears expression; Fallback
  disables chapters; Playlist collection type forces Count mode; duration/
  count/pad fields gated by mode). Collection pickers use the existing
  /api/library/browse client for all seven collection types incl. Playlist.
- Watermarks (System group): list + editor with mode-driven field enabling,
  ChannelLogo vs Custom image source, upload via /api/artwork/uploads
  (target=watermark) and preview via /artwork/watermarks/{path}.
- App.tsx: ScreenIds/routes (sub-paths /add + /{id}) and nav registration;
  api modules ffmpegProfiles/fillerPresets/watermarks with URL-assert tests;
  SettingsScreen Classic-UI help text updated (these are no longer
  Blazor-only) and the FFmpeg-profiles card now links to the new screen.
- Regenerated web/src/api/generated/v1.d.ts from the #159 OpenAPI changes.

web verification: typecheck clean; vitest 188/188 (14 files); vite build OK;
eslint clean on touched files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 14:33:01 +02:00
co-authored by Claude Fable 5
parent 46c8c2e2dc
commit e95eed11dd
14 changed files with 2805 additions and 16 deletions
+122 -9
View File
@@ -303,6 +303,23 @@ export interface components {
"normalizeFramerate": boolean;
"normalizeColors": boolean;
"deinterlaceVideo": boolean;
};
"CreateFillerPresetRequest": {
"name": string;
"fillerKind": components["schemas"]["FillerKind"];
"fillerMode": components["schemas"]["FillerMode"];
"duration": null | string;
"count": null | number;
"padToNearestMinute": null | number;
"allowWatermarks": boolean;
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"mediaItemId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"playlistId": null | number;
"expression": null | string;
"useChaptersAsMediaItems": boolean;
};
"CreatePlayoutRequest": {
"channelId": number;
@@ -323,21 +340,43 @@ export interface components {
"CreateSmartCollectionRequest": {
"name": null | string;
"query": null | string;
};
"CreateWatermarkRequest": {
"name": string;
"mode": components["schemas"]["ChannelWatermarkMode"];
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
"image": null | string;
"imageContentType": null | string;
"location": components["schemas"]["WatermarkLocation"];
"size": components["schemas"]["WatermarkSize"];
"width": number;
"horizontalMargin": number;
"verticalMargin": number;
"frequencyMinutes": number;
"durationSeconds": number;
"opacity": number;
"opacityExpression": null | string;
"zIndex": number;
"placeWithinSourceContent": boolean;
};
"FFmpegFullProfileResponseModel": {
"id": number;
"name": null | string;
"name": string;
"threadCount": number;
"normalizeAudio": boolean;
"normalizeVideo": boolean;
"hardwareAcceleration": components["schemas"]["HardwareAccelerationKind"];
"vaapiDisplay": null | string;
"vaapiDisplay": string;
"vaapiDriver": components["schemas"]["VaapiDriver"];
"vaapiDevice": null | string;
"vaapiDevice": string;
"qsvExtraHardwareFrames": null | number;
"resolution": null | string;
"resolutionId": number;
"resolution": string;
"scalingBehavior": components["schemas"]["ScalingBehavior"];
"padMode": components["schemas"]["FilterMode"];
"videoFormat": components["schemas"]["FFmpegProfileVideoFormat"];
"videoProfile": null | string;
"videoPreset": null | string;
"videoProfile": string;
"videoPreset": string;
"allowBFrames": boolean;
"bitDepth": components["schemas"]["FFmpegProfileBitDepth"];
"videoBitrate": number;
@@ -347,10 +386,12 @@ export interface components {
"audioBitrate": number;
"audioBufferSize": number;
"normalizeLoudnessMode": components["schemas"]["NormalizeLoudnessMode"];
"targetLoudness": null | number;
"audioChannels": number;
"audioSampleRate": number;
"normalizeFramerate": boolean;
"deinterlaceVideo": null | boolean;
"normalizeColors": boolean;
"deinterlaceVideo": boolean;
};
"FFmpegProfileAudioFormat": "None" | "Aac" | "Ac3" | "AacLatm" | "Copy";
"FFmpegProfileBitDepth": "EightBit" | "TenBit";
@@ -375,6 +416,24 @@ export interface components {
};
"FillerKind": "None" | "PreRoll" | "MidRoll" | "PostRoll" | "Tail" | "Fallback" | "GuideMode" | "DecoDefault";
"FillerMode": "None" | "Duration" | "Count" | "Pad" | "RandomCount";
"FillerPresetFullResponseModel": {
"id": number;
"name": string;
"fillerKind": components["schemas"]["FillerKind"];
"fillerMode": components["schemas"]["FillerMode"];
"duration": null | string;
"count": null | number;
"padToNearestMinute": null | number;
"allowWatermarks": boolean;
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"mediaItemId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"playlistId": null | number;
"expression": null | string;
"useChaptersAsMediaItems": boolean;
};
"FillerPresetResponseModel": {
"id": number;
"name": null | string;
@@ -832,6 +891,23 @@ export interface components {
"initialSegmentCount": number;
"hlsDirectOutputFormat": components["schemas"]["OutputFormatKind"];
"defaultMpegTsScript": null | string;
};
"UpdateFillerPresetRequest": {
"name": string;
"fillerKind": components["schemas"]["FillerKind"];
"fillerMode": components["schemas"]["FillerMode"];
"duration": null | string;
"count": null | number;
"padToNearestMinute": null | number;
"allowWatermarks": boolean;
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"mediaItemId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"playlistId": null | number;
"expression": null | string;
"useChaptersAsMediaItems": boolean;
};
"UpdateHdhrSettingsRequest": {
"tunerCount": number;
@@ -867,6 +943,24 @@ export interface components {
"UpdateUiSettingsRequest": {
"isDarkMode": boolean;
"language": null | string;
};
"UpdateWatermarkRequest": {
"name": string;
"mode": components["schemas"]["ChannelWatermarkMode"];
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
"image": null | string;
"imageContentType": null | string;
"location": components["schemas"]["WatermarkLocation"];
"size": components["schemas"]["WatermarkSize"];
"width": number;
"horizontalMargin": number;
"verticalMargin": number;
"frequencyMinutes": number;
"durationSeconds": number;
"opacity": number;
"opacityExpression": null | string;
"zIndex": number;
"placeWithinSourceContent": boolean;
};
"UpdateXmltvSettingsRequest": {
"daysToBuild": number;
@@ -874,12 +968,31 @@ export interface components {
"blockBehavior": components["schemas"]["XmltvBlockBehavior"];
};
"VaapiDriver": "Default" | "iHD" | "i965" | "RadeonSI" | "Nouveau";
"WatermarkLocation": number;
"WatermarkFullResponseModel": {
"id": number;
"name": string;
"mode": components["schemas"]["ChannelWatermarkMode"];
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
"image": null | string;
"imageContentType": null | string;
"location": components["schemas"]["WatermarkLocation"];
"size": components["schemas"]["WatermarkSize"];
"width": number;
"horizontalMargin": number;
"verticalMargin": number;
"frequencyMinutes": number;
"durationSeconds": number;
"opacity": number;
"opacityExpression": null | string;
"zIndex": number;
"placeWithinSourceContent": boolean;
};
"WatermarkLocation": "BottomRight" | "BottomLeft" | "TopRight" | "TopLeft" | "TopMiddle" | "RightMiddle" | "BottomMiddle" | "LeftMiddle" | "MiddleCenter";
"WatermarkResponseModel": {
"id": number;
"name": null | string;
};
"WatermarkSize": number;
"WatermarkSize": "Scaled" | "ActualSize";
"WatermarkViewModel": {
"id": number;
"image": components["schemas"]["ArtworkContentTypeModel"];