Packages
phoenix_kit
1.7.3
1.7.208
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
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
- `:async` - Whether to generate variants asynchronously (default: true)
- `:dimensions` - List of specific dimensions to generate (default: all enabled)
## Returns
- `{:ok, variants}` - List of generated file instances
- `{:error, reason}` - Error if generation fails
"""
def generate_variants(file, opts \\ []) do
async = Keyword.get(opts, :async, true)
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, []}
_ -> run_variant_processing(file, dimensions, async)
end
else
{:ok, []}
end
end
defp run_variant_processing(file, dimensions, true) do
task = Task.async(fn -> process_variants(file, dimensions) end)
# 30 second timeout
Task.await(task, 30_000)
end
defp run_variant_processing(file, dimensions, false) do
process_variants(file, dimensions)
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
variant_name = dimension.name
Logger.info("Generating variant: #{variant_name} for file: #{file.id}")
# Generate variant filename using MD5 hash + variant name
variant_ext = determine_variant_extension(file.ext, dimension.format)
# Extract MD5 hash from file_path for naming
[_, _, md5_hash | _] = String.split(file.file_path, "/")
variant_filename = "#{md5_hash}_#{variant_name}.#{variant_ext}"
variant_mime_type = determine_variant_mime_type(file.mime_type, dimension.format)
# Build the variant storage path - SAME directory structure as original!
# file.file_path is like: "01/ab/0123456789abcdef" (user_prefix/hash_prefix/md5_hash)
# Full path structure: "{user_prefix}/{hash_prefix}/{md5_hash}/{variant_filename}"
# Example: "01/ab/0123456789abcdef/image-thumbnail.jpg"
[user_prefix, hash_prefix, md5_hash | _] = String.split(file.file_path, "/")
variant_storage_path = "#{user_prefix}/#{hash_prefix}/#{md5_hash}/#{variant_filename}"
# Generate temp path for processing
variant_path = generate_temp_path(variant_ext)
# 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, dimension),
{:ok, file_stats} <- get_variant_file_stats(variant_path),
{:ok, storage_info} <-
store_variant_file(variant_path, variant_name, variant_storage_path, file.id),
{:ok, instance} <-
create_variant_instance(
file,
variant_name,
variant_storage_path,
variant_mime_type,
variant_ext,
file_stats
) do
# Create file location records for this variant instance
_ =
Storage.create_file_locations_for_instance(
instance.id,
storage_info.bucket_ids,
variant_storage_path
)
cleanup_temp_files([original_path, variant_path])
Logger.info("Variant #{variant_name} created successfully in database with locations")
{:ok, instance}
else
{:error, reason} = error ->
Logger.error("Variant #{variant_name} failed: #{inspect(reason)}")
error
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_id) 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_id do
nil ->
[generate_variants: false, path_prefix: storage_path]
file_id ->
# Get the original instance's bucket IDs
case Storage.get_file_instance_by_name(file_id, "original") do
%Storage.FileInstance{id: original_instance_id} ->
bucket_ids = Storage.get_file_instance_bucket_ids(original_instance_id)
if Enum.empty?(bucket_ids) do
[generate_variants: false, path_prefix: storage_path]
else
[
generate_variants: false,
path_prefix: storage_path,
force_bucket_ids: bucket_ids
]
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.id, 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_id: file.id
}
Storage.create_file_instance(instance_attrs)
end
end
defp cleanup_temp_files(paths) do
Enum.each(paths, &File.rm/1)
end
defp should_generate_variants?(file) do
file.file_type in ["image", "video"] and
Storage.get_auto_generate_variants()
end
defp get_dimensions_for_generation(file_type, specific_dimensions) do
base_query = Storage.list_dimensions_for_type(file_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
defp process_variants(file, dimensions) do
results =
dimensions
|> Enum.map(&Task.async(fn -> generate_variant(file, &1) end))
|> Task.await_many(30_000)
# 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
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
# Return original extension without leading dot
String.trim_leading(original_ext, ".")
end
end
defp retrieve_original_file(file) do
case Storage.retrieve_file(file.id) do
{:ok, path, _file} -> {:ok, path}
error -> error
end
end
defp process_variant(original_path, variant_path, mime_type, dimension) do
case String.starts_with?(mime_type, "image/") do
true ->
process_image_variant(original_path, variant_path, mime_type, dimension)
false ->
case String.starts_with?(mime_type, "video/") do
true ->
process_video_variant(original_path, variant_path, mime_type, dimension)
false ->
{:error, "Unsupported file type for variant generation"}
end
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} ->
# Get video dimensions
case get_video_dimensions(output_path) do
{:ok, {_width, _height}} ->
# Dimensions will be calculated later when creating instance
{:ok, output_path}
{:error, reason} ->
{:error, reason}
end
{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 and 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 get_video_dimensions(video_path) do
case System.cmd("ffprobe", [
"-v",
"quiet",
"-print_format",
"csv=p=0",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
video_path
]) do
{dimensions, 0} ->
case String.split(String.trim(dimensions), ",") do
[width, height] ->
{:ok, {String.to_integer(width), String.to_integer(height)}}
_ ->
{:error, "Invalid dimension format"}
end
{output, exit_code} ->
{:error, "Failed to probe video: #{output} (exit code: #{exit_code})"}
end
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 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