Add advanced canvas editing and Ubuntu deployment
This commit is contained in:
@@ -13,7 +13,6 @@ __pycache__
|
||||
service_workspace
|
||||
service_assets
|
||||
service_projects
|
||||
service_design_templates
|
||||
service_fonts
|
||||
*.egg-info
|
||||
build
|
||||
|
||||
+7
-1
@@ -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"]
|
||||
|
||||
Executable
+24
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user