-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.rb
More file actions
628 lines (525 loc) · 18.5 KB
/
app.rb
File metadata and controls
628 lines (525 loc) · 18.5 KB
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
require 'sinatra'
require 'sinatra/json'
require 'rack/cors'
require 'fileutils'
require_relative 'lib/database'
require_relative 'lib/models'
require_relative 'lib/psd_processor'
require_relative 'lib/version'
require 'json'
set :bind, '0.0.0.0'
set :port, 4567
# Helper method to get public path
def public_path
ENV['PUBLIC_PATH'] || 'public'
end
use Rack::Cors do
allow do
origins '*'
resource '*', headers: :any, methods: [:get, :post, :delete, :put, :options], expose: ['location', 'link']
end
end
# Environment-aware static file serving configuration
# 环境感知的静态文件服务配置
static_path = ENV['STATIC_PATH'] || 'dist'
# Detect environment - production in Docker, development otherwise
# 检测环境 - Docker中为生产环境,其他情况为开发环境
production_env = ENV['RACK_ENV'] == 'production' || File.exist?('/.dockerenv')
# Serve processed files from PUBLIC_PATH via /processed path in both environments
# 在两种环境中都通过/processed路径从PUBLIC_PATH服务处理后的文件
public_path = ENV['PUBLIC_PATH'] || 'public'
# Custom route for processed files
# 为processed文件添加自定义路由
get '/processed/*' do
filename = params[:splat].first
file_path = File.join(File.expand_path(public_path), 'processed', filename)
puts "DEBUG: Looking for processed file: #{file_path}"
puts "DEBUG: File exists: #{File.exist?(file_path)}"
if File.exist?(file_path)
send_file file_path
else
status 404
"File not found: #{file_path}"
end
end
if production_env
# Production environment (Docker): Serve static files from multiple sources
# 生产环境(Docker): 从多个来源服务静态文件
# Serve frontend assets from STATIC_PATH
# 从STATIC_PATH服务前端资源
set :public_folder, static_path
else
# Development environment: Keep frontend-backend separation
# 开发环境: 保持前后端分离
set :public_folder, ENV['PUBLIC_PATH'] || 'public'
# Serve frontend static assets (JS/CSS) from STATIC_PATH via /assets path
# 通过/assets路径从STATIC_PATH服务前端静态资源(JS/CSS)
use Rack::Static, :urls => ["/assets"], :root => static_path
end
# SPA Catch-all route
# Must be last to avoid overriding API routes
get '/' do
static_path = ENV['STATIC_PATH'] || 'dist'
send_file File.join(static_path, 'index.html')
end
get '*' do
pass if request.path_info.start_with?('/api')
pass if request.path_info.start_with?('/processed')
# Serve index.html for SPA routing
static_path = ENV['STATIC_PATH'] || 'dist'
send_file File.join(static_path, 'index.html')
end
# System Info
get '/api/version' do
json({
version: Sliceway::VERSION,
name: 'Sliceway',
description: '现代化的 Photoshop 文件处理和导出工具'
})
end
# Projects
get '/api/projects' do
page = (params[:page] || 1).to_i
per_page = 20
projects = Project.order(created_at: :desc).offset((page - 1) * per_page).limit(per_page)
json projects: projects.as_json, total: Project.count
end
post '/api/projects' do
# Expect multipart form data
if params[:file] && params[:file][:tempfile]
filename = params[:file][:filename]
if filename
filename.force_encoding('UTF-8')
unless filename.valid_encoding?
filename.encode!('UTF-8', invalid: :replace, undef: :replace, replace: '?')
end
end
# Save file to uploads
upload_dir = ENV['UPLOADS_PATH'] || File.join("uploads")
FileUtils.mkdir_p(upload_dir)
target_path = File.join(upload_dir, "#{Time.now.to_i}_#{filename}")
File.open(target_path, 'wb') do |f|
# Use streaming copy to handle large files (PSB) and avoid memory exhaustion/EINVAL errors
# params[:file][:tempfile] is already an open file handle from Rack
IO.copy_stream(params[:file][:tempfile], f)
end
# 处理导出路径:如果是相对路径,转换为绝对路径
export_path = if params[:export_path]
if params[:export_path].start_with?('/')
params[:export_path] # 已经是绝对路径
else
File.join(Dir.pwd, params[:export_path]) # 相对路径转绝对路径
end
else
base_export_path = ENV['EXPORTS_PATH'] || File.join(Dir.pwd, "exports")
File.join(base_export_path, "#{Time.now.to_i}") # 默认路径
end
project = Project.create!(
name: params[:name] || filename,
psd_path: File.absolute_path(target_path), # Store absolute path
export_path: export_path,
export_scales: params[:export_scales] ? JSON.parse(params[:export_scales]) : ['1x'],
processing_mode: params[:processing_mode],
status: 'pending'
)
# Trigger processing in a separate process
pid = spawn("bundle exec ruby bin/process_psd #{project.id}")
Process.detach(pid) # Avoid zombie processes
# 保存任务PID到全局变量
$running_tasks[project.id] = pid
json project.as_json
else
status 400
json error: "No file uploaded"
end
end
get '/api/projects/:id' do
project = Project.find(params[:id])
json project.as_json
end
post '/api/projects/:id/process' do
project = Project.find(params[:id])
# 如果项目状态不是pending,则不允许重新处理
if project.status != 'pending'
status 400
return json error: "项目状态为 #{project.status},无法重新处理"
end
# 更新项目状态为处理中
project.update(status: 'processing')
# 在后台进程中处理PSD文件
pid = spawn("bundle exec ruby bin/process_psd #{project.id}")
Process.detach(pid)
# 保存任务PID到全局变量
$running_tasks[project.id] = pid
json success: true
end
# 停止处理项目
post '/api/projects/:id/stop' do
project = Project.find(params[:id])
# 只有正在处理中的项目才能停止
if project.status != 'processing'
status 400
return json error: "项目状态为 #{project.status},无法停止处理"
end
# 中止对应的处理任务
if $running_tasks[project.id]
begin
Process.kill("TERM", $running_tasks[project.id])
puts "中止了项目 #{project.id} 的处理任务 (PID: #{$running_tasks[project.id]})"
rescue Errno::ESRCH
puts "进程 #{$running_tasks[project.id]} 不存在"
end
$running_tasks.delete(project.id)
end
# 清理已生成的文件
begin
# Clean up exported images in public directory
public_path = ENV['PUBLIC_PATH'] || 'public'
project.layers.each do |layer|
if layer.image_path && File.exist?(File.join(public_path, layer.image_path))
FileUtils.rm_rf(File.join(public_path, layer.image_path))
end
end
# Clean up processed images directory
processed_dir = File.join(public_path, 'processed', project.id.to_s)
if Dir.exist?(processed_dir)
FileUtils.rm_rf(processed_dir)
end
# 重置项目状态为初始状态
project.update(status: 'pending', processing_finished_at: Time.now)
# 删除已生成的图层记录
project.layers.destroy_all
rescue => e
puts "Warning: Failed to clean up some files: #{e.message}"
status 500
return json error: "停止处理时清理文件失败: #{e.message}"
end
json success: true
end
# System
get '/api/system/directories' do
current_path = params[:path] || Dir.pwd
# Security check: prevent going above root (though for this tool we might want full access)
# For now, we trust the user as this is a local tool.
begin
# Normalize path
current_path = File.absolute_path(current_path)
# Get parent path
parent_path = File.dirname(current_path)
# List directories
entries = Dir.entries(current_path).select do |entry|
next false if entry == '.' || entry == '..'
path = File.join(current_path, entry)
File.directory?(path) && File.readable?(path)
end.sort
json({
current_path: current_path,
parent_path: parent_path,
directories: entries,
sep: File::SEPARATOR
})
rescue => e
status 500
json error: "Failed to list directories: #{e.message}"
end
end
# 全局变量来跟踪正在运行的任务
$running_tasks = {}
delete '/api/projects/batch' do
content_type :json
begin
ids = JSON.parse(request.body.read)['ids']
if ids.nil? || !ids.is_a?(Array) || ids.empty?
status 400
return { error: 'Invalid project IDs' }.to_json
end
deleted_count = 0
errors = []
ids.each do |id|
begin
project = Project.find(id)
# 根据项目状态执行不同的清理逻辑
case project.status
when 'processing'
# 如果项目正在处理中,先中止后台任务
puts "项目 #{project.id} 正在处理中,先中止处理任务..."
if $running_tasks[project.id]
begin
Process.kill("TERM", $running_tasks[project.id])
puts "已中止项目 #{project.id} 的处理任务 (PID: #{$running_tasks[project.id]})"
rescue Errno::ESRCH
puts "进程 #{$running_tasks[project.id]} 不存在"
end
$running_tasks.delete(project.id)
end
when 'ready'
# 如果项目已完成,清理所有生成的文件
puts "项目 #{project.id} 已完成,清理生成的文件..."
when 'pending', 'error'
# 如果项目待处理或出错,清理基础文件
puts "项目 #{project.id} 状态为 #{project.status},清理相关文件..."
end
# 清理所有相关文件
begin
# Clean up uploaded PSD file
if project.psd_path && File.exist?(project.psd_path)
FileUtils.rm_rf(project.psd_path)
puts "已清理PSD文件: #{project.psd_path}"
end
# Clean up export directory
if project.export_path && Dir.exist?(project.export_path)
FileUtils.rm_rf(project.export_path)
puts "已清理导出目录: #{project.export_path}"
end
# Clean up exported images in public directory
public_path = ENV['PUBLIC_PATH'] || 'public'
project.layers.each do |layer|
if layer.image_path && File.exist?(File.join(public_path, layer.image_path))
FileUtils.rm_rf(File.join(public_path, layer.image_path))
puts "已清理图层图片: #{layer.image_path}"
end
end
# Clean up processed images directory
processed_dir = File.join(public_path, 'processed', project.id.to_s)
if Dir.exist?(processed_dir)
FileUtils.rm_rf(processed_dir)
puts "已清理处理图片目录: #{processed_dir}"
end
puts "项目 #{project.id} 文件清理完成"
rescue => e
puts "Warning: Failed to clean up some files: #{e.message}"
end
# 删除项目记录
project.destroy
puts "项目 #{project.id} 记录已删除"
deleted_count += 1
rescue ActiveRecord::RecordNotFound
errors << "Project #{id} not found"
rescue => e
errors << "Failed to delete project #{id}: #{e.message}"
end
end
if errors.any?
{
success: false,
deleted_count: deleted_count,
errors: errors
}.to_json
else
{
success: true,
deleted_count: deleted_count,
message: "Successfully deleted #{deleted_count} projects"
}.to_json
end
rescue JSON::ParserError
status 400
{ error: 'Invalid JSON format' }.to_json
end
end
delete '/api/projects/:id' do
project = Project.find(params[:id])
# 根据项目状态执行不同的清理逻辑
case project.status
when 'processing'
# 如果项目正在处理中,先中止后台任务
puts "项目 #{project.id} 正在处理中,先中止处理任务..."
if $running_tasks[project.id]
begin
Process.kill("TERM", $running_tasks[project.id])
puts "已中止项目 #{project.id} 的处理任务 (PID: #{$running_tasks[project.id]})"
rescue Errno::ESRCH
puts "进程 #{$running_tasks[project.id]} 不存在"
end
$running_tasks.delete(project.id)
end
when 'ready'
# 如果项目已完成,清理所有生成的文件
puts "项目 #{project.id} 已完成,清理生成的文件..."
when 'pending', 'error'
# 如果项目待处理或出错,清理基础文件
puts "项目 #{project.id} 状态为 #{project.status},清理相关文件..."
end
# 清理所有相关文件
begin
# Clean up uploaded PSD file
if project.psd_path && File.exist?(project.psd_path)
FileUtils.rm_rf(project.psd_path)
puts "已清理PSD文件: #{project.psd_path}"
end
# Clean up export directory
if project.export_path && Dir.exist?(project.export_path)
FileUtils.rm_rf(project.export_path)
puts "已清理导出目录: #{project.export_path}"
end
# Clean up exported images in public directory
public_path = ENV['PUBLIC_PATH'] || 'public'
project.layers.each do |layer|
if layer.image_path && File.exist?(File.join(public_path, layer.image_path))
FileUtils.rm_rf(File.join(public_path, layer.image_path))
puts "已清理图层图片: #{layer.image_path}"
end
end
# Clean up processed images directory
processed_dir = File.join(public_path, 'processed', project.id.to_s)
if Dir.exist?(processed_dir)
FileUtils.rm_rf(processed_dir)
puts "已清理处理图片目录: #{processed_dir}"
end
puts "项目 #{project.id} 文件清理完成"
rescue => e
puts "Warning: Failed to clean up some files: #{e.message}"
end
# 删除项目记录
project.destroy
puts "项目 #{project.id} 记录已删除"
json success: true
end
# Layers
get '/api/projects/:id/layers' do
project = Project.find(params[:id])
layers = project.layers
if params[:type] && !params[:type].empty?
layers = layers.where(layer_type: params[:type])
end
if params[:q] && !params[:q].empty?
layers = layers.where("name LIKE ?", "%#{params[:q]}%")
end
json layers.as_json
end
# Export
post '/api/projects/:id/export' do
project = Project.find(params[:id])
data = JSON.parse(request.body.read)
layer_ids = data['layer_ids']
renames = data['renames'] || {}
clear_directory = data['clear_directory'] || false
trim_transparent = data['trim_transparent'] || false
puts "DEBUG: Exporting layers #{layer_ids}"
puts "DEBUG: Renames received: #{renames.inspect}"
puts "DEBUG: Clear directory: #{clear_directory}"
puts "DEBUG: Trim transparent: #{trim_transparent}"
layers = project.layers.where(id: layer_ids)
export_count = 0
if clear_directory && File.directory?(project.export_path)
puts "DEBUG: Clearing directory #{project.export_path}"
# Remove all files in the directory, but keep the directory itself
FileUtils.rm_rf(Dir.glob(File.join(project.export_path, '*')))
end
FileUtils.mkdir_p(project.export_path)
requested_scales = data['scales'] || ['1x']
# Track used filenames to avoid conflicts
used_filenames = {}
layers.each do |layer|
next unless layer.image_path
# Get base path and extension
# image_path is like "processed/1/layer_123.png"
# We need to find variants like "processed/1/layer_123@2x.png"
public_path = ENV['PUBLIC_PATH'] || 'public'
base_source = File.join(public_path, layer.image_path)
ext = File.extname(base_source)
base_name_without_ext = File.basename(base_source, ext)
dir_name = File.dirname(base_source)
# Determine target base name
# If renamed, use new name directly. Otherwise use default format.
if renames[layer.id.to_s] && !renames[layer.id.to_s].empty?
target_base_name = renames[layer.id.to_s]
else
target_base_name = layer.name
end
requested_scales.each do |scale|
# Determine source filename for this scale
if scale == '1x'
source = base_source
target_suffix = ""
else
source = File.join(dir_name, "#{base_name_without_ext}@#{scale}#{ext}")
target_suffix = "@#{scale}"
end
# Determine target filename with conflict resolution
# All files go directly into export_path (flat structure)
desired_filename = "#{target_base_name}#{target_suffix}#{ext}"
# Check if filename already used, if so, add numeric suffix
if used_filenames[desired_filename]
counter = 1
loop do
new_filename = "#{target_base_name}_#{counter}#{target_suffix}#{ext}"
unless used_filenames[new_filename]
desired_filename = new_filename
break
end
counter += 1
end
end
used_filenames[desired_filename] = true
target = File.join(project.export_path, desired_filename)
if File.exist?(source)
if trim_transparent
# Load, trim and save
begin
require 'chunky_png'
png = ChunkyPNG::Image.from_file(source)
trimmed_png = trim_png_transparency(png)
if trimmed_png
trimmed_png.save(target, :fast_rgba)
export_count += 1
else
# If trim failed, just copy original
FileUtils.cp(source, target)
export_count += 1
end
rescue => e
puts "Error trimming #{source}: #{e.message}"
# Fallback to copy
FileUtils.cp(source, target)
export_count += 1
end
else
# Normal copy
FileUtils.cp(source, target)
export_count += 1
end
end
end
end
json success: true, count: export_count, path: project.export_path
end
# Helper method to trim PNG transparency
def trim_png_transparency(png)
width = png.width
height = png.height
# Find bounds of non-transparent pixels
min_x = width
max_x = 0
min_y = height
max_y = 0
found_opaque = false
(0...height).each do |y|
(0...width).each do |x|
pixel = png[x, y]
alpha = ChunkyPNG::Color.a(pixel)
if alpha > 0
found_opaque = true
min_x = x if x < min_x
max_x = x if x > max_x
min_y = y if y < min_y
max_y = y if y > max_y
end
end
end
return nil unless found_opaque
return png if min_x == 0 && min_y == 0 && max_x == width - 1 && max_y == height - 1
# Crop to non-transparent area
cropped_width = max_x - min_x + 1
cropped_height = max_y - min_y + 1
cropped_png = ChunkyPNG::Image.new(cropped_width, cropped_height, ChunkyPNG::Color::TRANSPARENT)
(0...cropped_height).each do |y|
(0...cropped_width).each do |x|
cropped_png[x, y] = png[min_x + x, min_y + y]
end
end
cropped_png
rescue => e
puts "Error in trim_png_transparency: #{e.message}"
png
end