require 'net/http' require 'securerandom' module Spree module Admin # Image Studio — the seller's control surface for the Photoroom pipeline. # # The real flow this exists for: photograph stock on a phone, open this page, # upload the shots, see what they look like standardised, and post them to # the store — either onto an existing product or as a brand new listing. # # Three steps, deliberately: # # show choose a source (uploaded photos, or an existing product's image) # preview send each image to Photoroom, hold the results as UNATTACHED # ActiveStorage blobs, show them beside the originals # apply attach those blobs — to an existing product, or to one created here # # Nothing touches the catalogue until `apply`. Rejecting a batch is just # navigating away: the blobs were never attached, so there is nothing to undo # and no rejects left behind in a product gallery. class ImageStudioController < BaseController PHOTOROOM_EDIT = 'https://image-api.photoroom.com/v2/edit'.freeze # Each image is a round trip of roughly 3-5 seconds, run one after another. # Past this many the request starts flirting with the web server's timeout, # so the cap is enforced rather than left to chance. MAX_PHOTOS = 8 BACKGROUNDS = { 'White' => 'FFFFFF', 'Studio grey' => 'F2F2F2', 'Beige' => 'EDE6DA', 'Black' => '0E0D0B' }.freeze SIZES = ['1000x1000', '1500x1500', '2000x2000'].freeze def show load_form end def preview load_form key = photoroom_key if key.blank? # Loud, not a guess. A missing key must never quietly become a no-op # that leaves the seller believing the photos were processed. flash.now[:error] = 'PHOTOROOM_API_KEY is not set on the server. ' \ 'Add it to .env and run `docker compose up -d`.' return render :show, status: :unprocessable_entity end sources = collect_sources if sources.empty? flash.now[:error] = 'Choose some photos to upload, or pick a product to reprocess.' return render :show, status: :unprocessable_entity end size = params[:size].presence_in(SIZES) || SIZES.first background = BACKGROUNDS.values.include?(params[:background]) ? params[:background] : 'FFFFFF' @results = [] sources.each do |src| begin png = photoroom_edit(src[:bytes], src[:filename], key, size, background) rescue StandardError => e @results << { filename: src[:filename], error: e.message } next end after_blob = ActiveStorage::Blob.create_and_upload!( io: StringIO.new(png), filename: "#{File.basename(src[:filename], '.*')}-photoroom.png", content_type: 'image/png' ) @results << { filename: src[:filename], before_url: blob_path(src[:before_blob]), after_url: blob_path(after_blob), signed_id: after_blob.signed_id, before_bytes: src[:bytes].bytesize, after_bytes: png.bytesize } end @processed = @results.count { |r| r[:error].blank? } @failed = @results.count { |r| r[:error].present? } @size = size if @processed.zero? flash.now[:error] = 'Every image failed. Nothing to apply.' return render :show, status: :unprocessable_entity end render :show end def apply signed_ids = Array(params[:signed_ids]).reject(&:blank?) if signed_ids.empty? return redirect_to(spree.admin_image_studio_path, alert: 'Nothing to apply.') end product = if params[:destination] == 'new' build_new_product else current_store.products.find_by!(slug: params[:target_slug]) end return redirect_to(spree.admin_image_studio_path, alert: @create_error) if @create_error make_primary = params[:make_primary] == '1' position = make_primary ? 1 : next_position(product) attached = 0 signed_ids.each_with_index do |signed_id, i| asset = product.media.build(position: position + i) asset.type = 'Spree::Image' asset.attachment.attach(signed_id) attached += 1 if asset.save end if attached.zero? return redirect_to(spree.admin_image_studio_path, alert: 'No images could be attached.') end notice = "#{attached} image#{'s' if attached != 1} added to #{product.name}" notice += ' — created as a draft, publish it once the details are filled in' if @created_draft if @visibility_warning return redirect_to spree.admin_product_path(product), alert: "#{notice}, #{@visibility_warning}." end redirect_to spree.admin_product_path(product), notice: notice end private def load_form @breadcrumb_icon = 'photo' add_breadcrumb 'Image Studio', spree.admin_image_studio_path @products = current_store.products.where(deleted_at: nil).order(:name) @backgrounds = BACKGROUNDS @sizes = SIZES @currency = current_store.default_currency @max_photos = MAX_PHOTOS end # Two ways in: files straight off the seller's device, or an image already # on a product (useful for re-cutting an old listing). def collect_sources uploads = Array(params[:photos]).reject(&:blank?).first(MAX_PHOTOS) if uploads.any? return uploads.map do |file| bytes = file.read blob = ActiveStorage::Blob.create_and_upload!( io: StringIO.new(bytes), filename: file.original_filename, content_type: file.content_type.presence || 'image/jpeg' ) { bytes: bytes, filename: file.original_filename, before_blob: blob } end end return [] if params[:slug].blank? product = current_store.products.find_by(slug: params[:slug]) asset = product && primary_asset(product) return [] if asset.nil? @product = product [{ bytes: asset.attachment.download, filename: asset.attachment.filename.to_s, before_blob: asset.attachment.blob }] end def build_new_product name = params[:new_name].to_s.strip if name.blank? @create_error = 'A new product needs a name.' return nil end price = params[:new_price].to_s.strip if price.blank? || price.to_f <= 0 # A product with no price row in the store currency is excluded from # the storefront with no error anywhere. Refuse rather than create one. @create_error = "A new product needs a price in #{current_store.default_currency}." return nil end publish = params[:publish] == '1' @created_draft = !publish # A product belongs to ONE store in Spree 5.6 (`belongs_to :store`). # `product.stores` is a Relation, not a collection to push onto — # `stores << store` raises NoMethodError. product = Spree::Product.new( name: name, store: current_store, shipping_category_id: Spree::ShippingCategory.first&.id, status: publish ? 'active' : 'draft', # beginning_of_minute, not Time.current: the storefront scope compares # against cutoff.beginning_of_minute, which truncates DOWN, so a # timestamp from seconds ago reads as the future and the product stays # invisible until the minute ticks over. available_on: publish ? Time.current.beginning_of_minute : nil ) product.save! # Set the price explicitly against the STORE currency. Spree's generic # price setter uses the global currency preference, which is not the same # thing — that mismatch is what makes products invisible on the storefront. product.master.prices .find_or_initialize_by(currency: current_store.default_currency) .update!(amount: price.to_f) # Stock, or the storefront will never show it. `available` requires the # product to be purchasable, and a master with no stock item is not: # in_stock? false -> purchasable? false -> excluded from the storefront # with no error anywhere. Matching how the existing catalogue is set up: # inventory untracked (on_hand Infinity) with a stock item per location. master = product.master master.update!(track_inventory: false) Spree::StockLocation.where(active: true).find_each do |location| master.stock_items.find_or_create_by!(stock_location_id: location.id) end # Publish to the channel. Visibility is per-channel via # Spree::ProductPublication, NOT the product's `status` alone — a product # with no publication row is dropped by the storefront's join, no matter # how active and priced and stocked it is. if publish channel = Spree::Current.channel || Spree::Channel.find_by(name: 'Online Store') || Spree::Channel.order(:id).first if channel Spree::ProductPublication .find_or_create_by!(product_id: product.id, channel_id: channel.id) do |pub| pub.published_at = Time.current.beginning_of_minute end end end # Check rather than assume. This is the exact failure that hid 130 # products for an hour: every write returned success and the storefront # showed nothing. unless current_store.products .available(Time.current, current_store.default_currency) .where(id: product.id).exists? # A warning, not an error: the product exists now, so bailing out here # would leave it behind with no images at all. Attach them, then say so. @visibility_warning = 'but it is NOT visible on the storefront yet — ' \ 'check its price, stock and status' end product rescue ActiveRecord::RecordInvalid => e @create_error = "Could not create product: #{e.record.errors.full_messages.to_sentence}" nil end # A product's gallery is the UNION of product-level `media` and any legacy # assets pinned to the master variant, so the next free slot has to clear # both. Taking max(:position) from `media` alone put an appended image at # position 2, in the middle of the gallery, instead of at the end. def next_position(product) product_max = product.media.maximum(:position).to_i master_max = product.master&.images&.maximum(:position).to_i || 0 [product_max, master_max].max + 1 end def primary_asset(product) product.media.order(:position).first || product.master&.images&.order(:position)&.first end # url_for on an attachment resolves to attachment_url, which does not exist # on a Spree engine controller. Go through the ActiveStorage helpers. def blob_path(blob_or_attachment) Rails.application.routes.url_helpers.rails_blob_path(blob_or_attachment, only_path: true) end def photoroom_key ENV['PHOTOROOM_API_KEY'] end def photoroom_edit(image_bytes, filename, key, size, background) boundary = SecureRandom.hex(16) fields = { 'background.color' => background, 'outputSize' => size, 'padding' => '0.05' } body = +'' fields.each do |k, v| body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{k}\"\r\n\r\n#{v}\r\n" end body << "--#{boundary}\r\nContent-Disposition: form-data; " \ "name=\"imageFile\"; filename=\"#{filename}\"\r\n" \ "Content-Type: image/jpeg\r\n\r\n" body = body.b + image_bytes.b + "\r\n--#{boundary}--\r\n".b uri = URI(PHOTOROOM_EDIT) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true http.read_timeout = 180 req = Net::HTTP::Post.new(uri) req['x-api-key'] = key req['Content-Type'] = "multipart/form-data; boundary=#{boundary}" req.body = body res = http.request(req) raise "HTTP #{res.code}: #{res.body.to_s[0, 200]}" unless res.is_a?(Net::HTTPSuccess) # A 200 is not proof. Photoroom answers 200 with an empty body on some # failures, and an empty attachment saves cleanly and serves nothing. raise 'returned 200 with an empty body' if res.body.nil? || res.body.empty? unless res['content-type'].to_s.start_with?('image/') raise "returned #{res['content-type']} instead of an image" end res.body end end end end