Banner
Banner

图片压缩上传S3图床

AI 时代,软件已死,你需要什么,直接和 AI 说就行。

我的需求其实非常具体,也非常直接:

按下快捷键 →
如果剪贴板是图片 →
自动去 EXIF →
压缩 →
上传到 S3 →
拿到 URL →
生成 Markdown 图片语法 →
回写剪贴板 →
弹一个通知

这事儿,说难不难,说简单也不简单。


从「找工具」开始的老路径

一开始我用的是 PicGo

说实话,它曾经应该是很辉煌的
插件一大堆,可以压缩、去 EXIF、上传 S3、生成 Markdown,甚至还能把 HEIC 转成 JPEG。

但现在用下来就一个感觉:年久失修

  • 偶尔有 bug
  • 插件市场打不开
  • 配置一多就开始玄学失效

放在以前,我大概会:

  • 重启电脑
  • 重装 PicGo
  • 搜半天 issue
  • 甚至怀疑是不是系统问题

但这次我不想再这么干了

这次我直接选择了一条完全不同的路。

没有再去找“哪个软件能满足我”
而是直接对 ChatGPT 说了一句话:

我想要这样一个功能……(把需求完整描述了一遍)

ChatGPT 给了我两个选项:

  • 原生用 Xcode 写一个 macOS 应用
  • 或者,用 Raycast 扩展

说实话,这一刻挺神奇的。

我压根没想到 Raycast
我甚至一开始还在想,要不要用 Python + Qt 自己写个小工具。

但 AI 提醒了我一件事:

你不一定要“做一个软件”,你只需要“拼出一个流程”。


从「写软件」到「组合能力」

接下来事情变得非常快。

我把完整需求直接丢进 Cursor
让它开始写 Raycast Extension。

中间调了一下几个点:

  • sips 处理图片
  • EXIF 是否真的被移除
  • S3 / R2 上传细节

一个小时左右,搞定。

没有安装 Xcode,
没有搭 UI,
没有设计窗口,
甚至没有“软件发布”。

只有一个快捷键,和一个刚好满足我需求的工具。

如果我有别的需求,我直径cursor里面调整就行了,我不需要一个GUI,也不需要那么多的配置可选项。


这就是 AI 时代真正改变的地方

以前我们的思路是:

我要做这件事 → 找一个现成软件 → 适应它的设计

现在变成了:

我要做这件事 → 把需求说清楚 → AI 帮我拼出解决方案

软件不再是一个固定形态的产品
而更像是一段随需生成的能力组合


最后的结果

我现在用的是一个极简单的 Raycast 扩展:

一个 Raycast 扩展,用于自动处理剪贴板中的图片:
去除 EXIF、压缩、上传到 S3,并生成 Markdown 图片链接。

它不在 App Store,
没有官网,
也不需要被任何人下载。

它只为我服务。

而这件事,在几年前几乎是不可想象的。




功能特性

  • 📋 剪贴板检测 - 自动检测剪贴板中的图片文件
  • 🖼️ 图片处理 - 自动应用 EXIF orientation、调整大小、压缩
  • 🔒 隐私保护 - 使用 exiftool 完全删除 EXIF 元数据
  • ☁️ 自动上传 - 上传到 S3/R2 云存储
  • 📝 Markdown 生成 - 自动生成 Markdown 图片语法并复制到剪贴板
  • 快捷操作 - 一键完成所有操作

工作流程

  1. 按下快捷键触发命令
  2. 检测剪贴板中的图片
  3. 使用 sips 处理图片(应用 orientation、调整大小、转换格式)
  4. 使用 exiftool 删除所有 EXIF 元数据
  5. 计算 MD5 并生成文件路径
  6. 上传到 S3/R2
  7. 生成 Markdown 图片链接并复制到剪贴板
  8. 显示成功通知

安装要求

系统要求

  • macOS
  • Raycast

依赖工具

  • exiftool - 用于删除 EXIF 元数据

安装 exiftool:

1
brew install exiftool

配置

编辑 src/upload-s3.ts 文件中的配置区域:

1
2
3
4
5
6
7
8
9
10
11
// ===== 配置区 =====
const S3_ENDPOINT = "https://your-endpoint.r2.cloudflarestorage.com";
const S3_ACCESS_KEY = "your-access-key";
const S3_SECRET_KEY = "your-secret-key";
const S3_BUCKET = "your-bucket-name";
const S3_REGION = "auto";
const CDN_DOMAIN = "https://your-cdn-domain.com";

const JPEG_QUALITY = 100; // JPEG 质量 (1-100)
const MAX_SIZE = 1600; // 最大尺寸(宽度或高度)
// ==================

配置说明

  • S3_ENDPOINT - S3 或 Cloudflare R2 的端点地址
  • S3_ACCESS_KEY - S3 访问密钥
  • S3_SECRET_KEY - S3 密钥
  • S3_BUCKET - S3 存储桶名称
  • CDN_DOMAIN - CDN 域名,用于生成公开访问 URL
  • JPEG_QUALITY - JPEG 压缩质量(1-100,100 为最高质量)
  • MAX_SIZE - 图片最大尺寸(像素),超过此尺寸会自动缩放

文件路径规则

上传的文件路径格式:

1
static/images/{year}/{month}/{day}/{md5}.jpg

例如:

1
static/images/2024/12/19/a1b2c3d4e5f6...jpg

生成的 CDN URL 格式:

