FANDEMiQ Back to Manager v10.60.0 live
FANDEMiQ Manager

Release notes

Everything that has changed in the platform, newest first. Each release says what happened and why, followed by the specific changes.

145
releases shown
631
logged changes
10.60.0 September 2026 Latest
Changed Fixed Under the hood

Three months of dependency updates, applied in one reviewed pass

Dependabot had opened 36 pull requests since June and nobody had opened any of them. Merging them one by one, blind, is how a quiet Wednesday turns into a broken build, so they were applied together on one branch in risk order, with the whole test suite run after each step, and the ones that turned out to be real migrations rather than updates were closed with a reason instead of forced through.

Changed
Entity Framework Core and the ASP.NET Core packages now match the .NET 10 runtime (10.0.11) instead of the 9.0.x versions that had been carried over. Microsoft.Data.SqlClient moves to 6.1.6.
Azure SDKs (Identity 1.21, Storage Blobs 12.29 / Files 12.27 / Queues 12.27, Data Protection Blobs 1.5.3, Resource Manager CDN 1.5.1), Hangfire 1.8.24, Swashbuckle 10.2, AWS Rekognition SDK 4.0, plus Newtonsoft.Json, Csv, MagicScaler, NetTopologySuite and Svg.Skia minors.
SkiaSharp's Linux native library now matches the managed library Svg.Skia pulls in (4.148). It had been pinned at 3.119 while the managed side moved on, which is the kind of mismatch that only shows up in the container.
GitHub Actions pins: checkout v6, azure/cli v3, buildx v4, build-push v7. The Manager image builds its assets on node 22 (the LTS line CI uses), not the node 25 Dependabot proposed.
Camera App: sass 1.100, Playwright 1.60. Manager assets: sass 1.99, util 0.12.5.
Dependabot now runs monthly and grouped — one minor-and-patch PR per project and a separate PR per major — so the queue stays reviewable.

Not taken, and why

