From 092cc6de5495f8099a07bf2de86be02725671c23 Mon Sep 17 00:00:00 2001 From: gallery Date: Mon, 14 Sep 2026 14:28:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E5=85=B7:=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8B=8D=E7=AB=8B=E5=BE=97=E8=BE=B9=E6=A1=86=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=B8=8E=E7=9B=B8=E5=86=8C=E4=B8=8A=E4=BC=A0=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/polaroid.py 批量套 Instax/Polaroid 边框并加中文题字(几何取自真机规格, 题字支持中文数字、超长自动缩字、手写抖动、做旧效果) tools/upload_to_gallery.sh 把成片上传到相册服务器(暂存区校验后才入库、默认不覆盖、 mtime 按 EXIF 拍摄时间设置) --- .gitattributes | 3 + tools/polaroid.py | 915 +++++++++++++++++++++++++++++++++++++ tools/upload_to_gallery.sh | 123 +++++ 3 files changed, 1041 insertions(+) create mode 100644 .gitattributes create mode 100755 tools/polaroid.py create mode 100755 tools/upload_to_gallery.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ea0328 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# 保持 shell 脚本为 LF:本机 core.autocrlf=true,checkout 时会写成 CRLF, +# 而 CRLF 的 shebang 在 Linux/WSL 上会变成「bad interpreter: /usr/bin/env bash」。 +tools/*.sh text eol=lf diff --git a/tools/polaroid.py b/tools/polaroid.py new file mode 100755 index 0000000..f657c2c --- /dev/null +++ b/tools/polaroid.py @@ -0,0 +1,915 @@ +#!/usr/bin/env python3 +"""批量为照片套拍立得 / Instax 边框并加文字。 + +设计原则:确定性、可重跑、绝不碰照片像素。同样的输入 + 同样的参数 → 逐字节一致的输出。 + +几何全部来自真机胶片规格(毫米),不是拍脑袋的百分比,可选: + instax_mini 54x86 底片 / 46x62 画面(3:4 竖版) + instax_wide 108x86 底片 / 99x62 画面(宽画幅) + instax_square 72x86 底片 / 62x62 画面 + polaroid600 88x107 底片 / 79x79 画面(正方形) +画面区四角一律直角(真机就是直角),想要圆角用 --inner-radius。 + +--film 默认 "instax_mini,instax_wide",即「竖排用 Mini、横拍用 Wide」。 +这两张胶片的基准方向都跟拍摄方向一致,所以宽边永远在下、题字永远横排, +相当于竖着拿 Mini 相机、平着拿 Wide 相机。 + +--film 只写一个规格时,宽边朝向回到真机物理:拍摄方向与胶片基准一致则 +宽边在下,反了就转 90° 让宽边落在左侧(横拍照片的题字会跟着转 90°)。 + +用法: + python tools/polaroid.py photos -o out + python tools/polaroid.py photos -o out --film polaroid600 --hand + python tools/polaroid.py photos -o out --captions captions.tsv + python tools/polaroid.py photos -o out --flat # 关掉做旧,纯白平边框 + +字体默认用 tools/ 里自带的罗西硬笔柳体(方正,Version 1.00); +用 --font 指定别的字体,或者直接把 .ttf 丢进 tools/ 也会被自动认到。 +""" + +from __future__ import annotations + +import argparse +import csv +import math +import random +import re +import sys +import zlib +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from PIL import Image, ImageChops, ImageColor, ImageDraw, ImageFilter, ImageFont, ImageOps + +# ── 真机胶片规格 ────────────────────────────────────────────────────────── +# 尺寸单位 mm,取自各胶片的公开规格。三个细边(左/右/上)在所有这些胶片上 +# 都是等宽的,宽边(下巴)在基准朝向下位于底部,所以只需要这四个数。 +# +# 注意:Instant 胶片的画面区是直角,没有圆角(实物和规格图都是), +# 所以圆角一律为 0;真想要圆角用 --inner-radius 自己加。 +# +# NAME: 底片宽, 底片高, 画面宽, 画面高, 画面区圆角 +FILMS: dict[str, tuple[float, float, float, float, float]] = { + "instax_mini": (54, 86, 46, 62, 0.0), + "instax_wide": (108, 86, 99, 62, 0.0), + "instax_square": (72, 86, 62, 62, 0.0), + "polaroid600": (88, 107, 79, 79, 0.0), +} + +DEFAULT_BORDER = "#f2f2f2" # 边框底色:中性灰白(原来是偏暖的奶白 #f7f4ec) +# 默认:竖排用 Mini、横拍用 Wide。两张胶片的基准方向鄽与拍摄方向一致, +# 于是宽边永远落在下方(文字不用转 90°),相当于「竖着拿 Mini、平着拿 Wide」。 +DEFAULT_FILM = "instax_mini,instax_wide" + +# 手写感排序:随工具自带的手写体 > 系统里的中文手写体 +HERE = Path(__file__).resolve().parent +BUNDLED_FONT = "fd0176cfb4dce3888188b7f89e5dfd5b.ttf" # 罗西硬笔柳体 +FONT_CANDIDATES = list(dict.fromkeys([ + str(HERE / BUNDLED_FONT), + *[str(p) for p in sorted(HERE.glob("*.tt[fc]"))], # 你往 tools/ 里放别的字体也能自动认 + "C:/Windows/Fonts/STXINGKA.TTF", # 华文行楷 + "C:/Windows/Fonts/FZSTK.TTF", # 方正舒体 + "C:/Windows/Fonts/simkai.ttf", # 楷体 + "C:/Windows/Fonts/STXINWEI.TTF", # 华文新魏 + "C:/Windows/Fonts/STKAITI.TTF", # 华文楷体 + "/System/Library/Fonts/Supplemental/Kaiti.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "C:/Windows/Fonts/msyh.ttc", # 兜底:雅黑(无手写感但不缺字) +])) + +IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"} +EXIF_ORIENTATION = 274 +EXIF_DATETIME_ORIGINAL = 36867 +EXIF_DATETIME = 306 +CHIN_SIDES = ("bottom", "left", "right", "top") + + +# ── 基本工具 ────────────────────────────────────────────────────────────── + +def pick_font(custom: str | None) -> str: + if custom: + return custom + for path in FONT_CANDIDATES: + if Path(path).exists(): + return path + raise SystemExit("找不到可用字体,请用 --font 指定字体文件路径") + + +def load_captions(path: str | None, sep: str) -> dict[str, str]: + """captions 文件:每行 `文件名<分隔符>文字`,# 开头是注释。""" + if not path: + return {} + raw_bytes = Path(path).read_bytes() + for enc in ("utf-8-sig", "gbk"): # 记事本另存成 ANSI 也能读 + try: + content = raw_bytes.decode(enc) + break + except UnicodeDecodeError: + continue + else: + raise SystemExit(f"读不了 {path},请另存为 UTF-8") + table: dict[str, str] = {} + for raw in content.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + name, _, text = line.partition(sep) + table[Path(name.strip()).name] = text.strip() + return table + + +CN_DIGIT = "〇一二三四五六七八九" + + +def cn_number(n: int) -> str: + """中文数词读法(不是逐位读):10→十、12→十二、20→二十、25→二十五、105→一百零五。 + + 这就是「10 月要写成十月」而不是「一十月」的关键。 + """ + if n < 0: + return "负" + cn_number(-n) + if n < 10: + return CN_DIGIT[n] + if n < 20: + return "十" + (CN_DIGIT[n - 10] if n > 10 else "") + if n < 100: + tens, rest = divmod(n, 10) + return CN_DIGIT[tens] + "十" + (CN_DIGIT[rest] if rest else "") + if n < 1000: + hundreds, rest = divmod(n, 100) + head = CN_DIGIT[hundreds] + "百" + if rest == 0: + return head + if rest < 10: + return head + "零" + cn_number(rest) + if rest < 20: + return head + "一" + cn_number(rest) # 115 → 一百一十五(不是一百十五) + return head + cn_number(rest) + thousands, rest = divmod(n, 1000) + head = CN_DIGIT[thousands] + "千" + if rest == 0: + return head + if rest < 100: + return head + "零" + cn_number(rest) + if rest < 200: + return head + "一" + cn_number(rest) + return head + cn_number(rest) + + +def to_cn_digits(text: str) -> str: + """把文字里的阿拉伯数字换成中文。 + + 年份逐位读(2026 年 → 二〇二六年),其余按数词读(10 月 → 十月)。 + 判别方式:紧跟「年」的按年份;或本身就是 1000~2999 的四位数(平时写 2026 + 就是指年份),也按年份。 + """ + out, last = [], 0 + for match in re.finditer(r"\d+", text): + out.append(text[last:match.start()]) + digits = match.group(0) + is_year = text[match.end():match.end() + 1] == "年" or ( + len(digits) == 4 and 1000 <= int(digits) <= 2999) + if is_year: + out.append("".join(CN_DIGIT[int(c)] for c in digits)) + else: + out.append(cn_number(int(digits))) + last = match.end() + out.append(text[last:]) + return "".join(out) + + +def exif_date(im: Image.Image) -> datetime | None: + try: + exif = im.getexif() + except Exception: + return None + for tags in (exif, exif.get_ifd(0x8769)): + for tag in (EXIF_DATETIME_ORIGINAL, EXIF_DATETIME): + raw = tags.get(tag) + if raw: + try: + return datetime.strptime(str(raw).strip(), "%Y:%m:%d %H:%M:%S") + except ValueError: + pass + return None + + +def caption_for(src: Path, im: Image.Image, args, table: dict[str, str]) -> str: + if src.name in table: + text = table[src.name] + elif args.text: + text = args.text + elif args.caption_source == "filename": + text = src.stem + else: + stamp = exif_date(im) or datetime.fromtimestamp(src.stat().st_mtime) + text = stamp.strftime(args.date_format) + return to_cn_digits(text) if args.cn_digits else text + + +# ── 几何:全部由胶片毫米尺寸推导 ────────────────────────────────────────── + +@dataclass(frozen=True) +class Layout: + outer: tuple[int, int] # 成片画布 + image_box: tuple[int, int, int, int] # 画面区(照片贴这儿) + radius: int # 画面区圆角 px + chin_px: int # 宽边宽度 px + + +def layout(film_key: str, chin: str, photo_size: int, inner_radius_mm: float | None = None + ) -> Layout: + """算出某个厚边朝向下画布的尺寸和画面区位置。 + + photo_size 是画面区「长边」的像素数,所以换成任何胶片规格,成片大小都可比。 + """ + fw, fh, iw, ih, rad_mm = FILMS[film_key] + if inner_radius_mm is not None: + rad_mm = inner_radius_mm + scale = photo_size / max(iw, ih) + thin = round((fw - iw) / 2 * scale) # 左/右/上三个细边等宽 + cw, ch = round(iw * scale), round(ih * scale) + chin_px = round(fh * scale) - thin - ch # 宽边 = 底片高 - 细边 - 画面高 + fw_px, fh_px = round(fw * scale), round(fh * scale) + radius = round(rad_mm * scale) + + if chin == "left": # 基准版顺时针转 90° + return Layout((fh_px, fw_px), (chin_px, thin, chin_px + ch, thin + cw), radius, chin_px) + if chin == "right": # 逆时针转 90° + return Layout((fh_px, fw_px), (thin, thin, thin + ch, thin + cw), radius, chin_px) + if chin == "top": # 转 180° + return Layout((fw_px, fh_px), (thin, chin_px, thin + cw, chin_px + ch), radius, chin_px) + return Layout((fw_px, fh_px), (thin, thin, thin + cw, thin + ch), radius, chin_px) + + +def base_layout(film_key: str, photo_size: int, inner_radius_mm: float | None = None) -> Layout: + """基准朝向(宽边在下)的版式,画边框和文字都在这上面做。""" + return layout(film_key, "bottom", photo_size, inner_radius_mm) + + +def margins_mm(film_key: str, chin: str) -> tuple[float, float, float, float]: + """四个边的理论留白(左, 上, 右, 下),单位 mm。就是个便于对照规格图的工具。""" + fw, fh, iw, ih, _ = FILMS[film_key] + thin = (fw - iw) / 2 + chin_mm = fh - ih - thin + return {"bottom": (thin, thin, thin, chin_mm), "top": (thin, chin_mm, thin, thin), + "left": (chin_mm, thin, thin, thin), "right": (thin, thin, chin_mm, thin)}[chin] + + +def parse_films(spec: str) -> tuple[str, str]: + """--film 可以是单个规格,也可以是「竖排规格,横拍规格」。""" + parts = [s.strip() for s in spec.split(",") if s.strip()] + for name in parts: + if name not in FILMS: + raise SystemExit(f"未知胶片规格 {name!r},可选:{', '.join(sorted(FILMS))}") + if len(parts) == 1: + return parts[0], parts[0] + if len(parts) != 2: + raise SystemExit("--film 只能是单个规格,或「竖排规格,横拍规格」两项") + return parts[0], parts[1] + + +def film_for(photo: tuple[int, int], films: tuple[str, str]) -> str: + """竖排照片用第一种胶片,横拍用第二种。""" + return films[0] if photo[1] >= photo[0] else films[1] + + +def chin_for(photo: tuple[int, int], film_key: str, override: str) -> str: + """真机规则:拍摄方向和胶片基准方向一致 → 宽边在下;反了 → 转 90°,宽边在左。""" + if override != "auto": + return override + fw, fh, _, _, _ = FILMS[film_key] + return "bottom" if (photo[0] > photo[1]) == (fw > fh) else "left" + + +# ── 遮罩 / 做旧用的场 ───────────────────────────────────────────────────── + +def rounded_mask(size: tuple[int, int], radius: int, ss: int = 3) -> Image.Image: + """圆角矩形遮罩(255 在内)。超采样再缩回来,圆角才没锯齿。""" + if radius <= 0: + return Image.new("L", size, 255) + big = Image.new("L", (size[0] * ss, size[1] * ss), 0) + ImageDraw.Draw(big).rounded_rectangle( + (0, 0, size[0] * ss - 1, size[1] * ss - 1), radius=radius * ss, fill=255) + return big.resize(size, Image.Resampling.LANCZOS) + + +def smooth_field(size: tuple[int, int], cell: int, seed: int) -> Image.Image: + """低频明暗场:白噪声 → 放大插值 → 大块起伏,模拟白边受光不匀。""" + small = (max(2, size[0] // cell + 1), max(2, size[1] // cell + 1)) + rng = random.Random(seed) + field = Image.new("L", small) + field.putdata([rng.randint(0, 255) for _ in range(small[0] * small[1])]) + return field.resize(size, Image.Resampling.BICUBIC) + + +def stretch(field: Image.Image, lo: int, hi: int) -> Image.Image: + """把噪点场的动态范围拉满到 [lo, hi]。""" + fmin, fmax = field.getextrema() + if fmax <= fmin: + return Image.new("L", field.size, (lo + hi) // 2) + scale = (hi - lo) / (fmax - fmin) + return field.point(lambda v: int(lo + (v - fmin) * scale)) + + +def darken_by(mask: Image.Image, depth: int) -> Image.Image: + """把「越暗越深」的遮罩映射成乘性遮罩:255 不变,最深暗 depth 级。""" + return mask.point(lambda v: 255 - (255 - v) * depth // 255) + + +def edge_ring(size: tuple[int, int], box, radius: int, spread: int, + depth: int) -> Image.Image: + """沿 box(画面区矩形)**外侧**一圈柔和变暗——像是照片浮在白边之上投下的影子。 + + box 内部恒为 255,所以照片像素乘完一点没变。 + """ + m = Image.new("L", size, 255) + d = ImageDraw.Draw(m) + d.rounded_rectangle((box[0] - spread, box[1] - spread, + box[2] + spread, box[3] + spread), + radius=radius + spread, fill=0) + d.rounded_rectangle(box, radius=radius, fill=255) + m = m.filter(ImageFilter.GaussianBlur(max(1, spread // 2))) + # 抹掉模糊渗进照片的那一两像素,保证画面区逐像素不变 + ImageDraw.Draw(m).rounded_rectangle(box, radius=radius, fill=255) + return darken_by(m, depth) + + +def inner_shadow_field(size: tuple[int, int], box, radius: int, spread: int, + depth: int) -> Image.Image: + """沿 box **内侧**一圈柔和变暗——边框压在照片上,影子落在画面里侧边缘。 + + 盒外恒为 255(白边完全不受影响)。注意这个效果会压暗照片最外沿那一圈, + 其余画面区仍然逐像素不变。 + """ + band = Image.new("L", size, 255) + d = ImageDraw.Draw(band) + d.rounded_rectangle(box, radius=radius, fill=0) + d.rounded_rectangle((box[0] + spread, box[1] + spread, + box[2] - spread, box[3] - spread), + radius=max(0, radius - spread), fill=255) + band = band.filter(ImageFilter.GaussianBlur(max(1, spread // 2))) + inside = Image.new("L", size, 0) + ImageDraw.Draw(inside).rounded_rectangle(box, radius=radius, fill=255) + # 盒外强制 255:阴影只允许落在照片里,不污染白边 + return darken_by(Image.composite(band, Image.new("L", size, 255), inside), depth) + + +def rim_mask(size: tuple[int, int], spread: int, depth: int) -> Image.Image: + """画布外沿柔和变暗。 + + 在 1/8 分辨率上做再放大:大半径高斯很贵,而这个场本来就是低频的。 + spread 必须远大于窄边宽,否则在宽边那片大片空白里会看到一条暗带及其 + 消隐边界(曾经用 长边/40,压暗刚好在窄边宽处结束,就成了一条可见分界线)。 + """ + ss = 8 + small = (max(4, size[0] // ss), max(4, size[1] // ss)) + s = max(2, spread // ss) + m = Image.new("L", small, 0) + ImageDraw.Draw(m).rounded_rectangle( + (s, s, small[0] - s - 1, small[1] - s - 1), radius=s, fill=255) + m = m.filter(ImageFilter.GaussianBlur(s)) + return darken_by(m.resize(size, Image.Resampling.BILINEAR), depth) + + +def multiply_rgb(frame: Image.Image, field: Image.Image) -> Image.Image: + out = frame.copy() + out.paste(ImageChops.multiply(frame.convert("RGB"), + Image.merge("RGB", (field, field, field))), (0, 0)) + return out + + +def apply_signed(frame: Image.Image, field: Image.Image) -> None: + """把一张 L 场(128 为中点)当作有符号偏移加到画面上:正的加、负的减、均值不动。""" + pos = field.point(lambda v: max(0, v - 128)) + neg = field.point(lambda v: max(0, 128 - v)) + frame.paste(ImageChops.subtract( + ImageChops.add(frame.convert("RGB"), Image.merge("RGB", (pos, pos, pos))), + Image.merge("RGB", (neg, neg, neg))), (0, 0)) + + +def stripe_field(size: tuple[int, int], angle: float, period: int, amount: int, + width: float = 0.07) -> Image.Image: + """方向性条纹场(L 图,128 为中点)。 + + 先画横条纹再整体旋转,比逐像素算投影便宜得多,而且不会有拼接缝。 + 每个周期里只有「线心」那一条被压暗,其余保持底色 —— 所以看上去是 + 细灰线,而不是宽灰带。(早先做成「宽灰底+细亮脊」,视觉上就是粗灰带。) + + amount 是线心的压暗级数,width 是线宽(高斯 σ 占周期的比例,越小线越细)。 + + angle 就是「条纹线本身的方向」,角度从水平顺时针增大: + 0=水平(—),45=左上→右下(\),90=垂直(|),135=左下→右上(/),180 回到水平。 + 注意 PIL 的 rotate 在 y 向下的坐标系里视觉上是反的,所以这里取负号, + 这样参数值等于实测方向(已用对角线方差验证),否则你没法自查。 + """ + diag = int(math.hypot(*size)) + 2 + prof = Image.new("L", (diag, diag), 128) + draw = ImageDraw.Draw(prof) + sigma = max(0.35, period * width) + k = 2 * (sigma / period) ** 2 + for y in range(diag): + t = (y % period) / period - 0.5 + line = math.exp(-(t * t) / k) + draw.line((0, y, diag, y), fill=max(0, 128 - int(amount * line))) + rotated = prof.rotate(-angle, resample=Image.Resampling.BILINEAR) + left, top = (diag - size[0]) // 2, (diag - size[1]) // 2 + return rotated.crop((left, top, left + size[0], top + size[1])) + + +# 强度换算成灰度级数:这几个数字决定「看得见但不过分」的量级 +STRIPE_LEVELS = 40 + + +def add_outer_shadow(frame: Image.Image, args) -> Image.Image: + """在胶片外侧留出一圈底色(并投影)。 + + 阴影必须有比它亮的背景才看得见,所以底色是黑的时候投影实际不可见 + (等于只留一圈黑框)。底色用 --outer-bg 改,透明用 --outer-bg none(需 png)。 + """ + long_side = max(frame.size) + margin = round(args.shadow_margin * long_side) + blur = max(2, round(0.012 * long_side)) + offset = round(0.008 * long_side) + strength = int(255 * args.shadow) + bg = args.outer_bg.strip().lower() + transparent = bg in ("none", "transparent") + if transparent and args.format != "png": + transparent, bg = False, "#ffffff" # JPEG 没有 alpha,退回白底 + if margin <= 0: + return frame + + canvas = Image.new("RGBA", (frame.width + 2 * margin, frame.height + 2 * margin), + (0, 0, 0, 0) if transparent else ImageColor.getrgb(bg) + (255,)) + silhouette = Image.new("L", canvas.size, 0) + silhouette.paste(frame.getchannel("A"), (margin, margin + offset)) + shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + shadow.putalpha(silhouette.filter(ImageFilter.GaussianBlur(blur)) + .point(lambda v: v * strength // 255)) + out = Image.alpha_composite(canvas, shadow) + out.paste(frame, (margin, margin), frame) + return out + + +def apply_paper(frame: Image.Image, seed: int, args) -> None: + """白边上的真实纸感:受光不匀 → 外缘变暗 → 纸纹颗粒 → 条纹 → 灰点。""" + size = frame.size + long_side = max(size) + + if args.light > 0: + blotch = stretch(smooth_field(size, max(8, long_side // 8), seed), + 255 - round(18 * args.light), 255) + frame.paste(multiply_rgb(frame, blotch), (0, 0)) + + if args.rim > 0: + # 外缘略暗,但要极缓:扩散半径取长边/10(远大于窄边),深度也只有几级 + rim = rim_mask(size, max(8, long_side // 10), round(5 * args.rim)) + frame.paste(multiply_rgb(frame, rim), (0, 0)) + + if args.texture > 0: + apply_signed(frame, Image.effect_noise(size, 10.0) + .point(lambda v: (v - 128) * max(1, round(9 * args.texture)) // 127 + 128)) + + if args.stripe > 0: + # 边框表面的方向性细纹(默认 45°),period 按长边比例换算 + period = max(3, round(args.stripe_period * long_side)) + apply_signed(frame, stripe_field(size, args.stripe_angle, period, + round(STRIPE_LEVELS * args.stripe), + args.stripe_width)) + + if args.dust > 0: + add_dust(frame, random.Random(seed ^ 0xD057), args.dust) + + +def add_dust(frame: Image.Image, rng: random.Random, amount: float) -> None: + """真底片上总有几个灰点和短纤维。""" + draw = ImageDraw.Draw(frame, "RGBA") + w, h = frame.size + for _ in range(int(amount * 40)): + x, y = rng.randrange(w), rng.randrange(h) + if rng.random() < 0.75: + alpha = rng.randint(8, 26) + draw.point((x, y), fill=(120, 115, 105, alpha)) + if rng.random() < 0.4: + draw.point((x + 1, y), fill=(120, 115, 105, alpha // 2)) + else: + length = rng.randint(2, 7) + draw.line((x, y, x + length, y + rng.randint(-1, 1)), + fill=(150, 145, 135, rng.randint(6, 16))) + + +# ── 画面处理 ────────────────────────────────────────────────────────────── + +def fit_photo(im: Image.Image, area: tuple[int, int], mode: str, + gravity: str) -> Image.Image: + """把照片放进 area 大小的画面区(crop=裁满,fit=整张保留)。""" + aw, ah = area + if mode == "crop": + scale = max(aw / im.width, ah / im.height) + new = (max(aw, round(im.width * scale)), max(ah, round(im.height * scale))) + im = im.resize(new, Image.Resampling.LANCZOS) + dx, dy = (new[0] - aw) // 2, (new[1] - ah) // 2 + if gravity.startswith("top"): + dy = 0 + elif gravity.startswith("bottom"): + dy = new[1] - ah + if gravity.endswith("left"): + dx = 0 + elif gravity.endswith("right"): + dx = new[0] - aw + return im.crop((dx, dy, dx + aw, dy + ah)) + + scale = min(aw / im.width, ah / im.height) + return im.resize((max(1, round(im.width * scale)), max(1, round(im.height * scale))), + Image.Resampling.LANCZOS) + + +def draw_caption(canvas: Image.Image, caption: str, font: ImageFont.FreeTypeFont, + x: int, center_y: int, color, hand: bool, rng: random.Random) -> None: + """在 canvas 上写一行字。hand=True 时逐字微旋转 + 微抖动,模拟手写。 + + 纵向按「墨迹」而不是「行框」居中:不同字体的 ascent/descent 差很多, + 按行框居中的话换个字体就得手调 --text-y。 + """ + draw = ImageDraw.Draw(canvas) + as_, ds = font.getmetrics() + bbox = font.getbbox(caption) or (0, 0, 0, as_ + ds) + top = center_y - (bbox[1] + bbox[3]) // 2 + + if not hand: + draw.text((x, top), caption, font=font, fill=color) + return + + jitter = max(1.0, font.size * 0.02) + pen = float(x) + for ch in caption: + w = font.getlength(ch) + pad = int(font.size * 0.4) + tile = Image.new("RGBA", (int(w) + pad * 2, as_ + ds + pad * 2), (0, 0, 0, 0)) + ImageDraw.Draw(tile).text((pad, pad), ch, font=font, fill=color) + tile = tile.rotate(rng.uniform(-2.2, 2.2), resample=Image.Resampling.BICUBIC) + canvas.paste(tile, (int(pen) - pad + int(rng.uniform(-jitter, jitter)), + top - pad + int(rng.uniform(-jitter, jitter))), tile) + pen += w + rng.uniform(-jitter * 0.4, jitter * 0.4) + + +def make_film(im: Image.Image, caption: str, args, font_path: str, seed: int, + chin: str, film: str) -> Image.Image: + base = base_layout(film, args.photo_size, args.inner_radius) + final = layout(film, chin, args.photo_size, args.inner_radius) + + # 先在基准朝向(宽边在下)上画边框和文字,转框不会把照片转歪 + frame = Image.new("RGB", base.outer, ImageColor.getrgb(args.border_color)) + apply_paper(frame, seed, args) + + if caption: + indent = round(args.photo_size * args.text_indent) + start_x = base.image_box[0] + indent # 与画面区左缘对齐,再缩进一点 + size_px = max(12, round(args.photo_size * args.font_ratio)) + font = ImageFont.truetype(font_path, size_px) + if args.fit_text: + # 有些手写体(比如硬笔柳体)数字是全角,长题字会冲出胶片,这里自动缩到能放下。 + # 可用长度必须从真实起点算起:漏掉画面区左边距就会让字贴到胶片边上。 + avail = base.outer[0] - start_x - indent + need = font.getlength(caption) + if need > avail > 0: + font = ImageFont.truetype(font_path, max(12, int(size_px * avail / need))) + draw_caption(frame, caption, font, start_x, + base.outer[1] - base.chin_px + round(base.chin_px * args.text_y), + args.text_color, args.hand, random.Random(seed)) + + if chin == "left": + frame = frame.transpose(Image.Transpose.ROTATE_270) # 顺时针 90° + elif chin == "right": + frame = frame.transpose(Image.Transpose.ROTATE_90) + elif chin == "top": + frame = frame.transpose(Image.Transpose.ROTATE_180) + + area = (final.image_box[2] - final.image_box[0], final.image_box[3] - final.image_box[1]) + inner = fit_photo(im, area, args.mode, args.gravity) + photo = inner.convert("RGBA") + photo.putalpha(rounded_mask(inner.size, final.radius)) + canvas = frame.convert("RGBA") + canvas.paste(photo, (final.image_box[0] + (area[0] - inner.width) // 2, + final.image_box[1] + (area[1] - inner.height) // 2), photo) + frame = canvas.convert("RGB") + + # 照片实际占的矩形——接缝和黑内框都跟着它,不跟画面区 + box = (final.image_box[0] + (area[0] - inner.width) // 2, + final.image_box[1] + (area[1] - inner.height) // 2, + final.image_box[0] + (area[0] + inner.width) // 2, + final.image_box[1] + (area[1] + inner.height) // 2) + + spread = args.edge_spread or max(2, max(frame.size) // 300) + if args.edge > 0 and args.edge_side in ("in", "both"): + # 边框压在照片上,影子落在画面里侧 + frame = multiply_rgb(frame, inner_shadow_field( + frame.size, box, final.radius, spread, round(40 * args.edge))) + + if args.edge > 0 and args.edge_side in ("out", "both"): + # 接缝必须跟着「照片」而不是「画面区」:fit 时两者不重合, + # 绕画面区画会在白边上浮出一个假边框 + frame = multiply_rgb(frame, edge_ring(frame.size, box, final.radius, spread, + round(40 * args.edge))) + + if args.inner_line > 0: + # 画面区外沿的 1~2px 黑框。画在照片外侧,所以照片像素一点不变。 + w = args.inner_line + ImageDraw.Draw(frame).rounded_rectangle( + (box[0] - w, box[1] - w, box[2] + w - 1, box[3] + w - 1), + radius=final.radius + w, outline=ImageColor.getrgb(args.inner_line_color), + width=w) + + out = frame.convert("RGBA") + if args.radius > 0 and args.format == "png": + out.putalpha(rounded_mask(out.size, args.radius)) + if args.shadow > 0: + out = add_outer_shadow(out, args) + return out + + +# ── 单文件流水线 ────────────────────────────────────────────────────────── + +def process(src: Path, dst_dir: Path, args, table: dict[str, str], font_path: str) -> dict: + with Image.open(src) as raw: + # 关键:先按 EXIF 方向摆正,否则竖拍照片会躺着出锅 + im = ImageOps.exif_transpose(raw).convert("RGB") + exif = raw.getexif() + icc = raw.info.get("icc_profile") + caption = caption_for(src, raw, args, table) + # 不能拿内置 hash() 当种子:它每个进程都会加盐,会让 --hand 不可重跑 + seed = zlib.crc32(src.name.encode()) & 0xFFFF + film = film_for(im.size, args.films) + chin = chin_for(im.size, film, args.chin) + + framed = make_film(im, caption, args, font_path, seed, chin, film) + + dst = dst_dir / (src.stem + "." + args.format) + dst.parent.mkdir(parents=True, exist_ok=True) + + save_kw: dict = {"quality": args.quality, "subsampling": 0, "optimize": True} + if args.keep_metadata and exif: + exif[EXIF_ORIENTATION] = 1 # 已经摆正过了 + save_kw["exif"] = exif.tobytes() + if icc and args.keep_metadata: + save_kw["icc_profile"] = icc # 丢了 ICC,调色后的颜色会整体偏 + if args.format in ("jpg", "jpeg"): + framed.convert("RGB").save(dst, "JPEG", progressive=True, **save_kw) + else: + framed.save(dst, "PNG") + return {"src": str(src), "out": str(dst), "caption": caption, + "film": film, "chin": chin, "size": f"{framed.width}x{framed.height}"} + + +def too_small(size: tuple[int, int], area: tuple[int, int], mode: str) -> bool: + """这张源图放进画面区会不会被放大(放大就一定发虚)。""" + w, h = size + aw, ah = area + scale = max(aw / w, ah / h) if mode == "crop" else min(aw / w, ah / h) + return scale > 1.0 + + +def excluded(path: Path, root: Path, patterns: list[str]) -> bool: + """相对路径 / 文件名 / 任一层目录名命中 glob 就算排除。""" + import fnmatch + rel = path.relative_to(root) + names = [path.name, str(rel).replace("\\", "/")] + [p.name for p in rel.parents if p.name] + return any(fnmatch.fnmatch(n, pat) for pat in patterns for n in names) + + +def main() -> int: + p = argparse.ArgumentParser(description="批量套拍立得 / Instax 边框 + 文字") + p.add_argument("input", help="输入目录(递归)或单个文件") + p.add_argument("-o", "--out", required=True, help="输出目录") + p.add_argument("--text", help="所有照片用同一句文字") + p.add_argument("--captions", help="逐张文字表:文件名<分隔符>文字") + p.add_argument("--sep", default="\t", help="captions 分隔符,默认 Tab") + p.add_argument("--caption-source", choices=["exif", "filename"], default="exif", + help="没给文字时从哪取(默认 exif 拍摄时间)") + p.add_argument("--cn-digits", action="store_true", + help="把题字里的阿拉伯数字换成中文:年份逐位读(2026→二〇二六)," + "其余按数词读(10 月→十月、25 日→二十五日)") + p.add_argument("--date-format", default="%Y.%m.%d", + help="EXIF 日期格式,默认 %%Y.%%m.%%d") + p.add_argument("--film", choices=None, default=DEFAULT_FILM, + help=f"胶片规格,默认 {DEFAULT_FILM}(竖排用第一个、横拍用第二个);" + f"也可以只写一个,可选:{', '.join(sorted(FILMS))}") + p.add_argument("--photo-size", type=int, default=1200, + help="画面区长边 px,默认 1200") + p.add_argument("--mode", choices=["crop", "fit"], default="crop", + help="crop=裁满画面区(默认,真机就是这样);fit=整张保留") + p.add_argument("--crop", dest="mode", action="store_const", const="crop", + help="同 --mode crop") + p.add_argument("--fit", dest="mode", action="store_const", const="fit", + help="同 --mode fit") + p.add_argument("--gravity", default="center", + choices=["center", "top", "bottom", "left", "right", + "top-left", "top-right", "bottom-left", "bottom-right"], + help="crop 时保留哪个部位(人像常需要 top)") + p.add_argument("--chin", choices=["auto", *CHIN_SIDES], default="auto", + help="宽边在哪一侧。auto=跟随拍摄方向,与真机一致") + p.add_argument("--font", help="字体文件,默认自动选中文手写体") + p.add_argument("--font-ratio", type=float, default=0.058, + help="字号 / 画面区长边") + p.add_argument("--text-indent", type=float, default=0.03, + help="文字起点 / 画面区长边") + p.add_argument("--text-y", type=float, default=0.42, + help="文字在宽边内的位置:0=靠画面,1=靠外缘") + p.add_argument("--text-color", default="#2b2b2b", help="文字颜色,默认近黑") + p.add_argument("--hand", action="store_true", help="手写抖动效果") + p.add_argument("--no-fit-text", dest="fit_text", action="store_false", + help="题字超出宽边时不要自动缩字(默认会自动缩)") + # ── 做旧:默认开,想要纯白平边框就用 --flat ── + p.add_argument("--flat", action="store_true", + help="关掉全部做旧效果(受光不匀/纸纹/灰点/条纹/接缝/投影),只留底色和文字") + p.add_argument("--light", type=float, default=0.5, help="白边受光不匀强度 0~1") + p.add_argument("--rim", type=float, default=0.5, + help="外缘变暗强度 0~1,默认 0.5(很轻)。若在宽边上还看得到暗带,给 0") + p.add_argument("--texture", "--grain", dest="texture", type=float, default=0.35, + help="纸纹颗粒强度 0~1") + p.add_argument("--dust", type=float, default=0.3, help="灰点/纤维强度 0~1") + p.add_argument("--edge", type=float, default=0.6, + help="画面区边缘的阴影强度 0~1,默认 0.6") + p.add_argument("--edge-spread", type=int, default=0, metavar="PX", + help="阴影向内(或向外)延伸多少 px,默认 0=自动(长边/300 ≈ 4px)") + p.add_argument("--edge-side", choices=["in", "out", "both"], default="in", + help="阴影落在哪侧:in=照片内侧(默认,边框压在照片上的感觉);" + "out=白边一侧;both=两侧都加") + p.add_argument("--inner-line", type=int, default=1, metavar="PX", + help="画面区内侧的黑框宽度 px,默认 1;0=不要") + p.add_argument("--inner-line-color", default="#000000", help="黑框颜色,默认纯黑") + p.add_argument("--stripe", type=float, default=0.3, + help="条纹强度 0~1,默认 0.3;0=不要") + p.add_argument("--stripe-angle", type=float, default=45.0, metavar="DEG", + help="条纹线方向(度,从水平顺时针):0=水平,45=左上→右下(\)," + "90=垂直,135=左下→右上(/)。默认 45") + p.add_argument("--stripe-period", type=float, default=0.008, + help="条纹间距 / 长边,默认 0.008(Wide 约 10px)") + p.add_argument("--stripe-width", type=float, default=0.07, + help="灰线粗细 / 间距,默认 0.07(越小线越细)") + p.add_argument("--shadow", type=float, default=0.0, + metavar="AMOUNT", help="胶片外侧投影强度 0~1,默认 0(关);" + "要开得同时给 --shadow-margin") + p.add_argument("--shadow-margin", type=float, default=0.0, + help="外侧底色圈宽度 / 长边,默认 0(不加外圈)") + p.add_argument("--outer-bg", "--shadow-bg", dest="outer_bg", default="#ffffff", + metavar="COLOR", help="外圈底色,仅在 --shadow-margin > 0 时有意义。" + "阴影只有在比它亮的底色上才看得见;给 none 则放进" + " alpha(需 --format png)") + p.add_argument("--inner-radius", type=float, default=None, metavar="MM", + help="画面区圆角半径 mm。默认 0:真机画面区是直角") + p.add_argument("--border-color", default=DEFAULT_BORDER, help="边框底色,默认灰白 #f2f2f2") + p.add_argument("--radius", type=int, default=0, help="外框圆角(仅 png 输出生效)") + p.add_argument("--format", default="jpg", choices=["jpg", "jpeg", "png"]) + p.add_argument("--quality", type=int, default=92) + p.add_argument("--strip-metadata", dest="keep_metadata", action="store_false", + help="剥掉 EXIF/GPS/ICC(默认保留拍摄时间、GPS 和色彩配置)") + p.add_argument("--skip-small", action="store_true", + help="跳过会被放大而发虚的源图(边框模板、图标混进来时的保险)") + p.add_argument("--skip-existing", action="store_true", help="输出已存在就跳过") + p.add_argument("--exclude", action="append", default=[], metavar="GLOB", + help="跳过匹配的输入(可重复)。匹配相对路径、文件名或任一层目录名,\n" + "例如 --exclude 'processed' --exclude 'polaroid_preview/*'") + p.add_argument("--limit", type=int, help="只处理前 N 张,用来快速试参数") + p.add_argument("--manifest", help="把处理结果写成 CSV") + p.add_argument("--dry-run", action="store_true", help="只列出会做什么") + p.add_argument("-j", "--jobs", type=int, default=0, help="并行进程数,0=CPU 核数") + args = p.parse_args() + + if args.flat: + args.light = args.rim = args.texture = args.dust = args.edge = args.stripe = 0.0 + args.shadow = 0.0 + # 不劫持边框底色:--flat 只表示「不做旧」,颜色照 --border-color + + src_root = Path(args.input).resolve() + dst_root = Path(args.out).resolve() + if src_root.is_dir() and dst_root == src_root: + print("[错误] 输出目录不能等于输入目录,会覆盖原图", file=sys.stderr) + return 2 + if dst_root.exists() and not dst_root.is_dir(): + print(f"[错误] 输出路径已存在且不是目录:{dst_root}", file=sys.stderr) + return 2 + + if src_root.is_file(): + files = [src_root] + else: + files = sorted(f for f in src_root.rglob("*") + if f.suffix.lower() in IMAGE_EXT and f.is_file()) + # 输出目录如果在输入目录里(比如 -o photos/polaroid),别把刚生成的图 + # 再当成原图处理一遍——套两层框的下场很难看 + files = [f for f in files if dst_root not in f.resolve().parents] + if args.exclude: + files = [f for f in files if not excluded(f, src_root, args.exclude)] + if args.limit: + files = files[:args.limit] + files = [f for f in files if not (args.skip_existing + and (dst_root / (f.stem + "." + args.format)).exists())] + if not files: + print("没有需要处理的文件") + return 0 + + font_path = pick_font(args.font) + table = load_captions(args.captions, args.sep) + base = src_root if src_root.is_dir() else src_root.parent + for f in files: + (dst_root / f.relative_to(base)).parent.mkdir(parents=True, exist_ok=True) + + args.films = parse_films(args.film) + + fw, fh, iw, ih, _ = FILMS[args.films[0]] + print(f"共 {len(files)} 张 → {dst_root}") + if args.films[0] == args.films[1]: + fw, fh, iw, ih, _ = FILMS[args.films[0]] + print(f"胶片 {args.films[0]}(底片 {fw:g}x{fh:g} 画面 {iw:g}x{ih:g}mm)" + f" / 画面区长边 {args.photo_size}px / {args.mode} / 宽边 {args.chin}") + else: + print(f"胶片 竖排={args.films[0]} 横拍={args.films[1]}" + f" / 画面区长边 {args.photo_size}px / {args.mode} / 宽边 {args.chin}") + print(f"字体 {font_path}") + if args.films[0] == args.films[1]: + for side in CHIN_SIDES: + gaps = " / ".join(f"{v:.1f}" for v in margins_mm(args.films[0], side)) + print(f" 宽边在{side:6s} → 左/上/右/下留白 {gaps} mm") + else: + for film in args.films: + fw, fh, iw, ih, _ = FILMS[film] + gaps = " / ".join(f"{v:.1f}" for v in margins_mm(film, "bottom")) + lay_b = layout(film, "bottom", args.photo_size) + lay_l = layout(film, "left", args.photo_size) + print(f" {film:14s} 底片 {fw:g}x{fh:g} 画面 {iw:g}x{ih:g}mm;" + f"宽边在下留白 {gaps}mm → {lay_b.outer[0]}x{lay_b.outer[1]}," + f"宽边在左 → {lay_l.outer[0]}x{lay_l.outer[1]}") + + # 预检:会被放大的源图一定发虚(边框模板、图标、成品图都是这类) + small = [] + for f in files: + try: + with Image.open(f) as probe: + size = probe.size + except Exception: # noqa: BLE001 + continue + film = film_for(size, args.films) + area = layout(film, chin_for(size, film, args.chin), + args.photo_size, args.inner_radius).image_box + if too_small(size, (area[2] - area[0], area[3] - area[1]), args.mode): + small.append((f, size)) + if small: + if args.skip_small: + drop = {f for f, _ in small} + files = [f for f in files if f not in drop] + print(f"已跳过 {len(small)} 张放进画面区会被放大的图") + else: + print(f"注意:{len(small)} 张放进画面区会被放大而发虚" + "(加 --skip-small 可跳过):") + for f, (w, h) in small[:5]: + print(f" {f.name} {w}x{h}") + if len(small) > 5: + print(f" ...还有 {len(small) - 5} 张") + if not files: + print("跳完就没剩什么了") + return 0 + + if args.dry_run: + for f in files: + print(" ", f.relative_to(base)) + return 0 + + jobs = max(1, args.jobs or (__import__("os").cpu_count() or 4)) + results, failed = [], [] + with ProcessPoolExecutor(max_workers=jobs) as pool: + futures = {pool.submit(process, f, dst_root / f.relative_to(base).parent, + args, table, font_path): f for f in files} + for i, fut in enumerate(as_completed(futures), 1): + src = futures[fut] + try: + results.append(fut.result()) + except Exception as exc: # noqa: BLE001 + failed.append((src, exc)) + print(f" [跳过] {src.name}: {exc}", file=sys.stderr) + if i % 25 == 0 or i == len(files): + print(f" {i}/{len(files)}") + + if results: + went: dict[tuple[str, str, str], int] = {} + for r in results: + key = (r["film"], r["chin"], r["size"]) + went[key] = went.get(key, 0) + 1 + print("完成 %d 张:" % len(results) + + ";".join(f"{k[0]} 宽边在{k[1]} {k[2]} x{v}张" for k, v in sorted(went.items()))) + if args.manifest: + with open(args.manifest, "w", newline="", encoding="utf-8-sig") as fh: + writer = csv.DictWriter(fh, fieldnames=["src", "out", "film", "chin", + "caption", "size"]) + writer.writeheader() + writer.writerows(sorted(results, key=lambda r: r["src"])) + print(f"清单写到 {args.manifest}") + if failed: + print(f"失败 {len(failed)} 张", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/upload_to_gallery.sh b/tools/upload_to_gallery.sh new file mode 100755 index 0000000..0290477 --- /dev/null +++ b/tools/upload_to_gallery.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# 把处理好的成片上传到相册服务器。 +# +# 设计原则:先在服务器暂存区校验通过,才动相册目录;同名文件默认不覆盖。 +# 用法: +# bash tools/upload_to_gallery.sh # 传 photos/polaroid 到 /home/gallery/photos +# bash tools/upload_to_gallery.sh --dry-run # 只校验本地清单并打印计划,不联网 +# bash tools/upload_to_gallery.sh --dest /home/gallery/photos/polaroid # 传到子目录,绝不覆盖原图 +# bash tools/upload_to_gallery.sh --overwrite # 明确允许覆盖同名文件 +# +# 上传完还需要在相册后台 Settings -> Scan All 触发扫描才会出现在界面里。 + +set -euo pipefail + +SRC="photos/polaroid" +HOST="tx-ubuntu" +DEST="/home/gallery/photos" +MANIFEST="_sha256.txt" +OVERWRITE=0 +DRY_RUN=0 +OWNER="gallery:gallery" + +while [ $# -gt 0 ]; do + case "$1" in + --src) SRC="$2"; shift 2 ;; + --host) HOST="$2"; shift 2 ;; + --dest) DEST="$2"; shift 2 ;; + --owner) OWNER="$2"; shift 2 ;; + --overwrite) OVERWRITE=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "未知参数:$1" >&2; exit 2 ;; + esac +done + +cd "$SRC" +count=$(ls -1 *.jpg 2>/dev/null | wc -l | tr -d ' ') +[ "$count" -gt 0 ] || { echo "在 $SRC 里没找到 .jpg" >&2; exit 1; } + +echo "== 1/6 本地校验($count 个文件)==" +if [ -f "$MANIFEST" ]; then + sha256sum -c "$MANIFEST" +else + echo "没有 $MANIFEST,现场生成一份" + sha256sum *.jpg | tee "$MANIFEST" +fi + +echo +echo "== 2/6 计划 ==" +echo " 源目录 $(pwd)" +echo " 目标 $HOST:$DEST" +echo " 同名处理 $( [ "$OVERWRITE" = 1 ] && echo '允许覆盖' || echo '不覆盖(发现同名就中止)' )" +if [ "$DRY_RUN" = 1 ]; then + echo + echo "(--dry-run,到此为止,没有联网)" + exit 0 +fi + +echo "== 3/6 连通性 ==" +if ! ssh -o BatchMode=yes -o ConnectTimeout=8 "$HOST" true 2>/dev/null; then + { + echo "连不上 $HOST 的 SSH。依次排查:" + echo " 1) 当前网络是否封了出站 22 端口(酒店/办公网/部分运营商常见)。验证:" + echo " ssh -o ConnectTimeout=8 git@github.com # 也超时就说明是本机网络封了 22" + echo " 换手机热点或别的网络再跑。" + echo " 2) ~/.ssh/config 里有没有这个别名,或用 --host user@ip。" + echo " 3) 服务器是否在线:curl -sI https://gallery.fukun.net | head -1" + } >&2 + exit 4 +fi + +stage="/tmp/pv-photos-$(date +%Y%m%d-%H%M%S)" + +echo +echo "== 4/6 上传到暂存区 $stage ==" +ssh "$HOST" "mkdir -p '$stage'" +if command -v rsync >/dev/null 2>&1; then + rsync -av --partial ./*.jpg "$MANIFEST" "$HOST:$stage/" +else + scp ./*.jpg "$MANIFEST" "$HOST:$stage/" +fi + +echo +echo "== 5/6 服务器侧校验 ==" +ssh "$HOST" "cd '$stage' && sha256sum -c '$MANIFEST'" + +echo +echo "== 6/6 入库 ==" +clash=$(ssh "$HOST" "cd '$stage' && for f in *.jpg; do [ -e '$DEST'/\$f ] && echo \"\$f\"; done" || true) +if [ -n "$clash" ]; then + echo "以下文件在 $DEST 里已存在:" + echo "$clash" | sed 's/^/ /' + if [ "$OVERWRITE" != 1 ]; then + echo + echo "已中止,服务器暂存区保留在 $stage(可以改 --dest 传到子目录重来)。" >&2 + echo "确认要覆盖原图时加 --overwrite。" >&2 + exit 3 + fi + echo "(--overwrite 已开启,继续覆盖)" +fi +# 顺序很讲究,三步都不能换: +# 1) 先改暂存区(真实目录)属主。不能对 $DEST 做 chown -R:/home/gallery/photos 是指向 +# /data/gallery-photos 的软链接,chown -R 默认不跟随,会「退出码 0 但什么都没做」。 +# 2) 再把文件 mtime 设成 EXIF 拍摄时间。Photoview 建 media 记录时用 mtime 初始化 +# date_shot,而它的 EXIF 任务有 bug(先存库后赋值 + 已关联即提前返回)不会纠正, +# 所以 mtime 就是相册里最终显示和排序的日期。 +# 3) 最后用 root 的 mv 移入相册(保留属主和 mtime)。 +ssh "$HOST" "sudo chown -R '$OWNER' '$stage'" +ssh "$HOST" "for f in '$stage'/*.jpg; do sudo -u '${OWNER%%:*}' exiftool -q '-FileModifyDate/dev/null || true; done" +echo "暂存区文件的 mtime(应等于拍摄时间):" +ssh "$HOST" "ls -l --time-style=+%Y-%m-%d_%H:%M '$stage'/*.jpg | awk '{print \" \" \$6, \$7}' | head -5" +ssh "$HOST" "sudo mv '$stage'/*.jpg '$DEST'/" +owners=$(ssh "$HOST" "ls -l '$DEST'/*.jpg | awk '{print \$3\":\"\$4}' | sort | uniq -c") +echo "目标目录属主统计(应为 $OWNER):" +echo "$owners" | sed 's/^/ /' +if echo "$owners" | grep -qv "$OWNER"; then + echo "注意:上面有不属于 $OWNER 的文件,检查一下(可能是旧文件或权限漂移)。" >&2 +fi +ssh "$HOST" "rm -rf '$stage'" + +echo +echo "完成。还差一步:相册后台 Settings -> Scan All 触发扫描,才会出现在界面里。" +echo "公开原图地址形如 https://cdn.fukun.net/gallery/photos/<文件名>"