Skip to content

Commit b000755

Browse files
committed
feat: 移动端适配 + 仓库整理(README/LICENSE/.gitignore)
1 parent 139b8d0 commit b000755

4 files changed

Lines changed: 185 additions & 38 deletions

File tree

‎.gitignore‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# 操作系统 / 编辑器
2+
.DS_Store
3+
Thumbs.db
4+
*.swp
5+
6+
# 依赖与构建
7+
node_modules/
8+
*.log
9+
10+
# 临时文件
11+
*.tmp

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 tangyue
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md‎

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,64 @@
11
# strokeoil · 油画工序逐笔重绘
22

3-
在浏览器里把任意图片重绘成「按真实油画十道工序逐笔画出」的动画。
3+
把任意图片重绘成「按真实油画十道工序逐笔画出」的动画。**纯前端实现,图片不上传、无需后端**,可直接托管到 GitHub Pages。
44

5-
- **纯前端**:所有计算在访客浏览器里完成,图片不上传,无后端
6-
- **可下载**:生成的播放器是单个 HTML 文件,双击就能离线打开
7-
- **十道工序**:刷底色 → 起稿 → 单色底层 → 铺大色 → 塑造 → 塑形 → 罩染 → 干扫 → 细部 → 提白
5+
在线体验:<https://tangyue.me/>
6+
7+
## 特点
8+
9+
- 🎨 **真实油画工序**:刷底色 → 起稿 → 单色底层 → 铺大色 → 塑造 → 塑形 → 罩染 → 干扫 → 细部 → 提白,十道工序顺序严格
10+
- 🖌 **物理笔触**:起收锋、笔毛分叉、颜料渐枯、厚涂浮雕、罩染透叠
11+
- 📱 **响应式**:桌面双栏、移动端单栏,自动适配触屏
12+
- 🔒 **本地处理**:图片只在你的浏览器里,绝不联网上传
13+
- 📦 **可下载**:生成结果打包成单个 HTML 文件,双击离线播放、随意分享
14+
- ⚡ **快**:1500px 图约 1.5–3 秒,8 万条笔触
815

916
## 使用
1017

11-
1. 打开网站
12-
2. 拖入图片(PNG / JPG / WEBP)
13-
3. 调「细腻程度」和「动画时长」
14-
4. 点「开始生成」,1.5–3 秒出结果
15-
5. 页面内实时预览,或下载单文件播放器 HTML
18+
1. 打开网站(或本地 `python -m http.server` 后访问)
19+
2. 拖入 / 选择图片(PNG / JPG / WEBP)
20+
3. 调「细腻程度」「动画时长」「处理精度」
21+
4. 点「开始生成」
22+
5. 页面内实时预览,或下载单文件播放器
1623

17-
## 技术
24+
## 文件结构
1825

1926
| 文件 | 说明 |
2027
|---|---|
21-
| `index.html` | 主页面 |
22-
| `core.js` | 算法核心:五层 k-means 分层、Canny 边缘、结构张量方向场、十道工序笔触生成、严格串行时间轴 |
28+
| `index.html` | 主页面(上传、调参、实时预览、下载) |
29+
| `core.js` | 算法核心 |
2330
| `worker.js` | Web Worker,后台生成不卡界面 |
24-
| `player-template.html` | 下载用单文件播放器模板 |
31+
| `player-template.html` | 下载用的单文件播放器模板 |
32+
| `CNAME` | 自定义域名(tangyue.me) |
33+
| `.github/workflows/deploy.yml` | GitHub Pages 自动部署 |
34+
35+
## 算法原理
36+
37+
**分层定色、结构张量定走向、工序定笔触语言、串行时间轴定节奏。**
38+
39+
1. **五层明暗分层**:在 L\*(亮度)× S(饱和度)平面上做 k-means,把人像与背景按材质分开
40+
2. **轮廓与方向**:Canny 提取边缘,结构张量(Sobel → 特征分解)求每一笔的走向;平坦区改用低频方向场,避免笔触乱指
41+
3. **十道工序**:每道工序有自己的笔宽、密度、透明度与颜色处理,宽度逐档过渡(否则细笔硬盖巨笔会毛躁)
42+
4. **严格串行**:`start[i] == start[i-1] + dur[i-1]`,逐条校验零重叠零断点
43+
5. **颜色取自原图**:沿笔触取中位色,只做轻微抖动,绝不用调色板
44+
45+
## 部署到 GitHub Pages
46+
47+
仓库本身就是 Pages 站点,`main` 分支 + GitHub Actions 自动部署。
48+
49+
换成你自己的域名:改 `CNAME` 文件内容即可。
50+
51+
其他静态托管(Cloudflare Pages / Netlify / Vercel)同样适用——四个静态文件,无构建步骤。
52+
53+
## 本地开发
54+
55+
```bash
56+
python -m http.server 8000
57+
# 访问 http://localhost:8000/
58+
```
59+
60+
> 注意:不要用 `file://` 直接双击 `index.html`,Web Worker 和 fetch 会被浏览器拦截。
2561
26-
## 原理
62+
## 许可证
2763

