Initial commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 CGHEVEN
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,62 @@
|
||||
# CGHEVEN Asset Library — Godot Editor Plugin
|
||||
|
||||
Browse, download and import **CGHEVEN** CG/VFX assets — 3D models, HDRIs, materials and
|
||||
flipbook VFX — directly inside the Godot editor. Assets stream from `api.cgheven.com`
|
||||
using the same account, library and freemium access you get in the CGHEVEN addons for
|
||||
Blender, After Effects, Premiere Pro and DaVinci Resolve.
|
||||
|
||||
A dark dock styled to match the CGHEVEN desktop addons, written entirely in GDScript
|
||||
(`@tool` `EditorPlugin`) — no native builds, so the same install runs on Windows, macOS
|
||||
and Linux.
|
||||
|
||||
## Requirements
|
||||
- **Godot 4.x** (built and tested on 4.6; uses 4.2+ APIs)
|
||||
- Internet access (asset CDN + `api.cgheven.com`)
|
||||
|
||||
## Install
|
||||
|
||||
**From the Godot Asset Library (recommended):**
|
||||
1. In the Godot editor, open the **AssetLib** tab.
|
||||
2. Search **"CGHEVEN"** → open the asset → **Download** → **Install**.
|
||||
3. **Project → Project Settings → Plugins** → enable **CGHEVEN Asset Library**.
|
||||
4. The **CGHEVEN** dock appears on the right. No editor restart needed.
|
||||
|
||||
**Manual:** copy the `addons/cgheven/` folder into your project's `addons/` folder, then
|
||||
enable it in Project Settings → Plugins.
|
||||
|
||||
Godot plugins are per-project (there is no global install), so the addon lives inside
|
||||
each project's `addons/` folder.
|
||||
|
||||
## Features
|
||||
- Dark UI matched to the CGHEVEN desktop addons
|
||||
- Asset grid with responsive columns, cached thumbnails, category + subcategory filters, search and sort
|
||||
- Categories: 3D Models, HDRI, Flipbooks
|
||||
- Guest browsing + one-click account login (web broker) or license-key activation
|
||||
- Live plan refresh via heartbeat (Free → Pro without re-login)
|
||||
- Server-side freemium gating (Download vs Upgrade)
|
||||
- Asset cards: NEW / Premium / resolution badges, downloaded tick, favourite, per-card format/resolution picker, on-card progress bar
|
||||
- One-click **download → auto-import**: glTF/FBX → scene, HDRI → world environment, flipbook → AnimatedSprite3D (auto-unpacks `.zip`)
|
||||
- Favourites (persisted) + Downloads history
|
||||
- Settings: account details, plan/version, check for updates, upgrade, Discord
|
||||
- Built-in self-update with changelog
|
||||
|
||||
## Configuration (optional)
|
||||
Environment variables, for development / self-hosting:
|
||||
- `CGHEVEN_API_BASE` — API base (default `https://api.cgheven.com`)
|
||||
- `CGHEVEN_WEB_BASE` — website base (default `https://cgheven.com`)
|
||||
- `CGHEVEN_POSTHOG_KEY` — analytics key (empty = analytics off)
|
||||
|
||||
The addon ships **no secrets** — analytics run server-side and all endpoints are public.
|
||||
|
||||
## Notes & limits
|
||||
Godot is a game engine, so a few DCC-only asset types are intentionally surfaced as
|
||||
unavailable: VDB volumetrics, `.mp4` video previews (Godot plays only Ogg Theora `.ogv`),
|
||||
and Blender node-shader graphs (only baked PBR imports). Auto-update applies after an
|
||||
editor restart.
|
||||
|
||||
## License
|
||||
See [LICENSE](LICENSE). © CGHEVEN.
|
||||
|
||||
## Links
|
||||
- Website: https://cgheven.com
|
||||
- Discord: https://discord.gg/cgheven
|
||||
@@ -0,0 +1,66 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
## CGHEVEN Asset Library — Godot editor plugin entry point.
|
||||
## Adds an After-Effects-styled dock that browses/downloads/imports CGHEVEN
|
||||
## assets from api.cgheven.com, reusing the existing backend and gating.
|
||||
|
||||
var _dock: CghMainDock
|
||||
var _api: CghApiClient
|
||||
var _auth: CghAuth
|
||||
var _downloader: CghDownloader
|
||||
var _analytics: CghAnalytics
|
||||
var _updater: CghUpdater
|
||||
var _theme: Theme
|
||||
var _heartbeat: Timer
|
||||
|
||||
func _enter_tree() -> void:
|
||||
_theme = CghTheme.build()
|
||||
|
||||
# core services (must be in the editor tree for HTTPRequest to work)
|
||||
_api = CghApiClient.new()
|
||||
_auth = CghAuth.new()
|
||||
_downloader = CghDownloader.new()
|
||||
_analytics = CghAnalytics.new()
|
||||
_updater = CghUpdater.new()
|
||||
|
||||
_dock = CghMainDock.new()
|
||||
_dock.name = "CGHEVEN"
|
||||
_dock.theme = _theme
|
||||
_dock.api = _api
|
||||
_dock.auth = _auth
|
||||
_dock.downloader = _downloader
|
||||
_dock.analytics = _analytics
|
||||
_dock.updater = _updater
|
||||
|
||||
# services live under the dock so they are inside the tree
|
||||
_dock.add_child(_api)
|
||||
_dock.add_child(_auth)
|
||||
_dock.add_child(_downloader)
|
||||
_dock.add_child(_analytics)
|
||||
_dock.add_child(_updater)
|
||||
|
||||
add_control_to_dock(DOCK_SLOT_RIGHT_UL, _dock)
|
||||
|
||||
# defer boot one frame so all children are ready
|
||||
_dock.call_deferred("boot")
|
||||
|
||||
# heartbeat: live plan refresh (Free -> Pro without re-login)
|
||||
_heartbeat = Timer.new()
|
||||
_heartbeat.wait_time = 120.0
|
||||
_heartbeat.autostart = true
|
||||
_heartbeat.timeout.connect(func():
|
||||
if _auth and _auth.is_logged_in():
|
||||
_auth.heartbeat())
|
||||
_dock.add_child(_heartbeat)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if _dock:
|
||||
remove_control_from_docks(_dock)
|
||||
_dock.queue_free()
|
||||
_dock = null
|
||||
|
||||
func _has_main_screen() -> bool:
|
||||
return false
|
||||
|
||||
func _get_plugin_name() -> String:
|
||||
return "CGHEVEN Asset Library"
|
||||
@@ -0,0 +1 @@
|
||||
uid://ba3y4otrvrgbm
|
||||
@@ -0,0 +1,59 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name CghAnalytics
|
||||
## Analytics — identical model to the AE/Blender/DaVinci/Premiere addons.
|
||||
## Posts to the BACKEND (/api/analytics/track) which holds the PostHog key, so the
|
||||
## addon never ships a key (the old direct-to-PostHog path was a silent no-op
|
||||
## because the key shipped empty). Body shape matches the desktop addons exactly:
|
||||
## { event, license_key: <distinct_id>, addon_name, properties{...} }
|
||||
## distinct_id = account email -> guest_<machine_hash> -> anonymous (never a token).
|
||||
|
||||
const ADDON_NAME := "godot-asset-library"
|
||||
const HOST_APP := "godot"
|
||||
|
||||
var _email := ""
|
||||
var _plan := "Free"
|
||||
|
||||
func identify(email: String, plan: String) -> void:
|
||||
_email = email
|
||||
_plan = plan if plan != "" else "Free"
|
||||
|
||||
func _distinct_id() -> String:
|
||||
if _email != "":
|
||||
return _email
|
||||
var fp := CghAuth.machine_hash()
|
||||
return ("guest_" + fp) if fp != "" else "anonymous"
|
||||
|
||||
func _is_premium() -> bool:
|
||||
return _plan == "Pro" or _plan == "Studio" or _plan == "Bundle"
|
||||
|
||||
func track(event: String, props := {}) -> void:
|
||||
var properties := {
|
||||
"addon_version": CghConfig.ADDON_VERSION,
|
||||
"host_app": HOST_APP,
|
||||
"os": OS.get_name(),
|
||||
"plan": _plan,
|
||||
"is_premium": _is_premium(),
|
||||
"is_guest": _email == "",
|
||||
"account_email": _email,
|
||||
"install_source": CghConfig.install_source(),
|
||||
}
|
||||
for k in props:
|
||||
properties[k] = props[k]
|
||||
var payload := {
|
||||
"event": event,
|
||||
"license_key": _distinct_id(),
|
||||
"addon_name": ADDON_NAME,
|
||||
"properties": properties,
|
||||
}
|
||||
# Fire-and-forget on a one-off request node so bursts (session_started +
|
||||
# addon_opened on boot) never drop and the editor never blocks.
|
||||
var h := HTTPRequest.new()
|
||||
h.timeout = 5.0
|
||||
add_child(h)
|
||||
h.request_completed.connect(func(_a, _b, _c, _d): h.queue_free())
|
||||
var err := h.request(CghConfig.analytics_track_url(),
|
||||
PackedStringArray(["Content-Type: application/json"]),
|
||||
HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if err != OK:
|
||||
h.queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://xa7h3bnk8yvj
|
||||
@@ -0,0 +1,117 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name CghApiClient
|
||||
## Thin wrapper over HTTPRequest for the Strapi proxy endpoints.
|
||||
## Must live in the editor tree (added as a child of the dock) or HTTPRequest
|
||||
## returns ERR_UNCONFIGURED.
|
||||
|
||||
signal assets_loaded(assets: Array, meta: Dictionary)
|
||||
signal categories_loaded(cats: Array)
|
||||
signal request_failed(message: String)
|
||||
|
||||
var _http: HTTPRequest
|
||||
var _cat_http: HTTPRequest
|
||||
var _session_token := ""
|
||||
|
||||
func _ready() -> void:
|
||||
_http = HTTPRequest.new()
|
||||
_http.use_threads = true
|
||||
_http.timeout = 20.0 # never hang forever (a stuck request blocks all later ones -> ERR_BUSY 44)
|
||||
add_child(_http)
|
||||
_http.request_completed.connect(_on_completed)
|
||||
# Separate request node for the category tree so it never cancels an asset fetch.
|
||||
_cat_http = HTTPRequest.new()
|
||||
_cat_http.use_threads = true
|
||||
_cat_http.timeout = 20.0
|
||||
add_child(_cat_http)
|
||||
_cat_http.request_completed.connect(_on_cat_completed)
|
||||
|
||||
func set_session_token(tok: String) -> void:
|
||||
_session_token = tok
|
||||
|
||||
func _headers() -> PackedStringArray:
|
||||
var h := PackedStringArray(["Content-Type: application/json"])
|
||||
if _session_token != "":
|
||||
h.append("Authorization: Bearer " + _session_token)
|
||||
return h
|
||||
|
||||
## page is 1-based. category_slug "" = all. subcat is a SUBcategory slug (e.g. "props")
|
||||
## which lives on the asset's `subcategories[]` relation, NOT `categorie` — so it needs
|
||||
## a different Strapi filter. sort = Strapi sort param.
|
||||
func fetch_assets(page: int, page_size: int, category_slug: String, search: String, sort := "createdAt:desc", subcat := "") -> void:
|
||||
var base := CghConfig.authed_assets_url() if _session_token != "" else CghConfig.public_assets_url()
|
||||
var q := "?addonSlug=%s&pagination[page]=%d&pagination[pageSize]=%d&populate=*" % [
|
||||
CghConfig.ADDON_SLUG, page, page_size]
|
||||
if sort != "":
|
||||
q += "&sort=" + sort.uri_encode()
|
||||
# Blender-exact: one slug, matched against EITHER the top-level category OR a
|
||||
# subcategory ($or). So "props" (a 3d-models subcategory) is found correctly.
|
||||
var sel := subcat if subcat != "" else category_slug
|
||||
if sel != "":
|
||||
var es := sel.uri_encode()
|
||||
q += "&filters[$or][0][categorie][Slug][$eq]=" + es
|
||||
q += "&filters[$or][1][subcategories][Slug][$eq]=" + es
|
||||
else:
|
||||
# "All" tab — the Godot-usable categories only (3d-models / hdri / flipbooks + their
|
||||
# subcategories). Match EITHER the top-level categorie OR the subcategories relation for
|
||||
# EACH usable slug, exactly like the individual tabs do (those work reliably). The old
|
||||
# `categorie.Slug $in [...]` form matched only the top level and behaved inconsistently on
|
||||
# the authenticated proxy, so "All" showed only a handful of assets.
|
||||
var slugs: Array = []
|
||||
var usable := {}
|
||||
for cat in CghConfig.CATEGORIES:
|
||||
if cat["slug"] != "":
|
||||
usable[cat["slug"]] = true
|
||||
slugs.append(cat["slug"])
|
||||
for parent in CghConfig.SUBCATEGORIES.keys():
|
||||
if not usable.has(parent):
|
||||
continue
|
||||
for s in CghConfig.SUBCATEGORIES[parent]:
|
||||
slugs.append(s["slug"])
|
||||
var oi := 0
|
||||
for cs in slugs:
|
||||
var es := str(cs).uri_encode()
|
||||
q += "&filters[$or][%d][categorie][Slug][$eq]=%s" % [oi, es]; oi += 1
|
||||
q += "&filters[$or][%d][subcategories][Slug][$eq]=%s" % [oi, es]; oi += 1
|
||||
if search.strip_edges() != "":
|
||||
q += "&filters[Title][$containsi]=" + search.strip_edges().uri_encode()
|
||||
# Drop any stale/in-flight request first, otherwise a previous (slow) request
|
||||
# leaves the node busy and this one fails with ERR_BUSY (44).
|
||||
_http.cancel_request()
|
||||
var err := _http.request(base + q, _headers(), HTTPClient.METHOD_GET)
|
||||
if err != OK:
|
||||
request_failed.emit("Request could not start (err %d)" % err)
|
||||
|
||||
func _on_completed(result: int, code: int, _headers_in: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
request_failed.emit("Network error (result %d)" % result)
|
||||
return
|
||||
if code < 200 or code >= 300:
|
||||
request_failed.emit("Server returned HTTP %d" % code)
|
||||
return
|
||||
var parsed = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
request_failed.emit("Bad JSON from server")
|
||||
return
|
||||
var data = parsed.get("data", [])
|
||||
var meta = parsed.get("meta", {})
|
||||
assets_loaded.emit(data if data is Array else [], meta if meta is Dictionary else {})
|
||||
|
||||
## Fetch the category tree so the UI can build an accurate subcategory dropdown from the
|
||||
## live backend (like the Blender addon), not a hardcoded list. Non-fatal on failure.
|
||||
func fetch_categories() -> void:
|
||||
var base := CghConfig.authed_categories_url() if _session_token != "" else CghConfig.public_categories_url()
|
||||
var q := "?addonSlug=%s&pagination[pageSize]=200&populate=*" % CghConfig.ADDON_SLUG
|
||||
_cat_http.cancel_request()
|
||||
var err := _cat_http.request(base + q, _headers(), HTTPClient.METHOD_GET)
|
||||
if err != OK:
|
||||
return # keep the hardcoded subcategory fallback
|
||||
|
||||
func _on_cat_completed(result: int, code: int, _h: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
||||
return
|
||||
var parsed = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return
|
||||
var data = parsed.get("data", [])
|
||||
categories_loaded.emit(data if data is Array else [])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3v25j2woseak
|
||||
@@ -0,0 +1,373 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghAsset
|
||||
## Helpers to read a Strapi asset dict and turn its files[] / green_screen[]
|
||||
## into structured, Godot-relevant download entries.
|
||||
|
||||
static func attrs(asset: Dictionary) -> Dictionary:
|
||||
return asset.get("attributes", asset)
|
||||
|
||||
static func title(asset: Dictionary) -> String:
|
||||
return attrs(asset).get("Title", "Untitled")
|
||||
|
||||
static func id(asset: Dictionary) -> String:
|
||||
var raw = asset.get("id", null)
|
||||
if raw == null:
|
||||
raw = attrs(asset).get("assets_slug", title(asset))
|
||||
# JSON parses numeric ids as float -> str(2139.0) = "2139.0", which breaks the
|
||||
# /assets/<id> URL and the cross-addon manifest. Always render whole ids as ints.
|
||||
if raw is float:
|
||||
return str(int(raw))
|
||||
if raw is int:
|
||||
return str(raw)
|
||||
return str(raw)
|
||||
|
||||
## Thumbnail URL — mirrors AE's AU.thumb() + Blender's _normalize_media_url():
|
||||
## accepts a plain string OR a Strapi media relation, and prepends the CDN base
|
||||
## only for host-relative ("/uploads/…") URLs.
|
||||
static func thumbnail(asset: Dictionary) -> String:
|
||||
var t = attrs(asset).get("thumbnail", "")
|
||||
var url := ""
|
||||
if t is String:
|
||||
url = t
|
||||
elif t is Dictionary:
|
||||
var d = t.get("data", null)
|
||||
if d is Dictionary:
|
||||
var da = d.get("attributes", d)
|
||||
if da is Dictionary:
|
||||
url = str(da.get("url", ""))
|
||||
elif t.has("url"):
|
||||
url = str(t.get("url", ""))
|
||||
return _normalize_media_url(url)
|
||||
|
||||
static func _normalize_media_url(url: String) -> String:
|
||||
var raw := url.strip_edges()
|
||||
if raw == "":
|
||||
return ""
|
||||
if raw.begins_with("//"):
|
||||
return "https:" + raw
|
||||
if raw.begins_with("/"):
|
||||
return "https://cdn.cgheven.com" + raw
|
||||
return raw
|
||||
|
||||
## Category slug — flat-first, exactly like the AE/Blender/DaVinci addons:
|
||||
## the proxy returns a flat `categorie.Slug`, but tolerate nested `data.attributes`.
|
||||
static func _cat_attrs(asset: Dictionary) -> Dictionary:
|
||||
var c = attrs(asset).get("categorie", {})
|
||||
if not (c is Dictionary):
|
||||
return {}
|
||||
var d = c.get("data", c) # nested Strapi shape -> unwrap, else flat
|
||||
if d is Dictionary:
|
||||
var da = d.get("attributes", d)
|
||||
if da is Dictionary:
|
||||
return da
|
||||
return {}
|
||||
|
||||
static func category_slug(asset: Dictionary) -> String:
|
||||
var da := _cat_attrs(asset)
|
||||
return str(da.get("Slug", da.get("slug", "")))
|
||||
|
||||
## True when this asset is a flipbook. CRITICAL: the category slug may be the PARENT
|
||||
## ("flipbooks") OR a SUBCATEGORY ("fire-flipbooks", "muzzle-flashes-flipbooks",
|
||||
## "tornado-fx-flipbooks", "magic-effects-flipbooks", …) — so match the "flipbook" TOKEN,
|
||||
## not exact equality. Before this, subcategory flipbooks silently missed the sprite-sheet
|
||||
## hover animation + PNG-preference + EXR fallback and fell back to the janky still-slideshow.
|
||||
static func is_flipbook(asset: Dictionary) -> bool:
|
||||
return category_slug(asset).to_lower().contains("flipbook")
|
||||
|
||||
## True when this asset is an HDRI (parent "hdri" or a subcategory like "space-hdr").
|
||||
static func is_hdri(asset: Dictionary) -> bool:
|
||||
var s := category_slug(asset).to_lower()
|
||||
return s == "hdri" or s.contains("hdr")
|
||||
|
||||
## Human-readable asset slug, like the Blender addon (assets_slug, fallbacks).
|
||||
static func assets_slug(asset: Dictionary) -> String:
|
||||
var a := attrs(asset)
|
||||
var s = a.get("assets_slug", a.get("Assets_slug", a.get("asset_slug", a.get("slug", ""))))
|
||||
return str(s).strip_edges()
|
||||
|
||||
## Canonical website URL — matches Blender: https://cgheven.com/assets/<assets_slug>.
|
||||
## Falls back to the numeric id only if there's no usable slug.
|
||||
static func web_url(asset: Dictionary) -> String:
|
||||
var slug := assets_slug(asset).split("/")[0].split("?")[0].strip_edges().to_lower()
|
||||
var rx := RegEx.new()
|
||||
rx.compile("^[a-z0-9_-]+$")
|
||||
if slug != "" and rx.search(slug) != null:
|
||||
return CghConfig.web_base() + "/assets/" + slug
|
||||
return CghConfig.web_base() + "/assets/" + id(asset)
|
||||
|
||||
static func category_title(asset: Dictionary) -> String:
|
||||
var da := _cat_attrs(asset)
|
||||
var t := str(da.get("Title", da.get("Name", "")))
|
||||
return t if t != "" else "Asset"
|
||||
|
||||
## Hover-preview image URLs (additional_images[] + previews). Godot can't play
|
||||
## the mp4/webm preview VIDEO, so we cycle these stills as a slideshow instead.
|
||||
static func preview_images(asset: Dictionary) -> Array:
|
||||
var a := attrs(asset)
|
||||
var out := []
|
||||
var add = a.get("additional_images", [])
|
||||
if add is Array:
|
||||
for u in add:
|
||||
if u is String and u != "":
|
||||
out.append(u)
|
||||
var prev = a.get("previews", "")
|
||||
if prev is String and prev != "" and not prev.to_lower().ends_with(".mp4") \
|
||||
and not prev.to_lower().ends_with(".webm") and not prev.to_lower().ends_with(".mov"):
|
||||
out.append(prev)
|
||||
return out
|
||||
|
||||
## Flipbook sprite-sheet grid (cols x rows) parsed from the asset's file names /
|
||||
## previews / thumbnail (e.g. "..._8x8.png"). Vector2i(0,0) when unknown. Lets the card
|
||||
## animate the flipbook on hover by slicing its sprite-sheet thumbnail into frames.
|
||||
static func flipbook_grid(asset: Dictionary) -> Vector2i:
|
||||
# File-name grid — used to slice the REAL downloaded sheet in the importer.
|
||||
var cands := []
|
||||
for e in file_entries(asset):
|
||||
cands.append(str(e.get("filename", "")))
|
||||
var a := attrs(asset)
|
||||
cands.append(str(a.get("previews", "")))
|
||||
cands.append(thumbnail(asset))
|
||||
for c in cands:
|
||||
var g := _grid_from(str(c))
|
||||
if g.x >= 1 and g.y >= 1:
|
||||
return g
|
||||
return Vector2i(0, 0)
|
||||
|
||||
## Grid used to slice the THUMBNAIL for the hover animation. The thumbnail's own layout
|
||||
## can differ from the downloadable sheet, so read the thumbnail/preview URL FIRST and
|
||||
## only fall back to file names. This fixes flipbooks whose hover preview looked wrong
|
||||
## because we were slicing the thumbnail by the (different) full-res sheet's grid.
|
||||
static func flipbook_thumb_grid(asset: Dictionary) -> Vector2i:
|
||||
var a := attrs(asset)
|
||||
var cands := [thumbnail(asset), str(a.get("previews", ""))]
|
||||
for e in file_entries(asset):
|
||||
cands.append(str(e.get("filename", "")))
|
||||
for c in cands:
|
||||
var g := _grid_from(str(c))
|
||||
if g.x >= 1 and g.y >= 1:
|
||||
return g
|
||||
return Vector2i(0, 0)
|
||||
|
||||
## Parse an NxM sprite-sheet grid from a filename/URL. Scans ALL "AxB" tokens and keeps
|
||||
## the last one where BOTH numbers are small (<=32) — a real grid like "_6x6". This skips
|
||||
## pixel dimensions such as "1920x1080" or "_4K_" that used to be grabbed as the grid
|
||||
## (returning the first match only), which produced 36 garbage frames from a resized sheet.
|
||||
static func _grid_from(text: String) -> Vector2i:
|
||||
var rx := RegEx.new()
|
||||
rx.compile("(\\d+)\\s*[xX]\\s*(\\d+)")
|
||||
var best := Vector2i(0, 0)
|
||||
for m in rx.search_all(text):
|
||||
var cols := int(m.get_string(1))
|
||||
var rows := int(m.get_string(2))
|
||||
if cols >= 1 and rows >= 1 and cols <= 32 and rows <= 32:
|
||||
best = Vector2i(cols, rows) # keep the LAST small token (grid is the trailing _NxM)
|
||||
return best
|
||||
|
||||
static func is_new(asset: Dictionary) -> bool:
|
||||
# treat early_access>0 or a releaseDate within ~21 days as "NEW"
|
||||
var a := attrs(asset)
|
||||
return int(a.get("early_access", 0)) > 0
|
||||
|
||||
static func patreon_locked(asset: Dictionary) -> bool:
|
||||
return attrs(asset).get("is_patreon_locked", false) == true
|
||||
|
||||
## Returns an array of file entries:
|
||||
## {url, filename, ext, res, res_rank, format, locked}
|
||||
## Mirrors AE/Blender/DaVinci parseFiles(): per-file CGHLOCKED:: gating, resolution
|
||||
## parsed from the filename, and 3D format detected inside .zip archives. Each file
|
||||
## entry may be a plain URL string OR a Strapi media object {url}.
|
||||
static func file_entries(asset: Dictionary) -> Array:
|
||||
var out := []
|
||||
var a := attrs(asset)
|
||||
for key in ["files", "green_screen"]:
|
||||
var arr = a.get(key, [])
|
||||
if not (arr is Array):
|
||||
continue
|
||||
for raw in arr:
|
||||
var u := ""
|
||||
if raw is String:
|
||||
u = raw
|
||||
elif raw is Dictionary:
|
||||
u = str(raw.get("url", ""))
|
||||
if u == "":
|
||||
continue
|
||||
var locked: bool = u.begins_with(CghConfig.LOCK_PREFIX)
|
||||
var real: String = u.substr(CghConfig.LOCK_PREFIX.length()) if locked else u
|
||||
var fname: String = real.get_file()
|
||||
var q := fname.find("?") # strip any query string
|
||||
if q != -1:
|
||||
fname = fname.substr(0, q)
|
||||
var ext: String = fname.get_extension().to_lower()
|
||||
var res := detect_res(fname)
|
||||
var fmt := ext.to_upper()
|
||||
if ext == "zip" or ext == "rar":
|
||||
var zf := _archive_format(fname) # FBX/GLTF/OBJ/BLEND from the archive name
|
||||
if zf != "":
|
||||
fmt = zf
|
||||
if key == "green_screen":
|
||||
fmt += " (GS)"
|
||||
out.append({
|
||||
"url": ("" if locked else real),
|
||||
"filename": fname,
|
||||
"ext": ext,
|
||||
"res": res,
|
||||
"res_rank": res_rank(res),
|
||||
"format": fmt,
|
||||
"locked": locked,
|
||||
"preview": false,
|
||||
})
|
||||
# 3D models ship only as .zip/.rar archives; the directly Godot-importable model is
|
||||
# the preview .glb in `previews`. Add it so 3D assets are importable (no "No file"),
|
||||
# unless the whole asset is patreon-locked (then it stays Upgrade-only).
|
||||
var prev = a.get("previews", "")
|
||||
if prev is String and prev != "" and not patreon_locked(asset):
|
||||
var pf: String = prev
|
||||
var pq2 := pf.find("?")
|
||||
if pq2 != -1:
|
||||
pf = pf.substr(0, pq2)
|
||||
var pext := pf.get_file().get_extension().to_lower()
|
||||
if pext in ["glb", "gltf", "fbx"]:
|
||||
out.append({
|
||||
"url": prev,
|
||||
"filename": pf.get_file(),
|
||||
"ext": pext,
|
||||
"res": "",
|
||||
"res_rank": 1, # above a real full-res file (rank 0) so that's preferred
|
||||
"format": pext.to_upper(),
|
||||
"locked": false,
|
||||
"preview": true,
|
||||
})
|
||||
return out
|
||||
|
||||
## Format encoded in an archive's filename (CGHEVEN names them _Fbx/_Blend/_Gltf/_Obj).
|
||||
static func _archive_format(fname: String) -> String:
|
||||
var fu := fname.to_upper()
|
||||
if fu.find("_GLTF") != -1 or fu.find("_GLB") != -1:
|
||||
return "GLTF"
|
||||
if fu.find("_FBX") != -1:
|
||||
return "FBX"
|
||||
if fu.find("_OBJ") != -1:
|
||||
return "OBJ"
|
||||
if fu.find("_USDC") != -1 or fu.find("_USDA") != -1 or fu.find("_USDZ") != -1 or fu.find("_USD") != -1:
|
||||
return "USD" # Godot 4.7 has no native USD import -> treat as non-importable
|
||||
if fu.find("_BLEND") != -1: # also matches _BLENDER
|
||||
return "BLEND"
|
||||
return ""
|
||||
|
||||
## Only the entries Godot can actually import:
|
||||
## - direct glb/gltf/fbx/exr/hdr/png/jpg/webp (incl. the preview .glb)
|
||||
## - a .zip Godot can unpack (ZIPReader) UNLESS it's a Blender-only archive (.blend inside)
|
||||
## - never .rar (Godot can't extract it) or a BLEND archive
|
||||
static func importable_entries(asset: Dictionary) -> Array:
|
||||
var direct := CghConfig.EXT_3D + CghConfig.EXT_HDRI + CghConfig.EXT_IMAGE
|
||||
var out := []
|
||||
for e in file_entries(asset):
|
||||
var ext: String = e["ext"]
|
||||
if ext in direct:
|
||||
out.append(e)
|
||||
elif ext == "zip" and not (e["format"] in ["BLEND", "USD"]):
|
||||
# .blend needs Blender; .usd/.usdc has no native Godot 4.7 importer — skip both so
|
||||
# the addon never auto-picks a zip whose only model Godot can't read ("no importable
|
||||
# file inside"). fbx/gltf/glb/obj zips still qualify.
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
## True if at least one file is unlocked (-> Download, not Upgrade).
|
||||
## Matches AE's hasFreeFile()/lockedForUser(): lock is purely server-driven via the
|
||||
## CGHLOCKED:: marker, so we only trust per-file `locked`. The backend already frees
|
||||
## the lowest available resolution (e.g. 4K is free on a 4K/8K-only asset).
|
||||
static func has_free_file(asset: Dictionary) -> bool:
|
||||
for e in file_entries(asset):
|
||||
if not e["locked"]:
|
||||
return true
|
||||
return false
|
||||
|
||||
## Best free entry: lowest-resolution unlocked importable file. For flipbooks, prefer a
|
||||
## PNG/JPG/WebP sheet over EXR — Godot's EXR loader (tinyexr) can't read some compressions,
|
||||
## so an EXR flipbook fails to import while the PNG version always works.
|
||||
static func best_free_entry(asset: Dictionary) -> Dictionary:
|
||||
var cands := importable_entries(asset).filter(func(e): return not e["locked"])
|
||||
if cands.is_empty():
|
||||
return {}
|
||||
if is_flipbook(asset):
|
||||
var imgs := cands.filter(func(e): return e["ext"] in ["png", "jpg", "jpeg", "webp"])
|
||||
if not imgs.is_empty():
|
||||
cands = imgs
|
||||
# Lowest resolution first; at the SAME resolution prefer a Godot-native model format
|
||||
# (gltf/glb > fbx > obj) so a 3D asset never auto-picks a format Godot imports poorly.
|
||||
cands.sort_custom(func(a, b):
|
||||
if int(a["res_rank"]) != int(b["res_rank"]):
|
||||
return int(a["res_rank"]) < int(b["res_rank"])
|
||||
return _fmt_priority(str(a["format"])) < _fmt_priority(str(b["format"])))
|
||||
return cands[0]
|
||||
|
||||
## Godot-import preference among 3D model formats (lower = better). Non-model formats tie at 3.
|
||||
static func _fmt_priority(fmt: String) -> int:
|
||||
var f := fmt.to_upper()
|
||||
if f == "GLTF" or f == "GLB":
|
||||
return 0
|
||||
if f == "FBX":
|
||||
return 1
|
||||
if f == "OBJ":
|
||||
return 2
|
||||
return 3
|
||||
|
||||
## Lowest-resolution UNLOCKED file of ANY format (even ones Godot can't auto-import,
|
||||
## e.g. .rar). Fallback so a card whose only free file is non-importable still DOWNLOADS
|
||||
## it to the shared folder (user can extract/use it) instead of the click doing nothing.
|
||||
static func best_free_any(asset: Dictionary) -> Dictionary:
|
||||
var cands := file_entries(asset).filter(func(e): return not e["locked"] and e["url"] != "")
|
||||
if cands.is_empty():
|
||||
return {}
|
||||
cands.sort_custom(func(a, b): return a["res_rank"] < b["res_rank"])
|
||||
return cands[0]
|
||||
|
||||
# Resolution detection — ported from the desktop addons (RES_PATTERNS). Dimension
|
||||
# patterns first (most specific), then word/number tokens. First match wins.
|
||||
const _RES_DEFS := [
|
||||
["7680\\s*[x×]\\s*4320", "8K"],
|
||||
["(4096|3840)\\s*[x×]\\s*2160", "4K"],
|
||||
["(2560\\s*[x×]\\s*1440|2048\\s*[x×]\\s*1080)", "2K"],
|
||||
["1920\\s*[x×]\\s*1080", "1080p"],
|
||||
# ALL K resolutions must be recognised. A missing one (e.g. 6K) resolves to "" -> rank 0,
|
||||
# which best_free_entry then treats as the LOWEST/"best" file — so a logged-in (unlocked)
|
||||
# user would auto-pick the 6K DWAA HDRI Godot can't read. Cover 1K..16K explicitly.
|
||||
["(?<![a-z0-9])16\\s*k(?![a-z0-9])|16384", "16K"],
|
||||
["(?<![a-z0-9])12\\s*k(?![a-z0-9])|12288", "12K"],
|
||||
["(?<![a-z0-9])8\\s*k(?![a-z0-9])|8192", "8K"],
|
||||
["(?<![a-z0-9])6\\s*k(?![a-z0-9])|6144", "6K"],
|
||||
["(?<![a-z0-9])5\\s*k(?![a-z0-9])|5120", "5K"],
|
||||
["(?<![a-z0-9])4\\s*k(?![a-z0-9])|4096|uhd", "4K"],
|
||||
["(?<![a-z0-9])3\\s*k(?![a-z0-9])|3072", "3K"],
|
||||
["(?<![a-z0-9])2\\s*k(?![a-z0-9])|2048", "2K"],
|
||||
["(?<![a-z0-9])1\\s*k(?![a-z0-9])|1024", "1K"],
|
||||
["2160", "4K"],
|
||||
["1080", "1080p"],
|
||||
["720", "720p"],
|
||||
["(?<![a-z0-9])hd(?![a-z0-9])", "HD"],
|
||||
]
|
||||
# Low-first rank used to pick the "best free entry" (1K before 4K), like the addons.
|
||||
const _RES_RANK := {
|
||||
"": 0, "PREVIEW": 1, "480P": 2, "720P": 3, "HD": 3,
|
||||
"1080P": 4, "1K": 5, "2K": 6, "3K": 7, "4K": 8, "5K": 9, "6K": 10, "8K": 11, "12K": 12, "16K": 13,
|
||||
}
|
||||
static var _res_rx := [] # [{rx, label}] compiled lazily
|
||||
|
||||
static func _ensure_res_rx() -> void:
|
||||
if not _res_rx.is_empty():
|
||||
return
|
||||
for d in _RES_DEFS:
|
||||
var rx := RegEx.new()
|
||||
rx.compile("(?i)" + d[0]) # case-insensitive
|
||||
_res_rx.append({"rx": rx, "label": d[1]})
|
||||
|
||||
static func detect_res(fname: String) -> String:
|
||||
_ensure_res_rx()
|
||||
for e in _res_rx:
|
||||
if e["rx"].search(fname) != null:
|
||||
return e["label"]
|
||||
return ""
|
||||
|
||||
static func res_rank(label: String) -> int:
|
||||
return int(_RES_RANK.get(label.to_upper(), 0))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn5sgwiqu512l
|
||||
@@ -0,0 +1,285 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name CghAuth
|
||||
## Web-broker login (BlenderKit-style), mirroring the Blender addon:
|
||||
## 1. open system browser -> cgheven.com/addon-login
|
||||
## 2. run a loopback server, catch the handoff ?code=
|
||||
## 3. exchange the code with the backend for a session token (JWT)
|
||||
## Godot has NO HTTP server, only raw TCP, so we hand-parse the request line
|
||||
## and hand-write the HTTP response. Also supports license-key + machine_hash.
|
||||
|
||||
signal logged_in(plan: String)
|
||||
signal login_failed(message: String)
|
||||
signal plan_refreshed(plan: String)
|
||||
|
||||
var session_token := ""
|
||||
var plan := "Free"
|
||||
var email := ""
|
||||
var expires_at := "" # ISO date the plan/session expires (shown in the Account popup)
|
||||
|
||||
var _server: TCPServer
|
||||
var _port := 0
|
||||
var _state := ""
|
||||
var _peers: Array = [] # [{peer, req, frames}] — ALL live loopback connections
|
||||
var _wait_frames := 0 # global poll ticks since login started (overall timeout)
|
||||
var _poll_timer: Timer = null # polls the loopback server (Timers fire reliably in the editor; _process may not)
|
||||
var _exchange_http: HTTPRequest
|
||||
var _heartbeat_http: HTTPRequest
|
||||
|
||||
func _ready() -> void:
|
||||
_exchange_http = HTTPRequest.new(); add_child(_exchange_http)
|
||||
_exchange_http.request_completed.connect(_on_exchange_done)
|
||||
_heartbeat_http = HTTPRequest.new(); add_child(_heartbeat_http)
|
||||
_heartbeat_http.request_completed.connect(_on_heartbeat_done)
|
||||
_load_session()
|
||||
|
||||
# -------- persisted session (EditorSettings, per-user, cross-project) --------
|
||||
func _load_session() -> void:
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es.has_setting("cgheven/session_token"):
|
||||
session_token = es.get_setting("cgheven/session_token")
|
||||
plan = es.get_setting("cgheven/plan") if es.has_setting("cgheven/plan") else "Free"
|
||||
email = es.get_setting("cgheven/email") if es.has_setting("cgheven/email") else ""
|
||||
expires_at = es.get_setting("cgheven/expires_at") if es.has_setting("cgheven/expires_at") else ""
|
||||
|
||||
func _save_session() -> void:
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
es.set_setting("cgheven/session_token", session_token)
|
||||
es.set_setting("cgheven/plan", plan)
|
||||
es.set_setting("cgheven/email", email)
|
||||
es.set_setting("cgheven/expires_at", expires_at)
|
||||
|
||||
func is_logged_in() -> bool:
|
||||
return session_token != ""
|
||||
|
||||
func is_premium() -> bool:
|
||||
return plan == "Pro" or plan == "Studio" or plan == "Bundle"
|
||||
|
||||
func logout() -> void:
|
||||
session_token = ""
|
||||
plan = "Free"
|
||||
email = ""
|
||||
expires_at = ""
|
||||
_save_session()
|
||||
|
||||
# -------------------------- web-broker login --------------------------
|
||||
func start_login(register := false) -> void:
|
||||
_server = TCPServer.new()
|
||||
_port = 0
|
||||
for p in CghConfig.LOOPBACK_PORTS:
|
||||
if _server.listen(p, "127.0.0.1") == OK:
|
||||
_port = p
|
||||
break
|
||||
if _port == 0:
|
||||
login_failed.emit("Could not open a local login port. Close other addons and retry.")
|
||||
return
|
||||
# Build the SAME broker URL the Blender/AE addons use, so the website handles it
|
||||
# identically: /addon-login?redirect=<loopback>&state=&addon=godot&machine=&label=
|
||||
_state = "%08x%08x" % [randi(), randi()]
|
||||
var redirect := CghConfig.loopback_redirect(_port)
|
||||
var label := _device_label()
|
||||
var url := "%s?redirect=%s&state=%s&addon=%s&machine=%s&label=%s" % [
|
||||
CghConfig.login_base_url(),
|
||||
redirect.uri_encode(), _state, CghConfig.ADDON_SLUG,
|
||||
machine_hash().uri_encode(), label.uri_encode()]
|
||||
if register:
|
||||
url += "&screen=register"
|
||||
OS.shell_open(url)
|
||||
_start_poll() # poll the loopback server on a Timer (reliable in the editor)
|
||||
|
||||
func _start_poll() -> void:
|
||||
if _poll_timer == null:
|
||||
_poll_timer = Timer.new()
|
||||
_poll_timer.wait_time = 0.05 # tick fast so we answer the browser's GET promptly
|
||||
_poll_timer.one_shot = false
|
||||
add_child(_poll_timer)
|
||||
_poll_timer.timeout.connect(_poll_login)
|
||||
_peers = []
|
||||
_wait_frames = 0
|
||||
_poll_timer.start()
|
||||
|
||||
func _stop_poll() -> void:
|
||||
if _poll_timer != null:
|
||||
_poll_timer.stop()
|
||||
|
||||
func _device_label() -> String:
|
||||
var host := OS.get_environment("COMPUTERNAME") # Windows
|
||||
if host == "":
|
||||
host = OS.get_environment("HOSTNAME") # macOS / Linux
|
||||
if host == "":
|
||||
host = "Godot"
|
||||
return "%s (%s)" % [host, OS.get_name()]
|
||||
|
||||
func _poll_login() -> void:
|
||||
if _server == null or not _server.is_listening():
|
||||
_stop_poll()
|
||||
return
|
||||
_wait_frames += 1
|
||||
# Accept EVERY pending connection this tick, not just one. Chrome/Edge open a speculative
|
||||
# "preconnect" socket alongside the real request; the old code latched onto ONE socket, so
|
||||
# if it grabbed the empty preconnect it blocked there while the real GET (carrying ?code=)
|
||||
# sat unanswered in the backlog — the browser then just spun on the localhost URL until a
|
||||
# 30s timeout. We now track them all and answer whichever one actually delivers the code.
|
||||
while _server.is_connection_available():
|
||||
var np := _server.take_connection()
|
||||
if np != null:
|
||||
_peers.append({"peer": np, "req": "", "frames": 0})
|
||||
var i := _peers.size() - 1
|
||||
while i >= 0:
|
||||
var slot: Dictionary = _peers[i]
|
||||
var peer: StreamPeerTCP = slot["peer"]
|
||||
peer.poll()
|
||||
var st := peer.get_status()
|
||||
if st == StreamPeerTCP.STATUS_CONNECTING:
|
||||
slot["frames"] = int(slot["frames"]) + 1
|
||||
i -= 1
|
||||
continue
|
||||
if st != StreamPeerTCP.STATUS_CONNECTED:
|
||||
_peers.remove_at(i) # closed/errored (usually the idle preconnect) — drop it
|
||||
i -= 1
|
||||
continue
|
||||
var avail := peer.get_available_bytes()
|
||||
if avail > 0:
|
||||
slot["req"] = str(slot["req"]) + peer.get_utf8_string(avail)
|
||||
slot["frames"] = int(slot["frames"]) + 1
|
||||
var req: String = str(slot["req"])
|
||||
if req.find("\r\n") != -1:
|
||||
var code := _extract_query_param(req, "code")
|
||||
if code != "":
|
||||
_answer_and_close(peer, code) # 302 -> branded cgheven.com/addon-login/done page
|
||||
_peers.remove_at(i)
|
||||
_finish_login(code)
|
||||
return
|
||||
# Complete request line but no ?code= (favicon, bare hit) — send it to the error
|
||||
# page and drop it; another peer may still carry the real code.
|
||||
_answer_and_close(peer, "")
|
||||
_peers.remove_at(i)
|
||||
i -= 1
|
||||
continue
|
||||
# Idle socket that never sends a request line — retire it after ~4s. This NEVER fails
|
||||
# the login; only the global timeout below can.
|
||||
if int(slot["frames"]) > 80:
|
||||
peer.disconnect_from_host()
|
||||
_peers.remove_at(i)
|
||||
i -= 1
|
||||
if _wait_frames > 1200: # ~60s overall safety timeout at 0.05s/tick
|
||||
_finish_login("")
|
||||
|
||||
# Write the browser's 302 redirect to the branded cgheven.com/addon-login/done page — exactly
|
||||
# like the Blender/AE/Premiere addons — so the user lands on our site, NOT a localhost page.
|
||||
# The loopback is only the secure handoff; it's never the landing.
|
||||
func _answer_and_close(peer: StreamPeerTCP, code: String) -> void:
|
||||
var status := "ok" if code != "" else "error"
|
||||
var dest := "%s/addon-login/done?addon=%s&status=%s" % [
|
||||
CghConfig.web_base(), CghConfig.ADDON_SLUG, status]
|
||||
var resp := "HTTP/1.1 302 Found\r\nLocation: %s\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" % dest
|
||||
peer.put_data(resp.to_utf8_buffer())
|
||||
peer.poll()
|
||||
peer.disconnect_from_host()
|
||||
|
||||
# Tear down the loopback (close any stragglers + stop the server) and, if we caught a code,
|
||||
# redeem it for the session token.
|
||||
func _finish_login(code: String) -> void:
|
||||
for slot in _peers:
|
||||
var p: StreamPeerTCP = slot["peer"]
|
||||
if p != null:
|
||||
p.disconnect_from_host()
|
||||
_peers = []
|
||||
if _server != null:
|
||||
_server.stop()
|
||||
_stop_poll()
|
||||
if code == "":
|
||||
login_failed.emit("No login code received. Please try logging in again.")
|
||||
return
|
||||
_exchange_code(code)
|
||||
|
||||
func _extract_query_param(req: String, key: String) -> String:
|
||||
# req first line looks like: GET /callback?code=ABC&... HTTP/1.1
|
||||
var line := req.split("\r\n")[0]
|
||||
var qpos := line.find("?")
|
||||
if qpos == -1:
|
||||
return ""
|
||||
var path := line.substr(qpos + 1)
|
||||
path = path.split(" ")[0] # strip trailing " HTTP/1.1"
|
||||
for pair in path.split("&"):
|
||||
# Split on the FIRST '=' only — the value is a JWT/base64 handoff code that itself
|
||||
# contains '=' padding, so a plain split("=") would break into 3+ parts and drop it.
|
||||
var eq := pair.find("=")
|
||||
if eq == -1:
|
||||
continue
|
||||
if pair.substr(0, eq) == key:
|
||||
return pair.substr(eq + 1).uri_decode()
|
||||
return ""
|
||||
|
||||
func _exchange_code(code: String) -> void:
|
||||
# Redeem the handoff code for the session payload (same as Blender/AE).
|
||||
var body := JSON.stringify({"code": code})
|
||||
_exchange_http.request(CghConfig.addon_exchange_url(),
|
||||
PackedStringArray(["Content-Type: application/json"]),
|
||||
HTTPClient.METHOD_POST, body)
|
||||
|
||||
func _on_exchange_done(_r: int, code_status: int, _h: PackedStringArray, b: PackedByteArray) -> void:
|
||||
if code_status < 200 or code_status >= 300:
|
||||
login_failed.emit("Login exchange failed (HTTP %d)" % code_status)
|
||||
return
|
||||
var d = JSON.parse_string(b.get_string_from_utf8())
|
||||
if typeof(d) != TYPE_DICTIONARY or not d.has("session_token"):
|
||||
login_failed.emit("Login exchange returned no token.")
|
||||
return
|
||||
session_token = d["session_token"]
|
||||
plan = _plan_name_from(d)
|
||||
email = str(d.get("email", ""))
|
||||
expires_at = str(d.get("expires_at", ""))
|
||||
_save_session()
|
||||
logged_in.emit(plan)
|
||||
|
||||
# The broker's `plan` field is the BILLING CYCLE ("Yearly"/"Monthly"), not the plan
|
||||
# name — the real name is in `plan_label` ("Studio (Yearly)"). Derive a clean name
|
||||
# so is_premium()/gating/analytics recognise paid users.
|
||||
func _plan_name_from(d: Dictionary) -> String:
|
||||
var hay := (str(d.get("plan_label", "")) + " " + str(d.get("plan", ""))).to_lower()
|
||||
if hay.contains("studio"):
|
||||
return "Studio"
|
||||
if hay.contains("pro"):
|
||||
return "Pro"
|
||||
if hay.contains("bundle"):
|
||||
return "Bundle"
|
||||
if bool(d.get("is_premium", false)):
|
||||
return "Pro"
|
||||
return "Free"
|
||||
|
||||
# -------------------------- license-key activation --------------------------
|
||||
func activate_key(key: String) -> void:
|
||||
var body := JSON.stringify({
|
||||
"license_key": key,
|
||||
"machine_hash": machine_hash(),
|
||||
"source": "godot",
|
||||
})
|
||||
# reuse the exchange HTTPRequest; same _on_exchange_done handler shape
|
||||
_exchange_http.request(CghConfig.addon_activate_url(),
|
||||
PackedStringArray(["Content-Type: application/json"]),
|
||||
HTTPClient.METHOD_POST, body)
|
||||
|
||||
# -------------------------- heartbeat (live plan refresh) --------------------------
|
||||
func heartbeat() -> void:
|
||||
if session_token == "":
|
||||
return
|
||||
_heartbeat_http.request(CghConfig.heartbeat_url(),
|
||||
PackedStringArray(["Authorization: Bearer " + session_token]),
|
||||
HTTPClient.METHOD_GET)
|
||||
|
||||
func _on_heartbeat_done(_r: int, code_status: int, _h: PackedStringArray, b: PackedByteArray) -> void:
|
||||
if code_status < 200 or code_status >= 300:
|
||||
return
|
||||
var d = JSON.parse_string(b.get_string_from_utf8())
|
||||
if typeof(d) == TYPE_DICTIONARY and d.has("plan"):
|
||||
var new_plan := _plan_name_from(d)
|
||||
if new_plan != plan:
|
||||
plan = new_plan
|
||||
_save_session()
|
||||
plan_refreshed.emit(plan)
|
||||
|
||||
# -------------------------- machine fingerprint (license-key path) --------------------------
|
||||
static func machine_hash() -> String:
|
||||
# NOTE (risk): must match the Blender addon fingerprint or existing keys won't validate.
|
||||
return (OS.get_unique_id() + "|" + OS.get_name()).sha256_text()
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6ock0fik2qi8
|
||||
@@ -0,0 +1,75 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghCache
|
||||
## Local JSON cache + thumbnail cache under user://cgheven (cross-project).
|
||||
## Plan-keyed so a Free->Pro upgrade re-fetches instead of serving stale gated data.
|
||||
|
||||
# Bump this when the asset query changes shape (e.g. the "All" tab now excludes
|
||||
# vfx/vdbs) — old cache files have a different prefix and are simply ignored.
|
||||
const ASSET_CACHE_VERSION := "v2"
|
||||
|
||||
static func _asset_cache_path(plan: String, key: String) -> String:
|
||||
return "%s/assets_%s_%s_%s.json" % [CghConfig.CACHE_DIR, ASSET_CACHE_VERSION, plan, key.sha256_text().substr(0, 12)]
|
||||
|
||||
## Store the page envelope {assets, meta} so pagination (pageCount) survives too.
|
||||
static func save_assets(plan: String, key: String, assets: Array, meta := {}) -> void:
|
||||
CghConfig.ensure_dirs()
|
||||
var f := FileAccess.open(_asset_cache_path(plan, key), FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_string(JSON.stringify({"assets": assets, "meta": meta}))
|
||||
f.close()
|
||||
|
||||
## Returns {"assets":[...], "meta":{...}} or {} on a miss. Instant (disk read) —
|
||||
## a page that was loaded once is served from here forever (no re-fetch).
|
||||
static func load_page(plan: String, key: String) -> Dictionary:
|
||||
var p := _asset_cache_path(plan, key)
|
||||
if not FileAccess.file_exists(p):
|
||||
return {}
|
||||
var f := FileAccess.open(p, FileAccess.READ)
|
||||
if not f:
|
||||
return {}
|
||||
var parsed = JSON.parse_string(f.get_as_text())
|
||||
f.close()
|
||||
if parsed is Dictionary and parsed.has("assets"):
|
||||
return parsed
|
||||
if parsed is Array: # back-compat with the old (array-only) cache format
|
||||
return {"assets": parsed, "meta": {}}
|
||||
return {}
|
||||
|
||||
## Wipe only the asset-list cache (keeps thumbnails) — used by the manual Refresh.
|
||||
static func clear_assets() -> void:
|
||||
var d := DirAccess.open(CghConfig.CACHE_DIR)
|
||||
if d:
|
||||
for file in d.get_files():
|
||||
d.remove(file)
|
||||
|
||||
# --- Thumbnails: store the ALREADY-DECODED image as PNG, rebuild on load. ---
|
||||
# (Source thumbnails are .webp; we must re-encode to PNG, not dump raw webp into a
|
||||
# .png file — otherwise Image.load() picks the decoder by extension and fails.)
|
||||
static func _thumb_path(url: String) -> String:
|
||||
return "%s/%s.png" % [CghConfig.THUMB_DIR, url.sha256_text().substr(0, 16)]
|
||||
|
||||
static func has_thumb(url: String) -> bool:
|
||||
return FileAccess.file_exists(_thumb_path(url))
|
||||
|
||||
static func save_thumb(url: String, img: Image) -> void:
|
||||
if img == null:
|
||||
return
|
||||
CghConfig.ensure_dirs()
|
||||
img.save_png(_thumb_path(url))
|
||||
|
||||
static func load_thumb(url: String) -> Texture2D:
|
||||
var p := _thumb_path(url)
|
||||
if not FileAccess.file_exists(p):
|
||||
return null
|
||||
var img := Image.new()
|
||||
if img.load(p) != OK:
|
||||
return null
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
static func clear() -> void:
|
||||
for dir in [CghConfig.CACHE_DIR, CghConfig.THUMB_DIR]:
|
||||
var d := DirAccess.open(dir)
|
||||
if d:
|
||||
for file in d.get_files():
|
||||
d.remove(file)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7tbtkjio3q4g
|
||||
@@ -0,0 +1,269 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghConfig
|
||||
## Central config + exact After-Effects-addon color palette.
|
||||
## All colors copied 1:1 from the AE/PPRO addon spec so the Godot panel looks identical.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend endpoints (override via OS env for dev, like the desktop addons)
|
||||
# ---------------------------------------------------------------------------
|
||||
const ADDON_SLUG := "godot"
|
||||
const ADDON_VERSION := "1.0.0"
|
||||
|
||||
# Distribution channel tag (build-time). Lets PostHog split installs by where the
|
||||
# user got the addon (website / godot_asset_library / adobe_exchange / …).
|
||||
# Resolution order: env CGHEVEN_INSTALL_SOURCE -> bundled install_source.txt
|
||||
# (res://addons/cgheven/install_source.txt, dropped in per-channel builds) -> default.
|
||||
const INSTALL_SOURCE_DEFAULT := "website"
|
||||
static func install_source() -> String:
|
||||
var e := OS.get_environment("CGHEVEN_INSTALL_SOURCE").strip_edges()
|
||||
if e != "":
|
||||
return e
|
||||
var p := "res://addons/cgheven/install_source.txt"
|
||||
if FileAccess.file_exists(p):
|
||||
var f := FileAccess.open(p, FileAccess.READ)
|
||||
if f:
|
||||
var t := f.get_as_text().strip_edges()
|
||||
f.close()
|
||||
if t != "":
|
||||
return t
|
||||
return INSTALL_SOURCE_DEFAULT
|
||||
|
||||
static func api_base() -> String:
|
||||
var e := OS.get_environment("CGHEVEN_API_BASE")
|
||||
return e if e != "" else "https://api.cgheven.com"
|
||||
|
||||
static func web_base() -> String:
|
||||
var e := OS.get_environment("CGHEVEN_WEB_BASE")
|
||||
return e if e != "" else "https://cgheven.com"
|
||||
|
||||
# Asset listing goes through the same gating proxy the other addons use.
|
||||
static func public_assets_url() -> String:
|
||||
return api_base() + "/api/proxy/public/assets" # guest browsing
|
||||
static func authed_assets_url() -> String:
|
||||
return api_base() + "/api/proxy/assets" # logged-in (Bearer)
|
||||
# Category tree (top categories + subcategories) — same proxy, for the live
|
||||
# subcategory dropdown.
|
||||
static func public_categories_url() -> String:
|
||||
return api_base() + "/api/proxy/public/categories"
|
||||
static func authed_categories_url() -> String:
|
||||
return api_base() + "/api/proxy/categories"
|
||||
# BlenderKit-exact broker: the addon opens our website's /addon-login page, which logs
|
||||
# the user in and redirects to the loopback with a short ?code=. addon.start_login()
|
||||
# builds the full URL (it has the port/state/machine). Loopback path mirrors Blender.
|
||||
static func login_base_url() -> String:
|
||||
return web_base() + "/addon-login"
|
||||
static func loopback_redirect(port: int) -> String:
|
||||
return "http://127.0.0.1:%d/consumer/exchange/" % port
|
||||
# Same endpoint the Blender/AE addons redeem their handoff code at.
|
||||
static func addon_exchange_url() -> String:
|
||||
return api_base() + "/api/logto/addon-exchange"
|
||||
static func addon_activate_url() -> String:
|
||||
return api_base() + "/api/addon-license/activate"
|
||||
static func heartbeat_url() -> String:
|
||||
return api_base() + "/api/addon-updates/heartbeat"
|
||||
# Analytics goes through the backend (same as every desktop addon) so the PostHog
|
||||
# key stays server-side — the addon never ships a key.
|
||||
static func analytics_track_url() -> String:
|
||||
return api_base() + "/api/analytics/track"
|
||||
static func check_update_url() -> String:
|
||||
return api_base() + "/api/addon-updates/check"
|
||||
static func pricing_url() -> String:
|
||||
return web_base() + "/pricing"
|
||||
static func discord_url() -> String:
|
||||
return "https://discord.gg/cgheven"
|
||||
|
||||
# PostHog (analytics) — same project as the desktop addons.
|
||||
static func posthog_host() -> String:
|
||||
return "https://us.i.posthog.com"
|
||||
static func posthog_key() -> String:
|
||||
var e := OS.get_environment("CGHEVEN_POSTHOG_KEY")
|
||||
return e if e != "" else "" # filled at build/ship time
|
||||
|
||||
# Loopback OAuth ports (avoid 62485 = BlenderKit, like the Blender addon)
|
||||
const LOOPBACK_PORTS := [62490, 62491, 62492]
|
||||
|
||||
# Locked-file marker the backend sends instead of a real CDN url.
|
||||
const LOCK_PREFIX := "CGHLOCKED::"
|
||||
|
||||
# Local data lives under user:// (cross-project, per-user, always writable).
|
||||
const DATA_DIR := "user://cgheven"
|
||||
const CACHE_DIR := "user://cgheven/cache"
|
||||
const THUMB_DIR := "user://cgheven/thumbs"
|
||||
const DOWNLOAD_DIR := "user://cgheven/downloads"
|
||||
const PREFS_FILE := "user://cgheven/prefs.json"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User prefs (persisted JSON) — e.g. a custom download folder the user picks.
|
||||
# ---------------------------------------------------------------------------
|
||||
static func _read_prefs() -> Dictionary:
|
||||
if not FileAccess.file_exists(PREFS_FILE):
|
||||
return {}
|
||||
var f := FileAccess.open(PREFS_FILE, FileAccess.READ)
|
||||
if f == null:
|
||||
return {}
|
||||
var txt := f.get_as_text()
|
||||
f.close()
|
||||
var d = JSON.parse_string(txt)
|
||||
return d if d is Dictionary else {}
|
||||
|
||||
static func _write_prefs(d: Dictionary) -> void:
|
||||
DirAccess.make_dir_recursive_absolute(DATA_DIR)
|
||||
var f := FileAccess.open(PREFS_FILE, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_string(JSON.stringify(d))
|
||||
f.close()
|
||||
|
||||
# Where the user chose to save downloads ("" = use the default shared folder).
|
||||
static func custom_download_dir() -> String:
|
||||
return str(_read_prefs().get("download_dir", ""))
|
||||
|
||||
static func set_custom_download_dir(p: String) -> void:
|
||||
var d := _read_prefs()
|
||||
d["download_dir"] = p
|
||||
_write_prefs(d)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COLORS — exact hex from the After Effects addon (addon-ui-spec)
|
||||
# ---------------------------------------------------------------------------
|
||||
const C_BG := Color("141416")
|
||||
const C_BG2 := Color("1a1a1f")
|
||||
const C_CARD := Color("1e1e24")
|
||||
const C_CARD_HOVER := Color("252530")
|
||||
const C_INPUT := Color("1a1a20")
|
||||
const C_NAV := Color("111114")
|
||||
const C_ACCENT := Color("5b7cff")
|
||||
const C_ACCENT_HOVER:= Color("6e8dff")
|
||||
const C_ACCENT_DIM := Color(0.357, 0.486, 1.0, 0.14) # rgba(91,124,255,.14)
|
||||
const C_TEXT1 := Color("f0f0f5")
|
||||
const C_TEXT2 := Color("8a8a9a")
|
||||
const C_TEXT3 := Color("505060")
|
||||
const C_BORDER := Color(1, 1, 1, 0.07) # rgba(255,255,255,.07)
|
||||
const C_BORDER_HOVER:= Color(1, 1, 1, 0.13) # rgba(255,255,255,.13)
|
||||
const C_DROPDOWN := Color("1c1c22") # popover bg --bgd
|
||||
const C_OK := Color("34c759")
|
||||
const C_ERR := Color("ff453a")
|
||||
const C_WARN := Color("ff9f0a")
|
||||
# CGHEVEN brand gradient: #f97316 -> #ef4444 (StyleBoxFlat has no gradient,
|
||||
# so the Upgrade/Login buttons use a solid mid-orange that reads the same).
|
||||
const C_BRAND_A := Color("f97316")
|
||||
const C_BRAND_B := Color("ef4444")
|
||||
const C_BRAND := Color("f4502c") # mid of the gradient
|
||||
const C_BRAND_HOVER := Color("e0431f")
|
||||
const C_RES_BADGE := Color(0.039, 0.039, 0.071, 0.82) # rgba(10,10,18,.82)
|
||||
const C_FOOTER_PILL := Color(0.5, 0.5, 0.5, 0.07) # rgba(128,128,128,.07)
|
||||
|
||||
# Radii / spacing (AE spec)
|
||||
const RADIUS_SM := 4
|
||||
const RADIUS_MD := 6
|
||||
const RADIUS_LG := 8 # card radius
|
||||
const RADIUS_XL := 10
|
||||
const GRID_GAP := 7
|
||||
|
||||
# Category pills. Real backend slugs (from the Blender addon):
|
||||
# vfx, 3d-models, hdri, flipbooks, vdbs, shaders, tutorials
|
||||
# Godot can usefully consume only: 3d-models, hdri, flipbooks.
|
||||
# vdbs / vfx(video) / tutorials / shaders are Blender-only and intentionally NOT listed.
|
||||
const CATEGORIES := [
|
||||
{"label": "All", "slug": ""},
|
||||
{"label": "3D Models", "slug": "3d-models"},
|
||||
{"label": "HDRI", "slug": "hdri"},
|
||||
{"label": "Flipbooks", "slug": "flipbooks"},
|
||||
]
|
||||
|
||||
# Subcategories per parent slug (from the Blender addon _PARENT_MAP).
|
||||
# Each subcategory is itself a backend category Slug; selecting one filters by it.
|
||||
const SUBCATEGORIES := {
|
||||
"3d-models": [
|
||||
{"label": "Furniture", "slug": "furniture"},
|
||||
{"label": "Props", "slug": "props"},
|
||||
{"label": "Weapons", "slug": "weapons"},
|
||||
],
|
||||
"hdri": [
|
||||
{"label": "Space", "slug": "space-hdr"},
|
||||
],
|
||||
"flipbooks": [
|
||||
{"label": "Fire", "slug": "fire-flipbooks"},
|
||||
{"label": "Magic Effects", "slug": "magic-effects-flipbooks"},
|
||||
{"label": "Muzzle Flashes","slug": "muzzle-flashes-flipbooks"},
|
||||
{"label": "Tornado FX", "slug": "tornado-fx-flipbooks"},
|
||||
],
|
||||
"shaders": [],
|
||||
}
|
||||
|
||||
# Sort options (matches AE/Blender)
|
||||
const SORTS := [
|
||||
{"label": "Newest", "param": "createdAt:desc"},
|
||||
{"label": "Oldest", "param": "createdAt:asc"},
|
||||
{"label": "A -> Z", "param": "Title:asc"},
|
||||
{"label": "Z -> A", "param": "Title:desc"},
|
||||
]
|
||||
|
||||
# File extensions Godot can import, grouped by purpose.
|
||||
const EXT_3D := ["glb", "gltf", "fbx"]
|
||||
const EXT_HDRI := ["exr", "hdr"]
|
||||
const EXT_IMAGE := ["png", "jpg", "jpeg", "webp"] # flipbook sheets / textures
|
||||
const EXT_ARCHIVE := ["zip"]
|
||||
# Resolution tokens recognised in filenames (index 0 = lowest = free fallback)
|
||||
const RES_ORDER := ["1k", "2k", "4k", "8k", "16k"]
|
||||
|
||||
# Discord invite (Godot community — placeholder, set real link before ship)
|
||||
const DISCORD := "https://discord.gg/cgheven"
|
||||
|
||||
static func ensure_dirs() -> void:
|
||||
for d in [DATA_DIR, CACHE_DIR, THUMB_DIR, DOWNLOAD_DIR]:
|
||||
DirAccess.make_dir_recursive_absolute(d)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-addon "file scanner" — shared CGHEVEN download folders on the OS.
|
||||
# A file downloaded by AE (~/Downloads/CGHEVEN), Blender (~/Documents/CGHEVEN)
|
||||
# or this addon should show the ✓ "downloaded" tick everywhere. We scan all of
|
||||
# these by filename so the tick is shared across every CGHEVEN addon.
|
||||
# ---------------------------------------------------------------------------
|
||||
static func os_home() -> String:
|
||||
var h := OS.get_environment("USERPROFILE") # Windows
|
||||
if h == "":
|
||||
h = OS.get_environment("HOME") # macOS / Linux
|
||||
return h
|
||||
|
||||
static func shared_download_dirs() -> Array:
|
||||
var home := os_home()
|
||||
var dirs := []
|
||||
var custom := custom_download_dir()
|
||||
if custom != "":
|
||||
dirs.append(custom) # user-chosen folder
|
||||
if home != "":
|
||||
dirs.append(home.path_join("Downloads").path_join("CGHEVEN")) # AE / PPRO default
|
||||
dirs.append(home.path_join("Documents").path_join("CGHEVEN")) # Blender default
|
||||
dirs.append(ProjectSettings.globalize_path(DOWNLOAD_DIR)) # this addon
|
||||
return dirs
|
||||
|
||||
# Where THIS addon saves downloads — a user-chosen folder if set, else the shared
|
||||
# AE folder so the other CGHEVEN addons see the files too.
|
||||
static func primary_download_dir() -> String:
|
||||
var custom := custom_download_dir()
|
||||
if custom != "":
|
||||
return custom
|
||||
var home := os_home()
|
||||
if home != "":
|
||||
return home.path_join("Downloads").path_join("CGHEVEN")
|
||||
return ProjectSettings.globalize_path(DOWNLOAD_DIR)
|
||||
|
||||
# Sanitize a CDN filename the same way the Blender addon does (cross-addon match).
|
||||
static func safe_download_name(filename: String) -> String:
|
||||
var f := filename.uri_decode()
|
||||
for ch in ["<", ">", ":", "\"", "/", "\\", "|", "?", "*"]:
|
||||
f = f.replace(ch, "_")
|
||||
return f
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Responsive grid columns — exact AE breakpoints (panel width -> column count).
|
||||
# ---------------------------------------------------------------------------
|
||||
static func responsive_columns(width: float) -> int:
|
||||
# Default narrow dock shows 2 large AE-style cards; grows on wider panels.
|
||||
if width < 520.0: return 2
|
||||
if width < 780.0: return 3
|
||||
if width < 1040.0: return 4
|
||||
if width < 1300.0: return 5
|
||||
if width < 1560.0: return 6
|
||||
return 7
|
||||
@@ -0,0 +1 @@
|
||||
uid://67wa4ry6asqt
|
||||
@@ -0,0 +1,90 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name CghDownloader
|
||||
## Threaded file download with polled progress (Godot has no progress signal).
|
||||
## Emits progress every frame while a download is in flight.
|
||||
|
||||
signal progress(pct: float, downloaded: int, total: int)
|
||||
signal finished(path: String)
|
||||
signal failed(message: String)
|
||||
|
||||
var _http: HTTPRequest
|
||||
var _active := false
|
||||
var _dest := ""
|
||||
var _last_got := 0 # bytes seen last frame — to detect a dead/stalled connection
|
||||
var _stall := 0.0 # seconds with zero new bytes
|
||||
const STALL_LIMIT := 30.0 # a hung download must not block every later one forever
|
||||
|
||||
func _ready() -> void:
|
||||
_http = HTTPRequest.new()
|
||||
_http.use_threads = true
|
||||
add_child(_http)
|
||||
_http.request_completed.connect(_on_done)
|
||||
|
||||
func download(url: String, filename: String, headers := PackedStringArray()) -> void:
|
||||
if _active:
|
||||
failed.emit("A download is already in progress.")
|
||||
return
|
||||
CghConfig.ensure_dirs()
|
||||
# Save into the SHARED CGHEVEN folder so AE/Blender/etc. see it too (file scanner).
|
||||
var dir := CghConfig.primary_download_dir()
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
_dest = dir.path_join(CghConfig.safe_download_name(filename))
|
||||
# Clear any orphaned/stuck request first (mirrors api_client) so a previously hung
|
||||
# download that left the node "processing" can't block this new one from starting.
|
||||
_http.cancel_request()
|
||||
_http.download_file = _dest
|
||||
var err := _http.request(url, headers, HTTPClient.METHOD_GET)
|
||||
if err != OK:
|
||||
failed.emit("Download could not start (err %d)" % err)
|
||||
return
|
||||
_active = true
|
||||
_last_got = 0
|
||||
_stall = 0.0
|
||||
set_process(true)
|
||||
|
||||
func _process(dt: float) -> void:
|
||||
if not _active:
|
||||
set_process(false)
|
||||
return
|
||||
var got := _http.get_downloaded_bytes()
|
||||
var total := _http.get_body_size() # -1 on chunked / no Content-Length
|
||||
# Dead-connection guard: if no new bytes arrive for STALL_LIMIT seconds, give up so
|
||||
# the download queue isn't blocked forever (a hung request never fires _on_done).
|
||||
if got > _last_got:
|
||||
_last_got = got
|
||||
_stall = 0.0
|
||||
else:
|
||||
_stall += dt
|
||||
if _stall >= STALL_LIMIT:
|
||||
_http.cancel_request()
|
||||
_active = false
|
||||
set_process(false)
|
||||
failed.emit("Download stalled (no data for %ds) — check your connection and retry." % int(STALL_LIMIT))
|
||||
return
|
||||
if total > 0:
|
||||
progress.emit(float(got) / float(total) * 100.0, got, total)
|
||||
else:
|
||||
progress.emit(-1.0, got, -1) # indeterminate -> spinner
|
||||
|
||||
func cancel() -> void:
|
||||
if _active:
|
||||
_http.cancel_request()
|
||||
_active = false
|
||||
set_process(false)
|
||||
|
||||
func _on_done(result: int, code: int, _h: PackedStringArray, _b: PackedByteArray) -> void:
|
||||
_active = false
|
||||
set_process(false)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
||||
var hint := ""
|
||||
if code == 401 or code == 403:
|
||||
hint = " — access denied (file link may be expired/invalid on the server)"
|
||||
elif code == 404:
|
||||
hint = " — file not found on the server"
|
||||
elif result != HTTPRequest.RESULT_SUCCESS:
|
||||
hint = " — network/connection issue"
|
||||
failed.emit("Download failed for %s%s [result %d, HTTP %d]" % [_dest.get_file(), hint, result, code])
|
||||
return
|
||||
CghDownloads.mark_saved(_dest.get_file())
|
||||
finished.emit(_dest)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3p8ea4j8waqs
|
||||
@@ -0,0 +1,221 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghDownloads
|
||||
## Cross-addon "downloaded" state — matches the After Effects / Premiere / DaVinci
|
||||
## addons, which all share ONE manifest file: ~/.cgheven_cg_dl_v2.json
|
||||
##
|
||||
## Detection is by ASSET ID (each record is {id, fp, fmt, res, title, cat, at}); an
|
||||
## asset counts as downloaded if the manifest has a record for its id whose file is
|
||||
## still on disk. We append a record whenever this addon finishes a download, so a
|
||||
## file grabbed in Godot shows the ✓ in AE/Premiere/DaVinci too, and vice-versa.
|
||||
##
|
||||
## We ALSO keep a cheap filename scan of the shared download folders as a secondary
|
||||
## signal (this catches the Blender addon, which uses its own ~/Documents/CGHEVEN +
|
||||
## a separate prefs file and isn't part of the v2 manifest).
|
||||
|
||||
const MANIFEST_NAME := ".cgheven_cg_dl_v2.json"
|
||||
const MAX_RECORDS := 200
|
||||
|
||||
static var _names := {} # lower-case filename -> true (folder scan)
|
||||
static var _ids := {} # asset-id string -> true (manifest, file-exists verified)
|
||||
static var _id_res := {} # "id|res" -> true (per-resolution, cross-addon)
|
||||
static var _scanned := false
|
||||
|
||||
# Normalize an id so "3535.0" (an old float-string id) matches "3535".
|
||||
static func _norm_id(v) -> String:
|
||||
var s := str(v)
|
||||
if s.ends_with(".0"):
|
||||
s = s.substr(0, s.length() - 2)
|
||||
return s
|
||||
|
||||
static func _manifest_path() -> String:
|
||||
var home := CghConfig.os_home()
|
||||
if home == "":
|
||||
return ""
|
||||
return home.path_join(MANIFEST_NAME)
|
||||
|
||||
static func _read_manifest() -> Array:
|
||||
var p := _manifest_path()
|
||||
if p == "" or not FileAccess.file_exists(p):
|
||||
return []
|
||||
var f := FileAccess.open(p, FileAccess.READ)
|
||||
if f == null:
|
||||
return []
|
||||
var txt := f.get_as_text()
|
||||
f.close()
|
||||
var d = JSON.parse_string(txt)
|
||||
return d if d is Array else []
|
||||
|
||||
static func _write_manifest(arr: Array) -> void:
|
||||
var p := _manifest_path()
|
||||
if p == "":
|
||||
return
|
||||
var f := FileAccess.open(p, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_string(JSON.stringify(arr))
|
||||
f.close()
|
||||
|
||||
static func rescan() -> void:
|
||||
_names.clear()
|
||||
_ids.clear()
|
||||
_id_res.clear()
|
||||
# 1) shared manifest (AE / Premiere / DaVinci) — verify each file still exists
|
||||
for r in _read_manifest():
|
||||
if not (r is Dictionary):
|
||||
continue
|
||||
var fp := str(r.get("fp", ""))
|
||||
if fp != "" and FileAccess.file_exists(fp):
|
||||
var rid := _norm_id(r.get("id", ""))
|
||||
_ids[rid] = true
|
||||
_id_res["%s|%s" % [rid, str(r.get("res", ""))]] = true
|
||||
# 2) folder filename scan (Blender + this addon's own folder)
|
||||
for dir in CghConfig.shared_download_dirs():
|
||||
_scan_dir(dir)
|
||||
_scanned = true
|
||||
|
||||
static func _scan_dir(abs_dir: String) -> void:
|
||||
var da := DirAccess.open(abs_dir)
|
||||
if da == null:
|
||||
return
|
||||
da.list_dir_begin()
|
||||
var fn := da.get_next()
|
||||
while fn != "":
|
||||
if not da.current_is_dir() and not fn.ends_with(".cghpart"):
|
||||
_names[fn.to_lower()] = true
|
||||
fn = da.get_next()
|
||||
da.list_dir_end()
|
||||
|
||||
static func _ensure() -> void:
|
||||
if not _scanned:
|
||||
rescan()
|
||||
|
||||
## True if this asset is already downloaded by ANY CGHEVEN addon.
|
||||
static func is_downloaded(asset: Dictionary) -> bool:
|
||||
_ensure()
|
||||
# id match via the shared manifest (the AE/Premiere/DaVinci scheme; cross-addon)
|
||||
if _ids.has(_norm_id(CghAsset.id(asset))):
|
||||
return true
|
||||
# fallback: any of this asset's files present on disk by name
|
||||
for e in CghAsset.file_entries(asset):
|
||||
if e["locked"]:
|
||||
continue
|
||||
var nm: String = CghConfig.safe_download_name(e["filename"]).to_lower()
|
||||
if _names.has(nm):
|
||||
return true
|
||||
return false
|
||||
|
||||
## True if a SPECIFIC resolution of this asset was downloaded by any addon.
|
||||
static func is_res_downloaded(asset_id: String, res: String) -> bool:
|
||||
_ensure()
|
||||
return _id_res.has("%s|%s" % [_norm_id(asset_id), res])
|
||||
|
||||
## True if THIS specific file (by name) is already on disk in a shared folder —
|
||||
## used to switch a per-resolution menu row from "Download" to "Import".
|
||||
static func has_file(filename: String) -> bool:
|
||||
_ensure()
|
||||
return _names.has(CghConfig.safe_download_name(filename).to_lower())
|
||||
|
||||
## Absolute path of this file in ANY shared CGHEVEN folder, or "" if not on disk.
|
||||
static func local_path(filename: String) -> String:
|
||||
var safe := CghConfig.safe_download_name(filename)
|
||||
for d in CghConfig.shared_download_dirs():
|
||||
var p: String = d.path_join(safe)
|
||||
if FileAccess.file_exists(p):
|
||||
return p
|
||||
return ""
|
||||
|
||||
## Record a finished download into BOTH the in-memory caches and the shared manifest
|
||||
## so every other CGHEVEN addon sees it immediately.
|
||||
static func record(asset: Dictionary, fp: String, fmt: String, res: String) -> void:
|
||||
var id := _norm_id(CghAsset.id(asset))
|
||||
_ids[id] = true
|
||||
_id_res["%s|%s" % [id, res]] = true
|
||||
_names[CghConfig.safe_download_name(fp.get_file()).to_lower()] = true
|
||||
var arr := _read_manifest()
|
||||
# drop any prior record for the same id+fmt+res, then prepend the new one
|
||||
var kept := []
|
||||
for r in arr:
|
||||
if not (r is Dictionary):
|
||||
continue
|
||||
if _norm_id(r.get("id", "")) == id and str(r.get("fmt", "")) == fmt and str(r.get("res", "")) == res:
|
||||
continue
|
||||
kept.append(r)
|
||||
kept.insert(0, {
|
||||
"id": id,
|
||||
"fp": fp,
|
||||
"fmt": fmt,
|
||||
"res": res,
|
||||
"title": CghAsset.title(asset),
|
||||
"cat": CghAsset.category_slug(asset),
|
||||
"at": int(Time.get_unix_time_from_system() * 1000.0),
|
||||
})
|
||||
if kept.size() > MAX_RECORDS:
|
||||
kept = kept.slice(0, MAX_RECORDS)
|
||||
_write_manifest(kept)
|
||||
|
||||
## Delete every downloaded copy of this asset from the shared folders and drop its
|
||||
## manifest records, so the card flips back to "Download". Use it to re-fetch a file
|
||||
## that went missing or imported corrupt. Returns how many files were removed.
|
||||
static func delete(asset: Dictionary) -> int:
|
||||
_ensure()
|
||||
var id := _norm_id(CghAsset.id(asset))
|
||||
var names := {}
|
||||
for e in CghAsset.file_entries(asset):
|
||||
names[CghConfig.safe_download_name(e["filename"]).to_lower()] = true
|
||||
var removed := 0
|
||||
# Drop this asset's manifest records, deleting the files they point at first.
|
||||
var kept := []
|
||||
for r in _read_manifest():
|
||||
if not (r is Dictionary):
|
||||
continue
|
||||
if _norm_id(r.get("id", "")) == id:
|
||||
var fp := str(r.get("fp", ""))
|
||||
if fp != "":
|
||||
names[fp.get_file().to_lower()] = true
|
||||
if FileAccess.file_exists(fp) and DirAccess.remove_absolute(fp) == OK:
|
||||
removed += 1
|
||||
continue
|
||||
kept.append(r)
|
||||
_write_manifest(kept)
|
||||
# Delete any remaining copies by filename across every shared CGHEVEN folder.
|
||||
for d in CghConfig.shared_download_dirs():
|
||||
for nm in names.keys():
|
||||
var p: String = d.path_join(nm)
|
||||
if FileAccess.file_exists(p) and DirAccess.remove_absolute(p) == OK:
|
||||
removed += 1
|
||||
# Clear the in-memory caches so is_downloaded()/has_file() update immediately.
|
||||
_ids.erase(id)
|
||||
for k in _id_res.keys():
|
||||
if str(k).begins_with(id + "|"):
|
||||
_id_res.erase(k)
|
||||
for nm in names.keys():
|
||||
_names.erase(nm)
|
||||
return removed
|
||||
|
||||
## Back-compat shim — older callers just marked a filename saved.
|
||||
static func mark_saved(filename: String) -> void:
|
||||
_names[CghConfig.safe_download_name(filename).to_lower()] = true
|
||||
|
||||
## Titles (+ resolutions) of assets present in the shared cross-addon manifest whose
|
||||
## file is still on disk — used by the ⤓ Sync button to show "what synced". One row
|
||||
## per asset (resolutions merged), newest first.
|
||||
static func synced_records() -> Array:
|
||||
var by_id := {} # id -> {title, cat, res:[...]}
|
||||
var order := [] # id first-seen order (manifest is newest-first)
|
||||
for r in _read_manifest():
|
||||
if not (r is Dictionary):
|
||||
continue
|
||||
var fp := str(r.get("fp", ""))
|
||||
if fp == "" or not FileAccess.file_exists(fp):
|
||||
continue
|
||||
var id := _norm_id(r.get("id", ""))
|
||||
if not by_id.has(id):
|
||||
by_id[id] = {"title": str(r.get("title", "")), "cat": str(r.get("cat", "")), "res": []}
|
||||
order.append(id)
|
||||
var res := str(r.get("res", ""))
|
||||
if res != "" and not (res in by_id[id]["res"]):
|
||||
by_id[id]["res"].append(res)
|
||||
var out := []
|
||||
for id in order:
|
||||
out.append(by_id[id])
|
||||
return out
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3l7vtfgil2ed
|
||||
@@ -0,0 +1,44 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghFavorites
|
||||
## Favorites store (persisted to user://cgheven/favorites.json). Stores the
|
||||
## whole asset dict so the Favorites tab can render without a re-fetch.
|
||||
|
||||
const _PATH := "user://cgheven/favorites.json"
|
||||
|
||||
static func _load() -> Dictionary:
|
||||
if not FileAccess.file_exists(_PATH):
|
||||
return {}
|
||||
var f := FileAccess.open(_PATH, FileAccess.READ)
|
||||
if not f:
|
||||
return {}
|
||||
var d = JSON.parse_string(f.get_as_text())
|
||||
f.close()
|
||||
return d if d is Dictionary else {}
|
||||
|
||||
static func _save(d: Dictionary) -> void:
|
||||
CghConfig.ensure_dirs()
|
||||
var f := FileAccess.open(_PATH, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_string(JSON.stringify(d))
|
||||
f.close()
|
||||
|
||||
static func is_fav(asset: Dictionary) -> bool:
|
||||
return _load().has(CghAsset.id(asset))
|
||||
|
||||
static func toggle(asset: Dictionary) -> bool:
|
||||
var d := _load()
|
||||
var k := CghAsset.id(asset)
|
||||
if d.has(k):
|
||||
d.erase(k)
|
||||
_save(d)
|
||||
return false
|
||||
d[k] = asset
|
||||
_save(d)
|
||||
return true
|
||||
|
||||
static func all() -> Array:
|
||||
return _load().values()
|
||||
|
||||
static func count() -> int:
|
||||
return _load().size()
|
||||
@@ -0,0 +1 @@
|
||||
uid://drlc4v2qvmasj
|
||||
@@ -0,0 +1,114 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghFlipbookImport
|
||||
## Builds a PLAYING flipbook from a sprite-sheet (NxM grid) and adds it to the
|
||||
## open scene. Uses Sprite3D (hframes/vframes) + an AnimationPlayer that keyframes
|
||||
## the `frame` property — the official, non-deprecated path that animates BOTH in
|
||||
## the editor viewport and at runtime (AnimatedTexture is deprecated/broken).
|
||||
## Handles .exr HDR sheets (load_exr_from_buffer) as well as png/jpg/webp.
|
||||
|
||||
static func from_sheet(sheet_path: String, h_frames: int, v_frames: int, fps := 30.0) -> Sprite3D:
|
||||
# SNIFF the real format from magic bytes rather than trusting the extension — CDN sheets
|
||||
# are sometimes served as webp/jpg but named ".png", and Godot's extension-based img.load()
|
||||
# then fails with a misleading "PNG corrupt" (png_driver_common). Sniffing decodes them.
|
||||
var img := _decode_sheet(FileAccess.get_file_as_bytes(sheet_path), sheet_path.get_extension().to_lower())
|
||||
if img == null:
|
||||
push_error("[CGHEVEN] Could not load flipbook sheet: " + sheet_path)
|
||||
return null
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
|
||||
var cols: int = max(1, h_frames)
|
||||
var rows: int = max(1, v_frames)
|
||||
var total: int = cols * rows
|
||||
|
||||
# Sprite3D slices the sheet natively via hframes/vframes; `frame` indexes the grid.
|
||||
var spr := Sprite3D.new()
|
||||
spr.name = "CghFlipbook"
|
||||
spr.texture = tex
|
||||
spr.hframes = cols
|
||||
spr.vframes = rows
|
||||
spr.frame = 0
|
||||
spr.billboard = BaseMaterial3D.BILLBOARD_ENABLED # face the camera (VFX)
|
||||
spr.alpha_cut = SpriteBase3D.ALPHA_CUT_DISABLED # default = alpha blend
|
||||
|
||||
var edited := EditorInterface.get_edited_scene_root()
|
||||
if edited == null:
|
||||
push_warning("[CGHEVEN] No open scene — flipbook node created but not added. Open a scene and re-import.")
|
||||
return spr
|
||||
|
||||
# A Sprite3D only lives/renders in a 3D world. If the open scene is a UI
|
||||
# (Control) or 2D (Node2D) scene, nesting it inside that tree makes it
|
||||
# invisible. So guarantee a 3D host: use the root if it is Node3D, else drop
|
||||
# the flipbook under a dedicated Node3D wrapper (reused across imports).
|
||||
var parent_3d: Node = _ensure_3d_parent(edited)
|
||||
parent_3d.add_child(spr)
|
||||
spr.owner = edited
|
||||
|
||||
# Single-frame sheet (no NxM in the filename) — nothing to animate.
|
||||
if total <= 1:
|
||||
return spr
|
||||
|
||||
# Build a discrete, looping animation that steps `frame` 0..total-1.
|
||||
var anim := Animation.new()
|
||||
var track := anim.add_track(Animation.TYPE_VALUE)
|
||||
anim.track_set_path(track, NodePath(".:frame"))
|
||||
anim.value_track_set_update_mode(track, Animation.UPDATE_DISCRETE)
|
||||
anim.length = float(total) / fps
|
||||
anim.loop_mode = Animation.LOOP_LINEAR
|
||||
for i in total:
|
||||
anim.track_insert_key(track, float(i) / fps, i)
|
||||
|
||||
var lib := AnimationLibrary.new()
|
||||
lib.add_animation("play", anim)
|
||||
|
||||
var ap := AnimationPlayer.new()
|
||||
ap.name = "FlipbookPlayer"
|
||||
spr.add_child(ap)
|
||||
ap.owner = edited
|
||||
ap.add_animation_library("", lib)
|
||||
ap.autoplay = "play" # plays automatically when the scene runs
|
||||
ap.play("play") # and starts immediately in the editor viewport
|
||||
return spr
|
||||
|
||||
|
||||
## Decode a sprite-sheet from raw bytes, sniffing the real format from magic bytes (the file
|
||||
## extension often lies). PNG=89 50 4E 47, JPEG=FF D8, WEBP=RIFF....WEBP, EXR=76 2F 31 01.
|
||||
## Returns null on an unrecognised/corrupt body.
|
||||
static func _decode_sheet(body: PackedByteArray, ext: String) -> Image:
|
||||
if body.size() < 12:
|
||||
return null
|
||||
var img := Image.new()
|
||||
var ok := ERR_INVALID_DATA
|
||||
if body[0] == 0x89 and body[1] == 0x50 and body[2] == 0x4E and body[3] == 0x47:
|
||||
ok = img.load_png_from_buffer(body)
|
||||
elif body[0] == 0xFF and body[1] == 0xD8:
|
||||
ok = img.load_jpg_from_buffer(body)
|
||||
elif body[0] == 0x52 and body[1] == 0x49 and body[2] == 0x46 and body[3] == 0x46 \
|
||||
and body[8] == 0x57 and body[9] == 0x45 and body[10] == 0x42 and body[11] == 0x50:
|
||||
ok = img.load_webp_from_buffer(body)
|
||||
elif body[0] == 0x76 and body[1] == 0x2F and body[2] == 0x31 and body[3] == 0x01:
|
||||
ok = img.load_exr_from_buffer(body)
|
||||
elif ext == "exr":
|
||||
ok = img.load_exr_from_buffer(body) # magic missing but named .exr — try anyway
|
||||
else:
|
||||
return null
|
||||
return img if ok == OK else null
|
||||
|
||||
## Returns a 3D-capable parent for the flipbook. If the edited scene root is a
|
||||
## Node3D we use it directly; otherwise (Control / Node2D / plain Node scene) we
|
||||
## create — or reuse — a "CghevenFlipbooks" Node3D so the sprite always sits in a
|
||||
## real 3D context and renders in the 3D viewport instead of vanishing into UI.
|
||||
static func _ensure_3d_parent(edited: Node) -> Node:
|
||||
if edited is Node3D:
|
||||
return edited
|
||||
var existing := edited.get_node_or_null(NodePath("CghevenFlipbooks"))
|
||||
if existing != null and existing is Node3D:
|
||||
return existing
|
||||
var host := Node3D.new()
|
||||
host.name = "CghevenFlipbooks"
|
||||
edited.add_child(host)
|
||||
host.owner = edited
|
||||
push_warning("[CGHEVEN] Open scene is not 3D — flipbook placed under a new "
|
||||
+ "'CghevenFlipbooks' Node3D. Switch to the 3D viewport (or open a 3D "
|
||||
+ "scene) to see it play.")
|
||||
return host
|
||||
@@ -0,0 +1 @@
|
||||
uid://d06uk4ol66bf2
|
||||
@@ -0,0 +1,26 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghGltfImport
|
||||
## Imports a downloaded .glb/.gltf into the currently edited scene.
|
||||
|
||||
static func import_into_scene(glb_path: String) -> Node:
|
||||
var doc := GLTFDocument.new()
|
||||
var state := GLTFState.new()
|
||||
var err := doc.append_from_file(glb_path, state)
|
||||
if err != OK:
|
||||
push_error("[CGHEVEN] glTF parse failed: %d" % err)
|
||||
return null
|
||||
var scene_root := doc.generate_scene(state)
|
||||
if scene_root == null:
|
||||
push_error("[CGHEVEN] glTF generate_scene returned null")
|
||||
return null
|
||||
var edited := EditorInterface.get_edited_scene_root()
|
||||
if edited == null:
|
||||
push_warning("[CGHEVEN] No open scene to import into.")
|
||||
return scene_root
|
||||
edited.add_child(scene_root)
|
||||
# own the node so it saves with the scene
|
||||
scene_root.owner = edited
|
||||
for c in scene_root.find_children("*", "", true, false):
|
||||
c.owner = edited
|
||||
return scene_root
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2th51vfweq7f
|
||||
@@ -0,0 +1,48 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghHdriImport
|
||||
## Sets the scene's world environment from an equirectangular HDRI.
|
||||
## Builds WorldEnvironment → Environment(BG_SKY) → Sky → PanoramaSkyMaterial,
|
||||
## with ambient + reflections taken from the sky (official Godot sky workflow).
|
||||
## Reuses an existing WorldEnvironment if the scene already has one (Godot allows
|
||||
## only one active per scene tree). HDRIs are served as .exr (load_exr_from_buffer).
|
||||
|
||||
static func apply_as_world(exr_path: String) -> WorldEnvironment:
|
||||
var img := Image.new()
|
||||
var ext := exr_path.get_extension().to_lower()
|
||||
var ok: int = img.load_exr_from_buffer(FileAccess.get_file_as_bytes(exr_path)) if ext == "exr" \
|
||||
else img.load(exr_path)
|
||||
if ok != OK:
|
||||
push_error("[CGHEVEN] Could not load HDRI: " + exr_path)
|
||||
return null
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
|
||||
var sky_mat := PanoramaSkyMaterial.new()
|
||||
sky_mat.panorama = tex
|
||||
var sky := Sky.new()
|
||||
sky.sky_material = sky_mat
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_SKY
|
||||
env.sky = sky
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||
|
||||
var edited := EditorInterface.get_edited_scene_root()
|
||||
if edited == null:
|
||||
push_warning("[CGHEVEN] No open scene — WorldEnvironment created but not added.")
|
||||
var orphan := WorldEnvironment.new()
|
||||
orphan.environment = env
|
||||
return orphan
|
||||
|
||||
# Reuse an existing WorldEnvironment (only one may be active per scene tree).
|
||||
var existing := edited.find_children("*", "WorldEnvironment", true, false)
|
||||
var we: WorldEnvironment
|
||||
if existing.is_empty():
|
||||
we = WorldEnvironment.new()
|
||||
we.name = "WorldEnvironment"
|
||||
edited.add_child(we)
|
||||
we.owner = edited
|
||||
else:
|
||||
we = existing[0]
|
||||
we.environment = env
|
||||
return we
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6ro7gtcoafbj
|
||||
@@ -0,0 +1,42 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghMaterialImport
|
||||
## Builds a StandardMaterial3D from a PBR texture set and assigns it to the
|
||||
## selected MeshInstance3D. NOTE: Blender procedural node-graphs do NOT import;
|
||||
## only the baked-PBR subset is reproduced here.
|
||||
|
||||
static func build_pbr(tex_paths: Dictionary) -> StandardMaterial3D:
|
||||
# tex_paths keys: albedo, normal, roughness, metallic, emission, ao
|
||||
var m := StandardMaterial3D.new()
|
||||
if tex_paths.has("albedo"):
|
||||
m.albedo_texture = _load(tex_paths["albedo"])
|
||||
if tex_paths.has("normal"):
|
||||
m.normal_enabled = true
|
||||
m.normal_texture = _load(tex_paths["normal"])
|
||||
if tex_paths.has("roughness"):
|
||||
m.roughness_texture = _load(tex_paths["roughness"])
|
||||
if tex_paths.has("metallic"):
|
||||
m.metallic = 1.0
|
||||
m.metallic_texture = _load(tex_paths["metallic"])
|
||||
if tex_paths.has("ao"):
|
||||
m.ao_enabled = true
|
||||
m.ao_texture = _load(tex_paths["ao"])
|
||||
if tex_paths.has("emission"):
|
||||
m.emission_enabled = true
|
||||
m.emission_texture = _load(tex_paths["emission"])
|
||||
return m
|
||||
|
||||
static func _load(path: String) -> Texture2D:
|
||||
var img := Image.new()
|
||||
if img.load(path) != OK:
|
||||
return null
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
static func assign_to_selection(mat: Material) -> bool:
|
||||
var sel := EditorInterface.get_selection().get_selected_nodes()
|
||||
for n in sel:
|
||||
if n is MeshInstance3D:
|
||||
n.material_override = mat
|
||||
return true
|
||||
push_warning("[CGHEVEN] Select a MeshInstance3D to apply the material.")
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://binrjwgiv4o85
|
||||
@@ -0,0 +1,231 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghImportRouter
|
||||
## Routes a downloaded file to the right importer. CRITICAL: it routes by the
|
||||
## asset's CATEGORY first, not just the extension — an `.exr` can be EITHER an
|
||||
## HDRI panorama (hdri category) OR a flipbook sprite-sheet (flipbooks category),
|
||||
## so extension alone is ambiguous and used to send flipbook .exr sheets to the
|
||||
## HDRI importer (sky), which is why flipbooks "imported but never played".
|
||||
##
|
||||
## After a successful live-instance into the open scene, the source file is ALSO
|
||||
## copied into res://CGHEVEN/<category>/ so it shows up in the FileSystem dock and
|
||||
## the user can find / reuse it (Godot only imports files that live under res://).
|
||||
|
||||
const RES_ROOT := "res://CGHEVEN"
|
||||
|
||||
# Model formats Godot's EDITOR can import (into a PackedScene, or a Mesh for .obj) — far
|
||||
# more than the runtime GLTFDocument path, which only handles glTF. Order = most reliable
|
||||
# first (used to pick the best model inside a zip, like the Blender addon's priority).
|
||||
const EDITOR_MODEL_PRIORITY := ["glb", "gltf", "fbx", "dae", "obj"]
|
||||
|
||||
static func import_file(path: String, category_slug: String) -> String:
|
||||
var ext := path.get_extension().to_lower()
|
||||
|
||||
# unpack archives first, then re-route on the inner importable file
|
||||
if ext == "zip":
|
||||
var inner := _extract_zip(path)
|
||||
if inner == "":
|
||||
return "Extracted zip, but found no importable file inside."
|
||||
return import_file(inner, category_slug)
|
||||
|
||||
var cat := category_slug.to_lower()
|
||||
var msg := ""
|
||||
# Category decides intent. Match the TOKEN, not exact equality, because the slug can be a
|
||||
# SUBCATEGORY ("muzzle-flashes-flipbooks", "space-hdr", …) — matching "flipbooks" exactly
|
||||
# used to send subcategory flipbook .exr sheets to the HDRI importer (wrong). Fall back to
|
||||
# extension when the category is unknown.
|
||||
if cat.contains("flipbook"):
|
||||
msg = _do_flipbook(path)
|
||||
elif cat.contains("hdr"):
|
||||
msg = _do_hdri(path)
|
||||
elif cat == "3d-models" or cat.contains("model"):
|
||||
msg = _do_3d(path)
|
||||
elif ext in CghConfig.EXT_3D:
|
||||
msg = _do_3d(path)
|
||||
elif ext in CghConfig.EXT_HDRI:
|
||||
msg = _do_hdri(path)
|
||||
elif ext in CghConfig.EXT_IMAGE:
|
||||
msg = _do_flipbook(path)
|
||||
else:
|
||||
# Not auto-importable — still drop it in the project so the user has it.
|
||||
var rp0 := copy_into_project(path, cat)
|
||||
return "Saved to %s (no auto-import for .%s)." % [rp0, ext] if rp0 != "" else \
|
||||
"Downloaded to %s (no auto-import for .%s)." % [path, ext]
|
||||
|
||||
# Copy a reusable source into the project (FileSystem dock) and tell the user.
|
||||
var rp := copy_into_project(path, cat)
|
||||
if rp != "":
|
||||
msg += " Saved in %s/" % rp.get_base_dir()
|
||||
return msg
|
||||
|
||||
# ---------------- per-type importers ----------------
|
||||
static func _do_flipbook(path: String) -> String:
|
||||
var hv := _flipbook_grid(path)
|
||||
var spr := CghFlipbookImport.from_sheet(path, hv.x, hv.y)
|
||||
if spr == null:
|
||||
if path.get_extension().to_lower() == "exr":
|
||||
return "Flipbook import failed — this .exr uses a compression Godot can't read. Pick the PNG version from the ▾ menu."
|
||||
return "Flipbook import failed."
|
||||
if hv == Vector2i(1, 1):
|
||||
return "Flipbook added (single frame — grid NxM not in filename, can't auto-play)."
|
||||
return "Flipbook added & playing (%dx%d grid)." % [hv.x, hv.y]
|
||||
|
||||
static func _do_hdri(path: String) -> String:
|
||||
var we := CghHdriImport.apply_as_world(path)
|
||||
return "HDRI applied as the scene's world/sky." if we else \
|
||||
"HDRI import failed — this .exr uses a compression Godot can't read (e.g. DWAA/PXR24). Try another HDRI."
|
||||
|
||||
static func _do_3d(path: String) -> String:
|
||||
var ext := path.get_extension().to_lower()
|
||||
var n: Node = _import_fbx(path) if ext == "fbx" else CghGltfImport.import_into_scene(path)
|
||||
return "3D model added to the scene." if n else "3D import failed (open a scene first?)."
|
||||
|
||||
static func _import_fbx(path: String) -> Node:
|
||||
# ufbx (4.3+) — FBXDocument mirrors GLTFDocument
|
||||
var doc := FBXDocument.new()
|
||||
var state := FBXState.new()
|
||||
if doc.append_from_file(path, state) != OK:
|
||||
return null
|
||||
var root := doc.generate_scene(state)
|
||||
var edited := EditorInterface.get_edited_scene_root()
|
||||
if edited and root:
|
||||
edited.add_child(root)
|
||||
root.owner = edited
|
||||
for c in root.find_children("*", "", true, false):
|
||||
c.owner = edited
|
||||
return root
|
||||
|
||||
# ---------------- project copy (FileSystem dock) ----------------
|
||||
# Copy the downloaded source into res://CGHEVEN/<category>/<filename> so Godot
|
||||
# imports it and it appears in the FileSystem dock (only files under res:// are
|
||||
# importable/usable). Returns the res:// path, or "" on failure.
|
||||
static func copy_into_project(path: String, category_slug: String) -> String:
|
||||
var bytes := FileAccess.get_file_as_bytes(path)
|
||||
if bytes.is_empty():
|
||||
return ""
|
||||
var sub := category_slug if category_slug != "" else "assets"
|
||||
var dir := RES_ROOT.path_join(sub)
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
var dest := dir.path_join(CghConfig.safe_download_name(path.get_file()))
|
||||
var f := FileAccess.open(dest, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return ""
|
||||
f.store_buffer(bytes)
|
||||
f.close()
|
||||
# Make the editor pick up + import the new file so it shows in the dock.
|
||||
if Engine.is_editor_hint():
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs and not efs.is_scanning():
|
||||
efs.scan()
|
||||
return dest
|
||||
|
||||
## Extract EVERY file from a zip into a sibling temp dir and return the best
|
||||
## editor-importable MODEL inside (glb/gltf > fbx > dae > obj), or "" if none. Extracts
|
||||
## the whole tree (not just the model) so .bin/.mtl/textures land next to it and survive
|
||||
## the import. Mirrors the Blender addon's _pick_best_model_in_tree.
|
||||
static func extract_model_from_zip(zip_path: String) -> String:
|
||||
var reader := ZIPReader.new()
|
||||
if reader.open(zip_path) != OK:
|
||||
return ""
|
||||
var out_dir := zip_path.get_basename() + "_unzip"
|
||||
DirAccess.make_dir_recursive_absolute(out_dir)
|
||||
var best := ""
|
||||
var best_rank := 999
|
||||
for entry in reader.get_files():
|
||||
if entry.ends_with("/"):
|
||||
continue
|
||||
var rel := entry.replace("\\", "/")
|
||||
var out := out_dir.path_join(rel)
|
||||
DirAccess.make_dir_recursive_absolute(out.get_base_dir())
|
||||
var f := FileAccess.open(out, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_buffer(reader.read_file(entry))
|
||||
f.close()
|
||||
var rank: int = EDITOR_MODEL_PRIORITY.find(rel.get_extension().to_lower())
|
||||
if rank != -1 and rank < best_rank:
|
||||
best_rank = rank
|
||||
best = out
|
||||
reader.close()
|
||||
return best
|
||||
|
||||
## Copy a model file AND its sibling files (textures / .bin / .mtl in the SAME folder)
|
||||
## into res://CGHEVEN/<category>/<modelname>/ so the editor import keeps materials. Use
|
||||
## this for models extracted from a zip (isolated temp dir); for a bare downloaded file
|
||||
## use copy_into_project (single file). Returns the model's res:// path, or "" on failure.
|
||||
static func copy_model_into_project(model_path: String, category_slug: String) -> String:
|
||||
var src_dir := model_path.get_base_dir()
|
||||
var sub := category_slug if category_slug != "" else "assets"
|
||||
var stem := CghConfig.safe_download_name(model_path.get_file()).get_basename()
|
||||
var dest_dir := RES_ROOT.path_join(sub).path_join(stem)
|
||||
DirAccess.make_dir_recursive_absolute(dest_dir)
|
||||
var model_dest := ""
|
||||
var da := DirAccess.open(src_dir)
|
||||
if da:
|
||||
da.list_dir_begin()
|
||||
var fn := da.get_next()
|
||||
while fn != "":
|
||||
if not da.current_is_dir():
|
||||
var bytes := FileAccess.get_file_as_bytes(src_dir.path_join(fn))
|
||||
var d := dest_dir.path_join(CghConfig.safe_download_name(fn))
|
||||
var wf := FileAccess.open(d, FileAccess.WRITE)
|
||||
if wf:
|
||||
wf.store_buffer(bytes)
|
||||
wf.close()
|
||||
if fn == model_path.get_file():
|
||||
model_dest = d
|
||||
fn = da.get_next()
|
||||
da.list_dir_end()
|
||||
if model_dest == "":
|
||||
# fallback: copy just the model file (distinct var names — GDScript locals are
|
||||
# function-scoped, so re-declaring bytes/wf from the loop above would error).
|
||||
var fbytes := FileAccess.get_file_as_bytes(model_path)
|
||||
if fbytes.is_empty():
|
||||
return ""
|
||||
model_dest = dest_dir.path_join(CghConfig.safe_download_name(model_path.get_file()))
|
||||
var fwf := FileAccess.open(model_dest, FileAccess.WRITE)
|
||||
if fwf == null:
|
||||
return ""
|
||||
fwf.store_buffer(fbytes)
|
||||
fwf.close()
|
||||
if Engine.is_editor_hint():
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs and not efs.is_scanning():
|
||||
efs.scan()
|
||||
return model_dest
|
||||
|
||||
static func _extract_zip(zip_path: String) -> String:
|
||||
var reader := ZIPReader.new()
|
||||
if reader.open(zip_path) != OK:
|
||||
return ""
|
||||
var ok := CghConfig.EXT_3D + CghConfig.EXT_HDRI + CghConfig.EXT_IMAGE
|
||||
var out_dir := zip_path.get_basename() + "_unzip"
|
||||
DirAccess.make_dir_recursive_absolute(out_dir)
|
||||
var best := ""
|
||||
var best_rank := 99
|
||||
for entry in reader.get_files():
|
||||
if entry.ends_with("/"):
|
||||
continue
|
||||
var e_ext := entry.get_extension().to_lower()
|
||||
if not (e_ext in ok):
|
||||
continue
|
||||
var out := out_dir + "/" + entry.get_file()
|
||||
var f := FileAccess.open(out, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_buffer(reader.read_file(entry))
|
||||
f.close()
|
||||
# prefer 3D > hdri > image when several importable files exist
|
||||
var rank := 0 if e_ext in CghConfig.EXT_3D else (1 if e_ext in CghConfig.EXT_HDRI else 2)
|
||||
if rank < best_rank:
|
||||
best_rank = rank
|
||||
best = out
|
||||
reader.close()
|
||||
return best
|
||||
|
||||
static func _flipbook_grid(path: String) -> Vector2i:
|
||||
# look for an NxM token in the filename, e.g. fire_8x8.png / Portal_04_4K_8X8.exr
|
||||
var rx := RegEx.new()
|
||||
rx.compile("(\\d+)\\s*[xX]\\s*(\\d+)")
|
||||
var m := rx.search(path.get_file())
|
||||
if m:
|
||||
return Vector2i(int(m.get_string(1)), int(m.get_string(2)))
|
||||
return Vector2i(1, 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyrdsmgv6xlmi
|
||||
@@ -0,0 +1,82 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name CghUpdater
|
||||
## Checks the public update endpoint; if a newer semver exists, downloads the
|
||||
## addon zip and extracts it over res://addons/cgheven, then asks for an editor
|
||||
## restart (Godot has no silent plugin hot-reload).
|
||||
|
||||
signal update_available(version: String, notes: String, url: String)
|
||||
signal up_to_date()
|
||||
signal update_ready_to_restart()
|
||||
signal update_failed(message: String)
|
||||
|
||||
var _check_http: HTTPRequest
|
||||
var _dl: CghDownloader
|
||||
|
||||
func _ready() -> void:
|
||||
_check_http = HTTPRequest.new(); add_child(_check_http)
|
||||
_check_http.request_completed.connect(_on_check)
|
||||
_dl = CghDownloader.new(); add_child(_dl)
|
||||
_dl.finished.connect(_on_zip_downloaded)
|
||||
_dl.failed.connect(func(m): update_failed.emit(m))
|
||||
|
||||
func check() -> void:
|
||||
# Backend contract: POST /api/addon-updates/check with body {addon_slug,
|
||||
# current_version}; public, no auth (matches autoupdate-public-popup). Response:
|
||||
# {update_available, latest_version, changelog, download_url}.
|
||||
var body := JSON.stringify({
|
||||
"addon_slug": CghConfig.ADDON_SLUG,
|
||||
"current_version": CghConfig.ADDON_VERSION,
|
||||
})
|
||||
_check_http.request(CghConfig.check_update_url(),
|
||||
PackedStringArray(["Content-Type: application/json"]),
|
||||
HTTPClient.METHOD_POST, body)
|
||||
|
||||
func _on_check(_r: int, code: int, _h: PackedStringArray, b: PackedByteArray) -> void:
|
||||
if code < 200 or code >= 300:
|
||||
update_failed.emit("Update check failed (HTTP %d)" % code)
|
||||
return
|
||||
var d = JSON.parse_string(b.get_string_from_utf8())
|
||||
if typeof(d) != TYPE_DICTIONARY:
|
||||
update_failed.emit("Bad update response")
|
||||
return
|
||||
if d.get("update_available", false):
|
||||
var ver := str(d.get("latest_version", "?"))
|
||||
var notes_v = d.get("changelog", "")
|
||||
var notes := str(notes_v) if notes_v != null else ""
|
||||
var url_v = d.get("download_url", "")
|
||||
var url := str(url_v) if url_v != null else ""
|
||||
if url == "":
|
||||
# Update exists but no ZIP uploaded to the release yet — don't prompt a
|
||||
# download that can't complete; surface only on a manual check.
|
||||
update_failed.emit("Update %s available, but no download is published yet." % ver)
|
||||
return
|
||||
update_available.emit(ver, notes, url)
|
||||
else:
|
||||
up_to_date.emit()
|
||||
|
||||
func download_and_stage(url: String) -> void:
|
||||
_dl.download(url, "cgheven_update.zip")
|
||||
|
||||
func _on_zip_downloaded(zip_path: String) -> void:
|
||||
var reader := ZIPReader.new()
|
||||
if reader.open(zip_path) != OK:
|
||||
update_failed.emit("Could not open update zip")
|
||||
return
|
||||
var base := "res://addons/cgheven/"
|
||||
for entry in reader.get_files():
|
||||
if entry.ends_with("/"):
|
||||
continue
|
||||
# entries are expected under addons/cgheven/... ; strip leading path to map onto base
|
||||
var rel := entry
|
||||
var idx := rel.find("addons/cgheven/")
|
||||
if idx != -1:
|
||||
rel = rel.substr(idx + "addons/cgheven/".length())
|
||||
var out_path := base + rel
|
||||
DirAccess.make_dir_recursive_absolute(out_path.get_base_dir())
|
||||
var f := FileAccess.open(out_path, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_buffer(reader.read_file(entry))
|
||||
f.close()
|
||||
reader.close()
|
||||
update_ready_to_restart.emit() # caller shows "Restart editor?" dialog -> EditorInterface.restart_editor()
|
||||
@@ -0,0 +1 @@
|
||||
uid://brkjphruderpy
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dwfj6l81dsw08"
|
||||
path="res://.godot/imported/icon.png-ca09efc2a8c1d46936a8e38ee5827a42.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/cgheven/icon.png"
|
||||
dest_files=["res://.godot/imported/icon.png-ca09efc2a8c1d46936a8e38ee5827a42.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="CGHEVEN Asset Library"
|
||||
description="CG/VFX asset library (3D, HDRI, materials, VFX) powered by api.cgheven.com"
|
||||
author="CGHEVEN"
|
||||
version="1.0.0"
|
||||
script="cgheven_plugin.gd"
|
||||
@@ -0,0 +1,579 @@
|
||||
@tool
|
||||
extends PanelContainer
|
||||
class_name CghAssetCard
|
||||
## One asset card — 1:1 with the After Effects addon card anatomy:
|
||||
## square thumbnail (object-fit: cover) + badges (resolution pill / NEW / Premium)
|
||||
## + favorite ♥ + cross-addon ✓ downloaded tick + title + "Category / Subcategory"
|
||||
## + a .cdl-row footer: [⬇ label | sep | ▾ dropdown]; Upgrade = orange gradient.
|
||||
## The card background is ALWAYS #1e1e24 (uniform grid); hover only lifts the border.
|
||||
|
||||
signal request_download(asset: Dictionary, entry: Dictionary) # entry={} -> best free
|
||||
signal request_upgrade(asset: Dictionary)
|
||||
signal favorite_toggled(asset: Dictionary, is_fav: bool)
|
||||
signal preview_requested(card, asset) # hover -> main_dock loads still frames
|
||||
signal flipbook_sheet_requested(card, asset) # hover on a flipbook -> main_dock fetches the real sprite sheet
|
||||
signal cancel_requested(card) # ✕ on the in-flight download
|
||||
signal delete_requested(asset: Dictionary) # 🗑 delete the downloaded file(s) -> re-download
|
||||
signal viewed(asset) # user opened the format/options dropdown
|
||||
signal login_required() # guest tried to favourite -> ask them to log in
|
||||
|
||||
const CARD_W := 168 # reference width
|
||||
const MIN_W := 92 # min width so the responsive column count always fits the dock
|
||||
const HOVER_DELAY := 0.12 # brief debounce before the NETWORK still-preview (flipbooks play instantly)
|
||||
const PREVIEW_FPS := 0.45 # seconds per still frame
|
||||
|
||||
# dropdown special item ids (above any file index)
|
||||
const ID_FAV := 9000
|
||||
const ID_COPY := 9001
|
||||
const ID_VIEW := 9002
|
||||
const ID_DEL := 9003
|
||||
|
||||
var asset: Dictionary
|
||||
var auth: CghAuth # set by main_dock; favourites need login
|
||||
var _locked := false
|
||||
var _thumb: TextureRect
|
||||
var _wrap: Control
|
||||
var _title: Label
|
||||
var _subtitle: Label
|
||||
var _action: Button
|
||||
var _menu_btn: MenuButton
|
||||
var _sep: ColorRect # divider between the main button and the ▾ dropdown
|
||||
var _badge_row: HBoxContainer
|
||||
var _fav_btn: Button
|
||||
var _progress: ProgressBar
|
||||
var _footer: PanelContainer
|
||||
var _prog_row: HBoxContainer
|
||||
var _thumb_base: Texture2D
|
||||
var _preview_frames: Array = []
|
||||
var _preview_idx := 0
|
||||
var _preview_loaded := false
|
||||
var _flipbook_sheet_asked := false # asked main_dock for this flipbook's real sprite sheet already
|
||||
var _menu_entries: Array = [] # deduped entries backing the dropdown items
|
||||
var _delay_timer: Timer
|
||||
var _preview_timer: Timer
|
||||
|
||||
func setup(a: Dictionary) -> void:
|
||||
asset = a
|
||||
custom_minimum_size = Vector2(MIN_W, 0) # min width; height is content-driven
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
add_theme_stylebox_override("panel", CghTheme.card_box(false))
|
||||
mouse_filter = Control.MOUSE_FILTER_PASS
|
||||
mouse_entered.connect(_on_enter)
|
||||
mouse_exited.connect(_on_exit)
|
||||
|
||||
# hover-preview timers
|
||||
_delay_timer = Timer.new()
|
||||
_delay_timer.one_shot = true
|
||||
_delay_timer.wait_time = HOVER_DELAY
|
||||
_delay_timer.timeout.connect(_start_preview)
|
||||
add_child(_delay_timer)
|
||||
_preview_timer = Timer.new()
|
||||
_preview_timer.wait_time = PREVIEW_FPS
|
||||
_preview_timer.timeout.connect(_next_preview_frame)
|
||||
add_child(_preview_timer)
|
||||
|
||||
var v := VBoxContainer.new()
|
||||
v.add_theme_constant_override("separation", 4)
|
||||
# PASS (not the default STOP) so a hover anywhere over this container — thumbnail,
|
||||
# title, subtitle, footer gaps — propagates up to the card and fires mouse_entered.
|
||||
# Without this the card only "saw" hovers on the bare label rows, so the preview
|
||||
# played only when the pointer was on/below the title (the thumbnail ate the event).
|
||||
v.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||
add_child(v)
|
||||
|
||||
# --- square thumbnail wrap (keeps a 1:1 aspect as the card width changes) ---
|
||||
_wrap = Control.new()
|
||||
_wrap.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_wrap.custom_minimum_size = Vector2(0, MIN_W)
|
||||
_wrap.clip_contents = true
|
||||
_wrap.mouse_filter = Control.MOUSE_FILTER_PASS # let hover over the thumbnail reach the card
|
||||
_wrap.resized.connect(_keep_square)
|
||||
v.add_child(_wrap)
|
||||
|
||||
# dark bg behind the (possibly still-loading) thumbnail
|
||||
var thumb_bg := ColorRect.new()
|
||||
thumb_bg.color = CghConfig.C_NAV
|
||||
thumb_bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
thumb_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_wrap.add_child(thumb_bg)
|
||||
|
||||
_thumb = TextureRect.new()
|
||||
_thumb.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_thumb.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||||
_thumb.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_thumb.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_wrap.add_child(_thumb)
|
||||
|
||||
# badges top-left
|
||||
_badge_row = HBoxContainer.new()
|
||||
_badge_row.position = Vector2(5, 5)
|
||||
_badge_row.add_theme_constant_override("separation", 3)
|
||||
_badge_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_wrap.add_child(_badge_row)
|
||||
|
||||
# top-right cluster: favorite ♥
|
||||
var topright := HBoxContainer.new()
|
||||
topright.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
topright.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
topright.offset_top = 4
|
||||
topright.offset_right = -4
|
||||
topright.add_theme_constant_override("separation", 2)
|
||||
_wrap.add_child(topright)
|
||||
|
||||
_fav_btn = Button.new()
|
||||
_fav_btn.flat = true
|
||||
_fav_btn.custom_minimum_size = Vector2(22, 22)
|
||||
_fav_btn.pressed.connect(_on_fav)
|
||||
topright.add_child(_fav_btn)
|
||||
_refresh_fav_icon()
|
||||
|
||||
# --- title ---
|
||||
_title = Label.new()
|
||||
_title.text = CghAsset.title(asset)
|
||||
_title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_title.clip_text = true
|
||||
_title.add_theme_color_override("font_color", CghConfig.C_TEXT1)
|
||||
_title.add_theme_font_size_override("font_size", 12)
|
||||
_title.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||
v.add_child(_title)
|
||||
|
||||
# --- subtitle: "Category / Subcategory" ---
|
||||
_subtitle = Label.new()
|
||||
_subtitle.text = _subtitle_text()
|
||||
_subtitle.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_subtitle.clip_text = true
|
||||
_subtitle.add_theme_color_override("font_color", CghConfig.C_TEXT3)
|
||||
_subtitle.add_theme_font_size_override("font_size", 10)
|
||||
_subtitle.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||
v.add_child(_subtitle)
|
||||
|
||||
# --- footer: .cdl-row pill [ main | sep | ▾ ] ---
|
||||
_footer = PanelContainer.new()
|
||||
_footer.add_theme_stylebox_override("panel", CghTheme.footer_pill_box())
|
||||
_footer.custom_minimum_size = Vector2(0, 24)
|
||||
_footer.mouse_filter = Control.MOUSE_FILTER_PASS # gaps around the buttons still hover the card
|
||||
v.add_child(_footer)
|
||||
var frow := HBoxContainer.new()
|
||||
frow.add_theme_constant_override("separation", 0)
|
||||
_footer.add_child(frow)
|
||||
|
||||
_action = Button.new()
|
||||
_action.flat = true
|
||||
_action.custom_minimum_size = Vector2(0, 22)
|
||||
_action.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_action.add_theme_font_size_override("font_size", 11)
|
||||
_action.pressed.connect(_on_action)
|
||||
frow.add_child(_action)
|
||||
|
||||
_sep = ColorRect.new()
|
||||
_sep.color = CghConfig.C_BORDER
|
||||
_sep.custom_minimum_size = Vector2(1, 14)
|
||||
_sep.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
frow.add_child(_sep)
|
||||
|
||||
_menu_btn = MenuButton.new()
|
||||
_menu_btn.flat = true
|
||||
_menu_btn.text = "▾"
|
||||
_menu_btn.custom_minimum_size = Vector2(22, 22)
|
||||
frow.add_child(_menu_btn)
|
||||
_build_format_menu()
|
||||
_menu_btn.get_popup().about_to_popup.connect(func(): viewed.emit(asset))
|
||||
|
||||
# --- progress row (hidden until downloading): thin accent bar + ✕ cancel ---
|
||||
_prog_row = HBoxContainer.new()
|
||||
_prog_row.add_theme_constant_override("separation", 4)
|
||||
_prog_row.visible = false
|
||||
v.add_child(_prog_row)
|
||||
_progress = ProgressBar.new()
|
||||
_progress.custom_minimum_size = Vector2(0, 6)
|
||||
_progress.show_percentage = false
|
||||
_progress.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_progress.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
var pfill := StyleBoxFlat.new()
|
||||
pfill.bg_color = CghConfig.C_ACCENT
|
||||
pfill.set_corner_radius_all(2)
|
||||
_progress.add_theme_stylebox_override("fill", pfill)
|
||||
var pbg := StyleBoxFlat.new()
|
||||
pbg.bg_color = Color(0.5, 0.5, 0.5, 0.15)
|
||||
pbg.set_corner_radius_all(2)
|
||||
_progress.add_theme_stylebox_override("background", pbg)
|
||||
_prog_row.add_child(_progress)
|
||||
var cancel := Button.new()
|
||||
cancel.flat = true
|
||||
cancel.text = "✕"
|
||||
cancel.tooltip_text = "Cancel download"
|
||||
cancel.custom_minimum_size = Vector2(18, 18)
|
||||
cancel.add_theme_font_size_override("font_size", 10)
|
||||
cancel.add_theme_color_override("font_color", CghConfig.C_TEXT3)
|
||||
cancel.add_theme_color_override("font_hover_color", CghConfig.C_ERR)
|
||||
cancel.pressed.connect(func(): cancel_requested.emit(self))
|
||||
_prog_row.add_child(cancel)
|
||||
|
||||
_apply_state()
|
||||
|
||||
func _keep_square() -> void:
|
||||
if not _wrap:
|
||||
return
|
||||
var w := _wrap.size.x
|
||||
if w > 1.0 and absf(_wrap.custom_minimum_size.y - w) > 0.5:
|
||||
_wrap.custom_minimum_size.y = w
|
||||
|
||||
func _subtitle_text() -> String:
|
||||
var cat := CghAsset.category_title(asset)
|
||||
return cat if cat != "" else "Asset"
|
||||
|
||||
func _apply_state() -> void:
|
||||
# Badges (NEW / Premium / resolution) intentionally removed per design.
|
||||
_locked = not CghAsset.has_free_file(asset)
|
||||
|
||||
# footer main button — Upgrade (orange) / Import (downloaded) / <res> / Download
|
||||
if _locked:
|
||||
_action.text = "Upgrade"
|
||||
_action.flat = false
|
||||
_action.add_theme_stylebox_override("normal", CghTheme.brand_button_box())
|
||||
_action.add_theme_stylebox_override("hover", CghTheme.brand_button_box(true))
|
||||
_action.add_theme_stylebox_override("pressed", CghTheme.brand_button_box(true))
|
||||
_action.add_theme_color_override("font_color", Color.WHITE)
|
||||
_action.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
# A locked (patreon) asset for a non-paying user has nothing to choose/download,
|
||||
# so hide the ▾ dropdown + divider — the orange Upgrade fills the whole footer.
|
||||
_menu_btn.visible = false
|
||||
_sep.visible = false
|
||||
else:
|
||||
var best := CghAsset.best_free_entry(asset)
|
||||
var rl: String = best.get("res", "") if not best.is_empty() else ""
|
||||
# Cross-addon: "downloaded" if any CGHEVEN addon grabbed this asset (id match).
|
||||
var dl: bool = CghDownloads.is_downloaded(asset)
|
||||
if dl:
|
||||
_action.text = ("Import %s" % rl).strip_edges() if rl != "" else "Import"
|
||||
else:
|
||||
_action.text = ("⤓ %s" % rl) if rl != "" else "⤓ Download"
|
||||
_action.flat = true
|
||||
_action.add_theme_color_override("font_color", CghConfig.C_TEXT2)
|
||||
_action.add_theme_color_override("font_hover_color", CghConfig.C_ACCENT)
|
||||
_menu_btn.visible = true
|
||||
_menu_btn.disabled = false
|
||||
_sep.visible = true
|
||||
|
||||
## Public: re-evaluate the download state (called after a delete) so the footer flips
|
||||
## between Download / Import and the dropdown's delete row appears/disappears.
|
||||
func refresh_state() -> void:
|
||||
_apply_state()
|
||||
_build_format_menu()
|
||||
|
||||
func _top_res_label() -> String:
|
||||
var best := -1
|
||||
var lbl := ""
|
||||
for e in CghAsset.file_entries(asset):
|
||||
if e["res"] != "" and e["res_rank"] >= best:
|
||||
best = e["res_rank"]; lbl = e["res"]
|
||||
return lbl
|
||||
|
||||
# AE-style dropdown: grouped by FORMAT, one row per resolution (deduped).
|
||||
func _build_format_menu() -> void:
|
||||
var pm := _menu_btn.get_popup()
|
||||
pm.clear()
|
||||
pm.add_theme_stylebox_override("panel", CghTheme.dropdown_box())
|
||||
_menu_entries = []
|
||||
var entries := CghAsset.importable_entries(asset)
|
||||
# Hide the low-poly preview .glb from the menu when real files exist (it's only an
|
||||
# internal download fallback) — keeps the list clean.
|
||||
var non_preview := entries.filter(func(e): return not e.get("preview", false))
|
||||
if not non_preview.is_empty():
|
||||
entries = non_preview
|
||||
var pref := ["GLTF", "GLB", "FBX", "OBJ", "ZIP", "EXR", "HDR", "PNG", "JPG", "JPEG", "WEBP"]
|
||||
var verb := "Download"
|
||||
if entries.is_empty():
|
||||
pm.add_item("No Godot-importable file", -1)
|
||||
pm.set_item_disabled(0, true)
|
||||
elif _collapse_by_resolution(entries):
|
||||
# Like Blender for 3D models / flipbooks: ONE resolution ladder, no format
|
||||
# names (gltf/glb/fbx are interchangeable for Godot; png/jpg/webp likewise).
|
||||
var by_res := {} # res -> best entry
|
||||
for e in entries:
|
||||
var rk: String = e["res"]
|
||||
var cur = by_res.get(rk, null)
|
||||
if cur == null:
|
||||
by_res[rk] = e
|
||||
else:
|
||||
var better: bool = bool(cur["locked"]) and not bool(e["locked"])
|
||||
if not better and bool(cur["locked"]) == bool(e["locked"]):
|
||||
var ie: int = pref.find(_base_fmt(e["format"]))
|
||||
var ic: int = pref.find(_base_fmt(cur["format"]))
|
||||
if ie == -1: ie = 99
|
||||
if ic == -1: ic = 99
|
||||
better = ie < ic
|
||||
if better:
|
||||
by_res[rk] = e
|
||||
var rows: Array = by_res.values()
|
||||
rows.sort_custom(func(a, b): return int(a["res_rank"]) < int(b["res_rank"]))
|
||||
for e in rows:
|
||||
var v := "Import" if (CghDownloads.has_file(e["filename"]) or CghDownloads.is_res_downloaded(CghAsset.id(asset), e["res"])) else verb
|
||||
var label := ("%s (%s)" % [e["res"], v]) if e["res"] != "" else v
|
||||
if e["locked"]:
|
||||
label += " 🔒"
|
||||
_menu_entries.append(e)
|
||||
pm.add_item(label, _menu_entries.size() - 1)
|
||||
if e["locked"]:
|
||||
pm.set_item_disabled(pm.get_item_count() - 1, true)
|
||||
else:
|
||||
# Mixed media (VFX/HDRI): keep format groups (MP4 vs EXR actually differ).
|
||||
var groups := {} # format -> { res -> entry }
|
||||
var order := [] # format first-seen order
|
||||
for e in entries:
|
||||
var fmt: String = e["format"]
|
||||
var rk: String = e["res"]
|
||||
if not groups.has(fmt):
|
||||
groups[fmt] = {}
|
||||
order.append(fmt)
|
||||
var g: Dictionary = groups[fmt]
|
||||
if not g.has(rk) or (bool(g[rk]["locked"]) and not bool(e["locked"])):
|
||||
g[rk] = e
|
||||
order.sort_custom(func(a, b):
|
||||
var ia: int = pref.find(a); var ib: int = pref.find(b)
|
||||
if ia == -1: ia = 99
|
||||
if ib == -1: ib = 99
|
||||
return ia < ib)
|
||||
for fmt in order:
|
||||
pm.add_separator(fmt)
|
||||
var rows: Array = groups[fmt].values()
|
||||
rows.sort_custom(func(a, b): return int(a["res_rank"]) < int(b["res_rank"]))
|
||||
for e in rows:
|
||||
var v := "Import" if (CghDownloads.has_file(e["filename"]) or CghDownloads.is_res_downloaded(CghAsset.id(asset), e["res"])) else verb
|
||||
var label := ("%s (%s)" % [e["res"], v]) if e["res"] != "" else v
|
||||
if e["locked"]:
|
||||
label += " 🔒"
|
||||
_menu_entries.append(e)
|
||||
pm.add_item(label, _menu_entries.size() - 1)
|
||||
if e["locked"]:
|
||||
pm.set_item_disabled(pm.get_item_count() - 1, true)
|
||||
# AE dropdown footer items
|
||||
pm.add_separator()
|
||||
pm.add_item(("♥ Remove from Favorites" if CghFavorites.is_fav(asset) else "♡ Add to Favorites"), ID_FAV)
|
||||
pm.add_item("⧉ Copy asset link", ID_COPY)
|
||||
pm.add_item("🌐 View online", ID_VIEW)
|
||||
# Only offer delete when a copy is actually on disk — lets the user wipe a missing/
|
||||
# corrupt download so the footer flips back to Download and they can re-fetch.
|
||||
if CghDownloads.is_downloaded(asset):
|
||||
pm.add_item("🗑 Delete download", ID_DEL)
|
||||
if not pm.id_pressed.is_connected(_on_menu_pick):
|
||||
pm.id_pressed.connect(_on_menu_pick)
|
||||
|
||||
# True when every importable entry is a 3D model (gltf/glb/fbx/obj/zip) OR every one
|
||||
# is an image (png/jpg/webp) — those formats are interchangeable for Godot, so we show
|
||||
# a single resolution list instead of one group per format (matches the Blender addon).
|
||||
func _collapse_by_resolution(entries: Array) -> bool:
|
||||
var model := ["glb", "gltf", "fbx", "obj", "zip"]
|
||||
var image := ["png", "jpg", "jpeg", "webp"]
|
||||
var all_model := true
|
||||
var all_image := true
|
||||
for e in entries:
|
||||
if not (e["ext"] in model):
|
||||
all_model = false
|
||||
if not (e["ext"] in image):
|
||||
all_image = false
|
||||
return all_model or all_image
|
||||
|
||||
func _base_fmt(fmt: String) -> String:
|
||||
return fmt.replace(" (GS)", "").to_upper()
|
||||
|
||||
func _on_menu_pick(idx: int) -> void:
|
||||
match idx:
|
||||
ID_FAV:
|
||||
_on_fav()
|
||||
ID_COPY:
|
||||
DisplayServer.clipboard_set(_asset_url())
|
||||
ID_VIEW:
|
||||
OS.shell_open(_asset_url())
|
||||
ID_DEL:
|
||||
delete_requested.emit(asset)
|
||||
_:
|
||||
if idx >= 0 and idx < _menu_entries.size():
|
||||
request_download.emit(asset, _menu_entries[idx])
|
||||
|
||||
func _asset_url() -> String:
|
||||
return CghAsset.web_url(asset) # https://cgheven.com/assets/<assets_slug>, like Blender
|
||||
|
||||
func _on_action() -> void:
|
||||
if _locked:
|
||||
request_upgrade.emit(asset)
|
||||
else:
|
||||
request_download.emit(asset, {}) # {} -> caller uses best free entry
|
||||
|
||||
func _on_fav() -> void:
|
||||
# Favourites require login — a guest gets a "please login" prompt instead.
|
||||
if auth == null or not auth.is_logged_in():
|
||||
login_required.emit()
|
||||
return
|
||||
var now := CghFavorites.toggle(asset)
|
||||
_refresh_fav_icon()
|
||||
_build_format_menu()
|
||||
favorite_toggled.emit(asset, now)
|
||||
|
||||
func _refresh_fav_icon() -> void:
|
||||
var fav := CghFavorites.is_fav(asset)
|
||||
_fav_btn.text = "♥" if fav else "♡"
|
||||
_fav_btn.add_theme_color_override("font_color", CghConfig.C_ERR if fav else CghConfig.C_TEXT1)
|
||||
|
||||
func _add_res_badge(text: String) -> void:
|
||||
var b := Label.new()
|
||||
b.text = text
|
||||
b.add_theme_color_override("font_color", Color.WHITE)
|
||||
b.add_theme_font_size_override("font_size", 9)
|
||||
var p := PanelContainer.new()
|
||||
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
p.add_theme_stylebox_override("panel", CghTheme.res_badge_box())
|
||||
p.add_child(b)
|
||||
_badge_row.add_child(p)
|
||||
|
||||
func _add_badge(text: String, col: Color) -> void:
|
||||
var b := Label.new()
|
||||
b.text = text
|
||||
b.add_theme_color_override("font_color", Color.WHITE)
|
||||
b.add_theme_font_size_override("font_size", 9)
|
||||
var p := PanelContainer.new()
|
||||
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
p.add_theme_stylebox_override("panel", CghTheme.badge_box(col))
|
||||
p.add_child(b)
|
||||
_badge_row.add_child(p)
|
||||
|
||||
# ---- called by main_dock during this card's download ----
|
||||
func set_progress(pct: float) -> void:
|
||||
_prog_row.visible = true
|
||||
_footer.visible = false
|
||||
if pct < 0:
|
||||
_progress.indeterminate = true
|
||||
else:
|
||||
_progress.indeterminate = false
|
||||
_progress.value = pct
|
||||
|
||||
func set_download_done(_ok: bool) -> void:
|
||||
_prog_row.visible = false
|
||||
_footer.visible = true
|
||||
|
||||
## Download cancelled — restore the footer to its pre-download state.
|
||||
func reset_after_cancel() -> void:
|
||||
_prog_row.visible = false
|
||||
_footer.visible = true
|
||||
_apply_state()
|
||||
|
||||
func set_thumbnail(tex: Texture2D) -> void:
|
||||
if tex and _thumb:
|
||||
_thumb.texture = tex
|
||||
_thumb_base = tex
|
||||
|
||||
func get_thumb_url() -> String:
|
||||
return CghAsset.thumbnail(asset)
|
||||
|
||||
# ---------------- hover preview (image slideshow; Godot can't play mp4) ----------------
|
||||
func _on_enter() -> void:
|
||||
add_theme_stylebox_override("panel", CghTheme.card_box(true))
|
||||
if _preview_loaded:
|
||||
if _preview_frames.size() > 1:
|
||||
_preview_timer.start()
|
||||
return
|
||||
if CghAsset.is_flipbook(asset):
|
||||
# Flipbooks: play the REAL sprite sheet, not the card thumbnail. Ask main_dock to fetch
|
||||
# the low-res sheet once; it calls set_flipbook_sheet() when ready. (Slicing the card
|
||||
# thumbnail was unreliable — some categories, e.g. muzzle-flashes, use a single-frame
|
||||
# thumbnail whose layout doesn't match the sheet's NxM, so slicing it showed garbage.)
|
||||
if not _flipbook_sheet_asked:
|
||||
_flipbook_sheet_asked = true
|
||||
flipbook_sheet_requested.emit(self, asset)
|
||||
return
|
||||
# Non-flipbooks: brief debounce, then load still frames over the network.
|
||||
_delay_timer.start()
|
||||
|
||||
func _on_exit() -> void:
|
||||
# Moving the pointer onto a child button (Download / ▾ / ♥) fires mouse_exited on the
|
||||
# card even though the pointer is still INSIDE it. Ignore those spurious exits so the
|
||||
# preview keeps playing across the whole card — only stop when truly leaving the card.
|
||||
# (If the pointer then leaves the card VIA that child button, the card gets NO second
|
||||
# mouse_exited — the watchdog in _next_preview_frame catches that case and stops.)
|
||||
if is_visible_in_tree() and get_global_rect().has_point(get_global_mouse_position()):
|
||||
return
|
||||
_stop_preview()
|
||||
|
||||
# Stop the hover preview and restore the card to its resting look.
|
||||
func _stop_preview() -> void:
|
||||
add_theme_stylebox_override("panel", CghTheme.card_box(false))
|
||||
_delay_timer.stop()
|
||||
_preview_timer.stop()
|
||||
# If the flipbook sheet never actually loaded (download failed / not a flipbook), allow a
|
||||
# fresh attempt on the next hover instead of being stuck forever.
|
||||
if not _preview_loaded:
|
||||
_flipbook_sheet_asked = false
|
||||
if _thumb_base:
|
||||
_thumb.texture = _thumb_base # revert to thumbnail
|
||||
|
||||
func _start_preview() -> void:
|
||||
if _preview_loaded:
|
||||
return
|
||||
# Non-flipbooks: ask main_dock for still frames (flipbooks are handled in _on_enter via the
|
||||
# real sprite-sheet download).
|
||||
preview_requested.emit(self, asset)
|
||||
|
||||
# main_dock delivers the REAL low-res sprite sheet (as a texture) + its grid; slice it into
|
||||
# per-cell AtlasTexture frames and play. Using the actual sheet (not the card thumbnail)
|
||||
# guarantees the grid matches the image — this fixes categories like muzzle-flashes whose card
|
||||
# thumbnail is a single frame, not a contact sheet, so slicing it NxM produced garbage.
|
||||
func set_flipbook_sheet(tex: Texture2D, grid: Vector2i) -> void:
|
||||
if tex == null:
|
||||
return
|
||||
var cols: int = maxi(1, grid.x)
|
||||
var rows: int = maxi(1, grid.y)
|
||||
if cols * rows <= 1:
|
||||
return
|
||||
var tw := tex.get_width()
|
||||
var th := tex.get_height()
|
||||
if tw < cols or th < rows:
|
||||
return
|
||||
# INTEGER cell size (float-divide then floor) so regions land on exact pixel boundaries —
|
||||
# fractional regions sample slivers of the neighbouring cell and make the playback shimmer.
|
||||
# The last column/row absorbs any remainder pixels so we never read past the sheet edge.
|
||||
var cw := int(float(tw) / float(cols))
|
||||
var ch := int(float(th) / float(rows))
|
||||
if cw < 1 or ch < 1:
|
||||
return
|
||||
var frames := []
|
||||
for r in rows:
|
||||
for c in cols:
|
||||
var w := cw if c < cols - 1 else tw - cw * (cols - 1)
|
||||
var h := ch if r < rows - 1 else th - ch * (rows - 1)
|
||||
var at := AtlasTexture.new()
|
||||
at.atlas = tex
|
||||
at.region = Rect2(c * cw, r * ch, w, h)
|
||||
frames.append(at)
|
||||
if frames.size() <= 1:
|
||||
return
|
||||
_preview_frames = frames
|
||||
_preview_loaded = true
|
||||
_preview_idx = 0
|
||||
_preview_timer.wait_time = 0.04 # ~25 fps flipbook playback, smooth (stills use PREVIEW_FPS)
|
||||
# Only start playing if the pointer is still over this card (the sheet may have finished
|
||||
# downloading after the user moved on).
|
||||
if is_visible_in_tree() and get_global_rect().has_point(get_global_mouse_position()):
|
||||
_thumb.texture = frames[0]
|
||||
_preview_timer.start()
|
||||
|
||||
func set_preview_frames(frames: Array) -> void:
|
||||
_preview_frames = frames
|
||||
_preview_loaded = true
|
||||
if _preview_frames.size() >= 1 and is_visible_in_tree():
|
||||
if get_global_rect().has_point(get_global_mouse_position()):
|
||||
_preview_idx = 0
|
||||
_thumb.texture = _preview_frames[0]
|
||||
if _preview_frames.size() > 1:
|
||||
_preview_timer.start()
|
||||
|
||||
func _next_preview_frame() -> void:
|
||||
# Watchdog: when the pointer leaves the card VIA a child button, the card never gets a
|
||||
# second mouse_exited, so the preview would otherwise play forever. This frame timer only
|
||||
# runs while previewing, so re-check the pointer each tick and stop once it's truly gone.
|
||||
if not is_visible_in_tree() or not get_global_rect().has_point(get_global_mouse_position()):
|
||||
_stop_preview()
|
||||
return
|
||||
if _preview_frames.is_empty():
|
||||
return
|
||||
_preview_idx = (_preview_idx + 1) % _preview_frames.size()
|
||||
_thumb.texture = _preview_frames[_preview_idx]
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3jrkpt271t81
|
||||
@@ -0,0 +1,158 @@
|
||||
@tool
|
||||
extends AcceptDialog
|
||||
class_name CghLoginDialog
|
||||
## Sign-in / Account dialog. Renders TWO views from the same window:
|
||||
## • Guest → "Sign in to CGHEVEN" + [ Login ][ Sign up ] brand buttons.
|
||||
## • Logged in → account card (email · plan · version) + [ Logout ].
|
||||
## The top-nav button opens this for both states, so account details live HERE
|
||||
## (the Settings popup no longer carries them).
|
||||
|
||||
signal logout_requested()
|
||||
|
||||
var auth: CghAuth
|
||||
var _content: VBoxContainer
|
||||
var _msg: Label
|
||||
|
||||
func build() -> void:
|
||||
title = "CGHEVEN — Account"
|
||||
min_size = Vector2i(380, 0)
|
||||
get_ok_button().visible = false # custom buttons below
|
||||
|
||||
_content = VBoxContainer.new()
|
||||
_content.add_theme_constant_override("separation", 10)
|
||||
_content.custom_minimum_size = Vector2(360, 0)
|
||||
add_child(_content)
|
||||
|
||||
auth.login_failed.connect(func(m): _set_msg(m))
|
||||
auth.logged_in.connect(func(_p): _render(); hide())
|
||||
_render()
|
||||
|
||||
## Open the dialog showing the right view for the current auth state.
|
||||
func open() -> void:
|
||||
_render()
|
||||
reset_size()
|
||||
popup_centered()
|
||||
|
||||
## Re-render in place (called when auth state changes while open).
|
||||
func refresh() -> void:
|
||||
if _content:
|
||||
_render()
|
||||
|
||||
func _render() -> void:
|
||||
if _content == null:
|
||||
return
|
||||
for c in _content.get_children():
|
||||
_content.remove_child(c)
|
||||
c.queue_free()
|
||||
if auth.is_logged_in():
|
||||
_build_profile()
|
||||
else:
|
||||
_build_login()
|
||||
|
||||
# ---------------- logged-in: account card ----------------
|
||||
func _build_profile() -> void:
|
||||
title = "CGHEVEN — Account"
|
||||
var h := Label.new()
|
||||
h.text = "Your account"
|
||||
h.add_theme_color_override("font_color", CghConfig.C_TEXT1)
|
||||
h.add_theme_font_size_override("font_size", 16)
|
||||
_content.add_child(h)
|
||||
|
||||
_kv("Account", auth.email if auth.email != "" else "Logged in")
|
||||
_kv("Plan", auth.plan)
|
||||
if auth.expires_at != "":
|
||||
_kv("Expires", _fmt_date(auth.expires_at))
|
||||
_kv("Version", CghConfig.ADDON_VERSION)
|
||||
|
||||
# Premium users (Pro/Studio/Bundle) have nothing to upgrade to — hide the button.
|
||||
if not auth.is_premium():
|
||||
var up := _brand_btn("⚡ Upgrade Plan", func(): OS.shell_open(CghConfig.pricing_url()))
|
||||
_content.add_child(up)
|
||||
|
||||
var lo := Button.new()
|
||||
lo.text = "Logout"
|
||||
lo.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
lo.custom_minimum_size = Vector2(0, 30)
|
||||
lo.pressed.connect(func(): logout_requested.emit(); hide())
|
||||
_content.add_child(lo)
|
||||
|
||||
# ---------------- guest: login / sign up ----------------
|
||||
func _build_login() -> void:
|
||||
title = "CGHEVEN — Sign in"
|
||||
var h := Label.new()
|
||||
h.text = "Sign in to CGHEVEN"
|
||||
h.add_theme_color_override("font_color", CghConfig.C_TEXT1)
|
||||
h.add_theme_font_size_override("font_size", 16)
|
||||
_content.add_child(h)
|
||||
|
||||
var sub := Label.new()
|
||||
sub.text = "Log in to download assets for your plan."
|
||||
sub.add_theme_color_override("font_color", CghConfig.C_TEXT2)
|
||||
sub.add_theme_font_size_override("font_size", 11)
|
||||
_content.add_child(sub)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
_content.add_child(row)
|
||||
row.add_child(_brand_btn("Login", func(): _start(false)))
|
||||
row.add_child(_brand_btn("Sign up", func(): _start(true)))
|
||||
|
||||
_msg = Label.new()
|
||||
_msg.add_theme_color_override("font_color", CghConfig.C_TEXT2)
|
||||
_msg.add_theme_font_size_override("font_size", 11)
|
||||
_msg.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_content.add_child(_msg)
|
||||
|
||||
# Format an ISO date ("2027-06-20T12:06:39.945Z") as "20 Jun 2027" for display.
|
||||
func _fmt_date(iso: String) -> String:
|
||||
if iso == "":
|
||||
return ""
|
||||
var date := iso.split("T")[0]
|
||||
var parts := date.split("-")
|
||||
if parts.size() != 3:
|
||||
return date
|
||||
var months := ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
var mi := int(parts[1]) - 1
|
||||
var mon: String = months[mi] if mi >= 0 and mi < 12 else parts[1]
|
||||
return "%s %s %s" % [parts[2], mon, parts[0]]
|
||||
|
||||
# ---------------- builders ----------------
|
||||
func _kv(k: String, val: String) -> void:
|
||||
var h := HBoxContainer.new()
|
||||
h.add_theme_constant_override("separation", 8)
|
||||
var kl := Label.new()
|
||||
kl.text = k
|
||||
kl.custom_minimum_size = Vector2(80, 0)
|
||||
kl.add_theme_color_override("font_color", CghConfig.C_TEXT2)
|
||||
kl.add_theme_font_size_override("font_size", 12)
|
||||
var vl := Label.new()
|
||||
vl.text = val
|
||||
vl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
vl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
vl.clip_text = true
|
||||
vl.add_theme_color_override("font_color", CghConfig.C_TEXT1)
|
||||
vl.add_theme_font_size_override("font_size", 12)
|
||||
h.add_child(kl)
|
||||
h.add_child(vl)
|
||||
_content.add_child(h)
|
||||
|
||||
func _brand_btn(label: String, cb: Callable) -> Button:
|
||||
var b := Button.new()
|
||||
b.text = label
|
||||
b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
b.custom_minimum_size = Vector2(0, 34)
|
||||
b.add_theme_stylebox_override("normal", CghTheme.brand_button_box())
|
||||
b.add_theme_stylebox_override("hover", CghTheme.brand_button_box(true))
|
||||
b.add_theme_stylebox_override("pressed", CghTheme.brand_button_box(true))
|
||||
b.add_theme_color_override("font_color", Color.WHITE)
|
||||
b.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
b.pressed.connect(cb)
|
||||
return b
|
||||
|
||||
func _start(register: bool) -> void:
|
||||
_set_msg("A browser window has opened — log in there, then come back.")
|
||||
auth.start_login(register)
|
||||
|
||||
func _set_msg(m: String) -> void:
|
||||
if _msg:
|
||||
_msg.text = m
|
||||
@@ -0,0 +1 @@
|
||||
uid://dr3frhbhm7nn
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://cxjitcjxagfe
|
||||
@@ -0,0 +1,195 @@
|
||||
@tool
|
||||
extends AcceptDialog
|
||||
class_name CghSettingsModal
|
||||
## Compact settings window (AE settings-modal style): App · Downloads ·
|
||||
## Community. Kept small/dense — all details fit a tight popup. Account details
|
||||
## live in the panel's Account dialog, not here. Downloads section lets the user
|
||||
## pick their own save folder.
|
||||
|
||||
signal logout_requested()
|
||||
signal check_updates_requested()
|
||||
signal download_dir_changed()
|
||||
|
||||
var auth: CghAuth
|
||||
var _path_lbl: Label
|
||||
var _dir_dialog: FileDialog
|
||||
var _upgrade_btn: Button # hidden for premium users (nothing to upgrade to)
|
||||
|
||||
func build() -> void:
|
||||
title = "CGHEVEN — Settings"
|
||||
# Small floor; the dialog auto-sizes to its (now compact) content.
|
||||
min_size = Vector2i(320, 0)
|
||||
# Wrap the window to its content's minimum size every time it opens — without this
|
||||
# the FIRST popup opens huge (content min-size not computed yet) and only the 2nd is
|
||||
# correct. wrap_controls makes it size right on the very first open.
|
||||
wrap_controls = true
|
||||
get_ok_button().text = "Close"
|
||||
|
||||
# outer padding (tight)
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_top", 7)
|
||||
margin.add_theme_constant_override("margin_bottom", 7)
|
||||
add_child(margin)
|
||||
|
||||
var v := VBoxContainer.new()
|
||||
v.add_theme_constant_override("separation", 3)
|
||||
v.custom_minimum_size = Vector2(300, 0)
|
||||
margin.add_child(v)
|
||||
|
||||
# ---------- App / actions ----------
|
||||
# Account details (email/plan) live in the panel's Account dialog now, NOT here.
|
||||
_section(v, "App")
|
||||
_kv(v, "Version", CghConfig.ADDON_VERSION)
|
||||
_action_btn(v, "Check for Updates", func(): check_updates_requested.emit())
|
||||
_upgrade_btn = _brand_btn(v, "⚡ Upgrade Plan", func(): OS.shell_open(CghConfig.pricing_url()))
|
||||
_gap(v)
|
||||
|
||||
# ---------- Downloads (user can choose the folder) ----------
|
||||
_section(v, "Downloads")
|
||||
var pathbox := PanelContainer.new()
|
||||
pathbox.add_theme_stylebox_override("panel", CghTheme.footer_pill_box())
|
||||
_path_lbl = Label.new()
|
||||
_path_lbl.text = CghConfig.primary_download_dir()
|
||||
_path_lbl.add_theme_color_override("font_color", CghConfig.C_TEXT2)
|
||||
_path_lbl.add_theme_font_size_override("font_size", 10)
|
||||
_path_lbl.autowrap_mode = TextServer.AUTOWRAP_ARBITRARY
|
||||
var pm := MarginContainer.new()
|
||||
for s in ["margin_left", "margin_right", "margin_top", "margin_bottom"]:
|
||||
pm.add_theme_constant_override(s, 6)
|
||||
pm.add_child(_path_lbl)
|
||||
pathbox.add_child(pm)
|
||||
v.add_child(pathbox)
|
||||
|
||||
# Change / Reset / Open — one tight row
|
||||
var brow := HBoxContainer.new()
|
||||
brow.add_theme_constant_override("separation", 4)
|
||||
_small_btn(brow, "Change folder…", _pick_folder)
|
||||
_small_btn(brow, "Reset", func():
|
||||
CghConfig.set_custom_download_dir("")
|
||||
_refresh_path()
|
||||
download_dir_changed.emit())
|
||||
_small_btn(brow, "Open", func():
|
||||
var d := CghConfig.primary_download_dir()
|
||||
DirAccess.make_dir_recursive_absolute(d)
|
||||
OS.shell_open(d))
|
||||
v.add_child(brow)
|
||||
|
||||
var note := Label.new()
|
||||
note.text = "Shared with all CGHEVEN addons (so downloads show everywhere)."
|
||||
note.add_theme_color_override("font_color", CghConfig.C_TEXT3)
|
||||
note.add_theme_font_size_override("font_size", 9)
|
||||
note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
v.add_child(note)
|
||||
_gap(v)
|
||||
|
||||
# ---------- Community ----------
|
||||
_section(v, "Community")
|
||||
_brand_btn(v, "Join Discord", func(): OS.shell_open(CghConfig.DISCORD))
|
||||
|
||||
# folder picker (native OS dialog)
|
||||
_dir_dialog = FileDialog.new()
|
||||
_dir_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR
|
||||
_dir_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||||
_dir_dialog.use_native_dialog = true
|
||||
_dir_dialog.title = "Choose download folder"
|
||||
_dir_dialog.dir_selected.connect(_on_dir_picked)
|
||||
add_child(_dir_dialog)
|
||||
|
||||
func refresh() -> void:
|
||||
_refresh_path()
|
||||
# Premium users (Pro/Studio/Bundle) have nothing to upgrade to — hide the button.
|
||||
if _upgrade_btn:
|
||||
_upgrade_btn.visible = not (auth != null and auth.is_premium())
|
||||
|
||||
## Open centered at the CORRECT size on the first click too. AcceptDialog computes its
|
||||
## content min-size only after it is shown, so the naive reset_size()+popup_centered()
|
||||
## opened huge the first time. Show → wait one frame (layout runs) → wrap to content →
|
||||
## re-center. Subsequent opens are already correct.
|
||||
func open_centered() -> void:
|
||||
refresh()
|
||||
popup_centered()
|
||||
await get_tree().process_frame
|
||||
reset_size()
|
||||
move_to_center()
|
||||
|
||||
func _pick_folder() -> void:
|
||||
if _dir_dialog:
|
||||
_dir_dialog.current_dir = CghConfig.primary_download_dir()
|
||||
_dir_dialog.popup_centered(Vector2i(700, 480))
|
||||
|
||||
func _on_dir_picked(dir: String) -> void:
|
||||
CghConfig.set_custom_download_dir(dir)
|
||||
_refresh_path()
|
||||
download_dir_changed.emit()
|
||||
|
||||
func _refresh_path() -> void:
|
||||
if _path_lbl:
|
||||
_path_lbl.text = CghConfig.primary_download_dir()
|
||||
|
||||
# ---------------- builders ----------------
|
||||
func _section(parent: Node, t: String) -> void:
|
||||
var l := Label.new()
|
||||
l.text = t.to_upper()
|
||||
l.add_theme_color_override("font_color", CghConfig.C_ACCENT)
|
||||
l.add_theme_font_size_override("font_size", 10)
|
||||
parent.add_child(l)
|
||||
var sep := HSeparator.new()
|
||||
parent.add_child(sep)
|
||||
|
||||
func _gap(parent: Node) -> void:
|
||||
var c := Control.new()
|
||||
c.custom_minimum_size = Vector2(0, 1)
|
||||
parent.add_child(c)
|
||||
|
||||
func _kv(parent: Node, k: String, val: String) -> Label:
|
||||
var h := HBoxContainer.new()
|
||||
h.add_theme_constant_override("separation", 8)
|
||||
var kl := Label.new()
|
||||
kl.text = k
|
||||
kl.custom_minimum_size = Vector2(80, 0)
|
||||
kl.add_theme_color_override("font_color", CghConfig.C_TEXT2)
|
||||
kl.add_theme_font_size_override("font_size", 11)
|
||||
var vl := Label.new()
|
||||
vl.text = val
|
||||
vl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
vl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
vl.clip_text = true
|
||||
vl.add_theme_color_override("font_color", CghConfig.C_TEXT1)
|
||||
vl.add_theme_font_size_override("font_size", 11)
|
||||
h.add_child(kl)
|
||||
h.add_child(vl)
|
||||
parent.add_child(h)
|
||||
return vl
|
||||
|
||||
func _action_btn(parent: Node, label: String, cb: Callable) -> void:
|
||||
var b := Button.new()
|
||||
b.text = label
|
||||
b.custom_minimum_size = Vector2(0, 24)
|
||||
b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
b.pressed.connect(cb)
|
||||
parent.add_child(b)
|
||||
|
||||
func _small_btn(parent: Node, label: String, cb: Callable) -> void:
|
||||
var b := Button.new()
|
||||
b.text = label
|
||||
b.custom_minimum_size = Vector2(0, 22)
|
||||
b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
b.add_theme_font_size_override("font_size", 10)
|
||||
b.pressed.connect(cb)
|
||||
parent.add_child(b)
|
||||
|
||||
func _brand_btn(parent: Node, label: String, cb: Callable) -> Button:
|
||||
var b := Button.new()
|
||||
b.text = label
|
||||
b.custom_minimum_size = Vector2(0, 26)
|
||||
b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
b.add_theme_stylebox_override("normal", CghTheme.brand_button_box())
|
||||
b.add_theme_stylebox_override("hover", CghTheme.brand_button_box(true))
|
||||
b.add_theme_stylebox_override("pressed", CghTheme.brand_button_box(true))
|
||||
b.add_theme_color_override("font_color", Color.WHITE)
|
||||
b.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
b.pressed.connect(cb)
|
||||
parent.add_child(b)
|
||||
return b
|
||||
@@ -0,0 +1 @@
|
||||
uid://pd7unipywc00
|
||||
@@ -0,0 +1,118 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghTheme
|
||||
## Builds a Godot Theme resource in code that matches the After Effects addon
|
||||
## dark look 1:1 (StyleBoxFlat — rounded cards, dark bg, accent borders).
|
||||
|
||||
static func _box(bg: Color, radius: int, border := 0, border_col := Color.TRANSPARENT) -> StyleBoxFlat:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = bg
|
||||
sb.set_corner_radius_all(radius)
|
||||
sb.corner_detail = 8
|
||||
if border > 0:
|
||||
sb.set_border_width_all(border)
|
||||
sb.border_color = border_col
|
||||
sb.content_margin_left = 10
|
||||
sb.content_margin_right = 10
|
||||
sb.content_margin_top = 6
|
||||
sb.content_margin_bottom = 6
|
||||
return sb
|
||||
|
||||
static func build() -> Theme:
|
||||
var t := Theme.new()
|
||||
|
||||
# --- Panel / background ---
|
||||
t.set_stylebox("panel", "PanelContainer", _box(CghConfig.C_BG, 0))
|
||||
|
||||
# --- Buttons (default = subtle, accent on hover) ---
|
||||
var btn_normal := _box(CghConfig.C_CARD, CghConfig.RADIUS_MD, 1, CghConfig.C_BORDER)
|
||||
var btn_hover := _box(CghConfig.C_CARD_HOVER, CghConfig.RADIUS_MD, 1, CghConfig.C_ACCENT)
|
||||
var btn_pressed := _box(CghConfig.C_ACCENT_DIM, CghConfig.RADIUS_MD, 1, CghConfig.C_ACCENT)
|
||||
t.set_stylebox("normal", "Button", btn_normal)
|
||||
t.set_stylebox("hover", "Button", btn_hover)
|
||||
t.set_stylebox("pressed", "Button", btn_pressed)
|
||||
t.set_stylebox("focus", "Button", _box(Color.TRANSPARENT, CghConfig.RADIUS_MD))
|
||||
t.set_color("font_color", "Button", CghConfig.C_TEXT1)
|
||||
t.set_color("font_hover_color", "Button", CghConfig.C_TEXT1)
|
||||
t.set_color("font_pressed_color", "Button", CghConfig.C_ACCENT_HOVER)
|
||||
|
||||
# --- LineEdit (search box, key input) ---
|
||||
var le := _box(CghConfig.C_INPUT, CghConfig.RADIUS_MD, 1, CghConfig.C_BORDER)
|
||||
var le_focus := _box(CghConfig.C_INPUT, CghConfig.RADIUS_MD, 1, CghConfig.C_ACCENT)
|
||||
t.set_stylebox("normal", "LineEdit", le)
|
||||
t.set_stylebox("focus", "LineEdit", le_focus)
|
||||
t.set_color("font_color", "LineEdit", CghConfig.C_TEXT1)
|
||||
t.set_color("font_placeholder_color", "LineEdit", CghConfig.C_TEXT3)
|
||||
t.set_color("caret_color", "LineEdit", CghConfig.C_ACCENT)
|
||||
|
||||
# --- Labels ---
|
||||
t.set_color("font_color", "Label", CghConfig.C_TEXT1)
|
||||
|
||||
# --- OptionButton (sort / format dropdowns) ---
|
||||
t.set_stylebox("normal", "OptionButton", btn_normal)
|
||||
t.set_stylebox("hover", "OptionButton", btn_hover)
|
||||
t.set_stylebox("pressed", "OptionButton", btn_pressed)
|
||||
t.set_color("font_color", "OptionButton", CghConfig.C_TEXT1)
|
||||
|
||||
# --- ScrollContainer / grid background transparent over bg ---
|
||||
t.set_stylebox("panel", "ScrollContainer", _box(Color.TRANSPARENT, 0))
|
||||
|
||||
# --- PopupMenu (dropdowns) ---
|
||||
t.set_stylebox("panel", "PopupMenu", _box(CghConfig.C_BG2, CghConfig.RADIUS_LG, 1, CghConfig.C_BORDER))
|
||||
t.set_color("font_color", "PopupMenu", CghConfig.C_TEXT1)
|
||||
t.set_color("font_hover_color", "PopupMenu", CghConfig.C_ACCENT_HOVER)
|
||||
|
||||
return t
|
||||
|
||||
## Reusable styleboxes pulled by widgets (cards, badges, banner).
|
||||
## AE card: background is ALWAYS #1e1e24 — hover only brightens the border
|
||||
## (never recolors the card). This is what makes the grid look uniform.
|
||||
static func card_box(hover := false) -> StyleBoxFlat:
|
||||
var border_col := CghConfig.C_BORDER_HOVER if hover else CghConfig.C_BORDER
|
||||
return _box(CghConfig.C_CARD, CghConfig.RADIUS_LG, 1, border_col)
|
||||
|
||||
static func accent_button_box(hover := false) -> StyleBoxFlat:
|
||||
var c := CghConfig.C_ACCENT_HOVER if hover else CghConfig.C_ACCENT
|
||||
return _box(c, CghConfig.RADIUS_MD)
|
||||
|
||||
## Orange brand button (Login / Sign up / Upgrade) — solid mid-gradient color.
|
||||
static func brand_button_box(hover := false) -> StyleBoxFlat:
|
||||
var c := CghConfig.C_BRAND_HOVER if hover else CghConfig.C_BRAND
|
||||
return _box(c, CghConfig.RADIUS_MD)
|
||||
|
||||
## The AE .cdl-row footer pill (subtle grey, thin border, 4px radius).
|
||||
static func footer_pill_box(hover := false) -> StyleBoxFlat:
|
||||
var border_col := Color(0.357, 0.486, 1.0, 0.35) if hover else CghConfig.C_BORDER
|
||||
var sb := _box(CghConfig.C_FOOTER_PILL, CghConfig.RADIUS_SM, 1, border_col)
|
||||
sb.content_margin_left = 0
|
||||
sb.content_margin_right = 0
|
||||
sb.content_margin_top = 0
|
||||
sb.content_margin_bottom = 0
|
||||
return sb
|
||||
|
||||
## Dropdown / popover background (--bgd #1c1c22).
|
||||
static func dropdown_box() -> StyleBoxFlat:
|
||||
return _box(CghConfig.C_DROPDOWN, CghConfig.RADIUS_XL, 1, CghConfig.C_BORDER)
|
||||
|
||||
## Resolution badge — dark translucent pill, small radius (AE .rbadge).
|
||||
static func res_badge_box() -> StyleBoxFlat:
|
||||
var sb := _box(CghConfig.C_RES_BADGE, 3)
|
||||
sb.content_margin_left = 5
|
||||
sb.content_margin_right = 5
|
||||
sb.content_margin_top = 2
|
||||
sb.content_margin_bottom = 2
|
||||
return sb
|
||||
|
||||
## Coloured badge (NEW = red, Premium = orange) — small 3px pill like AE.
|
||||
static func badge_box(col: Color) -> StyleBoxFlat:
|
||||
var sb := _box(col, 3)
|
||||
sb.content_margin_left = 5
|
||||
sb.content_margin_right = 5
|
||||
sb.content_margin_top = 2
|
||||
sb.content_margin_bottom = 2
|
||||
return sb
|
||||
|
||||
static func banner_box() -> StyleBoxFlat:
|
||||
# brand gradient is approximated with a flat brand color (StyleBoxFlat has no gradient);
|
||||
# a true gradient uses a TextureRect with a GradientTexture2D behind the banner.
|
||||
return _box(CghConfig.C_BRAND_A, CghConfig.RADIUS_LG)
|
||||
@@ -0,0 +1 @@
|
||||
uid://x7gvw0ql4ctd
|
||||
Reference in New Issue
Block a user