1969 年 7 月,Buzz Aldrin 胸前那台 Hasselblad 500EL Data Camera 与同时期的民用 500EL 之间,最关键的差别是底片与镜头之间那一片 réseau 玻璃板——平面上蚀刻着 5 × 5 = 25 个细十字。
曝光时,这些十字会随着场景一同被印到底片上。正中央那一个画得比其他显眼——一个大十字里嵌一个小十字,正中心还有一个针尖大的实心圆点做精确配准 (registration dot)。NASA 后来用它们做月面照片的 photogrammetry:丈量距离、检查胶片平整度、推算镜头畸变。
所以这台 app 的第一行 SVG 就是它。整张取景画面被一片虚拟玻璃罩着,不是滤镜,是凭据。
关于「为什么是十字而不是网格」——蚀刻细线的衍射比方点更好控制,且十字的中心可以做到像素级定位。这是 Reseau plate 自一百多年前用于天文摄影起就有的智慧。
哈苏从 1948 年的 1600F 开始做的相机,胶片格式都是 6 × 6——一个真正的正方形。但 1954 年的广告里他们说:
"For a world less square."
不是说世界不应该是正方形——是说世界其实从来就比正方形更宽。我们这台 app 给六种画幅,从极方到极宽:
中间那个 32 : 64 是 1998 年 Hasselblad XPan 的画幅——一台 135 全景相机,胶片窗口 65 × 24 mm,约等于两张普通 135 横向拼接,简化为 32:64 (1:2)。这是哈苏家族唯一的全景成员,也是这台 app 名正言顺扛 HASSELBLAD wordmark 的画幅。
更宽的 6 : 17 不属于哈苏——那是 Linhof Technorama 617 与 Fuji G617 的领地:120 中画幅、60 × 170 mm,约 1 : 2.83 的极端宽幅。我们把它也收进来,是为了那些真正需要横扫地平线的瞬间。
在手机上,两个全景画幅都沿屏幕短边走:竖握时上下黑边的电影宽幅,横握时铺满屏幕的真宽屏。
哈苏 V-System 用的是 Zeiss——每一支镜头都有自己的名字,不是焦段。我们把这七支搬进 app:
手机上你不可能真的有七个物理头。所以 app 在启动时调用 enumerateDevices(),按 label 关键字 + 索引启发式把手机的物理头分类成 wide / main / tele / super-tele,然后每一支 Zeiss 找最接近的物理头去映射,剩下的差额用数字裁切补齐。
你按 80mm PLANAR ★,无论手上是 Find N6 还是任意一台安卓,它都会切到主摄;按 500mm SONNAR,能找到长焦就给长焦,找不到就在主摄上数字放大 6.25 倍。七个焦段永远在,这是承诺。
六种胶卷模拟。每一种都对应一组 Canvas filter 参数(饱和度、对比度、色温、绿/品偏移),加上一层与胶卷颗粒性匹配的 grain 噪声。
每一种胶卷启用时,画框也会切换:选 EKTAR / PORTRA / GOLD / TRI-X,得到的是 135 sprocket-hole 胶片边——上下黑条带齿孔,左下角写 KODAK 标号。选 PROVIA / VELVIA,是中画幅 XPan poster 框——上下不带齿孔,slogan 居中,HASSELBLAD italic 收尾。
B&W 模式的颗粒度调过两轮——一开始颗粒太重像电视雪花,opacity 0.35 → 0.18,density 0.55 → 0.30,对比度区间收紧,才有了 Tri-X 的味儿。
每一张出片自带画框,画框三件套:
Slogan 居中 Courier 大写、HASSELBLAD wordmark 用真正的字形 SVG(185 × 15.5 viewBox,与官方品牌资产 1:1)收在 slogan 正下方,外加细方框 + 8% 内边距——这是哈苏自家产品上的规范处理方式,不是 Times Italic 凑数。两侧两条信息条:一条记下你按的镜头、画幅、胶卷,另一条记下你按快门时站在哪里。
GPS 默认开——开机一秒后自动 click 一下定位开关。哈苏 EDC 在月面拍下脚印的瞬间就把姿态、时间、坐标记在底片边——这台 app 想做的也是这件事。
同一份代码,三个 applicationId,可以同时安装到一台机器上互不打架:
三个版本的代码完全一样——区别只在 launcher label 和 applicationId。Runtime 自己看 device 决定要展开还是合上、要不要点亮潜望长焦的 500mm。变体 tag 只是为了用户在桌面上能一眼分辨。
这套差异化打包用一个 tools/build-variants.ps1 脚本做掉:snapshot 原始 build.gradle 与 strings.xml → 三次循环 patch + assembleDebug → 末尾还原。整个跑下来一分钟出头。
整个 app 是一份 单文件 HTML——所有 CSS 与 JS 内联,没有 React,没有 Vue,没有 webpack。原因是这台 app 的 UI 状态机不复杂——它本质上是 7 支镜头 × 6 种胶卷 × 6 种画幅 × 一个快门,状态可以用一个 module-level closure 装得下。
外面包一层 Capacitor 6 把它装进 Android WebView。MainActivity 干三件事:
① 继承 BridgeWebChromeClient 接管 camera + geolocation 权限对话框;
② 注入 GalleryBridge JavascriptInterface,把 Canvas 出来的 Blob 写入系统相册(MediaStore API);
③ 调用 setMediaPlaybackRequiresUserGesture(false),否则 OPPO 自带 WebView 会在 video 元素上盖一个巨大的 ▶ 播放按钮,挡住整个取景。
JDK 与 Android SDK 都装在项目下的 .build/ 目录,无任何全局依赖。tools/env.ps1 把 JAVA_HOME / ANDROID_HOME 指过来,gradlew.bat assembleDebug 在 12 秒内出 APK。
给未来回看做的几条备忘:
WebChromeClient 替换会破坏 Capacitor 的 bridge。第一版直接 setWebChromeClient 一个新 client,结果原生 ↔ JS 调用全部静默失效——必须继承 BridgeWebChromeClient,只 override 需要 override 的那几个方法。
OPPO 自带 WebView 给 video 盖播放按钮。真机上拍开发者机会发现整个取景被遮成 50% 透明的黑色矩形,中间一个巨大的 ▶。setMediaPlaybackRequiresUserGesture(false) + webkit-playsinline x5-playsinline x5-video-player-type="h5" disablepictureinpicture 一起上,干净。
flex item 的 min-width 默认 auto 会让取景框在横屏崩成 224×224。flex 子项的 min-width 默认是 auto——里面的 video 元素一旦有 intrinsic size,外层 overflow: hidden 就会缩到 video 的最小尺寸。必须在 JS 里 inline 写 elt.style.minWidth = elt.style.width,CSS 拗不过来。
PowerShell 写 Java 源码会塞 UTF-8 BOM。用 Set-Content -Encoding UTF8 在 5.1 上会写 BOM,javac 直接拒收。改用 [System.IO.File]::WriteAllText(..., New-Object System.Text.UTF8Encoding($false))。
PowerShell GBK 把 ASCII 中点 · (U+00B7) 解成中文「路」。script 里所有的中点全部改成 [char]0x00B7 显式转义。
B&W 颗粒度一开始像电视雪花。用户反馈"颗粒太重了,太假了"——opacity 0.35 → 0.18,density 0.55 → 0.30,对比度区间收紧,才像 Tri-X。
Watermark slogan + HASSELBLAD wordmark 一开始撑出 canvas。HASSELBLAD 这个字本身宽高比 12:1,跟 slogan 同字号会越界。slogan 字号 = padBot×0.14,wordmark 高 = padBot×0.13,加细方框收边,XPan poster 的味就出来了。