Project: ClipDownloader (macOS Desktop Only)
0) Purpose & Non-Goals
- Purpose: A simple, reliable macOS desktop app that lets me (I will be the only user ever of this app) enter a YouTube URL + start/stop times, pick quality/container options, and save the resulting clip locally.
- Non-Goals: No playlist scraping, no preview player, no background downloading service, no login/cookies. Not targeting the Mac App Store (MAS).
1) Technology & Distribution
-
Platform: macOS 15+, desktop only, apple silicon
-
UI: SwiftUI (App lifecycle), with AppKit bridges for folder pickers.
-
Process execution:
Process+Pipefor invoking bundled binaries. -
Bundled tools:
yt-dlp(standalone macOS universal2 executable).ffmpeg(universal2, LGPL-only build preferred; useh264_videotoolboxandaac_atto avoid GPL encoders).
-
Packaging: Include binaries under
ClipDownloader.app/Contents/Resources/bin/. -
Code signing & notarization: Developer ID + Hardened Runtime. (Distribution outside MAS.)
-
Sandbox: Start without App Sandbox to simplify invoking helper binaries (or, if sandboxed later, ensure helpers are inside the bundle and signed with same team ID).
2) UX / UI Design (Desktop-only)
Single-window, clean layout:
-
Source Panel
-
TextField: “YouTube URL”
-
Time Inputs: “Start” + “End”
- Accept
SS,MM:SS, orHH:MM:SS
- Accept
-
Validation labels (inline): invalid URL, malformed time, end <= start.
-
-
Options Panel
-
Quality (Picker): Auto (best ≤1080p), 1080p, 720p, 480p.
-
Container (Picker): MP4 (default), WebM, Audio-only (M4A/Opus).
-
Clip Accuracy (Radio):
- Frame-accurate (re-encode) — default, slower, exact.
- Keyframe-only (no re-encode) — very fast, may trim to nearest keyframe.
-
Advanced (Disclosure):
- “Download only the selected section (yt-dlp
--download-sections)” (experimental). - Video bitrate (if re-encoding), Audio bitrate.
- Hardware acceleration toggle (defaults on;
h264_videotoolbox).
- “Download only the selected section (yt-dlp
-
-
Output Panel
-
Folder selector (defaults to
~/Downloads/ClipDownloader/). -
Filename field with tokens support (live preview):
- Tokens:
{title},{id},{start},{end},{res},{container} - Default:
{title}_{start}-{end}.{container}
- Tokens:
-
-
Run Controls
-
Primary button: “Download Clip”
-
Progress view with two stages:
- Stage A: “Fetching & downloading source” (yt-dlp progress %).
- Stage B: “Cutting & encoding” (ffmpeg progress %).
-
Console log area (collapsible).
-
Cancel button (terminates current process tree).
-
Result toast with “Reveal in Finder”.
-
-
Help & Compliance
- Mini help popover: time format examples.
- Reuse / fair-use reminder.
Keyboard shortcuts:
- ⌘R — Run (Download Clip)
- ⌘. — Cancel
- ⌘O — Choose output folder
Accessibility:
- Proper labels, VoiceOver friendly, high-contrast validation states.
3) Data Model & State
struct ClipRequest: Codable, Equatable {
var url: String
var startTime: String // raw user input
var endTime: String // raw user input
var quality: VideoQuality // .auto1080, .p1080, .p720, .p480
var container: Container // .mp4, .webm, .m4a, .opus
var accuracy: Accuracy // .frameAccurate, .keyframeCopy
var useDownloadSections: Bool
var videoBitrateMbps: Double? // re-encode only
var audioBitrateKbps: Int? // re-encode only
var outputFolder: URL
var filenameTemplate: String
}
enum JobStage { case idle, downloading, cutting, finished, failed, canceled }
Preferences (UserDefaults):
- Last used output folder
- Last selected quality/container/accuracy
- Filename template
- Show legal reminder (dismissed?)
4) Core Workflow
4.1 Validation
- URL must match YouTube patterns.
- Parse times into seconds (support
SS,MM:SS,HH:MM:SS). - Ensure
end > start. - If container = M4A/Opus → auto-set audio-only formats in yt-dlp.
4.2 Build yt-dlp command
-
Format string (quality picker):
- Auto ≤1080p:
bv*[height<=1080]+ba/b[height<=1080]/b - 1080p:
bv*[height=1080]+ba/b[height=1080]/b - 720p:
bv*[height=720]+ba/b[height=720]/b - 480p:
bv*[height=480]+ba/b[height=480]/b
- Auto ≤1080p:
-
Container merge preference:
--merge-output-format mp4(if MP4) orwebmif chosen; for audio-only use-x --audio-format m4aoropus. -
Output template: temp dir, e.g.,
/tmp/clipdownloader/<jobID>/input.%(ext)s -
Sections (optional):
--download-sections "*START-END"(formatted HH:MM:SS). Keep in Advanced; still keep ffmpeg stage for final trimming if accuracy requested.
Example (auto 1080p, MP4):
yt-dlp --no-playlist \
-f 'bv*[height<=1080]+ba/b[height<=1080]/b' \
-o '/tmp/clipdownloader/JOB/input.%(ext)s' \
--merge-output-format mp4 \
--newline \
URL
4.3 Parse yt-dlp progress
- Launch with
--newlineand readstderrline-by-line. - Parse lines beginning with
[download]for percent like(\d+\.\d+)%. - Update
JobStage.downloadingprogress (0–1.0).
4.4 Cut with ffmpeg
-
Determine accurate vs keyframe copy:
-
Accurate (re-encode):
ffmpeg -y -ss START -to END -i input.mp4 \ -c:v h264_videotoolbox -b:v {videoBitrate}M -maxrate {ceil(1.5×)}M -bufsize {2×}M \ -c:a aac_at -b:a {audioBitrate}k \ -movflags +faststart \ -progress pipe:1 -nostats OUTPUT -
Keyframe-only (no re-encode):
ffmpeg -y -ss START -to END -i input.mp4 \ -c copy \ -movflags +faststart \ -progress pipe:1 -nostats OUTPUT
-
-
Read
-progress pipe:1key=value pairs; compute % fromout_time_msvs total duration (END-START).
4.5 Save result
- Resolve filename from template (sanitize illegal characters).
- Write to chosen folder; if exists, append “(1)”.
- Show completion toast + “Reveal in Finder”.
4.6 Cancellation & Cleanup
- On Cancel, terminate child
Process(and any spawned sub-processes). - Always cleanup temp dir.
5) File & Module Structure
ClipDownloader/
ClipDownloaderApp.swift // @main entry
Models/
ClipRequest.swift
Enums.swift // VideoQuality, Container, Accuracy, JobStage
Validation.swift // URL + time parsing
Services/
Toolchain.swift // resolves paths to bundled yt-dlp/ffmpeg
YtDlpService.swift // builds & runs yt-dlp, progress parsing
FFmpegService.swift // builds & runs ffmpeg, progress parsing
FilenameTemplating.swift // {title}/{start}/{end} substitutions
JobOrchestrator.swift // state machine for a run
Views/
ContentView.swift // main form
OptionsView.swift
OutputView.swift
ProgressConsoleView.swift
ViewModels/
ClipViewModel.swift // binds UI to JobOrchestrator
Resources/
bin/yt-dlp // executable (universal2)
bin/ffmpeg // executable (universal2, LGPL)
Licenses/ // license texts for bundled tools
6) Key Implementation Details
6.1 Bundled Binaries Resolution
enum Toolchain {
static func binURL(_ name: String) -> URL {
Bundle.main.url(forResource: "bin/\(name)", withExtension: nil)!
}
}
- Mark both files as Executable in build phase.
- Ensure they’re codesigned with the app (copy phase: “Code Sign on Copy”).
6.2 Secure Process Launch Helper
func runProcess(executable: URL,
arguments: [String],
environment: [String: String] = [:],
onStdout: @escaping (String)->Void,
onStderr: @escaping (String)->Void) async throws -> Int32 {
let proc = Process()
proc.executableURL = executable
proc.arguments = arguments
proc.environment = environment
let outPipe = Pipe(); let errPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = errPipe
try proc.run()
// async readers
Task { for try await line in outPipe.fileHandleForReading.bytes.lines { onStdout(String(line)) } }
Task { for try await line in errPipe.fileHandleForReading.bytes.lines { onStderr(String(line)) } }
await withCheckedContinuation { cont in
proc.terminationHandler = { _ in cont.resume() }
}
return proc.terminationStatus
}
6.3 Time Parsing
func parseTime(_ s: String) -> Double? {
// Accept "SS", "MM:SS", "HH:MM:SS"
let parts = s.split(separator: ":").map(String.init).reversed()
var sec = 0.0
for (i, p) in parts.enumerated() {
guard let v = Double(p) else { return nil }
sec += v * pow(60, Double(i))
}
return sec
}
6.4 Building yt-dlp Args (examples)
func ytdlpArgs(for req: ClipRequest, tempOut: URL) -> [String] {
var fmt: String = "bv*[height<=1080]+ba/b[height<=1080]/b"
switch req.quality {
case .p1080: fmt = "bv*[height=1080]+ba/b[height=1080]/b"
case .p720: fmt = "bv*[height=720]+ba/b[height=720]/b"
case .p480: fmt = "bv*[height=480]+ba/b[height=480]/b"
case .auto1080: break
}
var args = ["--no-playlist", "--newline", "-f", fmt, "-o", tempOut.path]
switch req.container {
case .mp4: args += ["--merge-output-format", "mp4"]
case .webm: args += ["--merge-output-format", "webm"]
case .m4a: args += ["-x", "--audio-format", "m4a"]
case .opus: args += ["-x", "--audio-format", "opus"]
}
if req.useDownloadSections {
args += ["--download-sections", "*\(req.startTime)-\(req.endTime)"]
}
args.append(req.url)
return args
}
6.5 Building ffmpeg Args
func ffmpegArgs(input: URL, output: URL, req: ClipRequest, start: String, end: String) -> [String] {
var args = ["-y", "-ss", start, "-to", end, "-i", input.path,
"-movflags", "+faststart",
"-progress", "pipe:1", "-nostats"]
switch req.accuracy {
case .keyframeCopy:
args += ["-c", "copy", output.path]
case .frameAccurate:
// defaults
let vBit = (req.videoBitrateMbps ?? 5.0)
let aBit = (req.audioBitrateKbps ?? 160)
args += ["-c:v", "h264_videotoolbox",
"-b:v", "\(vBit)M",
"-maxrate", "\(ceil(vBit * 1.5))M",
"-bufsize", "\(Int(vBit * 2))M"]
args += ["-c:a", "aac_at", "-b:a", "\(aBit)k", output.path]
}
return args
}
6.6 Progress Parsing (ffmpeg)
- Use
out_time_msfrom-progressoutput. - Compute
progress = min(1.0, out_time_ms / (durationSeconds * 1000000)).
7) Error Handling & Edge Cases
- Network / 404 / removal: surface yt-dlp error lines; suggest checking URL.
- Age-restricted / region-blocked: surface error; do not implement cookies/login.
- Invalid times: show inline error; disable Run button.
- Zero-length clip: disallow; highlight end time.
- No disk space or write perms: present actionable alert; allow choosing another folder.
- ffmpeg copy mode failures when times aren’t on keyframes → instruct to retry with frame-accurate mode.
- Unexpected tool missing/permission: verify binaries executable on first run; if not, re-chmod +x and retry.
8) Preferences & Filename Tokens
-
Render tokens with safe replacements:
{title}→ fetched from yt-dlp metadata (-JJSON or%()in filename then read back).{start},{end}→ canonicalHH-MM-SS.{res}→ selected or detected height.{container}→mp4/webm/m4a/opus.
-
Sanitize file name for
/ : ? * " < > |etc.
9) Testing Plan
- Unit tests: time parsing, argument builders, filename templating, sanitizer.
- Integration tests (local fixtures): run ffmpeg on a sample file to verify cutting modes.
- Manuals: several real YT URLs (public domain or your own uploads), all quality/container combos, path with spaces, long titles.
- Performance: ensure UI remains responsive; parsing pipes on a background actor.
10) Security, Signing, Licensing
- Code sign app + bundled binaries with same Team ID; enable Hardened Runtime.
- Notarize before distributing.
- Licenses: include
Licenses/with texts for yt-dlp and ffmpeg; if distributing an ffmpeg build, comply with its license (prefer LGPL components only; avoid GPL encoders likelibx264). - Disclaimer UI: “Only download/clip content you’re authorized to use.”
11) Future Enhancements (Out of Scope Now)
- Clip preview player.
- Batch queue of multiple clips.
- Metadata sidecar (CSV/JSON) export.
- Automatic chapter/title token extraction.
- Localization.
12) Acceptance Criteria
- App launches on macOS 15+.
- User can paste a YouTube URL, enter
Start/End, choose quality/container, and successfully save a clip to a chosen folder with a chosen name. - Progress shows two stages (download → cut).
- Frame-accurate and keyframe-only modes both work.
- Output filename follows the template, sanitized, with token replacement.
- Binaries bundled, signed, and run without additional installs.
13) Developer Checklist (Build Steps)
- Add
yt-dlpandffmpeguniversal2 binaries toResources/bin/, mark executable, ensure “Code Sign on Copy”. - Implement
Toolchain,YtDlpService,FFmpegService, andJobOrchestrator. - Build SwiftUI views and validation.
- Wire progress parsing to UI.
- Add preferences + filename templating.
- Sign, enable Hardened Runtime, notarize.
- Smoke test on Apple Silicon and Intel (or Rosetta).