[{"data":1,"prerenderedAt":311},["ShallowReactive",2],{"project-\u002Fprojects\u002Fpeliculle-en":3,"related-\u002Fprojects\u002Fpeliculle-en":289,"legal-link-en":309},{"id":4,"title":5,"body":6,"description":267,"extension":268,"git":269,"git_soon":272,"meta":273,"navigation":274,"order":254,"path":275,"seo":276,"stack":277,"stem":282,"type":283,"updated":284,"web":285,"__hash__":288},"projects_en\u002Fprojects\u002Fpeliculle.md","Peliculle",{"type":7,"value":8,"toc":252},"minimark",[9,17,22,25,29,105,116,120,194,198,201,204,208,213,219,221,225,230,232,236,241,243,247],[10,11,12,16],"p",{},[13,14,15],"strong",{},"Culling 45 MP RAW files straight off an SD card with a Photos.app-level feel"," — no import, no subscription: only the keepers ever reach your camera roll, the card stays strictly read-only.",[18,19,21],"h2",{"id":20},"whats-the-context-behind-peliculle","What's the context behind Peliculle?",[10,23,24],{},"When you plug a camera SD card into a USB-C iPhone, iOS offers no real culling workflow: the native import dialog has no navigable full-screen view, the Files app handles RAW poorly, and the alternatives (Lightroom, Raw Power) require a prior import or a subscription. Peliculle fills that gap: a fluid full-screen viewer working directly off the card, gesture-based triage (keep \u002F reject), and only the keepers get saved to the camera roll — the card stays untouched, strictly read-only. There is no previous version: the app is a v1 born from a full PRD, then extended (photo library and albums as sources, videos, Trip Mode, confirmed deletion of rejects).",[18,26,28],{"id":27},"whats-the-tech-stack","What's the tech stack?",[30,31,32,47,61,71,81,87,93,99],"ul",{},[33,34,35,42,43,46],"li",{},[13,36,37,38],{},"Swift, strict concurrency ",[39,40,41],"code",{},"complete"," — the entire pipeline (decoding, EXIF indexing, Vision analysis) is compiler-checked: actors for shared indexes, UI state isolated to the main actor, every non-trivial ",[39,44,45],{},"Sendable"," justified in a comment.",[33,48,49,52,53,56,57,60],{},[13,50,51],{},"SwiftUI + Observation, iOS 26 target"," — 100% native to compete with Photos.app: hero transition from grid to viewer (",[39,54,55],{},".navigationTransition(.zoom)","), ",[39,58,59],{},"TabView"," paging with system inertia, native haptics.",[33,62,63,66,67,70],{},[13,64,65],{},"ImageIO"," — previews generated by bounded downsampling (",[39,68,69],{},"kCGImageSourceThumbnailMaxPixelSize","): full-resolution decoding never happens while scrolling; for a RAW file, ImageIO returns the embedded JPEG preview, near-instantly.",[33,72,73,80],{},[13,74,75,76,79],{},"Core Image (",[39,77,78],{},"CIRAWFilter",")"," — full-size RAW decoding only on demand (zooming to check focus), routed by file type so a Core Image pipeline is never built for a plain JPEG.",[33,82,83,86],{},[13,84,85],{},"PhotoKit"," — the second source family (photo library, albums) and writing keepers to the camera roll.",[33,88,89,92],{},[13,90,91],{},"Vision"," — aesthetic score computed on-device, as a low-priority background pass, never in the cloud.",[33,94,95,98],{},[13,96,97],{},"AVFoundation"," — thumbnails and playback for video clips on the card.",[33,100,101,104],{},[13,102,103],{},"Zero third-party dependencies"," — no SPM packages: ~11,500 lines of Swift on Apple frameworks only. FR\u002FEN localization via String Catalogs (318 keys), privacy manifest, unit tests on the pure logic (bursts, sorting, persistence, Trip Mode).",[10,106,107,108,111,112,115],{},"The entire difference between sources lives in the ",[39,109,110],{},"PhotoBacking"," enum: the rest of the app (grid, viewer, quick cull, filters, Trip Mode) works with ",[39,113,114],{},"PhotoItem","s without knowing whether they come from a card or the photo library. A session can even combine several sources, each with its own persistence.",[18,117,119],{"id":118},"notable-technical-points","Notable Technical Points",[30,121,122,135,149,155,184],{},[33,123,124,127,128,130,131,134],{},[13,125,126],{},"A three-tier image pipeline sized for iPhone RAM."," ~400 px previews for the grid and ~2048 px for the viewer via ImageIO downsampling; full resolution (",[39,129,78],{},") only when zooming. The preview cache is an ",[39,132,133],{},"NSCache"," bounded in bytes (one eighth of physical RAM, capped at 300 MB); full resolution gets its own dedicated 2-entry cache: a decoded 45 MP RAW weighs ~180 MB of pixels and would have evicted the entire grid from the shared cache.",[33,136,137,140,141,144,145,148],{},[13,138,139],{},"Cooperative cancellation of decodes."," Each load inherits cancellation from its cell's SwiftUI ",[39,142,143],{},".task",": on a fast scroll, decodes for off-screen cells stop instead of running to completion. An earlier ",[39,146,147],{},"Task.detached"," version left hundreds of off-screen decodes stealing CPU and battery from the visible cells.",[33,150,151,154],{},[13,152,153],{},"Stable photo identity across card mounts."," An SD card's absolute URL changes on every mount; persistence (decisions, ratings) and the analysis cache are therefore keyed by file name + size, never by URL. The result: unplug the card, plug it back in three days later, and resume culling exactly where you left off.",[33,156,157,160,161,164,165,168,169,172,173,176,177,180,181,183],{},[13,158,159],{},"Strict concurrency end to end."," ",[39,162,163],{},"VisionAnalyzer"," and ",[39,166,167],{},"ExifIndexer"," are shared actors with in-flight request deduplication (two cells asking for the same photo await the same task), off-actor computation at low priority, and ",[39,170,171],{},"allowNetwork: false"," for background passes — analyzing 10,000 photos must never trigger 10,000 iCloud downloads. Conversely, ",[39,174,175],{},"ImageCache"," stays a plain ",[39,178,179],{},"@unchecked Sendable"," class: ",[39,182,133],{}," is already thread-safe, and an actor would only have added a context hop per thumbnail.",[33,185,186,189,190,193],{},[13,187,188],{},"Culling as a gesture machine."," Keep \u002F reject \u002F later swipes with haptic feedback, burst stacks detected by temporal chaining (",[39,191,192],{},"BurstGrouper",", unit-tested), A\u002FB tournament duel with synchronized zoom on both photos (one pinch, the same transform applied to both panels — the most reliable way to compare sharpness at the same spot), and an undo journal where every gesture, even a bulk one, is a single entry.",[18,195,197],{"id":196},"what-i-learned-contributed","What I Learned \u002F Contributed",[10,199,200],{},"The hardest part was keeping the promise of a Photos.app-level feel on 45 MP RAW files read from an SD card: every pipeline choice (resolution tier, cache bound, cancellation, identity key) stems from a concrete problem hit in real use. Moving to the Swift compiler's strict concurrency checking forced me to make explicit who owns which state, and to document in the code why each exception is safe.",[202,203],"hr",{},[18,205,207],{"id":206},"diagrams","Diagrams",[209,210,212],"h3",{"id":211},"overall-architecture","Overall architecture",[10,214,215],{},[216,217],"img",{"alt":212,"src":218},"\u002Fdiagrams\u002Fpeliculle\u002F01-architecture.svg",[202,220],{},[209,222,224],{"id":223},"three-tier-image-pipeline","Three-tier image pipeline",[10,226,227],{},[216,228],{"alt":224,"src":229},"\u002Fdiagrams\u002Fpeliculle\u002F02-image-pipeline.svg",[202,231],{},[209,233,235],{"id":234},"cooperative-decode-cancellation","Cooperative decode cancellation",[10,237,238],{},[216,239],{"alt":235,"src":240},"\u002Fdiagrams\u002Fpeliculle\u002F03-cancellation.svg",[202,242],{},[209,244,246],{"id":245},"stable-photo-identity-across-mounts","Stable photo identity across mounts",[10,248,249],{},[216,250],{"alt":246,"src":251},"\u002Fdiagrams\u002Fpeliculle\u002F04-stable-identity.svg",{"title":253,"searchDepth":254,"depth":254,"links":255},"",2,[256,257,258,259,260],{"id":20,"depth":254,"text":21},{"id":27,"depth":254,"text":28},{"id":118,"depth":254,"text":119},{"id":196,"depth":254,"text":197},{"id":206,"depth":254,"text":207,"children":261},[262,264,265,266],{"id":211,"depth":263,"text":212},3,{"id":223,"depth":263,"text":224},{"id":234,"depth":263,"text":235},{"id":245,"depth":263,"text":246},"iOS culling app: sort RAW photos straight from the SD card, no import, and save only the best ones to the camera roll.","md",[270,271],"Git Repository","https:\u002F\u002Fgithub.com\u002FOwenLB\u002Fpeliculle",false,{},true,"\u002Fprojects\u002Fpeliculle",{"title":5,"description":267},[278,279,280,65,281,85,91],"Swift","SwiftUI","Swift Concurrency","Core Image","projects\u002Fpeliculle","iOS app","2026-07-17",[286,287],"View landing","https:\u002F\u002Fpeliculle.netlify.app","9KViyegFDDcn1oepafMMLU-yqf38SyMPqOO08x2-78Q",[290,295,299,304],{"title":291,"type":292,"path":293,"order":294},"Finixa","Web & mobile app","\u002Fprojects\u002Ffinixa",1,{"title":296,"type":297,"path":298,"order":263},"ScanAuto","Web app","\u002Fprojects\u002Fscanauto",{"title":300,"type":301,"path":302,"order":303},"Developer Portfolio","Website","\u002Fprojects\u002Fportfolio-dev",4,{"title":305,"type":306,"path":307,"order":308},"Photo Portfolio","Full-stack site","\u002Fprojects\u002Fportfolio-photo",5,{"title":310},"Legal notices",1784470460443]