Packages
phoenix_kit
1.7.206
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/storage/services/variant_generator.ex
defmodule PhoenixKit.Modules.Storage.VariantGenerator do
@moduledoc """
Variant generation system for images and videos.
This module handles the creation of different variants (thumbnails, resizes,
quality adjustments) for uploaded files based on dimension configurations.
## Supported Operations
### Images
- Resize to specific dimensions
- Generate thumbnails (square crops)
- Quality adjustments
- Format conversion (JPEG, PNG, WebP)
### Videos
- Quality variants (360p, 720p, 1080p)
- Thumbnail extraction
- Format conversion (MP4)
## Dependencies
Requires external tools to be installed:
- Images: ImageMagick (`convert` and `identify` commands)
- Videos: FFmpeg
"""
alias PhoenixKit.Modules.Storage
alias PhoenixKit.Modules.Storage.ImageProcessor
alias PhoenixKit.Modules.Storage.Manager
alias PhoenixKit.Modules.Storage.PdfProcessor
require Logger
@doc """
Generates variants for a file based on enabled dimensions.
## Parameters
- `file` - The file struct to generate variants for
- `opts` - Options for variant generation
## Options
- `:dimensions` - List of specific dimensions to generate (default: all enabled)
Variant generation is always synchronous from the caller's perspective.
Internally `process_variants/2` parallelizes the per-dimension work via
`Task.await_many/2` with a 10-minute cap. Callers that need fire-and-forget
semantics should enqueue the work via Oban (see `process_file_job.ex`).
## Returns
- `{:ok, variants}` - List of generated file instances
- `{:error, reason}` - Error if generation fails
"""
def generate_variants(file, opts \\ []) do
specific_dimensions = Keyword.get(opts, :dimensions, [])
if should_generate_variants?(file) do
dimensions = get_dimensions_for_generation(file.file_type, specific_dimensions)
case dimensions do
[] -> {:ok, []}
_ -> process_variants(file, dimensions)
end
else
{:ok, []}
end
end
@doc """
Generates a specific variant for a file.
## Parameters
- `file` - The file struct
- `dimension` - The dimension configuration
## Returns
- `{:ok, file_instance}` - Generated variant
- `{:error, reason}` - Error if generation fails
"""
def generate_variant(file, dimension) do
# Guard: file_path must exist to generate variants
if is_nil(file.file_path) do
Logger.warning("Cannot generate variant for file #{file.uuid}: file_path is nil")
{:error, :file_path_missing}
else
do_generate_variant(file, dimension, dimension.name, dimension.format)
end
end
defp do_generate_variant(file, dimension, variant_name, format_override) do
Logger.info("Generating variant: #{variant_name} for file: #{file.uuid}")
# Generate variant filename using file checksum + variant name for uniqueness
variant_ext = determine_variant_extension(file.ext, format_override)
# Use file_checksum or file_name basename for naming (works with any path structure)
base_name = file.file_checksum || Path.basename(file.file_name, Path.extname(file.file_name))
variant_filename = "#{base_name}_#{variant_name}.#{variant_ext}"
variant_mime_type = determine_variant_mime_type(file.mime_type, format_override)
# Build the variant storage path using file_path as base directory
# file_path can be any directory structure (timestamp-based or hierarchical)
variant_storage_path = "#{file.file_path}/#{variant_filename}"
# Generate temp path for processing
variant_path = generate_temp_path(variant_ext)
# Override dimension format for alternative format variants
effective_dimension = %{dimension | format: format_override}
# Download original file to temp location
with {:ok, original_path} <- retrieve_original_file(file),
{:ok, variant_path} <-
process_variant(original_path, variant_path, file.mime_type, effective_dimension),
{:ok, file_stats} <- get_variant_file_stats(variant_path),
{:ok, storage_info} <-
store_variant_file(variant_path, variant_name, variant_storage_path, file.uuid),
{:ok, instance} <-
create_variant_instance(
file,
variant_name,
variant_storage_path,
variant_mime_type,
variant_ext,
file_stats
),
# Create file location records for this variant instance
{:ok, _locations} <-
create_variant_file_locations(instance, storage_info.bucket_ids, variant_storage_path) do
cleanup_temp_files([original_path, variant_path])
Logger.info("Variant #{variant_name} created successfully in database with locations")
{:ok, instance}
else
{:error, :file_locations_failed} = error ->
Logger.error("Variant #{variant_name} failed: file locations could not be created")
error
{:error, reason} = error ->
Logger.error("Variant #{variant_name} failed: #{inspect(reason)}")
error
end
end
@doc """
Stores an already-rendered variant file as `variant_name` on `file`.
Use this when the variant bytes are produced outside the normal resize
pipeline (e.g. the baked annotated thumbnail). Handles stats, bucket storage,
the `FileInstance` row, and file-location records — then removes `prepared_path`.
Returns `{:ok, instance}` or `{:error, reason}`.
"""
def store_prepared_variant(file, variant_name, prepared_path, variant_ext, variant_mime_type) do
base_name = file.file_checksum || Path.basename(file.file_name, Path.extname(file.file_name))
variant_filename = "#{base_name}_#{variant_name}.#{variant_ext}"
variant_storage_path = "#{file.file_path}/#{variant_filename}"
with {:ok, file_stats} <- get_variant_file_stats(prepared_path),
{:ok, storage_info} <-
store_variant_file(prepared_path, variant_name, variant_storage_path, file.uuid),
{:ok, instance} <-
create_variant_instance(
file,
variant_name,
variant_storage_path,
variant_mime_type,
variant_ext,
file_stats
),
{:ok, _locations} <-
create_variant_file_locations(instance, storage_info.bucket_ids, variant_storage_path) do
cleanup_temp_files([prepared_path])
{:ok, instance}
end
end
# Private functions
defp get_variant_file_stats(variant_path) do
with {:ok, stat} <- File.stat(variant_path) do
checksum = calculate_file_checksum(variant_path)
width = get_width_from_file(variant_path)
height = get_height_from_file(variant_path)
{:ok, %{size: stat.size, checksum: checksum, width: width, height: height}}
end
end
defp store_variant_file(variant_path, variant_name, storage_path, file_uuid) do
Logger.info("Storing variant #{variant_name} to storage buckets at path: #{storage_path}")
# Get the bucket IDs from the original file instance if available
opts =
case file_uuid do
nil ->
[generate_variants: false, path_prefix: storage_path]
file_uuid ->
# Get the original instance's bucket UUIDs
case Storage.get_file_instance_by_name(file_uuid, "original") do
%Storage.FileInstance{uuid: original_instance_uuid} ->
bucket_uuids = Storage.get_file_instance_bucket_uuids(original_instance_uuid)
if Enum.empty?(bucket_uuids) do
[generate_variants: false, path_prefix: storage_path]
else
[
generate_variants: false,
path_prefix: storage_path,
force_bucket_ids: bucket_uuids
]
end
nil ->
[generate_variants: false, path_prefix: storage_path]
end
end
case Manager.store_file(variant_path, opts) do
{:ok, _storage_info} = success ->
Logger.info("Variant #{variant_name} stored successfully in buckets")
success
error ->
error
end
end
defp create_variant_instance(file, variant_name, storage_path, mime_type, ext, stats) do
# Check if variant already exists
case Storage.get_file_instance_by_name(file.uuid, variant_name) do
%Storage.FileInstance{} = existing_instance ->
# Variant already exists, return it
{:ok, existing_instance}
nil ->
# Create new variant instance
instance_attrs = %{
variant_name: variant_name,
file_name: storage_path,
mime_type: mime_type,
ext: ext,
checksum: stats.checksum,
size: stats.size,
width: stats.width,
height: stats.height,
processing_status: "completed",
file_uuid: file.uuid
}
Storage.create_file_instance(instance_attrs)
end
end
defp create_variant_file_locations(instance, bucket_uuids, storage_path) do
case Storage.create_file_locations_for_instance(instance.uuid, bucket_uuids, storage_path) do
{:ok, locations} ->
{:ok, locations}
{:error, :file_locations_failed, errors} ->
Logger.error(
"Failed to create file locations for instance #{instance.uuid}: #{inspect(errors)}"
)
# Rollback: delete the orphaned instance
repo = PhoenixKit.Config.get_repo()
repo.delete(instance)
{:error, :file_locations_failed}
end
end
defp cleanup_temp_files(paths) do
Enum.each(paths, &File.rm/1)
end
defp should_generate_variants?(file) do
# System-managed media (Tessera DZI tiles + manifests) never get
# quality variants — a tile is already 256×256, generating a smaller
# tile-of-a-tile would waste CPU and disk for no user-facing value.
not file.system_managed and
(file.file_type in ["image", "video"] or
(file.file_type == "document" and file.mime_type == "application/pdf")) and
Storage.get_auto_generate_variants()
end
defp get_dimensions_for_generation(file_type, specific_dimensions) do
# PDFs generate image thumbnails, so use image dimensions
query_type = if file_type == "document", do: "image", else: file_type
base_query = Storage.list_dimensions_for_type(query_type)
dimensions =
if Enum.empty?(specific_dimensions) do
base_query
else
Enum.filter(base_query, &(&1.name in specific_dimensions))
end
# Filter out the "original" dimension as that's handled separately
Enum.filter(dimensions, &(&1.name != "original"))
end
# Expands each dimension into {dimension, variant_name, format_override} tuples,
# including one tuple per alternative format configured on the dimension.
defp expand_dimensions_with_alternatives(dimensions) do
Enum.flat_map(dimensions, fn dim ->
primary = {dim, dim.name, dim.format}
alt_formats = Map.get(dim, :alternative_formats, []) || []
alternatives =
Enum.map(alt_formats, fn fmt ->
{dim, "#{dim.name}_#{fmt}", fmt}
end)
[primary | alternatives]
end)
end
defp process_variants(file, dimensions) do
expanded = expand_dimensions_with_alternatives(dimensions)
results =
expanded
|> Enum.map(fn {dim, vname, fmt} ->
Task.async(fn -> do_generate_variant(file, dim, vname, fmt) end)
end)
# Video transcoding can take several minutes for large files
|> Task.await_many(:timer.minutes(10))
# Separate successful and failed results
{successful, failed} =
Enum.split_with(results, fn
{:ok, _} -> true
_ -> false
end)
if Enum.empty?(successful) and not Enum.empty?(failed) do
{:error, "All variant generations failed"}
else
variants = Enum.map(successful, fn {:ok, variant} -> variant end)
{:ok, variants}
end
end
defp determine_variant_mime_type(original_mime, format_override) do
if format_override do
case format_override do
"jpg" -> "image/jpeg"
"jpeg" -> "image/jpeg"
"png" -> "image/png"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
_ -> original_mime
end
else
# PDF variants are rendered as JPEG images
if original_mime == "application/pdf", do: "image/jpeg", else: original_mime
end
end
defp determine_variant_extension(original_ext, format_override) do
if format_override do
# Return extension WITHOUT leading dot - generate_temp_path will add it
if String.starts_with?(format_override, ".") do
String.trim_leading(format_override, ".")
else
format_override
end
else
ext = String.trim_leading(original_ext, ".")
# PDF variants are rendered as JPEG images
if ext == "pdf", do: "jpg", else: ext
end
end
defp retrieve_original_file(file) do
case Storage.retrieve_file(file.uuid) do
{:ok, path, _file} -> {:ok, path}
error -> error
end
end
defp process_variant(original_path, variant_path, mime_type, dimension) do
cond do
String.starts_with?(mime_type, "image/") ->
process_image_variant(original_path, variant_path, mime_type, dimension)
String.starts_with?(mime_type, "video/") ->
process_video_variant(original_path, variant_path, mime_type, dimension)
mime_type == "application/pdf" ->
process_pdf_variant(original_path, variant_path, dimension)
true ->
{:error, "Unsupported file type for variant generation"}
end
end
defp process_image_variant(input_path, output_path, _mime_type, dimension) do
Logger.info(
"process_image_variant: input=#{input_path} output=#{output_path} width=#{dimension.width} height=#{dimension.height} maintain_aspect=#{dimension.maintain_aspect_ratio}"
)
quality = dimension.quality || 85
format = dimension.format
# Decision based on maintain_aspect_ratio setting
case dimension.maintain_aspect_ratio do
true ->
# Maintain aspect ratio - use only width
Logger.info("Using responsive resize for #{dimension.name} (width: #{dimension.width}px)")
ImageProcessor.resize(input_path, output_path, dimension.width, nil,
quality: quality,
format: format
)
false ->
# Fixed dimensions - use center-crop with gravity
Logger.info(
"Using center-crop for #{dimension.name} (#{dimension.width}x#{dimension.height})"
)
ImageProcessor.resize_and_crop_center(
input_path,
output_path,
dimension.width,
dimension.height,
quality: quality,
format: format,
background: "white"
)
end
end
defp process_video_variant(input_path, output_path, _mime_type, dimension) do
# Build FFmpeg command
args = build_ffmpeg_args(input_path, output_path, dimension)
case System.cmd("ffmpeg", args, stderr_to_stdout: true) do
{_output, 0} ->
# FFmpeg succeeded - dimensions are enforced by the scale filter
{:ok, output_path}
{output, exit_code} ->
{:error, "FFmpeg failed with exit code #{exit_code}: #{output}"}
end
end
defp build_ffmpeg_args(input_path, output_path, dimension) do
# -y to overwrite output file
args = ["-i", input_path, "-y"]
# Handle video quality variants
args =
case dimension.name do
"360p" ->
args ++ ["-vf", "scale=640:360", "-crf", "28"]
"720p" ->
args ++ ["-vf", "scale=1280:720", "-crf", "25"]
"1080p" ->
args ++ ["-vf", "scale=1920:1080", "-crf", "23"]
"video_thumbnail" ->
args ++ ["-ss", "00:00:01.000", "-vframes", "1", "-vf", "scale=640:360"]
_ ->
if dimension.width and dimension.height do
args ++ ["-vf", "scale=#{dimension.width}:#{dimension.height}"]
else
args
end
end
# Handle quality (override for specific variants)
args =
if dimension.quality && dimension.name not in ["360p", "720p", "1080p"] do
quality = convert_video_quality(dimension.quality)
args ++ ["-crf", quality]
else
args
end
args ++ [output_path]
end
defp convert_video_quality(quality) when is_integer(quality) do
# FFmpeg CRF uses 0-51 (lower = higher quality)
# Map image quality (1-100) to CRF (51-0)
crf = 51 - trunc(quality / 100 * 51)
Integer.to_string(crf)
end
defp calculate_file_checksum(file_path) do
file_path
|> File.read!()
|> then(fn data -> :crypto.hash(:sha256, data) end)
|> Base.encode16(case: :lower)
end
defp get_width_from_file(file_path) do
ImageProcessor.get_width(file_path)
end
defp get_height_from_file(file_path) do
ImageProcessor.get_height(file_path)
end
defp process_pdf_variant(input_path, output_path, dimension) do
temp_prefix = generate_temp_prefix()
case PdfProcessor.first_page_to_jpeg(input_path, temp_prefix) do
{:ok, jpeg_path} ->
result = process_image_variant(jpeg_path, output_path, "image/jpeg", dimension)
File.rm(jpeg_path)
result
{:error, reason} ->
{:error, reason}
end
end
defp generate_temp_prefix do
temp_dir = System.tmp_dir!()
random_name = :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
Path.join(temp_dir, "phoenix_kit_pdf_#{random_name}")
end
defp generate_temp_path(extension) do
temp_dir = System.tmp_dir!()
random_name = :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
Path.join(temp_dir, "phoenix_kit_variant_#{random_name}.#{extension}")
end
end