0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-07 13:19:19 +08:00
discourse/app/services/video_conversion/aws_media_convert_adapter.rb
Natalie Tay 5b9de8b100
FIX: Maintain rotation for video uploads to AWS (#41767)
Videos uploaded to AWS MediaConvert were losing their orientation after
"conversion".

The original file can rely on rotation metadata from the device, but
MediaConvert does not apply or preserve that metadata by default.

So adding `rotate: "AUTO"` here, so MediaConvert will read the input
rotation metadata and bake the correct orientation into the converted
video.
2026-07-16 21:07:38 +08:00

438 lines
14 KiB
Ruby
Vendored

# frozen_string_literal: true
require "aws-sdk-mediaconvert"
module VideoConversion
class AwsMediaConvertAdapter < BaseAdapter
ADAPTER_NAME = "aws_mediaconvert"
def convert
return false if !valid_settings?
begin
new_sha1 = SecureRandom.hex(20)
# Extract the path from the URL
# The URL format is: //bucket.s3.dualstack.region.amazonaws.com/path/to/file
# or: //bucket.s3.region.amazonaws.com/path/to/file
url = @upload.url.sub(%r{^//}, "") # Remove leading //
# Split on the first / to separate the domain from the path
domain, path = url.split("/", 2)
# Verify the domain contains our bucket
if !domain&.include?(s3_upload_bucket)
raise Discourse::InvalidParameters.new(
"Upload URL domain for upload ID #{@upload.id} does not contain expected bucket name: #{s3_upload_bucket}",
)
end
input_path = "s3://#{s3_upload_bucket}/#{path}"
settings = build_conversion_settings(input_path, new_sha1)
begin
response =
mediaconvert_client.create_job(
role: SiteSetting.mediaconvert_role_arn,
settings: settings,
status_update_interval: "SECONDS_10",
user_metadata: {
"upload_id" => @upload.id.to_s,
"new_sha1" => new_sha1,
},
)
# Enqueue status check job
Jobs.enqueue_in(
30.seconds,
:check_video_conversion_status,
upload_id: @upload.id,
job_id: response.job.id,
new_sha1: new_sha1,
original_filename: @upload.original_filename,
user_id: @upload.user_id,
adapter_type: "aws_mediaconvert",
)
true # Return true on success
rescue Aws::MediaConvert::Errors::ServiceError => e
Discourse.warn_exception(
e,
message: "MediaConvert job creation failed",
env: {
upload_id: @upload.id,
},
)
false
rescue => e
Discourse.warn_exception(
e,
message: "Unexpected error in MediaConvert job creation",
env: {
upload_id: @upload.id,
},
)
false
end
rescue Discourse::InvalidParameters => e
Rails.logger.error("Invalid parameters for upload #{@upload.id}: #{e.message}")
false
rescue => e
Discourse.warn_exception(
e,
message: "Unexpected error in video conversion",
env: {
upload_id: @upload.id,
},
)
false
end
end
def check_status(job_id)
response = mediaconvert_client.get_job(id: job_id)
case response.job.status
when "COMPLETE"
STATUS_COMPLETE
when "ERROR"
Rails.logger.error(
"MediaConvert job #{job_id} failed. Error Code: #{response.job.error_code}, " \
"Error Message: #{response.job.error_message}, Upload ID: #{@upload.id}",
)
STATUS_ERROR
when "SUBMITTED", "PROGRESSING"
STATUS_PENDING
else
Rails.logger.warn(
"Unexpected MediaConvert job status for job #{job_id}: #{response.job.status}",
)
STATUS_ERROR
end
end
def handle_completion(job_id, new_sha1)
s3_store = FileStore::S3Store.new
temp_path = File.join(SiteSetting.mediaconvert_output_subdirectory, "#{new_sha1}.mp4")
temp_object = find_temp_file(s3_store, temp_path)
return false if !temp_object
begin
filesize = temp_object.size
final_path = get_final_upload_path(new_sha1)
destination_path, etag = copy_file_to_final_location(s3_store, temp_path, final_path)
return false if !destination_path
update_file_acl(s3_store, destination_path)
remove_temp_file(s3_store, temp_path)
url = build_file_url(s3_store, destination_path)
output_path = get_output_path_for_record(final_path)
optimized_video =
create_optimized_video_record(output_path, new_sha1, filesize, url, etag: etag)
if optimized_video
update_posts_with_optimized_video(optimized_video)
true
else
Rails.logger.error("Failed to create OptimizedVideo record for upload #{@upload.id}")
false
end
rescue => e
Discourse.warn_exception(
e,
message: "Error in video processing completion",
env: {
upload_id: @upload.id,
job_id: job_id,
temp_path: temp_path,
error_class: e.class.name,
error_message: e.message,
},
)
false
end
end
private
def valid_settings?
SiteSetting.video_conversion_enabled && SiteSetting.mediaconvert_role_arn.present?
end
def find_temp_file(s3_store, temp_path)
temp_object = s3_store.s3_helper.object(temp_path)
if !temp_object.exists?
Rails.logger.error(
"MediaConvert temp file not found at #{temp_path} for upload #{@upload.id}",
)
return nil
end
temp_object
end
def get_final_upload_path(new_sha1)
temp_upload = build_temp_upload_for_path_generation(new_sha1)
Discourse.store.get_path_for_upload(temp_upload)
end
def copy_file_to_final_location(s3_store, temp_path, final_path)
s3_helper = s3_store.s3_helper
destination_path = get_s3_path(final_path)
copy_options = s3_store.default_s3_options(secure: @upload.secure?)
begin
destination_path, etag = s3_helper.copy(temp_path, destination_path, options: copy_options)
destination_object = s3_helper.object(destination_path)
if !destination_object.exists?
Rails.logger.error(
"MediaConvert copy completed but destination file not found at #{destination_path} for upload #{@upload.id}",
)
[nil, nil]
end
# Log ACL settings for debugging
if copy_options[:acl].present?
Rails.logger.debug(
"MediaConvert copied file with ACL: #{copy_options[:acl]} (secure: #{@upload.secure?}) for upload #{@upload.id}",
)
end
[destination_path, etag]
rescue Aws::S3::Errors::NotFound => e
Rails.logger.error(
"MediaConvert copy failed - source or destination not found: #{e.message} (source: #{temp_path}, destination: #{destination_path}) for upload #{@upload.id}",
)
[nil, nil]
rescue Aws::S3::Errors::AccessDenied, Aws::S3::Errors::Forbidden => e
# Copy failed due to permissions - this is critical
Discourse.warn_exception(
e,
message: "MediaConvert copy failed due to permissions - ACL may not be applied",
env: {
upload_id: @upload.id,
temp_path: temp_path,
destination_path: destination_path,
secure: @upload.secure?,
copy_options: copy_options,
},
)
raise
rescue => e
Rails.logger.error(
"MediaConvert copy failed: #{e.class.name} - #{e.message} (source: #{temp_path}, destination: #{destination_path}) for upload #{@upload.id}",
)
raise
end
end
def update_file_acl(s3_store, destination_path)
s3_store.update_file_access_control(destination_path, @upload.secure?)
rescue Aws::S3::Errors::NotFound => e
Rails.logger.error(
"MediaConvert file not found when updating access control at #{destination_path} for upload #{@upload.id}: #{e.message}",
)
raise
rescue Aws::S3::Errors::AccessDenied, Aws::S3::Errors::Forbidden => e
# ACL update failed due to permissions - this is critical as it will cause 403s
Discourse.warn_exception(
e,
message: "MediaConvert ACL update failed due to permissions - file may be inaccessible",
env: {
upload_id: @upload.id,
destination_path: destination_path,
secure: @upload.secure?,
},
)
raise
rescue Aws::S3::Errors::ServiceError => e
# Catch other AWS S3 errors that might prevent ACL from being set
Discourse.warn_exception(
e,
message: "MediaConvert ACL update failed with AWS error",
env: {
upload_id: @upload.id,
destination_path: destination_path,
secure: @upload.secure?,
error_code: e.code,
},
)
raise
end
def remove_temp_file(s3_store, temp_path)
s3_helper = s3_store.s3_helper
begin
s3_helper.remove(temp_path, false)
rescue => e
# Log but don't fail if deletion fails - file will be cleaned up later
Rails.logger.warn("Failed to delete temporary MediaConvert file #{temp_path}: #{e.message}")
end
end
def build_file_url(s3_store, destination_path)
# Ensure path has leading slash for URL
path_with_slash =
destination_path.start_with?("/") ? destination_path : "/#{destination_path}"
# Use s3_store.absolute_base_url which handles dualstack, region, and custom endpoints
"#{s3_store.absolute_base_url}#{path_with_slash}"
end
def get_output_path_for_record(final_path)
# Remove .mp4 extension for the record path
get_s3_path(final_path).sub(/\.mp4$/, "")
end
def get_s3_path(path)
return path if path.blank?
if Rails.configuration.multisite
multisite_prefix = build_multisite_path
# Prevent double-prepending if path already includes the multisite prefix
return path if path.start_with?(multisite_prefix)
File.join(multisite_prefix, path)
else
path
end
end
def build_multisite_path
path = File.join("uploads", RailsMultisite::ConnectionManagement.current_db)
"#{path}/"
end
def build_temp_upload_for_path_generation(new_sha1)
# Create a temporary upload object to leverage FileStore::BaseStore path generation
# This object is only used for path generation and won't be saved to the database
Upload.new(
id: @upload.id, # Use the same ID to get the same depth calculation
sha1: new_sha1,
extension: "mp4",
)
end
def mediaconvert_client
@mediaconvert_client ||= build_client
end
def build_client
# For some reason the endpoint is not visible in the aws console UI so we need to get it from the API
if SiteSetting.mediaconvert_endpoint.blank?
client = create_basic_client
resp = client.describe_endpoints
SiteSetting.mediaconvert_endpoint = resp.endpoints[0].url
end
# Validate that we have an endpoint before proceeding
if SiteSetting.mediaconvert_endpoint.blank?
error_msg = "MediaConvert endpoint is required but could not be discovered"
Discourse.warn_exception(
StandardError.new(error_msg),
message: error_msg,
env: {
upload_id: @upload.id,
},
)
raise StandardError, error_msg
end
create_basic_client(endpoint: SiteSetting.mediaconvert_endpoint)
end
def create_basic_client(endpoint: nil)
client_options = { region: SiteSetting.s3_region }
client_options[:endpoint] = endpoint if endpoint.present?
creds = S3Helper.s3_credentials(SiteSetting)
client_options[:credentials] = creds if creds
Aws::MediaConvert::Client.new(client_options)
end
def s3_upload_bucket
self.class.s3_upload_bucket
end
def self.s3_upload_bucket
# MediaConvert needs just the bucket name, not the folder path
# If s3_upload_bucket is "bucket-name/folder", we need just "bucket-name"
bucket_name, _folder_path =
S3Helper.get_bucket_and_folder_path(SiteSetting.Upload.s3_upload_bucket)
bucket_name
end
def build_conversion_settings(input_path, output_path)
self.class.build_conversion_settings(input_path, output_path)
end
def self.build_conversion_settings(input_path, temp_output_filename)
# temp_output_filename is just the filename without extension (e.g., "new_sha1")
# MediaConvert will automatically add .mp4 extension based on container type
# We write it to the subdirectory as a temporary location
subdirectory = SiteSetting.mediaconvert_output_subdirectory
destination_path = File.join(subdirectory, temp_output_filename)
{
timecode_config: {
source: "ZEROBASED",
},
output_groups: [
{
name: "File Group",
output_group_settings: {
type: "FILE_GROUP_SETTINGS",
file_group_settings: {
destination: "s3://#{s3_upload_bucket}/#{destination_path}",
},
},
outputs: [
{
container_settings: {
container: "MP4",
},
video_description: {
codec_settings: {
codec: "H_264",
h264_settings: {
bitrate: 2_000_000,
rate_control_mode: "CBR",
},
},
},
audio_descriptions: [
{
codec_settings: {
codec: "AAC",
aac_settings: {
bitrate: 96_000,
sample_rate: 48_000,
coding_mode: "CODING_MODE_2_0",
},
},
},
],
},
],
},
],
inputs: [
{
file_input: input_path,
audio_selectors: {
"Audio Selector 1": {
default_selection: "DEFAULT",
},
},
video_selector: {
rotate: "AUTO",
},
},
],
}
end
end
end