28-
分层定色、结构张量定走向、工序定笔触语言、串行时间轴定节奏。
29-
详见 `gh-pages/README.md` 或项目根目录的完整源码。
64+
MIT

‎index.html‎

Lines changed: 102 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
<html lang="zh-CN">
33
<head>
44
<meta charset="utf-8">
5-
<meta name="viewport" content="width=device-width,initial-scale=1">
5+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6+
<meta name="theme-color" content="#0f0d0b">
67
<title>strokeoil · 油画工序重绘</title>
78
<meta name="description" content="在浏览器里把图片重绘成按真实油画十道工序逐笔绘制的动画,全部本地处理,不上传。">
89
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23d9a441'/%3E%3Cpath d='M7 22c4-9 8-12 11-12s5 3 7 12' stroke='%2317130c' stroke-width='3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
@@ -33,6 +34,7 @@
3334
#drop .ico{font-size:34px;opacity:.55;margin-bottom:8px}
3435
#drop .t1{font-size:14.5px;margin-bottom:4px}
3536
#drop .t2{font-size:12px;color:var(--dim2)}
37+
.pickBtn{display:none;margin:12px auto 0;min-height:40px;padding:8px 20px}
3638
#thumbWrap{display:none;margin-top:14px}
3739
#thumb{width:100%;border-radius:10px;display:block;outline:1px solid var(--line2)}
3840
.fname{font-size:12px;color:var(--dim);margin-top:7px;display:flex;justify-content:space-between}
@@ -93,6 +95,59 @@
9395
color:var(--gold);border:1px solid rgba(217,164,65,.3)}
9496
footer{margin-top:34px;font-size:11.5px;color:var(--dim2);line-height:1.9;text-align:center}
9597
footer a{color:var(--dim)}
98+
99+
/* ---------------- 移动端适配 ---------------- */
100+
/* 让触控目标达到 44px 最小可点区域 */
101+
@media(max-width:720px){
102+
.wrap{padding:16px 14px 60px}
103+
h1{font-size:21px}
104+
.sub{font-size:12px}
105+
.flow{font-size:11.5px;margin:4px 0 18px;line-height:1.9}
106+
.grid{grid-template-columns:1fr;gap:14px}
107+
.card{padding:14px;border-radius:14px}
108+
.card h2{font-size:12.5px;margin-bottom:11px}
109+
110+
/* 拖拽区更大更易点 */
111+
#drop{padding:28px 16px;border-radius:12px}
112+
#drop .ico{font-size:30px}
113+
#drop .t1{font-size:13.5px}
114+
#drop .t2{font-size:11px;line-height:1.6}
115+
.pickBtn{display:block} /* 手机上显示明确的按钮 */
116+
117+
/* 参数行改成上下堆叠,label 不占固定宽度 */
118+
.row{flex-wrap:wrap;gap:8px;margin:13px 0}
119+
.row label{width:100%;font-size:12px}
120+
.row .val{width:auto;margin-left:auto}
121+
.row input[type=range]{flex:1;height:32px} /* 加高滑动条触控区 */
122+
.row select,.row input[type=number],.row input[type=text]{
123+
width:100%;padding:10px 12px;font-size:14px; /* 手机输入避免缩放 */
124+
min-height:44px;border-radius:10px}
125+
.row input[type=number]{width:100%}
126+
127+
button{min-height:46px;padding:12px 18px;font-size:14.5px;border-radius:12px}
128+
button.primary{padding:14px;font-size:15.5px}
129+
130+
/* 结果数字与 KV */
131+
#result .big{font-size:30px}
132+
.kv{grid-template-columns:1fr 1fr;gap:7px 10px;font-size:12px}
133+
134+
/* 下载按钮竖排,方便拇指点 */
135+
.files{flex-direction:column}
136+
.files a,.files button{min-width:0;padding:13px;font-size:13.5px;min-height:46px}
137+
138+
/* 预览画布占满宽度 */
139+
.tips{font-size:11.5px}
140+
141+
/* 高级选项 summary 也加大触控区 */
142+
.adv summary{min-height:40px;display:flex;align-items:center;font-size:13px}
143+
}
144+
145+
/* 中屏(平板):两栏但缩小间距 */
146+
@media(min-width:721px) and (max-width:940px){
147+
.wrap{padding:24px 18px 70px}
148+
.grid{gap:14px}
149+
.card{padding:15px}
150+
}
96151
</style>
97152
</head>
98153
<body>
@@ -111,6 +166,7 @@ <h2>① 选一张图</h2>
111166
<div class="ico">🖼</div>
112167
<div class="t1">把图片拖到这里,或点击选择</div>
113168
<div class="t2">PNG / JPG / WEBP · 图片不会上传,全部在本机处理</div>
169+
<button type="button" class="pickBtn" id="pickBtn">选择图片</button>
114170
</div>
115171
<input type="file" id="file" accept="image/*" hidden>
116172
<div id="thumbWrap">
@@ -132,14 +188,18 @@ <h2>② 调参数</h2>
132188
<div class="val" id="secsV">90s</div>
133189
</div>
134190
<div class="row">
135-
<label>画质</label>
136-
<select id="quality">
137-
<option value="1.8">标准(快)</option>
138-
<option value="2.3" selected>高清(推荐)</option>
139-
<option value="3.0">超清(吃内存)</option>
191+
<label>处理精度</label>
192+
<select id="workRes">
193+
<option value="1000">快(1000px)</option>
194+
<option value="1400" selected>标准(1400px)</option>
195+
<option value="2000">精(2000px)</option>
196+
<option value="0">原始分辨率(最慢)</option>
140197
</select>
141198
<div class="val"></div>
142199
</div>
200+
<div class="tips" style="margin-top:-4px">
201+
手机建议用「快」,电脑可用「标准」或更高。
202+
</div>
143203
<details class="adv">
144204
<summary>高级选项</summary>
145205
<div class="row">
@@ -155,16 +215,6 @@ <h2>② 调参数</h2>
155215
<input type="range" id="rois" min="0" max="14" step="1" value="6">
156216
<div class="val" id="roisV">6</div>
157217
</div>
158-
<div class="row">
159-
<label>分析分辨率</label>
160-
<select id="workRes">
161-
<option value="1000">快(1000px)</option>
162-
<option value="1400" selected>标准(1400px)</option>
163-
<option value="2000">精(2000px)</option>
164-
<option value="0">原始分辨率(最慢)</option>
165-
</select>
166-
<div class="val"></div>
167-
</div>
168218
<div class="row">
169219
<label>随机种子</label>
170220
<input type="number" id="seed" value="20261109" step="1" style="width:120px">
@@ -234,9 +284,22 @@ <h2>说明</h2>
234284
let curFile = null, curImg = null, curRGBA = null, curW = 0, curH = 0;
235285
let worker = null, result = null;
236286

