diff --git a/ui/v2.5/.stylelintrc b/ui/v2.5/.stylelintrc index 5f5be26f0..7442e1729 100644 --- a/ui/v2.5/.stylelintrc +++ b/ui/v2.5/.stylelintrc @@ -4,7 +4,7 @@ ], "extends": "stylelint-config-prettier", "rules": { - "indentation": 2, + "indentation": null, "at-rule-empty-line-before": [ "always", { except: ["after-same-name", "first-nested" ], ignore: ["after-comment"], diff --git a/ui/v2.5/src/components/Changelog/versions/v040.md b/ui/v2.5/src/components/Changelog/versions/v040.md index d4f8cd087..71f6bbfa7 100644 --- a/ui/v2.5/src/components/Changelog/versions/v040.md +++ b/ui/v2.5/src/components/Changelog/versions/v040.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Add filters tab in scene page. * Add selectable streaming quality profiles in the scene player. * Add scrapers list setting page. * Add support for individual images and manual creation of galleries. diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index d6838c66c..6b8352ef3 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -1,4 +1,4 @@ -import { Tab, Nav, Dropdown, Button } from "react-bootstrap"; +import { Tab, Nav, Dropdown, Button, ButtonGroup } from "react-bootstrap"; import queryString from "query-string"; import React, { useEffect, useState } from "react"; import { useParams, useLocation, useHistory, Link } from "react-router-dom"; @@ -25,6 +25,7 @@ import { OCounterButton } from "./OCounterButton"; import { SceneMoviePanel } from "./SceneMoviePanel"; import { DeleteScenesDialog } from "../DeleteScenesDialog"; import { SceneGenerateDialog } from "../SceneGenerateDialog"; +import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel"; interface ISceneParams { id?: string; @@ -226,22 +227,27 @@ export const Scene: React.FC = () => { ) : ( "" )} + + Filters + File Info Edit - - - - {renderOperations()} + + + + + {renderOperations()} + @@ -266,6 +272,9 @@ export const Scene: React.FC = () => { ) : ( "" )} + + + = ( + props: ISceneVideoFilterPanelProps +) => { + const contrastRange: SliderRange = { + min: 0, + default: 100, + max: 200, + divider: 1, + }; + const brightnessRange: SliderRange = { + min: 0, + default: 100, + max: 200, + divider: 1, + }; + const gammaRange: SliderRange = { + min: 0, + default: 100, + max: 200, + divider: 200, + }; + const saturateRange: SliderRange = { + min: 0, + default: 100, + max: 200, + divider: 1, + }; + const hueRotateRange: SliderRange = { + min: 0, + default: 0, + max: 360, + divider: 1, + }; + const whiteBalanceRange: SliderRange = { + min: 0, + default: 100, + max: 200, + divider: 200, + }; + const colourRange: SliderRange = { + min: 0, + default: 100, + max: 200, + divider: 1, + }; + const blurRange: SliderRange = { min: 0, default: 0, max: 250, divider: 10 }; + const rotateRange: SliderRange = { + min: 0, + default: 2, + max: 4, + divider: 1 / 90, + }; + const scaleRange: SliderRange = { + min: 0, + default: 100, + max: 200, + divider: 1, + }; + const aspectRatioRange: SliderRange = { + min: 0, + default: 150, + max: 300, + divider: 100, + }; + + const [contrastValue, setContrastValue] = useState(contrastRange.default); + const [brightnessValue, setBrightnessValue] = useState( + brightnessRange.default + ); + const [gammaValue, setGammaValue] = useState(gammaRange.default); + const [saturateValue, setSaturateValue] = useState(saturateRange.default); + const [hueRotateValue, setHueRotateValue] = useState(hueRotateRange.default); + const [whiteBalanceValue, setWhiteBalanceValue] = useState( + whiteBalanceRange.default + ); + const [redValue, setRedValue] = useState(colourRange.default); + const [greenValue, setGreenValue] = useState(colourRange.default); + const [blueValue, setBlueValue] = useState(colourRange.default); + const [blurValue, setBlurValue] = useState(blurRange.default); + const [rotateValue, setRotateValue] = useState(rotateRange.default); + const [scaleValue, setScaleValue] = useState(scaleRange.default); + const [aspectRatioValue, setAspectRatioValue] = useState( + aspectRatioRange.default + ); + + function updateVideoStyle() { + const playerId = JWUtils.playerID; + const playerVideoElement = document + .getElementById(playerId) + ?.getElementsByClassName("jw-video")[0]; + + if (playerVideoElement != null) { + let styleString = "filter:"; + let style = playerVideoElement.attributes.getNamedItem("style"); + + if (style == null) { + style = document.createAttribute("style"); + playerVideoElement.attributes.setNamedItem(style); + } + + if ( + whiteBalanceValue !== whiteBalanceRange.default || + redValue !== colourRange.default || + greenValue !== colourRange.default || + blueValue !== colourRange.default || + gammaValue !== gammaRange.default + ) { + styleString += " url(#videoFilter)"; + } + + if (contrastValue !== contrastRange.default) { + styleString += ` contrast(${contrastValue}%)`; + } + + if (brightnessValue !== brightnessRange.default) { + styleString += ` brightness(${brightnessValue}%)`; + } + + if (saturateValue !== saturateRange.default) { + styleString += ` saturate(${saturateValue}%)`; + } + + if (hueRotateValue !== hueRotateRange.default) { + styleString += ` hue-rotate(${hueRotateValue}deg)`; + } + + if (blurValue > blurRange.default) { + styleString += ` blur(${blurValue / blurRange.divider}px)`; + } + + styleString += "; transform:"; + + if (rotateValue !== rotateRange.default) { + styleString += ` rotate(${ + (rotateValue - rotateRange.default) / rotateRange.divider + }deg)`; + } + + if ( + scaleValue !== scaleRange.default || + aspectRatioValue !== aspectRatioRange.default + ) { + let xScale = scaleValue / scaleRange.divider / 100.0; + let yScale = scaleValue / scaleRange.divider / 100.0; + + if (aspectRatioValue > aspectRatioRange.default) { + xScale *= + (aspectRatioRange.divider + + aspectRatioValue - + aspectRatioRange.default) / + aspectRatioRange.divider; + } else if (aspectRatioValue < aspectRatioRange.default) { + yScale *= + (aspectRatioRange.divider + + aspectRatioRange.default - + aspectRatioValue) / + aspectRatioRange.divider; + } + + styleString += ` scale(${xScale},${yScale})`; + } + + style.value = `${styleString};`; + } + } + + function updateVideoFilters() { + const filterContainer = document.getElementById("video-filter-container"); + + if (filterContainer == null) { + return; + } + + const svg1 = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const videoFilter = document.createElementNS( + "http://www.w3.org/2000/svg", + "filter" + ); + videoFilter.setAttribute("id", "videoFilter"); + + if ( + whiteBalanceValue !== whiteBalanceRange.default || + redValue !== colourRange.default || + greenValue !== colourRange.default || + blueValue !== colourRange.default + ) { + const feColorMatrix = document.createElementNS( + "http://www.w3.org/2000/svg", + "feColorMatrix" + ); + feColorMatrix.setAttribute( + "values", + `${ + 1 + + (whiteBalanceValue - whiteBalanceRange.default) / + whiteBalanceRange.divider + + (redValue - colourRange.default) / colourRange.divider + } 0 0 0 0 0 ${ + 1.0 + (greenValue - colourRange.default) / colourRange.divider + } 0 0 0 0 0 ${ + 1 - + (whiteBalanceValue - whiteBalanceRange.default) / + whiteBalanceRange.divider + + (blueValue - colourRange.default) / colourRange.divider + } 0 0 0 0 0 1.0 0` + ); + videoFilter.appendChild(feColorMatrix); + } + + if (gammaValue !== gammaRange.default) { + const feComponentTransfer = document.createElementNS( + "http://www.w3.org/2000/svg", + "feComponentTransfer" + ); + + const feFuncR = document.createElementNS( + "http://www.w3.org/2000/svg", + "feFuncR" + ); + feFuncR.setAttribute("type", "gamma"); + feFuncR.setAttribute("amplitude", "1.0"); + feFuncR.setAttribute( + "exponent", + `${1 + (gammaRange.default - gammaValue) / gammaRange.divider}` + ); + feFuncR.setAttribute("offset", "0.0"); + feComponentTransfer.appendChild(feFuncR); + + const feFuncG = document.createElementNS( + "http://www.w3.org/2000/svg", + "feFuncG" + ); + feFuncG.setAttribute("type", "gamma"); + feFuncG.setAttribute("amplitude", "1.0"); + feFuncG.setAttribute( + "exponent", + `${1 + (gammaRange.default - gammaValue) / gammaRange.divider}` + ); + feFuncG.setAttribute("offset", "0.0"); + feComponentTransfer.appendChild(feFuncG); + + const feFuncB = document.createElementNS( + "http://www.w3.org/2000/svg", + "feFuncB" + ); + feFuncB.setAttribute("type", "gamma"); + feFuncB.setAttribute("amplitude", "1.0"); + feFuncB.setAttribute( + "exponent", + `${1 + (gammaRange.default - gammaValue) / gammaRange.divider}` + ); + feFuncB.setAttribute("offset", "0.0"); + feComponentTransfer.appendChild(feFuncB); + + const feFuncA = document.createElementNS( + "http://www.w3.org/2000/svg", + "feFuncA" + ); + feFuncA.setAttribute("type", "gamma"); + feFuncA.setAttribute("amplitude", "1.0"); + feFuncA.setAttribute("exponent", "1.0"); + feFuncA.setAttribute("offset", "0.0"); + feComponentTransfer.appendChild(feFuncA); + + videoFilter.appendChild(feComponentTransfer); + } + + svg1.appendChild(videoFilter); + + // Add or Replace existing svg + const filterContainerSvgs = filterContainer.getElementsByTagNameNS( + "http://www.w3.org/2000/svg", + "svg" + ); + if (filterContainerSvgs.length === 0) { + // attach container to document + filterContainer.appendChild(svg1); + } else { + // assume only one svg... maybe issue + filterContainer.replaceChild(svg1, filterContainerSvgs[0]); + } + } + + interface ISliderProps { + title: string; + className?: string; + range: SliderRange; + value: number; + setValue: (value: React.SetStateAction) => void; + displayValue: string; + } + + function renderSlider(sliderProps: ISliderProps) { + return ( +
+ {sliderProps.title} + + ) => + sliderProps.setValue(Number.parseInt(e.currentTarget.value, 10)) + } + /> + + sliderProps.setValue(sliderProps.range.default)} + onKeyPress={() => sliderProps.setValue(sliderProps.range.default)} + > + {sliderProps.displayValue} + +
+ ); + } + + function renderBlur() { + return renderSlider({ + title: "Blur", + range: blurRange, + value: blurValue, + setValue: setBlurValue, + displayValue: `${blurValue / blurRange.divider}px`, + }); + } + + function renderContrast() { + return renderSlider({ + title: "Contrast", + className: "contrast-slider", + range: contrastRange, + value: contrastValue, + setValue: setContrastValue, + displayValue: `${contrastValue / brightnessRange.divider}%`, + }); + } + + function renderBrightness() { + return renderSlider({ + title: "Brightness", + className: "brightness-slider", + range: brightnessRange, + value: brightnessValue, + setValue: setBrightnessValue, + displayValue: `${brightnessValue / brightnessRange.divider}%`, + }); + } + + function renderGammaSlider() { + return renderSlider({ + title: "Gamma", + className: "gamma-slider", + range: gammaRange, + value: gammaValue, + setValue: setGammaValue, + displayValue: `${(gammaValue - gammaRange.default) / gammaRange.divider}`, + }); + } + + function renderSaturate() { + return renderSlider({ + title: "Saturation", + className: "saturation-slider", + range: saturateRange, + value: saturateValue, + setValue: setSaturateValue, + displayValue: `${saturateValue / saturateRange.divider}%`, + }); + } + + function renderHueRotateSlider() { + return renderSlider({ + title: "Hue", + className: "hue-rotate-slider", + range: hueRotateRange, + value: hueRotateValue, + setValue: setHueRotateValue, + displayValue: `${hueRotateValue / hueRotateRange.divider}\xB0`, + }); + } + + function renderWhiteBalance() { + return renderSlider({ + title: "Warmth", + className: "white-balance-slider", + range: whiteBalanceRange, + value: whiteBalanceValue, + setValue: setWhiteBalanceValue, + displayValue: `${ + (whiteBalanceValue - whiteBalanceRange.default) / + whiteBalanceRange.divider + }`, + }); + } + + function renderRedSlider() { + return renderSlider({ + title: "Red", + className: "red-slider", + range: colourRange, + value: redValue, + setValue: setRedValue, + displayValue: `${ + (redValue - colourRange.default) / colourRange.divider + }%`, + }); + } + + function renderGreenSlider() { + return renderSlider({ + title: "Green", + className: "green-slider", + range: colourRange, + value: greenValue, + setValue: setGreenValue, + displayValue: `${ + (greenValue - colourRange.default) / colourRange.divider + }%`, + }); + } + + function renderBlueSlider() { + return renderSlider({ + title: "Blue", + className: "blue-slider", + range: colourRange, + value: blueValue, + setValue: setBlueValue, + displayValue: `${ + (blueValue - colourRange.default) / colourRange.divider + }%`, + }); + } + + function renderRotate() { + return renderSlider({ + title: "Rotate", + range: rotateRange, + value: rotateValue, + setValue: setRotateValue, + displayValue: `${ + (rotateValue - rotateRange.default) / rotateRange.divider + }\xB0`, + }); + } + + function renderScale() { + return renderSlider({ + title: "Scale", + range: scaleRange, + value: scaleValue, + setValue: setScaleValue, + displayValue: `${scaleValue / scaleRange.divider}%`, + }); + } + + function renderAspectRatio() { + return renderSlider({ + title: "Aspect", + range: aspectRatioRange, + value: aspectRatioValue, + setValue: setAspectRatioValue, + displayValue: `${ + (aspectRatioValue - aspectRatioRange.default) / aspectRatioRange.divider + }`, + }); + } + + function onRotateAndScale(direction: number) { + if (direction === 0) { + // Left -90 + setRotateValue(1); + } else { + // Right +90 + setRotateValue(3); + } + + // Calculate Required Scaling. + const sceneWidth = props.scene.file.width ?? 1; + const sceneHeight = props.scene.file.height ?? 1; + const sceneAspectRatio = sceneWidth / sceneHeight; + const sceneNewAspectRatio = sceneHeight / sceneWidth; + + const playerId = JWUtils.playerID; + const playerVideoElement = document + .getElementById(playerId) + ?.getElementsByClassName("jw-video")[0]; + + const playerWidth = playerVideoElement?.clientWidth ?? 1; + const playerHeight = playerVideoElement?.clientHeight ?? 1; + const playerAspectRation = playerWidth / playerHeight; + + // rs > ri ? (wi * hs/hi, hs) : (ws, hi * ws/wi) + // Determine if video is currently constrained by player height or width. + let scaledVideoHeight = 0; + let scaledVideoWidth = 0; + if (playerAspectRation > sceneAspectRatio) { + // Video has it's width scaled + // Video is constrained by it's height + scaledVideoHeight = playerHeight; + scaledVideoWidth = (playerHeight / sceneHeight) * sceneWidth; + } else { + // Video has it's height scaled + // Video is constrained by it's width + scaledVideoWidth = playerWidth; + scaledVideoHeight = (playerWidth / sceneWidth) * sceneHeight; + } + + // but now the video is rotated + let scaleFactor = 1; + if (playerAspectRation > sceneNewAspectRatio) { + // Rotated video will be constrained by it's height + // so we need to scaledVideoWidth to match the player height + scaleFactor = playerHeight / scaledVideoWidth; + } else { + // Rotated video will be constrained by it's width + // so we need to scaledVideoHeight to match the player width + scaleFactor = playerWidth / scaledVideoHeight; + } + + setScaleValue(scaleFactor * 100); + } + + function renderRotateAndScale() { + return ( +
+ + + + + + +
+ ); + } + + function onResetFilters() { + setContrastValue(contrastRange.default); + setBrightnessValue(brightnessRange.default); + setGammaValue(gammaRange.default); + setSaturateValue(saturateRange.default); + setHueRotateValue(hueRotateRange.default); + setWhiteBalanceValue(whiteBalanceRange.default); + setRedValue(colourRange.default); + setGreenValue(colourRange.default); + setBlueValue(colourRange.default); + setBlurValue(blurRange.default); + } + + function onResetTransforms() { + setScaleValue(scaleRange.default); + setRotateValue(rotateRange.default); + setAspectRatioValue(aspectRatioRange.default); + } + + function renderResetButton() { + return ( +
+ + + + + + +
+ ); + } + + function renderFilterContainer() { + return
; + } + + // On render update video style. + updateVideoFilters(); + updateVideoStyle(); + + return ( +
+
+ +
Filters
+
+
+ {renderBrightness()} + {renderContrast()} + {renderGammaSlider()} + {renderSaturate()} + {renderHueRotateSlider()} + {renderWhiteBalance()} + {renderRedSlider()} + {renderGreenSlider()} + {renderBlueSlider()} + {renderBlur()} +
+ +
Transforms
+
+
+ {renderRotate()} + {renderScale()} + {renderAspectRatio()} +
+ +
Actions
+
+
+ {renderRotateAndScale()} + {renderResetButton()} + {renderFilterContainer()} +
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index ba557d9a1..6368a68ab 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -266,6 +266,191 @@ textarea.scene-description { word-wrap: break-word; } +input[type="range"].filter-slider { + height: 100%; + margin: 0; + padding-left: 0; + padding-right: 0; +} + +@mixin contrast-slider() { + background: rgb(255, 255, 255); + background: linear-gradient( + -1deg, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 1) 40%, + rgba(0, 0, 0, 1) 60%, + rgba(0, 0, 0, 1) 100% + ), + linear-gradient(90deg, rgba(61, 61, 61, 1) 0%, rgba(255, 255, 255, 0) 100%); + background-blend-mode: color; +} + +input[type="range"].contrast-slider { + &::-webkit-slider-runnable-track { + @include contrast-slider; + } + + &::-moz-range-track { + @include contrast-slider; + } + + &::-ms-track { + @include contrast-slider; + } +} + +@mixin brightness-slider() { + background: rgb(41, 41, 41); + background: linear-gradient( + 90deg, + rgba(41, 41, 41, 1) 0%, + rgba(255, 255, 255, 1) 100% + ); +} + +input[type="range"].brightness-slider { + &::-webkit-slider-runnable-track { + @include brightness-slider; + } + + &::-moz-range-track { + @include brightness-slider; + } + + &::-ms-track { + @include brightness-slider; + } +} + +@mixin saturation-slider() { + background: rgb(198, 198, 199); + background: linear-gradient( + 90deg, + rgba(198, 198, 199, 1) 0%, + rgba(255, 71, 71, 1) 100% + ); +} + +input[type="range"].saturation-slider { + &::-webkit-slider-runnable-track { + @include saturation-slider; + } + + &::-moz-range-track { + @include saturation-slider; + } + + &::-ms-track { + @include saturation-slider; + } +} + +@mixin hue-rotate-slider() { + background: rgb(198, 198, 199); + background: linear-gradient( + to right, + orange, + yellow, + green, + cyan, + blue, + violet + ); +} + +input[type="range"].hue-rotate-slider { + &::-webkit-slider-runnable-track { + @include hue-rotate-slider; + } + + &::-moz-range-track { + @include hue-rotate-slider; + } + + &::-ms-track { + @include hue-rotate-slider; + } +} + +@mixin white-balance-slider() { + background: rgb(90, 138, 210); + background: linear-gradient( + 90deg, + rgba(90, 138, 210, 1) 0%, + rgba(83, 72, 72, 1) 50%, + rgba(252, 186, 8, 1) 100% + ); +} + +input[type="range"].white-balance-slider { + &::-webkit-slider-runnable-track { + @include white-balance-slider; + } + + &::-moz-range-track { + @include white-balance-slider; + } + + &::-ms-track { + @include white-balance-slider; + } +} + +@mixin red-slider() { + background: rgb(255, 0, 0); +} + +input[type="range"].red-slider { + &::-webkit-slider-runnable-track { + @include red-slider; + } + + &::-moz-range-track { + @include red-slider; + } + + &::-ms-track { + @include red-slider; + } +} + +@mixin green-slider() { + background: rgb(0, 255, 0); +} + +input[type="range"].green-slider { + &::-webkit-slider-runnable-track { + @include green-slider; + } + + &::-moz-range-track { + @include green-slider; + } + + &::-ms-track { + @include green-slider; + } +} + +@mixin blue-slider() { + background: rgb(0, 0, 255); +} + +input[type="range"].blue-slider { + &::-webkit-slider-runnable-track { + @include blue-slider; + } + + &::-moz-range-track { + @include blue-slider; + } + + &::-ms-track { + @include blue-slider; + } +} + @media (min-width: 1200px), (max-width: 575px) { .performer-card .flag-icon { height: 1.33rem;