From d32e7c05c6b709d66cf952e39ba71ce9764aff78 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Wed, 17 Jun 2026 00:36:38 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(#292):=20YouTube=20wallpaper=20?= =?UTF-8?q?=E3=82=92=E6=9C=80=E9=AB=98=E7=94=BB=E8=B3=AADL=20+=20HEVC?= =?UTF-8?q?=E3=83=88=E3=83=A9=E3=83=B3=E3=82=B9=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=A7=E5=85=A8Mac=E5=86=8D=E7=94=9F=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit player_client を android→default(web) に切替え、SABR で 360p に潰されていた 動画取得を全解像度の https DASH に戻す。ffmpeg+ffprobe があれば最高画質 (4K VP9/AV1) を取得し、ffprobe でコーデック判定して AVC/HEVC は stream-copy、 AVFoundation が再生できない AV1/VP9 は hevc_videotoolbox で HEVC へ ハードウェアトランスコード。ツールチェーン未導入時は再生可能な AVC(1080p) 天井へフォールバック。processRunner を (status, stdout, stderr) に拡張 (コーデック判定に ffprobe の stdout が必要)。 --- .../YouTubeWallpaperDataSourceImpl.swift | 140 ++++++++++++---- .../YouTubeToolDetectionTests.swift | 6 +- .../YouTubeWallpaperResolveTests.swift | 155 ++++++++++++++++-- 3 files changed, 253 insertions(+), 48 deletions(-) diff --git a/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift b/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift index 7bfd6fb..afd0dd1 100644 --- a/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift +++ b/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift @@ -5,7 +5,7 @@ import Foundation public struct YouTubeWallpaperDataSourceImpl: Sendable { @Dependency(\.processGateway) private var gateway let tempPathFor: @Sendable (URL, String?) throws -> String - let processRunner: @Sendable (String, [String]) async throws -> (status: Int32, stderr: String) + let processRunner: @Sendable (String, [String]) async throws -> (status: Int32, stdout: String, stderr: String) let fileExistsAtPath: @Sendable (String) -> Bool let removeItemAtPath: @Sendable (String) -> Void let replaceItemAtPath: @Sendable (String, String) -> Void @@ -35,7 +35,7 @@ public struct YouTubeWallpaperDataSourceImpl: Sendable { init( tempPathFor: @escaping @Sendable (URL, String?) throws -> String, - processRunner: @escaping @Sendable (String, [String]) async throws -> (status: Int32, stderr: String), + processRunner: @escaping @Sendable (String, [String]) async throws -> (status: Int32, stdout: String, stderr: String), fileExistsAtPath: @escaping @Sendable (String) -> Bool, removeItemAtPath: @escaping @Sendable (String) -> Void, replaceItemAtPath: @escaping @Sendable (String, String) -> Void @@ -49,17 +49,24 @@ public struct YouTubeWallpaperDataSourceImpl: Sendable { } extension YouTubeWallpaperDataSourceImpl: WallpaperDataSource { - /// Downloads and remuxes to a temp file in the cache folder. Returns the temp file path. + /// Downloads and normalizes to a temp file in the cache folder. Returns the temp file path. /// Cache deduplication is handled by WallpaperRepository. public func resolve(_ location: YouTubeWallpaper) async throws -> String { let tempPath = try tempPathFor(location.url, location.format) + // Highest-quality codecs (VP9 / AV1 4K) are not natively playable by AVFoundation on + // pre-M3 Apple Silicon and Intel Macs, so we only request them when we can transcode the + // result to HEVC. Without a transcode toolchain we stay on the natively-playable AVC ceiling. + let ffmpeg = findExecutable("ffmpeg") + let ffprobe = findExecutable("ffprobe") + let canTranscode = ffmpeg != nil && ffprobe != nil + let tool = try detectTool() let args = buildArgs( tool: tool, url: location.url, maxHeight: location.maxHeight, - format: location.format, destPath: tempPath) + format: location.format, destPath: tempPath, allowAnyCodec: canTranscode) - let (status, stderr) = try await processRunner(tool.executablePath, args) + let (status, _, stderr) = try await processRunner(tool.executablePath, args) guard status == 0 else { throw YouTubeDownloadError.downloadFailed(status: status, stderr: stderr) @@ -69,7 +76,7 @@ extension YouTubeWallpaperDataSourceImpl: WallpaperDataSource { throw YouTubeDownloadError.outputNotFound } - try await remuxToStandardMP4(at: tempPath) + try await normalizeForPlayback(at: tempPath, ffmpeg: ffmpeg, ffprobe: ffprobe) return tempPath } @@ -108,63 +115,120 @@ extension YouTubeWallpaperDataSourceImpl { // MARK: - Command Building extension YouTubeWallpaperDataSourceImpl { - func buildArgs(tool: Tool, url: URL, maxHeight: Int, format: String, destPath: String) -> [String] { - let ytdlpArgs = [ - // Use the Android player client — the default web client triggers YouTube SABR - // streaming (HTTP 403). Android avoids SABR, but video-only formats require a - // GVS PO Token that we don't have, so they get skipped. Adding - // best[ext=format][height<=maxHeight] as fallback picks the combined A/V format - // (e.g. format 18) when video-only streams are unavailable. --no-audio is a - // no-op for combined formats and still suppresses separate audio downloads. - "--extractor-args", "youtube:player_client=android", - "-f", "bestvideo[ext=\(format)][height<=\(maxHeight)][vcodec^=avc]/best[ext=\(format)][height<=\(maxHeight)]", - "--no-audio", - "-o", destPath, - url.absoluteString, - ] + func buildArgs( + tool: Tool, url: URL, maxHeight: Int, format: String, destPath: String, allowAnyCodec: Bool + ) -> [String] { + let ytdlpArgs = + [ + // The Android player client is now crippled by YouTube SABR streaming, which skips + // every video-only format and leaves only the combined 360p (format 18) — the + // "sometimes terrible quality" bug. The default web client publishes the full + // https DASH ladder (all resolutions, all codecs) with no PO Token required, so it + // is the client that can actually reach 4K. --no-audio keeps this to a video-only + // download (audio is never needed for a wallpaper). + "--extractor-args", "youtube:player_client=default", + "-f", formatSelector(maxHeight: maxHeight, format: format, allowAnyCodec: allowAnyCodec), + "--no-audio", + "--no-progress", + "-o", destPath, + url.absoluteString, + ] switch tool { case .ytdlp: return ytdlpArgs case .uvx: return ["yt-dlp"] + ytdlpArgs } } + + /// When `allowAnyCodec` is true, take the highest-resolution video-only stream regardless of + /// codec (VP9 / AV1 reach 4K; the result is transcoded to HEVC downstream). Otherwise restrict + /// to AVC, which AVFoundation plays natively but YouTube caps at 1080p. + func formatSelector(maxHeight: Int, format: String, allowAnyCodec: Bool) -> String { + guard allowAnyCodec else { + return "bestvideo[ext=\(format)][height<=\(maxHeight)][vcodec^=avc]/best[ext=\(format)][height<=\(maxHeight)]" + } + return "bestvideo[height<=\(maxHeight)]/best[height<=\(maxHeight)]" + } } -// MARK: - Remux +// MARK: - Playback Normalization extension YouTubeWallpaperDataSourceImpl { - /// Remux DASH container to standard MP4 for AVPlayer compatibility (loop support). - private func remuxToStandardMP4(at path: String) async throws { - guard let ffmpeg = findExecutable("ffmpeg") else { return } - let tmpPath = path + ".remux.mp4" + /// Rewrites the downloaded file into an AVFoundation-playable MP4: AVC/HEVC are stream-copied + /// (cheap), while AV1/VP9 are hardware-transcoded to HEVC so every Mac can play the wallpaper. + /// A no-op when ffmpeg is unavailable (the raw DASH file is used as-is). + private func normalizeForPlayback(at path: String, ffmpeg: String?, ffprobe: String?) async throws { + guard let ffmpeg else { return } + + let codec = await videoCodec(at: path, ffprobe: ffprobe) + let needsTranscode = codec.map(Self.requiresTranscode) ?? false + let tmpPath = path + ".normalized.mp4" removeItemAtPath(tmpPath) - let (status, stderr) = try await processRunner( - ffmpeg, - ["-nostdin", "-y", "-i", path, "-c", "copy", "-movflags", "+faststart", tmpPath] - ) + + let arguments = + needsTranscode + ? Self.transcodeArguments(input: path, output: tmpPath) + : Self.remuxArguments(input: path, output: tmpPath) + let (status, _, stderr) = try await processRunner(ffmpeg, arguments) + guard status == 0 else { removeItemAtPath(tmpPath) - throw YouTubeDownloadError.remuxFailed(stderr: stderr) + throw needsTranscode + ? YouTubeDownloadError.transcodeFailed(stderr: stderr) + : YouTubeDownloadError.remuxFailed(stderr: stderr) } replaceItemAtPath(path, tmpPath) } + + /// The codec name of the first video stream, lowercased, or nil when ffprobe is unavailable or + /// detection fails (callers then default to a cheap stream copy). + func videoCodec(at path: String, ffprobe: String?) async -> String? { + guard let ffprobe else { return nil } + let arguments = [ + "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=codec_name", "-of", "default=nw=1:nokey=1", path, + ] + guard let result = try? await processRunner(ffprobe, arguments), result.status == 0 else { + return nil + } + let codec = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return codec.isEmpty ? nil : codec + } + + /// AVFoundation cannot decode AV1 (pre-M3) or VP9 (ever), so those must be transcoded. + static func requiresTranscode(_ codec: String) -> Bool { + ["av1", "av01", "vp9", "vp09"].contains(codec) + } + + static func remuxArguments(input: String, output: String) -> [String] { + ["-nostdin", "-y", "-i", input, "-c", "copy", "-movflags", "+faststart", output] + } + + static func transcodeArguments(input: String, output: String) -> [String] { + [ + "-nostdin", "-y", "-i", input, "-an", + "-c:v", "hevc_videotoolbox", "-tag:v", "hvc1", "-movflags", "+faststart", output, + ] + } } // MARK: - Async Process extension YouTubeWallpaperDataSourceImpl { - static func executeProcess(executablePath: String, arguments: [String]) async throws -> (status: Int32, stderr: String) { + static func executeProcess(executablePath: String, arguments: [String]) async throws -> (status: Int32, stdout: String, stderr: String) { try await withCheckedThrowingContinuation { continuation in let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments process.standardInput = FileHandle.nullDevice + let stdoutPipe = Pipe() let stderrPipe = Pipe() + process.standardOutput = stdoutPipe process.standardError = stderrPipe process.terminationHandler = { proc in - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stderrString = String(data: stderrData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - continuation.resume(returning: (proc.terminationStatus, stderrString)) + let stdoutString = Self.trimmedString(from: stdoutPipe) + let stderrString = Self.trimmedString(from: stderrPipe) + continuation.resume(returning: (proc.terminationStatus, stdoutString, stderrString)) } do { @@ -174,6 +238,11 @@ extension YouTubeWallpaperDataSourceImpl { } } } + + private static func trimmedString(from pipe: Pipe) -> String { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } } // MARK: - Errors @@ -183,6 +252,7 @@ public enum YouTubeDownloadError: Error, CustomStringConvertible { case downloadFailed(status: Int32, stderr: String) case outputNotFound case remuxFailed(stderr: String) + case transcodeFailed(stderr: String) public var description: String { switch self { @@ -194,6 +264,8 @@ public enum YouTubeDownloadError: Error, CustomStringConvertible { "yt-dlp completed but output file not found" case .remuxFailed(let stderr): "ffmpeg remux failed" + (stderr.isEmpty ? "" : "\n\(stderr)") + case .transcodeFailed(let stderr): + "ffmpeg HEVC transcode failed" + (stderr.isEmpty ? "" : "\n\(stderr)") } } } diff --git a/Tests/WallpaperDataSourceTests/YouTubeToolDetectionTests.swift b/Tests/WallpaperDataSourceTests/YouTubeToolDetectionTests.swift index 4cceeeb..b314113 100644 --- a/Tests/WallpaperDataSourceTests/YouTubeToolDetectionTests.swift +++ b/Tests/WallpaperDataSourceTests/YouTubeToolDetectionTests.swift @@ -136,7 +136,7 @@ struct YouTubeToolDetectionTests { let url = URL(string: "https://www.youtube.com/watch?v=test123")! let args = ds.buildArgs( tool: .ytdlp(path: "/usr/local/bin/yt-dlp"), - url: url, maxHeight: 1080, format: "mp4", destPath: "/tmp/out.mp4" + url: url, maxHeight: 1080, format: "mp4", destPath: "/tmp/out.mp4", allowAnyCodec: false ) #expect(args.contains("-f")) #expect(args.contains("--no-audio")) @@ -155,7 +155,7 @@ struct YouTubeToolDetectionTests { let url = URL(string: "https://youtu.be/abc")! let args = ds.buildArgs( tool: .uvx(path: "/opt/homebrew/bin/uvx"), - url: url, maxHeight: 2160, format: "mp4", destPath: "/tmp/out.mp4" + url: url, maxHeight: 2160, format: "mp4", destPath: "/tmp/out.mp4", allowAnyCodec: false ) #expect(args.first == "yt-dlp") } @@ -170,7 +170,7 @@ struct YouTubeToolDetectionTests { let url = URL(string: "https://youtu.be/test")! let args = ds.buildArgs( tool: .ytdlp(path: "/usr/bin/yt-dlp"), - url: url, maxHeight: 720, format: "webm", destPath: "/tmp/out.webm" + url: url, maxHeight: 720, format: "webm", destPath: "/tmp/out.webm", allowAnyCodec: false ) let formatArg = args.first { $0.contains("height<=") } #expect(formatArg?.contains("720") == true) diff --git a/Tests/WallpaperDataSourceTests/YouTubeWallpaperResolveTests.swift b/Tests/WallpaperDataSourceTests/YouTubeWallpaperResolveTests.swift index 41ab083..2ce3c08 100644 --- a/Tests/WallpaperDataSourceTests/YouTubeWallpaperResolveTests.swift +++ b/Tests/WallpaperDataSourceTests/YouTubeWallpaperResolveTests.swift @@ -29,7 +29,7 @@ struct YouTubeWallpaperResolveTests { @Test("resolve throws downloadFailed when downloader exits non-zero") func resolveDownloadFailed() async { - let runner = ProcessRunner(results: [(1, "download failed")]) + let runner = ProcessRunner(results: [(1, "", "download failed")]) let dataSource = makeDataSource( gateway: StubGateway(executables: ["yt-dlp": "/usr/bin/yt-dlp"]), runner: runner, @@ -53,7 +53,7 @@ struct YouTubeWallpaperResolveTests { @Test("resolve throws outputNotFound when downloader succeeds without file") func resolveOutputMissing() async { - let runner = ProcessRunner(results: [(0, "")]) + let runner = ProcessRunner(results: [(0, "", "")]) let dataSource = makeDataSource( gateway: StubGateway(executables: ["yt-dlp": "/usr/bin/yt-dlp"]), runner: runner, @@ -75,7 +75,7 @@ struct YouTubeWallpaperResolveTests { @Test("resolve returns temp path when download succeeds and ffmpeg is unavailable") func resolveSuccessWithoutRemux() async throws { - let runner = ProcessRunner(results: [(0, "")]) + let runner = ProcessRunner(results: [(0, "", "")]) let dataSource = makeDataSource( gateway: StubGateway(executables: ["yt-dlp": "/usr/bin/yt-dlp"]), runner: runner, @@ -89,11 +89,14 @@ struct YouTubeWallpaperResolveTests { #expect(calls.count == 1) #expect(calls[0].executablePath == "/usr/bin/yt-dlp") #expect(calls[0].arguments.contains(location.url.absoluteString)) + // Without a transcode toolchain the AVC ceiling selector is used (natively playable). + #expect(calls[0].arguments.contains { $0.contains("vcodec^=avc") }) + #expect(calls[0].arguments.contains("youtube:player_client=default")) } @Test("resolve uses uvx fallback when yt-dlp is missing") func resolveUsesUvx() async throws { - let runner = ProcessRunner(results: [(0, "")]) + let runner = ProcessRunner(results: [(0, "", "")]) let dataSource = makeDataSource( gateway: StubGateway(executables: ["uvx": "/usr/bin/uvx"]), runner: runner, @@ -108,9 +111,10 @@ struct YouTubeWallpaperResolveTests { #expect(calls[0].arguments.first == "yt-dlp") } - @Test("resolve throws remuxFailed when ffmpeg step exits non-zero") + @Test("resolve throws remuxFailed when ffmpeg copy step exits non-zero") func resolveRemuxFailed() async { - let runner = ProcessRunner(results: [(0, ""), (1, "remux failed")]) + // ffmpeg present but ffprobe absent → no transcode capability → AVC selector + stream-copy. + let runner = ProcessRunner(results: [(0, "", ""), (1, "", "remux failed")]) let dataSource = makeDataSource( gateway: StubGateway(executables: ["yt-dlp": "/usr/bin/yt-dlp", "ffmpeg": "/usr/bin/ffmpeg"]), runner: runner, @@ -134,6 +138,80 @@ struct YouTubeWallpaperResolveTests { #expect(calls.count == 2) #expect(calls[1].executablePath == "/usr/bin/ffmpeg") #expect(calls[1].arguments.first == "-nostdin") + #expect(calls[1].arguments.contains("copy")) + } + + @Test("resolve transcodes AV1 to HEVC when ffmpeg and ffprobe are available") + func resolveTranscodesAV1() async throws { + // download → ffprobe (reports av1) → ffmpeg HEVC transcode + let runner = ProcessRunner(results: [(0, "", ""), (0, "av1", ""), (0, "", "")]) + let dataSource = makeDataSource( + gateway: StubGateway(executables: [ + "yt-dlp": "/usr/bin/yt-dlp", "ffmpeg": "/usr/bin/ffmpeg", "ffprobe": "/usr/bin/ffprobe", + ]), + runner: runner, + fileExists: true + ) + + let result = try await dataSource.resolve(location) + let calls = await runner.calls + + #expect(result == tempPath) + #expect(calls.count == 3) + // Download requests the highest-quality (codec-agnostic) selector — no AVC restriction. + #expect(!calls[0].arguments.contains { $0.contains("vcodec^=avc") }) + // Codec probe. + #expect(calls[1].executablePath == "/usr/bin/ffprobe") + #expect(calls[1].arguments.contains("stream=codec_name")) + // HEVC hardware transcode. + #expect(calls[2].executablePath == "/usr/bin/ffmpeg") + #expect(calls[2].arguments.contains("hevc_videotoolbox")) + #expect(calls[2].arguments.contains("hvc1")) + } + + @Test("resolve stream-copies AVC/HEVC instead of transcoding") + func resolveCopiesH264() async throws { + let runner = ProcessRunner(results: [(0, "", ""), (0, "h264", ""), (0, "", "")]) + let dataSource = makeDataSource( + gateway: StubGateway(executables: [ + "yt-dlp": "/usr/bin/yt-dlp", "ffmpeg": "/usr/bin/ffmpeg", "ffprobe": "/usr/bin/ffprobe", + ]), + runner: runner, + fileExists: true + ) + + _ = try await dataSource.resolve(location) + let calls = await runner.calls + + #expect(calls.count == 3) + #expect(calls[2].executablePath == "/usr/bin/ffmpeg") + #expect(calls[2].arguments.contains("copy")) + #expect(!calls[2].arguments.contains("hevc_videotoolbox")) + } + + @Test("resolve throws transcodeFailed when the HEVC step exits non-zero") + func resolveTranscodeFailed() async { + let runner = ProcessRunner(results: [(0, "", ""), (0, "vp9", ""), (1, "", "gpu busy")]) + let dataSource = makeDataSource( + gateway: StubGateway(executables: [ + "yt-dlp": "/usr/bin/yt-dlp", "ffmpeg": "/usr/bin/ffmpeg", "ffprobe": "/usr/bin/ffprobe", + ]), + runner: runner, + fileExists: true + ) + + do { + _ = try await dataSource.resolve(location) + Issue.record("Expected transcodeFailed") + } catch let error as YouTubeDownloadError { + guard case .transcodeFailed(let stderr) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(stderr == "gpu busy") + } catch { + Issue.record("Unexpected error: \(error)") + } } @Test("public init helper closures execute successfully") @@ -166,14 +244,15 @@ struct YouTubeWallpaperResolveTests { #expect(fm.fileExists(atPath: originalPath)) } - @Test("executeProcess returns status and captured stderr") - func executeProcessCapturesStderr() async throws { + @Test("executeProcess captures status, stdout, and stderr") + func executeProcessCapturesStreams() async throws { let result = try await YouTubeWallpaperDataSourceImpl.executeProcess( executablePath: "/bin/sh", - arguments: ["-c", "echo boom 1>&2; exit 7"] + arguments: ["-c", "echo hello; echo boom 1>&2; exit 7"] ) #expect(result.status == 7) + #expect(result.stdout == "hello") #expect(result.stderr == "boom") } @@ -193,6 +272,60 @@ struct YouTubeWallpaperResolveTests { #expect(YouTubeDownloadError.downloadFailed(status: 7, stderr: "boom").description == "yt-dlp exited with status 7\nboom") #expect(YouTubeDownloadError.outputNotFound.description == "yt-dlp completed but output file not found") #expect(YouTubeDownloadError.remuxFailed(stderr: "bad mux").description == "ffmpeg remux failed\nbad mux") + #expect(YouTubeDownloadError.transcodeFailed(stderr: "no gpu").description == "ffmpeg HEVC transcode failed\nno gpu") + } +} + +@Suite("YouTubeWallpaperDataSourceImpl format selection and normalization") +struct YouTubeWallpaperFormatTests { + private let dataSource = YouTubeWallpaperDataSourceImpl() + + @Test("format selector keeps the AVC ceiling when transcoding is unavailable") + func selectorAVCWhenNoTranscode() { + let selector = dataSource.formatSelector(maxHeight: 2160, format: "mp4", allowAnyCodec: false) + #expect(selector.contains("vcodec^=avc")) + #expect(selector.contains("ext=mp4")) + #expect(selector.contains("height<=2160")) + } + + @Test("format selector drops codec restrictions when transcoding is available") + func selectorAnyCodecWhenTranscode() { + let selector = dataSource.formatSelector(maxHeight: 2160, format: "mp4", allowAnyCodec: true) + #expect(!selector.contains("vcodec")) + #expect(!selector.contains("ext=")) + #expect(selector.contains("bestvideo[height<=2160]")) + } + + @Test("requiresTranscode flags only AV1 and VP9 families") + func requiresTranscodeMatrix() { + #expect(YouTubeWallpaperDataSourceImpl.requiresTranscode("av1")) + #expect(YouTubeWallpaperDataSourceImpl.requiresTranscode("av01")) + #expect(YouTubeWallpaperDataSourceImpl.requiresTranscode("vp9")) + #expect(YouTubeWallpaperDataSourceImpl.requiresTranscode("vp09")) + #expect(!YouTubeWallpaperDataSourceImpl.requiresTranscode("h264")) + #expect(!YouTubeWallpaperDataSourceImpl.requiresTranscode("hevc")) + } + + @Test("ffmpeg argument builders produce playback-ready MP4 commands") + func ffmpegArgumentBuilders() { + let remux = YouTubeWallpaperDataSourceImpl.remuxArguments(input: "/in.mp4", output: "/out.mp4") + #expect(remux.first == "-nostdin") + #expect(remux.contains("copy")) + #expect(remux.contains("+faststart")) + #expect(remux.last == "/out.mp4") + + let transcode = YouTubeWallpaperDataSourceImpl.transcodeArguments(input: "/in.mp4", output: "/out.mp4") + #expect(transcode.contains("hevc_videotoolbox")) + #expect(transcode.contains("hvc1")) + #expect(transcode.contains("-an")) + #expect(transcode.contains("+faststart")) + #expect(transcode.last == "/out.mp4") + } + + @Test("videoCodec returns nil when ffprobe is unavailable") + func videoCodecNilWithoutFfprobe() async { + let codec = await dataSource.videoCodec(at: "/tmp/whatever.mp4", ffprobe: nil) + #expect(codec == nil) } } @@ -238,7 +371,7 @@ private struct StubGateway: ProcessGateway { } private actor ProcessRunner { - typealias ResultTuple = (status: Int32, stderr: String) + typealias ResultTuple = (status: Int32, stdout: String, stderr: String) private(set) var calls: [(executablePath: String, arguments: [String])] = [] private var results: [ResultTuple] @@ -251,7 +384,7 @@ private actor ProcessRunner { calls.append((executablePath, arguments)) guard !results.isEmpty else { Issue.record("ProcessRunner.run called more times than stubbed results for \(executablePath) \(arguments)") - return (status: 1, stderr: "No stubbed process result available.") + return (status: 1, stdout: "", stderr: "No stubbed process result available.") } return results.removeFirst() } From f52d834c8f60bbdf060c17a0b3e41ea462135838 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Wed, 17 Jun 2026 00:36:43 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs(#292):=20YouTube=E7=94=BB=E8=B3=AA/?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=87=E3=83=83=E3=82=AF=E4=BA=92=E6=8F=9B?= =?UTF-8?q?=E3=81=AE=E8=A8=AD=E8=A8=88=E5=88=A4=E6=96=AD=E3=82=92CLAUDE.md?= =?UTF-8?q?=E3=83=BBREADME=E3=81=AB=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 4 +++- README.md | 13 +++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 43893b1..4388e16 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -242,12 +242,14 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via - `LocalWallpaperDataSourceImpl: WallpaperDataSource` — relative/absolute path resolution via Files library - `RemoteWallpaperDataSourceImpl: WallpaperDataSource` — HTTP(S) download with SHA256-keyed cache -- `YouTubeWallpaperDataSourceImpl: WallpaperDataSource` — yt-dlp/uvx download with H.264/AVC codec, SHA256-keyed cache +- `YouTubeWallpaperDataSourceImpl: WallpaperDataSource` — yt-dlp/uvx download, highest-quality video-only stream with HEVC transcode fallback (see Key Design Decisions), SHA256-keyed cache **WallpaperRepository URL classification**: Repository classifies wallpaper config string and dispatches to the appropriate DataSource. Priority: local path (no scheme) → YouTube URL (host contains youtube.com/youtu.be) → remote HTTP(S) URL. All paths converge to a local file path string. **Wallpaper cache**: `~/.cache/lyra/wallpapers/SHA256(url).{ext}`. Cache is permanent (wallpapers are reused). `WallpaperCache` helper shared by Remote and YouTube DataSources. +**YouTube highest-quality download + codec compatibility (#292)**: YouTube only serves H.264/AVC up to 1080p; 1440p/4K exist solely as VP9 or AV1. The old `player_client=android` selector is now crippled by YouTube SABR streaming — it skips every video-only format and leaves only the combined 360p (the "sometimes terrible quality" bug). The fix uses `player_client=default` (the web client), which publishes the full https DASH ladder at every resolution with no PO Token required. But raising the ceiling exposes a playback wall: **AVFoundation cannot decode AV1 on pre-M3 Apple Silicon or Intel Macs (`VTIsHardwareDecodeSupported(AV1)=false`), and never decodes VP9/WebM at all** — empirically confirmed on M1 Max. So `YouTubeWallpaperDataSourceImpl` gates the codec-agnostic `bestvideo[height<=maxHeight]` selector on a *transcode capability* check (`ffmpeg` **and** `ffprobe` both present): with the toolchain it grabs the best 4K stream and, after download, `ffprobe` detects the codec — AVC/HEVC are stream-copied (`-c copy`, cheap), while AV1/VP9 are hardware-transcoded to HEVC (`-c:v hevc_videotoolbox -tag:v hvc1`, ~4x realtime, one-time per wallpaper before the SHA256 cache fills). Without the toolchain it falls back to the AVC-only selector (natively playable, 1080p ceiling). `processRunner` returns `(status, stdout, stderr)` because codec detection needs `ffprobe`'s stdout. + **Wallpaper async resolution**: `WallpaperPresenter.start()` consumes `WallpaperInteractor.resolvedWallpapers()` — an `AsyncStream` — in a background Task, starting playback the moment the first item arrives. `WallpaperPresenter` also manages AVPlayer lifecycle (create, seek, loop, pause/play) and owns sleep/wake monitoring via `observeSleepWake()`. **Multi-wallpaper playback**: `WallpaperStyle` is a list of `WallpaperItem` with a `WallpaperPlaybackMode` (`.cycle` or `.shuffle`). Config accepts a bare string, a legacy `[wallpaper]` table, or an array-of-tables `[[wallpaper.items]]` with optional `mode`; each item can specify optional trim (`start`/`end`) and per-item `scale`. `WallpaperInteractorImpl` resolves every item in parallel via a `TaskGroup`. In cycle mode it buffers completions and yields in configured order (skipping items that fail), so playback order is deterministic even when downloads finish out of order. In shuffle mode it yields items as they complete, so playback starts with whichever item resolves first. `WallpaperPresenter` advances to the next item on `AVPlayerItemDidPlayToEndTime` (or when the end-trim boundary is reached) when `items.count > 1`; `nextIndex(from:)` dispatches on mode — cycle uses `(current + 1) % count`, shuffle picks via `RandomSource.next(below:)` from indices excluding the current item. diff --git a/README.md b/README.md index 5da288e..b2766d2 100644 --- a/README.md +++ b/README.md @@ -256,11 +256,16 @@ Remote and YouTube videos are downloaded once and cached in `~/.cache/lyra/wallp | Tool | Install | Notes | |---|---|---| -| `yt-dlp` | `brew install yt-dlp` | Preferred. Downloads video-only H.264 at up to 4K | +| `yt-dlp` | `brew install yt-dlp` | Preferred. Downloads the highest-quality video-only stream, up to 4K | | `uvx` | `brew install uv` | Zero-install alternative — runs `uvx yt-dlp` without global install | -| `ffmpeg` | `brew install ffmpeg` | Required for auto-loop. Remuxes DASH container to standard MP4 | - -If neither `yt-dlp` nor `uvx` is found, lyra will show an error. If `ffmpeg` is not found, the video plays but may not loop automatically. +| `ffmpeg` + `ffprobe` | `brew install ffmpeg` | Unlocks 4K. Remuxes DASH to MP4 and transcodes AV1/VP9 to HEVC so any Mac can play it | + +If neither `yt-dlp` nor `uvx` is found, lyra will show an error. The maximum +quality depends on `ffmpeg`/`ffprobe`: with them installed, lyra downloads the +best stream (4K VP9/AV1) and transcodes non-natively-playable codecs to HEVC so +every Mac (including pre-M3 Apple Silicon and Intel, which cannot decode AV1) +can play it. Without them, lyra falls back to the natively-playable H.264 +ceiling (1080p) and the video may not loop automatically. **Trim playback range** (optional): From 4dd029b9219487c74e23dc91205d9beca669c566 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Wed, 17 Jun 2026 00:36:43 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore(#292):=20=E3=83=90=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=922.16.0=E3=81=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/VersionHandler/Resources/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 3b1fc79..7524906 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.15.1 +2.16.0 From 5ad7bac3b936adc2915d304fbca32ef13bb59696 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Wed, 17 Jun 2026 03:08:00 +0900 Subject: [PATCH 4/5] fix(#292): prevent pipe-buffer deadlock in executeProcess and clarify README toolchain rows --- README.md | 17 ++++--- .../YouTubeWallpaperDataSourceImpl.swift | 51 +++++++++++++++---- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b2766d2..2761bdb 100644 --- a/README.md +++ b/README.md @@ -258,14 +258,15 @@ Remote and YouTube videos are downloaded once and cached in `~/.cache/lyra/wallp |---|---|---| | `yt-dlp` | `brew install yt-dlp` | Preferred. Downloads the highest-quality video-only stream, up to 4K | | `uvx` | `brew install uv` | Zero-install alternative — runs `uvx yt-dlp` without global install | -| `ffmpeg` + `ffprobe` | `brew install ffmpeg` | Unlocks 4K. Remuxes DASH to MP4 and transcodes AV1/VP9 to HEVC so any Mac can play it | - -If neither `yt-dlp` nor `uvx` is found, lyra will show an error. The maximum -quality depends on `ffmpeg`/`ffprobe`: with them installed, lyra downloads the -best stream (4K VP9/AV1) and transcodes non-natively-playable codecs to HEVC so -every Mac (including pre-M3 Apple Silicon and Intel, which cannot decode AV1) -can play it. Without them, lyra falls back to the natively-playable H.264 -ceiling (1080p) and the video may not loop automatically. +| `ffmpeg` | `brew install ffmpeg` | Remuxes DASH to MP4 and adds `+faststart` for seamless looping (1080p H.264 ceiling) | +| `ffprobe` | included with `brew install ffmpeg` | Unlocks 4K: detects codec and transcodes AV1/VP9 → HEVC for pre-M3 Apple Silicon and Intel | + +If neither `yt-dlp` nor `uvx` is found, lyra will show an error. `ffmpeg` alone +enables DASH-to-MP4 remuxing for seamless looping at the H.264 1080p ceiling. +Pair it with `ffprobe` (included in `brew install ffmpeg`) to unlock 4K: lyra +then downloads the best VP9/AV1 stream and hardware-transcodes non-natively-playable +codecs to HEVC so every Mac — including pre-M3 Apple Silicon and Intel — can play +it. Without `ffmpeg`, lyra downloads a direct H.264 stream that may not loop automatically. **Trim playback range** (optional): diff --git a/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift b/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift index afd0dd1..97daa44 100644 --- a/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift +++ b/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift @@ -225,23 +225,56 @@ extension YouTubeWallpaperDataSourceImpl { process.standardOutput = stdoutPipe process.standardError = stderrPipe - process.terminationHandler = { proc in - let stdoutString = Self.trimmedString(from: stdoutPipe) - let stderrString = Self.trimmedString(from: stderrPipe) - continuation.resume(returning: (proc.terminationStatus, stdoutString, stderrString)) - } - do { try process.run() } catch { continuation.resume(throwing: error) + return + } + + // Drain both pipes concurrently on background threads so that neither fills + // its OS pipe buffer and deadlocks the child process. ffmpeg in particular + // streams extensive progress output to stderr throughout a transcode, which + // can easily exceed the ~64 KB pipe buffer before the process exits. + let buffer = PipeBuffer() + let group = DispatchGroup() + + group.enter() + DispatchQueue.global().async { + buffer.stdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + + group.enter() + DispatchQueue.global().async { + buffer.stderr = stderrPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + + group.notify(queue: .global()) { + // Both pipes have drained (EOF received → child has exited), but NSTask's + // internal SIGCHLD processing may not have run yet. waitUntilExit() ensures + // terminationStatus is valid before we read it. + process.waitUntilExit() + continuation.resume( + returning: (process.terminationStatus, buffer.stdoutTrimmed, buffer.stderrTrimmed)) } } } +} + +/// Accumulates stdout and stderr bytes from concurrent pipe-drain tasks. +/// Marked `@unchecked Sendable` because each stored property is written by exactly +/// one DispatchQueue task and read only after the DispatchGroup barrier — no lock needed. +private final class PipeBuffer: @unchecked Sendable { + var stdout = Data() + var stderr = Data() + + var stdoutTrimmed: String { trimmed(stdout) } + var stderrTrimmed: String { trimmed(stderr) } - private static func trimmedString(from pipe: Pipe) -> String { - let data = pipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + private func trimmed(_ data: Data) -> String { + String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } } From 9ef432fce5e51d0d90fbc152b7114d31a131fc5a Mon Sep 17 00:00:00 2001 From: GeneralD Date: Wed, 17 Jun 2026 03:42:50 +0900 Subject: [PATCH 5/5] fix(#292): transcode on ffprobe probe failure to preserve every-Mac playback --- .../YouTubeWallpaperDataSourceImpl.swift | 15 ++++++- .../YouTubeWallpaperResolveTests.swift | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift b/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift index 97daa44..a5b7e89 100644 --- a/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift +++ b/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift @@ -159,8 +159,7 @@ extension YouTubeWallpaperDataSourceImpl { private func normalizeForPlayback(at path: String, ffmpeg: String?, ffprobe: String?) async throws { guard let ffmpeg else { return } - let codec = await videoCodec(at: path, ffprobe: ffprobe) - let needsTranscode = codec.map(Self.requiresTranscode) ?? false + let needsTranscode = await self.needsTranscode(at: path, ffprobe: ffprobe) let tmpPath = path + ".normalized.mp4" removeItemAtPath(tmpPath) @@ -179,6 +178,18 @@ extension YouTubeWallpaperDataSourceImpl { replaceItemAtPath(path, tmpPath) } + /// Whether the downloaded file must be hardware-transcoded to HEVC for universal playback. + /// Without `ffprobe` we only ever requested the natively-playable AVC stream, so a stream + /// copy is safe. With `ffprobe` available we transcode AV1/VP9 — and, crucially, also + /// transcode when codec detection itself *fails*: the `allowAnyCodec` download path may have + /// produced an AV1/VP9 file, and defaulting a probe failure to a stream copy would silently + /// leave it unplayable on pre-M3 Apple Silicon / Intel Macs, defeating the every-Mac guarantee. + func needsTranscode(at path: String, ffprobe: String?) async -> Bool { + guard ffprobe != nil else { return false } + let codec = await videoCodec(at: path, ffprobe: ffprobe) + return codec.map(Self.requiresTranscode) ?? true + } + /// The codec name of the first video stream, lowercased, or nil when ffprobe is unavailable or /// detection fails (callers then default to a cheap stream copy). func videoCodec(at path: String, ffprobe: String?) async -> String? { diff --git a/Tests/WallpaperDataSourceTests/YouTubeWallpaperResolveTests.swift b/Tests/WallpaperDataSourceTests/YouTubeWallpaperResolveTests.swift index 2ce3c08..32aae9e 100644 --- a/Tests/WallpaperDataSourceTests/YouTubeWallpaperResolveTests.swift +++ b/Tests/WallpaperDataSourceTests/YouTubeWallpaperResolveTests.swift @@ -214,6 +214,29 @@ struct YouTubeWallpaperResolveTests { } } + @Test("resolve transcodes when ffprobe was expected but codec detection fails") + func resolveTranscodesOnProbeFailure() async throws { + // download → ffprobe FAILS (non-zero) → must still HEVC-transcode, never stream-copy: + // the codec-agnostic download path may have produced an AV1/VP9 file that a copy would + // leave unplayable on pre-M3 / Intel Macs. + let runner = ProcessRunner(results: [(0, "", ""), (1, "", "probe error"), (0, "", "")]) + let dataSource = makeDataSource( + gateway: StubGateway(executables: [ + "yt-dlp": "/usr/bin/yt-dlp", "ffmpeg": "/usr/bin/ffmpeg", "ffprobe": "/usr/bin/ffprobe", + ]), + runner: runner, + fileExists: true + ) + + _ = try await dataSource.resolve(location) + let calls = await runner.calls + + #expect(calls.count == 3) + #expect(calls[2].executablePath == "/usr/bin/ffmpeg") + #expect(calls[2].arguments.contains("hevc_videotoolbox")) + #expect(!calls[2].arguments.contains("copy")) + } + @Test("public init helper closures execute successfully") func publicInitHelpers() async throws { let dataSource = YouTubeWallpaperDataSourceImpl() @@ -327,6 +350,26 @@ struct YouTubeWallpaperFormatTests { let codec = await dataSource.videoCodec(at: "/tmp/whatever.mp4", ffprobe: nil) #expect(codec == nil) } + + @Test("needsTranscode is false without ffprobe (AVC-only download is always playable)") + func needsTranscodeFalseWithoutFfprobe() async { + let needs = await dataSource.needsTranscode(at: "/tmp/whatever.mp4", ffprobe: nil) + #expect(needs == false) + } + + @Test("needsTranscode is true when ffprobe was expected but the probe fails") + func needsTranscodeTrueOnProbeFailure() async { + // A probe failure in the codec-agnostic download path cannot rule out AV1/VP9, so the + // file must be transcoded rather than risk an unplayable stream copy. + let runner = ProcessRunner(results: [(1, "", "probe error")]) + let dataSource = makeDataSource( + gateway: StubGateway(executables: ["ffprobe": "/usr/bin/ffprobe"]), + runner: runner, + fileExists: true + ) + let needs = await dataSource.needsTranscode(at: "/tmp/whatever.mp4", ffprobe: "/usr/bin/ffprobe") + #expect(needs == true) + } } private func makeDataSource(