From bc01dcd1f3ca9ebec55496c18a87216e0b5baa36 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 13 Aug 2026 18:14:52 -0400 Subject: [PATCH] Make the Reels player read every account, not just the factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window only ever knew the oskiewar ledger, so reels published through ig.mjs were invisible to it. Now it merges one ledger per account, found by listing social/instagram rather than by a hardcoded list, and stamps each post with where it came from — provisioning an account is enough to put its reels in the window. The two ledger shapes had to stop fighting. The factory stamps mode/id/ segment on everything; a hand-published reel knows only its media id, so those fields are optional now and identity falls back to the media id. Requiring either made one file's shape empty the whole window. ig.mjs earns its half: a publish appends to the account's ledger instead of only dropping a sidecar next to one mp4, and insights --refresh hangs the numbers on it. Captions are editable in the app long after publish, so the refresh re-reads Meta's copy rather than trusting what we sent — the laerklokken reel had already been retitled by hand. Segment is a factory concept; posts without one now roll up under their account instead of collecting in a single unlabeled row. --- .../Sources/SlabMenubar/ReelStats.swift | 386 ++++++++++++++ .../Sources/SlabMenubar/ReelsPlayerView.swift | 495 ++++++++++++++++++ social/instagram/aesthetic-ledger.json | 31 +- social/instagram/menuband-ledger.json | 38 +- toolchain/instagram/ig.mjs | 133 ++++- 5 files changed, 1074 insertions(+), 9 deletions(-) create mode 100644 slab/menubar-swift/Sources/SlabMenubar/ReelStats.swift create mode 100644 slab/menubar-swift/Sources/SlabMenubar/ReelsPlayerView.swift 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