Add advanced canvas editing and Ubuntu deployment

This commit is contained in:
2026-07-14 20:32:42 +08:00
parent f8a907e7c5
commit bf2b138007
19 changed files with 2026 additions and 255 deletions
-1
View File
@@ -13,7 +13,6 @@ __pycache__
service_workspace
service_assets
service_projects
service_design_templates
service_fonts
*.egg-info
build
+7 -1
View File
@@ -22,15 +22,21 @@ COPY EfficientWordCloud ./EfficientWordCloud
COPY assets ./assets
COPY start-dev.sh ./
COPY wordcloud_generate_hybrid.py ./
COPY docker-entrypoint.sh ./
# Optional design-template seeds (copied into volume on first boot)
COPY service_design_templates ./service_design_templates_seed
# Build the C++ extension in-place
RUN cd EfficientWordCloud && python setup.py build_ext --inplace
# Runtime data directories (will be mounted as volumes)
RUN mkdir -p service_workspace service_assets service_projects service_design_templates service_fonts
RUN mkdir -p service_workspace service_assets service_projects service_design_templates service_fonts \
&& chmod +x /app/docker-entrypoint.sh
EXPOSE 8000
ENV PYTHONPATH=/app/EfficientWordCloud
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["uvicorn", "service.app:app", "--host", "0.0.0.0", "--port", "8000"]
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
# Seed design templates into the mounted volume on first boot.
SEED_DIR="/app/service_design_templates_seed"
TARGET_DIR="/app/service_design_templates"
if [[ -d "$SEED_DIR" ]]; then
mkdir -p "$TARGET_DIR"
# only copy when target has no template.json yet
if ! find "$TARGET_DIR" -type f -name 'template.json' 2>/dev/null | grep -q .; then
echo "[entrypoint] seeding design templates into volume..."
cp -a "$SEED_DIR"/. "$TARGET_DIR"/
fi
fi
# Ensure runtime dirs exist
mkdir -p \
/app/service_workspace \
/app/service_assets \
/app/service_projects \
/app/service_fonts \
/app/service_design_templates
exec "$@"
+68 -17
View File
@@ -885,27 +885,76 @@ async def upload_asset(
return Asset(**meta)
@app.post("/api/assets/from-job/{job_id}", response_model=Asset)
async def import_asset_from_job(
job_id: str,
name: str = Form(""),
) -> Asset:
# 尝试从内存获取产物路径;若服务已重启则从磁盘回退
def _svg_with_transparent_background(svg_bytes: bytes) -> bytes:
"""Convert full-canvas opaque backgrounds to transparent for sticker use.
Job pipeline SVG (`to_svg`) embeds a white full-rect so standalone preview
looks solid. Canvas stickers must stay transparent so the mask layer shows
through. Only the full-canvas background rect is touched.
"""
try:
text = svg_bytes.decode("utf-8")
except UnicodeDecodeError:
text = svg_bytes.decode("utf-8", errors="ignore")
patterns = [
# <rect width="100%" height="100%" fill="white"/>
(
r'(<rect\b[^>]*\bwidth=["\']100%["\'][^>]*\bheight=["\']100%["\'][^>]*\bfill=["\'])(?:white|#fff(?:fff)?|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))(["\'][^>]*?/?>)',
r'\1none\2',
),
# attribute order: fill before width/height
(
r'(<rect\b[^>]*\bfill=["\'])(?:white|#fff(?:fff)?|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))(["\'][^>]*\bwidth=["\']100%["\'][^>]*\bheight=["\']100%["\'][^>]*?/?>)',
r'\1none\2',
),
]
for pattern, repl in patterns:
updated, n = re.subn(pattern, repl, text, count=1, flags=re.IGNORECASE)
if n:
return updated.encode("utf-8")
return svg_bytes.encode("utf-8") if isinstance(svg_bytes, str) else svg_bytes
def _find_job_svg(job_id: str) -> Path | None:
"""Locate the main (non-stroke) SVG for a job.
Prefer in-memory artifact path when valid; always fall back to the job
workspace on disk so empty/stale memory state cannot hide existing files.
"""
svg_path: Path | None = None
if manager.exists(job_id):
status = manager.get_status(job_id)
svg_path_str = status.artifacts.get("svg", "")
if svg_path_str:
svg_path = Path(svg_path_str)
else:
# 服务重启后内存丢失,直接从工作区目录查找
fallback_dir = storage.base_dir / job_id / "output"
if fallback_dir.exists():
for candidate in fallback_dir.glob("*.svg"):
candidate = Path(svg_path_str)
if candidate.exists():
svg_path = candidate
break
if svg_path is not None:
return svg_path
fallback_dir = storage.base_dir / job_id / "output"
if not fallback_dir.exists():
return None
candidates = sorted(fallback_dir.glob("*.svg"))
for candidate in candidates:
if candidate.name.endswith("_stroke.svg"):
continue
return candidate
return candidates[0] if candidates else None
@app.post("/api/assets/from-job/{job_id}", response_model=Asset)
async def import_asset_from_job(
job_id: str,
name: str = Form(""),
type: str = Form("wordcloud"),
) -> Asset:
svg_path = _find_job_svg(job_id)
if svg_path is None or not svg_path.exists():
raise HTTPException(status_code=404, detail="svg not found")
@@ -914,16 +963,18 @@ async def import_asset_from_job(
asset_path.mkdir(parents=True, exist_ok=True)
dest = asset_path / "asset.svg"
shutil.copy2(svg_path, dest)
content = dest.read_bytes()
raw = svg_path.read_bytes()
# 画布贴纸需要透明底;任务主 SVG 默认带白底 rect
content = _svg_with_transparent_background(raw)
dest.write_bytes(content)
width, height = _parse_svg_viewbox(dest)
asset_type = (type or "wordcloud").strip() or "wordcloud"
display_name = name or f"词云_{job_id[:8]}"
meta = {
"asset_id": asset_id,
"name": display_name,
"type": "wordcloud",
"type": asset_type,
"mime_type": "image/svg+xml",
"width": width,
"height": height,
+6 -1
View File
@@ -96,7 +96,12 @@ class JobRunner:
log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed)
png = next(paths.output_dir.glob("*.png"), None)
svg = next(paths.output_dir.glob("*[!_stroke].svg"), 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.
svg = next(
(p for p in sorted(paths.output_dir.glob("*.svg")) if not p.name.endswith("_stroke.svg")),
None,
)
svg_stroke = next(paths.output_dir.glob("*_stroke.svg"), None)
db = next(paths.output_dir.glob("*.db"), None)
metrics = next(paths.output_dir.glob("*metrics*.json"), None)