mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
182 lines
6.6 KiB
Swift
182 lines
6.6 KiB
Swift
//
|
|
// Storyboard.swift
|
|
// Yattee
|
|
//
|
|
// Represents storyboard sprite sheet data for video preview thumbnails.
|
|
//
|
|
|
|
import CoreGraphics
|
|
import Foundation
|
|
|
|
/// Represents a storyboard (sprite sheet) for video preview thumbnails.
|
|
struct Storyboard: Hashable, Sendable, Codable {
|
|
/// Proxied URL path (e.g., /api/v1/storyboards/VIDEO_ID?width=160)
|
|
/// This is the preferred URL as it goes through the instance proxy
|
|
let proxyUrl: String?
|
|
|
|
/// URL template for sprite sheets (contains M$M placeholder for sheet index)
|
|
/// This is the direct YouTube URL which may be blocked
|
|
let templateUrl: String
|
|
|
|
/// Base URL of the instance (used to make proxyUrl absolute)
|
|
let instanceBaseURL: URL?
|
|
|
|
/// Width of each thumbnail in the grid
|
|
let width: Int
|
|
|
|
/// Height of each thumbnail in the grid
|
|
let height: Int
|
|
|
|
/// Total number of thumbnails across all sheets
|
|
let count: Int
|
|
|
|
/// Milliseconds between each thumbnail
|
|
let interval: Int
|
|
|
|
/// Number of columns in each sprite sheet grid
|
|
let storyboardWidth: Int
|
|
|
|
/// Number of rows in each sprite sheet grid
|
|
let storyboardHeight: Int
|
|
|
|
/// Total number of sprite sheet images
|
|
let storyboardCount: Int
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// Number of thumbnails per sprite sheet
|
|
var thumbnailsPerSheet: Int {
|
|
storyboardWidth * storyboardHeight
|
|
}
|
|
|
|
/// Interval between thumbnails in seconds
|
|
var intervalSeconds: TimeInterval {
|
|
TimeInterval(interval) / 1000.0
|
|
}
|
|
|
|
// MARK: - Methods
|
|
|
|
/// Returns the URL for a specific sprite sheet index.
|
|
/// Prefers proxied URL (goes through instance) over direct YouTube URL.
|
|
/// - Parameter index: The sheet index (0-based)
|
|
/// - Returns: URL for the sprite sheet, or nil if invalid
|
|
func sheetURL(for index: Int) -> URL? {
|
|
guard index >= 0, index < storyboardCount else { return nil }
|
|
|
|
// Prefer proxied URL if available (goes through instance, not blocked)
|
|
if let proxyUrl {
|
|
// The proxy URL returns the full sprite sheet, append index parameter
|
|
var urlString = proxyUrl
|
|
if urlString.contains("?") {
|
|
urlString += "&storyboard=\(index)"
|
|
} else {
|
|
urlString += "?storyboard=\(index)"
|
|
}
|
|
|
|
// Check if proxyUrl is already an absolute URL
|
|
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
|
return URL(string: urlString)
|
|
}
|
|
|
|
// Construct absolute URL from relative path (relative URLs don't work with URLSession)
|
|
if let baseURL = instanceBaseURL {
|
|
var baseString = baseURL.absoluteString
|
|
if baseString.hasSuffix("/") && urlString.hasPrefix("/") {
|
|
baseString = String(baseString.dropLast())
|
|
}
|
|
let absoluteURLString = baseString + urlString
|
|
return URL(string: absoluteURLString)
|
|
}
|
|
}
|
|
|
|
// Fallback to templateUrl (direct YouTube URL, may be blocked)
|
|
return directSheetURL(for: index)
|
|
}
|
|
|
|
/// Returns the direct URL for a specific sprite sheet index.
|
|
/// Uses templateUrl directly, bypassing the proxy. Use for downloads.
|
|
/// - Parameter index: The sheet index (0-based)
|
|
/// - Returns: Direct URL for the sprite sheet, or nil if invalid
|
|
func directSheetURL(for index: Int) -> URL? {
|
|
guard index >= 0, index < storyboardCount else { return nil }
|
|
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "\(index)")
|
|
return URL(string: urlString)
|
|
}
|
|
|
|
/// Calculates the position of a thumbnail for a given timestamp.
|
|
/// - Parameter time: The time in seconds
|
|
/// - Returns: Tuple of (sheetIndex, row, column), or nil if time is out of range
|
|
func position(for time: TimeInterval) -> (sheetIndex: Int, row: Int, column: Int)? {
|
|
guard time >= 0, intervalSeconds > 0 else { return nil }
|
|
|
|
let thumbnailIndex = Int(time / intervalSeconds)
|
|
guard thumbnailIndex < count else { return nil }
|
|
|
|
let sheetIndex = thumbnailIndex / thumbnailsPerSheet
|
|
let positionInSheet = thumbnailIndex % thumbnailsPerSheet
|
|
let row = positionInSheet / storyboardWidth
|
|
let column = positionInSheet % storyboardWidth
|
|
|
|
return (sheetIndex, row, column)
|
|
}
|
|
|
|
/// Calculates the crop rect for extracting a thumbnail at the given time.
|
|
/// - Parameter time: The time in seconds
|
|
/// - Returns: CGRect for cropping, or nil if time is out of range
|
|
func cropRect(for time: TimeInterval) -> CGRect? {
|
|
guard let position = position(for: time) else { return nil }
|
|
|
|
return CGRect(
|
|
x: CGFloat(position.column * width),
|
|
y: CGFloat(position.row * height),
|
|
width: CGFloat(width),
|
|
height: CGFloat(height)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Local Storyboard Support
|
|
|
|
extension Storyboard {
|
|
/// Creates a Storyboard configured for local file access.
|
|
/// - Parameters:
|
|
/// - original: The original storyboard with metadata
|
|
/// - localDirectory: The directory URL containing downloaded sprite sheets
|
|
/// - Returns: A new Storyboard with file:// URLs for local access
|
|
static func localStoryboard(from original: Storyboard, localDirectory: URL) -> Storyboard {
|
|
// Create template URL pointing to local files: sb_M$M.jpg
|
|
let templatePath = localDirectory.appendingPathComponent("sb_M$M.jpg").absoluteString
|
|
|
|
return Storyboard(
|
|
proxyUrl: nil, // No proxy for local files
|
|
templateUrl: templatePath,
|
|
instanceBaseURL: nil,
|
|
width: original.width,
|
|
height: original.height,
|
|
count: original.count,
|
|
interval: original.interval,
|
|
storyboardWidth: original.storyboardWidth,
|
|
storyboardHeight: original.storyboardHeight,
|
|
storyboardCount: original.storyboardCount
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Storyboard Selection
|
|
|
|
extension Array where Element == Storyboard {
|
|
/// Selects the preferred storyboard based on desired width.
|
|
/// Prefers the largest storyboard that doesn't exceed maxWidth.
|
|
/// - Parameter maxWidth: Maximum preferred width (default 160)
|
|
/// - Returns: Best matching storyboard, or nil if array is empty
|
|
func preferred(maxWidth: Int = 160) -> Storyboard? {
|
|
let suitable = filter { $0.width <= maxWidth }
|
|
return suitable.max(by: { $0.width < $1.width }) ?? first
|
|
}
|
|
|
|
/// Returns the highest quality storyboard (largest width).
|
|
func highest() -> Storyboard? {
|
|
self.max(by: { $0.width < $1.width })
|
|
}
|
|
}
|