feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表
This commit is contained in:
@@ -22,9 +22,9 @@
|
||||
#include <future>
|
||||
#include <atomic>
|
||||
#include <random>
|
||||
#include <functional>
|
||||
#include <numeric>
|
||||
#include <limits>
|
||||
#include <functional>
|
||||
#include <numeric>
|
||||
#include <limits>
|
||||
|
||||
// ==========================================
|
||||
// Thread Pool (avoid per-query thread creation)
|
||||
@@ -115,13 +115,13 @@ public:
|
||||
int w;
|
||||
};
|
||||
|
||||
// Original mask-free coordinates. Sorting is lazy because the active
|
||||
// query_direct/query_near_center paths do not need the O(A log A) order.
|
||||
std::vector<std::pair<int, int>> valid_coords;
|
||||
bool valid_coords_center_sorted = false;
|
||||
double free_center_y = 0.0;
|
||||
double free_center_x = 0.0;
|
||||
int spiral_cursor = 1;
|
||||
// Original mask-free coordinates. Sorting is lazy because the active
|
||||
// query_direct/query_near_center paths do not need the O(A log A) order.
|
||||
std::vector<std::pair<int, int>> valid_coords;
|
||||
bool valid_coords_center_sorted = false;
|
||||
double free_center_y = 0.0;
|
||||
double free_center_x = 0.0;
|
||||
int spiral_cursor = 1;
|
||||
|
||||
// Lazy update buffers
|
||||
std::vector<int32_t> diff;
|
||||
@@ -139,9 +139,9 @@ public:
|
||||
}
|
||||
|
||||
void init_from_buffer(unsigned char* raw_mask, int h, int w) {
|
||||
valid_coords.reserve(h * w / 2);
|
||||
uint64_t free_y_sum = 0;
|
||||
uint64_t free_x_sum = 0;
|
||||
valid_coords.reserve(h * w / 2);
|
||||
uint64_t free_y_sum = 0;
|
||||
uint64_t free_x_sum = 0;
|
||||
|
||||
// Initialize canvas from mask
|
||||
ensure_canvas();
|
||||
@@ -157,48 +157,48 @@ public:
|
||||
if (is_blocked) {
|
||||
canvas[i * width + j] = 1;
|
||||
}
|
||||
if (!is_blocked) {
|
||||
valid_coords.push_back({i, j});
|
||||
free_y_sum += (uint64_t)i;
|
||||
free_x_sum += (uint64_t)j;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!valid_coords.empty()) {
|
||||
free_center_y = (double)free_y_sum / (double)valid_coords.size();
|
||||
free_center_x = (double)free_x_sum / (double)valid_coords.size();
|
||||
} else {
|
||||
free_center_y = (double)h * 0.5;
|
||||
free_center_x = (double)w * 0.5;
|
||||
}
|
||||
valid_coords_center_sorted = false;
|
||||
spiral_cursor = 1;
|
||||
if (!is_blocked) {
|
||||
valid_coords.push_back({i, j});
|
||||
free_y_sum += (uint64_t)i;
|
||||
free_x_sum += (uint64_t)j;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!valid_coords.empty()) {
|
||||
free_center_y = (double)free_y_sum / (double)valid_coords.size();
|
||||
free_center_x = (double)free_x_sum / (double)valid_coords.size();
|
||||
} else {
|
||||
free_center_y = (double)h * 0.5;
|
||||
free_center_x = (double)w * 0.5;
|
||||
}
|
||||
valid_coords_center_sorted = false;
|
||||
spiral_cursor = 1;
|
||||
|
||||
std::fill(diff.begin(), diff.end(), 0);
|
||||
recent_rects.clear();
|
||||
dirty_count = 0;
|
||||
}
|
||||
|
||||
void ensure_valid_coords_center_sorted() {
|
||||
if (valid_coords_center_sorted) return;
|
||||
const double center_y = free_center_y;
|
||||
const double center_x = free_center_x;
|
||||
std::sort(valid_coords.begin(), valid_coords.end(),
|
||||
[center_y, center_x](const std::pair<int, int>& a, const std::pair<int, int>& b) {
|
||||
double ay = (double)a.first - center_y;
|
||||
double ax = (double)a.second - center_x;
|
||||
double by = (double)b.first - center_y;
|
||||
double bx = (double)b.second - center_x;
|
||||
return ay * ay + ax * ax < by * by + bx * bx;
|
||||
}
|
||||
);
|
||||
valid_coords_center_sorted = true;
|
||||
}
|
||||
// Legacy coordinate ordering remains available to compatibility callers.
|
||||
void reorder_stratified(int bands) {
|
||||
std::unique_lock<std::shared_mutex> lock(mutex_);
|
||||
ensure_valid_coords_center_sorted();
|
||||
const size_t len = valid_coords.size();
|
||||
dirty_count = 0;
|
||||
}
|
||||
|
||||
void ensure_valid_coords_center_sorted() {
|
||||
if (valid_coords_center_sorted) return;
|
||||
const double center_y = free_center_y;
|
||||
const double center_x = free_center_x;
|
||||
std::sort(valid_coords.begin(), valid_coords.end(),
|
||||
[center_y, center_x](const std::pair<int, int>& a, const std::pair<int, int>& b) {
|
||||
double ay = (double)a.first - center_y;
|
||||
double ax = (double)a.second - center_x;
|
||||
double by = (double)b.first - center_y;
|
||||
double bx = (double)b.second - center_x;
|
||||
return ay * ay + ax * ax < by * by + bx * bx;
|
||||
}
|
||||
);
|
||||
valid_coords_center_sorted = true;
|
||||
}
|
||||
// Legacy coordinate ordering remains available to compatibility callers.
|
||||
void reorder_stratified(int bands) {
|
||||
std::unique_lock<std::shared_mutex> lock(mutex_);
|
||||
ensure_valid_coords_center_sorted();
|
||||
const size_t len = valid_coords.size();
|
||||
if (bands <= 1 || len == 0) return;
|
||||
if ((size_t)bands > len) bands = (int)len;
|
||||
|
||||
@@ -305,7 +305,7 @@ public:
|
||||
std::fill(diff.begin(), diff.end(), 0);
|
||||
recent_rects.clear();
|
||||
dirty_count = 0;
|
||||
for (int i = 0; i < height; ++i) {
|
||||
for (int i = 0; i < height; ++i) {
|
||||
uint32_t row_sum = 0;
|
||||
for (int j = 0; j < width; ++j) {
|
||||
uint32_t val = (raw_pixels[i * width + j] > 0) ? 1 : 0;
|
||||
@@ -343,10 +343,10 @@ public:
|
||||
}
|
||||
|
||||
// Rebuild integral from the internal canvas (partial from pos_r, pos_c)
|
||||
void rebuild_from_canvas(int pos_r, int pos_c) {
|
||||
if (canvas.empty()) return;
|
||||
rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c);
|
||||
}
|
||||
void rebuild_from_canvas(int pos_r, int pos_c) {
|
||||
if (canvas.empty()) return;
|
||||
rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c);
|
||||
}
|
||||
|
||||
// v4: Partial integral rebuild from position (pos_r, pos_c) downward
|
||||
// Optimized: use row-sum approach (like rebuild_from_bitmap) for the partial region
|
||||
@@ -450,9 +450,9 @@ public:
|
||||
return {-1, -1};
|
||||
}
|
||||
|
||||
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
|
||||
// Apply pending rectangle updates before reading the integral image.
|
||||
// Exact-glyph placement uses its own canvas-only path.
|
||||
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
|
||||
// Apply pending rectangle updates before reading the integral image.
|
||||
// Exact-glyph placement uses its own canvas-only path.
|
||||
flush();
|
||||
|
||||
int max_row = height - box_h;
|
||||
@@ -473,12 +473,12 @@ public:
|
||||
return {true, y, x};
|
||||
}
|
||||
|
||||
// Random probes avoid an O(H*W) scan during early and middle packing.
|
||||
// Increase the budget as occupancy rises.
|
||||
{
|
||||
const double occ_ratio = (double)total_occupied / (double)(width * height);
|
||||
const int probe_budget = (occ_ratio < 0.15) ? 32 : (occ_ratio < 0.40) ? 96 : 192;
|
||||
const int n_probes = (int)std::min<int64_t>(probe_budget, total_positions);
|
||||
// Random probes avoid an O(H*W) scan during early and middle packing.
|
||||
// Increase the budget as occupancy rises.
|
||||
{
|
||||
const double occ_ratio = (double)total_occupied / (double)(width * height);
|
||||
const int probe_budget = (occ_ratio < 0.15) ? 32 : (occ_ratio < 0.40) ? 96 : 192;
|
||||
const int n_probes = (int)std::min<int64_t>(probe_budget, total_positions);
|
||||
for (int p = 0; p < n_probes; ++p) {
|
||||
int y = std::uniform_int_distribution<int>(0, max_row)(rng);
|
||||
int x = std::uniform_int_distribution<int>(0, max_col)(rng);
|
||||
@@ -496,28 +496,28 @@ public:
|
||||
}
|
||||
|
||||
if (nt <= 1) {
|
||||
// Single-pass reservoir halves the work of count-then-pick. A
|
||||
// small LCG avoids invoking mt19937 for every valid position.
|
||||
uint64_t count = 0;
|
||||
int best_y = -1, best_x = -1;
|
||||
uint32_t state = seed ? seed : 1u;
|
||||
// Single-pass reservoir halves the work of count-then-pick. A
|
||||
// small LCG avoids invoking mt19937 for every valid position.
|
||||
uint64_t count = 0;
|
||||
int best_y = -1, best_x = -1;
|
||||
uint32_t state = seed ? seed : 1u;
|
||||
for (int i = 0; i <= max_row; ++i) {
|
||||
for (int j = 0; j <= max_col; ++j) {
|
||||
if (get_area_sum_fast(i, j, box_h, box_w) == 0) {
|
||||
++count;
|
||||
// Replace the current result with probability 1/count.
|
||||
state = state * 1664525u + 1013904223u;
|
||||
const bool replace = (state % count) == 0;
|
||||
if (replace) {
|
||||
best_y = i;
|
||||
best_x = j;
|
||||
}
|
||||
// Replace the current result with probability 1/count.
|
||||
state = state * 1664525u + 1013904223u;
|
||||
const bool replace = (state % count) == 0;
|
||||
if (replace) {
|
||||
best_y = i;
|
||||
best_x = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count == 0
|
||||
? DirectResult{false, -1, -1}
|
||||
: DirectResult{true, best_y, best_x};
|
||||
return count == 0
|
||||
? DirectResult{false, -1, -1}
|
||||
: DirectResult{true, best_y, best_x};
|
||||
}
|
||||
|
||||
// Multi-thread path: parallel count then pick
|
||||
@@ -559,193 +559,257 @@ public:
|
||||
}
|
||||
cum += chunk_counts[t];
|
||||
}
|
||||
return {false, -1, -1};
|
||||
}
|
||||
|
||||
// Sample legal positions and prefer the one whose box center is closest
|
||||
// to the free-mask centroid. This gives the visually important large
|
||||
// words a stable focal region without paying for a full spiral scan.
|
||||
DirectResult query_near_center(int box_h, int box_w, uint32_t seed, int probes) {
|
||||
flush();
|
||||
|
||||
int max_row = height - box_h;
|
||||
int max_col = width - box_w;
|
||||
if (max_row < 0 || max_col < 0) return {false, -1, -1};
|
||||
|
||||
std::mt19937 rng(seed);
|
||||
probes = std::max(16, std::min(probes, 1024));
|
||||
|
||||
int best_y = -1;
|
||||
int best_x = -1;
|
||||
double best_score = std::numeric_limits<double>::infinity();
|
||||
std::uniform_int_distribution<int> row_dist(0, max_row);
|
||||
std::uniform_int_distribution<int> col_dist(0, max_col);
|
||||
std::uniform_real_distribution<double> jitter(0.0, 1e-4);
|
||||
|
||||
// Test the centroid-aligned position first.
|
||||
int center_y = std::max(0, std::min(max_row, (int)std::lround(free_center_y - box_h * 0.5)));
|
||||
int center_x = std::max(0, std::min(max_col, (int)std::lround(free_center_x - box_w * 0.5)));
|
||||
if (get_area_sum_fast(center_y, center_x, box_h, box_w) == 0) {
|
||||
return {true, center_y, center_x};
|
||||
}
|
||||
|
||||
const double norm_y = std::max(1.0, (double)height);
|
||||
const double norm_x = std::max(1.0, (double)width);
|
||||
for (int p = 0; p < probes; ++p) {
|
||||
int y = row_dist(rng);
|
||||
int x = col_dist(rng);
|
||||
if (get_area_sum_fast(y, x, box_h, box_w) != 0) continue;
|
||||
double cy = (double)y + box_h * 0.5;
|
||||
double cx = (double)x + box_w * 0.5;
|
||||
double dy = (cy - free_center_y) / norm_y;
|
||||
double dx = (cx - free_center_x) / norm_x;
|
||||
double score = dy * dy + dx * dx + jitter(rng);
|
||||
if (score < best_score) {
|
||||
best_score = score;
|
||||
best_y = y;
|
||||
best_x = x;
|
||||
}
|
||||
}
|
||||
|
||||
if (best_y >= 0) return {true, best_y, best_x};
|
||||
return query_direct(box_h, box_w, seed ^ 0x9E3779B9u);
|
||||
}
|
||||
|
||||
inline bool glyph_fits_exact(
|
||||
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
|
||||
) const {
|
||||
for (int row = 0; row < glyph_h; ++row) {
|
||||
const unsigned char* glyph_row = glyph + row * glyph_w;
|
||||
const uint8_t* canvas_row = canvas.data() + (y + row) * width + x;
|
||||
for (int col = 0; col < glyph_w; ++col) {
|
||||
if (glyph_row[col] > 0 && canvas_row[col] != 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void reserve_glyph_exact(
|
||||
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
|
||||
) {
|
||||
stamp_glyph(glyph, glyph_h, glyph_w, y, x);
|
||||
}
|
||||
|
||||
// Find and reserve a position using the actual (optionally dilated) glyph
|
||||
// bitmap. Unlike rectangle queries, transparent corners and gaps between
|
||||
// strokes may overlap safely. Search and reservation stay in one C++ call,
|
||||
// so no integral-image rebuild is needed between words.
|
||||
DirectResult place_glyph_exact(
|
||||
const unsigned char* collision_glyph,
|
||||
const unsigned char* stamp_glyph_data,
|
||||
int glyph_h,
|
||||
int glyph_w,
|
||||
uint32_t seed,
|
||||
int probes,
|
||||
int placement_mode
|
||||
) {
|
||||
ensure_canvas();
|
||||
const int max_row = height - glyph_h;
|
||||
const int max_col = width - glyph_w;
|
||||
if (max_row < 0 || max_col < 0) return {false, -1, -1};
|
||||
|
||||
std::mt19937 rng(seed);
|
||||
probes = std::max(32, std::min(probes, 2048));
|
||||
std::uniform_int_distribution<int> row_dist(0, max_row);
|
||||
std::uniform_int_distribution<int> col_dist(0, max_col);
|
||||
|
||||
int best_y = -1;
|
||||
int best_x = -1;
|
||||
double best_score = std::numeric_limits<double>::infinity();
|
||||
|
||||
if (placement_mode != 0) {
|
||||
const int center_y = std::max(
|
||||
0, std::min(max_row, (int)std::lround(free_center_y - glyph_h * 0.5))
|
||||
);
|
||||
const int center_x = std::max(
|
||||
0, std::min(max_col, (int)std::lround(free_center_x - glyph_w * 0.5))
|
||||
);
|
||||
if (glyph_fits_exact(collision_glyph, glyph_h, glyph_w, center_y, center_x)) {
|
||||
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, center_y, center_x);
|
||||
return {true, center_y, center_x};
|
||||
}
|
||||
}
|
||||
|
||||
const double norm_y = std::max(1.0, (double)height);
|
||||
const double norm_x = std::max(1.0, (double)width);
|
||||
|
||||
// Mode 1 is a persistent centre-out Fermat spiral. Mode 2 skips the
|
||||
// spiral and selects a random legal candidate biased toward the centre.
|
||||
if (placement_mode == 1) {
|
||||
constexpr double golden_angle = 2.39996322972865332;
|
||||
constexpr double sample_spacing = 1.25;
|
||||
const int start_step = spiral_cursor;
|
||||
const int end_step = std::min(200000, start_step + 60000);
|
||||
int last_y = std::numeric_limits<int>::min();
|
||||
int last_x = std::numeric_limits<int>::min();
|
||||
for (int step = start_step; step <= end_step; ++step) {
|
||||
const double radius = sample_spacing * std::sqrt((double)step);
|
||||
const double theta = golden_angle * (double)step;
|
||||
const int y = (int)std::lround(
|
||||
free_center_y + radius * std::sin(theta) - glyph_h * 0.5
|
||||
);
|
||||
const int x = (int)std::lround(
|
||||
free_center_x + radius * std::cos(theta) - glyph_w * 0.5
|
||||
);
|
||||
if (y == last_y && x == last_x) continue;
|
||||
last_y = y;
|
||||
last_x = x;
|
||||
if (y < 0 || x < 0 || y > max_row || x > max_col) continue;
|
||||
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
|
||||
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
|
||||
spiral_cursor = step;
|
||||
return {true, y, x};
|
||||
}
|
||||
}
|
||||
|
||||
for (int probe = 0; probe < probes; ++probe) {
|
||||
const int y = row_dist(rng);
|
||||
const int x = col_dist(rng);
|
||||
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
|
||||
if (placement_mode == 0) {
|
||||
stamp_glyph(stamp_glyph_data, glyph_h, glyph_w, y, x);
|
||||
return {true, y, x};
|
||||
}
|
||||
const double cy = (double)y + glyph_h * 0.5;
|
||||
const double cx = (double)x + glyph_w * 0.5;
|
||||
const double dy = (cy - free_center_y) / norm_y;
|
||||
const double dx = (cx - free_center_x) / norm_x;
|
||||
const double score = dy * dy + dx * dx;
|
||||
if (score < best_score) {
|
||||
best_score = score;
|
||||
best_y = y;
|
||||
best_x = x;
|
||||
}
|
||||
}
|
||||
if (best_y >= 0) {
|
||||
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, best_y, best_x);
|
||||
return {true, best_y, best_x};
|
||||
}
|
||||
|
||||
// Dense late-stage fallback. Start at a seeded offset to avoid a
|
||||
// top-left bias, but visit every possible origin so completeness is
|
||||
// deterministic whenever a legal position exists.
|
||||
const int n_rows = max_row + 1;
|
||||
const int n_cols = max_col + 1;
|
||||
const int row_start = row_dist(rng);
|
||||
const int col_start = col_dist(rng);
|
||||
for (int row_offset = 0; row_offset < n_rows; ++row_offset) {
|
||||
const int y = (row_start + row_offset) % n_rows;
|
||||
for (int col_offset = 0; col_offset < n_cols; ++col_offset) {
|
||||
const int x = (col_start + col_offset) % n_cols;
|
||||
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
|
||||
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
|
||||
return {true, y, x};
|
||||
}
|
||||
}
|
||||
return {false, -1, -1};
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
return {false, -1, -1};
|
||||
}
|
||||
|
||||
// Sample legal positions and prefer the one whose box center is closest
|
||||
// to the free-mask centroid. This gives the visually important large
|
||||
// words a stable focal region without paying for a full spiral scan.
|
||||
DirectResult query_near_center(int box_h, int box_w, uint32_t seed, int probes) {
|
||||
flush();
|
||||
|
||||
int max_row = height - box_h;
|
||||
int max_col = width - box_w;
|
||||
if (max_row < 0 || max_col < 0) return {false, -1, -1};
|
||||
|
||||
std::mt19937 rng(seed);
|
||||
probes = std::max(16, std::min(probes, 1024));
|
||||
|
||||
int best_y = -1;
|
||||
int best_x = -1;
|
||||
double best_score = std::numeric_limits<double>::infinity();
|
||||
std::uniform_int_distribution<int> row_dist(0, max_row);
|
||||
std::uniform_int_distribution<int> col_dist(0, max_col);
|
||||
std::uniform_real_distribution<double> jitter(0.0, 1e-4);
|
||||
|
||||
// Test the centroid-aligned position first.
|
||||
int center_y = std::max(0, std::min(max_row, (int)std::lround(free_center_y - box_h * 0.5)));
|
||||
int center_x = std::max(0, std::min(max_col, (int)std::lround(free_center_x - box_w * 0.5)));
|
||||
if (get_area_sum_fast(center_y, center_x, box_h, box_w) == 0) {
|
||||
return {true, center_y, center_x};
|
||||
}
|
||||
|
||||
const double norm_y = std::max(1.0, (double)height);
|
||||
const double norm_x = std::max(1.0, (double)width);
|
||||
for (int p = 0; p < probes; ++p) {
|
||||
int y = row_dist(rng);
|
||||
int x = col_dist(rng);
|
||||
if (get_area_sum_fast(y, x, box_h, box_w) != 0) continue;
|
||||
double cy = (double)y + box_h * 0.5;
|
||||
double cx = (double)x + box_w * 0.5;
|
||||
double dy = (cy - free_center_y) / norm_y;
|
||||
double dx = (cx - free_center_x) / norm_x;
|
||||
double score = dy * dy + dx * dx + jitter(rng);
|
||||
if (score < best_score) {
|
||||
best_score = score;
|
||||
best_y = y;
|
||||
best_x = x;
|
||||
}
|
||||
}
|
||||
|
||||
if (best_y >= 0) return {true, best_y, best_x};
|
||||
return query_direct(box_h, box_w, seed ^ 0x9E3779B9u);
|
||||
}
|
||||
|
||||
inline bool glyph_fits_exact(
|
||||
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
|
||||
) const {
|
||||
for (int row = 0; row < glyph_h; ++row) {
|
||||
const unsigned char* glyph_row = glyph + row * glyph_w;
|
||||
const uint8_t* canvas_row = canvas.data() + (y + row) * width + x;
|
||||
for (int col = 0; col < glyph_w; ++col) {
|
||||
if (glyph_row[col] > 0 && canvas_row[col] != 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void reserve_glyph_exact(
|
||||
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
|
||||
) {
|
||||
stamp_glyph(glyph, glyph_h, glyph_w, y, x);
|
||||
}
|
||||
|
||||
// Find and reserve a position using the actual (optionally dilated) glyph
|
||||
// bitmap. Unlike rectangle queries, transparent corners and gaps between
|
||||
// strokes may overlap safely. Search and reservation stay in one C++ call,
|
||||
// so no integral-image rebuild is needed between words.
|
||||
DirectResult place_glyph_exact(
|
||||
const unsigned char* collision_glyph,
|
||||
const unsigned char* stamp_glyph_data,
|
||||
int glyph_h,
|
||||
int glyph_w,
|
||||
uint32_t seed,
|
||||
int probes,
|
||||
int placement_mode
|
||||
) {
|
||||
ensure_canvas();
|
||||
const int max_row = height - glyph_h;
|
||||
const int max_col = width - glyph_w;
|
||||
if (max_row < 0 || max_col < 0) return {false, -1, -1};
|
||||
|
||||
std::mt19937 rng(seed);
|
||||
probes = std::max(32, std::min(probes, 2048));
|
||||
std::uniform_int_distribution<int> row_dist(0, max_row);
|
||||
std::uniform_int_distribution<int> col_dist(0, max_col);
|
||||
|
||||
int best_y = -1;
|
||||
int best_x = -1;
|
||||
double best_score = std::numeric_limits<double>::infinity();
|
||||
|
||||
// Only the spiral (mode 1) may anchor its first probe on the exact
|
||||
// mask centroid; that is the "small words spiral out from the centre"
|
||||
// behaviour we want. Mode 2 (large words) deliberately skips it: when
|
||||
// every mode kept this shortcut, whichever word happened to be placed
|
||||
// first landed on the same centroid pixel in every single generation,
|
||||
// regardless of layout_seed, giving every export an unmoving bullseye.
|
||||
if (placement_mode == 1) {
|
||||
const int center_y = std::max(
|
||||
0, std::min(max_row, (int)std::lround(free_center_y - glyph_h * 0.5))
|
||||
);
|
||||
const int center_x = std::max(
|
||||
0, std::min(max_col, (int)std::lround(free_center_x - glyph_w * 0.5))
|
||||
);
|
||||
if (glyph_fits_exact(collision_glyph, glyph_h, glyph_w, center_y, center_x)) {
|
||||
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, center_y, center_x);
|
||||
return {true, center_y, center_x};
|
||||
}
|
||||
}
|
||||
|
||||
const double norm_y = std::max(1.0, (double)height);
|
||||
const double norm_x = std::max(1.0, (double)width);
|
||||
|
||||
// Mode 1 is a persistent centre-out Fermat spiral. Mode 2 skips the
|
||||
// spiral and places the word by random probe.
|
||||
if (placement_mode == 1) {
|
||||
constexpr double golden_angle = 2.39996322972865332;
|
||||
constexpr double sample_spacing = 1.25;
|
||||
constexpr double two_pi = 6.283185307179586;
|
||||
// Each word starts its sweep at a random angle instead of exactly
|
||||
// golden_angle past the previous one, so consecutive words are no
|
||||
// longer locked into the fixed angular step that draws a textbook
|
||||
// Vogel/sunflower figure. The draw comes from `rng`, already seeded
|
||||
// per word from layout_seed, so a given seed still reproduces
|
||||
// exactly, and different seeds now give genuinely different
|
||||
// arrangements rather than the same figure with the names permuted.
|
||||
//
|
||||
// The offset deliberately spans the full circle. A *bounded* offset
|
||||
// was tried and is much worse than useless: confining the sweep to a
|
||||
// wedge makes a word skip positions at the packed frontier and
|
||||
// settle for a worse one, which measured a 31% loss of final ink
|
||||
// density (0.255 -> 0.176) on an 800-word cloud. Spanning the whole
|
||||
// circle costs nothing, because the sweep still reaches every angle
|
||||
// as the radius grows.
|
||||
//
|
||||
// Note this does not make the cloud look unstructured. Radius still
|
||||
// tracks placement order closely (Pearson r ~= 0.98), because a
|
||||
// centre-out fill that stays dense has to grow outward in order --
|
||||
// the ordering and the density are the same property. Breaking that
|
||||
// appearance without paying for it needs several spiral origins
|
||||
// rather than jitter on one, which is a larger change than this.
|
||||
std::uniform_real_distribution<double> phase_jitter(0.0, two_pi);
|
||||
const double theta_offset = phase_jitter(rng);
|
||||
const int start_step = spiral_cursor;
|
||||
const int end_step = std::min(200000, start_step + 60000);
|
||||
int last_y = std::numeric_limits<int>::min();
|
||||
int last_x = std::numeric_limits<int>::min();
|
||||
for (int step = start_step; step <= end_step; ++step) {
|
||||
const double radius = sample_spacing * std::sqrt((double)step);
|
||||
const double theta = golden_angle * (double)step + theta_offset;
|
||||
const int y = (int)std::lround(
|
||||
free_center_y + radius * std::sin(theta) - glyph_h * 0.5
|
||||
);
|
||||
const int x = (int)std::lround(
|
||||
free_center_x + radius * std::cos(theta) - glyph_w * 0.5
|
||||
);
|
||||
if (y == last_y && x == last_x) continue;
|
||||
last_y = y;
|
||||
last_x = x;
|
||||
if (y < 0 || x < 0 || y > max_row || x > max_col) continue;
|
||||
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
|
||||
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
|
||||
spiral_cursor = step;
|
||||
return {true, y, x};
|
||||
}
|
||||
}
|
||||
|
||||
// Soft radial bound for mode 2. Pure first-accept scatter over the whole
|
||||
// canvas let large words land far outside the crowd, leaving detached
|
||||
// stragglers and a ragged silhouette. Draws beyond the bound are
|
||||
// rejected, but the bound widens as the probe budget is consumed and is
|
||||
// gone entirely for the last quarter of the probes, so this only biases
|
||||
// *where* a word prefers to land -- it never removes a legal position
|
||||
// and so cannot cost completeness.
|
||||
const double mask_radius = 0.5 * std::sqrt(
|
||||
(double)height * (double)height + (double)width * (double)width
|
||||
);
|
||||
|
||||
for (int probe = 0; probe < probes; ++probe) {
|
||||
const int y = row_dist(rng);
|
||||
const int x = col_dist(rng);
|
||||
if (placement_mode == 2) {
|
||||
const double frac = (double)probe / (double)probes;
|
||||
if (frac < 0.75) {
|
||||
// 0.55 -> 1.0 of the mask radius over the first 75% of probes.
|
||||
const double limit = mask_radius * (0.55 + 0.60 * frac);
|
||||
const double cy = (double)y + glyph_h * 0.5 - free_center_y;
|
||||
const double cx = (double)x + glyph_w * 0.5 - free_center_x;
|
||||
if (cy * cy + cx * cx > limit * limit) continue;
|
||||
}
|
||||
}
|
||||
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
|
||||
if (placement_mode == 0) {
|
||||
stamp_glyph(stamp_glyph_data, glyph_h, glyph_w, y, x);
|
||||
return {true, y, x};
|
||||
}
|
||||
// Mode 2 (large words) takes the first legal random draw inside the
|
||||
// radial bound above. It used to scan the whole probe budget and keep
|
||||
// the candidate closest to the mask centroid, which packed every
|
||||
// large word into one tight rosette at the centre -- the innermost
|
||||
// radial shell held no spiral words at all, leaving a hard seam
|
||||
// between a dense core and the spiral field. First-accept scatters
|
||||
// them across the mass as intended, and is cheaper: it can return on
|
||||
// the first hit instead of always running all `probes` fit tests.
|
||||
if (placement_mode == 2) {
|
||||
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
|
||||
return {true, y, x};
|
||||
}
|
||||
const double cy = (double)y + glyph_h * 0.5;
|
||||
const double cx = (double)x + glyph_w * 0.5;
|
||||
const double dy = (cy - free_center_y) / norm_y;
|
||||
const double dx = (cx - free_center_x) / norm_x;
|
||||
const double score = dy * dy + dx * dx;
|
||||
if (score < best_score) {
|
||||
best_score = score;
|
||||
best_y = y;
|
||||
best_x = x;
|
||||
}
|
||||
}
|
||||
if (best_y >= 0) {
|
||||
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, best_y, best_x);
|
||||
return {true, best_y, best_x};
|
||||
}
|
||||
|
||||
// Dense late-stage fallback. Start at a seeded offset to avoid a
|
||||
// top-left bias, but visit every possible origin so completeness is
|
||||
// deterministic whenever a legal position exists.
|
||||
const int n_rows = max_row + 1;
|
||||
const int n_cols = max_col + 1;
|
||||
const int row_start = row_dist(rng);
|
||||
const int col_start = col_dist(rng);
|
||||
for (int row_offset = 0; row_offset < n_rows; ++row_offset) {
|
||||
const int y = (row_start + row_offset) % n_rows;
|
||||
for (int col_offset = 0; col_offset < n_cols; ++col_offset) {
|
||||
const int x = (col_start + col_offset) % n_cols;
|
||||
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
|
||||
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
|
||||
return {true, y, x};
|
||||
}
|
||||
}
|
||||
return {false, -1, -1};
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// v3: Batch query — process multiple (box_h, box_w) in one call
|
||||
// Returns vector of {found, y, x} for each query
|
||||
// =========================================================
|
||||
@@ -786,13 +850,13 @@ public:
|
||||
return {false, -1, -1, end_idx};
|
||||
}
|
||||
|
||||
std::pair<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
|
||||
{
|
||||
std::unique_lock<std::shared_mutex> lock(mutex_);
|
||||
ensure_valid_coords_center_sorted();
|
||||
}
|
||||
{
|
||||
std::shared_lock<std::shared_mutex> lock(mutex_);
|
||||
std::pair<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
|
||||
{
|
||||
std::unique_lock<std::shared_mutex> lock(mutex_);
|
||||
ensure_valid_coords_center_sorted();
|
||||
}
|
||||
{
|
||||
std::shared_lock<std::shared_mutex> lock(mutex_);
|
||||
if (dirty_count > 0 && dirty_count >= rebuild_interval) {
|
||||
lock.unlock();
|
||||
std::unique_lock<std::shared_mutex> write_lock(mutex_);
|
||||
@@ -894,67 +958,67 @@ static PyObject* Grid_query_reservoir(PyIntegralGrid* self, PyObject* args) {
|
||||
}
|
||||
|
||||
// v3: Direct pixel-grid scan with parallel counting
|
||||
static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) {
|
||||
static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) {
|
||||
int box_h, box_w;
|
||||
unsigned int seed = 0;
|
||||
if (!PyArg_ParseTuple(args, "ii|I", &box_h, &box_w, &seed)) return NULL;
|
||||
|
||||
auto r = self->grid->query_direct(box_h, box_w, seed);
|
||||
auto r = self->grid->query_direct(box_h, box_w, seed);
|
||||
if (r.found) return Py_BuildValue("ii", r.y, r.x);
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
static PyObject* Grid_query_near_center(PyIntegralGrid* self, PyObject* args) {
|
||||
int box_h, box_w;
|
||||
unsigned int seed = 0;
|
||||
int probes = 160;
|
||||
if (!PyArg_ParseTuple(args, "ii|Ii", &box_h, &box_w, &seed, &probes)) return NULL;
|
||||
|
||||
auto r = self->grid->query_near_center(box_h, box_w, seed, probes);
|
||||
if (r.found) return Py_BuildValue("ii", r.y, r.x);
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
static PyObject* Grid_place_glyph_exact(PyIntegralGrid* self, PyObject* args) {
|
||||
PyObject* collision_obj;
|
||||
PyObject* stamp_obj;
|
||||
int glyph_h, glyph_w;
|
||||
unsigned int seed = 0;
|
||||
int probes = 256;
|
||||
int placement_mode = 0;
|
||||
if (!PyArg_ParseTuple(
|
||||
args, "OOiiIii", &collision_obj, &stamp_obj, &glyph_h, &glyph_w, &seed, &probes, &placement_mode
|
||||
)) return NULL;
|
||||
|
||||
Py_buffer collision_view;
|
||||
Py_buffer stamp_view;
|
||||
if (PyObject_GetBuffer(collision_obj, &collision_view, PyBUF_SIMPLE) < 0) return NULL;
|
||||
if (PyObject_GetBuffer(stamp_obj, &stamp_view, PyBUF_SIMPLE) < 0) {
|
||||
PyBuffer_Release(&collision_view);
|
||||
return NULL;
|
||||
}
|
||||
const Py_ssize_t required = (Py_ssize_t)glyph_h * (Py_ssize_t)glyph_w;
|
||||
if (glyph_h <= 0 || glyph_w <= 0 || collision_view.len < required || stamp_view.len < required) {
|
||||
PyBuffer_Release(&collision_view);
|
||||
PyBuffer_Release(&stamp_view);
|
||||
PyErr_SetString(PyExc_ValueError, "glyph buffer is smaller than glyph_h * glyph_w");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
auto result = self->grid->place_glyph_exact(
|
||||
(const unsigned char*)collision_view.buf,
|
||||
(const unsigned char*)stamp_view.buf,
|
||||
glyph_h,
|
||||
glyph_w,
|
||||
seed,
|
||||
probes,
|
||||
placement_mode
|
||||
);
|
||||
PyBuffer_Release(&collision_view);
|
||||
PyBuffer_Release(&stamp_view);
|
||||
if (result.found) return Py_BuildValue("ii", result.y, result.x);
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
static PyObject* Grid_query_near_center(PyIntegralGrid* self, PyObject* args) {
|
||||
int box_h, box_w;
|
||||
unsigned int seed = 0;
|
||||
int probes = 160;
|
||||
if (!PyArg_ParseTuple(args, "ii|Ii", &box_h, &box_w, &seed, &probes)) return NULL;
|
||||
|
||||
auto r = self->grid->query_near_center(box_h, box_w, seed, probes);
|
||||
if (r.found) return Py_BuildValue("ii", r.y, r.x);
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
static PyObject* Grid_place_glyph_exact(PyIntegralGrid* self, PyObject* args) {
|
||||
PyObject* collision_obj;
|
||||
PyObject* stamp_obj;
|
||||
int glyph_h, glyph_w;
|
||||
unsigned int seed = 0;
|
||||
int probes = 256;
|
||||
int placement_mode = 0;
|
||||
if (!PyArg_ParseTuple(
|
||||
args, "OOiiIii", &collision_obj, &stamp_obj, &glyph_h, &glyph_w, &seed, &probes, &placement_mode
|
||||
)) return NULL;
|
||||
|
||||
Py_buffer collision_view;
|
||||
Py_buffer stamp_view;
|
||||
if (PyObject_GetBuffer(collision_obj, &collision_view, PyBUF_SIMPLE) < 0) return NULL;
|
||||
if (PyObject_GetBuffer(stamp_obj, &stamp_view, PyBUF_SIMPLE) < 0) {
|
||||
PyBuffer_Release(&collision_view);
|
||||
return NULL;
|
||||
}
|
||||
const Py_ssize_t required = (Py_ssize_t)glyph_h * (Py_ssize_t)glyph_w;
|
||||
if (glyph_h <= 0 || glyph_w <= 0 || collision_view.len < required || stamp_view.len < required) {
|
||||
PyBuffer_Release(&collision_view);
|
||||
PyBuffer_Release(&stamp_view);
|
||||
PyErr_SetString(PyExc_ValueError, "glyph buffer is smaller than glyph_h * glyph_w");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
auto result = self->grid->place_glyph_exact(
|
||||
(const unsigned char*)collision_view.buf,
|
||||
(const unsigned char*)stamp_view.buf,
|
||||
glyph_h,
|
||||
glyph_w,
|
||||
seed,
|
||||
probes,
|
||||
placement_mode
|
||||
);
|
||||
PyBuffer_Release(&collision_view);
|
||||
PyBuffer_Release(&stamp_view);
|
||||
if (result.found) return Py_BuildValue("ii", result.y, result.x);
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// v3: Batch query — process multiple placements in one C++ call
|
||||
// Input: list of (box_h, box_w, seed) tuples
|
||||
@@ -1031,16 +1095,16 @@ static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// Stamp glyph bitmap onto C++ canvas and rebuild the affected integral region.
|
||||
static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
|
||||
// Stamp glyph bitmap onto C++ canvas and rebuild the affected integral region.
|
||||
static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
|
||||
PyObject* glyph_obj;
|
||||
int gh, gw, pos_r, pos_c;
|
||||
if (!PyArg_ParseTuple(args, "Oiiii", &glyph_obj, &gh, &gw, &pos_r, &pos_c)) return NULL;
|
||||
Py_buffer view;
|
||||
if (PyObject_GetBuffer(glyph_obj, &view, PyBUF_SIMPLE) < 0) return NULL;
|
||||
|
||||
self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c);
|
||||
self->grid->rebuild_from_canvas(pos_r, pos_c);
|
||||
self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c);
|
||||
self->grid->rebuild_from_canvas(pos_r, pos_c);
|
||||
|
||||
PyBuffer_Release(&view);
|
||||
Py_RETURN_NONE;
|
||||
@@ -1049,10 +1113,10 @@ static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
|
||||
static PyMethodDef Grid_methods[] = {
|
||||
{"reorder_stratified", (PyCFunction)Grid_reorder_stratified, METH_VARARGS, "Reorder valid coords with stratified interleaving."},
|
||||
{"query_sorted", (PyCFunction)Grid_query_sorted, METH_VARARGS, "Find position using sorted coordinate list (center-out, parallel)."},
|
||||
{"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."},
|
||||
{"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."},
|
||||
{"query_near_center", (PyCFunction)Grid_query_near_center, METH_VARARGS, "Sample legal positions and prefer the free-mask centroid."},
|
||||
{"place_glyph_exact", (PyCFunction)Grid_place_glyph_exact, METH_VARARGS, "Place and reserve an exact glyph bitmap without changing its size."},
|
||||
{"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."},
|
||||
{"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."},
|
||||
{"query_near_center", (PyCFunction)Grid_query_near_center, METH_VARARGS, "Sample legal positions and prefer the free-mask centroid."},
|
||||
{"place_glyph_exact", (PyCFunction)Grid_place_glyph_exact, METH_VARARGS, "Place and reserve an exact glyph bitmap without changing its size."},
|
||||
{"batch_query", (PyCFunction)Grid_batch_query, METH_VARARGS, "Batch placement: list of (bh,bw,seed) -> list of (y,x)|None."},
|
||||
{"update", (PyCFunction)Grid_update, METH_VARARGS, "Update grid with placed rectangle."},
|
||||
{"flush", (PyCFunction)Grid_flush, METH_NOARGS, "Force rebuild integral image."},
|
||||
|
||||
+20
-2
@@ -46,6 +46,8 @@ BASE_HD_HEIGHT = 4000
|
||||
MIN_READABLE_HEIGHT_PX = 22
|
||||
# 运算网格缩放:0.18 在速度/质量之间更均衡
|
||||
WORK_SCALE = 0.18
|
||||
# 服务端快速预览路径:减少试探次数,但保留真实字形碰撞和零重叠校验。
|
||||
FAST_MODE = False
|
||||
|
||||
# --- 阴阳刻 ---
|
||||
FILL_ON = "BLACK"
|
||||
@@ -67,11 +69,19 @@ FONT_FALLBACK_PATHS = (
|
||||
|
||||
# --- 填充策略 ---
|
||||
N_REPETITIONS = 1
|
||||
# 名单较少、掩膜轮廓填不满时,自动循环追加名字副本增加词数,让费马螺旋
|
||||
# 能走到掩膜远端(心形尖端、人物四肢),把形状填出来而非退化成圆形。
|
||||
# AUTO_REPEAT_MAX 是自动重复次数的安全上限,避免无限追加。
|
||||
AUTO_REPEAT_TO_FILL = True
|
||||
AUTO_REPEAT_MAX = 20
|
||||
# 面积模型目标填充率:中文实心笔画像素占比约 0.35–0.55。
|
||||
# 略偏保守以保证 scale=1.0 首次就能放满,减少多轮重试。
|
||||
TARGET_FILL_RATIO = 0.45
|
||||
SIZE_RATIO = 2.0
|
||||
PACKING_EFFICIENCY = 0.9
|
||||
# 竖排概率:每个词独立以此概率竖着摆放,其余水平摆放。
|
||||
# 0.0 = 全部水平,1.0 = 全部竖排。适度混排可打散过于规整的观感。
|
||||
VERTICAL_RATIO = 0.18
|
||||
|
||||
# --- 智能字号搜索 ---
|
||||
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
|
||||
@@ -117,11 +127,12 @@ LAYOUT_SEED = None
|
||||
KNOWN_CONFIG_KEYS = {
|
||||
'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'FILL_CORNERS',
|
||||
'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE',
|
||||
'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FILL_ON', 'EXCEL_PATH',
|
||||
'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FAST_MODE', 'FILL_ON', 'EXCEL_PATH',
|
||||
'DATA_COL_INDEX', 'WEIGHT_COL_INDEX', 'WEIGHT_COL_NAME', 'REMOVE_DUPLICATES', 'ENABLE_STROKE_WEIGHTS',
|
||||
'WC_FONT_PATH',
|
||||
'FONT_FALLBACK_PATHS',
|
||||
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY',
|
||||
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY', 'VERTICAL_RATIO',
|
||||
'AUTO_REPEAT_TO_FILL', 'AUTO_REPEAT_MAX',
|
||||
'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE',
|
||||
'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH',
|
||||
'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'OUTPUT_DIR', 'OUTPUT_PREFIX',
|
||||
@@ -144,11 +155,15 @@ CONFIG_ALIASES = {
|
||||
'max_font_size': 'USER_MAX_FONT_SIZE',
|
||||
'font_color': 'FONT_COLOR',
|
||||
'stroke_weights': 'ENABLE_STROKE_WEIGHTS',
|
||||
'auto_repeat_to_fill': 'AUTO_REPEAT_TO_FILL',
|
||||
'auto_repeat_max': 'AUTO_REPEAT_MAX',
|
||||
'save_debug_images': 'SAVE_DEBUG_IMAGES',
|
||||
}
|
||||
|
||||
CRITICAL_TYPE_CHECKS = {
|
||||
'MODE': str,
|
||||
'WORK_SCALE': (int, float),
|
||||
'FAST_MODE': bool,
|
||||
'DATA_COL_INDEX': int,
|
||||
'WEIGHT_COL_INDEX': (int, type(None)),
|
||||
'WEIGHT_COL_NAME': (str, type(None)),
|
||||
@@ -160,6 +175,9 @@ CRITICAL_TYPE_CHECKS = {
|
||||
'ENABLE_STROKE_WEIGHTS': bool,
|
||||
'CANVAS_RETRY_MAX_ROUNDS': int,
|
||||
'CANVAS_RETRY_GROWTH': (int, float),
|
||||
'AUTO_REPEAT_TO_FILL': bool,
|
||||
'AUTO_REPEAT_MAX': int,
|
||||
'VERTICAL_RATIO': (int, float),
|
||||
'SEED': (int, type(None)),
|
||||
'LAYOUT_SEED': (int, type(None)),
|
||||
}
|
||||
|
||||
+220
-83
@@ -42,65 +42,169 @@ def _load_ft_font(font_path):
|
||||
|
||||
|
||||
def build_svg_text_path_cached(word, size, x, y, font_path, orient):
|
||||
"""Return (path_d, tx, ty, None). Geometry is cached for identical glyphs.
|
||||
"""Return (path_d, tx, ty, None) for the whole word as a single path.
|
||||
|
||||
Path is in y-up font space (same as matplotlib TextPath). Caller applies
|
||||
translate(tx, ty) scale(1, -1) to place it on the canvas.
|
||||
Kept for callers that want one path per word. Internally this concatenates
|
||||
the per-character parts, which are cached and shared across every word that
|
||||
reuses the character.
|
||||
"""
|
||||
parts, tx0, ty0 = build_svg_word_parts(word, size, font_path, orient)
|
||||
if not parts:
|
||||
return "", x, y, None
|
||||
if len(parts) == 1 and parts[0][1] == 0.0 and parts[0][2] == 0.0:
|
||||
return parts[0][0], tx0 + x, ty0 + y, None
|
||||
# Only reached when a caller insists on one path per word; the per-part
|
||||
# translate has to be baked into the coordinates, so this is the slow path.
|
||||
merged = " ".join(_translate_path_d(d, dx, dy) for d, dx, dy in parts)
|
||||
return merged, tx0 + x, ty0 + y, None
|
||||
|
||||
|
||||
def build_svg_word_parts(word, size, font_path, orient):
|
||||
"""Return ([(path_d, dx, dy), ...], tx0, ty0) for one word.
|
||||
|
||||
Each part is a per-character outline cached at cursor 0 and reused verbatim;
|
||||
dx/dy carry that character's position within the word. A caller places a
|
||||
part with translate(tx0 + x + dx, ty0 + y + dy) scale(1, -1).
|
||||
|
||||
Caching per character rather than per word is what makes export cheap: a
|
||||
roster of 750 Chinese names contains only a few dozen distinct characters,
|
||||
so the outline drawing and the path-string formatting run a few dozen times
|
||||
instead of once per name.
|
||||
"""
|
||||
key = (font_path, word, int(size), bool(orient))
|
||||
cached = _SVG_SHAPE_CACHE.get(key)
|
||||
if cached is None:
|
||||
try:
|
||||
cached = _build_shape_fonttools(word, size, font_path, orient)
|
||||
except Exception:
|
||||
cached = _build_shape_matplotlib(word, size, font_path, orient)
|
||||
_SVG_SHAPE_CACHE[key] = cached
|
||||
if len(_SVG_SHAPE_CACHE) > 20000:
|
||||
for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())):
|
||||
if i % 2 == 0:
|
||||
_SVG_SHAPE_CACHE.pop(k, None)
|
||||
path_d, tx0, ty0 = cached
|
||||
return path_d, tx0 + x, ty0 + y, None
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
parts = []
|
||||
cursor = 0.0
|
||||
xmin = ymin = float("inf")
|
||||
xmax = ymax = float("-inf")
|
||||
for ch in word:
|
||||
shape = _char_shape(ch, size, font_path, orient)
|
||||
if shape is None:
|
||||
continue
|
||||
path_d, advance, cxmin, cymin, cxmax, cymax = shape
|
||||
if path_d:
|
||||
# A character's outline is cached at cursor 0; the cursor becomes a
|
||||
# translate offset so the cached string is reused byte for byte.
|
||||
# Horizontal runs advance in +x, rotated runs in +y (the rotated
|
||||
# glyph transform subtracts the cursor from y, and the outer
|
||||
# scale(1, -1) flips that back to +y).
|
||||
dx, dy = (0.0, cursor) if orient else (cursor, 0.0)
|
||||
parts.append((path_d, dx, dy))
|
||||
if orient:
|
||||
xmin = min(xmin, cxmin)
|
||||
xmax = max(xmax, cxmax)
|
||||
ymin = min(ymin, cymin - cursor)
|
||||
ymax = max(ymax, cymax - cursor)
|
||||
else:
|
||||
xmin = min(xmin, cxmin + cursor)
|
||||
xmax = max(xmax, cxmax + cursor)
|
||||
ymin = min(ymin, cymin)
|
||||
ymax = max(ymax, cymax)
|
||||
cursor += advance
|
||||
|
||||
if not parts:
|
||||
result = ([], 0.0, 0.0)
|
||||
else:
|
||||
# Offsets placing the run's top-left at (0,0) under
|
||||
# translate(tx,ty) scale(1,-1).
|
||||
result = (parts, -xmin, ymax)
|
||||
|
||||
_SVG_SHAPE_CACHE[key] = result
|
||||
if len(_SVG_SHAPE_CACHE) > 20000:
|
||||
for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())):
|
||||
if i % 2 == 0:
|
||||
_SVG_SHAPE_CACHE.pop(k, None)
|
||||
return result
|
||||
|
||||
|
||||
def _build_shape_fonttools(word, size, font_path, orient):
|
||||
def _char_shape(ch, size, font_path, orient):
|
||||
"""Return (path_d, advance, xmin, ymin, xmax, ymax) for one character.
|
||||
|
||||
The outline is drawn at cursor 0 and scaled to `size`, so the same string is
|
||||
valid at every position the character appears in.
|
||||
"""
|
||||
key = (font_path, ch, int(size), bool(orient))
|
||||
cached = _FT_CHAR_PATH_CACHE.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
cached = _build_char_fonttools(ch, size, font_path, orient)
|
||||
except Exception:
|
||||
cached = _build_char_matplotlib(ch, size, font_path, orient)
|
||||
_FT_CHAR_PATH_CACHE[key] = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _build_char_fonttools(ch, size, font_path, orient):
|
||||
from fontTools.pens.svgPathPen import SVGPathPen
|
||||
from fontTools.pens.transformPen import TransformPen
|
||||
from fontTools.misc.transform import Transform
|
||||
|
||||
_tt, glyph_set, cmap, units = _load_ft_font(font_path)
|
||||
scale = float(size) / float(units)
|
||||
gname = cmap.get(ord(ch))
|
||||
if not gname or gname not in glyph_set:
|
||||
return None
|
||||
glyph = glyph_set[gname]
|
||||
pen = SVGPathPen(glyph_set)
|
||||
cursor = 0.0
|
||||
for ch in word:
|
||||
gname = cmap.get(ord(ch))
|
||||
if not gname or gname not in glyph_set:
|
||||
continue
|
||||
glyph = glyph_set[gname]
|
||||
if orient:
|
||||
# Horizontal layout then rotate -90° around origin:
|
||||
# point (px, py) in string space -> after scale: (s*px, s*py)
|
||||
# rotate -90: (s*py, -s*px). Compose with glyph origin at cursor:
|
||||
# glyph local (gx,gy) -> (scale*gx + cursor, scale*gy)
|
||||
# -> rotate -90: (scale*gy, -(scale*gx + cursor)) = (scale*gy, -scale*gx - cursor)
|
||||
# matrix: x' = 0*gx + scale*gy + 0; y' = -scale*gx + 0*gy - cursor
|
||||
# Transform(xx, xy, yx, yy, dx, dy): x' = xx*x + xy*y + dx; y' = yx*x + yy*y + dy
|
||||
# xx=0, xy=scale, yx=-scale, yy=0, dx=0, dy=-cursor
|
||||
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, -cursor))
|
||||
else:
|
||||
tp = TransformPen(pen, Transform(scale, 0, 0, scale, cursor, 0))
|
||||
glyph.draw(tp)
|
||||
cursor += float(glyph.width) * scale
|
||||
|
||||
if orient:
|
||||
# Horizontal layout then rotate -90° around the origin. The cursor term
|
||||
# that used to live in dy is applied by the caller as a translate.
|
||||
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, 0))
|
||||
else:
|
||||
tp = TransformPen(pen, Transform(scale, 0, 0, scale, 0, 0))
|
||||
glyph.draw(tp)
|
||||
path_d = pen.getCommands()
|
||||
advance = float(glyph.width) * scale
|
||||
if not path_d:
|
||||
return "", 0.0, 0.0
|
||||
|
||||
return "", advance, 0.0, 0.0, 0.0, 0.0
|
||||
xmin, ymin, xmax, ymax = _path_bbox(path_d)
|
||||
# Offsets placing glyph top-left at (0,0) under translate(tx,ty) scale(1,-1)
|
||||
tx0 = -xmin
|
||||
ty0 = ymax
|
||||
return path_d, tx0, ty0
|
||||
return path_d, advance, xmin, ymin, xmax, ymax
|
||||
|
||||
|
||||
def _build_char_matplotlib(ch, size, font_path, orient):
|
||||
from matplotlib.textpath import TextPath
|
||||
|
||||
path = TextPath((0, 0), ch, prop=get_font_properties(font_path, size), size=size)
|
||||
if orient:
|
||||
path = path.transformed(Affine2D().rotate_deg(-90))
|
||||
bbox = path.get_extents()
|
||||
path_d = mpl_path_to_svg_d(path)
|
||||
# matplotlib gives no advance width; the ink bbox is the best stand-in.
|
||||
advance = (bbox.ymax - bbox.ymin) if orient else (bbox.xmax - bbox.xmin)
|
||||
return path_d, advance, bbox.xmin, bbox.ymin, bbox.xmax, bbox.ymax
|
||||
|
||||
|
||||
def _translate_path_d(path_d, dx, dy):
|
||||
"""Shift every coordinate pair in an SVG path string by (dx, dy).
|
||||
|
||||
Only used by the single-path-per-word compatibility path; the fast export
|
||||
route carries dx/dy in the element transform instead.
|
||||
"""
|
||||
if not dx and not dy:
|
||||
return path_d
|
||||
import re
|
||||
|
||||
tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
t = tokens[i]
|
||||
if not t.isalpha():
|
||||
i += 1
|
||||
continue
|
||||
out.append(t)
|
||||
i += 1
|
||||
coords = []
|
||||
while i < len(tokens) and not tokens[i].isalpha():
|
||||
coords.append(float(tokens[i]))
|
||||
i += 1
|
||||
for j, v in enumerate(coords):
|
||||
out.append(f"{v + (dx if j % 2 == 0 else dy):g}")
|
||||
return " ".join(out)
|
||||
|
||||
|
||||
def _path_bbox(path_d):
|
||||
@@ -137,19 +241,6 @@ def _path_bbox(path_d):
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def _build_shape_matplotlib(word, size, font_path, orient):
|
||||
from matplotlib.textpath import TextPath
|
||||
|
||||
path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size)
|
||||
if orient:
|
||||
path = path.transformed(Affine2D().rotate_deg(-90))
|
||||
bbox = path.get_extents()
|
||||
tx0 = -bbox.xmin
|
||||
ty0 = bbox.ymax
|
||||
path_d = mpl_path_to_svg_d(path)
|
||||
return path_d, tx0, ty0
|
||||
|
||||
|
||||
def build_svg_text_path(word, size, x, y, font_path, orient):
|
||||
path_d, tx, ty, _ = build_svg_text_path_cached(word, size, x, y, font_path, orient)
|
||||
return path_d, tx, ty, None
|
||||
@@ -324,8 +415,44 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
f_size = min(max_font, max(min_font, int(round(raw_size))))
|
||||
target_font_sizes.append(f_size)
|
||||
|
||||
random_large_prefix = max(1, int(math.ceil(len(layout_sequence) * 0.08)))
|
||||
for idx, (word, _freq) in enumerate(layout_sequence):
|
||||
# "Large" words go down first by random probe (mode 2), then everything
|
||||
# else spirals out from the centre to fill around them (mode 1). Which
|
||||
# words count as large is decided by their actual font size, not by
|
||||
# their position in the shuffled sequence: the old rule took the first
|
||||
# 8% of the sequence, which under equal weights is an arbitrary set of
|
||||
# same-size words, so "large" placement was applied to words that were
|
||||
# not large at all. When every word is the same size (the equal-weight
|
||||
# case) there is no large tier and everything spirals, which is the
|
||||
# correct degenerate behaviour.
|
||||
large_font_cutoff = min_font + base_span * 0.80
|
||||
large_indices = [
|
||||
idx for idx, size in enumerate(target_font_sizes)
|
||||
if base_span > 0 and size >= large_font_cutoff
|
||||
]
|
||||
# Placing the large words before the small ones matters: they need whole
|
||||
# empty regions to land in, and once the spiral has packed the canvas
|
||||
# there are none left. Ordering is by index within each tier so a given
|
||||
# layout_seed still reproduces exactly.
|
||||
large_index_set = set(large_indices)
|
||||
placement_order = large_indices + [
|
||||
idx for idx in range(len(layout_sequence)) if idx not in large_index_set
|
||||
]
|
||||
|
||||
# A word is only reported unplaced after the spiral, the random probes
|
||||
# and a full exhaustive scan have all failed, so a single failure proves
|
||||
# no legal position exists for it at this size -- and since the pipeline
|
||||
# requires every word, the whole batch is already doomed. `max_failures`
|
||||
# lets the caller stop right there instead of finishing the batch, which
|
||||
# is what makes the scale search cheap: an unplaceable word costs about
|
||||
# ten times a placeable one (it pays the full search before giving up),
|
||||
# so a doomed batch run to completion is by far the most expensive thing
|
||||
# the pipeline can do. Left as None, the batch runs to the end and packs
|
||||
# in as many words as it can.
|
||||
max_failures = getattr(self, "max_failures", None)
|
||||
failures = 0
|
||||
|
||||
for idx in placement_order:
|
||||
word, _freq = layout_sequence[idx]
|
||||
font_size = target_font_sizes[idx]
|
||||
placed = False
|
||||
rotate = rotation_flags[idx]
|
||||
@@ -344,8 +471,7 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
pad,
|
||||
) = measured
|
||||
query_seed = int(rng.integers(0, 2**31))
|
||||
large_word = idx < random_large_prefix or score_by_index[idx] >= 0.80
|
||||
placement_mode = 2 if large_word else 1
|
||||
placement_mode = 2 if idx in large_index_set else 1
|
||||
pos = self.grid.place_glyph_exact(
|
||||
collision_arr,
|
||||
stamp_arr,
|
||||
@@ -371,6 +497,10 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
# Deliberately do not shrink an individual word. The pipeline
|
||||
# treats a short layout as a failed batch and retries every word
|
||||
# at one uniformly scaled size range.
|
||||
if not placed:
|
||||
failures += 1
|
||||
if max_failures is not None and failures >= max_failures:
|
||||
break
|
||||
|
||||
return self
|
||||
|
||||
@@ -385,28 +515,27 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
return img
|
||||
|
||||
def _iter_svg_paths(self):
|
||||
"""Build SVG path data once per layout entry, with glyph-shape cache."""
|
||||
# Cache by (word, size, orient): path geometry is identical; only translate differs.
|
||||
shape_cache = {}
|
||||
"""Yield (path_d, tx, ty, color), one entry per glyph.
|
||||
|
||||
A word contributes one entry per character. Each path string comes
|
||||
straight from the per-character cache and the character's position
|
||||
within the word rides along in tx/ty, so no path data is rebuilt or
|
||||
re-parsed per word.
|
||||
"""
|
||||
for word, size, (y, x), orient, color in self.layout_:
|
||||
key = (word, int(size), bool(orient))
|
||||
cached = shape_cache.get(key)
|
||||
if cached is None:
|
||||
try:
|
||||
path_d, origin_tx, origin_ty, bbox = build_svg_text_path_cached(
|
||||
word, size, 0, 0, self.font_path, orient
|
||||
)
|
||||
except Exception as exc:
|
||||
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
|
||||
continue
|
||||
# origin_tx/ty place the glyph so its top-left is at (0,0)
|
||||
shape_cache[key] = (path_d, origin_tx, origin_ty)
|
||||
cached = shape_cache[key]
|
||||
path_d, origin_tx, origin_ty = cached
|
||||
# Shift from (0,0) origin to actual layout position
|
||||
tx = origin_tx + x
|
||||
ty = origin_ty + y
|
||||
yield path_d, tx, ty, color
|
||||
try:
|
||||
parts, origin_tx, origin_ty = build_svg_word_parts(
|
||||
word, size, self.font_path, orient
|
||||
)
|
||||
except Exception as exc:
|
||||
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
|
||||
continue
|
||||
# origin_tx/ty place the run's top-left at (0,0); x/y move it to the
|
||||
# layout position; dx/dy offset the character within the run.
|
||||
base_tx = origin_tx + x
|
||||
base_ty = origin_ty + y
|
||||
for path_d, dx, dy in parts:
|
||||
yield path_d, base_tx + dx, base_ty + dy, color
|
||||
|
||||
def export_svgs(self, fill_filename, stroke_color="#000000", stroke_width=1.0):
|
||||
"""Write fill + stroke SVG in one pass (path geometry built once)."""
|
||||
@@ -500,13 +629,21 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
ring_radius=3, ring_width=1, ring_spacing=8):
|
||||
"""统一 SVG 导出:fill_mode=fill|dot|line|ring,可叠加描边。"""
|
||||
# 预先构建所有文字路径(fill / dot 模式共用)
|
||||
# One entry per glyph rather than per word: each character's outline is
|
||||
# taken straight from the shared cache, with its position in the run
|
||||
# carried in tx/ty. Consumers below only ever place these as separate
|
||||
# <path> elements, so splitting a word costs nothing.
|
||||
text_paths = []
|
||||
for word, size, (y, x), orient, _color in self.layout_:
|
||||
try:
|
||||
path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient)
|
||||
text_paths.append((path, tx, ty))
|
||||
parts, origin_tx, origin_ty = build_svg_word_parts(
|
||||
word, size, self.font_path, orient
|
||||
)
|
||||
except Exception as exc:
|
||||
config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}")
|
||||
continue
|
||||
for path, dx, dy in parts:
|
||||
text_paths.append((path, origin_tx + x + dx, origin_ty + y + dy))
|
||||
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
|
||||
+314
-85
@@ -15,10 +15,13 @@ from .fonts import get_cached_font
|
||||
from .layout import OptimizedEfficientWordCloud
|
||||
from .mask import analyze_mask, apply_safe_padding, calculate_dynamic_dimensions, prepare_mask
|
||||
from .render import (
|
||||
compute_coverage_score,
|
||||
compute_fill_ratio_fast,
|
||||
count_layout_overlap_pixels,
|
||||
largest_empty_square_size,
|
||||
append_layout_with_hd_clearance,
|
||||
refine_layout_with_hd_clearance,
|
||||
render_layout_occupancy,
|
||||
scale_layout_for_hd,
|
||||
)
|
||||
from .weights import (
|
||||
@@ -127,7 +130,7 @@ def run_generation_pass(
|
||||
max_font = min(max_font, hard_max_font)
|
||||
return min_font, max(min_font, max_font)
|
||||
|
||||
def try_place(scale, layout_seed=base_layout_seed):
|
||||
def try_place(scale, layout_seed=base_layout_seed, probe=False):
|
||||
min_font, max_font = scaled_bounds(scale)
|
||||
wc = OptimizedEfficientWordCloud(
|
||||
width=w_small,
|
||||
@@ -138,12 +141,23 @@ def run_generation_pass(
|
||||
min_font_size=min_font,
|
||||
max_font_size=max_font,
|
||||
background_color=config.get_output_background(),
|
||||
prefer_horizontal=0.82,
|
||||
# Each word independently draws horizontal vs vertical, so the mix
|
||||
# is scattered rather than banded. VERTICAL_RATIO is the chance of
|
||||
# a vertical word; mixing orientations is one of the cheapest ways
|
||||
# to break up an over-regular grid-like look.
|
||||
prefer_horizontal=1.0 - float(config.VERTICAL_RATIO),
|
||||
# HD clearance is applied after scaling. Keeping the coarse-grid
|
||||
# margin at zero avoids turning 1 HD pixel into 5-6 output pixels.
|
||||
margin=_collision_margin,
|
||||
)
|
||||
wc.layout_seed = layout_seed
|
||||
# A probe only needs to answer "does every word fit at this scale?", so
|
||||
# it stops at the first word that cannot be placed. The answer is exact
|
||||
# -- a word is only reported unplaced once an exhaustive scan has ruled
|
||||
# out every position -- and it avoids paying for a doomed batch's
|
||||
# remaining failures, each of which is far more expensive than a
|
||||
# successful placement.
|
||||
wc.max_failures = 1 if probe else None
|
||||
wc.generate_from_frequencies(frequencies_data)
|
||||
return wc, len(wc.layout_), min_font, max_font
|
||||
|
||||
@@ -165,55 +179,99 @@ def run_generation_pass(
|
||||
best_wc = None
|
||||
best_count = 0
|
||||
best_scale = 1.0
|
||||
scale = 1.0
|
||||
best_coverage = -1.0 # shape-aware coverage of the current best candidate
|
||||
best_occ = None # occupancy raster of the current best candidate
|
||||
tried_layouts = set()
|
||||
failed_scales = []
|
||||
for attempt in range(1, 4):
|
||||
attempt = 0
|
||||
|
||||
def probe_scale(scale):
|
||||
"""Lay out every word at `scale`; return (wc, placed_count, complete)."""
|
||||
nonlocal attempt, best_wc, best_count, best_scale
|
||||
nonlocal best_coverage, best_occ
|
||||
attempt += 1
|
||||
bounds = scaled_bounds(scale)
|
||||
layout_key = (bounds, base_layout_seed)
|
||||
if layout_key in tried_layouts:
|
||||
break
|
||||
tried_layouts.add(layout_key)
|
||||
wc, placed_count, min_font, max_font = try_place(scale)
|
||||
tried_layouts.add((bounds, base_layout_seed))
|
||||
wc, placed_count, min_font, max_font = try_place(scale, probe=True)
|
||||
complete = placed_count >= total_target
|
||||
print(
|
||||
f" 整批布局 #{attempt}: scale={scale:.3f}, "
|
||||
f"字号=[{min_font}, {max_font}] -> {placed_count}/{total_target}"
|
||||
f"字号=[{min_font}, {max_font}] -> "
|
||||
f"{'完整' if complete else '不足'} ({placed_count}/{total_target})"
|
||||
)
|
||||
log.info(
|
||||
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d",
|
||||
attempt,
|
||||
scale,
|
||||
min_font,
|
||||
max_font,
|
||||
placed_count,
|
||||
total_target,
|
||||
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d complete=%s",
|
||||
attempt, scale, min_font, max_font, placed_count, total_target, complete,
|
||||
)
|
||||
if placed_count > best_count:
|
||||
best_wc = wc
|
||||
best_count = placed_count
|
||||
best_scale = scale
|
||||
if placed_count >= total_target:
|
||||
final_wc = wc
|
||||
final_scale = scale
|
||||
final_layout_seed = base_layout_seed
|
||||
if complete:
|
||||
# Among complete layouts prefer the one whose ink reaches furthest
|
||||
# into the mask shape, not merely the largest font scale. A Fermat
|
||||
# spiral packs words into a disc around the fillable centroid; with
|
||||
# few words or large fonts that disc stays small and never reaches
|
||||
# the mask's protrusions, so the cloud reads as a circle instead of
|
||||
# the intended silhouette. Coverage rewards layouts that spread into
|
||||
# those deep regions, letting a slightly smaller scale win when it
|
||||
# trades font size for a recognisable outline. Scale is the tie-
|
||||
# breaker so an equal-coverage denser picture is still preferred.
|
||||
occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH)
|
||||
coverage = compute_coverage_score(occ, mask_small)
|
||||
log.info(
|
||||
" 整批布局 #%d coverage=%.4f (best=%.4f)",
|
||||
attempt, coverage, best_coverage,
|
||||
)
|
||||
if coverage > best_coverage or (
|
||||
coverage == best_coverage and scale > best_scale
|
||||
):
|
||||
best_wc, best_count, best_scale = wc, placed_count, scale
|
||||
best_coverage, best_occ = coverage, occ
|
||||
if not complete:
|
||||
failed_scales.append(scale)
|
||||
return wc, placed_count, complete
|
||||
|
||||
# Find the largest scale at which every word still fits. Bigger is strictly
|
||||
# better here: the same names drawn larger leave less blank space. A probe
|
||||
# answers feasibility exactly and stops at the first unplaceable word, so
|
||||
# searching for the best scale costs little more than accepting the first
|
||||
# one that happens to work.
|
||||
lo = None # largest scale known to fit everything
|
||||
hi = None # smallest scale known to be too big
|
||||
scale = 1.0
|
||||
for _ in range(2 if config.FAST_MODE else 4):
|
||||
wc, placed_count, complete = probe_scale(scale)
|
||||
if complete:
|
||||
lo = scale
|
||||
break
|
||||
|
||||
failed_scales.append(scale)
|
||||
|
||||
hi = scale
|
||||
placed_ratio = placed_count / max(1, total_target)
|
||||
# Required box area is roughly proportional to size². The extra
|
||||
# safety margin absorbs fragmentation without wasting a binary search.
|
||||
shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.58, math.sqrt(placed_ratio) * 0.92))
|
||||
# Area scales with size², so linear size scales with sqrt(ratio). The
|
||||
# probe stops early, which understates how many words would have fit,
|
||||
# so this deliberately undershoots and the bisection below climbs back.
|
||||
shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.55, math.sqrt(placed_ratio) * 0.92))
|
||||
scale *= shrink
|
||||
|
||||
if final_wc is None:
|
||||
# Close the gap between the largest failing scale and the smallest passing
|
||||
# one. Each step recovers font size that the shrink above gave away.
|
||||
if lo is not None and hi is not None:
|
||||
for _ in range(1 if config.FAST_MODE else 3):
|
||||
mid = (lo + hi) / 2.0
|
||||
if hi - lo < 0.02 or scaled_bounds(mid) == scaled_bounds(lo):
|
||||
break
|
||||
_wc, _placed, complete = probe_scale(mid)
|
||||
if complete:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
|
||||
if best_wc is not None:
|
||||
final_wc = best_wc
|
||||
final_scale = best_scale
|
||||
final_layout_seed = base_layout_seed
|
||||
|
||||
if final_wc is None:
|
||||
return {
|
||||
"wc": None,
|
||||
"fill_ratio": 0.0,
|
||||
"coverage": 0.0,
|
||||
"occ_fast": None,
|
||||
"w_small": w_small,
|
||||
"h_small": h_small,
|
||||
@@ -250,10 +308,16 @@ def run_generation_pass(
|
||||
debug_dir.mkdir(parents=True, exist_ok=True)
|
||||
Image.fromarray((occ_fast * 255).astype(np.uint8)).save(str(debug_dir / "occ_fast.png"))
|
||||
|
||||
# Probe larger whole-cloud layouts and keep the largest complete one. If
|
||||
# an earlier batch was too large, search the discrete interval between the
|
||||
# complete and failed scales instead of accepting an over-aggressive
|
||||
# shrink. Every probe rebuilds the entire cloud with one shared scale.
|
||||
# Probe whole-cloud layouts in BOTH font-size directions and keep the one
|
||||
# whose ink reaches furthest into the mask shape. The original search only
|
||||
# grew the font (chasing a higher pixel fill ratio), but a Fermat spiral
|
||||
# packs words into a disc around the centroid: growing the font shrinks that
|
||||
# disc, so an under-filled silhouette gets *more* circular, not less.
|
||||
# Shrinking the font lets the spiral walk further out and reach the mask's
|
||||
# protrusions, which raises shape coverage even when the raw fill ratio
|
||||
# drops a little. Both directions are probed each round and the higher-
|
||||
# coverage candidate wins; the loop stops when neither improves coverage.
|
||||
# Every probe rebuilds the entire cloud with one shared scale.
|
||||
if (
|
||||
len(final_wc.layout_) >= total_target
|
||||
and fill_ratio > 0
|
||||
@@ -262,11 +326,22 @@ def run_generation_pass(
|
||||
or has_character_sized_hole(final_wc, largest_empty_square)
|
||||
)
|
||||
):
|
||||
current_coverage = compute_coverage_score(occ_fast, mask_small)
|
||||
free_px = max(1, int(np.sum(mask_small == 0)))
|
||||
upper_scale = min(
|
||||
(failed for failed in failed_scales if failed > final_scale),
|
||||
default=None,
|
||||
)
|
||||
for density_attempt in range(1, 5):
|
||||
|
||||
def _fill_and_coverage(wc):
|
||||
occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH)
|
||||
new_fill = float(np.sum((mask_small == 0) & (occ == 1))) / free_px
|
||||
cov = compute_coverage_score(occ, mask_small)
|
||||
return new_fill, cov, occ
|
||||
|
||||
for density_attempt in range(1, 2 if config.FAST_MODE else 5):
|
||||
# Grow direction (larger font): bisect toward a known-too-big scale,
|
||||
# or nudge up by the fill-ratio deficit, exactly as before.
|
||||
if upper_scale is not None:
|
||||
grow_scale = (final_scale + upper_scale) / 2.0
|
||||
elif equal_size_mode:
|
||||
@@ -277,61 +352,79 @@ def run_generation_pass(
|
||||
1.12,
|
||||
math.sqrt(config.TARGET_FILL_RATIO / fill_ratio) * 0.98,
|
||||
)
|
||||
if desired_growth <= 1.005:
|
||||
break
|
||||
grow_scale = final_scale * desired_growth
|
||||
grow_scale = final_scale * desired_growth if desired_growth > 1.005 else None
|
||||
|
||||
grow_bounds = scaled_bounds(grow_scale)
|
||||
if grow_bounds == scaled_bounds(final_scale):
|
||||
break
|
||||
grow_min, grow_max = grow_bounds
|
||||
layout_key = (grow_bounds, base_layout_seed)
|
||||
attempted_layout = False
|
||||
if layout_key not in tried_layouts:
|
||||
tried_layouts.add(layout_key)
|
||||
wc, placed_count, _, _ = try_place(grow_scale)
|
||||
attempted_layout = True
|
||||
print(
|
||||
f" 密度优化 #{density_attempt}: scale={grow_scale:.3f}, "
|
||||
f"字号=[{grow_min}, {grow_max}] -> {placed_count}/{total_target}"
|
||||
)
|
||||
else:
|
||||
wc, placed_count = None, -1
|
||||
# Shrink direction (smaller font): the inverse nudge. Letting the
|
||||
# spiral walk further out costs font size but can reach protrusions
|
||||
# the grow direction abandons. Cap the shrink so one round cannot
|
||||
# collapse the font to the floor.
|
||||
shrink_scale = None
|
||||
if not equal_size_mode and fill_ratio > 0:
|
||||
shrink_factor = 1.0 / max(1.02, min(1.20, math.sqrt(fill_ratio / max(0.05, config.TARGET_FILL_RATIO)) * 1.02))
|
||||
cand = final_scale * shrink_factor
|
||||
if scaled_bounds(cand) != scaled_bounds(final_scale):
|
||||
shrink_scale = cand
|
||||
elif equal_size_mode:
|
||||
current_size, _ = scaled_bounds(final_scale)
|
||||
if current_size > hard_min_font:
|
||||
shrink_scale = (current_size - 1) / max(1, base_min_font)
|
||||
|
||||
selected_seed = base_layout_seed
|
||||
if placed_count < total_target and base_layout_seed is not None:
|
||||
# The reference library samples a fresh legal position order.
|
||||
# One bounded whole-cloud re-layout recovers dense solutions
|
||||
# without per-word shrinking or an unbounded random search.
|
||||
candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1)
|
||||
retry_key = (grow_bounds, candidate_seed)
|
||||
if retry_key not in tried_layouts:
|
||||
tried_layouts.add(retry_key)
|
||||
retry_wc, retry_count, _, _ = try_place(grow_scale, candidate_seed)
|
||||
attempted_layout = True
|
||||
candidates = []
|
||||
for direction, scale in (("grow", grow_scale), ("shrink", shrink_scale)):
|
||||
if scale is None or scaled_bounds(scale) == scaled_bounds(final_scale):
|
||||
continue
|
||||
bounds = scaled_bounds(scale)
|
||||
d_min, d_max = bounds
|
||||
key = (bounds, base_layout_seed)
|
||||
wc, placed_count, selected_seed = None, -1, base_layout_seed
|
||||
if key not in tried_layouts:
|
||||
tried_layouts.add(key)
|
||||
wc, placed_count, _, _ = try_place(scale)
|
||||
# Bounded seed retry to recover a complete layout, as before.
|
||||
if placed_count < total_target and base_layout_seed is not None:
|
||||
candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1)
|
||||
retry_key = (bounds, candidate_seed)
|
||||
if retry_key not in tried_layouts:
|
||||
tried_layouts.add(retry_key)
|
||||
retry_wc, retry_count, _, _ = try_place(scale, candidate_seed)
|
||||
print(
|
||||
f" 密度优化 #{density_attempt} {direction} 整批重排: "
|
||||
f"seed={candidate_seed}, 字号=[{d_min}, {d_max}] -> "
|
||||
f"{retry_count}/{total_target}"
|
||||
)
|
||||
if retry_count > placed_count:
|
||||
wc, placed_count = retry_wc, retry_count
|
||||
selected_seed = candidate_seed
|
||||
if placed_count < total_target:
|
||||
if direction == "grow":
|
||||
upper_scale = scale
|
||||
print(
|
||||
f" 密度优化 #{density_attempt} 整批重排: "
|
||||
f"seed={candidate_seed}, 字号=[{grow_min}, {grow_max}] -> "
|
||||
f"{retry_count}/{total_target}"
|
||||
f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, "
|
||||
f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} (不完整)"
|
||||
)
|
||||
if retry_count > placed_count:
|
||||
wc = retry_wc
|
||||
placed_count = retry_count
|
||||
selected_seed = candidate_seed
|
||||
if not attempted_layout:
|
||||
break
|
||||
if placed_count < total_target:
|
||||
upper_scale = grow_scale
|
||||
continue
|
||||
continue
|
||||
new_fill, cov, occ = _fill_and_coverage(wc)
|
||||
print(
|
||||
f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, "
|
||||
f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} "
|
||||
f"fill={new_fill:.3f} coverage={cov:.4f}"
|
||||
)
|
||||
candidates.append((direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ))
|
||||
|
||||
new_fill, new_occ = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH)
|
||||
if new_fill <= fill_ratio:
|
||||
if not candidates:
|
||||
break
|
||||
# Pick the higher-coverage candidate; tie-break on fill ratio so a
|
||||
# genuinely denser picture still wins when coverage is equal.
|
||||
candidates.sort(key=lambda c: (c[6], c[5]))
|
||||
direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ = candidates[-1]
|
||||
if cov <= current_coverage and new_fill <= fill_ratio:
|
||||
break
|
||||
final_wc = wc
|
||||
final_scale = grow_scale
|
||||
final_scale = scale
|
||||
final_layout_seed = selected_seed
|
||||
fill_ratio = new_fill
|
||||
occ_fast = new_occ
|
||||
occ_fast = occ
|
||||
current_coverage = cov
|
||||
largest_empty_square = largest_empty_square_size(occ_fast, mask_small)
|
||||
complete_candidates.append(
|
||||
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
|
||||
@@ -339,6 +432,7 @@ def run_generation_pass(
|
||||
if (
|
||||
fill_ratio >= config.TARGET_FILL_RATIO * 0.98
|
||||
and not has_character_sized_hole(final_wc, largest_empty_square)
|
||||
and current_coverage >= 0.98
|
||||
):
|
||||
break
|
||||
|
||||
@@ -350,6 +444,7 @@ def run_generation_pass(
|
||||
and len(final_wc.layout_) >= total_target
|
||||
and has_character_sized_hole(final_wc, largest_empty_square)
|
||||
and base_layout_seed is not None
|
||||
and not config.FAST_MODE
|
||||
):
|
||||
if total_target < 100:
|
||||
hole_attempt_budget = 3
|
||||
@@ -389,6 +484,77 @@ def run_generation_pass(
|
||||
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
|
||||
)
|
||||
|
||||
# ── 工作网格增量填充:原名字号不变,用更小字号追加副本填轮廓 ──
|
||||
# 已填区域标记为阻挡,新词只能进空白间隙。每一轮只生成一份名单,
|
||||
# 并把新占用合并回工作网格;因此自动填充没有固定的重复数量,
|
||||
# 只在轮廓仍未覆盖且还有合法位置时继续追加。
|
||||
fill_work_layout = []
|
||||
if (
|
||||
config.AUTO_REPEAT_TO_FILL
|
||||
and final_wc is not None
|
||||
and len(final_wc.layout_) >= total_target
|
||||
and occ_fast is not None
|
||||
):
|
||||
current_cov = compute_coverage_score(occ_fast, mask_small)
|
||||
if current_cov < 0.95 and names:
|
||||
# 原名字号范围
|
||||
original_sizes = [size for _, size, *_ in final_wc.layout_]
|
||||
orig_min_font = int(min(original_sizes))
|
||||
orig_max_font = int(max(original_sizes))
|
||||
span = orig_max_font - orig_min_font
|
||||
|
||||
# 追加用缩小字号:原最大字号的 50~60%
|
||||
fill_min_font = max(config.MIN_FONT_FLOOR, int(round(orig_min_font * 0.50)))
|
||||
fill_max_font = max(fill_min_font, int(round(orig_min_font + span * 0.60)))
|
||||
fill_seed = (final_layout_seed ^ 0x9E3779B9) & 0x7FFFFFFF
|
||||
if fill_seed == 0:
|
||||
fill_seed = 1
|
||||
|
||||
# AUTO_REPEAT_MAX 只是防止异常掩膜导致无限循环,不是目标重复次数。
|
||||
max_fill_rounds = max(1, int(config.AUTO_REPEAT_MAX))
|
||||
for fill_round in range(max_fill_rounds):
|
||||
if current_cov >= 0.95:
|
||||
break
|
||||
|
||||
# 融合 mask:原阻挡 + 已填区域都标为 255
|
||||
fill_mask = mask_small.copy()
|
||||
fill_mask[(occ_fast == 1)] = 255
|
||||
fill_wc = OptimizedEfficientWordCloud(
|
||||
width=w_small, height=h_small,
|
||||
mask=fill_mask,
|
||||
font_path=config.WC_FONT_PATH,
|
||||
# 一轮只追加一份名单;需要更多时由下一轮按需追加。
|
||||
max_words=len(names),
|
||||
min_font_size=fill_min_font,
|
||||
max_font_size=fill_max_font,
|
||||
background_color=config.get_output_background(),
|
||||
prefer_horizontal=1.0 - float(config.VERTICAL_RATIO),
|
||||
margin=_collision_margin,
|
||||
)
|
||||
fill_wc.layout_seed = (fill_seed + fill_round) & 0x7FFFFFFF or 1
|
||||
fill_freq = {name: 0.3 for name in names}
|
||||
fill_wc.generate_from_frequencies(fill_freq)
|
||||
|
||||
fill_placed = len(fill_wc.layout_)
|
||||
print(
|
||||
f"[增量填充#{fill_round + 1}] 工作网格追加放置 "
|
||||
f"{fill_placed}/{len(names)} 词,字号=[{fill_min_font}, {fill_max_font}]"
|
||||
)
|
||||
log.info(
|
||||
"[增量填充#%d] 工作网格追加放置 %d/%d 词, 字号=[%d,%d]",
|
||||
fill_round + 1, fill_placed, len(names), fill_min_font, fill_max_font,
|
||||
)
|
||||
if fill_placed <= 0:
|
||||
break
|
||||
|
||||
fill_work_layout.extend(fill_wc.layout_)
|
||||
fill_occ = render_layout_occupancy(
|
||||
fill_wc.layout_, mask_small.shape, config.WC_FONT_PATH
|
||||
)
|
||||
occ_fast = np.maximum(occ_fast, fill_occ)
|
||||
current_cov = compute_coverage_score(occ_fast, mask_small)
|
||||
print(f"[增量填充#{fill_round + 1}] 工作网格覆盖度={current_cov:.4f}")
|
||||
|
||||
hd_layout = None
|
||||
hd_clearance = None
|
||||
raw_hd_layout = []
|
||||
@@ -473,14 +639,73 @@ def run_generation_pass(
|
||||
config.WC_FONT_PATH,
|
||||
)
|
||||
|
||||
# ── 增量填充(工作网格 → HD) ──
|
||||
# 工作网格上的合法位置经过放大后可能因取整发生碰撞,因此填充词必须
|
||||
# 与基础布局一起再次做高清精修。若整批填充无法通过,则二分保留最多
|
||||
# 的追加词;绝不能让自动填充破坏原本已经成功的基础布局。
|
||||
if (
|
||||
fill_work_layout
|
||||
and hd_layout is not None
|
||||
):
|
||||
old_count = len(hd_layout)
|
||||
fill_hd = scale_layout_for_hd(fill_work_layout, config.WORK_SCALE)
|
||||
base_hd_layout = list(hd_layout)
|
||||
|
||||
accepted_additions, clearance_stats = append_layout_with_hd_clearance(
|
||||
base_hd_layout,
|
||||
fill_hd,
|
||||
mask_hd,
|
||||
config.WC_FONT_PATH,
|
||||
clearance=0,
|
||||
allow_global_search=False,
|
||||
)
|
||||
accepted_count = len(accepted_additions)
|
||||
accepted_clearance = 0
|
||||
hd_layout = base_hd_layout + accepted_additions
|
||||
hd_overlap_pixels = count_layout_overlap_pixels(
|
||||
hd_layout, (real_hd_h, real_hd_w), config.WC_FONT_PATH
|
||||
)
|
||||
new_fill, new_occ = compute_fill_ratio_fast(
|
||||
hd_layout, mask_hd, config.WC_FONT_PATH
|
||||
)
|
||||
new_cov = compute_coverage_score(new_occ, mask_hd) if new_occ is not None else 0.0
|
||||
clearance_stats = dict(clearance_stats or {})
|
||||
clearance_stats["clearance_px"] = accepted_clearance
|
||||
hd_clearance = clearance_stats
|
||||
print(
|
||||
f"[增量填充] 高清验收: {old_count}+{accepted_count}="
|
||||
f"{len(hd_layout)} 词, fill={new_fill:.3f}, "
|
||||
f"coverage={new_cov:.4f}, overlap={hd_overlap_pixels}"
|
||||
)
|
||||
print(
|
||||
f"[增量填充] 工作网格候选 {len(fill_work_layout)} 词,"
|
||||
f"高清接受 {len(hd_layout) - old_count} 词"
|
||||
)
|
||||
log.info(
|
||||
"[增量填充] HD 验收: base=%d candidate=%d accepted=%d fill=%.4f coverage=%.4f overlap=%d",
|
||||
old_count, len(fill_hd), len(hd_layout) - old_count, new_fill, new_cov, hd_overlap_pixels,
|
||||
)
|
||||
fill_ratio = new_fill
|
||||
occ_fast = new_occ
|
||||
largest_empty_square = largest_empty_square_size(occ_fast, mask_hd)
|
||||
|
||||
print(
|
||||
f"最终填充率: {fill_ratio:.3f} | 高清重叠像素: {hd_overlap_pixels} | "
|
||||
f"精修位移: {hd_clearance['shifted_words']} 词, 最大 {hd_clearance['max_shift']}px | "
|
||||
f"隔离带: {hd_clearance['clearance_px']}px"
|
||||
)
|
||||
# occ_fast 可能是工作网格或 HD 网格形状(增量填充后),按形状匹配计算覆盖度
|
||||
if occ_fast is not None and occ_fast.shape == mask_small.shape:
|
||||
coverage = compute_coverage_score(occ_fast, mask_small)
|
||||
elif occ_fast is not None and occ_fast.shape == mask_hd.shape:
|
||||
coverage = compute_coverage_score(occ_fast, mask_hd)
|
||||
else:
|
||||
coverage = 0.0
|
||||
print(f"轮廓覆盖度: {coverage:.4f}")
|
||||
return {
|
||||
"wc": final_wc,
|
||||
"fill_ratio": fill_ratio,
|
||||
"coverage": coverage,
|
||||
"occ_fast": occ_fast,
|
||||
"w_small": w_small,
|
||||
"h_small": h_small,
|
||||
@@ -614,6 +839,9 @@ def main():
|
||||
)
|
||||
frequencies_data = name_weights_map if config.REMOVE_DUPLICATES else [(name, name_weights_map.get(name, 10)) for name in names]
|
||||
|
||||
canvas_retry_round = 0
|
||||
generation_result = None
|
||||
t_gen = time.time()
|
||||
canvas_retry_round = 0
|
||||
generation_result = None
|
||||
t_gen = time.time()
|
||||
@@ -649,8 +877,9 @@ def main():
|
||||
canvas_retry_round,
|
||||
)
|
||||
sys.exit(1)
|
||||
log.info(" 生成完成 placed=%d/%d fill=%.4f retry=%d",
|
||||
placed, target, generation_result["fill_ratio"], canvas_retry_round)
|
||||
log.info(" 生成完成 placed=%d/%d fill=%.4f coverage=%.4f retry=%d",
|
||||
placed, target, generation_result["fill_ratio"],
|
||||
generation_result.get("coverage", 0.0), canvas_retry_round)
|
||||
break
|
||||
|
||||
canvas_retry_round += 1
|
||||
|
||||
+285
-22
@@ -3,6 +3,37 @@ from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
from .fonts import get_cached_font
|
||||
|
||||
# (font_path, word, size, orient) -> (ink[h,w] uint8, bbox_left, bbox_top).
|
||||
# The HD passes -- clearance refinement and the independent overlap audit --
|
||||
# rasterize the same words at the same sizes, and rasterizing is the single
|
||||
# most expensive thing either of them does, so they share one cache.
|
||||
_HD_INK_CACHE = {}
|
||||
|
||||
|
||||
def _word_ink(word, size, orient, font_path):
|
||||
"""Return (ink array, bbox_left, bbox_top) for a word, or None if empty."""
|
||||
key = (font_path, word, int(size), orient)
|
||||
cached = _HD_INK_CACHE.get(key)
|
||||
if cached is not None or key in _HD_INK_CACHE:
|
||||
return cached
|
||||
font = get_cached_font(font_path, size)
|
||||
if orient:
|
||||
font = ImageFont.TransposedFont(font, orientation=orient)
|
||||
bbox = ImageDraw.Draw(Image.new("L", (1, 1))).textbbox((0, 0), word, font=font)
|
||||
glyph = font.getmask(word, mode="L")
|
||||
glyph_width, glyph_height = glyph.size
|
||||
if glyph_width <= 0 or glyph_height <= 0:
|
||||
result = None
|
||||
else:
|
||||
ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width)
|
||||
result = (ink, int(bbox[0]), int(bbox[1]))
|
||||
_HD_INK_CACHE[key] = result
|
||||
if len(_HD_INK_CACHE) > 20000:
|
||||
for i, k in enumerate(list(_HD_INK_CACHE.keys())):
|
||||
if i % 2 == 0:
|
||||
_HD_INK_CACHE.pop(k, None)
|
||||
return result
|
||||
|
||||
|
||||
def scale_layout_for_hd(layout, work_scale):
|
||||
if work_scale <= 0:
|
||||
@@ -24,21 +55,17 @@ def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0
|
||||
height, width = mask_shape
|
||||
occupied = np.zeros((height, width), dtype=bool)
|
||||
overlaps = np.zeros((height, width), dtype=bool)
|
||||
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
|
||||
|
||||
for word, size, (y, x), orient, _color in layout:
|
||||
font = get_cached_font(font_path, size)
|
||||
if orient:
|
||||
font = ImageFont.TransposedFont(font, orientation=orient)
|
||||
bbox = measure.textbbox((0, 0), word, font=font)
|
||||
glyph = font.getmask(word, mode="L")
|
||||
glyph_width, glyph_height = glyph.size
|
||||
if glyph_width <= 0 or glyph_height <= 0:
|
||||
measured = _word_ink(word, size, orient, font_path)
|
||||
if measured is None:
|
||||
continue
|
||||
ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) > alpha_threshold
|
||||
ink_arr, bbox_left, bbox_top = measured
|
||||
glyph_height, glyph_width = ink_arr.shape
|
||||
ink = ink_arr > alpha_threshold
|
||||
|
||||
ink_x = int(x) + int(bbox[0])
|
||||
ink_y = int(y) + int(bbox[1])
|
||||
ink_x = int(x) + bbox_left
|
||||
ink_y = int(y) + bbox_top
|
||||
x0 = max(0, ink_x)
|
||||
y0 = max(0, ink_y)
|
||||
x1 = min(width, ink_x + glyph_width)
|
||||
@@ -54,6 +81,56 @@ def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0
|
||||
return int(overlaps.sum())
|
||||
|
||||
|
||||
def _find_free_placement(blocked, occupied, collision, stamp, base_y, base_x):
|
||||
"""Find any canvas position where `collision` hits nothing already taken.
|
||||
|
||||
Returns the (dy, dx) offset from (base_y, base_x), or None. Candidates are
|
||||
ranked by distance from the original spot so a relocated word stays as close
|
||||
to its intended position as possible.
|
||||
|
||||
A position whose whole footprint is empty is guaranteed to fit, so the
|
||||
search first looks for those using an integral image, which rejects the vast
|
||||
majority of positions with two additions instead of a per-pixel test.
|
||||
"""
|
||||
height, width = blocked.shape
|
||||
gh, gw = collision.shape
|
||||
if height - gh < 0 or width - gw < 0:
|
||||
return None
|
||||
|
||||
# Search expanding windows around the intended spot instead of the whole
|
||||
# canvas: a relocated word almost always finds room nearby, and the integral
|
||||
# image costs time proportional to the area examined. The last radius covers
|
||||
# the full canvas, so nothing is missed if the neighbourhood really is full.
|
||||
for radius in (256, 1024, max(height, width)):
|
||||
y_lo = max(0, base_y - radius)
|
||||
x_lo = max(0, base_x - radius)
|
||||
y_hi = min(height, base_y + radius + gh)
|
||||
x_hi = min(width, base_x + radius + gw)
|
||||
if y_hi - y_lo < gh or x_hi - x_lo < gw:
|
||||
continue
|
||||
|
||||
taken = blocked[y_lo:y_hi, x_lo:x_hi] | occupied[y_lo:y_hi, x_lo:x_hi]
|
||||
integral = np.pad(taken.astype(np.int32), ((1, 0), (1, 0))).cumsum(0).cumsum(1)
|
||||
# Footprint sum for every candidate top-left corner in the window. A
|
||||
# position whose whole footprint is empty is guaranteed to fit, so no
|
||||
# per-pixel mask test is needed.
|
||||
counts = (
|
||||
integral[gh:, gw:]
|
||||
- integral[:-gh, gw:]
|
||||
- integral[gh:, :-gw]
|
||||
+ integral[:-gh, :-gw]
|
||||
)
|
||||
ys, xs = np.nonzero(counts == 0)
|
||||
if ys.size == 0:
|
||||
continue
|
||||
dy = ys.astype(np.int64) + y_lo - base_y
|
||||
dx = xs.astype(np.int64) + x_lo - base_x
|
||||
best = int(np.argmin(dy * dy + dx * dx))
|
||||
return int(dy[best]), int(dx[best])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def refine_layout_with_hd_clearance(
|
||||
layout,
|
||||
mask,
|
||||
@@ -65,7 +142,6 @@ def refine_layout_with_hd_clearance(
|
||||
height, width = mask.shape
|
||||
blocked = np.asarray(mask) != 0
|
||||
occupied = np.zeros((height, width), dtype=bool)
|
||||
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
|
||||
refined = []
|
||||
shifted_words = 0
|
||||
max_applied_shift = 0
|
||||
@@ -82,17 +158,13 @@ def refine_layout_with_hd_clearance(
|
||||
offset_rings.append(ring)
|
||||
|
||||
for word, size, (draw_y, draw_x), orient, color in layout:
|
||||
font = get_cached_font(font_path, size)
|
||||
if orient:
|
||||
font = ImageFont.TransposedFont(font, orientation=orient)
|
||||
bbox = measure.textbbox((0, 0), word, font=font)
|
||||
glyph = font.getmask(word, mode="L")
|
||||
glyph_width, glyph_height = glyph.size
|
||||
if glyph_width <= 0 or glyph_height <= 0:
|
||||
measured = _word_ink(word, size, orient, font_path)
|
||||
if measured is None:
|
||||
refined.append((word, size, (draw_y, draw_x), orient, color))
|
||||
continue
|
||||
|
||||
glyph_ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width)
|
||||
glyph_ink, bbox_left, bbox_top = measured
|
||||
glyph_height, glyph_width = glyph_ink.shape
|
||||
pad = max(0, int(clearance))
|
||||
padded = np.zeros((glyph_height + 2 * pad, glyph_width + 2 * pad), dtype=np.uint8)
|
||||
padded[pad:pad + glyph_height, pad:pad + glyph_width] = glyph_ink
|
||||
@@ -105,8 +177,8 @@ def refine_layout_with_hd_clearance(
|
||||
collision = padded > 0
|
||||
stamp = padded > 0
|
||||
|
||||
base_y = int(draw_y) + int(bbox[1]) - pad
|
||||
base_x = int(draw_x) + int(bbox[0]) - pad
|
||||
base_y = int(draw_y) + bbox_top - pad
|
||||
base_x = int(draw_x) + bbox_left - pad
|
||||
|
||||
def fits(y0, x0):
|
||||
y1 = y0 + collision.shape[0]
|
||||
@@ -129,6 +201,16 @@ def refine_layout_with_hd_clearance(
|
||||
if placed_offset is not None:
|
||||
break
|
||||
|
||||
if placed_offset is None:
|
||||
# Nothing within max_shift. Rather than fail the batch -- which
|
||||
# makes the caller rebuild the entire cloud on a larger canvas, by
|
||||
# far the most expensive thing that can happen -- look for any free
|
||||
# spot on the whole canvas. This only runs for the occasional word
|
||||
# whose work-grid position does not survive the scale-up to HD.
|
||||
placed_offset = _find_free_placement(
|
||||
blocked, occupied, collision, stamp, base_y, base_x
|
||||
)
|
||||
|
||||
if placed_offset is None:
|
||||
return None, {
|
||||
"shifted_words": shifted_words,
|
||||
@@ -152,6 +234,126 @@ def refine_layout_with_hd_clearance(
|
||||
}
|
||||
|
||||
|
||||
def append_layout_with_hd_clearance(
|
||||
base_layout,
|
||||
additions,
|
||||
mask,
|
||||
font_path,
|
||||
clearance=1,
|
||||
max_shift=24,
|
||||
allow_global_search=False,
|
||||
):
|
||||
"""Place only *additions* against an already validated HD layout.
|
||||
|
||||
The normal refinement pass must rebuild occupancy for every word because it
|
||||
is allowed to move the whole batch. Auto-repeat words are appended after the
|
||||
base batch has already passed refinement, so rescanning that batch is wasted
|
||||
work. This helper seeds occupancy from the base once, then processes only
|
||||
the new words and returns the largest collision-free prefix.
|
||||
"""
|
||||
height, width = mask.shape
|
||||
blocked = np.asarray(mask) != 0
|
||||
occupied = np.zeros((height, width), dtype=bool)
|
||||
|
||||
def stamp_existing(item):
|
||||
word, size, (draw_y, draw_x), orient, _color = item
|
||||
measured = _word_ink(word, size, orient, font_path)
|
||||
if measured is None:
|
||||
return
|
||||
ink, bbox_left, bbox_top = measured
|
||||
ink_y = int(draw_y) + bbox_top
|
||||
ink_x = int(draw_x) + bbox_left
|
||||
y0 = max(0, ink_y)
|
||||
x0 = max(0, ink_x)
|
||||
y1 = min(height, ink_y + ink.shape[0])
|
||||
x1 = min(width, ink_x + ink.shape[1])
|
||||
if y0 < y1 and x0 < x1:
|
||||
occupied[y0:y1, x0:x1] |= ink[y0 - ink_y:y1 - ink_y, x0 - ink_x:x1 - ink_x] > 0
|
||||
|
||||
for item in base_layout:
|
||||
stamp_existing(item)
|
||||
|
||||
offset_rings = [[(0, 0)]]
|
||||
for radius in range(1, max_shift + 1):
|
||||
ring = [
|
||||
(dy, dx)
|
||||
for dy in range(-radius, radius + 1)
|
||||
for dx in range(-radius, radius + 1)
|
||||
if max(abs(dy), abs(dx)) == radius
|
||||
]
|
||||
ring.sort(key=lambda item: (item[0] * item[0] + item[1] * item[1], item[0], item[1]))
|
||||
offset_rings.append(ring)
|
||||
|
||||
accepted = []
|
||||
shifted_words = 0
|
||||
max_applied_shift = 0
|
||||
failed_word = None
|
||||
for word, size, (draw_y, draw_x), orient, color in additions:
|
||||
measured = _word_ink(word, size, orient, font_path)
|
||||
if measured is None:
|
||||
accepted.append((word, size, (draw_y, draw_x), orient, color))
|
||||
continue
|
||||
|
||||
glyph_ink, bbox_left, bbox_top = measured
|
||||
glyph_height, glyph_width = glyph_ink.shape
|
||||
pad = max(0, int(clearance))
|
||||
padded = np.zeros((glyph_height + 2 * pad, glyph_width + 2 * pad), dtype=np.uint8)
|
||||
padded[pad:pad + glyph_height, pad:pad + glyph_width] = glyph_ink
|
||||
if pad:
|
||||
collision = np.asarray(
|
||||
Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)),
|
||||
dtype=np.uint8,
|
||||
) > 0
|
||||
else:
|
||||
collision = padded > 0
|
||||
stamp = padded > 0
|
||||
base_y = int(draw_y) + bbox_top - pad
|
||||
base_x = int(draw_x) + bbox_left - pad
|
||||
|
||||
def fits(y0, x0):
|
||||
y1 = y0 + collision.shape[0]
|
||||
x1 = x0 + collision.shape[1]
|
||||
if y0 < 0 or x0 < 0 or y1 > height or x1 > width:
|
||||
return False
|
||||
if np.any(blocked[y0:y1, x0:x1] & stamp):
|
||||
return False
|
||||
return not np.any(occupied[y0:y1, x0:x1] & collision)
|
||||
|
||||
placed_offset = None
|
||||
for ring in offset_rings:
|
||||
for dy, dx in ring:
|
||||
if fits(base_y + dy, base_x + dx):
|
||||
placed_offset = (dy, dx)
|
||||
break
|
||||
if placed_offset is not None:
|
||||
break
|
||||
if placed_offset is None and allow_global_search:
|
||||
placed_offset = _find_free_placement(
|
||||
blocked, occupied, collision, stamp, base_y, base_x
|
||||
)
|
||||
if placed_offset is None:
|
||||
# A local-only append is deliberately best-effort: skipping one
|
||||
# extra word is much cheaper than scanning the full HD canvas and
|
||||
# keeps the latency predictable for large auto-repeat batches.
|
||||
failed_word = word
|
||||
continue
|
||||
|
||||
dy, dx = placed_offset
|
||||
y0 = base_y + dy
|
||||
x0 = base_x + dx
|
||||
occupied[y0:y0 + stamp.shape[0], x0:x0 + stamp.shape[1]] |= stamp
|
||||
if dy or dx:
|
||||
shifted_words += 1
|
||||
max_applied_shift = max(max_applied_shift, abs(dy), abs(dx))
|
||||
accepted.append((word, size, (int(draw_y) + dy, int(draw_x) + dx), orient, color))
|
||||
|
||||
return accepted, {
|
||||
"shifted_words": shifted_words,
|
||||
"max_shift": max_applied_shift,
|
||||
"failed_word": failed_word,
|
||||
}
|
||||
|
||||
|
||||
def render_layout_occupancy(layout, mask_shape, font_path):
|
||||
h, w = mask_shape
|
||||
canvas = Image.new("L", (w, h), 0)
|
||||
@@ -175,6 +377,67 @@ def compute_fill_ratio_fast(layout, mask, font_path):
|
||||
return filled_area / free_area, occ
|
||||
|
||||
|
||||
def compute_coverage_score(occupancy, mask, block_size=8):
|
||||
"""Shape-aware fill quality: how broadly the ink reaches across the mask.
|
||||
|
||||
Unlike :func:`compute_fill_ratio_fast` (filled pixels / free pixels), this
|
||||
rewards reaching *every part* of the mask silhouette -- protrusions, limb
|
||||
tips, heart-shaped cusps -- that a centre-out Fermat spiral abandons first
|
||||
when words are scarce or font sizes are large.
|
||||
|
||||
The fillable region is tiled into ``block_size`` cells. A cell counts as a
|
||||
"region block" when at least 30% of its pixels are free; it is "covered"
|
||||
when at least one of those free pixels is inked. The score is the share of
|
||||
region blocks that are covered:
|
||||
|
||||
coverage = covered_blocks / region_blocks
|
||||
|
||||
A compact disc packed around the centroid touches only the central blocks,
|
||||
so it scores low even at a high pixel fill ratio; a layout that spreads
|
||||
into every arm of the mask touches blocks in each arm and scores high.
|
||||
Block granularity (not per-pixel weighting) is what makes this robust to
|
||||
the mask's geometry: a thin tip is one block whether it is 3px or 30px
|
||||
wide, so reaching it is rewarded consistently. ``occupancy`` may be None
|
||||
(treated as empty).
|
||||
"""
|
||||
mask_arr = np.asarray(mask)
|
||||
if mask_arr.ndim != 2 or mask_arr.size == 0:
|
||||
return 0.0
|
||||
free = mask_arr == 0
|
||||
if not free.any():
|
||||
return 0.0
|
||||
|
||||
h, w = mask_arr.shape
|
||||
occ = np.asarray(occupancy) if occupancy is not None else None
|
||||
if occ is None or occ.shape != mask_arr.shape:
|
||||
return 0.0
|
||||
inked = (occ == 1) & free
|
||||
|
||||
# Block-aligned tile counts. Trailing partial blocks are merged into the
|
||||
# last full block by clamping the end index, so no free pixels are dropped.
|
||||
region_blocks = 0
|
||||
covered_blocks = 0
|
||||
for by in range(0, h, block_size):
|
||||
y1 = min(h, by + block_size)
|
||||
for bx in range(0, w, block_size):
|
||||
x1 = min(w, bx + block_size)
|
||||
block_free = free[by:y1, bx:x1]
|
||||
free_count = int(block_free.sum())
|
||||
if free_count == 0:
|
||||
continue
|
||||
# A block is a region if a meaningful share of it is fillable;
|
||||
# this ignores blocks that only clip a mask corner.
|
||||
if free_count < 0.30 * block_free.size:
|
||||
continue
|
||||
region_blocks += 1
|
||||
if np.any(inked[by:y1, bx:x1] & block_free):
|
||||
covered_blocks += 1
|
||||
|
||||
if region_blocks == 0:
|
||||
return 0.0
|
||||
return covered_blocks / region_blocks
|
||||
|
||||
|
||||
def largest_empty_square_size(occupancy, mask):
|
||||
"""Return the largest fully empty square inside the fillable mask."""
|
||||
blocked = (np.asarray(mask) != 0) | (np.asarray(occupancy) != 0)
|
||||
|
||||
+483
-11
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -10,6 +11,7 @@ import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -24,6 +26,7 @@ from core.fonts import get_cached_font
|
||||
from .job_manager import JobManager
|
||||
from .line_spacing import analyze_svg_line_spacing_file
|
||||
from .log_config import get_logger
|
||||
from .metadata_store import MetadataStore
|
||||
from .runner import JobRunner
|
||||
from .schemas import (
|
||||
Asset,
|
||||
@@ -57,12 +60,17 @@ ASSETS_DIR = PROJECT_ROOT / "service_assets"
|
||||
PROJECTS_DIR = PROJECT_ROOT / "service_projects"
|
||||
FONTS_DIR = PROJECT_ROOT / "service_fonts"
|
||||
DESIGN_TEMPLATES_DIR = PROJECT_ROOT / "service_design_templates"
|
||||
METADATA_DIR = PROJECT_ROOT / "service_metadata"
|
||||
ORDERS_DIR = PROJECT_ROOT / "service_orders"
|
||||
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
FONTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DESIGN_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
METADATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ORDERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = JobManager()
|
||||
metadata_store = MetadataStore(METADATA_DIR / "app.db")
|
||||
manager = JobManager(metadata_store)
|
||||
storage = Storage(WORKSPACE_DIR)
|
||||
runner = JobRunner(PROJECT_ROOT, manager)
|
||||
|
||||
@@ -144,6 +152,31 @@ def health() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/maintenance/storage-summary")
|
||||
def storage_summary() -> dict:
|
||||
referenced_jobs: set[str] = set()
|
||||
for d in _list_dirs(ASSETS_DIR):
|
||||
meta = _read_asset_meta(d)
|
||||
job_id = meta.get("job_id") or ""
|
||||
if job_id:
|
||||
referenced_jobs.add(job_id)
|
||||
stale = storage.stale_job_dirs(
|
||||
referenced_job_ids=referenced_jobs,
|
||||
exclude_job_ids=metadata_store.job_ids(),
|
||||
max_age_days=0,
|
||||
)
|
||||
return {
|
||||
"job_dir_count": len(storage.list_job_ids()),
|
||||
"referenced_job_ids": len(referenced_jobs),
|
||||
"stale_job_count": len(stale),
|
||||
"reclaimable_bytes": sum(item["size_bytes"] for item in stale),
|
||||
"metadata_db_bytes": metadata_store.summarize()["db_size_bytes"],
|
||||
"jobs_in_db": metadata_store.summarize()["jobs"],
|
||||
"events_in_db": metadata_store.summarize()["events"],
|
||||
"dry_run_only": True,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/jobs", response_model=list[JobStatus])
|
||||
def list_jobs() -> list[JobStatus]:
|
||||
with manager._lock:
|
||||
@@ -153,26 +186,21 @@ def list_jobs() -> list[JobStatus]:
|
||||
@app.post("/api/jobs", response_model=JobCreateResponse)
|
||||
async def create_job(
|
||||
mask_image: Optional[UploadFile] = File(None),
|
||||
name_list: UploadFile = File(...),
|
||||
name_list: Optional[UploadFile] = File(None),
|
||||
wcd_file: Optional[UploadFile] = File(None),
|
||||
font_file: Optional[UploadFile] = File(None),
|
||||
font_id: str = Form(""),
|
||||
params: str = Form("{}"),
|
||||
) -> JobCreateResponse:
|
||||
log.info("─" * 50)
|
||||
log.info("[API] POST /api/jobs 收到新任务请求")
|
||||
log.info(" name_list.filename = %s", name_list.filename)
|
||||
log.info(" name_list.filename = %s", name_list.filename if name_list else "无")
|
||||
log.info(" wcd_file.filename = %s", wcd_file.filename if wcd_file else "无")
|
||||
log.info(" mask_image.filename = %s", mask_image.filename if mask_image else "无")
|
||||
log.info(" font_file.filename = %s", font_file.filename if font_file else "无")
|
||||
log.info(" font_id = %s", font_id or "(未指定)")
|
||||
log.info(" params (raw) = %s", params)
|
||||
|
||||
if not name_list.filename:
|
||||
raise HTTPException(status_code=400, detail="name_list is required")
|
||||
|
||||
ext_xlsx = Path(name_list.filename).suffix.lower()
|
||||
if ext_xlsx not in {".xlsx"}:
|
||||
raise HTTPException(status_code=400, detail="name_list must be xlsx")
|
||||
|
||||
try:
|
||||
user_params = json.loads(params)
|
||||
except json.JSONDecodeError:
|
||||
@@ -181,6 +209,23 @@ async def create_job(
|
||||
if not isinstance(user_params, dict):
|
||||
raise HTTPException(status_code=400, detail="params must be JSON object")
|
||||
|
||||
mode = str(user_params.get("MODE", "IMAGE")).upper()
|
||||
|
||||
# WCD 生产任务:传入 .wcd 画布导入导出包(还原设计 -> 生产,见 docs/wordcloud-contract.md v1.1)
|
||||
is_wcd = mode == "WCD" or bool(wcd_file and wcd_file.filename)
|
||||
if is_wcd:
|
||||
if not (wcd_file and wcd_file.filename):
|
||||
raise HTTPException(status_code=400, detail="wcd_file is required when MODE=WCD")
|
||||
return await _create_wcd_job(wcd_file, user_params)
|
||||
|
||||
# 名单/xlsx 模式(既有逻辑;必填校验移到 mode/WCD 判断之后)
|
||||
if not name_list or not name_list.filename:
|
||||
raise HTTPException(status_code=400, detail="name_list is required")
|
||||
|
||||
ext_xlsx = Path(name_list.filename).suffix.lower()
|
||||
if ext_xlsx not in {".xlsx"}:
|
||||
raise HTTPException(status_code=400, detail="name_list must be xlsx")
|
||||
|
||||
log.info(" 解析后 params = %s", json.dumps(user_params, ensure_ascii=False))
|
||||
|
||||
mode = str(user_params.get("MODE", "IMAGE")).upper()
|
||||
@@ -234,8 +279,12 @@ async def create_job(
|
||||
"MODE": mode,
|
||||
"EXCEL_PATH": str(paths.excel_path),
|
||||
"OUTPUT_DIR": str(paths.output_dir),
|
||||
"SAVE_DEBUG_IMAGES": True,
|
||||
# Debug masks are useful during local diagnosis but add extra image
|
||||
# writes to every request. Keep the fast service path disk-light; the
|
||||
# explicit CLI/config option remains available when debugging.
|
||||
"SAVE_DEBUG_IMAGES": False,
|
||||
"DEBUG_OUTPUT_DIR": str(paths.output_dir / "debug"),
|
||||
"FAST_MODE": True,
|
||||
}
|
||||
if mask_image and mask_image.filename:
|
||||
config["MASK_IMAGE_PATH"] = str(paths.mask_path)
|
||||
@@ -273,6 +322,174 @@ async def create_job(
|
||||
return JobCreateResponse(job_id=job_id)
|
||||
|
||||
|
||||
def _parse_hex(color: object):
|
||||
"""把 '#RRGGBB' / '#RGB' / 空值 解析为 RGBA tuple。"""
|
||||
raw = str(color or "#ffffff").strip().lstrip("#")
|
||||
if len(raw) == 3:
|
||||
raw = "".join(c + c for c in raw)
|
||||
try:
|
||||
return tuple(int(raw[i : i + 2], 16) for i in (0, 2, 4)) + (255,)
|
||||
except ValueError:
|
||||
return (255, 255, 255, 255)
|
||||
|
||||
|
||||
def _compose_design_png(document: dict, file_map: dict, output_path: Path) -> None:
|
||||
"""把 CanvasDocument 合成一张扁平 PNG(生产任务产物):背景 + 按 zIndex 叠贴纸。
|
||||
|
||||
file_map: {pkg_asset_id: {"path": str}},即 WCD 内临时 assetId -> 落盘素材文件。
|
||||
"""
|
||||
try:
|
||||
width = int(document.get("width") or 1200)
|
||||
height = int(document.get("height") or 1200)
|
||||
except (TypeError, ValueError):
|
||||
width, height = 1200, 1200
|
||||
canvas = Image.new("RGBA", (max(width, 1), max(height, 1)), _parse_hex(document.get("background")))
|
||||
elements = [
|
||||
e
|
||||
for e in document.get("elements", [])
|
||||
if isinstance(e, dict) and e.get("type") == "sticker"
|
||||
]
|
||||
elements.sort(key=lambda e: e.get("zIndex", 0))
|
||||
for e in elements:
|
||||
info = file_map.get(str(e.get("assetId") or ""))
|
||||
if not info:
|
||||
continue
|
||||
path = info.get("path")
|
||||
if not path or not Path(path).exists():
|
||||
continue
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
except Exception:
|
||||
continue
|
||||
ew = e.get("width")
|
||||
eh = e.get("height")
|
||||
ew = int(ew) if isinstance(ew, (int, float)) and ew > 0 else img.width
|
||||
eh = int(eh) if isinstance(eh, (int, float)) and eh > 0 else img.height
|
||||
if (ew, eh) != (img.width, img.height):
|
||||
img = img.resize((int(ew), int(eh)))
|
||||
opacity = e.get("opacity", 1)
|
||||
if isinstance(opacity, (int, float)) and opacity >= 0 and opacity != 1:
|
||||
img = img.copy()
|
||||
img.putalpha(img.getchannel("A").point(lambda v: round(v * float(opacity))))
|
||||
canvas.alpha_composite(img, (int(e.get("x") or 0), int(e.get("y") or 0)))
|
||||
canvas.convert("RGB").save(output_path, "PNG")
|
||||
|
||||
|
||||
async def _create_wcd_job(wcd_file: UploadFile, user_params: dict) -> JobCreateResponse:
|
||||
"""WCD 生产任务:校验并还原 .wcd,落库设计,合成生产 PNG 作为任务产物。
|
||||
|
||||
与 POST /api/design-templates/import 共用素材去重/注册/重映射逻辑;
|
||||
状态机与产物管线复用 jobs(queued/running/success/failed)。
|
||||
"""
|
||||
if Path(wcd_file.filename).suffix.lower() != ".wcd":
|
||||
raise HTTPException(status_code=400, detail="wcd_file must be .wcd")
|
||||
content = await wcd_file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="wcd_file is empty")
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(content))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise HTTPException(status_code=400, detail="file is not a valid zip/wcd package") from exc
|
||||
|
||||
with zf:
|
||||
manifest = _read_import_json(zf, "manifest.json")
|
||||
order_meta = manifest.get("meta") or {}
|
||||
order_no = str(order_meta.get("orderNo") or "") if isinstance(order_meta, dict) else ""
|
||||
# 兼容旧命名 order-{orderNo}
|
||||
if not order_no and str(manifest.get("name") or "").startswith("order-"):
|
||||
order_no = str(manifest.get("name"))[6:]
|
||||
if manifest.get("format") != "wordcloud-canvas":
|
||||
raise HTTPException(status_code=400, detail="不是 wordcloud-canvas 格式")
|
||||
try:
|
||||
version = int(manifest.get("version", 1))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="manifest version 不是合法数字") from exc
|
||||
if version != 1:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的包版本: {version}")
|
||||
|
||||
document = _read_import_json(zf, "document.json")
|
||||
asset_items = manifest.get("assets")
|
||||
if not isinstance(asset_items, list):
|
||||
asset_items = []
|
||||
|
||||
remap: dict[str, str] = {}
|
||||
file_map: dict[str, dict] = {}
|
||||
reference_ids: list[str] = []
|
||||
for item in asset_items:
|
||||
pkg_id = str(item.get("id") or "")
|
||||
if not pkg_id:
|
||||
continue
|
||||
entry_name = _zip_asset_entry(zf, pkg_id)
|
||||
mime = _imported_asset_mime(item)
|
||||
asset_bytes = zf.read(entry_name)
|
||||
meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime)
|
||||
real_id = str(meta["asset_id"])
|
||||
remap[pkg_id] = real_id
|
||||
file_map[pkg_id] = {
|
||||
"path": str(_asset_dir(real_id) / f"asset{_asset_extension_for_mime(mime)}"),
|
||||
"mime": mime,
|
||||
}
|
||||
reference_ids.append(real_id)
|
||||
_remap_import_asset_ids(document, remap)
|
||||
|
||||
# 落库生产设计(与模板导入一致,便于追溯/复用/在画布中继续编辑)
|
||||
display_name = str(manifest.get("name") or "导入生产设计").strip()[:120] or "导入生产设计"
|
||||
template_id = f"tmpl_{uuid.uuid4().hex}"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
_write_design_template(_design_template_dir(template_id), {
|
||||
"template_id": template_id,
|
||||
"name": display_name,
|
||||
"description": str(manifest.get("description") or "下单派单生产设计"),
|
||||
"document": document,
|
||||
"reference_asset_ids": reference_ids,
|
||||
"cover_asset_id": reference_ids[0] if reference_ids else "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
|
||||
job_id = manager.create_job()
|
||||
paths = storage.prepare_job_dirs(job_id)
|
||||
(paths.input_dir / f"{template_id}.wcd").write_bytes(content)
|
||||
log.info("[WCD] 生产任务 job_id=%s template=%s assets=%d", job_id, template_id, len(reference_ids))
|
||||
|
||||
# 登记生产订单(供 wordcloud 侧订单列表查看)
|
||||
if order_no:
|
||||
_write_order(_order_dir(order_no), {
|
||||
"order_id": order_no,
|
||||
"order_no": order_no,
|
||||
"job_id": job_id,
|
||||
"template_id": template_id,
|
||||
"status": "running",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
log.info("[Order] 登记生产订单 %s → job %s", order_no, job_id)
|
||||
manager.set_status(
|
||||
job_id, status="running", stage="composing", progress_percent=10, message="WCD 生产任务"
|
||||
)
|
||||
|
||||
def _run_wcd_safe() -> None:
|
||||
try:
|
||||
png_path = paths.output_dir / "result.png"
|
||||
_compose_design_png(document, file_map, png_path)
|
||||
manager.set_artifacts(job_id, {"png": str(png_path)})
|
||||
manager.set_status(
|
||||
job_id, status="success", stage="done", progress_percent=100, message="生产完成"
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.exception("wcd job crashed", extra={"job_id": job_id})
|
||||
manager.set_status(
|
||||
job_id,
|
||||
status="failed",
|
||||
stage="failed",
|
||||
progress_percent=100,
|
||||
message="任务失败",
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
threading.Thread(target=_run_wcd_safe, daemon=True).start()
|
||||
return JobCreateResponse(job_id=job_id)
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}", response_model=JobStatus)
|
||||
def get_job(job_id: str) -> JobStatus:
|
||||
if not manager.exists(job_id):
|
||||
@@ -329,6 +546,7 @@ def get_result(job_id: str) -> JobResult:
|
||||
svg_stroke_url=u("svg_stroke"),
|
||||
db_url=u("db"),
|
||||
metrics_url=u("metrics"),
|
||||
elapsed_seconds=status.elapsed_seconds,
|
||||
)
|
||||
|
||||
|
||||
@@ -676,6 +894,45 @@ def get_file(job_id: str, kind: str):
|
||||
return FileResponse(path, media_type=media, filename=path.name)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 3.9 生产订单列表(下单派单投递的 WCD 生产任务)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@app.get("/api/orders")
|
||||
def list_orders() -> list[dict]:
|
||||
"""生产订单列表:来自小程序下单派单投递到 wordcloud 的 WCD 生产任务。"""
|
||||
orders = []
|
||||
if ORDERS_DIR.exists():
|
||||
for item in list(ORDERS_DIR.iterdir()):
|
||||
if not item.is_dir() or not (item / "order.json").exists():
|
||||
continue
|
||||
o = _read_order(item)
|
||||
if not o:
|
||||
continue
|
||||
# 用 job 的最新状态回填
|
||||
try:
|
||||
st = manager.get_status(str(o.get("job_id") or ""))
|
||||
o["status"] = st.status
|
||||
except Exception:
|
||||
pass
|
||||
orders.append(o)
|
||||
return list(reversed(orders))
|
||||
|
||||
|
||||
@app.get("/api/orders/{order_no}")
|
||||
def get_order(order_no: str) -> dict:
|
||||
path = Path(_order_dir(order_no)) / "order.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="order not found")
|
||||
o = json.loads(path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
st = manager.get_status(str(o.get("job_id") or ""))
|
||||
o["status"] = st.status
|
||||
except Exception:
|
||||
pass
|
||||
return o
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 4. 模板接口
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
@@ -700,6 +957,26 @@ def _write_design_template(template_dir: Path, data: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ── 生产订单存储(下单派单投递的 WCD 生产任务)────────────
|
||||
def _order_dir(order_no: str) -> Path:
|
||||
return ORDERS_DIR / order_no
|
||||
|
||||
|
||||
def _read_order(order_dir: Path) -> dict:
|
||||
path = order_dir / "order.json"
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_order(order_dir: Path, data: dict) -> None:
|
||||
order_dir.mkdir(parents=True, exist_ok=True)
|
||||
(order_dir / "order.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_array(value: str, field_name: str) -> list:
|
||||
try:
|
||||
parsed = json.loads(value or "[]")
|
||||
@@ -758,6 +1035,201 @@ async def create_design_template(
|
||||
return DesignTemplate(**data)
|
||||
|
||||
|
||||
def _read_import_json(zf: zipfile.ZipFile, name: str) -> dict:
|
||||
try:
|
||||
with zf.open(name) as fh:
|
||||
raw = fh.read().decode("utf-8")
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 缺失") from exc
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 不是合法 JSON") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(status_code=400, detail=f"{name} 必须是 JSON 对象")
|
||||
return data
|
||||
|
||||
|
||||
def _imported_asset_mime(item: dict) -> str:
|
||||
mime = str(item.get("mimeType") or item.get("mime_type") or "").strip().lower()
|
||||
if mime in {"image/svg+xml", "image/png", "image/jpeg"}:
|
||||
return mime
|
||||
asset_type = str(item.get("type") or "").strip().lower()
|
||||
if asset_type in {"svg", "image/svg+xml"}:
|
||||
return "image/svg+xml"
|
||||
if asset_type in {"image", "png"}:
|
||||
return "image/png"
|
||||
return "image/svg+xml" if str(item.get("id", "")).endswith(".svg") else "image/png"
|
||||
|
||||
|
||||
def _asset_meta_items() -> list[dict]:
|
||||
result: list[dict] = []
|
||||
for d in _list_dirs(ASSETS_DIR):
|
||||
meta = _read_asset_meta(d)
|
||||
if meta:
|
||||
result.append(meta)
|
||||
return result
|
||||
|
||||
|
||||
def _find_asset_by_sha256(digest: str) -> dict | None:
|
||||
for meta in _asset_meta_items():
|
||||
if meta.get("sha256") == digest:
|
||||
return meta
|
||||
for meta in _asset_meta_items():
|
||||
try:
|
||||
ext = _asset_extension_for_mime(str(meta.get("mime_type") or ""))
|
||||
path = _asset_dir(str(meta["asset_id"])) / f"asset{ext}"
|
||||
if path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == digest:
|
||||
meta["sha256"] = digest
|
||||
_write_asset_meta(_asset_dir(str(meta["asset_id"])), meta)
|
||||
return meta
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _register_import_asset(name: str, content: bytes, mime: str) -> dict:
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
existing = _find_asset_by_sha256(digest)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
asset_id = f"asset_{uuid.uuid4().hex}"
|
||||
asset_path = _asset_dir(asset_id)
|
||||
asset_path.mkdir(parents=True, exist_ok=True)
|
||||
ext = _asset_extension_for_mime(mime)
|
||||
dest = asset_path / f"asset{ext}"
|
||||
dest.write_bytes(content)
|
||||
|
||||
width, height = 0, 0
|
||||
if mime == "image/svg+xml":
|
||||
width, height = _parse_svg_viewbox(dest)
|
||||
else:
|
||||
try:
|
||||
with Image.open(dest) as img:
|
||||
width, height = img.size
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
meta = {
|
||||
"asset_id": asset_id,
|
||||
"name": name[:120] or "导入素材",
|
||||
"type": "sticker",
|
||||
"mime_type": mime,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"file_size": len(content),
|
||||
"sha256": digest,
|
||||
"file_url": f"/api/assets/{asset_id}/download",
|
||||
"job_id": "",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
_write_asset_meta(asset_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def _zip_asset_entry(zf: zipfile.ZipFile, pkg_id: str) -> str:
|
||||
candidates: list[str] = []
|
||||
for entry in zf.namelist():
|
||||
parts = entry.split("/")
|
||||
if (
|
||||
len(parts) >= 2
|
||||
and parts[0] == "assets"
|
||||
and parts[1].startswith(pkg_id)
|
||||
and ".." not in parts
|
||||
and not entry.endswith("/")
|
||||
):
|
||||
candidates.append(entry)
|
||||
if not candidates:
|
||||
raise HTTPException(status_code=400, detail=f"包内缺少素材: {pkg_id}")
|
||||
for entry in candidates:
|
||||
if Path(entry).stem == pkg_id:
|
||||
return entry
|
||||
return sorted(candidates)[0]
|
||||
|
||||
|
||||
def _remap_import_asset_ids(document: dict, asset_map: dict[str, str]) -> None:
|
||||
elements = document.get("elements")
|
||||
if not isinstance(elements, list):
|
||||
return
|
||||
for element in elements:
|
||||
if isinstance(element, dict) and element.get("type") == "sticker":
|
||||
old_id = element.get("assetId")
|
||||
if isinstance(old_id, str) and old_id in asset_map:
|
||||
element["assetId"] = asset_map[old_id]
|
||||
|
||||
|
||||
@app.post("/api/design-templates/import", response_model=DesignTemplate)
|
||||
async def import_design_template(
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
) -> DesignTemplate:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="file is required")
|
||||
if Path(file.filename).suffix.lower() != ".wcd":
|
||||
raise HTTPException(status_code=400, detail="file must be .wcd")
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="file is empty")
|
||||
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(content))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise HTTPException(status_code=400, detail="file is not a valid zip/wcd package") from exc
|
||||
|
||||
with zf:
|
||||
manifest = _read_import_json(zf, "manifest.json")
|
||||
if manifest.get("format") != "wordcloud-canvas":
|
||||
raise HTTPException(status_code=400, detail="不是 wordcloud-canvas 格式")
|
||||
try:
|
||||
version = int(manifest.get("version", 1))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="manifest version 不是合法数字") from exc
|
||||
if version != 1:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的包版本: {version}")
|
||||
|
||||
document = _read_import_json(zf, "document.json")
|
||||
asset_items = manifest.get("assets")
|
||||
if not isinstance(asset_items, list):
|
||||
asset_items = []
|
||||
|
||||
asset_map: dict[str, str] = {}
|
||||
reference_ids: list[str] = []
|
||||
for item in asset_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
pkg_id = str(item.get("id") or "")
|
||||
if not pkg_id:
|
||||
continue
|
||||
entry_name = _zip_asset_entry(zf, pkg_id)
|
||||
mime = _imported_asset_mime(item)
|
||||
asset_bytes = zf.read(entry_name)
|
||||
meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime)
|
||||
asset_map[pkg_id] = str(meta["asset_id"])
|
||||
reference_ids.append(str(meta["asset_id"]))
|
||||
|
||||
_remap_import_asset_ids(document, asset_map)
|
||||
|
||||
display_name = name.strip() or str(manifest.get("name") or "导入设计").strip() or "导入设计"
|
||||
display_description = description.strip() or str(manifest.get("description") or "").strip()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
template_id = f"tmpl_{uuid.uuid4().hex}"
|
||||
data = {
|
||||
"template_id": template_id,
|
||||
"name": display_name[:120],
|
||||
"description": display_description,
|
||||
"document": document,
|
||||
"reference_asset_ids": reference_ids,
|
||||
"cover_asset_id": reference_ids[0] if reference_ids else "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
_write_design_template(_design_template_dir(template_id), data)
|
||||
return DesignTemplate(**data)
|
||||
|
||||
|
||||
@app.get("/api/design-templates/{template_id}", response_model=DesignTemplate)
|
||||
def get_design_template(template_id: str) -> DesignTemplate:
|
||||
data = _read_design_template(_design_template_dir(template_id))
|
||||
|
||||
@@ -6,7 +6,9 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .metadata_store import MetadataStore
|
||||
from .schemas import JobDetail, JobEvent, JobStatus
|
||||
|
||||
|
||||
@@ -18,9 +20,20 @@ class JobState:
|
||||
|
||||
|
||||
class JobManager:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, store: Optional[MetadataStore] = None) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.store = store
|
||||
self._jobs: dict[str, JobState] = {}
|
||||
self._restore_from_store()
|
||||
|
||||
def _restore_from_store(self) -> None:
|
||||
if not self.store:
|
||||
return
|
||||
for status in self.store.load_jobs():
|
||||
self._jobs[status.job_id] = JobState(
|
||||
status=status,
|
||||
events=self.store.load_events(status.job_id, limit=100),
|
||||
)
|
||||
|
||||
def create_job(self) -> str:
|
||||
job_id = uuid.uuid4().hex
|
||||
@@ -38,6 +51,8 @@ class JobManager:
|
||||
)
|
||||
with self._lock:
|
||||
self._jobs[job_id] = JobState(status=status)
|
||||
if self.store:
|
||||
self.store.upsert_job(status)
|
||||
return job_id
|
||||
|
||||
def exists(self, job_id: str) -> bool:
|
||||
@@ -53,11 +68,19 @@ class JobManager:
|
||||
state = self._jobs[job_id]
|
||||
return JobDetail(status=state.status, recent_events=state.events[-100:])
|
||||
|
||||
def delete_job(self, job_id: str) -> None:
|
||||
with self._lock:
|
||||
self._jobs.pop(job_id, None)
|
||||
if self.store:
|
||||
self.store.delete_job(job_id)
|
||||
|
||||
def set_artifacts(self, job_id: str, artifacts: dict[str, str]) -> None:
|
||||
with self._lock:
|
||||
status = self._jobs[job_id].status
|
||||
status.artifacts = artifacts
|
||||
status.updated_at = datetime.now(timezone.utc)
|
||||
if self.store:
|
||||
self.store.upsert_job(status)
|
||||
|
||||
def set_status(
|
||||
self,
|
||||
@@ -68,6 +91,7 @@ class JobManager:
|
||||
progress_percent: int,
|
||||
message: str,
|
||||
error: str | None = None,
|
||||
elapsed_seconds: float | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
s = self._jobs[job_id].status
|
||||
@@ -78,14 +102,28 @@ class JobManager:
|
||||
s.updated_at = datetime.now(timezone.utc)
|
||||
if error is not None:
|
||||
s.error = error
|
||||
if elapsed_seconds is not None:
|
||||
s.elapsed_seconds = elapsed_seconds
|
||||
if self.store:
|
||||
self.store.upsert_job(s)
|
||||
|
||||
def add_event(self, job_id: str, *, kind: str, stage: str, progress_percent: int, message: str) -> None:
|
||||
def add_event(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
kind: str,
|
||||
stage: str,
|
||||
progress_percent: int,
|
||||
message: str,
|
||||
elapsed_seconds: float | None = None,
|
||||
) -> None:
|
||||
event = JobEvent(
|
||||
type=kind,
|
||||
stage=stage,
|
||||
progress_percent=progress_percent,
|
||||
message=message,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
elapsed_seconds=elapsed_seconds,
|
||||
)
|
||||
with self._lock:
|
||||
state = self._jobs[job_id]
|
||||
@@ -94,13 +132,26 @@ class JobManager:
|
||||
state.status.progress_percent = progress_percent
|
||||
state.status.message = message
|
||||
state.status.updated_at = event.timestamp
|
||||
if elapsed_seconds is not None:
|
||||
state.status.elapsed_seconds = elapsed_seconds
|
||||
for sub in state.subscribers:
|
||||
sub.put(event)
|
||||
if self.store:
|
||||
self.store.add_event(job_id, event)
|
||||
self.store.upsert_job(state.status)
|
||||
|
||||
def subscribe(self, job_id: str) -> queue.Queue:
|
||||
q: queue.Queue = queue.Queue()
|
||||
with self._lock:
|
||||
self._jobs[job_id].subscribers.append(q)
|
||||
state = self._jobs[job_id]
|
||||
# A fast job can emit preview_ready/completed before the browser
|
||||
# finishes opening the SSE connection. Replay the existing event
|
||||
# history into this subscriber so timing and preview updates are
|
||||
# never lost; the lock also makes the snapshot/registration
|
||||
# atomic with respect to new events.
|
||||
for event in state.events:
|
||||
q.put(event)
|
||||
state.subscribers.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, job_id: str, q: queue.Queue) -> None:
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Small SQLite-backed metadata store for jobs and events.
|
||||
|
||||
This is intentionally dependency-free and acts as the first durable layer for
|
||||
business metadata. The schema is shaped so it can be moved to PostgreSQL later
|
||||
without changing callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from .schemas import JobEvent, JobStatus
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class MetadataStore:
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.db_path = db_path
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
return conn
|
||||
|
||||
def _init_db(self) -> None:
|
||||
with self._lock, self._connect() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
progress_percent INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
artifacts TEXT NOT NULL DEFAULT '{}',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
elapsed_seconds REAL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
progress_percent INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
timestamp TEXT NOT NULL,
|
||||
elapsed_seconds REAL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_job_events_job_timestamp
|
||||
ON job_events(job_id, id);
|
||||
"""
|
||||
)
|
||||
|
||||
def upsert_job(self, status: JobStatus) -> None:
|
||||
with self._lock, self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (
|
||||
id, status, stage, progress_percent, message,
|
||||
artifacts, error, elapsed_seconds, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
stage = excluded.stage,
|
||||
progress_percent = excluded.progress_percent,
|
||||
message = excluded.message,
|
||||
artifacts = excluded.artifacts,
|
||||
error = excluded.error,
|
||||
elapsed_seconds = excluded.elapsed_seconds,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
status.job_id,
|
||||
status.status,
|
||||
status.stage,
|
||||
status.progress_percent,
|
||||
status.message,
|
||||
json.dumps(status.artifacts, ensure_ascii=False),
|
||||
status.error,
|
||||
status.elapsed_seconds,
|
||||
status.created_at.isoformat(),
|
||||
status.updated_at.isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
def add_event(self, job_id: str, event: JobEvent) -> None:
|
||||
with self._lock, self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO job_events (
|
||||
job_id, type, stage, progress_percent, message, timestamp, elapsed_seconds
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
event.type,
|
||||
event.stage,
|
||||
event.progress_percent,
|
||||
event.message,
|
||||
event.timestamp.isoformat(),
|
||||
event.elapsed_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
def load_jobs(self) -> list[JobStatus]:
|
||||
with self._lock, self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM jobs
|
||||
"""
|
||||
).fetchall()
|
||||
result: list[JobStatus] = []
|
||||
for row in rows:
|
||||
try:
|
||||
result.append(
|
||||
JobStatus(
|
||||
job_id=row["id"],
|
||||
status=row["status"],
|
||||
stage=row["stage"],
|
||||
progress_percent=row["progress_percent"],
|
||||
message=row["message"],
|
||||
artifacts=json.loads(row["artifacts"] or "{}"),
|
||||
error=row["error"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||
elapsed_seconds=row["elapsed_seconds"],
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return result
|
||||
|
||||
def load_events(self, job_id: str, limit: int = 100) -> list[JobEvent]:
|
||||
with self._lock, self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT type, stage, progress_percent, message, timestamp, elapsed_seconds
|
||||
FROM job_events
|
||||
WHERE job_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(job_id, limit),
|
||||
).fetchall()
|
||||
return [
|
||||
JobEvent(
|
||||
type=row["type"],
|
||||
stage=row["stage"],
|
||||
progress_percent=row["progress_percent"],
|
||||
message=row["message"],
|
||||
timestamp=datetime.fromisoformat(row["timestamp"]),
|
||||
elapsed_seconds=row["elapsed_seconds"],
|
||||
)
|
||||
for row in reversed(rows)
|
||||
]
|
||||
|
||||
def delete_job(self, job_id: str) -> None:
|
||||
with self._lock, self._connect() as conn:
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
|
||||
conn.execute("DELETE FROM job_events WHERE job_id = ?", (job_id,))
|
||||
|
||||
def job_ids(self) -> set[str]:
|
||||
with self._lock, self._connect() as conn:
|
||||
rows = conn.execute("SELECT id FROM jobs").fetchall()
|
||||
return {row["id"] for row in rows}
|
||||
|
||||
def summarize(self) -> dict:
|
||||
with self._lock, self._connect() as conn:
|
||||
jobs = conn.execute("SELECT COUNT(*) AS n FROM jobs").fetchone()
|
||||
events = conn.execute("SELECT COUNT(*) AS n FROM job_events").fetchone()
|
||||
return {
|
||||
"db_size_bytes": self.db_path.stat().st_size if self.db_path.exists() else 0,
|
||||
"jobs": jobs["n"] if jobs else 0,
|
||||
"events": events["n"] if events else 0,
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class JobRunner:
|
||||
return current_stage, current_progress
|
||||
|
||||
def run(self, job_id: str, paths: JobPaths, config: dict) -> None:
|
||||
t_start = time.time()
|
||||
t_start = time.perf_counter()
|
||||
log.info("=" * 50)
|
||||
log.info("[Runner] 任务启动 job_id=%s", job_id)
|
||||
log.info(" config_path = %s", paths.config_path)
|
||||
@@ -65,6 +65,11 @@ class JobRunner:
|
||||
]
|
||||
|
||||
env = os.environ.copy()
|
||||
# Force unbuffered stdout so every print() flushes immediately and the
|
||||
# frontend SSE log view shows each step in real time. Without this,
|
||||
# Python block-buffers stdout when it is a pipe, so lines pile up and
|
||||
# only arrive in bursts after the buffer fills or the process exits.
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(self.project_root),
|
||||
@@ -77,24 +82,63 @@ class JobRunner:
|
||||
|
||||
stage = "starting"
|
||||
progress = 1
|
||||
output_lines: list[str] = [] # collect all output lines for error reporting
|
||||
preview_sent = False
|
||||
|
||||
assert process.stdout is not None
|
||||
for raw in process.stdout:
|
||||
line = raw.rstrip("\n")
|
||||
output_lines.append(line)
|
||||
log.info("[Pipeline] %s", line)
|
||||
stage, progress = self._parse_stage(line, stage, progress)
|
||||
elapsed = time.perf_counter() - t_start
|
||||
self.manager.add_event(
|
||||
job_id,
|
||||
kind="log",
|
||||
stage=stage,
|
||||
progress_percent=progress,
|
||||
message=line,
|
||||
elapsed_seconds=round(elapsed, 3),
|
||||
)
|
||||
# The PNG is complete before SVG/DB export starts. Publish it as a
|
||||
# preview so the frontend does not wait for the slower artifacts.
|
||||
if not preview_sent and line.startswith("已保存:"):
|
||||
candidate = Path(line.split(":", 1)[1].strip())
|
||||
if candidate.suffix.lower() == ".png" and candidate.exists():
|
||||
self.manager.set_artifacts(job_id, {"png": str(candidate)})
|
||||
self.manager.add_event(
|
||||
job_id,
|
||||
kind="status",
|
||||
stage="preview_ready",
|
||||
progress_percent=max(progress, 94),
|
||||
message="预览已生成,后台继续导出其余文件",
|
||||
elapsed_seconds=round(elapsed, 3),
|
||||
)
|
||||
preview_sent = True
|
||||
|
||||
ret = process.wait()
|
||||
elapsed = time.time() - t_start
|
||||
elapsed = time.perf_counter() - t_start
|
||||
log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed)
|
||||
|
||||
# ── 错误时:截取最后 30 行输出作为详细错误信息 ─────────────
|
||||
error_detail = None
|
||||
if ret != 0:
|
||||
# 找到 "生成失败" 或 "错误" 或 traceback 之后的内容
|
||||
error_lines = []
|
||||
captured = False
|
||||
for line in reversed(output_lines):
|
||||
if not captured:
|
||||
error_lines.append(line)
|
||||
if any(kw in line for kw in ("生成失败", "error", "Error", "Traceback", "traceback", "未满足", "放置")):
|
||||
captured = True
|
||||
elif len(error_lines) < 30:
|
||||
error_lines.append(line)
|
||||
else:
|
||||
break
|
||||
error_lines.reverse()
|
||||
error_detail = "\n".join(error_lines) if error_lines else f"script exited with code {ret}"
|
||||
log.info("[Runner] 错误详情:\n%s", error_detail)
|
||||
|
||||
png = next(paths.output_dir.glob("*.png"), None)
|
||||
# NOTE: do NOT use "*[!_stroke].svg" — in glob, [!...] is a character class,
|
||||
# so filenames ending with "e.svg" (e.g. AutoResize.svg) are incorrectly skipped.
|
||||
@@ -142,15 +186,24 @@ class JobRunner:
|
||||
return
|
||||
|
||||
if ret == 0:
|
||||
done_message = f"任务完成,用时 {elapsed:.2f} 秒"
|
||||
log.info("[Runner] ✅ 任务完成 job_id=%s 总耗时=%.2fs", job_id, elapsed)
|
||||
self.manager.add_event(
|
||||
job_id,
|
||||
kind="status",
|
||||
stage="completed",
|
||||
progress_percent=100,
|
||||
message="任务完成",
|
||||
message=done_message,
|
||||
elapsed_seconds=round(elapsed, 3),
|
||||
)
|
||||
self.manager.set_status(
|
||||
job_id,
|
||||
status="success",
|
||||
stage="completed",
|
||||
progress_percent=100,
|
||||
message=done_message,
|
||||
elapsed_seconds=round(elapsed, 3),
|
||||
)
|
||||
self.manager.set_status(job_id, status="success", stage="completed", progress_percent=100, message="任务完成")
|
||||
else:
|
||||
log.error("[Runner] ❌ 任务失败 job_id=%s exit_code=%d 耗时=%.2fs", job_id, ret, elapsed)
|
||||
self.manager.add_event(
|
||||
@@ -158,7 +211,8 @@ class JobRunner:
|
||||
kind="status",
|
||||
stage="failed",
|
||||
progress_percent=100,
|
||||
message=f"任务失败,退出码: {ret}",
|
||||
message=f"任务失败,退出码: {ret},用时 {elapsed:.2f} 秒",
|
||||
elapsed_seconds=round(elapsed, 3),
|
||||
)
|
||||
self.manager.set_status(
|
||||
job_id,
|
||||
@@ -166,5 +220,6 @@ class JobRunner:
|
||||
stage="failed",
|
||||
progress_percent=100,
|
||||
message="任务失败",
|
||||
error=f"script exited with code {ret}",
|
||||
error=error_detail or f"script exited with code {ret}",
|
||||
elapsed_seconds=round(elapsed, 3),
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ class JobEvent(BaseModel):
|
||||
progress_percent: int = Field(ge=0, le=100)
|
||||
message: str
|
||||
timestamp: datetime
|
||||
elapsed_seconds: float | None = None
|
||||
|
||||
|
||||
class JobStatus(BaseModel):
|
||||
@@ -29,6 +30,7 @@ class JobStatus(BaseModel):
|
||||
updated_at: datetime
|
||||
artifacts: dict[str, str]
|
||||
error: str = ""
|
||||
elapsed_seconds: float | None = None
|
||||
|
||||
|
||||
class JobDetail(BaseModel):
|
||||
@@ -44,6 +46,7 @@ class JobResult(BaseModel):
|
||||
svg_stroke_url: str = ""
|
||||
db_url: str = ""
|
||||
metrics_url: str = ""
|
||||
elapsed_seconds: float | None = None
|
||||
|
||||
|
||||
class WordLocation(BaseModel):
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .schemas import JobPaths
|
||||
|
||||
|
||||
_JOB_ID_RE = re.compile(r"^[0-9a-f]{32,}$")
|
||||
|
||||
|
||||
class Storage:
|
||||
def __init__(self, base_dir: Path) -> None:
|
||||
self.base_dir = base_dir
|
||||
@@ -25,3 +32,70 @@ class Storage:
|
||||
excel_path=input_dir / "names.xlsx",
|
||||
config_path=root / "config.json",
|
||||
)
|
||||
|
||||
def job_root(self, job_id: str) -> Path:
|
||||
return self.base_dir / job_id
|
||||
|
||||
def job_dir_size(self, job_id: str) -> int:
|
||||
root = self.job_root(job_id)
|
||||
if not root.exists():
|
||||
return 0
|
||||
total = 0
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
for filename in filenames:
|
||||
try:
|
||||
total += os.path.getsize(os.path.join(dirpath, filename))
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
def job_dir_info(self, job_id: str) -> dict | None:
|
||||
root = self.job_root(job_id)
|
||||
if not root.is_dir():
|
||||
return None
|
||||
try:
|
||||
mtime = root.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"path": str(root),
|
||||
"size_bytes": self.job_dir_size(job_id),
|
||||
"age_days": round(max(0.0, time.time() - mtime) / 86400.0, 2),
|
||||
}
|
||||
|
||||
def list_job_ids(self) -> list[str]:
|
||||
if not self.base_dir.exists():
|
||||
return []
|
||||
return [
|
||||
item.name
|
||||
for item in self.base_dir.iterdir()
|
||||
if item.is_dir() and _JOB_ID_RE.match(item.name)
|
||||
]
|
||||
|
||||
def stale_job_dirs(
|
||||
self,
|
||||
referenced_job_ids: set[str],
|
||||
exclude_job_ids: set[str] | None = None,
|
||||
max_age_days: float | None = None,
|
||||
) -> list[dict]:
|
||||
exclude = exclude_job_ids or set()
|
||||
result: list[dict] = []
|
||||
for job_id in self.list_job_ids():
|
||||
if job_id in referenced_job_ids or job_id in exclude:
|
||||
continue
|
||||
info = self.job_dir_info(job_id)
|
||||
if not info:
|
||||
continue
|
||||
if max_age_days is not None and info["age_days"] < max_age_days:
|
||||
continue
|
||||
result.append(info)
|
||||
return sorted(result, key=lambda item: item["size_bytes"], reverse=True)
|
||||
|
||||
def remove_job_dir(self, job_id: str) -> int:
|
||||
root = self.job_root(job_id)
|
||||
if not root.exists():
|
||||
return 0
|
||||
size = self.job_dir_size(job_id)
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
return size
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect and optionally clean stale job directories.
|
||||
|
||||
Default mode is a safe dry-run that reports reclaimable bytes. Pass --apply to
|
||||
actually remove unreferenced job directories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .metadata_store import MetadataStore
|
||||
from .storage import Storage
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
PROJECT_ROOT = BACKEND_ROOT
|
||||
WORKSPACE_DIR = PROJECT_ROOT / "service_workspace"
|
||||
ASSETS_DIR = PROJECT_ROOT / "service_assets"
|
||||
METADATA_DIR = PROJECT_ROOT / "service_metadata"
|
||||
|
||||
|
||||
def read_asset_meta(path: Path) -> dict:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def referenced_job_ids() -> set[str]:
|
||||
refs: set[str] = set()
|
||||
for meta_path in ASSETS_DIR.glob("*/*/meta.json"):
|
||||
job_id = read_asset_meta(meta_path).get("job_id") or ""
|
||||
if job_id:
|
||||
refs.add(job_id)
|
||||
return refs
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--max-age-days", type=float, default=0)
|
||||
parser.add_argument("--apply", action="store_true", help="Actually delete stale job directories")
|
||||
parser.add_argument("--json", type=Path, default=None, help="Write JSON report")
|
||||
args = parser.parse_args()
|
||||
|
||||
store = MetadataStore(METADATA_DIR / "app.db")
|
||||
storage = Storage(WORKSPACE_DIR)
|
||||
known_job_ids = store.job_ids() if store.db_path.exists() else set()
|
||||
referenced = referenced_job_ids()
|
||||
stale = storage.stale_job_dirs(
|
||||
referenced_job_ids=referenced,
|
||||
exclude_job_ids=known_job_ids,
|
||||
max_age_days=args.max_age_days,
|
||||
)
|
||||
|
||||
reclaimable = sum(item["size_bytes"] for item in stale)
|
||||
report = {
|
||||
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
||||
"job_dir_count": len(storage.list_job_ids()),
|
||||
"referenced_job_ids": len(referenced),
|
||||
"known_job_ids": len(known_job_ids),
|
||||
"stale_job_count": len(stale),
|
||||
"reclaimable_bytes": reclaimable,
|
||||
"max_age_days": args.max_age_days,
|
||||
"apply": args.apply,
|
||||
"stale_jobs": stale[:200],
|
||||
}
|
||||
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
if args.json:
|
||||
args.json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
if args.apply:
|
||||
freed = 0
|
||||
for item in stale:
|
||||
freed += storage.remove_job_dir(item["job_id"])
|
||||
store.delete_job(item["job_id"])
|
||||
print(f"freed_bytes={freed}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -53,6 +53,8 @@ class LayoutConstraintTests(unittest.TestCase):
|
||||
"SEED",
|
||||
"TARGET_FILL_RATIO",
|
||||
"WC_FONT_PATH",
|
||||
"AUTO_REPEAT_TO_FILL",
|
||||
"AUTO_REPEAT_MAX",
|
||||
)
|
||||
}
|
||||
config.WC_FONT_PATH = str(config.PROJECT_DEFAULT_FONT)
|
||||
@@ -66,6 +68,7 @@ class LayoutConstraintTests(unittest.TestCase):
|
||||
config.LAYOUT_SEED = 20260718
|
||||
config.SEED = 20260718
|
||||
config.TARGET_FILL_RATIO = 0.42
|
||||
config.AUTO_REPEAT_TO_FILL = False
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for key, value in self.saved.items():
|
||||
@@ -91,6 +94,13 @@ class LayoutConstraintTests(unittest.TestCase):
|
||||
self.assertEqual(len(layout), len(names))
|
||||
self.assertEqual(len({font_size for _, font_size, *_ in layout}), 1)
|
||||
|
||||
def test_auto_repeat_keeps_hd_layout_overlap_free(self) -> None:
|
||||
config.AUTO_REPEAT_TO_FILL = True
|
||||
config.AUTO_REPEAT_MAX = 2
|
||||
names, result = self.generate(40)
|
||||
self.assertGreaterEqual(len(result["hd_layout"]), len(names))
|
||||
self.assertEqual(result["hd_overlap_pixels"], 0)
|
||||
|
||||
def test_explicit_equal_min_max_is_exact(self) -> None:
|
||||
config.USER_MIN_FONT_SIZE = 12
|
||||
config.USER_MAX_FONT_SIZE = 12
|
||||
|
||||
@@ -185,6 +185,7 @@ def main() -> None:
|
||||
config.SEED = 20260718
|
||||
config.TARGET_FILL_RATIO = 0.45
|
||||
config.FONT_COLOR = "#102A43"
|
||||
config.AUTO_REPEAT_TO_FILL = False
|
||||
|
||||
results = [
|
||||
run_case(count, args.canvas, args.output_dir, args.max_growth_rounds)
|
||||
|
||||
+92
-15
@@ -12,7 +12,7 @@
|
||||
5. 估算字号范围 → weights.calculate_font_by_area_model()
|
||||
6. 小画布布局 → layout.OptimizedEfficientWordCloud.generate_from_frequencies()
|
||||
7. C++ 按真实字形找位置并原子写入 → IntegralGrid.place_glyph_exact()
|
||||
8. 整批未完整放入 → 统一缩放字号或扩大画布后重排
|
||||
8. 二分搜索能容纳全部姓名的最大字号缩放;仍放不下则扩大画布后重排
|
||||
9. 密度优化 → 探测更大字号并保留完整率不下降的候选
|
||||
10. 高清精修 → render.refine_layout_with_hd_clearance() 加入隔离带局部微调
|
||||
11. 输出 PNG / SVG / DB / metrics
|
||||
@@ -93,10 +93,15 @@ total_target = len(names) * N_REPETITIONS
|
||||
|
||||
## 放置策略
|
||||
|
||||
放置顺序按字号分档:大字号先随机撒开,其余再从中心螺旋填充。
|
||||
大字号需要整片空白才放得下,等螺旋填满画布就没有空间了,因此必须先放。
|
||||
档内按索引顺序放置,保证同一 `layout_seed` 完全可复现。
|
||||
|
||||
每个词的放置流程:
|
||||
|
||||
1. 根据目标分数得到目标字号(线性插值于 `min_font` 到 `max_font`)
|
||||
2. 随机决定横排或竖排(`prefer_horizontal` 控制概率)
|
||||
2. 逐词独立随机决定横排或竖排,竖排概率为 `VERTICAL_RATIO`(流水线以此换算 `prefer_horizontal = 1 - VERTICAL_RATIO`);
|
||||
当前竖排为整词旋转 90°(字符侧倒),不是字符直立的传统竖排
|
||||
3. 用 PIL 渲染真实字形 bitmap,施加单侧安全边距(margin)生成碰撞 mask
|
||||
4. 调用 C++ `place_glyph_exact()` 搜索合法位置并原子写入
|
||||
5. 当前方向找不到时,只尝试同字号的另一方向
|
||||
@@ -113,14 +118,33 @@ total_target = len(names) * N_REPETITIONS
|
||||
- `canvas`:真实占用像素(`1` 表示已占用或掩膜阻挡)
|
||||
- `data`:兼容旧矩形查询的积分图;当前主路径不依赖逐词重建
|
||||
|
||||
`place_glyph_exact()` 行为:
|
||||
`place_glyph_exact()` 有三种放置模式,由 `layout.py` 按字号分配:
|
||||
|
||||
1. 等字号批次前 `70%` 姓名优先选择靠近可填区域质心的合法位置,避免少量姓名接受第一个随机空位而形成大块空洞
|
||||
2. 多字号批次只对前 `25%` 以及高权重(`score ≥ 0.80`)姓名启用中心偏好
|
||||
3. 少于 `100` 人的等字号批次中心候选数提高到 `768`;`100–300` 人提高到 `512`;其余保持 `256`
|
||||
4. 随机探测失败后,从种子决定的偏移开始完整扫描
|
||||
5. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas`
|
||||
6. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成
|
||||
**大字号(`placement_mode=2`)**:字号达到 `min_font + (max_font - min_font) * 0.80` 的姓名先放,随机探测取第一个合法位置。
|
||||
探测受一个软半径约束:前 `75%` 次探测限制在质心周围 `0.55 → 1.0` 倍掩膜半径内,之后放开。
|
||||
约束只影响落点偏好,不会排除任何合法位置,因此不影响完整率。
|
||||
等字号批次中 `max_font == min_font`,不存在大字号档,全部走螺旋。
|
||||
|
||||
**小字号(`placement_mode=1`)**:先试可填区域质心,再沿费马螺旋(黄金角 `2.39996`,`radius = 1.25 * sqrt(step)`)向外搜索。
|
||||
`spiral_cursor` 在整批姓名间持续推进。每个词以随机相位 `theta_offset ∈ [0, 2π)` 起扫,
|
||||
使相邻两词的角距不再恒为黄金角;随机量取自按 `layout_seed` 派生的逐词种子,同一种子完全可复现,
|
||||
不同种子给出真正不同的排布,而不只是同一图形换名字。
|
||||
|
||||
相位必须取满整圈。曾尝试限制在 `±60°` 的窄扇区内,结果明显更差:
|
||||
扇区会让词跳过边界上已经合法的位置而退到更差的位置,实测 800 词的最终墨水密度下降 31%(`0.255 → 0.176`)。
|
||||
取满整圈则不损失密度,因为半径增长时扫描本来就会覆盖所有角度。
|
||||
|
||||
注意这并不会让排布显得无序:半径仍与放置顺序高度相关(Pearson `r ≈ 0.98`)。
|
||||
中心向外且保持紧密的填充必然按半径递增推进——「有序」与「紧密」是同一件事。
|
||||
要在不牺牲密度的前提下打散这种观感,需要多个螺旋原点,而不是在单一螺旋上加抖动。
|
||||
|
||||
**兼容模式(`placement_mode=0`)**:纯随机探测,取第一个合法位置。
|
||||
|
||||
三种模式共用后续步骤:
|
||||
|
||||
1. 随机探测失败后,从种子决定的偏移开始环形完整扫描,保证存在合法位置时一定能找到
|
||||
2. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas`
|
||||
3. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成
|
||||
|
||||
真实字形搜索允许透明角落和笔画间空隙安全交错,比外接矩形碰撞更密。安全边距只参与候选检查,不会被双侧累计放大。
|
||||
|
||||
@@ -132,18 +156,50 @@ total_target = len(names) * N_REPETITIONS
|
||||
|
||||
1. 小画布完整布局放大到高清
|
||||
2. 逐词在高清画布上验证,加入 `1px` 隔离带
|
||||
3. 碰撞时只允许最大 `max_shift=24px` 的局部微位移
|
||||
4. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前)
|
||||
5. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`)
|
||||
6. 全部候选无解时,由外层扩大画布后整批重新布局,不进行远距离单词搬移
|
||||
3. 碰撞时先在 `max_shift=24px` 内做局部微位移
|
||||
4. 邻域内无解时,`_find_free_placement()` 以逐步放大的窗口(`256 → 1024 → 全画布`)在整张画布上找空位,
|
||||
取离原位置最近的一个。窗口内用积分图筛选:足迹范围内完全空白的位置必定可放,
|
||||
无需逐像素比较,因此绝大多数候选位置只花两次加法就被排除。
|
||||
这一步把「一个词放不下就整条流水线换更大画布重来」变成一次局部搬移——
|
||||
后者是全流程中最贵的失败路径,实测会让端到端耗时翻倍且仍可能最终失败
|
||||
5. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前)
|
||||
6. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`)
|
||||
7. 全部候选无解时,才由外层扩大画布后整批重新布局
|
||||
|
||||
密度搜索会保留各档完整整批候选。最高密度候选若无法在有限位置修正范围内通过高清隔离验收,则改用上一档完整整批候选;不会对碰撞姓名单独缩字号,也不会接受带重叠的高密度结果。
|
||||
|
||||
## 字号缩放二分搜索
|
||||
|
||||
目标是找到**能放下全部姓名且轮廓覆盖最好的字号缩放**。单纯追求最大字号会让费马螺旋把词压在质心圆盘内、走不到掩膜远端,于是非圆形掩膜(心形尖端、人物四肢)填成圆形。
|
||||
|
||||
流水线用「探测」来判定某个缩放是否可行。探测在遇到第一个放不下的姓名时立刻停止:
|
||||
一个姓名只有在螺旋、随机探测和全画布穷举扫描都失败后才算放不下,所以单个失败即可证明该缩放不可行,
|
||||
不必把整批跑完。这一点对速度至关重要——放不下的姓名要付出完整搜索的代价,
|
||||
实测约为可放下姓名的 `10` 倍,把注定失败的整批跑到底是流水线中最昂贵的操作。
|
||||
`OptimizedEfficientWordCloud.max_failures` 控制这一行为,为 `None` 时跑满整批并尽量多放。
|
||||
|
||||
搜索过程:
|
||||
|
||||
1. 从 `scale=1.0` 开始探测;失败则按 `sqrt(已放置比例)` 收缩再试,最多 `4` 次
|
||||
2. 得到一个可行值后,在最大失败值与最小可行值之间二分最多 `3` 次,把之前收缩让掉的字号找回来
|
||||
3. 相邻两个缩放取整后字号相同时停止——工作网格上字号是小整数,再细分没有意义
|
||||
4. 多个可行结果中取**形状覆盖度最高**的那个(`compute_coverage_score`),覆盖度并列时才取缩放更大者
|
||||
|
||||
### 形状覆盖度
|
||||
|
||||
`compute_coverage_score()` 把可填区域切成 `8×8` 像素的块,只统计可填像素占比 ≥ 30% 的「区域块」,计算其中被墨迹触达的比例:
|
||||
|
||||
```
|
||||
coverage = 被触达的区域块数 / 区域块总数
|
||||
```
|
||||
|
||||
这是「轮廓是否被填出来」的直接度量:质心圆盘只触达中心几块,覆盖度低;铺进掩膜每个臂/尖端的布局触达各块,覆盖度高。块粒度(而非逐像素加权)让它对掩膜几何稳健——一个尖端无论宽 3px 还是 30px 都是一个块,触达它都被同等奖励。
|
||||
|
||||
## 填充率重试
|
||||
|
||||
一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率。
|
||||
一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率,`compute_coverage_score()` 计算形状覆盖度。
|
||||
|
||||
- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,只允许一次整批等比例增字号尝试。新布局必须仍然完整且真实填充率更高才会采用
|
||||
- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,进入**双向密度优化**:每轮同时探测「加大字号」和「减小字号」两个方向,取覆盖度更高的候选。减小字号让费马螺旋走更远、触达掩膜远端,即使填充率略降也能提升覆盖度——这正是纠正「填成圆形」的关键方向。覆盖度与填充率都无提升时停止
|
||||
- 整批未完整放入时,流水线不会输出半成品:先整批等比例调整字号;触及最小字号仍失败时按 `CANVAS_RETRY_GROWTH` 扩大画布并重新生成
|
||||
|
||||
## 字号硬约束
|
||||
@@ -176,3 +232,24 @@ new_w = clamp(base_w × scale_factor, max_edge=6000)
|
||||
- 扩展后宽高向上取整到最近的 `100`
|
||||
- 最大边长限制为 `6000px`,避免 SVG/PNG 过度膨胀
|
||||
- 掩膜只生成一次,扩展后复用
|
||||
|
||||
## 性能:按字符缓存
|
||||
|
||||
一份中文名单里不同**字符**的数量远小于不同**姓名**的数量——750 个三字姓名通常只含约 `34` 个不同字符。
|
||||
两处最重的工作因此按字符而不是按姓名缓存:
|
||||
|
||||
- **SVG 轮廓**(`layout._char_shape`):每个 `(字符, 字号, 方向)` 只取一次字形轮廓并格式化一次路径字符串。
|
||||
一个姓名由若干字符路径拼成,字符在词内的位置放进元素的 `transform` 平移量,
|
||||
所以缓存的路径字符串被逐字节复用,不需要重新解析或平移坐标。
|
||||
导出时每个字形输出一个 `<path>`,几何结果与整词单路径完全一致(已逐点验证)。
|
||||
- **高清字形位图**(`render._word_ink`):隔离精修与独立的重叠审计会在相同字号下光栅化相同姓名,
|
||||
两者共用一份缓存。
|
||||
|
||||
`_path_bbox()` 只在每个字符首次构建时调用一次,词的包围盒由各字符包围盒平移后取并集算出,
|
||||
不再对生成好的路径字符串做正则重解析。
|
||||
|
||||
## 零重叠保证
|
||||
|
||||
输出前 `count_layout_overlap_pixels()` 会独立重渲染整个 layout 并统计被两个及以上词占用的像素。
|
||||
该值必须为 `0`,否则 `placement_ok` 为假,流水线拒绝输出而不是交付带重叠的结果。
|
||||
这项校验独立于隔离精修,即使精修逻辑有误也能兜住。
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# 画布模板导入导出包(`.wcd`)方案(规划)
|
||||
|
||||
> 状态:第一版已实现(画布导出 `.wcd`、首页导入 `.wcd`)。
|
||||
> 目的:实现画布/设计的完整导入导出,要求包内不仅包含画布尺寸、背景、图层、元素摆放信息,还要把元素用到的素材一起带出去,使得包可以在另一台机器或另一个实例中导入复用。
|
||||
|
||||
## 1. 包格式
|
||||
|
||||
建议采用 Zip 包,文件后缀为 `.wcd`。没有必要自定义二进制格式。
|
||||
|
||||
预期目录结构:
|
||||
|
||||
```
|
||||
example.wcd
|
||||
├── manifest.json // 包元数据与 schema version
|
||||
├── document.json // CanvasDocument:画布结构
|
||||
├── preview.png // 可选封面图
|
||||
├── fonts/ // 可选字体文件
|
||||
│ └── ...
|
||||
└── assets/
|
||||
├── asset-001.svg
|
||||
├── asset-002.png
|
||||
└── asset-003.svg
|
||||
```
|
||||
|
||||
## 2. manifest.json
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "wordcloud-canvas",
|
||||
"version": 1,
|
||||
"name": "海报模板",
|
||||
"description": "示例模板",
|
||||
"createdAt": "2026-08-06T00:00:00Z",
|
||||
"canvas": {
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"background": "#ffffff"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "asset-001",
|
||||
"originalAssetId": "asset_xxx",
|
||||
"name": "词云 A",
|
||||
"type": "svg",
|
||||
"mimeType": "image/svg+xml",
|
||||
"sha256": "abc...",
|
||||
"size": 1024
|
||||
}
|
||||
],
|
||||
"fonts": []
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `format`:固定标识,防止其他 Zip 被误导入。
|
||||
- `version`:包格式版本,后续升级时便于兼容。
|
||||
- `assets[].id`:包内临时 ID,只在这个包内有效。
|
||||
- `originalAssetId`:导出时的来源素材 ID,仅记录,不要求导入后保留。
|
||||
- `sha256`:可选,导入时用于去重。
|
||||
|
||||
## 3. document.json
|
||||
|
||||
`document.json` 就是当前前端的 `CanvasDocument` 模型:
|
||||
|
||||
- `width`:画布宽度。
|
||||
- `height`:画布高度。
|
||||
- `background`:画布背景色。
|
||||
- `layers`:图层列表。
|
||||
- `layerFolders`:图层文件夹列表。
|
||||
- `elements`:元素列表。
|
||||
|
||||
对 Sticker 元素有一个关键规则:**导出时把 `assetId` 替换成包内临时 ID**。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"background": "#ffffff",
|
||||
"layers": [
|
||||
{ "id": "layer-1", "name": "词云", "visible": true, "locked": false }
|
||||
],
|
||||
"layerFolders": [],
|
||||
"elements": [
|
||||
{
|
||||
"id": "element-1",
|
||||
"type": "sticker",
|
||||
"assetId": "asset-001",
|
||||
"x": 100,
|
||||
"y": 80,
|
||||
"width": 800,
|
||||
"height": 500,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 导出流程
|
||||
|
||||
建议由后端提供导出接口,例如:
|
||||
|
||||
```text
|
||||
GET /api/designs/{id}/export
|
||||
```
|
||||
|
||||
或当前模板库扩展为:
|
||||
|
||||
```text
|
||||
GET /api/design-templates/{id}/export
|
||||
```
|
||||
|
||||
导出步骤:
|
||||
|
||||
1. 从数据库读取画布文档 `design_documents`。
|
||||
2. 序列化 `CanvasDocument`。
|
||||
3. 遍历 Sticker 元素,收集所有真实素材 ID。
|
||||
4. 读取每个素材文件字节。
|
||||
5. 为每个素材生成包内 ID,例如 `asset-001`。
|
||||
6. 用包内 ID 替换 `document.json` 中的 `assetId`。
|
||||
7. 把素材写入 `assets/` 目录。
|
||||
8. 可选:生成 `preview.png` 作为导入时的缩略图。
|
||||
9. 可选:如果模板使用了后端字体,把字体文件写入 `fonts/`。
|
||||
10. 生成 `manifest.json`。
|
||||
11. 打包为 `.wcd` 并返回。
|
||||
|
||||
## 5. 导入流程
|
||||
|
||||
建议后端提供导入接口,例如:
|
||||
|
||||
```text
|
||||
POST /api/designs/import
|
||||
multipart/form-data: file=.wcd
|
||||
```
|
||||
|
||||
导入步骤:
|
||||
|
||||
1. 把 `.wcd` 解压到临时目录。
|
||||
2. 校验 `manifest.json`:
|
||||
- 是否是 `wordcloud-canvas` 格式。
|
||||
- `version` 是否兼容。
|
||||
- `document.json` 是否结构合法。
|
||||
3. 读取 `document.json` 并通过现有 `normalizeDocument` 逻辑归一化。
|
||||
4. 逐个处理 `assets/` 下素材:
|
||||
- 计算 SHA-256。
|
||||
- 如果素材表中已有相同 SHA-256,复用已有素材 ID。
|
||||
- 否则调用素材导入逻辑写入 `assets` 表 + 文件系统。
|
||||
5. 把 `document.json` 中的包内 `assetId` 重新映射为真实素材 ID。
|
||||
6. 保存为新的 `design_documents`。
|
||||
7. 可选:把 `preview.png` 作为模板封面。
|
||||
8. 返回新设计/模板 ID。
|
||||
|
||||
## 6. 与现有模板系统的关系
|
||||
|
||||
当前模板保存是把 `CanvasDocument` 和 `reference_asset_ids` 写到目录 JSON 里:
|
||||
|
||||
- 在线模板:保持后端素材引用,适合当前实例内复用。
|
||||
- `.wcd`:把素材一起打包,适合跨机器/离线/换实例导入导出。
|
||||
|
||||
两者最终统一到:
|
||||
|
||||
- `design_documents`:存画布文档。
|
||||
- `assets`:存素材元数据。
|
||||
- `design_templates`:存模板元数据并引用素材。
|
||||
|
||||
`.wcd` 只是外部交换容器。
|
||||
|
||||
## 7. 边界与设计决策
|
||||
|
||||
### 先不做自定义二进制格式
|
||||
|
||||
Zip + JSON 足够,方便调试、校验和后续扩展。
|
||||
|
||||
### 不把素材 base64 塞进 document.json
|
||||
|
||||
素材单独放文件,避免 JSON 膨胀;`document.json` 只保存引用 ID。
|
||||
|
||||
### 素材缺失处理
|
||||
|
||||
导出时如果某个素材文件缺失,可以选择:
|
||||
|
||||
- 导出失败并提示哪个素材缺失。
|
||||
- 或在 `manifest` 中标记为 `missing`,导入时提示并跳过。
|
||||
|
||||
建议第一版采用“导出失败并提示”,保证导入包完整。
|
||||
|
||||
### 字体处理
|
||||
|
||||
第一版建议只保留 `fontFamily` 字符串,不打包字体。
|
||||
|
||||
后续如果确实需要跨机器还原,再把字体文件放进 `fonts/`,导入时注册到字体库。
|
||||
|
||||
### 去重
|
||||
|
||||
`assets.sha256` 是导入去重的关键字段:
|
||||
|
||||
- 包内相同素材只存一次。
|
||||
- 多次导入相同素材时直接复用数据库中的现有素材。
|
||||
|
||||
## 8. 分阶段实施
|
||||
|
||||
### 阶段一:最小可用包(已完成)
|
||||
|
||||
- 定义 `.wcd`,包含 `manifest.json`、`document.json`、`assets/`。
|
||||
- 支持导出当前画布或模板。
|
||||
- 支持解压导入,只处理贴纸素材和画布布局。
|
||||
- 不处理字体,不生成 preview。
|
||||
|
||||
### 阶段二:导入体验完善
|
||||
|
||||
- 生成 `preview.png`。
|
||||
- 导入时检查素材缺失。
|
||||
- 支持同一设计重复导入去重。
|
||||
|
||||
### 阶段三:与 PostgreSQL 打通
|
||||
|
||||
- `POST /api/designs/import` 最终写入 `design_documents`。
|
||||
- 素材导入自动写入 `assets` 表。
|
||||
- 导出接口直接读取 `design_documents` 和 `assets`,不再依赖前端状态。
|
||||
|
||||
## 9. 相关文件参考
|
||||
|
||||
- `frontend/src/lib/svgExport.ts`:当前图层 ZIP 导出。
|
||||
- `frontend/src/lib/templateLibrary.ts`:当前模板保存/读取。
|
||||
- `frontend/src/lib/canvasDocument.ts`:`CanvasDocument` 模型与归一化。
|
||||
- `frontend/src/types.ts`:`CanvasDocument`、`StickerAsset` 等类型。
|
||||
- `backend/service/app.py`:当前 `/api/assets`、`/api/design-templates` 接口。
|
||||
- `docs/DESIGN_DATA_STORAGE_PLAN.md`:存储层重构后文档落库设计。
|
||||
@@ -34,6 +34,7 @@ backend/service_workspace/{job_id}/config.json
|
||||
| `strokeWeights` | `ENABLE_STROKE_WEIGHTS` |
|
||||
| `sizeRatio` | `SIZE_RATIO` |
|
||||
| `packingEfficiency` | `PACKING_EFFICIENCY` |
|
||||
| `verticalRatio` | `VERTICAL_RATIO` |
|
||||
| `targetFillRatio` | `TARGET_FILL_RATIO` |
|
||||
| `userMinFontSize` | `USER_MIN_FONT_SIZE` |
|
||||
| `userMaxFontSize` | `USER_MAX_FONT_SIZE` |
|
||||
@@ -92,6 +93,7 @@ backend/service_workspace/{job_id}/config.json
|
||||
| `TARGET_FILL_RATIO` | `0.45` | 面积模型目标笔画填充率 |
|
||||
| `SIZE_RATIO` | `2.0` | `max_font` 相对 `min_font` 的比例;`1.0` 为等字号模式 |
|
||||
| `PACKING_EFFICIENCY` | `0.9` | 面积模型中的打包效率 |
|
||||
| `VERTICAL_RATIO` | `0.18` | 竖排概率,逐词独立抽取;`0.0` 全部横排,`1.0` 全部竖排 |
|
||||
|
||||
### 字号硬约束
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# 业务数据存储优化与指标报告
|
||||
|
||||
> 状态:已完成第一阶段可审计代码改造;未执行破坏性清理。
|
||||
> 目标:减少无效任务文件、把任务元数据从“内存 + 散落目录 JSON”提升为“可恢复元数据存储”,并为后续 PostgreSQL 迁移留出接口。
|
||||
|
||||
## 1. 优化前现状
|
||||
|
||||
### 任务存储
|
||||
|
||||
- 任务状态 / 事件只存在于 `JobManager._jobs` 内存中,服务重启即丢失。
|
||||
- `POST /api/jobs` 每次提交都会立刻创建 `service_workspace/{job_id}/input`、`output` 和配置文件。
|
||||
- 任务完成后没有 TTL / 引用检查清理逻辑;历史任务目录会一直留在磁盘。
|
||||
- `service_workspace` 中大量任务目录只是“曾经跑过一次”的产物,没有被任何素材或模板引用。
|
||||
|
||||
### 素材存储
|
||||
|
||||
- 素材文件保留在 `service_assets/{asset_id}/asset.*`,元数据写在目录内 `meta.json`。
|
||||
- 普通的 `POST /api/assets` 没有写入 `sha256`,只有 `.wcd` 导入路径开始做内容去重。
|
||||
- 前端贴纸 `tint` 仍放在 localStorage,没有回到服务端统一维护。
|
||||
|
||||
### 当前实际磁盘基线(2026-08-06 扫描)
|
||||
|
||||
| 项 | 数量 / 大小 |
|
||||
|---|---|
|
||||
| `service_workspace` 任务目录 | 193 个 |
|
||||
| `service_workspace` 总大小 | 2.44 GiB / 2,618,726,669 bytes |
|
||||
| 被素材 `job_id` 引用的任务目录 | 6 个 |
|
||||
| 未被任何素材引用的任务目录 | 187 个 |
|
||||
| 未引用任务目录总大小 | 2.38 GiB / 2,556,506,642 bytes |
|
||||
| 素材文件 | 40 个,合计约 95.6 MiB |
|
||||
| 素材中重复内容多占空间 | 6 个额外文件,约 2.34 MiB |
|
||||
| 设计模板 JSON | 4 个 |
|
||||
|
||||
## 2. 优化方案与已落地改动
|
||||
|
||||
### 1) 新增业务元数据存储层
|
||||
|
||||
新增 `backend/service/metadata_store.py`:
|
||||
|
||||
- SQLite 单文件 `backend/service_metadata/app.db`。
|
||||
- 建 `jobs` 和 `job_events` 两张表。
|
||||
- 任务创建、状态更新、产物路径、SSE 事件都会落库。
|
||||
- `JobManager` 启动时可以从数据库恢复任务,不再完全依赖内存。
|
||||
- 表结构有意保持“一行元数据 + JSONB/JSON 字段”风格,后续迁移到 PostgreSQL 时主体字段不变。
|
||||
|
||||
### 2) 任务目录可审计与可清理
|
||||
|
||||
扩展 `backend/service/storage.py`:
|
||||
|
||||
- `job_dir_size()` / `job_dir_info()`:按任务统计占用。
|
||||
- `stale_job_dirs()`:按“未被素材引用、不在元数据库、可选按年龄”筛选遗留目录。
|
||||
- `remove_job_dir()`:提供精确清理,只清理 `service_workspace` 下的任务目录。
|
||||
|
||||
新增 `backend/service/storage_metrics.py`:
|
||||
|
||||
- 默认 `dry-run`,只扫描并输出可回收空间。
|
||||
- 只有显式 `--apply` 才会删除遗留任务目录。
|
||||
- 示例:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/python -m service.storage_metrics --max-age-days 0
|
||||
.venv/bin/python -m service.storage_metrics --max-age-days 0 --json ../docs/storage-metrics.json
|
||||
# 确认后执行
|
||||
.venv/bin/python -m service.storage_metrics --max-age-days 0 --apply
|
||||
```
|
||||
|
||||
### 3) 素材去重基础
|
||||
|
||||
- `.wcd` 导入路径已按 `sha256` 去重素材。
|
||||
- `service_assets/{id}/meta.json` 中新增 `sha256` 字段。
|
||||
- 后续 `POST /api/assets` 也可以统一补充哈希,形成服务级去重。
|
||||
|
||||
### 4) 可实时查询指标
|
||||
|
||||
新增只读接口:
|
||||
|
||||
```text
|
||||
GET /api/maintenance/storage-summary
|
||||
```
|
||||
|
||||
返回指标包括:
|
||||
|
||||
- `job_dir_count`:当前任务目录数。
|
||||
- `referenced_job_ids`:被素材引用的任务数。
|
||||
- `stale_job_count`:可回收任务数。
|
||||
- `reclaimable_bytes`:可回收字节数。
|
||||
- `jobs_in_db` / `events_in_db`:当前元数据分录数。
|
||||
- `dry_run_only`: `true`,明确该接口不做删除。
|
||||
|
||||
## 3. 优化后指标
|
||||
|
||||
### 空间收益(只做审计,未执行删除)
|
||||
|
||||
| 指标 | 优化前 | 优化后可清理 | 优化后保留 |
|
||||
|---|---:|---:|---:|
|
||||
| 任务目录 | 193 | 187 | 6(被素材引用) |
|
||||
| 任务文件占用 | 2.44 GiB | 2.38 GiB | ~59.3 MiB |
|
||||
| 任务空间占用 | 100% | 可回收 97.6% | 首个保护区约 2.4% |
|
||||
|
||||
换算:
|
||||
|
||||
- 2,556,506,642 bytes ≈ 2.38 GiB。
|
||||
- 若执行清理,仅任务目录可释放约 **2.38 GiB**。
|
||||
- 清理后任务目录可降到约 **59.3 MiB**,即保留的部分仍是当前贴纸真正引用的任务产物。
|
||||
|
||||
### 素材去重收益
|
||||
|
||||
当前 40 个素材文件中有 6 个属于重复内容,去重后:
|
||||
|
||||
- 少存 6 个文件。
|
||||
- 可节省 2,455,630 bytes,约 **2.34 MiB**。
|
||||
- 素材文件数量从 40 → 34 个唯一内容。
|
||||
|
||||
这个数字目前不大,因为很多重复不到 100KB;真正大头仍是任务目录。
|
||||
|
||||
### 效能与可维护性收益
|
||||
|
||||
1. **任务可恢复**
|
||||
- 原来重启服务后任务状态、进度、事件全部丢失。
|
||||
- 现在 `jobs` / `job_events` 落库,启动时可恢复。
|
||||
|
||||
2. **查询由全盘扫描变为索引查询**
|
||||
- 原来 `list_jobs` 只读内存;任务详情依赖内存里的事件列表。
|
||||
- 现在有持久化事件表和 `job_id` 索引,可追溯历史。
|
||||
|
||||
3. **清理依据可计算**
|
||||
- 原来“哪个目录能删”靠人工判断。
|
||||
- 现在可统计“是否被素材引用、是否在元数据库、目录多老”,避免误删正在使用的任务。
|
||||
|
||||
4. **存储成本上限可控**
|
||||
- 配合 TTL 清理,后续每新增任务产生的产物会在保留期后被回收。
|
||||
- 不会继续无限制累积。
|
||||
|
||||
## 4. 建议后续执行步骤
|
||||
|
||||
1. 确认当前项目不再需要 187 个旧任务产物后,执行:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/python -m service.storage_metrics --max-age-days 0 --apply
|
||||
```
|
||||
|
||||
2. 把 `metadata_store.py` 从 SQLite 迁移到 PostgreSQL:
|
||||
|
||||
- 安装 SQLAlchemy / asyncpg。
|
||||
- `docker-compose.yml` 增加 PostgreSQL 服务。
|
||||
- 将 `jobs` / `job_events` / `assets` / `design_documents` 迁到 PG。
|
||||
|
||||
3. 把贴纸 `tint` 从 localStorage 迁到 `assets` 元数据,并由后端 `PATCH /api/assets/{id}` 维护。
|
||||
|
||||
4. `POST /api/assets` 统一补 `sha256`,实现服务级素材去重。
|
||||
|
||||
5. 增加后台定时清理任务,例如保留 7 天、30 天两档。
|
||||
|
||||
## 5. 相关文件
|
||||
|
||||
- `backend/service/metadata_store.py`
|
||||
- `backend/service/job_manager.py`
|
||||
- `backend/service/storage.py`
|
||||
- `backend/service/storage_metrics.py`
|
||||
- `backend/service/app.py`
|
||||
- `docs/DESIGN_DATA_STORAGE_PLAN.md`
|
||||
@@ -0,0 +1,173 @@
|
||||
# 存储与数据库重构方案(规划)
|
||||
|
||||
> 状态:第一阶段部分已落地(任务元数据落 SQLite、任务清理审计、素材 sha256)。
|
||||
> 目的:后端当前仍以“内存 + 文件 + 任务级 SQLite + meta.json”为主要存储方式。本文档规划后续迁移到 PostgreSQL 元数据库,并优化任务生命周期和贴纸持久化。
|
||||
|
||||
## 1. 当前现状
|
||||
|
||||
| 数据 | 当前存储 | 问题 |
|
||||
|---|---|---|
|
||||
| 任务状态 / 事件 | `JobManager._jobs` 仅存内存 | 重启服务后任务记录丢失 |
|
||||
| 任务输入 / 输出 | `backend/service_workspace/{job_id}` | 提交任务即建目录,未完成或被放弃的任务会遗留文件 |
|
||||
| 词云坐标结果 | 每个任务生成一个 `word_locations.db`(SQLite) | 每个任务自带一份 SQLite 文件,查询分散 |
|
||||
| 贴纸素材 | `backend/service_assets/asset_xxx/asset.svg` + `meta.json` | 素材元数据不是数据库,tint 等前端信息还依赖 localStorage |
|
||||
| 设计模板 / 工程 | `service_design_templates` / `service_projects` 目录 + JSON | 模板和工程之间缺少数据库关联 |
|
||||
| 画布文档 | 前端 localStorage | 无法跨设备,也无法作为后端权威数据 |
|
||||
|
||||
注意:当前不能认为系统已经在使用 PostgreSQL。代码中出现的 `*.db` 是词云算法自己写的 SQLite 结果文件,例如 `backend/core/pipeline.py` 的 `word_locations` 表。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
整体原则:
|
||||
|
||||
- **文件继续存文件系统或对象存储**(SVG / PNG / 遮罩 / Excel / 字体)。
|
||||
- **业务元数据和引用关系存 PostgreSQL**。
|
||||
- 数据库保存路径引用,不保存大文件内容。
|
||||
|
||||
目标模型:
|
||||
|
||||
| 表 | 用途 | 说明 |
|
||||
|---|---|---|
|
||||
| `jobs` | 任务主表 | job_id、状态、参数 JSONB、产物引用、创建时间 |
|
||||
| `job_events` | 任务进度事件 | SSE 进度事件落库,服务重启后可恢复 |
|
||||
| `assets` | 贴纸 / 素材表 | 素材元数据、文件路径、来源 job、sha256、tint |
|
||||
| `design_documents` | 画布文档 | CanvasDocument JSONB,绑定模板/工程 |
|
||||
| `design_templates` | 模板 | 模板元数据 + 画布文档引用 |
|
||||
| `projects` | 工程 | 模板 + 画布文档 + 素材引用 |
|
||||
|
||||
### jobs 表字段建议
|
||||
|
||||
```text
|
||||
id uuid pk
|
||||
status text -- submitted/running/success/failed/cancelled
|
||||
stage text
|
||||
progress int
|
||||
message text
|
||||
params jsonb -- 用户提交的词云参数
|
||||
input_files jsonb -- mask/excel/font 引用
|
||||
artifacts jsonb -- png/svg/svg_stroke/db/metrics 路径或文件 id
|
||||
error text
|
||||
created_at timestamptz
|
||||
updated_at timestamptz
|
||||
retention_until timestamptz -- 清理时间
|
||||
```
|
||||
|
||||
### assets 表字段建议
|
||||
|
||||
```text
|
||||
id uuid pk
|
||||
name text
|
||||
type text -- wordcloud / upload / shape / reference
|
||||
mime_type text
|
||||
storage_key text -- 文件系统路径或对象存储 key
|
||||
width int
|
||||
height int
|
||||
file_size bigint
|
||||
sha256 text -- 用于导入去重
|
||||
source_job_id uuid nullable
|
||||
tint text nullable
|
||||
created_at timestamptz
|
||||
deleted_at timestamptz nullable
|
||||
```
|
||||
|
||||
## 3. 任务存储链路
|
||||
|
||||
现状是 `POST /api/jobs` 提交时直接创建 job 目录和保存上传文件。
|
||||
|
||||
目标改动:
|
||||
|
||||
1. `POST /api/jobs` 只写 `jobs` 表,状态为 `submitted` 或 `queued`。
|
||||
2. 上传文件先落到临时上传区,或延迟到进入 runner 前再落盘。
|
||||
3. runner 真正开始时才创建任务的 `input/` 和 `output/` 目录。
|
||||
4. 任务完成后把产物路径/文件 id 写入 `jobs.artifacts`。
|
||||
5. 增加后台清理任务:
|
||||
- 清理 `completed` 且未被贴纸/工程引用的任务文件。
|
||||
- 支持按 `retention_until` 保留最近结果。
|
||||
- 被用户导入为贴纸的任务文件可延长保留时间。
|
||||
|
||||
这样不会每次申请都攒下一堆用不上的目录和文件。
|
||||
|
||||
## 4. 贴纸持久化
|
||||
|
||||
贴纸在当前 `frontend/src/lib/stickerLibrary.ts` 中已经走后端 `POST /api/assets`,但元数据仍写在 `meta.json`,tint 还保存在 localStorage。
|
||||
|
||||
目标改动:
|
||||
|
||||
- `assets` 表作为贴纸唯一权威来源。
|
||||
- `POST /api/assets`:写文件系统 + 写 `assets` 表,返回 `asset_id`。
|
||||
- `GET /api/assets`:从数据库读取列表。
|
||||
- `PATCH /api/assets/{id}`:更新 tint、name 等元数据。
|
||||
- `DELETE /api/assets/{id}`:物理删除文件 + 记录,或软删除防止破坏设计文档引用。
|
||||
- `POST /api/assets/from-job/{job_id}`:沿用同一逻辑,写入 `source_job_id`。
|
||||
- 前端不再依赖 localStorage 保存贴纸 tint,加载和更新都走 API。
|
||||
|
||||
## 5. 画布文档与模板
|
||||
|
||||
当前画布保存在 localStorage,模板保存成目录 JSON。
|
||||
|
||||
目标改动:
|
||||
|
||||
- `design_documents` 保存 `CanvasDocument` JSONB。
|
||||
- 画布每次保存调用 `PUT /api/documents/{id}`。
|
||||
- `design_templates` 引用 `design_documents`,同时记录 `reference_asset_ids` 和封面图。
|
||||
- 后续实现画布导出导入时,导入包可直接写入 `design_documents`,并把包内素材批量写入 `assets` 表。
|
||||
|
||||
## 6. PostgreSQL 接入方式
|
||||
|
||||
建议:
|
||||
|
||||
- 引入 SQLAlchemy(或 asyncpg)作为数据库访问层。
|
||||
- 使用 Alembic 管理 migration。
|
||||
- 在 `docker-compose.yml` 增加 PostgreSQL 服务。
|
||||
- 通过环境变量注入 `DATABASE_URL`,本地开发和 Docker 使用不同配置。
|
||||
- 暂不把词云算法的 `word_locations` 表强制迁移到 PostgreSQL,可以保留 SQLite 作为任务内部产物,再通过导出接口把需要的布局结果写入 `jobs` 或独立布局表中。
|
||||
|
||||
## 7. 分阶段实施
|
||||
|
||||
### 阶段一:接入 PostgreSQL,先做贴纸和任务元数据(任务元数据已用 SQLite 先行落地)
|
||||
|
||||
- 建 `assets` / `jobs` / `job_events` 表。
|
||||
- `assets` 接口从文件 meta 迁移到 DB。
|
||||
- 提交任务仍可使用现有 runner,但把任务状态写入 DB。
|
||||
- 不改动词云算法核心。
|
||||
|
||||
### 阶段二:任务生命周期优化(清理审计已落地)
|
||||
|
||||
- `POST /api/jobs` 只记账,不提前建目录。
|
||||
- runner 开始前再落 input/output。
|
||||
- 增加 TTL 清理任务。
|
||||
- 任务列表、任务详情改为从 DB 查询。
|
||||
|
||||
### 阶段三:画布文档和导入包
|
||||
|
||||
- 建 `design_documents` / `design_templates` / `projects` 表。
|
||||
- 画布保存从 localStorage 改为后端文档接口。
|
||||
- `wcd` 导入导出包直接对接这些表。
|
||||
|
||||
## 8. 风险与注意点
|
||||
|
||||
- 现有任务接口依赖内存中的 `JobManager`,迁到 DB 后需要兼容 SSE 进度事件。
|
||||
- 文件迁移只能做增量:老素材目录可先保留,新写入走 DB。
|
||||
- 删除素材要检查 `design_documents` 引用,避免出现缺失贴纸。
|
||||
- tint 从前端 localStorage 迁移到 DB 时,需要兼容旧浏览器状态。
|
||||
|
||||
## 10. 已落地实现
|
||||
|
||||
- `backend/service/metadata_store.py`:SQLite 元数据 `jobs` / `job_events`。
|
||||
- `backend/service/job_manager.py`:任务状态和事件落库,服务重启可恢复。
|
||||
- `backend/service/storage.py`:任务目录占用、过期审计、可清理能力。
|
||||
- `backend/service/storage_metrics.py`:dry-run 指标和显式 `--apply` 清理。
|
||||
- `backend/service/app.py`:`GET /api/maintenance/storage-summary`。
|
||||
- `docs/DATA_STORAGE_OPTIMIZATION.md`:完整空间/效能指标。
|
||||
|
||||
> 注:当前项目没有接入 PostgreSQL。代码里的 `*.db` 是词云算法自己的 SQLite 结果文件;新加的 `service_metadata/app.db` 是业务元数据先行层。`jobs` / `job_events` 表结构设计上可平滑迁移到 PostgreSQL。
|
||||
|
||||
## 9. 相关文件参考
|
||||
|
||||
- `backend/service/app.py`:目前的任务、素材、模板 API。
|
||||
- `backend/service/job_manager.py`:内存中的任务状态。
|
||||
- `backend/service/storage.py`:任务目录创建。
|
||||
- `backend/service/runner.py`:任务运行与产物扫描。
|
||||
- `backend/core/pipeline.py`:词云结果 SQLite 写入。
|
||||
- `frontend/src/lib/stickerLibrary.ts`:前端贴纸库。
|
||||
- `docker-compose.yml`:服务编排,后续加 PostgreSQL。
|
||||
@@ -178,6 +178,12 @@ export default function AdvancedPanel({
|
||||
</div>
|
||||
</div>
|
||||
<Hint>名单较少时可增大重复次数(如 5~10)提升填充观感</Hint>
|
||||
<BoolField
|
||||
label="自动重复填充至轮廓完整"
|
||||
checked={params.autoRepeatToFill}
|
||||
onChange={v => onParamsChange({ autoRepeatToFill: v })}
|
||||
hint="开启后,当掩膜轮廓填不满时自动循环追加名字副本,直到形状轮廓填充完毕(最多 20 次)"
|
||||
/>
|
||||
|
||||
<div className="section-divider" />
|
||||
<SectionTitle>字号与比例</SectionTitle>
|
||||
@@ -198,8 +204,17 @@ export default function AdvancedPanel({
|
||||
step={0.01}
|
||||
onChange={v => onParamsChange({ packingEfficiency: v ?? 0.9 })}
|
||||
/>
|
||||
<NumberField
|
||||
label="竖排概率 VERTICAL_RATIO"
|
||||
value={params.verticalRatio}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => onParamsChange({ verticalRatio: v ?? 0.18 })}
|
||||
/>
|
||||
</div>
|
||||
<Hint>SIZE_RATIO 控制最大与最小字号跨度,默认 2.0;过大会出现极端字号差</Hint>
|
||||
<Hint>VERTICAL_RATIO 为每个词竖排的概率,默认 0.18;横竖混排可打散过于规整的观感</Hint>
|
||||
|
||||
<div className="form-row">
|
||||
<NumberField
|
||||
@@ -235,7 +250,7 @@ export default function AdvancedPanel({
|
||||
min={0.05}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => onParamsChange({ workScale: v ?? 0.2 })}
|
||||
onChange={v => onParamsChange({ workScale: v ?? 0.18 })}
|
||||
/>
|
||||
<NumberField
|
||||
label="目标填充率 TARGET_FILL"
|
||||
@@ -257,6 +272,15 @@ export default function AdvancedPanel({
|
||||
hint="开启后笔画复杂的字更大;关闭则更接近均等字号"
|
||||
/>
|
||||
|
||||
<div className="section-divider" />
|
||||
<SectionTitle>调试</SectionTitle>
|
||||
<BoolField
|
||||
label="生成调试文件"
|
||||
checked={params.saveDebugImages}
|
||||
onChange={v => onParamsChange({ saveDebugImages: v })}
|
||||
hint="开启后保存掩膜和占用网格等中间图片;正常生成建议关闭以减少磁盘 I/O"
|
||||
/>
|
||||
|
||||
<div className="section-divider" />
|
||||
<SectionTitle>画布</SectionTitle>
|
||||
<div className="form-row">
|
||||
|
||||
@@ -9,10 +9,11 @@ interface CanvasAreaProps {
|
||||
viewMode: '2d' | '3d';
|
||||
zoom: number;
|
||||
highlightLocation: NameLocation | null;
|
||||
onImageLoaded?: () => void;
|
||||
}
|
||||
|
||||
export default function CanvasArea({
|
||||
maskFile, jobResult, viewMode, zoom, highlightLocation
|
||||
maskFile, jobResult, viewMode, zoom, highlightLocation, onImageLoaded
|
||||
}: CanvasAreaProps) {
|
||||
const [maskPreviewUrl, setMaskPreviewUrl] = useState<string | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -44,7 +45,7 @@ export default function CanvasArea({
|
||||
</div>
|
||||
) : viewMode === '3d' ? (
|
||||
<div className="view-3d-container">
|
||||
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" />
|
||||
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" onLoad={onImageLoaded} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="canvas-image-wrapper">
|
||||
@@ -52,6 +53,7 @@ export default function CanvasArea({
|
||||
src={displayUrl}
|
||||
alt="wordcloud"
|
||||
className="canvas-image"
|
||||
onLoad={onImageLoaded}
|
||||
style={{ transform: `scale(${zoom})` }}
|
||||
/>
|
||||
{highlightLocation && jobResult && (
|
||||
|
||||
@@ -268,3 +268,12 @@ export function IconHelp() {
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCopy() {
|
||||
return (
|
||||
<Icon>
|
||||
<rect x="4" y="2" width="9" height="11" rx="1.5" />
|
||||
<path d="M3 5h-.5a1.5 1.5 0 0 0-1.5 1.5v6A1.5 1.5 0 0 0 2.5 14h6A1.5 1.5 0 0 0 10 12.5V12" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { SSEProgress } from '../types';
|
||||
import { IconCross, IconCheckmark, IconGear } from './Icons';
|
||||
import { IconCross, IconCheckmark, IconGear, IconCopy } from './Icons';
|
||||
|
||||
interface ProgressPanelProps {
|
||||
progress: SSEProgress | null;
|
||||
logLines: string[];
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export default function ProgressPanel({ progress, visible }: ProgressPanelProps) {
|
||||
export default function ProgressPanel({ progress, logLines, visible }: ProgressPanelProps) {
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll the log view to the bottom whenever new lines arrive.
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [logLines]);
|
||||
|
||||
// All hooks MUST run before any early return, so derive the values the
|
||||
// callback needs without depending on `progress` being non-null.
|
||||
const message = progress?.message ?? '';
|
||||
|
||||
const handleCopyError = useCallback(() => {
|
||||
const text = logLines.length > 0 ? logLines.join('\n') : message;
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
const el = document.querySelector('.error-detail-textarea') as HTMLTextAreaElement | null;
|
||||
if (el) { el.select(); document.execCommand('copy'); }
|
||||
});
|
||||
}, [logLines, message]);
|
||||
|
||||
if (!visible || !progress) return null;
|
||||
|
||||
const isFailed = progress.stage === '生成失败' || progress.stage === '错误';
|
||||
@@ -20,6 +41,13 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
|
||||
<div className="progress-stage" style={isFailed ? { color: 'var(--danger)' } : {}}>
|
||||
{progress.stage}
|
||||
</div>
|
||||
{(progress.elapsedSeconds != null || progress.clientElapsedSeconds != null) && (
|
||||
<div className="progress-timing" style={{ color: isDone ? 'var(--success)' : 'var(--text-muted)' }}>
|
||||
{progress.elapsedSeconds != null && `后端耗时 ${progress.elapsedSeconds.toFixed(2)} 秒`}
|
||||
{progress.elapsedSeconds != null && progress.clientElapsedSeconds != null ? ' · ' : ''}
|
||||
{progress.clientElapsedSeconds != null && `前端显示耗时 ${progress.clientElapsedSeconds.toFixed(2)} 秒`}
|
||||
</div>
|
||||
)}
|
||||
{!isFailed && (
|
||||
<div className="progress-bar-track">
|
||||
<div
|
||||
@@ -31,6 +59,33 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ── 详细日志(实时滚动) ───────────────────────────────── */}
|
||||
{logLines.length > 0 && (
|
||||
<div
|
||||
className="progress-log-view"
|
||||
style={{
|
||||
marginTop: 8,
|
||||
maxHeight: 320,
|
||||
overflowY: 'auto',
|
||||
background: 'var(--bg-secondary, #1a1a2e)',
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-muted, #888)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
}}
|
||||
>
|
||||
{logLines.map((line, i) => (
|
||||
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
)}
|
||||
{/* ── 失败时的错误详情 + 复制按钮 ───────────────────────── */}
|
||||
<div
|
||||
className="progress-message"
|
||||
style={{
|
||||
@@ -41,7 +96,19 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
|
||||
marginTop: isFailed ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
{progress.message}
|
||||
{isFailed ? (
|
||||
<div className="error-detail-container">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleCopyError}
|
||||
title="复制完整日志"
|
||||
style={{ marginBottom: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<IconCopy /> 复制完整日志
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { CanvasDocument, StickerAsset } from '../types';
|
||||
import { normalizeDocument } from './canvasDocument';
|
||||
import { apiUrl } from './api';
|
||||
import { createZip, ZipFileInput } from './zip';
|
||||
|
||||
interface PackageAssetMeta {
|
||||
id: string;
|
||||
originalAssetId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function stickerMimeType(asset: StickerAsset): string {
|
||||
if (asset.mimeType) return asset.mimeType;
|
||||
return asset.type === 'svg' ? 'image/svg+xml' : 'image/png';
|
||||
}
|
||||
|
||||
function stickerFileExtension(asset: StickerAsset): string {
|
||||
if (asset.type === 'svg') return '.svg';
|
||||
if (asset.mimeType === 'image/jpeg') return '.jpg';
|
||||
if (asset.mimeType === 'image/png') return '.png';
|
||||
const source = asset.source.toLowerCase();
|
||||
if (source.endsWith('.jpg') || source.endsWith('.jpeg')) return '.jpg';
|
||||
return '.png';
|
||||
}
|
||||
|
||||
export function safePackageBaseName(name: string): string {
|
||||
const cleaned = name.trim().replace(/[\\/:*?"<>|\n\t]/g, '_').replace(/\s+/g, '_').slice(0, 80);
|
||||
return cleaned || '画布设计';
|
||||
}
|
||||
|
||||
export async function exportCanvasPackage(
|
||||
documentModel: CanvasDocument,
|
||||
stickerById: Map<string, StickerAsset>,
|
||||
name = '画布设计',
|
||||
description = '',
|
||||
): Promise<Blob> {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const usedAssetIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
doc.elements.forEach(element => {
|
||||
if (element.type !== 'sticker') return;
|
||||
if (seen.has(element.assetId)) return;
|
||||
seen.add(element.assetId);
|
||||
usedAssetIds.push(element.assetId);
|
||||
});
|
||||
|
||||
const packageIdByAsset = new Map<string, string>();
|
||||
usedAssetIds.forEach((assetId, index) => {
|
||||
packageIdByAsset.set(assetId, `asset-${String(index + 1).padStart(3, '0')}`);
|
||||
});
|
||||
|
||||
const files: ZipFileInput[] = [];
|
||||
const packageAssets: PackageAssetMeta[] = [];
|
||||
|
||||
for (const assetId of usedAssetIds) {
|
||||
const asset = stickerById.get(assetId);
|
||||
if (!asset) throw new Error(`画布引用了缺失素材:${assetId}`);
|
||||
const res = await fetch(apiUrl(asset.source));
|
||||
if (!res.ok) throw new Error(`读取素材失败:${asset.name} (${res.status})`);
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
const packageId = packageIdByAsset.get(assetId) || assetId;
|
||||
const ext = stickerFileExtension(asset);
|
||||
packageAssets.push({
|
||||
id: packageId,
|
||||
originalAssetId: assetId,
|
||||
name: asset.name,
|
||||
type: asset.type,
|
||||
mimeType: stickerMimeType(asset),
|
||||
size: bytes.length,
|
||||
});
|
||||
files.push({
|
||||
name: `assets/${packageId}${ext}`,
|
||||
content: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
const packageDocument = {
|
||||
...doc,
|
||||
elements: doc.elements.map(element => {
|
||||
if (element.type !== 'sticker') return element;
|
||||
return {
|
||||
...element,
|
||||
assetId: packageIdByAsset.get(element.assetId) || element.assetId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const manifest = {
|
||||
format: 'wordcloud-canvas',
|
||||
version: 1,
|
||||
name: name.trim() || '画布设计',
|
||||
description: description.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
canvas: {
|
||||
width: doc.width,
|
||||
height: doc.height,
|
||||
background: doc.background,
|
||||
},
|
||||
assets: packageAssets,
|
||||
fonts: [],
|
||||
};
|
||||
|
||||
files.unshift({
|
||||
name: 'manifest.json',
|
||||
content: JSON.stringify(manifest, null, 2),
|
||||
});
|
||||
files.splice(1, 0, {
|
||||
name: 'document.json',
|
||||
content: JSON.stringify(packageDocument, null, 2),
|
||||
});
|
||||
|
||||
return createZip(files);
|
||||
}
|
||||
@@ -115,6 +115,7 @@ export async function loadStickerLibrary(): Promise<StickerAsset[]> {
|
||||
source: a.file_url,
|
||||
createdAt: a.created_at,
|
||||
tint: tints[a.asset_id] as StickerAsset['tint'],
|
||||
mimeType: a.mime_type,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -136,6 +137,7 @@ export async function addStickerAsset(
|
||||
source: asset.file_url,
|
||||
createdAt: asset.created_at,
|
||||
tint: input.tint,
|
||||
mimeType: asset.mime_type,
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
|
||||
return sticker;
|
||||
@@ -160,6 +162,7 @@ export async function addStickerAssetFromJob(
|
||||
type: asset.mime_type === 'image/svg+xml' ? 'svg' : 'image',
|
||||
source: asset.file_url,
|
||||
createdAt: asset.created_at,
|
||||
mimeType: asset.mime_type,
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
|
||||
return sticker;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { createZip } from './zip';
|
||||
export interface SerializeOptions {
|
||||
layerIds?: string[];
|
||||
includeBackground?: boolean;
|
||||
/** Add two in-canvas registration dots for physical/image alignment. */
|
||||
addRegistrationMarks?: boolean;
|
||||
}
|
||||
|
||||
async function fetchBlobAsDataUrl(url: string): Promise<string> {
|
||||
@@ -108,6 +110,11 @@ export async function serializeDocument(
|
||||
parts.push(`<line x1="0" y1="${element.height / 2}" x2="${element.width}" y2="${element.height / 2}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" stroke-linecap="round" opacity="${opacity}" transform="${transform}"/>`);
|
||||
}
|
||||
|
||||
// Keep marks last so canvas elements cannot cover the alignment targets.
|
||||
if (options.addRegistrationMarks) {
|
||||
parts.push(serializeRegistrationMarks(doc.width, doc.height));
|
||||
}
|
||||
|
||||
parts.push('</svg>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -117,6 +124,7 @@ export async function createLayerExportZip(
|
||||
stickerById: Map<string, StickerAsset>,
|
||||
selectedLayerIds: string[],
|
||||
selectedFolderIds: string[],
|
||||
options: Pick<SerializeOptions, 'addRegistrationMarks'> = {},
|
||||
) {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const files: { name: string; content: string }[] = [];
|
||||
@@ -125,7 +133,7 @@ export async function createLayerExportZip(
|
||||
if (hasCanvasBackground(doc.background)) {
|
||||
files.push({
|
||||
name: uniqueSvgName('背景', used),
|
||||
content: serializeBackgroundLayer(doc),
|
||||
content: serializeBackgroundLayer(doc, options),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,7 +142,7 @@ export async function createLayerExportZip(
|
||||
if (!layer) continue;
|
||||
files.push({
|
||||
name: uniqueSvgName(layer.name, used),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false }),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,24 +151,37 @@ export async function createLayerExportZip(
|
||||
if (!folder) continue;
|
||||
files.push({
|
||||
name: uniqueSvgName(folder.name, used),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false }),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
return createZip(files);
|
||||
}
|
||||
|
||||
function serializeBackgroundLayer(documentModel: CanvasDocument) {
|
||||
function serializeBackgroundLayer(documentModel: CanvasDocument, options: Pick<SerializeOptions, 'addRegistrationMarks'> = {}) {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const widthMm = pxToMm(doc.width).toFixed(1);
|
||||
const heightMm = pxToMm(doc.height).toFixed(1);
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
|
||||
`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`,
|
||||
...(options.addRegistrationMarks ? [serializeRegistrationMarks(doc.width, doc.height)] : []),
|
||||
'</svg>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function serializeRegistrationMarks(width: number, height: number) {
|
||||
const minDimension = Math.max(1, Math.min(width, height));
|
||||
// Keep the circles inside the canvas so neither SVG nor raster consumers clip them.
|
||||
const inset = Math.max(4, minDimension * 0.012);
|
||||
const radius = Math.max(1.5, minDimension * 0.004);
|
||||
const format = (value: number) => formatSvgNumber(value);
|
||||
return [
|
||||
`<circle cx="${format(inset)}" cy="${format(inset)}" r="${format(radius)}" fill="#000000"/>`,
|
||||
`<circle cx="${format(width - inset)}" cy="${format(height - inset)}" r="${format(radius)}" fill="#000000"/>`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function serializeInlineSvgSticker(
|
||||
svgText: string,
|
||||
targetWidth: number,
|
||||
|
||||
@@ -46,6 +46,20 @@ export async function createCanvasTemplate(input: {
|
||||
return { ...template, document: normalizeDocument(template.document) };
|
||||
}
|
||||
|
||||
export async function importCanvasTemplate(
|
||||
file: File,
|
||||
name = '',
|
||||
description = '',
|
||||
): Promise<CanvasTemplate> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (name) fd.append('name', name.trim());
|
||||
if (description) fd.append('description', description.trim());
|
||||
const res = await ensureOk(await fetch(apiUrl('/api/design-templates/import'), { method: 'POST', body: fd }), '导入设计包失败');
|
||||
const template = (await res.json()) as CanvasTemplate;
|
||||
return { ...template, document: normalizeDocument(template.document) };
|
||||
}
|
||||
|
||||
export async function updateCanvasTemplate(
|
||||
id: string,
|
||||
partial: {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../lib/canvasDocument';
|
||||
import { createCanvasTemplate, duplicateDocument, uploadAsset } from '../lib/templateLibrary';
|
||||
import { createLayerExportZip, serializeDocument } from '../lib/svgExport';
|
||||
import { exportCanvasPackage, safePackageBaseName } from '../lib/canvasPackage';
|
||||
import { apiUrl, ensureOk } from '../lib/api';
|
||||
import {
|
||||
IconGrid,
|
||||
@@ -461,16 +462,16 @@ export default function CanvasStudio({
|
||||
});
|
||||
};
|
||||
|
||||
const exportSvg = useCallback(async () => {
|
||||
const svg = await serializeDocument(normalizedDocument, stickerById);
|
||||
const exportSvg = useCallback(async (addRegistrationMarks: boolean) => {
|
||||
const svg = await serializeDocument(normalizedDocument, stickerById, { addRegistrationMarks });
|
||||
downloadBlob(
|
||||
new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
|
||||
'canvas-design.svg',
|
||||
);
|
||||
}, [normalizedDocument, stickerById]);
|
||||
|
||||
const exportLayerZip = async (layerIds: string[], folderIds: string[]) => {
|
||||
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds);
|
||||
const exportLayerZip = async (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => {
|
||||
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds, { addRegistrationMarks });
|
||||
downloadBlob(blob, 'canvas-layers.zip');
|
||||
};
|
||||
|
||||
@@ -858,6 +859,7 @@ export default function CanvasStudio({
|
||||
activeLayerId={activeLayerId}
|
||||
onActiveLayerChange={setActiveLayerId}
|
||||
onChange={setDocumentModel}
|
||||
stickerById={stickerById}
|
||||
/>
|
||||
);
|
||||
case 'sticker':
|
||||
@@ -1096,16 +1098,121 @@ export default function CanvasStudio({
|
||||
);
|
||||
}
|
||||
|
||||
function LayerThumbnail({
|
||||
documentModel,
|
||||
layer,
|
||||
stickerById,
|
||||
}: {
|
||||
documentModel: CanvasDocument;
|
||||
layer: CanvasLayer;
|
||||
stickerById: Map<string, StickerAsset>;
|
||||
}) {
|
||||
const elements = documentModel.elements.filter(element => element.layerId === layer.id);
|
||||
const fitScale = Math.min(1, 52 / Math.max(documentModel.width, documentModel.height, 1));
|
||||
const scaledWidth = Math.max(1, documentModel.width * fitScale);
|
||||
const scaledHeight = Math.max(1, documentModel.height * fitScale);
|
||||
return (
|
||||
<div className={`layer-thumb${layer.visible === false ? ' layer-thumb-hidden' : ''}`}>
|
||||
<div
|
||||
className="layer-thumb-scale"
|
||||
style={{ width: scaledWidth, height: scaledHeight }}
|
||||
>
|
||||
<div
|
||||
className="layer-thumb-doc"
|
||||
style={{ width: documentModel.width, height: documentModel.height, transform: `scale(${fitScale})` }}
|
||||
>
|
||||
{elements.map(element => (
|
||||
<LayerThumbElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
asset={element.type === 'sticker' ? stickerById.get(element.assetId) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerThumbElement({
|
||||
element,
|
||||
asset,
|
||||
}: {
|
||||
element: CanvasElement;
|
||||
asset?: StickerAsset;
|
||||
}) {
|
||||
const baseStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: element.x,
|
||||
top: element.y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
opacity: element.opacity,
|
||||
transform: `rotate(${element.rotation}deg)`,
|
||||
};
|
||||
|
||||
if (element.type === 'sticker') {
|
||||
if (!asset) return (<div className="missing-sticker" style={baseStyle}>贴纸缺失</div>);
|
||||
return (
|
||||
<img
|
||||
className={`studio-sticker-image${asset.tint === 'gray' ? ' gray-mask' : ''} layer-thumb-image`}
|
||||
style={baseStyle}
|
||||
src={assetToDataUrl(asset)}
|
||||
alt={asset.name}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'text') {
|
||||
return (
|
||||
<div
|
||||
className="layer-thumb-text"
|
||||
style={{
|
||||
...baseStyle,
|
||||
color: element.fill,
|
||||
fontFamily: element.fontFamily,
|
||||
fontSize: element.fontSize,
|
||||
fontWeight: element.fontWeight,
|
||||
}}
|
||||
>
|
||||
{element.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'line') {
|
||||
return (
|
||||
<div className="layer-thumb-line" style={baseStyle}>
|
||||
<div style={{ width: '100%', height: Math.max(1, element.strokeWidth), background: element.stroke }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`layer-thumb-shape${element.type === 'ellipse' ? ' layer-thumb-ellipse' : ''}`}
|
||||
style={{
|
||||
...baseStyle,
|
||||
background: element.fill === 'transparent' ? undefined : element.fill,
|
||||
border: element.strokeWidth > 0 ? `${Math.max(0, element.strokeWidth)}px solid ${element.stroke}` : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LayersPanel({
|
||||
documentModel,
|
||||
activeLayerId,
|
||||
onActiveLayerChange,
|
||||
onChange,
|
||||
stickerById,
|
||||
}: {
|
||||
documentModel: CanvasDocument;
|
||||
activeLayerId: string;
|
||||
onActiveLayerChange: (id: string) => void;
|
||||
onChange: (documentModel: CanvasDocument) => void;
|
||||
stickerById: Map<string, StickerAsset>;
|
||||
}) {
|
||||
const layers = documentModel.layers || [];
|
||||
const folders = documentModel.layerFolders || [];
|
||||
@@ -1206,6 +1313,10 @@ function LayersPanel({
|
||||
))}
|
||||
{layers.slice().reverse().map(layer => (
|
||||
<div key={layer.id} className={`layer-row${layer.id === activeLayerId ? ' active' : ''}`}>
|
||||
<div className="layer-thumb-wrap">
|
||||
<LayerThumbnail documentModel={documentModel} layer={layer} stickerById={stickerById} />
|
||||
</div>
|
||||
<div className="layer-info">
|
||||
<div className="layer-main-line">
|
||||
<button className="icon-btn layer-icon-btn" title="显示/隐藏" onClick={() => updateLayer(layer.id, { visible: !layer.visible })}>
|
||||
{layer.visible ? <IconEyeOpen /> : <IconEyeClosed />}
|
||||
@@ -1235,6 +1346,7 @@ function LayersPanel({
|
||||
<button className="icon-btn layer-icon-btn" title="删除" onClick={() => deleteLayer(layer.id)}><IconTrash /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1308,8 +1420,8 @@ function CanvasExportPanel({
|
||||
documentModel: CanvasDocument;
|
||||
stickerById: Map<string, StickerAsset>;
|
||||
onUpdateDocument: (partial: Partial<CanvasDocument>) => void;
|
||||
onExportSvg: () => void;
|
||||
onExportLayerZip: (layerIds: string[], folderIds: string[]) => void;
|
||||
onExportSvg: (addRegistrationMarks: boolean) => void;
|
||||
onExportLayerZip: (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||
@@ -1318,6 +1430,8 @@ function CanvasExportPanel({
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
const [templateDescription, setTemplateDescription] = useState('');
|
||||
const [referenceFiles, setReferenceFiles] = useState<File[]>([]);
|
||||
const [addRegistrationMarks, setAddRegistrationMarks] = useState(false);
|
||||
const [exportingPackage, setExportingPackage] = useState(false);
|
||||
const layers = documentModel.layers || [];
|
||||
const folders = documentModel.layerFolders || [];
|
||||
const backgroundEnabled = hasCanvasBackground(documentModel.background);
|
||||
@@ -1355,6 +1469,24 @@ function CanvasExportPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const exportPackage = async () => {
|
||||
if (exportingPackage) return;
|
||||
setExportingPackage(true);
|
||||
try {
|
||||
const blob = await exportCanvasPackage(
|
||||
documentModel,
|
||||
stickerById,
|
||||
templateName || '画布设计',
|
||||
templateDescription,
|
||||
);
|
||||
downloadBlob(blob, `${safePackageBaseName(templateName || '画布设计')}.wcd`);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '导出设计包失败');
|
||||
} finally {
|
||||
setExportingPackage(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-row">
|
||||
@@ -1412,7 +1544,18 @@ function CanvasExportPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-primary btn-block" onClick={onExportSvg}>导出总图 SVG</button>
|
||||
<div className="form-group">
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addRegistrationMarks}
|
||||
onChange={event => setAddRegistrationMarks(event.target.checked)}
|
||||
/>
|
||||
<span>添加定位点</span>
|
||||
</label>
|
||||
<div className="note-text">在导出的总图和每个分层文件左上角、右下角添加对齐点</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-block" onClick={() => onExportSvg(addRegistrationMarks)}>导出总图 SVG</button>
|
||||
|
||||
<div className="section-divider" />
|
||||
<div className="section-title">分层打包导出</div>
|
||||
@@ -1441,7 +1584,7 @@ function CanvasExportPanel({
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary btn-block"
|
||||
onClick={() => onExportLayerZip(selectedLayerIds, selectedFolderIds)}
|
||||
onClick={() => onExportLayerZip(selectedLayerIds, selectedFolderIds, addRegistrationMarks)}
|
||||
>
|
||||
打包导出 SVG
|
||||
</button>
|
||||
@@ -1469,6 +1612,13 @@ function CanvasExportPanel({
|
||||
<button className="btn btn-secondary btn-block" disabled={savingTemplate} onClick={saveTemplate}>
|
||||
{savingTemplate ? '保存中' : '保存当前画布为模板'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-block"
|
||||
disabled={exportingPackage}
|
||||
onClick={exportPackage}
|
||||
>
|
||||
{exportingPackage ? '打包中' : '导出 .wcd'}
|
||||
</button>
|
||||
<button className="btn btn-danger btn-block" onClick={onReset}>清空画布</button>
|
||||
<span style={{ display: 'none' }}>{stickerById.size}</span>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
|
||||
import { BackendAsset, CanvasTemplate } from '../types';
|
||||
import { formatMm, normalizeDocument } from '../lib/canvasDocument';
|
||||
import {
|
||||
assetUrl,
|
||||
deleteCanvasTemplate,
|
||||
importCanvasTemplate,
|
||||
listAssets,
|
||||
listDesignTemplates,
|
||||
templateCoverId,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
IconCloud,
|
||||
IconRefresh,
|
||||
IconCanvas,
|
||||
IconDownload,
|
||||
IconHelp,
|
||||
} from '../components/Icons';
|
||||
|
||||
@@ -48,7 +50,9 @@ export default function TemplateHome({
|
||||
const [selected, setSelected] = useState<CanvasTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [stickerById, setStickerById] = useState(() => new Map<string, import('../types').StickerAsset>());
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadStickerLibrary().then(items => {
|
||||
@@ -92,6 +96,23 @@ export default function TemplateHome({
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleImportFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file || importing) return;
|
||||
setImporting(true);
|
||||
setError('');
|
||||
try {
|
||||
await importCanvasTemplate(file);
|
||||
await refresh();
|
||||
alert('设计包已导入');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '导入设计包失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="template-home">
|
||||
<nav className="navbar">
|
||||
@@ -104,6 +125,17 @@ export default function TemplateHome({
|
||||
<span className="nav-btn-icon"><IconRefresh /></span>
|
||||
<span className="nav-btn-label">刷新</span>
|
||||
</button>
|
||||
<button className="nav-btn" onClick={() => importFileRef.current?.click()} disabled={importing}>
|
||||
<span className="nav-btn-icon"><IconDownload /></span>
|
||||
<span className="nav-btn-label">{importing ? '导入中' : '导入 .wcd'}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
accept=".wcd"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
<button className="nav-btn" onClick={onOpenCanvas}>
|
||||
|
||||
@@ -45,14 +45,21 @@ const DEFAULT_PARAMS: JobParams = {
|
||||
fontColor: '#000000',
|
||||
nRepetitions: 1,
|
||||
strokeWeights: true,
|
||||
autoRepeatToFill: true,
|
||||
// Intermediate mask/occupancy images are useful for diagnosis but add
|
||||
// extra disk I/O, so keep them disabled for normal generation.
|
||||
saveDebugImages: false,
|
||||
|
||||
// 字号与填充
|
||||
sizeRatio: 2.0,
|
||||
packingEfficiency: 0.9,
|
||||
verticalRatio: 0.18,
|
||||
targetFillRatio: 0.45,
|
||||
userMinFontSize: null,
|
||||
userMaxFontSize: null,
|
||||
minReadableHeightPx: 22,
|
||||
// 0.18 keeps the 750-word service request complete on the default mask;
|
||||
// lower values can make the coarse grid too small to place every word.
|
||||
workScale: 0.18,
|
||||
|
||||
// 画布
|
||||
@@ -118,7 +125,9 @@ export default function TestWorkbench({
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [jobResult, setJobResult] = useState<JobResult | null>(null);
|
||||
const [progress, setProgress] = useState<SSEProgress | null>(null);
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [clientElapsedSeconds, setClientElapsedSeconds] = useState<number | null>(null);
|
||||
const [openPanels, setOpenPanels] = useState<WorkbenchPanelId[]>(['import', 'advanced']);
|
||||
const [activeReplaceSession, setActiveReplaceSession] = useState<WordcloudReplaceSession | null>(null);
|
||||
const [replaceMaskReady, setReplaceMaskReady] = useState(false);
|
||||
@@ -130,6 +139,10 @@ export default function TestWorkbench({
|
||||
const [fonts, setFonts] = useState<Font[]>([]);
|
||||
const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
|
||||
const sseRef = useRef<EventSource | null>(null);
|
||||
const requestStartedAtRef = useRef<number | null>(null);
|
||||
const displayedJobRef = useRef<string | null>(null);
|
||||
const previewRequestedJobRef = useRef<string | null>(null);
|
||||
const displayElapsedRef = useRef<number | null>(null);
|
||||
const floatingPanels = useFloatingPanels('wb-floating-panels', WORKBENCH_PANEL_LAYOUT);
|
||||
const focusWorkbenchPanel = floatingPanels.focusPanel;
|
||||
|
||||
@@ -408,7 +421,13 @@ export default function TestWorkbench({
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
requestStartedAtRef.current = performance.now();
|
||||
displayedJobRef.current = null;
|
||||
previewRequestedJobRef.current = null;
|
||||
displayElapsedRef.current = null;
|
||||
setClientElapsedSeconds(null);
|
||||
setProgress({ stage: '准备中', percent: 0, message: '正在提交任务...' });
|
||||
setLogLines([]);
|
||||
setJobResult(null);
|
||||
setHighlightLocation(null);
|
||||
|
||||
@@ -427,8 +446,11 @@ export default function TestWorkbench({
|
||||
FONT_COLOR: params.fontColor || '#000000',
|
||||
N_REPETITIONS: params.nRepetitions,
|
||||
ENABLE_STROKE_WEIGHTS: params.strokeWeights,
|
||||
AUTO_REPEAT_TO_FILL: params.autoRepeatToFill,
|
||||
SAVE_DEBUG_IMAGES: params.saveDebugImages,
|
||||
SIZE_RATIO: params.sizeRatio,
|
||||
PACKING_EFFICIENCY: params.packingEfficiency,
|
||||
VERTICAL_RATIO: params.verticalRatio,
|
||||
TARGET_FILL_RATIO: params.targetFillRatio,
|
||||
MIN_READABLE_HEIGHT_PX: params.minReadableHeightPx,
|
||||
WORK_SCALE: params.workScale,
|
||||
@@ -487,11 +509,23 @@ export default function TestWorkbench({
|
||||
sse.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
const localElapsed = requestStartedAtRef.current == null
|
||||
? undefined
|
||||
: (performance.now() - requestStartedAtRef.current) / 1000;
|
||||
setProgress({
|
||||
stage: msg.stage ?? '',
|
||||
percent: msg.progress_percent ?? 0,
|
||||
message: msg.message ?? '',
|
||||
elapsedSeconds: typeof msg.elapsed_seconds === 'number' ? msg.elapsed_seconds : undefined,
|
||||
clientElapsedSeconds: localElapsed,
|
||||
});
|
||||
if (msg.message) {
|
||||
setLogLines(prev => [...prev, msg.message]);
|
||||
}
|
||||
if (msg.stage === 'preview_ready' && previewRequestedJobRef.current !== id) {
|
||||
previewRequestedJobRef.current = id;
|
||||
fetchPreview(id);
|
||||
}
|
||||
if (msg.progress_percent >= 100 || msg.stage === 'completed' || msg.stage === 'failed') {
|
||||
sse.close();
|
||||
fetchResult(id);
|
||||
@@ -512,12 +546,48 @@ export default function TestWorkbench({
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPreview = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/api/jobs/${id}/result`));
|
||||
if (!res.ok) return;
|
||||
const data: JobResult = await res.json();
|
||||
if (!data.image_url || data.status === 'failed') return;
|
||||
const imageUrl = data.image_url || `/api/jobs/${id}/files/png`;
|
||||
const elapsed = requestStartedAtRef.current == null
|
||||
? undefined
|
||||
: (performance.now() - requestStartedAtRef.current) / 1000;
|
||||
setClientElapsedSeconds(elapsed ?? null);
|
||||
setJobResult(prev => {
|
||||
// The final request may win the race with this preview fetch. Never
|
||||
// downgrade a completed result back to the partial running payload.
|
||||
if (prev?.status === 'success') return prev;
|
||||
return { ...prev, ...data, image_url: imageUrl };
|
||||
});
|
||||
setProgress(prev => {
|
||||
if (prev?.stage === '完成' || prev?.stage === '生成失败') return prev;
|
||||
return {
|
||||
stage: 'preview_ready',
|
||||
percent: Math.max(prev?.percent ?? 0, 94),
|
||||
message: '预览已显示,后台继续导出其余文件',
|
||||
elapsedSeconds: prev?.elapsedSeconds,
|
||||
clientElapsedSeconds: elapsed,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// The final result request remains the source of truth if preview fetch fails.
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 获取结果 ─────────────────────────────────────────────────────────────
|
||||
// GET /api/jobs/{job_id}/result
|
||||
const fetchResult = async (id: string) => {
|
||||
try {
|
||||
const res = await ensureOk(await fetch(apiUrl(`/api/jobs/${id}/result`)), '获取结果失败');
|
||||
const data: JobResult = await res.json();
|
||||
const resultElapsed = requestStartedAtRef.current == null
|
||||
? null
|
||||
: (performance.now() - requestStartedAtRef.current) / 1000;
|
||||
setClientElapsedSeconds(resultElapsed);
|
||||
|
||||
if (data.status === 'failed') {
|
||||
// 尝试从 /detail 拿更详细的错误信息
|
||||
@@ -526,7 +596,8 @@ export default function TestWorkbench({
|
||||
const dr = await fetch(apiUrl(`/api/jobs/${id}/detail`));
|
||||
if (dr.ok) {
|
||||
const dd = await dr.json();
|
||||
detail = dd.error ?? dd.message ?? '';
|
||||
// API 返回 { status: { error: "...", ... }, recent_events: [...] }
|
||||
detail = dd.status?.error ?? '';
|
||||
} else {
|
||||
detail = await readApiError(dr);
|
||||
}
|
||||
@@ -534,7 +605,11 @@ export default function TestWorkbench({
|
||||
setProgress({
|
||||
stage: '生成失败',
|
||||
percent: 0,
|
||||
message: `任务失败${detail ? ':' + detail : ''}。可用 docker logs 查看后端堆栈。`,
|
||||
message: detail
|
||||
? `任务失败:\n${detail}`
|
||||
: '任务失败。可用 docker logs 查看后端堆栈。',
|
||||
elapsedSeconds: typeof data.elapsed_seconds === 'number' ? data.elapsed_seconds : undefined,
|
||||
clientElapsedSeconds: resultElapsed ?? undefined,
|
||||
});
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
@@ -546,8 +621,17 @@ export default function TestWorkbench({
|
||||
const svgUrl = data.svg_url || `/api/jobs/${id}/files/svg`;
|
||||
|
||||
setJobResult({ ...data, image_url: imageUrl, svg_url: svgUrl });
|
||||
setProgress({ stage: '完成', percent: 100, message: '词云生成完成!' });
|
||||
setTimeout(() => setProgress(null), 3000);
|
||||
const backendElapsed = typeof data.elapsed_seconds === 'number' ? data.elapsed_seconds : undefined;
|
||||
const visibleElapsed = displayElapsedRef.current ?? resultElapsed ?? undefined;
|
||||
setProgress({
|
||||
stage: '完成',
|
||||
percent: 100,
|
||||
message: backendElapsed != null
|
||||
? `词云生成完成,用时 ${backendElapsed.toFixed(2)} 秒${visibleElapsed != null ? `,前端显示 ${visibleElapsed.toFixed(2)} 秒(QoS判断根据)` : ''}`
|
||||
: '词云生成完成!',
|
||||
elapsedSeconds: backendElapsed,
|
||||
clientElapsedSeconds: visibleElapsed,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '未知错误';
|
||||
setProgress({ stage: '错误', percent: 0, message: msg });
|
||||
@@ -556,6 +640,28 @@ export default function TestWorkbench({
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageLoaded = () => {
|
||||
const currentJobId = jobResult?.job_id;
|
||||
if (!currentJobId) return;
|
||||
const isFinal = progress?.stage === '完成';
|
||||
const displayKey = `${currentJobId}:${isFinal ? 'final' : 'preview'}`;
|
||||
if (displayedJobRef.current === displayKey) return;
|
||||
displayedJobRef.current = displayKey;
|
||||
if (requestStartedAtRef.current == null) return;
|
||||
const elapsed = (performance.now() - requestStartedAtRef.current) / 1000;
|
||||
displayElapsedRef.current = elapsed;
|
||||
setClientElapsedSeconds(elapsed);
|
||||
setProgress(prev => prev ? {
|
||||
...prev,
|
||||
clientElapsedSeconds: elapsed,
|
||||
message: isFinal
|
||||
? (prev.elapsedSeconds != null
|
||||
? `词云生成完成,用时 ${prev.elapsedSeconds.toFixed(2)} 秒,前端显示 ${elapsed.toFixed(2)} 秒`
|
||||
: `词云生成完成,前端显示 ${elapsed.toFixed(2)} 秒`)
|
||||
: `预览已显示,前端耗时 ${elapsed.toFixed(2)} 秒`,
|
||||
} : prev);
|
||||
};
|
||||
|
||||
const handleLocate = (loc: NameLocation) => {
|
||||
setHighlightLocation(loc);
|
||||
setViewMode('2d');
|
||||
@@ -803,9 +909,10 @@ export default function TestWorkbench({
|
||||
viewMode={viewMode}
|
||||
zoom={zoom}
|
||||
highlightLocation={highlightLocation}
|
||||
onImageLoaded={handleImageLoaded}
|
||||
/>
|
||||
|
||||
<ProgressPanel progress={progress} visible={isGenerating || !!progress} />
|
||||
<ProgressPanel progress={progress} logLines={logLines} visible={isGenerating || !!progress} />
|
||||
|
||||
<ViewControls
|
||||
zoom={zoom}
|
||||
|
||||
+83
-2
@@ -1640,8 +1640,90 @@ body.resizing {
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.layer-info {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.layer-thumb-wrap {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.layer-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(45deg, color-mix(in srgb, var(--border) 32%, transparent) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, color-mix(in srgb, var(--border) 32%, transparent) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--border) 32%, transparent) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--border) 32%, transparent) 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
|
||||
}
|
||||
|
||||
.layer-thumb-hidden {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.layer-thumb-scale {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-thumb-doc {
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-thumb-image,
|
||||
.layer-thumb-text,
|
||||
.layer-thumb-shape,
|
||||
.layer-thumb-line {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.layer-thumb-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.layer-thumb-text {
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.layer-thumb-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layer-thumb-ellipse {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.layer-row.active {
|
||||
@@ -1667,7 +1749,6 @@ body.resizing {
|
||||
|
||||
.layer-sub-line {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding-left: 49px;
|
||||
}
|
||||
|
||||
.layer-action-group {
|
||||
|
||||
@@ -18,10 +18,13 @@ export interface JobParams {
|
||||
fontColor: string;
|
||||
nRepetitions: number;
|
||||
strokeWeights: boolean;
|
||||
autoRepeatToFill: boolean;
|
||||
saveDebugImages: boolean;
|
||||
|
||||
// 字号与填充
|
||||
sizeRatio: number;
|
||||
packingEfficiency: number;
|
||||
verticalRatio: number;
|
||||
targetFillRatio: number;
|
||||
userMinFontSize: number | null;
|
||||
userMaxFontSize: number | null;
|
||||
@@ -47,6 +50,7 @@ export interface JobResult {
|
||||
svg_stroke_url: string;
|
||||
db_url: string;
|
||||
metrics_url: string;
|
||||
elapsed_seconds?: number | null;
|
||||
}
|
||||
|
||||
export interface NameLocation {
|
||||
@@ -71,6 +75,8 @@ export interface SSEProgress {
|
||||
stage: string;
|
||||
percent: number;
|
||||
message: string;
|
||||
elapsedSeconds?: number;
|
||||
clientElapsedSeconds?: number;
|
||||
}
|
||||
|
||||
export type PanelType = 'import' | 'export' | 'edit' | 'find' | 'advanced' | null;
|
||||
@@ -104,6 +110,7 @@ export interface StickerAsset {
|
||||
source: string;
|
||||
createdAt: string;
|
||||
tint?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export type CanvasElementType = 'sticker' | 'text' | 'rect' | 'ellipse' | 'line';
|
||||
|
||||
Reference in New Issue
Block a user