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 ">
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}
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>
234284let curFile = null , curImg = null , curRGBA = null , curW = 0 , curH = 0 ;
235285let worker = null , result = null ;
236286
287+ /* ---------------- 移动端自适应 ---------------- */
288+ ( function adaptMobile ( ) {
289+ const isMobile = / M o b i | A n d r o i d | i P h o n e | i P a d | i P o d / 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' ) ;
239301drop . 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
333409function renderLayers ( layers ) {
@@ -338,6 +414,10 @@ <h2>说明</h2>
338414function 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