Changed
ImageSharp 4.x enforces a Six Labors licence key at build time for Release builds (the Split Licence's commercial tier). Debug builds and the whole test suite passed locally, and every CI image build failed. Staying on 3.1.12 until a licence is bought — that is a purchasing decision, not a code one.
Application Insights 3.x is an OpenTelemetry-based rewrite; the platform's telemetry processors and initialisers do not exist in it. A migration, not an update.
Vue 2 → Vue 3 for the Manager SPA, jQuery 4, gulp 5, vue-cli 5 and TypeScript 7 for the Camera App. Each is a project in its own right.
actions/upload-artifact v7 needs its download-artifact twin bumped with it; deferred to be done as a pair.
Fixed
The mobile API's OpenAPI document generates again. Two upload actions declared [FromForm] on an IFormFile parameter, which Swashbuckle has refused since 6.5, so /swagger/v1/swagger.json answered 500 (development only; the deployed environments do not expose it). The attribute was redundant — a form file binds from the multipart body by default — and is gone.
Under the hood
The AWS SDK v4 made every numeric Rekognition response field nullable; the face service treats an absent value as 0, which is what the v3 SDK's defaults gave it.
Azure.Storage.Blobs 12.29 made the named-prefix GetBlobs call ambiguous; the blob helper spells the overload out.
10.59.0 September 2026
Changed Under the hood

The Marketplace stack is switched on — deploys now carry the Microsoft ids

Everything built for the Microsoft Marketplace listing — the buyer landing page with Microsoft sign-in, the fulfillment webhook, the metering and reconciliation jobs — has been in every release since 10.12.0, but the deploy never told the Manager which Entra tenant and apps to talk to. On staging and production the landing page answered "Setup isn't available just yet", the webhook rejected every token and the metering jobs returned quietly. All of it was live code doing nothing.

Changed
The deploy pins the Marketplace tenant and app ids on the Manager, in staging and production alike. Those ids are not secrets and are the same for both environments, because both talk to the one FANDEMiQ Entra tenant.
The fulfillment client secret comes from the MARKETPLACE_CLIENT_SECRET repository secret when it is present. Until it is, the deploy prints a warning and the landing page keeps its "not configured" card — nothing breaks, nothing is visible either. Setting the secret and re-running the deploy is the whole switch-on.
Under the hood
The mobile API test host no longer 500s an upload after another test host has been disposed. Hangfire keeps its log provider in a process-wide static that every test host overwrote from its own container, so a host serving its first upload after a neighbour had been torn down built its job client against a dead logger. The test host now pins a provider that owns nothing disposable and mocks the job clients, and a dedicated test proves a host survives its neighbour's disposal.
10.58.1 September 2026
Fixed Under the hood

Six defects the local end-to-end baseline turned up, fixed before the feature that found them

Building a full local test harness for the reusable-templates work meant driving every existing journey end to end with synthetic accounts, and that shook out a handful of real defects in code that has been live for months. None of them needs the templates feature, so they ship first, on their own.

Fixed
Declined terms no longer create content. An upload whose terms answer was an explicit "no" (acceptTerms: false, or a base consent module with granted: false) still created the Photo, Clip or Text row and put it through the pipeline. The three upload endpoints now return 400 before anything is written. Legacy clients that send no terms answer at all keep their existing behaviour.
Media Library update, delete and restore check the owner. PUT /spa-api/media/{id}, DELETE and POST …/restore authorised only that the caller had an account, not that the media belonged to it, and the update path accepted a new owner from the form. All three now 404 for another account's media; global (account-less) media is administrator-only.
FanMoment previews check the images belong to the campaign's account. The save, sync-preview, test-preview and media-bytes endpoints accepted any media id, and the queued test-preview worker never re-checked the account it had been asked to render for. Every path now verifies each image is an owned or shared, undeleted image before it is read, and the worker refuses a job whose config, campaign or event has moved or been deleted. The legacy synchronous preview no longer accepts Enhanced mode at all, since it had no charge acknowledgement to check.
A failed cut-out on a Story backdrop fails the render instead of hiding the fan. When a part has "remove background" and a frame, the frame is an opaque scene the fan stands in front of. If rembg failed, returned nothing or was not configured, the code fell back to the original photo with the cut-out flag off, so the opaque scene was drawn over the fan and the story "succeeded". Backdrop parts now throw; unframed cut-outs keep the lenient fallback.
Live FanMoment updates no longer try to serialise the whole entity graph. The Manager's notification hub pushed the tracked PhotoAITransformation (with its Photo, back-references and storage paths) to SignalR, which failed on the circular reference. A bounded DTO is pushed instead.
Scheduler batches are ordered. The minutely film, wall, audio-mix and test-preview sweeps took TOP n rows with no ORDER BY, so a backlog was drained in whatever order SQL Server chose. They are oldest-first now.
Under the hood
scripts/e2e-test/e2e.py no longer fabricates undecodable clip bytes and now fails when a required pipeline stage has not completed, rather than passing on a skipped stage.
10.58.0 September 2026
Added Changed Notes

Connect Claude to FANDEMiQ properly — OAuth, with two-factor required

Until now the only way to drive the platform from Claude was a tool running on one laptop that logged in by typing an administrator's email and password into the login page. That could never be given to a client, could never run anywhere but that laptop, and would have broken the moment two-factor authentication became compulsory. The Manager is now its own OAuth server: an app such as Claude registers itself, sends the admin through the normal FANDEMiQ login, and is granted access only to the accounts that admin ticks — so connecting an agent for one club does not hand it the other three. Nothing can be connected without two-factor authentication turned on, and if two-factor is later switched off, the connection stops working at its next token refresh.

Added
OAuth 2.1 authorisation server in the Manager (OpenIddict): /connect/authorize, /connect/token, /connect/revoke, /connect/register (RFC 7591 dynamic registration, rate limited per IP), plus /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource (RFC 9728) so a connector can discover all of it.
A consent screen (/connect/authorize) listing what the app will be able to do and which accounts it may work on.
Four scopes — fandemiq.read, fandemiq.write, fandemiq.publish, fandemiq.admin — that narrow, never widen, what the signed-in user can already do.
OAuthDbContext and its migration 20260916071656_AddOpenIddictTables, in the Manager database under its own history table; scripts/check-db-migrations.sh now checks it.
Tests/FilmilyManager.Tests/Integration/OAuthFlowTests.cs walks the whole flow over real HTTP.
Changed
/spa-api accepts an OAuth bearer token as well as the Identity cookie. Cookie requests are untouched, and every existing [Permission] check applies to both.
Authorisation-code PKCE is S256 only; plain is no longer offered. OpenID Connect is not offered and an openid request is refused rather than quietly downgraded.
Manager EF Core packages aligned to 9.0.6.
Notes
Decision record: docs/adr/ADR-006-oauth-and-mcp-hosting.md.
Still to come: mandatory two-factor for everyone, the hosted /mcp endpoint, and the wider tool set. /spa-api still has no antiforgery check on cookie-authenticated writes — unchanged by this release, and worth closing on its own.
10.57.0 September 2026
Added Fixed Changed Notes

The camera says when a photo needs another go, loads faster, and a paid FanMoment cuts the fan out once

Before a fan accepts a photo, the Camera App now checks it on their phone. If it is clearly blurry, too dark, washed out, or a file they have already sent this visit, one line on the review screen suggests taking it again. It is advice, not a bouncer: Accept always works, nothing measured leaves the phone, and there is no face or person detection. Separately, an Enhanced AI (Tier 2) FanMoment used to cut the fan out of their photo twice, once for a fallback picture it then threw away, so every paid moment spent about eleven seconds of the platform's slowest step on nothing. It now cuts out once. And the Camera App's scripts and styles were reaching fans uncompressed, about a megabyte more than needed on a stadium network; they are now compressed at the edge.

Added
Photo review advice in the Camera App (helpers/CaptureQuality.ts): variance of the Laplacian for blur, mean brightness and blown-out share for exposure, SHA-256 of the file for "already sent". Thresholds were calibrated on the photos in the repo and lean towards staying quiet; a picture under 64 px on its short side is not judged at all. Four new translatable texts under App Skin → App Text: CAPTURE_PHOTO_BLURRY, CAPTURE_PHOTO_DARK, CAPTURE_PHOTO_BRIGHT, CAPTURE_PHOTO_DUPLICATE.
tests/e2e/photo-quality.e2e.cjs, a seeded "Photo quality e2e" event in scripts/seed-e2e-events.sql, and synthetic photo fixtures.
Fixed
AISceneService cuts the fan out once per transform and loads the scene once; the composite is only built when it is the picture being delivered (Tier2SingleCutOutTests).
The Camera App's JS and CSS are compressed by Front Door. Production's camera route listed only image and video types, and staging's camera route did not cache at all, which Front Door requires before it compresses. scripts/ci/afd_camera_edge.py now keeps both routes caching and compressing on every camera deploy and purges them after each upload. Staging does not wait for the purge to report done (it took 17 minutes once), because staging's release check reads the storage origin directly; production still waits, because its check reads app.fandemiq.net through Front Door.
Local development: FilmAPI's notifications now reach the Manager (FilmilyManager__ApiRoot gained its /api), so a local FanMoment triggers its email job and live dashboard push like staging does.
The AV "ready" email claim is taken one at a time within a process. Production was already protected by a unique index, but the test database hands out ids when a row is added rather than saved, so the "lowest id wins" fallback could let two jobs through; that flaky test stopped this release's first staging deploy. SendReadyEmailsJob_WhenTheFirstClaimIsSavedLast_StillSendsOneEmail now forces the interleaving instead of waiting for it.
A staging deploy no longer fails on a single transient Azure error while checking the deployment order; the check retries the read up to four times and still stops the deploy if the state stays unreadable.
Changed
Advice under a review title (the new photo hints and the existing "no sound was recorded") lines up with the title instead of sitting centred under it.
Notes
The Enhanced AI cut-out still reaches OpenAI as a JPEG, so its transparent background arrives as solid black. That is now a decision, not an accident: a paid side-by-side (one picture each way) showed no dark halo, and sending a transparent PNG made the model redraw the admin's scene more. OpenAiImageEditRequestTests pins it.
A "similar picture" duplicate rule was tried and dropped: two selfies taken seconds apart measure almost identically, so only the exact same file counts as already sent.
10.56.4 September 2026
Fixed Under the hood

Deleted means stopped, for stories, films and share links too

After fixing FanMoments and fan walls we checked everything else a deleted campaign or event leaves behind. Several paths still ignored the deletion. The background job that retries unsent emails would still email a fan their personalised story or fan wall. The fan's share link kept working. A film or audio mix already queued still rendered. A Smart Films request picked up just after a deletion still planned a film, and that planning costs OpenAI money. A story recipe filtering on a deleted campaign kept making and emailing stories. That last one was deliberate until now; it now stops, as agreed. None of this was happening on production, where no content sits under a deleted campaign or event. Restoring a campaign or event brings everything back.

Fixed
Personalised stories and fan walls are not emailed, by the live handler, the retry job or an operator's resend, when their story recipe, wall, event or campaign is deleted. The retry jobs leave those rows out in SQL so they can't clog a batch.
Fan share links (story, fan wall, FanMoment) and their share images return 404 once the output or its campaign or event is deleted, or when an admin has deleted a FanMoment output.
Fan emails are no longer sent for a deleted event at all (EventEmailSender refused only events that no longer existed).
Queued films and audio mixes of a deleted campaign or event are held rather than rendered, and resume if it is restored. Smart Films refuses them with "This campaign or its event has been deleted".
A story recipe whose include or exclude campaign is deleted stops, instead of carrying on with that campaign's filter. A personal story already queued for one is dropped.
Admin "job finished" alerts for a deleted output, campaign or event are closed without an email.
Under the hood
Shared/Services/OutputLiveness.cs is the one definition of "live" (campaign, event, story recipe, FanMoment) that every one of these paths uses.
10.56.3 September 2026
Fixed

Fan walls and AV links forget a deleted campaign too

After the FanMoment fix we checked everything else that hangs off a campaign. Fan walls had the same gap. Deleting a campaign leaves its walls in place with their switches on, so a wall set to fill automatically, start the next wall in a series, or refresh every half hour kept doing it. The AV hand-off page also kept listing that campaign's walls and films and emailed the AV team when they finished. None of this was happening on production (no walls or films sit under a deleted campaign there), but it would have the first time someone deleted a campaign with a live wall. The automatic wall jobs and the AV page now treat a deleted campaign or event as "stop". Restoring the campaign picks everything up again.

Fixed
Mosaics: auto-fill, rolling-series and auto-refresh get an empty clip pool for a deleted campaign or event. A wall already queued is parked back in NeedsQueueing instead of rendering, and QueueMosaic holds it there while the campaign stays deleted. Personalised walls skip it too (new personal rows were already blocked).
AvHubQueryService: the output list, the per-wall scope check and the "ready" email lookup for walls and films all exclude deleted campaigns.
10.56.2 September 2026
Fixed

A deleted campaign stops sending FanMoments

Fans on one event got two FanMoment emails, one of them from a campaign deleted long ago. Deleting a campaign only marks the campaign as deleted; its FanMoment stays switched on. The live FanMoment job only checked the FanMoment's own switches, so the deleted campaign kept making its picture for every fan it matched, and the email went out with it. The job now ignores FanMoments whose campaign or event is deleted. The email sender and the re-delivery sweep also refuse pictures already made for one.

Fixed
AllData.GetActiveCampaignAISceneConfigsForEvent (the live FanMoment pass) and GetPhotosAwaitingFanMomentDecision skip FanMoments whose campaign or event is soft-deleted. Only the "process missing" path checked this before.
FanMomentService.SendFanMomentEmail returns the new Withdrawn outcome for a deleted FanMoment, campaign or event. The admin Resend button explains why, and RedeliverUnsentEmails leaves those rows out of its batch.
10.56.1 September 2026
Fixed Changed

A clip's "used in" list now stays inside your own account

The Clip Info popout asks the server which films, fan walls and audio mixes use a clip. That endpoint never checked whose clip it was, so any signed-in Manager user could type in other clip numbers and read the names of another account's films and fan walls. It now answers "not found" for any clip that isn't on one of your events, the same way every other clip action already did.

Fixed
GET /spa-api/clips/{id}/usage returns 404 unless the clip is on an event the caller can access (ClipUsageIdorTests).
The Clip Info popout ignores a failed usage lookup instead of leaving an unhandled rejection in the console.
Changed
The clip ownership check reads only the clip's event id instead of loading the whole clip with tags, snippets and geography; ClipService.Usage no longer loads a clip it never used.
10.56.0 September 2026
Added Changed

Photos can be deleted in the Clip Manager, and new fans always make the next wall

The Clip Manager only ever listed videos, so there was no way to delete photos in bulk, and the Content page never had a delete button at all (the help said it did). The Clip Manager now has Videos and Photos tabs: list, delete, restore and purge photos one at a time or in bulk, the same as clips. A photo still on a fan wall is refused on purge, and the reason is shown. Separately, on a rolling fan wall series that allows repeats, the fans who uploaded since the last wall now always get a tile on the next one; older clips only fill the space left, and the new fans are spread across the grid rather than grouped in the top rows.

Added
Clip Manager Videos / Photos tabs (spa/lib/contentManager.js, tests/unit/contentManager.test.mjs); bulk actions and their Undo act on the kind that was showing.
Purge for a deleted photo in the item popup (clips/purgePhoto), with the server's refusal shown when the photo is in use.
Changed
Reuse-allowed series walls choose uploads since the last wall first (Mosaics.BuildAutoParts preferCreatedAfter); AutoWallCentreAudioTests.ReuseAllowedSeries_NewestUploadsAlwaysGetATile failed before the change.
The item popup no longer asks the clips usage endpoint about photo ids, which looked up an unrelated clip.
Help: corrected where deleting happens.
10.55.0 September 2026
Added Changed

Fan walls have names, and the AV hub uses them

A fan wall can now be given a name, just like a story or a FanMoment. The AV hand-off hub shows that name instead of "Video wall #8", which told the AV desk nothing about which wall was which. A wall with no name is shown by its grid and length, for example "5x7 · 20s" (a photo wall just "5x7"), and the editor's Name box shows that default as its placeholder. Each new wall in a rolling series is named after the first wall with its number, "Goal cam (wall 2)", so a series never lists as several identical entries.

Added
Mosaic.Name (nvarchar(200), nullable; migration 20260915180005_AddMosaicName, add-only) and Mosaic.DisplayName, the one labelling rule.
Name field in the fan wall editor; the campaign's fan wall list shows the name.
Changed
The AV hub's wall cards, uploader view and "tile N" links on fan answers use the wall's display name.
10.54.0 September 2026
Added Changed Notes for whoever reads this next

Rolling fan walls, and the zoom never lands on a silent fan

A fan wall set to generate automatically can now keep going. Set Maximum walls and, once the first wall is built, another is built every time a full grid of new clips is available, until the maximum. With Exclude clips used in other fan walls on, each new wall only uses clips no wall has used yet; with it off, a new wall arrives after every full grid of uploads and clips may repeat. Each wall is its own fan wall, so earlier walls, their AV hub entries and share links are never overwritten. Separately, an automatically filled wall no longer zooms onto a silent clip: the fill now puts a clip with sound in the zoom position, closing the gap that sent prod's mosaic 8 fading into silence. A new Zoom to a random fan each time the wall is generated option picks a different fan with sound on every render.

Added
Mosaic.MaxWalls, SeriesParentId, PartsAssignedAt, RandomZoomEachRender (migration 20260915165943_AddFanWallSeriesAndRandomZoom, add-only).
FilmilyShared.Services.AutoWallPlanner: the silent-centre swap, the random zoom pick (skips silent clips, photos and tiles hidden under a scaled tile) and the next-wall rule, capped at 50 walls.
Mosaics.StartNextSeriesWalls runs in the minutely CheckPendingMosaics scan, one new wall per series per scan. Mosaic.CopyForSeries() copies every setting; a reflection test fails if a new column is not copied.
Maximum walls and random-zoom controls in the fan wall editor; help updated.
Changed
Mosaics.BuildAutoParts (auto-fill) swaps a clip with sound into the zoom slot. AutoWallCentreAudioTests fails on the old blind shuffle.
Auto-refresh no longer rebuilds the first wall of a rolling series in place.
A random-zoom wall is not refused on save for its stored zoom target; the render picks a clip with sound.
Notes for whoever reads this next
A rolling "reuse allowed" wall picks its tiles at random from every usable clip, so new uploads are not guaranteed a tile on the next wall. With exclusion on, every new wall is made only of clips not yet used.
Every wall in a series carries the first wall's personalised-wall and email settings. Whether a fan on two walls gets two personalised walls has not been checked end to end.
Local verification used seeded clips with no media files, so the series walls were created and filled correctly but their renders failed on missing inputs. A real render of a series wall has not been run.
10.53.0 September 2026
Added Notes for whoever reads this next

The AV hub now shows what fans are saying — and, on an email link, who said it

Every AV hand-off link now carries a "What fans are saying" panel: the question, a one-line headline and up to five themes with their share, read straight from the same cached answer summaries the Manager's Answers report and the live dashboard already use — nothing here ever asks the model to build a new one. On a link with Show uploader emails switched on, each theme also carries a couple of verbatim quotes, and an "Answers" toggle opens the individual transcribed answers with a filter box, so the AV desk can pick a fan to feature. Each answer links straight to the tile it appears on in any fan wall, opening the uploader view already scrolled to and highlighting that tile. The uploader view itself now shows each fan's own answer under their tile, and the tile search matches it. Every piece of AI-written or fan-spoken text — theme labels, the headline, quotes, transcripts — is screened by the same rule the camera app's typed answers and live dashboard already use: the account's profanity list first, then Azure AI Content Safety when it's configured, with anything flagged or unscreenable simply left off the page.

Added
GET /av/{token}/answers: themes/headline per question with a stored summary in the link's scope; quotes only on an email link.
GET /av/{token}/answers/{ctaId}/responses?cursor=: paged (50) individual transcribed answers with uploader email, upload time and the wall/tile locations the clip appears at. 404 without the email switch — the query never touches transcripts or emails on that path.
GET /av/{token}/walls/{id}/uploaders now carries each tile's own screened transcript (email links only); the uploader search matches it too.
FilmilyShared.Services.ContentScreeningService / AzureContentModeration (moved out of PublicAPI's ProfanityService): one screening implementation shared by PublicAPI (typed answers, live dashboard) and the Manager (AV hub), so a screening fix only has to be made once.
deploy-apps.yml pins the same MSContentModeration__* secret onto ca-manager-* as ca-publicapi-*, so the AV hub gets the same AI content-safety check.
ContentScreeningService.ScreenMany: the AV hub screens all of a page's text in one batch. It reads the account's term list once, reuses clean/flagged verdicts for 10 minutes (an unchecked one is never cached), and runs Content Safety calls in parallel, at most 8 at a time. Screening a 250-tile wall's transcripts one by one would have been 250 database reads and 250 sequential paid calls per page open. PublicAPI's single-text Screen is unchanged. A deleted clip's answer is no longer offered for featuring.
Notes for whoever reads this next
The Manager's AV hub had no Content Safety config at all before this release — its screening fell back to each account's profanity word list only, same as PublicAPI before v10.50.1. deploy-apps.yml now pins the same key to both, but until a deploy carrying this change runs, the AV hub's screening is word-list-only in every environment.
10.52.3 September 2026
Changed

Oops, all dressed up and nowhere to go!

A revoked or expired AV link used to land the AV team on the browser's bare "page can't be found". It now shows a FANDEMiQ page that says so with a smile and tells them to ask the event team for a fresh link. Deliberately, it is the same page for every dead link, whether it was revoked, expired, regenerated or never existed, with no account name or branding on it. A mistyped or guessed link therefore reveals nothing about which links once worked or whose they were, and it still answers with a 404. The open hub's own "no longer active" message now uses the same wording.

Changed
AvHubController.Index renders Views/AvHub/LinkGone.cshtml with status 404 (plus noindex and no-referrer) for every unresolvable token. AvHubControllerTests.DeadLinks_ShowOneBrandedNotFoundPage_IdenticalForEveryReason pins byte-identical bodies across revoked, expired, tampered and malformed tokens.
10.52.2 September 2026
Fixed

Staging's clip stills and media links work again

On staging the fan wall editor showed a broken image on every tile, although each clip's stills were in storage. Every staging deploy had been writing an empty storage address to the three staging apps. So every signed media link (clip stills, wall thumbnails, downloads) pointed at the Manager's own site instead of blob storage. The staging configuration step had been split in two to stay under GitHub's size limit, and the storage address stayed defined in the first half while only the second half used it. Each step runs in its own shell, so the second half saw nothing, and nothing complained. Production defines and uses it in the same step and was never affected.

Fixed
deploy-apps.yml defines STORAGE_URI in the "Apply staging container app core configuration" step that uses it.
New CI check scripts/ci/check-workflow-step-vars.py: fails the build when a workflow run: step reads a shell variable it never defines, so splitting a step can't silently blank a setting again.
10.52.1 September 2026
Fixed

A duplicated fan email no longer blocks every upload from that fan

On staging, every upload using one tester's email failed, while uploads with any other email worked. Two identity records shared that email, and the upload looked the fan up expecting exactly one. With two it threw "Sequence contains more than one element", after the clip or photo row had already been saved. So the fan saw a failed upload, and an uploader-less row was left behind. Duplicates can happen without a bug in that lookup (two first uploads at the same moment, copied accounts), so it now uses the oldest matching identity and logs a warning naming both, so they can be merged. The staging duplicate has been merged.

Fixed
DataContent.FetchOrCreateIdentity takes the oldest identity matching the email (or phone) instead of SingleOrDefaultAsync, and logs Duplicate identities match one email with both ids. Covers clip, photo and text uploads and ApplyAccountSettings.
10.52.0 September 2026
Added Fixed

The AV hub gets a control-room look, and shows who is on each tile

The AV hand-off page has been rebuilt from the "AV Hand-off Hub" design: dark by default for a control room, the account's logo and brand colours in the header, Ready outputs first and loudest, walls grouped by event on a group link, a pulse on anything that became ready while the page was open, and clear states for "nothing yet", "couldn't refresh" and "this link is no longer active". Every fan wall now has View uploaders: its tiles in order, each with a still and upload time, the tile the wall zooms into highlighted, and a search by tile number, so the AV desk can match the tile on screen to a person on the day. Uploaders' email addresses appear only on links created with Show uploader emails. Anyone holding such a link sees every uploader's email, so it can stay open at most 48 hours after the event ends and says "don't forward it" at the top.

Added
AvShareLink.ShowUploaderEmails (migration 20260915073232_AddAvShareLinkUploaderEmails, add-only, default off), set from the AV hand-off panel's Show uploader emails switch and marked on the link in the list.
GET /av/{token}/walls/{id}/uploaders: tiles in wall order, upload times, the zoom target (ZoomTarget.EffectiveIndex, the renderer's rule), and each tile's uploader email only when the link's switch is on. The no-email query never selects the email column. no-store, token-gated, 404 outside the link's scope.
GET /av/{token}/walls/{id}/tiles/{n}/still.jpg: thumbnail-sized tile stills, under their own per-IP limit so a 250-tile wall can load.
The hub shows the account logo from branding (same-host or https only, falling back to initials on a white plate with ink that stays readable on any brand colour).
Fixed
Creating an AV link whose expiry was already in the past (the default for an event that ended weeks ago) produced a URL that 404'd from the first click with no explanation. It is now refused with a reason.
10.51.1 September 2026
Fixed

The staging Camera App address opens again

stagcam.fandemiq.net sat on its loading dots for ever: it was never on staging PublicAPI's CORS allow-list, so the browser refused the app's very first API call. Testers had to use the raw storage address instead. Staging only; production's camera address was never affected.

Fixed
deploy-apps.yml adds https://stagcam.fandemiq.net to Cors__AllowedOrigins on ca-publicapi-staging.
10.51.0 September 2026
Added Fixed Notes

Hand the AV team a link, not a login

Whoever plays the fan wall on the stadium screen is rarely whoever set the event up in the Manager, and until now the only way to get them a video was to wait for the render and forward a signed-in download link by hand. An admin can now create an AV hand-off link for one event or one event group — no login, no PIN — from a new AV hand-off panel on the event editor (Sharing tab) and the event group editor. The page it opens (/av/{token}) lists every full wall and film in scope with a thumbnail, its status, and MP4/ProRes/MXF downloads that refresh themselves every 30 seconds and always resolve to the latest render. When a full wall or film finishes, everyone on the link's notify list gets an email. While building this we also found and closed a real gap: FilmsApiController.FilmFile had no ownership check at all, so any signed-in user could download any account's film by guessing its id.

Added
AvShareLink (Manager DB): scoped to exactly one event or one event group (DB check constraint), a random salt, a notify list and an expiry (event/group end + 14 days by default). The token is base62(id)-HMAC(id, salt) using the same signing key and HMAC helper as /share links, but with no legacy-unsigned fallback — an unconfigured Share:LinkSigningKey means AV links can be neither created nor served.
AvHubController (/av/{token}, /av/{token}/outputs, /av/{token}/walls|films/{id}.{ext}, /av/{token}/walls/{id}/thumbnail.jpg): anonymous, rate-limited per IP, noindex/no-referrer, and a versioned footer. Invalid, expired, revoked and out-of-scope requests all answer the same 404.
AvLinksApiController (spa-api/av-links): create/list/update/revoke/regenerate, scoped through EventService.CanAccessEvent.
AvOutputFileService: the quality/fallback logic (hq/web falls back to standard, broadcast 404s when missing) that used to be duplicated between MosaicApiController.MosaicFile and FilmsApiController.FilmFile is now one shared service both controllers — and the AV hub — call.
A ready email (avready.html) to the link's notify list once a full wall or film completes, deduplicated per (link, output, render version) so a re-render emails again but a Hangfire retry never double-sends.
The AV hand-off panel (components/AvHandoffPanel.vue) on the event editor's Sharing tab and the event group editor: lists the scope's links with their URL, expiry in local time, status (active/expired/revoked), last opened and notify list; creates a link (name, notify emails, expiry pre-filled with the server's default); copies the URL with a visible "Copied" (or selects it when the clipboard is unavailable); edits emails and expiry; revokes and regenerates after a confirmation. Every failed request, including "signing key not configured", shows inline in the panel. Hidden behind "Save first to create AV links" until the event or group exists. Its date, status and email rules live in spa/lib/avLinks.js with node:test coverage.
FilmilyManager/Help/15-av-handoff.md.
Fixed
FilmsApiController.FilmFile had no ownership check — any signed-in user holding campaigns.view could download any account's film by id, unlike every other action in the same controller. It now checks the caller can access the film's event, mirroring MosaicApiController's existing guard.
app.UseRateLimiter() was positioned before routing ever matches an endpoint in this app's implicit-routing pipeline, so [EnableRateLimiting] was invisible to it and the AV hub's per-IP limit silently never applied to a single request. Moved to after UseAuthorization(), immediately before the Map* calls.
EventService.HardDeleteEvent removed the event row without first removing its AvShareLinks, which have a Restrict FK to Events/EventGroups — hard-deleting any event with a live AV link threw a foreign-key violation instead of deleting it. scripts/copy-account-to-staging.py's account reset had the identical gap for any staging account that had ever had one.
The AV hub and its ready email now ignore a soft-deleted event, a soft-deleted event group, or an event whose account is soft-deleted — re-checked on every request, not cached from when the link was created, so a deleted event stops serving an old link immediately rather than only once the link itself expires.
The hub page could never have listed anything on staging or production. It is served in the share pages' sandbox (an opaque origin), so its own poll of /outputs is a cross-origin request, and the deployed CORS policy only allows the Manager's own origins; locally the permissive Debug policy hid it. /outputs (and a rate-limited /av request) now carries Access-Control-Allow-Origin: *: it is token-gated and sends no cookies.
The hub page turned any failed poll into an empty list ("Nothing to show yet"). It now says when a link has been revoked or has expired while the page was open, and when a refresh failed and it is retrying.
Wall names and event titles are typed by admins and shown on a page that needs no login: the hub now sets them as text rather than building markup from them, only emits brand colours that are valid hex values, and the ready email HTML-encodes them.
Downloads from the hub carry the same file name as the Manager's own download instead of the blob's GUID.
Hub thumbnails are served card-sized (the medium image the Manager's wall list uses) rather than the full still or a photo wall's HQ PNG.
The hub's per-IP limit is 300 requests a minute, not 60: one page load is two requests plus one thumbnail per wall, and a venue's AV crew usually shares an IP.
deploy-apps.yml now sets Website__Root on the Manager apps. It was set by hand on both environments and read nowhere in the workflow; the ready email's links depend on it.
Notes
The Azure Blob SAS URL a wall/film download redirects to is time-stamped fresh on every single call (AzureBlobHelper.GetSASQueryString's ExpiresOn = UtcNow.AddYears(1)), so a re-render is picked up on the very next request without any extra cache-busting — verified in AvHubControllerTests.WallDownload_RedirectsWithNoCache_AndTargetChangesBetweenCalls.
The ready email's dedupe row is now claimed before sending, not written after. Two jobs for the same completion (the email server runs them side by side) both passed the old check and both emailed; on SQL Server the unique index now turns the second claim into a skip. A failed send releases its claim so the Hangfire retry still emails. The email also refuses to send, and fails the job, when Website:Root is not an absolute URL: its per-format download links were relative (/av/...), which open nothing from an inbox; they are now absolute like the hub button.
10.50.1 September 2026
Fixed Added Notes

The AI content-safety check on fans' typed answers finally runs

The Help page has always said every typed fan answer gets an AI content-safety check. It never did. Production carried an old all-purpose Azure key that works for image analysis but is refused by Content Safety, staging had no key at all, and neither had the service's address, so every check failed quietly and a failed check was treated as "clean". Typed answers were only ever checked against each account's own word list. Each environment now has its own Content Safety resource, the deploy wires it to PublicAPI, and a check that cannot be completed now refuses the answer and asks the fan to try again instead of letting it through. The live dashboard's answer themes use the same rule.

Fixed
ProfanityService.Screen is the one screening rule (term list, then Content Safety when configured): true = profane, false = clean, null = could not check. ProfanityService.Refusal maps null to a new TEXT_NOT_CHECKED response (HTTP 503) whose message never contains "profanity", so the camera does not mislabel it. DashboardTextScreen delegates to it.
MSContentModeration logs the HTTP status of a failed call and the exception of a thrown one; both used to vanish.
Added
deploy-apps.yml: "Pin staging content safety" / "Pin prod content safety" steps set MSContentModeration__URI (cs-fandemiq-{env}.cognitiveservices.azure.com) and the mscontentmod-apikey secret from CONTENT_SAFETY_KEY_{STAGING,PROD}.
PublicAPI logs a startup warning outside local dev when Content Safety is not configured.
Notes
A Content Safety outage now blocks typed fan answers until it recovers (Andy, 2026-09-14). Photos, clips and consent are unaffected.
10.50.0 September 2026
Added Changed

"What fans are saying" on the Live Dashboard

Ask fans a question on video and the big screen can now tell the crowd what they said. When an event on the Live Dashboard has transcription on, a new panel sits between brand exposure and value out: the question, the top themes from fans' spoken answers with their shares as bars, and a one-line read of the set, one question at a time. Nobody has to press "Build summary" mid-match: a background job rebuilds each question's summary once it has 5 answers, and again when the answers have grown by a fifth, at most every 5 minutes, which caps a busy question at twelve AI calls an hour. It deliberately never shows a fan's own words. The dashboard's link is on stadium screens, so the endpoint does not send quotes at all, and the theme names and summaries, which the AI writes from what fans said, are checked against the account's profanity list (and the content-safety service where it is configured) before they reach the screen. Anything flagged, or anything that could not be checked, is left off.

Added
LiveAnswerSummaryJob (Manager, Hangfire recurring LiveAnswerSummaries, every 2 minutes): rebuilds answer summaries for events with live dashboard stats on, transcription on and the upload window open (plus a 30-minute tail). Spend rule in ShouldBuild; thresholds under LiveAnswers:* (Enabled, MinAnswers 5, GrowthPercent 20, MinGrowth 5, MinMinutesBetweenBuilds 5, TailMinutes 30).
GET /api/Dashboard/answers (PublicAPI, dashboard token): screened summaries for the account's live-dashboard events, top 5 themes, no quotes property anywhere in the response model.
FanAnswers.vue tile on the Live Gallery and TV mode (four themes on the TV stage), polled once a minute; the brand band keeps its two columns when there is nothing to show.
Changed
CtaAnswerService.BuildSummary split into the checked admin path and an unchecked BuildSummaryForLiveDashboard used only by the job. The summary version constant now lives on CtaAnswerSummary so PublicAPI skips superseded summaries by the same rule.
Help: Content, moderation and the Live Dashboard describes the new panel.
10.49.4 September 2026
Added

A venue-only event is part of the local seed

No product change. The venue-only browser check used to need a flag flipped by hand on the local test event before each run and flipped back after. The local seed now creates a second event, "Venue-only e2e", with the flag on, a Chicago map box, a text activity and a photo activity, so the check runs cold. It is idempotent and keyed on the title, and it re-asserts the properties the check relies on if a local admin has clicked them off.

Added
scripts/seed-e2e-events.sql, applied by scripts/dev.py (start-all) after the permissions seed; tests/e2e/geofence.e2e.cjs defaults to that event's text activity, restoring the original "a venue-only text answer still shows the permissions step" assertion.
10.49.3 September 2026
Changed Fixed Added Notes

The camera's browser tests reach the permissions screen again

No product change. Two of the camera's end-to-end checks, the venue-only refusal and the consent step never scrolling, had been failing before they tested anything: they were written before the activity chooser arrived (v10.36.2) and clicked the wrong thing on an event with more than one activity. They now share one walk with the location-wait check, all three pass against the local stack, and any future e2e that needs the permissions screen uses the same helper.

Changed
tests/e2e/lib/walk.cjs (reachPermissions: event list → activity chooser by title → permissions) used by geofence, consent-frame and location-wait; EVENT_TITLE / CTA_TITLE pick the event and activity.

Every camera clip uploaded to production since Saturday stalled on its first pipeline step

The real reason the two clips from 13 September never reached the Manager. A phone's recording usually carries no duration in its container, so the first job in the clip chain ("store the clip's length") fell back to probing the web copy — which does not exist until the next job makes it. Since v10.40.0 (12 September) the media runner treats a missing probe input as an error rather than a shrug, so that first job failed, Hangfire retried it every hour or two, and because a failed job is not a finished one nothing after it ever ran: no web copy, no stills, no analysis, and the clip was never marked processed. The probe now answers "unknown" when neither the upload nor the stream says, the web-encode step stores the real length once the web copy exists, and the two stuck clips pick up from the failed job on their next retry.

Fixed
FFProbeFunctions.GetVideoDuration: container duration → stream duration → web copy, and a missing web copy is 0, not an exception. FFMPEG.CreateWebVersionOfClip stores the length from the web copy when the clip has none. ClipLengthProbeTests pin all of it (they fail on the previous code with FileNotFoundException).

A clip uploaded without a location was invisible to the Manager — and the Live Gallery thought every upload was an hour old

Two faults found from one upload on 13 September. First, and the serious one: a clip uploaded with the Location slider off never appeared in the Manager. The pipeline's "approximate location" step only matched clips that had location samples; with none it returned nothing, the chain carried on happily, and the clip was never marked as processed. Every clip search in the platform is gated on that mark, so the clip stayed out of the event's counts, content browse, campaigns and fan walls for good. Photos already had a "no GPS, location skipped" path; clips now take the same one, and a one-off backfill catches up every clip already stuck that way. Second: the Live Gallery showed a minute-old clip as "1h ago". The API sent the upload's created with no time-zone marker and the browser read it as local time, an hour out on a British Summer Time screen. Every dashboard timestamp now ends in Z, and the page treats a marker-less one as UTC anyway.

The app now waits for the phone's location, twice, briefly

Saying yes to location does not give the app a position: the phone produces its first fix later, indoors often ten to thirty seconds later, and until now nothing waited for it. A fan could switch the slider on, record straight away and upload a moment with no position at all, which is how the clip above came to have none. Now, after the Location slider is switched on, NEXT holds for up to two seconds ("We're still finding your location") and releases the instant the fix lands. And at SUBMIT, once the fan has committed by recording, the button holds for up to twenty seconds with the same line if the fix still has not arrived. A fix that arrives during that wait is attached to the upload. After the ceiling the moment goes up without a position: a wait must never cost a fan their moment. The location watch also no longer switches itself off after seven seconds without a fix, which was why a slow indoor fix could never arrive at all.

Added
helpers/LocationWait.ts (the two ceilings), SensorDataRecorder.awaitFix/hasFix, WizardStore.locationExpected/awaitLocationFix, withLateFix in SensorDataConverter.ts; the GENERAL_LOCATION_WAIT app text (camera default, Skin seed, Translation Manager editor). consent-submit.test.cjs and upload-location.test.cjs cover the hold order and the late fix.
tests/e2e/location-wait.e2e.cjs (Playwright, local stack): no fix ever → NEXT released at the 2 s ceiling; immediate fix → no hold; fix after 900 ms → released then; fix after 14 s → NEXT releases at its ceiling, the fan takes a photo, SUBMIT holds with the line until the fix lands, and the upload's metadata carries it. It stubs the browser's position watch in the page because headless Chromium with the permission granted hands out a position instantly.
Two more found by that e2e and fixed: tapping an already-granted Camera row switched LOCATION off (it called "deactivate location" whatever row was tapped), and a slow first fix was recorded as a refusal (the permission request marked location "denied" on its 7 s timeout), which would also have switched the SUBMIT hold off for exactly the fans it is for.
Fixed
DataContent.ApproximateMetaData marks a clip with no location samples as approximated (no coordinates, no map position) instead of skipping it; ClipApproximationTests pin the no-location, located and unknown-clip cases.
Content migration 20260913192054_MarkLocationlessClipsApproximated (data only): every processed clip with no Location rows and HasBeenApproximated = 0 is flipped to 1. Apply with scripts/migrations/20260913-locationless-clips/apply.sh <staging|prod> --apply; its pre-flight prints how many clips were affected.
DashboardUpload.Created and DashboardEvent.StartDateTime/EndDateTime normalise to DateTimeKind.Utc through UtcTime.Normalise (EF materialises datetime2 as Unspecified, which System.Text.Json writes without a zone); DashboardStatsTests pin the serialised Z.
parseUtc in helpers/dashboard/LiveStats.ts replaces Date.parse in the dashboard store for serverTimeUtc and every created; live-stats.test.cjs covers zone-less, Z, +01:00 and junk.
Notes
Manager event and campaign cards already refresh their counts on the minute while they are on screen, and the server caches each event's counts for one minute, so a new upload shows within about two minutes without a page refresh. A count that is still wrong after that is a pipeline problem, not a refresh problem.
10.49.0 September 2026
Added

TV mode for the Live Gallery

The same page, restyled for a television from the "Live Dashboard TV" design: a fixed 1920×1080 stage scaled to whatever screen it is on (a 4K set gets it at 2×, a 720p one at two-thirds), bigger type throughout, and nothing that needs a cursor — the map walks through its scopes on its own every 14 seconds ("auto view · UK") and calls out a pin every 7 seconds with the event's uploads and rate, the way a mouse hover would. Feed cards carry the upload's reference number. It switches itself on when the browser looks like a TV (a Smart TV, Fire TV, Chromecast or Android TV user agent, or a big landscape screen with no pointer), and can be forced either way with ?tv=1 or ?tv=0 on the dashboard address, which is then remembered on that device.

Added
helpers/dashboard/TvMode.ts: the decision (?tv → remembered choice → TV user agent → big screen with no hover pointer), the stage scale, the scope cycle and the callout placement; tests/unit/tv-mode.test.cjs pins them.
#stage.tv overrides in style/dashboard/gallery.scss — the design's second style block, with rows that clip instead of spilling when the KPI band is too short for four rows.
Layout test case for the TV stage; e2e pass for ?tv=1 (stage, auto view, callout, remembered) and ?tv=0.
10.48.1 September 2026
Fixed Changed

The live feed actually connects, and the screen never reloads itself

On production the Live Gallery's socket never established: behind Azure SignalR Service the hub has no ambient HTTP request, and the hub read its token from one, so every connection threw. The client retried for a minute, gave up, and the old last-resort page reload fired — which also threw away the scope you had chosen on the map. The hub now reads the connection's own HTTP context (the same in both modes), and the page never reloads: it polls the snapshot every minute while the socket is down, retries the socket until it returns, refreshes every ten minutes even when live so a missed push cannot drift the counters, merges each refresh into the numbers on screen rather than remounting the cards and pins, and remembers the map scope.

Fixed
DashboardHub.AccountKey() uses Context.GetHttpContext() before IHttpContextAccessor; DashboardHubTests pin the Azure SignalR shape (no ambient request), the in-process shape and the no-token paths.
The camera store no longer calls location.reload(); applySnapshot merges events, brands and the feed in place; the chosen scope is kept in local storage.
Changed
The pipeline pill reads "Connecting live feed" / "Reconnecting live feed" / "Live feed down · refreshing every minute" so it is not mistaken for the LIVE pill, which is about the events.
10.48.0 September 2026
Added Changed Removed Under the hood

The Live Gallery

The live dashboard — the public page at your Camera App address plus /dashboard, for the big screen at the ground or a sponsor's phone — has been rebuilt from the "Live Dashboard" design, band for band. Across the top: the FANDEMiQ mark (click it for full screen), a LIVE pill that reads STANDBY when no event is accepting uploads, how many events are in play, the clock, a sparkline of uploads per minute for the last twenty minutes, uploads per minute, uploads, fans contributing, and a pipeline pill that says how long the last uploads took to reach the screen. Below it, a map of where the content is coming from — a pin per event sized by its uploads, the real upload positions dotted around each venue, scope buttons for Active events / Global / the regions your events are in, and a strip of event cards with each event's count and rate — beside a "Landing now" column where every finished upload lands as a card as it happens. Underneath, "Brand exposure" tiles sized by each recognised logo's share of detections with the last hour's change in share, and "Value out": earned media value by exactly the maths the Reports page uses, branded impressions, social echo, and how many FanMoments, stories, fan-wall frames and AudioMix clips the platform has made from the uploads. It still shows numbers, never fan content: cards name the event, not the fan, and no picture is put on the screen.

Added
GET /api/Dashboard/stats now also returns the events in play (title, country, map centre, live, uploads, rate), uploads per minute for the last 20 minutes, uploads today, fans contributing, the median upload-to-processed time over the last 30 minutes, brand exposure with last-hour and previous-hour counts, the output counters, the value-out figures and the newest uploads to seed the feed. The pre-v10.48 fields are unchanged, so a cached camera build running the old page keeps working.
Clips reach the dashboard as they land. FilmAPI's clip chain has always ended in a "report finished" step, but its target URL was hard-coded to null, so only photos were ever pushed; it now reads the same FilmilyContent:PublicApiRoot as the photo chain, sends the internal secret, and PublicAPI's GetClipUpload (previously NotImplementedException) builds the push from the clip's stills.
Each push and each feed entry carries its event, kind (photo / clip) and upload time; the page measures pipeline latency as arrival minus upload time on the server's clock.
The socket reconnects on its own (with a snapshot refresh to catch up) instead of reloading the page ten seconds after any drop.
Changed
SponsorValuation (Shared) is now the one home of the sponsor-value maths — impressions ÷ 1,000 × CPM × quality, quality = prominence × sentiment, case-insensitive brand grouping. The Manager's Reports page and the live dashboard both hand their rows to it, so the two screens agree to the penny. SponsorValueApiTests are unchanged and green.
UploadCount on the stats response is now the count of uploads on the events in play; it used to be the count of uploads that had a location.
Map points carry their event, so an event with no saved map location is pinned at the centre of its uploads.
Removed
The old dashboard widgets (Google Maps heatmap, word clouds, age bars, gender split) and with them the camera's Google Maps key and the wordcloud dependency. The map is d3-geo over a bundled world outline (world-atlas 110m), so a venue with a locked-down network still draws it.
Under the hood
Camera: helpers/dashboard/LiveStats.ts holds every pure derivation (sparkline re-alignment on the server's minute grid, live rate with the server's rate fading out over the first minute, brand shares and trends, map scopes and label placement inputs, the pipeline verdict); store/DashboardStore.ts folds pushes into the snapshot; components/dashboard/{LiveHeader,Sparkline,EventMap,LandingFeed,BrandTiles,ValueOut}.vue are the design's bands; style/dashboard/{tokens,gallery,icons}.scss are the design-system tokens and the design's own CSS.
Tests: DashboardStatsTests (PublicAPI, 15 cases on the InMemory provider — scope, counts, series, windows, outputs, value, feed, pushes); live-stats.test.cjs (19 cases); dashboard-layout.test.cjs lays the real markup out in Chromium at 1920×1080, 1366×768 and 1024×768; tests/e2e/live-dashboard.e2e.cjs drives the real page against the real API, then a staged snapshot plus pushes, and takes the screenshots.
The raw tag SQL in the old stats query is now LINQ, so the whole snapshot runs on the InMemory provider under test.
10.47.0 September 2026
Added Changed Under the hood

Florence sets the event up

Describe the event to Florence — "home game v Arsenal, 14 March, Etihad, we want a fan wall and a FanMoment in the new kit, ask fans for their player of the match" — and it drafts the whole set-up: the event with its upload window and country, the questions fans will be asked (with record lengths and tags), the campaigns and what each is for, a fan wall that starts itself when enough clips arrive, and a FanMoment. The plan appears in the chat as a review card you can edit field by field. Nothing is created until you press Create; then everything is made through the same services as the Manager's own forms, with links to each thing, a list of what still needs a person (the map location, the FanMoment base image and test preview), and an audit row recording who confirmed it, the brief and the plan as applied. A generative FanMoment shows its cost per fan and cannot be created until the acknowledgement on the card is ticked. Florence can also add questions and campaigns to an event you have already created, as long as it is not live and nobody has uploaded yet — live events must be stopped by hand first. The same three steps are available to Claude Desktop and other agents through the MCP admin server.

Added
draft_event_setup tool and POST spa-api/florence/setup/draft — one JSON-mode model call with the account's context (existing groups, how its recent events asked their questions, the Tier 2 unit price), validated by EventSetupValidator, cached two hours. Writes nothing.
Review card (FlorenceSetupCard.vue) rendered from a ```setup block carrying only the plan id: editable event, questions, campaigns, FanMoment tier with cost and acknowledgement, fan wall size; "Why these choices" and "Still needs a person"; Create / Apply changes; result with links.
POST spa-api/florence/setup/{planId}/apply — requires events.manage and campaigns.manage; creates event + questions + campaigns + FanMoment draft (off until previewed) + auto-start fan wall; refuses a generative FanMoment without the acknowledgement, live events, events with uploads, and another account's event; records an AssistantActions row. Assistant:SetupApplyEnabled=false switches creation off on an environment while leaving drafting on.
MCP admin server: ask_florence, draft_event_setup, get_event_setup_plan, apply_event_setup (acknowledging charges is an explicit boolean the agent must not set without a human).
A "Set up a new event for me" starter in the panel.
Changed
Migration 20260912213414_AddAssistantActions creates the audit table and, as planned in v10.38.0, drops EventTheme.BannerMediaId (unmapped since v10.39.1; no running image references it). Apply before deploying, as with every migration.
Under the hood
Services/Florence/Setup/: EventSetupPlan (the one JSON contract for the model, the card and MCP), EventSetupValidator, EventSetupPlanner, EventSetupApplier. The applier is deliberately not one transaction across services: if a later step fails the result names the step and lists what was already created rather than hiding it.
Tests: FlorenceEventSetupTests — draft writes nothing and normalises; apply creates everything through the forms' services and leaves the audit row; generative needs the acknowledgement and records who ticked it; edit adds to a draft event and can never be re-pointed at a live one; unknown plan and switched-off environment are refused; validator rules. spa-v3: setup block parsing.
10.46.0 September 2026
Added Under the hood

Florence on every page

Florence lived behind the Reports link. Now the same launcher sits on every page of the Manager — the events list, a campaign, the fan wall editor, the App Skin page, the reports dashboard and the platform admin pages — and it knows where you are: on an event's page, "how is this event doing" and "who uploaded most here" need no name. Opening the panel shows a "What's working" card before you ask anything: the last 30 days against the 30 before, the standout event of the last 90 days against your average, where uploads came from, how many fans have come back to more than one event and how many are on the mailing list, and today's AI Insights ideas when they have already been generated. Every line is a click away from the question behind it.

Added
Florence widget (spa-v3/src/widget.ts) — a second Vite entry that mounts the launcher and panel beside the Vue 2 Manager SPA and the MVC layout, resolved from the Vite manifest like the dashboard itself. On admin pages it follows the account you last opened; the API re-checks access on every call.
Page context — each turn carries {path, eventId, campaignId} read from the URL; the server names the event in its prompt only if the caller may see it.
GET spa-api/florence/digest — the "What's working" card. Deterministic and cached six hours per account; it never triggers a paid model call, so it works with no OpenAI key and only shows ideas AI Insights has already produced today.
The reports dashboard mounts Florence from the app shell, so it is on every spa-v3 route.
Under the hood
Services/ViteManifest.cs is the one manifest resolver for the host pages (the AppV2 model's ParseManifest delegates to it, keeping its unit test). Pages/Partials/_FlorenceWidget.cshtml is the MVC mount.
Tests: page context names the current event and never a foreign id; the digest summarises this tenant only and makes no model call (strict mock); florenceContext.test.ts pins the URL parsing for both SPAs.
10.45.0 September 2026
Added Under the hood

Florence draws the answer

A split with a dozen rows read as a wall of numbers in a chat bubble. Florence now draws it: when the answer is a split, a trend or a share, the reply carries a chart drawn with the reports dashboard's own Chart.js setup and palette — brand purple first, DM Sans, the same axes and tooltips as the Overview page — with a one-line figure above it and a "Copy data" button below. Comparisons are a first-class question too: "the last 30 days against the 30 before", "the Cup Final against the rest of the season", "this platform's share then and now" each come back with previous, change and percentage change per row, and Florence explains a change with the splits that moved rather than a guess.

Added
Charts in the chat. Florence emits a fenced chart block (bar, line or doughnut, up to six series) and the panel renders it with FlorenceChart.vue; the spec is validated strictly and a malformed one stays visible as code rather than vanishing. "Copy data" puts the chart's table on the clipboard.
compare on query_data{previous:true} for the window of equal length before from..to, or {from,to} / {eventIds} for any baseline; every measure gains Prev, Delta and DeltaPct columns and the result says which rows exist only in the baseline.
The panel is wider (460px) so tables and charts breathe.
Under the hood
spa-v3/src/lib/florenceChart.ts splits an answer into markdown and chart segments (the fenced form and the bare chart + JSON form the model sometimes writes) and validates the spec; florenceChart.test.ts covers both forms, every refusal reason, formatting by unit and the copy table. FlorenceSemanticToolsTests adds the three comparison cases (baseline by event, previous window, refused without dates).
10.44.0 September 2026
Added Changed Under the hood

Florence learns the data

Florence, the reports assistant, could answer thirteen shapes of question and nothing else. Ask where the content came from, which phones fans used, how many opted in to marketing by country, who uploads most or who keeps coming back, and it had no tool to call — so it guessed or declined. It also could not find an event by name once an account had more than fifty, and it ran on the smallest model with its tool results chopped mid-sentence. This release gives Florence a data dictionary and one general query that splits any dataset by up to three things with the grouping done in the database, a fan directory that never shows the model an email address, event and campaign search, the platform's own recommendations as a tool, a stronger planner model, and a prompt that states plainly where the data starts and stops. The programme continues in docs/florence-programme.md: charts in the chat next, then Florence on every page, then setting an event up from a brief.

Added
query_data — eight datasets (uploads, faces, emotions, tags, brands, FanMoments, consents, answers), each with named dimensions and measures: split uploads by capture country and platform, faces by age band per event, consents by purpose and month, FanMoments by campaign and status, and more. Groups run in SQL Server; rows beyond the limit collapse into "other"; unique fans are re-derived whenever groups merge, so a season-ticket holder at six events is one fan, not six.
describe_data — the dictionary itself, with notes on every gap (country only since September 2026, consent country since 3 September, phone model never captured, emotion off in the EU by policy).
find_events / find_campaigns — search by title, date, group or country, with upload and fan counts, so "the Cup Final" becomes an id on an account with hundreds of events.
top_fans, fan_split, get_fan — who uploaded most, attended most events, received most FanMoments, answered most questions or interacts most (a transparent score), fans split by country, language, mailing-list opt-in, returning-fan band and first/last seen, and one fan's history. Fans are pseudonymous refs (fan#123, device#…) to the model; the dashboard swaps them for the fan's details after the answer is written. Fan contact details never reach the AI provider.
get_recommendations — the AI Insights ideas as a tool, so "what should we do next" starts from the platform's own recommendations and is then checked against the data.
Five new starter prompts in the panel; a "What can I ask Florence?" section in Help & FAQ.
Changed
Planner model gpt-4.1 (was gpt-4.1-mini), 10 tool iterations (was 6), 2,500 output tokens (was 1,200), 400k daily tokens per account (was 200k). A tool result that would exceed 16,000 characters now comes back as a clear "narrow the query" error instead of truncated JSON.
The system prompt carries the account's event count and span, the dataset summary, the fan-ref rule and the honesty rules; it tells the model to filter by event id rather than by an event's dates.
get_uploads_timeseries and get_breakdown are retired — query_data covers both with more dimensions.
Under the hood
Services/Florence/Semantic/: SemanticRow (one flat fact shape every dataset projects into), SemanticGroupCompiler (the GROUP BY key as an expression tree binding only the requested slots), SemanticCatalog, SemanticSources, SemanticQueryService; Services/Florence/FanDirectory.
Tests: SemanticQueryTranslationTests compiles the real queries against the SQL Server provider with ToQueryString() and asserts GROUP BY, UNION ALL, COUNT(DISTINCT, CONVERT(date and DATEPART(hour are in the SQL — the InMemory provider would hide a translation failure. FlorenceSemanticToolsTests drives every new tool through the chat endpoint: roll-ups, where-filters, no double-counting across merged groups, unknown names refused with the valid list, no email in any tool result, refs resolved only for this tenant. spa-v3: florenceEntities.test.ts.
On .NET 10 an array's Contains binds to the ReadOnlySpan overload, which EF cannot translate — event-id lists must be List<long>.
10.43.0 September 2026
Added Fixed Removed

Help that ships with the release

The Help & FAQ page had been showing "Unable to load help content" on every environment since May. The query that read it coalesced a navigation collection in a way SQL Server cannot translate, so it failed on every request while the health check stayed green, and the content behind it — four sections written in February — lived only in a database table nobody could see in git or update from a feature commit. Help now ships as files in the repository: one Markdown file per section under FilmilyManager/Help, read by the same endpoint and parsed the way the release notes are. Fourteen sections and 107 answers describe the platform as it is today, in the customer's names for things: the three event clocks, venue-only events, Calls to Action and the fan journey, campaigns and the five filter families, FanMoment tiers, ceilings and priority routing, fan walls and silent fans, films and Smart Films, branding and the App Skin, moderation and the Live Dashboard, reports and Answers, consent and data protection, translations, roles, and a troubleshooting list ordered by how often each thing actually happens.

Added
Help & FAQ rebuilt from files — a search box across every question and answer, one chip per section, deep links (#help-events), and answers rendered from Markdown so they can carry bold leads, lists and tables. Clicking a question opens it; the answer no longer closes itself when you click inside it.
FilmilyManager/Help/*.md is the source of truth. Front matter carries the title and icon, every ## heading is a question and the text beneath it is the answer. The two-digit prefix is the page order. Edit the file in the same commit as the feature it describes.
Tests: HelpContentParserTests pins the file format and parses the real folder — every icon must exist in the icon font, every question must have an answer, and nothing may say "Filmily"; HelpApiTests walks /spa-api/help through the real pipeline.
Fixed
Help & FAQ no longer fails. The endpoint reads the files instead of composing an EF projection over the navigation collection.
Removed
Admin → Help Content and its editor pages. The HelpSections and HelpQuestions tables stay for one release and are dropped with the next schema release, alongside EventTheme.BannerMediaId and the frame-rate columns.
10.42.1 September 2026
Fixed Under the hood

The tests that failed after they passed

CI marked both Recommendations API tests failed on 12 September even though every assertion in them had passed. The failure was in the tidy-up. The in-process test host was being told to stop twice — once by the test harness and once by the app's own shutdown, which the first stop wakes — and Hangfire's background server is the one component that throws if the second stop lands during its disposal. The tests never needed that server: every job client is mocked, so nothing was ever queued for it to run. It was only there because the test host boots the real Program. It is now removed from the test host, and a test pins that it stays removed.

Fixed
Flaky "Test Class Cleanup Failure" on the Manager integration tests. ManagerWebApplicationFactory no longer starts Hangfire's in-process servers, so the harness's double stop can no longer race Hangfire's Dispose into an ObjectDisposedException.
Under the hood
ManagerWebApplicationFactoryTests pins three things: the test host runs no BackgroundJobServerHostedService; RemoveHangfireServers drops every AddHangfireServer registration and nothing else; and disposing the factory after the app has already shut itself down stays clean.
Not fixed on purpose: the harness's double Host.StopAsync itself. It is how WebApplicationFactory and a minimal-hosting Program interact, and every other hosted service tolerates it. The fixture documents it so the next non-tolerant hosted service is recognised for what it is.
10.42.0 September 2026
Added Changed Under the hood

Release notes you can actually read

The release notes page was a Markdown dump: one long column, every release at the same weight, nothing to find the one you wanted with. It is now the designed page from the platform website project — a sticky header with the version this Manager is running, a hero with the counts, filter pills by kind of change, a search box, a version rail down the left, and one card per release with its headline, its story and its changes grouped and colour-coded. The page still reads CHANGELOG.md — nothing moved into a database — and it understands every heading shape the file has used since February, so every release renders, not only the recent ones.

Added
Filter and search — Added, Fixed, Changed and Notes pills (Removed appears once something is removed), and a search across versions, headlines and every change. The version rail dims the releases a filter hides, and "releases shown" counts down with it.
A running-version badge in the header — v10.42.0 live, staging or local — read from the deploy's ReleaseNumber, the same source as every other footer.
A headline for every release. Releases written before the notes had stories get one derived from their first change, so the February entries sit in the same cards as this one.
Changed
/ReleaseNotes is a standalone page with its own header and footer; "Back to Manager" is the way home. /changelog still redirects to it.
The SPA host reads the latest version from an unbracketed heading too (## 10.40.0 — …), so its footer cannot fall back to the release before the real one.
Under the hood
ReleaseNotesParser reads the changelog as releases → sections → groups → entries from Markdig's tree, never by regex over the source. ReleaseNotesParserTests pins both heading shapes, the group vocabulary and the narrative rules, and parses the real file to prove no heading is lost; ReleaseNotesPageTests renders the real page.
10.41.2 September 2026
Changed

Start the local stack with one command, watch frontend changes, and restart individual…

Changed
Start the local stack with one command, watch frontend changes, and restart individual backend services.
Build release artifacts once, verify staging, and promote those exact images and camera bundles to production. Reuse unchanged image inputs on subsequent staging builds.
Recover non-exclusive FanMoments when an upload never received a transformation attempt, preserving campaign filters, existing output records and paid-processing guards.
Include the camera story-upload association, coordinate payload and service-worker activation fixes in the coordinated release.
10.41.1 September 2026
Fixed Documentation

Sweeping the branches: every finished change from Codex and Claude, through staging to production

Three pieces of work were sitting on side branches, verified but never merged. This release brings them onto main in one go so nothing verified is left behind. Nothing here changes a schema.

Fixed
Campaigns narrowed by an email list now narrow Fan Walls and Smart Films too. There were two separate translations of a campaign into a content filter, and the one used by walls and films had drifted: it silently dropped the email list, so a wall or film for "these fans" drew from every fan in the event. There is one mapping now and every output type goes through it, with a test pinning each filter field.
The Razor email-literal guard no longer fails on its own explanation. A comment describing the v@version bug class inside a template was itself being flagged. Razor comments are stripped before scanning.
Documentation
Codex's verified staging record for the silent-clip release (10.40.0), with commands, evidence and limits.
10.41.0 September 2026
Changed

The frame rate comes from the fans' phones, not a PAL/NTSC menu

Fan walls, films and personal stories used to carry a "Frame rate" setting with two options, 29.97 (NTSC) and 25 (PAL). Those are television standards; the clips are phone recordings, which default to 30 fps and occasionally 24, 25, 50 or 60 when a fan has changed a camera setting, and nothing on the web or social needs 29.97. The setting is gone from all three editors. The renderer now reads the frame rate of the source clips and encodes at the rate most of them share, snapped to a standard rate (29.97 counts as 30), falling back to 30 when nothing can be read. The broadcast .mxf keeps its own specification.

Changed
Fan wall, film and story editors — no Frame rate control. Stored values are ignored; the columns are dropped with the next schema release.
Renderer — probes up to twelve distinct source clips per output for their average frame rate and picks the majority, logging the choice.
10.40.1 September 2026
Fixed

Every existing fan wall lost its shuffle button

Minutes after 10.40.0 went live, the first fan wall opened in the editor had its Shuffle and Save buttons greyed out and a red line asking for a wall type. The new "video walls take videos, photo walls take photos" rule read the wall's stored types too literally: every wall saved before this week has the old "Any" as its input type, because the previous editor never set it, and the rule treated "Any + Video" as "no type at all". The same rule sat in front of the renderer, so those walls would not have refreshed either. A legacy wall is now what its typed side says it is, or what its tiles are, and it heals its stored types the first time it is saved. Only a genuine video-versus-photo conflict still asks for a choice.

Fixed
Shuffle and Save work on walls made before 10.39.0 — in the editor, on save, in the renderer, in the personalised-wall check and in the uploader searches, all through one shared rule.
10.40.0 12 September 2026
Changed

Silent clip guard integrated with the FFmpeg 9 runtime

Changed
Deploy the v10.37.0 silent-clip handover alongside the published v10.39.1 media/event changes: measured audio at upload, a bounded ten-minute backfill, silent zoom-target refusal, and camera recording/review microphone warnings.
Preserve FFmpeg 9 process cancellation, Blob input reuse, matching video/photo wall types and optional ProRes outputs when reconciling the shared files.
Failed audio measurements remain retryable instead of permanently labelling healthy clips silent. Confirm absent audio with ffprobe; retain successful analysis diagnostics.
Match the renderer when overlapping enlarged tiles determine the zoom target, so the editor and server check the fan who will actually be shown.
10.39.1 September 2026
Changed

The banner column comes off the model

Housekeeping after 10.38.0. The retired event banner is no longer part of the data model at all, so nothing running on production reads EventTheme.BannerMediaId any more. That is the first of two steps to removing it: this release stops reading the column, and the next release's migration drops it. Doing both at once would have given every event query a few minutes of errors between the column disappearing and the new image arriving, during a live event.

Changed
EventTheme.BannerMediaId removed from the entity. No database change in this release.
10.39.0 September 2026
Changed

Media processing and Azure costs

Changed
Fan walls now have one matching wall type: video in/video out, or photos in/image out. Mixed and crossed types are rejected before rendering. Changing a wall type keeps existing tiles visible until they are explicitly replaced.
Ordinary films and video walls produce MP4 without an unnecessary ProRes master. ProRes remains available for editing, and broadcast exports retain their required master. Downloads identify the actual format.
FilmAPI uses verified, pinned FFmpeg 9.0.1 with codec and filter smoke checks during the Docker build. Process output is drained safely, failures surface, and cancellation stops child processes.
Personalised walls without zoom now put each fan inside the actual wall canvas; previously the replacement could land outside the image and leave the original centre tile visible.
Film opening and closing fades preserve the complete requested video duration. Personalised image links advertise images; Live walls retain reusable bases and each recipient’s own media.
Successful fast SQL dependency telemetry is sampled at 1%; slow/failed dependencies and requests, exceptions and render logs remain available. Staging deployments retire old revisions once the current healthy revision owns all traffic.
Real media regression coverage includes films, Stories, deterministic FanMoments, photo/video walls, broadcast output and personalised Live walls. See docs/media-runtime-validation.md.
10.38.0 September 2026
Changed Fixed Notes

An icon for the event, not a banner

The event "banner" was a full-width image with exactly one job: the tile on the fan app's event list. It was never good at it. Since the list became the design's choice cards the tile is 44px square, and a banner squeezed into that read as a smear of yellow text — which is why the previous release stopped showing it and drew a glyph instead. The banner is gone from the event editor. In its place is Icon: upload a square image and the tile shows it; leave it empty and the app draws a glyph. Andy's call: "if we upload an icon its added, otherwise its a glyph."

Changed
Event editor → App Theme — "Banner" is replaced by "Icon" (square, shown at 44px), with a line explaining the fallback.
Fan app event list — the tile is the event's icon when one is set, otherwise the crowd glyph (a team glyph for a group of events). Groups always use the glyph.
Mobile API — the event carries iconUrl / iconDescription; bannerUrl is no longer served.
Cloning an event copies the icon.
Fixed
The mobile API never loaded the event background image (the media preload listed the intro video twice and the background not at all), so backgroundUrl was always empty.
Notes
Migration 20260912080947_AddEventThemeIcon is add-only. The old BannerMediaId column stays for one release and is dropped next, so this deploy has no drop-column window.
10.37.0 September 2026
Added Changed Notes

The fan nobody could hear

A wall on production zoomed in on a fan and the crowd faded out into nothing. The fan's clip had a silent audio track: the microphone had produced nothing, the app had said nothing, and the platform had no way of knowing. Until now a clip's AudioScore had been a stub that wrote zero for every upload, so the editor could not tell a muted recording from a loud one.

Added
Clips are measured for sound at upload. The scoring step now runs ffmpeg's loudness detector on the clip and stores its peak level; a 10-minute sweep measures clips uploaded before this release, so existing walls get the flag too.
The fan wall editor knows which fans are silent. Silent tiles carry a muted badge, the zoom-target ring is always shown (on the default centre too, not only after "Centre here"), a silent target is called out under the grid, "Centre here" is disabled on a silent clip, and a wall that would zoom onto a silent fan cannot be saved — the server refuses it with the same message.
"We can't hear you" in the fan app. While a take records, the app listens to the microphone; if nothing has come through after two and a half seconds, or the track reports itself muted, a pill appears over the video. The review screen repeats it so the fan can redo before uploading. Two new translatable texts: CAPTURE_NO_SOUND, CAPTURE_NO_SOUND_REVIEW.
Changed
ffmpeg's log is now kept for every command, not only failures, and a command with no file output (analysis filters) no longer re-uploads its input.
Notes
Clips whose sound has not been measured yet are never treated as silent, so nothing is blocked on an account until the sweep has reached it.
The Manager's App-Skin preview does not yet mirror the capture bands, so the pill is not shown there.
10.36.3 September 2026
Changed

Choose an activity, without the icons

Design 02 draws the activity cards as text: a title, one explaining line, a chevron. v10.36.2 had given each card an accent icon tile, and Andy did not want it. Gone. The event list keeps its tile, but no longer fills it with the event's banner: that banner is button art drawn for the full width of the screen, and squeezed into 44px it read as a smear of yellow text. The tile now holds a glyph from the icon font, the way the design draws its tiles.

Changed
Choose an activity — cards are title + line + chevron, the design's 20px inset and 20px title.
Event list — the card tile is an icon-font glyph (a crowd for an event, a team for a group), never the event banner.
10.36.2 September 2026
Changed

The event list joins the design

The one screen in the Let's Get Engaged journey still on the old layout was the first one after Welcome: the list of events. It was drawn with the account's large yellow button art, floating title and all, while the design draws that step as dark choice cards with an accent icon tile. It now uses the same bands and cards as "Choose an activity", and so does the activity list itself — the design is explicit that a choice is never a block of solid accent, so the skin's large-button art is no longer used on either list.

Changed
Event list — header, title band (the account's event title and body texts), one card per event or group with the event banner as its icon, the leftover space, the partner logo. Groups open as a second level of the same cards.
Choose an activity — always the design's cards, even when the skin has large-button art.
10.36.1 September 2026
Fixed Added

The fan who froze mid-zoom

The first wall rendered on 10.34.0 (the fan wall speed release) zoomed in on a fan whose clip was 5.5 seconds long, in a 10-second wall with "Loop clips" off. The fan stopped dead the moment the zoom reached them, and the soundtrack stopped with them, because the centre clip's audio was mixed with a "shortest wins" rule. Neither was new — the same wall on the old code did the same — but the new code made walls fast enough to notice. Both are fixed, and every kind of wall now has a local render matrix that proves it.

Fixed
The zoom target keeps moving. When "Loop clips" is off and the clip the wall zooms onto is shorter than the wall, that one tile now loops instead of holding its last frame full-screen. Other tiles behave as before.
The soundtrack runs the full length of the wall. The wall's own audio always sets the mix length; a short centre clip no longer truncates it (with or without "fade audio with zoom").
Personalised walls with "enable looping" actually loop the fan's clip. The setting was never applied to the render, so a fan whose clip was shorter than the wall was held on their last frame for the remainder.
Broadcast encode works. AVC-Intra 100 is 10-bit 4:2:2 only; the encode asked for 4:4:4 and x264 refused, so no .mxf had ever been produced.
Added
scripts/fanwall-render-matrix.py + docs/fanwall-render-matrix.md: renders plain, looped/staggered, zoom-in with overlay and fades, zoom-out from the centre, audio track, filter + grayscale, photo output, photo input, broadcast, the 36-tile chunked path and the personalised wall through the real local stack, and checks video and audio lengths, overlay timing, fade-out, grayscale and that the hero is still moving after the zoom.
Full ffmpeg arguments are logged at Debug level (Logging:LogLevel:FFMPEGHelper=Debug).
10.36.0 September 2026
Changed Under the hood Added

The fan app, built from the design's own HTML

The screens looked like a cousin of the design rather than the design. The reskin in 10.31 had taken the design's numbers and poured them into the app's older scaffolding — a 10px page padding, 10px item margins, 90%-wide blocks centred in the column, spacers that grew — so every band landed a little off, and the selfie sat in a small square in the middle of a black screen instead of filling it. This release throws that scaffolding away for the seven designed screens and builds each one from the design's HTML: the same bands in the same order, the same paddings, radii, sizes and opacities, with only the six things a client owns — accent, accent ink, canvas, ink, display face, body face — read from the account skin.

Changed
Welcome — the lockup at 230×120 in a 44px top band, the title and copy centred in the space that is left, the primary action 28px beneath, the partner logo at the foot.
Choose an activity — header band, 24px title band, cards at 16px sides with 12px between them, the partner logo at the foot.
Permissions — the camera card above the location card; hint, NEXT and footnote pinned together above the partner logo; the title band no longer floats away from its intro.
Answer a question — a solid question band (label, REC pill, prompt, progress bar), the camera feed edge to edge in the space between, and a solid control bar with back on the left and the 72px record button in the middle. No more gradients over the feed.
Review your answer — the take runs edge to edge under the question, with a 68px accent play disc; accept, then BACK / capture as a text row.
Your moment — the selfie fills a 16px-radius stage the full width of the phone and all the height that is left; the picture just taken reviews on the same stage with accept and BACK / capture. The record and take-picture buttons now wait for the feed to be live, so a fan can no longer take a black picture.
Send us your moment — the consent region and its pinned SUBMIT now use the design's 16px sides and 10px card gaps.
Desktop preview — the virtual phone is the design's 390×780 frame with 28px corners on the design's light grey canvas.
Under the hood
The skeleton lives in FilmilyCamera/src/style/app/design.scss as ds-* bands; a screen opts in with ds-screen. Screens the design does not cover (event list, text answer, multi-upload, thank-you) keep the older layout.
The layout test and the no-scroll walkthrough follow the new markup; the Manager's App Skin preview has not yet been re-mirrored to these bands.

The consent screen stops scrolling

The last screen before a fan sends their moment had grown a scrollbar. Every consent the law asks of a fan — the terms tick, a biometric release for an Illinois fan, marketing, whether we may write down what they said — was a full checkbox card stacked under the last one, and on a phone the SUBMIT button ended up somewhere below the fold. The number of ticks is jurisdictional, so no amount of trimming could make a fixed-height screen safe: a fan in Chicago simply has more to agree to than a fan in London. The screen is now a fixed frame — header, a consent region that takes the space that is left, and an action footer that never moves — and the region scrolls inside itself, with no scrollbar and a fade at whichever edge has more.

Changed
Optional consents are pill-toggle rows, not checkbox cards. Marketing and speech transcription use the same switch the fan just used on the Permissions screen; only a blocking grant (the terms tick, a biometric release) keeps the heavier checkbox card. The split follows each module's own blocking flag, never its name, so a jurisdiction that makes a tick blocking gets the right control without a code change.
A consent row shows both its explanation and the sentence the toggle answers. The ledger's wording hash covers both, so hiding either would have a fan agreeing to a hash over text they never saw. The explanation runs full width; the toggle sits beside the sentence, not beside the paragraph.
Marketing disappears for an anonymous under-age fan instead of sitting on the screen forced off — there is no address to market to. The speech consent stays: transcription is content-linked and works for an anonymous upload.
Marketing is asked after the upload, on the thank-you screen. It has nothing to do with sending the moment, and asking for it once the fan has received something is both the honest moment and the better-converting one. The terms screen is down to the ticks that actually gate the upload. The row is the same pill toggle; each tap sends its own request (POST api/App/consent), and a "no" after a "yes" is a withdrawal, so it is genuinely two-way.
An upload with no marketing answer now leaves the fan's marketing flag alone. It used to write "no" — which, with the question moved off the upload, would have silently unsubscribed every returning fan on their next moment. Tri-state on the server: only a real yes/no overwrites.
The Manager's App-Skin preview mirrors the toggle row on its thank-you slide.
Added
A consent receipt on the thank-you screen — one line per decision the fan made (terms, release, speech, marketing) with their answer, read from the same in-memory answers the upload carried, so it cannot disagree with the ledger. Plus a footnote on how to change their mind. All copy is app-text (translatable): CONSENT_MARKETING_AFTER_TITLE, CONSENT_MARKETING_SENT_YES/NO, CONSENT_MARKETING_ERROR, CONSENT_RECEIPT_TITLE/YES/NO/FOOT.
POST api/App/consent accepts only the marketing module — a biometric release or the terms tick must never be grantable against content that is already processed — requires an email or phone, and only a wording hash this server composed. PostUploadConsentTests pins every refusal.
tests/e2e/consent-frame.e2e.cjs in the camera: walks a fan to the consent step against the local stack and asserts the step never scrolls, SUBMIT is fully on screen, blocking and optional grants use different controls, and an optional toggle turns on and back off; then continues through the upload and checks the thank-you row, the receipt, the two-way request, and the endpoint's refusals with the fan's own headers.
10.34.1 September 2026
Fixed Changed

The selfie frame that was two pixels wide

Since this morning's v10.31.1, the photo and inline-video capture screens on production showed a fan a black box where their camera should be — with a working "Take Selfie" button under it. The camera was streaming fine; the frame holding it had collapsed to a 2px dot. The frame moved into a flex column in v10.31.1 so it could take the height that was left, and a flex item with margin: auto on both sides is never stretched: it shrinks to its content, and its only content is absolutely positioned, so it was zero pixels wide. The dev account's skin uses a capture frame style whose own margin rule happened to override the auto margins, which is why every local screenshot looked perfect.

Fixed
The inline capture frame is full width on every capture frame style. .frame-fill now has fixed side margins so the column stretches it. A new layout test compiles the real stylesheet and lays the real step out in Chromium for frame styles 0, 1 and 2 — the class of bug a fake-camera walkthrough on one skin can never catch.
Changed
The "take picture" button lost its camera glyph. The icon was added with the reskin and sat flush against the label with no gap; the label says what the button does.
10.34.0 September 2026
Changed Added

The six-minute fan wall

A 5×5 fan wall at 1920×1080 took six minutes to render on production, using two and a half of the four CPUs it had. Twenty-five full-resolution 1080×1920 camera originals were being decoded, every frame, to paint twenty-five 384×216 tiles — the shortcut that swaps in a clip's small web encode only fired for tiles of 360 pixels or less, and 384 is not 360. Then the master was decoded three more times for the delivery file, the web preview and the stills. And before any of that, the wall sat in the queue for up to a minute waiting for a timer to notice it. Now it doesn't.

Changed
Web encodes for any tile they can fill. Tiles larger than 360px are checked against the web encode's real dimensions and use it when the upscale is within 10% (FFMPEG:TileUpscaleTolerance). Local benchmark, same 5×5 grid: 4.2× less CPU and 2.3× less memory in the grid pass.
One xstack instead of a chain of 25 overlays for plain grids. Pixel-identical output (PSNR ∞ against the old chain), 6–13% less CPU and ~40% less memory. Scaled tiles and background layers keep the overlay chain.
Every tile input caps its decoder at 2 threads, the same guard the effects pass has had since July. ffmpeg sizes decoder threads by the host's cores, not the container's, and 25 uncapped decoders were holding ~5GB of frames in flight.
Filter-graph threads follow the container's CPU quota (FFMPEG:FilterThreads, 0 = auto) instead of a hard-coded 2 from the 2-vCPU days.
Delivery mp4, web mp4 and stills come from one decode of the master (a split filter with three outputs) instead of three separate runs. The web preview encode uses -preset veryfast, as the delivery encode already did.
Saving a wall queues the render immediately. The Manager tells FilmAPI to queue it the moment it is saved (POST /api/mosaics/{id}/queue, internal secret); the minutely scan stays as the safety net. Up to 60s of dead time gone from every wall.
Saving a wall makes two clip lookups, not twenty-five. The per-part Find() calls on the Manager's request thread are one query per media type.
Added
Render timings in the FilmAPI log. Every ffmpeg/ffprobe run is logged with its phase (grid, audio, merge, effects, outputs) and duration, plus blob download/upload time, and each wall render ends with a one-line summary. Until now, "where does the time go" could only be inferred from the container's memory curve.
10.33.1 September 2026
Added

The FanMoments section catches up with its neighbours

On the campaign page, Fan Walls and Films had a Show: dropdown and a six-item cap with Show More; FanMoments had neither. The old filter's options (favourite / complete / incomplete) never applied to a FanMoment — it is a configured scene, not a rendered output — so the section shipped with no filter rather than a meaningless one. Now it has one of its own.

Added
Show: live / off / generative / composite / all on the FanMoments section, filtering on the two badges each card already shows.
FanMoments list capped at six with Show More / Show Less, matching Fan Walls, Films and Audio Mixes.
Manager SPA helper tests (npm run test:unit in FilmilyManager) now run in CI.
10.33.0 September 2026
Added Changed

Spoken answers on every event, not just storified ones

The Answers report could only see answers from storified events, because the clip-to-question link was written on the story item and nowhere else. Ordinary events and Multi Video Upload activations — the ones that collect the most spoken answers — were invisible by construction. The camera now stamps the question on the clip itself at upload, so the link exists whatever kind of event it is.

Added
Clip.CallToActionId, written by the camera on the plain upload path; the transcription job reads it first and falls back to the story item link. Migration refreshes the two search functions in the same step, so clip searches keep working.
Changed
The event edit and the Answers report no longer say "storified events only".
10.32.1 September 2026
Added Changed

The transcription switch that the report was pointing at

The Answers report's empty state told admins to "switch answer transcription on for this account" — and there was nothing to switch. The account flag and the per-event override existed only in the database. The event edit now has the control.

Added
Transcribe spoken answers (event edit, App settings, Data protection): Use account setting / On / Off. Fans on an event where it resolves to On see the optional speech-consent tick, and only the answers they agree to share are transcribed into the Answers report. Storified events with a video call to action only, as before.
Changed
The Answers report's empty state now says where the switch is.
10.32.0 September 2026
Added Changed

Only fans at the event can take part

Until now an event's location was a picture: the circle the admin fitted the venue into on the map meant nothing to the app, so a fan on their sofa could take part in a stadium-only activation just as easily as one in the stands. One new switch on the event, under the map, changes that — and it is eligibility, not decoration: the app checks the fan's position on the permissions screen they already see, and the server checks every upload again, so a fiddled client gets nowhere.

Added
"Only fans at the event can take part" (event edit, under the map). Ticked, the fan's location becomes required (the select locks to Required), the Location card explains why ("this event is only open to fans at the venue…"), and the Next button stays off while their fix is outside the circle — "You need to be at the event to take part." Inside, they carry on exactly as before. No new screen. Unticked, nothing changes: anyone can take part and campaigns still target fans by where they were.
Server-side gate. Every photo, clip and text answer sent to a venue-only event is checked against the same circle at ingest and refused (403, NOT_AT_EVENT) when it is outside or carries no fix. A clip counts as inside if any of its per-second samples is; the device's reported accuracy is allowed for, up to 250 m. The circle is never smaller than 300 m, because GPS in a concourse is not that good. Text answers now carry the fan's fix, since they have no sensor metadata of their own.
The radius is derived, not stored. The Manager keeps the map's bounding box; the circle on the overlay covers 90% of it, so the rule uses 45% of the shorter side. The readout under the map shows it ("about 24 km"), the API sends it to the camera (locationRadiusMetres), and the three cannot drift apart.
New app-texts PERMISSIONS_LOCATING, PERMISSIONS_NOT_AT_EVENT, UPLOAD_ERROR_NOT_AT_EVENT and consent wording CONSENT_LOCATION_NOTE_AT_EVENT, seeded on Skin save and editable under App Text.
Changed
Text answers are always checked for profanity. The per-event "Filter Profanity?" switch is gone (nobody ever wanted it off) and its column is dropped; the account word list and Azure Content Safety run on every text answer.
Event switches say what they do. "Is Public?" → "Visible in the Camera App" (unticked hides the event from every fan); "Allow Anonymous?" → "Allow anonymous fans (no contact details)"; "Only for VIP members?" → "VIP event (report tag only)" (it never restricted anyone — the camera does not read it); "Showcase this event?" → "Highlight in the event list" (the button turns gold); "Allow Dashboard Stats?" → "Live dashboard stats".
A running location watch survives a transient error. A momentary position-unavailable or timeout used to tear the watch down and wipe the last fix, so later samples silently lost location. It now keeps watching; only a withdrawn permission (or an error before the first fix) stops it.
10.31.1 September 2026
Fixed

The fan app fits the phone again

Two screens in the reskinned journey had quietly grown taller than a phone. On a video question, the moment a fan stopped recording the compact question band over the camera gave way to the 28px page title, the take dropped into a framed box under it, and the accept button fell below the fold — the fan had to scroll to keep their own video. And the permissions screen, with two cards plus a title, intro, hint, button and footnote, ran a few dozen pixels past an iPhone. The rule is simple and now enforced: no fan-app screen scrolls.

Fixed
Reviewing a take — the review is now the same surface the fan recorded on: black, full-bleed, the question band on top, the take where the live feed was, a play control in the middle and accept / back / redo where the record button sat. Nothing is in the document flow, so the screen can never grow past the phone.
Permissions — tighter vertical rhythm (intro, card padding, hint, footnote) and the partner logo in the footer trimmed to 32px; a plain refusal is no longer repeated inside every card as well as under Next — the card only speaks up when the browser refused without asking, because that is when the help video matters. Fits 375×667 with the switches on or off.
Framed capture (storified events) — the picture/video frame now takes the height that is left after the prompt and the actions instead of a fixed 48vh, so a short screen gets a smaller frame rather than a scrollbar.
Layout contract — a step's column is exactly the screen (not "at least the screen"), so flexible regions can only take the space that is left; tests/e2e/no-scroll.e2e.cjs walks the video journey at 375×667, 390×664 and 400×710 and fails if any screen overflows.
10.31.0 September 2026
Changed

The whole fan journey, on the Fan App UI guidelines

v10.29.0 rebuilt the permissions step to the guidelines and left every other screen in the old skin — which is why the consent screen still had full-width tinted rows and radio circles. This release brings the rest of the journey in line with the six-screen design: welcome, choose an activity, permissions, answer a question, your moment, and send us your moment. Same structure on every screen; colours, fonts, logos and copy still come from the account skin.

Changed
Header band — "back" with a chevron on the left, the account lockup on the right at 96×44, 44px hit target.
Buttons — every skinned primary is full-width, 54px, 12px radius, uppercase; new outline secondary (50px) for "upload instead" and "try again"; disabled is a neutral 7% fill with a 45% label, never a dimmed accent. Client button art is untouched.
Inputs — 52px, 12px radius, 1px 16% ink border, accent border on focus; placeholders are hints, not labels. Fill and text colours remain the skin's input colours.
Consent screen — each decision is a checkbox card (22px box, 6px radius, tick in the accent-ink, card tints to the accent when on); the marketing note sits inside its card; the AI and retention notices collapse behind one summary line ("How we use your moment", editable and translatable); Submit is disabled until the fan can submit and the hint beneath says what would enable it.
Choose an activity — choice cards: accent icon tile, title, one explaining line, chevron. Optional title and intro line (new app-texts). Clients with uploaded large-button art keep it.
Answer a question — a question band over the camera: "Question N" in the mono accent role, a REC pill while recording, the prompt, and a progress bar against the recording limit; the prompt no longer fades out mid-take. Record button is 72px, red inside a white ring.
Your moment — primary "take picture" with the camera glyph, outline "upload" beneath; after capture, primary accept and outline retake. Preview frame is 16px radius with a 1px border and fills more of the column.
Welcome — the one centred screen: hero lockup, 34px title, 17px intro.
Footer — partner logo centred at 45% opacity.
New app-textsCAPTURE_QUESTION_LABEL, CTA_SELECTION_TITLE, CTA_SELECTION_BODY, CONSENT_SMALLPRINT_TITLE, seeded on Skin save and editable under App Text.
10.30.1 September 2026
Fixed

The retention purge runs again

Since the v10.26.1 deploy on 7 September, every hourly retention purge on staging and production had failed before deleting anything. The purge composes the "still used by a live film or fan wall" test into its own query, but that test was bound to a different database context than the one running the query, and Entity Framework refuses to mix two. The Manager creates a fresh content context per service, so the two were never the same instance in a deployed environment; the single-context unit tests could not see it. The predicate now binds to whichever context is asking, and a new test runs the purge exactly the way production wires it.

Fixed
Retention purge failed every run — "Cannot use multiple context instances within a single query execution" on the in-use exclusion added in v10.26.1. Both the exclusion and the per-row re-check now share one definition bound to the querying context.
10.30.0 September 2026
Changed Fixed

Deploys in half the time

A push to main took 18–19 minutes to reach staging and a production release 15–29. Almost none of that was the deploy: the images swapped in about a minute. The rest was the pipeline waiting on itself — tests in front of builds that did not need them, a second push queued behind the first one's deploy, a production release rebuilding images that staging had already verified — and six minutes per environment of writing configuration values that were already live: secrets, ingress ports, scale rules and the rembg sidecar's identity, each re-written on every deploy at fifteen to twenty-five seconds a call.

Now tests and builds run together, only the deploy step of each environment is serialised, a production release reuses the exact images staging ran, and every configuration write is compared before it happens. A push should reach staging in nine to ten minutes; a production release in eight to nine.

Changed
Pipelinerun-tests and the four image builds start at the same time; the deploy jobs require both. concurrency moved from the workflow to the deploy-staging / deploy-prod jobs. The three image swaps run concurrently.
Production releases reuse images — a workflow_dispatch skips any build whose <image>:<sha> already exists in ACR, so prod runs byte-identical images to staging (previously PublicAPI was rebuilt with a fresh GeoLite2 layer and differed). New force_rebuild input opts out.
Compare-before-write for every deploy callaca_secret_set compares against the live secret values, aca_update understands scale rules and --command, and new aca_ingress_update / aca_identity_assign / aca_registry_set wrappers replace the bare az calls. All fail-safe: anything not understood is written.
Ordering guard — on an automatic staging deploy, deploy_app will not overwrite a higher live BuildNumber, so two quick pushes cannot land out of order now that their builds overlap.
Fixed
Two bare az containerapp update calls in the production data-protection step, and the SignalR Key Vault reference on both environments, were written on every deploy and each minted a revision; they now go through the wrappers.
10.29.1 September 2026
Fixed

Fresh builds reach open tabs

A camera deploy that changed the app but not its service worker left already-open tabs and home-screen installs on the old build until a fresh navigation. Each build now stamps its commit into the service worker, so every deploy is a new worker and open sessions reload themselves once, as the update logic always intended.

Fixed
Camera app stuck on an old build after deploy — service-worker cache names include the build commit.
10.29.0 September 2026
Changed Fixed

The permissions screen, designed on purpose

The camera's permissions step now follows the Fan App UI guidelines. Each permission is a card: an icon, the name, a "Required" pill where it applies, one plain line saying what it is for, and a pill toggle. A hint above Next says what still needs turning on, and a footnote reminds fans they can change their mind in their phone settings. Every colour and font still comes from the client's App Skin, so a dark canvas or a light one both read correctly.

The Manager's App Skin preview gains a Permissions slide, so a client can see this screen while choosing their colours. The new copy is editable and translatable like everything else.

Fan emails now match your Branding page

The delivery email used to paint its header and button with the branding primary colour and hardcode a grey "Hello from FANDEMiQ!" greeting on top. A client with a black primary got a black header swallowing a black logo, a black button, and grey text nobody could read. The email is now the card you see on the Branding page's live preview: accent stripe, your logo, the subject as the heading in your primary colour, the body text, and a button on your accent with a label that always reads. Subject, body and button text stay editable per event exactly as before.

Changed
Permissions step — card layout, pill toggles (56×32), uppercase left-aligned title, guideline opacities and radii; new editable texts for the Required pill, the camera/video description, the hint and the footnote.
App Skin preview — new Permissions slide; hover a field in the form and the slide highlights the element it colours.
Skinned buttons — 12px corner radius across the app (was 5px), matching the design system.
Emails — all three templates rebuilt as the branded card; new tokens BRAND_ACCENT, BRAND_BUTTON_TEXT, BRAND_BACKGROUND, BRAND_TEXT, BRAND_MUTED_TEXT, BRAND_HEADING_FONT, ACCOUNT_NAME, YEAR (listed in the event's email template editor). The dead FilmilyApp social links and the hardcoded greeting are gone.
Fixed
Black-on-black emails for accounts whose primary colour is dark — the header is now the background colour and the button takes the accent, with the label colour computed for contrast.
Unbranded tokens in system emails — templates fall back to FANDEMiQ's own palette instead of leaking {{…}} placeholders.
10.28.1 September 2026
Changed Fixed

One switch, one decision

The consent screens that arrived with the September privacy work were legally right and visually wrong. Fans were shown a "use my location" tick box sitting on top of a greyed-out Location switch — two controls for one yes. On the terms screen the explanatory paragraphs were scattered between the tick boxes, and anyone with a marketing opt-in configured saw the "happy to be contacted" sentence printed twice.

The Location switch is now the consent. The sentence a fan is agreeing to sits directly above it, in the client's own words and language, and the switch label stays whatever the client has called it. One tap records the answer and then asks the browser. The switch shows off for a returning fan until they tap it this session — a control that is already on is not consent. Nothing about what gets recorded has changed: the same module answers, the same wording hashes, the same ledger.

Changed
Location consent is the switch — the separate tick box is gone; the consent sentence and note render above the Location switch on the permissions step.
Terms screen small print — the "we will turn your photo…" note, the AI notice, the generative notice and "Optional. Unsubscribe any time." now sit in one smaller block directly above Submit, still on the screen the fan acts on.
Events with location set to "Assume" now show an optional Location switch. Previously the consent was asked for but nothing rendered, so those events could never record a location.
Fixed
Marketing sentence printed twice on the terms screen — the module's body text and tick label are the same sentence; it is now shown once.
Nine consent texts could not be translated — the speech-analysis wording, the under-age messages, the "couldn't load consent details" error and the Loading / Try again labels were missing from the App Text page, so they never appeared in the Translation Manager. All are now editable and translatable per language.
10.28.0 September 2026
Fixed

The admin sidebar, sorted into three piles

The rail had grown one item at a time and it showed: Content sat at the very top away from the rest of the media tools, Reports appeared twice, and on a normal-height screen the list ran off the bottom and painted Help straight on top of Translations.

It's now grouped by what you're actually doing. Set up — Events, Campaigns, App Skin, Branding. Watch what comes back — Content, Live Dashboard, Reports. The library — Media Manager, Clip Manager, Content Status, Shared with you, Translations. Lines between the three so you can see where one ends.

Reports is one item now, pointing at the new dashboard. The older report pages — Brands, Devices, Users, Emotions, Tags — haven't been rebuilt there yet, so they've moved to a "Classic reports" link at the bottom of that dashboard's own sidebar rather than being left with no way in.

Deleting a FanMoment output said it had failed when it had worked

Removing an output takes every attempt for that fan's photo with it — that is deliberate, because leaving some behind re-arms the retry sweeper into fresh paid runs. But the list only refreshed when a delete succeeded. So the first click quietly cleared a batch of rows, the screen kept showing them, and every click after that hit a row the server had already removed. Each one came back as a blunt "Error 404 — try reloading the page or contact support", for work that had in fact gone through.

The list now resyncs whether the delete succeeded or not, so a row that is already gone disappears instead of sitting there collecting clicks — and a 404 from a delete no longer raises an error banner, because there is nothing wrong with removing something twice.

Fixed
Help no longer overlaps Translations — the expanded rail scrolls when the list is taller than the screen, instead of spilling over the items below it.
Content moved down next to the other media tools.
One Reports entry, not two.
Deleting FanMoment outputs stops reporting false failures — the list refreshes after a failed delete, and an "already gone" 404 is no longer shown as an error.
10.27.1 September 2026
Fixed

The camera build tripped over a comment

v10.27.0's camera app never reached staging: the build's linter rejected a two-line HTML comment for mixing tabs and spaces. Nothing about behaviour changed; the comment is now indented the way the linter likes.

Fixed
Camera app builds again — indentation in a template comment.
10.27.0 September 2026
Fixed Notes for admins

The other four findings — and one of them turned out to be a gap in the law, not just the code

Same independent review as v10.26.1; these are the four we'd queued. Two of them are about what happens to a fan's words after they change their mind or their content is removed. Two are about being honest in the Answers report.

A fan's quoted words outlived their clip. When a clip was deleted — by an admin, by retention, or because the fan withdrew consent — its transcript went too, but the themed summary it had fed kept the quote in a cached column nothing else knew about. It could still be read by the report and by Florence. Now: deleting a clip drops the summary for that question; withdrawing speech consent blanks the fan's transcripts and drops the summary; and on every read, any quote that no longer has a living answer behind it is filtered out anyway. Rebuilding with no answers left now clears the cache instead of leaving it.

A network wobble could remove consent steps. If the camera couldn't load the consent screen from the server — a timeout, a brief outage, a server error — it assumed the server must be an old one and fell back to the single-tick screen from before consent modules existed. That skipped the age declaration and any blocking module for as long as the wobble lasted. Only a definitive "that page doesn't exist" now means "old server"; anything else retries, then shows the fan a clear message and a Try again button, and nothing proceeds on a guess. On the server side, face analysis now also checks for the Illinois / Texas / Washington release it asks for, rather than trusting that the app showed the tick.

Big events were summarised from the last 1,500 answers and described as the whole crowd. The grouping reads at most 1,500 answers per pass. It used to take the newest 1,500 and then present the result as if it covered every answer. It now takes a random 1,500 — the crowd at kick-off and at full time answer differently — and the report says plainly that it's a sample, how big, and that the counts are estimates (shown with a ≈).

Theme counts could add up to more than the answers. The model was asked for a count per theme and each was capped individually, so two themes could each claim all six of six answers and show as twelve at 50%/50%. The model now lists which answers belong to each theme; the count is derived in code, an answer can only be counted once, and a model that ignores the instruction is scaled to fit. Existing summaries are marked for rebuild so nothing built the old way is still on screen.

Fixed
Erased answers are not quoted from the cache — on clip deletion, on speech-consent withdrawal, and on every read.
Rebuilding with nothing left clears the stale summary.
Soft-deleted clips are excluded from the answers list, the count and the quote check.
A failed consent-flow load blocks and retries instead of falling back to the legacy screen; "old server" now means exactly a 404.
Face analysis requires the biometric release it asks for, enforced at processing time, with unresolved jurisdiction treated as release-required — the same rule the consent screen already applied.
Answer summaries over 1,500 read a random sample and declare it; theme counts show as estimates.
Theme counts are derived from the model's assignments, never from numbers it wrote; totals cannot exceed the answers.
Notes for admins
Every existing Answers summary will show as needing a rebuild. That's deliberate: the counting method changed, and a summary built the old way must not sit next to one built the new way.
No database changes. Three new app-text keys for the camera (CONSENT_LOAD_FAILED, GENERAL_LOADING, GENERAL_RETRY) have sensible English defaults.
10.26.1 September 2026
Fixed Notes for admins

Five things an independent review caught before anyone else did

A second pair of eyes went over the last week's changes and found five places where the code did the right thing in the obvious case and quietly the wrong thing in the edge case. None had bitten a real fan yet. All five are fixed, each with the reviewer's own reproduction kept as a permanent test.

A fan who withdrew consent to transcription was still transcribed. The consent ledger never edits history — a withdrawal is a new entry, not a change to the old one. The transcription check only looked at the old entry, which still said "yes". It now looks for the withdrawal too, so changing your mind actually changes what happens.

Clearing FanMoment outputs could quietly regenerate them. Deleting a FanMoment output was designed to stop the retry sweeper. It did. It also made the photo look brand new to the other sweeper, the one that settles photos left undecided at the end of an event — which happily generated a fresh picture, charged for it, and emailed the fan again. Deleted history now still counts as history.

A storage hiccup during deletion could leave a fan's photo behind for good. Deletion removed the database record first and the files second. If the second step failed, nothing was left pointing at the files, so nothing could ever come back for them. Files go first now; if that fails, the record survives and the next hourly run tries again.

Retention could get stuck behind content still in use. The purge always took the oldest 200 items due for deletion. Anything still baked into a live fan wall is deliberately held back — but held-back items stay put, so an account with 200 of them at the front of the queue never got past them. Held-back content is now stepped over, and still reported.

Dashboards waited on transcription they didn't need. The "clip finished" signal to live dashboards had been chained after the answer-transcription step, so every clip queued behind AI work before the dashboard heard about it — and a transcription that gave up stopped the signal altogether. It now runs alongside, not in front.

Fixed
Withdrawn speech consent is honoured at processing time, even though the ledger is append-only.
Deleted FanMoment outputs no longer re-arm automatic generation or lose the photo's routing decision.
Hard-deletes remove files before records, so a storage failure leaves something to retry.
Retention steps past in-use content instead of re-selecting the same stuck batch every hour.
Live-dashboard clip notifications no longer depend on transcription.
Notes for admins
No configuration or database changes. Four further review findings (report totals, cached quotes after deletion, the camera's handling of a failed consent-flow load, and sampled answer counts) are queued for the next release.
10.26.0 September 2026
Added Notes for admins

The background remover now admits when it isn't using the GPU

FanMoment cuts a fan out of their photo before it composites or regenerates them, and that cut-out is the slowest thing in the pipeline. We're looking at whether a GPU makes it fast enough to matter, which turned up a nastier problem than "is it faster": when the graphics libraries are missing or the wrong version, the machine learning runtime doesn't complain. It quietly carries on using the processor instead. A GPU replica that isn't using its GPU looks exactly like one that is, only slower — and you'd have paid for the GPU either way.

The service now says which one it actually picked, in its logs and on its health endpoint. If it fell back, that's visible rather than something you have to infer from a stopwatch.

Nothing changes for what's running today: with no provider set it uses the processor, exactly as before, and the processor is always kept as a fallback so a replica with a bad GPU still serves rather than refusing to start. The experimental GPU build is a separate image that nothing deploys — it can't break staging or production.

Added
The background-removal service reports its active execution provider in its logs and health response, so a silent fallback to the processor is visible.
A separate, unwired GPU image for the ongoing speed experiment, with the two failed approaches documented in it so nobody repeats them.
Notes for admins
No configuration change is needed and no behaviour changes on staging or production.
Findings so far: shrinking the photo before sending it buys nothing — the model resizes everything to a fixed size first — so roughly eight gigabytes is the floor, and on our hosting that memory forces four processor cores per replica.
10.25.0 September 2026
Added Notes for admins

Ask a crowd a question, and find out what they actually said

Set a video Call to Action — "tell us your player of the match, you have ten seconds" — and until now you got several hundred videos and no way to know what was in them short of watching every one. The answers were there. Nobody could read them.

FANDEMiQ now transcribes those answers and groups them. Not counted — grouped: five hundred fans naming a player will give you "Saka", "Bukayo", "number 7" and "saka obviously", and a straight tally of that returns five hundred results of one. The new Answers report reads them as a set, works out which ones mean the same thing, and shows you the themes with real counts and real quotes. Florence can answer straight out too — ask her what fans said about the new kit and she will tell you.

Two things we were deliberate about. Quotes are exactly what fans said, never tidied and never generated, because this ends up in front of sponsors. And grouping runs when you press the button, not when the page loads, so two people looking at the same event see the same numbers.

Transcription is off until you switch it on, per account or per event. When it is on, fans get their own tick for it — separate from the terms — and anyone who says no still takes part and still gets their video. We simply do not turn their words into text. The report tells you how many declined, so a smaller number of answers reads as what it is rather than as a fault.

Added
Answers report (Reports → Answers): every question fans answered on camera, grouped into themes with counts, shares and verbatim quotes.
Speech-to-text on Call to Action replies, with the question itself used as context to sharpen short answers.
Three new Florence tools — list the questions, read a summary, or pull individual answers.
Per-account and per-event switch for answer transcription, off by default.
A separate fan consent tick for having their words written down. Optional, never blocks taking part.
Skip reasons on every answer — declined, switched off, too long, no audio — so a thin result set is always explainable.
Notes for admins
Nothing is transcribed until an account is opted in AND the fan has ticked the speech box. Both are checked again at processing time, not just in the app.
Grouping an answer set uses AI and costs a little, which is why it is a button rather than automatic. Rebuild when new answers arrive — the report tells you when they have.
10.24.1 September 2026
Fixed

The login screen tells you which version you're on

Every page in the Manager carries a footer with the running version, linked to these release notes — except the five pages you see before you log in. Those had their own hand-written footer with just a copyright line, so the one screen you can always reach without signing in was the one screen that couldn't tell you what was deployed.

Login, Forgot password, Reset password and both confirmation screens now show the version and link to the release notes, reading the same live value as everywhere else rather than a number typed into the page.

Fixed
The sign-in screens show the running version, linked to the release notes.
10.24.0 September 2026
Fixed

The camera app tells you what to film, not just what to photograph

Set a photo Call to Action and the fan reads your prompt before they take the shot. Set a video one and, on most events, they saw a camera and nothing else — no title, no instructions, no clue what you wanted them to say.

The text was there the whole time; the fullscreen video camera was simply painted on top of it. Photo CTAs only go fullscreen when there's a lead-in video to play, so they never hit the problem, which is why this looked like a setting that video CTAs were missing.

The prompt now sits over the camera, and it gets out of the way: three seconds after recording starts it fades out, leaving the fan a clean frame. Stop and retake and it comes back. Photo CTAs with a lead-in video, which had the same blind spot, now show their prompt too — it stays up until the picture is taken, since there's no recording to fade out of.

Fixed
Video Call to Action prompts are visible — title and description now render over the fullscreen camera instead of behind it.
The prompt fades three seconds into recording, and returns on a retake.
Photo Call to Actions with a lead-in video show their prompt, which they previously never did.
10.23.0 September 2026
Added

Clearing out FanMoment outputs

There was no way to remove a FanMoment output from the list. Test runs and failed attempts stayed there permanently, and a screen full of old errors makes the real ones hard to see.

Each row can now be deleted, and there's a button to clear all the failed ones at once. Completed outputs are never touched by the bulk clear, so a fan who actually received their picture keeps it. Nothing is destroyed — the record is hidden from the list and any delivered image is left where it is.

One thing works differently from how it looks: deleting a row removes every attempt for that fan's photo, not just the line you clicked. That is deliberate. The system counts failed attempts to decide when to stop retrying, so removing only some of them would convince it to start again — and on a generative FanMoment, retrying costs money. The confirmation says so.

Added
Delete on each FanMoment output, and Clear all failed outputs for a FanMoment.
10.22.0 September 2026
Fixed

Failures that used to be swallowed now get reported

A reliability review found several places where something going wrong looked, from the outside, like it had gone right.

The biggest was file storage. When a file was replaced with different content of exactly the same size, the upload was skipped and the old content stayed — reported as a success. Storage errors were caught and reduced to a plain "didn't work", losing the diagnosis. And a fan's FanMoment scene image failing to load was treated as "there is no scene", so the fan received their cut-out on a blank background instead of the sponsor's artwork, with nothing recorded as wrong.

The camera app had two related problems. Its request timeout only covered the response headers, so a stalled download could hang indefinitely, and a timed-out request was never actually cancelled. And when loading the event list failed, the app could get stuck believing a request was still in flight, so refreshing did nothing until the fan reloaded the page.

Uploads that hit a genuine storage problem now tell the fan exactly that, with the same message as before, while the technical detail goes to the log rather than to the phone.

Fixed
Replacing a file with same-sized content now actually replaces it.
Storage errors are reported and retried instead of being reduced to a silent failure.
A FanMoment with an unreadable scene image fails and retries rather than delivering an unbranded picture.
The camera's request timeout covers the whole request and cancels it properly.
A failed event-list load no longer wedges the app until reload.
10.21.7 September 2026
Changed

One build cache instead of two, after the second one hung a deploy for an hour

Yesterday's change shrank what was written to GitHub's build cache so it would stop deleting itself. The next deploy then got stuck: the images built and uploaded in about a minute, and writing to GitHub's cache carried on for fifty-two minutes before the run was stopped by hand.

That backend had now failed in every way it could — it exceeded its size limit and evicted the layers the next build needed, it was consistently the slower of the two caches, and finally it stopped responding altogether. It has been removed. The container registry keeps the full cache, has no size limit, and finishes in seconds.

The build step also gained a time limit, so a cache that stops responding fails quickly and can be retried, rather than quietly consuming an hour.

One honest cost: builds on a pull request now start cold, because a pull request has no registry credentials. Nothing else changes.

Changed
Removed the GitHub Actions build cache. The container registry cache is now the only one.
Builds time out after 25 minutes instead of hanging indefinitely.
10.21.6 September 2026
Fixed

The build cache was evicting itself

Deploys spent most of their build time uploading a cache that was about to be thrown away. GitHub allows a repository 10 GB of build cache and deletes the oldest entries once you go past it. Every build was writing two to three gigabytes — one blob alone was 942 MB — so the repository sat just over the limit and each build quietly deleted the very layers the next one needed.

The evidence was a rebuild of an identical commit twenty-five minutes after the first: it should have been almost instant, and instead it rebuilt from scratch. On the video-processing image, 352 of 402 seconds went on uploading cache, against about 50 seconds of actual building.

The full cache still lives in the container registry, which has no size limit and is what makes a normal deploy fast. What goes to GitHub's store is now the smaller final-image portion, which fits inside the quota and therefore survives. A smaller cache that stays is worth more than a bigger one that keeps being deleted.

Fixed
Build cache stops evicting itself. The GitHub cache export is now the final-image layers rather than every intermediate build stage.
Cleared 10 GB of thrashed cache entries so the next build starts from a clean quota.
10.21.5 September 2026
Changed

Deploys stop rewriting things that were already right

A deploy took about 25 minutes. Shipping the code took 42 seconds. The rest was the pipeline dutifully writing every single setting — connection strings, storage URLs, CORS origins, CPU and memory — onto every container, on every deploy, whether or not a single one of them had changed. On the production run measured before this change, that was 13½ of 16 minutes spent setting values to what they already were.

The settings are still checked on every deploy, exactly as before — that guarantee is why they were being written in the first place, and configuration silently drifting is a genuinely bad way to lose an afternoon. What changed is that the pipeline now reads each app first and only writes where the live value actually differs. Same protection, without the wait. Anything it can't confidently compare it writes anyway, so the safe outcome is the slow one, never the wrong one.

The version stamp also moves into the same operation that swaps the code. It used to be applied a few minutes earlier, which meant that during every deploy there was a window where the footer advertised a version the site wasn't running yet.

Changed
Deploys are roughly twice as fast. Configuration is compared rather than rewritten; measured against live production, 15 of 24 writes disappear.
The version and the code become true at the same moment. No more window where the footer is ahead of what's serving.
The deploy step itself is never skipped. Re-running a deploy always produces a fresh revision, whatever the tag says.
10.21.4 September 2026
Fixed

"Resent" now means the fan can actually open it

Pressing Resend on a FanMoment that had failed reported success, marked the row as sent, and emailed the fan a link to a not-found page. The output had errored, so there was no picture behind the link — but the sender never checked, and the share page did. Two systems disagreeing about the same output, with the fan finding out.

The sender now applies exactly the test the share page applies: an output that never produced an image cannot be emailed. Where there is nothing to deliver, the button is replaced by "Nothing to send" and the screen explains that the FanMoment needs re-running first. When a send genuinely doesn't happen, the row no longer claims it did.

Also fixed: a FanMoment share page returned a server error, rather than the picture, for any account that hasn't set up an app skin.

Fixed
Resend refuses an output with no image, instead of emailing a link that 404s and reporting success.
The outputs list tells the truth about what was sent. Any successful response used to be treated as "delivered".
FanMoment share pages work for accounts with no skin. They were looking for a page layout that has never existed.
10.21.3 September 2026
Added Fixed Changed Known limit

The background remover stops running out of memory

FanMoments were failing on live. The picture a fan gets is built in two steps — cut the fan out of their photo, then place them on the campaign's scene — and the first step was dying. Not occasionally: the service behind it was being killed and restarted in a loop.

The cause turned out to be nothing to do with the size of anyone's photo. The off-the-shelf background remover we were running holds on to the memory it uses for one picture so it can reuse it for the next, rather than handing it back. The result is a service whose appetite grows after the first request and then never shrinks — it survived picture one and was killed on picture two, every time, which is why this looked so much like a random intermittent fault. It also meant every previous attempt to measure "how much memory does this need" measured the hoard rather than the need, and kept arriving at numbers far larger than the real answer.

We now run our own build of that service. It hands memory back after every picture, and it uses one fixed, pinned cut-out model instead of whichever the tool happened to default to — the default was a model that cannot finish a single picture inside the largest container this platform can run. Steady memory use went from "grows until killed" to a flat 1.7 GB. Cut-out quality went up rather than down: the new model correctly separates a person from a cluttered background — bookshelves, a guitar case, an office behind them — where the old fallbacks merged nearby dark objects into the subject.

The service also now refuses to work on more than one picture at a time, and says so plainly when a cut-out fails rather than quietly returning the original photo with its background intact. A fan should never receive a picture that looks half-made; if we cannot do it properly, the job fails, retries, and is swept up again later.

Added
A purpose-built background-removal service (docker/rembg), built and deployed by our own pipeline instead of pulling a public image, with the model pinned and memory retention turned off.
A build check that stops a workflow step growing past GitHub's size limit. Twice now, an oversized step has made the entire deploy pipeline fail to start — no jobs, no logs, and a green tick everywhere else. It is now caught on every pull request.
Fixed
FanMoment cut-outs no longer crash the background remover. Memory use is flat across requests instead of growing until the service is killed.
Better cut-outs on busy backgrounds. The pinned model separates people from cluttered rooms cleanly.
The deploy now pins how the service starts. The container app carried a leftover startup command from the old public image, which quietly ran the old program out of the new image — a deploy that reports success while nothing has changed. Caught on staging before it reached live.
A deployed instance can now prove it works. A built-in self-check generates its own picture, cuts it out, and reports the timing and memory — no fan data, no storage, no billable calls — so "is this really running what we think it is?" has an answer that takes seconds.
A failed cut-out is now visible. It can never be mistaken for a finished picture, and there is a test that fails the build if that ever changes.
Changed
Background-removal capacity re-sized. The service now gets the memory it actually needs and handles one picture at a time.
Known limit
Cut-outs take about eleven seconds each, measured on the real staging hardware — the same as on a laptop, so there is no speed-up to bank on. At the capacity the platform is allowed to use, that works out at roughly 110 fan pictures a minute rather than the 200 the platform is designed for. Reaching 200 needs more compute allowance from Microsoft than this subscription currently has; the four services already ask for more than the allowance at full stretch, which predates this work. Nothing breaks when the ceiling is reached — pictures simply arrive more slowly.
The cut-out timeout rose from 30 to 120 seconds, which is honest about how long this work takes rather than abandoning it halfway.
10.20.2 September 2026
Fixed

"No uploaders found" now tells you why

Open the uploader list on a fan wall that hasn't collected enough content yet and it said "No uploaders found". Read literally that means "we don't know who these people are", which is a frightening thing to see next to a wall of faces — the first assumption is a consent or terms-and-conditions problem, and the real answer was much duller: the wall simply has nothing in it yet.

A fan wall only assigns its slots once the campaign has enough distinct content to fill every one without repeating anyone. Until then it holds no uploaders, because it holds no clips. The message now says so and shows the arithmetic: how many slots the wall needs, and how many the campaign currently matches. A 5×5 wall with three recordings against it reads "It needs 25 and the campaign currently matches 3", which is the whole story in one line.

Separately, and more seriously: a fan wall built from photos never reported its uploaders at all. Every slot came back as anonymous no matter how many of those fans had given us an email, and the CSV a sponsor gets came back empty. Fan walls have accepted photos as well as video for a long time; the uploader list only ever looked at the video. It now looks at both.

Fixed
Photo fan walls report their uploaders. Previously only video slots were read, so a photo-built wall — and its sponsor CSV — reported everyone as anonymous.
The empty uploader list explains itself, with the slot count and how much content the campaign currently matches, instead of the ambiguous "No uploaders found".
10.20.1 September 2026
Fixed

Test clips you can actually see

Building a fan wall before an event has any content, you can upload a few of your own files to check the layout. Feed it video — which is what a video fan wall wants — and every tile came back as a broken-image icon. The preview was drawing each tile as a picture, and a picture frame cannot hold a video. Videos now render as videos, showing their first frame, and the tile you click still opens and plays.

Files the browser genuinely cannot open (iPhone HEIC photos are the usual one) now say so by name, rather than leaving you to guess which of the files you picked was the problem.

Separately, two behind-the-scenes search functions had drifted out of step with the filters they are called with. The campaign descriptor list — the tag cloud showing what the AI has spotted across a campaign's content — was failing every time it was asked, and the event stats popup had quietly gone the same way for a second time. Both work again, and there is now a test that fails the build the next time a new filter is added to some of the search functions but not all of them.

Fixed
Video test clips render in the fan wall preview instead of showing a broken image for every tile.
Unsupported test-clip files are named. A clear warning replaces silently blank tiles.
The campaign descriptor list works again. tfnClipSearchDescriptors was missing two parameters its caller has been passing, so every request returned an error.
The event stats popup works again. tfnClipSearchReport lost step with the search a second time, for the same reason.
10.20.0 September 2026
Added Fixed

"Everyone who isn't at Millwall"

You could always say "only fans in this part of the ground". You could never say "and everyone else gets this instead" — there was no ELSE. The nearest thing was a second campaign with no filters at all, and because that one accepts every photo, the fans in the first campaign matched it too and received both sponsors' pictures.

Events can now send each photo to a single campaign. Give the campaigns a priority, and a photo goes to the first one that accepts it and no others. Put the zone campaigns at the top and an unfiltered campaign at the bottom, and that last one becomes the catch-all: everyone the campaigns above didn't claim. Four sponsor zones at one match, four different backdrops, one picture each — which is the thing that wasn't possible before today.

It is off by default and nothing changes on an event that doesn't turn it on. The campaign list shows the order the rules are actually evaluated in, marks the catch-all, and warns about the two ways this gets set up wrongly: no catch-all at all, or an unfiltered campaign sitting somewhere other than last, where it silently swallows everything and starves the campaigns beneath it.

One honest trade-off, stated in the admin screen rather than discovered later: when the first campaign in the order filters on location, faces or tags, that question can't be answered until the photo has been analysed, so those FanMoments arrive after analysis rather than the instant the photo lands. An unfiltered campaign at the top is unaffected. The alternative was guessing early and emailing some fans the wrong sponsor's picture, which cannot be taken back.

Added
Send each photo to a single campaign on the event, and FanMoment priority on the campaign (lower is checked first).
A catch-all badge and misconfiguration warnings on the campaign list, plus the evaluation order made visible.
A sweep that settles undecided photos. If a photo's analysis fails outright, its campaign is chosen from what is known rather than the fan being left with nothing.
Fixed
The documentation was wrong. docs/PLATFORM-TAXONOMY.md still said FanMoments ignore campaign filters. They stopped doing that when the filter fix shipped; the doc has been corrected, and the four-sponsor scenario it lists as impossible now works.
10.19.0 September 2026
Fixed

Deleting a photo now deletes the photo

Three things were wrong with removing a fan's photo, and together they meant the platform did not do what our published retention schedule says it does.

Deleting photos in bulk from the admin screens operated on the wrong table. The code was copied from the video equivalent and never switched over, so selecting a photo soft-deleted whichever video happened to share its id — someone else's content — while leaving the photo the operator picked exactly where it was. Nobody would have seen it happen.

The single "purge" button did nothing at all: it raised an error every time. That made our data processing agreement's promise of per-item erasure "immediately through the admin platform" untrue for photos, where the only way one ever left was the retention clock.

And a hard delete missed the FanMoment images. Those are a composite of the fan's own face, stored in a different place from the photo, so the deletion tidied away the records pointing at them and left the pictures themselves sitting in storage — still reachable by any link already sent out.

Fixed
Bulk photo actions act on photos, not on videos that happen to share an id.
Purging a single photo works, with the same rules as video: it must already be deleted, you must own the event, and a photo still used in a live fan wall is refused rather than quietly breaking it.
A hard delete removes the FanMoment images too, alongside the photo, its stills and every derived piece of analysis.
10.18.0 September 2026
Fixed

The address we thought was the fan's

In production the fan-facing API sits behind Azure Front Door. That means the network address the platform sees is the door's, not the fan's — and we were reading it as though it were the fan's.

Three things depended on it, all shipped in the last fortnight. The jurisdiction stamp, which decides which consent screen a fan is shown, what age line they see, and whether mood inference is allowed to run. The consent record's address fingerprint, which we describe as per-fan evidence — in reality every fan arriving through the same door produced an identical value, so it evidenced nothing. And the per-address rate limit, which pooled everyone behind one door into a single allowance.

Staging could never have shown this: its camera app talks to the container directly, with no door in between.

Front Door does pass the fan's real address along, but that header can only be believed when the request genuinely came through our own door — otherwise anyone calling the container directly could name their own country and pick their own consent screen. So it is honoured only when the door identifies itself with the id we expect, and ignored otherwise.

Fixed
The fan's real address is now used for the jurisdiction stamp, the consent record fingerprint and the per-address rate limits, with the forwarded value accepted only from our own Front Door.
Requests that reach the container by any other route keep using the address they actually came from, so local development and direct traffic are unchanged.
10.17.1 September 2026
Fixed

Closing the enumeration hole properly

The fix that shipped in 10.16.0 closed fan-media enumeration for new content and left everything older than 18 August open. Verifying the live production deploy found two of six guessed addresses still returning a real fan photo.

The cause was a piece of reuse that looked right and was not. The signing helper deliberately keeps honouring older, unsigned links, because share links are emailed to fans and breaking them would strand content people already have. The integration addresses are not like that: they are generated fresh inside every API response and never sent to anyone. Grandfathering them protected nothing and kept the whole historical library reachable by counting. They now require a valid signature or the owning account's token whatever their age.

Fixed
Integration media addresses require a signature regardless of content age. Anything not signed, and not accompanied by the owning account's own token, is refused.
10.17.0 September 2026
Fixed Added Still needs you

The next six from the review

Items five to ten of the review's top ten. One of them turned out to be a smaller problem than reported and a different one turned out to be bigger, which is the useful kind of surprise.

The bigger one: three environment config files had been sitting in the repository since the original code import, carrying a customer's production API key, our email-sending key and the mailbox password. An ignore rule was added months later, which does nothing to a file already committed. Checking what production actually runs proved something reassuring and something awkward at once — those files are excluded from the container images, so none of it was live config; but that also means the Manchester City webhook integration has not been firing at all. The keys are out of the current code and the build now fails if anyone commits one again. They remain in git history, so all four still need rotating, and that needs you and Man City.

The smaller-sounding but more useful fix: the mobile API answered HTTP 200 to everything, including failures, with the real result hidden in the response body. Nothing that watches HTTP could tell a working event from a broken one. Failures now return a real status code, and the fan still gets the same specific message, because the app was already written to read it. Alongside that, caught exceptions were being written into logs as plain text rather than as exceptions, which is why they never appeared in the platform's own error tracking. They do now.

Share pages were the other real risk. They are served from the same web address as the admin dashboard, and a client's own "script tags" field is placed on that page as-is — so a script saved by any member of any account could act as whoever opened the link, including us. The page now runs in a sandbox with no access to the admin session, saving script tags requires account-management permission rather than mere membership, and the field is no longer sent to fans' phones, which never used it. The permanent fix is to move share pages to their own web address; that needs a DNS change and is tracked.

Finally, two quieter ones. A single failed render used to freeze every other fan wall and film queued in the same minute, because a failed job in the queue never counts as "finished" and everything chained behind it waited forever. And the retention purge — which deletes fan media hourly on production — had no test covering the one check that stops it deleting content still in use.

Fixed
Mobile API failures return a real HTTP status. One filter maps the code the response body already carried onto the status line, so no controller had to change and the body a fan's app reads is identical.
Caught exceptions reach error tracking as exceptions, with their stack trace, instead of being flattened into a log message.
Share pages are sandboxed off the admin origin, with security headers added; the script-tags field now needs account.manage, and is no longer sent to the camera app.
A failed render no longer strands its batch: the four chained render jobs now reach a final state on failure, so the rest of the minute's queue drains. Failures are still logged and still mark their row.
Environment config files removed from git, with a CI step that fails the build if one is ever committed again.
Corrected two alert settings that could not have worked: a replica alert set to half the real ceiling, and a database metric that the planned tier will not emit.
Added
The telemetry connection string is now set by the deploy on all three services in both environments. It was set on the live apps but declared in nothing that runs, so a re-created app would silently have gone dark.
Tests: the retention purge's in-use guard and full delete subtree (six cases), the render-chain finality contract, real status codes on failure, exceptions logged as exceptions, and the share-page sandbox.
Still needs you
Rotate four credentials exposed in git history: the Man City API key and its subscription key, the Resend key, and the SMTP password.
Man City's webhook integration is dead and has been for some time. Left off deliberately rather than silently switched back on.
10.16.2 September 2026
Fixed

The blank page that wouldn't tell you anything

Opening an event's campaigns page could render nothing at all — no heading, no error, not even a spinner. It looked like the page had simply given up, and there was nothing to click.

It had. Three separate crashes could cause it, all of them triggered by an account or event with no data behind it yet — a brand-new client, or an event that has been removed. Underneath them sat the real problem: the page waited on three things loading at once and had no handler for any of them failing, so a single failure left it stuck mid-load forever while the loading indicator switched itself off. One missing line of error handling turned three small crashes into an entirely blank screen.

All three crashes are fixed, and the page now says what went wrong instead of showing nothing. If an event genuinely isn't there — deleted, or belonging to another account — you get "Event not found" and a link back to your events, which is the honest answer.

Fixed
Empty events list no longer crashes the page. Falling back to "the first event" on an account with no events read a property off nothing at all, which broke every page waiting on that list.
An account with no app skin saved no longer crashes the App Skin page. Two settings were read outside the check that guarded against a missing skin.
Choosing a location no longer crashes the event editor when the result comes back without a map viewport.
Failures are visible. The campaigns page now reports a load failure with a "Try again" link, and shows a proper "Event not found" screen instead of a blank one.
10.16.1 September 2026
Fixed Known issue — needs a Google Cloud change, not a deploy

Pick a country, don't type one

The event country was a two-letter text box. Type "UK" — which is what most people type, and which is not an ISO code — and the event quietly ended up with no country at all: strictest consent flow, no emotion inference, no warning that anything had gone wrong. It is now a searchable dropdown of every country, and you can find one by name or by code. The same for US states, which is where the Illinois and New York face-analysis rules hang off. Changing the country away from the US now clears the state too, so moving an event to France and back to the US can't silently re-arm Illinois' rules.

The three data-protection dropdowns were also squashed into three columns too narrow to hold them and overlapping each other. They are two rows of two now.

Fixed
Event country and US state are searchable dropdowns rather than free-text ISO boxes, so an unrecognised code can no longer be entered at all.
The data-protection controls no longer overlap — two columns instead of three, all full width.
Stale US state cleared when the event country moves away from the US.
No more stray hairline over the popout. Google's Places autocomplete appends its dropdown to the page body and leaves it there when a lookup fails; an empty one was drawing as a 1px line across whatever tab you switched to. Empty dropdowns are now hidden.
Known issue — needs a Google Cloud change, not a deploy
Location search in the event popout returns "This page can't load Google Maps correctly" on staging and production. The Maps JS Places library now calls Places API (New), which has never been enabled on the Google Cloud project behind our Maps key. Enabling it (and adding it to the key's API restrictions) fixes the search everywhere; no code change will.
10.16.0 September 2026
Fixed Added

Four ways in, closed

A read-only review of the whole codebase turned up four things that were wrong in a way nobody would have noticed from the outside: not crashes or slow pages, but doors that had been left open and a delete rule that was quietly overriding our own retention promise. All four are fixed here.

The worst of them was arithmetic. The short code in a fan media link is not a secret — it is the row's database id, shifted and encoded, plus a check character from a random number generator seeded with that same id. Anyone who worked that out could count upwards through it. We had already spotted this once and closed it for the share links we email to fans; the copy of the same pattern on the integration API had been left open, so every fan photo, clip and personalised video on the platform could be pulled with no login and no token. Those links are now signed, and a link that is not signed only opens for the account that owns the content.

The admin dashboard had six media routes with no authentication at all — not weak authentication, none. Every neighbouring endpoint in the same files had it; these six had simply been missed, and they served rendered fan walls, audio mixes and story previews for any client by integer id. There is now a test that walks every endpoint in the admin API and fails the build if one of them loses its lock, because "somebody forgot" is exactly how the first six happened.

There was also a leftover debug endpoint from before our time on this codebase, gated by a permission that every Director happens to hold and that checked nothing about which club you belonged to. It returned a clip and everything the AI had derived from the faces in it. It is deleted, and so is the permission.

Last, the one that was actively costing us: a storage rule was deleting every original fan photo and video 90 days after upload, regardless of what retention period the client had bought and regardless of whether that clip was still baked into a live fan wall. It also sat two days away from deleting the key file that signs everyone's login session. It has been removed from both environments.

Fixed
Integration media links are signed. /api/integration/data/{upload,output} now requires a signed link or the owning account's own integration token; an enumerated id gets a 404. The same signing key the Manager already uses is now set on the mobile API in both environments.
Six admin media endpoints require permission and ownership: fan-wall video and stills, audio mix and waveform, story preview and still. Their long-lived caching is now private rather than shared, so no proxy can hand one client's file to another.
Debug endpoint removed along with the debug permission, in the code, in the catalogue and in the seed script (which now also clears the claim from existing roles).
Storage no longer deletes fan originals. The 90-day delete is gone from the uploads lifecycle rule in infrastructure code and on the live staging and production accounts. Deletion of fan media is the retention service's job alone, because it is the only thing that knows the account's period and that a clip is still in use.
Added
A test that every admin API endpoint carries an authorisation attribute, and a test that the debug route stays deleted.
scripts/fix-uploads-lifecycle-delete.sh — idempotent, dry-run by default, for re-checking the storage rule.
10.15.0 September 2026
Added Changed

The consent screen now knows where the fan is standing

Until now every fan on the planet saw the same terms tick. That was fine when the only thing we asked for was an email address; it is not fine when a fan in Illinois has a statutory right to a written release before their face is analysed, a fan in Berlin must be told an AI is estimating ages, and a fan anywhere must be asked separately before we take a precise location.

The capture screen is now composed on the server for each fan and each event. Everyone gets one base screen with one tick. On top of that, the fan's jurisdiction (resolved inside the platform, never sent to a third party) adds what their location requires: the AI transparency line, the Illinois release, the Texas and Washington notice, and a precise-location opt-in that is off by default everywhere. Client switches add the marketing tick and the AI-made-output notice. If the fan's location cannot be resolved, they get the strictest set, not the loosest. Under-age fans get an honest "I'm under 13" link that switches them to anonymous participation instead of a dead end that invites a false date of birth.

And the ledger finally records what it claims to. Every tick is its own row, stamped with which module it came from, the country and state the fan was resolved to, how that was resolved, and a keyed hash of the address it came from. The wording behind every hash is stored the moment the screen is composed, so "show me exactly what this fan agreed to" has an answer even after the terms have been edited a dozen times.

Two decisions taken with this release: emotion inference is on by default and switches itself off wherever the law says it must (EU/EEA fans or events, staff or student audiences, unknown locations), and the retention purge is live on both environments.

Added
Server-composed consent flow: GET /api/App/consent-flow returns the modules for this fan at this event, each with its wording hash; the camera app renders them and echoes the answers with the upload.
Jurisdiction-keyed modules: EU/UK AI notice (AI Act Art 50(3)), Illinois written release (BIPA §15(b)), Texas/Washington notice and consent, precise-location opt-in everywhere, marketing (never pre-ticked), generative-output notice (AI Act Art 50(4)).
Age floor by jurisdiction (UK/US 13, EU per member state, India 18, otherwise 16) with a per-account minimum that can only raise it, and an anonymous path below the floor.
Consent wording store and ledger evidence columns: module key, detected country/region, detection method, event country, keyed IP hash.
Account establishment region and event US state in the Manager, with data-protection warnings on the event editor (staff/student audiences, no-go countries, Illinois, New York signage).
Client-editable consent copy in the app-text editor, translatable like every other string.
Legal document drafts for counsel under docs/legal/: DPA, CCPA service-provider annex, sub-processor register, DPIA, records of processing, retention schedule, consent wording.
Changed
Emotion inference defaults on for every account, and is switched off automatically by policy for EU/EEA fans or events, non-consumer audiences and unknown locations. Existing accounts are moved to the new default; an account explicitly switched off stays off.
Retention purge is live on staging and production (no longer dry-run).
Older cached camera builds keep working: their legacy consent fields map onto the same two ledger modules.
10.14.0 September 2026
Added Changed Removed

Doing what the privacy page already says we do

Our public data-protection page has been honest about its gaps for a while: fan media is "deleted after 90 days", an IP-based location lookup "still runs when a fan refuses location", and a few things were "not yet implemented". This release closes the ones that were actually wrong today, and adds the switch that the EU's AI Act now makes necessary.

The big one is emotion. Estimating a fan's mood from their face is an "emotion recognition system" under the EU AI Act — high-risk from December 2027 and already prohibited outright for staff and students. It used to run for every fan on the planet with no way to turn it off. It is now opt-in per account (and per event), and the platform refuses to run it for anyone in the EU or EEA, or at any event whose audience is employees or pupils, whatever the setting says. Age and gender estimation carries on, because that is transparency-only.

Location got the same treatment. If a fan says no to sharing their location, that is the end of it: the app no longer quietly asks our server to guess where they are from their IP address, and the second geolocation provider that guess used to fall back to has been removed entirely. Every new upload records whether its coordinates came from the device with the fan's permission or not at all, and the heatmaps only plot the ones that did.

And the 90-day promise finally has something enforcing it. A retention job now hard-deletes clips and photos (and everything the AI derived from them) when their time is up, and forgets the contact details of fans who have nothing left on the platform. It ships counting, not deleting, until each environment has had its numbers checked.

Added
Face-analysis mode per account (Off / Age & gender / Age, gender & emotion, default age & gender) with a per-event override, plus an event audience type and country — all in the Manager's account and event editors.
FaceAnalysisPolicy: the rule set that decides what Rekognition may infer for a given fan and event. Emotion is forced off for EU/EEA fans or events and for staff/student audiences; the whole face pipeline is off for events in, or uploads from, China, Russia and Vietnam.
Location provenance (LocationSource) and capture jurisdiction (CaptureCountryCode/CaptureRegionCode) stamped on every clip and photo at ingest; GET /api/App/jurisdiction resolves country + US state from a local GeoLite2 database inside the API image, so a fan's IP never leaves the platform to be looked up.
Retention purge job with per-account RetentionDays (default 90), a photo hard-delete to match the clip one, orphan-identity pseudonymisation, and a dry-run mode that is ON by default.
Anonymous consent evidence: an upload with no email or phone now still records the terms tick, keyed to the content rather than a person.
Changed
Emotion inference is opt-in. Existing accounts land on age & gender only; nothing changes for them unless an admin opts in.
A refused location permission means no location. The camera app no longer substitutes an IP-derived guess, and assume mode no longer blocks or guesses — it simply uses a fix if one is already granted.
Heatmaps and league usage maps plot consented GPS fixes only. Older rows with no provenance are included only when their accuracy looks like a device fix rather than an IP guess.
Azure Vision failures no longer post the fan's image analysis to Slack; only a length and status are logged.
Removed
The ipapi.co fallback, and any code path that turns an IP address into coordinates.
10.13.0 August 2026
Added Changed Fixed

Consent you can prove, not just assume

Until now, a fan's consent lived in exactly one place: a tickbox in the camera app. The app checked it, the server trusted the app, and nothing anywhere recorded what was actually agreed to, when, or to which version of the terms. If anyone ever asked "show me this fan agreed" — a regulator, a rights team, a fan themselves — the honest answer was that we couldn't.

This release gives the platform a consent ledger: an append-only record of every terms acceptance (including a fingerprint of the exact terms text that was on screen when the box was ticked), every marketing opt-in and opt-out, and every club-sharing choice — each one tied to the specific photo, clip or text it covered. Records are only ever added, never edited: a withdrawal doesn't erase the original grant, it sits alongside it, so the history stays honest in both directions.

The camera app also lost a few doors that should never have existed. It was possible for a link to arrive with terms "pre-accepted" or the marketing box pre-ticked — a pre-ticked box isn't consent, so links and scripts can now fill in your email address and nothing else. And club sharing has flipped from opt-out to opt-in: new fans are not shareable with partner organisations unless they actually say yes.

Added
Consent ledger (ConsentRecords + ContentConsents): append-only, deduplicated, with lawful-basis stamping, a SHA-256 hash of the terms text at the moment of acceptance, and a snapshot linking each active consent to the exact content it covered.
Explicit withdrawal support: withdrawals are always evidenced — even for fans whose original consent predates the ledger — and repeat calls are idempotent, so a double-clicked "save preferences" can't pollute the record.
Retention hooks: RetainUntilUtc on clips and photos, creation timestamps on face-analysis rows, ready for retention policies to act on.
Changed
Club sharing is now opt-in. New fan identities default to not-shareable; the share pipeline fails closed (a fan it can't identify is excluded, not included) and honours ledger withdrawals even if the operational flag disagrees.
The camera app's terms gate fails closed. An event whose flow requires terms but has none configured blocks capture instead of silently proceeding — while genuinely anonymous events are unaffected.
Marketing consent is tri-state end-to-end. A checkbox the fan never saw is "not asked", not "no" — so an unshown question can never generate a withdrawal, including from older cached versions of the app.
Hard-deleting a clip now destroys its face-analysis data (age/gender inference, emotions, tags, brands, captions) and snippets along with it — previously a brand-tagged clip couldn't be hard-deleted at all.
Fixed
A dormant search-breaking bug from the August 11th release. The SQL functions behind clip and photo search freeze their column list when created, so columns added since then were invisible to them — one code path away from every fan wall and content search failing platform-wide. Both functions are now refreshed, and the migration pattern is documented so it can't recur.
10.12.4 August 2026
Changed Fixed A note on the required check

Waiting twenty minutes to find out a change is fine

Turning the packaging check back on in the last release had an obvious cost: every proposed change now took about twenty minutes to come back green. That's long enough to stop what you're doing and go elsewhere, which is exactly how small changes turn into slow days.

Three things were causing it, and none of them was the check itself being thorough.

The three services were being packaged one after another, though nothing about them depends on each other — so they now happen at the same time. Proposed changes were also starting from scratch every time: the shortcut that lets a build reuse the previous one's work was only ever saved on the main line, never in a form a proposed change could read. And the release notes file was being handed to the packaging step so early that editing it — which every release does, by definition — threw away all the reused work for the admin service and forced a full rebuild.

Roughly twenty minutes down to about five, and releases stop paying a needless five-minute penalty for updating this very file.

Changed
The three image builds now run as a matrix instead of sequentially. Measured cold on run 31391676600: 10 + 5.7 + 5.6 min serial. In parallel the job is bounded by the slowest (Manager) rather than the sum.
main now writes the GitHub Actions build cache as well as the registry cache. Actions caches written on the default branch are readable from every branch, whereas a cache written on a PR branch is scoped to that PR alone — so with main writing only the registry cache, no PR could ever start warm. The same images built on main against a warm cache took 0.1 min each versus 5.7/5.6 cold.
PRs read both caches but write neither. cache-to: type=registry exports to the registry even when push is false, so a PR writing cache would put unreviewed layers in the production registry; and a PR-scoped Actions cache helps only that one PR while competing for the repo's 10 GB quota and risking eviction of the shared main cache.
Fixed
COPY CHANGELOG.md sat in the Manager image's build stage, ahead of dotnet publish, so every release commit invalidated the .NET layer and forced a ~5 min rebuild. Evidence: on run 31395625964 Manager took 4.9 min against a warm cache while PublicAPI and FilmAPI took 0.1 min each on the same commit — Manager is the only Dockerfile that copies it (.dockerignore excludes *.md but re-includes !CHANGELOG.md). It is now injected into the runtime stage, with an Exists() guard on the None item in FilmilyManager.csproj so the publish still succeeds when the file is absent from the build context.
A note on the required check
Build & Push Images is a required status check on main. Matrixing the job renames its checks, so a gate job of that exact name now aggregates the three legs — without it the required check would never report and every PR would be unmergeable again, the precise failure 10.12.3 fixed.
10.12.3 August 2026
Fixed A note on what did not change

The safety check that never actually ran

Every proposed change is supposed to get its packaging instructions test-built before anyone merges it — the step that catches "this won't even assemble" before it reaches a real environment. That check has never run. Not once, on any change, ever.

It wasn't broken so much as unrunnable: the check was labelled as belonging to the production environment, and production is locked to accept only finished, merged work. So the check was refused permission to start before it did anything, failed in about a second, and reported red. Because it had always been red, the redness stopped meaning anything.

The practical cost: a packaging mistake stayed invisible until the deploy that followed the merge — the worst possible moment to find out, and one that blocks everyone else's work too.

It now runs on every proposed change, and it builds all three services for real.

Fixed
build-and-push declared environment: production unconditionally. That environment's branch policy allows only main, so on any PR the job was rejected at setup — ~1s, runner_id: 0, no logs, "cannot be retried" — and the deploy jobs downstream reported skipped. The environment is now conditional, so a PR run carries no environment at all. Confirmed on every historical pull_request run of this workflow: not one has ever succeeded, including those for #148, #149 and #151, which merged anyway via admin override.
cache-to: type=registry exports to the registry even when push is false — it is not covered by the push flag. Had the job simply been allowed to run on PRs, unreviewed PR code would have written build-cache layers into the production ACR. PR builds now use the repo-scoped GitHub Actions cache instead.
The prod Azure OIDC login and az acr login ran unconditionally. They are only needed to push, so they are now skipped on PRs — PR code never holds production registry credentials, and the job needs no environment-scoped secrets to do its job.
A note on what did not change
The deploy jobs were already gated independently — deploy-staging on github.event_name != 'pull_request', deploy-prod on workflow_dispatch — so this job going from "always fails" to "passes" on a PR cannot cause a deploy.
10.12.2 August 2026
Changed

The version on the Marketplace welcome page is now a proper link

The welcome page new customers land on after buying through Microsoft had just started showing its version number correctly. Now that number behaves like it does everywhere else in FANDEMiQ — click it and the release notes open in a new tab.

Small thing, but it's the first page a new customer ever sees, and the one Microsoft reads when reviewing the listing. It should look finished.

Changed
The footer version on /marketplace/landing links to /changelog, matching every other page
If an environment hasn't been stamped with a version, the footer now shows just "FANDEMiQ" rather than the literal placeholder text
Added tests that render the real page and check the version a customer actually receives — the previous tests only read the source file, so they couldn't have caught the version being read from the wrong setting
10.12.1 August 2026
Fixed Changed

Two sets of instructions were fighting over the same servers

Our infrastructure is described in code, so it can be rebuilt from scratch and can't drift into a state nobody remembers creating. Two separate descriptions had grown up claiming ownership of the same thing — the servers the platform actually runs on. One of them, the older one, had fallen months behind.

That's more dangerous than it sounds. Applying the stale description would have quietly deleted most of the credentials those servers use — the AI keys, the image-analysis keys, the internal ones the services use to talk to each other — along with their permission to fetch new software builds. Nothing would have failed loudly. FanMoments, photo analysis and deployments would simply have stopped working, at whatever moment someone next ran a routine infrastructure update.

There's now one owner. The deployment pipeline owns the servers, and re-applies every setting on them each time it runs, so they can't drift. The infrastructure description owns the durable things around them — the network, the databases, storage, the vault. We verified the fix by asking Azure to plan the change before and after: the four servers went from "about to be modified" to not appearing at all.

A correction to the previous release. The 10.12.0 notes said the database now scales itself. That work is written and ready but is not switched on yet — it needs an infrastructure update, which is precisely what the ownership clash above was blocking. Both environments are still on the old fixed-size plan. It's now unblocked and waiting on a quiet window, because switching it over briefly interrupts the database and shouldn't be done during an event.

Fixed
Bicep and deploy-apps.yml both declared Microsoft.App/containerApps. ARM replaces the whole secrets array on a PUT, and bicep declared only the 6 Key Vault secrets — the other 23 come from GitHub Secrets. A what-if on staging showed an apply would strip fanmoment-openai-apikey, internal-callback-secret, msvision-key1, rekognition-access-key, rekognition-secret-key and slack-logging-webhook from ca-filmapi-staging, delete properties.configuration.registries (the ACR pull identity) and flip ingress.allowInsecure to true. The module is now unwired: staging's plan went from 37 modified resources to 33, with all four container apps dropping out and nothing new entering; on prod all four report Ignore.
The SignalR backplane connection string lived in no active infrastructure code. Azure__SignalR__ConnectionString is a Key Vault reference, not a GitHub Secret, so unwiring the bicep module would have left it declared nowhere that ever runs — a re-created app would silently fall back to in-process SignalR, the exact fault fixed in 10.11.0. deploy-apps.yml now pins both the secret and the env var on ca-publicapi-* in its own step, deliberately separate from the config steps that keep breaching the 21,000-character run limit.
Changed
Front Door derives its origin hostnames (ca-{svc}-{env}.{envDefaultDomain}, .internal. for internal ingress) rather than reference()-ing the container apps, so main.bicep neither declares nor depends on them existing. The unused managerImage/publicApiImage/filmApiImage/cameraAppRoot/filmApiInternalUrl params went with the module.
New-environment bootstrap is now an explicit, unwired artefactinfra/bootstrap/containerapps-bootstrap.bicep. deploy-apps.yml only runs az containerapp update, which cannot create an app, so a fresh environment needs this applied once against an empty resource group. It carries a never-apply-to-a-live-environment header and is referenced by no workflow.
The infrastructure apply gate stays shut — but for a different, smaller reason. The container-app hazard is gone; what remains on the first apply is the SQL move to GP serverless, which is online but causes a brief failover.
10.12.0 August 2026
Fixed Changed Docs

The platform now warms up before an event instead of reacting to it

Automatic scaling is always slightly behind: it notices load, then takes about a minute to add capacity. That delay lands exactly on the opening minutes of an event — when the most fans are uploading and the platform looks its slowest.

It doesn't need to guess any more. We already know when every event opens for uploads, so the platform now brings extra capacity online shortly beforehand and stands it back down once the event is over and the backlog has cleared.

One deliberate restraint worth knowing: it never changes capacity settings during an open event. Changing them restarts the services, which is fine half an hour before kick-off and the last thing you'd want mid-match. During the event itself the normal automatic scaling takes over, which is what it's good at.

Off by default — it's switched on per environment once the permissions are in place.

The database now grows and shrinks with demand

The database was on a fixed-size plan — a set amount of horsepower, shared between the admin and content databases, that couldn't grow no matter how busy an event got. It was the last part of the platform that couldn't flex, and once everything else was allowed to scale up it became the thing that would give way first.

It now scales itself: compute grows and shrinks automatically with load, and we're billed for what's actually used. That suits the shape of this business — close to idle most of the week, then enormous for three hours on a Saturday.

Two extra benefits. The admin and content databases no longer share one pot, so a busy event can't slow the admin dashboard down at exactly the moment you need it. And FilmAPI's own database — the busiest of the three during an event, and the one that had never been described in our infrastructure code at all — is now included rather than left behind as the new bottleneck.

The live fan wall was only getting a fraction of its updates

We pay for an Azure service whose whole job is to relay live updates between copies of the API when more than one is running. It was set up, connected and handed its credentials months ago — and nothing in the code ever used it.

The effect: the API runs up to fifteen copies during a busy event. A fan wall connects to one of them, but the "this photo is ready" message arrives at a different one, so the wall never hears about it. The busier the event, the more copies run, and the smaller the fraction of updates that got through — during a big event, most photos simply never appeared. It looked like the wall was slow or stuck.

Now wired up properly, so every update reaches every screen regardless of which copy handled it. Nothing new to pay for; we were already being billed for it.

A spending limit for the paid AI option

The "Enhanced AI" FanMoment tier sends each photo to OpenAI to be properly regenerated, and it's charged per image. Turning it on required ticking a box confirming you know it costs money — but that was the only control. There was no limit on how many paid images a campaign could produce, no alert, and no way to stop it mid-event short of someone spotting the bill.

There's now a spending limit you set on the FanMoment itself, in the editor, next to the charges acknowledgement. Enter a maximum number of Enhanced AI images; the editor shows roughly what that will cost as you type, and states plainly that it's billed separately from your FANDEMiQ subscription. Once the limit is reached everyone after that gets the free standard version instead — so fans still get their picture — and a warning goes to Slack naming the campaign so you can decide whether to raise it. Leave it blank for no limit, which is what every existing FanMoment does, so nothing changes until you set a number.

Separately, we now pace the requests to OpenAI. They cap how many images you can generate a minute, and we were firing far more than that at once. Going over doesn't just slow things down — OpenAI rejects the extras outright, and that rejection is the kind we don't retry, so those fans lost their picture.

Photo analysis no longer floods the services it depends on

Every fan photo goes to Microsoft's image-analysis service for tags, captions, faces and sponsor logos — the data behind the dashboard, the wall filters and sponsor reporting. Our allowance there is about ten photos a second. The processing service can have thirty going at once on a single machine, so a rush of uploads fired far more than we were allowed, all at the same instant. Microsoft refused the excess, and although we retried, each retry waited longer than the last — up to a couple of minutes. Photos ended up spending most of a busy spell queuing for permission rather than being analysed.

Requests are now paced to the allowance instead of arriving in bursts. Same treatment for the face-detection service, which has a more generous allowance but exactly the same unbounded fan-out. Both rates are configurable per environment, so they can be tuned for a big event without a code change.

Worth knowing: this smooths the shape of the traffic, which is what was causing the pile-ups. It can't raise the ceiling — for sustained volume above a few thousand fans, the allowance itself needs increasing with Microsoft.

FanMoments: stop losing fans, and stop making them wait for each other

Three changes to how FanMoments are produced, all aimed at what happens when a lot of fans upload at once.

Nobody gets silently dropped any more. If a FanMoment failed to generate — the background-removal service timed out, OpenAI refused the request — the failure was caught, written to the database as an error, and then reported to the job runner as a success. Nothing retried it. The delivery safety net only re-sends emails for FanMoments that already finished, so it never looked at the broken ones. That fan simply never got their picture, and the only trace was a line in Slack. Failures now retry properly, and anything that still can't be produced is picked up again by a new sweep for up to an hour afterwards.

The first fan no longer waits for the last fan. The FanMoment step used to sit at the very end of a seven-step chain, on the lowest-priority queue, behind photo scoring and two external image-analysis services. During a busy spell the entire event's analysis backlog had to clear before any fan's picture started being made — so how long you waited had almost nothing to do with when you uploaded. FanMoment generation now starts the moment the upload lands, in parallel with the analysis, on its own dedicated pool of workers that can't be blocked by (or block) the rest of the pipeline.

The background-removal service is now sized deliberately. It's the one component every FanMoment depends on, and it was set up by hand with its capacity recorded nowhere. It's now described in our infrastructure code alongside everything else, and the platform won't send it more work at once than it can handle — requests wait their turn instead of piling on until they time out.

And the emails themselves stop getting lost. Delivery emails were sent on the spot, in the middle of handling the "this FanMoment is ready" message from the processing service. That tied up one of the admin site's request threads for the whole time the email provider took to respond — and the admin site runs on a single CPU, so a rush of finished FanMoments jammed it. Worse, our email provider allows ten sends a second across the whole account; go over and it starts refusing. We didn't handle being refused at all, so those emails were simply lost, and the same refusal also stopped that fan's photo appearing on the wall. Emails are now queued and sent in the background at a controlled rate, a refusal is waited out and retried rather than dropped, and nothing that happens to an email can take the fan wall down with it. The hourly catch-up sweep also got four times faster and no longer double-sends when more than one copy of the admin site is running.

Fixed
Resend 429s silently lost fan emailsResendEmailSender threw on any non-2xx with no retry. It now retries 429/5xx up to 4 attempts, honouring Retry-After and otherwise backing off exponentially with jitter (every replica hits the same team-wide limit simultaneously, so un-jittered retries re-collide). Non-429 4xx still fails fast. The request body is rebuilt per attempt — StringContent can only be sent once.
A throwing notification subscriber cancelled the whole notificationOnNotificationReceived was a bare multicast Invoke, so the first subscriber to throw stopped the rest, aborted Add() before the SignalR broadcast, and 500'd /notifications/add. One rate-limited email therefore also cost that photo its live dashboard tile. Subscribers are now invoked individually with failures logged and contained.
The delivery sweeper duplicated itself across Manager replicas — it's a BackgroundService, so every replica ran it, each selecting the same OrderBy(Id).Take(N) window and racing to email the same fans. It now takes a Hangfire distributed lock; with one sweeper at a time the batch could rise from 50 to 200 (600/hour → 2,400/hour).
FanMoment transform failures were reported as successes and never retried (AISceneService). They now propagate to Hangfire's retry policy, with a bounded RedriveFailedFanMoments sweep re-driving anything that exhausts its attempts. Attempt counting is per (photo, FanMoment) pair, so a retry never re-charges a paid Tier 2 call for work already delivered.
The dashboard push crashed on photos with no vision dataGetPhotoUpload dereferenced ImageVision.Faces/Brands/Tags/Captions unconditionally. When Azure Vision isn't configured (or hasn't run yet) those are null, so the response threw, /dashboard/photo-finished 500'd, and FilmAPI logged it as non-fatal — the wall silently received nothing. All four are now optional.
Changed
Delivery emails are Hangfire jobs, not inline sends. All three handlers (FanMoment, Story, Mosaic) used AsyncExtensions.RunSync inside the /notifications/add request, blocking a Manager request thread for the full Resend round trip on a 1-vCPU container. They now enqueue onto 0_10_email_delivery, served by a dedicated bounded pool (Email:WorkerCount, default 2/replica) — that count is the deliberate brake on provider throughput, since cluster concurrency is replicas × WorkerCount. The jobs are idempotent on EmailSent (the old inline path sent first and set the flag after, so a duplicate notification meant a duplicate email).
Story/Mosaic SignalR payloads no longer carry an immediate EmailSent = true — the send now happens moments after the push. Previously a Resend 429 threw out of the handler and suppressed the push entirely, so the client got nothing at all.
FanMoment work runs on a dedicated Hangfire server ({host}:ai, Hangfire:AiWorkerCount, default 8) instead of sharing the 30-worker general pool. Hangfire.SqlServer dequeues with a single TOP (1) seek over an index keyed on (Queue, FetchedAt), which made the numerically-highest queue name effectively lowest priority — 4_00_ai_processing only ran once the clip queue was empty.
TransformPhoto is enqueued in parallel with the analysis chain rather than as its sixth link. It depends on none of those steps. ReportPhotoFinished stays at the tail of the analysis chain, so its payload keeps its vision data; its FanMomentUrl is now best-effort.
New rembg controlsFanMoment:RembgMaxConcurrency (default 8, 0 = unlimited), FanMoment:RembgQueueTimeoutSeconds (default 60) and FanMoment:RembgTimeoutSeconds (default 30, was a hardcoded 15). The per-process concurrency cap and the sidecar's replica ceiling are a matched pair — change them together.
ca-rembg-{env} is declared in infra/modules/containerapps.bicep and its sizing re-applied by the deploy workflow, like FilmAPI and Manager.
Docs
docs/fanmoment-scalability-analysis.md — capacity model for the FanMoment pipeline at 100 / 1,000 / 5,000 / 10,000 fans, with the remaining bottlenecks and the measurements needed to replace its estimates with real numbers.

Groundwork for selling FANDEMiQ through the Microsoft Marketplace

Clubs, leagues and venues frequently hold a Microsoft spending commitment — budget already promised to Microsoft that has to be spent with them. If FANDEMiQ can be bought through the Microsoft Marketplace, that budget can pay for it, with no new procurement line to open. It also means Microsoft's own salespeople earn credit for selling FANDEMiQ, which gives them a reason to put us in front of clients we'd never reach alone. Both of those depend on being a properly transactable listing rather than a "get in touch" entry, which is a much higher bar.

This release puts the machinery in place: signing in with a Microsoft account, a page that greets a buyer arriving from Microsoft and sets their account up, and the billing engine that reports usage back to Microsoft each hour and reconciles it daily.

Charging is a flat monthly fee with an included allowance, and usage above that allowance billed on top — which matches how the platform actually incurs cost, since renders, AI images and emails scale with the number of fans rather than the number of admins.

Nothing here is switched on. It stays completely dormant until the Marketplace details are configured, and none of it affects existing clients, who continue to be billed exactly as they are today.

Docs
docs/adr/ADR-005-marketplace-transactable-saas-offer.md — the billing and metering design, the options rejected, and the pricing questions still open.

The Marketplace welcome page showed a broken version number

The page a buyer lands on after purchasing from Microsoft was printing the raw placeholder v@version in its footer instead of the actual version. Harmless to how the page works, but it's the first thing a new customer sees — and it's the page Microsoft reviews when certifying the listing.

The cause is a quirk of the page-templating language: it reads an @ that directly follows a letter as the start of an email address and prints it as-is rather than filling in the value. The rest of the site was unaffected. There's now an automated check across every page template that fails the build if the same mistake is made again.

10.11.0 August 2026
Added Changed Fixed

We could not see the platform. Now we can.

A go-live readiness audit of staging turned up something uncomfortable: the platform had almost no way of telling us when it was failing. Application Insights was set up in both environments, the connection string was being handed to every service on every deploy — and no service had ever actually used it. Thirty days of nothing. The exception alert that was meant to email us when things broke was watching an empty resource, so it could never have fired, not once.

That is now fixed and verified: telemetry is flowing, and the alert that was decorative for months can finally do its job.

Three more failures were hiding behind the same blind spot. Background job failures were recorded only in a dashboard nobody can reach — 198 of them had piled up on staging unseen. Application errors were being carefully written to a database table that nothing on earth reads. And a single malformed row in one campaign could stop every render on the platform — every fan wall, every film, every audio mix, for every client — while deleting the evidence on its way out.

Also worth knowing for matchday: if an event is saved without "public" ticked, every fan opening the app is told there are no active events. The API reports complete success while doing it. That now raises an alarm within five minutes.

Added
Application Insights across all three services, tagged per service so it is obvious which one is misbehaving. Sampling is deliberately off — the moment we most need a complete record is the moment traffic is highest.
Background job failures now surface wherever we are already looking, including the jobs that previously deleted themselves on failure and left nothing behind.
Three new alerts, straight to ops email: fans locked out of a live event, fans failing to upload, and background jobs failing.
Early rejection of unreadable photos. A corrupted upload used to be accepted with a cheerful thumbs-up and then fail hours later in a background job, long after the fan had left. The app can now tell them to try again while they are still in the ground.
A post-deploy verification script (scripts/verify-staging-observability.sh) that checks the whole observability chain in one go.
Changed
The processing pipeline talks now. It had good, detailed logging all along; the log level was set to discard it. A complete photo-and-video run through the system used to produce zero lines.
Fan uploads survive a wobbly database. Azure SQL drops connections as a matter of routine, and five of our six database connections had no retry policy — a momentary blip meant a fan's photo was simply lost.
The upload API got more room. Double the CPU and memory, it no longer scales from zero, and it now adds capacity far sooner under a crowd surge. The background-removal service can scale out too, instead of every fan's photo queueing behind a single container.
FanMoments are roughly eight times smaller. The premium AI images were being saved as PNGs but labelled as JPEGs — around 2.6MB each, downloaded by every fan on stadium wifi.
Fixed
One bad row could stop every render on the platform. A malformed value in a single campaign crashed the job that queues renders, for all clients, and the crash deleted itself so nothing was ever reported.
Video uploads could fail with an unusable error. A phone that reports no accelerometer, or a fan who declines location access, could have their video rejected with "Object reference not set to an instance of an object."
Connections leaked under load, which is exactly the wrong moment for it, and a stalled call could tie up a processing slot for a full minute and a half.
Fan sign-in state broke across servers. Encryption keys lived inside each container, so anything issued by one server could not be read by another — and everything was invalidated on restart.
A configuration landmine: the upload API silently failed to start up properly under one environment name.
Two high-severity security advisories in dependencies.
10.10.0 August 2026
Changed Fixed

The League dashboard, rebuilt to the design

The league view has gone from a plain stack of tables to a proper dashboard. Seven headline tiles across the top — clubs, events, fan uploads, campaigns, films, fan walls and spend — each with its own icon and a one-line "so what", greyed out when there's nothing to report so your eye goes straight to what's actually moving. Below that, the usage map, a ranked club leaderboard with numbered positions, and the spend rollup sitting beside a per-club breakdown.

Two genuinely new things: a period picker in the header so you can look back over the last twelve months of spend, and an Export button that drops the whole dashboard — leaderboard and spend rollup — into a CSV.

And a fix worth calling out: the usage map has been rendering blank. Google retired the heatmap layer we were using from their Maps API, so the call was quietly failing and the map came up empty every time. It now draws the clustered activity itself and zooms to wherever your fans actually are. Still aggregate-only — clustered counts, never individual fans or content.

Changed
League dashboard rebuilt to the "League" design — icon-tile stat grid with zero-state dimming, card-based layout, ranked leaderboard with position pills and monospace figures, and a two-column spend rollup / by-club split. The per-club drill-down (aggregate insights, device split, demographics) is unchanged in behaviour and restyled to match.
Spend period picker — pick any of the last 12 months; the spend rollup, its badge and the spend tile all follow. League totals remain all-time, as before.
Export to CSV — one click gets you the club leaderboard plus the full spend rollup for the selected period.
Fixed
Every heatmap in the Manager was blank — the League usage map, the event stats map and the Users report map all used google.maps.visualization.HeatmapLayer, which Google removed from the Maps JavaScript API at v3.65. The call threw, the throw was swallowed, and each map drew perfectly with no overlay — so it looked like "no data" rather than a failure. All three now render properly again: the League map as a weighted cluster overlay (circle area tracks upload count), and the event-stats and Users maps as a true density heatmap with the familiar blue→red ramp, drawn on our own canvas with no new dependencies. Same data as before, nothing new exposed.
Event stats map zoomed too far in — with every upload coming from one venue, the map auto-fit to maximum zoom, past the point where satellite imagery exists, and went blank grey. It now stops at a level where you can still see the ground.
Cards had 80px of dead space — a global #content section rule was outranking the page's own padding. The Smart Films page had the same bug and is fixed too: its cards were rendering with no side padding at all (content jammed against the edges) plus 80px of dead space underneath, and the page is now the same 1080px width as League.
Dropped the dead visualization Maps library from the SPA's Google Maps loader — nothing consumes it now that HeatmapLayer is gone.
10.9.4 July 2026
Changed

Smart Films page: give it some room to breathe

Follow-up to the 10.9.3 rebuild. The page content was running full-bleed and hugging the right edge of the screen with no gutter — not what the design shows. Now the content is capped and centred (max 920px wide) with proper side padding, exactly like the mockup, so it sits comfortably in the middle of the canvas instead of stretching wall-to-wall.

Changed
Smart Films page gutters — the content area is now max-width: 920px, centred, with 32px horizontal padding (16px on mobile), matching the "Films Global" design. Fixes the edge-to-edge sprawl where the card touched the right of the viewport.
10.9.3 July 2026
Changed

Smart Films page, properly designed this time

The v10.9.1 pass only nudged a colour — this one actually rebuilds the Smart Films page to the design. The three settings now read as one aligned row: Target film length is a proper dropdown that matches How often, both sitting flush with Maximum films at the same height, each with a clean chevron. The buttons got a real system — a solid purple Save settings beside a crisp purple-outline Preview this film (no more washed-out grey), with a matching Preview a film button on the empty state. Badges are uppercase, cards breathe a little more, and the whole thing finally looks like it was drawn on purpose. Under the hood nothing dangerous changed — Generate still generates, cadence and run history still work.

Changed
Smart Films page rebuilt to the "Films Global" design — styled native dropdowns (Target length + How often) replacing the mismatched number box and select2 widget; a consistent 44px control height across the settings row; a proper button system (solid/outline/light, purple not grey); uppercase pill badges; roomier card padding; and "Preview this film" / "Preview a film" actions per the design. Purely visual/UX — the underlying generation, cadence and history behaviour is unchanged.
SPA shell is now served no-store — the /App single-page entry HTML no longer lets the browser cache it, so a deploy's fresh asset hash is always picked up on the next load (belt-and-braces against a stale bundle after release).
10.9.2 July 2026
Changed

FanMoment editor: the same right shade of purple

The exact same tidy-up we just did on the Films page, now on the FanMoment scene editor. Its "purple" badge was carrying the old drifted lavender; we nudged it onto the real FANDEMiQ brand token so it matches the Films page, the App Skin menu and the rest of the Manager to the hex. Nothing else moved — pure visual alignment.

Changed
FanMoment editor purple-light surface — the .fm-badge-purple badge lavender in the AI scene editor now uses the brand token #e8d4f5 ($brand-purple-light / design-system --fq-purple-light) instead of the old drifted #ede7f6, matching the Films (Global) fix in 10.9.1. No behaviour change.
10.9.1 July 2026
Changed

Films page: the exact right shade of purple

A small but honest bit of housekeeping on the Smart Films page. Its lavender was a hair off the FANDEMiQ brand purple — close enough that nobody would file a bug, wrong enough that a designer would twitch. We nudged it onto the real brand token so the icon tiles, badges and film thumbnails match the rest of the Manager to the hex, and switched the film timestamps to our proper mono typeface. No behaviour changed — Generate, cadence and run history all work exactly as before. Just tidier.

Changed
Films (Global) page reskin polish — aligned the purple-light surfaces (icon tiles, "Global" badge, film thumbnails, empty-state icon) to the brand token #e8d4f5, set film/player metadata in JetBrains Mono, and gave the preview player a subtly branded video frame. Pure visual alignment to the "Films Global" design; no functional change.
10.9.0 July 2026
Added Changed / Removed

Leagues, reimagined: insight up, content down

We've reworked how a league sits above its clubs, around a simple, privacy-first principle: a league sees the numbers, never the fans. A league now gets a rich, aggregate picture of every club — a leaderboard, per-club drill-downs, and a live usage map of where activity is happening — but it can no longer see a club's actual fan photos, clips or email addresses. Each club's fan base stays the club's own.

In return, the league becomes a fan-acquisition engine for its clubs. When a league runs its own fan engagement and content comes in, it can now find the right content and hand it to the right club. Say the league spots a wave of Manchester City fans — it selects them and shares them down to the Manchester City account. The club gets a "Shared with you" inbox, reviews the batch, and on Accept the content and those fans (with their contact details, subject to consent) land in the club's own account, ready to engage.

And the Netflix-style content browser we built for leagues has moved to where it's most useful: every account now has it as a "Content" page for finding its own fan content — now with brand search (filter to the photos where a given brand was detected).

Added
League usage map + per-club drill-down — the league dashboard gains an aggregate activity heatmap and a click-through per-club analytics view.
Account content browser — the shelves/search/lightbox content browser is now available to every account for its own content, with a new brand filter (search by detected brand).
League → club content distribution — leagues select their own content and share a batch to a linked club; the club reviews it in a "Shared with you" inbox and Accepts (ingesting content + fan contacts into its account) or Rejects. Sharing a fan's contact is gated on the fan's consent.
Changed / Removed
Leagues no longer browse clubs' content or fan emails. The previous cross-club content library (which pulled clubs' content up to the league) has been removed in favour of aggregate analytics up + content distribution down. Privacy is enforced in code and locked by a regression test.
10.8.0 July 2026
Added

Leagues: see every club's fans from one place

The biggest structural change to FANDEMiQ yet — and the one that unlocks league-sized deals. Accounts can now be organised into a league (a master account) sitting above its clubs (sub-accounts). Every club still runs completely independently, exactly as before — nothing about a standalone account changes. But link a few clubs under a league and that league gets a brand-new, read-only League area. There's a cross-club dashboard: a leaderboard of which clubs are busiest, combined fan-engagement totals, and a monthly spend rollup. And there's a content library that browses every club's fan photos and clips as one big Netflix-style wall — rows that organise themselves by club, by what the AI sees in the content (crowds, celebrations, flags…), and by the busiest locations, named automatically ("Old Trafford", "Etihad Campus", a city or a county). Search runs against the AI's read of the content itself, and every filter — club, campaign, date — narrows the wall instantly. The league can look but never touch: it's pure oversight, with a hard boundary so a league only ever sees the clubs actually linked to it.

Added
League (master) accounts — link club accounts beneath a league from Admin → Leagues. An account becomes a league the moment its first club is linked; standalone accounts are untouched, and a club can be moved under a league (or unlinked) at any time.
League dashboard — a read-only cross-club overview (club leaderboard, combined totals, monthly spend / ROI rollup), shown only to league accounts.
Content library — a Netflix-style browser of every club's fan photos and clips, with shelves by club, by AI theme, by recency, and by top locations (reverse-geocoded to real place names). Filter by club, campaign and date; search the content's own AI tags; open any item full-size.
League-scoped media — the league views and opens club content through a permission-checked route, so it never needs sign-in to a club's own account, and can never reach a club it isn't linked to.
10.7.11 July 2026
Changed

The App Skin page gets a proper makeover

Setting up how your Camera App looks just became a lot more pleasant. The App Skin page — where you choose your colours, fonts, header shape and behaviour — has been rebuilt from the ground up. Everything now lives in one clean card with icon tabs across the top, your live phone preview sitting alongside it, and a tidy Save / Discard bar pinned to the bottom. The old grid of checkboxes became friendly on/off switches (each with a plain-English line telling you what it actually does), colour pickers now show their hex value at a glance, and the busiest tab is split into clear numbered steps — Appearance, Behaviour, Fan sign-up, Images and Fonts. Every setting that was there before is still there and works exactly as it did — including click-the-preview-to-jump-to-a-setting — it just looks and reads a whole lot better. We also added a Discard changes button so you can back out of edits without reaching for the browser refresh.

Changed
App Skin page redesign — the admin App Skin screen is rebuilt as a single settings card: icon pill-tabs (Theme / Text / Share page / Event / Domain / QR code), a sticky live preview, numbered sections, on/off switches with descriptions, colour swatches showing their hex, and a sticky Save / Discard footer. All existing fields, the live preview, and the click-preview-to-flash behaviour are preserved.
Discard changes — a new button on the App Skin page throws away unsaved edits and reloads the last saved values.
10.7.10 July 2026
Fixed / Changed

The Camera App wears its proper fonts again

Client-branded fonts stopped showing up in the Camera App — a beautifully set-up skin (say, Oswald for headings and Inter for body) was landing as plain browser Times. The fonts themselves were fine and correctly attached to the skin; the trouble was one missing setting on our storage. Brand fonts live as files on our storage account, and the browser politely asks permission (a "CORS" check) before using a font from another address. That permission had never been switched on, so every request was quietly refused and the app fell back to its default lettering. We turned the permission on across staging and production, baked it into our infrastructure blueprint so it can't go missing again, and taught the app to fall back to a clean sans-serif — never Times — on the off chance a font can't be fetched.

Fixed / Changed
Brand fonts load in the Camera App — enabled blob CORS (GET) on the staging and production storage accounts so the app's FontFace requests for skin fonts succeed. Codified the rule in infra/modules/storage.bicep (previously absent) so it survives an infrastructure redeploy.
Graceful font fallback — a skin font that fails to load now degrades to sans-serif instead of the browser default (serif), so a blocked font can never render as Times again.
10.7.9 July 2026
Fixed / Changed

Future-proofing the live dashboard map

The heat-map on the Camera App's live dashboard was leaning on a mapping feature Google has retired. We'd been holding it together by pinning an old, frozen version of Google's maps engine — a ticking clock, not a fix. So we rebuilt the heat-map ourselves: the same glowing view of where your fans are, now drawn on our own canvas with no dependency on the removed feature. It's off the deprecation treadmill for good, and the Camera App always runs the current maps engine again.

Fixed / Changed
Heat-map rebuilt off the deprecated Google layer — the live-dashboard fan heat-map no longer uses Google's removed visualization.HeatmapLayer. It's rendered on our own canvas overlay (zero new dependencies) using the map's projection. We dropped the v=3.64 Maps version pin and the visualization library, so there's nothing left to deprecate.
10.7.8 July 2026
Fixed / Changed

The live dashboard map is back

The heat-map on the Camera App's live dashboard had gone dark. It leaned on a Google Maps account that had been deleted out from under it, so every load hit a wall and flashed "Something went wrong." We repointed it at our live maps account, and the map's back to plotting where your fans are — no new sign-ups, no new keys, just the lights switched back on.

Fixed / Changed
Live dashboard map restored — the fan heat-map loads again. The previous maps key's Google Cloud project had been deleted (DeletedApiProjectMapError); we swapped in the working key and pinned it at build time so it can't quietly drift back.
10.7.7 July 2026
Fixed / Changed

A polished Films page and refreshed navigation icons

We finished the Films makeover. Your generated films now appear as clean, scannable rows — each with a one-tap play button, when it was made, its length and how many clips went into it, and an at-a-glance status (Ready, Processing or Error). Click any film to watch it in a branded pop-up player, with download right there. Before any films exist, a friendly empty state points you at what to do next.

We also refreshed the icons down the left-hand navigation so they line up with the rest of the FANDEMiQ interface — same menu, cleaner glyphs.

Fixed / Changed
Films list redesign — films render as tidy rows (play thumbnail, generated time, length · clip count, and a status pill) instead of the old generic list, with a polished "no films yet" empty state.
In-page film player — click a film to play it in a branded pop-up, with download and quick access to edit.
Refreshed navigation icons — the left menu now uses the FANDEMiQ design-system icon set for a cleaner, more consistent look.
10.7.6 July 2026
Fixed / Changed

Faster uploads, a lighter Camera App, and no more spinner-of-doom

The Camera App got a proper tune-up under the bonnet — uploads are quicker and far more reliable, the app loads lighter, and we squashed a lurking bug that could take the whole Camera App offline for a branded event.

The headline: uploads were being quietly rate-limited. A single Story (a photo and a video) is nine or ten separate requests behind the scenes, and the old cap — ten requests a minute, shared across everyone on the same network — meant a fan at a packed stadium (where hundreds share one connection) could hit the ceiling and watch their upload silently give up. The cap is now per-device and far more generous, and the app backs off and retries politely instead of surrendering on the first "no". Video also uploads in bigger chunks, so there's less chatter over the wire.

We also trimmed the start-up: the QR-code page only desktop visitors ever see is no longer downloaded by every phone, fonts no longer hold up the first paint, and the background image is roughly half the size.

And a genuinely nasty latent one: an account that had quietly accumulated a few access tokens over time (which happens naturally) could make the Camera App fail to load entirely on its custom domain — an endless spinner. Fixed, and pinned down with tests so it can't sneak back.

Fixed / Changed
Uploads no longer throttled on shared networks — the upload limit is now per-device rather than per-IP and much higher, so fans on one stadium/venue connection don't collectively hit the cap. The app also honours a proper back-off-and-retry instead of failing on the first rejection.
Faster, more reliable video upload — larger upload chunks (fewer round-trips) and each piece is held more efficiently on the device.
Lighter Camera App start-up — the desktop-only QR view now loads on demand, web-fonts no longer block first paint, and the background image is ~half the size.
No more endless spinner on some branded domains — the lookup that resolves a custom domain/link to the right account is now robust when an account has more than one access token, and warns (instead of failing) if a domain is ever misconfigured across two accounts.
Backend hardening — an internal logging path could error under heavy concurrent load; it now scopes correctly, so a burst of activity no longer produces stray 500s.
10.7.5 July 2026
Fixed / Changed

A friendlier live dashboard, and a snappier cut-out preview

Two bits of polish. The live operator dashboard now greets you with an on-brand screen instead of a bare error when there's nothing to show yet — a clear "Live dashboard isn't switched on yet" message (pointing you at the Allow Dashboard Stats toggle), a proper loading state, and a graceful error state, all co-branded with your logo. New events now have Allow Dashboard Stats switched on by default, so the dashboard just works out of the box. We also fixed the dashboard map, which had stopped loading after a Google Maps change removed the old heatmap component. Separately, the Story cut-out preview now loads near-instantly (it was fetching a full-resolution, multi-megabyte image; it's now a lightweight preview).

Fixed / Changed
Live dashboard empty/loading/error states — branded splash instead of a raw error message when the account has no dashboard-enabled event (or the data can't load).
New events default to "Allow Dashboard Stats" ON — the live dashboard works without an extra manual step (an explicit off still sticks).
Dashboard map restored — pinned Google Maps to the last version that still ships the heatmap layer (Google removed it); the map no longer fails to load.
Faster Story cut-out preview — the editor now serves a downscaled preview image (~1000px) instead of the full-resolution cut-out, so it appears immediately.
10.7.4 July 2026
Fixed

Cut-out fans now sit in front of their frame — even with Scale Media on

When you removed a fan's background AND set a Frame as their backdrop AND turned on Scale Media to size/position them, the rendered story showed the fan on black instead of in front of the frame. The scale/position step was compositing the cut-out onto a solid black background, which wiped out the transparency before the frame could be drawn behind — so the frame ended up hidden. It only happened with Scale Media switched on (plain cut-outs were fine). Fixed: a scaled cut-out now keeps its transparent background, so the Frame shows through and the fan sits in front of it as intended.

Fixed
Scaled/positioned cut-outs no longer render on black — the Scale Media step now preserves a background-removed subject's transparency (matching how Layers already behaved), so the part's Frame (or the layer beneath) shows behind the fan instead of being covered.
10.7.3 July 2026
Fixed

Video stories now render on production

Story recipes that output a video were failing during generation — the main video actually rendered fine, but the very last step (grabbing the preview thumbnail from it) crashed, which marked the whole story as failed. The cause was a tiny formatting slip in how the render engine passes its thread-limit settings to ffmpeg: a missing space glued two options together (…threads 2 + -y2-y), which ffmpeg rejects. It only bit commands that begin a certain way, which is why films, mosaics and image stories were unaffected but video-story thumbnails weren't. One-character fix, with a test that fails loudly if it ever regresses. (Discovered while testing the new Story cut-out — the cut-out itself was not the cause.)

Fixed
Video-story rendering on production — the ffmpeg thread-cap flags are now correctly space-separated from the rest of the command, so preview-thumbnail extraction (and any other helper command built without a leading space) no longer fails. Video stories generate again.
10.7.2 July 2026
Fixed

Story cut-out preview now shows in the preview pane

Ticking Remove background on a Story image part now updates the big preview on the left to show the actual cut-out — the fan lifted off their background, sitting in front of the Frame (which becomes the backdrop) — instead of a small separate thumbnail. It's a true preview of what the finished story will look like. This also fixes the preview failing to load for non-admin users (the image request wasn't carrying the account context, so it came back blank).

Fixed
Cut-out preview moved into the main Story preview pane — tick "Remove background" and the left-hand preview swaps to the cut-out, with the frame composited behind the subject (matching the render). The separate thumbnail button is gone.
Cut-out preview no longer fails for non-admin users — the preview image request now passes the account context (via query string), so the background-removal preview loads for everyone, not just admins.
10.7.1 July 2026
Changed

The Films page got a makeover

Same clever Smart Films engine underneath — but the Films (Global) page now looks the part. A proper page header with a title, a "Global" badge and a one-line explainer; the settings wrapped in clean cards with helpful hints under each field; a real on/off switch for automatic generation (that politely waits until you've acknowledged the charges); and the branding-inheritance note turned into a clear info panel instead of grey small print. The run-history table got the same tidy-up. Nothing about how films are made changed — it's purely a nicer place to set them up.

Changed
Films (Global) page redesign — new header, card layout, inline field hints, a toggle switch for automatic generation, an info panel for branding inheritance, and restyled run-history. Behaviour and settings are unchanged.
10.7.0 July 2026
Added Under the hood

Cut the fan out of the crowd — now in Stories

The background-removal trick FanMoments has always done — lifting a fan cleanly off their surroundings — is now available on image parts inside the Story builder. Tick Remove background on any image part and two things happen: the editor shows you a live cut-out preview of the image you've added (checkerboard behind it so you can see exactly what's transparent), and at the event the fan's own captured photo for that part gets the same treatment as they take part — no green screen, no manual masking. It uses the very same cut-out engine as FanMoments, so it's fast, free, and looks identical to what you already trust. Video parts don't offer it (there's nothing to cut a still out of), and anywhere the cut-out service isn't switched on, Stories render exactly as before.

Added
"Remove background" on Story image parts — a per-part toggle in the Story editor's Video/Image tab, with an inline "Preview cut-out" so you can see the effect on the uploaded image before saving.
Render-time cut-out for fans' photos — when the toggle is on, a fan's captured image for that part is passed through the background-removal sidecar during rendering, mirroring the FanMoment pipeline. Falls back gracefully to the original image if the cut-out can't be produced.
The cut-out stays transparent so the fan sits in front of other content — set a Frame on the part and it becomes the backdrop behind the fan (the FanMoment "scene + subject" look), or make the part a Layer to place the fan over the part beneath it.
Under the hood
One shared background-removal service — FanMoments and Stories (render and editor preview) now call a single BackgroundRemovalService instead of three copies of the same rembg call, so every cut-out on the platform is byte-identical and there's one place to change it.
10.6.12 July 2026
Fixes

Belt, braces, and a smaller appetite

The render server's logs showed jobs now running properly and dying minutes in — the last gap between "fits on my machine" and "fits in the container" turned out to be the memory allocator itself: on Linux it pre-carves arenas per CPU core of the host machine and happily triples a video encoder's real footprint through fragmentation. Three final tightenings: the allocator is capped at two arenas, the video engine's filter threads are capped to match the CPUs it actually has, and renders now run strictly one at a time per server (they saturate both CPUs anyway — busy events get more servers, not more crowding).

Fixes
Memory allocator arena cap (MALLOC_ARENA_MAX=2) on the processing service — stops glibc inflating multithreaded ffmpeg memory 2–3× through per-core arenas counted against the container limit.
ffmpeg filter threading capped at 2 — thread pools were sized by host cores (16+) instead of the container's 2 CPUs; the extra threads added buffers, not speed.
One render at a time per server — measured single-render peak for a typical wall personalisation is ~0.75GB with all caps in place, leaving generous headroom in the 4GB container.
10.6.11 July 2026
Fixes

Personal fan walls: the actual ending

Confession time: the last three releases each fixed a real memory problem, and none of them was the one crashing production. The database finally told the whole story — the wall was a modest 3×3, but ten fans qualified for personal walls at once, and the job runner cheerfully started all ten video renders simultaneously on a machine with the memory for about three. Every restart it tried all ten again. Renders now run through a dedicated lane capped at two at a time per server — matching the CPUs they'd fight over anyway — and when there's a genuine backlog the platform spins up more servers instead (whose autoscaler, it turns out, had been silently watching the wrong database's queue and holding an unusable credential — both now fixed). The three memory diets from 10.6.8–10.6.10 stay: each render is now several times cheaper than it was this morning, and at two-at-a-time the sums finally work.

Fixes
Render jobs are capped at two per server — walls, films, audio mixes and personal fan walls now run on a dedicated worker lane (2 workers, matching the 2 vCPUs) instead of sharing the 30-worker pool that fanned a 10-render burst out all at once and out-of-memoried the container in a loop. Scale-out for busy events comes from additional server instances, driven by queue depth.
The production autoscaler now watches the right queue — the scale rule counted jobs in a database that only ever held upload callbacks; it now reads the render queue FilmAPI actually uses, so backlogs genuinely add capacity.
10.6.10 July 2026
Fixes

Personal fan walls, part three: the real culprit

Two rounds of taming the video renderer's memory made every render cheaper — and the crashes kept coming, 15 seconds after each attempt started. That was the tell: the server was falling over before any video work began. The actual killer was the database query that loads a personal wall's details: it pulled the wall, every tile, and every language variant of its audio track, overlay, and filter image in one combined query, and the database multiplies those lists together — a large branded wall came back as thousands of duplicated rows carrying megabytes of clip metadata each, gigabytes in seconds. The query now fetches each list separately (identical results, a handful of slim queries), and the "Stop" button on personal fan walls now genuinely stops renders that were already queued.

Fixes
Personal wall/story detail queries no longer multiply into gigabytes — the loads behind personal fan walls, personal stories, and story samples use split queries, so tiles × language-variants no longer join into a cartesian explosion during materialisation. This was the crash-looper on production; the renderer fixes from 10.6.8/10.6.9 stand as real (and large) memory/speed wins on top.
"Stop" on personal fan walls now stops queued renders — cancelled renders are skipped when their job comes up instead of rendering anyway, so switching the feature off actually halts the pipeline.
10.6.9 July 2026
Fixes

Personal fan walls, part two: video walls covered

The 10.6.8 memory fix proved itself on photo walls but production promptly found the next appetite: walls built from video clips. Decoding the full-resolution wall video spins up one decoder thread per CPU core on the host machine — each holding a wall-sized frame — which quietly ate another 2GB+ and could still take the server down on bigger walls. The decoder is now told to keep just two frames in flight, cutting a 6×6 video wall's render from 2.4GB to 1.2GB (an 8×8 fits in 1.7GB). Photo and video walls of any realistic size now render personal fan walls comfortably.

Fixes
Video-wall personal renders no longer exhaust server memory — the wall-video input is decoded with a 2-thread cap (ffmpeg otherwise sizes decoder threading by host cores, not the container's CPU limit, holding ~16 wall-sized frames in memory). Measured: 6×6 video wall 2.4GB → 1.2GB, 8×8 → 1.7GB peak. Encoding threads are unaffected.
10.6.8 July 2026
Fixes

Personal fan walls actually arrive now

Turning on personal fan walls was quietly taking down the video processor. Each personalised render — the one that zooms from the full wall into a fan's own tile — asked for memory proportional to the wall's total resolution (a 6×6 wall wanted about 4GB just to zoom), which was more than the production server had. The render was killed mid-encode, retried a few minutes later, and killed again, forever — so no personal walls were ever finished and no "your video is ready" emails ever went out. The zoom is now rendered in bite-sized segments that never need more than a few hundred megabytes, regardless of wall size, and it's about three times faster too. Same zoom, same sharpness, a fraction of the appetite.

Fixes
Personal fan wall renders no longer exhaust server memory — the zoom effect is built from time segments that each crop the needed window from the full-resolution wall (a free operation) before scaling, instead of scaling the entire wall canvas every frame. Peak ffmpeg memory for a 6×6 wall personalisation drops from ~4.0GB to ~0.9GB (8×8: 5.0GB → 1.1GB) with identical visual output, and renders complete ~3× faster. This unblocks personal wall creation, the completion emails, and "send to existing uploaders".
Wall base stills are decoded once per render, not once per frame — the looping still-image input re-decoded the full-canvas PNG for every output frame (~2GB of decoder buffers alone on a 6×6). It's now decoded a single time and repeated.
10.6.7 July 2026
Improvements

The same, more useful numbers on every card

Event and campaign cards now show a consistent set of headline numbers everywhere you see them — the events list, an individual event, and each campaign — in the same order: Users, Locations, Clips, Photos, Films. The new one is Locations: how many uploads arrived with a GPS position attached, so you can see at a glance how much of your content is map-plottable. Paired with last release's live updates, these tick up on their own during an event.

Improvements
Consistent card metrics + new "Locations" count — every event and campaign card shows Users / Locations / Clips / Photos / Films in the same order. "Locations" counts uploads (clips + photos) that carry any GPS coordinate — not deduplicated by point, so it reflects total geo-tagged uploads rather than distinct spots. Computed on both the event and campaign stats paths, and it updates live with the rest of the counts.
10.6.6 July 2026
Fixes

Uploading videos to the Media Library works again

Uploading an MP4 (or any video) into the Media Manager was failing with a server error, while images uploaded fine. The cause was in how we saved the small preview thumbnail that videos generate: on our cloud servers the thumbnail's filename was being stored with its full storage path attached, which overflowed the database field and rejected the whole upload. Photos never hit this because they don't produce a separate thumbnail. Videos now save their preview correctly and upload cleanly.

Fixes
Video uploads to the Media Library no longer 500 — the preview-thumbnail filename is now extracted correctly regardless of storage path separators (cloud blob storage uses \, which the Linux hosts weren't stripping), so it fits the PreviewFile column instead of overflowing it. Added a regression test covering the cloud-storage path.
10.6.5 July 2026
Improvements

Event and campaign counts now update while you watch

The numbers on your event and campaign cards — Films, Clips, Photos, Users — used to load once when the page opened and then sit still. During a live event that meant refreshing the page to see fresh uploads land. Now any card on screen quietly re-checks its counts every minute, so clips and photos coming in during an event tick upward on their own. No refresh, no clicking — just leave the page open and watch it fill up. Cards you've scrolled past stay quiet, so it stays light on the server.

Improvements
Live-updating card counts — event and campaign cards now refresh their Films/Clips/Photos/Users counts on the minute while visible, so content uploaded during an event appears without a page reload. Off-screen cards don't poll; the refresh aligns with the one-minute server stats cache so it adds no extra load.
10.6.4 July 2026
New Features

Branded QR codes for your camera app

The App Skin area has a new QR Code tab. It generates a QR code that opens your camera app — styled in your brand colour with your logo in the centre — ready to drop onto signage, screens or print. Preview it live, copy it to the clipboard, or download it as a high-resolution PNG or a print-ready SVG. If your brand colour is too light to scan reliably, the code automatically switches to dark modules (keeping your logo) rather than producing a pretty code no phone can read.

New Features
Camera App QR code generator — a new "QR Code" tab on the App Skin page produces a branded QR pointing at your camera app's address (custom domain or default URL), with your brand colour and centre logo, high error-correction so the logo doesn't break scanning, Copy / Download PNG (1024px) / Download SVG, and an automatic dark-module fallback for unscannably-light brand colours.
10.6.3 July 2026
Fixes

Cost rates are for administrators only

The Costs report has always been visible to clients — that's the point, they can watch their running spend. But the "Cost settings" editor behind it lets you change the underlying rates and free allowances, and those are FANDEMiQ's commercial terms, not the client's to edit. That editor is now restricted to platform administrators: the button is hidden for everyone else, and the endpoints refuse non-admins outright. The Costs table itself is unchanged for clients.

Fixes
Cost settings are admin-only — the Cost settings button is hidden for non-admin users and the view/save endpoints now require the Administrator role (previously any account manager could edit their own account's pricing). The client-facing Costs table is unaffected.
10.6.2 July 2026
Fixes

The event stats popup actually shows stats now

The event stats popup (the chart icon on an event card) was showing an empty white panel where the charts should be. The real culprit turned out to be a database function that had drifted out of sync with a recent search change, so the whole stats query errored out. That's fixed, and two display problems went with it: the charts were rendering at zero height (invisible even when the data loaded), and the map defaulted to a zoomed-out view of the entire globe when an event's photos had no GPS. The popup now loads the audience/device charts properly and frames the map on the event's own venue.

Fixes
Stats render again — an internal report function (tfnClipSearchReport) was missing a parameter added to the shared clip-search function by an earlier change, so every stats query returned a server error and the popup stayed blank. The function is realigned; the stats endpoint works again.
Charts are visible — the audience and device charts sized themselves to a zero-height container while the popup animated open and never recovered; they now render at a fixed height.
Map frames the venue, not the world — with no GPS-tagged uploads the map centred on {0,0} at world zoom (Greenland to Antarctica); it now fits any located content, or centres on the event's own location.
10.6.1 July 2026
Fixes

Cloning an event no longer resurrects deleted FanMoments

Cloning an event with "Clone FanMoments" ticked copied every FanMoment on the campaign — including ones that had been deleted — so a removed FanMoment could quietly reappear on the copy. It now clones only live FanMoments, matching how fan walls and stories already skip deleted items.

Fixes
Deleted FanMoments are no longer cloned — event clone copies only non-deleted FanMoment configs onto the cloned campaigns (verified end-to-end on real data; the copies still reset their charge acknowledgement and go-live state as before).
10.6.0 July 2026
Added Fixed Changed / removed

Housekeeping across reporting, cloning, security and the codebase

A broad maintenance sweep. Reports now behave sanely when you tick every filter box, cloning an event can bring its FanMoments along (safely), the event stats popup actually shows stats again, a pile of dead code and dependency vulnerabilities are gone, and the build is a lot quieter.

Added
Clone FanMoments — the event clone dialog has a new "Clone FanMoments" option (only available when cloning campaigns). Each copied FanMoment resets its charges acknowledgement and preview state, so a duplicate can never silently incur paid AI charges or go live until an admin re-approves and re-previews it.
Fixed
Report filters — "all ticked" means "Any" — ticking every Age, Sex or Camera box now behaves exactly like an unfiltered report instead of hiding content that has no face data.
Event stats popup — it was showing no stats and a squashed world-map strip. The stats now load independently of the map (a maps hiccup no longer blanks the whole popup), and the map renders at a proper size.
Changed / removed
Dead code removed — the unused Together.ai (FLUX.1 Kontext) FanMoment preview path is gone; OpenAI is the sole enhanced-AI backend.
Security patches — replaced the deprecated @aspnet/signalr client with the maintained @microsoft/signalr, and patched high/critical npm advisories in the Manager and Camera apps (both apps rebuild clean). No vulnerable .NET packages.
Quieter builds — build warnings cut from ~308 to well under target, keeping nullable analysis meaningful on hand-written code.
10.5.3 July 2026
Improvements

The Plausible field now tells you what to type

The App Skin "Plausible domain" box used to be a blank you had to guess at. It now defaults to the camera app's own address (e.g. app.fandemiq.net/msg) — which is exactly the site name to create in your Plausible account — so wiring up web analytics for a new account is a single Save. Clear the field to turn tracking off.

Improvements
Plausible domain field self-populates — the App Skin analytics field defaults to the account's camera app address (shown as a placeholder and pre-filled on an unconfigured skin, never overwriting an existing value), with a help tip explaining it must match the Plausible site name.
10.5.2 July 2026
Fixes

Counts you can trust, walls that don't dawdle

Two annoyances caught during live testing of auto-generated fan walls. First, a campaign card could insist "0 clips" for up to an hour after fans had uploaded — its counts were cached for an hour while the event header refreshed every minute (and the wall generator, checking the database directly, was already happily rendering those "non-existent" clips). Campaign counts now refresh every minute like everything else. Second, a wall whose content had arrived could still sit waiting up to five minutes before anyone noticed it was ready — the readiness scan now runs every minute, so walls start rendering within about a minute of the last needed upload.

Fixes
Campaign card counts refresh every minute — the clips/photos/films numbers on campaign cards were cached for an hour with no invalidation; they now use the same one-minute cache as the event header.
Auto-generated walls start ~4 minutes sooner — the pending-wall readiness scan runs every minute instead of every five.
10.5.1 July 2026
Fixes

The platform stops re-checking its own homework

The mystery of the slow admin pages is solved. On every single request — every page, every click, every background job — the platform was quietly re-verifying that its database schema was up to date, taking a database-wide lock while it did so. That meant every signed-in request paid a one-to-three-second toll and then queued behind every other request paying the same toll. Local development skipped the check entirely, which is why it always felt fast on a laptop and slow in the cloud. The check is gone (deployments already verify the schema before any code goes live), and signed-in pages are now several times faster — with requests finally running in parallel.

Fixes
Admin pages are dramatically faster — the per-request schema/migration check (and its exclusive database lock) has been removed from all three services; schema safety is enforced at deploy time instead, where it always was. Manager requests that took 1–3 seconds each, one at a time, now run in a few hundred milliseconds, concurrently. The camera app's API and background render jobs shed the same overhead.
10.5.0 July 2026
Improvements Fixes

The render engine went on a diet

With fan walls rendering again, we turned to why they were ever slow. The answer: the renderer was doing far more work than the eye can see. Intermediate files were encoded at true mathematical losslessness (several times the compute, ten times the file size, zero visible difference), every wall got a full second re-encode even when no effect changed a single pixel, and the final video was compressed with the slowest settings ffmpeg offers. All three are fixed — plain walls now skip the second encode entirely by copying the video stream, and every wall encodes meaningfully faster. Faster walls, smaller storage bills, and noticeably less compute per render.

Improvements
No more pointless re-encode — when a wall has no zoom, overlay, filter or video fade, the finished base video is stream-copied into the master (audio is still mixed properly); the most expensive encode of a plain wall render is simply gone.
Visually-lossless intermediates — internal render files moved from mathematically lossless (CRF 0) to visually lossless (CRF 14): several times faster to encode and roughly a tenth of the storage traffic, with no difference in the delivered wall.
Faster delivery encode — the final mp4 now uses an explicit fast preset at equivalent perceptual quality instead of ffmpeg's slow default.
Fixes
Muted clips no longer break the audio mix — a wall whose clips include videos with no audio track at all now mixes the audible ones (or renders silent) instead of failing the whole render.
10.4.3 July 2026
Fixes

Follow-up: preview stills need a sane frame rate

The revived renderer got all the way to producing the fan wall video on staging, then tripped on the very last step — generating the little preview thumbnails. The setting that controls how often to grab a still frame was shipping as zero (its real value lived in an environment-specific config file that never makes it into the cloud image), and "one still every zero seconds" is an impossible frame rate that crashed the thumbnail encoder. The renderer now treats a missing/zero value as "one per second", and the shipped defaults have been corrected.

Fixes
Preview stills generate correctly — the still-frame rate is clamped to a valid value instead of dividing by a zero interval (which produced an infinite frame rate and failed ffmpeg's encoder), fixing the last step of fan wall / film rendering. The shipped FFmpeg defaults were also corrected so the cloud image no longer relies on a config file that isn't deployed.
10.4.2 July 2026
Fixes

Follow-up: the renderer needed to know where FFmpeg lives

The revived render engine worked perfectly in local testing but fell over on the first real render in the cloud: a leftover configuration setting pointed it at an empty path for the FFmpeg program, so it couldn't launch. The renderer now falls back to finding FFmpeg on the system path (where it's always installed) whenever that setting is blank, so it no longer depends on a config value that isn't reliably present.

Fixes
Renderer finds FFmpeg even when the configured path is blank — an empty FFMPEG:FFMPEGPath no longer overrides the sensible default, so fan wall / film / audio renders start correctly on the container apps.
10.4.1 July 2026
Fixes

The projector was never plugged in

An archaeology dig into "why do fan walls take so long?" found something better: on the current cloud infrastructure they weren't slow, they were impossible. The video-rendering engine still expected a worker fleet from the old Kubernetes days — a fleet that no longer exists — so every fan wall, film and audio mix quietly failed the moment it started. The renderer now runs right inside the processing service (which has had FFmpeg on board all along), and fan walls render again. While we were in there, we fixed the crash that hit auto-start walls created on campaigns that already had plenty of content.

Fixes
Fan walls, films and audio mixes render again — video processing now runs in-process on the processing service, downloading source clips from storage and uploading finished renders back, instead of queueing work for a worker fleet that no longer exists. A config switch can restore the remote-worker mode if it's ever rebuilt.
Auto-start walls no longer crash when content is already plentiful — a wall saved with "auto start when ready" on a campaign that already had enough clips went straight to the renderer with no tiles assigned and crashed; it now takes the same assign-then-render path as every other auto-start wall, and the renderer refuses gracefully if it's ever handed an empty wall again.
10.4.0 July 2026
New

See the bill before it bites

Clients can now watch their running costs the way they watch their uploads. A new Costs section in Reports shows, month by month, exactly what's been used — fan contacts captured, AI images generated, videos processed, emails sent, fan walls and films produced, and every paid AI call in between — with each line priced at the account's rates after its monthly free allowance is used up. Not an invoice, but a very honest preview of one.

New
Costs page in Reports — nine cost lines with usage, included-free allowance, chargeable units, unit price and £ amount, plus an estimated monthly total and a month picker to browse history. Allowances reset each calendar month.
Per-account cost settings — account admins set the unit price and monthly free allowance for every line via the new Cost settings modal (sensible placeholder defaults out of the box; everyone else can look but not touch).
Email send log — fan emails were previously sent with no record kept; every send is now logged (who, which event, which template), which powers the "Emails sent" line and doubles as an audit trail.
AI preview log — paid "Generate AI preview" calls in the FanMoment editor are now recorded per account, so admin experimentation shows up on the bill preview instead of vanishing into the OpenAI invoice.
10.3.3 July 2026
Improvements

House rules for the AI, in writing

Every FanMoment image the AI generates now carries three non-negotiable instructions baked into the prompt, whatever the campaign's scene prompt says. Think of it as the studio's house style guide: heads the right size, skin tones true to the fan, logos exactly as the brand intended.

Improvements
Quality guardrails on every AI image — the generation prompt now always instructs the model to keep the fan's head in natural proportion to their body, match all visible skin to the fan's actual complexion in their photo, and reproduce any logos, crests or brand marks accurately and on-brand. Applies identically to admin previews and fan-delivered images, in both prompted and unprompted modes.
10.3.2 July 2026
Under the hood

Sweeping up after the old film engine

When Smart Films replaced the random film generator, we left its three retired campaign settings in the database as ghosts — insurance in case we needed to roll back. The new engine has proven itself locally and the ghosts have now been laid to rest.

Under the hood
Dropped the legacy NeedsFilms, GenerateFilmsAutomatically and MaxAutoFilms columns from Campaigns (nothing has read them since the Smart Films switchover). The migration's rollback path can restore them with defaults if ever needed.
10.3.1 July 2026
Fixes

One at a time is no way to run a platform

A detective story: fan wall pages on the live site would sometimes hang for minutes, and the culprit turned out to be a single innocent-looking line that asked the database "do we have any users yet?" — on every single request, while holding up the only available thread. The platform was politely serving logged-in requests one at a time, like a bank with one teller. The teller now remembers the answer. Also, fan walls that are waiting for more content now tell you how much they're waiting for.

Fixes
Requests are served in parallel again — the per-request "is the platform set up?" database check is now asynchronous and remembered after the first answer, instead of quietly serialising every authenticated request behind a blocked thread (the cause of the multi-minute page hangs and 503s on busy page loads).
Fan walls say how many clips they're waiting for — a fan wall waiting for content used to claim "Waiting for clips: 0 of 9" even when most of the content had already arrived; the campaign page now shows the real count (e.g. "6 of 9") so you can see exactly how far off it is.
10.3.0 July 2026
Improvements

The FanMoment editor got a makeover

The FanMoment — AI Scene editor has been rebuilt to match the new FANDEMiQ design language. Same brains, much better face: everything you need to configure a FanMoment now lives in tidy cards on the left, with a proper test bench pinned on the right so you can see exactly what fans will get before you flip it Live.

Improvements
Card-based editor layout — Setup (name + Live toggle with a status badge), Processing (mode choice, charges, AI model) and Base image each get their own card; the page header carries Cancel / Save Changes so they're always in reach.
Clearer mode choice — Standard composite and Enhanced AI sit side by side with Free / Billed badges, and the charge authorisation now lives inline with who acknowledged it and when.
Smarter AI model picker — a compact radio list showing quality tier, cost positioning and what each model is best for.
Better media handling — the base image shows a real thumbnail with its dimensions and a one-click Replace; the test photo row shows the background-removed cutout the pipeline will actually use.
Test bench upgrades — the last generated preview shows which model produced it and when, and can be downloaded or shared with a link in one click.
10.2.1 July 2026
Fixes

The case of the vanishing font

A shared media file whose stored copy had gone missing (long story: an old background job once ate two font files) would take the whole request down with a server error. Now the platform shrugs gracefully — if the file lives somewhere public, you're sent there; if not, you get an honest "not found" instead of a crash.

Fixes
Share media never 500s on a missing file/Share/media/… now redirects to the media's public home (Google-hosted fonts keep working) or returns a clean 404 when the stored file is gone, instead of crashing.
10.2.0 July 2026
New Features Improvements Under the hood

Films finally got a brain

Automatic films used to be a slot machine: random clips, random cuts, and no off switch — hundreds of near-identical films if you let it run. This release replaces the whole engine. FANDEMiQ now reads the AI video analysis of every fan clip (what's happening, who's cheering, exactly when) and plans each film like an editor would — to your brief.

New Features
Smart film generation — Tell FANDEMiQ what you want ("a 30-second high-energy reel of crowd celebrations") and it plans each edit from time-coded video insights: labels, sentiments, audio events and shot boundaries. Every cut lands on an actual moment, not a random offset.
The Films Hub — One home per campaign for everything films: your creative brief, target length, generation schedule, a hard cap on how many films can ever be generated, the film list, run history (including what each run cost in AI tokens), and a "Generate a film now" button.
You're in control of spend — Smart generation is off until charges are explicitly acknowledged, every campaign has a hard maximum film count, failures never auto-retry paid calls, and a platform-wide kill switch can stop all generation instantly.
Fresh films, not reruns — Each new film is checked against previous ones; plans that mostly re-cut the same moments are rejected and re-planned.
Improvements
Films with errors can now be retried from the film list, and deleting a film asks first.
The Edit Event popout has been detangled: Films, Contact and Sharing each have their own tab.
Campaign settings no longer duplicate film generation options — the hub owns them; campaigns keep only an optional branding override.
Film branding defaults (intro, outro, logo, audio) are resolved in exactly one place and the hub shows whether they're inherited from the event or overridden.
Under the hood
The legacy random-shuffle film engine is gone. Every clip's Video Indexer insights are distilled into a compact per-clip digest at analysis time, ready for planning.
10.1.0 July 2026
New Features Improvements

Reporting, meet your glow-up

The old Reports page was five static cards and a scheduled CSV email. This release replaces it with a proper analytics dashboard — graphs, filters, leaderboards — plus Florence, an AI analyst who answers questions about your account's data in plain English.

New Features
New Reports dashboard — A brand-new analytics home at /AppV2/{account}/reports: KPI tiles, uploads-over-time charts, device split, audience demographics (age × gender from vision AI), top tags, emotions and brands, and a geo heatmap — all filterable by date range, event, demographic and camera type, with shareable URLs.
Event leaderboard — Rank your events by uploads, unique fans, engagement rate, FanMoments delivered or share views, with per-event sparklines and drill-down detail pages.
Campaign performance — Per-campaign health scores and the full FanMoment delivery funnel (created → complete → emailed) at a glance.
AI campaign recommendations — One click generates data-grounded campaign ideas for your account: what to run, who to target, when to run it.
Meet Florence — A chat assistant on every reports page. Ask "Which event had the best engagement this month?" and Florence consults your live stats — scoped strictly to your account, read-only, and honest when there's no data.
Web analytics, built in — Plausible (cookie-free) web analytics is now provisioned automatically for new accounts and embedded right in the dashboard — no third-party logins, ever.
Scheduled exports moved home — The daily CSV email reports are now configured from the Reports area instead of being buried in the event editor. The emails themselves are unchanged.
Bring your own BI — A new Integrations page lets you generate API keys and pull your analytics straight into Power BI, Looker Studio, Excel or Google Sheets: every dashboard dataset is available as a JSON or CSV feed, read-only and scoped to your account.
Improvements
A dashboard with a face — The Reports dashboard adopted its new design language: a standout-event hero with fan-photo mosaic and vs-average comparison, KPI cards with period-on-period deltas and sparklines, a content output breakdown (films, stories, mosaics, FanMoments), and the FANDEMiQ look — Sora headings, gradient sidebar, the works.
App Skin editor, demystified — Click any element in the phone preview to jump straight to its setting (and hover a setting to see what it controls). The preview now loads your actual fonts, matches the real app's colours and background behaviour, and every setting has a clearer name and a hint about where it appears.
The Manager now shows its version in the footer of the new dashboard, linking to this very page (/changelog works too).
Share pages now record cookie-free visit analytics per account.
10.0.8 June 2026
New Features Improvements Infrastructure

FanMoment AI

New Features
FanMoment AI — Enhanced photo transformation powered by OpenAI gpt-image-2. Fans receive AI-enhanced personalised images that match exactly what the campaign administrator approved in preview.
Per-campaign AI model selection — Campaign administrators can choose between three AI models per FanMoment scene: Standard (gpt-image-2, highest quality), Balanced (gpt-image-1), and Fast (gpt-image-1-mini, ideal for high-volume events).
Improvements
Photos uploaded without GPS data now appear correctly in campaign content views.
Google Maps integration in the event management dashboard is now stable across all deployments.
Processing pipeline now marks all uploaded photos as ready for campaign search, even when device location is unavailable.
Infrastructure
Production deploys now require explicit manual approval, ensuring staging is always verified before fans are affected.
All three services send real-time error alerts to the engineering team Slack channel.
Deploy pipeline now re-applies all configuration on every deploy, preventing environment drift.
10.0.7 May 2026
New Features Improvements

FanMoment editor redesign

New Features
FanMoment editor redesign — New full-page editor with live preview panel, mode cards (Composite vs Enhanced AI), and sticky preview column that stays visible while configuring settings.
Charges acknowledgement gate — Three-layer protection prevents accidental AI processing charges: the UI requires acknowledgement before saving, the API validates before accepting the configuration, and the processing pipeline double-checks before invoking any paid service.
Improvements
FanMoment preview and production delivery now use the same AI model and prompt, ensuring fans receive images that exactly match what administrators approved.
10.0.6 April 2026
New Features Improvements

Retroactive FanMoment processing

New Features
Retroactive FanMoment processing — Campaign administrators can re-trigger AI transformation on previously processed photos without fans needing to resubmit content.
Fan wall improvements — Faster content load times and better error recovery during high-traffic events.
Improvements
SignalR live dashboard connection stability improvements.
Reduced processing queue backlog during concurrent event peaks.
10.0.5 March 2026
New Features Fixed

FanMoment launch

New Features
FanMoment launch — Two-tier personalised photo experience for fans: Composite (free tier, instant) and Generative Enhanced (AI-powered, highest quality).
Background removal — Automatic fan subject extraction for seamless compositing onto campaign backgrounds.
Fixed
Film generation jobs no longer retry indefinitely on permanent asset failures.
Campaign date range filter correctly handles multi-timezone events.
10.0.4 February 2026
New Features Improvements

Live dashboard

New Features
Live dashboard — Real-time event monitoring with fan upload counts, processing status, and delivery stats.
Multi-language support — Campaign content and fan-facing emails can now be delivered in the fan's preferred language.
Improvements
Hangfire processing queue optimisation — four dedicated queues prevent slow AI jobs from blocking fast composite jobs.
Improved error logging with full stack traces in the engineering Slack channel.
For technical support or questions about a specific release, contact the FANDEMiQ team.