diff --git a/clip-wizard/Sources/ClipWizard/Assets/clipwizard-backdrop.mp4 b/clip-wizard/Sources/ClipWizard/Assets/clipwizard-backdrop.mp4 new file mode 100644 --- /dev/null +++ b/clip-wizard/Sources/ClipWizard/Assets/clipwizard-backdrop.mp4 diff --git a/clip-wizard/Sources/ClipWizard/BackdropView.swift b/clip-wizard/Sources/ClipWizard/BackdropView.swift new file mode 100644 --- /dev/null +++ b/clip-wizard/Sources/ClipWizard/BackdropView.swift @@ -0,0 +1,62 @@ +// BackdropView.swift — living video backdrop (Seedance loop of the mascot +// illy, bin/gen-backdrop.mjs) drawn aspect-fill at low opacity behind the UI. +// Falls back to the faded static mascot if the video is absent. `mood` +// nudges opacity so the wizard "leans in" while listening. +import AppKit +import AVFoundation + +final class BackdropView: NSView { + enum Mood { case idle, listening } + var mood: Mood = .idle { didSet { playerLayer.opacity = mood == .listening ? 0.24 : base } } + private let base: Float = 0.16 + private var player: AVQueuePlayer? + private var looper: AVPlayerLooper? + private let playerLayer = AVPlayerLayer() + private let fallbackImage: NSImage? + + override init(frame: NSRect) { + fallbackImage = { + let b = Bundle.module + if let u = b.url(forResource: "clipwizard-mascot", withExtension: "png", subdirectory: "Assets") + ?? b.url(forResource: "clipwizard-mascot", withExtension: "png"), + let img = NSImage(contentsOf: u) { return img } + return nil + }() + super.init(frame: frame) + wantsLayer = true + setupVideo() + } + required init?(coder: NSCoder) { fatalError() } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + private func setupVideo() { + let b = Bundle.module + guard let url = b.url(forResource: "clipwizard-backdrop", withExtension: "mp4", subdirectory: "Assets") + ?? b.url(forResource: "clipwizard-backdrop", withExtension: "mp4") else { return } + let item = AVPlayerItem(url: url) + let queue = AVQueuePlayer() + queue.isMuted = true + looper = AVPlayerLooper(player: queue, templateItem: item) + player = queue + playerLayer.player = queue + playerLayer.videoGravity = .resizeAspectFill + playerLayer.opacity = base + layer?.addSublayer(playerLayer) + queue.play() + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); if window != nil { player?.play() } } + override func layout() { + super.layout() + CATransaction.begin(); CATransaction.setDisableActions(true) + playerLayer.frame = bounds + CATransaction.commit() + } + override func draw(_ dirtyRect: NSRect) { + guard player == nil, let img = fallbackImage else { return } + let ar = img.size.width / img.size.height, var_ar = bounds.width / max(1, bounds.height) + var r = bounds + if ar > var_ar { let w = bounds.height * ar; r = NSRect(x: (bounds.width - w) * 0.5, y: 0, width: w, height: bounds.height) } + else { let h = bounds.width / ar; r = NSRect(x: 0, y: (bounds.height - h) * 0.5, width: bounds.width, height: h) } + img.draw(in: r, from: .zero, operation: .sourceOver, fraction: 0.10) + } +} diff --git a/clip-wizard/Sources/ClipWizard/ClipWizardController.swift b/clip-wizard/Sources/ClipWizard/ClipWizardController.swift --- a/clip-wizard/Sources/ClipWizard/ClipWizardController.swift +++ b/clip-wizard/Sources/ClipWizard/ClipWizardController.swift @@ -22,7 +22,7 @@ var current = 0 var previewingFinal = false var previewFile: String? - var mascot: MascotView! + var mascot: BackdropView! var titleLabel: NSTextField! var progressLabel: NSTextField! var prevButton: NSButton! @@ -92,7 +92,7 @@ func setupUI() { guard let cv = window?.contentView else { return } cv.wantsLayer = true - mascot = MascotView(frame: cv.bounds) + mascot = BackdropView(frame: cv.bounds) mascot.autoresizingMask = [.width, .height] cv.addSubview(mascot) diff --git a/clip-wizard/bin/gen-backdrop.mjs b/clip-wizard/bin/gen-backdrop.mjs new file mode 100644 --- /dev/null +++ b/clip-wizard/bin/gen-backdrop.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// clip-wizard/bin/gen-backdrop.mjs — animate the ClipWizard mascot illy into a +// seamless looping video backdrop (Seedance 2.0 via fal). end_image = start +// frame → the loop returns home seamlessly. The illy carries the look; the +// prompt carries only gentle ambient MOTION. +// node clip-wizard/bin/gen-backdrop.mjs [--force] + +import { existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { generateShot } from "../../pop/lib/fal-seedance.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ASSETS = resolve(HERE, "../Sources/ClipWizard/Assets"); +const IMAGE = `${ASSETS}/clipwizard-mascot.png`; +const OUT = `${ASSETS}/clipwizard-backdrop.mp4`; +const FORCE = process.argv.includes("--force"); + +if (existsSync(OUT) && !FORCE) { console.log(`✓ cached → ${OUT}`); process.exit(0); } +if (!existsSync(IMAGE)) { console.error(`✗ mascot missing: ${IMAGE}`); process.exit(1); } + +const prompt = [ + "Gentle ambient loop, hand-drawn illustration staying on the warm cream paper, camera locked and still.", + "The little wizard breathes softly and sways almost imperceptibly; his robe and hat tip shift gently.", + "The strip of film/celluloid curling around him undulates slowly and its few frames flicker with warm light, the tiny glowing scenes inside shimmering and shifting.", + "The clapperboard top opens and closes once, slowly. A few soft dust motes drift through the warm light.", + "Warm, cozy, calm, dreamy, looping; no camera movement, no zoom, subtle motion only.", +].join(" "); + +console.log("▸ clipwizard backdrop · Seedance 2.0 (fast) · 1:1 · 720p · 5s · seamless loop"); +const r = await generateShot({ + image: IMAGE, endImage: IMAGE, prompt, duration: "5", ratio: "1:1", + resolution: "720p", tier: "fast", audio: false, outPath: OUT, label: "clip-backdrop", +}); +if (!r.ok) { console.error(`✗ ${r.error}`); process.exit(1); } +console.log(`✓ ${r.seconds.toFixed(0)}s · ${(r.bytes / 1e6).toFixed(1)}MB → ${OUT}`); diff --git a/wave-wizard/Sources/WaveWizard/Assets/wavewizard-backdrop.mp4 b/wave-wizard/Sources/WaveWizard/Assets/wavewizard-backdrop.mp4 new file mode 100644 --- /dev/null +++ b/wave-wizard/Sources/WaveWizard/Assets/wavewizard-backdrop.mp4 diff --git a/wave-wizard/Sources/WaveWizard/BackdropView.swift b/wave-wizard/Sources/WaveWizard/BackdropView.swift new file mode 100644 --- /dev/null +++ b/wave-wizard/Sources/WaveWizard/BackdropView.swift @@ -0,0 +1,70 @@ +// BackdropView.swift — living video backdrop (Seedance loop of the mascot +// illy, bin/gen-backdrop.mjs) drawn aspect-fill at low opacity behind the UI. +// Falls back to the faded static mascot if the video is absent. `mood` nudges +// the backdrop's presence with the app's state (the wizard "leans in"). +import AppKit +import AVFoundation + +final class BackdropView: NSView { + enum Mood { case idle, listening, recording, success, error } + var mood: Mood = .idle { didSet { playerLayer.opacity = opacity(for: mood) } } + private func opacity(for m: Mood) -> Float { + switch m { + case .idle: return 0.16 + case .listening: return 0.24 + case .recording: return 0.26 + case .success: return 0.22 + case .error: return 0.20 + } + } + private var player: AVQueuePlayer? + private var looper: AVPlayerLooper? + private let playerLayer = AVPlayerLayer() + private let fallbackImage: NSImage? + + override init(frame: NSRect) { + fallbackImage = { + let b = Bundle.module + if let u = b.url(forResource: "wavewizard-mascot", withExtension: "png", subdirectory: "Assets") + ?? b.url(forResource: "wavewizard-mascot", withExtension: "png"), + let img = NSImage(contentsOf: u) { return img } + return nil + }() + super.init(frame: frame) + wantsLayer = true + setupVideo() + } + required init?(coder: NSCoder) { fatalError() } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + private func setupVideo() { + let b = Bundle.module + guard let url = b.url(forResource: "wavewizard-backdrop", withExtension: "mp4", subdirectory: "Assets") + ?? b.url(forResource: "wavewizard-backdrop", withExtension: "mp4") else { return } + let item = AVPlayerItem(url: url) + let queue = AVQueuePlayer() + queue.isMuted = true + looper = AVPlayerLooper(player: queue, templateItem: item) + player = queue + playerLayer.player = queue + playerLayer.videoGravity = .resizeAspectFill + playerLayer.opacity = opacity(for: .idle) + layer?.addSublayer(playerLayer) + queue.play() + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); if window != nil { player?.play() } } + override func layout() { + super.layout() + CATransaction.begin(); CATransaction.setDisableActions(true) + playerLayer.frame = bounds + CATransaction.commit() + } + override func draw(_ dirtyRect: NSRect) { + guard player == nil, let img = fallbackImage else { return } + let ar = img.size.width / img.size.height, varAr = bounds.width / max(1, bounds.height) + var r = bounds + if ar > varAr { let w = bounds.height * ar; r = NSRect(x: (bounds.width - w) * 0.5, y: 0, width: w, height: bounds.height) } + else { let h = bounds.width / ar; r = NSRect(x: 0, y: (bounds.height - h) * 0.5, width: bounds.width, height: h) } + img.draw(in: r, from: .zero, operation: .sourceOver, fraction: 0.10) + } +} diff --git a/wave-wizard/Sources/WaveWizard/WizardController.swift b/wave-wizard/Sources/WaveWizard/WizardController.swift --- a/wave-wizard/Sources/WaveWizard/WizardController.swift +++ b/wave-wizard/Sources/WaveWizard/WizardController.swift @@ -30,7 +30,7 @@ var sequenceToken: Int = 0 var hasGreeted: Bool = false // UI - var mascot: MascotView! + var mascot: BackdropView! var timeline: TimelineView! var alignment: AlignmentView! var crossfader: NSSlider! @@ -124,7 +124,7 @@ let pad: CGFloat = 18 // 3-layer hamburger stripes more let colW = W - 2 * pad // vertical room (stripes ~23 px tall) // Background mascot — full window, faded watermark - mascot = MascotView(frame: cv.bounds) + mascot = BackdropView(frame: cv.bounds) mascot.autoresizingMask = [NSView.AutoresizingMask.width, NSView.AutoresizingMask.height] cv.addSubview(mascot) diff --git a/wave-wizard/bin/gen-backdrop.mjs b/wave-wizard/bin/gen-backdrop.mjs new file mode 100644 --- /dev/null +++ b/wave-wizard/bin/gen-backdrop.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// wave-wizard/bin/gen-backdrop.mjs — animate the WaveWizard mascot illy into a +// seamless looping video backdrop (Seedance 2.0 via fal). end_image = start +// frame → seamless loop. The illy carries the look; the prompt carries MOTION. +// node wave-wizard/bin/gen-backdrop.mjs [--force] + +import { existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { generateShot } from "../../pop/lib/fal-seedance.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ASSETS = resolve(HERE, "../Sources/WaveWizard/Assets"); +const IMAGE = `${ASSETS}/wavewizard-mascot.png`; +const OUT = `${ASSETS}/wavewizard-backdrop.mp4`; +const FORCE = process.argv.includes("--force"); + +if (existsSync(OUT) && !FORCE) { console.log(`✓ cached → ${OUT}`); process.exit(0); } +if (!existsSync(IMAGE)) { console.error(`✗ mascot missing: ${IMAGE}`); process.exit(1); } + +const prompt = [ + "Gentle ambient loop, hand-drawn colored-pencil illustration on a clean pure-white background, camera locked and still.", + "The young wizard breathes softly and sways almost imperceptibly; his robe and hat tip drift gently.", + "The hand-drawn sine-wave ribbon trailing from his wand undulates slowly and the colored note-bubbles strung along it pulse and bob gently.", + "The microphone glints; two or three little eighth-notes drift upward and fade; the small traffic-light count-in dots softly glow in sequence.", + "Warm, calm, dreamy, looping; pure white background stays white; no camera movement, no zoom, subtle motion only.", +].join(" "); + +console.log("▸ wavewizard backdrop · Seedance 2.0 (fast) · 1:1 · 720p · 5s · seamless loop"); +const r = await generateShot({ + image: IMAGE, endImage: IMAGE, prompt, duration: "5", ratio: "1:1", + resolution: "720p", tier: "fast", audio: false, outPath: OUT, label: "wave-backdrop", +}); +if (!r.ok) { console.error(`✗ ${r.error}`); process.exit(1); } +console.log(`✓ ${r.seconds.toFixed(0)}s · ${(r.bytes / 1e6).toFixed(1)}MB → ${OUT}`);