diff --git a/slab/menubar-swift/Sources/SlabMenubar/ReelStats.swift b/slab/menubar-swift/Sources/SlabMenubar/ReelStats.swift new file mode 100644 index 000000000..3d6a7e18f --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ReelStats.swift @@ -0,0 +1,386 @@ +// ReelStats — the data half of the Reels player (ReelsPlayerView.swift): +// what the oskiewar reel factory knows about each rendered reel, read +// strictly from its own local JSON records — no network, no tokens, ever. +// Two sources merge on reel id: +// +// • the publish ledger — one post per publish; `refreshInsights` hangs +// each post's Instagram numbers on it later. `insights: null` means +// Meta has not measured yet, and that nil is preserved all the way to +// the view (which renders "—", never a fake zero). +// • //reel.json — the staged render's local media + meta +// (mp4 path, duration, dimensions, size, caption). The queue is +// machine-local build output and may be missing entirely; a reel with +// no staged copy still lists from the ledger alone, and vice versa. +// +// Both trees live outside this repo. Paths default from Paths.acRepo and +// can be repointed per host in untracked ~/.config/slab/reels.json: +// { "ledger": "/abs/path/ledger.json", "queue": "/abs/path/queue" } +import Foundation + +extension Paths { + /// Untracked per-host override for the ledger + queue locations — + /// same convention as imsgConfig: plain local paths, never secrets. + static var reelsConfig: String { "\(home)/.config/slab/reels.json" } +} + +/// One post's Instagram numbers, exactly the ledger's `insights` object +/// (publish.mjs `reelMetrics`). Every field is optional because Meta only +/// returns a metric once it has computed it; nil reads as "not measured". +/// All numerics decode as Double so a drifting int/float wire shape can't +/// break the whole ledger read. +struct ReelInsights: Decodable { + var views: Double? + var reach: Double? + var likes: Double? + var comments: Double? + var saved: Double? + var shares: Double? + var totalInteractions: Double? + var reposts: Double? + var avgWatchTimeMs: Double? + var viewTotalTimeMs: Double? + var skipRate: Double? + + enum CodingKeys: String, CodingKey { + case views, reach, likes, comments, saved, shares, reposts + case totalInteractions = "total_interactions" + case avgWatchTimeMs = "ig_reels_avg_watch_time" + case viewTotalTimeMs = "ig_reels_video_view_total_time" + case skipRate = "reels_skip_rate" + } +} + +/// One ledger entry — a single publish of a reel. The same reel id can +/// appear more than once (re-published with a different round/audio). +/// +/// Fields are optional past `nothing`: the oskiewar factory stamps every post +/// with `mode`/`id`/`segment`, while ig.mjs's per-account ledgers carry only +/// what a hand-published reel knows (media id, caption, permalink). A missing +/// `id` is normal there, so identity falls back to the media id — see +/// `resolvedID`. Requiring either would make one ledger's shape fail the whole +/// decode and silently empty the window. +struct ReelPost: Decodable { + var mode: String? + var id: String? + var segment: String? + var day: String? + var publishedAt: String? + var mediaId: String? + var permalink: String? + var caption: String? + var audioName: String? + var source: String? + var urls: [String: String]? + var insights: ReelInsights? + var insightsAt: String? + + /// Which account published it. Not in the file — stamped from the ledger + /// it was read out of, so one merged list can still say where a reel went. + var account: String = "" + + enum CodingKeys: String, CodingKey { + case mode, id, segment, day, publishedAt, mediaId, permalink + case caption, audioName, source, urls, insights, insightsAt + } + + /// Stable identity across both ledger shapes. + var resolvedID: String { id ?? mediaId ?? permalink ?? "" } + + /// The oskiewar factory records dry runs too and marks the real ones + /// `live`; the per-account ledgers only ever record real publishes, so an + /// absent `mode` means live rather than unknown. + var isLive: Bool { (mode ?? "live") == "live" } + + /// `day` is a factory field. Elsewhere the publish timestamp carries it. + var resolvedDay: String { day ?? String((publishedAt ?? "").prefix(10)) } +} + +/// A ledger to merge, and the account to stamp on everything inside it. +struct ReelLedgerSource { + var account: String + var path: String +} + +/// The staged render's `meta` block (ffprobe-derived). +struct ReelMeta: Decodable { + var width: Double? + var height: Double? + var seconds: Double? + var megabytes: Double? + var fps: Double? +} + +/// The slice of //reel.json the player needs. +struct ReelQueueEntry: Decodable { + var id: String + var segment: String? + var segmentName: String? + var day: String? + var caption: String? + var audioName: String? + var builtAt: String? + var files: [String: String]? + var meta: ReelMeta? +} + +/// One reel as the player sees it: staged media (when this machine has the +/// render) + the latest live post (when it has been published). Either half +/// may be absent. +struct Reel: Identifiable { + let id: String + var account: String + var segment: String + var day: String + var segmentName: String? + var caption: String? + var audioName: String? + var localVideo: String? // playable mp4 on disk, or nil + var thumbnail: String? // small local jpg for the row, or nil + var meta: ReelMeta? + var post: ReelPost? // latest live post for this id + var postCount: Int // live publishes sharing this id + + /// What to call it in a list. Factory ids already read as names + /// (`2026-08-13-s0-retro`), but an ig.mjs reel is keyed by an opaque media + /// id — so prefer its render's filename there and leave the raw id to the + /// detail pane, which is where you go when you need to match it to Meta. + var displayTitle: String { + if let name = post?.source?.split(separator: "/").last { return String(name) } + return id + } + + var insights: ReelInsights? { post?.insights } + var permalink: URL? { post?.permalink.flatMap(URL.init(string:)) } + /// The published CDN copy — offered as an openable link, never fetched. + var publishedURL: URL? { post?.urls?["reel"].flatMap(URL.init(string:)) } +} + +/// A segmentReport() row (publish.mjs), computed the same way: live posts +/// with insights only, every post counted (re-publishes included), nils +/// summed as zero, viewsPerPost rounded, interactionRate in % to 2 places. +struct ReelSegmentRow: Identifiable { + let segment: String + var posts = 0 + var views = 0.0 + var reach = 0.0 + var interactions = 0.0 + var id: String { segment } + var viewsPerPost: Int { posts > 0 ? Int((views / Double(posts)).rounded()) : 0 } + var interactionRate: Double { views > 0 ? (interactions / views * 100) : 0 } +} + +enum ReelStats { + /// { "ledger": …, "queue": … } from the untracked per-host config. + private static func config() -> [String: String] { + guard let data = FileManager.default.contents(atPath: Paths.reelsConfig), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return [:] } + return json + } + + static var ledgerPath: String { + config()["ledger"] ?? "\(Paths.acRepo)/xbox/live/marketing/ledger.json" + } + + static var queueRoot: String { + config()["queue"] ?? "\(Paths.acRepo)/tmp/oskiewar-reels/queue" + } + + /// Every ledger the window merges: the oskiewar factory's, plus one per + /// account from `social/instagram/-ledger.json` (ig.mjs writes + /// these). Discovered by listing that directory rather than by a hardcoded + /// account list, so provisioning a new account puts its reels in the window + /// without touching this file. `ledger` in the per-host config still + /// repoints the factory ledger; nothing here needs a token. + static var ledgerSources: [ReelLedgerSource] { + var sources = [ReelLedgerSource(account: "oskiewar", path: ledgerPath)] + let dir = "\(Paths.acRepo)/social/instagram" + let names = (try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [] + for name in names.sorted() where name.hasSuffix("-ledger.json") { + let account = String(name.dropLast("-ledger.json".count)) + guard account != "oskiewar" else { continue } // the factory's wins + sources.append(ReelLedgerSource(account: account, path: "\(dir)/\(name)")) + } + return sources + } + + /// Merge ledger + queue into the player's rows, newest first. Missing + /// files are normal (fresh checkout, cleaned tmp) and read as empty. + static func load() -> (reels: [Reel], segments: [ReelSegmentRow]) { + let posts = livePosts() + let staged = queueEntries() + + // Latest live post per id (ISO timestamps order lexically). + var latest: [String: ReelPost] = [:] + var count: [String: Int] = [:] + for post in posts { + let key = post.resolvedID + guard !key.isEmpty else { continue } + count[key, default: 0] += 1 + if (latest[key]?.publishedAt ?? "") <= (post.publishedAt ?? "") { + latest[key] = post + } + } + + var reels: [String: Reel] = [:] + for entry in staged { + let dir = "\(queueRoot)/\(entry.id)" + reels[entry.id] = Reel( + id: entry.id, + account: latest[entry.id]?.account ?? "oskiewar", + segment: entry.segment ?? "", + day: entry.day ?? "", + segmentName: entry.segmentName, + caption: entry.caption, + audioName: entry.audioName, + localVideo: firstExisting([entry.files?["reel"], "\(dir)/reel.mp4"]), + thumbnail: firstExisting([entry.files?["thumbnail"], + "\(dir)/thumbnail-10-percent.jpg", + entry.files?["cover"], "\(dir)/cover.jpg"]), + meta: entry.meta, + post: latest[entry.id], + postCount: count[entry.id] ?? 0) + } + for (id, post) in latest where reels[id] == nil { + // A ledger-only reel still plays when its `source` render survives + // on this machine — that path is repo-relative in ig.mjs ledgers. + let local = post.source.map { $0.hasPrefix("/") ? $0 : "\(Paths.acRepo)/\($0)" } + reels[id] = Reel( + id: id, + account: post.account, + segment: post.segment ?? "", + day: post.resolvedDay, + segmentName: nil, + caption: post.caption, + audioName: post.audioName, + localVideo: firstExisting([local]), + thumbnail: nil, meta: nil, + post: post, + postCount: count[id] ?? 0) + } + + // Newest first. Factory ids lead with the day, ig.mjs ids are opaque + // media ids, so sort on the publish date and fall back to the id. + let sorted = reels.values.sorted { + let a = $0.post?.publishedAt ?? $0.day, b = $1.post?.publishedAt ?? $1.day + return a == b ? $0.id > $1.id : a > b + } + return (sorted, segmentReport(posts)) + } + + /// Posts that actually went out, across every ledger, each stamped with + /// the account it came from. A ledger that is absent or unreadable + /// contributes nothing — absence is "nothing published", not an error, and + /// one malformed file must not take the other accounts down with it. + private static func livePosts() -> [ReelPost] { + struct Ledger: Decodable { var posts: [ReelPost]? } + return ledgerSources.flatMap { source -> [ReelPost] in + guard let data = FileManager.default.contents(atPath: source.path), + let ledger = try? JSONDecoder().decode(Ledger.self, from: data) + else { return [] } + return (ledger.posts ?? []).filter(\.isLive).map { post in + var stamped = post + stamped.account = source.account + return stamped + } + } + } + + /// Every readable //reel.json. A directory without one (or + /// with a malformed one) is a render in progress — skipped, not fatal. + private static func queueEntries() -> [ReelQueueEntry] { + let fm = FileManager.default + guard let names = try? fm.contentsOfDirectory(atPath: queueRoot) else { return [] } + return names.compactMap { name in + guard let data = fm.contents(atPath: "\(queueRoot)/\(name)/reel.json") else { return nil } + return try? JSONDecoder().decode(ReelQueueEntry.self, from: data) + } + } + + /// Mirrors publish.mjs segmentReport(): per-market rollup over live + /// posts that have insights. Segment is a factory concept, so posts from + /// the per-account ledgers roll up under their account name instead — + /// otherwise every one of them lands in a single unlabeled row. + private static func segmentReport(_ posts: [ReelPost]) -> [ReelSegmentRow] { + var rows: [String: ReelSegmentRow] = [:] + for post in posts { + guard let insights = post.insights else { continue } + let named = post.segment ?? "" + let segment = named.isEmpty ? post.account : named + var row = rows[segment] ?? ReelSegmentRow(segment: segment) + row.posts += 1 + row.views += insights.views ?? 0 + row.reach += insights.reach ?? 0 + row.interactions += insights.totalInteractions ?? 0 + rows[segment] = row + } + return rows.values.sorted { $0.views > $1.views } + } + + private static func firstExisting(_ paths: [String?]) -> String? { + paths.compactMap { $0 }.first { FileManager.default.fileExists(atPath: $0) } + } +} + +/// The live model behind the window: reloads itself when the ledger is +/// rewritten (an insights refresh) or the queue gains a reel, so the open +/// panel tracks the factory without polling. Tiny files, main-queue reads. +final class ReelsStore: ObservableObject { + @Published var reels: [Reel] = [] + @Published var segments: [ReelSegmentRow] = [] + @Published var loadedAt: Date? + + private var watchers: [DispatchSourceFileSystemObject] = [] + private var reloadPending = false + + init() { + reload() + watch() + } + + deinit { unwatch() } + + func reload() { + let loaded = ReelStats.load() + reels = loaded.reels + segments = loaded.segments + loadedAt = Date() + } + + private func watch() { + // Ledgers are rewritten in place (a publish, or an insights refresh); + // the queue root gains/loses subdirectories. Watch every one that + // exists today — a ledger created later is picked up on the next + // reload, since re-arming re-reads the source list. + for path in ReelStats.ledgerSources.map(\.path) + [ReelStats.queueRoot] { + let fd = Darwin.open(path, O_EVTONLY) + guard fd >= 0 else { continue } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, eventMask: [.write, .rename, .delete, .extend], + queue: .main) + source.setEventHandler { [weak self] in self?.scheduleReload() } + source.setCancelHandler { Darwin.close(fd) } + source.resume() + watchers.append(source) + } + } + + private func unwatch() { + for watcher in watchers { watcher.cancel() } + watchers = [] + } + + /// Writers replace files non-atomically — debounce past the last event, + /// then reload and re-arm on the (possibly new) inodes. + private func scheduleReload() { + if reloadPending { return } + reloadPending = true + unwatch() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self = self else { return } + self.reloadPending = false + self.reload() + self.watch() + } + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/ReelsPlayerView.swift b/slab/menubar-swift/Sources/SlabMenubar/ReelsPlayerView.swift new file mode 100644 index 000000000..e930d05a0 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ReelsPlayerView.swift @@ -0,0 +1,495 @@ +// ReelsPlayerView — the Reels player: one management window where the +// oskiewar reels can be watched and their Instagram numbers read side by +// side. Left: every reel the ledger or the local queue knows, newest +// first, with a per-segment rollup (publish.mjs segmentReport) up top. +// Right: the selected reel playing (AVKit, straight off the staged mp4) +// over its caption, render meta, and the per-post insight grid. +// +// Data comes from ReelStats.swift — local JSON only, live-reloading when +// the ledger is rewritten or the queue gains a render. A reel whose +// insights are still null reads "—" (not measured), never zero; a reel +// whose render was cleaned from tmp still lists, with its published CDN +// copy and permalink as openable links (opened in the browser — this app +// never fetches them itself). +import AppKit +import SwiftUI +import AVKit + +/// One shared window, SlabAboutWindow's lifecycle: show() focuses the +/// existing panel or builds a fresh one; closing tears everything down +/// (including the store's file watchers and any playing video). +final class ReelsPlayerWindow: NSObject, NSWindowDelegate { + private static var shared: ReelsPlayerWindow? + + private let window: NSWindow + private let store = ReelsStore() + + static func show() { + let controller = shared ?? ReelsPlayerWindow() + shared = controller + NSApp.activate(ignoringOtherApps: true) + controller.store.reload() + controller.window.makeKeyAndOrderFront(nil) + } + + private override init() { + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 920, height: 620), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + super.init() + window.title = "Reels" + window.minSize = NSSize(width: 700, height: 440) + window.isReleasedWhenClosed = false + window.delegate = self + window.contentView = NSHostingView(rootView: ReelsRootView(store: store)) + window.center() + } + + func windowWillClose(_ notification: Notification) { + // Drop the SwiftUI tree now so onDisappear pauses playback and the + // store's watchers cancel with it. + window.contentView = nil + ReelsPlayerWindow.shared = nil + } +} + +extension AppDelegate { + @objc func openReelsPlayer() { ReelsPlayerWindow.show() } +} + +// MARK: - views + +struct ReelsRootView: View { + @ObservedObject var store: ReelsStore + @State private var selectedID: String? + + private var selected: Reel? { + store.reels.first { $0.id == selectedID } ?? store.reels.first + } + + var body: some View { + if store.reels.isEmpty { + emptyState + } else { + HSplitView { + reelList + .frame(minWidth: 330, maxWidth: 430) + if let reel = selected { + ReelDetailView(reel: reel) + .id(reel.id) // fresh detail (and player) per reel + .frame(minWidth: 320, maxWidth: .infinity, maxHeight: .infinity) + } + } + } + } + + private var reelList: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + let published = store.reels.filter { $0.post != nil }.count + Text("\(store.reels.count) reels · \(published) published") + .font(.system(size: 12, weight: .semibold)) + Spacer() + Button(action: { store.reload() }) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(PlainButtonStyle()) + .help("Re-read the ledger and queue now (they also reload on change).") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + if !store.segments.isEmpty { + VStack(alignment: .leading, spacing: 1) { + ForEach(store.segments) { row in + Text(segmentLine(row)) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 6) + .help("Per-segment rollup over every live post with measured insights — the same arithmetic as publish.mjs segmentReport().") + } + Divider() + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(store.reels) { reel in + ReelRowView(reel: reel, isSelected: reel.id == selected?.id) { + selectedID = reel.id + } + Divider().padding(.leading, 12) + } + } + } + } + } + + /// `fgc 3 posts 1528 views 509/post 0.46%` + private func segmentLine(_ row: ReelSegmentRow) -> String { + let name = row.segment.padding(toLength: 10, withPad: " ", startingAt: 0) + let posts = "\(row.posts) post\(row.posts == 1 ? " " : "s")" + return name + + posts.padding(toLength: 9, withPad: " ", startingAt: 0) + + "\(Int(row.views)) views".padding(toLength: 13, withPad: " ", startingAt: 0) + + "\(row.viewsPerPost)/post".padding(toLength: 11, withPad: " ", startingAt: 0) + + String(format: "%.2f%%", row.interactionRate) + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "film.stack") + .font(.system(size: 34)) + .foregroundColor(.secondary) + Text("No reels found").font(.headline) + Text("Looked for publish ledgers at\n" + + ReelStats.ledgerSources.map(\.path).joined(separator: "\n") + + "\nand staged renders under\n\(ReelStats.queueRoot)") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + Text("Point elsewhere in ~/.config/slab/reels.json — {\"ledger\": …, \"queue\": …}") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } +} + +/// One list row: thumbnail, id, segment + day + duration, and a one-line +/// status — measured numbers, "not measured", or "unpublished". +private struct ReelRowView: View { + let reel: Reel + let isSelected: Bool + let select: () -> Void + + var body: some View { + Button(action: select) { + HStack(spacing: 10) { + ReelThumb(path: reel.thumbnail) + VStack(alignment: .leading, spacing: 3) { + Text(reel.displayTitle) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .lineLimit(1) + HStack(spacing: 6) { + AccountChip(account: reel.account) + // Segment is a factory-only concept; the per-account + // ledgers have none, and an empty chip reads as "?". + if !reel.segment.isEmpty { SegmentChip(segment: reel.segment) } + Text(metaLine).font(.system(size: 10)).foregroundColor(.secondary) + } + Text(statusLine) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .background(isSelected ? Color.accentColor.opacity(0.16) : Color.clear) + } + + private var metaLine: String { + var bits: [String] = [] + if !reel.day.isEmpty { bits.append(reel.day) } + if let seconds = reel.meta?.seconds { bits.append(String(format: "%.1fs", seconds)) } + if reel.localVideo == nil { bits.append("no local render") } + return bits.joined(separator: " · ") + } + + private var statusLine: String { + guard let post = reel.post else { return "staged · unpublished" } + guard let insights = post.insights else { return "published · not measured yet" } + return "\(whole(insights.views)) views · reach \(whole(insights.reach))" + + " · skip \(pct(insights.skipRate))" + } +} + +/// The right pane: player (or a "render not on disk" placeholder) above +/// caption, render meta, insight grid, and the openable links. +private struct ReelDetailView: View { + let reel: Reel + + var body: some View { + VStack(spacing: 0) { + if let path = reel.localVideo { + ReelVideo(path: path) + .aspectRatio(aspect, contentMode: .fit) + .frame(maxWidth: .infinity, maxHeight: 400) + .background(Color.black) + } else { + missingRender + } + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 10) { + header + if let caption = reel.caption, !caption.isEmpty { + Text(caption).font(.system(size: 11)).foregroundColor(.secondary) + } + Text(renderLine).font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + insightGrid + footer + links + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var aspect: CGFloat { + guard let w = reel.meta?.width, let h = reel.meta?.height, h > 0 else { return 9 / 16 } + return CGFloat(w / h) + } + + private var missingRender: some View { + VStack(spacing: 6) { + Image(systemName: "film").font(.system(size: 28)).foregroundColor(.secondary) + Text("local render not on disk").font(.system(size: 11)).foregroundColor(.secondary) + if reel.publishedURL != nil { + Text("the published copy opens in the browser below") + .font(.system(size: 10)).foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, minHeight: 160) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text(reel.displayTitle).font(.system(size: 14, weight: .bold, design: .monospaced)) + if reel.displayTitle != reel.id { + // The media id is what matches this row to Meta's dashboard, + // so it stays visible even when the row is titled by filename. + Text(reel.id).font(.system(size: 9, design: .monospaced)) + .foregroundColor(.secondary) + .help("Instagram media id — matches this reel in Meta's dashboard.") + } + HStack(spacing: 6) { + AccountChip(account: reel.account) + if !reel.segment.isEmpty { SegmentChip(segment: reel.segment) } + if let name = reel.segmentName { + Text(name).font(.system(size: 10)).foregroundColor(.secondary) + } + if let audio = reel.audioName { + Text("♪ \(audio)").font(.system(size: 10)).foregroundColor(.secondary) + } + if reel.postCount > 1 { + Text("posted ×\(reel.postCount)") + .font(.system(size: 10)).foregroundColor(.secondary) + .help("This id went out more than once; the numbers below are the latest post's.") + } + } + } + } + + /// `14.4s · 1080×1920 · 60fps · 9.4 MB` — em-dash cells when the queue + /// meta is gone with the render. + private var renderLine: String { + let meta = reel.meta + let dims: String + if let w = meta?.width, let h = meta?.height { + dims = "\(Int(w))×\(Int(h))" + } else { dims = "—" } + return [ + meta?.seconds.map { String(format: "%.1fs", $0) } ?? "—", + dims, + meta?.fps.map { "\(Int($0))fps" } ?? "—", + meta?.megabytes.map { String(format: "%.1f MB", $0) } ?? "—", + ].joined(separator: " · ") + } + + private var insightGrid: some View { + let insights = reel.insights + let cells: [(String, String)] = [ + ("views", whole(insights?.views)), + ("reach", whole(insights?.reach)), + ("likes", whole(insights?.likes)), + ("comments", whole(insights?.comments)), + ("saves", whole(insights?.saved)), + ("shares", whole(insights?.shares)), + ("interactions", whole(insights?.totalInteractions)), + ("skip rate", pct(insights?.skipRate)), + ("avg watch", seconds(fromMs: insights?.avgWatchTimeMs)), + ] + return LazyVGrid(columns: [GridItem(.adaptive(minimum: 74), spacing: 8)], spacing: 10) { + ForEach(cells, id: \.0) { cell in + VStack(spacing: 2) { + Text(cell.1) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + Text(cell.0).font(.system(size: 9)).foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + } + } + .padding(.vertical, 4) + } + + private var footer: some View { + Group { + if reel.post == nil { + Text("staged — not published yet") + } else if reel.insights == nil { + Text("published — insights not measured yet (nothing pulled, not zero views)") + } else if let at = shortDate(reel.post?.insightsAt) { + Text("insights pulled \(at)") + } else { + Text("insights pulled") + } + } + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + private var links: some View { + HStack(spacing: 12) { + if let permalink = reel.permalink { + LinkButton(title: "Open on Instagram", url: permalink) + } + if let published = reel.publishedURL { + LinkButton(title: "Published mp4", url: published) + } + if let path = reel.localVideo { + Button("Show in Finder") { + NSWorkspace.shared.activateFileViewerSelecting( + [URL(fileURLWithPath: path)]) + } + } + } + .font(.system(size: 11)) + } +} + +/// Local playback only — the staged mp4 straight off disk. Autoplays on +/// select, pauses when the reel changes or the window closes. +private struct ReelVideo: View { + @State private var player: AVPlayer + + init(path: String) { + _player = State(initialValue: AVPlayer(url: URL(fileURLWithPath: path))) + } + + var body: some View { + VideoPlayer(player: player) + .onAppear { player.play() } + .onDisappear { player.pause() } + } +} + +/// Which account a reel went out on — the first thing to know now that one +/// window holds several. Outlined rather than filled so it reads as a +/// different KIND of fact than the segment chip beside it. +private struct AccountChip: View { + let account: String + + var body: some View { + Text(account.isEmpty ? "—" : account) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(hue.opacity(0.75), lineWidth: 1)) + .foregroundColor(hue) + } + + private var hue: Color { + var hash: UInt32 = 2166136261 + for byte in account.utf8 { hash = (hash ^ UInt32(byte)) &* 16777619 } + return Color(hue: Double(hash % 360) / 360, saturation: 0.5, brightness: 0.9) + } +} + +/// A little segment tag in a deterministic hue, so fgc/retro/gamedev rows +/// sort themselves visually. +private struct SegmentChip: View { + let segment: String + + var body: some View { + Text(segment.isEmpty ? "?" : segment) + .font(.system(size: 9, weight: .semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(hue.opacity(0.22)) + .cornerRadius(4) + } + + private var hue: Color { + var hash: UInt32 = 2166136261 + for byte in segment.utf8 { hash = (hash ^ UInt32(byte)) &* 16777619 } + return Color(hue: Double(hash % 360) / 360, saturation: 0.55, brightness: 0.85) + } +} + +private struct LinkButton: View { + let title: String + let url: URL + + var body: some View { + Button(title) { NSWorkspace.shared.open(url) } + .help(url.absoluteString) + } +} + +/// Row thumbnail off the queue's tiny thumbnail-10-percent.jpg (cover as +/// fallback); a film glyph when this machine has no render. +private struct ReelThumb: View { + let path: String? + @State private var image: NSImage? + + var body: some View { + ZStack { + Color.secondary.opacity(0.12) + if let image = image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "film").foregroundColor(.secondary) + } + } + .frame(width: 36, height: 64) + .clipped() + .cornerRadius(4) + .onAppear { + if image == nil, let path = path { image = NSImage(contentsOfFile: path) } + } + } +} + +// MARK: - formatting ("—" is "not measured", never zero) + +private func whole(_ value: Double?) -> String { + guard let value = value else { return "—" } + return String(Int(value.rounded())) +} + +private func pct(_ value: Double?) -> String { + guard let value = value else { return "—" } + return String(format: "%.1f%%", value) +} + +private func seconds(fromMs value: Double?) -> String { + guard let value = value else { return "—" } + return String(format: "%.1fs", value / 1000) +} + +private func shortDate(_ iso: String?) -> String? { + guard let iso = iso else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = fractional.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) + else { return String(iso.prefix(10)) } + let out = DateFormatter() + out.dateStyle = .medium + out.timeStyle = .short + return out.string(from: date) +} diff --git a/social/instagram/aesthetic-ledger.json b/social/instagram/aesthetic-ledger.json index 4747e7dde..f7760c15b 100644 --- a/social/instagram/aesthetic-ledger.json +++ b/social/instagram/aesthetic-ledger.json @@ -2,5 +2,34 @@ "format": "ac.instagram.reel-ledger", "version": 1, "account": "aesthetic", - "posts": [] + "posts": [ + { + "mediaId": "18091507286114607", + "containerId": "18042395462809746", + "publishedAt": "2026-08-13T22:31:00.000Z", + "permalink": "https://www.instagram.com/reel/Db_hgi9iNaW/", + "caption": "@laerklokken", + "source": "marketing/klokkentales/out/reels/1000-lyttere/1000-lyttere-chrome.mp4", + "audioName": "laklok.com", + "collaborators": [ + "laerklokken" + ], + "urls": { + "reel": "https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/ig/aesthetic/2026-08-13/1000-lyttere-chrome.mp4" + }, + "insights": { + "views": 230, + "reach": 170, + "likes": 2, + "comments": 0, + "saved": 0, + "shares": 0, + "total_interactions": 2, + "ig_reels_avg_watch_time": 5323, + "ig_reels_video_view_total_time": 942313, + "reels_skip_rate": 51.6 + }, + "insightsAt": "2026-08-13T22:06:51.111Z" + } + ] } diff --git a/social/instagram/menuband-ledger.json b/social/instagram/menuband-ledger.json index 6c8ea772e..483e9419e 100644 --- a/social/instagram/menuband-ledger.json +++ b/social/instagram/menuband-ledger.json @@ -19,7 +19,43 @@ "permalink": "https://www.instagram.com/reel/Db7cNlzDsg3/", "caption": "Lantern — Menu Band Waltz No. 1. MenuBand.app", "source": "pop/menuband/out/menu-band-waltzes/01-lantern/01-lantern.mp4", - "voiceover": false + "voiceover": false, + "insights": { + "views": 231, + "reach": 207, + "likes": 4, + "comments": 1, + "saved": 0, + "shares": 0, + "total_interactions": 5, + "ig_reels_avg_watch_time": 7148, + "ig_reels_video_view_total_time": 1522602, + "reels_skip_rate": 40.7 + }, + "insightsAt": "2026-08-13T22:02:57.233Z" + }, + { + "mediaId": "18098114018458417", + "containerId": "18086077667299689", + "publishedAt": "2026-08-13T16:29:52.709Z", + "permalink": "https://www.instagram.com/reel/Db_KzJkD2AU/", + "caption": "Window — Menu Band Waltz No. 2. MenuBand.app", + "source": "pop/menuband/out/menu-band-waltzes/02-window/02-window.mp4", + "voiceover": false, + "audioName": "window-waltz.mbscore", + "insights": { + "views": 116, + "reach": 103, + "likes": 0, + "comments": 0, + "saved": 0, + "shares": 0, + "total_interactions": 0, + "ig_reels_avg_watch_time": 5120, + "ig_reels_video_view_total_time": 542752, + "reels_skip_rate": 50 + }, + "insightsAt": "2026-08-13T22:02:57.637Z" } ] } diff --git a/toolchain/instagram/ig.mjs b/toolchain/instagram/ig.mjs index 14cc5e233..bcbdfed36 100644 --- a/toolchain/instagram/ig.mjs +++ b/toolchain/instagram/ig.mjs @@ -27,7 +27,9 @@ // node toolchain/instagram/ig.mjs --as oskiewar snapshot // // Every publish writes a sidecar