287+
/* ---------------- 移动端自适应 ---------------- */
288+
(function adaptMobile() {
289+
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
290+
|| (window.matchMedia && window.matchMedia('(max-width:720px)').matches);
291+
const lowMem = (navigator.deviceMemory && navigator.deviceMemory <= 4) || isMobile;
292+
if (lowMem) {
293+
// 手机/小内存默认用最快精度,避免卡顿和内存溢出
294+
const sel = $('workRes');
295+
if (sel && sel.value === '1400') { sel.value = '1000'; }
296+
}
297+
})();
298+
237299
/* ---------------- 文件选择 ---------------- */
238-
const drop = $('drop'), fileIn = $('file');
300+
const drop = $('drop'), fileIn = $('file'), pickBtn = $('pickBtn');
239301
drop.onclick = () => fileIn.click();
302+
if (pickBtn) pickBtn.onclick = (e) => { e.stopPropagation(); fileIn.click(); };
240303
['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => {
241304
e.preventDefault(); drop.classList.add('on');
242305
}));
@@ -286,13 +349,26 @@ <h2>说明</h2>
286349
$('result').style.display = 'none';
287350
setProg(2); setStat('读取像素…');
288351

289-
// 抽像素
352+
// 抽像素:超大图先降采样到安全上限(手机内存保护)
353+
const MAX_PIXELS = 16e6; // 约 4000×4000
354+
let srcW = curW, srcH = curH, srcImg = curImg;
355+
if (curW * curH > MAX_PIXELS) {
356+
const k = Math.sqrt(MAX_PIXELS / (curW * curH));
357+
srcW = Math.round(curW * k); srcH = Math.round(curH * k);
358+
const tc = document.createElement('canvas');
359+
tc.width = srcW; tc.height = srcH;
360+
tc.getContext('2d').drawImage(curImg, 0, 0, srcW, srcH);
361+
srcImg = tc;
362+
setStat('超大图,已自动缩到 ' + srcW + '×' + srcH + ' 处理…');
363+
}
290364
const cv = document.createElement('canvas');
291-
cv.width = curW; cv.height = curH;
365+
cv.width = srcW; cv.height = srcH;
292366
const cx = cv.getContext('2d', { willReadFrequently: true });
293-
cx.drawImage(curImg, 0, 0);
294-
const data = cx.getImageData(0, 0, curW, curH).data;
367+
cx.drawImage(srcImg, 0, 0);
368+
const data = cx.getImageData(0, 0, srcW, srcH).data;
295369
curRGBA = new Uint8ClampedArray(data.buffer.slice(0));
370+
// 后续用实际抽样的尺寸作为画布尺寸
371+
const procW = srcW, procH = srcH;
296372

