@xiaobuyu/nodesign 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +140 -0
- package/bin/nodesign.js +176 -0
- package/package.json +104 -0
- package/server/api/README.md +45 -0
- package/server/api/_guard.js +75 -0
- package/server/api/admin.js +187 -0
- package/server/api/assets/docx-page.js +103 -0
- package/server/api/assets/helpers.js +22 -0
- package/server/api/assets/notes.js +144 -0
- package/server/api/assets.js +896 -0
- package/server/api/board.js +57 -0
- package/server/api/browse.js +138 -0
- package/server/api/canvas.js +444 -0
- package/server/api/chatai.js +324 -0
- package/server/api/exports/build-standalone.js +980 -0
- package/server/api/exports/cards.js +118 -0
- package/server/api/exports/docx-pdf.js +46 -0
- package/server/api/exports/export-page.js +142 -0
- package/server/api/exports/handoff.js +147 -0
- package/server/api/exports.js +690 -0
- package/server/api/instruction.js +75 -0
- package/server/api/local.js +115 -0
- package/server/api/me.js +116 -0
- package/server/api/memory.js +185 -0
- package/server/api/pending-changes.js +366 -0
- package/server/api/plugins.js +164 -0
- package/server/api/projects.js +220 -0
- package/server/api/publish.js +64 -0
- package/server/api/recent.js +73 -0
- package/server/api/sessions.js +589 -0
- package/server/api/skills.js +77 -0
- package/server/api/standalone-fit.js +168 -0
- package/server/api/turn-compose.js +180 -0
- package/server/api/turn-inflight.js +44 -0
- package/server/api/turn.js +669 -0
- package/server/auth/README.md +17 -0
- package/server/auth/middleware.js +138 -0
- package/server/auth/origin-guard.js +71 -0
- package/server/auth/session.js +108 -0
- package/server/auth/tier.js +113 -0
- package/server/auth/users-store.js +348 -0
- package/server/edit/README.md +29 -0
- package/server/engine/README.md +113 -0
- package/server/engine/agent/agent-shared.js +550 -0
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -0
- package/server/engine/agent/auto-mode-rules.js +78 -0
- package/server/engine/agent/context.js +418 -0
- package/server/engine/agent/events.js +464 -0
- package/server/engine/agent/hooks/canvas-validate.js +199 -0
- package/server/engine/agent/hooks/failure.js +120 -0
- package/server/engine/agent/hooks/file-events.js +75 -0
- package/server/engine/agent/hooks/lifecycle.js +233 -0
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -0
- package/server/engine/agent/hooks/post-guidance.js +206 -0
- package/server/engine/agent/hooks/post-subagent-report.js +104 -0
- package/server/engine/agent/hooks/post-trim.js +58 -0
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -0
- package/server/engine/agent/hooks/pre-defaults.js +84 -0
- package/server/engine/agent/hooks/pre-injectors.js +277 -0
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -0
- package/server/engine/agent/hooks/pre-starter-files.js +61 -0
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -0
- package/server/engine/agent/hooks/site-validate.js +94 -0
- package/server/engine/agent/hooks/tool-prompts.js +31 -0
- package/server/engine/agent/hooks/turn-state-memory.js +56 -0
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -0
- package/server/engine/agent/hooks.js +354 -0
- package/server/engine/agent/init-contract.js +69 -0
- package/server/engine/agent/isolation.js +159 -0
- package/server/engine/agent/model-context.js +359 -0
- package/server/engine/agent/model-table.js +292 -0
- package/server/engine/agent/plugin-loader.js +266 -0
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -0
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -0
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -0
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -0
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -0
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -0
- package/server/engine/agent/prompts/tools/site-reference.md +106 -0
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -0
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -0
- package/server/engine/agent/session-loop.js +1024 -0
- package/server/engine/agent/session-model.js +164 -0
- package/server/engine/agent/skill.js +204 -0
- package/server/engine/agent/system-prompts.js +86 -0
- package/server/engine/agent/task-events.js +78 -0
- package/server/engine/agents/ds-extractor.md +132 -0
- package/server/engine/agents/explorer.md +190 -0
- package/server/engine/agents/index.js +230 -0
- package/server/engine/agents/schemas/design-system.json +156 -0
- package/server/engine/agents/schemas/tweak-schema.json +113 -0
- package/server/engine/agents/tweak-proposer.md +127 -0
- package/server/engine/agents/vision-checker.md +254 -0
- package/server/engine/browse/capture.js +422 -0
- package/server/engine/browse/card.js +137 -0
- package/server/engine/browse/handover.js +59 -0
- package/server/engine/browse/page-digest.js +119 -0
- package/server/engine/browse/refs.js +196 -0
- package/server/engine/browse/registry.js +307 -0
- package/server/engine/browse/screencast.js +170 -0
- package/server/engine/browse/state.js +121 -0
- package/server/engine/chatai/chat-log.js +79 -0
- package/server/engine/chatai/index.js +193 -0
- package/server/engine/chatai/openai-compat.js +152 -0
- package/server/engine/chatai/orchestrate.js +309 -0
- package/server/engine/chatai/perform.js +40 -0
- package/server/engine/chatai/summarize.js +79 -0
- package/server/engine/mcp/capability-gate.js +52 -0
- package/server/engine/mcp/index.js +339 -0
- package/server/engine/mcp/param-sanitizer.js +142 -0
- package/server/engine/mcp/tools/arrange-on-board.js +124 -0
- package/server/engine/mcp/tools/artifact-session.js +278 -0
- package/server/engine/mcp/tools/browse-computer.js +421 -0
- package/server/engine/mcp/tools/browse-find-batch.js +183 -0
- package/server/engine/mcp/tools/browse-screenshot.js +157 -0
- package/server/engine/mcp/tools/browse.js +480 -0
- package/server/engine/mcp/tools/build-docx.js +101 -0
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -0
- package/server/engine/mcp/tools/create-on-board.js +155 -0
- package/server/engine/mcp/tools/crystallize-skill.js +168 -0
- package/server/engine/mcp/tools/deliver-files.js +0 -0
- package/server/engine/mcp/tools/explain-style.js +237 -0
- package/server/engine/mcp/tools/export-handoff.js +133 -0
- package/server/engine/mcp/tools/expose-tweaks.js +149 -0
- package/server/engine/mcp/tools/generate-image.js +842 -0
- package/server/engine/mcp/tools/get-computed-styles.js +164 -0
- package/server/engine/mcp/tools/get-pending-changes.js +205 -0
- package/server/engine/mcp/tools/h3box-ssh.js +84 -0
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -0
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -0
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -0
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -0
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -0
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -0
- package/server/engine/mcp/tools/helpers/rembg.js +273 -0
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -0
- package/server/engine/mcp/tools/highlight.js +68 -0
- package/server/engine/mcp/tools/list-pages.js +222 -0
- package/server/engine/mcp/tools/lookup-tags.js +59 -0
- package/server/engine/mcp/tools/navigate-to-page.js +60 -0
- package/server/engine/mcp/tools/organize-board.js +64 -0
- package/server/engine/mcp/tools/paint-still.js +434 -0
- package/server/engine/mcp/tools/pin-to-board.js +130 -0
- package/server/engine/mcp/tools/preview-deck.js +130 -0
- package/server/engine/mcp/tools/profile-scroll.js +305 -0
- package/server/engine/mcp/tools/publish-site.js +102 -0
- package/server/engine/mcp/tools/query-elements.js +195 -0
- package/server/engine/mcp/tools/read-board.js +103 -0
- package/server/engine/mcp/tools/read-document.js +93 -0
- package/server/engine/mcp/tools/read-page.js +264 -0
- package/server/engine/mcp/tools/read-tavern-json.js +143 -0
- package/server/engine/mcp/tools/record-decision.js +140 -0
- package/server/engine/mcp/tools/relate-on-board.js +110 -0
- package/server/engine/mcp/tools/remove-background.js +391 -0
- package/server/engine/mcp/tools/report-issue.js +115 -0
- package/server/engine/mcp/tools/roll-film.js +266 -0
- package/server/engine/mcp/tools/screenshot-docx.js +138 -0
- package/server/engine/mcp/tools/screenshot-url.js +195 -0
- package/server/engine/mcp/tools/screenshot.js +583 -0
- package/server/engine/mcp/tools/tier-gate.js +96 -0
- package/server/engine/mcp/tools/trace-motion.js +215 -0
- package/server/engine/mcp/tools/web-search.js +548 -0
- package/server/engine/motion/inventory.js +349 -0
- package/server/engine/perception/session.js +235 -0
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -0
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -0
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -0
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -0
- package/server/engine/runs/active-runs.js +698 -0
- package/server/engine/runs/live-turn.js +252 -0
- package/server/engine/runs/store.js +364 -0
- package/server/engine/runs/turn-relay.js +217 -0
- package/server/engine/runtime/workspace.js +164 -0
- package/server/index.js +235 -0
- package/server/lib/artifact-file-path.js +80 -0
- package/server/lib/artifact-target.js +284 -0
- package/server/lib/asset-refs.js +239 -0
- package/server/lib/async-queue.js +104 -0
- package/server/lib/auto-relations.js +130 -0
- package/server/lib/binding-types.js +60 -0
- package/server/lib/board-kind-sizes.js +62 -0
- package/server/lib/board-relations.js +133 -0
- package/server/lib/browse-proxy.js +180 -0
- package/server/lib/canvas-id.js +36 -0
- package/server/lib/cover.js +227 -0
- package/server/lib/danbooru-tags.js +291 -0
- package/server/lib/doc-extract.js +156 -0
- package/server/lib/docx/build-from-source.js +260 -0
- package/server/lib/docx/build.js +490 -0
- package/server/lib/docx/dump-styles.js +287 -0
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -0
- package/server/lib/docx/merge-runs.js +129 -0
- package/server/lib/docx/numbering.js +218 -0
- package/server/lib/docx/order.js +152 -0
- package/server/lib/docx/rawzip.js +172 -0
- package/server/lib/docx/render.js +92 -0
- package/server/lib/docx/text-lint.js +225 -0
- package/server/lib/docx/tokens.js +335 -0
- package/server/lib/docx/units.js +29 -0
- package/server/lib/docx/xml.js +291 -0
- package/server/lib/docx-pages.js +187 -0
- package/server/lib/export-collect.js +289 -0
- package/server/lib/export-package.js +210 -0
- package/server/lib/html-srcset.js +145 -0
- package/server/lib/image-variant.js +460 -0
- package/server/lib/ingress/forward-openai-chat.js +305 -0
- package/server/lib/ingress/openai-chat.js +491 -0
- package/server/lib/ingress/session-notice.js +66 -0
- package/server/lib/ingress/session-routes.js +68 -0
- package/server/lib/ingress/slot-probe.js +130 -0
- package/server/lib/ingress/upstream-billing.js +52 -0
- package/server/lib/ingress/upstream-fail-streak.js +72 -0
- package/server/lib/ingress/upstream-truncation.js +48 -0
- package/server/lib/issues-store.js +182 -0
- package/server/lib/kinds/deck.js +72 -0
- package/server/lib/kinds/docx.js +283 -0
- package/server/lib/kinds/file-kinds.js +113 -0
- package/server/lib/kinds/index.js +202 -0
- package/server/lib/kinds/site.js +161 -0
- package/server/lib/model-ingress.js +543 -0
- package/server/lib/moderation.js +255 -0
- package/server/lib/notice-store.js +103 -0
- package/server/lib/plugin-install.js +104 -0
- package/server/lib/plugin-validator.js +721 -0
- package/server/lib/publish-store.js +115 -0
- package/server/lib/quick-summary.js +52 -0
- package/server/lib/quota.js +270 -0
- package/server/lib/rate-window.js +29 -0
- package/server/lib/reference-assets.js +92 -0
- package/server/lib/region-shot.js +135 -0
- package/server/lib/safe-path.js +73 -0
- package/server/lib/sdk-session.js +33 -0
- package/server/lib/showcase-store.js +92 -0
- package/server/lib/site-publish.js +465 -0
- package/server/lib/ssrf-guard.js +432 -0
- package/server/lib/task-scan.js +158 -0
- package/server/lib/tavern-json.js +154 -0
- package/server/lib/video-variant.js +237 -0
- package/server/lib/workspace-path.js +33 -0
- package/server/ops/fix-sdk-musl.mjs +40 -0
- package/server/ops/install-macos-fonts.sh +114 -0
- package/server/ops/macos-fonts.conf +336 -0
- package/server/ops/sandbox-shim/bwrap +57 -0
- package/server/projects/assets-summary.js +119 -0
- package/server/projects/auto-name.js +53 -0
- package/server/projects/board-store.js +640 -0
- package/server/projects/move-entry.js +103 -0
- package/server/projects/store.js +270 -0
- package/server/projects/ui-config.js +54 -0
- package/server/projects/workspace-templates.js +53 -0
- package/server/projects/workspace.js +1025 -0
- package/server/runtime/capabilities.js +153 -0
- package/server/runtime/local-config.js +177 -0
- package/server/runtime/local-env.js +88 -0
- package/server/runtime/platform.js +342 -0
- package/server/runtime/profile.js +72 -0
- package/server/services/rembg-launcher.js +313 -0
- package/server/services/rembg-service.py +334 -0
- package/server/shared/README.md +25 -0
- package/server/shared/deck.js +54 -0
- package/server/shared/time.js +49 -0
- package/server/style-pipeline/README.md +53 -0
- package/server/ws/broker.js +36 -0
- package/server/ws/browse-channel.js +177 -0
- package/server/ws/index.js +386 -0
- package/web/README.md +89 -0
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +11 -0
- package/web/dist/assets/DeckWindow-4G0xX7P3.js +16 -0
- package/web/dist/assets/DocxWindow-BXQsoPkh.js +16 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SiteWindow-D6eS-2gM.js +18 -0
- package/web/dist/assets/again-D8tfgKKO.webp +0 -0
- package/web/dist/assets/caveat-nd-CP6HlsNg.woff2 +0 -0
- package/web/dist/assets/clock-xJhvfYpS.webp +0 -0
- package/web/dist/assets/film-cut-BBKMwH7q.webp +0 -0
- package/web/dist/assets/film-still-BZ_DL9Tt.webp +0 -0
- package/web/dist/assets/index-C0KUf6Wd.css +1 -0
- package/web/dist/assets/index-Cbl3ATvp.js +2125 -0
- package/web/dist/assets/ink-desk-BRbxeDsR.webp +0 -0
- package/web/dist/assets/ink-night-Bz4BT3XH.webp +0 -0
- package/web/dist/assets/ink-portrait-C0DM4V8Q.webp +0 -0
- package/web/dist/assets/longcang-regular-C0uvTuKa.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-bold-DbRfiTgo.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-regular-CpMw9Ehg.woff2 +0 -0
- package/web/dist/assets/pending-edit-apply-DTjo2geW.js +31 -0
- package/web/dist/assets/question-8H4HSqxh.webp +0 -0
- package/web/dist/assets/reject-BaPJqElj.webp +0 -0
- package/web/dist/assets/rotate-cw-LMT3x1Zj.js +11 -0
- package/web/dist/assets/rp-portrait-CbrGpew2.webp +0 -0
- package/web/dist/assets/rp-street-C4jVKjxx.webp +0 -0
- package/web/dist/assets/square-dashed-mouse-pointer-CBoAkfhi.js +11 -0
- package/web/dist/assets/tangle-DIxHVNuT.webp +0 -0
- package/web/dist/assets/thisone-BylZKglV.webp +0 -0
- package/web/dist/assets/thumb-DO1wfK1I.webp +0 -0
- package/web/dist/index.html +18 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/image-variant.js — 显示用图片派生层(webp / avif / 多宽度)
|
|
3
|
+
*
|
|
4
|
+
* 规矩:**磁盘上的原图是交付物,一个字节都不改;HTTP 显示路径一律发派生图。**
|
|
5
|
+
* 导出那三条路(build-standalone 的 inline base64 / PDF-PPTX 的 file:// 渲染 /
|
|
6
|
+
* 站点 zip 的文件拷贝)全部从磁盘直读,不经过任何 HTTP 资源路由,所以最终交付
|
|
7
|
+
* 拿到的永远是原图。这条边界是既有的,本模块只是靠着它站。
|
|
8
|
+
*
|
|
9
|
+
* ── 这台机器只有 1 核 ──────────────────────────────────────────────
|
|
10
|
+
* 所有设计取舍都从这条来。实测(Neoverse-V2 单核,3.1MB PNG 源):
|
|
11
|
+
* webp q82 全尺寸 259ms
|
|
12
|
+
* webp q78 长边512 53ms
|
|
13
|
+
* avif q55 全尺寸 ~2-4s ← 绝不能放在请求路径上
|
|
14
|
+
* 所以分两档处理:
|
|
15
|
+
* **请求路径**只做 webp。冷缓存 259ms 可以忍,且预热之后基本不会发生。
|
|
16
|
+
* **后台队列**做 avif 和其它宽度档。串行 + 每件之间让出 CPU,不跟请求抢。
|
|
17
|
+
* 队列没编完的规格一律降级发上一档(avif 没有就发 webp),不让用户等。
|
|
18
|
+
*
|
|
19
|
+
* ── 缓存 ──────────────────────────────────────────────────────────
|
|
20
|
+
* <cacheDir>/<sha1(源路径|mtime|size|规格)>.<ext>。key 带 mtime + size,agent
|
|
21
|
+
* 重画同名文件下次请求自然重编码,不需要清缓存这一步。
|
|
22
|
+
*
|
|
23
|
+
* 并发去重:同一 key 同时到 N 个请求只编码一次。站点页面一次能打十几张图,
|
|
24
|
+
* 冷缓存时不去重就是十几个 sharp 管线在 1 个核上互相拖。
|
|
25
|
+
*
|
|
26
|
+
* fail-soft:sharp 任何异常返 null,调用方原样发原图。一张坏图不该变成 500。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { platform } from '../runtime/platform.js';
|
|
31
|
+
import fs from 'node:fs/promises';
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
import sharp from 'sharp';
|
|
34
|
+
|
|
35
|
+
const CACHE_DIR = path.join(platform.cacheRoot, 'variants');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 每种格式的默认质量 + 文件扩展名 + 编码器。
|
|
39
|
+
* avif 的 effort 压到 3(默认 4):单核上每一档 effort 都是实打实的秒。
|
|
40
|
+
* q55 的 avif 观感约等于 q82 的 webp,体积再小两三成。
|
|
41
|
+
*/
|
|
42
|
+
const FORMATS = {
|
|
43
|
+
webp: {
|
|
44
|
+
ext: '.webp',
|
|
45
|
+
mime: 'image/webp',
|
|
46
|
+
quality: Number(process.env.NODESIGN_WEBP_QUALITY) || 82,
|
|
47
|
+
encode: (p, q) => p.webp({ quality: q }),
|
|
48
|
+
},
|
|
49
|
+
avif: {
|
|
50
|
+
ext: '.avif',
|
|
51
|
+
mime: 'image/avif',
|
|
52
|
+
quality: Number(process.env.NODESIGN_AVIF_QUALITY) || 55,
|
|
53
|
+
encode: (p, q) => p.avif({ quality: q, effort: 3 }),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 只转这三种源格式。有意排除的:
|
|
59
|
+
* .webp — 已经是目标格式之一,再编一遍是二次有损(avif 档另说,见 pickFormat)
|
|
60
|
+
* .svg — 矢量,本来就是几 KB 的文本,转成位图是倒退
|
|
61
|
+
* .gif — 动图。sharp 要 animated:true 才不丢帧,而 GIF 在这里基本只有表情图,
|
|
62
|
+
* 不值得为它多一条分支;原样发。
|
|
63
|
+
*/
|
|
64
|
+
const TRANSCODABLE = new Set(['.png', '.jpg', '.jpeg']);
|
|
65
|
+
|
|
66
|
+
/** 小于这个的不转:编码开销比省下的字节还贵,且小图多半是图标 */
|
|
67
|
+
const MIN_SOURCE_BYTES = 4096;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 缩略图规格的唯一出处 —— generate_image 落盘时按它编,资源路由给老图现补时
|
|
71
|
+
* 也按它编。两边写各自的数字会让"预生成"和"现补"出两种尺寸的图,而这种不一致
|
|
72
|
+
* 只会表现为某些图偶尔糊一点,没人查得出来。
|
|
73
|
+
* webp q78 的观感约等于原来的 mozjpeg q80,体积再小三成。
|
|
74
|
+
*/
|
|
75
|
+
export const THUMBNAIL_MAX_DIM = Number(process.env.NODESIGN_THUMBNAIL_MAX_DIM) || 512;
|
|
76
|
+
export const THUMBNAIL_QUALITY = Number(process.env.NODESIGN_THUMBNAIL_QUALITY) || 78;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ?w= 只认这几档。开放任意宽度等于让任何人用 ?w=1,2,3... 把磁盘刷爆,
|
|
80
|
+
* 而且每多一档就多一份要预热的编码量(单核上这是真钱)。
|
|
81
|
+
* 480 覆盖手机,960 覆盖平板和一般栏宽,1440 覆盖桌面满宽和 2x 的 720。
|
|
82
|
+
*/
|
|
83
|
+
export const RESPONSIVE_WIDTHS = Object.freeze([480, 960, 1440]);
|
|
84
|
+
|
|
85
|
+
/** key → Promise<{buf, etag, mime}|null>,编码中的请求挂在同一个 promise 上 */
|
|
86
|
+
const inFlight = new Map();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 缩略图地址:assets/generated/.thumbnails/<name>.thumb.webp
|
|
90
|
+
* 同时认 .thumb.jpg,因为 2026-07-31 之前发出去的 URL 还在浏览器缓存和已保存的
|
|
91
|
+
* HTML 里流通,而磁盘上那批 .thumb.jpg 已经删了。
|
|
92
|
+
*/
|
|
93
|
+
const THUMB_RE = /^(.*)[/\\]\.thumbnails[/\\](.+)\.thumb\.(?:webp|jpg)$/;
|
|
94
|
+
|
|
95
|
+
/** 这个扩展名值不值得转 */
|
|
96
|
+
export function shouldTranscode(ext) {
|
|
97
|
+
return TRANSCODABLE.has(String(ext || '').toLowerCase());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 客户端最想要哪种格式。avif > webp > 原样。
|
|
102
|
+
* 所有现役浏览器发 <img> 请求都会带 Accept;curl / 老客户端不带就走原图。
|
|
103
|
+
*/
|
|
104
|
+
export function pickFormat(req) {
|
|
105
|
+
const accept = String(req.headers?.accept || '');
|
|
106
|
+
if (accept.includes('image/avif')) return 'avif';
|
|
107
|
+
if (accept.includes('image/webp')) return 'webp';
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* ?w= 解析。不在白名单里的一律当没传(不报错:这是显示优化,不是 API 契约,
|
|
113
|
+
* 参数写错该退回全尺寸而不是让页面裂图)。
|
|
114
|
+
*/
|
|
115
|
+
export function parseWidth(req) {
|
|
116
|
+
const w = Number(req.query?.w);
|
|
117
|
+
return RESPONSIVE_WIDTHS.includes(w) ? w : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ?f= 显式指定格式,绕开 Accept 协商。
|
|
122
|
+
*
|
|
123
|
+
* 存在的理由是缓存正确性:按 Accept 协商的响应必须带 Vary: Accept,而中间缓存
|
|
124
|
+
* (包括 Cloudflare 免费版,它只认 Vary: Accept-Encoding)会忽略它,把 avif 喂给
|
|
125
|
+
* 不认 avif 的客户端。URL 里写死格式的响应没有这个歧义,可以放心长缓存。
|
|
126
|
+
*
|
|
127
|
+
* 用在我们自己产的 HTML 上(deck 预览重写、产物墙缩略图);用户站点的 <img>
|
|
128
|
+
* 不用它,走 Accept 协商,因为站点 HTML 是 agent 写的,不该被我们钉死格式。
|
|
129
|
+
*/
|
|
130
|
+
export function parseFormat(req) {
|
|
131
|
+
const f = String(req.query?.f || '');
|
|
132
|
+
return FORMATS[f] ? f : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 源文件的版本标记:mtime + size 的短哈希。
|
|
137
|
+
*
|
|
138
|
+
* 用途是让 URL 变成"内容寻址"的:图变了标记就变,URL 就变,于是旧 URL 的缓存
|
|
139
|
+
* 永远不可能是脏的,可以放心发 immutable。
|
|
140
|
+
* 不用真正的内容哈希是因为那要把 3MB 读进来算一遍,而这个值在每次 serve HTML
|
|
141
|
+
* 时都要算一次。mtime + size 已经是变体缓存 key 的一部分,口径一致。
|
|
142
|
+
*/
|
|
143
|
+
export function versionTag(stat) {
|
|
144
|
+
return crypto.createHash('sha1')
|
|
145
|
+
.update(`${stat.mtimeMs}|${stat.size}`)
|
|
146
|
+
.digest('hex').slice(0, 10);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 该发什么 Cache-Control。
|
|
151
|
+
*
|
|
152
|
+
* 带 ?v= 的 URL 是内容寻址的(源图一变 v 就变),浏览器可以永久缓存不再回源;
|
|
153
|
+
* 不带的只能短缓存,因为同一个 URL 明天可能是另一张图。
|
|
154
|
+
*
|
|
155
|
+
* 一律 private:这些路由全部要登录,响应里可能是别人看不得的项目内容,
|
|
156
|
+
* 不能让任何共享缓存(含 CDN 边缘)存下来。真要上 CF 边缘缓存得另开一条
|
|
157
|
+
* 公开免鉴权的内容寻址路径,那是隐私决策不是性能决策。
|
|
158
|
+
*/
|
|
159
|
+
export function imageCacheControl(req) {
|
|
160
|
+
return req.query?.v
|
|
161
|
+
? 'private, max-age=31536000, immutable'
|
|
162
|
+
: 'private, max-age=300';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 这个绝对路径是不是在问一张缩略图 */
|
|
166
|
+
export function isThumbPath(absPath) {
|
|
167
|
+
return THUMB_RE.test(absPath);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 缩略图文件不存在时,回头找它的原图。
|
|
172
|
+
*
|
|
173
|
+
* 三种命中场景:① 老图(generate_image 加 thumbnail 流程之前生成的)
|
|
174
|
+
* ② thumbnail 当时生成失败 ③ 2026-07-31 之前的 .thumb.jpg(新地址问 .thumb.webp)。
|
|
175
|
+
* 三种都是同一个处理:拿原图现编一张 512 长边的 webp 缓存下来,等于把当年该生成
|
|
176
|
+
* 的那张现补出来。所以不需要 backfill 脚本。
|
|
177
|
+
*
|
|
178
|
+
* @returns {Promise<string|null>} 原图绝对路径,找不到时 null
|
|
179
|
+
*/
|
|
180
|
+
export async function findOriginalForThumbnail(absThumbPath) {
|
|
181
|
+
const m = absThumbPath.match(THUMB_RE);
|
|
182
|
+
if (!m) return null;
|
|
183
|
+
const [, parentDir, baseName] = m;
|
|
184
|
+
for (const ext of ['.png', '.jpg', '.jpeg', '.webp', '.gif']) {
|
|
185
|
+
const candidate = path.join(parentDir, baseName + ext);
|
|
186
|
+
try {
|
|
187
|
+
const s = await fs.stat(candidate);
|
|
188
|
+
if (s.isFile()) return candidate;
|
|
189
|
+
} catch { /* try next ext */ }
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function specOf(format, quality, maxDim) {
|
|
195
|
+
return `${format}/q${quality}/d${maxDim || 0}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function cacheKey(absPath, stat, spec) {
|
|
199
|
+
return crypto
|
|
200
|
+
.createHash('sha1')
|
|
201
|
+
.update(`${absPath}|${stat.mtimeMs}|${stat.size}|${spec}`)
|
|
202
|
+
.digest('hex');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function encode(absPath, format, quality, maxDim) {
|
|
206
|
+
const raw = await fs.readFile(absPath);
|
|
207
|
+
const meta = await sharp(raw).metadata();
|
|
208
|
+
let pipeline = sharp(raw);
|
|
209
|
+
if (maxDim) {
|
|
210
|
+
const longEdge = Math.max(meta.width || 0, meta.height || 0);
|
|
211
|
+
if (longEdge > maxDim) {
|
|
212
|
+
pipeline = pipeline.resize({
|
|
213
|
+
width: (meta.width || 0) >= (meta.height || 0) ? maxDim : null,
|
|
214
|
+
height: (meta.height || 0) > (meta.width || 0) ? maxDim : null,
|
|
215
|
+
fit: 'inside',
|
|
216
|
+
withoutEnlargement: true,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// 不 flatten:webp / avif 都支持 alpha,透明该留着。JPEG 缩略图那条老路必须
|
|
221
|
+
// 铺白底,抠图产物在预览里就带一圈白。
|
|
222
|
+
return FORMATS[format].encode(pipeline, quality).toBuffer();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 取(或生成)某张图的派生图。
|
|
227
|
+
*
|
|
228
|
+
* @param {string} absPath 源图绝对路径(调用方已做过 traversal 校验)
|
|
229
|
+
* @param {import('node:fs').Stats} stat 源图 stat(调用方已经 stat 过,别重复 IO)
|
|
230
|
+
* @param {{ format?: string, quality?: number, maxDim?: number|null, cacheOnly?: boolean }} [opts]
|
|
231
|
+
* cacheOnly — 只查缓存,不编码。avif 走这个:单核上现编要几秒,宁可这次发 webp
|
|
232
|
+
* 再让后台补,也不让一张图把请求挂住。
|
|
233
|
+
* @returns {Promise<{ buf: Buffer, etag: string, mime: string }|null>} null = 没有,用上一档
|
|
234
|
+
*/
|
|
235
|
+
export async function imageVariant(absPath, stat, opts = {}) {
|
|
236
|
+
const format = opts.format || 'webp';
|
|
237
|
+
const conf = FORMATS[format];
|
|
238
|
+
if (!conf) return null;
|
|
239
|
+
const quality = opts.quality ?? conf.quality;
|
|
240
|
+
const maxDim = opts.maxDim ?? null;
|
|
241
|
+
|
|
242
|
+
if (!shouldTranscode(path.extname(absPath))) return null;
|
|
243
|
+
if (stat.size < MIN_SOURCE_BYTES) return null;
|
|
244
|
+
|
|
245
|
+
const spec = specOf(format, quality, maxDim);
|
|
246
|
+
const key = cacheKey(absPath, stat, spec);
|
|
247
|
+
const cacheFile = path.join(CACHE_DIR, `${key}${conf.ext}`);
|
|
248
|
+
|
|
249
|
+
if (opts.cacheOnly) {
|
|
250
|
+
try {
|
|
251
|
+
return { buf: await fs.readFile(cacheFile), etag: key, mime: conf.mime };
|
|
252
|
+
} catch { return null; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const hit = inFlight.get(key);
|
|
256
|
+
if (hit) return hit;
|
|
257
|
+
|
|
258
|
+
const job = (async () => {
|
|
259
|
+
try {
|
|
260
|
+
return { buf: await fs.readFile(cacheFile), etag: key, mime: conf.mime };
|
|
261
|
+
} catch { /* 冷缓存,往下编 */ }
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const buf = await encode(absPath, format, quality, maxDim);
|
|
265
|
+
// 编出来比原图还大就别用了(已经压得很好的 JPEG 偶尔会这样)
|
|
266
|
+
if (buf.length >= stat.size) return null;
|
|
267
|
+
await fs.mkdir(CACHE_DIR, { recursive: true });
|
|
268
|
+
// 先写临时名再 rename:并发进程/重启撞在一起时不会读到半截文件
|
|
269
|
+
const tmp = `${cacheFile}.${process.pid}.tmp`;
|
|
270
|
+
await fs.writeFile(tmp, buf);
|
|
271
|
+
await fs.rename(tmp, cacheFile).catch(() => {});
|
|
272
|
+
return { buf, etag: key, mime: conf.mime };
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.warn(`[image-variant] ${format} failed for ${path.basename(absPath)}: ${err.message}`);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
})().finally(() => { inFlight.delete(key); });
|
|
278
|
+
|
|
279
|
+
inFlight.set(key, job);
|
|
280
|
+
return job;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── 后台预热队列 ────────────────────────────────────────────────────
|
|
284
|
+
//
|
|
285
|
+
// 单核机器上这个队列必须是串行的,而且每件之间要真的让出 CPU,否则预热会把
|
|
286
|
+
// 正在看页面的人卡住。JS 是单线程但 sharp 跑在 libuv 线程池里,1 个核上一次
|
|
287
|
+
// 编码就是把核占满,所以"让出"靠的是件与件之间的间隔,不是 await 本身。
|
|
288
|
+
|
|
289
|
+
/** 队列项:{ absPath, stat, format, quality, maxDim, key } */
|
|
290
|
+
const warmQueue = [];
|
|
291
|
+
const warmSeen = new Set();
|
|
292
|
+
let warmRunning = false;
|
|
293
|
+
|
|
294
|
+
/** 队列上限:防止一次扫全库把内存撑爆(每项只存路径和数字,很小,但要有底) */
|
|
295
|
+
const WARM_QUEUE_MAX = 5000;
|
|
296
|
+
|
|
297
|
+
/** 每件之间歇多久。单核上这是给请求路径留的口子 */
|
|
298
|
+
const WARM_GAP_MS = Number(process.env.NODESIGN_WARM_GAP_MS) || 120;
|
|
299
|
+
|
|
300
|
+
async function drainWarmQueue() {
|
|
301
|
+
if (warmRunning) return;
|
|
302
|
+
warmRunning = true;
|
|
303
|
+
try {
|
|
304
|
+
while (warmQueue.length) {
|
|
305
|
+
const job = warmQueue.shift();
|
|
306
|
+
warmSeen.delete(job.key);
|
|
307
|
+
try {
|
|
308
|
+
await imageVariant(job.absPath, job.stat, {
|
|
309
|
+
format: job.format, quality: job.quality, maxDim: job.maxDim,
|
|
310
|
+
});
|
|
311
|
+
} catch { /* imageVariant 自己 fail-soft 了,这里只是兜底 */ }
|
|
312
|
+
if (warmQueue.length) await new Promise(r => setTimeout(r, WARM_GAP_MS));
|
|
313
|
+
}
|
|
314
|
+
} finally {
|
|
315
|
+
warmRunning = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 把一张图的若干规格排进后台预热队列。立即返回,不等编码。
|
|
321
|
+
*
|
|
322
|
+
* 谁调用:① generate_image 落盘之后(此时用户正在等模型出下一段话,CPU 闲着)
|
|
323
|
+
* ② 请求路径发现 avif 没缓存时(这次发 webp,下次就有 avif 了)
|
|
324
|
+
*
|
|
325
|
+
* @param {string} absPath
|
|
326
|
+
* @param {import('node:fs').Stats} stat
|
|
327
|
+
* @param {Array<{format?: string, quality?: number, maxDim?: number|null}>} specs
|
|
328
|
+
*/
|
|
329
|
+
export function enqueueWarm(absPath, stat, specs) {
|
|
330
|
+
if (!shouldTranscode(path.extname(absPath))) return;
|
|
331
|
+
if (stat.size < MIN_SOURCE_BYTES) return;
|
|
332
|
+
for (const s of specs) {
|
|
333
|
+
const format = s.format || 'webp';
|
|
334
|
+
if (!FORMATS[format]) continue;
|
|
335
|
+
const quality = s.quality ?? FORMATS[format].quality;
|
|
336
|
+
const maxDim = s.maxDim ?? null;
|
|
337
|
+
const key = cacheKey(absPath, stat, specOf(format, quality, maxDim));
|
|
338
|
+
if (warmSeen.has(key)) continue;
|
|
339
|
+
if (warmQueue.length >= WARM_QUEUE_MAX) return;
|
|
340
|
+
warmSeen.add(key);
|
|
341
|
+
warmQueue.push({ absPath, stat, format, quality, maxDim, key });
|
|
342
|
+
}
|
|
343
|
+
drainWarmQueue();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 一张图的标准预热套餐。
|
|
348
|
+
*
|
|
349
|
+
* 全尺寸 webp 是首屏真正会用到的那张(站点按真实设备宽取景,不缩尺寸);
|
|
350
|
+
* 512 缩略图给 deck 预览和产物墙;三档响应式宽度给 srcset;avif 各档垫底。
|
|
351
|
+
* 顺序有意:先把请求路径会立刻要的 webp 编完,再慢慢补 avif。
|
|
352
|
+
*/
|
|
353
|
+
export function warmSpecsFor() {
|
|
354
|
+
const specs = [
|
|
355
|
+
{ format: 'webp', maxDim: null },
|
|
356
|
+
{ format: 'webp', maxDim: THUMBNAIL_MAX_DIM, quality: THUMBNAIL_QUALITY },
|
|
357
|
+
];
|
|
358
|
+
for (const w of RESPONSIVE_WIDTHS) specs.push({ format: 'webp', maxDim: w });
|
|
359
|
+
specs.push({ format: 'avif', maxDim: null });
|
|
360
|
+
for (const w of RESPONSIVE_WIDTHS) specs.push({ format: 'avif', maxDim: w });
|
|
361
|
+
return specs;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 队列现状(预热脚本和 /admin 观测用) */
|
|
365
|
+
export function warmQueueStats() {
|
|
366
|
+
return { pending: warmQueue.length, running: warmRunning };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 资源路由共用的应答收尾:按 Accept 和 ?w= 选最合适的一档发出去。
|
|
371
|
+
*
|
|
372
|
+
* 降级链:avif(只查缓存)→ webp(可现编)→ 原图。
|
|
373
|
+
* avif 没命中时顺手把它排进后台队列,下一个访问者就有了。
|
|
374
|
+
*
|
|
375
|
+
* Vary: Accept 是必须的:同一 URL 对带 image/avif、只带 image/webp、和什么都不带
|
|
376
|
+
* 的客户端返回不同字节,浏览器和任何中间缓存不加 Vary 会串味。
|
|
377
|
+
*
|
|
378
|
+
* @param {object} req
|
|
379
|
+
* @param {object} res
|
|
380
|
+
* @param {string} absPath
|
|
381
|
+
* @param {import('node:fs').Stats} stat
|
|
382
|
+
* @param {{ fallbackMime: string, maxDim?: number|null, quality?: number }} opts
|
|
383
|
+
* maxDim — 调用方定死的长边(缩略图路径传 512)。为 null 时看 ?w=。
|
|
384
|
+
*/
|
|
385
|
+
export async function sendImage(req, res, absPath, stat, opts) {
|
|
386
|
+
const maxDim = opts.maxDim ?? parseWidth(req);
|
|
387
|
+
const transcodable = shouldTranscode(path.extname(absPath));
|
|
388
|
+
const forced = transcodable ? parseFormat(req) : null;
|
|
389
|
+
|
|
390
|
+
// 只有按 Accept 协商时才需要 Vary。URL 里写死了格式的响应对所有客户端同一份,
|
|
391
|
+
// 加 Vary 只会让缓存白白多分一档。
|
|
392
|
+
//
|
|
393
|
+
// 必须用 res.vary() 追加而不是 setHeader 覆盖:cors 中间件已经设了 Vary: Origin
|
|
394
|
+
// (origin:true 会把请求的 Origin 回显进 Access-Control-Allow-Origin),覆盖掉
|
|
395
|
+
// 它就等于告诉缓存"这个响应跟 Origin 无关",跨源缓存会拿到别人 Origin 的那份。
|
|
396
|
+
if (!forced) res.vary('Accept');
|
|
397
|
+
|
|
398
|
+
const want = forced || (transcodable ? pickFormat(req) : null);
|
|
399
|
+
|
|
400
|
+
let variant = null;
|
|
401
|
+
if (want === 'avif') {
|
|
402
|
+
// 单核上 avif 现编要几秒(实测 651ms 起,大图更久),只认缓存。
|
|
403
|
+
// 没有就这次发 webp,同时排后台,下一个访问者就有了。
|
|
404
|
+
variant = await imageVariant(absPath, stat, { format: 'avif', maxDim, cacheOnly: true });
|
|
405
|
+
if (!variant) {
|
|
406
|
+
enqueueWarm(absPath, stat, [{ format: 'avif', maxDim }]);
|
|
407
|
+
// ?f=avif 是调用方点名要的,降级发 webp 会让 Content-Type 跟 URL 说的不一致。
|
|
408
|
+
// 这没问题:客户端信 Content-Type,而且这条路只有我们自己的 HTML 会走。
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!variant && want) {
|
|
412
|
+
variant = await imageVariant(absPath, stat, { format: 'webp', maxDim, quality: opts.quality });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (variant) {
|
|
416
|
+
if (req.headers['if-none-match'] === `"${variant.etag}"`) return res.status(304).end();
|
|
417
|
+
res.setHeader('ETag', `"${variant.etag}"`);
|
|
418
|
+
res.setHeader('Content-Type', variant.mime);
|
|
419
|
+
res.setHeader('Content-Length', variant.buf.length);
|
|
420
|
+
return res.end(variant.buf);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
res.setHeader('Content-Type', opts.fallbackMime);
|
|
424
|
+
res.setHeader('Content-Length', stat.size);
|
|
425
|
+
return res.end(await fs.readFile(absPath));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* 产物目录里落一份**兄弟 webp**(2026-08-18)。
|
|
430
|
+
*
|
|
431
|
+
* 跟这个模块本来的活刻意分开:派生层落的是 server 缓存、按 HTTP 显示路径发,
|
|
432
|
+
* **磁盘原图一个字节不改**;这个函数落的是产物目录里一个真实文件,因为站点
|
|
433
|
+
* 源码里要 `<img src="...webp">` 引得到它,发布/导出也要带出去。
|
|
434
|
+
*
|
|
435
|
+
* 为什么必须有:落盘一直是 PNG,而 PNG 是无损格式,对摄影级生成图是最差选择。
|
|
436
|
+
* 一个站引了 15 张生成图 = 33.7MB,转 webp q82 之后 1.1MB(3%),视觉看不出
|
|
437
|
+
* 差别,滚动掉帧从 63% 降到 9%。这个损失同时打在首屏、帧率和导出包体积上。
|
|
438
|
+
*
|
|
439
|
+
* 质量跟派生层同一个 q82(同一个常量,不另立第二个数字)。
|
|
440
|
+
* @returns {Promise<{rel: string, bytes: number}|null>} null = 编不出来(不致命)
|
|
441
|
+
*/
|
|
442
|
+
export async function writeWebpSibling(absPng, buf, relDir) {
|
|
443
|
+
if (!/\.png$/i.test(absPng)) return null;
|
|
444
|
+
const absWebp = absPng.replace(/\.png$/i, '.webp');
|
|
445
|
+
try {
|
|
446
|
+
const out = await sharp(buf).webp({ quality: FORMATS.webp.quality }).toBuffer();
|
|
447
|
+
await fs.writeFile(absWebp, out);
|
|
448
|
+
// ⭐ 顺带预热派生档。这两件事是一件事的两半:兄弟 webp 是**真文件**(给 agent
|
|
449
|
+
// 在页面里引),预热填的是显示通道按 `?w=` 现编的那层缓存。两条产线以前各写
|
|
450
|
+
// 一遍,08-18 生图那边被顺手换掉一半 → 12 图站点冷开从 72ms 退回 5.4 秒,
|
|
451
|
+
// 而 paint_still 那边没变,于是分叉。收在一处就不会只改一半。
|
|
452
|
+
fs.stat(absPng)
|
|
453
|
+
.then(st => enqueueWarm(absPng, st, warmSpecsFor()))
|
|
454
|
+
.catch(() => { /* 预热是尽力而为,失败下次请求现编 */ });
|
|
455
|
+
return { rel: `${relDir}/${path.basename(absWebp)}`, bytes: out.length };
|
|
456
|
+
} catch (err) {
|
|
457
|
+
console.warn('[image-variant] 兄弟 webp 没编出来(不影响主流程):', err.message);
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|