├── Makefile ├── .gitignore ├── .kamal └── secrets ├── .env-sample ├── CONTRIBUTING.md ├── src ├── helpers.go ├── main.go ├── go.mod ├── s3.go ├── go.sum ├── web.go └── jobs.go ├── Dockerfile ├── ffmpeg ├── presets │ ├── hls_480p.sh │ ├── hls_720p.sh │ └── hls_1080p.sh └── convert-video.sh ├── LICENSE.md ├── SECURITY.md ├── README.md ├── CODE_OF_CONDUCT.md └── TRADEMARK_GUIDELINES.md /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | @echo "Building mediaconverter..." 3 | cd src && go build -o ../mediaconverter 4 | 5 | .PHONY: build 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.production 3 | 4 | config/deploy.yml 5 | 6 | # Generated binary 7 | /mediaconverter 8 | 9 | /local-testing 10 | -------------------------------------------------------------------------------- /.kamal/secrets: -------------------------------------------------------------------------------- 1 | API_KEY=$API_KEY 2 | AWS_REGION=$AWS_REGION 3 | AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID 4 | AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY 5 | WEBHOOK_SECRET=$WEBHOOK_SECRET 6 | -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | PORT=7454 2 | API_KEY=please_set_me 3 | WEBHOOK_SECRET=please_set_me 4 | MAX_SIMULTANEOUS_JOBS=20 5 | AWS_REGION=please_set_me 6 | AWS_ACCESS_KEY_ID=please_set_me 7 | AWS_SECRET_ACCESS_KEY=please_set_me 8 | 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Gumroad Mediaconverter 2 | 3 | Please see the main [Antiwork Contributing Guidelines](https://github.com/antiwork/.github/blob/main/CONTRIBUTING.md) for development guidelines. 4 | 5 | Generally: include an AI disclosure, self-review (comment) on your code, break up big 1k+ line PRs into smaller PRs (100 loc), and include video of before/after with light/dark mode and mobile/desktop experiences represented. And include e2e tests! 6 | -------------------------------------------------------------------------------- /src/helpers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func boolToInt(val bool) int { 10 | if val { 11 | return 1 12 | } 13 | return 0 14 | } 15 | 16 | func contains(slice []string, item string) bool { 17 | for _, s := range slice { 18 | if s == item { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | 25 | func createTempDirectory(path string) error { 26 | err := os.MkdirAll(filepath.Dir(path), 0755) 27 | if err != nil { 28 | return fmt.Errorf("failed to create temp directory: %v", err) 29 | } 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY ./src /app 6 | 7 | ENV CGO_ENABLED=0 GOOS=linux 8 | 9 | RUN go build -o mediaconverter 10 | 11 | FROM ubuntu:24.10 12 | 13 | RUN apt-get update && \ 14 | apt-get install -y --no-install-recommends \ 15 | ffmpeg \ 16 | bc \ 17 | ca-certificates \ 18 | && rm -rf /var/lib/apt/lists/* 19 | 20 | WORKDIR /app 21 | 22 | COPY --from=builder /app/mediaconverter /app/mediaconverter 23 | 24 | COPY ./ffmpeg /app/ffmpeg 25 | RUN chmod -R +x /app/ffmpeg/*.sh 26 | 27 | EXPOSE 80 28 | EXPOSE 7454 29 | 30 | CMD ["/app/mediaconverter"] 31 | -------------------------------------------------------------------------------- /ffmpeg/presets/hls_480p.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | ffmpeg -i "$input_video" \ 6 | -vf "scale=-2:480" \ 7 | -c:v libx264 \ 8 | -b:v 700k \ 9 | -maxrate 700k \ 10 | -bufsize 700k \ 11 | -pix_fmt yuv420p \ 12 | -g 60 \ 13 | -keyint_min 60 \ 14 | -sc_threshold 0 \ 15 | -refs 3 \ 16 | -profile:v main \ 17 | -x264-params "aq-mode=2:qpmin=0:qpmax=51:me=umh:subme=7:bframes=0" \ 18 | -framerate 30 \ 19 | -r 30 \ 20 | -c:a aac \ 21 | -b:a 128k \ 22 | -ac 2 \ 23 | -ar 44100 \ 24 | -f hls \ 25 | -hls_time 9 \ 26 | -hls_list_size 0 \ 27 | -hls_segment_type mpegts \ 28 | -hls_flags delete_segments \ 29 | -master_pl_name master_480p.m3u8 \ 30 | -hls_segment_filename "$output_dir/index_480p_%05d.ts" \ 31 | "$output_dir/index_480p.m3u8" 32 | -------------------------------------------------------------------------------- /ffmpeg/presets/hls_720p.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | ffmpeg -i "$input_video" \ 6 | -vf "scale=-2:720" \ 7 | -c:v libx264 \ 8 | -b:v 1000k \ 9 | -maxrate 1000k \ 10 | -bufsize 1000k \ 11 | -pix_fmt yuv420p \ 12 | -g 60 \ 13 | -keyint_min 60 \ 14 | -sc_threshold 0 \ 15 | -refs 3 \ 16 | -profile:v main \ 17 | -x264-params "aq-mode=2:qpmin=0:qpmax=51:me=umh:subme=7:bframes=0" \ 18 | -framerate 30 \ 19 | -r 30 \ 20 | -c:a aac \ 21 | -b:a 128k \ 22 | -ac 2 \ 23 | -ar 44100 \ 24 | -f hls \ 25 | -hls_time 9 \ 26 | -hls_list_size 0 \ 27 | -hls_segment_type mpegts \ 28 | -hls_flags delete_segments \ 29 | -master_pl_name master_720p.m3u8 \ 30 | -hls_segment_filename "$output_dir/index_720p_%05d.ts" \ 31 | "$output_dir/index_720p.m3u8" 32 | -------------------------------------------------------------------------------- /ffmpeg/presets/hls_1080p.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | ffmpeg -i "$input_video" \ 6 | -vf "scale=-2:1080" \ 7 | -c:v libx264 \ 8 | -b:v 4000k \ 9 | -maxrate 4000k \ 10 | -bufsize 4000k \ 11 | -pix_fmt yuv420p \ 12 | -g 60 \ 13 | -keyint_min 60 \ 14 | -sc_threshold 0 \ 15 | -refs 3 \ 16 | -profile:v main \ 17 | -x264-params "aq-mode=2:qpmin=0:qpmax=51:me=umh:subme=7:bframes=0" \ 18 | -framerate 30 \ 19 | -r 30 \ 20 | -c:a aac \ 21 | -b:a 128k \ 22 | -ac 2 \ 23 | -ar 44100 \ 24 | -f hls \ 25 | -hls_time 9 \ 26 | -hls_list_size 0 \ 27 | -hls_segment_type mpegts \ 28 | -hls_flags delete_segments \ 29 | -master_pl_name master_1080p.m3u8 \ 30 | -hls_segment_filename "$output_dir/index_1080p_%05d.ts" \ 31 | "$output_dir/index_1080p.m3u8" 32 | -------------------------------------------------------------------------------- /src/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | awsConfig "github.com/aws/aws-sdk-go-v2/config" 9 | "github.com/aws/aws-sdk-go-v2/service/s3" 10 | ) 11 | 12 | var awsClient *s3.Client 13 | 14 | const defaultPort = "7454" 15 | 16 | func init() { 17 | log.SetFlags(log.LstdFlags | log.Lmicroseconds) 18 | 19 | cfg, err := awsConfig.LoadDefaultConfig(context.TODO(), 20 | awsConfig.WithRegion(os.Getenv("AWS_REGION")), 21 | ) 22 | if err != nil { 23 | log.Fatalf("Error initializing AWS config: %v", err) 24 | } 25 | 26 | awsClient = s3.NewFromConfig(cfg) 27 | } 28 | 29 | func main() { 30 | for i := 0; i < MaxSimultaneousJobs; i++ { 31 | go jobWorker() 32 | } 33 | 34 | port := os.Getenv("PORT") 35 | if port == "" { 36 | port = defaultPort 37 | } 38 | startWebServer(port) 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Gumroad, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/go.mod: -------------------------------------------------------------------------------- 1 | module gumroad-mediaconverter 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.32.1 7 | github.com/aws/aws-sdk-go-v2/config v1.27.42 8 | github.com/aws/aws-sdk-go-v2/service/s3 v1.65.1 9 | github.com/gorilla/mux v1.8.1 10 | ) 11 | 12 | require ( 13 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect 14 | github.com/aws/aws-sdk-go-v2/credentials v1.17.40 // indirect 15 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.16 // indirect 16 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.20 // indirect 17 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.20 // indirect 18 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect 19 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.20 // indirect 20 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect 21 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.1 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.1 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.1 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.1 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.1 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/sts v1.32.1 // indirect 27 | github.com/aws/smithy-go v1.22.0 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | We take the security of Gumroad and its users seriously. If you believe you have found a security vulnerability, please report it to us as described below. 6 | 7 | **Please do not report security vulnerabilities through public GitHub issues.** 8 | 9 | Instead, please report them via email to hi@gumroad.com. You should receive a response within 48 hours. If for some reason you do not, please follow up via email to ensure we received your original message. 10 | 11 | When submitting your vulnerability report, please provide the following details: 12 | 13 | - Vulnerability category (for example, buffer overflow, SQL injection, cross-site scripting, etc.) 14 | - Complete file paths for the source file(s) where the issue appears 15 | - A reference to the affected source code (such as a tag, branch, commit, or direct URL) 16 | - Any specific configuration settings necessary to replicate the issue 17 | - A detailed sequence of steps required to reproduce the issue 18 | - If available, a sample proof-of-concept or exploit code 19 | - An explanation of the vulnerability's potential impact, including how an attacker might exploit it 20 | 21 | ## Preferred Languages 22 | 23 | We prefer all communications to be in English. 24 | 25 | ## Security Update Policy 26 | 27 | When we receive a security bug report, we will: 28 | 29 | - Confirm the problem and determine the affected versions 30 | - Audit code to find any potential similar problems 31 | - Prepare fixes for all affected versions 32 | - Release new security fix versions 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gumroad Mediaconverter (GRMC) 2 | 3 | A video conversion service that transforms video files into HLS streaming formats using FFmpeg. 4 | 5 | ## Endpoints 6 | 7 | ### POST /convert 8 | 9 | Requires authentication. Enqueues an internal job to convert a video. 10 | If too many jobs are already processing, the request will be rejected with a 429 status code. 11 | 12 | ```shell 13 | curl --basic -u $YOUR_API_KEY: -X POST $GRMC_SERVER/convert -H "Content-Type: application/json" -d '{ "id": "123456", "s3_video_uri": "s3://[......]", "s3_hls_dir_uri": "s3://[......]/hls/", "presets": ["hls_480p", "hls_720p"], "callback_url": "https://example.com/callback" }' 14 | 15 | # HTTP 200 OK 16 | # {"job_id":"f2fea87585"} 17 | # or 18 | # HTTP 429 Too Many Requests 19 | ``` 20 | 21 | ### GET /status 22 | 23 | Requires authentication. Returns list of jobs currently being processed. 24 | 25 | ```shell 26 | curl --basic -u $YOUR_API_KEY: -X GET $GRMC_SERVER/status 27 | 28 | # HTTP 200 OK 29 | # {"running_jobs":[{"JobID":"f2fea87585","Request":{"s3_video_uri":"s3://[......]","s3_hls_dir_uri":"s3://[......]/hls/","presets":["hls_480p","hls_720p"],"id":"123456","callback_url":"https://example.com/callback"},"Status":"processing","ErrorMsg":"","StartTime":"2024-10-11T08:44:45.09950379Z"}]} 30 | ``` 31 | 32 | ### GET /up 33 | 34 | Returns 200 OK if the server is running and environment variables are set. 35 | 36 | ## Development 37 | 38 | You need to set a `.env` at the root of the project. 39 | 40 | ```shell 41 | # Via docker 42 | docker run -p 7454:7454 --env-file .env $(docker build -f Dockerfile . -q) 43 | 44 | # Outside of docker 45 | make && dotenv -f .env ./mediaconverter 46 | ``` 47 | 48 | ## Deployment to production 49 | 50 | - Assuming you're using kamal to deploy 51 | - Assuming you have a `.env.production` at the root of the project 52 | 53 | ```shell 54 | # First time: dotenv -f ./.env.production kamal setup 55 | dotenv -f ./.env.production kamal deploy 56 | ``` 57 | 58 | ## License 59 | 60 | Gumroad Mediaconverter is licensed under the [MIT License](LICENSE.md). 61 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Commitment 4 | 5 | We commit to making participation in our community a respectful and productive experience for everyone. 6 | 7 | ## Standards 8 | 9 | Examples of behavior that contributes to a positive environment for our 10 | community include: 11 | 12 | - Treating others with respect 13 | - Being open to different opinions and experiences 14 | - Providing and accepting constructive feedback 15 | - Acknowledging and correcting mistakes 16 | 17 | Examples of unacceptable behavior include: 18 | 19 | - Harassment of any kind 20 | - Offensive or derogatory comments 21 | - Personal attacks 22 | - Sharing private information without consent 23 | - Any behavior considered unprofessional 24 | 25 | ## Responsibilities 26 | 27 | Community leaders will: 28 | 29 | - Enforce these standards 30 | - Take action against violations, which may include warnings, temporary bans, or permanent bans 31 | - Clarify reasons for any moderation decisions when appropriate 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies in all community spaces and when representing the community publicly. 36 | 37 | ## Enforcement 38 | 39 | Instances of unacceptable behavior should be reported to the community leaders at hi@gumroad.com. All reports will be handled promptly, fairly, and with respect to privacy. 40 | 41 | ## Enforcement 42 | 43 | Community leaders will follow these Community Impact Guidelines in determining 44 | the consequences for any action they deem in violation of this Code of Conduct: 45 | 46 | 1. Warning 47 | 48 | - Behavior: Minor violation or single inappropriate act. 49 | - Action: Private warning with explanation of why the behavior was inappropriate. A public apology might be requested. 50 | 51 | 2. Temporary Ban 52 | 53 | - Behavior: More severe or repeated violations. 54 | - Action: Temporary ban from community interaction, including no unsolicited contact with those enforcing the code. 55 | 56 | 3. Permanent Ban 57 | 58 | - Behavior: Severe or ongoing violations. 59 | - Action: Permanent exclusion from community interaction. 60 | 61 | ## Attribution 62 | 63 | This code is adapted from the [Contributor Covenant, version 2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html), but simplified for clarity and neutrality. 64 | -------------------------------------------------------------------------------- /src/s3.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/aws/aws-sdk-go-v2/aws" 13 | "github.com/aws/aws-sdk-go-v2/service/s3" 14 | "github.com/aws/aws-sdk-go-v2/service/s3/types" 15 | ) 16 | 17 | func downloadFromS3(s3URI string, localPath string) error { 18 | log.Printf("downloading from %s to %s\n", s3URI, localPath) 19 | 20 | bucket, key := parseS3URI(s3URI) 21 | 22 | output, err := awsClient.GetObject(context.TODO(), &s3.GetObjectInput{ 23 | Bucket: aws.String(bucket), 24 | Key: aws.String(key), 25 | }) 26 | if err != nil { 27 | return fmt.Errorf("failed to download video from S3: %v", err) 28 | } 29 | defer output.Body.Close() 30 | 31 | // Create a local file to save the video 32 | localFile, err := os.Create(localPath) 33 | if err != nil { 34 | return fmt.Errorf("failed to create local file: %v", err) 35 | } 36 | defer localFile.Close() 37 | 38 | // Write video to local file 39 | _, err = io.Copy(localFile, output.Body) 40 | if err != nil { 41 | return fmt.Errorf("failed to save video: %v", err) 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func uploadToS3(localDir string, s3URI string) error { 48 | log.Printf("uploading from %s to %s\n", localDir, s3URI) 49 | 50 | bucket, key := parseS3URI(s3URI) 51 | 52 | files, err := os.ReadDir(localDir) 53 | if err != nil { 54 | return fmt.Errorf("failed to read HLS output directory: %v", err) 55 | } 56 | 57 | for _, file := range files { 58 | filePath := filepath.Join(localDir, file.Name()) 59 | fileContent, err := os.Open(filePath) 60 | if err != nil { 61 | return fmt.Errorf("failed to open HLS file: %v", err) 62 | } 63 | 64 | _, err = awsClient.PutObject(context.TODO(), &s3.PutObjectInput{ 65 | Bucket: aws.String(bucket), 66 | Key: aws.String(filepath.Join(key, file.Name())), 67 | Body: fileContent, 68 | ACL: types.ObjectCannedACLPublicRead, 69 | }) 70 | if err != nil { 71 | return fmt.Errorf("failed to upload HLS file to S3: %v", err) 72 | } 73 | 74 | fileContent.Close() 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func parseS3URI(s3URI string) (bucket string, key string) { 81 | if !strings.HasPrefix(s3URI, "s3://") { 82 | return "", "" 83 | } 84 | parts := strings.SplitN(strings.TrimPrefix(s3URI, "s3://"), "/", 2) 85 | if len(parts) < 2 { 86 | return "", "" 87 | } 88 | bucket = parts[0] 89 | key = parts[1] 90 | return 91 | } 92 | -------------------------------------------------------------------------------- /ffmpeg/convert-video.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -o pipefail 5 | 6 | # Ensure required arguments are set 7 | if [[ -z "$input_video" ]] || [[ -z "$output_dir" ]]; then 8 | echo "Error: input_video and output_dir are required." 9 | exit 1 10 | fi 11 | 12 | # Ensure at least one of the hls_480p, hls_720p, or hls_1080p is set to 1 13 | if [[ "$hls_480p" != "1" && "$hls_720p" != "1" && "$hls_1080p" != "1" ]]; then 14 | echo "Error: At least one of hls_480p, hls_720p, or hls_1080p must be set to 1." 15 | exit 1 16 | fi 17 | 18 | # Export input_video and output_dir to be accessible in the preset scripts 19 | export input_video 20 | export output_dir 21 | 22 | # Create the output directory if it doesn't exist 23 | mkdir -p "$output_dir" 24 | 25 | complete_master_playlist() { 26 | height="$1" 27 | 28 | # Extract current bandwidth from the master playlist 29 | current_bandwidth=$(grep 'BANDWIDTH' "$output_dir/master_${height}p.m3u8" | sed -n 's/.*BANDWIDTH=\([0-9]*\).*/\1/p' | head -1) 30 | if [[ -z "$current_bandwidth" ]]; then 31 | echo "Failed to extract bandwidth" 32 | return 1 33 | fi 34 | 35 | # Calculate 75% of the current bandwidth for average bandwidth 36 | average_bandwidth=$(($current_bandwidth * 75 / 100)) 37 | 38 | # Update master playlist with the new average bandwidth and frame rate 39 | if [[ "$(uname)" == "Darwin" ]]; then 40 | sed -i "" "s/\(BANDWIDTH=$current_bandwidth\)/\1,AVERAGE-BANDWIDTH=$average_bandwidth,FRAME-RATE=30.000/" "$output_dir/master_${height}p.m3u8" 41 | else 42 | sed -i "s/\(BANDWIDTH=$current_bandwidth\)/\1,AVERAGE-BANDWIDTH=$average_bandwidth,FRAME-RATE=30.000/" "$output_dir/master_${height}p.m3u8" 43 | fi 44 | 45 | if [ $? -ne 0 ]; then 46 | echo "Error updating master playlist" 47 | return 1 48 | fi 49 | } 50 | 51 | # Initialize the master playlist content 52 | master_playlist_content="#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-INDEPENDENT-SEGMENTS" 53 | 54 | # Process 480p if requested 55 | if [[ "$hls_480p" == "1" ]]; then 56 | echo "Processing 480p..." 57 | bash "$(dirname "$0")/presets/hls_480p.sh" 58 | complete_master_playlist "480" 59 | master_playlist_content+="\n$(tail -n 3 "$output_dir/master_480p.m3u8")" 60 | fi 61 | 62 | # Process 720p if requested 63 | if [[ "$hls_720p" == "1" ]]; then 64 | echo "Processing 720p..." 65 | bash "$(dirname "$0")/presets/hls_720p.sh" 66 | complete_master_playlist "720" 67 | master_playlist_content+="\n$(tail -n 3 "$output_dir/master_720p.m3u8")" 68 | fi 69 | 70 | # Process 1080p if requested 71 | if [[ "$hls_1080p" == "1" ]]; then 72 | echo "Processing 1080p..." 73 | bash "$(dirname "$0")/presets/hls_1080p.sh" 74 | complete_master_playlist "1080" 75 | master_playlist_content+="\n$(tail -n 3 "$output_dir/master_1080p.m3u8")" 76 | fi 77 | 78 | # Write the combined master playlist to index.m3u8 in the output directory 79 | echo -e "$master_playlist_content" > "$output_dir/index.m3u8" 80 | 81 | echo "Master playlist created at $output_dir/index.m3u8" 82 | -------------------------------------------------------------------------------- /src/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.32.1 h1:8WuZ43ytA+TV6QEPT/R23mr7pWyI7bSSiEHdt9BS2Pw= 2 | github.com/aws/aws-sdk-go-v2 v1.32.1/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo= 3 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0= 4 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA= 5 | github.com/aws/aws-sdk-go-v2/config v1.27.42 h1:Zsy9coUPuOsCWkjTvHpl2/DB9bptXtv7WeNPxvFr87s= 6 | github.com/aws/aws-sdk-go-v2/config v1.27.42/go.mod h1:FGASs+PuJM2EY+8rt8qyQKLPbbX/S5oY+6WzJ/KE7ko= 7 | github.com/aws/aws-sdk-go-v2/credentials v1.17.40 h1:RjnlA7t0p/IamxAM7FUJ5uS13Vszh4sjVGvsx91tGro= 8 | github.com/aws/aws-sdk-go-v2/credentials v1.17.40/go.mod h1:dgpdnSs1Bp/atS6vLlW83h9xZPP+uSPB/27dFSgC1BM= 9 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.16 h1:fwrer1pJeaiia0CcOfWVbZxvj9Adc7rsuaMTwPR0DIA= 10 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.16/go.mod h1:XyEwwp8XI4zMar7MTnJ0Sk7qY/9aN8Hp929XhuX5SF8= 11 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.20 h1:OErdlGnt+hg3tTwGYAlKvFkKVUo/TXkoHcxDxuhYYU8= 12 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.20/go.mod h1:HsPfuL5gs+407ByRXBMgpYoyrV1sgMrzd18yMXQHJpo= 13 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.20 h1:822cE1CYSwY/EZnErlF46pyynuxvf1p+VydHRQW+XNs= 14 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.20/go.mod h1:79/Tn7H7hYC5Gjz6fbnOV4OeBpkao7E8Tv95RO72pMM= 15 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= 16 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= 17 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.20 h1:HO5UCCkLmeWkJZHLvLDfylKv8ca28XLAX3HojZz2shI= 18 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.20/go.mod h1:IO0HUM6Ouk/s7Rx3hiLtFU3mc+9OJFFygjsaxFBhAbk= 19 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g= 20 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ= 21 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.1 h1:UeW3Ul28hkKvB3beWImBvO7U62tSmapxaqk8sX9SMCU= 22 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.1/go.mod h1:TER/1DuTxSN6RFQpk3xfD9hK4A1gQ7ainfkwHV3LPtU= 23 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.1 h1:5vBMBTakOvtd8aNaicswcrr9qqCYUlasuzyoU6/0g8I= 24 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.1/go.mod h1:WSUbDa5qdg05Q558KXx2Scb+EDvOPXT9gfET0fyrJSk= 25 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.1 h1:T6oOYbNQ+iqdtG1/mTJvMBg/YFyHR8Z8URyG3qK+Anc= 26 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.1/go.mod h1:25CEM6c1e2vyLcr3fPritPsdsoMwNAOc9//M1QAwtDk= 27 | github.com/aws/aws-sdk-go-v2/service/s3 v1.65.1 h1:HQR79P0F0C2YQOaS2Z+90YK9DH22z9D6Neplaj0yuy4= 28 | github.com/aws/aws-sdk-go-v2/service/s3 v1.65.1/go.mod h1:xYVl5BX9Ws7+ZM58b3w0kq36TR1Dgw2OMkjSr6YTWXg= 29 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.1 h1:aAIr0WhAgvKrxZtkBqne87Gjmd7/lJVTFkR2l2yuhL8= 30 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.1/go.mod h1:8XhxGMWUfikJuginPQl5SGZ0LSJuNX3TCEQmFWZwHTM= 31 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.1 h1:J6kIsIkgFOaU6aKjigXJoue1XEHtKIIrpSh4vKdmRTs= 32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.1/go.mod h1:2V2JLP7tXOmUbL3Hd1ojq+774t2KUAEQ35//shoNEL0= 33 | github.com/aws/aws-sdk-go-v2/service/sts v1.32.1 h1:q76Ig4OaJzVJGNUSGO3wjSTBS94g+EhHIbpY9rPvkxs= 34 | github.com/aws/aws-sdk-go-v2/service/sts v1.32.1/go.mod h1:664dajZ7uS7JMUMUG0R5bWbtN97KECNCVdFDdQ6Ipu8= 35 | github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM= 36 | github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 37 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 38 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 39 | -------------------------------------------------------------------------------- /src/web.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "maps" 9 | "math/rand" 10 | "net/http" 11 | "os" 12 | "slices" 13 | "strings" 14 | "time" 15 | 16 | "github.com/gorilla/mux" 17 | ) 18 | 19 | // Struct for the POST request to /convert 20 | type ConvertRequest struct { 21 | S3VideoURI string `json:"s3_video_uri"` 22 | S3HLSDirURI string `json:"s3_hls_dir_uri"` 23 | Presets []string `json:"presets"` 24 | ID string `json:"id"` 25 | CallbackURL string `json:"callback_url"` 26 | } 27 | 28 | // Handler for GET / 29 | func handleIndex(w http.ResponseWriter, r *http.Request) { 30 | fmt.Fprintln(w, "Gumroad: MediaConverter.") 31 | } 32 | 33 | // AuthenticateRequest checks the Authorization header and returns an error if authentication fails 34 | func AuthenticateRequest(r *http.Request) error { 35 | authHeader := r.Header.Get("Authorization") 36 | if authHeader == "" { 37 | return fmt.Errorf("authorization header is required") 38 | } 39 | 40 | parts := strings.SplitN(authHeader, " ", 2) 41 | if len(parts) != 2 || parts[0] != "Basic" { 42 | return fmt.Errorf("invalid Authorization header format") 43 | } 44 | 45 | payload, err := base64.StdEncoding.DecodeString(parts[1]) 46 | if err != nil { 47 | return fmt.Errorf("invalid Authorization header encoding") 48 | } 49 | 50 | pair := strings.SplitN(string(payload), ":", 2) 51 | if len(pair) != 2 || pair[1] != "" { 52 | return fmt.Errorf("invalid Authorization header content") 53 | } 54 | 55 | if pair[0] != os.Getenv("API_KEY") { 56 | return fmt.Errorf("unauthorized") 57 | } 58 | 59 | return nil 60 | } 61 | 62 | // Handler for POST /convert 63 | func handleConvert(w http.ResponseWriter, r *http.Request) { 64 | if err := AuthenticateRequest(r); err != nil { 65 | http.Error(w, err.Error(), http.StatusUnauthorized) 66 | return 67 | } 68 | 69 | var req ConvertRequest 70 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 71 | http.Error(w, "Invalid request body", http.StatusBadRequest) 72 | return 73 | } 74 | 75 | // Validate request 76 | if req.S3VideoURI == "" || req.S3HLSDirURI == "" || len(req.Presets) == 0 || req.ID == "" || req.CallbackURL == "" { 77 | http.Error(w, "Missing required fields", http.StatusBadRequest) 78 | return 79 | } 80 | 81 | jobID := fmt.Sprintf("%x", rand.Int63())[:10] 82 | job := Job{ 83 | JobID: jobID, 84 | Request: req, 85 | Status: "pending", 86 | StartTime: time.Now(), 87 | } 88 | 89 | select { 90 | case JobQueue <- job: 91 | log.Printf("Job queued: %s\n", jobID) 92 | w.Header().Set("Content-Type", "application/json") 93 | json.NewEncoder(w).Encode(map[string]string{"job_id": jobID}) 94 | default: 95 | http.Error(w, "Too many jobs processing, please try again later", http.StatusTooManyRequests) 96 | } 97 | } 98 | 99 | // Handler for GET /status 100 | func handleStatus(w http.ResponseWriter, r *http.Request) { 101 | if err := AuthenticateRequest(r); err != nil { 102 | http.Error(w, err.Error(), http.StatusUnauthorized) 103 | return 104 | } 105 | w.Header().Set("Content-Type", "application/json") 106 | 107 | var jobs = []Job{} 108 | if len(RunningJobs) > 0 { 109 | jobs = slices.Collect(maps.Values(RunningJobs)) 110 | } 111 | 112 | response := map[string][]Job{ 113 | "running_jobs": jobs, 114 | } 115 | json.NewEncoder(w).Encode(response) 116 | } 117 | 118 | func handleUp(w http.ResponseWriter, r *http.Request) { 119 | requiredEnvVars := []string{"PORT", "API_KEY", "MAX_SIMULTANEOUS_JOBS", "AWS_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"} 120 | missingVars := []string{} 121 | 122 | for _, envVar := range requiredEnvVars { 123 | if os.Getenv(envVar) == "" { 124 | missingVars = append(missingVars, envVar) 125 | } 126 | } 127 | 128 | if len(missingVars) > 0 { 129 | errorMsg := fmt.Sprintf("Missing required environment variables: %s", strings.Join(missingVars, ", ")) 130 | http.Error(w, errorMsg, http.StatusInternalServerError) 131 | return 132 | } 133 | 134 | fmt.Fprintln(w, "Ok.") 135 | } 136 | 137 | func startWebServer(port string) { 138 | logRequests := func(next http.Handler) http.Handler { 139 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 140 | requestID := fmt.Sprintf("%x", rand.Int63())[:10] 141 | log.Printf("[rid: %s] Processing %s request for %s from %s\n", requestID, r.Method, r.URL.Path, r.RemoteAddr) 142 | 143 | start := time.Now() 144 | next.ServeHTTP(w, r) 145 | 146 | duration := time.Since(start) 147 | log.Printf("[rid: %s] Request completed - Duration: %.4fms\n", requestID, float64(duration.Nanoseconds())/1e6) 148 | }) 149 | } 150 | 151 | r := mux.NewRouter() 152 | r.Use(logRequests) 153 | r.HandleFunc("/", handleIndex).Methods("GET") 154 | r.HandleFunc("/convert", handleConvert).Methods("POST") 155 | r.HandleFunc("/status", handleStatus).Methods("GET") 156 | r.HandleFunc("/up", handleUp).Methods("GET") 157 | 158 | fmt.Println("Starting web server on " + port) 159 | http.ListenAndServe(":"+port, r) 160 | } 161 | -------------------------------------------------------------------------------- /src/jobs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "encoding/json" 9 | "fmt" 10 | "log" 11 | "net/http" 12 | "os" 13 | "os/exec" 14 | "strconv" 15 | "sync" 16 | "time" 17 | ) 18 | 19 | type Job struct { 20 | JobID string 21 | Request ConvertRequest 22 | Status string 23 | ErrorMsg string 24 | StartTime time.Time 25 | } 26 | 27 | var MaxSimultaneousJobs int 28 | 29 | var JobQueue chan Job 30 | var RunningJobs = map[string]Job{} 31 | var JobMutex sync.Mutex 32 | 33 | func init() { 34 | if envMaxSimultaneousJobs := os.Getenv("MAX_SIMULTANEOUS_JOBS"); envMaxSimultaneousJobs != "" { 35 | var err error 36 | MaxSimultaneousJobs, err = strconv.Atoi(envMaxSimultaneousJobs) 37 | if err != nil { 38 | log.Fatalf("Error: converting MAX_SIMULTANEOUS_JOBS to int: %v", err) 39 | } 40 | JobQueue = make(chan Job, MaxSimultaneousJobs) 41 | } else { 42 | log.Fatalf("Error: MAX_SIMULTANEOUS_JOBS is not set") 43 | } 44 | 45 | if os.Getenv("WEBHOOK_SECRET") == "" { 46 | log.Fatalf("Error: WEBHOOK_SECRET is not set") 47 | } 48 | } 49 | 50 | func jobWorker() { 51 | for job := range JobQueue { 52 | processJob(job) 53 | } 54 | } 55 | 56 | func processJob(job Job) { 57 | log.Printf("[jid: %s] Processing job (%s)\n", job.JobID, job.Request.S3VideoURI) 58 | 59 | updateJobStatus(job, "processing") 60 | videoPath, HLSDir := setupPaths(job.JobID) 61 | 62 | if err := createTempDirectory(videoPath); err != nil { 63 | handleJobFailure(job, err.Error()) 64 | return 65 | } 66 | 67 | if err := downloadVideo(job, videoPath); err != nil { 68 | handleJobFailure(job, err.Error()) 69 | return 70 | } 71 | 72 | if err := runConversionScript(job, videoPath, HLSDir); err != nil { 73 | handleJobFailure(job, err.Error()) 74 | return 75 | } 76 | 77 | if err := uploadHLSFiles(job, HLSDir); err != nil { 78 | handleJobFailure(job, err.Error()) 79 | return 80 | } 81 | 82 | finishJobSuccessfully(job) 83 | 84 | log.Printf("[jid: %s] Finished processing successfully\n", job.JobID) 85 | } 86 | 87 | func updateJobStatus(job Job, status string) { 88 | JobMutex.Lock() 89 | defer JobMutex.Unlock() 90 | job.Status = status 91 | RunningJobs[job.JobID] = job 92 | } 93 | 94 | func setupPaths(jobID string) (string, string) { 95 | return fmt.Sprintf("/tmp/%s/in/video", jobID), fmt.Sprintf("/tmp/%s/out", jobID) 96 | } 97 | 98 | func downloadVideo(job Job, videoPath string) error { 99 | log.Printf("[jid: %s] Downloading video\n", job.JobID) 100 | 101 | return downloadFromS3(job.Request.S3VideoURI, videoPath) 102 | } 103 | 104 | func runConversionScript(job Job, inputPath, outputDir string) error { 105 | log.Printf("[jid: %s] Running conversion script...\n", job.JobID) 106 | 107 | cmd := exec.Command("./ffmpeg/convert-video.sh") 108 | cmd.Env = append(os.Environ(), 109 | fmt.Sprintf("input_video=%s", inputPath), 110 | fmt.Sprintf("output_dir=%s", outputDir), 111 | fmt.Sprintf("hls_480p=%d", boolToInt(contains(job.Request.Presets, "hls_480p"))), 112 | fmt.Sprintf("hls_720p=%d", boolToInt(contains(job.Request.Presets, "hls_720p"))), 113 | fmt.Sprintf("hls_1080p=%d", boolToInt(contains(job.Request.Presets, "hls_1080p"))), 114 | ) 115 | 116 | if err := cmd.Run(); err != nil { 117 | return fmt.Errorf("failed to run conversion script: %v", err) 118 | } 119 | return nil 120 | } 121 | 122 | func uploadHLSFiles(job Job, localDir string) error { 123 | log.Printf("[jid: %s] Uploading HLS files\n", job.JobID) 124 | 125 | if err := uploadToS3(localDir, job.Request.S3HLSDirURI); err != nil { 126 | return fmt.Errorf("failed to upload HLS files: %v", err) 127 | } 128 | return nil 129 | } 130 | 131 | func finishJobSuccessfully(job Job) { 132 | postToCallback(job, "success", "") 133 | cleanUpJob(job) 134 | } 135 | 136 | func handleJobFailure(job Job, errorMsg string) { 137 | log.Printf("[jid: %s] Job failed. Error: %s\n", job.JobID, errorMsg) 138 | 139 | postToCallback(job, "failure", errorMsg) 140 | cleanUpJob(job) 141 | } 142 | 143 | func postToCallback(job Job, status string, message string) { 144 | log.Printf("[jid: %s] Posting callback...\n", job.JobID) 145 | 146 | var payload = map[string]interface{}{} 147 | payload["id"] = job.Request.ID 148 | payload["job_id"] = job.JobID 149 | payload["status"] = status 150 | if status != "success" { 151 | payload["message"] = message 152 | } 153 | 154 | jsonPayload, _ := json.Marshal(payload) 155 | 156 | secret := []byte(os.Getenv("WEBHOOK_SECRET")) 157 | h := hmac.New(sha256.New, secret) 158 | h.Write(jsonPayload) 159 | signature := hex.EncodeToString(h.Sum(nil)) 160 | 161 | retryIntervals := []time.Duration{time.Minute * 1, time.Minute * 5, time.Minute * 15, time.Minute * 30} 162 | var resp *http.Response 163 | var err error 164 | 165 | for i, interval := range retryIntervals { 166 | 167 | req, reqErr := http.NewRequest("POST", job.Request.CallbackURL, bytes.NewReader(jsonPayload)) 168 | if reqErr != nil { 169 | log.Printf("[jid: %s] Failed to create request: %v\n", job.JobID, reqErr) 170 | continue 171 | } 172 | req.Header.Set("Content-Type", "application/json") 173 | 174 | timestamp := time.Now().UnixMilli() 175 | req.Header.Set("Grmc-Signature", fmt.Sprintf("t=%d,v0=%s", timestamp, signature)) 176 | 177 | client := &http.Client{} 178 | resp, err = client.Do(req) 179 | if err == nil && resp.StatusCode == 200 { 180 | log.Printf("[jid: %s] Successfully posted callback\n", job.JobID) 181 | break 182 | } 183 | 184 | if i == len(retryIntervals)-1 { 185 | log.Printf("[jid: %s] Failed to post callback after all retries: %v\n", job.JobID, err) 186 | break 187 | } 188 | 189 | log.Printf("[jid: %s] Failed to post callback (attempt %d): %v / %s. Retrying in %v...\n", job.JobID, i+1, err, resp.Status, interval) 190 | time.Sleep(interval) 191 | } 192 | 193 | if resp != nil { 194 | resp.Body.Close() 195 | } 196 | } 197 | 198 | func cleanUpJob(job Job) { 199 | os.RemoveAll("/tmp/" + job.JobID) 200 | JobMutex.Lock() 201 | delete(RunningJobs, job.JobID) 202 | JobMutex.Unlock() 203 | } 204 | -------------------------------------------------------------------------------- /TRADEMARK_GUIDELINES.md: -------------------------------------------------------------------------------- 1 | # Trademark Guidelines 2 | 3 | ## Version 1.0 dated Jan 1, 2025 4 | 5 | This trademark policy was prepared to help you understand how to use the Gumroad, Inc. trademarks, service marks and logos with Gumroad, Inc.'s Gumroad Mediaconverter software. 6 | 7 | While some of our software is available under a free and open source software license, that copyright license does not include a license to use our trademark, and this Policy is intended to explain how to use our marks consistent with background law and community expectation. 8 | 9 | This Policy covers: 10 | 11 | - Our word trademarks and service marks: Gumroad Mediaconverter 12 | - Our logos: The Gumroad Mediaconverter logo 13 | 14 | This policy encompasses all trademarks and service marks, whether they are registered or not. 15 | 16 | ## GENERAL GUIDELINES 17 | 18 | Whenever you use one of our marks, you must always do so in a way that does not mislead anyone about what they are getting and from whom. For example, you cannot say you are distributing the Gumroad, Inc. Gumroad Mediaconverter software when you're distributing a modified version of it, because recipients may not understand the differences between your modified versions and our own. 19 | 20 | You also cannot use our logo on your website in a way that suggests that your website is an official website or that we endorse your website. 21 | 22 | You can, though, say you like the Gumroad, Inc. Gumroad Mediaconverter software, that you participate in the Gumroad Mediaconverter community, that you are providing an unmodified version of the Gumroad Mediaconverter software. 23 | 24 | You may not use or register our marks, or variations of them as part of your own trademark, service mark, domain name, company name, trade name, product name or service name. 25 | 26 | Trademark law does not allow your use of names or trademarks that are too similar to ours. You therefore may not use an obvious variation of any of our marks or any phonetic equivalent, foreign language equivalent, takeoff, or abbreviation for a similar or compatible product or service. We would consider the following too similar to one of our Marks: 27 | 28 | - GumroadConverter 29 | 30 | ## ACCEPTABLE USES 31 | 32 | ### Unmodified code 33 | 34 | When you redistribute an unmodified copy of our software -- the exact form in which we make it available -- you must retain the marks we have placed on the software to identify your redistribution. 35 | 36 | ### Modified code 37 | 38 | If you distribute a modified version of our software, you must remove all of our logos from it. To assist you with this, we have removed our logos from our source trees, and include them only in our binaries. You may use our word marks, but not our logos, to truthfully describe the origin of the software that you are providing. For example, if the code you are distributing is a modification of our software, you may say, "This software is derived from the source code for Gumroad, Inc. Gumroad Mediaconverter software." 39 | 40 | ### Statements about compatibility 41 | 42 | You may use the word marks, but not the logos, to truthfully describe the relationship between your software and ours. Any other use may imply that we have certified or approved your software. If you wish to use our logos, please contact us to discuss license terms. 43 | 44 | ### Naming Compatible Products 45 | 46 | If you wish to describe your product with reference to the Gumroad Mediaconverter software, here are the conditions under which you may do so. You may call your software XYZ (where XYZ is your product name) for Gumroad Mediaconverter only if: 47 | 48 | - All versions of the Gumroad Mediaconverter software you deliver with your product are the exact binaries provided by us. 49 | - You use the following legend in marketing materials or product descriptions: "Gumroad Mediaconverter is a trademark of Gumroad, Inc." 50 | 51 | ### Managed Services 52 | 53 | You must not offer a hosted or managed service using our marks. The operation of your service would have implications for the quality control represented by our trademark. If you wish to do this, you can only do so under a license from us. If you offer such a service under your own marks, you can only state that your service is built using the Gumroad Mediaconverter software, and you may not use our logo. 54 | 55 | ### User Groups 56 | 57 | You can use the Word Marks as part of your user group name provided that: 58 | 59 | - The main focus of the group is our software 60 | - The group does not make a profit 61 | - Any charge to attend meetings are to cover the cost of the venue, food and drink only 62 | 63 | You are not authorized to conduct a conference using our marks. 64 | 65 | ### No Domain Names 66 | 67 | You must not register any domain that includes our word marks or any variant or combination of them. 68 | 69 | ## HOW TO DISPLAY OUR MARKS 70 | 71 | When you have the right to use our mark, here is how to display it. 72 | 73 | ### Trademark marking and legends 74 | 75 | The first or most prominent mention of a mark on a webpage, document, or documentation should be accompanied by a symbol indicating whether the mark is a registered trademark ("®") or an unregistered trademark ("™"). If you don't know which applies, contact us. 76 | 77 | Place the following notice at the foot of the page where you have used the mark: "Gumroad Mediaconverter is a trademark of Gumroad, Inc." 78 | 79 | ### Use of trademarks in text 80 | 81 | Always use trademarks in their exact form with the correct spelling, neither abbreviated, hyphenated, or combined with any other word or words. 82 | 83 | Unacceptable: Gumroad, Inc.-DB 84 | 85 | Don't pluralize a trademark. 86 | 87 | Unacceptable: I have seventeen Gumroad, Inc.s running in my lab. 88 | 89 | Always use a trademark as an adjective modifying a noun. 90 | 91 | Unacceptable: This is a Gumroad, Inc. 92 | Acceptable: This is a Gumroad, Inc. software application. 93 | 94 | ### Use of Logos 95 | 96 | You may not change any logo except to scale it. This means you may not add decorative elements, change the colors, change the proportions, distort it, add elements, or combine it with other logos. 97 | 98 | However, when the context requires the use of black-and-white graphics and the logo is color, you may reproduce the logo in a manner that produces a black-and-white image. 99 | 100 | --- 101 | 102 | These guidelines are based on the Model Trademark Guidelines, available at http://www.modeltrademarkguidelines.org., used under a Creative Commons Attribution 3.0 Unported license: https://creativecommons.org/licenses/by/3.0/deed.en_US. 103 | --------------------------------------------------------------------------------