From 35f38387f3322b764c3d34724f2997b1a82dc167 Mon Sep 17 00:00:00 2001 From: NeoGe0 Date: Sat, 5 Sep 2026 23:32:00 +0200 Subject: [PATCH 1/4] Added and updated customisation options for overlay --- ...c5d7f9b2_center_gravity_uses_the_offset.py | 71 ++++++++++++++++++ .../affiche/api/schemas/settings_schema.py | 1 + .../app/image/model/overlay_options.py | 7 ++ .../affiche/app/image/overlay_generator.py | 42 ++++++----- .../affiche/app/image/text_renderer.py | 3 +- affiche-backend/requirements.txt | 8 ++ .../tests/test_overlay_generator.py | 43 +++++++++++ affiche-backend/tests/test_text_renderer.py | 12 +++ .../src/components/image/OverlayRenderer.tsx | 51 ++++++++++--- .../image/PosterStyleControls.test.tsx | 35 +++++++++ .../components/image/PosterStyleControls.tsx | 74 +++++++++++++++++-- .../src/components/image/TextRenderer.tsx | 3 +- affiche-frontend/src/types/index.ts | 1 + 13 files changed, 310 insertions(+), 41 deletions(-) create mode 100644 affiche-backend/affiche/alembic/versions/e1a3c5d7f9b2_center_gravity_uses_the_offset.py diff --git a/affiche-backend/affiche/alembic/versions/e1a3c5d7f9b2_center_gravity_uses_the_offset.py b/affiche-backend/affiche/alembic/versions/e1a3c5d7f9b2_center_gravity_uses_the_offset.py new file mode 100644 index 0000000..9b7733b --- /dev/null +++ b/affiche-backend/affiche/alembic/versions/e1a3c5d7f9b2_center_gravity_uses_the_offset.py @@ -0,0 +1,71 @@ +import json +import logging + +import sqlalchemy as sa +from alembic import op + +revision = 'e1a3c5d7f9b2' +down_revision = 'd5a9c3e7b1f4' +branch_labels = None +depends_on = None + +logger = logging.getLogger(__name__) + +CENTER_OFFSET = 0.5 + +TABLES = (('library_settings', 'library_id'), ('style_profile', 'id')) + +def _repin(raw): + if not raw: + return None + try: + options = json.loads(raw) if isinstance(raw, str) else raw + except (TypeError, ValueError): + return None + if not isinstance(options, dict) or options.get('gravity') != 'center': + return None + if options.get('text_offset_ratio') == CENTER_OFFSET: + return None + options['text_offset_ratio'] = CENTER_OFFSET + return json.dumps(options) + +def _patch_tables(connection): + for table, key in TABLES: + rows = connection.execute( + sa.text(f"SELECT {key}, text_options FROM {table} WHERE text_options IS NOT NULL") + ).fetchall() + for row_key, raw in rows: + patched = _repin(raw) + if patched is not None: + connection.execute( + sa.text(f"UPDATE {table} SET text_options = :opts WHERE {key} = :key"), + {"opts": patched, "key": row_key}, + ) + +def _patch_defaults_file(): + from affiche.config.env_config import POSTER_CONFIG_FILE + from pathlib import Path + + path = Path(POSTER_CONFIG_FILE) + if not path.exists(): + return + data = json.loads(path.read_text(encoding='utf-8')) + text_options = data.get('text_options') + if not isinstance(text_options, dict) or text_options.get('gravity') != 'center': + return + if text_options.get('text_offset_ratio') == CENTER_OFFSET: + return + text_options['text_offset_ratio'] = CENTER_OFFSET + path.write_text(json.dumps(data, indent=2), encoding='utf-8') + +def upgrade() -> None: + connection = op.get_bind() + _patch_tables(connection) + try: + _patch_defaults_file() + except Exception: + logger.warning("Could not pin the global style's center offset; set it by hand in " + "Settings -> Style Options if the title has moved", exc_info=True) + +def downgrade() -> None: + pass diff --git a/affiche-backend/affiche/api/schemas/settings_schema.py b/affiche-backend/affiche/api/schemas/settings_schema.py index 3345187..e918e5f 100644 --- a/affiche-backend/affiche/api/schemas/settings_schema.py +++ b/affiche-backend/affiche/api/schemas/settings_schema.py @@ -10,6 +10,7 @@ class OverlayOptionsResponse(BaseModel): matte_height_ratio: float fade_height_ratio: float gradient_color: str + gradient_direction: Literal["bottom", "top", "left", "right"] = "bottom" vignette_strength: float vignette_color: str inner_glow_strength: float diff --git a/affiche-backend/affiche/app/image/model/overlay_options.py b/affiche-backend/affiche/app/image/model/overlay_options.py index 532a202..30d286d 100644 --- a/affiche-backend/affiche/app/image/model/overlay_options.py +++ b/affiche-backend/affiche/app/image/model/overlay_options.py @@ -36,6 +36,7 @@ class OverlayOptions: matte_height_ratio: float = 0.0 fade_height_ratio: float = 0.0 gradient_color: str = "#000000" + gradient_direction: Literal["bottom", "top", "left", "right"] = "bottom" vignette_strength: float = 0.0 vignette_color: str = "#000000" @@ -58,6 +59,12 @@ def __post_init__(self): if self.overlay_type not in ("poster", "background"): raise ValueError(f"overlay_type must be 'poster' or 'background', got {self.overlay_type}") + if self.gradient_direction not in ("bottom", "top", "left", "right"): + raise ValueError( + f"gradient_direction must be bottom, top, left or right, " + f"got {self.gradient_direction}" + ) + self.border_color = validate_hex_color(self.border_color) self.gradient_color = validate_hex_color(self.gradient_color) self.vignette_color = validate_hex_color(self.vignette_color) diff --git a/affiche-backend/affiche/app/image/overlay_generator.py b/affiche-backend/affiche/app/image/overlay_generator.py index 4dc118c..d4e10f0 100644 --- a/affiche-backend/affiche/app/image/overlay_generator.py +++ b/affiche-backend/affiche/app/image/overlay_generator.py @@ -89,23 +89,31 @@ def _apply_gradient_matte(self, canvas: Image.Image, options: OverlayOptions) -> mask = Image.new("L", (width, height), 0) draw = ImageDraw.Draw(mask) - matte_h_px = int(height * options.matte_height_ratio) - fade_h_px = int(height * options.fade_height_ratio) - - if matte_h_px > 0: - draw.rectangle( - [0, height - matte_h_px, width, height], - fill=255 - ) - - if fade_h_px > 0: - start_y = height - matte_h_px - fade_h_px - for y in range(fade_h_px): - alpha = int(255 * (y / fade_h_px)) - draw.line( - [(0, start_y + y), (width, start_y + y)], - fill=alpha - ) + vertical = options.gradient_direction in ("bottom", "top") + extent = height if vertical else width + + matte_px = int(extent * options.matte_height_ratio) + fade_px = int(extent * options.fade_height_ratio) + + profile = [255] * min(matte_px, extent) + for step in range(fade_px): + if len(profile) >= extent: + break + profile.append(int(255 * (fade_px - 1 - step) / fade_px)) + + for offset, alpha in enumerate(profile): + if alpha <= 0: + continue + if options.gradient_direction == "bottom": + y = height - 1 - offset + draw.line([(0, y), (width, y)], fill=alpha) + elif options.gradient_direction == "top": + draw.line([(0, offset), (width, offset)], fill=alpha) + elif options.gradient_direction == "right": + x = width - 1 - offset + draw.line([(x, 0), (x, height)], fill=alpha) + else: + draw.line([(offset, 0), (offset, height)], fill=alpha) gradient_layer.putalpha(mask) return Image.alpha_composite(canvas, gradient_layer) diff --git a/affiche-backend/affiche/app/image/text_renderer.py b/affiche-backend/affiche/app/image/text_renderer.py index 67ce07d..ac467a9 100644 --- a/affiche-backend/affiche/app/image/text_renderer.py +++ b/affiche-backend/affiche/app/image/text_renderer.py @@ -234,8 +234,7 @@ def _calculate_text_position( elif options.gravity == "north": y = padding + text_offset else: - safe_height = img_height - (2 * padding) - y = padding + (safe_height - text_height) // 2 + y = (img_height - text_offset) - text_height // 2 x = max(padding, min(x, img_width - padding - text_width)) diff --git a/affiche-backend/requirements.txt b/affiche-backend/requirements.txt index bed8a96..3a28427 100644 --- a/affiche-backend/requirements.txt +++ b/affiche-backend/requirements.txt @@ -1,16 +1,24 @@ fastapi>=0.115.0 uvicorn[standard]>=0.27.0 python-multipart>=0.0.6 + sqlalchemy>=2.0.25 alembic>=1.13.1 + plexapi>=4.15.10 + requests>=2.31.0 + Pillow>=10.3.0 numpy + python-jose[cryptography]>=3.3.0 bcrypt>=4.0.1 python-dotenv>=1.0.1 + pydantic>=2.5.3 + tvdb-v4-official + pytest httpx diff --git a/affiche-backend/tests/test_overlay_generator.py b/affiche-backend/tests/test_overlay_generator.py index 22f4500..90ceda1 100644 --- a/affiche-backend/tests/test_overlay_generator.py +++ b/affiche-backend/tests/test_overlay_generator.py @@ -103,6 +103,49 @@ def test_gradient_matte_applied(self): top_pixel = overlay.getpixel((1000, 100)) assert top_pixel[3] == 0 + def test_gradient_direction_defaults_to_bottom(self): + generator = OverlayGenerator() + options = OverlayOptions(matte_height_ratio=0.1, fade_height_ratio=0.2) + + assert options.gradient_direction == "bottom" + + overlay = generator.generate_overlay(options) + + assert overlay.getpixel((1000, 2900))[3] == 255 + assert overlay.getpixel((1000, 100))[3] == 0 + + @pytest.mark.parametrize("direction,solid,clear", [ + ("bottom", (1000, 2950), (1000, 50)), + ("top", (1000, 50), (1000, 2950)), + ("left", (50, 1500), (1950, 1500)), + ("right", (1950, 1500), (50, 1500)), + ]) + def test_gradient_grows_from_the_chosen_edge(self, direction, solid, clear): + generator = OverlayGenerator() + options = OverlayOptions( + matte_height_ratio=0.1, + fade_height_ratio=0.2, + gradient_direction=direction, + ) + + overlay = generator.generate_overlay(options) + + assert overlay.getpixel(solid)[3] == 255 + assert overlay.getpixel(clear)[3] == 0 + + def test_horizontal_gradient_measures_against_the_width(self): + generator = OverlayGenerator() + options = OverlayOptions(matte_height_ratio=0.1, gradient_direction="left") + + overlay = generator.generate_overlay(options) + + assert overlay.getpixel((199, 1500))[3] == 255 + assert overlay.getpixel((201, 1500))[3] == 0 + + def test_rejects_an_unknown_direction(self): + with pytest.raises(ValueError, match="gradient_direction"): + OverlayOptions(gradient_direction="sideways") + def test_inner_glow_applied(self): generator = OverlayGenerator() options = OverlayOptions( diff --git a/affiche-backend/tests/test_text_renderer.py b/affiche-backend/tests/test_text_renderer.py index ee1600c..6604385 100644 --- a/affiche-backend/tests/test_text_renderer.py +++ b/affiche-backend/tests/test_text_renderer.py @@ -213,6 +213,7 @@ def test_position_north_gravity(self): def test_position_center_gravity(self): options = TextOptions( gravity="center", + text_offset_ratio=0.5, border_padding_ratio=0, ) @@ -224,6 +225,17 @@ def test_position_center_gravity(self): assert y == 1450 + def test_center_offset_measures_from_the_bottom(self): + high = TextOptions(gravity="center", text_offset_ratio=0.75, border_padding_ratio=0) + low = TextOptions(gravity="center", text_offset_ratio=0.25, border_padding_ratio=0) + + _, y_high = self.renderer._calculate_text_position((2000, 3000), (500, 100), high) + _, y_low = self.renderer._calculate_text_position((2000, 3000), (500, 100), low) + + assert y_high == 3000 - 2250 - 50 + assert y_low == 3000 - 750 - 50 + assert y_low > y_high + def test_position_horizontal_centering(self): options = TextOptions( gravity="south", diff --git a/affiche-frontend/src/components/image/OverlayRenderer.tsx b/affiche-frontend/src/components/image/OverlayRenderer.tsx index 9bf743c..38e5595 100644 --- a/affiche-frontend/src/components/image/OverlayRenderer.tsx +++ b/affiche-frontend/src/components/image/OverlayRenderer.tsx @@ -49,22 +49,49 @@ function applyGradientMatte(ctx: CanvasRenderingContext2D, o: OverlayOptions) { const { width, height } = ctx.canvas - const matteH = Math.floor(height * o.matte_height_ratio) - const fadeH = Math.floor(height * o.fade_height_ratio) - - const startY = height - matteH - fadeH - const g = ctx.createLinearGradient(0, startY, 0, height - matteH) - g.addColorStop(0, hexToRgba(o.gradient_color, 0)) - g.addColorStop(1, hexToRgba(o.gradient_color, 1)) - - if (fadeH > 0) { + const vertical = o.gradient_direction === "bottom" || o.gradient_direction === "top" + const extent = vertical ? height : width + const matte = Math.floor(extent * o.matte_height_ratio) + const fade = Math.floor(extent * o.fade_height_ratio) + + const band = { + bottom: { + solid: [0, height - matte, width, matte], + fade: [0, height - matte - fade, width, fade], + from: [0, height - matte - fade] as const, + to: [0, height - matte] as const, + }, + top: { + solid: [0, 0, width, matte], + fade: [0, matte, width, fade], + from: [0, matte + fade] as const, + to: [0, matte] as const, + }, + left: { + solid: [0, 0, matte, height], + fade: [matte, 0, fade, height], + from: [matte + fade, 0] as const, + to: [matte, 0] as const, + }, + right: { + solid: [width - matte, 0, matte, height], + fade: [width - matte - fade, 0, fade, height], + from: [width - matte - fade, 0] as const, + to: [width - matte, 0] as const, + }, + }[o.gradient_direction] + + if (fade > 0) { + const g = ctx.createLinearGradient(band.from[0], band.from[1], band.to[0], band.to[1]) + g.addColorStop(0, hexToRgba(o.gradient_color, 0)) + g.addColorStop(1, hexToRgba(o.gradient_color, 1)) ctx.fillStyle = g - ctx.fillRect(0, startY, width, fadeH) + ctx.fillRect(band.fade[0], band.fade[1], band.fade[2], band.fade[3]) } - if (matteH > 0) { + if (matte > 0) { ctx.fillStyle = o.gradient_color - ctx.fillRect(0, height - matteH, width, matteH) + ctx.fillRect(band.solid[0], band.solid[1], band.solid[2], band.solid[3]) } } diff --git a/affiche-frontend/src/components/image/PosterStyleControls.test.tsx b/affiche-frontend/src/components/image/PosterStyleControls.test.tsx index 004ca89..1f68fbd 100644 --- a/affiche-frontend/src/components/image/PosterStyleControls.test.tsx +++ b/affiche-frontend/src/components/image/PosterStyleControls.test.tsx @@ -74,6 +74,16 @@ describe('PosterStyleControls gradient', () => { }); }); +describe('PosterStyleControls gradient direction', () => { + it('sends the edge the matte grows from', () => { + const onOverlayChange = renderOverlayControls({ gradient_direction: 'bottom' }); + + fireEvent.change(screen.getByLabelText('Direction'), { target: { value: 'left' } }); + + expect(onOverlayChange).toHaveBeenCalledWith({ gradient_direction: 'left' }); + }); +}); + describe('PosterStyleControls line layout', () => { it('sends line spacing as a ratio of the font size', () => { const onTextChange = renderControls(); @@ -91,6 +101,31 @@ describe('PosterStyleControls line layout', () => { expect(onTextChange).toHaveBeenCalledWith({ line_spacing_ratio: -0.15 }); }); + it('sends the text block height, which is what line spacing competes against', () => { + const onTextChange = renderControls(); + + fireEvent.change(screen.getByLabelText('Text block height'), { target: { value: '45' } }); + + expect(onTextChange).toHaveBeenCalledWith({ max_height_ratio: 0.45 }); + }); + + it('re-bases the offset when the title moves to the centre', () => { + + const onTextChange = renderControls({ gravity: 'south', text_offset_ratio: 0.143 }); + + fireEvent.change(screen.getByLabelText('Position'), { target: { value: 'center' } }); + + expect(onTextChange).toHaveBeenCalledWith({ gravity: 'center', text_offset_ratio: 0.5 }); + }); + + it('leaves a chosen centre offset alone when the position is already centre', () => { + const onTextChange = renderControls({ gravity: 'center', text_offset_ratio: 0.7 }); + + fireEvent.change(screen.getByLabelText('Vertical position'), { target: { value: '30' } }); + + expect(onTextChange).toHaveBeenCalledWith({ text_offset_ratio: 0.3 }); + }); + it('sends text width as a ratio of the poster width', () => { const onTextChange = renderControls(); diff --git a/affiche-frontend/src/components/image/PosterStyleControls.tsx b/affiche-frontend/src/components/image/PosterStyleControls.tsx index 7ebb60c..1348daf 100644 --- a/affiche-frontend/src/components/image/PosterStyleControls.tsx +++ b/affiche-frontend/src/components/image/PosterStyleControls.tsx @@ -22,6 +22,13 @@ interface PosterStyleControlsProps { const asPercent = (ratio: number) => Math.round(ratio * 100); const fromPercent = (value: string) => parseInt(value) / 100; +const GRADIENT_DIRECTIONS: { value: OverlayOptions['gradient_direction']; label: string }[] = [ + { value: 'bottom', label: 'Bottom' }, + { value: 'top', label: 'Top' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, +]; + const GRAVITY_LABELS: { value: TextOptions['gravity']; label: string }[] = [ { value: 'south', label: 'Bottom' }, { value: 'center', label: 'Center' }, @@ -39,7 +46,8 @@ export function PosterStyleControls({ titleSlot, }: PosterStyleControlsProps) { const fontSizePercent = asPercent(textOptions.max_font_ratio); - const textHeightPercent = asPercent(textOptions.text_offset_ratio); + const verticalPositionPercent = asPercent(textOptions.text_offset_ratio); + const textBlockHeightPercent = asPercent(textOptions.max_height_ratio); const lineSpacingPercent = asPercent(textOptions.line_spacing_ratio); const textWidthPercent = asPercent(textOptions.max_width_ratio); @@ -114,6 +122,22 @@ export function PosterStyleControls({ +
+ + +
+ { }
@@ -236,7 +260,14 @@ export function PosterStyleControls({ id={`${uid}-position`} className={styles.select} value={textOptions.gravity} - onChange={(e) => onTextChange({ gravity: e.target.value as TextOptions['gravity'] })} + onChange={(e) => { + const gravity = e.target.value as TextOptions['gravity']; + onTextChange( + gravity === 'center' && textOptions.gravity !== 'center' + ? { gravity, text_offset_ratio: 0.5 } + : { gravity } + ); + }} > {GRAVITY_LABELS.map(({ value, label }) => ( @@ -247,22 +278,24 @@ export function PosterStyleControls({
onTextChange({ text_offset_ratio: fromPercent(e.target.value) })} /> - {textHeightPercent}% + {verticalPositionPercent}%
@@ -310,6 +343,31 @@ export function PosterStyleControls({
+ { + +} +
+ +
+ onTextChange({ max_height_ratio: fromPercent(e.target.value) })} + /> + {textBlockHeightPercent}% +
+
+