/* =================================================================== Die Buehnen der Modi-App (crew.dogfather-universe.com, 10.09.2026) Filipe: "auch die hintergrund bilder vom crew espace sollen anderes aussehen, der community angepasst wie die zugangscode seite die ist mega." --------------------------------------------------------------------- WARUM UMFAERBEN HIER NICHT GENUEGT Bei der Zugangswand hat es gereicht, die Szene ins Blau zu ziehen -- dort war Rot das Problem. INNEN ist es etwas anderes: Die neun Buehnen zeigen nicht bloss rotes Licht, sondern das SPICY-MEDIA-LOGO selbst, in `halle` und `arena` sogar vielfach nebeneinander. Ein blaues Spicy-Logo waere schlimmer als ein rotes: Es saehe aus, als haette jemand die falsche Marke eingefaerbt. Die Modi-App braucht deshalb eigene Motive -- und die gibt es laengst. Auf der oeffentlichen Dogfather-Seite liegen die Community-Bilder, die genau das zeigen, was in dieser App passiert. --------------------------------------------------------------------- WELCHES BILD WOHIN -- und das folgt dem, was auf der Seite passiert studio Startseite "Team Dogi -- Familie. Treue. Zusammenhalt." Filipe mit seinem Husky. Der Eingang gehoert dem, worum es geht. lounge Chat, Community dasselbe Motiv: Wo geredet wird, steht das Miteinander im Bild. halle Angebote, Eingang, TEAM DOGI mit den Menschen des Steckbrief Teams -- hier geht es um sie. skyline Kalender "Streamplan": Dogi und HasiDog auf der Buehne. Woertlich das Thema der Seite. portal Ideen-Board, dieselbe Buehne -- dort wird Live-Ablauf gesendet, und dorthin gehen die Ideen. showbuehne Dashboard, Reports ebenfalls die Buehne. arena Dateien, Wissen "DogFathers Galerie": Filmrollen und Bildschirme. Ein Archiv sieht so aus. garage Aufgaben, Technik "Der Streamer": Filipe an seinem Platz, Monitore, Ringlicht. Dort wird gearbeitet. ZUERST STAND HIER DER HOLO-KONTROLL- RAUM, und er sah grossartig aus -- aber darauf steht "WERDE MODI". Eine Einladung hinter dem Aufgabenbrett von Leuten, die laengst dabei sind, liest sich falsch. Ein Bild sagt etwas, auch wenn es nur Hintergrund ist. wald Start-Check wieder das Familienmotiv; die Seite gibt es fuer diese Rollen gar nicht und sie ist nur da, damit kein Rueckfall auf eine Spicy-Buehne moeglich ist. FUENF BILDER AUF NEUN PLAETZE, mit denselben Regeln wie im Haus: Paare, die inhaltlich benachbart sind und nie nebeneinander auf einer Seite stehen -- man sieht immer nur eins. ALLE NEUN werden gebaut, auch die, die eine Modi-Rolle nie zu sehen bekommt. Der Grund steht in `start.css`: Dort ist `studio` der Rueckfall fuer jede Seite ohne eigene Szene. Eine Luecke hier hiesse, dass irgendwann doch ein Spicy-Logo im Hintergrund der Modi-App steht -- und zwar genau auf der Seite, an die niemand gedacht hat. --------------------------------------------------------------------- DIE GEGENPROBE: NICHT HELLER ALS DAS, WAS SCHON LAEUFT Ueber diesen Bildern steht Text. Die Dogfather-Motive sind heller als die Spicy-Szenen -- weisse Schrift im Bild, helles Fell, Scheinwerfer. Ein Bild, das gut aussieht und die Schrift darueber unlesbar macht, ist der schnellste Weg zu einer Oberflaeche, die niemand benutzt. Deshalb wird jede fertige Fassung gegen die BESTEHENDE gleicher Szene und Groesse gehalten: Das obere Zehntel der Helligkeit im mittleren Band -- dort, wo der Inhalt steht -- darf nicht heller sein als dort. Das ist kein selbst gewaehlter Grenzwert, sondern der Stand, der seit Wochen laeuft und den pruef-buehne an echten Bildpunkten gemessen hat. Aufrufen mit: node tools/crew-buehnen.mjs =================================================================== */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const WURZEL = join(dirname(fileURLToPath(import.meta.url)), ".."); const QUELLE = join(WURZEL, "assets", "img"); const ZIEL = process.env.BUEHNEN_ZIEL || join(WURZEL, "workspace", "assets", "img"); /* Zum Vergleichen immer die echten -- auch wenn das Ziel umgelenkt ist. */ const VERGLEICH = join(WURZEL, "workspace", "assets", "img"); const SZENEN = { studio: "bg-community.jpg", lounge: "bg-community.jpg", wald: "bg-community.jpg", halle: "bg-modis.jpg", skyline: "bg-streamplan.jpg", portal: "bg-streamplan.jpg", showbuehne: "bg-streamplan.jpg", arena: "bg-dogfather.jpg", garage: "bg-streamer.jpg", }; /* WIE STARK ABGEDUNKELT -- AUSGERECHNET, NICHT GEWAEHLT. Erster Anlauf: ein fester Wert fuer alle, 0,34. Die Messung unten hat ihn zerlegt -- achtzehn von siebenundzwanzig Fassungen waren heller als die Buehne, die sie ersetzen, teils deutlich (114 gegen 67). Ueber ihnen steht derselbe kleine Text; ich haette Bilder ausgeliefert, die gut aussehen und die Schrift darueber schlechter lesbar machen. EIN FESTER WERT KANN DAS AUCH NICHT LOESEN. Die fuenf Motive sind verschieden hell: "Team Dogi" ist eine Nacht mit einem Ring, der Holo-Kontrollraum ist von vorne bis hinten beleuchtet. Was fuer das eine reicht, macht das andere zum Schemen -- und genau das ist beim Tresorbild schon einmal passiert (0,62 uebernommen, man sah nichts mehr). Also wird je Fassung gerechnet: Erst ohne Abdunklung bauen und messen, dann ausrechnen, wie viel Schwarz noetig ist, um genau auf den Wert der bestehenden Buehne zu kommen -- und nur so viel. Schwarz mit Deckkraft a macht aus L ein L*(1-a); daraus folgt a = 1 - Ziel/Ist. So dunkel wie noetig, nicht dunkler. */ const GAMMA_MAX = 3.2; // darueber bleibt vom Motiv ein Schemen /** Ein ERSTER Schaetzwert fuer die Kurve. * * Aus v' = 255*(v/255)^g folgt g = ln(Ziel/255) / ln(Ist/255) -- das * gilt aber nur fuer GRAUE Bildpunkte. Die Kurve wirkt auf jeden * Farbkanal einzeln, die Helligkeit ist eine gewichtete Summe der * drei, und L(v^g) ist nicht L(v)^g, sobald ein Bildpunkt farbig ist. * * Genau daran ist der erste Anlauf gescheitert: Die Rechnung sagte * 61,5 und heraus kamen 67. Deshalb ist das hier nur der Startwert -- * danach wird nachgemessen und nachgezogen, bis es stimmt. */ function gammaFuer(ist, ziel) { if (!(ist > 0) || ist >= 255 || ziel <= 0) return 1; const g = Math.log(ziel / 255) / Math.log(ist / 255); return Math.min(Math.max(g, 1), GAMMA_MAX); } /** * Baut so lange nach, bis der Spitzenwert das Ziel erreicht. * * ZWEI WERKZEUGE, IN DIESER REIHENFOLGE: * 1. Die Kurve -- sie senkt die Lichter viel staerker als die Tiefen * und laesst dem Bild seine Zeichnung. * 2. Erst wenn die Kurve am Anschlag ist (GAMMA_MAX), kommt ein * flacher schwarzer Schleier dazu. Er senkt alles gleich und legt * Nebel ueber das Bild -- deshalb so spaet und so wenig wie * moeglich. * * Hoechstens sechs Anlaeufe. Ein Naeherungsverfahren ohne Deckel ist * eine Endlosschleife mit Umweg. */ async function annaehern(bauArgs, ziel) { let gamma = 1, dunkel = 0; let r = await bauen({ ...bauArgs, gamma, dunkel }); gamma = gammaFuer(r.spitze, ziel); for (let runde = 0; runde < 6; runde++) { r = await bauen({ ...bauArgs, gamma, dunkel }); if (r.spitze <= ziel + 1) break; if (gamma < GAMMA_MAX) { /* Noch einmal dieselbe Rechnung, diesmal vom gemessenen Stand aus -- die Kurven verketten sich. */ gamma = Math.min(gamma * gammaFuer(r.spitze, ziel), GAMMA_MAX); } else { /* Kurve am Anschlag: den Rest flach wegnehmen. Schwarz mit Deckkraft a macht aus L ein L*(1-a) -- und das gilt fuer Farbe genauso wie fuer Grau, hier rechnet nichts falsch. */ dunkel = Math.min(1 - (1 - dunkel) * (ziel / r.spitze), 0.8); } } return { ...r, gamma, dunkel }; } /* WIE STARK GEKUEHLT. Die Motive sind unterschiedlich warm: "Team Dogi" hat einen orangen Ring, "TEAM DOGI mit den Menschen" ist Feuer. Nebeneinander in einer App wirken sie wie fuenf verschiedene Programme. Ein Drittel Blau ueber den Mischmodus "color" zieht sie zusammen, ohne sie zu faelschen -- das Feuer bleibt Feuer, es glueht nur kuehler. Ganz blau waere die Zugangswand, und die ist eine Buehne fuer EIN Motiv; hier sind es fuenf, die zusammenpassen muessen. */ const KUEHLE = 0.32; for (const datei of new Set(Object.values(SZENEN))) { const p = join(QUELLE, datei); if (!existsSync(p)) { console.error(`Fehlt: ${p}`); process.exit(1); } } const alsDaten = (p) => "data:image/jpeg;base64," + readFileSync(p).toString("base64"); const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); const seite = await (await browser.newContext({ deviceScaleFactor: 1 })).newPage(); await seite.setViewportSize({ width: 400, height: 300 }); if (!existsSync(ZIEL)) mkdirSync(ZIEL, { recursive: true }); /** Baut eine Fassung und misst sie gleich mit. */ async function bauen({ quelle, breite, ausschnitt = null, gamma = 1, dunkel = 0, guete }) { const roh = await seite.evaluate(async (a) => { const i = new Image(); i.src = a.daten; await i.decode(); const qx = a.ausschnitt ? a.ausschnitt[0] * i.width : 0; const qy = a.ausschnitt ? a.ausschnitt[1] * i.height : 0; const qb = a.ausschnitt ? a.ausschnitt[2] * i.width : i.width; const qh = a.ausschnitt ? a.ausschnitt[3] * i.height : i.height; const zb = a.breite; const zh = Math.round(zb * (qh / qb)); const c = document.createElement("canvas"); c.width = zb; c.height = zh; const g = c.getContext("2d"); g.imageSmoothingQuality = "high"; g.filter = "contrast(1.06) saturate(1.04)"; g.drawImage(i, qx, qy, qb, qh, 0, 0, zb, zh); g.filter = "none"; /* Kuehlen: Farbton und Saettigung von der Farbe, Helligkeit vom Bild -- jede Kante und jeder Funke bleibt stehen. */ g.globalCompositeOperation = "color"; g.globalAlpha = a.kuehle; g.fillStyle = "#2a5f8c"; g.fillRect(0, 0, zb, zh); g.globalAlpha = 1; g.globalCompositeOperation = "source-over"; /* Vignette: aussen dunkler, damit die Raender der Seite ruhig bleiben und die Mitte trotzdem etwas zeigt. */ const v = g.createRadialGradient(zb / 2, zh / 2, Math.min(zb, zh) * 0.26, zb / 2, zh / 2, Math.max(zb, zh) * 0.74); v.addColorStop(0, "rgba(0,0,0,0)"); v.addColorStop(0.6, "rgba(0,0,0,.18)"); v.addColorStop(1, "rgba(0,0,0,.46)"); g.fillStyle = v; g.fillRect(0, 0, zb, zh); /* =================================================================== ABDUNKELN MIT EINER KURVE, NICHT MIT EINEM SCHLEIER Ein flaches Schwarz mit Deckkraft a macht aus jedem L ein L*(1-a): Es senkt alles im selben Verhaeltnis. Bei den hellsten Motiven waeren dafuer ueber 80 % noetig gewesen -- und darunter liegt dann ein grauer Nebel statt eines Bildes. Genau das ist beim Tresorbild schon einmal passiert. Eine Gammakurve senkt die Lichter viel staerker als die Tiefen: v' = 255 * (v/255)^g. Bei g = 1,8 faellt 200 auf 165 (-17 %), aber 60 auf 19 (-69 %). Das Bild wird dunkler UND behaelt seine Zeichnung -- die hellen Stellen bleiben die hellen, sie schreien nur nicht mehr. `g` wird nicht geraten: Aus dem gemessenen Spitzenwert p und dem Ziel folgt g = ln(Ziel/255) / ln(p/255). Die Tabelle wird einmal gefuellt und dann 256-fach nachgeschlagen statt millionenfach gerechnet. */ if (a.gamma && a.gamma !== 1) { const tabelle = new Uint8ClampedArray(256); for (let v = 0; v < 256; v++) tabelle[v] = Math.round(255 * (v / 255) ** a.gamma); const bd = g.getImageData(0, 0, zb, zh); const px = bd.data; for (let k = 0; k < px.length; k += 4) { px[k] = tabelle[px[k]]; px[k + 1] = tabelle[px[k + 1]]; px[k + 2] = tabelle[px[k + 2]]; } g.putImageData(bd, 0, 0); } /* Der flache Schleier -- nur, wenn die Kurve nicht gereicht hat. */ if (a.dunkel > 0) { g.fillStyle = `rgba(0,0,0,${a.dunkel})`; g.fillRect(0, 0, zb, zh); } /* MESSUNG: das obere Zehntel der Helligkeit im MITTLEREN BAND. Dort steht der Inhalt; die Raender sind ohnehin dunkel und wuerden den Wert schoenrechnen. */ const by = Math.round(zh * 0.18), bh = Math.round(zh * 0.64); const d = g.getImageData(0, by, zb, bh).data; const hell = []; for (let k = 0; k < d.length; k += 4) { hell.push(0.2126 * d[k] + 0.7152 * d[k + 1] + 0.0722 * d[k + 2]); } hell.sort((x, y) => x - y); /* Die Streuung sagt, wie viel MOTIV in dem Ausschnitt steckt. Eine leere dunkle Ecke ist zwar schoen dunkel -- und zeigt nichts. */ let summe = 0, quadrate = 0; for (const h of hell) { summe += h; quadrate += h * h; } const mittel = summe / hell.length; return { bild: c.toDataURL("image/webp", a.guete), spitze: hell[Math.floor(hell.length * 0.9)], streuung: Math.sqrt(Math.max(quadrate / hell.length - mittel * mittel, 0)), breite: zb, hoehe: zh, }; }, { daten: alsDaten(quelle), breite, ausschnitt, gamma, dunkel, guete, kuehle: KUEHLE }); return { ...roh, puffer: Buffer.from(roh.bild.split(",")[1], "base64") }; } /** Dieselbe Messung an einer BESTEHENDEN Datei -- der Massstab. */ async function messen(pfad) { if (!existsSync(pfad)) return null; return await seite.evaluate(async (b64) => { const i = new Image(); i.src = "data:image/webp;base64," + b64; await i.decode(); const c = document.createElement("canvas"); c.width = i.width; c.height = i.height; const g = c.getContext("2d"); g.drawImage(i, 0, 0); const by = Math.round(i.height * 0.18), bh = Math.round(i.height * 0.64); const d = g.getImageData(0, by, i.width, bh).data; const hell = []; for (let k = 0; k < d.length; k += 4) { hell.push(0.2126 * d[k] + 0.7152 * d[k + 1] + 0.0722 * d[k + 2]); } hell.sort((x, y) => x - y); return hell[Math.floor(hell.length * 0.9)]; }, readFileSync(pfad).toString("base64")); } const maengel = []; let gebaut = 0, verglichen = 0, gesamt = 0; console.log("\n--- Die neun Buehnen der Modi-App ---"); for (const [szene, datei] of Object.entries(SZENEN)) { const quelle = join(QUELLE, datei); const zeile = []; for (const [name, breite, guete] of [ ["uhd", 2400, 0.88], ["breit", 1600, 0.88], /* HOCHKANT: die Mitte, hochkant beschnitten -- ein auf 9:16 gequetschtes Breitbild zeigt sonst nur Rand. Und dunkler: Auf dem Handy liegen die kleinsten Texte der ganzen Anwendung genau in der Bildmitte. Dieselbe Ueberlegung wie bei den Spicy-Buehnen, dort am 07.09.2026 an echten Bildpunkten gemessen. */ ["schmal", 900, 0.87], ]) { /* Der Massstab: dieselbe Szene, dieselbe Groesse, wie sie seit Wochen laeuft. Ohne ihn ist "nicht heller" eine Behauptung -- deshalb ist sein Fehlen ein FEHLER und kein Ueberspringen. */ const alt = await messen(join(VERGLEICH, `buehne-${szene}-${name}.webp`)); if (alt === null) { maengel.push(`${szene}/${name}: keine bestehende Fassung zum Vergleichen`); continue; } verglichen++; /* =================================================================== DER HOCHKANT-AUSSCHNITT WIRD GESUCHT, NICHT GESETZT Das Haus schneidet fuer das Handy die MITTE heraus -- bei den Spicy-Szenen richtig, dort steht das Logo mittig und der Grund ringsum ist dunkel. Bei den Dogfather-Motiven ist die Mitte genau das Hellste: "STREAMPLAN" in weisser Schrift, die Scheinwerfer, der beleuchtete Kontrollraum. Fuenf von neun Fassungen blieben deshalb auch mit 66 % Schwarz zu hell -- und 81 % waeren noetig gewesen, also ein Schemen. Jetzt werden fuenf Fenster durchprobiert und zwei Fragen gestellt, in dieser Reihenfolge: 1. Wird es mit hoechstens 66 % Schwarz dunkel genug? (Pflicht) 2. In welchem steckt am meisten Motiv? (Kuer, ueber die Streuung) So viel Motiv wie moeglich, so dunkel wie noetig. Faellt Frage 1 ueberall durch, wird das dunkelste genommen UND gemeldet -- die Messung ganz unten schlaegt dann ohnehin an. */ let ausschnitt = null; if (name === "schmal") { /* Das Fenster wird gesucht: erst dunkel genug, dann so viel Motiv wie moeglich. */ let bester = null; for (const x of [0, 0.11, 0.22, 0.33, 0.44]) { const fenster = [x, 0, 0.56, 1]; const probe = await bauen({ quelle, breite, ausschnitt: fenster, guete }); const g = gammaFuer(probe.spitze, alt); const passt = g < GAMMA_MAX; if (!bester || (passt && !bester.passt) || (passt === bester.passt && (passt ? probe.streuung > bester.probe.streuung : probe.spitze < bester.probe.spitze))) { bester = { fenster, probe, passt }; } } ausschnitt = bester.fenster; } const r = await annaehern({ quelle, breite, ausschnitt, guete }, alt); const { gamma, dunkel } = r; writeFileSync(join(ZIEL, `crew-buehne-${szene}-${name}.webp`), r.puffer); gebaut++; gesamt += r.puffer.length; /* Und danach NOCH EINMAL messen. Die Rechnung sagt, was noetig waere; ob es gereicht hat, sagt nur das fertige Bild -- WebP rundet, und die Deckelung bei 0,66 kann greifen. */ if (r.spitze > alt + 2) { maengel.push(`${szene}/${name}: bleibt heller als die bestehende Buehne ` + `(${r.spitze.toFixed(1)} gegen ${alt.toFixed(1)}, Kurve ${gamma.toFixed(2)}, ` + `Schleier ${(dunkel * 100).toFixed(0)} %)`); } zeile.push(`${name} ${Math.round(r.puffer.length / 1024)} KB ` + `· Kurve ${gamma.toFixed(2)}${dunkel ? ` +${(dunkel * 100).toFixed(0)} %` : ""}` + ` · ${r.spitze.toFixed(0)}/${alt.toFixed(0)}` + (ausschnitt ? ` · ab ${(ausschnitt[0] * 100).toFixed(0)} %` : "")); } console.log(` ${szene.padEnd(11)} ${datei.padEnd(22)} ${zeile.join(" · ")}`); } await browser.close(); /* Die Zahlen gehoeren in die Bedingung, nicht nur in den Bericht. */ const soll = Object.keys(SZENEN).length * 3; if (gebaut !== soll) { console.error(`\nFEHLER: ${gebaut} von ${soll} Fassungen erzeugt.`); process.exit(1); } if (verglichen !== soll) { console.error(`\nFEHLER: nur ${verglichen} von ${soll} gegen eine bestehende Buehne gehalten.`); process.exit(1); } if (maengel.length) { console.error("\nFEHLER:\n " + maengel.join("\n ")); process.exit(1); } console.log(`\n${gebaut} Fassungen, ${Math.round(gesamt / 1024 / 1024 * 10) / 10} MB.` + ` Keine ist heller als die bestehende Buehne derselben Szene --` + ` der Text darueber bleibt also mindestens so lesbar wie bisher.`);