From 6d8dceb1ab3965038dd811e0e9e7ca6317273427 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 28 Aug 2026 00:01:20 +1000 Subject: [PATCH 01/27] feat(signage-manager): create and edit signage artwork with AI Adds an AI image service, a multi state modal and a canvas layer editor. The modal runs compose, generating, choose and layer. Refine sends the chosen image back with a follow up instruction and keeps the whole chain on a rail, so any earlier version is one click away. The words and the logo are drawn in the browser over the artwork, at its native size, rather than asked of the model: no image model spells reliably at small sizes, and a logo the model drew is the part of a poster a trademark claim lands on. The prompt asks for a clear area for both. The poll loop lives in the service rather than the modal so a job survives the dialog being closed. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app/ai/ai-image-modal.component.ts | 569 ++++++++++++++++++ .../src/app/ai/ai-image.service.ts | 202 +++++++ .../src/app/ai/ai-layer.component.ts | 423 +++++++++++++ apps/signage-manager/src/app/ai/ai.fn.ts | 98 +++ apps/signage-manager/src/app/ai/ai.types.ts | 120 ++++ apps/signage-manager/src/app/app.component.ts | 7 + .../app/media/media-list-header.component.ts | 25 + .../src/app/signage.service.ts | 106 +++- shared/assets/locale/en-AU.json | 102 +++- 9 files changed, 1623 insertions(+), 29 deletions(-) create mode 100644 apps/signage-manager/src/app/ai/ai-image-modal.component.ts create mode 100644 apps/signage-manager/src/app/ai/ai-image.service.ts create mode 100644 apps/signage-manager/src/app/ai/ai-layer.component.ts create mode 100644 apps/signage-manager/src/app/ai/ai.fn.ts create mode 100644 apps/signage-manager/src/app/ai/ai.types.ts diff --git a/apps/signage-manager/src/app/ai/ai-image-modal.component.ts b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts new file mode 100644 index 0000000000..95184da4d2 --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts @@ -0,0 +1,569 @@ +import { Component, computed, inject, signal, viewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { i18n, notifyError } from '@placeos/common'; +import { + AuthenticatedImageDirective, + FullscreenModalShellComponent, + IconComponent, + TranslatePipe, +} from '@placeos/components'; + +import { SignageService } from '../signage.service'; +import { AiImageService, isFinal } from './ai-image.service'; +import { AiLayerComponent } from './ai-layer.component'; +import { AiJob, AiJobImage, AiLayerState } from './ai.types'; + +export interface AiImageModalData { + /** pre-set from the playlist a user opened this from */ + aspect_ratio?: string; + playlist_id?: string; + /** editing an existing item rather than starting from nothing */ + source_upload_id?: string; + source_item_id?: string; + source_name?: string; +} + +type ModalState = 'compose' | 'generating' | 'choose' | 'layer'; + +interface Candidate { + job_id: string; + index: number; + upload_id: string; + url: string; +} + +@Component({ + selector: 'ai-image-modal', + template: ` + + @switch (state()) { + @case ('compose') { + + + + + +
+ + + @for ( + option of aspect_options(); + track option + ) { + {{ + option + }} + } + + + + + @for (count of candidate_options(); track count) { + {{ + count + }} + } + + +
+ + + {{ 'SIGNAGE_MANAGER.AI_ADD_WORDS_LAYER' | translate }} + +

+ {{ 'SIGNAGE_MANAGER.AI_ADD_WORDS_LAYER_HINT' | translate }} +

+ + @if (has_logo()) { + + {{ 'SIGNAGE_MANAGER.AI_LEAVE_LOGO_SPACE' | translate }} + + } + + @if (quota_note()) { +

+ {{ quota_note() }} +

+ } + } + @case ('generating') { +
+ +

+ {{ 'SIGNAGE_MANAGER.AI_WORKING' | translate }} +

+

+ {{ progress_note() }} +

+ +
+ } + @case ('choose') { +
+ @for (candidate of candidates_list(); track candidate.upload_id) { + + } +
+ + @if (versions().length > 1) { +
+

+ {{ 'SIGNAGE_MANAGER.AI_VERSIONS' | translate }} +

+
+ @for (version of versions(); track version.job_id) { + + } +
+
+ } + +
+ +
+ + + + +
+

+ {{ 'SIGNAGE_MANAGER.AI_REFINE_NOTE' | translate }} +

+
+ } + @case ('layer') { + + } + } +
+ `, + imports: [ + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatProgressSpinnerModule, + MatSelectModule, + MatSlideToggleModule, + AuthenticatedImageDirective, + FullscreenModalShellComponent, + IconComponent, + TranslatePipe, + AiLayerComponent, + ], +}) +export class AiImageModalComponent { + private readonly _data = inject(MAT_DIALOG_DATA); + private readonly _dialog_ref = + inject>(MatDialogRef); + private readonly _service = inject(SignageService); + private readonly _ai = inject(AiImageService); + + private readonly _layer = viewChild(AiLayerComponent); + + public readonly state = signal('compose'); + public readonly saving = signal(false); + + public readonly brief = signal(''); + public readonly refinement = signal(''); + public readonly aspect = signal(this._data.aspect_ratio || '16:9'); + public readonly candidates = signal(2); + public readonly add_text_with_layer = signal(true); + public readonly include_logo = signal(true); + public readonly layer_state = signal(null); + + public readonly current_job_id = signal(''); + public readonly selected = signal(null); + public readonly selected_object_url = signal(''); + public readonly logo_object_url = signal(''); + + public readonly brand = this._ai.brand_kit; + public readonly is_edit = computed(() => !!this._data.source_upload_id); + public readonly has_logo = computed( + () => !!this._ai.capabilities()?.logo_layer, + ); + + public readonly aspect_options = computed( + () => this._ai.capabilities()?.aspect_ratios || ['16:9', '9:16'], + ); + public readonly candidate_options = computed(() => { + const max = this._ai.capabilities()?.max_candidates || 2; + return Array.from({ length: max }, (_, index) => index + 1); + }); + + public readonly job = computed( + () => this._ai.jobs()[this.current_job_id()], + ); + + public readonly candidates_list = computed(() => { + const job = this.job(); + if (!job) return []; + return (job.images || []) + .map((image, index) => ({ image, index })) + .filter(({ image }) => !!image?.upload_id) + .map(({ image, index }) => ({ + job_id: job.id, + index, + upload_id: (image as AiJobImage).upload_id as string, + url: (image as AiJobImage).url as string, + })); + }); + + /** every job in the refine chain that produced something, oldest first */ + public readonly versions = computed(() => { + const chain: Candidate[] = []; + const jobs = this._ai.jobs(); + let id = this.current_job_id(); + const seen = new Set(); + while (id && jobs[id] && !seen.has(id)) { + seen.add(id); + const job = jobs[id]; + const first = (job.images || []).find((image) => image?.upload_id); + if (first) { + chain.unshift({ + job_id: job.id, + index: 0, + upload_id: first.upload_id as string, + url: first.url as string, + }); + } + id = job.parent_job_id || ''; + } + return chain; + }); + + public readonly progress_note = computed(() => { + const job = this.job(); + if (!job) return ''; + return `${job.images_produced} / ${job.candidates}`; + }); + + public readonly quota_note = computed(() => { + const quota = this._ai.capabilities()?.quota; + const left = quota?.user_remaining_today; + if (left === null || left === undefined) return ''; + return i18n('SIGNAGE_MANAGER.AI_QUOTA_LEFT', { count: `${left}` }); + }); + + public readonly heading = computed(() => { + if (this.state() === 'layer') return 'SIGNAGE_MANAGER.AI_ADD_WORDS'; + if (this.state() === 'choose') return 'SIGNAGE_MANAGER.AI_PICK_ONE'; + return this.is_edit() + ? 'SIGNAGE_MANAGER.AI_EDIT_IMAGE' + : 'SIGNAGE_MANAGER.AI_CREATE_IMAGE'; + }); + + public readonly confirm_text = computed(() => { + switch (this.state()) { + case 'compose': + return 'SIGNAGE_MANAGER.AI_GENERATE'; + case 'choose': + return this.add_text_with_layer() + ? 'SIGNAGE_MANAGER.AI_ADD_WORDS' + : 'COMMON.SAVE'; + case 'layer': + return 'COMMON.SAVE'; + default: + return 'COMMON.CANCEL'; + } + }); + + public readonly can_confirm = computed(() => { + if (this.saving()) return false; + switch (this.state()) { + case 'compose': + return !!this.brief().trim(); + case 'choose': + return !!this.selected(); + case 'layer': + return true; + default: + return false; + } + }); + + public confirm() { + switch (this.state()) { + case 'compose': + return this.start(); + case 'choose': + return this.add_text_with_layer() + ? this.openLayer() + : this.save(); + case 'layer': + return this.save(); + } + } + + public async start() { + const prompt = this.brief().trim(); + if (!prompt) return; + this.state.set('generating'); + try { + const job = this._data.source_upload_id + ? await this._ai.edit({ + prompt, + aspect_ratio: this.aspect(), + candidates: this.candidates(), + include_logo: this.include_logo(), + add_text_with_layer: this.add_text_with_layer(), + source_upload_id: this._data.source_upload_id, + source_item_id: this._data.source_item_id, + }) + : await this._ai.generate({ + prompt, + aspect_ratio: this.aspect(), + candidates: this.candidates(), + include_logo: this.include_logo(), + add_text_with_layer: this.add_text_with_layer(), + }); + this.current_job_id.set(job.id); + this._awaitJob(job.id); + } catch (error) { + this.state.set('compose'); + notifyError(this._message(error)); + } + } + + public async refine() { + const instruction = this.refinement().trim(); + const source = this.selected(); + if (!instruction || !source) return; + this.refinement.set(''); + this.state.set('generating'); + try { + const job = await this._ai.edit({ + prompt: instruction, + aspect_ratio: this.aspect(), + candidates: 1, + include_logo: this.include_logo(), + add_text_with_layer: this.add_text_with_layer(), + source_upload_id: source.upload_id, + parent_job_id: source.job_id, + }); + this.current_job_id.set(job.id); + this._awaitJob(job.id); + } catch (error) { + this.state.set('choose'); + notifyError(this._message(error)); + } + } + + public openVersion(job_id: string) { + this.current_job_id.set(job_id); + const first = this.candidates_list()[0]; + if (first) this.select(first); + } + + public async select(candidate: Candidate) { + this.selected.set(candidate); + const url = await this._ai.loadImage(candidate.url).catch(() => ''); + this.selected_object_url.set(url); + } + + public async cancel() { + const id = this.current_job_id(); + if (id) await this._ai.cancel(id); + this.state.set('compose'); + } + + public async openLayer() { + const logo_id = this.brand()?.logo_upload_id; + if (logo_id && !this.logo_object_url()) { + const url = await this._ai + .loadImage(`/api/engine/v2/uploads/${logo_id}/url`) + .catch(() => ''); + this.logo_object_url.set(url); + } + this.state.set('layer'); + } + + public async save() { + const candidate = this.selected(); + if (!candidate) return; + this.saving.set(true); + try { + const name = this._name(); + let media: any; + + if (this.state() === 'layer') { + const blob = await this._layer()?.toBlob(); + if (!blob) throw new Error(i18n('SIGNAGE_MANAGER.AI_NO_IMAGE')); + const file = new File([blob], `${name}.png`, { + type: 'image/png', + }); + media = await this._service.addMedia(file, { + name, + tags: this._tags(candidate), + } as any); + } else { + media = await this._service.addMediaFromUpload( + candidate.upload_id, + { + name, + tags: this._tags(candidate), + orientation: + this.aspect() === '9:16' ? 'portrait' : 'landscape', + }, + this._data.playlist_id, + ); + } + + if (media?.id) { + await this._ai.claim( + candidate.job_id, + candidate.upload_id, + media.id, + ); + } + this._dialog_ref.close(media); + } catch (error) { + notifyError(this._message(error)); + } finally { + this.saving.set(false); + } + } + + /** poll until the job reaches a final state, then move on */ + private _awaitJob(id: string) { + const check = () => { + const job = this._ai.jobs()[id]; + if (!job) return setTimeout(check, 250); + if (!isFinal(job)) return setTimeout(check, 250); + if (job.state === 'failed') { + this.state.set(this.versions().length ? 'choose' : 'compose'); + notifyError( + job.error_message || + i18n('SIGNAGE_MANAGER.AI_JOB_FAILED'), + ); + return; + } + if (job.state === 'cancelled') { + this.state.set('compose'); + return; + } + const first = this.candidates_list()[0]; + if (first) this.select(first); + this.state.set('choose'); + }; + check(); + } + + private _name() { + const brief = (this.brief() || this._data.source_name || '').trim(); + const words = brief.split(/\s+/).slice(0, 6).join(' '); + return words || i18n('SIGNAGE_MANAGER.AI_DEFAULT_NAME'); + } + + private _tags(candidate: Candidate) { + const tags = ['ai-generated', `ai-job-${candidate.job_id}`]; + if (this._data.source_upload_id) { + tags.push(`ai-source-${this._data.source_upload_id}`); + } + return tags; + } + + private _message(error: any) { + return ( + error?.error?.error || + error?.error || + error?.message || + i18n('SIGNAGE_MANAGER.AI_JOB_FAILED') + ); + } +} diff --git a/apps/signage-manager/src/app/ai/ai-image.service.ts b/apps/signage-manager/src/app/ai/ai-image.service.ts new file mode 100644 index 0000000000..e299ebe108 --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-image.service.ts @@ -0,0 +1,202 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { AsyncHandler, i18n, notifyError, notifyInfo } from '@placeos/common'; +import { loadAuthenticatedImage } from '@placeos/components'; +import { showMetadata } from '@placeos/ts-client'; + +import { + cancelSignageAIJob, + claimSignageAIImage, + editSignageImage, + generateSignageImage, + querySignageAIJobs, + showSignageAIJob, + signageAICapabilities, +} from './ai.fn'; +import { + AiBrandKit, + AiCapabilities, + AiEditRequest, + AiGenerateRequest, + AiJob, +} from './ai.types'; + +const FINAL_STATES = ['done', 'failed', 'cancelled']; + +/** how long a single long poll holds the connection open, server capped at 25 */ +const POLL_WAIT = 25; + +export function isFinal(job?: AiJob | null) { + return !!job && FINAL_STATES.includes(job.state); +} + +/** + * Owns generation state for the app. + * + * The poll loop lives here rather than in the modal so a job survives the modal + * being closed: the user can start four candidates, close the dialog, keep + * working, and still be told when they land. + */ +@Injectable({ providedIn: 'root' }) +export class AiImageService extends AsyncHandler { + /** null until asked; `enabled: false` hides every entry point */ + public readonly capabilities = signal(null); + public readonly brand_kit = signal(null); + public readonly jobs = signal>({}); + + public readonly enabled = computed(() => !!this.capabilities()?.enabled); + public readonly running_count = computed( + () => Object.values(this.jobs()).filter((job) => !isFinal(job)).length, + ); + public readonly recent = computed(() => + Object.values(this.jobs()).sort( + (a, b) => (b.created_at || 0) - (a.created_at || 0), + ), + ); + + private _loaded = false; + + /** + * Read what this domain can do. An older backend has no such route, which + * is indistinguishable from the feature being switched off, so both are + * treated as disabled rather than surfaced as an error. + */ + public async load(org_zone_id?: string) { + if (this._loaded) return this.capabilities(); + this._loaded = true; + const capabilities = await signageAICapabilities().catch(() => null); + this.capabilities.set( + capabilities || { + enabled: false, + providers: [], + aspect_ratios: [], + qualities: [], + max_candidates: 1, + logo_layer: false, + quota: { + user_remaining_today: null, + domain_remaining_month: null, + }, + }, + ); + if (capabilities?.enabled && org_zone_id) { + const metadata = await showMetadata(org_zone_id, { + name: 'signage_ai', + } as any).catch(() => null); + const details = (metadata as any)?.details; + if (details) this.brand_kit.set(details as AiBrandKit); + } + return this.capabilities(); + } + + /** the jobs the user started recently, so the list survives a reload */ + public async loadRecent() { + const jobs = await querySignageAIJobs({ mine: true, limit: 20 }).catch( + () => [] as AiJob[], + ); + this._merge(jobs); + jobs.filter((job) => !isFinal(job)).forEach((job) => this.watch(job.id)); + return jobs; + } + + public async generate(request: AiGenerateRequest) { + const job = await generateSignageImage({ + idempotency_key: crypto.randomUUID(), + ...request, + }); + this._merge([job]); + this.watch(job.id); + return job; + } + + public async edit(request: AiEditRequest) { + const job = await editSignageImage({ + idempotency_key: crypto.randomUUID(), + ...request, + }); + this._merge([job]); + this.watch(job.id); + return job; + } + + public async cancel(id: string) { + const job = await cancelSignageAIJob(id).catch(() => null); + if (job) this._merge([job]); + return job; + } + + public claim(id: string, upload_id: string, item_id: string) { + return claimSignageAIImage(id, { upload_id, item_id }).catch(() => null); + } + + public job(id: string) { + return this.jobs()[id]; + } + + /** + * One loop per job. Each request holds open until the job's version moves + * or the wait runs out, so a candidate shows up about half a second after + * it lands without polling in a tight circle. A job that outlives one wait + * simply spans several requests. + */ + public watch(id: string) { + if (this._timers[`watch-${id}`]) return; + this.timeout(`watch-${id}`, () => this._poll(id), 1); + } + + private async _poll(id: string) { + const known = this.jobs()[id]?.version ?? 0; + const job = await showSignageAIJob(id, { + wait: POLL_WAIT, + since: known, + }).catch(() => null); + + if (!job) { + // a dropped connection is normal on a long poll; try again shortly + this.timeout(`watch-${id}`, () => this._poll(id), 2000); + return; + } + + this._merge([job]); + + if (isFinal(job)) { + this.clearTimeout(`watch-${id}`); + this._announce(job); + return; + } + + this.timeout(`watch-${id}`, () => this._poll(id), 1); + } + + /** told once, when a job the user may no longer be watching finishes */ + private _announce(job: AiJob) { + if (job.state === 'failed') { + notifyError( + job.error_message || i18n('SIGNAGE_MANAGER.AI_JOB_FAILED'), + ); + } else if (job.state === 'done' && job.images_produced > 0) { + notifyInfo(i18n('SIGNAGE_MANAGER.AI_JOB_DONE')); + } + } + + private _merge(jobs: AiJob[]) { + if (!jobs?.length) return; + this.jobs.update((existing) => { + const next = { ...existing }; + for (const job of jobs) next[job.id] = job; + return next; + }); + } + + /** + * Read a generated image back out as something an or a canvas can + * take. Candidates are private uploads, so this goes through the same + * authenticated image path the media thumbnails use, which sets the cookie + * and caches the result. + */ + public loadImage(url: string): Promise { + const source = url.startsWith('http') + ? url + : `${location.origin}${url}`; + return loadAuthenticatedImage(source, '/api/engine/v2/uploads'); + } +} diff --git a/apps/signage-manager/src/app/ai/ai-layer.component.ts b/apps/signage-manager/src/app/ai/ai-layer.component.ts new file mode 100644 index 0000000000..8731a6e196 --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-layer.component.ts @@ -0,0 +1,423 @@ +import { + Component, + computed, + effect, + ElementRef, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { TranslatePipe } from '@placeos/components'; + +import { AiBrandKit, AiLayerState } from './ai.types'; + +const DEFAULT_STATE: AiLayerState = { + headline: '', + body: '', + position: 'top', + align: 'left', + colour: '#FFFFFF', + panel: true, + logo: true, + logo_position: 'bottom-right', + logo_scale: 0.14, +}; + +/** + * The words and the logo, drawn over the artwork in the browser. + * + * The model is asked for a background with a clear area and no lettering, + * because no image model spells reliably at small sizes and because a logo the + * model drew is the one part of a poster a trademark claim would land on. Both + * are composited here from real text and the customer's own logo file, at the + * artwork's native size. + */ +@Component({ + selector: 'ai-layer', + template: ` +
+
+ +
+
+ + + + + + + + +
+ + + {{ + 'SIGNAGE_MANAGER.AI_POS_TOP' | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_CENTRE' | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_BOTTOM' | translate + }} + + + + + {{ + 'SIGNAGE_MANAGER.AI_ALIGN_LEFT' | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ALIGN_CENTRE' | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ALIGN_RIGHT' | translate + }} + + +
+
+ {{ + 'SIGNAGE_MANAGER.AI_TEXT_COLOUR' | translate + }} + @for (colour of palette(); track colour) { + + } +
+ + {{ 'SIGNAGE_MANAGER.AI_TEXT_PANEL' | translate }} + + @if (logo_url()) { + + {{ 'SIGNAGE_MANAGER.AI_SHOW_LOGO' | translate }} + + @if (state().logo) { + + + {{ + 'SIGNAGE_MANAGER.AI_POS_BOTTOM_RIGHT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_BOTTOM_LEFT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_TOP_RIGHT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_TOP_LEFT' + | translate + }} + + + } + } +
+
+ `, + imports: [ + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatSlideToggleModule, + TranslatePipe, + ], +}) +export class AiLayerComponent { + /** object URL for the chosen candidate */ + public readonly image_url = input.required(); + public readonly logo_url = input(''); + public readonly brand = input(null); + + public readonly changed = output(); + + public readonly state = signal({ ...DEFAULT_STATE }); + + private readonly _canvas = + viewChild>('canvas'); + private _artwork: HTMLImageElement | null = null; + private _logo: HTMLImageElement | null = null; + + public readonly palette = computed(() => { + const brand = this.brand(); + const colours = Object.values(brand?.palette || {}); + return ['#FFFFFF', '#1B2420', ...colours].filter( + (colour, index, all) => all.indexOf(colour) === index, + ); + }); + + constructor() { + effect(() => { + const url = this.image_url(); + if (url) this._loadArtwork(url); + }); + effect(() => { + const url = this.logo_url(); + if (url) this._loadLogo(url); + }); + effect(() => { + this.state(); + this._draw(); + }); + } + + public patch(changes: Partial) { + this.state.update((state) => ({ ...state, ...changes })); + this.changed.emit(this.state()); + } + + /** the composited image, at the artwork's native size */ + public toBlob(): Promise { + const canvas = this._canvas()?.nativeElement; + if (!canvas) return Promise.resolve(null); + return new Promise((resolve) => + canvas.toBlob((blob) => resolve(blob), 'image/png'), + ); + } + + private _loadArtwork(url: string) { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => { + this._artwork = image; + const canvas = this._canvas()?.nativeElement; + if (canvas) { + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + } + this._draw(); + }; + image.src = url; + } + + private _loadLogo(url: string) { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => { + this._logo = image; + this._draw(); + }; + image.src = url; + } + + private _draw() { + const canvas = this._canvas()?.nativeElement; + const artwork = this._artwork; + if (!canvas || !artwork) return; + const context = canvas.getContext('2d'); + if (!context) return; + + const { width, height } = canvas; + context.clearRect(0, 0, width, height); + context.drawImage(artwork, 0, 0, width, height); + + const state = this.state(); + this._drawText(context, width, height, state); + if (state.logo) this._drawLogo(context, width, height, state); + } + + private _drawText( + context: CanvasRenderingContext2D, + width: number, + height: number, + state: AiLayerState, + ) { + const headline = state.headline.trim(); + const body = state.body.trim(); + if (!headline && !body) return; + + const margin = Math.round(width * 0.06); + const headline_size = Math.round(height * 0.11); + const body_size = Math.round(height * 0.05); + const family = this._fontFamily(); + + const lines: { text: string; size: number; weight: string }[] = []; + if (headline) { + context.font = `700 ${headline_size}px ${family}`; + for (const line of this._wrap( + context, + headline, + width - margin * 2, + )) { + lines.push({ text: line, size: headline_size, weight: '700' }); + } + } + if (body) { + context.font = `400 ${body_size}px ${family}`; + for (const line of this._wrap(context, body, width - margin * 2)) { + lines.push({ text: line, size: body_size, weight: '400' }); + } + } + + const spacing = Math.round(headline_size * 0.28); + const block = + lines.reduce((total, line) => total + line.size, 0) + + spacing * Math.max(0, lines.length - 1); + + let top = margin; + if (state.position === 'centre') top = (height - block) / 2; + if (state.position === 'bottom') top = height - block - margin; + + if (state.panel) { + const pad = Math.round(headline_size * 0.4); + context.fillStyle = this._panelColour(state.colour); + context.fillRect( + 0, + Math.max(0, top - pad), + width, + block + pad * 2, + ); + } + + let x = margin; + context.textAlign = 'left'; + if (state.align === 'centre') { + x = width / 2; + context.textAlign = 'center'; + } else if (state.align === 'right') { + x = width - margin; + context.textAlign = 'right'; + } + + context.fillStyle = state.colour; + context.textBaseline = 'top'; + let y = top; + for (const line of lines) { + context.font = `${line.weight} ${line.size}px ${family}`; + context.fillText(line.text, x, y); + y += line.size + spacing; + } + } + + private _drawLogo( + context: CanvasRenderingContext2D, + width: number, + height: number, + state: AiLayerState, + ) { + const logo = this._logo; + if (!logo) return; + + const margin = Math.round(width * 0.04); + const target_width = Math.round(width * state.logo_scale); + const scale = target_width / logo.naturalWidth; + const target_height = Math.round(logo.naturalHeight * scale); + + const left = state.logo_position.endsWith('left') + ? margin + : width - target_width - margin; + const top = state.logo_position.startsWith('top') + ? margin + : height - target_height - margin; + + context.drawImage(logo, left, top, target_width, target_height); + } + + /** a translucent band behind the words, tinted away from the text colour */ + private _panelColour(text_colour: string) { + return this._isLight(text_colour) + ? 'rgba(0, 0, 0, 0.45)' + : 'rgba(255, 255, 255, 0.6)'; + } + + private _isLight(hex: string) { + const value = hex.replace('#', ''); + if (value.length < 6) return true; + const r = parseInt(value.slice(0, 2), 16); + const g = parseInt(value.slice(2, 4), 16); + const b = parseInt(value.slice(4, 6), 16); + return (r * 299 + g * 587 + b * 114) / 1000 > 140; + } + + private _fontFamily() { + const brand = this.brand(); + const font = brand?.font; + const family = typeof font === 'string' ? font : font?.family; + return family + ? `"${family}", system-ui, sans-serif` + : 'system-ui, sans-serif'; + } + + private _wrap( + context: CanvasRenderingContext2D, + text: string, + max_width: number, + ) { + const words = text.split(/\s+/); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (context.measureText(candidate).width > max_width && current) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current) lines.push(current); + return lines; + } +} diff --git a/apps/signage-manager/src/app/ai/ai.fn.ts b/apps/signage-manager/src/app/ai/ai.fn.ts new file mode 100644 index 0000000000..ad7a6e3ca0 --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai.fn.ts @@ -0,0 +1,98 @@ +import { apiEndpoint, del, get, post } from '@placeos/ts-client'; + +import { + AiCapabilities, + AiEditRequest, + AiGenerateRequest, + AiJob, +} from './ai.types'; + +const AI_PATH = () => `${apiEndpoint()}/signage/ai`; + +function toQuery(params: Record) { + const pairs = Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + ); + return pairs.length ? `?${pairs.join('&')}` : ''; +} + +export function signageAICapabilities(): Promise { + return get(`${AI_PATH()}/capabilities`) as Promise; +} + +export function generateSignageImage( + request: AiGenerateRequest, +): Promise { + return post(`${AI_PATH()}/generate`, request) as Promise; +} + +export function editSignageImage(request: AiEditRequest): Promise { + return post(`${AI_PATH()}/edit`, request) as Promise; +} + +/** + * Ask for the job, optionally holding the request open until something + * changes. `wait` is capped at 25 seconds server side; a job that takes longer + * simply spans several of these calls. + */ +export function showSignageAIJob( + id: string, + query: { wait?: number; since?: number } = {}, +): Promise { + return get( + `${AI_PATH()}/jobs/${encodeURIComponent(id)}${toQuery(query)}`, + ) as Promise; +} + +export function querySignageAIJobs( + query: { mine?: boolean; limit?: number } = {}, +): Promise { + return get(`${AI_PATH()}/jobs${toQuery(query)}`) as Promise; +} + +export function cancelSignageAIJob(id: string): Promise { + return post( + `${AI_PATH()}/jobs/${encodeURIComponent(id)}/cancel`, + {}, + ) as Promise; +} + +export function claimSignageAIImage( + id: string, + body: { upload_id: string; item_id: string }, +): Promise { + return post( + `${AI_PATH()}/jobs/${encodeURIComponent(id)}/claim`, + body, + ) as Promise; +} + +export function signageAIUsage( + query: { from?: number; to?: number } = {}, +): Promise { + return get(`${AI_PATH()}/usage${toQuery(query)}`) as Promise; +} + +export function querySignageAIProviders( + query: { authority_id?: string; include_shared?: boolean } = {}, +): Promise { + return get(`${AI_PATH()}/providers${toQuery(query)}`) as Promise; +} + +export function addSignageAIProvider(body: any): Promise { + return post(`${AI_PATH()}/providers`, body) as Promise; +} + +export function removeSignageAIProvider(id: string): Promise { + return del(`${AI_PATH()}/providers/${encodeURIComponent(id)}`) as any; +} + +export function testSignageAIProvider(id: string): Promise { + return post( + `${AI_PATH()}/providers/${encodeURIComponent(id)}/test`, + {}, + ) as Promise; +} diff --git a/apps/signage-manager/src/app/ai/ai.types.ts b/apps/signage-manager/src/app/ai/ai.types.ts new file mode 100644 index 0000000000..d386cf6b0b --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai.types.ts @@ -0,0 +1,120 @@ +/** Mirrors the structs in rest-api's SignageAI controller. */ + +export interface AiModelCapabilities { + id: string; + name: string; + generate: boolean; + edit: boolean; + enhance: boolean; + max_references: number; + max_candidates: number; + qualities: string[]; + aspect_ratios: string[]; +} + +export interface AiProviderCapabilities { + id: string; + name: string; + provider: string; + region?: string; + default_model?: string; + models: AiModelCapabilities[]; +} + +export interface AiCapabilities { + enabled: boolean; + reason?: string; + providers: AiProviderCapabilities[]; + default_provider_id?: string; + aspect_ratios: string[]; + qualities: string[]; + max_candidates: number; + logo_layer: boolean; + quota: { + user_remaining_today: number | null; + domain_remaining_month: number | null; + }; +} + +export interface AiJobImage { + state?: string; + index?: number; + upload_id?: string; + url?: string; + width?: number; + height?: number; + mime?: string; + bytes?: number; + item_id?: string; +} + +export type AiJobState = + | 'queued' + | 'running' + | 'done' + | 'failed' + | 'cancelled'; + +export interface AiJob { + id: string; + state: AiJobState; + kind: 'generate' | 'edit'; + provider?: string; + model?: string; + candidates: number; + images_produced: number; + parent_job_id?: string; + version: number; + prompt?: string; + /** one slot per candidate, null until that candidate lands */ + images: (AiJobImage | null)[]; + error_kind?: string; + error_message?: string; + cost_units?: number; + latency_ms?: number; + created_at?: number; + finished_at?: number; +} + +export interface AiGenerateRequest { + prompt: string; + aspect_ratio?: string; + quality?: 'standard' | 'high'; + candidates?: number; + references?: string[]; + include_logo?: boolean; + add_text_with_layer?: boolean; + words?: string; + provider_id?: string; + model?: string; + group_id?: string; + idempotency_key?: string; +} + +export interface AiEditRequest extends AiGenerateRequest { + source_upload_id: string; + source_item_id?: string; + parent_job_id?: string; +} + +export interface AiBrandKit { + organisation?: string; + palette?: Record; + tone?: string; + logo_upload_id?: string; + never_include?: string[]; + font?: { url?: string; family?: string } | string; +} + +/** what the layer editor produces, kept on the modal between states */ +export interface AiLayerState { + headline: string; + body: string; + position: 'top' | 'centre' | 'bottom'; + align: 'left' | 'centre' | 'right'; + colour: string; + panel: boolean; + logo: boolean; + logo_position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; + logo_scale: number; +} diff --git a/apps/signage-manager/src/app/app.component.ts b/apps/signage-manager/src/app/app.component.ts index 08ec58ba52..7fea8a60ed 100644 --- a/apps/signage-manager/src/app/app.component.ts +++ b/apps/signage-manager/src/app/app.component.ts @@ -8,6 +8,9 @@ import { TranslatePipe, } from '@placeos/components'; import { mocksInit } from '@placeos/mocks'; +import { authority } from '@placeos/ts-client'; + +import { AiImageService } from './ai/ai-image.service'; import * as SETTINGS_SCHEMA from '../environments/settings.schema.json'; @@ -51,10 +54,14 @@ export class AppComponent implements OnInit { private _placeos = inject(PlaceOS_Service); private _uploads = inject(UploadsService); + private _ai = inject(AiImageService); public async ngOnInit() { setMocks(mocksInit); await this._placeos.init(); this._uploads.init(); + // asks the backend once whether image generation is available here, so + // the entry points can hide themselves on a domain without a provider + this._ai.load(authority()?.config?.org_zone); } } diff --git a/apps/signage-manager/src/app/media/media-list-header.component.ts b/apps/signage-manager/src/app/media/media-list-header.component.ts index a52cf605ce..0e841c0d0c 100644 --- a/apps/signage-manager/src/app/media/media-list-header.component.ts +++ b/apps/signage-manager/src/app/media/media-list-header.component.ts @@ -16,6 +16,7 @@ import { import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; import { MediaAddModalComponent } from '../shared/media-add-modal.component'; import { SignageService } from '../signage.service'; +import { AiImageService } from '../ai/ai-image.service'; function isValidUrl(url: string): boolean { try { @@ -238,6 +239,23 @@ function isValidUrl(url: string): boolean { add + @if (ai_enabled()) { + + } + } } + @if (can_update() && ai_enabled() && isImage(media_item)) { + + } @if (sidebar_hidden() && can_update()) { - } - - + @for (block of state().blocks; track block.id; let i = $index) { +
+
+ + + + +
+ +
+ + + {{ + 'SIGNAGE_MANAGER.AI_ROLE_HEADLINE' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ROLE_SUBHEADING' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ROLE_BODY' + | translate + }} + + + + + + @for (anchor of anchors; track anchor) { + {{ + anchorLabel(anchor) | translate + }} + } + + + + @for (colour of palette(); track colour) { + + } + + + {{ + 'SIGNAGE_MANAGER.AI_TEXT_PANEL' | translate + }} + +
+
+ } + + + @if (logo_url()) { - - {{ 'SIGNAGE_MANAGER.AI_SHOW_LOGO' | translate }} - - @if (state().logo) { - - + {{ 'SIGNAGE_MANAGER.AI_SHOW_LOGO' | translate }} +
+ @if (state().logo) { + - {{ - 'SIGNAGE_MANAGER.AI_POS_BOTTOM_RIGHT' - | translate - }} - {{ - 'SIGNAGE_MANAGER.AI_POS_BOTTOM_LEFT' - | translate - }} - {{ - 'SIGNAGE_MANAGER.AI_POS_TOP_RIGHT' - | translate - }} - {{ - 'SIGNAGE_MANAGER.AI_POS_TOP_LEFT' - | translate - }} - - - } + + {{ + 'SIGNAGE_MANAGER.AI_POS_BOTTOM_RIGHT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_BOTTOM_LEFT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_TOP_RIGHT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_TOP_LEFT' + | translate + }} + + + } + } `, imports: [ FormsModule, + IconComponent, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatSlideToggleModule, + MatTooltipModule, TranslatePipe, ], }) @@ -193,7 +277,14 @@ export class AiLayerComponent { public readonly changed = output(); - public readonly state = signal({ ...DEFAULT_STATE }); + public readonly anchors = ANCHORS; + + public readonly state = signal({ + blocks: [newBlock('headline', 'top-left')], + logo: true, + logo_position: 'bottom-right', + logo_scale: 0.14, + }); private readonly _canvas = viewChild>('canvas'); @@ -228,6 +319,45 @@ export class AiLayerComponent { this.changed.emit(this.state()); } + public patchBlock(id: string, changes: Partial) { + this.patch({ + blocks: this.state().blocks.map((block) => + block.id === id ? { ...block, ...changes } : block, + ), + }); + } + + public addBlock() { + // a second block is usually the detail line under the title, and a + // third is usually somewhere else on the poster + const count = this.state().blocks.length; + const role: AiTextRole = count === 1 ? 'subheading' : 'body'; + const anchor: AiAnchor = + count < 2 ? this.state().blocks[0]?.anchor || 'top-left' : 'bottom-left'; + this.patch({ blocks: [...this.state().blocks, newBlock(role, anchor)] }); + } + + public removeBlock(id: string) { + if (this.state().blocks.length < 2) return; + this.patch({ + blocks: this.state().blocks.filter((block) => block.id !== id), + }); + } + + public placeholderFor(role: AiTextRole) { + return role === 'headline' + ? 'SIGNAGE_MANAGER.AI_HEADLINE' + : role === 'subheading' + ? 'SIGNAGE_MANAGER.AI_SUBHEADING' + : 'SIGNAGE_MANAGER.AI_BODY_TEXT'; + } + + public anchorLabel(anchor: AiAnchor) { + return `SIGNAGE_MANAGER.AI_ANCHOR_${anchor + .toUpperCase() + .replace('-', '_')}`; + } + /** the composited image, at the artwork's native size */ public toBlob(): Promise { const canvas = this._canvas()?.nativeElement; @@ -274,80 +404,93 @@ export class AiLayerComponent { context.drawImage(artwork, 0, 0, width, height); const state = this.state(); - this._drawText(context, width, height, state); + this._drawBlocks(context, width, height, state); if (state.logo) this._drawLogo(context, width, height, state); } - private _drawText( + /** blocks sharing an anchor are laid out as one stack, in order */ + private _drawBlocks( context: CanvasRenderingContext2D, width: number, height: number, state: AiLayerState, ) { - const headline = state.headline.trim(); - const body = state.body.trim(); - if (!headline && !body) return; - const margin = Math.round(width * 0.06); - const headline_size = Math.round(height * 0.11); - const body_size = Math.round(height * 0.05); const family = this._fontFamily(); + const max_width = width - margin * 2; - const lines: { text: string; size: number; weight: string }[] = []; - if (headline) { - context.font = `700 ${headline_size}px ${family}`; - for (const line of this._wrap( - context, - headline, - width - margin * 2, - )) { - lines.push({ text: line, size: headline_size, weight: '700' }); - } - } - if (body) { - context.font = `400 ${body_size}px ${family}`; - for (const line of this._wrap(context, body, width - margin * 2)) { - lines.push({ text: line, size: body_size, weight: '400' }); + for (const anchor of ANCHORS) { + const blocks = state.blocks.filter( + (block) => block.anchor === anchor && block.text.trim(), + ); + if (!blocks.length) continue; + + // measure the whole stack first so it can be placed as one unit + const lines: { + text: string; + size: number; + weight: string; + colour: string; + panel: boolean; + }[] = []; + for (const block of blocks) { + const size = Math.round(height * ROLE_SIZE[block.role]); + const weight = block.role === 'headline' ? '700' : '400'; + context.font = `${weight} ${size}px ${family}`; + for (const text of this._wrap( + context, + block.text.trim(), + max_width, + )) { + lines.push({ + text, + size, + weight, + colour: block.colour, + panel: block.panel, + }); + } } - } - const spacing = Math.round(headline_size * 0.28); - const block = - lines.reduce((total, line) => total + line.size, 0) + - spacing * Math.max(0, lines.length - 1); - - let top = margin; - if (state.position === 'centre') top = (height - block) / 2; - if (state.position === 'bottom') top = height - block - margin; - - if (state.panel) { - const pad = Math.round(headline_size * 0.4); - context.fillStyle = this._panelColour(state.colour); - context.fillRect( - 0, - Math.max(0, top - pad), - width, - block + pad * 2, - ); - } + const spacing = Math.round(height * 0.02); + const block_height = + lines.reduce((total, line) => total + line.size, 0) + + spacing * Math.max(0, lines.length - 1); - let x = margin; - context.textAlign = 'left'; - if (state.align === 'centre') { - x = width / 2; - context.textAlign = 'center'; - } else if (state.align === 'right') { - x = width - margin; - context.textAlign = 'right'; - } + let top = margin; + if (anchor.startsWith('centre')) top = (height - block_height) / 2; + if (anchor.startsWith('bottom')) + top = height - block_height - margin; + + const horizontal = anchor.endsWith('right') + ? 'right' + : anchor.endsWith('left') + ? 'left' + : 'center'; + let x = margin; + if (horizontal === 'center') x = width / 2; + if (horizontal === 'right') x = width - margin; - context.fillStyle = state.colour; - context.textBaseline = 'top'; - let y = top; - for (const line of lines) { - context.font = `${line.weight} ${line.size}px ${family}`; - context.fillText(line.text, x, y); - y += line.size + spacing; + if (lines.some((line) => line.panel)) { + const pad = Math.round(height * 0.022); + context.fillStyle = this._panelColour(lines[0].colour); + context.fillRect( + 0, + Math.max(0, top - pad), + width, + block_height + pad * 2, + ); + } + + context.textAlign = horizontal as CanvasTextAlign; + context.textBaseline = 'top'; + let y = top; + for (const line of lines) { + context.font = `${line.weight} ${line.size}px ${family}`; + context.fillStyle = line.colour; + context.fillText(line.text, x, y); + y += line.size + spacing; + } } } diff --git a/apps/signage-manager/src/app/ai/ai.types.ts b/apps/signage-manager/src/app/ai/ai.types.ts index d386cf6b0b..f21b90fb29 100644 --- a/apps/signage-manager/src/app/ai/ai.types.ts +++ b/apps/signage-manager/src/app/ai/ai.types.ts @@ -106,14 +106,33 @@ export interface AiBrandKit { font?: { url?: string; family?: string } | string; } -/** what the layer editor produces, kept on the modal between states */ -export interface AiLayerState { - headline: string; - body: string; - position: 'top' | 'centre' | 'bottom'; - align: 'left' | 'centre' | 'right'; +/** where a block or the logo sits. Blocks sharing an anchor stack in order. */ +export type AiAnchor = + | 'top-left' + | 'top-centre' + | 'top-right' + | 'centre-left' + | 'centre' + | 'centre-right' + | 'bottom-left' + | 'bottom-centre' + | 'bottom-right'; + +/** drives the size the text is drawn at */ +export type AiTextRole = 'headline' | 'subheading' | 'body'; + +export interface AiTextBlock { + id: string; + text: string; + role: AiTextRole; + anchor: AiAnchor; colour: string; panel: boolean; +} + +/** what the layer editor produces, kept on the modal between states */ +export interface AiLayerState { + blocks: AiTextBlock[]; logo: boolean; logo_position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; logo_scale: number; diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 5bae7e8cbd..8c65c748ee 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -21,12 +21,23 @@ "ADD_ZONE_TOOLTIP": "Add zone", "ADD_ZONE_TO_PLAYLIST_ARIA": "Add zone to playlist", "ADVANCED_SCHEDULE_WARNING": "This playlist uses an advanced schedule that cannot be edited with the simple recurrence controls. Choose one of the repeat patterns above to replace it.", + "AI_ADD_TEXT": "Add another block of text", "AI_ADD_WORDS": "Add the words", "AI_ADD_WORDS_LAYER": "Add the words myself afterwards", "AI_ADD_WORDS_LAYER_HINT": "The image is generated without any text, then you type the headline over it. This keeps the words sharp and spelt correctly.", "AI_ALIGN_CENTRE": "Centre", "AI_ALIGN_LEFT": "Left", "AI_ALIGN_RIGHT": "Right", + "AI_ANCHOR_BOTTOM_CENTRE": "Bottom centre", + "AI_ANCHOR_BOTTOM_LEFT": "Bottom left", + "AI_ANCHOR_BOTTOM_RIGHT": "Bottom right", + "AI_ANCHOR_CENTRE": "Middle", + "AI_ANCHOR_CENTRE_LEFT": "Middle left", + "AI_ANCHOR_CENTRE_RIGHT": "Middle right", + "AI_ANCHOR_TOP_CENTRE": "Top centre", + "AI_ANCHOR_TOP_LEFT": "Top left", + "AI_ANCHOR_TOP_RIGHT": "Top right", + "AI_BODY_TEXT": "Smaller detail", "AI_BRIEF": "What should the image show?", "AI_BRIEF_HINT": "A poster for our office Christmas party on Friday 10 December", "AI_CHANGING_THIS": "Changing this image", @@ -59,6 +70,10 @@ "AI_REFINE_ACTION": "Refine", "AI_REFINE_HINT": "Make the background darker", "AI_REFINE_NOTE": "Every version stays available above, so you can always go back to one you liked.", + "AI_REMOVE_TEXT": "Remove this block", + "AI_ROLE_BODY": "Detail", + "AI_ROLE_HEADLINE": "Headline", + "AI_ROLE_SUBHEADING": "Subheading", "AI_SAVING": "Saving", "AI_SHAPE": "Shape", "AI_SHOW_LOGO": "Show our logo", @@ -67,6 +82,7 @@ "AI_TEXT_COLOUR": "Colour", "AI_TEXT_PANEL": "Shade behind the text", "AI_TEXT_POSITION": "Text position", + "AI_TEXT_SIZE": "Size", "AI_VERSIONS": "Earlier versions", "AI_WORKING": "Making your images", "ALL_DAY": "All day", From f29a5fbd45be55b8158ae9f00399a9f67be5056d Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 28 Aug 2026 11:54:30 +1000 Subject: [PATCH 11/27] feat(signage-manager): let a logo be added, since nothing stores one The layer offered to place a logo and reserve space for it, but there was no way for anyone to supply one: nothing in PlaceOS holds a customer logo, and the only route in was writing zone metadata by hand. The layer editor now takes an upload and keeps it in the brand kit, so it is supplied once by whoever first wants it on a poster and used by everyone afterwards. Without one it says so and offers to add it, rather than hiding the feature or drawing a placeholder. Metadata writes need only the metadata scope, so this does not need an administrator. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app/ai/ai-image-modal.component.ts | 26 +++++++- .../src/app/ai/ai-image.service.ts | 48 +++++++++++++- .../src/app/ai/ai-layer.component.ts | 64 +++++++++++++++++-- shared/assets/locale/en-AU.json | 6 ++ 4 files changed, 135 insertions(+), 9 deletions(-) diff --git a/apps/signage-manager/src/app/ai/ai-image-modal.component.ts b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts index 77ddc345f3..090563cac9 100644 --- a/apps/signage-manager/src/app/ai/ai-image-modal.component.ts +++ b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts @@ -7,7 +7,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { i18n, notifyError } from '@placeos/common'; +import { i18n, notifyError, notifySuccess } from '@placeos/common'; import { AuthenticatedImageDirective, FullscreenModalShellComponent, @@ -265,7 +265,9 @@ interface Candidate { [image_url]="selected_object_url()" [logo_url]="logo_object_url()" [brand]="brand()" + [uploading]="uploading_logo()" (changed)="layer_state.set($event)" + (logoPicked)="uploadLogo($event)" > } } @@ -310,6 +312,7 @@ export class AiImageModalComponent { public readonly selected = signal(null); public readonly selected_object_url = signal(''); public readonly logo_object_url = signal(''); + public readonly uploading_logo = signal(false); public readonly brand = this._ai.brand_kit; public readonly is_edit = computed(() => !!this._data.source_upload_id); @@ -531,6 +534,27 @@ export class AiImageModalComponent { this.state.set('compose'); } + /** + * Keep a logo for the domain. Nothing in PlaceOS stores one, so the first + * person to want one on a poster is the person who supplies it, and it is + * remembered for everyone afterwards. + */ + public async uploadLogo(file: File) { + this.uploading_logo.set(true); + try { + const upload_id = await this._ai.uploadBrandLogo(file); + const url = await this._ai + .loadImage(`/api/engine/v2/uploads/${upload_id}/url`) + .catch(() => ''); + this.logo_object_url.set(url); + notifySuccess(i18n('SIGNAGE_MANAGER.AI_LOGO_SAVED')); + } catch (error) { + notifyError(this._message(error)); + } finally { + this.uploading_logo.set(false); + } + } + public async openLayer() { const logo_id = this.brand()?.logo_upload_id; if (logo_id && !this.logo_object_url()) { diff --git a/apps/signage-manager/src/app/ai/ai-image.service.ts b/apps/signage-manager/src/app/ai/ai-image.service.ts index b8f67a116c..649d3701d3 100644 --- a/apps/signage-manager/src/app/ai/ai-image.service.ts +++ b/apps/signage-manager/src/app/ai/ai-image.service.ts @@ -1,7 +1,13 @@ import { computed, inject, Injectable, signal } from '@angular/core'; -import { AsyncHandler, i18n, notifyError, notifyInfo } from '@placeos/common'; +import { + AsyncHandler, + i18n, + notifyError, + notifyInfo, + UploadsService, +} from '@placeos/common'; import { loadAuthenticatedImage } from '@placeos/components'; -import { showMetadata } from '@placeos/ts-client'; +import { showMetadata, updateMetadata } from '@placeos/ts-client'; import { cancelSignageAIJob, @@ -53,7 +59,10 @@ export class AiImageService extends AsyncHandler { ), ); + private readonly _uploads = inject(UploadsService); + private _loaded = false; + private _org_zone = ''; /** * Read what this domain can do. An older backend has no such route, which @@ -63,6 +72,7 @@ export class AiImageService extends AsyncHandler { public async load(org_zone_id?: string) { if (this._loaded) return this.capabilities(); this._loaded = true; + this._org_zone = org_zone_id || ''; const capabilities = await signageAICapabilities().catch(() => null); this.capabilities.set( capabilities || { @@ -91,6 +101,40 @@ export class AiImageService extends AsyncHandler { return this.capabilities(); } + /** + * Store a logo for the domain and remember it. + * + * There is nowhere in PlaceOS that a customer logo lives, so it is kept in + * the same brand kit metadata as the palette and the tone. Set once here + * and every later poster picks it up, rather than being re-attached each + * time. + */ + public async uploadBrandLogo(file: File): Promise { + if (!this._org_zone) { + throw new Error(i18n('SIGNAGE_MANAGER.AI_NO_ORG_ZONE')); + } + + const upload_id = await this._uploads.uploadFileToCompletion(file); + const details = { ...(this.brand_kit() || {}), logo_upload_id: upload_id }; + + await updateMetadata( + this._org_zone, + { + name: 'signage_ai', + description: 'Brand kit used when generating signage artwork', + details, + } as any, + 'patch', + ); + + this.brand_kit.set(details); + // the capability is read once at start up; keep it honest for this session + this.capabilities.update((current) => + current ? { ...current, logo_layer: true } : current, + ); + return upload_id; + } + /** the jobs the user started recently, so the list survives a reload */ public async loadRecent() { const jobs = await querySignageAIJobs({ mine: true, limit: 20 }).catch( diff --git a/apps/signage-manager/src/app/ai/ai-layer.component.ts b/apps/signage-manager/src/app/ai/ai-layer.component.ts index 6d9ad9e8fa..0a8bf4d294 100644 --- a/apps/signage-manager/src/app/ai/ai-layer.component.ts +++ b/apps/signage-manager/src/app/ai/ai-layer.component.ts @@ -207,10 +207,29 @@ function newBlock(role: AiTextRole, anchor: AiAnchor): AiTextBlock { {{ 'SIGNAGE_MANAGER.AI_ADD_TEXT' | translate }} - @if (logo_url()) { -
+
+ @if (!logo_url()) { + + {{ + 'SIGNAGE_MANAGER.AI_NO_LOGO_YET' | translate + }} + + } @else { + } -
- } + } +
+ + `, @@ -276,6 +317,10 @@ export class AiLayerComponent { public readonly brand = input(null); public readonly changed = output(); + public readonly logoPicked = output(); + + /** set by the parent while the upload is in flight */ + public readonly uploading = input(false); public readonly anchors = ANCHORS; @@ -314,6 +359,13 @@ export class AiLayerComponent { }); } + public pickLogo(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (file) this.logoPicked.emit(file); + } + public patch(changes: Partial) { this.state.update((state) => ({ ...state, ...changes })); this.changed.emit(this.state()); diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 8c65c748ee..28aa25738e 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -21,6 +21,7 @@ "ADD_ZONE_TOOLTIP": "Add zone", "ADD_ZONE_TO_PLAYLIST_ARIA": "Add zone to playlist", "ADVANCED_SCHEDULE_WARNING": "This playlist uses an advanced schedule that cannot be edited with the simple recurrence controls. Choose one of the repeat patterns above to replace it.", + "AI_ADD_LOGO": "Add your logo", "AI_ADD_TEXT": "Add another block of text", "AI_ADD_WORDS": "Add the words", "AI_ADD_WORDS_LAYER": "Add the words myself afterwards", @@ -54,7 +55,11 @@ "AI_LAYER_PREVIEW": "Preview of the finished image", "AI_LEAVE_LOGO_SPACE": "Leave room for our logo", "AI_LOGO_POSITION": "Logo position", + "AI_LOGO_SAVED": "Logo saved. It will be used on future posters too.", + "AI_LOGO_UPLOADING": "Saving logo...", "AI_NO_IMAGE": "There is no image to save", + "AI_NO_LOGO_YET": "No logo saved for this organisation yet.", + "AI_NO_ORG_ZONE": "This domain has no organisation zone, so a logo cannot be saved", "AI_OPTION": "Generated option", "AI_OPTIONS_COUNT": "Options to generate", "AI_PICK_ONE": "Pick one", @@ -71,6 +76,7 @@ "AI_REFINE_HINT": "Make the background darker", "AI_REFINE_NOTE": "Every version stays available above, so you can always go back to one you liked.", "AI_REMOVE_TEXT": "Remove this block", + "AI_REPLACE_LOGO": "Replace logo", "AI_ROLE_BODY": "Detail", "AI_ROLE_HEADLINE": "Headline", "AI_ROLE_SUBHEADING": "Subheading", From fe0490e81b5baca51fbc2b4d8b8cd33a457b60c3 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 28 Aug 2026 12:02:19 +1000 Subject: [PATCH 12/27] feat(signage-manager): a branding page for the organisation The brand kit shapes every generated poster but had no interface: colours, font and logo could only be set by writing zone metadata by hand. Adds a Branding section: organisation name, one to three brand colours, a font, and the logo. It sits with the other signage sections rather than in Backoffice, because the people who choose a house style are the people making the posters, and metadata writes need only the metadata scope. The entry hides itself where image generation is not configured, since nothing else reads the kit yet. Faces load from Google Fonts on demand rather than being bundled: a canvas can only draw what the document has loaded, and the app already pulls Roboto from there. Two bugs found while testing the save: the API deep merges a PATCH, so a colour taken out of the palette came back, and reading the palette by value order shuffled the names on a round trip. The write replaces rather than merges, having already merged against the loaded kit, and the palette is read in a known order. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app/ai/ai-image.service.ts | 46 ++- apps/signage-manager/src/app/app.config.ts | 7 + .../src/app/branding/brand-fonts.ts | 51 +++ .../src/app/branding/branding.component.ts | 324 ++++++++++++++++++ .../src/app/shared/nav-footer.component.ts | 4 + .../src/app/shared/nav-items.ts | 8 + .../src/app/shared/nav-sidebar.component.ts | 3 + shared/assets/locale/en-AU.json | 14 + 8 files changed, 449 insertions(+), 8 deletions(-) create mode 100644 apps/signage-manager/src/app/branding/brand-fonts.ts create mode 100644 apps/signage-manager/src/app/branding/branding.component.ts diff --git a/apps/signage-manager/src/app/ai/ai-image.service.ts b/apps/signage-manager/src/app/ai/ai-image.service.ts index 649d3701d3..144eaac1cd 100644 --- a/apps/signage-manager/src/app/ai/ai-image.service.ts +++ b/apps/signage-manager/src/app/ai/ai-image.service.ts @@ -110,13 +110,34 @@ export class AiImageService extends AsyncHandler { * time. */ public async uploadBrandLogo(file: File): Promise { + const upload_id = await this._uploads.uploadFileToCompletion(file); + await this.saveBrandKit({ logo_upload_id: upload_id }); + // the capability is read once at start up; keep it honest for this session + this.capabilities.update((current) => + current ? { ...current, logo_layer: true } : current, + ); + return upload_id; + } + + /** + * Merge changes into the domain's brand kit. + * + * Merged rather than replaced so the branding page and the logo upload can + * each write their own part without clearing the other's, and so anything + * set by hand outside this app survives. + */ + public async saveBrandKit(changes: Partial): Promise { if (!this._org_zone) { throw new Error(i18n('SIGNAGE_MANAGER.AI_NO_ORG_ZONE')); } + const details = { ...(this.brand_kit() || {}), ...changes }; + for (const key of Object.keys(details)) { + if (details[key] === undefined) delete details[key]; + } - const upload_id = await this._uploads.uploadFileToCompletion(file); - const details = { ...(this.brand_kit() || {}), logo_upload_id: upload_id }; - + // replace rather than merge: the API deep merges a PATCH, so a colour + // taken out of the palette would survive the save. The merge above is + // against the kit we loaded, so nothing else is lost. await updateMetadata( this._org_zone, { @@ -124,15 +145,24 @@ export class AiImageService extends AsyncHandler { description: 'Brand kit used when generating signage artwork', details, } as any, - 'patch', + 'put', ); this.brand_kit.set(details); - // the capability is read once at start up; keep it honest for this session - this.capabilities.update((current) => - current ? { ...current, logo_layer: true } : current, + return details; + } + + /** re-read the kit, for a page opened before start up finished */ + public async reloadBrandKit(): Promise { + if (!this._org_zone) return null; + const metadata = await showMetadata(this._org_zone, 'signage_ai').catch( + () => null, ); - return upload_id; + const details = (metadata as any)?.details; + if (details && Object.keys(details).length) { + this.brand_kit.set(details as AiBrandKit); + } + return this.brand_kit(); } /** the jobs the user started recently, so the list survives a reload */ diff --git a/apps/signage-manager/src/app/app.config.ts b/apps/signage-manager/src/app/app.config.ts index ef1c3dc35d..2b44c58cd6 100644 --- a/apps/signage-manager/src/app/app.config.ts +++ b/apps/signage-manager/src/app/app.config.ts @@ -90,6 +90,13 @@ const APP_ROUTES: Routes = [ (m) => m.DisplaysSectionComponent, ), }, + { + path: 'branding', + loadComponent: () => + import('./branding/branding.component').then( + (m) => m.BrandingComponent, + ), + }, { path: 'groups', loadComponent: () => diff --git a/apps/signage-manager/src/app/branding/brand-fonts.ts b/apps/signage-manager/src/app/branding/brand-fonts.ts new file mode 100644 index 0000000000..e85de96272 --- /dev/null +++ b/apps/signage-manager/src/app/branding/brand-fonts.ts @@ -0,0 +1,51 @@ +/** + * Faces offered for signage artwork. + * + * Loaded from Google Fonts on demand rather than bundled: the app already + * pulls Roboto from there, the list needs to be long enough to feel like a + * choice, and a canvas can only draw a face the document has loaded. + */ +export const BRAND_FONTS = [ + { family: '', label: 'SIGNAGE_MANAGER.BRAND_FONT_SYSTEM' }, + { family: 'Inter', label: 'Inter' }, + { family: 'Roboto', label: 'Roboto' }, + { family: 'Open Sans', label: 'Open Sans' }, + { family: 'Lato', label: 'Lato' }, + { family: 'Montserrat', label: 'Montserrat' }, + { family: 'Poppins', label: 'Poppins' }, + { family: 'Work Sans', label: 'Work Sans' }, + { family: 'DM Sans', label: 'DM Sans' }, + { family: 'Source Sans 3', label: 'Source Sans 3' }, + { family: 'Space Grotesk', label: 'Space Grotesk' }, + { family: 'Bricolage Grotesque', label: 'Bricolage Grotesque' }, + { family: 'Playfair Display', label: 'Playfair Display' }, +]; + +const LOADED = new Set(); + +/** + * Make a face available to the document, and so to a canvas. + * + * Resolves either way: a face that will not load is a poster in the fallback + * face, which is better than a preview that never renders. + */ +export async function ensureBrandFont(family?: string | null): Promise { + if (!family) return; + if (!LOADED.has(family)) { + LOADED.add(family); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent( + family, + )}:wght@400;700&display=swap`; + document.head.appendChild(link); + } + try { + await Promise.all([ + (document as any).fonts?.load(`400 16px "${family}"`), + (document as any).fonts?.load(`700 16px "${family}"`), + ]); + } catch { + // a face that will not load falls back, which is fine + } +} diff --git a/apps/signage-manager/src/app/branding/branding.component.ts b/apps/signage-manager/src/app/branding/branding.component.ts new file mode 100644 index 0000000000..6dff039d5a --- /dev/null +++ b/apps/signage-manager/src/app/branding/branding.component.ts @@ -0,0 +1,324 @@ +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { i18n, notifyError, notifySuccess } from '@placeos/common'; +import { + AuthenticatedImageDirective, + IconComponent, + TranslatePipe, +} from '@placeos/components'; + +import { AiImageService } from '../ai/ai-image.service'; +import { NavFooterComponent } from '../shared/nav-footer.component'; +import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { BRAND_FONTS, ensureBrandFont } from './brand-fonts'; + +/** the palette is stored named, so the prompt can say what each colour is for */ +const COLOUR_NAMES = ['primary', 'secondary', 'accent']; + +@Component({ + selector: 'app-branding', + template: ` +
+ +
+

+ {{ 'SIGNAGE_MANAGER.BRAND_HEADER' | translate }} +

+

+ {{ 'SIGNAGE_MANAGER.BRAND_HINT' | translate }} +

+ + + + + + +
+ + @if (colours().length < 3) { + + } +
+
+ @for (colour of colours(); track $index) { +
+ + + + + {{ + colourName($index) + }} + +
+ } +
+ + + + + @for (option of fonts; track option.family) { + {{ + option.family + ? option.label + : (option.label | translate) + }} + } + + +

+ {{ 'SIGNAGE_MANAGER.BRAND_FONT_SAMPLE' | translate }} +

+ + +
+ @if (logo_id()) { + + } @else { + {{ + 'SIGNAGE_MANAGER.AI_NO_LOGO_YET' | translate + }} + } + + +
+ +
+ + @if (!enabled()) { + {{ + 'SIGNAGE_MANAGER.BRAND_AI_OFF' | translate + }} + } +
+
+ +
+ `, + imports: [ + AuthenticatedImageDirective, + NavFooterComponent, + NavSidebarComponent, + FormsModule, + IconComponent, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatTooltipModule, + TranslatePipe, + ], +}) +export class BrandingComponent implements OnInit { + private readonly _ai = inject(AiImageService); + + public readonly fonts = BRAND_FONTS; + public readonly enabled = this._ai.enabled; + + public readonly organisation = signal(''); + public readonly colours = signal(['#0E6E52']); + public readonly font = signal(''); + public readonly logo_id = signal(''); + public readonly saving = signal(false); + public readonly uploading = signal(false); + + public readonly logo_url = computed(() => { + const id = this.logo_id(); + return id ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` : ''; + }); + + public readonly font_stack = computed(() => { + const family = this.font(); + return family ? `"${family}", system-ui, sans-serif` : 'system-ui, sans-serif'; + }); + + public async ngOnInit() { + const brand = this._ai.brand_kit(); + if (brand) this._apply(brand); + // the kit is loaded once at start up; if that has not happened yet, wait + if (!brand) { + await this._ai.reloadBrandKit(); + const loaded = this._ai.brand_kit(); + if (loaded) this._apply(loaded); + } + this.previewFont(); + } + + public colourName(index: number) { + return COLOUR_NAMES[index] || `colour ${index + 1}`; + } + + public addColour() { + if (this.colours().length >= 3) return; + this.colours.update((list) => [...list, '#1B2420']); + } + + public removeColour(index: number) { + if (this.colours().length < 2) return; + this.colours.update((list) => list.filter((_, i) => i !== index)); + } + + public setColour(index: number, value: string) { + this.colours.update((list) => + list.map((colour, i) => (i === index ? value : colour)), + ); + } + + public previewFont() { + ensureBrandFont(this.font()); + } + + public async pickLogo(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) return; + this.uploading.set(true); + try { + const id = await this._ai.uploadBrandLogo(file); + this.logo_id.set(id); + notifySuccess(i18n('SIGNAGE_MANAGER.AI_LOGO_SAVED')); + } catch (error) { + notifyError(this._message(error)); + } finally { + this.uploading.set(false); + } + } + + public async save() { + this.saving.set(true); + try { + const palette: Record = {}; + this.colours().forEach((colour, index) => { + palette[this.colourName(index)] = colour; + }); + await this._ai.saveBrandKit({ + organisation: this.organisation().trim() || undefined, + palette, + font: this.font() ? { family: this.font() } : undefined, + }); + notifySuccess(i18n('SIGNAGE_MANAGER.BRAND_SAVED')); + } catch (error) { + notifyError(this._message(error)); + } finally { + this.saving.set(false); + } + } + + private _apply(brand: any) { + this.organisation.set(brand.organisation || ''); + // named order first, then anything else, so loading and saving is stable + const palette = brand.palette || {}; + const ordered = [ + ...COLOUR_NAMES.map((name) => palette[name]).filter(Boolean), + ...Object.keys(palette) + .filter((key) => !COLOUR_NAMES.includes(key)) + .map((key) => palette[key]), + ] as string[]; + if (ordered.length) this.colours.set(ordered.slice(0, 3)); + const font = brand.font; + this.font.set(typeof font === 'string' ? font : font?.family || ''); + this.logo_id.set(brand.logo_upload_id || ''); + } + + private _message(error: any) { + return ( + error?.error?.error || + error?.error || + error?.message || + i18n('SIGNAGE_MANAGER.BRAND_SAVE_FAILED') + ); + } +} diff --git a/apps/signage-manager/src/app/shared/nav-footer.component.ts b/apps/signage-manager/src/app/shared/nav-footer.component.ts index de7f5f3af6..bfe542ff3f 100644 --- a/apps/signage-manager/src/app/shared/nav-footer.component.ts +++ b/apps/signage-manager/src/app/shared/nav-footer.component.ts @@ -6,6 +6,7 @@ import { RouterModule } from '@angular/router'; import { i18n } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { dialogClosed, SignageService } from '../signage.service'; +import { AiImageService } from '../ai/ai-image.service'; import { GroupSelectModalComponent } from './group-select-modal.component'; import { filterManageNavItems } from './nav-items'; @@ -129,6 +130,7 @@ import { filterManageNavItems } from './nav-items'; }) export class NavFooterComponent { private readonly _service = inject(SignageService); + private readonly _ai = inject(AiImageService); private readonly _dialog = inject(MatDialog); private readonly can_manage_groups = computed( @@ -142,12 +144,14 @@ export class NavFooterComponent { filterManageNavItems( this.can_manage_groups(), this._service.templates_enabled(), + this._ai.enabled(), ).filter((item) => !this.MORE_MENU_ROUTES.includes(item.route)), ); public readonly more_nav_items = computed(() => filterManageNavItems( this.can_manage_groups(), this._service.templates_enabled(), + this._ai.enabled(), ).filter((item) => this.MORE_MENU_ROUTES.includes(item.route)), ); public readonly groups = this._service.signage_groups; diff --git a/apps/signage-manager/src/app/shared/nav-items.ts b/apps/signage-manager/src/app/shared/nav-items.ts index 2af5256548..f54ae28c8e 100644 --- a/apps/signage-manager/src/app/shared/nav-items.ts +++ b/apps/signage-manager/src/app/shared/nav-items.ts @@ -21,6 +21,11 @@ const NAV_ITEMS = [ icon: 'display_settings', label: 'SIGNAGE_MANAGER.NAV_DISPLAYS', }, + { + route: '/branding', + icon: 'palette', + label: 'SIGNAGE_MANAGER.NAV_BRANDING', + }, { route: '/groups', icon: 'groups', label: 'COMMON.GROUPS' }, ]; @@ -29,10 +34,13 @@ export type NavItem = (typeof NAV_ITEMS)[number]; export function filterManageNavItems( can_manage_groups: boolean, templates_enabled = false, + ai_enabled = false, ): NavItem[] { return NAV_ITEMS.filter((item) => { if (item.route === '/groups') return can_manage_groups; if (item.route === '/templates') return templates_enabled; + // branding only feeds image generation today, so it is noise without it + if (item.route === '/branding') return ai_enabled; return true; }); } diff --git a/apps/signage-manager/src/app/shared/nav-sidebar.component.ts b/apps/signage-manager/src/app/shared/nav-sidebar.component.ts index 7599e31025..fe1b893080 100644 --- a/apps/signage-manager/src/app/shared/nav-sidebar.component.ts +++ b/apps/signage-manager/src/app/shared/nav-sidebar.component.ts @@ -10,6 +10,7 @@ import { TranslatePipe, } from '@placeos/components'; import { SignageService } from '../signage.service'; +import { AiImageService } from '../ai/ai-image.service'; import { filterManageNavItems } from './nav-items'; import { SignageGroupSelectorComponent } from './signage-group-selector.component'; @@ -152,6 +153,7 @@ export class NavSidebarComponent { private readonly _settings = inject(SettingsService); private readonly _locale = inject(LocaleService); private readonly _service = inject(SignageService); + private readonly _ai = inject(AiImageService); public readonly locales = this._settings.signal< { id: string; name: string; local?: string }[] >('locales', []); @@ -165,6 +167,7 @@ export class NavSidebarComponent { this._service.can_manage_all_groups() || !!this._service.manageable_signage_groups().length, this._service.templates_enabled(), + this._ai.enabled(), ), ); public readonly active_locale = computed(() => this._locale.locale); diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 28aa25738e..07deead977 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -121,6 +121,19 @@ "BACK_TO_PLAYLISTS": "Back to playlists list", "BACK_TO_TEMPLATES": "Back to templates list", "BACK_TO_ZONES": "Back to zones list", + "BRAND_ADD_COLOUR": "Add a colour", + "BRAND_AI_OFF": "Image generation is not switched on for this domain, so this is not used yet.", + "BRAND_COLOURS": "Brand colours", + "BRAND_FONT": "Brand font", + "BRAND_FONT_SAMPLE": "The quick brown fox jumps over the lazy dog", + "BRAND_FONT_SYSTEM": "System default", + "BRAND_HEADER": "Branding", + "BRAND_HINT": "Used whenever artwork is generated for this organisation, so posters come back in your colours rather than the model's.", + "BRAND_LOGO": "Logo", + "BRAND_ORGANISATION": "Organisation name", + "BRAND_REMOVE_COLOUR": "Remove this colour", + "BRAND_SAVED": "Branding saved", + "BRAND_SAVE_FAILED": "The branding could not be saved", "BULK_UPLOAD_CLOSE": "Close", "BULK_UPLOAD_CLOSE_ARIA": "Close bulk upload", "BULK_UPLOAD_FAILED": "{{ count }} media files failed to upload.", @@ -246,6 +259,7 @@ "MORE": "More", "MORE_NAV_OPTIONS": "More navigation options", "NAME_REQUIRED": "Name is required", + "NAV_BRANDING": "Branding", "NAV_DISPLAYS": "Displays", "NAV_MEDIA": "Media", "NAV_PLAYLISTS": "Playlists", From 1e9a09c04b4c1a14cb0d3aac6bc216fb66679c44 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 28 Aug 2026 12:41:27 +1000 Subject: [PATCH 13/27] feat(signage-manager): full screen AI modal, preview left, settings right Alex asked for the modal to take the whole screen, with the preview as the primary section and the settings in a sidebar. The small boxes in his sketch become a candidate and version rail under the preview: every option of every round, oldest first, so going back to one you liked is a click. The wizard is gone with it. Compose and layer were separate states, which meant typing a headline happened on a screen where the picture was a thumbnail. Now the sidebar carries the brief, then the change box and the words and logo controls, against a preview that stays put the whole time. The layer component keeps only the canvas; its controls move to ai-layer-controls beside it. Flattening on save is decided by whether anything is actually drawn over the artwork rather than by which state the modal is in. --- .../src/app/ai/ai-image-modal.component.ts | 690 ++++++++++-------- .../src/app/ai/ai-layer-controls.component.ts | 319 ++++++++ .../src/app/ai/ai-layer.component.ts | 382 +--------- .../src/app/signage.service.ts | 3 +- shared/assets/locale/en-AU.json | 5 +- 5 files changed, 745 insertions(+), 654 deletions(-) create mode 100644 apps/signage-manager/src/app/ai/ai-layer-controls.component.ts diff --git a/apps/signage-manager/src/app/ai/ai-image-modal.component.ts b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts index 090563cac9..bf2a9dc376 100644 --- a/apps/signage-manager/src/app/ai/ai-image-modal.component.ts +++ b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts @@ -1,22 +1,31 @@ import { Component, computed, inject, signal, viewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatRippleModule } from '@angular/material/core'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { i18n, notifyError, notifySuccess } from '@placeos/common'; import { AuthenticatedImageDirective, - FullscreenModalShellComponent, IconComponent, TranslatePipe, } from '@placeos/components'; import { SignageService } from '../signage.service'; import { AiImageService, isFinal } from './ai-image.service'; +import { + AiLayerControlsComponent, + newTextBlock, +} from './ai-layer-controls.component'; import { AiLayerComponent } from './ai-layer.component'; import { AiJob, AiJobImage, AiLayerState } from './ai.types'; @@ -30,262 +39,369 @@ export interface AiImageModalData { source_name?: string; } -type ModalState = 'compose' | 'generating' | 'choose' | 'layer'; +type ModalState = 'compose' | 'generating' | 'review'; interface Candidate { job_id: string; index: number; upload_id: string; url: string; + /** position in the refine chain, 1 for the first generation */ + version: number; } @Component({ selector: 'ai-image-modal', template: ` - - @switch (state()) { - @case ('compose') { - @if (source_url()) { -

- {{ 'SIGNAGE_MANAGER.AI_CHANGING_THIS' | translate }} -

-
+
+
+

+ {{ heading() | translate }} +

+ +
+ +
+ +
+
+ @if (selected_object_url()) { + + } @else if (source_url()) { -
- } - - - - - -
- - @if (!is_edit()) { - - - @for ( - option of aspect_options(); - track option - ) { - {{ - option - }} - } - - - } - - - @for (count of candidate_options(); track count) { - {{ - count - }} - } - - -
- - - @if (!is_edit()) { - - {{ - 'SIGNAGE_MANAGER.AI_ADD_WORDS_LAYER' | translate - }} - -

- {{ - 'SIGNAGE_MANAGER.AI_ADD_WORDS_LAYER_HINT' - | translate - }} -

- - @if (has_logo()) { - + } @else if (state() !== 'generating') { +

{{ - 'SIGNAGE_MANAGER.AI_LEAVE_LOGO_SPACE' + 'SIGNAGE_MANAGER.AI_PREVIEW_EMPTY' | translate }} - +

} - } - @if (quota_note()) { -

- {{ quota_note() }} -

- } - @if (engine_note()) { -

- {{ engine_note() }} -

- } - } - @case ('generating') { -
- -

- {{ 'SIGNAGE_MANAGER.AI_WORKING' | translate }} -

-

- {{ progress_note() }} -

- -
- } - @case ('choose') { -
- @for (candidate of candidates_list(); track candidate.upload_id) { - + +

+ {{ + 'SIGNAGE_MANAGER.AI_WORKING' | translate + }} +

+

+ {{ progress_note() }} +

+
}
- @if (versions().length > 1) { -
-

+ + @if (rail().length) { +

+

{{ 'SIGNAGE_MANAGER.AI_VERSIONS' | translate }}

- @for (version of versions(); track version.job_id) { + @for ( + candidate of rail(); + track candidate.job_id + + '-' + + candidate.index + ) { }
} + + + +
- } - @case ('layer') { - - } - } - + } @else if (!rail().length) { + + } @else { + + } + + +
+
`, imports: [ FormsModule, MatButtonModule, + MatDialogModule, MatFormFieldModule, MatInputModule, MatProgressSpinnerModule, + MatRippleModule, MatSelectModule, MatSlideToggleModule, + MatTooltipModule, AuthenticatedImageDirective, - FullscreenModalShellComponent, IconComponent, TranslatePipe, AiLayerComponent, + AiLayerControlsComponent, ], }) export class AiImageModalComponent { @@ -306,8 +422,15 @@ export class AiImageModalComponent { public readonly candidates = signal(2); public readonly add_text_with_layer = signal(!this._data.source_upload_id); public readonly include_logo = signal(!this._data.source_upload_id); - public readonly layer_state = signal(null); + public readonly layer_state = signal({ + blocks: [newTextBlock('headline', 'top-left')], + logo: false, + logo_position: 'bottom-right', + logo_scale: 0.14, + }); + + /** the newest job; the rail walks back from here through its parents */ public readonly current_job_id = signal(''); public readonly selected = signal(null); public readonly selected_object_url = signal(''); @@ -320,9 +443,7 @@ export class AiImageModalComponent { /** the image being changed, so the brief is not written blind */ public readonly source_url = computed(() => { const id = this._data.source_upload_id; - return id - ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` - : ''; + return id ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` : ''; }); public readonly has_logo = computed( () => !!this._ai.capabilities()?.logo_layer, @@ -340,41 +461,35 @@ export class AiImageModalComponent { () => this._ai.jobs()[this.current_job_id()], ); - public readonly candidates_list = computed(() => { - const job = this.job(); - if (!job) return []; - return (job.images || []) - .map((image, index) => ({ image, index })) - .filter(({ image }) => !!image?.upload_id) - .map(({ image, index }) => ({ - job_id: job.id, - index, - upload_id: (image as AiJobImage).upload_id as string, - url: (image as AiJobImage).url as string, - })); - }); - - /** every job in the refine chain that produced something, oldest first */ - public readonly versions = computed(() => { - const chain: Candidate[] = []; + /** + * Every candidate of every job in the refine chain, oldest first: the first + * generation's options and each round of changes since, so going back to a + * version you liked is one click rather than a re-generate. + */ + public readonly rail = computed(() => { const jobs = this._ai.jobs(); - let id = this.current_job_id(); + const chain: AiJob[] = []; const seen = new Set(); + let id = this.current_job_id(); while (id && jobs[id] && !seen.has(id)) { seen.add(id); - const job = jobs[id]; - const first = (job.images || []).find((image) => image?.upload_id); - if (first) { - chain.unshift({ + chain.unshift(jobs[id]); + id = jobs[id].parent_job_id || ''; + } + const rail: Candidate[] = []; + chain.forEach((job, version) => { + (job.images || []).forEach((image, index) => { + if (!image?.upload_id) return; + rail.push({ job_id: job.id, - index: 0, - upload_id: first.upload_id as string, - url: first.url as string, + index, + upload_id: image.upload_id as string, + url: (image as AiJobImage).url as string, + version: version + 1, }); - } - id = job.parent_job_id || ''; - } - return chain; + }); + }); + return rail; }); public readonly progress_note = computed(() => { @@ -412,54 +527,24 @@ export class AiImageModalComponent { }); }); - public readonly heading = computed(() => { - if (this.state() === 'layer') return 'SIGNAGE_MANAGER.AI_ADD_WORDS'; - if (this.state() === 'choose') return 'SIGNAGE_MANAGER.AI_PICK_ONE'; - return this.is_edit() + public readonly heading = computed(() => + this.is_edit() ? 'SIGNAGE_MANAGER.AI_EDIT_IMAGE' - : 'SIGNAGE_MANAGER.AI_CREATE_IMAGE'; - }); - - public readonly confirm_text = computed(() => { - switch (this.state()) { - case 'compose': - return 'SIGNAGE_MANAGER.AI_GENERATE'; - case 'choose': - return this.add_text_with_layer() - ? 'SIGNAGE_MANAGER.AI_ADD_WORDS' - : 'COMMON.SAVE'; - case 'layer': - return 'COMMON.SAVE'; - default: - return 'COMMON.CANCEL'; - } - }); + : 'SIGNAGE_MANAGER.AI_CREATE_IMAGE', + ); - public readonly can_confirm = computed(() => { - if (this.saving()) return false; - switch (this.state()) { - case 'compose': - return !!this.brief().trim(); - case 'choose': - return !!this.selected(); - case 'layer': - return true; - default: - return false; - } + /** whether anything is drawn over the artwork, and so has to be flattened */ + public readonly has_overlay = computed(() => { + const state = this.layer_state(); + if (state.blocks.some((block) => block.text.trim())) return true; + return state.logo && !!this.logo_object_url(); }); - public confirm() { - switch (this.state()) { - case 'compose': - return this.start(); - case 'choose': - return this.add_text_with_layer() - ? this.openLayer() - : this.save(); - case 'layer': - return this.save(); - } + public versionLabel(candidate: Candidate) { + return i18n('SIGNAGE_MANAGER.AI_VERSION_LABEL', { + version: `${candidate.version}`, + option: `${candidate.index + 1}`, + }); } public async start() { @@ -511,17 +596,11 @@ export class AiImageModalComponent { this.current_job_id.set(job.id); this._awaitJob(job.id); } catch (error) { - this.state.set('choose'); + this.state.set('review'); notifyError(this._message(error)); } } - public openVersion(job_id: string) { - this.current_job_id.set(job_id); - const first = this.candidates_list()[0]; - if (first) this.select(first); - } - public async select(candidate: Candidate) { this.selected.set(candidate); const url = await this._ai.loadImage(candidate.url).catch(() => ''); @@ -531,7 +610,7 @@ export class AiImageModalComponent { public async cancel() { const id = this.current_job_id(); if (id) await this._ai.cancel(id); - this.state.set('compose'); + this.state.set(this.rail().length ? 'review' : 'compose'); } /** @@ -547,6 +626,7 @@ export class AiImageModalComponent { .loadImage(`/api/engine/v2/uploads/${upload_id}/url`) .catch(() => ''); this.logo_object_url.set(url); + this.layer_state.set({ ...this.layer_state(), logo: true }); notifySuccess(i18n('SIGNAGE_MANAGER.AI_LOGO_SAVED')); } catch (error) { notifyError(this._message(error)); @@ -555,30 +635,17 @@ export class AiImageModalComponent { } } - public async openLayer() { - const logo_id = this.brand()?.logo_upload_id; - if (logo_id && !this.logo_object_url()) { - const url = await this._ai - .loadImage(`/api/engine/v2/uploads/${logo_id}/url`) - .catch(() => ''); - this.logo_object_url.set(url); - } - this.state.set('layer'); - } - public async save() { const candidate = this.selected(); if (!candidate) return; - // The shell swaps its projected content for a spinner while `loading` - // is set, which destroys the layer component. Take the composited image - // before that happens. + // Take the composited image before the button swaps to a spinner: a + // change-detection pass that tears the canvas down mid-save would leave + // nothing to read it from. const name = this._name(); - const blob = - this.state() === 'layer' - ? await this._layer()?.toBlob() - : undefined; - if (this.state() === 'layer' && !blob) { + const overlay = this.has_overlay(); + const blob = overlay ? await this._layer()?.toBlob() : undefined; + if (overlay && !blob) { notifyError(i18n('SIGNAGE_MANAGER.AI_NO_IMAGE')); return; } @@ -642,24 +709,39 @@ export class AiImageModalComponent { if (!job) return setTimeout(check, 250); if (!isFinal(job)) return setTimeout(check, 250); if (job.state === 'failed') { - this.state.set(this.versions().length ? 'choose' : 'compose'); + this.state.set(this.rail().length ? 'review' : 'compose'); notifyError( - job.error_message || - i18n('SIGNAGE_MANAGER.AI_JOB_FAILED'), + job.error_message || i18n('SIGNAGE_MANAGER.AI_JOB_FAILED'), ); return; } if (job.state === 'cancelled') { - this.state.set('compose'); + this.state.set(this.rail().length ? 'review' : 'compose'); return; } - const first = this.candidates_list()[0]; - if (first) this.select(first); - this.state.set('choose'); + const newest = this.rail().filter( + (candidate) => candidate.job_id === id, + ); + if (newest.length) this.select(newest[0]); + this._loadBrandLogo(); + this.state.set('review'); }; check(); } + /** the saved logo, so the toggle in the sidebar has something to show */ + private async _loadBrandLogo() { + const logo_id = this.brand()?.logo_upload_id; + if (!logo_id || this.logo_object_url()) return; + const url = await this._ai + .loadImage(`/api/engine/v2/uploads/${logo_id}/url`) + .catch(() => ''); + this.logo_object_url.set(url); + if (url && this.include_logo()) { + this.layer_state.set({ ...this.layer_state(), logo: true }); + } + } + private _name() { const brief = (this.brief() || this._data.source_name || '').trim(); const words = brief.split(/\s+/).slice(0, 6).join(' '); diff --git a/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts b/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts new file mode 100644 index 0000000000..360e83cedb --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts @@ -0,0 +1,319 @@ +import { Component, computed, input, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { IconComponent, TranslatePipe } from '@placeos/components'; + +import { ANCHORS } from './ai-layer.component'; +import { + AiAnchor, + AiBrandKit, + AiLayerState, + AiTextBlock, + AiTextRole, +} from './ai.types'; + +export function newTextBlock(role: AiTextRole, anchor: AiAnchor): AiTextBlock { + return { + id: `${Date.now()}-${Math.round(Math.random() * 1e6)}`, + text: '', + role, + anchor, + colour: '#FFFFFF', + panel: true, + }; +} + +/** + * The words and the logo, as a sidebar panel beside the preview. + * + * Split from the canvas so the preview can hold the main pane: the person + * writing a headline wants to see it at size while they type, not in a strip + * above a stack of form fields. + */ +@Component({ + selector: 'ai-layer-controls', + template: ` +
+ @for (block of state().blocks; track block.id) { +
+
+ + + + +
+ +
+ + + {{ + 'SIGNAGE_MANAGER.AI_ROLE_HEADLINE' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ROLE_SUBHEADING' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ROLE_BODY' | translate + }} + + + + + + @for (anchor of anchors; track anchor) { + {{ + anchorLabel(anchor) | translate + }} + } + + +
+ +
+ @for (colour of palette(); track colour) { + + } + + {{ 'SIGNAGE_MANAGER.AI_TEXT_PANEL' | translate }} + +
+
+ } + + + +
+ @if (!logo_url()) { + {{ + 'SIGNAGE_MANAGER.AI_NO_LOGO_YET' | translate + }} + + } @else { + + {{ 'SIGNAGE_MANAGER.AI_SHOW_LOGO' | translate }} + + @if (state().logo) { + + + {{ + 'SIGNAGE_MANAGER.AI_POS_BOTTOM_RIGHT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_BOTTOM_LEFT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_TOP_RIGHT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_POS_TOP_LEFT' + | translate + }} + + + } + } + +
+
+ `, + imports: [ + FormsModule, + IconComponent, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatSlideToggleModule, + MatTooltipModule, + TranslatePipe, + ], +}) +export class AiLayerControlsComponent { + public readonly state = input.required(); + public readonly logo_url = input(''); + public readonly brand = input(null); + public readonly uploading = input(false); + + public readonly changed = output(); + public readonly logoPicked = output(); + + public readonly anchors = ANCHORS; + + public readonly palette = computed(() => { + const colours = Object.values(this.brand()?.palette || {}); + return ['#FFFFFF', '#1B2420', ...colours].filter( + (colour, index, all) => all.indexOf(colour) === index, + ); + }); + + public patch(changes: Partial) { + this.changed.emit({ ...this.state(), ...changes }); + } + + public patchBlock(id: string, changes: Partial) { + this.patch({ + blocks: this.state().blocks.map((block) => + block.id === id ? { ...block, ...changes } : block, + ), + }); + } + + public addBlock() { + const blocks = this.state().blocks; + const role: AiTextRole = blocks.length === 1 ? 'subheading' : 'body'; + const anchor: AiAnchor = + blocks.length < 2 ? blocks[0]?.anchor || 'top-left' : 'bottom-left'; + this.patch({ blocks: [...blocks, newTextBlock(role, anchor)] }); + } + + public removeBlock(id: string) { + if (this.state().blocks.length < 2) return; + this.patch({ + blocks: this.state().blocks.filter((block) => block.id !== id), + }); + } + + public pickLogo(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (file) this.logoPicked.emit(file); + } + + public placeholderFor(role: AiTextRole) { + return role === 'headline' + ? 'SIGNAGE_MANAGER.AI_HEADLINE' + : role === 'subheading' + ? 'SIGNAGE_MANAGER.AI_SUBHEADING' + : 'SIGNAGE_MANAGER.AI_BODY_TEXT'; + } + + public anchorLabel(anchor: AiAnchor) { + return `SIGNAGE_MANAGER.AI_ANCHOR_${anchor + .toUpperCase() + .replace('-', '_')}`; + } +} diff --git a/apps/signage-manager/src/app/ai/ai-layer.component.ts b/apps/signage-manager/src/app/ai/ai-layer.component.ts index 0a8bf4d294..d67703aa11 100644 --- a/apps/signage-manager/src/app/ai/ai-layer.component.ts +++ b/apps/signage-manager/src/app/ai/ai-layer.component.ts @@ -4,20 +4,11 @@ import { effect, ElementRef, input, - output, - signal, viewChild, } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { AiAnchor, AiBrandKit, AiLayerState, AiTextBlock, AiTextRole } from './ai.types'; +import { ensureBrandFont } from '../branding/brand-fonts'; +import { AiAnchor, AiBrandKit, AiLayerState, AiTextRole } from './ai.types'; /** share of the artwork's height each role is drawn at */ const ROLE_SIZE: Record = { @@ -26,7 +17,7 @@ const ROLE_SIZE: Record = { body: 0.038, }; -const ANCHORS: AiAnchor[] = [ +export const ANCHORS: AiAnchor[] = [ 'top-left', 'top-centre', 'top-right', @@ -38,310 +29,52 @@ const ANCHORS: AiAnchor[] = [ 'bottom-right', ]; -function newBlock(role: AiTextRole, anchor: AiAnchor): AiTextBlock { - return { - id: `${Date.now()}-${Math.round(Math.random() * 1e6)}`, - text: '', - role, - anchor, - colour: '#FFFFFF', - panel: true, - }; -} - /** - * The words and the logo, drawn over the artwork in the browser. + * The finished poster: the artwork with the words and the logo drawn over it, + * at the artwork's native size. * * The model is asked for a background with a clear area and no lettering, * because no image model spells reliably at small sizes and because a logo the - * model drew is the one part of a poster a trademark claim would land on. Both - * are composited here from real text and the customer's own logo file, at the - * artwork's native size. + * model drew is the one part of a poster a trademark claim would land on. * - * Text is a list of blocks rather than a fixed headline and subheading: a - * poster usually wants a title, a date and a location, and they do not all - * belong in the same corner. Blocks sharing an anchor stack in order, so - * placement stays predictable without a drag surface. + * This renders only. The controls live beside it in the modal's sidebar, so the + * preview can hold the whole of the main pane. */ @Component({ selector: 'ai-layer', template: ` -
-
- -
- -
- @for (block of state().blocks; track block.id; let i = $index) { -
-
- - - - -
- -
- - - {{ - 'SIGNAGE_MANAGER.AI_ROLE_HEADLINE' - | translate - }} - {{ - 'SIGNAGE_MANAGER.AI_ROLE_SUBHEADING' - | translate - }} - {{ - 'SIGNAGE_MANAGER.AI_ROLE_BODY' - | translate - }} - - - - - - @for (anchor of anchors; track anchor) { - {{ - anchorLabel(anchor) | translate - }} - } - - - - @for (colour of palette(); track colour) { - - } - - - {{ - 'SIGNAGE_MANAGER.AI_TEXT_PANEL' | translate - }} - -
-
- } - - - -
- @if (!logo_url()) { - - {{ - 'SIGNAGE_MANAGER.AI_NO_LOGO_YET' | translate - }} - - } @else { - - {{ 'SIGNAGE_MANAGER.AI_SHOW_LOGO' | translate }} - - @if (state().logo) { - - - {{ - 'SIGNAGE_MANAGER.AI_POS_BOTTOM_RIGHT' - | translate - }} - {{ - 'SIGNAGE_MANAGER.AI_POS_BOTTOM_LEFT' - | translate - }} - {{ - 'SIGNAGE_MANAGER.AI_POS_TOP_RIGHT' - | translate - }} - {{ - 'SIGNAGE_MANAGER.AI_POS_TOP_LEFT' - | translate - }} - - - - } - } -
- - -
-
+ `, - imports: [ - FormsModule, - IconComponent, - MatButtonModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatSlideToggleModule, - MatTooltipModule, - TranslatePipe, + styles: [ + ` + :host { + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + min-width: 0; + } + `, ], }) export class AiLayerComponent { - /** object URL for the chosen candidate */ public readonly image_url = input.required(); public readonly logo_url = input(''); public readonly brand = input(null); - - public readonly changed = output(); - public readonly logoPicked = output(); - - /** set by the parent while the upload is in flight */ - public readonly uploading = input(false); - - public readonly anchors = ANCHORS; - - public readonly state = signal({ - blocks: [newBlock('headline', 'top-left')], - logo: true, - logo_position: 'bottom-right', - logo_scale: 0.14, - }); + public readonly state = input.required(); private readonly _canvas = viewChild>('canvas'); private _artwork: HTMLImageElement | null = null; private _logo: HTMLImageElement | null = null; - public readonly palette = computed(() => { - const brand = this.brand(); - const colours = Object.values(brand?.palette || {}); - return ['#FFFFFF', '#1B2420', ...colours].filter( - (colour, index, all) => all.indexOf(colour) === index, - ); + private readonly _family = computed(() => { + const font = this.brand()?.font; + return typeof font === 'string' ? font : font?.family || ''; }); constructor() { @@ -353,63 +86,17 @@ export class AiLayerComponent { const url = this.logo_url(); if (url) this._loadLogo(url); }); + effect(() => { + // a face has to be in the document before a canvas can draw with it + const family = this._family(); + if (family) ensureBrandFont(family).then(() => this._draw()); + }); effect(() => { this.state(); this._draw(); }); } - public pickLogo(event: Event) { - const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - input.value = ''; - if (file) this.logoPicked.emit(file); - } - - public patch(changes: Partial) { - this.state.update((state) => ({ ...state, ...changes })); - this.changed.emit(this.state()); - } - - public patchBlock(id: string, changes: Partial) { - this.patch({ - blocks: this.state().blocks.map((block) => - block.id === id ? { ...block, ...changes } : block, - ), - }); - } - - public addBlock() { - // a second block is usually the detail line under the title, and a - // third is usually somewhere else on the poster - const count = this.state().blocks.length; - const role: AiTextRole = count === 1 ? 'subheading' : 'body'; - const anchor: AiAnchor = - count < 2 ? this.state().blocks[0]?.anchor || 'top-left' : 'bottom-left'; - this.patch({ blocks: [...this.state().blocks, newBlock(role, anchor)] }); - } - - public removeBlock(id: string) { - if (this.state().blocks.length < 2) return; - this.patch({ - blocks: this.state().blocks.filter((block) => block.id !== id), - }); - } - - public placeholderFor(role: AiTextRole) { - return role === 'headline' - ? 'SIGNAGE_MANAGER.AI_HEADLINE' - : role === 'subheading' - ? 'SIGNAGE_MANAGER.AI_SUBHEADING' - : 'SIGNAGE_MANAGER.AI_BODY_TEXT'; - } - - public anchorLabel(anchor: AiAnchor) { - return `SIGNAGE_MANAGER.AI_ANCHOR_${anchor - .toUpperCase() - .replace('-', '_')}`; - } - /** the composited image, at the artwork's native size */ public toBlob(): Promise { const canvas = this._canvas()?.nativeElement; @@ -456,6 +143,7 @@ export class AiLayerComponent { context.drawImage(artwork, 0, 0, width, height); const state = this.state(); + if (!state) return; this._drawBlocks(context, width, height, state); if (state.logo) this._drawLogo(context, width, height, state); } @@ -587,9 +275,7 @@ export class AiLayerComponent { } private _fontFamily() { - const brand = this.brand(); - const font = brand?.font; - const family = typeof font === 'string' ? font : font?.family; + const family = this._family(); return family ? `"${family}", system-ui, sans-serif` : 'system-ui, sans-serif'; diff --git a/apps/signage-manager/src/app/signage.service.ts b/apps/signage-manager/src/app/signage.service.ts index 986d4005b9..ea514e9190 100644 --- a/apps/signage-manager/src/app/signage.service.ts +++ b/apps/signage-manager/src/app/signage.service.ts @@ -3158,7 +3158,8 @@ export class SignageService { if (this._ai_modal_ref) return; const ref = this._dialog.open(AiImageModalComponent, { data: options, - panelClass: 'mobile-fullscreen', + panelClass: 'fullscreen-dialog', + autoFocus: false, }); this._ai_modal_ref = ref; try { diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 07deead977..dcd19130cb 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -70,6 +70,7 @@ "AI_POS_TOP": "Top", "AI_POS_TOP_LEFT": "Top left", "AI_POS_TOP_RIGHT": "Top right", + "AI_PREVIEW_EMPTY": "Your image will show here once it is made.", "AI_QUOTA_LEFT": "{{ count }} images left today", "AI_REFINE": "Ask for a change", "AI_REFINE_ACTION": "Refine", @@ -89,8 +90,10 @@ "AI_TEXT_PANEL": "Shade behind the text", "AI_TEXT_POSITION": "Text position", "AI_TEXT_SIZE": "Size", - "AI_VERSIONS": "Earlier versions", + "AI_VERSIONS": "Options and versions", + "AI_VERSION_LABEL": "Version {{ version }}, option {{ option }}", "AI_WORKING": "Making your images", + "AI_WORDS_AND_LOGO": "Words and logo", "ALL_DAY": "All day", "ALL_DAY_LOWER": "all day", "ALL_GROUPS": "All Groups", From 43a934cae7da258ca63a93f86c47d32a4d257f25 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 28 Aug 2026 13:12:13 +1000 Subject: [PATCH 14/27] feat(signage-manager): a light and a dark logo, and words you drag Two changes to the layer. **Both logos.** A brand has one for light backgrounds and one for dark, and only ever one turns up. The missing one is made from the one you have by flipping lightness and keeping hue and saturation, so black ink goes white and a brand colour stays recognisably itself. That is pixel work rather than a model call on purpose: a logo is the one part of a poster that has to come back identical, and a model asked to recolour a wordmark will quietly redraw the letters. Either slot can still be replaced with a real file. Which one gets drawn is read off the artwork under the logo, because the corner is different on every generated poster and picking by hand each time is asking the person to do the machine's job. There is an override for when it is wrong. The uploaded file lands in the slot its own ink says it belongs to, so one upload gives you both and neither has to be labelled by hand. **Dragging.** Nine anchors never put a headline quite in the gap the artwork left for it, which is the whole point of asking for a background with a clear area. Blocks now carry a position, dragged on the image itself, with arrow keys for whoever cannot use a mouse. The shade behind the text follows the block rather than banding the full width. Also fixes the Add a colour button, which was overflowing the branding page. --- .../src/app/ai/ai-image-modal.component.ts | 50 +- .../src/app/ai/ai-image.service.ts | 97 +++- .../src/app/ai/ai-layer-controls.component.ts | 107 ++-- .../src/app/ai/ai-layer.component.ts | 404 +++++++++++---- apps/signage-manager/src/app/ai/ai.types.ts | 41 +- .../src/app/branding/branding.component.ts | 465 ++++++++++++------ .../src/app/branding/logo-variant.ts | 159 ++++++ shared/assets/locale/en-AU.json | 18 +- 8 files changed, 999 insertions(+), 342 deletions(-) create mode 100644 apps/signage-manager/src/app/branding/logo-variant.ts diff --git a/apps/signage-manager/src/app/ai/ai-image-modal.component.ts b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts index bf2a9dc376..2a24dd7165 100644 --- a/apps/signage-manager/src/app/ai/ai-image-modal.component.ts +++ b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts @@ -76,9 +76,11 @@ interface Candidate { class="h-full w-full" [class.opacity-40]="state() === 'generating'" [image_url]="selected_object_url()" - [logo_url]="logo_object_url()" + [logo_on_light]="logo_on_light()" + [logo_on_dark]="logo_on_dark()" [brand]="brand()" [state]="layer_state()" + (changed)="layer_state.set($event)" > } @else if (source_url()) { ({ - blocks: [newTextBlock('headline', 'top-left')], + blocks: [newTextBlock('headline')], logo: false, logo_position: 'bottom-right', logo_scale: 0.14, + logo_choice: 'auto', }); /** the newest job; the rail walks back from here through its parents */ public readonly current_job_id = signal(''); public readonly selected = signal(null); public readonly selected_object_url = signal(''); - public readonly logo_object_url = signal(''); + public readonly logo_on_light = signal(''); + public readonly logo_on_dark = signal(''); public readonly uploading_logo = signal(false); public readonly brand = this._ai.brand_kit; @@ -537,7 +542,7 @@ export class AiImageModalComponent { public readonly has_overlay = computed(() => { const state = this.layer_state(); if (state.blocks.some((block) => block.text.trim())) return true; - return state.logo && !!this.logo_object_url(); + return state.logo && !!(this.logo_on_light() || this.logo_on_dark()); }); public versionLabel(candidate: Candidate) { @@ -621,11 +626,8 @@ export class AiImageModalComponent { public async uploadLogo(file: File) { this.uploading_logo.set(true); try { - const upload_id = await this._ai.uploadBrandLogo(file); - const url = await this._ai - .loadImage(`/api/engine/v2/uploads/${upload_id}/url`) - .catch(() => ''); - this.logo_object_url.set(url); + await this._ai.uploadBrandLogo(file); + await this._loadBrandLogos(); this.layer_state.set({ ...this.layer_state(), logo: true }); notifySuccess(i18n('SIGNAGE_MANAGER.AI_LOGO_SAVED')); } catch (error) { @@ -723,25 +725,33 @@ export class AiImageModalComponent { (candidate) => candidate.job_id === id, ); if (newest.length) this.select(newest[0]); - this._loadBrandLogo(); + this._loadBrandLogos(); this.state.set('review'); }; check(); } - /** the saved logo, so the toggle in the sidebar has something to show */ - private async _loadBrandLogo() { - const logo_id = this.brand()?.logo_upload_id; - if (!logo_id || this.logo_object_url()) return; - const url = await this._ai - .loadImage(`/api/engine/v2/uploads/${logo_id}/url`) - .catch(() => ''); - this.logo_object_url.set(url); - if (url && this.include_logo()) { + /** both saved logos, so the toggle in the sidebar has something to show */ + private async _loadBrandLogos() { + const brand = this.brand(); + const [on_light, on_dark] = await Promise.all([ + this._readUpload(brand?.logo_upload_id), + this._readUpload(brand?.logo_dark_upload_id), + ]); + this.logo_on_light.set(on_light); + this.logo_on_dark.set(on_dark); + if ((on_light || on_dark) && this.include_logo()) { this.layer_state.set({ ...this.layer_state(), logo: true }); } } + private _readUpload(id?: string) { + if (!id) return Promise.resolve(''); + return this._ai + .loadImage(`/api/engine/v2/uploads/${encodeURIComponent(id)}/url`) + .catch(() => ''); + } + private _name() { const brief = (this.brief() || this._data.source_name || '').trim(); const words = brief.split(/\s+/).slice(0, 6).join(' '); diff --git a/apps/signage-manager/src/app/ai/ai-image.service.ts b/apps/signage-manager/src/app/ai/ai-image.service.ts index 144eaac1cd..51b3b757b4 100644 --- a/apps/signage-manager/src/app/ai/ai-image.service.ts +++ b/apps/signage-manager/src/app/ai/ai-image.service.ts @@ -9,6 +9,7 @@ import { import { loadAuthenticatedImage } from '@placeos/components'; import { showMetadata, updateMetadata } from '@placeos/ts-client'; +import { flipLightness, inkIsLight } from '../branding/logo-variant'; import { cancelSignageAIJob, claimSignageAIImage, @@ -24,10 +25,18 @@ import { AiEditRequest, AiGenerateRequest, AiJob, + AiLogoSlot, } from './ai.types'; const FINAL_STATES = ['done', 'failed', 'cancelled']; +/** the brand kit key each slot is stored under */ +export function logoKey( + slot: AiLogoSlot, +): 'logo_upload_id' | 'logo_dark_upload_id' { + return slot === 'on_light' ? 'logo_upload_id' : 'logo_dark_upload_id'; +} + /** how long a single long poll holds the connection open, server capped at 25 */ const POLL_WAIT = 25; @@ -108,15 +117,85 @@ export class AiImageService extends AsyncHandler { * the same brand kit metadata as the palette and the tone. Set once here * and every later poster picks it up, rather than being re-attached each * time. + * + * Which slot the file lands in is read off its own ink: dark ink is for + * light backgrounds and light ink is for dark ones. The other version is + * made from it, so a poster of either kind has a logo that reads, from one + * upload. Either can be replaced with a real file later. */ - public async uploadBrandLogo(file: File): Promise { + public async uploadBrandLogo(file: File): Promise { + const slot: AiLogoSlot = (await inkIsLight(file).catch(() => false)) + ? 'on_dark' + : 'on_light'; + return this.replaceBrandLogo(slot, file, true); + } + + /** + * Put a file in one of the two slots. `derive_other` fills the empty + * counterpart from it; an explicit upload into one slot leaves the other + * alone, since someone supplying their own file has said what they want. + */ + public async replaceBrandLogo( + slot: AiLogoSlot, + file: File, + derive_other = false, + ): Promise { const upload_id = await this._uploads.uploadFileToCompletion(file); - await this.saveBrandKit({ logo_upload_id: upload_id }); + const changes: Partial = { [logoKey(slot)]: upload_id }; + + const other = slot === 'on_light' ? 'on_dark' : 'on_light'; + const other_id = this.brand_kit()?.[logoKey(other)]; + const derived = this.brand_kit()?.logo_derived; + // a derived counterpart is a guess at this file, so it is remade rather + // than left pointing at the version of a logo that is no longer here + if (derive_other && (!other_id || derived === other)) { + const flipped = await this._flip(file, other).catch(() => null); + if (flipped) { + changes[logoKey(other)] = flipped; + changes.logo_derived = other; + } + } else if (derived === slot) { + changes.logo_derived = undefined; + } + + const kit = await this.saveBrandKit(changes); // the capability is read once at start up; keep it honest for this session this.capabilities.update((current) => current ? { ...current, logo_layer: true } : current, ); - return upload_id; + return kit; + } + + /** make one slot from the other, on request rather than on upload */ + public async deriveBrandLogo(target: AiLogoSlot): Promise { + const source_id = + this.brand_kit()?.[ + logoKey(target === 'on_light' ? 'on_dark' : 'on_light') + ]; + if (!source_id) throw new Error(i18n('SIGNAGE_MANAGER.AI_NO_LOGO_YET')); + const url = await this.loadImage( + `/api/engine/v2/uploads/${encodeURIComponent(source_id)}/url`, + ); + const upload_id = await this._flip(url, target); + return this.saveBrandKit({ + [logoKey(target)]: upload_id, + logo_derived: target, + }); + } + + private async _flip( + source: File | string, + target: AiLogoSlot, + ): Promise { + const stem = + typeof source === 'string' + ? 'logo' + : source.name.replace(/\.[^.]+$/, ''); + const file = await flipLightness( + source, + `${stem}-${target.replace('_', '-')}.png`, + ); + return this._uploads.uploadFileToCompletion(file); } /** @@ -126,7 +205,9 @@ export class AiImageService extends AsyncHandler { * each write their own part without clearing the other's, and so anything * set by hand outside this app survives. */ - public async saveBrandKit(changes: Partial): Promise { + public async saveBrandKit( + changes: Partial, + ): Promise { if (!this._org_zone) { throw new Error(i18n('SIGNAGE_MANAGER.AI_NO_ORG_ZONE')); } @@ -171,7 +252,9 @@ export class AiImageService extends AsyncHandler { () => [] as AiJob[], ); this._merge(jobs); - jobs.filter((job) => !isFinal(job)).forEach((job) => this.watch(job.id)); + jobs.filter((job) => !isFinal(job)).forEach((job) => + this.watch(job.id), + ); return jobs; } @@ -202,7 +285,9 @@ export class AiImageService extends AsyncHandler { } public claim(id: string, upload_id: string, item_id: string) { - return claimSignageAIImage(id, { upload_id, item_id }).catch(() => null); + return claimSignageAIImage(id, { upload_id, item_id }).catch( + () => null, + ); } public job(id: string) { diff --git a/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts b/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts index 360e83cedb..8e4c0cc14e 100644 --- a/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts +++ b/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts @@ -8,21 +8,20 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { IconComponent, TranslatePipe } from '@placeos/components'; -import { ANCHORS } from './ai-layer.component'; -import { - AiAnchor, - AiBrandKit, - AiLayerState, - AiTextBlock, - AiTextRole, -} from './ai.types'; +import { AiBrandKit, AiLayerState, AiTextBlock, AiTextRole } from './ai.types'; -export function newTextBlock(role: AiTextRole, anchor: AiAnchor): AiTextBlock { +/** first block sits under the top left margin, each next one below it */ +const FIRST_Y = 0.06; +const BLOCK_GAP = 0.18; + +export function newTextBlock(role: AiTextRole, index = 0): AiTextBlock { return { id: `${Date.now()}-${Math.round(Math.random() * 1e6)}`, text: '', role, - anchor, + x: 0.06, + y: FIRST_Y + BLOCK_GAP * index, + align: 'left', colour: '#FFFFFF', panel: true, }; @@ -31,14 +30,17 @@ export function newTextBlock(role: AiTextRole, anchor: AiAnchor): AiTextBlock { /** * The words and the logo, as a sidebar panel beside the preview. * - * Split from the canvas so the preview can hold the main pane: the person - * writing a headline wants to see it at size while they type, not in a strip - * above a stack of form fields. + * Position is not here: the words are dragged on the image itself, which is the + * only way to put a headline in the gap the artwork actually left for it. */ @Component({ selector: 'ai-layer-controls', template: `
+

