chore: lock AI providers and standardize bouquet images to 3:4
This commit is contained in:
38
.env.example
38
.env.example
@@ -1,46 +1,26 @@
|
|||||||
# Gemini
|
# Gemini — mood analysis, recipe, florist note
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
GEMINI_TEXT_MODEL=gemini-2.5-flash-lite
|
GEMINI_TEXT_MODEL=gemini-2.5-flash-lite
|
||||||
|
|
||||||
# Image generation
|
# OpenAI — bouquet image generation & edit (output 768×1024, 3:4)
|
||||||
# IMAGE_PROVIDER: openai | gemini | mock
|
OPENAI_API_KEY=
|
||||||
# mock = instant placeholder images, zero API calls (develop without burning quota)
|
|
||||||
IMAGE_PROVIDER=openai
|
|
||||||
OPENAI_API_KEY=your_openai_api_key_here
|
|
||||||
OPENAI_IMAGE_MODEL=gpt-image-1
|
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_REST_API_KEY=
|
||||||
|
|
||||||
# Kakao Maps JavaScript key (map display on /map — public, client-side)
|
|
||||||
PUBLIC_KAKAO_MAP_KEY=
|
PUBLIC_KAKAO_MAP_KEY=
|
||||||
|
|
||||||
# Supabase (server-side only)
|
# Supabase — job storage & generated image uploads
|
||||||
SUPABASE_URL=
|
SUPABASE_URL=
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
SUPABASE_STORAGE_BUCKET=flower-bouquets
|
SUPABASE_STORAGE_BUCKET=flower-bouquets
|
||||||
|
|
||||||
# adapter-node (Railway / any Node host)
|
# adapter-node (Railway / Node host)
|
||||||
# Default body limit is 512K — mood-analysis allows up to 10 MB.
|
|
||||||
BODY_SIZE_LIMIT=10M
|
BODY_SIZE_LIMIT=10M
|
||||||
# Public URL after deploy (required for CSRF / form actions).
|
|
||||||
# ORIGIN=https://your-app.up.railway.app
|
# ORIGIN=https://your-app.up.railway.app
|
||||||
# Real client IP behind Railway's proxy (for rate limiting).
|
|
||||||
# ADDRESS_HEADER=x-forwarded-for
|
# ADDRESS_HEADER=x-forwarded-for
|
||||||
# XFF_DEPTH=1
|
# XFF_DEPTH=1
|
||||||
|
|
||||||
# Dev seed button: shown only when `npm run dev` (production build hides it).
|
# Optional: flower catalog batch (npm run generate:flowers)
|
||||||
# To mute during local dev, set DEV_SEED_MUTED = true in DevSeedButton.svelte.
|
# OPENAI_IMAGE_CATALOG_SIZE=1024x1536
|
||||||
# Replace static/dev/bouquet-{s,m,l}.jpg with real photos for richer UI previews.
|
# OPENAI_IMAGE_CATALOG_QUALITY=low
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
564
package-lock.json
generated
564
package-lock.json
generated
@@ -12,7 +12,8 @@
|
|||||||
"@supabase/supabase-js": "^2.108.1",
|
"@supabase/supabase-js": "^2.108.1",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"openai": "^6.42.0",
|
"openai": "^6.42.0",
|
||||||
"p5": "^2.3.0"
|
"p5": "^2.3.0",
|
||||||
|
"sharp": "^0.35.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.4",
|
"@eslint/compat": "^2.0.4",
|
||||||
@@ -303,6 +304,516 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"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": {
|
"node_modules/@japont/unicode-range": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@japont/unicode-range/-/unicode-range-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@japont/unicode-range/-/unicode-range-1.0.0.tgz",
|
||||||
@@ -3290,10 +3801,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.8.1",
|
"version": "7.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -3308,6 +3818,50 @@
|
|||||||
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@supabase/supabase-js": "^2.108.1",
|
"@supabase/supabase-js": "^2.108.1",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"openai": "^6.42.0",
|
"openai": "^6.42.0",
|
||||||
"p5": "^2.3.0"
|
"p5": "^2.3.0",
|
||||||
|
"sharp": "^0.35.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,54 +178,14 @@ export function buildOpenAIEditMask(width, height, selection) {
|
|||||||
return encodePng(width, height, rgba);
|
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 {{ base64: string, mimeType: string }} sourceImage
|
||||||
* @param {Array<{ x: number, y: number }>} selection
|
* @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 buffer = Buffer.from(sourceImage.base64, 'base64');
|
||||||
const { width, height } = readImageDimensions(buffer, sourceImage.mimeType);
|
const { width, height } = readImageDimensions(buffer, sourceImage.mimeType);
|
||||||
const maskBuffer =
|
const maskBuffer = buildOpenAIEditMask(width, height, selection);
|
||||||
provider === 'gemini'
|
|
||||||
? buildGeminiEditMask(width, height, selection)
|
|
||||||
: buildOpenAIEditMask(width, height, selection);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
base64: maskBuffer.toString('base64'),
|
base64: maskBuffer.toString('base64'),
|
||||||
|
|||||||
@@ -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
|
* @param {string} text
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,42 +1,11 @@
|
|||||||
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
||||||
|
|
||||||
import { env } from '$env/dynamic/private';
|
|
||||||
import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js';
|
import { BOUQUET_IMAGE_ASPECT_PROMPT } from '../../flowerFlow/bouquetImageFormat.js';
|
||||||
import { getImageModel, isGeminiConfigured } from './client.js';
|
|
||||||
import { mockGeneratedImage } from './mock.js';
|
import { mockGeneratedImage } from './mock.js';
|
||||||
import { generateOpenAIImage, editOpenAIImage, isOpenAIConfigured } from '../openai/image.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() {
|
export function isImageGenerationConfigured() {
|
||||||
const provider = getImageProvider();
|
return isOpenAIConfigured();
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,27 +16,12 @@ function imageFromGeminiResult(result) {
|
|||||||
export async function generateBouquetImage(basePrompt) {
|
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 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 prompt = `${basePrompt}\n\n${suffix}`;
|
||||||
const provider = getImageProvider();
|
|
||||||
|
|
||||||
if (provider === 'mock') {
|
if (!isOpenAIConfigured()) {
|
||||||
return mockGeneratedImage();
|
return mockGeneratedImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'openai') {
|
return generateOpenAIImage(prompt);
|
||||||
if (!isOpenAIConfigured()) {
|
|
||||||
return mockGeneratedImage();
|
|
||||||
}
|
|
||||||
|
|
||||||
return generateOpenAIImage(prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isGeminiConfigured()) {
|
|
||||||
return mockGeneratedImage();
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = getImageModel();
|
|
||||||
const result = await model.generateContent(prompt);
|
|
||||||
return imageFromGeminiResult(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,52 +32,11 @@ export async function generateBouquetImage(basePrompt) {
|
|||||||
* @returns {Promise<GeneratedImage>}
|
* @returns {Promise<GeneratedImage>}
|
||||||
*/
|
*/
|
||||||
export async function editBouquetImage(sourceImage, editPrompt, options = {}) {
|
export async function editBouquetImage(sourceImage, editPrompt, options = {}) {
|
||||||
const provider = getImageProvider();
|
|
||||||
const mask = options.mask ?? null;
|
const mask = options.mask ?? null;
|
||||||
|
|
||||||
if (provider === 'mock' || sourceImage.mimeType === 'image/svg+xml') {
|
if (sourceImage.mimeType === 'image/svg+xml' || !isOpenAIConfigured()) {
|
||||||
return mockGeneratedImage('Edited bouquet');
|
return mockGeneratedImage('Edited bouquet');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'openai') {
|
return editOpenAIImage(editPrompt, sourceImage, mask);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function mockGeneratedImage(label = 'Bouquet') {
|
|||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
|
||||||
<rect width="768" height="1024" fill="#f7f3ef"/>
|
<rect width="768" height="1024" fill="#f7f3ef"/>
|
||||||
<text x="50%" y="48%" text-anchor="middle" font-size="42" fill="#6b5b53" font-family="Arial">Mock ${label}</text>
|
<text x="50%" y="48%" text-anchor="middle" font-size="42" fill="#6b5b53" font-family="Arial">Mock ${label}</text>
|
||||||
<text x="50%" y="54%" text-anchor="middle" font-size="22" fill="#9a8d84" font-family="Arial">Set GEMINI_API_KEY for real images</text>
|
<text x="50%" y="54%" text-anchor="middle" font-size="22" fill="#9a8d84" font-family="Arial">Set OPENAI_API_KEY for real images</text>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
95
src/lib/server/openai/bouquetImageFrame.js
Normal file
95
src/lib/server/openai/bouquetImageFrame.js
Normal file
@@ -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<Buffer>}
|
||||||
|
*/
|
||||||
|
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<Buffer>}
|
||||||
|
*/
|
||||||
|
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<Buffer>}
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import OpenAI, { toFile } from 'openai';
|
import OpenAI, { toFile } from 'openai';
|
||||||
|
import {
|
||||||
|
frameToBouquetOutput,
|
||||||
|
padMaskToOpenAIRequestSize,
|
||||||
|
padToOpenAIRequestSize,
|
||||||
|
OPENAI_REQUEST_SIZE
|
||||||
|
} from './bouquetImageFrame.js';
|
||||||
|
|
||||||
let client = null;
|
let client = null;
|
||||||
|
|
||||||
@@ -19,6 +25,25 @@ function getOpenAIClient() {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('openai').Images.ImagesResponse['data']} data
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
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
|
* @param {string} prompt
|
||||||
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
|
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
|
||||||
@@ -27,30 +52,16 @@ export async function generateOpenAIImage(prompt) {
|
|||||||
const response = await getOpenAIClient().images.generate({
|
const response = await getOpenAIClient().images.generate({
|
||||||
model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1',
|
model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1',
|
||||||
prompt,
|
prompt,
|
||||||
size: env.OPENAI_IMAGE_SIZE || '1024x1536',
|
size: OPENAI_REQUEST_SIZE,
|
||||||
n: 1
|
n: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
const image = response.data?.[0];
|
const framed = await frameToBouquetOutput(await readImageBytes(response.data));
|
||||||
|
|
||||||
if (image?.b64_json) {
|
return {
|
||||||
return {
|
mimeType: 'image/png',
|
||||||
mimeType: 'image/png',
|
base64: framed.toString('base64')
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,45 +71,30 @@ export async function generateOpenAIImage(prompt) {
|
|||||||
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
|
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
|
||||||
*/
|
*/
|
||||||
export async function editOpenAIImage(prompt, sourceImage, mask = null) {
|
export async function editOpenAIImage(prompt, sourceImage, mask = null) {
|
||||||
const buffer = Buffer.from(sourceImage.base64, 'base64');
|
const paddedSource = await padToOpenAIRequestSize(
|
||||||
const imageFile = await toFile(buffer, 'bouquet.png', { type: sourceImage.mimeType });
|
Buffer.from(sourceImage.base64, 'base64')
|
||||||
|
);
|
||||||
|
const imageFile = await toFile(paddedSource, 'bouquet.png', { type: 'image/png' });
|
||||||
|
|
||||||
/** @type {import('openai').default.Images.ImageEditParams} */
|
/** @type {import('openai').default.Images.ImageEditParams} */
|
||||||
const params = {
|
const params = {
|
||||||
model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1',
|
model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1',
|
||||||
image: imageFile,
|
image: imageFile,
|
||||||
prompt,
|
prompt,
|
||||||
size: env.OPENAI_IMAGE_SIZE || '1024x1536',
|
size: OPENAI_REQUEST_SIZE,
|
||||||
n: 1
|
n: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mask) {
|
if (mask) {
|
||||||
const maskFile = await toFile(Buffer.from(mask.base64, 'base64'), 'mask.png', {
|
const paddedMask = await padMaskToOpenAIRequestSize(Buffer.from(mask.base64, 'base64'));
|
||||||
type: 'image/png'
|
params.mask = await toFile(paddedMask, 'mask.png', { type: 'image/png' });
|
||||||
});
|
|
||||||
params.mask = maskFile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getOpenAIClient().images.edit(params);
|
const response = await getOpenAIClient().images.edit(params);
|
||||||
|
const framed = await frameToBouquetOutput(await readImageBytes(response.data));
|
||||||
|
|
||||||
const image = response.data?.[0];
|
return {
|
||||||
|
mimeType: 'image/png',
|
||||||
if (image?.b64_json) {
|
base64: framed.toString('base64')
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { buildAreaEditMask } from '$lib/server/flowerFlow/selectionMask.js';
|
|||||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||||
import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
|
import { formatBouquetEditPrompt } from '$lib/flowerFlow/bouquetImageFormat.js';
|
||||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||||
import {
|
import { editBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js';
|
||||||
editBouquetImage,
|
|
||||||
getImageProvider,
|
|
||||||
isImageGenerationConfigured
|
|
||||||
} from '$lib/server/gemini/image.js';
|
|
||||||
import { applyRecipeEdit } from '$lib/server/gemini/text.js';
|
import { applyRecipeEdit } from '$lib/server/gemini/text.js';
|
||||||
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
|
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
|
||||||
import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
|
||||||
@@ -58,18 +54,13 @@ function editForJob(jobId, job, instruction) {
|
|||||||
recipeChanged
|
recipeChanged
|
||||||
});
|
});
|
||||||
|
|
||||||
const provider = getImageProvider();
|
|
||||||
const mask =
|
const mask =
|
||||||
instruction.mode === 'area' && instruction.selection.length >= 3
|
instruction.mode === 'area' && instruction.selection.length >= 3
|
||||||
? buildAreaEditMask(
|
? buildAreaEditMask(sourceImage, instruction.selection)
|
||||||
sourceImage,
|
|
||||||
instruction.selection,
|
|
||||||
provider === 'gemini' ? 'gemini' : 'openai'
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
console.log(
|
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 generatedImage = await editBouquetImage(sourceImage, editPrompt, { mask });
|
||||||
const images = await uploadGeneratedImages(
|
const images = await uploadGeneratedImages(
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||||
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
|
||||||
import { buildImagePrompt } from '$lib/server/gemini/text.js';
|
import { buildImagePrompt } from '$lib/server/gemini/text.js';
|
||||||
import {
|
import { generateBouquetImage, isImageGenerationConfigured } from '$lib/server/gemini/image.js';
|
||||||
generateBouquetImage,
|
|
||||||
getImageProvider,
|
|
||||||
isImageGenerationConfigured
|
|
||||||
} from '$lib/server/gemini/image.js';
|
|
||||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||||
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
|
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
|
||||||
import { json, readJsonBody, enforceRateLimit, toErrorResponse } from '$lib/server/http.js';
|
import { json, readJsonBody, enforceRateLimit, toErrorResponse } from '$lib/server/http.js';
|
||||||
@@ -80,9 +76,7 @@ export async function POST({ request, getClientAddress }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(`[flower-flow] generate-images job=${jobId.slice(0, 8)} → generating...`);
|
||||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} → generating...`
|
|
||||||
);
|
|
||||||
const { imagePrompt, images, recipe: savedRecipe } = await generateForJob(jobId, job.recipe);
|
const { imagePrompt, images, recipe: savedRecipe } = await generateForJob(jobId, job.recipe);
|
||||||
console.log(
|
console.log(
|
||||||
`[flower-flow] generate-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
|
`[flower-flow] generate-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
|
||||||
|
|||||||
Reference in New Issue
Block a user