Initial commit

This commit is contained in:
scorpion21211-cmd
2026-10-03 23:29:41 -04:00
commit e24bbbda5d
10586 changed files with 1030090 additions and 0 deletions
+21
View File
@@ -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.
+62
View File
@@ -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
+66
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
uid://ba3y4otrvrgbm
+59
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://xa7h3bnk8yvj
+117
View File
@@ -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 [])
+1
View File
@@ -0,0 +1 @@
uid://b3v25j2woseak
+373
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
uid://dn5sgwiqu512l
+285
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://c6ock0fik2qi8
+75
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://c7tbtkjio3q4g
+269
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://67wa4ry6asqt
+90
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://c3p8ea4j8waqs
+221
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c3l7vtfgil2ed
+44
View File
@@ -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()
+1
View File
@@ -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
+231
View File
@@ -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
+82
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://brkjphruderpy
BIN
View File
Binary file not shown.
+40
View File
@@ -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
+7
View File
@@ -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"
+579
View File
@@ -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]
+1
View File
@@ -0,0 +1 @@
uid://b3jrkpt271t81
+158
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dr3frhbhm7nn
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://cxjitcjxagfe
+195
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://pd7unipywc00
+118
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://x7gvw0ql4ctd