diff --git a/.env.example b/.env.example index 142e0e4..2c8e3e8 100644 --- a/.env.example +++ b/.env.example @@ -1,46 +1,26 @@ -# Gemini +# Gemini — mood analysis, recipe, florist note GEMINI_API_KEY= GEMINI_TEXT_MODEL=gemini-2.5-flash-lite -# Image generation -# IMAGE_PROVIDER: openai | gemini | mock -# mock = instant placeholder images, zero API calls (develop without burning quota) -IMAGE_PROVIDER=openai -OPENAI_API_KEY=your_openai_api_key_here +# OpenAI — bouquet image generation & edit (output 768×1024, 3:4) +OPENAI_API_KEY= OPENAI_IMAGE_MODEL=gpt-image-1 -# Bouquet preview (generating flow) -OPENAI_IMAGE_SIZE=1024x1536 -# Flower catalog batch (scripts/generate-flower-catalog.js) — portrait cards -OPENAI_IMAGE_CATALOG_SIZE=1024x1536 -OPENAI_IMAGE_CATALOG_QUALITY=low -GEMINI_IMAGE_MODEL=gemini-3.1-flash-image -# Kakao REST API (shop search for /map) +# Kakao — shop search (/map) and map display KAKAO_REST_API_KEY= - -# Kakao Maps JavaScript key (map display on /map — public, client-side) PUBLIC_KAKAO_MAP_KEY= -# Supabase (server-side only) +# Supabase — job storage & generated image uploads SUPABASE_URL= SUPABASE_SERVICE_ROLE_KEY= SUPABASE_STORAGE_BUCKET=flower-bouquets -# adapter-node (Railway / any Node host) -# Default body limit is 512K — mood-analysis allows up to 10 MB. +# adapter-node (Railway / Node host) BODY_SIZE_LIMIT=10M -# Public URL after deploy (required for CSRF / form actions). # ORIGIN=https://your-app.up.railway.app -# Real client IP behind Railway's proxy (for rate limiting). # ADDRESS_HEADER=x-forwarded-for # XFF_DEPTH=1 -# Dev seed button: shown only when `npm run dev` (production build hides it). -# To mute during local dev, set DEV_SEED_MUTED = true in DevSeedButton.svelte. -# Replace static/dev/bouquet-{s,m,l}.jpg with real photos for richer UI previews. - -# Flower catalog (result cards) — one-time batch, not per user request: -# npm run generate:flowers -- --dry-run -# npm run generate:flowers -- --missing-only -# npm run generate:flowers -- --ids 7,14 -# Output: static/flowers/{flowerDB.id}.png +# Optional: flower catalog batch (npm run generate:flowers) +# OPENAI_IMAGE_CATALOG_SIZE=1024x1536 +# OPENAI_IMAGE_CATALOG_QUALITY=low diff --git a/package-lock.json b/package-lock.json index 6454b19..556af0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@supabase/supabase-js": "^2.108.1", "@sveltejs/adapter-node": "^5.5.4", "openai": "^6.42.0", - "p5": "^2.3.0" + "p5": "^2.3.0", + "sharp": "^0.35.1" }, "devDependencies": { "@eslint/compat": "^2.0.4", @@ -303,6 +304,516 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.1.tgz", + "integrity": "sha512-T15JRWOubQ3f5+GxnWeIvo47u5qV0M9HBgJhT+f2gE1e9e6OhR6K73Re52Hm80qWcu1DNb3GweKmpr/MnuP2Ow==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.1.tgz", + "integrity": "sha512-t1CPD0cr7XCHjwUj6tQ5MC0pCi866I+gUW6zbUX4aFPnKd1DFBtk0M+gWcjX8VeEzgfCNiSiNTVFZ6b7kvdbnQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.0" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.1.tgz", + "integrity": "sha512-MBSQXqNPThW9EcZ905H6N4sEdX5EwZEYzGx5EBq9ncDCGJALMiY1xPFJxNdzuB1iBjLOpIfxajM6YxdvwmQSLA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz", + "integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz", + "integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz", + "integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz", + "integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz", + "integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz", + "integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz", + "integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz", + "integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz", + "integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.1.tgz", + "integrity": "sha512-jygmR02PpCYypt7xB7nst1vqjZp/BpRA/Kf9nK7qRponJ/KrLPaZWEG4G15z1d2FZ6XqI+T0350ha3RSnKx24A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.1.tgz", + "integrity": "sha512-ErCRyGU7LeoaFBZ0xW8hhLlXzhAg80sc4vxePB86qvtEvW1jEhhmbiNBP4oEzZfPMnu6HwHXfzD2W2kBU+RnCw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.1.tgz", + "integrity": "sha512-LUWZ2+r2UoLCd8j0RLCwQ4gL6w47+Y7igxtVnPIDXOOEjV86LpBkAHq5VpJeg+GHbw0KN/JWlPJOdZjyZnFqFQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.0" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.1.tgz", + "integrity": "sha512-i7x6J3mwF4JgT0sM4V4WlAWdJ0bucPtA9rzO1bTji1n5qgBq/W5nn87RvOQPleuuxahNoLdTngByD8/vDDLArw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.1.tgz", + "integrity": "sha512-0zSaTUjTF0kIWTSYxD4EG/nvCU4jez53+3RdURtoY3HvbXtIQ98W90JnrGz/oLRFuEnfIy9+7xeq883euc0ZWw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.1.tgz", + "integrity": "sha512-NbJD4mWdeyrNQKluO/tR/wBDOelcowSVGNBWxI0e3ZtlXc6F/UOVKDj1MLD4zl3oHTuvKW3s+MA9N54YTldAYw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.1.tgz", + "integrity": "sha512-VoW2sQCWI+0YIKQEmWJ8vzaQjTg9wIyfkFpvEfAS2h43X6iHu7GTk1hhOgB4IpSzCHe8UwQZIcx7b81VTaOrJA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.1.tgz", + "integrity": "sha512-LjBoSd/c5JU0/K5MwzDMlgsSRP2bPn98JQGFFQAOLQ0bU/1z4ekxUdSKY9BmlwSh/cA+OrvpgsWqfZyYfVHBRw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.1.tgz", + "integrity": "sha512-PCQUoQdZyE8tp3HpbevuihfUmgSP4qWI0FGEPWoeXqaS+cUrFfemabHQiebUmUmlUhCuNnQMxGrQ+CPqK4hnxg==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.0" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.1.tgz", + "integrity": "sha512-xU2ml2bU2OPxYVvW2A6ae4M1g5QKyhKG06P4FAt+YEaFQQO0919Qx+XxIZEUuWTMoDViLpMws2/dQwoe/VcA6A==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.1.tgz", + "integrity": "sha512-IkmHwuFhYpd3bTsN5SAahjwhiAcyXPooBt8vEUgxY3T0IP70sSJ0nU1xiPzZY8AH/OB1XpV3j8aZSVSOSfTbdA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.1.tgz", + "integrity": "sha512-wQahqCi9MD8Yxzg4gVM4fNrZxh+r6vD55PyIg+WJPaM5ZRUyF35iQpwJCuma3r6viU9/8Pxlc+XHV+woVa6nCQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.1.tgz", + "integrity": "sha512-WzBtkYtZHATLPe8XRharxZXxQ9cdLrQWHiwxt+BJ5rBsisQrKeeV86ErxPSVhcG6xCEuNhs0SqLpWr7XDa2k6w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@japont/unicode-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@japont/unicode-range/-/unicode-range-1.0.0.tgz", @@ -3290,10 +3801,9 @@ } }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "dev": true, + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3308,6 +3818,50 @@ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "license": "MIT" }, + "node_modules/sharp": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.1.tgz", + "integrity": "sha512-lW979AMi+ESidzMv/Lnv+F9bknzLyxLqFI05Sm433vOeRcltgxQmXpnfOOFIAlKtwXU/ksupm2srQoFCkR214g==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.1", + "@img/sharp-darwin-x64": "0.35.1", + "@img/sharp-freebsd-wasm32": "0.35.1", + "@img/sharp-libvips-darwin-arm64": "1.3.0", + "@img/sharp-libvips-darwin-x64": "1.3.0", + "@img/sharp-libvips-linux-arm": "1.3.0", + "@img/sharp-libvips-linux-arm64": "1.3.0", + "@img/sharp-libvips-linux-ppc64": "1.3.0", + "@img/sharp-libvips-linux-riscv64": "1.3.0", + "@img/sharp-libvips-linux-s390x": "1.3.0", + "@img/sharp-libvips-linux-x64": "1.3.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.0", + "@img/sharp-libvips-linuxmusl-x64": "1.3.0", + "@img/sharp-linux-arm": "0.35.1", + "@img/sharp-linux-arm64": "0.35.1", + "@img/sharp-linux-ppc64": "0.35.1", + "@img/sharp-linux-riscv64": "0.35.1", + "@img/sharp-linux-s390x": "0.35.1", + "@img/sharp-linux-x64": "0.35.1", + "@img/sharp-linuxmusl-arm64": "0.35.1", + "@img/sharp-linuxmusl-x64": "0.35.1", + "@img/sharp-webcontainers-wasm32": "0.35.1", + "@img/sharp-win32-arm64": "0.35.1", + "@img/sharp-win32-ia32": "0.35.1", + "@img/sharp-win32-x64": "0.35.1" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index fd94cb5..a0db9b5 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@supabase/supabase-js": "^2.108.1", "@sveltejs/adapter-node": "^5.5.4", "openai": "^6.42.0", - "p5": "^2.3.0" + "p5": "^2.3.0", + "sharp": "^0.35.1" } } diff --git a/src/lib/server/flowerFlow/selectionMask.js b/src/lib/server/flowerFlow/selectionMask.js index 5a30f29..b765ce8 100644 --- a/src/lib/server/flowerFlow/selectionMask.js +++ b/src/lib/server/flowerFlow/selectionMask.js @@ -178,54 +178,14 @@ export function buildOpenAIEditMask(width, height, selection) { return encodePng(width, height, rgba); } -/** - * Visual mask for Gemini: white polygon on black = edit region. - * @param {number} width - * @param {number} height - * @param {Array<{ x: number, y: number }>} selection - */ -export function buildGeminiEditMask(width, height, selection) { - const polygon = closePolygon( - selection.map((point) => ({ - x: (point.x / 100) * width, - y: (point.y / 100) * height - })) - ); - - const rgba = new Uint8Array(width * height * 4); - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const index = (y * width + x) * 4; - const inside = pointInPolygon(x + 0.5, y + 0.5, polygon); - if (inside) { - rgba[index] = 255; - rgba[index + 1] = 255; - rgba[index + 2] = 255; - rgba[index + 3] = 255; - } else { - rgba[index] = 0; - rgba[index + 1] = 0; - rgba[index + 2] = 0; - rgba[index + 3] = 255; - } - } - } - - return encodePng(width, height, rgba); -} - /** * @param {{ base64: string, mimeType: string }} sourceImage * @param {Array<{ x: number, y: number }>} selection - * @param {'openai' | 'gemini'} provider */ -export function buildAreaEditMask(sourceImage, selection, provider) { +export function buildAreaEditMask(sourceImage, selection) { const buffer = Buffer.from(sourceImage.base64, 'base64'); const { width, height } = readImageDimensions(buffer, sourceImage.mimeType); - const maskBuffer = - provider === 'gemini' - ? buildGeminiEditMask(width, height, selection) - : buildOpenAIEditMask(width, height, selection); + const maskBuffer = buildOpenAIEditMask(width, height, selection); return { base64: maskBuffer.toString('base64'), diff --git a/src/lib/server/gemini/client.js b/src/lib/server/gemini/client.js index 52f44cf..073ece0 100644 --- a/src/lib/server/gemini/client.js +++ b/src/lib/server/gemini/client.js @@ -31,12 +31,6 @@ export function getVisionModel() { }); } -export function getImageModel() { - return getClient().getGenerativeModel({ - model: env.GEMINI_IMAGE_MODEL || 'gemini-3.1-flash-image' - }); -} - /** * @param {string} text */ diff --git a/src/lib/server/gemini/image.js b/src/lib/server/gemini/image.js index 6c9fa82..95d7157 100644 --- a/src/lib/server/gemini/image.js +++ b/src/lib/server/gemini/image.js @@ -1,42 +1,11 @@ /** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */ -import { env } from '$env/dynamic/private'; import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js'; -import { getImageModel, isGeminiConfigured } from './client.js'; import { mockGeneratedImage } from './mock.js'; import { generateOpenAIImage, editOpenAIImage, isOpenAIConfigured } from '../openai/image.js'; -export function getImageProvider() { - const configured = env.IMAGE_PROVIDER?.trim().toLowerCase(); - if (configured === 'mock' || configured === 'openai' || configured === 'gemini') { - return configured; - } - return isOpenAIConfigured() ? 'openai' : 'gemini'; -} - export function isImageGenerationConfigured() { - const provider = getImageProvider(); - if (provider === 'mock') return false; - return provider === 'openai' ? isOpenAIConfigured() : isGeminiConfigured(); -} - -/** - * @param {import('@google/generative-ai').GenerateContentResult} result - * @returns {GeneratedImage} - */ -function imageFromGeminiResult(result) { - const parts = result.response.candidates?.[0]?.content?.parts ?? []; - - for (const part of parts) { - if (part.inlineData?.data) { - return { - mimeType: part.inlineData.mimeType || 'image/png', - base64: part.inlineData.data - }; - } - } - - throw new Error('Gemini image model did not return image data'); + return isOpenAIConfigured(); } /** @@ -47,27 +16,12 @@ function imageFromGeminiResult(result) { export async function generateBouquetImage(basePrompt) { const suffix = `Generate one final bouquet image. ${BOUQUET_IMAGE_ASPECT_PROMPT} The STRICT RECIPE flower list above is mandatory: include every listed species and do not add any other flowers. Keep it realistic, orderable from a real florist, front-facing, and suitable for a customer preview.`; const prompt = `${basePrompt}\n\n${suffix}`; - const provider = getImageProvider(); - if (provider === 'mock') { + if (!isOpenAIConfigured()) { return mockGeneratedImage(); } - if (provider === 'openai') { - if (!isOpenAIConfigured()) { - return mockGeneratedImage(); - } - - return generateOpenAIImage(prompt); - } - - if (!isGeminiConfigured()) { - return mockGeneratedImage(); - } - - const model = getImageModel(); - const result = await model.generateContent(prompt); - return imageFromGeminiResult(result); + return generateOpenAIImage(prompt); } /** @@ -78,52 +32,11 @@ export async function generateBouquetImage(basePrompt) { * @returns {Promise} */ export async function editBouquetImage(sourceImage, editPrompt, options = {}) { - const provider = getImageProvider(); const mask = options.mask ?? null; - if (provider === 'mock' || sourceImage.mimeType === 'image/svg+xml') { + if (sourceImage.mimeType === 'image/svg+xml' || !isOpenAIConfigured()) { return mockGeneratedImage('Edited bouquet'); } - if (provider === 'openai') { - if (!isOpenAIConfigured()) { - return mockGeneratedImage('Edited bouquet'); - } - - return editOpenAIImage(editPrompt, sourceImage, mask); - } - - if (!isGeminiConfigured()) { - return mockGeneratedImage('Edited bouquet'); - } - - const model = getImageModel(); - /** @type {import('@google/generative-ai').Part[]} */ - const parts = [ - { text: editPrompt }, - { - inlineData: { - data: sourceImage.base64, - mimeType: sourceImage.mimeType - } - } - ]; - - if (mask) { - parts.push( - { - text: 'This mask marks the edit region. Modify the bouquet photo only where the mask is white. Keep black areas unchanged.' - }, - { - inlineData: { - data: mask.base64, - mimeType: mask.mimeType - } - } - ); - } - - const result = await model.generateContent(parts); - - return imageFromGeminiResult(result); + return editOpenAIImage(editPrompt, sourceImage, mask); } diff --git a/src/lib/server/gemini/mock.js b/src/lib/server/gemini/mock.js index 91e139e..f8556fe 100644 --- a/src/lib/server/gemini/mock.js +++ b/src/lib/server/gemini/mock.js @@ -66,7 +66,7 @@ export function mockGeneratedImage(label = 'Bouquet') { const svg = ` Mock ${label} - Set GEMINI_API_KEY for real images + Set OPENAI_API_KEY for real images `; return { diff --git a/src/lib/server/openai/bouquetImageFrame.js b/src/lib/server/openai/bouquetImageFrame.js new file mode 100644 index 0000000..30e48b9 --- /dev/null +++ b/src/lib/server/openai/bouquetImageFrame.js @@ -0,0 +1,95 @@ +import sharp from 'sharp'; + +/** Product bouquet output — 3:4 portrait (matches UI aspect-[3/4] and mock SVG). */ +export const BOUQUET_OUTPUT_WIDTH = 768; +export const BOUQUET_OUTPUT_HEIGHT = 1024; +export const BOUQUET_OUTPUT_SIZE = `${BOUQUET_OUTPUT_WIDTH}x${BOUQUET_OUTPUT_HEIGHT}`; + +/** Closest portrait size supported by gpt-image-1 (2:3). Cropped to 3:4 after generation. */ +export const OPENAI_REQUEST_WIDTH = 1024; +export const OPENAI_REQUEST_HEIGHT = 1536; +export const OPENAI_REQUEST_SIZE = `${OPENAI_REQUEST_WIDTH}x${OPENAI_REQUEST_HEIGHT}`; + +const PAD_LEFT = (OPENAI_REQUEST_WIDTH - BOUQUET_OUTPUT_WIDTH) / 2; +const PAD_TOP = (OPENAI_REQUEST_HEIGHT - BOUQUET_OUTPUT_HEIGHT) / 2; + +/** + * Center-crop (and resize if needed) to exact 3:4 bouquet output. + * @param {Buffer} buffer + * @returns {Promise} + */ +export async function frameToBouquetOutput(buffer) { + const meta = await sharp(buffer).metadata(); + const width = meta.width ?? OPENAI_REQUEST_WIDTH; + const height = meta.height ?? OPENAI_REQUEST_HEIGHT; + + if (width === BOUQUET_OUTPUT_WIDTH && height === BOUQUET_OUTPUT_HEIGHT) { + return buffer; + } + + const targetRatio = BOUQUET_OUTPUT_WIDTH / BOUQUET_OUTPUT_HEIGHT; + let cropWidth = width; + let cropHeight = height; + + if (width / height > targetRatio) { + cropWidth = Math.round(height * targetRatio); + } else { + cropHeight = Math.round(width / targetRatio); + } + + const left = Math.max(0, Math.round((width - cropWidth) / 2)); + const top = Math.max(0, Math.round((height - cropHeight) / 2)); + + return sharp(buffer) + .extract({ left, top, width: cropWidth, height: cropHeight }) + .resize(BOUQUET_OUTPUT_WIDTH, BOUQUET_OUTPUT_HEIGHT) + .png() + .toBuffer(); +} + +/** + * Pad a 3:4 bouquet image to OpenAI's 2:3 request size (white letterbox). + * @param {Buffer} buffer + * @returns {Promise} + */ +export async function padToOpenAIRequestSize(buffer) { + const meta = await sharp(buffer).metadata(); + if (meta.width === OPENAI_REQUEST_WIDTH && meta.height === OPENAI_REQUEST_HEIGHT) { + return buffer; + } + + return sharp(buffer) + .resize(BOUQUET_OUTPUT_WIDTH, BOUQUET_OUTPUT_HEIGHT, { fit: 'fill' }) + .extend({ + top: PAD_TOP, + bottom: PAD_TOP, + left: PAD_LEFT, + right: PAD_LEFT, + background: { r: 255, g: 255, b: 255, alpha: 1 } + }) + .png() + .toBuffer(); +} + +/** + * Pad an OpenAI edit mask (transparent=edit, opaque=preserve) to the request canvas. + * @param {Buffer} maskBuffer + * @returns {Promise} + */ +export async function padMaskToOpenAIRequestSize(maskBuffer) { + const meta = await sharp(maskBuffer).metadata(); + if (meta.width === OPENAI_REQUEST_WIDTH && meta.height === OPENAI_REQUEST_HEIGHT) { + return maskBuffer; + } + + return sharp(maskBuffer) + .extend({ + top: PAD_TOP, + bottom: PAD_TOP, + left: PAD_LEFT, + right: PAD_LEFT, + background: { r: 255, g: 255, b: 255, alpha: 255 } + }) + .png() + .toBuffer(); +} diff --git a/src/lib/server/openai/image.js b/src/lib/server/openai/image.js index 8bd2c94..1991ced 100644 --- a/src/lib/server/openai/image.js +++ b/src/lib/server/openai/image.js @@ -1,5 +1,11 @@ import { env } from '$env/dynamic/private'; import OpenAI, { toFile } from 'openai'; +import { + frameToBouquetOutput, + padMaskToOpenAIRequestSize, + padToOpenAIRequestSize, + OPENAI_REQUEST_SIZE +} from './bouquetImageFrame.js'; let client = null; @@ -19,6 +25,25 @@ function getOpenAIClient() { return client; } +/** + * @param {import('openai').Images.ImagesResponse['data']} data + * @returns {Promise} + */ +async function readImageBytes(data) { + const image = data?.[0]; + + if (image?.b64_json) { + return Buffer.from(image.b64_json, 'base64'); + } + + if (image?.url) { + const imageResponse = await fetch(image.url); + return Buffer.from(await imageResponse.arrayBuffer()); + } + + throw new Error('OpenAI image model did not return image data'); +} + /** * @param {string} prompt * @returns {Promise} @@ -27,30 +52,16 @@ export async function generateOpenAIImage(prompt) { const response = await getOpenAIClient().images.generate({ model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1', prompt, - size: env.OPENAI_IMAGE_SIZE || '1024x1536', + size: OPENAI_REQUEST_SIZE, n: 1 }); - const image = response.data?.[0]; + const framed = await frameToBouquetOutput(await readImageBytes(response.data)); - if (image?.b64_json) { - return { - mimeType: 'image/png', - base64: image.b64_json - }; - } - - if (image?.url) { - const imageResponse = await fetch(image.url); - const bytes = new Uint8Array(await imageResponse.arrayBuffer()); - - return { - mimeType: imageResponse.headers.get('content-type') || 'image/png', - base64: Buffer.from(bytes).toString('base64') - }; - } - - throw new Error('OpenAI image model did not return image data'); + return { + mimeType: 'image/png', + base64: framed.toString('base64') + }; } /** @@ -60,45 +71,30 @@ export async function generateOpenAIImage(prompt) { * @returns {Promise} */ export async function editOpenAIImage(prompt, sourceImage, mask = null) { - const buffer = Buffer.from(sourceImage.base64, 'base64'); - const imageFile = await toFile(buffer, 'bouquet.png', { type: sourceImage.mimeType }); + const paddedSource = await padToOpenAIRequestSize( + Buffer.from(sourceImage.base64, 'base64') + ); + const imageFile = await toFile(paddedSource, 'bouquet.png', { type: 'image/png' }); /** @type {import('openai').default.Images.ImageEditParams} */ const params = { model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1', image: imageFile, prompt, - size: env.OPENAI_IMAGE_SIZE || '1024x1536', + size: OPENAI_REQUEST_SIZE, n: 1 }; if (mask) { - const maskFile = await toFile(Buffer.from(mask.base64, 'base64'), 'mask.png', { - type: 'image/png' - }); - params.mask = maskFile; + const paddedMask = await padMaskToOpenAIRequestSize(Buffer.from(mask.base64, 'base64')); + params.mask = await toFile(paddedMask, 'mask.png', { type: 'image/png' }); } const response = await getOpenAIClient().images.edit(params); + const framed = await frameToBouquetOutput(await readImageBytes(response.data)); - const image = response.data?.[0]; - - if (image?.b64_json) { - return { - mimeType: 'image/png', - base64: image.b64_json - }; - } - - if (image?.url) { - const imageResponse = await fetch(image.url); - const bytes = new Uint8Array(await imageResponse.arrayBuffer()); - - return { - mimeType: imageResponse.headers.get('content-type') || 'image/png', - base64: Buffer.from(bytes).toString('base64') - }; - } - - throw new Error('OpenAI image edit did not return image data'); + return { + mimeType: 'image/png', + base64: framed.toString('base64') + }; } diff --git a/src/routes/api/flower-flow/edit-images/+server.js b/src/routes/api/flower-flow/edit-images/+server.js index 54882d9..5e98c62 100644 --- a/src/routes/api/flower-flow/edit-images/+server.js +++ b/src/routes/api/flower-flow/edit-images/+server.js @@ -4,11 +4,7 @@ import { buildAreaEditMask } from '$lib/server/flowerFlow/selectionMask.js'; import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js'; import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js'; import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js'; -import { - editBouquetImage, - getImageProvider, - isImageGenerationConfigured -} from '$lib/server/gemini/image.js'; +import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js'; import { applyRecipeEdit } from '$lib/server/gemini/text.js'; import { RATE_LIMITS } from '$lib/server/rateLimit.js'; import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js'; @@ -58,18 +54,13 @@ function editForJob(jobId, job, instruction) { recipeChanged }); - const provider = getImageProvider(); const mask = instruction.mode === 'area' && instruction.selection.length >= 3 - ? buildAreaEditMask( - sourceImage, - instruction.selection, - provider === 'gemini' ? 'gemini' : 'openai' - ) + ? buildAreaEditMask(sourceImage, instruction.selection) : null; console.log( - `[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${provider} mode=${instruction.mode}${mask ? ' (masked)' : ''} → editing...` + `[flower-flow] edit-images job=${jobId.slice(0, 8)} mode=${instruction.mode}${mask ? ' (masked)' : ''} → editing...` ); const generatedImage = await editBouquetImage(sourceImage, editPrompt, { mask }); const images = await uploadGeneratedImages( diff --git a/src/routes/api/flower-flow/generate-images/+server.js b/src/routes/api/flower-flow/generate-images/+server.js index e9cc80e..5a565d8 100644 --- a/src/routes/api/flower-flow/generate-images/+server.js +++ b/src/routes/api/flower-flow/generate-images/+server.js @@ -1,11 +1,7 @@ import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js'; import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js'; import { buildImagePrompt } from '$lib/server/gemini/text.js'; -import { - generateBouquetImage, - getImageProvider, - isImageGenerationConfigured -} from '$lib/server/gemini/image.js'; +import { generateBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js'; import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js'; import { RATE_LIMITS } from '$lib/server/rateLimit.js'; import { json, readJsonBody, enforceRateLimit, toErrorResponse } from '$lib/server/http.js'; @@ -80,9 +76,7 @@ export async function POST({ request, getClientAddress }) { }); } - console.log( - `[flower-flow] generate-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} → generating...` - ); + console.log(`[flower-flow] generate-images job=${jobId.slice(0, 8)} → generating...`); const { imagePrompt, images, recipe: savedRecipe } = await generateForJob(jobId, job.recipe); console.log( `[flower-flow] generate-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`