From 76dd9768544c8bf6563f817bed8ba356c41f68dd Mon Sep 17 00:00:00 2001 From: xds Date: Thu, 5 Feb 2026 20:52:50 +0300 Subject: [PATCH] feat: Implement image thumbnail generation, storage, and API endpoints for assets, including a regeneration utility. --- __pycache__/main.cpython-313.pyc | Bin 7119 -> 7119 bytes .../__pycache__/Exception.cpython-313.pyc | Bin 716 -> 716 bytes adapters/__pycache__/__init__.cpython-313.pyc | Bin 160 -> 160 bytes .../google_adapter.cpython-313.pyc | Bin 7479 -> 7479 bytes api/__pycache__/dependency.cpython-313.pyc | Bin 1421 -> 1421 bytes .../__pycache__/assets_router.cpython-313.pyc | Bin 4769 -> 7228 bytes .../character_router.cpython-313.pyc | Bin 3821 -> 4072 bytes .../generation_router.cpython-313.pyc | Bin 5167 -> 5167 bytes api/endpoints/assets_router.py | 70 ++++++++++++++++-- api/endpoints/character_router.py | 6 +- api/models/AssetDTO.py | 12 +-- .../__pycache__/AssetDTO.cpython-313.pyc | Bin 1024 -> 1069 bytes .../GenerationRequest.cpython-313.pyc | Bin 2543 -> 2543 bytes .../generation_service.cpython-313.pyc | Bin 16484 -> 17057 bytes api/service/generation_service.py | 16 +++- models/Asset.py | 1 + models/__pycache__/Asset.cpython-313.pyc | Bin 2071 -> 2121 bytes models/__pycache__/Generation.cpython-313.pyc | Bin 2172 -> 2172 bytes models/__pycache__/enums.cpython-313.pyc | Bin 2112 -> 2112 bytes repos/__pycache__/assets_repo.cpython-313.pyc | Bin 5730 -> 5888 bytes .../generation_repo.cpython-313.pyc | Bin 3478 -> 3518 bytes repos/assets_repo.py | 11 ++- repos/generation_repo.py | 4 +- .../__pycache__/gen_router.cpython-313.pyc | Bin 27905 -> 27905 bytes utils/__pycache__/image_utils.cpython-313.pyc | Bin 0 -> 1557 bytes utils/image_utils.py | 27 +++++++ 26 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 utils/__pycache__/image_utils.cpython-313.pyc create mode 100644 utils/image_utils.py diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc index 12cde9d4399baab18d3a503d181613b6b02e6ee5..f3c3640cd8e4738a222c0a03ab60fe8af68f922a 100644 GIT binary patch delta 19 ZcmX?ae%_qxGcPX}0}#xfwUO(nGyp$41^xg4 delta 19 ZcmX?ae%_qxGcPX}0}u%IY~(sB4FEg21!e#M diff --git a/adapters/__pycache__/Exception.cpython-313.pyc b/adapters/__pycache__/Exception.cpython-313.pyc index 6528c4be590bb47a06856dbce3f13f80108fad07..713f69cd0033ac76ab57f5ce362eb29437a4af61 100644 GIT binary patch delta 19 ZcmX@ZdWMzjGcPX}0}%Ml-pF-`2>>=j1uOsn delta 19 ZcmX@ZdWMzjGcPX}0}yc4Zsa<|1OPM@1j+ya diff --git a/adapters/__pycache__/__init__.cpython-313.pyc b/adapters/__pycache__/__init__.cpython-313.pyc index d4646d3330daf507205114074a598ed7e58588a1..60293c0eb40dcd25f555a02acc20ed59721315db 100644 GIT binary patch delta 19 ZcmZ3$xPX!SGcPX}0}%MlZkfnE6#y@w1p@#8 delta 19 ZcmZ3$xPX!SGcPX}0}yb?&aqC%*)Hg00gsVZRBcZ1pqIB1j+ya delta 19 YcmeC>?&aqC%*)Hg00iwl8@ZZU0WQM?r~m)} diff --git a/api/endpoints/__pycache__/assets_router.cpython-313.pyc b/api/endpoints/__pycache__/assets_router.cpython-313.pyc index ff270361429657e210eaea2f86bd3f8a92c0687d..ab6adc2b1f5dab70b910a0fc5cc48e77cf0a35bb 100644 GIT binary patch delta 4010 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 E09pKr@Bjb+ delta 1850 zcmZuyO>7fK6rS0gT|2v8Cr%v4aqPslk~o_}2uWySNU0o}pr-tK%i{0dcDx}V=>yTPJY2SPE zy*J;?+j+BlYvQv$=bEBOfXYYbKFr;buQ2rsgE?T2FCae&Q5(m^UquaEg&guOEO=U%liCU;(kuAp+HGJb5 z7lIR}Jyj}Yjrm;0wBhW#3)zxk@|jf86miyAz=brwX+@Fx7wEIL0?1XH>HvrI9FHK*a z{bcs~t_QY<@}V+1RCNJm`_mHJ$@%fvAp8c#2GF(s&ZM4W{vSuF%MktFZ8$~0PpHCMT3Wh4;K8B%O;aCwz$aGWaH5UaazELS2q5$5; z6q}E?Q&nOM2Q9%bF>ti57+vWodD65$x+-^G^E{MeWfZG60q4Y%*0HCh5RIST85@ST zU~CKeE=0XM-7q$U?({gBKLlgLWLXNpAh|C^WIIi_PJWVSCx0U(N1>EtZ^IH2ls)9O z!=*^cmO2)1CBHd5k|sw{0VTM&n>?|#wX*|hkOU${)Y&N{JL`djP;aUP$}8k8C9>#9 z2>)-kWMCs3#aT}@NCL&X8z4GNvbrsy@o?RLZZ%~GR~xbJVUqFykU(5K%(lPC+N?ni z$}gs(Xn#X09a6N;yeX(Tqk`2V&uEW)YF&ip1Ml}NoX!_hnR%VGsGo zIT|p7N@}R>R@31T z@_q9N3AeN^T7BuLWz<$R`I3S0D{OizQ;f~Dok*2Z2E{GfNrqd??xr5?*q1i|c0sYq z)F4yrlo~tMbUZIo$r^Ys%^7BS?~q?xzUo_bxKz);$z)aly!9lRr+kG)+0IkBQ0W3I-k}b3T6`+vgWx zjcQ=sJkvgTs+d{GXLsYH6k|Vv((B}N-_ga#ug=^)^XS!sRUpB`a4B7}?DF24z1dc? z<;2{&67%Q6rNeHz5ZRtKk@n}u^l(9NgAasb)b=wh_Gse)=em$B($(nw8YyqRcc$a zP9=m;5JFV+0%gjH0~|p@sK`j15Tr$-a3KW=%F;`@zy&EKl~7OcW;ae%)RE@R_r87a zo44=n{?-3yLEec(f(X{<-@dV`1}@8KoWERtenX4WXb}-aq-u{AqcJT`hh zToPzOFVv>r#Y#Elg%A=O7jz$FWV;y^OyYW&B=j^%>R3;)DXe%0coKU77dH0dh(b`s zL(+Q5iOuNyNEUWdmn%V%%c67QIXt#o7#-yK4_C=O(x;~$SpiplP(`ZZC;hIXwkNqC zBxfHXnb+f7(kZN<#~hiY9tVjcm)OfMv!?|kE!gt3WtnZOW?HRg!!qrN<99w0u3%pp z^l668h!=2)eJOq*e1q9L!I91-uMZzv@FvirWC@3mB_z-@WrP~ii#^?nYVj_5aF)=5 zRO@A5d$LpAZl4rphY$*(B5L>{UFim&dXq@w!XWaax%Xh7fqmQ(if~m!I)m7!J{Aen zK8U9M+~nDul*YqsMH<-%17XYOn{{(->4LGgS~uFJ9d!Nr`uP?OLBLRe4)VkdH~YCc z0LBh1pEanl+%_p4=Hx+6V#AIayh%%Rw@H>2fCt6yq60iExdIW(XW4I3G*|(G7iQgH z_oTNr?#z;(QuE(0-oRI0{Y5$YqjL1>^cN>SJJFf@Sy8`ReC^q-)coyG=EK6xkhUdi zccUmaao30B=kTxbr|$orn!j(&fZFSmY7u{h)n0KE9R|307^{l7d87pRS^}#X@mkW) zao?z#6R!uufUhf9jlhw?+{t;>BKyIA`w8AU+gmqZT3?}gz;p!6kmxqW9Xn@_fU!g{ z{8i}yyA#NGp9J?{=ik5#_PGOK)zE>w(@p*}QYU-je~E903f@WJ9%r49iXUVDgf0p+ z%w7qX79u=J24jbvAz7o&7f7R=>e;2#y}9r&%nneq)wD+IMw7<4FB`4Z(Yo0(8+Eg> z{4$NRzr&?j=Oul#xeoKRJun_x;=vxP4en~AP5GE8e;-=}xi;k$evEd&B6p_#3AlfK z&77iy8IgUSKhPw*79A~ delta 1136 zcmZvZ%}*0S7{+JXZM*$iy0oPqe3cexil!hGkpxsq|KOLBqT9Rk;ug8Qz@_ z=OQd(cuzjcqBJ^ub#*7WT+@lgJ_xZ|YKtS{B}ywM72OtAN9%@fn7#8#d=%554N64q zworQ<(Z~rSgGnXz5I+r(z{#wPodlz3TAIe4e=#g}M&?BvSw3A)LIh>3BupC&Muf&m z6h?q585gPkhmts>T*y~Lga1t?sAuVd8JX0Y#N27HMnLAz!%R==s49>~uzKn0>znz2rQpP6rBBzjlD_%b@($ZW0AXQ1DlOs$z9W6W!<+NA4xD_bp6qmgggCk_ zjYvjA1sLrx)Gq5IoRWv0w(wOsz;8Pio1Ls$kZOd~f|R|JT4`+Jae);kN*ZHM(Ma(h zj?2Anuqg*ih)P&UqmU+$vh!>~N)%f_bO%Vxd{~UXk)y6wsEAGUHNGLg8s2wD)~a^h zeLGU$ksGN!M|Py#!CxzH{?;=vYo#}v#Gi4tLE1uXV7Jx^6IfEF`|UEt2a_zO?H?HbhD_`0VbC;5TrC13K^M?7MM7o_ZY zGFQ}Q^tAC)nZiGLtC}lB!^yPyIXzu0%rT$%Rtob|>7q8T%@(!Ui6y4;18;oLIHaA; zEx=DITOl`V5R3D9u-t z_q{UC;*aDIiT8$XzIR4OO|zG5Ikbw-UT6PL!yH?Y Response: - logger.debug(f"get_asset called for ID: {asset_id}") +async def get_asset( + asset_id: str, + request: Request, + thumbnail: bool = False, + dao: DAO = Depends(get_dao) +) -> Response: + logger.debug(f"get_asset called for ID: {asset_id}, thumbnail={thumbnail}") asset = await dao.assets.get_asset(asset_id) # 2. Проверка на существование if not asset: raise HTTPException(status_code=404, detail="Asset not found") + headers = { # Кэшировать на 1 год (31536000 сек) "Cache-Control": "public, max-age=31536000, immutable" } - return Response(content=asset.data, media_type="image/png", headers=headers) + + content = asset.data + media_type = "image/png" # Default, or detect + + if thumbnail and asset.thumbnail: + content = asset.thumbnail + media_type = "image/jpeg" + + return Response(content=content, media_type=media_type, headers=headers) @router.get("") @@ -41,7 +55,13 @@ async def get_assets(request: Request, dao: DAO = Depends(get_dao), limit: int = # assets = await dao.assets.get_assets() # This line seemed redundant/conflicting in original code total_count = await dao.assets.get_asset_count() - return AssetsResponse(assets=assets, total_count=total_count) + # Manually map to DTO to trigger computed fields validation if necessary, + # but primarily to ensure valid Pydantic models for the response list. + # Asset.model_dump() generally includes computed fields (url) if configured. + # Let's ensure strict conversion. + asset_responses = [AssetResponse.model_validate(a.model_dump()) for a in assets] + + return AssetsResponse(assets=asset_responses, total_count=total_count) @@ -62,11 +82,16 @@ async def upload_asset( if not data: raise HTTPException(status_code=400, detail="Empty file") + # Generate thumbnail + from utils.image_utils import create_thumbnail + thumbnail_bytes = await asyncio.to_thread(create_thumbnail, data) + asset = Asset( name=file.filename or "upload", type=AssetType.IMAGE, linked_char_id=linked_char_id, data=data, + thumbnail=thumbnail_bytes ) asset_id = await dao.assets.create_asset(asset) @@ -79,4 +104,39 @@ async def upload_asset( type=asset.type.value if hasattr(asset.type, "value") else asset.type, linked_char_id=asset.linked_char_id, created_at=asset.created_at, - ) \ No newline at end of file + url=asset.url + ) + + +@router.post("/regenerate_thumbnails") +async def regenerate_thumbnails(dao: DAO = Depends(get_dao)): + """ + Regenerates thumbnails for all existing image assets that don't have one. + """ + logger.info("Starting thumbnail regeneration task") + from utils.image_utils import create_thumbnail + import asyncio + + # Get all assets (pagination loop might be needed for huge datasets, but simple list for now) + # We'll rely on DAO providing a method or just fetch large chunk. + # Assuming get_assets might have limit, let's fetch in chunks or just all if possible within limit. + # Ideally should use a specific repo method for iteration. + # For now, let's fetch first 1000 or similar. + assets = await dao.assets.get_assets(limit=1000, offset=0, with_data=True) + logger.info(f"Found {len(assets)} assets") + count = 0 + updated = 0 + + for asset in assets: + if asset.type == AssetType.IMAGE and asset.data : + try: + thumb = await asyncio.to_thread(create_thumbnail, asset.data) + if thumb: + asset.thumbnail = thumb + await dao.assets.update_asset(asset.id, asset) + updated += 1 + except Exception as e: + logger.error(f"Failed to regenerate thumbnail for asset {asset.id}: {e}") + count += 1 + + return {"status": "completed", "processed": count, "updated": updated} \ No newline at end of file diff --git a/api/endpoints/character_router.py b/api/endpoints/character_router.py index ae72b9b..2f55f5f 100644 --- a/api/endpoints/character_router.py +++ b/api/endpoints/character_router.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from starlette.exceptions import HTTPException from starlette.requests import Request -from api.models.AssetDTO import AssetsResponse +from api.models.AssetDTO import AssetsResponse, AssetResponse from api.models.GenerationRequest import GenerationRequest, GenerationResponse from models.Asset import Asset from models.Character import Character @@ -35,7 +35,9 @@ async def get_character_assets(character_id: str, dao: DAO = Depends(get_dao), l raise HTTPException(status_code=404, detail="Character not found") assets = await dao.assets.get_assets_by_char_id(character_id, limit, offset) total_count = await dao.assets.get_asset_count(character_id) - return AssetsResponse(assets=assets, total_count=total_count) + + asset_responses = [AssetResponse.model_validate(a.model_dump()) for a in assets] + return AssetsResponse(assets=asset_responses, total_count=total_count) @router.get("/{character_id}", response_model=Character) diff --git a/api/models/AssetDTO.py b/api/models/AssetDTO.py index 4df084e..9fefd7c 100644 --- a/api/models/AssetDTO.py +++ b/api/models/AssetDTO.py @@ -6,14 +6,14 @@ from pydantic import BaseModel from models.Asset import Asset -class AssetsResponse(BaseModel): - assets: List[Asset] - total_count: int - - class AssetResponse(BaseModel): id: str name: str type: str linked_char_id: Optional[str] = None - created_at: datetime \ No newline at end of file + created_at: datetime + url: Optional[str] = None + +class AssetsResponse(BaseModel): + assets: List[AssetResponse] + total_count: int \ No newline at end of file diff --git a/api/models/__pycache__/AssetDTO.cpython-313.pyc b/api/models/__pycache__/AssetDTO.cpython-313.pyc index 8a355fb3870f09b44b55287e922d6d95cdb16d43..2bfba91c24b0b8cc39fb10f1247fec95b01e05b4 100644 GIT binary patch delta 410 zcmZqRSj)ltnU|M~0SI;a)?f~GARWwE z#Ffsf$#sj@vA8(3Bq+7GAV04-^(E)z)l3GQx0p+da#k{Ea!+<)G`W(Cq6m9 zG_RxxB;%*aStJBfE({_>K!hlWU;z;zBZ|a<#4YAbpn3@q7o-VdffR@(10z7<#UMl4 z8E)_hOpv|EBY&Ai{)Qm8$Tb!bhz6O-NzAVDU>&HY+~Tmw%}*)KNwq6dnS6{{UYd(ZIp`nU|M~0SFE&H)lpqYDw`e?vnhH#GLr#{L;LV zl?;BGoRgG=I0g1$KPVk%qzJi8XsQ_5=xFwEGa3< zOe!r&1yka*Ae83pw!|5pxLP}`6kCO8blQF0`+Th74d=Gzz-q>K!hNWxW!ytQd9&ozepBD zu!0C?5Fr90KpKlgL4+8Hkbn^&lZsU*XE3R9U1O1esF0Z4&+HlrR)VVS7Kcr4eoARh zs$G#XP#+^muO^WAz|6?Vc$Y!;K7-;@200-5!p6iXIwA541CaW{#mXo-!SgEvkOG?z E0LP+UdH?_b diff --git a/api/models/__pycache__/GenerationRequest.cpython-313.pyc b/api/models/__pycache__/GenerationRequest.cpython-313.pyc index 152561ec99d8c446ffbf622258d98ddccaaddd87..9bddcb99c794c9cdcc63d26d607b6f4473ec101b 100644 GIT binary patch delta 19 ZcmaDa{9c&rGcPX}0}#xfwUO&7CjdVo1_S^A delta 19 ZcmaDa{9c&rGcPX}0}x#9*~s;j697Of1}*>q diff --git a/api/service/__pycache__/generation_service.cpython-313.pyc b/api/service/__pycache__/generation_service.cpython-313.pyc index 62c6ea22b3d8d2f1da2e4861c4ab8a804176e9fe..beca295aca605eed4fc5827538d33401e54f0972 100644 GIT binary patch delta 1692 zcmYLJZA?>F7(VCT+s{jH%WVqQWUEGN-yL^l7KZ6=uh*nTXFam-=dLaV8DWLx}UmYA3fP-pmIvU7@MC%Mo2ywCf* z=e#+$y-#N0wK>Q=*XgtXFJ0>6@IvEFZkdJl_yg7fp%90#Hq=Ixiw*)O6#W* z>ZzoDYAc$9O&6-`;MdT}e50s~Uorlpqkd%L^Se(n6+Z!RmI7e%HxbC3m@D#c*B*Ar zdI1C~aa(5j`jLS(F(+QTD3mP7<76%n%+^FB>aWZ*a6mK#2D74X7%jD!i_Bu43vW#p z2O5~b$TkPSH-?tV&1k07sFidQC+4>*WCQY)=A$EJCiCg6?f)nl&}xCjBIgFWvZ9ph zg4-MypH=R*8M3(mj{FP&3j`psMsS>uQce)1jNqUi0GXF~)KpbsHDYFz;5gQ{S72Y3 z`Nr{dCe&}!Dn$#nG^1JD0p`T9@fP&Bs*W{DCW)%W@6i`UiTpwqJKS$t(L%MLD3G8} zjc%2i6@{2JWmpkrEg7~8vlwP3wn4o!F!EMUE3DZ*YjL0=JB!3-P56dOGQGhfV}+mx z;_e1jwkCj4pawm3@3$GO$4K?g#rN2{D4Om)geGfhaC{Ff*4!wedq2#Wv{6dH*CO*H zq!sr5-As=_TWNgQuB0f9fKEHUEQpY|XM<~op-3ng3k}4^&Q1)3$}n;gQCP%IK2in@j1WOyKSE;Q6W zt+5NSGp9pxw0&A-7cv?;?CgVW@dal9TGw*V#=_%KM|dJQ8XCy($OU{*qznW4cW)G$ z(W$xxvwmlefx+{!P&8vgOubWS#zsLv&GkLhup&NNp9A@mUrnd0T9Q>QiOSYQkvpk& zqs4|-C69|s=a0Tr0$tl5i_c~ofIU{wX&{GoLd z`n}PmtN|sSje2BqP0Tz|bC=kQY$DIOr2S5JQ+ItP;qfKJ_tWCJq;{}4;|lfdEtxzN<_G60hVsKwaJ0J#?3;Y@xrZW1ODG!=+yYF@HPwknwi@< z-hDV(R>M9wv#jX^IJu*B90TA^3+!uW?zGlp{)@c=>nprMDg!I|P!gCG3q$xW7uN6Q zLcd;lm-jZ| zTCd&bq3<_AU#s$dvx;z!4i_GC!YvA1NLgvg15yISlcdT_QX8FW zCVQvc#~?}CGKr_spF=;aP#mvg9`1q1Yt;{HGJG#2dY#FyR;3jb=4llqT+I-k!{Ck5 zJQ*@=R{E{+sg{FO!&UOrTg0$NPlV;jHIljroFs6Dz!wB22$T@Gf}Xj(@H6CSv%%N# z6K!)4BJ|h3cd%5`zPL96m#V*SxLLanC`AOWM7LuiIQ)i8Og&Qb8#I)m=_RqCt@dui zI8=;KOM6~`3|a3-y&kLbGCA#Pe9FT>!>yg;5n;<{uA>Sj(T$GldOr<2HUYs1`R4rx Dx7V!5 delta 1253 zcmX|>ZA@EL7{||ZU;0XKOWR9n%b+cRzCfX)6et6sEC@3W9G62dUEE6mMWvgq`W0MU zG@FUCGosnd)R@ed#D(R4@dMF?AQG01HiaRk%tIvo3`JLx^&U2o7 z&$;)ri}2eNNRLb=1ETiuy|>4=4&Rm*j8LQIjdh@MrWG)KL_=F>3T<*UdPq z!#OkF%M{V<3;qav1$DyLVnqGi_P0s@fHqT!JZOd_0wMJWZ$OYgM(7O=q4W2WNxEX+ zl;SJCQbDmMPZC{qd6{BU>@k|cOhQp1d3qnI44fr7mz`Yl*>%{Y$Z;Vhk-DIR{8ep- zQSz3{1`;{#a5~J%qlG2d9D@VK5_#J14N8T%2OYnGkdbzWjW#r7$#C^3!`V=;8KIN~ zTgV6gibKVeiZQ%OeFv6oE6~6gZAK=OHF}LwLQ@*aw*MH!s0PTKS8udpE9S!V1q@7F zakvDU(M*1>!Ng-X688}k`66KF9T>>0Z1+e=DNR-tDq~}zN9uQC7)TLt*{WP-q`0Hn zLd=6+L^{;N_q9>w`sVjlR!c%hIiBnLiJYvhq2r(AQtdZRuJ5}H=b?|6vW&4s(6`yl zk3q0Z9SUnW(2*DFKXGpudPY;D=`*j7j-1+nQ{%t$2#uTl2n7XlIN}kvMrecNX5=|& zQ6EQ=K+ZK}z(zJ3u9K_LI>E-GOVJ)~Sx}1`i@-Yk>C5ZY&AIC4Y*kCv9nb0Fq(1f- z9+K{+R{f4%XkZQOxKz6)bBrCpxKvFZQB_=)jUn%;Jljxl+j$vGzMhmf9NQo&?~1ltqvK6#}* z2#?g6_KN`1#NF{Mb-mPaBQgZDfp4R?!`p}xhhQmlFlNt;Jb{VV9_Xw)Jfy(>FH0d! zo!yqRAdYdfp?}amY(GOTcOKGQWxHKdZ*>Y_nfrhHX-0R*e8La+$RCL>&BuA@*hNg! H?7RC9lE^lL diff --git a/api/service/generation_service.py b/api/service/generation_service.py index 3bcce54..9d143da 100644 --- a/api/service/generation_service.py +++ b/api/service/generation_service.py @@ -66,7 +66,7 @@ class GenerationService: async def ask_prompt_assistant(self, prompt: str, assets: List[str] = None) -> str: future_prompt = """You are an prompt-assistant. You improving user-entered prompts for image generation. User may upload reference image too. I will provide sources prompt entered by user. Understand user needs and generate best variation of prompt. - ANSWER ONLY PROMPT STRING!!! USER_ENTERED_PROMPT:""" + ANSWER ONLY PROMPT STRING!!! USER_ENTERED_PROMPT: """ future_prompt += prompt assets_data = [] if assets is not None: @@ -88,7 +88,7 @@ class GenerationService: async def get_generations(self, character_id: Optional[str] = None, limit: int = 10, offset: int = 0) -> List[ Generation]: - return await self.dao.generations.get_generations(limit=limit, offset=offset) + return await self.dao.generations.get_generations(character_id = character_id,limit=limit, offset=offset) async def get_generation(self, generation_id: str) -> Optional[GenerationResponse]: gen = await self.dao.generations.get_generation(generation_id) @@ -162,7 +162,7 @@ class GenerationService: for asset in reference_assets if asset.data is not None and asset.type == AssetType.IMAGE ) - generation_prompt+=f"PROMPT: {generation.prompt}" + generation_prompt+=f" PROMPT: {generation.prompt}" logger.info(f"Final generation prompt assembled. Length: {len(generation_prompt)}. Media count: {len(media_group_bytes)}") # 3. Запускаем процесс генерации и симуляцию прогресса @@ -209,11 +209,19 @@ class GenerationService: created_assets: List[Asset] = [] for idx, img_bytes in enumerate(generated_bytes_list): + # Generate thumbnail + thumbnail_bytes = None + # Assuming AssetType.IMAGE since we are in generated_bytes_list which are images usually + # Or use explicit check if we have distinct types in list (not currently) + from utils.image_utils import create_thumbnail + thumbnail_bytes = await asyncio.to_thread(create_thumbnail, img_bytes) + new_asset = Asset( name=f"Generated_{generation.linked_character_id}_{random.randint(1000, 9999)}", type=AssetType.IMAGE, linked_char_id=generation.linked_character_id, # Если генерация привязана к персонажу data=img_bytes, + thumbnail=thumbnail_bytes # Остальные поля заполнятся дефолтными значениями (created_at) ) @@ -236,6 +244,8 @@ class GenerationService: end_time = datetime.now() generation.execution_time_seconds = (end_time - start_time).total_seconds() + logger.info(f"DEBUG: Saving generation {generation.id}. Metrics: api_exec={generation.api_execution_time_seconds}, tokens={generation.token_usage}, exec={generation.execution_time_seconds}") + await self.dao.generations.update_generation(generation) logger.info(f"Generation {generation.id} completed successfully. {len(created_assets)} assets created. Total Time: {generation.execution_time_seconds:.2f}s") diff --git a/models/Asset.py b/models/Asset.py index 79730a2..4c27e61 100644 --- a/models/Asset.py +++ b/models/Asset.py @@ -18,6 +18,7 @@ class Asset(BaseModel): data: Optional[bytes] = None tg_doc_file_id: Optional[str] = None tg_photo_file_id: Optional[str] = None + thumbnail: Optional[bytes] = None tags: List[str] = [] created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/models/__pycache__/Asset.cpython-313.pyc b/models/__pycache__/Asset.cpython-313.pyc index 97a91d87aa63bfe123bf9f3e371f7ecc3c310326..feb82a5eb95ee82a46e344aee5bfb946cf53c5ce 100644 GIT binary patch delta 332 zcmbO(a8iKxGcPX}0}%Y%){=R3Bd-<{R4QyS~6LW*`JHEB%?GpDK9ZIXL1emWJdAH0xVq`8X)DG zAVLsCNP-9{5TOksbby2eh@rdr5Q_>Uqx9tGtVWCylcm_ARMbG?93X-lMCgMEW)NWr zB0!-~WHxy+n;PS)$-CIXc@K9iz&?l3V)9ycbw<0%SJ{pEMHz)U LJijskDX delta 275 zcmX>pFkOK6GcPX}0}wpuX~|r_kynd}@zrDtrtTDdh9bTg{unk^pj-@lFqb-r7Eodc z=9Xdz=CNcf5{wZ7iu1z7`Jm#$U~zu1xJa--44Vm4u%Hw}7K;h@rFjA&Uwlqts+JHX}yy z$(C$U@*E&>ZV;gdBA7vh0f+#3t;lrpb~ZJ}Ig{_Pg->>7pUr4K`98ZkqwQpN4r6{1 OMxhSRuM9v6Y##ulK{93l diff --git a/models/__pycache__/Generation.cpython-313.pyc b/models/__pycache__/Generation.cpython-313.pyc index c3f8a9fbef5e2ee260bdc040a415d0a3ed9b2efb..036c27fcafa212dcd3d426651eb2ca4185b5a93b 100644 GIT binary patch delta 19 Zcmew(@JE2_GcPX}0}#xfwUMic0{}m$1*8A~ delta 19 Zcmew(@JE2_GcPX}0}!P5Y~(89002HJ1!4dI diff --git a/models/__pycache__/enums.cpython-313.pyc b/models/__pycache__/enums.cpython-313.pyc index 254c746f149e169625ba3cdf7795c81856b6065b..e307fb2eee8b161b5ef41a201bcad77c8bb708a7 100644 GIT binary patch delta 19 ZcmX>ga6o|TGcPX}0}#xfwUNt=0{}L_1n>X= delta 19 ZcmX>ga6o|TGcPX}0}wpz*~n$a0RT8Q1uOsn diff --git a/repos/__pycache__/assets_repo.cpython-313.pyc b/repos/__pycache__/assets_repo.cpython-313.pyc index 3da758830d09a880d62da7a420aab4e594cf2073..317cb0ef27174872b121c9b01cde37a99517b110 100644 GIT binary patch delta 1548 zcmaJ>YiL_l9KR>`+*jUBZenfPEo=I^>nmx~R_+k}282+~ZDG7ua3Au+-}4E<0Lsm_54I?w+mli~*t+~5Cye*bgN z|9S8ImdkCn2R56TW6S^byYt^{*uFx)Z{+&cl!TcG2}?p0m?+!#W688G6ppIxfE;&a&g>cDxDdH5dO0WCPJ5q9y3?bzwx+FXy+Ei zxZvi7pXRtlX~OpScwC~(f`{7dE`{X`F5)7+B^>XQnFnbAb)zm87%V?`wwL29Ovc4J zPUd36J^T#Pl$Z`-6fvY$R95F+c{#P1oL`{72@@e%cQTuP0-zQkU>r^Avl;yu>>-XK zpI(~IKq35onh`tKZZq|OyUZ0F73=t(qq5+uULUyby>_PHKfx4V&RSDYD?dKBaW1bm z<c0A7UQOiG#NH&Mr}OHioO)?*VR>I& z$=g@f`9g(zUHsndUGM*)()UUK&7n_+s6z_X>*$Y+o5gV@5{y2JvK=V;49<2&6t*eK z$xP;@)v5V3U6#D)Cwf(?^_IHC6TaLfo?xcY4{@`J2=u;0=zD@Igoz0D2@3;b5eXUz zWj0FMVw6;l9YD=BtZVdj+3%@m8@&NQ12_rLOgH7@PXs}g7-&MN(%nD==skHrgi>eT zpiN4xxzNzKK63r7oHam4l=B`Jvs{Vrkub^%DAIwmVrwM4bz4Em4i5grbu2wi)KAhc z%^}oH56!)(ZL8JdMXg2v5pXidK;v}>==}gNgp4p6ZiF#Nm#ssnb8Fjr7p)mNz@TU* z9ZI0Xd}~N=1^q}`z$&F%Tfk(DP8(?L0M!6S)*;Z0)TNTo5g`7ZG&YZHsEqm2J6R); z<235tI_robbcWt=2G9uIarUA<`j>MC)zNfCMkcIhLBu|q?<&TTKtry2B+_wLJCR~W zgm3b7^NXcEKqBQnFin$;XS!~OfKX9P zEv-(aQyPE3CX+Kh(LoK3vFcxVyr&-ZLa>NaOG^tn4`dkNIe-CxVSpF_{IK*rVlwLws}MB4j5u@GP#$U%}vfd1lupQicr1D4luEe4Y4ac mgx#T+_IQ3|SJrqd%`?mgjDDtup7MGf{Rnw~4MVU1 delta 1368 zcmaJ>O>7%Q6rSgu{dLxf?X}zdkThx1WJ?k|q=5oa8XFS=C2r}|L=Z!i*bPl6v9u0q zK}ap-&;wk;AfXBlT!0*rd>|4?;BtvTLNS&wG6JNE9C8ak5rP9^-Xw*bSjpe}-Z!)F zy?OK2*9LF*+rQbiMa1)W^tHw4s{IB0zFHZ-s%EXMrV9Cl#>^J7M3=zxvu0*xb>`1D zFg2@*m4^KlTk2G3l-a0x0jUJCx=(a6e8HYY}^bS)IQH zhmML|SiymvltpB$FpGN5b0N32^fi3o^W_x&Ds>>sZ`#9FoG@U%IE?ssXQ)+G7!~Xd ztA)qZ>8KJT%QT~e$>alsEUPm%o}BR2Kr?^S2%9R?dWD~%yrOoD=y8%!Q*bYgfZRro zkf~8Z{K95vH!( zZTS36LAJ>?@||<|?K7)qYEECp>01k}EqpY$HdA%(uQ_8CXKekIjdNA!Y|Y73oXqBv z*{btQ&B;}q+-82U>MYe9ui|){#miOaN-c1OKc!E^UxGi2VG;a#q9fT4&J>fUEAcZ5V|U zJYlA^4nd0r{<_)4KQ^Cmg2KH%mWV-_Kw^NFVIUDJ-DLp;Fz8>jTb#?)yguIVAA({2 ztbY{T(mVbr49KLXFmOtMt9Xsb69_nCLXbBmQ>XZk4UfQJ>6rZ`T$Lr`#kxMP2e%(X z4&L3!4;%$mb1&*~T#|R+k8A*;4MCnXhD;t;-^)3P!oQ;;MoA2aoE0xFrlin#8iq=r zG^PO_<5ut}O!C3tC_KoY4=zAE|0Y=UjR2ntO@qSU5A^`$Uxi|is%cr<%S!j+@_w&H z7FU;tJy7NUlywfv8s<~s7KrnU;fJ+xArx=$Quw4&4UF^M@O0XS8=yu!H3W zxA;YFDzi+# delta 223 zcmdldJx!YTGcPX}0}w10XwGEV$lJyyR>sJ{pv<7aFqOf8A&)hbF^PqNAwqQXR List[Asset]: - res = await self.collection.find({}, {"data": 0}).sort("created_at", -1).skip(offset).limit(limit).to_list(None) + async def get_assets(self, limit: int = 10, offset: int = 0, with_data: bool = False) -> List[Asset]: + args = {} + if not with_data: + args["data"] = 0 + args["thumbnail"] = 0 + res = await self.collection.find({}, args).sort("created_at", -1).skip(offset).limit(limit).to_list(None) assets = [] for doc in res: # Конвертируем ObjectId в строку и кладем в поле id @@ -32,6 +36,7 @@ class AssetsRepo: projection = {"_id": 1, "name": 1, "type": 1, "tg_doc_file_id": 1} if with_data: projection["data"] = 1 + projection["thumbnail"] = 1 res = await self.collection.find_one({"_id": ObjectId(asset_id)}, projection) @@ -63,7 +68,7 @@ class AssetsRepo: async def get_assets_by_ids(self, asset_ids: List[str]) -> List[Asset]: object_ids = [ObjectId(asset_id) for asset_id in asset_ids] - res = self.collection.find({"_id": {"$in": object_ids}}) + res = self.collection.find({"_id": {"$in": object_ids}}, {"thumbnail": 0}) assets = [] async for doc in res: doc["id"] = str(doc.pop("_id")) diff --git a/repos/generation_repo.py b/repos/generation_repo.py index ed5af66..24f9ad8 100644 --- a/repos/generation_repo.py +++ b/repos/generation_repo.py @@ -28,7 +28,9 @@ class GenerationRepo: limit: int = 10, offset: int = 10) -> List[Generation]: args = {} if character_id is not None: - args["character_id"] = character_id + args["linked_character_id"] = character_id + else: + args["linked_character_id"] = None if status is not None: args["status"] = status res = await self.collection.find(args).sort("created_at", -1).skip( diff --git a/routers/__pycache__/gen_router.cpython-313.pyc b/routers/__pycache__/gen_router.cpython-313.pyc index ae783b4710f723655fcc6545f312279120eb6c85..bb98d46c3190c65e08f06224953d5f054751b4c9 100644 GIT binary patch delta 21 bcmZp?#n^a@k?S)rFBbz4%$~K8>w69WPSgi2 delta 21 bcmZp?#n^a@k?S)rFBbz4yzbe^^*sjwP;Lir diff --git a/utils/__pycache__/image_utils.cpython-313.pyc b/utils/__pycache__/image_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9c80bf2386eb809899812a5cb829243b87e81a9 GIT binary patch literal 1557 zcmZ8gO-vg{6rTO_dd=GA&k$%721*s206qX}(w0Vm(ilhx^43xmTv>}fb{6bicXo|1 zmqt+zp%)TGQ8@HKdunc0PCe60d!q$4Xqu=e!n1&3kXYc{A_Z zS}+&@bVPo6Xgm}E_?<1h;ctN20}7r24QSjvnBy=98RqBtIROiEA{O1XFfYydu+PQf zyo}|PoM`o^nsxxBlnJiB#sN*yq*ROMO9?48rH=Cw4QO&oOtmmH?$i9eU`1Tva4;`M z1IKJzlq2$V#X%ZQO~0V5-^xeXLUy>qHSy-;={-( zV>81up2m==r56Xam$ay7-~w3kA|P=#ui+g4ORO??B!PDk&+@?JlYGMGWi+kEeMlF2 z8vrz}H2ogZc#p$nXz~0_o8-I$a; z(hOSR|A%*m&1*9IO*GyGfEwelR_WJas6Pa-D8x2J^+9z zE_HgIM7M{UR`Vyt5Ewgs5yq&=rZ^?$>FLboxE!dnN0p6yU>!j+3--8uHb568ZD8UND>rW{F;Dz1Puj2uhioQb$OgFZZEaJyxBnj_pO>Kk#399qxF0BGKT#HWT-QzUfQg>#5P{9$`1EP`uj{n7%0Q zzAG?%(VZ^^X1avk@pCh6!uM?w#ZfNGA4A$S+)pW@A;KG0y@x)hQZmdu7O5Yz$0ow! z?9FKP$c4SN4^``@TQL4z6=2assIN7FB1y a>!`+GSnl$N^}Rcv{rJV-EQBvpvi|^S+F&~X literal 0 HcmV?d00001 diff --git a/utils/image_utils.py b/utils/image_utils.py new file mode 100644 index 0000000..480515d --- /dev/null +++ b/utils/image_utils.py @@ -0,0 +1,27 @@ +from io import BytesIO +from typing import Tuple, Optional +from PIL import Image +import logging + +logger = logging.getLogger(__name__) + +def create_thumbnail(image_data: bytes, size: Tuple[int, int] = (800, 800)) -> Optional[bytes]: + """ + Creates a thumbnail from image bytes. + Returns the thumbnail as bytes (JPEG format) or None if failed. + """ + try: + with Image.open(BytesIO(image_data)) as img: + # Convert to RGB if necessary (e.g. for RGBA/P images saving as JPEG) + if img.mode in ('RGBA', 'P'): + img = img.convert('RGB') + + img.thumbnail(size) + + thumb_io = BytesIO() + img.save(thumb_io, format='JPEG', quality=85) + thumb_io.seek(0) + return thumb_io.read() + except Exception as e: + logger.error(f"Failed to create thumbnail: {e}") + return None