Markdown Converter
Agent skill for markdown-converter
* **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.
Sign in to like and favorite skills
# 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` + `Pipe` for invoking bundled binaries.
* **Bundled tools:**
* `yt-dlp` (standalone macOS universal2 executable).
* `ffmpeg` (universal2, **LGPL-only build** preferred; use `h264_videotoolbox` and `aac_at` to 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:**
1. **Source Panel**
* **TextField**: “YouTube URL”
* **Time Inputs**: “Start” + “End”
* Accept `SS`, `MM:SS`, or `HH:MM:SS`
* **Validation labels** (inline): invalid URL, malformed time, end <= start.
2. **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`).
3. **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}`
4. **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”.
5. **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
```swift
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`
* **Container merge preference:** `--merge-output-format mp4` (if MP4) or `webm` if chosen; for audio-only use `-x --audio-format m4a` or `opus`.
* **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 `--newline` and read `stderr` line-by-line.
* Parse lines beginning with `[download]` for percent like `(\d+\.\d+)%`.
* Update `JobStage.downloading` progress (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:1` key=value pairs; compute % from `out_time_ms` vs 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
```swift
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
```swift
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
```swift
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)
```swift
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
```swift
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_ms` from `-progress` output.
* 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 (`-J` JSON or `%()` in filename then read back).
* `{start}`, `{end}` → canonical `HH-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 like `libx264`).
* **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)
1. Add `yt-dlp` and `ffmpeg` universal2 binaries to `Resources/bin/`, mark executable, ensure “Code Sign on Copy”.
2. Implement `Toolchain`, `YtDlpService`, `FFmpegService`, and `JobOrchestrator`.
3. Build SwiftUI views and validation.
4. Wire progress parsing to UI.
5. Add preferences + filename templating.
6. Sign, enable Hardened Runtime, notarize.
7. Smoke test on Apple Silicon and Intel (or Rosetta).
---
Platform: macOS 15+, desktop only, apple silicon
UI: SwiftUI (App lifecycle), with AppKit bridges for folder pickers.
Process execution:
Process + Pipe for invoking bundled binaries.
Bundled tools:
yt-dlp (standalone macOS universal2 executable).ffmpeg (universal2, LGPL-only build preferred; use h264_videotoolbox and aac_at to 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).
Single-window, clean layout:
Source Panel
TextField: “YouTube URL”
Time Inputs: “Start” + “End”
SS, MM:SS, or HH:MM:SSValidation 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):
Advanced (Disclosure):
--download-sections)” (experimental).h264_videotoolbox).Output Panel
Folder selector (defaults to
~/Downloads/ClipDownloader/).
Filename field with tokens support (live preview):
{title}, {id}, {start}, {end}, {res}, {container}{title}_{start}-{end}.{container}Run Controls
Primary button: “Download Clip”
Progress view with two stages:
Console log area (collapsible).
Cancel button (terminates current process tree).
Result toast with “Reveal in Finder”.
Help & Compliance
Keyboard shortcuts:
Accessibility:
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):
SS, MM:SS, HH:MM:SS).end > start.Format string (quality picker):
bv*[height<=1080]+ba/b[height<=1080]/bbv*[height=1080]+ba/b[height=1080]/bbv*[height=720]+ba/b[height=720]/bbv*[height=480]+ba/b[height=480]/bContainer merge preference:
--merge-output-format mp4 (if MP4) or webm if chosen; for audio-only use -x --audio-format m4a or opus.
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
--newline and read stderr line-by-line.[download] for percent like (\d+\.\d+)%.JobStage.downloading progress (0–1.0).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:1 key=value pairs; compute % from out_time_ms vs total duration (END-START).
Process (and any spawned sub-processes).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
enum Toolchain { static func binURL(_ name: String) -> URL { Bundle.main.url(forResource: "bin/\(name)", withExtension: nil)! } }
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 }
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 }
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 }
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 }
out_time_ms from -progress output.progress = min(1.0, out_time_ms / (durationSeconds * 1000000)).Render tokens with safe replacements:
{title} → fetched from yt-dlp metadata (-J JSON or %() in filename then read back).{start}, {end} → canonical HH-MM-SS.{res} → selected or detected height.{container} → mp4/webm/m4a/opus.Sanitize file name for
/ : ? * " < > | etc.
Licenses/ with texts for yt-dlp and ffmpeg; if distributing an ffmpeg build, comply with its license (prefer LGPL components only; avoid GPL encoders like libx264).Start/End, choose quality/container, and successfully save a clip to a chosen folder with a chosen name.yt-dlp and ffmpeg universal2 binaries to Resources/bin/, mark executable, ensure “Code Sign on Copy”.Toolchain, YtDlpService, FFmpegService, and JobOrchestrator.