+ {{ 'SIGNAGE_MANAGER.AI_TEXT_DRAG_HINT' | translate }} +

+ @for (block of state().blocks; track block.id) {
- @for (anchor of anchors; track anchor) { - {{ - anchorLabel(anchor) | translate - }} - } + {{ + 'SIGNAGE_MANAGER.AI_ALIGN_LEFT' | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ALIGN_CENTRE' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ALIGN_RIGHT' | translate + }}
@@ -166,7 +172,7 @@ export function newTextBlock(role: AiTextRole, anchor: AiAnchor): AiTextBlock {
- @if (!logo_url()) { + @if (!has_logo()) { {{ 'SIGNAGE_MANAGER.AI_NO_LOGO_YET' | translate }} @@ -224,6 +230,39 @@ export function newTextBlock(role: AiTextRole, anchor: AiAnchor): AiTextBlock { }} + + + @if (has_both_logos()) { + + {{ + 'SIGNAGE_MANAGER.AI_LOGO_VERSION' + | translate + }} + + {{ + 'SIGNAGE_MANAGER.AI_LOGO_AUTO' + | translate + }} + {{ + 'SIGNAGE_MANAGER.BRAND_LOGO_ON_LIGHT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.BRAND_LOGO_ON_DARK' + | translate + }} + + + } } } (); - public readonly logo_url = input(''); + public readonly logo_on_light = input(''); + public readonly logo_on_dark = input(''); public readonly brand = input(null); public readonly uploading = input(false); public readonly changed = output(); public readonly logoPicked = output(); - public readonly anchors = ANCHORS; + public readonly has_logo = computed( + () => !!(this.logo_on_light() || this.logo_on_dark()), + ); + public readonly has_both_logos = computed( + () => !!this.logo_on_light() && !!this.logo_on_dark(), + ); public readonly palette = computed(() => { const colours = Object.values(this.brand()?.palette || {}); @@ -284,9 +329,7 @@ export class AiLayerControlsComponent { public addBlock() { const blocks = this.state().blocks; const role: AiTextRole = blocks.length === 1 ? 'subheading' : 'body'; - const anchor: AiAnchor = - blocks.length < 2 ? blocks[0]?.anchor || 'top-left' : 'bottom-left'; - this.patch({ blocks: [...blocks, newTextBlock(role, anchor)] }); + this.patch({ blocks: [...blocks, newTextBlock(role, blocks.length)] }); } public removeBlock(id: string) { @@ -310,10 +353,4 @@ export class AiLayerControlsComponent { ? 'SIGNAGE_MANAGER.AI_SUBHEADING' : 'SIGNAGE_MANAGER.AI_BODY_TEXT'; } - - public anchorLabel(anchor: AiAnchor) { - return `SIGNAGE_MANAGER.AI_ANCHOR_${anchor - .toUpperCase() - .replace('-', '_')}`; - } } diff --git a/apps/signage-manager/src/app/ai/ai-layer.component.ts b/apps/signage-manager/src/app/ai/ai-layer.component.ts index d67703aa11..9925fca9db 100644 --- a/apps/signage-manager/src/app/ai/ai-layer.component.ts +++ b/apps/signage-manager/src/app/ai/ai-layer.component.ts @@ -4,11 +4,19 @@ import { effect, ElementRef, input, + output, + signal, viewChild, } from '@angular/core'; import { ensureBrandFont } from '../branding/brand-fonts'; -import { AiAnchor, AiBrandKit, AiLayerState, AiTextRole } from './ai.types'; +import { + AiBrandKit, + AiLayerState, + AiLogoSlot, + AiTextBlock, + AiTextRole, +} from './ai.types'; /** share of the artwork's height each role is drawn at */ const ROLE_SIZE: Record = { @@ -17,17 +25,16 @@ const ROLE_SIZE: Record = { body: 0.038, }; -export const ANCHORS: AiAnchor[] = [ - 'top-left', - 'top-centre', - 'top-right', - 'centre-left', - 'centre', - 'centre-right', - 'bottom-left', - 'bottom-centre', - 'bottom-right', -]; +/** how far an arrow key moves a block, as a share of the artwork */ +const NUDGE = 0.005; +const NUDGE_FAST = 0.02; + +interface Box { + left: number; + top: number; + width: number; + height: number; +} /** * The finished poster: the artwork with the words and the logo drawn over it, @@ -37,16 +44,26 @@ export const ANCHORS: AiAnchor[] = [ * because no image model spells reliably at small sizes and because a logo the * model drew is the one part of a poster a trademark claim would land on. * - * This renders only. The controls live beside it in the modal's sidebar, so the - * preview can hold the whole of the main pane. + * Words are placed by dragging them. Nine anchors were quicker to build and + * never put a headline quite where the artwork left room for it, which is the + * whole point of generating a background with a clear area. */ @Component({ selector: 'ai-layer', template: ` `, styles: [ @@ -58,19 +75,41 @@ export const ANCHORS: AiAnchor[] = [ min-height: 0; min-width: 0; } + canvas:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } `, ], }) export class AiLayerComponent { public readonly image_url = input.required(); - public readonly logo_url = input(''); + /** the logo to use on a light background, so dark ink */ + public readonly logo_on_light = input(''); + /** the logo to use on a dark background, so light ink */ + public readonly logo_on_dark = input(''); public readonly brand = input(null); public readonly state = input.required(); + /** a block was dragged or nudged */ + public readonly changed = output(); + + public readonly hover_id = signal(''); + public readonly drag_id = signal(''); + public readonly selected_id = signal(''); + private readonly _canvas = viewChild>('canvas'); private _artwork: HTMLImageElement | null = null; - private _logo: HTMLImageElement | null = null; + private readonly _logos: Record = { + on_light: null, + on_dark: null, + }; + + /** where each block ended up last draw, in artwork pixels, for hit testing */ + private _boxes = new Map(); + /** pointer offset inside the block when the drag started */ + private _grab = { x: 0, y: 0 }; private readonly _family = computed(() => { const font = this.brand()?.font; @@ -83,8 +122,12 @@ export class AiLayerComponent { if (url) this._loadArtwork(url); }); effect(() => { - const url = this.logo_url(); - if (url) this._loadLogo(url); + const url = this.logo_on_light(); + if (url) this._loadLogo('on_light', url); + }); + effect(() => { + const url = this.logo_on_dark(); + if (url) this._loadLogo('on_dark', url); }); effect(() => { // a face has to be in the document before a canvas can draw with it @@ -93,6 +136,8 @@ export class AiLayerComponent { }); effect(() => { this.state(); + this.hover_id(); + this.drag_id(); this._draw(); }); } @@ -101,11 +146,132 @@ export class AiLayerComponent { public toBlob(): Promise { const canvas = this._canvas()?.nativeElement; if (!canvas) return Promise.resolve(null); + // the outline is an editing aid, not part of the poster + const hovered = this.hover_id(); + this.hover_id.set(''); + this._draw(); return new Promise((resolve) => - canvas.toBlob((blob) => resolve(blob), 'image/png'), + canvas.toBlob((blob) => { + this.hover_id.set(hovered); + resolve(blob); + }, 'image/png'), ); } + public onPointerDown(event: PointerEvent) { + const point = this._toArtwork(event); + if (!point) return; + const block = this._blockAt(point.x, point.y); + this.selected_id.set(block?.id || ''); + if (!block) return; + const box = this._boxes.get(block.id); + if (!box) return; + this._grab = { x: point.x - box.left, y: point.y - box.top }; + this.drag_id.set(block.id); + this._canvas()?.nativeElement.setPointerCapture(event.pointerId); + event.preventDefault(); + } + + public onPointerMove(event: PointerEvent) { + const point = this._toArtwork(event); + if (!point) return; + + const dragging = this.drag_id(); + if (!dragging) { + this.hover_id.set(this._blockAt(point.x, point.y)?.id || ''); + return; + } + + const canvas = this._canvas()?.nativeElement; + const box = this._boxes.get(dragging); + if (!canvas || !box) return; + this._move( + dragging, + (point.x - this._grab.x) / canvas.width, + (point.y - this._grab.y) / canvas.height, + box, + ); + } + + public onPointerUp(event: PointerEvent) { + if (!this.drag_id()) return; + this._canvas()?.nativeElement.releasePointerCapture(event.pointerId); + this.drag_id.set(''); + } + + public onPointerLeave() { + if (!this.drag_id()) this.hover_id.set(''); + } + + /** the same moves without a mouse, for whoever cannot use one */ + public onKeyDown(event: KeyboardEvent) { + const id = this.selected_id() || this.state().blocks[0]?.id; + const box = id ? this._boxes.get(id) : null; + const block = this.state().blocks.find((item) => item.id === id); + if (!box || !block) return; + + const step = event.shiftKey ? NUDGE_FAST : NUDGE; + let x = block.x; + let y = block.y; + if (event.key === 'ArrowLeft') x -= step; + else if (event.key === 'ArrowRight') x += step; + else if (event.key === 'ArrowUp') y -= step; + else if (event.key === 'ArrowDown') y += step; + else return; + + event.preventDefault(); + this.selected_id.set(id); + this._move(id, x, y, box); + } + + /** keep the whole block on the artwork, then write the new position out */ + private _move(id: string, x: number, y: number, box: Box) { + const canvas = this._canvas()?.nativeElement; + if (!canvas) return; + const max_x = Math.max(0, 1 - box.width / canvas.width); + const max_y = Math.max(0, 1 - box.height / canvas.height); + const next = { + x: Math.min(Math.max(x, 0), max_x), + y: Math.min(Math.max(y, 0), max_y), + }; + const state = this.state(); + this.changed.emit({ + ...state, + blocks: state.blocks.map((block) => + block.id === id ? { ...block, ...next } : block, + ), + }); + } + + private _toArtwork(event: PointerEvent) { + const canvas = this._canvas()?.nativeElement; + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + return { + x: ((event.clientX - rect.left) / rect.width) * canvas.width, + y: ((event.clientY - rect.top) / rect.height) * canvas.height, + }; + } + + /** last drawn wins, so the block on top is the one you grab */ + private _blockAt(x: number, y: number): AiTextBlock | null { + const blocks = this.state().blocks; + for (let index = blocks.length - 1; index >= 0; index--) { + const box = this._boxes.get(blocks[index].id); + if (!box) continue; + if ( + x >= box.left && + x <= box.left + box.width && + y >= box.top && + y <= box.top + box.height + ) { + return blocks[index]; + } + } + return null; + } + private _loadArtwork(url: string) { const image = new Image(); image.crossOrigin = 'anonymous'; @@ -121,11 +287,11 @@ export class AiLayerComponent { image.src = url; } - private _loadLogo(url: string) { + private _loadLogo(slot: AiLogoSlot, url: string) { const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => { - this._logo = image; + this._logos[slot] = image; this._draw(); }; image.src = url; @@ -144,110 +310,105 @@ export class AiLayerComponent { const state = this.state(); if (!state) return; - this._drawBlocks(context, width, height, state); if (state.logo) this._drawLogo(context, width, height, state); + this._drawBlocks(context, width, height, state); } - /** blocks sharing an anchor are laid out as one stack, in order */ private _drawBlocks( context: CanvasRenderingContext2D, width: number, height: number, state: AiLayerState, ) { - const margin = Math.round(width * 0.06); + this._boxes.clear(); const family = this._fontFamily(); - const max_width = width - margin * 2; + const wrap_at = width * 0.88; - for (const anchor of ANCHORS) { - const blocks = state.blocks.filter( - (block) => block.anchor === anchor && block.text.trim(), - ); - if (!blocks.length) continue; - - // measure the whole stack first so it can be placed as one unit - const lines: { - text: string; - size: number; - weight: string; - colour: string; - panel: boolean; - }[] = []; - for (const block of blocks) { - const size = Math.round(height * ROLE_SIZE[block.role]); - const weight = block.role === 'headline' ? '700' : '400'; - context.font = `${weight} ${size}px ${family}`; - for (const text of this._wrap( - context, - block.text.trim(), - max_width, - )) { - lines.push({ - text, - size, - weight, - colour: block.colour, - panel: block.panel, - }); - } - } + for (const block of state.blocks) { + const text = block.text.trim(); + if (!text) continue; + + const size = Math.round(height * ROLE_SIZE[block.role]); + const weight = block.role === 'headline' ? '700' : '400'; + context.font = `${weight} ${size}px ${family}`; + const lines = this._wrap(context, text, wrap_at); + const spacing = Math.round(size * 0.22); + const line_height = size * 1.2; + const box: Box = { + left: block.x * width, + top: block.y * height, + width: Math.max( + ...lines.map((line) => context.measureText(line).width), + ), + height: + lines.length * line_height + spacing * (lines.length - 1), + }; + this._boxes.set(block.id, box); - const spacing = Math.round(height * 0.02); - const block_height = - lines.reduce((total, line) => total + line.size, 0) + - spacing * Math.max(0, lines.length - 1); - - let top = margin; - if (anchor.startsWith('centre')) top = (height - block_height) / 2; - if (anchor.startsWith('bottom')) - top = height - block_height - margin; - - const horizontal = anchor.endsWith('right') - ? 'right' - : anchor.endsWith('left') - ? 'left' - : 'center'; - let x = margin; - if (horizontal === 'center') x = width / 2; - if (horizontal === 'right') x = width - margin; - - if (lines.some((line) => line.panel)) { - const pad = Math.round(height * 0.022); - context.fillStyle = this._panelColour(lines[0].colour); + if (block.panel) { + const pad = Math.round(size * 0.35); + context.fillStyle = this._panelColour(block.colour); context.fillRect( - 0, - Math.max(0, top - pad), - width, - block_height + pad * 2, + box.left - pad, + box.top - pad * 0.6, + box.width + pad * 2, + box.height + pad * 1.2, ); } - context.textAlign = horizontal as CanvasTextAlign; + context.textAlign = ( + block.align === 'centre' ? 'center' : block.align + ) as CanvasTextAlign; context.textBaseline = 'top'; - let y = top; + const x = + block.align === 'left' + ? box.left + : block.align === 'right' + ? box.left + box.width + : box.left + box.width / 2; + context.fillStyle = block.colour; + let y = box.top; for (const line of lines) { - context.font = `${line.weight} ${line.size}px ${family}`; - context.fillStyle = line.colour; - context.fillText(line.text, x, y); - y += line.size + spacing; + context.fillText(line, x, y + (line_height - size) / 2); + y += line_height + spacing; + } + + if (this.hover_id() === block.id || this.drag_id() === block.id) { + this._outline(context, box, Math.round(size * 0.35)); } } } + /** shows what you are about to pick up; never drawn into the saved file */ + private _outline(context: CanvasRenderingContext2D, box: Box, pad: number) { + context.save(); + context.strokeStyle = 'rgba(255, 255, 255, 0.9)'; + context.lineWidth = Math.max(2, box.height * 0.02); + context.setLineDash([context.lineWidth * 3, context.lineWidth * 3]); + context.strokeRect( + box.left - pad, + box.top - pad * 0.6, + box.width + pad * 2, + box.height + pad * 1.2, + ); + context.restore(); + } + private _drawLogo( context: CanvasRenderingContext2D, width: number, height: number, state: AiLayerState, ) { - const logo = this._logo; - if (!logo) return; - const margin = Math.round(width * 0.04); const target_width = Math.round(width * state.logo_scale); - const scale = target_width / logo.naturalWidth; - const target_height = Math.round(logo.naturalHeight * scale); + // measured first, because which version to draw depends on what is + // behind it, and that is only known once the box is known + const sample = this._logos.on_light || this._logos.on_dark; + if (!sample) return; + const scale = target_width / (sample.naturalWidth || target_width); + const target_height = Math.round(sample.naturalHeight * scale); const left = state.logo_position.endsWith('left') ? margin : width - target_width - margin; @@ -255,9 +416,62 @@ export class AiLayerComponent { ? margin : height - target_height - margin; + const logo = this._logoFor(state, context, { + left, + top, + width: target_width, + height: target_height, + }); + if (!logo) return; context.drawImage(logo, left, top, target_width, target_height); } + /** + * On auto, the artwork under the logo decides: a dark corner takes the + * light version and a light corner takes the dark one. Posters are + * generated, so the corner is different every time and asking the user to + * pick each time is asking them to do the machine's job. + */ + private _logoFor( + state: AiLayerState, + context: CanvasRenderingContext2D, + box: Box, + ) { + const choice = + state.logo_choice === 'auto' + ? this._backgroundIsDark(context, box) + ? 'on_dark' + : 'on_light' + : state.logo_choice; + return ( + this._logos[choice] || this._logos.on_light || this._logos.on_dark + ); + } + + private _backgroundIsDark(context: CanvasRenderingContext2D, box: Box) { + try { + const { data } = context.getImageData( + Math.max(0, Math.round(box.left)), + Math.max(0, Math.round(box.top)), + Math.max(1, Math.round(box.width)), + Math.max(1, Math.round(box.height)), + ); + let total = 0; + let count = 0; + // every fourth pixel is plenty for an average and keeps this cheap + for (let index = 0; index < data.length; index += 16) { + total += + 0.299 * data[index] + + 0.587 * data[index + 1] + + 0.114 * data[index + 2]; + count++; + } + return count ? total / count < 140 : false; + } catch { + return false; + } + } + /** a translucent band behind the words, tinted away from the text colour */ private _panelColour(text_colour: string) { return this._isLight(text_colour) diff --git a/apps/signage-manager/src/app/ai/ai.types.ts b/apps/signage-manager/src/app/ai/ai.types.ts index f21b90fb29..717378ec3e 100644 --- a/apps/signage-manager/src/app/ai/ai.types.ts +++ b/apps/signage-manager/src/app/ai/ai.types.ts @@ -48,12 +48,7 @@ export interface AiJobImage { item_id?: string; } -export type AiJobState = - | 'queued' - | 'running' - | 'done' - | 'failed' - | 'cancelled'; +export type AiJobState = 'queued' | 'running' | 'done' | 'failed' | 'cancelled'; export interface AiJob { id: string; @@ -101,31 +96,40 @@ export interface AiBrandKit { organisation?: string; palette?: Record; tone?: string; + /** + * The logo to put on a light background, so dark ink. Keeps its original + * name because rest-api reads this key when it sends the logo to the model + * as a reference. + */ logo_upload_id?: string; + /** the logo to put on a dark background, so light ink */ + logo_dark_upload_id?: string; + /** which of the two was made by flipping the other, rather than uploaded */ + logo_derived?: AiLogoSlot; never_include?: string[]; font?: { url?: string; family?: string } | string; } -/** where a block or the logo sits. Blocks sharing an anchor stack in order. */ -export type AiAnchor = - | 'top-left' - | 'top-centre' - | 'top-right' - | 'centre-left' - | 'centre' - | 'centre-right' - | 'bottom-left' - | 'bottom-centre' - | 'bottom-right'; +/** which background a logo file is meant to sit on */ +export type AiLogoSlot = 'on_light' | 'on_dark'; + +/** on_light and on_dark pick a file; auto reads the artwork behind the logo */ +export type AiLogoChoice = 'auto' | AiLogoSlot; /** drives the size the text is drawn at */ export type AiTextRole = 'headline' | 'subheading' | 'body'; +/** how the lines inside a block line up with each other */ +export type AiTextAlign = 'left' | 'centre' | 'right'; + export interface AiTextBlock { id: string; text: string; role: AiTextRole; - anchor: AiAnchor; + /** top left of the block, as a fraction of the artwork's width and height */ + x: number; + y: number; + align: AiTextAlign; colour: string; panel: boolean; } @@ -136,4 +140,5 @@ export interface AiLayerState { logo: boolean; logo_position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; logo_scale: number; + logo_choice: AiLogoChoice; } diff --git a/apps/signage-manager/src/app/branding/branding.component.ts b/apps/signage-manager/src/app/branding/branding.component.ts index 6dff039d5a..c43eb05f27 100644 --- a/apps/signage-manager/src/app/branding/branding.component.ts +++ b/apps/signage-manager/src/app/branding/branding.component.ts @@ -1,4 +1,12 @@ -import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { + Component, + computed, + ElementRef, + inject, + OnInit, + signal, + viewChild, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -13,6 +21,7 @@ import { } from '@placeos/components'; import { AiImageService } from '../ai/ai-image.service'; +import { AiLogoSlot } from '../ai/ai.types'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; import { BRAND_FONTS, ensureBrandFont } from './brand-fonts'; @@ -28,161 +37,230 @@ const COLOUR_NAMES = ['primary', 'secondary', 'accent'];
-

- {{ 'SIGNAGE_MANAGER.BRAND_HEADER' | translate }} -

-

- {{ 'SIGNAGE_MANAGER.BRAND_HINT' | translate }} -

- - - - - - -
- - @if (colours().length < 3) { - - } -
-
- @for (colour of colours(); track $index) { -
- - +

+ {{ 'SIGNAGE_MANAGER.BRAND_HEADER' | translate }} +

+

+ {{ 'SIGNAGE_MANAGER.BRAND_HINT' | translate }} +

+ + + + + + + +
+ @for (colour of colours(); track $index) { +
- - {{ - colourName($index) - }} + + + + {{ colourName($index) }} + +
+ } + @if (colours().length < 3) { -
- } -
- - - - - @for (option of fonts; track option.family) { - {{ - option.family - ? option.label - : (option.label | translate) - }} } - - -

- {{ 'SIGNAGE_MANAGER.BRAND_FONT_SAMPLE' | translate }} -

- - -
- @if (logo_id()) { - - } @else { - {{ - 'SIGNAGE_MANAGER.AI_NO_LOGO_YET' | translate - }} - } - - -
- -
- - @if (!enabled()) { - {{ - 'SIGNAGE_MANAGER.BRAND_AI_OFF' | translate - }} - } -
+ {{ 'SIGNAGE_MANAGER.BRAND_FONT_SAMPLE' | translate }} +

+ + +

+ {{ 'SIGNAGE_MANAGER.BRAND_LOGO_HINT' | translate }} +

+
+ @for (slot of slots; track slot.id) { +
+
+ {{ + slot.label | translate + }} + @if (derived() === slot.id) { + {{ + 'SIGNAGE_MANAGER.BRAND_LOGO_DERIVED' + | translate + }} + } +
+ + +
+ @if (logoId(slot.id)) { + + } @else { + {{ + 'SIGNAGE_MANAGER.AI_NO_LOGO_YET' + | translate + }} + } +
+ +
+ + @if ( + !logoId(slot.id) && logoId(other(slot.id)) + ) { + + } +
+
+ } + +
+ +
+ + @if (!enabled()) { + {{ + 'SIGNAGE_MANAGER.BRAND_AI_OFF' | translate + }} + } +
@@ -210,18 +288,40 @@ export class BrandingComponent implements OnInit { public readonly organisation = signal(''); public readonly colours = signal(['#0E6E52']); public readonly font = signal(''); - public readonly logo_id = signal(''); public readonly saving = signal(false); - public readonly uploading = signal(false); - public readonly logo_url = computed(() => { - const id = this.logo_id(); - return id ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` : ''; + /** which slot is mid upload or mid conversion, so only one runs at a time */ + public readonly busy = signal(''); + public readonly logos = signal>({ + on_light: '', + on_dark: '', }); + public readonly derived = signal(''); + + public readonly slots = [ + { + id: 'on_light' as AiLogoSlot, + label: 'SIGNAGE_MANAGER.BRAND_LOGO_ON_LIGHT', + ground: '#FFFFFF', + faded: 'rgba(0, 0, 0, 0.45)', + }, + { + id: 'on_dark' as AiLogoSlot, + label: 'SIGNAGE_MANAGER.BRAND_LOGO_ON_DARK', + ground: '#1B2420', + faded: 'rgba(255, 255, 255, 0.55)', + }, + ]; + + private readonly _logo_input = + viewChild>('logo_input'); + private _target: AiLogoSlot = 'on_light'; public readonly font_stack = computed(() => { const family = this.font(); - return family ? `"${family}", system-ui, sans-serif` : 'system-ui, sans-serif'; + return family + ? `"${family}", system-ui, sans-serif` + : 'system-ui, sans-serif'; }); public async ngOnInit() { @@ -260,20 +360,59 @@ export class BrandingComponent implements OnInit { ensureBrandFont(this.font()); } + public logoId(slot: AiLogoSlot) { + return this.logos()[slot]; + } + + public logoUrl(slot: AiLogoSlot) { + const id = this.logos()[slot]; + return id ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` : ''; + } + + public other(slot: AiLogoSlot): AiLogoSlot { + return slot === 'on_light' ? 'on_dark' : 'on_light'; + } + + public pick(slot: AiLogoSlot) { + this._target = slot; + this._logo_input()?.nativeElement.click(); + } + public async pickLogo(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; - this.uploading.set(true); + const slot = this._target; + this.busy.set(slot); try { - const id = await this._ai.uploadBrandLogo(file); - this.logo_id.set(id); + // filling the empty counterpart is only right when there is nothing + // there yet; replacing one slot leaves a real file in the other one + const kit = await this._ai.replaceBrandLogo( + slot, + file, + !this.logoId(this.other(slot)), + ); + this._applyLogos(kit); notifySuccess(i18n('SIGNAGE_MANAGER.AI_LOGO_SAVED')); } catch (error) { notifyError(this._message(error)); } finally { - this.uploading.set(false); + this.busy.set(''); + } + } + + /** make this slot from the other one */ + public async derive(slot: AiLogoSlot) { + this.busy.set(slot); + try { + const kit = await this._ai.deriveBrandLogo(slot); + this._applyLogos(kit); + notifySuccess(i18n('SIGNAGE_MANAGER.BRAND_LOGO_MADE')); + } catch (error) { + notifyError(this._message(error)); + } finally { + this.busy.set(''); } } @@ -310,7 +449,15 @@ export class BrandingComponent implements OnInit { if (ordered.length) this.colours.set(ordered.slice(0, 3)); const font = brand.font; this.font.set(typeof font === 'string' ? font : font?.family || ''); - this.logo_id.set(brand.logo_upload_id || ''); + this._applyLogos(brand); + } + + private _applyLogos(brand: any) { + this.logos.set({ + on_light: brand.logo_upload_id || '', + on_dark: brand.logo_dark_upload_id || '', + }); + this.derived.set(brand.logo_derived || ''); } private _message(error: any) { diff --git a/apps/signage-manager/src/app/branding/logo-variant.ts b/apps/signage-manager/src/app/branding/logo-variant.ts new file mode 100644 index 0000000000..736e6def86 --- /dev/null +++ b/apps/signage-manager/src/app/branding/logo-variant.ts @@ -0,0 +1,159 @@ +/** + * Making the other version of a logo. + * + * A brand almost always has two: dark ink for light backgrounds, light ink for + * dark ones. Only one usually turns up, and a poster is as likely to be one as + * the other, so the missing one is made here rather than asked for. + * + * This is pixel work, not a model call. A logo is the one part of a poster that + * has to come back identical, and an image model asked to recolour a wordmark + * will quietly redraw the letters. Flipping lightness while keeping hue and + * saturation turns black ink white and leaves a brand colour recognisably + * itself, exactly, every time, in a few milliseconds and for nothing. + */ + +/** anything past this reads as light ink */ +const LIGHT_INK = 0.55; + +/** enough to judge colour and to redraw from; logos are not photographs */ +const MAX_EDGE = 1024; + +export async function loadBitmap( + source: Blob | string, +): Promise { + const url = + typeof source === 'string' ? source : URL.createObjectURL(source); + try { + const image = new Image(); + image.crossOrigin = 'anonymous'; + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error('logo could not be read')); + image.src = url; + }); + return image; + } finally { + if (typeof source !== 'string') { + // the element holds its own copy once decoded + setTimeout(() => URL.revokeObjectURL(url), 0); + } + } +} + +function toCanvas(image: HTMLImageElement) { + // an SVG with no intrinsic size decodes as 0x0 or 300x150; give it a box + const natural_width = image.naturalWidth || 512; + const natural_height = image.naturalHeight || 512; + const scale = Math.min( + 1, + MAX_EDGE / Math.max(natural_width, natural_height), + ); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(natural_width * scale)); + canvas.height = Math.max(1, Math.round(natural_height * scale)); + const context = canvas.getContext('2d'); + if (!context) throw new Error('logo could not be read'); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + return { canvas, context }; +} + +/** + * Whether the logo's own ink is light. + * + * Weighted by how opaque each pixel is, so a mark's anti-aliased edges and any + * transparent surround do not drag the answer toward the middle. + */ +export async function inkIsLight(source: Blob | string): Promise { + const image = await loadBitmap(source); + const { canvas, context } = toCanvas(image); + const { data } = context.getImageData(0, 0, canvas.width, canvas.height); + let weight = 0; + let total = 0; + for (let index = 0; index < data.length; index += 4) { + const alpha = data[index + 3] / 255; + if (alpha < 0.1) continue; + const lightness = + (0.299 * data[index] + + 0.587 * data[index + 1] + + 0.114 * data[index + 2]) / + 255; + total += lightness * alpha; + weight += alpha; + } + if (!weight) return false; + return total / weight > LIGHT_INK; +} + +/** the same logo with its lightness flipped, hue and saturation left alone */ +export async function flipLightness( + source: Blob | string, + name: string, +): Promise { + const image = await loadBitmap(source); + const { canvas, context } = toCanvas(image); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + const { data } = pixels; + for (let index = 0; index < data.length; index += 4) { + if (data[index + 3] === 0) continue; + const [hue, saturation, lightness] = toHsl( + data[index], + data[index + 1], + data[index + 2], + ); + const [red, green, blue] = toRgb(hue, saturation, 1 - lightness); + data[index] = red; + data[index + 1] = green; + data[index + 2] = blue; + } + context.putImageData(pixels, 0, 0); + const blob = await new Promise((resolve) => + canvas.toBlob((result) => resolve(result), 'image/png'), + ); + if (!blob) throw new Error('logo could not be converted'); + return new File([blob], name, { type: 'image/png' }); +} + +function toHsl(red: number, green: number, blue: number) { + const r = red / 255; + const g = green / 255; + const b = blue / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const lightness = (max + min) / 2; + if (max === min) return [0, 0, lightness]; + const span = max - min; + const saturation = + lightness > 0.5 ? span / (2 - max - min) : span / (max + min); + let hue = 0; + if (max === r) hue = (g - b) / span + (g < b ? 6 : 0); + else if (max === g) hue = (b - r) / span + 2; + else hue = (r - g) / span + 4; + return [hue / 6, saturation, lightness]; +} + +function toRgb(hue: number, saturation: number, lightness: number) { + if (!saturation) { + const value = Math.round(lightness * 255); + return [value, value, value]; + } + const q = + lightness < 0.5 + ? lightness * (1 + saturation) + : lightness + saturation - lightness * saturation; + const p = 2 * lightness - q; + return [ + Math.round(channel(p, q, hue + 1 / 3) * 255), + Math.round(channel(p, q, hue) * 255), + Math.round(channel(p, q, hue - 1 / 3) * 255), + ]; +} + +function channel(p: number, q: number, t: number) { + let value = t; + if (value < 0) value += 1; + if (value > 1) value -= 1; + if (value < 1 / 6) return p + (q - p) * 6 * value; + if (value < 1 / 2) return q; + if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6; + return p; +} diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index dcd19130cb..0a0b146bd6 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -29,15 +29,6 @@ "AI_ALIGN_CENTRE": "Centre", "AI_ALIGN_LEFT": "Left", "AI_ALIGN_RIGHT": "Right", - "AI_ANCHOR_BOTTOM_CENTRE": "Bottom centre", - "AI_ANCHOR_BOTTOM_LEFT": "Bottom left", - "AI_ANCHOR_BOTTOM_RIGHT": "Bottom right", - "AI_ANCHOR_CENTRE": "Middle", - "AI_ANCHOR_CENTRE_LEFT": "Middle left", - "AI_ANCHOR_CENTRE_RIGHT": "Middle right", - "AI_ANCHOR_TOP_CENTRE": "Top centre", - "AI_ANCHOR_TOP_LEFT": "Top left", - "AI_ANCHOR_TOP_RIGHT": "Top right", "AI_BODY_TEXT": "Smaller detail", "AI_BRIEF": "What should the image show?", "AI_BRIEF_HINT": "A poster for our office Christmas party on Friday 10 December", @@ -55,6 +46,8 @@ "AI_LAYER_PREVIEW": "Preview of the finished image", "AI_LEAVE_LOGO_SPACE": "Leave room for our logo", "AI_LOGO_POSITION": "Logo position", + "AI_LOGO_AUTO": "Choose for me", + "AI_LOGO_VERSION": "Logo version", "AI_LOGO_SAVED": "Logo saved. It will be used on future posters too.", "AI_LOGO_UPLOADING": "Saving logo...", "AI_NO_IMAGE": "There is no image to save", @@ -86,6 +79,7 @@ "AI_SHOW_LOGO": "Show our logo", "AI_SUBHEADING": "Second line", "AI_TEXT_ALIGN": "Text alignment", + "AI_TEXT_DRAG_HINT": "Drag the words on the image to put them where you want them. Arrow keys nudge, hold shift to move further.", "AI_TEXT_COLOUR": "Colour", "AI_TEXT_PANEL": "Shade behind the text", "AI_TEXT_POSITION": "Text position", @@ -133,6 +127,12 @@ "BRAND_HEADER": "Branding", "BRAND_HINT": "Used whenever artwork is generated for this organisation, so posters come back in your colours rather than the model's.", "BRAND_LOGO": "Logo", + "BRAND_LOGO_DERIVED": "Made from your other logo", + "BRAND_LOGO_HINT": "Upload one and the other version is made from it, keeping your colours. Replace either with your own file whenever you have one.", + "BRAND_LOGO_MADE": "Made the other version of your logo", + "BRAND_LOGO_MAKE_IT": "Make it from the other one", + "BRAND_LOGO_ON_DARK": "For dark backgrounds", + "BRAND_LOGO_ON_LIGHT": "For light backgrounds", "BRAND_ORGANISATION": "Organisation name", "BRAND_REMOVE_COLOUR": "Remove this colour", "BRAND_SAVED": "Branding saved", From ac8c0b027145233345da7d499d4bfc85246606ba Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 28 Aug 2026 13:28:24 +1000 Subject: [PATCH 15/27] feat(signage-manager): paragraphs, and any colour or face per block A block takes as many lines as you type. Line breaks you put in are kept, including the empty ones, since a gap between two paragraphs is a decision rather than stray whitespace; anything still too wide for the artwork wraps on top of that. Each role got its own leading with it, because a headline wants tight lines and a paragraph does not. Colour and face are now free, defaulting to the brand. The palette swatches stay one click away and a colour well sits beside them, and the face list is the same one the branding page offers, with the organisation's own face named as the default rather than left as a blank. Fixes a real bug that only showed up once faces could differ per block: ensureBrandFont appended the stylesheet and asked for the face in the same tick, so document.fonts.load resolved against a sheet that had not been parsed, reported success, and the canvas drew in the fallback face. It now waits for the sheet. The brand font was hitting this too on a cold load; it only looked fine because the branding page had usually loaded the face first. --- .../src/app/ai/ai-layer-controls.component.ts | 61 +++++++++++++- .../src/app/ai/ai-layer.component.ts | 79 +++++++++++++------ apps/signage-manager/src/app/ai/ai.types.ts | 2 + .../src/app/branding/brand-fonts.ts | 44 ++++++++--- shared/assets/locale/en-AU.json | 5 +- 5 files changed, 148 insertions(+), 43 deletions(-) diff --git a/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts b/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts index 8e4c0cc14e..df85e96df0 100644 --- a/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts +++ b/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts @@ -8,6 +8,7 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { IconComponent, TranslatePipe } from '@placeos/components'; +import { BRAND_FONTS } from '../branding/brand-fonts'; import { AiBrandKit, AiLayerState, AiTextBlock, AiTextRole } from './ai.types'; /** first block sits under the top left margin, each next one below it */ @@ -23,6 +24,7 @@ export function newTextBlock(role: AiTextRole, index = 0): AiTextBlock { y: FIRST_Y + BLOCK_GAP * index, align: 'left', colour: '#FFFFFF', + font: '', panel: true, }; } @@ -45,14 +47,15 @@ export function newTextBlock(role: AiTextRole, index = 0): AiTextBlock {
-
+
- + >
@@ -148,6 +178,22 @@ export function newTextBlock(role: AiTextRole, index = 0): AiTextBlock { [attr.aria-label]="colour" > } + max_width && current) { - lines.push(current); - current = word; - } else { - current = candidate; + for (const paragraph of text.split('\n')) { + const words = paragraph.trim().split(/\s+/).filter(Boolean); + if (!words.length) { + lines.push(''); + continue; + } + let current = ''; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if ( + context.measureText(candidate).width > max_width && + current + ) { + lines.push(current); + current = word; + } else { + current = candidate; + } } + if (current) lines.push(current); } - if (current) lines.push(current); return lines; } } diff --git a/apps/signage-manager/src/app/ai/ai.types.ts b/apps/signage-manager/src/app/ai/ai.types.ts index 717378ec3e..6644f04afe 100644 --- a/apps/signage-manager/src/app/ai/ai.types.ts +++ b/apps/signage-manager/src/app/ai/ai.types.ts @@ -131,6 +131,8 @@ export interface AiTextBlock { y: number; align: AiTextAlign; colour: string; + /** empty means the organisation's brand font */ + font: string; panel: boolean; } diff --git a/apps/signage-manager/src/app/branding/brand-fonts.ts b/apps/signage-manager/src/app/branding/brand-fonts.ts index e85de96272..9aff6909b0 100644 --- a/apps/signage-manager/src/app/branding/brand-fonts.ts +++ b/apps/signage-manager/src/app/branding/brand-fonts.ts @@ -21,7 +21,7 @@ export const BRAND_FONTS = [ { family: 'Playfair Display', label: 'Playfair Display' }, ]; -const LOADED = new Set(); +const LOADED = new Map>(); /** * Make a face available to the document, and so to a canvas. @@ -29,21 +29,39 @@ const LOADED = new Set(); * Resolves either way: a face that will not load is a poster in the fallback * face, which is better than a preview that never renders. */ -export async function ensureBrandFont(family?: string | null): Promise { - if (!family) return; - if (!LOADED.has(family)) { - LOADED.add(family); - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent( - family, - )}:wght@400;700&display=swap`; - document.head.appendChild(link); +export function ensureBrandFont(family?: string | null): Promise { + if (!family) return Promise.resolve(); + let loading = LOADED.get(family); + if (!loading) { + loading = load(family); + LOADED.set(family, loading); } + return loading; +} + +async function load(family: string): Promise { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent( + family, + )}:wght@400;700&display=swap`; + + // the stylesheet has to be parsed before the face has a name to load by: + // asking for it any earlier resolves against nothing, and a canvas that + // draws on the back of that quietly uses the fallback face instead + const parsed = new Promise((resolve) => { + link.onload = () => resolve(); + link.onerror = () => resolve(); + }); + document.head.appendChild(link); + await parsed; + + const faces = (document as any).fonts; + if (!faces) return; try { await Promise.all([ - (document as any).fonts?.load(`400 16px "${family}"`), - (document as any).fonts?.load(`700 16px "${family}"`), + faces.load(`400 16px "${family}"`), + faces.load(`700 16px "${family}"`), ]); } catch { // a face that will not load falls back, which is fine diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 0a0b146bd6..adb8a4b6f9 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -79,7 +79,10 @@ "AI_SHOW_LOGO": "Show our logo", "AI_SUBHEADING": "Second line", "AI_TEXT_ALIGN": "Text alignment", - "AI_TEXT_DRAG_HINT": "Drag the words on the image to put them where you want them. Arrow keys nudge, hold shift to move further.", + "AI_TEXT_ANY_COLOUR": "Pick any colour", + "AI_TEXT_BRAND_FONT": "Brand font", + "AI_TEXT_DRAG_HINT": "Drag the words on the image to put them where you want them. Arrow keys nudge, hold shift to move further. Enter starts a new line.", + "AI_TEXT_FONT": "Font", "AI_TEXT_COLOUR": "Colour", "AI_TEXT_PANEL": "Shade behind the text", "AI_TEXT_POSITION": "Text position", From 9d37a7024839134e7f7d6553de50527cbfb2ccbf Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 28 Aug 2026 13:31:28 +1000 Subject: [PATCH 16/27] fix(signage-manager): one gutter around the playlists panel The panel carried an 8px margin while the media grid pads 16, so the space above the cards, the space above the panel and the gap between them were three different widths, the gap between them being the sum of the other two. The panel now sits on the same 16 the header and the grid already use, and drops its left margin so the gutter is the grid's own padding rather than two spacings added together. --- .../signage-manager/src/app/media/playlist-sidebar.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/signage-manager/src/app/media/playlist-sidebar.component.ts b/apps/signage-manager/src/app/media/playlist-sidebar.component.ts index ff6b2496a8..c0663edfe4 100644 --- a/apps/signage-manager/src/app/media/playlist-sidebar.component.ts +++ b/apps/signage-manager/src/app/media/playlist-sidebar.component.ts @@ -24,7 +24,7 @@ type PlaylistStatus = selector: 'playlist-sidebar', template: ` }
- + @if (can_edit()) { + + } @if (!enabled()) { {{ 'SIGNAGE_MANAGER.BRAND_AI_OFF' | translate @@ -281,10 +305,18 @@ const COLOUR_NAMES = ['primary', 'secondary', 'accent']; }) export class BrandingComponent implements OnInit { private readonly _ai = inject(AiImageService); + private readonly _service = inject(SignageService); public readonly fonts = BRAND_FONTS; public readonly enabled = this._ai.enabled; + /** + * The brand kit is one object for the whole domain, so a change here lands + * on every screen every group runs. That is an administrator's call, and + * everyone else gets to see what it is set to. + */ + public readonly can_edit = this._service.is_sys_admin; + public readonly organisation = signal(''); public readonly colours = signal(['#0E6E52']); public readonly font = signal(''); @@ -374,11 +406,13 @@ export class BrandingComponent implements OnInit { } public pick(slot: AiLogoSlot) { + if (!this.can_edit()) return; this._target = slot; this._logo_input()?.nativeElement.click(); } public async pickLogo(event: Event) { + if (!this.can_edit()) return; const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; @@ -404,6 +438,7 @@ export class BrandingComponent implements OnInit { /** make this slot from the other one */ public async derive(slot: AiLogoSlot) { + if (!this.can_edit()) return; this.busy.set(slot); try { const kit = await this._ai.deriveBrandLogo(slot); @@ -417,6 +452,7 @@ export class BrandingComponent implements OnInit { } public async save() { + if (!this.can_edit()) return; this.saving.set(true); try { const palette: Record = {}; diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 4fbd5471db..2cb6d1b185 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -143,6 +143,7 @@ "BRAND_LOGO_MAKE_IT": "Make it from the other one", "BRAND_LOGO_ON_DARK": "For dark backgrounds", "BRAND_LOGO_ON_LIGHT": "For light backgrounds", + "BRAND_READ_ONLY": "Branding is set for the whole organisation, so only an administrator can change it.", "BRAND_ORGANISATION": "Organisation name", "BRAND_REMOVE_COLOUR": "Remove this colour", "BRAND_SAVED": "Branding saved", From e6d2418f0ce47e3a27aca9b62e40442de09bca95 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Mon, 31 Aug 2026 13:33:39 +1000 Subject: [PATCH 21/27] fix(signage-manager): the frontend defects found in the pre-push audit The API refuses a caller who is not support and names no group, and the browser never sent one, so nobody outside PlaceOS could generate anything. Every image in testing was made by the one local sys admin, which is why it went unnoticed. `api_group_id` is public now and rides on generate, edit and refine. Saving branding after a failed read wrote an empty kit over the real one and took the logo upload ids with it. The read result is tracked, and a save is refused unless the kit on screen is what the server holds. The modal's poll loop kept ticking for the life of the page and wrote into a destroyed component. The service polled a 404 or a 403 forever. Both stop now, and `watch` cannot start a second loop over one job. Job and source ids were written as media tags, and the library builds its folders from tags, so every generated image left behind a folder of one. Also: the Save button is reachable on a narrow screen, a failed artwork decode no longer saves a blank PNG, rapid rail clicks cannot leave the preview and the saved file disagreeing, the idempotency key is one per intent so it can actually prevent a double spend, a non-admin cannot set the organisation logo from the modal, the logo is measured from the file that is drawn, Tab steps through text blocks with a visible selection, a brand colour has to be a hex value, the quota line refreshes, and reference thumbnails read out their number. --- .../src/app/ai/ai-image-modal.component.ts | 114 ++++++++++++++++-- .../src/app/ai/ai-image.service.ts | 106 +++++++++++++--- .../src/app/ai/ai-layer-controls.component.ts | 34 +++--- .../src/app/ai/ai-layer.component.ts | 78 +++++++++--- .../src/app/ai/ai-references.component.ts | 17 ++- .../src/app/branding/branding.component.ts | 15 +++ .../src/app/signage.service.ts | 59 ++++----- .../src/tests/signage.service.spec.ts | 2 +- shared/assets/locale/en-AU.json | 5 + 9 files changed, 340 insertions(+), 90 deletions(-) diff --git a/apps/signage-manager/src/app/ai/ai-image-modal.component.ts b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts index 8cfec8005a..9c9351e9c7 100644 --- a/apps/signage-manager/src/app/ai/ai-image-modal.component.ts +++ b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts @@ -75,7 +75,9 @@ interface Candidate {
-
+
@@ -89,6 +91,7 @@ interface Candidate { [brand]="applied_brand()" [state]="layer_state()" (changed)="layer_state.set($event)" + (failed)="onArtworkFailed()" > } @else if (source_url()) {