297373
const opts = {
298374
layers: +$('layers').value,
@@ -327,7 +403,7 @@ <h2>说明</h2>
327403
setStat('Worker 出错:' + e.message, true);
328404
$('go').disabled = false;
329405
};
330-
worker.postMessage({ type: 'generate', rgba: curRGBA, w: curW, h: curH, opts }, [curRGBA.buffer]);
406+
worker.postMessage({ type: 'generate', rgba: curRGBA, w: procW, h: procH, opts }, [curRGBA.buffer]);
331407
};
332408

333409
function renderLayers(layers) {
@@ -338,6 +414,10 @@ <h2>说明</h2>
338414
function showResult(r) {
339415
$('result').style.display = 'block';
340416
$('hint').style.display = 'none';
417+
// 手机上结果在下方,滚动过去让用户看到
418+
setTimeout(() => {
419+
try { $('result').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
420+
}, 150);
341421
$('rStrokes').textContent = r.strokes.length.toLocaleString();
342422
const ok = r.breaks === 0 ? '<b style="color:var(--ok)">通过</b>' : '<b style="color:var(--err)">' + r.breaks + ' 处断裂</b>';
343423
const totalMB = (new Blob([JSON.stringify(r.strokes)]).size / 1024 / 1024).toFixed(1);

0 commit comments

Comments
 (0)