feat: per-cell content fit (cover|contain|fill), default cover

- Migration adds layout_cells.fit column (default 'cover')
- LayoutCell type + mapper + repo accept/persist fit
- Bundle ships fit per cell
- Admin cell edit form: Fit dropdown with industry-default Cover
- Rust kiosk applies ContentFit::Cover|Contain|Fill per cell.fit

Cover = fill cell, crop overflow (industry default — Nx Witness etc)
Contain = letterbox, no crop
Fill = stretch, distort
This commit is contained in:
Mitchell R 2026-05-11 13:52:22 +02:00
parent 9679ae7eb1
commit 1e09582379
9 changed files with 36 additions and 4 deletions

View file

@ -47,8 +47,12 @@ pub struct BundleCell {
pub web_url: Option<String>, pub web_url: Option<String>,
pub html_content: Option<String>, pub html_content: Option<String>,
pub cooling_timeout_seconds: Option<u32>, pub cooling_timeout_seconds: Option<u32>,
#[serde(default = "default_fit")]
pub fit: String,
} }
fn default_fit() -> String { "cover".to_string() }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleCamera { pub struct BundleCamera {
pub id: u32, pub id: u32,

View file

@ -256,7 +256,11 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s
let area = (cell.col_span * cell.row_span) as f32 / total_area; let area = (cell.col_span * cell.row_span) as f32 / total_area;
if let Some((paintable, badge)) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) { if let Some((paintable, badge)) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) {
let picture = Picture::for_paintable(&paintable); let picture = Picture::for_paintable(&paintable);
picture.set_content_fit(gtk::ContentFit::Contain); picture.set_content_fit(match cell.fit.as_str() {
"contain" => gtk::ContentFit::Contain,
"fill" => gtk::ContentFit::Fill,
_ => gtk::ContentFit::Cover,
});
picture.set_vexpand(true); picture.set_vexpand(true);
picture.set_hexpand(true); picture.set_hexpand(true);
// Wrap in Overlay so we can stack a stream-role badge on top // Wrap in Overlay so we can stack a stream-role badge on top

View file

@ -748,12 +748,16 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null; entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null;
deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null); deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null);
// stream_selector + spans are still settable on the cell. // stream_selector + spans + fit are still settable on the cell.
const dimsPatch: Record<string, unknown> = {}; const dimsPatch: Record<string, unknown> = {};
const streamSelector = body?.["stream_selector"]; const streamSelector = body?.["stream_selector"];
if (streamSelector === "auto" || streamSelector === "main" || streamSelector === "sub") { if (streamSelector === "auto" || streamSelector === "main" || streamSelector === "sub") {
dimsPatch["stream_selector"] = streamSelector; dimsPatch["stream_selector"] = streamSelector;
} }
const fit = body?.["fit"];
if (fit === "cover" || fit === "contain" || fit === "fill") {
dimsPatch["fit"] = fit;
}
const colSpanRaw = body?.["col_span"]; const colSpanRaw = body?.["col_span"];
const rowSpanRaw = body?.["row_span"]; const rowSpanRaw = body?.["row_span"];
if (colSpanRaw != null && String(colSpanRaw).trim() !== "") { if (colSpanRaw != null && String(colSpanRaw).trim() !== "") {

View file

@ -208,6 +208,7 @@ export function rowToLayoutCell(r: Row): LayoutCell {
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]), cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
options: j<Record<string, unknown>>(r["options"], {}), options: j<Record<string, unknown>>(r["options"], {}),
entity_id: nn(r["entity_id"]), entity_id: nn(r["entity_id"]),
fit: (s(r["fit"]) || "cover") as "cover" | "contain" | "fill",
}; };
} }

View file

@ -596,4 +596,9 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER"); addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER");
addColumnIfNotExists(db, "kiosks", "fan_pwm", "INTEGER"); addColumnIfNotExists(db, "kiosks", "fan_pwm", "INTEGER");
}, },
// ---- per-cell content fit (cover|contain|fill) ----
(db: DatabaseSync) => {
addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'");
},
]; ];

View file

@ -556,6 +556,7 @@ export class Repository {
cooling_timeout_seconds?: number | null; cooling_timeout_seconds?: number | null;
options?: Record<string, unknown>; options?: Record<string, unknown>;
entity_id?: number | null; entity_id?: number | null;
fit?: "cover" | "contain" | "fill";
}): LayoutCell { }): LayoutCell {
// Resolve content fields from the entity (if given). The legacy columns // Resolve content fields from the entity (if given). The legacy columns
// remain populated for backward-compatible bundle generation. // remain populated for backward-compatible bundle generation.
@ -574,8 +575,8 @@ export class Repository {
} }
const result = this.prep( const result = this.prep(
`INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options, entity_id) `INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options, entity_id, fit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
input.layout_id, input.layout_id,
input.row, input.row,
@ -590,6 +591,7 @@ export class Repository {
input.cooling_timeout_seconds ?? null, input.cooling_timeout_seconds ?? null,
J(input.options ?? {}), J(input.options ?? {}),
input.entity_id ?? null, input.entity_id ?? null,
input.fit ?? "cover",
); );
const id = Number(result.lastInsertRowid); const id = Number(result.lastInsertRowid);
void this.notify("layout_cells", "create", id); void this.notify("layout_cells", "create", id);

View file

@ -41,6 +41,7 @@ export interface BundleCell {
web_url: string | null; web_url: string | null;
html_content: string | null; html_content: string | null;
cooling_timeout_seconds: number | null; cooling_timeout_seconds: number | null;
fit: "cover" | "contain" | "fill";
} }
export interface BundleLayout { export interface BundleLayout {
@ -152,6 +153,7 @@ export function generateBundle(
web_url: webUrl, web_url: webUrl,
html_content: htmlContent, html_content: htmlContent,
cooling_timeout_seconds: c.cooling_timeout_seconds, cooling_timeout_seconds: c.cooling_timeout_seconds,
fit: c.fit,
}; };
}), }),
}; };

View file

@ -187,6 +187,7 @@ export interface LayoutCell {
cooling_timeout_seconds: number | null; cooling_timeout_seconds: number | null;
options: Record<string, unknown>; options: Record<string, unknown>;
entity_id: number | null; entity_id: number | null;
fit: "cover" | "contain" | "fill";
} }
export interface Kiosk { export interface Kiosk {

View file

@ -1610,6 +1610,15 @@ export function renderCell(
</select> </select>
</div> </div>
<div class="form-group">
<label>Fit</label>
<select name="fit" class="form-input">
<option value="cover" selected={c.fit === "cover"}>Cover (fill, crop overflow)</option>
<option value="contain" selected={c.fit === "contain"}>Contain (letterbox)</option>
<option value="fill" selected={c.fit === "fill"}>Fill (stretch)</option>
</select>
</div>
<div class="form-group span-grid"> <div class="form-group span-grid">
<div> <div>
<label>Width</label> <label>Width</label>