Pixevo Bouwen: Technische Uitdagingen Achter een AI-Beeldplatform
Wanneer iemand “een kat in een ruimtepak op Mars” intypt in Pixevo en drie seconden later een fotorealistisch beeld terugkrijgt, denken ze niet na over wat er onder de motorkap gebeurt. Dat hoeft ook niet. Maar het platform bouwen dat dit mogelijk maakt? Dat vereiste het oplossen van problemen die we in vijftien jaar Rails-ontwikkeling niet eerder waren tegengekomen.
Pixevo is een AI-beeld- en videogeneratieplatform dat we van de grond af hebben gebouwd. Het integreert meer dan 50 AI-modellen, verwerkt dagelijks duizenden generaties, en levert features als visuele workflow builders, batchverwerking, en een marktplaats waar gebruikers hun creaties verkopen. Dit is wat we leerden tijdens het bouwen.
Het Multi-Model Probleem
De meeste AI-beeldplatforms kiezen één model en bouwen daaromheen. Midjourney gebruikt hun eigen model. DALL-E gebruikt dat van OpenAI. Wij wilden dat Pixevo elk groot model aanbiedt — Flux, Imagen, Stable Diffusion, Kling voor video, plus ons eigen Nano Banana Pro — en gebruikers naadloos laat wisselen.
De uitdaging is niet het aanroepen van verschillende API’s. Dat is gewoon HTTP. De uitdaging is dat elk model andere invoerformaten, resolutiebeperkingen, generatietijden, prijsstructuren en foutmodi heeft. Flux wil beeldverhoudingen. Stable Diffusion wil exacte pixelafmetingen. Sommige modellen accepteren negatieve prompts. Andere negeren ze. Responstijden variëren van twee seconden tot twee minuten.
We bouwden een model-adapterlaag die dit alles normaliseert achter een consistente interface:
class Generation::Orchestrator
def generate(prompt:, model:, params: {})
adapter = ModelRegistry.adapter_for(model)
normalized = adapter.normalize_params(params)
adapter.validate!(normalized)
result = with_retry(adapter.retry_policy) do
adapter.generate(prompt: prompt, **normalized)
end
PostProcessor.new(result, target: params[:output_format]).process
end
end
Elke adapter handelt de eigenaardigheden van zijn model af. De orchestrator maakt het niet uit of hij met een lokaal GPU-cluster of een externe API praat. Nieuwe modellen worden toegevoegd door een adapter te schrijven — we hebben er sinds de lancering twaalf uitgebracht zonder de kerncode voor generatie aan te raken.
Real-Time Generatie Updates
Gebruikers verwachten hun beeld te zien verschijnen, niet dertig seconden naar een spinner te staren. Sommige modellen streamen tussenresultaten. Andere retourneren niets tot ze klaar zijn. We hadden een uniforme real-time ervaring nodig, ongeacht het model.
We gebruiken Action Cable met een generatie-specifiek kanaal dat zowel streaming- als polling-modellen afhandelt:
class GenerationChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
def self.broadcast_progress(user, generation)
broadcast_to(user, {
id: generation.id,
status: generation.status,
progress: generation.progress_percentage,
preview_url: generation.preview_url,
final_url: generation.completed? ? generation.result_url : nil
})
end
end
Voor streaming-modellen pushen we voorbeeldframes zodra ze binnenkomen. Voor niet-streaming modellen pollt de achtergrondtaak de provider en zendt voortgangsschattingen uit op basis van historische timingdata. De frontend kent het verschil niet — het rendert gewoon wat er via de socket binnenkomt.
Het lastige deel was de voortgangsschatting. Een model doet er typisch twaalf seconden over, maar bij drukte op de provider kan het veertig worden. We volgen P50 en P95 generatietijden per model en gebruiken een logaritmische curve voor de voortgangsbalk. Die start snel, vertraagt, en bereikt nooit 100% tot het beeld daadwerkelijk klaar is. Niemand merkt het, maar het voelt responsief.
De Workflow Engine
Pixevo’s visuele workflow builder laat gebruikers AI-operaties ketenen: een beeld genereren, opschalen, een gezicht verwisselen, de achtergrond verwijderen, kleurcorrectie toepassen. Elk knooppunt in de workflow is een onafhankelijke operatie met eigen invoer, uitvoer en foutafhandeling.
Een betrouwbare DAG (Directed Acyclic Graph) uitvoeringsengine bouwen binnen een Rails-app was het meest complexe onderdeel. Elke workflowstap kan één tot zestig seconden duren. Stappen kunnen parallel draaien als ze niet van elkaar afhangen. Elke stap kan falen, en je moet individuele knooppunten kunnen herhalen zonder de hele pipeline opnieuw te draaien.
We modelleerden het als een state machine met persistente status:
class Workflow::Execution < ApplicationRecord
has_many :node_executions, dependent: :destroy
state_machine :status, initial: :pending do
event :start do
transition pending: :running
end
event :complete do
transition running: :completed
end
event :fail do
transition running: :failed
end
end
def execute!
start!
ready_nodes.each { |node| WorkflowNodeJob.perform_later(self, node) }
end
def node_completed(node)
node.dependents.each do |dependent|
if dependent.dependencies_met?
WorkflowNodeJob.perform_later(self, dependent)
end
end
complete! if all_nodes_completed?
end
end
Elke knooppuntuitvoering draait als een onafhankelijke achtergrondtaak. Wanneer een knooppunt klaar is, controleert het welke downstream knooppunten nu gedeblokkeerd zijn en plaatst ze in de wachtrij. Dit geeft ons automatische parallellisatie — als een workflow drie onafhankelijke takken heeft, draaien ze allemaal tegelijk.
De marktplaats voegt een extra dimensie toe. Gebruikers publiceren workflows als templates, en andere gebruikers kunnen ze installeren en aanpassen. We moesten de workflowdefinitie (het template) scheiden van de uitvoering (een specifieke run), versiebeheer afhandelen wanneer makers hun workflows updaten, en het economische model beheren waarbij makers credits verdienen met installaties.
Beeldverwerking op Schaal
Gegenereerde beelden hebben nabewerking nodig voordat ze bij gebruikers terechtkomen. We schalen voor thumbnails, strippen metadata voor privacy, converteren formaten, genereren blurhash placeholders voor lazy loading, en voeren kwaliteitscontroles uit. Een enkele generatie kan zes afgeleide beelden produceren.
Dit synchroon verwerken zou de responstijden om zeep helpen. We bouwden een pipeline met Active Storage en aangepaste analyzers:
class ImageAnalyzer::Generation < ActiveStorage::Analyzer
def metadata
{
width: image.width,
height: image.height,
format: image.format,
blurhash: generate_blurhash,
nsfw_score: content_safety_check,
quality_score: assess_quality
}
end
end
De content safety check verdient aandacht. Elk gegenereerd beeld doorloopt een moderatiepipeline voordat het zichtbaar is. Dit moet snel zijn (gebruikers wachten) en nauwkeurig (false positives frustreren gebruikers, false negatives creëren juridische blootstelling). We gebruiken een combinatie van veiligheidsinstellingen op modelniveau en onze eigen classificatielaag.
Provider-Storingen Elegant Afhandelen
Wanneer je afhankelijk bent van externe AI-modelproviders, gaat er van alles mis. Rate limits worden bereikt. Providers gaan offline voor onderhoud. GPU-clusters raken vol. Eén model kan een prompt afwijzen die een ander prima afhandelt.
Onze circuit breaker implementatie volgt foutpercentages per provider en routeert automatisch om:
class ProviderCircuitBreaker
FAILURE_THRESHOLD = 5
RESET_TIMEOUT = 30.seconds
def call(provider, &block)
if circuit_open?(provider)
raise CircuitOpenError if no_fallback?(provider)
return fallback_provider(provider).call(&block)
end
begin
result = yield
record_success(provider)
result
rescue ProviderError => e
record_failure(provider)
retry_with_fallback(provider, e, &block)
end
end
end
Wanneer de API van Flux fouten begint te geven, stoppen we met bombarderen en zetten het verzoek in de wachtrij voor later, of bieden de gebruiker een alternatief model aan. Dit gebeurt transparant — de gebruiker ziet “generatie duurt langer dan gebruikelijk” in plaats van een foutpagina.
Databaseontwerp voor Generaties
Pixevo slaat miljoenen generatie-records op, elk met metadata over de prompt, het model, parameters, timing en resultaten. De naïeve aanpak — één grote tabel — zou bezwijken onder de querydruk.
We partitioneren generaties per maand en gebruiken een combinatie van PostgreSQL’s JSONB-kolommen voor flexibele modelspecifieke parameters en geïndexeerde kolommen voor dingen die we frequent bevragen:
class CreateGenerations < ActiveRecord::Migration[8.0]
def change
create_table :generations, id: :uuid do |t|
t.references :user, null: false
t.string :model_key, null: false
t.string :status, null: false, default: 'pending'
t.text :prompt
t.jsonb :params, default: {}
t.jsonb :result_metadata, default: {}
t.integer :generation_time_ms
t.decimal :cost_credits, precision: 10, scale: 4
t.timestamps
t.index [:user_id, :created_at]
t.index [:model_key, :status]
t.index :created_at
end
end
end
De JSONB params-kolom slaat modelspecifieke instellingen op zonder schemawijzigingen wanneer we modellen toevoegen. LoRA-gewichten voor Stable Diffusion opslaan? Gaat in params. Beeldverhouding voor Flux? Params. Camera-instellingen voor de JSON-modus van Nano Banana Pro? Params. De database maakt het niet uit.
Wat We Anders Zouden Doen
Drie dingen vallen achteraf op.
Ten eerste hadden we de workflow engine vanaf dag één als een apart service moeten bouwen. Het groeide organisch binnen de monoliet, en het later extraheren was pijnlijk. De uitvoeringsstatus, de knooppuntgrafiek, de marktplaats — het is complex genoeg om een eigen bounded context te rechtvaardigen.
Ten tweede onderschatten we het belang van generatie-caching. Identieke prompts met identieke parameters produceren identieke resultaten bij deterministische modellen. We cachen nu agressief, maar een cachelaag retrofitten op een bestaande generatiepipeline is rommeliger dan het er vanaf het begin in bouwen.
Ten derde hadden we eerder in observability moeten investeren. Wanneer een generatie om 2 uur ‘s nachts faalt, moet je weten of het je code is, de provider, of de prompt van de gebruiker. We voegden gestructureerde logging, distributed tracing en providerspecifieke health dashboards toe — maar pas nadat we te veel ochtenden hadden besteed aan het debuggen van productie-issues door het lezen van ruwe logs.
De Stack
Voor de geïnteresseerden, de technologiekeuzes:
- Backend: Ruby on Rails 8, PostgreSQL 16, Redis
- Real-time: Action Cable met AnyCable voor productie
- Achtergrondtaken: Solid Queue (gemigreerd van Sidekiq)
- Frontend: Hotwire met Stimulus controllers voor de interactieve delen
- Beeldverwerking: libvips via ImageProcessing gem
- Deployment: Kamal op Hetzner (EU-datacenters voor GDPR-compliance)
- CDN: Cloudflare met R2 voor beeldopslag
Rails lijkt misschien een ongewone keuze voor een AI-platform. Dat is het niet. De AI-modellen draaien elders — op GPU-clusters achter API’s. Onze taak is orchestratie, gebruikersbeheer, facturering, marktplaatslogica en real-time communicatie. Precies waar Rails in uitblinkt.
Afsluiting
Het bouwen van Pixevo leerde ons dat het moeilijke deel van een AI-product niet de AI is. Het is alles eromheen: de orchestratielaag die meerdere modellen als één platform laat aanvoelen, de real-time infrastructuur die generaties van dertig seconden instant laat voelen, de foutafhandeling die gebruikers blij houdt wanneer providers uitvallen, en het datamodel dat flexibel blijft terwijl het AI-landschap onder je verschuift.
Als je iets vergelijkbaars bouwt en over architectuur wilt praten, neem contact op. We hebben genoeg fouten gemaakt om je de dure te besparen.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation