← all posts

Two Rejections, a Video Renderer, and a Schema That Was Never the Problem

2026-08-10

Two apps, both at the awkward stage where the code works and the world starts pushing back. Mind Journeys spent the week in a loop with App Review. Speakit spent it on the two things that quietly decide whether an AI-heavy app is viable: what the audio costs, and whether the generator actually generates.

Getting Rejected by a Device I Don't Support

Mind Journeys — the rebranded sleep/focus audio app, formerly Sleep Beats — got bounced twice. The first was 3.1.2: the automated metadata check wanted a standard EULA link in the App Description. One line of metadata, resubmit.

The second rejection was the interesting one. Build 25 came back under 4.0 and 2.1(a): "not optimized to support all screen sizes" and "all the 'play' button were unresponsive." Tested on an iPad Air. The app is iPhone-only.

That's the catch. iPadOS runs iPhone-only apps in compatibility mode and hands them a 375×667 canvas — the iPhone 8 size, about 180pt shorter than any iPhone you can still buy and smaller than anything Xcode 26 will simulate for you. My Superwall paywall rendered 840pt tall in that box and didn't scroll, so the purchase button sat 81pt below the fold. Nobody on an iPad could subscribe — and the EULA link that had just cleared the previous rejection was off-screen there too.

The dead play button was a sneakier fault: a name collision. I'd registered it against a placement called session_start, which turns out to be one of SuperwallKit's own events and sits on its implicit-trigger list. So the SDK fired the campaign by itself at launch, presenting a paywall over the home screen, while the button's own register() call raced it. Placements are constants now.

The fix I'd keep even if Apple never complained: register() isn't documented to run its feature block when presentation fails, and it can hold that block while another presentation is in flight — so a paywall SDK having a bad day could freeze a button forever. The gate now watches onPresent/onError/onSkip and gives up after six seconds. A paywall reaching the screen counts as a response; silence does not.

A Renderer That Only Does the Work Once

The other Mind Journeys thread was Journey Studio, a private macOS app that turns the same journeys the phone plays into 30-minute-to-multi-hour videos for a YouTube channel: binaural tone, stacked soundscape layers, background images dissolving into each other.

It compiles the audio kernel straight out of ios/ by path reference rather than copying it, so a change to the engine changes both the app and the videos.

The part I'm pleased with is that it only ever encodes one pass of the image sequence. The slide order is circular, so the cycle joins itself seamlessly and can just be repeated in an AVMutableComposition and exported in passthrough. Filling three hours from a fifteen-minute cycle costs a file copy instead of three hours of H.264. A one-hour 1080p render takes about three and a half minutes.

Saved journeys also became real objects this week — rename, reopen in the builder, annotate, and a completion count that only counts sessions you actually finished. All on the SwiftData store that was already there.

Paying Twice for Every Sentence

Over on Speakit, lesson audio was being synthesized live on every single play. The TTS route is a POST, so its Cache-Control header was inert, and the iOS player's cache dies with the view. Same sentence, same voice, re-billed for every learner and every replay. Scaled to a hundred units: one learner finishing the course costs about the same as generating the entire course once, for everybody.

So authors now press "Generate audio" and clips are synthesized once into a Supabase bucket. The detail that makes it safe to leave that button live: each clip stores a SHA-256 of its spoken form — furigana and glosses stripped — so a re-run costs nothing and a run after one edit costs exactly one clip.

Verifying it surfaced two things I wouldn't have guessed. ElevenLabs starts throwing 429s at concurrency 4, so it's down to 2 with backoff. And the generator was only producing about 40% audible lessons — not a bug, just that nothing in the content contract ever asked for audio. It now requests spoken text on listening exercises and intro cards, with an explicit ~85% quota per lesson.

"Schema Is Too Complex" Was a Lie

Activity generation kept failing with Schema is too complex, and under load, Grammar compilation timed out. I spent a while trimming the schema. The schema was never the problem — it's 1.2 KB and the API accepts it happily.

Capturing the outgoing request found it: Output.object sends the schema as output_config.format.json_schema, the structured-output path, which compiles it into a constrained-decoding grammar. An array of objects with sixteen mostly-optional properties makes that grammar enormous — it has to encode every subset in every order. Send the identical schema as a tool's input_schema and it sails through. Nine variants, all fine as tools.

Worse, that path was also silently corrupting output. It rejects additionalProperties: true, and the SDK works around it by flipping the flag to false — so my deliberately-open payload field was being sent as an object permitted no keys at all. Even if the grammar had compiled, every activity would have come back empty: no dialogue scripts, no options, no tokens.

The fix is one forced tool call, and it repaired units, lessons and activities together. The lesson: when an API error names something you control, check whether it's really describing the surface you're calling before you start deleting your own work.

Three Passes at the Same Screens

Speakit's look also kept moving — neo-brutalist, then flat mist cards on white, and finally white cards floating on a faintly tinted wash. That last flip came from a competitor tutor app whose friendliness turned out to come almost entirely from having the ground and the cards the other way around from mine. I changed it in the tokens rather than per-screen; half an app in each grammar just looks broken. Then I rewrote the design doc against the current code rather than appending to it — so settled decisions stay settled.

#ios#swift#app-store#ai#audio