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..e1b4c5fdf0 --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-image-modal.component.ts @@ -0,0 +1,1012 @@ +import { + Component, + computed, + inject, + linkedSignal, + OnDestroy, + signal, + viewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +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, + IconComponent, + TranslatePipe, +} from '@placeos/components'; +import { SignageMedia } from '@placeos/ts-client'; + +import { SignageService } from '../signage.service'; +import { AiImageService, isFinal } from './ai-image.service'; +import { errorMessage } from './ai-image.util'; +import { + AiLayerControlsComponent, + newTextBlock, +} from './ai-layer-controls.component'; +import { AiLayerComponent } from './ai-layer.component'; +import { AiReferencesComponent } from './ai-references.component'; +import { + AiEditRequest, + AiGenerateRequest, + AiJob, + AiJobImage, + AiLayerState, + AiReference, +} from './ai.types'; + +/** A provider that cannot finish an image in this time has stopped responding. */ +const MAX_JOB_WAIT_MS = 30 * 60 * 1000; + +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' | '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: ` +
+
+

+ {{ heading() | translate }} +

+ +
+ +
+ +
+
+ @if (selected_object_url()) { + + } @else if (source_url()) { + + } @else if (state() !== 'generating') { +

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

+ } + + @if (state() === 'generating') { +
+ +

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

+

+ {{ progress_note() }} +

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

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

+
+ @for ( + candidate of rail(); + track candidate.job_id + + '-' + + candidate.index + ) { + + } +
+
+ } +
+ + + +
+
+ `, + imports: [ + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatProgressSpinnerModule, + MatRippleModule, + MatSelectModule, + MatSlideToggleModule, + MatTooltipModule, + AuthenticatedImageDirective, + IconComponent, + TranslatePipe, + AiLayerComponent, + AiLayerControlsComponent, + AiReferencesComponent, + ], +}) +export class AiImageModalComponent implements OnDestroy { + 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); + private readonly _aspect_options = computed(() => { + const capabilities = this._ai.capabilities(); + const model_options = this._ai.default_model()?.aspect_ratios || []; + const domain_options = capabilities?.aspect_ratios || []; + const shared = domain_options.filter((option) => + model_options.includes(option), + ); + return shared.length + ? shared + : model_options.length + ? model_options + : domain_options; + }); + private readonly _max_candidates = computed(() => { + const domain_max = this._ai.capabilities()?.max_candidates ?? 2; + const model_max = + this._ai.default_model()?.max_candidates ?? domain_max; + return Math.max(1, Math.min(domain_max, model_max)); + }); + + public readonly state = signal('compose'); + public readonly saving = signal(false); + + public readonly brief = signal(''); + public readonly refinement = signal(''); + public readonly aspect = linkedSignal(() => { + const options = this._aspect_options(); + const requested = this._data.aspect_ratio || ''; + return options.includes(requested) + ? requested + : options[0] || requested || '16:9'; + }); + public readonly candidates = linkedSignal(() => { + return Math.min(2, this._max_candidates()); + }); + public readonly add_text_with_layer = signal(!this._data.source_upload_id); + public readonly include_logo = signal(!this._data.source_upload_id); + public readonly use_branding = signal(true); + + public readonly layer_state = signal({ + 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_on_light = signal(''); + public readonly logo_on_dark = signal(''); + public readonly uploading_logo = signal(false); + + public readonly references = signal([]); + public readonly uploading_references = signal(false); + public readonly claim_pending = signal(false); + + public readonly brand = this._ai.brand_kit; + + public readonly can_set_logo = this._service.is_sys_admin; + + public readonly group_id = computed( + () => this._service.api_group_id() || undefined, + ); + + /** there is nothing to switch off if the organisation has set nothing */ + public readonly has_branding = computed(() => { + const brand = this.brand(); + if (!brand) return false; + const font = + typeof brand.font === 'string' ? brand.font : brand.font?.family; + return !!( + brand.organisation || + font || + Object.keys(brand.palette || {}).length + ); + }); + + /** + * What the poster is actually dressed in. + */ + public readonly applied_brand = computed(() => + this.use_branding() ? this.brand() : null, + ); + + public readonly is_edit = computed(() => !!this._data.source_upload_id); + + /** 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` : ''; + }); + public readonly has_logo = computed( + () => !!this._ai.capabilities()?.logo_layer, + ); + + public readonly aspect_options = this._aspect_options; + public readonly candidate_options = computed(() => { + const max = this._max_candidates(); + return Array.from({ length: max }, (_, index) => index + 1); + }); + public readonly max_references = computed( + () => this._ai.default_model()?.max_references ?? 8, + ); + + public readonly job = computed( + () => this._ai.jobs()[this.current_job_id()], + ); + + /** + * Every candidate of every job in the refine chain, oldest first: the first + * generation's options and each round of changes since. + */ + public readonly rail = computed(() => { + const jobs = this._ai.jobs(); + const chain: AiJob[] = []; + const seen = new Set(); + let id = this.current_job_id(); + while (id && jobs[id] && !seen.has(id)) { + seen.add(id); + 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, + upload_id: image.upload_id as string, + url: (image as AiJobImage).url as string, + version: version + 1, + }); + }); + }); + return rail; + }); + + 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}` }); + }); + + /** + * Which engine is behind the button. + */ + public readonly engine_note = computed(() => { + const capabilities = this._ai.capabilities(); + if (!capabilities?.enabled) return ''; + const provider = + capabilities.providers.find( + (p) => p.id === capabilities.default_provider_id, + ) || capabilities.providers[0]; + if (!provider) return ''; + const model = provider.models?.find( + (m) => m.id === provider.default_model, + ); + return i18n('SIGNAGE_MANAGER.AI_ENGINE', { + model: model?.name || provider.default_model || '', + provider: provider.name, + }); + }); + + public readonly heading = computed(() => + this.is_edit() + ? 'SIGNAGE_MANAGER.AI_EDIT_IMAGE' + : 'SIGNAGE_MANAGER.AI_CREATE_IMAGE', + ); + + /** 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_on_light() || this.logo_on_dark()); + }); + + public versionLabel(candidate: Candidate) { + return i18n('SIGNAGE_MANAGER.AI_VERSION_LABEL', { + version: `${candidate.version}`, + option: `${candidate.index + 1}`, + }); + } + + public async start() { + const prompt = this.brief().trim(); + if (!prompt) return; + this.state.set('generating'); + try { + const common = { + prompt, + candidates: this.candidates(), + include_logo: this.include_logo(), + add_text_with_layer: this.add_text_with_layer(), + use_branding: this.use_branding(), + group_id: this.group_id(), + references: this.reference_ids(), + }; + let job: AiJob; + if (this._data.source_upload_id) { + const request: AiEditRequest = { + ...common, + source_upload_id: this._data.source_upload_id, + source_item_id: this._data.source_item_id, + }; + job = await this._ai.edit({ + ...request, + idempotency_key: this._ai.intentKey('edit', request), + }); + } else { + const request: AiGenerateRequest = { + ...common, + aspect_ratio: this.aspect(), + }; + job = await this._ai.generate({ + ...request, + idempotency_key: this._ai.intentKey('generate', request), + }); + } + this.current_job_id.set(job.id); + this._awaitJob(job.id); + } catch (error) { + this.state.set('compose'); + notifyError( + errorMessage(error, i18n('SIGNAGE_MANAGER.AI_JOB_FAILED')), + ); + } + } + + 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 request: AiEditRequest = { + prompt: instruction, + candidates: 1, + include_logo: this.include_logo(), + add_text_with_layer: this.add_text_with_layer(), + use_branding: this.use_branding(), + group_id: this.group_id(), + source_upload_id: source.upload_id, + parent_job_id: source.job_id, + references: this.reference_ids(), + }; + const job = await this._ai.edit({ + ...request, + idempotency_key: this._ai.intentKey('edit', request), + }); + this.current_job_id.set(job.id); + this._awaitJob(job.id); + } catch (error) { + this.state.set('review'); + notifyError( + errorMessage(error, i18n('SIGNAGE_MANAGER.AI_JOB_FAILED')), + ); + } + } + + private _select_token = 0; + + public onArtworkFailed() { + this.selected_object_url.set(''); + notifyError(i18n('SIGNAGE_MANAGER.AI_IMAGE_UNREADABLE')); + } + + public async select(candidate: Candidate) { + if (this.claim_pending()) return; + const token = ++this._select_token; + this.selected.set(candidate); + this.selected_object_url.set(''); + const url = await this._ai.loadImage(candidate.url).catch(() => ''); + if (token !== this._select_token) return; + 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(this.rail().length ? 'review' : 'compose'); + } + + /** + * Keep a logo for the domain. + */ + public readonly reference_ids = computed(() => + this.references().map((item) => item.id), + ); + + /** + * Attach pictures for this request. + */ + public async addReferences(files: File[]) { + if (!files.length) return; + this.uploading_references.set(true); + try { + for (const file of files) { + const id = await this._ai.uploadReference(file); + this.references.update((list) => [ + ...list, + { id, name: file.name, url: URL.createObjectURL(file) }, + ]); + } + } catch (error) { + notifyError( + errorMessage(error, i18n('SIGNAGE_MANAGER.AI_JOB_FAILED')), + ); + } finally { + this.uploading_references.set(false); + } + } + + public removeReference(id: string) { + const item = this.references().find((entry) => entry.id === id); + if (item) URL.revokeObjectURL(item.url); + this.references.update((list) => + list.filter((entry) => entry.id !== id), + ); + this._ai.removeReference(id); + } + + private _closed = false; + + public ngOnDestroy() { + this._closed = true; + if (this._await_timer) clearTimeout(this._await_timer); + + const running = this.state() === 'generating'; + for (const item of this.references()) { + URL.revokeObjectURL(item.url); + // a running job reads the reference bytes server side, so those are + // left for the housekeeping sweep to clear + if (!running) this._ai.removeReference(item.id); + } + } + + public async uploadLogo(file: File) { + if (!this.can_set_logo()) return; + this.uploading_logo.set(true); + try { + 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) { + notifyError( + errorMessage(error, i18n('SIGNAGE_MANAGER.AI_JOB_FAILED')), + ); + } finally { + this.uploading_logo.set(false); + } + } + + public async save() { + const candidate = this.selected(); + if (!candidate) return; + + // Take the composited image before the button swaps to a spinner. + const name = this._name(); + const overlay = this.has_overlay(); + const blob = overlay ? await this._layer()?.toBlob() : undefined; + if (overlay && !blob) { + notifyError(i18n('SIGNAGE_MANAGER.AI_NO_IMAGE')); + return; + } + + this.saving.set(true); + try { + let media: SignageMedia | undefined; + + if (blob) { + const file = new File([blob], `${name}.png`, { + type: 'image/png', + }); + media = await this._service.addMedia( + file, + new SignageMedia({ + name, + tags: this._tags(candidate), + }), + ); + } else { + media = + this._pending_media || + (await this._service.addMediaFromUpload( + candidate.upload_id, + { + name, + tags: this._tags(candidate), + orientation: + this.aspect() === '9:16' + ? 'portrait' + : 'landscape', + }, + this._data.playlist_id, + )); + this._pending_media = media; + if (media?.id) { + this.claim_pending.set(true); + await this._ai.claim( + candidate.job_id, + candidate.upload_id, + media.id, + ); + this.claim_pending.set(false); + this._pending_media = undefined; + } + } + + if (blob && media?.id && this._data.playlist_id) { + await this._service.addMediaToPlaylist( + this._data.playlist_id, + media.id, + ); + } + + if (media?.id) { + // the list paints as soon as the dialog closes; give the + // thumbnail a moment to become readable so the tile is not + // briefly empty + if (media.thumbnail_id) { + await this._ai + .loadImage( + `/api/engine/v2/uploads/${media.thumbnail_id}/url`, + ) + .catch(() => ''); + } + } + this._dialog_ref.close(media); + } catch (error) { + if (!blob && this._pending_media?.id) { + await this._service + .discardCreatedMedia(this._pending_media.id) + .then(() => { + this._pending_media = undefined; + this.claim_pending.set(false); + }) + .catch(() => null); + } + notifyError( + errorMessage(error, i18n('SIGNAGE_MANAGER.AI_JOB_FAILED')), + ); + } finally { + if (!this._pending_media) this.claim_pending.set(false); + this.saving.set(false); + } + } + + private _pending_media: SignageMedia | undefined; + private _await_timer: ReturnType | null = null; + + /** poll until the job reaches a final state, then move on */ + private _awaitJob(id: string) { + const deadline = Date.now() + MAX_JOB_WAIT_MS; + const check = () => { + this._await_timer = null; + if (this._closed) return; + const job = this._ai.jobs()[id]; + if (!job || !isFinal(job)) { + if (Date.now() >= deadline) { + this.state.set(this.rail().length ? 'review' : 'compose'); + notifyError(i18n('SIGNAGE_MANAGER.AI_JOB_FAILED')); + return; + } + this._await_timer = setTimeout(check, 250); + return; + } + if (job.state === 'failed') { + this.state.set(this.rail().length ? 'review' : 'compose'); + return; + } + if (job.state === 'cancelled') { + this.state.set(this.rail().length ? 'review' : 'compose'); + return; + } + const newest = this.rail().filter( + (candidate) => candidate.job_id === id, + ); + if (newest.length) this.select(newest[0]); + this._loadBrandLogos(); + this.state.set('review'); + }; + check(); + } + + /** 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(' '); + return words || i18n('SIGNAGE_MANAGER.AI_DEFAULT_NAME'); + } + + /** + * Tags are what the media library builds its folders from, so only a label + * a person would want to browse by belongs here. + */ + private _tags(_candidate: Candidate) { + return ['ai-generated']; + } +} 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..aec7001c16 --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-image.service.ts @@ -0,0 +1,462 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { + AsyncHandler, + i18n, + notifyError, + notifyInfo, + UploadsService, +} from '@placeos/common'; +import { loadAuthenticatedImage } from '@placeos/components'; +import { showMetadata, updateMetadata } from '@placeos/ts-client'; + +import { flipLightness, inkIsLight } from '../branding/logo-variant'; +import { errorStatus } from './ai-image.util'; +import { + cancelSignageAIJob, + claimSignageAIImage, + editSignageImage, + generateSignageImage, + querySignageAIJobs, + removeSignageUpload, + showSignageAIJob, + signageAICapabilities, +} from './ai.fn'; +import { + AiBrandKit, + AiCapabilities, + 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; + +/** consecutive failures before a job is given up on */ +const POLL_RETRIES = 10; + +/** Fast retries keep a saved candidate from being left unclaimed on a blip. */ +const CLAIM_RETRY_DELAYS = [0, 500, 1500]; + +export function isFinal(job?: AiJob | null) { + return !!job && FINAL_STATES.includes(job.state); +} + +/** + * Owns generation state for the app. + */ +@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); + /** whether the kit above is what the server holds, or just an empty start */ + public readonly brand_kit_read = signal<'pending' | 'ok' | 'failed'>( + 'pending', + ); + public readonly jobs = signal>({}); + + public readonly enabled = computed(() => !!this.capabilities()?.enabled); + public readonly default_provider = computed(() => { + const capabilities = this.capabilities(); + if (!capabilities?.enabled) return null; + return ( + capabilities.providers.find( + (provider) => provider.id === capabilities.default_provider_id, + ) || + capabilities.providers[0] || + null + ); + }); + public readonly default_model = computed(() => { + const provider = this.default_provider(); + if (!provider) return null; + return ( + provider.models.find( + (model) => model.id === provider.default_model, + ) || + provider.models[0] || + null + ); + }); + public readonly can_generate = computed( + () => !!this.default_model()?.generate, + ); + public readonly can_edit = computed(() => !!this.default_model()?.edit); + 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 readonly _uploads = inject(UploadsService); + + private _loaded = false; + private _org_zone = ''; + + /** + * Read what this domain can do. + */ + 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 || { + 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) { + await this.reloadBrandKit(); + } + return this.capabilities(); + } + + /** + * Store a logo for the domain and remember it. + */ + 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. + */ + public async replaceBrandLogo( + slot: AiLogoSlot, + file: File, + derive_other = false, + ): Promise { + const upload_id = await this._uploads.uploadFileToCompletion(file); + 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; + 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 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); + } + + /** + * Store an image the person wants a request to draw on. + */ + public uploadReference(file: File): Promise { + return this._uploads.uploadFileToCompletion(file); + } + + /** done with, once the image it was for has been made */ + public removeReference(id: string) { + return removeSignageUpload(id).catch(() => null); + } + + /** + * Merge changes into the domain's brand kit. + */ + public async saveBrandKit( + changes: Partial, + ): Promise { + if (!this._org_zone) { + throw new Error(i18n('SIGNAGE_MANAGER.AI_NO_ORG_ZONE')); + } + if (this.brand_kit_read() !== 'ok') { + throw new Error(i18n('SIGNAGE_MANAGER.BRAND_NOT_LOADED')); + } + const details = { ...(this.brand_kit() || {}), ...changes }; + for (const key of Object.keys(details)) { + if (details[key] === undefined) delete details[key]; + } + + // replace rather than merge: the API deep merges a PATCH, so a colour + // taken out of the palette would survive the save. + await updateMetadata( + this._org_zone, + { + name: 'signage_ai', + description: 'Brand kit used when generating signage artwork', + details: details as unknown as Record, + }, + 'put', + ); + + this.brand_kit.set(details); + 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, + ); + if (!metadata) { + this.brand_kit_read.set('failed'); + return this.brand_kit(); + } + const details = metadata.details; + if (details && !Array.isArray(details) && Object.keys(details).length) { + this.brand_kit.set(details as unknown as AiBrandKit); + } + // an empty answer is a real answer: the organisation has set nothing + this.brand_kit_read.set('ok'); + return this.brand_kit(); + } + + /** + * One key per thing a person asked for, held here rather than on the modal. + */ + private readonly _intents = new Map(); + + public intentKey(kind: 'generate' | 'edit', request: object) { + const id = `${kind}:${JSON.stringify(request)}`; + let key = this._intents.get(id); + if (!key) { + key = crypto.randomUUID(); + this._intents.set(id, key); + } + return key; + } + + /** 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({ + ...request, + idempotency_key: request.idempotency_key || crypto.randomUUID(), + }); + this._merge([job]); + this.watch(job.id); + return job; + } + + public async edit(request: AiEditRequest) { + const job = await editSignageImage({ + ...request, + idempotency_key: request.idempotency_key || crypto.randomUUID(), + }); + 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 async claim(id: string, upload_id: string, item_id: string) { + let last_error: unknown; + for (const delay of CLAIM_RETRY_DELAYS) { + if (delay) { + await new Promise((resolve) => + setTimeout(resolve, delay), + ); + } + try { + return await claimSignageAIImage(id, { upload_id, item_id }); + } catch (error) { + last_error = error; + } + } + throw last_error; + } + + public job(id: string) { + return this.jobs()[id]; + } + + /** + * Watch a job until it finishes. + */ + public watch(id: string) { + if (this._watching.has(id)) return; + this._watching.add(id); + this._attempts.delete(id); + this.timeout(`watch-${id}`, () => this._poll(id), 1); + } + + public unwatch(id: string) { + this._watching.delete(id); + this._attempts.delete(id); + this.clearTimeout(`watch-${id}`); + } + + private readonly _watching = new Set(); + private readonly _attempts = new Map(); + + private async _poll(id: string) { + if (!this._watching.has(id)) return; + + const known = this.jobs()[id]?.version ?? 0; + const result: AiJob | { error: unknown } = await showSignageAIJob(id, { + wait: POLL_WAIT, + since: known, + }).catch((error: unknown) => ({ error })); + + if ('error' in result) { + const status = errorStatus(result.error); + const attempts = (this._attempts.get(id) || 0) + 1; + this._attempts.set(id, attempts); + if (status === 404 || status === 403 || attempts >= POLL_RETRIES) { + this._failJob(id); + this.unwatch(id); + return; + } + this.timeout(`watch-${id}`, () => this._poll(id), 2000); + return; + } + + const job = result; + this._attempts.delete(id); + this._merge([job]); + + if (isFinal(job)) { + this.unwatch(id); + this._announce(job); + this.refreshQuota(); + return; + } + + this.timeout(`watch-${id}`, () => this._poll(id), 1); + } + + /** + * Re-read what is left of the allowance. + */ + public async refreshQuota() { + const capabilities = await signageAICapabilities().catch(() => null); + if (capabilities?.quota) { + this.capabilities.update((current) => + current ? { ...current, quota: capabilities.quota } : current, + ); + } + } + + /** 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 _failJob(id: string) { + const current = this.jobs()[id]; + if (!current || isFinal(current)) return; + const failed: AiJob = { + ...current, + state: 'failed', + version: current.version + 1, + error_message: i18n('SIGNAGE_MANAGER.AI_JOB_FAILED'), + }; + this._merge([failed]); + this._announce(failed); + } + + 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. + */ + 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-image.util.ts b/apps/signage-manager/src/app/ai/ai-image.util.ts new file mode 100644 index 0000000000..15ba0be3bd --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-image.util.ts @@ -0,0 +1,41 @@ +/** Return the API's string error without leaking response objects to the UI. */ +export function errorMessage(error: unknown, fallback: string): string { + if (typeof error === 'string') return error; + if (!isRecord(error)) return fallback; + + const nested = error['error']; + if (typeof nested === 'string') return nested; + if (isRecord(nested)) { + const detail = nested['error']; + if (typeof detail === 'string') return detail; + const nested_message = nested['message']; + if (typeof nested_message === 'string') return nested_message; + } + + const message = error['message']; + return typeof message === 'string' ? message : fallback; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Return an HTTP status from either the error or its wrapped API response. */ +export function errorStatus(error: unknown): number | undefined { + if (!isRecord(error)) return undefined; + const status = error['status']; + if (typeof status === 'number') return status; + const nested = error['error']; + if (!isRecord(nested)) return undefined; + const nested_status = nested['status']; + return typeof nested_status === 'number' ? nested_status : undefined; +} + +/** Perceived sRGB brightness on a 0 to 255 scale. */ +export function perceivedLightness( + red: number, + green: number, + blue: number, +): number { + return (red * 299 + green * 587 + blue * 114) / 1000; +} 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..09306cd7f7 --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-layer-controls.component.ts @@ -0,0 +1,417 @@ +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 { 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 */ +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, + x: 0.06, + y: FIRST_Y + BLOCK_GAP * index, + align: 'left', + colour: '#FFFFFF', + font: '', + panel: true, + }; +} + +/** + * The words and the logo, as a sidebar panel beside the preview. + */ +@Component({ + selector: 'ai-layer-controls', + template: ` +
+

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