1
https://your-cdn-domain.com/static/images/{year}/{month}/{day}/{md5}.jpg

使用方法

  1. 复制图片到剪贴板

    • 截图或复制图片文件
  2. 触发命令

    • 在 Raycast 中搜索 “upload s3” 命令
    • 或设置快捷键直接触发
  3. 自动处理

    • 扩展会自动处理图片并上传
    • 完成后会在剪贴板中生成 Markdown 图片链接
  4. 粘贴使用

    • 直接粘贴到 Markdown 编辑器即可

开发

安装依赖

1
npm install

开发模式

1
npm run dev

构建

1
npm run build

代码检查

1
2
npm run lint
npm run fix-lint

技术栈

  • TypeScript - 编程语言
  • Raycast API - 扩展框架
  • AWS SDK v3 - S3 上传
  • sips - macOS 图片处理(系统自带)
  • exiftool - EXIF 元数据删除

注意事项

  1. exiftool 路径 - 代码会自动检测 exiftool 的安装路径(支持 Apple Silicon 和 Intel Mac)
  2. 临时文件 - 处理过程中会创建临时文件,处理完成后自动清理
  3. 错误处理 - 所有错误都会在 console 中输出详细信息,便于调试
  4. 文件格式 - 支持常见图片格式(jpg, png, gif, webp, bmp, tiff 等)

许可证

MIT

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import { Clipboard, showHUD } from "@raycast/api";
import { createHash, randomUUID } from "crypto";
import path from "path";
import os from "os";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { execFile } from "child_process";
import { promisify } from "util";
import { readFile, writeFile, unlink, access, constants } from "fs/promises";
import { fileURLToPath } from "url";

const execFileAsync = promisify(execFile);

// ===== 配置区 =====
const S3_ENDPOINT = "";
const S3_ACCESS_KEY = "";
const S3_SECRET_KEY = "";
const S3_BUCKET = "";
const S3_REGION = "auto";
const CDN_DOMAIN = ";

const JPEG_QUALITY = 100;
const MAX_SIZE = 1600;
// ==================

function initS3Client() {
return new S3Client({
endpoint: S3_ENDPOINT,
region: S3_REGION,
credentials: {
accessKeyId: S3_ACCESS_KEY,
secretAccessKey: S3_SECRET_KEY,
},
forcePathStyle: true,
});
}

export default async function main() {
const tmpInput = path.join(os.tmpdir(), `${randomUUID()}.input`);
const tmpOutput = path.join(os.tmpdir(), `${randomUUID()}.jpg`);

try {
// 1. 读取剪贴板内容
const clipboard = await Clipboard.read();
if (!clipboard.file) {
await showHUD("❌ 剪贴板不是图片");
return;
}

// 2. 读取图片文件并写入临时文件
const imageBuffer = await readFile(fileURLToPath(clipboard.file));
await writeFile(tmpInput, imageBuffer);

// 3. sips 处理(压缩 + 调整大小 + 转换格式)
// 第一步:应用 orientation、调整大小、转换格式(保留 EXIF 以便读取 orientation)
await execFileAsync("sips", [
tmpInput,
"--resampleHeightWidthMax", `${MAX_SIZE}`,
"-s", "format", "jpeg",
"-s", "formatOptions", `${JPEG_QUALITY}`,
"--out", tmpOutput,
]);

// 第二步:使用 exiftool 删除所有 EXIF 和元数据
// 尝试多个可能的 exiftool 路径(macOS 不同架构)
const exiftoolPaths = [
"/opt/homebrew/bin/exiftool", // Apple Silicon (M1/M2)
"/usr/local/bin/exiftool", // Intel Mac
"exiftool", // 如果在 PATH 中
];

let exiftoolPath: string | null = null;
for (const exifPath of exiftoolPaths) {
try {
await access(exifPath, constants.F_OK);
exiftoolPath = exifPath;
break;
} catch {
// 继续尝试下一个路径
}
}

if (!exiftoolPath) {
throw new Error("找不到 exiftool,请确保已安装: brew install exiftool");
}

await execFileAsync(exiftoolPath, [
"-all=", // 删除所有元数据
"-overwrite_original", // 直接覆盖原文件,不创建备份
tmpOutput,
]);

const finalOutput = tmpOutput;

await access(finalOutput, constants.F_OK);

// 4. 读取结果
const buffer = await readFile(finalOutput);

// 5. MD5
const md5 = createHash("md5").update(buffer).digest("hex");

// 6. 路径
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");

const key = `static/images/${y}/${m}/${d}/${md5}.jpg`;

// 7. 上传
const s3 = initS3Client();
await s3.send(
new PutObjectCommand({
Bucket: S3_BUCKET,
Key: key,
Body: buffer,
ContentType: "image/jpeg",
})
);

// 8. Markdown
const url = `${CDN_DOMAIN}/${key}`;
const markdown = `![](${url})`;

await Clipboard.copy(markdown);
await showHUD("✅ 图片已上传,Markdown 已复制");
} catch (err: any) {
// 打印详细错误信息到 console
console.error("上传失败:", err);
console.error("错误堆栈:", err.stack);
if (err.stderr) {
console.error("stderr:", err.stderr.toString());
}
if (err.stdout) {
console.error("stdout:", err.stdout.toString());
}
await showHUD(`❌ ${err.message || err}`);
} finally {
await unlink(tmpInput).catch(() => {});
await unlink(tmpOutput).catch(() => {});
}
}