Last active
December 4, 2025 07:00
-
-
Save Koshimizu-Takehito/f4cc94a1ab6dec7cd8b111a6509f5b6d to your computer and use it in GitHub Desktop.
CRTEffect
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import AVFoundation | |
| import CoreImage | |
| import SwiftUI | |
| struct ContentView: View { | |
| @State private var crtEffect = true | |
| @State private var start = Date.now | |
| @State private var provider = VideoImageProvider( | |
| url: URL(string: "https://devstreaming-cdn.apple.com/videos/wwdc/2025/219/4/476dabc8-f3bb-4190-8929-4ba7131c939d/downloads/wwdc2025-219_hd.mp4")! | |
| ) | |
| var body: some View { | |
| TimelineView(.animation, content: screen(context:)) | |
| .ignoresSafeArea() | |
| .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| .overlay(content: control) | |
| .onAppear(perform: provider.resume) | |
| .onDisappear(perform: provider.pause) | |
| .tint(.blue) | |
| } | |
| @ViewBuilder | |
| private func screen(context: TimelineViewDefaultContext) -> some View { | |
| if let image = provider.image { | |
| Image(decorative: image, scale: 1) | |
| .resizable() | |
| .scaledToFit() | |
| .layerEffect(shader(context.date), maxSampleOffset: .zero) | |
| } | |
| } | |
| @ViewBuilder | |
| private func control() -> some View { | |
| HStack { | |
| let buttonName = provider.isPaused ? "play" : "pause" | |
| Button(buttonName.capitalized, systemImage: buttonName, action: provider.toggle) | |
| Spacer() | |
| Toggle("CRT Effect", isOn: $crtEffect.animation()) | |
| .fixedSize() | |
| .shadow(color: .black, radius: 2) | |
| } | |
| .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) | |
| .padding() | |
| .fontWeight(.black) | |
| .buttonStyle(.glass) | |
| } | |
| private func shader(_ date: Date) -> Shader { | |
| let time = date.timeIntervalSince(start) | |
| .truncatingRemainder(dividingBy: 2.0 * .pi) | |
| let function = crtEffect ? ShaderLibrary.crtEffect : ShaderLibrary.id | |
| return function(.float(10 * time), .boundingRect) | |
| } | |
| } | |
| @MainActor | |
| @Observable | |
| final class VideoImageProvider { | |
| private(set) var image: CGImage? | |
| private(set) var isPaused: Bool = false | |
| private let context = CIContext() | |
| private let url: URL | |
| @ObservationIgnored private var player: AVPlayer? | |
| @ObservationIgnored private var output: AVPlayerItemVideoOutput! | |
| @ObservationIgnored private var observer: NSKeyValueObservation? | |
| @ObservationIgnored private lazy var displayLink = CADisplayLink( | |
| target: self, | |
| selector: #selector(copyPixelBuffers(link:)) | |
| ) | |
| init(url: URL) { | |
| self.url = url | |
| } | |
| isolated deinit { | |
| player?.pause() | |
| observer?.invalidate() | |
| } | |
| func pause() { | |
| player?.pause() | |
| isPaused = true | |
| } | |
| func resume() { | |
| guard let player else { | |
| return start() | |
| } | |
| player.play() | |
| isPaused = false | |
| } | |
| func toggle() { | |
| isPaused ? resume() : pause() | |
| } | |
| private func start() { | |
| let item = AVPlayerItem(url: url) | |
| player = AVPlayer(playerItem: item) | |
| let output = AVPlayerItemVideoOutput(outputSettings: [ | |
| AVVideoAllowWideColorKey: true, | |
| AVVideoColorPropertiesKey: [ | |
| AVVideoColorPrimariesKey: AVVideoColorPrimaries_P3_D65, | |
| AVVideoTransferFunctionKey: AVVideoTransferFunction_Linear, | |
| AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_2020, | |
| ], | |
| kCVPixelBufferPixelFormatTypeKey as String: NSNumber( | |
| value: kCVPixelFormatType_64RGBAHalf | |
| ), | |
| ]) | |
| self.output = output | |
| self.observer = item.observe(\.status, options: [.new, .old], changeHandler: { item, _ in | |
| guard item.status == .readyToPlay else { | |
| return | |
| } | |
| item.add(output) | |
| MainActor.assumeIsolated { | |
| self.displayLink.add(to: .main, forMode: .common) | |
| self.resume() | |
| } | |
| }) | |
| } | |
| @objc private func copyPixelBuffers(link: CADisplayLink) { | |
| let time = output.itemTime(forHostTime: CACurrentMediaTime()) | |
| if output.hasNewPixelBuffer(forItemTime: time), let buffer = output.copyPixelBuffer(forItemTime: time, itemTimeForDisplay: nil) { | |
| let image = CIImage(cvPixelBuffer: buffer) | |
| self.image = context.createCGImage(image, from: image.extent) | |
| } | |
| } | |
| } | |
| #Preview { | |
| ContentView() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment