Skip to content

Photo Studio

Overview

Photo Studio is Upvendo's AI photo generation tool. A merchant uploads (or captures) a photo of a product, optionally adds a text prompt, and an external AI service turns it into a clean, professional studio-style shot. Each successful generation consumes one AI photo credit. Generated images are stored in the merchant's media library (Content) and can then be used on menu items and across ordering channels.

Generation runs asynchronously: the upload returns immediately with a content_id and a pending status, a background job calls the AI provider, and the frontend polls a status endpoint until the photo is completed (or failed). Credits can be topped up through Stripe Checkout from inside the feature.

Purpose

This feature — a dialog opened from any image upload field, not a page of its own — lets you turn ordinary product photos into AI-generated studio shots, watch generation progress, reuse generated images on your items, and buy more AI photo credits when you run low.

Key Concepts

  • AI photo credit: A consumable unit. Generating one photo uses one credit. A merchant's balance is ai_photo_credits on the merchant record (default 20). When the balance is 0, generation is blocked. Credits are a one-off purchase, not a monthly trial allowance.
  • AI provider (Foodshot AI): Generation is performed by an external service, Foodshot AI, called server-side via FoodshotAiService. The job sends the source image (as a base64 data URI), the prompt, a style id (delivery-hero), an aspect ratio and an output format, and receives back a job id and a result image URL. (The exact behaviour of the third-party provider is not verifiable from this codebase.)
  • Content entity: Each generated photo is stored as a Content record with source: ai_photo (from the ImageSources::AIPhoto enum). The record tracks the generation lifecycle via ai_status, ai_prompt, ai_job_id and ai_result_url, and once finished holds the Cloudflare image reference. See the Content / media library feature for how these images surface elsewhere.
  • Generation lifecycle (ai_status): A content record moves through pendingprocessingcompleted. If the provider call fails the placeholder content is deleted; if the AI succeeds but the result image cannot be downloaded/stored on Cloudflare, the status becomes completed_with_errors and the raw provider ai_result_url is kept as a fallback.
  • Cloudflare Images: The finished image is downloaded from the provider and uploaded to Cloudflare Images; the resulting cloudflare_image_id is saved on the Content record and used to serve the image via CDN variants.