+ + @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 + }} + + + + + + {{ + 'SIGNAGE_MANAGER.AI_ALIGN_LEFT' | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ALIGN_CENTRE' + | translate + }} + {{ + 'SIGNAGE_MANAGER.AI_ALIGN_RIGHT' | translate + }} + + + + + + {{ + brand_font_label() | translate + }} + @for (option of fonts; track option.family) { + @if (option.family) { + {{ + option.label + }} + } + } + + +
+ +
+ @for (colour of palette(); track colour) { + + } + + + {{ 'SIGNAGE_MANAGER.AI_TEXT_PANEL' | translate }} + +
+
+ } + + + +
+ @if (!has_logo()) { + {{ + (can_set_logo() + ? 'SIGNAGE_MANAGER.AI_NO_LOGO_YET' + : 'SIGNAGE_MANAGER.AI_NO_LOGO_ADMIN' + ) | translate + }} + @if (can_set_logo()) { + + } + } @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 + }} + + + + + @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 + }} + + + } + } + } + +
+
+ `, + imports: [ + FormsModule, + IconComponent, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatSlideToggleModule, + MatTooltipModule, + TranslatePipe, + ], +}) +export class AiLayerControlsComponent { + public readonly state = input.required(); + public readonly logo_on_light = input(''); + public readonly logo_on_dark = input(''); + public readonly brand = input(null); + public readonly uploading = input(false); + public readonly can_set_logo = input(true); + + public readonly changed = output(); + public readonly logo_picked = output({ alias: 'logoPicked' }); + + 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 fonts = BRAND_FONTS; + + /** names the face a block falls back to */ + public readonly brand_font_label = computed(() => { + const font = this.brand()?.font; + const family = typeof font === 'string' ? font : font?.family; + return family || 'SIGNAGE_MANAGER.AI_TEXT_BRAND_FONT'; + }); + + 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 setBlockColour(id: string, event: Event) { + const input = event.target; + if (input instanceof HTMLInputElement) { + this.patchBlock(id, { colour: input.value }); + } + } + + public addBlock() { + const blocks = this.state().blocks; + const role: AiTextRole = blocks.length === 1 ? 'subheading' : 'body'; + this.patch({ blocks: [...blocks, newTextBlock(role, blocks.length)] }); + } + + 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.logo_picked.emit(file); + } + + public placeholderFor(role: AiTextRole) { + return role === 'headline' + ? 'SIGNAGE_MANAGER.AI_HEADLINE' + : role === 'subheading' + ? 'SIGNAGE_MANAGER.AI_SUBHEADING' + : 'SIGNAGE_MANAGER.AI_BODY_TEXT'; + } +} 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..e69281a72c --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-layer.component.ts @@ -0,0 +1,584 @@ +import { + Component, + computed, + effect, + ElementRef, + input, + output, + signal, + viewChild, +} from '@angular/core'; + +import { TranslatePipe } from '@placeos/components'; + +import { ensureBrandFont } from '../branding/brand-fonts'; +import { perceivedLightness } from './ai-image.util'; +import { + AiBrandKit, + AiLayerState, + AiLogoSlot, + AiTextBlock, + AiTextRole, +} from './ai.types'; + +/** share of the artwork's height each role is drawn at */ +const ROLE_SIZE: Record = { + headline: 0.11, + subheading: 0.055, + body: 0.038, +}; + +/** line to line, as a multiple of the type size */ +const ROLE_LEADING: Record = { + headline: 1.12, + subheading: 1.3, + body: 1.45, +}; + +/** 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, + * at the artwork's native size. + */ +@Component({ + selector: 'ai-layer', + template: ` + + `, + styles: [ + ` + :host { + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + min-width: 0; + } + canvas:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + `, + ], + imports: [TranslatePipe], +}) +export class AiLayerComponent { + public readonly image_url = input.required(); + /** 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(); + /** the artwork could not be decoded, so there is nothing to composite */ + public readonly failed = 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 _artwork_url = ''; + 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 }; + + /** the organisation's face, used by any block that has not picked its own */ + private readonly _brand_family = computed(() => { + const font = this.brand()?.font; + return typeof font === 'string' ? font : font?.family || ''; + }); + + constructor() { + effect(() => { + const url = this.image_url(); + if (url) this._loadArtwork(url); + }); + effect(() => { + 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 + const families = new Set( + [ + this._brand_family(), + ...this.state().blocks.map((block) => block.font), + ].filter(Boolean), + ); + for (const family of families) { + ensureBrandFont(family).then(() => this._draw()); + } + }); + effect(() => { + this.state(); + this.hover_id(); + this.drag_id(); + this.selected_id(); + this._draw(); + }); + } + + /** the composited image, at the artwork's native size */ + public toBlob(): Promise { + const canvas = this._canvas()?.nativeElement; + // without artwork the canvas is still its default 300x150 and toBlob + // hands back a valid blank image rather than failing + if (!canvas || !this._artwork) return Promise.resolve(null); + // the outline is an editing aid, not part of the poster + const hovered = this.hover_id(); + const selected = this.selected_id(); + this.hover_id.set(''); + this.selected_id.set(''); + this._draw(); + return new Promise((resolve) => + canvas.toBlob((blob) => { + this.hover_id.set(hovered); + this.selected_id.set(selected); + 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) { + if (event.key === 'Tab') { + const blocks = this.state().blocks.filter((b) => b.text.trim()); + if (blocks.length < 2) return; + const at = blocks.findIndex((b) => b.id === this.selected_id()); + const next = event.shiftKey ? at - 1 : at + 1; + + // Off either end, let the browser have the key. + if (next < 0 || next >= blocks.length) { + this.selected_id.set(''); + return; + } + + event.preventDefault(); + this.selected_id.set(blocks[next].id); + this._draw(); + return; + } + + 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'; + this._artwork = null; + this._artwork_url = url; + image.onerror = () => { + if (this._artwork_url !== url) return; + this._artwork = null; + this.failed.emit(); + }; + image.onload = () => { + if (this._artwork_url !== url) return; + 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(slot: AiLogoSlot, url: string) { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => { + this._logos[slot] = 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(); + if (!state) return; + if (state.logo) this._drawLogo(context, width, height, state); + this._drawBlocks(context, width, height, state); + } + + private _drawBlocks( + context: CanvasRenderingContext2D, + width: number, + height: number, + state: AiLayerState, + ) { + this._boxes.clear(); + const wrap_at = width * 0.88; + + 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 ${this._fontFamily(block.font)}`; + const lines = this._wrap(context, text, wrap_at); + const leading = Math.round(size * ROLE_LEADING[block.role]); + const line_height = Math.round(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: line_height + leading * (lines.length - 1), + }; + this._boxes.set(block.id, box); + + if (block.panel) { + const pad = Math.round(size * 0.35); + context.fillStyle = this._panelColour(block.colour); + context.fillRect( + box.left - pad, + box.top - pad * 0.6, + box.width + pad * 2, + box.height + pad * 1.2, + ); + } + + context.textAlign = ( + block.align === 'centre' ? 'center' : block.align + ) as CanvasTextAlign; + context.textBaseline = 'top'; + const x = + block.align === 'left' + ? box.left + : block.align === 'right' + ? box.left + box.width + : box.left + box.width / 2; + context.fillStyle = block.colour; + const offset = (line_height - size) / 2; + lines.forEach((line, index) => { + context.fillText(line, x, box.top + offset + leading * index); + }); + + if ( + this.hover_id() === block.id || + this.drag_id() === block.id || + this.selected_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 margin = Math.round(width * 0.04); + const target_width = Math.round(width * state.logo_scale); + + // Measured from a nominal square so the sampling box does not depend on + // which variant loaded first, then measured again from the file that is + // actually drawn. + const probe = this._logos.on_light || this._logos.on_dark; + if (!probe) return; + const nominal = Math.round(width * state.logo_scale * 0.4); + const logo = this._logoFor(state, context, { + left: state.logo_position.endsWith('left') + ? margin + : width - target_width - margin, + top: state.logo_position.startsWith('top') + ? margin + : height - nominal - margin, + width: target_width, + height: nominal, + }); + if (!logo) return; + + const scale = target_width / (logo.naturalWidth || target_width); + 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); + } + + /** + * On auto, the artwork under the logo decides: a dark corner takes the + * light version and a light corner takes the dark one. + */ + 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 += perceivedLightness( + data[index], + data[index + 1], + 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) + ? '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 perceivedLightness(r, g, b) > 140; + } + + private _fontFamily(chosen?: string) { + const family = chosen || this._brand_family(); + return family + ? `"${family}", system-ui, sans-serif` + : 'system-ui, sans-serif'; + } + + /** + * Line breaks the author typed are kept, including the empty ones. + * Anything still too wide for the artwork is wrapped on top of that. + */ + private _wrap( + context: CanvasRenderingContext2D, + text: string, + max_width: number, + ) { + const lines: string[] = []; + 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); + } + return lines; + } +} diff --git a/apps/signage-manager/src/app/ai/ai-references.component.ts b/apps/signage-manager/src/app/ai/ai-references.component.ts new file mode 100644 index 0000000000..3bb7d878a2 --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai-references.component.ts @@ -0,0 +1,117 @@ +import { Component, input, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { i18n } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; + +import { AiReference } from './ai.types'; + +/** + * Pictures to work from, numbered so the brief can name them. + */ +@Component({ + selector: 'ai-references', + template: ` +
+

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

+

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

+ + @if (items().length) { +
+ @for (item of items(); track item.id; let index = $index) { +
+ + + +
+ } +
+ } + + + +
+ `, + imports: [IconComponent, MatButtonModule, MatTooltipModule, TranslatePipe], +}) +export class AiReferencesComponent { + public readonly items = input.required(); + public readonly uploading = input(false); + /** the server takes the first eight and drops the rest */ + public readonly max = input(8); + + public readonly picked = output(); + public readonly removed = output(); + + public numberedLabel(index: number, name: string) { + return `${i18n('SIGNAGE_MANAGER.AI_REFERENCE_NUMBER', { + number: `${index + 1}`, + })}: ${name}`; + } + + public pick(event: Event) { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files || []); + input.value = ''; + const room = this.max() - this.items().length; + if (files.length) this.picked.emit(files.slice(0, room)); + } +} 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..e1a0527bdf --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai.fn.ts @@ -0,0 +1,84 @@ +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('&')}` : ''; +} + +/** an upload made for one request, cleared once the request is done with it */ +export function removeSignageUpload(id: string): Promise { + return del(`${apiEndpoint()}/uploads/${encodeURIComponent(id)}`, { + response_type: 'void', + }); +} + +export function signageAICapabilities(): Promise { + return get( + `${AI_PATH()}/capabilities`, + ) as unknown as Promise; +} + +export function generateSignageImage( + request: AiGenerateRequest, +): Promise { + return post(`${AI_PATH()}/generate`, request) as unknown as Promise; +} + +export function editSignageImage(request: AiEditRequest): Promise { + return post(`${AI_PATH()}/edit`, request) as unknown 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 unknown as Promise; +} + +export function querySignageAIJobs( + query: { mine?: boolean; limit?: number } = {}, +): Promise { + return get(`${AI_PATH()}/jobs${toQuery(query)}`) as unknown as Promise< + AiJob[] + >; +} + +export function cancelSignageAIJob(id: string): Promise { + return post( + `${AI_PATH()}/jobs/${encodeURIComponent(id)}/cancel`, + {}, + ) as unknown 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 unknown 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..95a7aaee78 --- /dev/null +++ b/apps/signage-manager/src/app/ai/ai.types.ts @@ -0,0 +1,153 @@ +/** 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; + /** false leaves the organisation's colours, face and tone out of it */ + use_branding?: 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; + /** the logo for a light background, so dark ink; rest-api reads this exact key */ + 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; +} + +/** 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; + +/** + * An image attached to a request, numbered from 1 so a brief can name it. + */ +export interface AiReference { + id: string; + name: string; + url: string; +} + +/** 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; + /** top left of the block, as a fraction of the artwork's width and height */ + x: number; + y: number; + align: AiTextAlign; + colour: string; + /** empty means the organisation's brand font */ + font: 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; + logo_choice: AiLogoChoice; +} diff --git a/apps/signage-manager/src/app/app.component.ts b/apps/signage-manager/src/app/app.component.ts index 08ec58ba52..3b46321607 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,15 @@ 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 + await this._ai.load(authority()?.config?.org_zone); + if (this._ai.enabled()) await this._ai.loadRecent(); } } 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..36c8db3cc1 --- /dev/null +++ b/apps/signage-manager/src/app/branding/brand-fonts.ts @@ -0,0 +1,63 @@ +/** + * Faces offered for signage artwork. + */ +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 Map>(); + +/** + * Make a face available to the document, and so to a canvas. Resolves either + * way: a face that will not load falls back rather than rejecting. + */ +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.fonts; + if (!faces) return; + try { + await Promise.all([ + 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/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..03fc68b4f7 --- /dev/null +++ b/apps/signage-manager/src/app/branding/branding.component.ts @@ -0,0 +1,515 @@ +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'; +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 { errorMessage } from '../ai/ai-image.util'; +import { AiBrandKit, AiLogoSlot } from '../ai/ai.types'; +import { NavFooterComponent } from '../shared/nav-footer.component'; +import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { SignageService } from '../signage.service'; +import { BRAND_FONTS, ensureBrandFont } from './brand-fonts'; + +const COLOUR_NAMES = ['primary', 'secondary', 'accent']; + +@Component({ + selector: 'app-branding', + template: ` +
+ +
+

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

+

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

+ + @if (!can_edit()) { +

+ lock + {{ 'SIGNAGE_MANAGER.BRAND_READ_ONLY' | translate }} +

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

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

+ + + @if (can_edit()) { +

+ {{ '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 (can_edit()) { +
+ + @if ( + !logoId(slot.id) && + logoId(other(slot.id)) + ) { + + } +
+ } +
+ } + +
+ +
+ @if (can_edit()) { + + } + @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); + private readonly _service = inject(SignageService); + + public readonly fonts = BRAND_FONTS; + public readonly enabled = this._ai.enabled; + + public readonly can_edit = this._service.is_sys_admin; + + public readonly organisation = signal(''); + public readonly colours = signal(['#0E6E52']); + public readonly font = signal(''); + public readonly saving = signal(false); + + /** 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'; + }); + + public async ngOnInit() { + const brand = this._ai.brand_kit(); + if (brand) this._apply(brand); + 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)); + } + + /** #rgb or #rrggbb, the only thing the canvas and the prompt can use */ + public static readonly COLOUR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i; + + public readonly colour_errors = computed(() => + this.colours().map((colour) => !BrandingComponent.COLOUR.test(colour)), + ); + + public setColour(index: number, value: string) { + this.colours.update((list) => + list.map((colour, i) => (i === index ? value : colour)), + ); + } + + public setColourFromInput(index: number, event: Event) { + const input = event.target; + if (input instanceof HTMLInputElement) { + this.setColour(index, input.value); + } + } + + public previewFont() { + 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) { + 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 = ''; + if (!file) return; + const slot = this._target; + this.busy.set(slot); + try { + 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( + errorMessage(error, i18n('SIGNAGE_MANAGER.BRAND_SAVE_FAILED')), + ); + } finally { + this.busy.set(''); + } + } + + /** 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); + this._applyLogos(kit); + notifySuccess(i18n('SIGNAGE_MANAGER.BRAND_LOGO_MADE')); + } catch (error) { + notifyError( + errorMessage(error, i18n('SIGNAGE_MANAGER.BRAND_SAVE_FAILED')), + ); + } finally { + this.busy.set(''); + } + } + + public async save() { + if (!this.can_edit()) return; + if (this.colour_errors().some(Boolean)) { + notifyError(i18n('SIGNAGE_MANAGER.BRAND_COLOUR_INVALID')); + return; + } + 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( + errorMessage(error, i18n('SIGNAGE_MANAGER.BRAND_SAVE_FAILED')), + ); + } finally { + this.saving.set(false); + } + } + + private _apply(brand: AiBrandKit) { + this.organisation.set(brand.organisation || ''); + 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._applyLogos(brand); + } + + private _applyLogos(brand: AiBrandKit) { + this.logos.set({ + on_light: brand.logo_upload_id || '', + on_dark: brand.logo_dark_upload_id || '', + }); + this.derived.set(brand.logo_derived || ''); + } +} 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..4f59d925b2 --- /dev/null +++ b/apps/signage-manager/src/app/branding/logo-variant.ts @@ -0,0 +1,145 @@ +import { perceivedLightness } from '../ai/ai-image.util'; + +/** + * Making the other version of a logo. + */ + +/** anything past this reads as light ink */ +const LIGHT_INK = 0.55; + +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. + */ +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 = + perceivedLightness(data[index], data[index + 1], 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/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..395abfa0bc 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 @@ -13,6 +13,7 @@ import { IconComponent, TranslatePipe, } from '@placeos/components'; +import { AiImageService } from '../ai/ai-image.service'; import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; import { MediaAddModalComponent } from '../shared/media-add-modal.component'; import { SignageService } from '../signage.service'; @@ -57,7 +58,7 @@ function isValidUrl(url: string): boolean {
@if (can_create()) { + @if (ai_enabled()) { + + } + @if (ai_enabled()) { + + }
} + @if (can_edit_with_ai() && isImage(media_item)) { + + } @if (sidebar_hidden() && can_update()) {