Route

  • Backoffice entry point: there is no /photo-studio back-office route and no dedicated Photo Studio page. The feature is a dialog, src/components/dialogs/StudioPhotoDialog.vue, opened by clicking an uploaded image in any image upload field (src/@core/components/DropZone.vue line 308) and registered for Emily as the studio-photo modal (src/utils/emilyModalRegistry.ts line 157). So it appears wherever a DropZone does — Content (media library), item images, category and display-group images, branding, device profiles, and online-ordering images. src/views/photo-studio/PhotoEditorPage.vue exists but has no importers and no route. (Verified: upvendo-backoffice origin/productionsrc/pages/** contains no photo-studio page and additional-routes.ts declares no such redirect.)
  • Backend controller: app/Http/Controllers/Api/BackOffice/AiPhotoController.php
  • Orchestrator: app/Services/Orchestrators/BackOffice/AiPhotoOrchestrator.php
  • Service: app/Services/BackOffice/AiPhotoService.php
  • Async job: app/Jobs/GenerateAiPhotoJob.php
  • AI provider client: app/Services/Common/FoodshotAiService.php
  • Usage / credits accounting: app/Services/Common/MagicUsageService.php
  • API route file: routes/api/backoffice/ai-photo.php (mounted under the backoffice prefix, i.e. /back-office/ai-photo/...)

Actions

All endpoints below are registered under the authenticated backoffice prefix /back-office/ai-photo.

Generate AI Photo

Upload a source image (and optional prompt) to start an AI generation. The service first checks the merchant has credits, creates a placeholder Content record with ai_status: pending, then dispatches GenerateAiPhotoJob which calls Foodshot AI and stores the result.

  • Endpoint: POST /back-office/ai-photo/generate
  • Request: GenerateAiPhotoRequest
  • Parameters: image (required, image file, max 20 MB), prompt (optional, max 500 chars), aspectRatio (optional), imageFormat (optional)
  • Response: JSON { success, content_id, status: "pending", message }

Check Generation Status

Poll the status of a generation by its content id. The frontend calls this repeatedly until the photo completes or fails.

  • Endpoint: GET /back-office/ai-photo/status/{contentId}
  • Response: JSON with content_id, status and prompt. When completed, also returns content (id, url, thumbnail_url, cloudflare_id, source). When failed, returns an error. When completed_with_errors, returns a warning and result_url.

Get Credits Balance

Return the current merchant's AI photo credit balance.

  • Endpoint: GET /back-office/ai-photo/credits
  • Response: JSON { credits, vendor_id }

Get Credit Package Options

List the purchasable credit packages, derived from active Stripe prices on the configured photo_studio_credits product. Each package includes price, currency, credit count, per-credit price and a computed save_percent versus the most expensive package.

  • Endpoint: GET /back-office/ai-photo/credits-package-options
  • Response: JSON array of package objects (id, amount, currency, credits, description, price_per_credit, save_percent)

Create Checkout URL

Create a Stripe Checkout Session to buy a credit package. Returns a hosted checkout URL the merchant is redirected to.

  • Endpoint: POST /back-office/ai-photo/create-checkout-url
  • Request: CreateCheckoutUrlRequest
  • Parameters: price_id (required), credits (required, integer ≥ 1), success_url (optional URL), cancel_url (optional URL)
  • Response: JSON { checkout_url, session_id }. Default success/cancel URLs return to {backoffice_url}/settings/photo-studio?payment=success|cancelled. Note this default is a dead path — no /settings/photo-studio route exists in the back office (Photo Studio is a dialog, not a page) — so callers should pass explicit success_url / cancel_url. Both back-office callers do, building them from the current location, so after hosted checkout the merchant lands back on the page they came from with ?payment=success or ?payment=cancelled rather than the 404 catch-all. (Verified: app/Services/BackOffice/AiPhotoService.php lines 244-245; upvendo-backoffice src/components/dialogs/PurchaseCreditsDialog.vue lines 291-297, which opens checkout in a new tab so the return happens there, and src/components/dialogs/AIImageEditor.vue lines 1024-1032, which redirects in place.)

Purchase With Default Payment Method

Buy credits using the location's saved default Stripe payment method (off-session), without going through hosted checkout.

  • Endpoint: POST /back-office/ai-photo/purchase-with-default
  • Request: HandleDefaultCheckoutRequest
  • Parameters: price_id (required), credits (required, integer ≥ 1), location_id (required)
  • Response: On success, JSON { success: true, payment_intent_id, credits_added, new_balance, message }. If the card requires authentication, returns requires_action with a client_secret. On failure, returns the payment intent status and a message.

Fields

Generate request (GenerateAiPhotoRequest)

FieldIDTypeRequiredValidation
Image fileimageFile (image)Yes`required
AI promptpromptStringNo`nullable
Aspect ratioaspectRatioStringNoOne of: 1:1, 16:9, 9:16, 4:3, 3:4
Image formatimageFormatStringNoOne of: png, jpg, webp

If omitted, the service defaults prompt to "Create a professional studio shot with clean background", aspectRatio to 1:1 and imageFormat to png.

Create checkout request (CreateCheckoutUrlRequest)

FieldIDTypeRequiredValidation
Price IDprice_idStringYes`required
CreditscreditsIntegerYes`required
Success URLsuccess_urlStringNo`nullable
Cancel URLcancel_urlStringNo`nullable

Purchase-with-default request (HandleDefaultCheckoutRequest)

FieldIDTypeRequiredValidation
Price IDprice_idStringYes`required
CreditscreditsIntegerYes`required
Location IDlocation_idStringYes`required

Business Rules

  • Generation is gated on credits. Before dispatching a generation, the service checks the Photo Studio feature limit via MagicUsageService::checkMagicLimit(...FEATURE_PHOTO_STUDIO); for Photo Studio this resolves purely to the credit balance — if ai_photo_credits === 0, the request is rejected with "No photo credits available".
  • One generation consumes one credit by default (creditsUsed from the provider response, falling back to 1); usage is recorded through MagicUsageService::logUsage.
  • The source image for generation may be up to 20 MB and must be a valid image file.
  • Generation is asynchronous. The generate endpoint returns a pending status immediately; the actual provider call and image storage happen in GenerateAiPhotoJob.
  • If the provider call fails, the placeholder Content record is deleted (no orphaned "Generating..." entry remains). If the AI succeeds but the result cannot be stored on Cloudflare, the record is kept with ai_status: completed_with_errors and the provider ai_result_url.
  • Generated images are tagged ImageSources::AIPhoto (ai_photo) so they are distinguishable from other content sources (direct_upload, existing, url, photo_studio, external, square, mpluskassa, shopcaisse, suggestions_api).
  • Credits are purchased via Stripe against the photo_studio_credits product. Purchases can be made through hosted Checkout (create-checkout-url) or off-session with a saved card (purchase-with-default); the latter writes a credit log entry recording before/after balances.
  • Stripe operations are region-aware: the Stripe client and product IDs are resolved from the merchant's country code (falling back to the EU configuration).

Customer Impact

Customer-facing effects are indirect — Photo Studio only changes how product images are produced:

  • Menus / online ordering / kiosk: Cleaner, more consistent AI-generated product images can improve how items are presented to customers.
  • Receipts and other channels: Because generated images are stored as normal Content and served via Cloudflare, they appear anywhere the item image is used.

(How a polished image affects conversion or order value is a marketing assumption, not something this codebase enforces or measures.)

FAQs

What does Photo Studio actually do?

You upload a product photo and Photo Studio uses an AI service to generate a professional studio-style version of it. You can add a short prompt to guide the result.

Does it cost anything?

Yes — each generation uses one AI photo credit. New merchants start with a balance of 20 credits. When you run out, generation is blocked until you buy more.

How do I get more credits?

From within Photo Studio you can buy a credit package. Packages come from your region's Stripe pricing, and you can pay either through Stripe's hosted checkout or with your saved default payment method.

Why is my photo "pending" or "processing"?

Generation runs in the background. The dialog polls for status and will switch to the finished image once the AI service returns a result. If it fails, you'll see an error and the placeholder is removed.

Where do generated photos go?

They're saved to your media library as Content with the ai_photo source, hosted on Cloudflare Images. You can then select them on items and elsewhere just like any other uploaded image.

What image formats and sizes are accepted?

The source image must be a valid image file up to 20 MB. The generated output format can be png, jpg or webp (default png), and the aspect ratio can be 1:1, 16:9, 9:16, 4:3 or 3:4 (default 1:1).

Modern phone and stock-photo images work. Upload validation identifies a file by its bytes, not its name, and a browser reports a file's type from the extension — so a picture saved from a stock-photo site or a modern CDN often lands on disk as AVIF while keeping a .jpeg name, renders perfectly in the page, and used to be rejected with "The image file field must be an image." AVIF is now an accepted upload format alongside JPEG, PNG, GIF, BMP and WebP.

AVIF and HEIC are converted in the browser before upload. Even though the upload endpoint accepts AVIF, the back office sniffs the bytes and re-encodes AVIF and HEIC to PNG first. This is deliberate: these files are forwarded to third-party services (Foodshot AI, Photoroom) whose decoders Upvendo does not control, and a format they cannot read fails asynchronously — after the credit has already been spent. A conversion costs one re-encode and removes that risk. JPEG, PNG, GIF, BMP and WebP pass through untouched.

A file that is empty, or whose bytes match no known image signature, is refused outright rather than retried.

What happens if the AI service is not configured?

Generation will fail — the Foodshot AI client throws if its API key is missing, and the placeholder content for that generation is deleted.

Troubleshooting

"No photo credits available" when generating

Your ai_photo_credits balance is 0. Buy a credit package (hosted checkout or saved card) to top up, then retry.

Generation stays pending and never completes

Generation is handled by GenerateAiPhotoJob calling Foodshot AI. Check the queue/worker is running and inspect logs for "Foodshot AI API error" or "AI photo generation job failed". A failed provider call deletes the placeholder content rather than leaving it pending.

Photo finished but the image looks wrong / "completed_with_errors"

This status means the AI returned a result but Upvendo could not download and store it on Cloudflare Images. The raw provider result URL is retained as ai_result_url. Check Cloudflare Images connectivity/credentials and the provider result URL.

Buying credits fails or asks for extra authentication

For "purchase with default payment method": if the location has no default Stripe payment method, the request aborts with a message to add one first. If the card needs 3-D Secure, the response includes requires_action and a client_secret to complete authentication. Otherwise check the merchant's Stripe configuration for the region.

Credit packages are empty

Package options are built from active Stripe prices on the photo_studio_credits product for the merchant's region. If the list is empty, verify that product and its prices are configured in Stripe (and in config/stripe.php).

Technical Details

Generation flow

  1. POST /back-office/ai-photo/generateAiPhotoService::generate.
  2. Credit check via MagicUsageService::checkMagicLimit(merchantId, FEATURE_PHOTO_STUDIO).
  3. The source image is read and encoded as a base64 data URI.
  4. A placeholder Content record is created with source: ai_photo, ai_status: pending, ai_prompt, and empty image fields.
  5. GenerateAiPhotoJob::dispatchSync(...) runs the generation (note: dispatched synchronously from the service, but the job itself is the unit that calls the provider and stores the image).
  6. Inside the job: FoodshotAiService::generatePhoto($imageData, $prompt, 'delivery-hero', $aspectRatio, $imageFormat) returns { jobId, resultUrl, creditsUsed, processingTime }.
  7. On success the content is updated to processing with ai_job_id/ai_result_url, usage is logged, then the result image is downloaded and uploaded to Cloudflare Images and the content is finalized to completed with its cloudflare_image_id.
  8. The frontend polls GET /back-office/ai-photo/status/{contentId} until completed/failed.

AI provider call

FoodshotAiService POSTs to {services.foodshot.url}/generate with a bearer token and a JSON body containing imageData, prompt (prefixed with background: #FFFFFF; ), styleId, aspectRatio and imageFormat, using a 300-second timeout. The provider response shape and image quality are external behaviour and are not verifiable from this repository.

Content / ai_status fields

The Content record for an AI photo carries: source (ai_photo), ai_status (pendingprocessingcompleted, or completed_with_errors), ai_prompt, ai_job_id, ai_result_url, and the standard Cloudflare image fields once finished.

Credits accounting

MagicUsageService exposes the photo_studio feature constant (FEATURE_PHOTO_STUDIO). Unlike other Magic features, Photo Studio is treated as a one-off purchase: the limit check is satisfied solely by a non-zero ai_photo_credits balance rather than a monthly trial limit. Off-session purchases also write a MerchantAiCreditLog entry with before/after balances.

The repository also contains a separate background-removal/photo-editing subsystem — PhotoStudioController, PhotoTemplateController, PhotoRoomService, PhotoProcessingService (Remove.bg), PhotoProcessingFactory, and PhotoTemplate requests with a #RRGGBB hex-colour regex and disabled/ai.soft/ai.hard shadow modes. As of this codebase these controllers are not referenced by any registered route (no route file or ::class binding maps to them), so the /photo-studio/... and /photo-studio/templates endpoints the backoffice photoroom.ts/photoTemplateService.ts utilities expect are not served by this backend. Treat that PhotoRoom/Remove.bg flow as inactive/legacy here; the live, routed feature is the Foodshot AI generation described above. (Whether those routes exist in a separate "photo studio" app referenced by comments in the route files is not verifiable from this repository.)

Assistant Guidance

When users ask about Photo Studio, describe the live feature: AI photo generation that turns an uploaded product photo into a studio-style image, using credits. Walk them through upload → optional prompt → generate → wait for completion → reuse the image. If they hit "No photo credits available", explain credits and how to buy more (hosted checkout or saved card). Generated images live in the media library with the ai_photo source. If a user describes background removal, custom hex background colours, shadow modes, or photo templates, note that this older PhotoRoom/Remove.bg flow exists in code but is not wired to any active route in this backend, so it is not the feature behind the live Photo Studio dialog. Do not send merchants to a /photo-studio page — there isn't one; they open Photo Studio by clicking an uploaded image in any image upload field.

Relations

Depends On

  • Foodshot AI: External service that performs the actual image generation (provider behaviour not verifiable here).
  • Stripe: Used for credit packages, hosted checkout and off-session purchases; region-resolved per merchant country.
  • Cloudflare Images: Hosts and serves the generated images via CDN variants.
  • Content (media library): Generated photos are stored and managed as Content records (source: ai_photo).
  • MagicUsageService: Enforces the credit gate and records usage/credit changes.

Affects

  • Content (media library): Adds AI-generated images with the ai_photo source.
  • Menu items: Generated images can be selected as item images.
  • Menus / online ordering / kiosk: Reflect whichever images are chosen for items, including AI-generated ones.