Initial commit: ComfyUI-AgeShift - age shift node pack

This commit is contained in:
hua zhuang
2026-05-21 10:49:06 +08:00
commit 2962154d69
20 changed files with 2798 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
.env
.idea/
.vscode/
.DS_Store
# build / dist
build/
dist/
*.egg-info/
*.tar.gz
# CLI runtime
out/
output/
*.dump.json
*.detect.json
*.shift_age*.json
# models (downloaded to HF cache, not committed)
models/
hf_cache/
+62
View File
@@ -0,0 +1,62 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.1] - 2026-05-21
### Documentation
- New `PROMPTS.md`: complete reference of all 20 age-range feature mappings,
full positive/negative prompt examples at ages 5/15/25/45/65/80, and
documentation of the anti-drift negative-prompt logic.
- Rewrote `README.md` with badges, pipeline diagram, identity-preservation
tuning guide, and a real-world troubleshooting section.
- Expanded `INSTALL_NOTES.md` with the InsightFace `antelopev2` manual
placement requirement (the double-`models` directory quirk) and a
self-check shell script.
### Notes
No code changes in this release — just documentation. Existing installations
need no action.
## [0.1.0] - 2026-05-21
### Added
- Initial release.
- `AgeDetectorLoader` — loads HF ViT age-classification models with an
in-process `_MODEL_CACHE` keyed by `model_id|device|dtype`.
- `AgeDetect` — runs age estimation on a ComfyUI IMAGE tensor; computes
continuous age via weighted mean over bin midpoints (or argmax of bin
centers if `method=argmax_midpoint`). Returns `(age:INT, band:STRING,
confidence:FLOAT, summary:STRING)`.
- `AgePromptBuilder` — emits paired positive/negative prompts with concrete
age-feature descriptors (e.g. "deep nasolabial folds, salt-and-pepper hair")
rather than just a numeric age. Optional anti-drift negative prompts auto-
inject contextual repulsion (e.g. for target=65, adds "baby face, teenage
features, youthful smooth skin" to the negative).
- `AgeRangeBatch` — emits JSONL prompts for a comma-separated list of target
ages, consumed by the CLI for batch sequence generation.
- `cli/age_shift_cli.py` — end-to-end CLI: upload → detect → PuLID-Flux gen.
Supports `--detect-only`, single `--target-age`, batched `--target-ages`,
full per-knob control over PuLID weight / Flux steps / guidance / seed,
and `--dump-workflow` for debugging.
Uses PEP 723 inline-script metadata so `uv run --script` handles deps
with no manual `pip install`.
- Example workflows: `age_detect_only.json` (UI, 6 nodes) and
`age_shift_pulid_flux.json` (UI, 17 nodes with grouped layout).
- `INSTALL_NOTES.md` documenting server-side install on Ubuntu 22.04 +
dual RTX 3090 + CUDA 12.6 + Python 3.13.
### Known limitations
- `AgeDetect` runs whole-image classification — input should be face-cropped.
- Image generation requires separately installing ComfyUI-PuLID-Flux and
Flux 1 dev / schnell weights.
- Aging-down (adult → child) is harder than aging-up because cranial
proportions change substantially. For child direction, manually lower
`--pulid-weight` to 0.75–0.80. Consider face-landmark ControlNet for
future versions.
- The `subject` widget is a single value across all ages in the batch CLI,
which produces awkward phrasing like "5 year old male man". Workaround:
call AgePromptBuilder once per age from a higher-level orchestrator with
the appropriate subject noun.
+92
View File
@@ -0,0 +1,92 @@
# Contributing
Thanks for your interest in ComfyUI-AgeShift! This project follows the same
patterns as the rest of the `aadebuger` ComfyUI node packages.
## Dev setup
```bash
git clone https://github.com/aadebuger/ComfyUI-AgeShift.git
cd ComfyUI-AgeShift
# install into your ComfyUI venv
source /path/to/ComfyUI/.venv/bin/activate
uv pip install -e .
```
Restart ComfyUI; the four nodes should appear under category `🎂 AgeShift`.
## Repo layout
```
ComfyUI-AgeShift/
├── __init__.py # NODE_CLASS_MAPPINGS / DISPLAY_NAME_MAPPINGS
├── nodes/
│ ├── __init__.py # empty package marker
│ ├── _types.py # shared constants + age-feature mapping
│ ├── detector_loader.py # _MODEL_CACHE pattern
│ ├── detect.py
│ └── prompt_builder.py
├── cli/
│ ├── README.md
│ └── age_shift_cli.py # ComfyClient + build_workflow + main
└── example_workflows/
├── age_detect_only.json
└── age_shift_pulid_flux.json
```
## Adding a new node
1. Drop a new file in `nodes/yourname.py`. Use this skeleton:
```python
class YourNode:
@classmethod
def INPUT_TYPES(cls):
return {"required": {...}, "optional": {...}}
RETURN_TYPES = (...)
RETURN_NAMES = (...)
FUNCTION = "run"
CATEGORY = "🎂 AgeShift"
def run(self, ...):
return (...)
```
2. Register it in the top-level `__init__.py`:
```python
from .nodes.yourname import YourNode
NODE_CLASS_MAPPINGS["YourNode"] = YourNode
NODE_DISPLAY_NAME_MAPPINGS["YourNode"] = "🎂 Your Node"
```
3. Update `cli/age_shift_cli.py` if the new node should be CLI-driven; add a
`build_*_workflow()` function.
4. Add an example workflow under `example_workflows/`.
5. Bump `CHANGELOG.md`.
## Coding conventions
- **Lazy-import heavy deps** (torch, transformers, PIL) inside the node method,
not at module top-level. Keeps ComfyUI startup fast.
- **Use `_MODEL_CACHE` for loaders**, keyed by all input args. Implement
`IS_CHANGED` to reflect that.
- **Strings, not enums for HF model IDs** — gives users a way to swap in
alternate models without code edits.
- Category prefix is always `🎂 AgeShift`.
- Display names always start with `🎂` for searchability.
## Pull requests
Small focused PRs preferred. Please include:
- A short description of what changed and why.
- A `CHANGELOG.md` entry under `[Unreleased]`.
- If a new node: an example workflow.
## License
By contributing, you agree your contributions are licensed under MIT.
+134
View File
@@ -0,0 +1,134 @@
# GitHub Publishing Cheat Sheet
发布到 `github.com/aadebuger/ComfyUI-AgeShift` 时的素材模板。
---
## Repository About (一行描述)
```
Face age detection and identity-preserved age-shift portraits for ComfyUI — pairs with PuLID-Flux
```
(或更短:`ComfyUI nodes for face age detection and age-shifted portraits via PuLID-Flux`)
## Topics(逗号分隔)
```
comfyui, comfyui-nodes, age-detection, age-progression, face-aging, pulid, flux, vit, huggingface, image-generation, ai
```
---
## Release v0.1.1 notes(复制到 GitHub Release 描述)
```markdown
## ComfyUI-AgeShift v0.1.1 — Documentation release
Documentation polish + complete prompt reference. No code changes; existing installs need no action.
### What's new
- 📖 **New PROMPTS.md** — every auto-generated prompt across all 20 age ranges, with full positive/negative examples at ages 5/15/25/45/65/80 and an explanation of the anti-drift negative logic.
- 🩹 **Troubleshooting section** added to README covering the four bugs commonly hit during first install: missing PuLID-Flux deps, InsightFace `antelopev2` path quirks, `websocket` vs `websocket-client` naming, and identity-drift tuning.
- 📝 **INSTALL_NOTES.md expanded** with the `antelopev2` manual placement step — the InsightFace lib requires a double `models/` directory (`models/insightface/models/antelopev2/`) which is not obvious.
- 🎯 **Identity preservation tuning guide** in README: when to bump `--pulid-weight`, when to drop guidance, and how to handle aging-down to child direction.
### Background
This release captures the lessons from the first end-to-end deployment on Ubuntu 22.04 + dual RTX 3090. See INSTALL_NOTES.md for the full record.
```
---
## Release v0.1.0 notes(初始发布,可选)
```markdown
## ComfyUI-AgeShift v0.1.0 — Initial release
Four ComfyUI nodes plus a CLI for face age detection and age-shifted portrait generation. Designed to pair with ComfyUI-PuLID-Flux for identity preservation.
### Nodes
- 🎂 **AgeDetectorLoader** — caches a HuggingFace ViT age classifier (default: `nateraw/vit-age-classifier`)
- 🎂 **AgeDetect** — continuous age estimation via weighted mean over bin midpoints
- 🎂 **AgePromptBuilder** — translates target age into concrete feature prompts ("deep nasolabial folds, salt-and-pepper hair") plus anti-drift negatives
- 🎂 **AgeRangeBatch** — JSONL output for batch age-progression sequences
### CLI
- End-to-end `age_shift_cli.py`: upload → detect → PuLID-Flux generation
- Supports `--detect-only`, single `--target-age`, or batched `--target-ages 5,15,25,45,65`
- PEP 723 inline metadata — no manual `pip install` needed when invoked with `uv run --script`
### Requirements
- ComfyUI
- (Optional, for image generation) ComfyUI-PuLID-Flux + Flux 1 dev/schnell weights
See README.md for full installation and quick-start.
```
---
## 发布步骤(沿用 aadebuger 节奏)
```bash
cd /path/to/local/ComfyUI-AgeShift
git init
git add .
git commit -m "v0.1.1: Documentation release with complete PROMPTS.md
- Add PROMPTS.md listing every auto-generated prompt across 20 age ranges
- Add troubleshooting section to README (PuLID deps, antelopev2 paths,
websocket-client naming, identity drift tuning)
- Expand INSTALL_NOTES.md with InsightFace antelopev2 manual placement
- Add identity preservation tuning guide"
# 在 github.com/new 创建 aadebuger/ComfyUI-AgeShift
git remote add origin git@github.com:aadebuger/ComfyUI-AgeShift.git
git branch -M main
git push -u origin main
git tag v0.1.1
git push origin v0.1.1
```
发布后:
- 仓库 About 粘上面那行
- Topics 粘上面那串
- Release 用 v0.1.1 那段 markdown
- 第一个 star 后考虑提 PR 到
https://github.com/ltdrdata/ComfyUI-Manager 的 `custom-node-list.json`
## ComfyUI-Manager 注册条目模板
提 PR 时往 `custom-node-list.json` 加这个对象:
```json
{
"author": "aadebuger",
"title": "ComfyUI-AgeShift",
"id": "comfyui-ageshift",
"reference": "https://github.com/aadebuger/ComfyUI-AgeShift",
"files": [
"https://github.com/aadebuger/ComfyUI-AgeShift"
],
"install_type": "git-clone",
"description": "Face age detection (ViT) and age-conditioned prompt building. Pairs with PuLID-Flux for identity-preserving age-shift portraits — from infant to 90+."
}
```
---
## Screenshots TODO
`README.md` 现在没有截图。发布时强烈建议放上:
- `docs/example_progression.png` — 一张源图 + 5/15/25/45/65 五张生成的拼图
- `docs/node_palette.png` — ComfyUI 节点 palette 截图,显示 4 个 🎂 节点
- `docs/workflow_screenshot.png` — `age_shift_pulid_flux.json` 加载到 UI 的样子
可以放 `docs/` 目录,然后在 README 的 Features 章节后面 / Quick start 章节前面插入:
```markdown
![Age progression example](docs/example_progression.png)
```
Hua 跑出第一张满意的样图后,扔进 `docs/` 直接 push,这页就完整了。
+263
View File
@@ -0,0 +1,263 @@
# INSTALL_NOTES
实战安装记录(脱敏)。环境:Ubuntu 22.04 + 双 RTX 3090 + CUDA 12.6 +
Python 3.13 + torch 2.11.0+cu126,ComfyUI 装在 `/mnt/modelall/ComfyUI/`,
uv 管理 venv。
> 这份文档按"从零到第一张图"的实际顺序写,包含每一步可能踩到的坑。如果在
> 别的机器上装,把 `/mnt/modelall` 替换成你的路径即可。
---
## 1. 安装 AgeShift 节点包
```bash
cd /mnt/modelall/ComfyUI/custom_nodes
git clone https://github.com/aadebuger/ComfyUI-AgeShift.git
cd ComfyUI-AgeShift
source /mnt/modelall/ComfyUI/.venv/bin/activate
export HF_HOME=/mnt/modelall/hf_cache # 关键 — 主盘只有 10GB
uv pip install -r requirements.txt
```
依赖很轻(`transformers / pillow / numpy`),几乎一定已经在 ComfyUI venv 里了。
---
## 2. 安装 ComfyUI-PuLID-Flux
```bash
cd /mnt/modelall/ComfyUI/custom_nodes
git clone https://github.com/balazik/ComfyUI-PuLID-Flux.git
cd ComfyUI-PuLID-Flux
uv pip install -r requirements.txt --no-cache-dir
```
主要拉:`facexlib / onnxruntime-gpu / insightface / timm / ftfy`。
### ⚠️ 容易踩的坑
**坑 1:`onnxruntime-gpu` 版本**
CUDA 12.6 需要 `onnxruntime-gpu >= 1.18`。装完确认:
```bash
uv pip show onnxruntime-gpu | grep -i version
```
**坑 2:`insightface` 编译失败**
```bash
# 如果报 Cython 缺失
uv pip install cython
uv pip install insightface --no-build-isolation
```
---
## 3. 下载模型权重
### Flux 主模型(选一个)
| 选项 | 文件 | 大小 | 备注 |
|---|---|---|---|
| **dev fp8(推荐)** | `flux1-dev-fp8.safetensors` | ~12 GB | 无 license,质量好 |
| dev 全精度 | `flux1-dev.safetensors` | ~23 GB | gated,需 HF token |
| schnell | `flux1-schnell.safetensors` | ~24 GB | 开放,4 步快,PuLID 效果一般 |
```bash
# 方案 A: dev fp8(最推荐)
cd /mnt/modelall/ComfyUI/models/unet
wget "https://huggingface.co/Kijai/flux-fp8/resolve/main/flux1-dev-fp8.safetensors"
# 方案 B: dev 全精度
huggingface-cli login # 粘贴 HF token,需要先在网页同意 license
huggingface-cli download black-forest-labs/FLUX.1-dev flux1-dev.safetensors \
--local-dir /mnt/modelall/ComfyUI/models/unet/
```
### Flux 配套(CLIP + VAE)
```bash
# 如果还没装 Flux 配套权重
cd /mnt/modelall/ComfyUI/models
huggingface-cli download MaxedOut/ComfyUI-Starter-Packs Flux1/clip/clip_l.safetensors \
--local-dir hf_cache/manual/
# 类似下载 t5xxl_fp8_scaled.safetensors, ae.safetensors,然后 symlink 进 models/clip/ models/vae/
# 实际项目里 Hua 的环境已经有,通过 symlink 指向 MaxedOut starter pack:
ls -lh /mnt/modelall/ComfyUI/models/clip/
# clip_l.safetensors -> /mnt/modelall/hf_cache/.../Flux1/clip/clip_l.safetensors
# t5xxl_fp8_scaled.safetensors -> ...
```
### PuLID-Flux 权重(~1.1 GB)
```bash
mkdir -p /mnt/modelall/ComfyUI/models/pulid
cd /mnt/modelall/ComfyUI/models/pulid
wget "https://huggingface.co/guozinan/PuLID/resolve/main/pulid_flux_v0.9.1.safetensors"
```
### ⚠️ InsightFace antelopev2(关键步骤,容易漏)
PuLID-Flux 用 `antelopev2` 不是默认的 `buffalo_l`,**不会自动下载**。
必须手动放,而且目录结构要正确(双层 `models`,反直觉但 InsightFace 库
就是这么要求的):
```bash
cd /mnt/modelall/ComfyUI/models/insightface
mkdir -p models && cd models
# 从 GitHub releases
wget "https://github.com/deepinsight/insightface/releases/download/v0.7/antelopev2.zip"
unzip antelopev2.zip && rm antelopev2.zip
# 验证目录结构
ls -lh antelopev2/
# 必须有 5 个文件:
# 1k3d68.onnx 2d106det.onnx genderage.onnx glintr100.onnx scrfd_10g_bnkps.onnx
```
**最终路径必须是**:`/mnt/modelall/ComfyUI/models/insightface/models/antelopev2/`
(注意两层 `models`。少一层会触发 `AssertionError: assert 'detection' in self.models`。)
如果 GitHub releases 拉不动:
```bash
# 方案 1: HF 镜像
huggingface-cli download MonsterMMORPG/tools antelopev2.zip --local-dir /tmp/
unzip /tmp/antelopev2.zip -d /mnt/modelall/ComfyUI/models/insightface/models/
# 方案 2: 走 GitHub 代理
wget "https://ghproxy.com/https://github.com/deepinsight/insightface/releases/download/v0.7/antelopev2.zip"
```
### EVA02-CLIP-L(自动下载,不用管)
PuLID-Flux 节点首次运行时自动从 HF 拉 `EVA02_CLIP_L_336_psz14_s6B.pt`(~430 MB)
到 `$HF_HOME`。第一张图会卡 1-2 分钟,之后从 cache 走。
### Age 检测器(自动下载,不用管)
`nateraw/vit-age-classifier`(~330 MB)首次运行从 HF 拉到 `$HF_HOME`。
---
## 4. 启动 ComfyUI(模板)
```bash
pkill -9 -f main.py; sleep 3
cd /mnt/modelall/ComfyUI && source .venv/bin/activate
export HF_HOME=/mnt/modelall/hf_cache # 关键!别忘
nohup python main.py --listen 0.0.0.0 --port 8188 > ~/comfy_start.log 2>&1 &
sleep 35
grep -iE "IMPORT FAILED|pulid|AgeShift|error" ~/comfy_start.log | head -20
```
预期:看到 PuLID-Flux 和 AgeShift 都被加载,没有 `IMPORT FAILED`。
---
## 5. 验证所有节点都注册了
```bash
curl -s http://127.0.0.1:8188/object_info | python3 -c "
import json,sys
d = json.load(sys.stdin)
need = ['AgeDetectorLoader','AgeDetect','AgePromptBuilder','AgeRangeBatch',
'PulidFluxModelLoader','PulidFluxEvaClipLoader',
'PulidFluxInsightFaceLoader','ApplyPulidFlux']
for n in need:
print(f' {n}:', '✓' if n in d else '✗')
print(f' total: {len(d)}')"
```
8 个都打 ✓ 才能跑完整 pipeline。
---
## 6. 客户端 CLI
CLI 自带 PEP 723 inline metadata,uv 会自动管依赖。**不用 pip install 任何东西**:
```bash
# Mac 本地
cd /path/to/ComfyUI-AgeShift
# 单图检测(~2 秒)
uv run --script cli/age_shift_cli.py face.jpg --detect-only --ip 10.8.0.12
# 完整流程
uv run --script cli/age_shift_cli.py face.jpg \
--target-ages 5,15,25,45,65 \
--gender male --subject man \
--flux-unet flux1-dev-fp8.safetensors \
--flux-t5 t5xxl_fp8_scaled.safetensors \
--pulid-weight 0.95 --guidance 3.0 --steps 28 --seed 42 \
--ip 10.8.0.12 --out ./out
```
`--flux-t5 t5xxl_fp8_scaled.safetensors` 是因为 MaxedOut Starter Pack 给的是
scaled 版,默认 CLI 里写的是 `e4m3fn`。其他 Flux 文件名也类似,以你服务器
`ls models/unet/` 实际看到的为准。
---
## 7. 历史踩过的坑汇总
| 坑 | 表现 | 解决 |
|---|---|---|
| 主盘 HF cache 撑满 | 模型下到一半失败 | **必须** `export HF_HOME=/mnt/modelall/hf_cache` |
| `processor` 类名不一致 | `ImportError: cannot import name 'ViTImageProcessor'` | 节点里改用 `AutoImageProcessor`(已修) |
| CPU 上跑 fp16 | NaN 或 warn | loader 强制 CPU=fp32(已修) |
| `image` tensor 是 fp16 | `(... * 255).astype(uint8)` 越界 | detect 里 `.float()` 兜底(已修) |
| `websocket` 包名错装 | `ModuleNotFoundError: websocket` | 装的是 `websocket-client`,不是 `websocket` |
| PuLID 节点目录在但不注册 | curl object_info 看不到 | 多半是 facexlib/onnxruntime-gpu 缺,装 requirements |
| `assert 'detection' in self.models` | InsightFace 启动失败 | antelopev2 路径错(必须双层 `models`)或文件不全(5 个 onnx 缺一不可) |
| 生成的脸不像源图 | 身份漂移 | 见 README 的 identity preservation tuning 章节 |
| 5 岁生成出"小型成人" | child 方向用了 adult 默认参数 | child 方向降 `--pulid-weight 0.75` |
---
## 8. CLI 自检 checklist(给新机器用)
```bash
SERVER=10.8.0.12:8188
# 0. 服务器在跑
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://$SERVER/
# 1. 节点都注册了
curl -s http://$SERVER/object_info | python3 -c "
import json, sys
d = json.load(sys.stdin)
for n in ['AgeDetectorLoader','AgeDetect','AgePromptBuilder','AgeRangeBatch',
'PulidFluxModelLoader','ApplyPulidFlux']:
print(f' {n}: {\"✓\" if n in d else \"✗\"}')"
# 2. 模型文件齐
for f in models/unet/flux1-dev-fp8.safetensors \
models/clip/clip_l.safetensors \
models/clip/t5xxl_fp8_scaled.safetensors \
models/vae/ae.safetensors \
models/pulid/pulid_flux_v0.9.1.safetensors \
models/insightface/models/antelopev2/scrfd_10g_bnkps.onnx; do
if [ -e "/mnt/modelall/ComfyUI/$f" ]; then echo " ✓ $f"; else echo " ✗ $f MISSING"; fi
done
# 3. 端到端检测
uv run --script cli/age_shift_cli.py test.jpg --detect-only --ip $SERVER
```
预期最终输出:
```
[1/3] Uploading test.jpg → 10.8.0.12:8188 ...
uploaded as: test.jpg
[2/3] Detecting age ...
prompt_id=...
→ Estimated age: 34 (adult) | top bin: 30-39 (0.62) | top-3: 30-39=0.62, ...
Done (detect only).
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 aadebuger
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.
+235
View File
@@ -0,0 +1,235 @@
# Prompt Reference
This document lists **every prompt** the `AgePromptBuilder` and `AgeRangeBatch`
nodes produce, so you can see what's going into Flux / SDXL and customize
where needed.
The core insight: **Flux responds far better to concrete physical descriptors
than to age numbers.** Compare:
| Naive | What AgeShift does |
|---|---|
| `a 70 year old man` | `a 70 year old man, late sixties, deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots, looser skin` |
| `a 5 year old child` | `a 5 year old male boy, young toddler, round face, big bright eyes, smooth soft skin, baby teeth, innocent expression` |
The numeric age is kept (for cases where the model has been trained on
"X year old" phrasing) but the concrete features carry most of the signal.
---
## How the prompt is assembled
`AgePromptBuilder` joins these parts in order:
```
positive = subject + features(age) + style + extra_positive
negative = negative_base + anti_age_drift(age)
```
Where:
- **subject**: `a {target_age} year old {gender }{subject_noun}` — e.g. `a 25 year old male man`
- **features(age)**: looked up from the table below
- **style**: user-controllable, defaults to `photorealistic portrait, sharp focus, natural studio lighting, neutral background`
- **extra_positive**: user-controllable, defaults to `same identity, same facial structure, same eye shape`
- **negative_base**: anti-cartoon/blur boilerplate
- **anti_age_drift**: contextual anti-prompts depending on the target band (see below)
---
## Age → feature mapping (full)
The internal `age_features(age: int) -> str` function picks one of 20 ranges:
| Age range | Band | Features |
|---|---|---|
| **0–2** | infant | infant, round soft chubby face, large innocent eyes, very smooth skin, tiny features, fine wispy hair |
| **3–5** | child | young toddler, round face, big bright eyes, smooth soft skin, baby teeth, innocent expression |
| **6–9** | child | young child, round youthful face, bright clear eyes, perfectly smooth skin, no facial hair, soft fine hair |
| **10–12** | child | pre-teen child, slightly more defined features, clear smooth skin, bright eyes, youthful softness, full thick hair |
| **13–15** | teenager | early teenager, transitional features, fresh smooth skin, occasional minor blemishes, youthful face shape, thick hair |
| **16–19** | teenager | late teenager, defined youthful features, smooth clear skin, full thick hair, sharp jawline beginning to form, no wrinkles |
| **20–24** | young adult | young adult in early twenties, smooth taut skin, fully developed features, full thick hair, vibrant complexion, no visible wrinkles |
| **25–29** | young adult | young adult in late twenties, smooth healthy skin, mature defined features, full hair, very faint laugh lines only when smiling |
| **30–34** | adult | adult in early thirties, slightly more mature skin, very subtle fine lines around eyes, full hair, healthy clear complexion |
| **35–39** | adult | adult in late thirties, mature features, subtle fine lines around eyes and forehead, slight nasolabial folds beginning, full hair perhaps thinning slightly |
| **40–44** | adult | early middle-aged adult, fine wrinkles around eyes and forehead, defined nasolabial folds, possibly slight graying at temples |
| **45–49** | middle-aged | middle-aged adult, visible fine wrinkles, deeper nasolabial folds, gray strands appearing in hair, slight skin texture changes |
| **50–54** | middle-aged | early fifties, clear wrinkles around eyes and mouth, salt-and-pepper hair, slight jowl formation, mature skin texture |
| **55–59** | middle-aged | late fifties, pronounced wrinkles, mostly graying hair, jowls forming, mature skin with some age spots beginning |
| **60–64** | senior | early sixties, deep wrinkles, predominantly gray hair possibly receding, sagging skin around jaw, visible age spots |
| **65–69** | senior | late sixties, deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots, looser skin |
| **70–74** | senior | early seventies, deeply wrinkled face, white or thin gray hair, pronounced sagging skin, age spots, hollowing around eyes |
| **75–79** | elderly | late seventies, very deep wrinkles all over face, thin white hair, significantly sagging skin, prominent age spots, frail features |
| **80–84** | elderly | early eighties, profoundly weathered face, sparse white hair, very loose sagging skin, hollow cheeks, frail elderly features |
| **85+** | elderly | very elderly, deeply weathered and wrinkled face, sparse thin white hair, extremely loose papery skin, hollow features, fragile appearance |
---
## Anti-drift negative prompts
When `include_negative_age_drift=True` (default), the builder appends contextual
anti-prompts based on the target band:
| Target band | Appended anti-prompts |
|---|---|
| young adult, adult, middle-aged, senior, elderly | `baby face, infant features, childish proportions` |
| middle-aged, senior, elderly | `teenage features, youthful smooth skin` |
| infant, child, teenager, young adult | `deep wrinkles, gray hair, age spots, sagging skin` |
| infant, child | `facial hair, beard, mustache, adult features` |
This stops Flux from regressing toward whatever age the source photo shows.
Without it, a source photo of a 40-year-old tends to leak adult features into
the "age 5" generation.
---
## Full example prompts at key ages
All examples below use `subject="man"`, `gender="male"`, and the default style.
### age = 5
**Positive**:
```
a 5 year old male man, young toddler, round face, big bright eyes, smooth soft skin, baby teeth, innocent expression, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape
```
**Negative**:
```
cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, deep wrinkles, gray hair, age spots, sagging skin, facial hair, beard, mustache, adult features
```
> 💡 For child generation, replace `--subject man` with `--subject boy` to avoid the awkward "male man" phrasing.
### age = 15
**Positive**:
```
a 15 year old male man, early teenager, transitional features, fresh smooth skin, occasional minor blemishes, youthful face shape, thick hair, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape
```
**Negative**:
```
cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, deep wrinkles, gray hair, age spots, sagging skin
```
### age = 25
**Positive**:
```
a 25 year old male man, young adult in late twenties, smooth healthy skin, mature defined features, full hair, very faint laugh lines only when smiling, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape
```
**Negative**:
```
cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, baby face, infant features, childish proportions, deep wrinkles, gray hair, age spots, sagging skin
```
### age = 45
**Positive**:
```
a 45 year old male man, middle-aged adult, visible fine wrinkles, deeper nasolabial folds, gray strands appearing in hair, slight skin texture changes, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape
```
**Negative**:
```
cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, baby face, infant features, childish proportions, teenage features, youthful smooth skin
```
### age = 65
**Positive**:
```
a 65 year old male man, late sixties, deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots, looser skin, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape
```
**Negative**:
```
cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, baby face, infant features, childish proportions, teenage features, youthful smooth skin
```
### age = 80
**Positive**:
```
a 80 year old male man, early eighties, profoundly weathered face, sparse white hair, very loose sagging skin, hollow cheeks, frail elderly features, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape
```
**Negative**:
```
cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, baby face, infant features, childish proportions, teenage features, youthful smooth skin
```
---
## AgeRangeBatch output (JSONL)
Calling `AgeRangeBatch` with `ages_csv="5,15,25,45,65"`,
`subject="man"`, `gender="male"` produces:
```jsonl
{"age": 5, "band": "child", "prompt": "a 5 year old male man, young toddler, round face, big bright eyes, smooth soft skin, baby teeth, innocent expression, photorealistic portrait, sharp focus, natural lighting, same identity"}
{"age": 15, "band": "teenager", "prompt": "a 15 year old male man, early teenager, transitional features, fresh smooth skin, occasional minor blemishes, youthful face shape, thick hair, photorealistic portrait, sharp focus, natural lighting, same identity"}
{"age": 25, "band": "young adult", "prompt": "a 25 year old male man, young adult in late twenties, smooth healthy skin, mature defined features, full hair, very faint laugh lines only when smiling, photorealistic portrait, sharp focus, natural lighting, same identity"}
{"age": 45, "band": "adult", "prompt": "a 45 year old male man, middle-aged adult, visible fine wrinkles, deeper nasolabial folds, gray strands appearing in hair, slight skin texture changes, photorealistic portrait, sharp focus, natural lighting, same identity"}
{"age": 65, "band": "senior", "prompt": "a 65 year old male man, late sixties, deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots, looser skin, photorealistic portrait, sharp focus, natural lighting, same identity"}
```
Consumed by the CLI (`age_shift_cli.py`) to loop one Flux generation per age.
---
## Customizing the prompts
### Per-call override (no code change)
The `AgePromptBuilder` node exposes these widgets, all overridable from the UI:
- `subject` — the noun. Default `person`. Set to `man` / `woman` / `boy` / `girl` for natural phrasing.
- `gender` — adds `male` / `female` / `non-binary` before the subject. Set to `unspecified` to skip.
- `style` — appended after features. Customize for cinematic / vintage / etc.
- `extra_positive` — identity-preserving anchors.
- `negative_base` — boilerplate negatives.
- `include_negative_age_drift` — toggle the contextual anti-prompts.
### Permanent override (edit code)
Edit `nodes/_types.py`, the `age_features()` function. Each `if`-branch
returns the feature string for that age window. Add new branches or rewrite
existing ones — no other code touches these strings.
### Custom subject nouns
When generating across a wide age range, no single subject noun fits all
ages (`man` is wrong for age 5, `boy` is wrong for age 50). The CLI keeps it
as one knob for simplicity; for production runs, call the node per-age with
the appropriate noun:
| Age | Suggested subject | Suggested gender |
|---|---|---|
| 0–12 | `boy` / `girl` | `male` / `female` (or `unspecified`) |
| 13–19 | `teenager` | `male` / `female` |
| 20–60 | `man` / `woman` | `male` / `female` |
| 60+ | `elderly man` / `elderly woman` | `male` / `female` |
---
## Why these specific features?
The features were chosen based on what Flux 1 dev actually responds to in
testing — not a generic dermatology textbook. Notably:
- **Nasolabial folds** are mentioned in the 35+ ranges because Flux reliably
draws them when prompted, and they're a strong age signal.
- **"Gray strands appearing in hair"** at 45–49 vs **"salt-and-pepper hair"** at
50–54 vs **"mostly graying hair"** at 55–59 — Flux responds to these
specific phrasings differently and produces visibly distinct outputs.
- **"Sparse white hair"** vs **"thin white hair"** — the former produces more
receded hairlines, the latter more even thinning.
- The **anti-drift negatives** are critical for child generation when the
source is an adult; without them, the model produces small adults with
child-sized bodies but adult facial proportions.
If you find a phrasing that consistently produces better results for a
specific age range, open a PR with before/after comparisons.
+393
View File
@@ -0,0 +1,393 @@
# ComfyUI-AgeShift
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
[![ComfyUI](https://img.shields.io/badge/ComfyUI-custom%20nodes-7e5cad.svg)](https://github.com/comfyanonymous/ComfyUI)
> **Detect a face's age, then generate identity-preserved portraits at any target age — from infant to 90+.**
ComfyUI nodes for face age detection (ViT classifier) and age-conditioned
prompt building. Pairs with [ComfyUI-PuLID-Flux](https://github.com/balazik/ComfyUI-PuLID-Flux)
(or [InstantID](https://github.com/cubiq/ComfyUI_InstantID)) to produce
identity-preserving age-shifted portraits.
```text
source face ──► AgeDetect ──► current age = 40
│
target age (e.g. 65) ──► AgePromptBuilder ──► positive + negative prompt
│
▼
PuLID-Flux + Flux 1 dev
│
▼
aged portrait (65 y/o)
```
---
## ✨ Features
- 🎂 **Continuous age estimation** — ViT classifier with weighted-mean
decoding over bin midpoints; produces smooth integer ages (`32`, `47`)
rather than snapping to bin centers.
- 🎂 **Concrete-feature prompts** — translates `target_age=65` into
`"deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots"`,
which Flux follows much more reliably than numeric age alone.
- 🎂 **Anti-drift negatives** — auto-injects contextual negatives so a 40-year-old
source doesn't leak adult features into the "age 5" generation.
- 🎂 **Identity-agnostic** — works with PuLID-Flux, InstantID, IPAdapter FaceID,
or any face-conditioning backbone. We don't reimplement ID injection; we just
drive the prompt side.
- 🎂 **Batch CLI** — generate an age progression sequence (5 → 15 → 25 → 45 → 65)
in one command from your laptop, talking to a remote ComfyUI server.
---
## 📦 Installation
### 1. Clone the node package
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/aadebuger/ComfyUI-AgeShift.git
cd ComfyUI-AgeShift
# in the ComfyUI venv:
source /path/to/ComfyUI/.venv/bin/activate
uv pip install -r requirements.txt
```
Dependencies are minimal (`transformers`, `pillow`, `numpy`) — almost
always already in a ComfyUI venv.
### 2. (Optional but recommended) Companion packages
The age-detect side works standalone. For identity-preserving generation
you also need PuLID-Flux + Flux:
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/balazik/ComfyUI-PuLID-Flux.git
cd ComfyUI-PuLID-Flux
uv pip install -r requirements.txt
```
| Asset | Where | Size | Notes |
|---|---|---|---|
| Flux 1 dev fp8 | `models/unet/flux1-dev-fp8.safetensors` | ~12 GB | from `Kijai/flux-fp8` (no license required) |
| CLIP-L | `models/clip/clip_l.safetensors` | ~250 MB | standard Flux |
| T5-XXL | `models/clip/t5xxl_fp8_*.safetensors` | ~5 GB | scaled or e4m3fn both work |
| VAE | `models/vae/ae.safetensors` | ~330 MB | standard Flux |
| PuLID-Flux weights | `models/pulid/pulid_flux_v0.9.1.safetensors` | ~1.1 GB | from `guozinan/PuLID` |
| InsightFace antelopev2 | `models/insightface/models/antelopev2/*.onnx` | ~360 MB | **manual placement required** — see below |
| EVA02-CLIP-L | auto-downloaded | ~430 MB | downloads on first run |
> ⚠️ **InsightFace `antelopev2` does NOT auto-download** (unlike the default
> `buffalo_l`). You must place the 5 ONNX files manually at
> `models/insightface/models/antelopev2/` (note the double `models` —
> that's how the InsightFace lib expects it). See
> [INSTALL_NOTES.md](INSTALL_NOTES.md) for one-liner downloads.
### 3. Restart ComfyUI
Search the node palette for `🎂` — four nodes should appear.
Quick verification from the CLI:
```bash
curl -s http://127.0.0.1:8188/object_info | python3 -c "
import json,sys; d = json.load(sys.stdin)
for n in ['AgeDetectorLoader','AgeDetect','AgePromptBuilder','AgeRangeBatch',
'PulidFluxModelLoader','ApplyPulidFlux']:
print(f' {n}:', '✓' if n in d else '✗')"
```
---
## 🚀 Quick start
### Detect only (UI)
Open `example_workflows/age_detect_only.json` in ComfyUI, drop a portrait
into the LoadImage node, queue. The summary `PreviewAny` shows:
```text
Estimated age: 40 (adult) | top bin: 30-39 (46.8%) | top-3: 30-39=0.47, 40-49=0.43, 50-59=0.05
```
### Full pipeline (UI)
Open `example_workflows/age_shift_pulid_flux.json`, set the `target_age`
on the AgePromptBuilder node, queue.
### CLI
```bash
# Detect only — runs in ~2 seconds on CPU
uv run --script cli/age_shift_cli.py face.jpg --detect-only --ip <server>
# Single target age
uv run --script cli/age_shift_cli.py face.jpg --target-age 65 --ip <server>
# Age progression sequence
uv run --script cli/age_shift_cli.py face.jpg \
--target-ages 5,15,25,45,65 \
--gender male --subject man \
--pulid-weight 0.95 --guidance 3.0 --steps 28 --seed 42 \
--flux-unet flux1-dev-fp8.safetensors \
--flux-t5 t5xxl_fp8_scaled.safetensors \
--ip <server> --out ./out
```
Output: `./out/age_005_*.png`, `./out/age_015_*.png`, ...
See [cli/README.md](cli/README.md) for the full argument list.
---
## 🧠 Node reference
### 🎂 Age Detector Loader
Loads a HF ViT age-classification model, with in-process cache.
| Input | Type | Default |
|---|---|---|
| `model_id` | STRING | `nateraw/vit-age-classifier` |
| `device` | enum | `auto` |
| `dtype` | enum | `fp32` |
| `force_reload` | BOOL | False |
Output: `AGE_DETECTOR`.
### 🎂 Age Detect
| Input | Type | Default |
|---|---|---|
| `detector` | AGE_DETECTOR | — |
| `image` | IMAGE | — |
| `method` | enum | `weighted_mean` |
Outputs: `age:INT`, `age_band:STRING`, `confidence:FLOAT`, `summary:STRING`.
> Note: this is whole-image classification. For non-portrait input, run a
> face-crop step upstream (e.g. via [ComfyUI-Crystools](https://github.com/crystian/ComfyUI-Crystools)
> or InsightFace).
### 🎂 Age Prompt Builder
| Input | Type | Default |
|---|---|---|
| `target_age` | INT | 30 |
| `subject` | STRING | `person` |
| `gender` | enum | `unspecified` |
| `style` | STRING | photorealistic portrait... |
| `extra_positive` | STRING | same identity... |
| `negative_base` | STRING | cartoon, illustration... |
| `include_negative_age_drift` | BOOL | True |
Outputs: `positive:STRING`, `negative:STRING`, `summary:STRING`, `target_age:INT`.
📖 **See [PROMPTS.md](PROMPTS.md) for the complete list of generated prompts
across all 20 age ranges + examples at age 5/15/25/45/65/80.**
### 🎂 Age Range Batch
CLI-oriented. Emits JSONL `{age, band, prompt}` for a comma-separated list
of target ages.
---
## 🎯 Identity preservation tuning
The default settings work for most cases. If the generated faces look like
*different people*, walk this checklist:
### 1. Lock the seed across ages
Different seeds = different sub-identities. Pass `--seed 42` (or any fixed
value) so all ages share the same noise schedule.
### 2. Bump PuLID weight
```bash
--pulid-weight 0.95 # default 0.9; up to 1.0
```
### 3. Lower the prompt guidance
```bash
--guidance 3.0 # default 3.5
```
The age prompt is a strong signal — lowering guidance leaves more room for
the identity to assert itself.
### 4. More steps
```bash
--steps 28 # default 20
```
PuLID continues injecting identity throughout sampling, so more steps =
more chances for ID to converge.
### 5. Crop the source to a clean portrait
PuLID extracts identity via InsightFace, which works best on a centered
frontal face. If the input has background / side angle / multiple faces,
crop first.
### 6. Strengthen identity anchors in the prompt
```bash
--extra-positive "exact same person, identical facial bone structure, same eye distance, same nose shape, same lip shape"
```
### Direction-specific tips
| Direction | Tip |
|---|---|
| **Aging up** (40 → 70) | Default settings work well. Optionally `--style "photorealistic portrait, soft natural lighting"` (avoid sharp focus, which over-emphasizes wrinkles) |
| **Aging down** (40 → 20) | Default settings work well. |
| **Going to child** (adult → 5–12) | Drop `--pulid-weight 0.75–0.80` — full strength preserves adult bone structure and produces unsettling "small adult" results. |
| **Going to infant** (adult → 0–3) | Hard. Consider face-landmark ControlNet weighted on upper face only, or accept that the result is "what this person's son/daughter might look like" rather than a literal regression. |
---
## 🐞 Troubleshooting
### `PulidFluxModelLoader not found`
PuLID-Flux either isn't installed or failed to import. Check:
```bash
grep -B2 -A8 -iE "pulid|IMPORT FAILED" ~/comfy_start.log | tail -40
```
Most often `facexlib` / `onnxruntime-gpu` / `insightface` is missing:
```bash
cd /path/to/ComfyUI/custom_nodes/ComfyUI-PuLID-Flux
uv pip install -r requirements.txt --no-cache-dir
```
Then restart ComfyUI.
### `assert 'detection' in self.models` (AssertionError from InsightFace)
The `antelopev2` model files aren't at the expected path. Required layout
(note the **double `models`** directory):
```text
/path/to/ComfyUI/models/insightface/models/antelopev2/
├── 1k3d68.onnx
├── 2d106det.onnx
├── genderage.onnx
├── glintr100.onnx
└── scrfd_10g_bnkps.onnx
```
Fix:
```bash
cd /path/to/ComfyUI/models/insightface
mkdir -p models && cd models
wget "https://github.com/deepinsight/insightface/releases/download/v0.7/antelopev2.zip"
unzip antelopev2.zip && rm antelopev2.zip
```
### `No module named 'websocket'`
The CLI needs `websocket-client` (NOT `websocket`, which is a separate
abandoned package):
```bash
uv pip install websocket-client
```
Or just use `uv run --script cli/age_shift_cli.py ...` — the script's
PEP 723 header declares deps, uv handles them automatically.
### Faces all look like different people
See the [identity preservation tuning](#-identity-preservation-tuning)
section. Most often: missing `--seed`, or `--pulid-weight` too low.
### "child" generation produces creepy mini-adults
PuLID weight is too high for child direction. Try `--pulid-weight 0.75`
for child-direction runs; keep it at 0.9–0.95 for aging-up runs.
### Hangs on first generation
PuLID's first run downloads EVA02-CLIP-L (~430 MB) and (if you skipped the
manual antelopev2 step) tries to set up InsightFace. Watch the ComfyUI server
log — once it prints `apply_pulid done`, generations become fast.
---
## 🧰 Project layout
```text
ComfyUI-AgeShift/
├── __init__.py # NODE_CLASS_MAPPINGS
├── nodes/
│ ├── _types.py # age → features mapping (the brain)
│ ├── detector_loader.py # _MODEL_CACHE pattern
│ ├── detect.py # ViT classifier execution
│ └── prompt_builder.py # AgePromptBuilder + AgeRangeBatch
├── cli/
│ ├── README.md
│ └── age_shift_cli.py # PEP 723 inline-deps script
├── example_workflows/
│ ├── age_detect_only.json # minimal UI workflow
│ └── age_shift_pulid_flux.json # full pipeline UI workflow
├── PROMPTS.md # complete prompt reference
├── INSTALL_NOTES.md # server-side install record
├── CHANGELOG.md
├── CONTRIBUTING.md
├── pyproject.toml
└── requirements.txt
```
---
## 🛣️ Roadmap
- [ ] Built-in face-crop node (so users don't need a separate package)
- [ ] CLI flag `--pulid-fusion {mean,concat}` and `--pulid-train-step` for advanced ID tuning
- [ ] InstantID + SDXL alternative workflow (for users without Flux)
- [ ] ControlNet face-landmark integration for stronger child-direction results
- [ ] Side-by-side grid output (concatenated PNG) for sequence runs
---
## 🤝 Contributing
PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). The contribution most
likely to help right now: testing different age-feature phrasings on Flux
and submitting improvements to `_types.py` with before/after comparison
images.
---
## 📜 License
MIT — see [LICENSE](LICENSE).
The age classifier (`nateraw/vit-age-classifier`) is MIT-licensed.
PuLID, Flux, and InsightFace weights have their own licenses — see those
projects for details.
---
## 🙏 Acknowledgments
- [PuLID](https://github.com/ToTheBeginning/PuLID) — Liu et al., the actual
identity-preservation magic.
- [ComfyUI-PuLID-Flux](https://github.com/balazik/ComfyUI-PuLID-Flux) — balazik,
the ComfyUI port we depend on.
- [nateraw/vit-age-classifier](https://huggingface.co/nateraw/vit-age-classifier) —
the age classifier we ship as default.
- [Flux](https://github.com/black-forest-labs/flux) — Black Forest Labs.
+22
View File
@@ -0,0 +1,22 @@
"""ComfyUI-AgeShift: face age detection + age-conditioned prompt building."""
from .nodes.detector_loader import AgeDetectorLoader
from .nodes.detect import AgeDetect
from .nodes.prompt_builder import AgePromptBuilder, AgeRangeBatch
NODE_CLASS_MAPPINGS = {
"AgeDetectorLoader": AgeDetectorLoader,
"AgeDetect": AgeDetect,
"AgePromptBuilder": AgePromptBuilder,
"AgeRangeBatch": AgeRangeBatch,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AgeDetectorLoader": "🎂 Age Detector Loader",
"AgeDetect": "🎂 Age Detect",
"AgePromptBuilder": "🎂 Age Prompt Builder",
"AgeRangeBatch": "🎂 Age Range Batch",
}
WEB_DIRECTORY = None
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+82
View File
@@ -0,0 +1,82 @@
# age_shift_cli.py
End-to-end CLI for ComfyUI-AgeShift. Talks to a running ComfyUI server,
uploads a face image, runs detection, and (optionally) drives PuLID-Flux to
generate age-shifted portraits.
## Quick start
```bash
# Detect age only
uv run python age_shift_cli.py face.jpg --detect-only --ip 10.8.0.12
# Single target age
uv run python age_shift_cli.py face.jpg --target-age 65 --ip 10.8.0.12
# Sequence
uv run python age_shift_cli.py face.jpg \
--target-ages 5,15,25,45,65 \
--gender male --out ./out --ip 10.8.0.12
# Debug — save generated workflow JSON(s) for inspection in UI
uv run python age_shift_cli.py face.jpg --target-age 65 \
--dump-workflow /tmp/agewf.json --ip 10.8.0.12
```
## Argparse reference
| Group | Flag | Default | Notes |
|---|---|---|---|
| **server** | `--ip` | `127.0.0.1` | ComfyUI host |
| | `--port` | `8188` | ComfyUI port |
| | `--server` | — | overrides `--ip:--port` |
| **mode** | `--detect-only` | False | skip generation |
| | `--target-age` | — | single target |
| | `--target-ages` | — | csv of targets, e.g. `5,15,25,45,65` |
| **subject** | `--subject` | `person` | injected into prompt |
| | `--gender` | `unspecified` | `male` / `female` / `non-binary` |
| | `--style` | `photorealistic ...` | base style prompt |
| | `--extra-positive` | `same identity, ...` | identity-preserving anchor |
| **gen** | `--width / --height` | 1024 | Flux native is 1024 |
| | `--steps` | 20 | Flux dev: 20–28 is fine |
| | `--cfg` | 1.0 | Flux uses CFG=1 + `--guidance` |
| | `--guidance` | 3.5 | Flux distillation guidance |
| | `--sampler / --scheduler` | `euler / simple` | |
| | `--seed` | 0 (random per age) | |
| **pulid** | `--pulid-weight` | 0.9 | identity strength |
| | `--pulid-start / --pulid-end` | 0.0 / 1.0 | active range |
| **models** | `--flux-unet` | `flux1-dev.safetensors` | filename on server |
| | `--flux-clip-l` | `clip_l.safetensors` | |
| | `--flux-t5` | `t5xxl_fp8_e4m3fn.safetensors` | |
| | `--flux-vae` | `ae.safetensors` | |
| | `--pulid-weights` | `pulid_flux_v0.9.1.safetensors` | |
| | `--insightface-provider` | `CUDA` | `CPU` / `ROCM` also valid |
| **detector** | `--detector-model` | `nateraw/vit-age-classifier` | HF ID or local path |
| | `--detector-device` | `auto` | |
| | `--detector-dtype` | `fp32` | |
| **debug** | `--out` | `./out` | output dir |
| | `--dump-workflow` | — | writes `*.detect.json` / `*.shift_ageN.json` |
| | `--timeout` | 600 | per-workflow seconds |
## Troubleshooting
**`/prompt 400 - node 'AgeDetectorLoader' not found`**
The node package isn't loaded on the server. Check
`http://server:8188/object_info | grep AgeDetect`.
**`/prompt 400 - node 'PulidFluxModelLoader' not found`**
Install `ComfyUI-PuLID-Flux` on the server.
**`Estimated age: 0`**
Detector hit a corrupt or all-black input. Confirm the source image renders
correctly: `curl http://server:8188/view?filename=face.jpg`.
**Hangs after `executing node sampler_node`**
PuLID weight too aggressive, or InsightFace download in progress. First run
of PuLID downloads ~600 MB of weights — watch the server log.
**Output looks like the source age, not the target**
- Bump `--guidance 4.5` (Flux follows prompt harder).
- Drop `--pulid-weight 0.75` (less identity dominance).
- Steps to 24–28.
- Make sure your style prompt isn't pulling toward "young model".
+543
View File
@@ -0,0 +1,543 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "websocket-client>=1.6",
# "requests>=2.31",
# "pillow>=10.0",
# ]
# ///
"""
age_shift_cli.py — detect current age and generate target-age portraits
via ComfyUI + ComfyUI-AgeShift + ComfyUI-PuLID-Flux.
Usage:
# Detect only
uv run python age_shift_cli.py face.jpg --detect-only --ip 10.8.0.12
# Single target age (e.g. age this person up to 65)
uv run python age_shift_cli.py face.jpg --target-age 65 --ip 10.8.0.12
# Age sequence (5/15/25/45/65)
uv run python age_shift_cli.py face.jpg \\
--target-ages 5,15,25,45,65 \\
--gender male --out ./out --ip 10.8.0.12
# Dump workflow JSONs for debugging
uv run python age_shift_cli.py face.jpg --target-age 65 \\
--dump-workflow /tmp/wf.json --ip 10.8.0.12
"""
import argparse
import json
import sys
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List
import requests
from websocket import create_connection
# =============================================================================
# ComfyClient
# =============================================================================
class ComfyClient:
def __init__(self, server: str = "127.0.0.1:8188"):
self.server = server
self.client_id = str(uuid.uuid4())
self.http = f"http://{server}"
self.ws = f"ws://{server}/ws?clientId={self.client_id}"
def upload_image(self, file_path: Path, overwrite: bool = True) -> str:
with open(file_path, "rb") as f:
files = {"image": (file_path.name, f, "application/octet-stream")}
data = {"overwrite": "true" if overwrite else "false", "type": "input"}
r = requests.post(f"{self.http}/upload/image", files=files, data=data, timeout=60)
r.raise_for_status()
return r.json().get("name", file_path.name)
def submit(self, workflow: dict) -> str:
payload = {"prompt": workflow, "client_id": self.client_id}
r = requests.post(f"{self.http}/prompt", json=payload, timeout=30)
if r.status_code != 200:
raise RuntimeError(f"/prompt {r.status_code}: {r.text[:1000]}")
return r.json()["prompt_id"]
def history(self, prompt_id: str) -> dict:
r = requests.get(f"{self.http}/history/{prompt_id}", timeout=30)
r.raise_for_status()
data = r.json()
return data.get(prompt_id, {})
def view(self, filename: str, subfolder: str = "", type_: str = "output") -> bytes:
params = {"filename": filename, "subfolder": subfolder, "type": type_}
r = requests.get(f"{self.http}/view", params=params, timeout=120)
r.raise_for_status()
return r.content
def run(self, workflow: dict, timeout_s: float = 600) -> dict:
ws = create_connection(self.ws, timeout=timeout_s)
try:
pid = self.submit(workflow)
print(f" prompt_id={pid}")
t0 = time.time()
last_node = None
while True:
if time.time() - t0 > timeout_s:
raise TimeoutError(f"Workflow timed out after {timeout_s}s")
msg = ws.recv()
if isinstance(msg, bytes):
continue
evt = json.loads(msg)
t = evt.get("type")
d = evt.get("data", {})
if d.get("prompt_id") and d["prompt_id"] != pid:
continue
if t == "progress":
cur, tot = d.get("value", 0), d.get("max", 1)
print(f"\r ▸ {cur:>4}/{tot} ", end="", flush=True)
elif t == "executing":
n = d.get("node")
if n is None:
print() # finalize line
break
if n != last_node:
print(f"\r ▸ node {str(n):<32}", end="", flush=True)
last_node = n
elif t == "execution_error":
raise RuntimeError(
f"Error in node {d.get('node_id')}: {d.get('exception_message')}"
)
return self.history(pid)
finally:
ws.close()
# =============================================================================
# Workflow builders (API format — node ids are strings)
# =============================================================================
def build_detect_workflow(
image_name: str,
*,
model_id: str = "nateraw/vit-age-classifier",
device: str = "auto",
dtype: str = "fp32",
) -> Dict[str, Any]:
return {
"load": {
"class_type": "LoadImage",
"inputs": {"image": image_name, "upload": "image"},
},
"loader": {
"class_type": "AgeDetectorLoader",
"inputs": {"model_id": model_id, "device": device, "dtype": dtype},
},
"detect": {
"class_type": "AgeDetect",
"inputs": {
"detector": ["loader", 0],
"image": ["load", 0],
"method": "weighted_mean",
},
},
"show_age": {
"class_type": "PreviewAny",
"inputs": {"source": ["detect", 0]},
},
"show_band": {
"class_type": "PreviewAny",
"inputs": {"source": ["detect", 1]},
},
"show_conf": {
"class_type": "PreviewAny",
"inputs": {"source": ["detect", 2]},
},
"show_summary": {
"class_type": "PreviewAny",
"inputs": {"source": ["detect", 3]},
},
}
def build_shift_workflow(
image_name: str,
target_age: int,
*,
# flux model file names (must be present in ComfyUI/models/*)
flux_unet: str,
flux_clip_l: str,
flux_t5: str,
flux_vae: str,
pulid_weights: str,
insightface_provider: str = "CUDA",
# generation knobs
width: int = 1024,
height: int = 1024,
steps: int = 20,
cfg: float = 1.0,
guidance: float = 3.5,
sampler: str = "euler",
scheduler: str = "simple",
seed: int = 0,
# pulid knobs
pulid_weight: float = 0.9,
pulid_start: float = 0.0,
pulid_end: float = 1.0,
# prompt knobs
subject: str = "person",
gender: str = "unspecified",
style: str = "photorealistic portrait, sharp focus, natural studio lighting",
extra_positive: str = "same identity, same facial structure, same eye shape",
negative_base: str = (
"cartoon, illustration, painting, anime, 3d render, "
"deformed, distorted, blurry, low quality, plastic skin, watermark, text"
),
) -> Dict[str, Any]:
"""Build a full PuLID-Flux + AgeShift workflow (API format).
Requires ComfyUI-PuLID-Flux installed alongside this package.
"""
return {
# Inputs
"load_face": {
"class_type": "LoadImage",
"inputs": {"image": image_name, "upload": "image"},
},
# Age-conditioned prompt
"prompt": {
"class_type": "AgePromptBuilder",
"inputs": {
"target_age": int(target_age),
"subject": subject,
"gender": gender,
"style": style,
"extra_positive": extra_positive,
"negative_base": negative_base,
"include_negative_age_drift": True,
},
},
# Flux backbone
"unet": {
"class_type": "UNETLoader",
"inputs": {"unet_name": flux_unet, "weight_dtype": "default"},
},
"dual_clip": {
"class_type": "DualCLIPLoader",
"inputs": {
"clip_name1": flux_clip_l,
"clip_name2": flux_t5,
"type": "flux",
},
},
"vae": {"class_type": "VAELoader", "inputs": {"vae_name": flux_vae}},
# PuLID-Flux identity injection
"pulid_model": {
"class_type": "PulidFluxModelLoader",
"inputs": {"pulid_file": pulid_weights},
},
"eva_clip": {"class_type": "PulidFluxEvaClipLoader", "inputs": {}},
"insightface": {
"class_type": "PulidFluxInsightFaceLoader",
"inputs": {"provider": insightface_provider},
},
"apply_pulid": {
"class_type": "ApplyPulidFlux",
"inputs": {
"model": ["unet", 0],
"pulid_flux": ["pulid_model", 0],
"eva_clip": ["eva_clip", 0],
"face_analysis": ["insightface", 0],
"image": ["load_face", 0],
"weight": pulid_weight,
"start_at": pulid_start,
"end_at": pulid_end,
"fusion": "mean",
"fusion_weight_max": 1.0,
"fusion_weight_min": 0.0,
"train_step": 1000,
"use_gray": True,
},
},
# Text → conditioning
"pos_encode": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": ["dual_clip", 0], "text": ["prompt", 0]},
},
"neg_encode": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": ["dual_clip", 0], "text": ["prompt", 1]},
},
"guidance_node": {
"class_type": "FluxGuidance",
"inputs": {"conditioning": ["pos_encode", 0], "guidance": guidance},
},
# Latent + sample
"latent": {
"class_type": "EmptySD3LatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"sampler_node": {
"class_type": "KSampler",
"inputs": {
"model": ["apply_pulid", 0],
"positive": ["guidance_node", 0],
"negative": ["neg_encode", 0],
"latent_image": ["latent", 0],
"seed": int(seed),
"steps": int(steps),
"cfg": float(cfg),
"sampler_name": sampler,
"scheduler": scheduler,
"denoise": 1.0,
},
},
"decode": {
"class_type": "VAEDecode",
"inputs": {"samples": ["sampler_node", 0], "vae": ["vae", 0]},
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": ["decode", 0],
"filename_prefix": f"ageshift_age{int(target_age):03d}",
},
},
"show_prompt_summary": {
"class_type": "PreviewAny",
"inputs": {"source": ["prompt", 2]},
},
}
# =============================================================================
# History helpers
# =============================================================================
def extract_text(history: dict, node_id: str) -> str:
outs = history.get("outputs", {})
if node_id not in outs:
return ""
no = outs[node_id]
if "text" in no:
v = no["text"]
return "\n".join(str(x) for x in v) if isinstance(v, list) else str(v)
parts = []
for v in no.values():
if isinstance(v, list):
parts.extend(str(x) for x in v)
return "\n".join(parts)
def extract_images(history: dict, node_id: str) -> List[Dict[str, str]]:
outs = history.get("outputs", {})
if node_id not in outs:
return []
return outs[node_id].get("images", [])
# =============================================================================
# Main
# =============================================================================
def main():
ap = argparse.ArgumentParser(
description="AgeShift: detect current age and generate target-age portraits "
"via ComfyUI + PuLID-Flux",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("image", type=Path, help="Source face image (jpg/png)")
# server
ap.add_argument("--ip", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8188)
ap.add_argument("--server", default=None, help="full host:port (overrides --ip --port)")
# mode
ap.add_argument("--detect-only", action="store_true", help="only run age detection")
ap.add_argument("--target-age", type=int, default=None, help="single target age to generate")
ap.add_argument(
"--target-ages",
default=None,
help="csv list of target ages, e.g. 5,15,25,45,65",
)
# generation knobs
ap.add_argument("--subject", default="person")
ap.add_argument(
"--gender",
default="unspecified",
choices=["unspecified", "male", "female", "non-binary"],
)
ap.add_argument(
"--style",
default="photorealistic portrait, sharp focus, natural studio lighting, neutral background",
)
ap.add_argument(
"--extra-positive",
default="same identity, same facial structure, same eye shape",
)
ap.add_argument("--width", type=int, default=1024)
ap.add_argument("--height", type=int, default=1024)
ap.add_argument("--steps", type=int, default=20)
ap.add_argument("--cfg", type=float, default=1.0)
ap.add_argument("--guidance", type=float, default=3.5)
ap.add_argument("--sampler", default="euler")
ap.add_argument("--scheduler", default="simple")
ap.add_argument("--seed", type=int, default=0, help="0 = random per age")
ap.add_argument("--pulid-weight", type=float, default=0.9)
ap.add_argument("--pulid-start", type=float, default=0.0)
ap.add_argument("--pulid-end", type=float, default=1.0)
# model files (must already exist on the ComfyUI server)
ap.add_argument("--flux-unet", default="flux1-dev.safetensors")
ap.add_argument("--flux-clip-l", default="clip_l.safetensors")
ap.add_argument("--flux-t5", default="t5xxl_fp8_e4m3fn.safetensors")
ap.add_argument("--flux-vae", default="ae.safetensors")
ap.add_argument("--pulid-weights", default="pulid_flux_v0.9.1.safetensors")
ap.add_argument(
"--insightface-provider",
default="CUDA",
choices=["CUDA", "CPU", "ROCM"],
)
# detector
ap.add_argument("--detector-model", default="nateraw/vit-age-classifier")
ap.add_argument(
"--detector-device",
default="auto",
choices=["auto", "cuda", "cuda:0", "cuda:1", "cpu"],
)
ap.add_argument("--detector-dtype", default="fp32", choices=["fp32", "fp16", "bf16"])
# output / debug
ap.add_argument("--out", type=Path, default=Path("./out"))
ap.add_argument(
"--dump-workflow",
type=Path,
default=None,
help="dump generated workflow JSON(s) for debugging",
)
ap.add_argument("--timeout", type=float, default=600.0)
args = ap.parse_args()
server = args.server or f"{args.ip}:{args.port}"
args.out.mkdir(parents=True, exist_ok=True)
if not args.image.exists():
print(f"ERROR: image not found: {args.image}", file=sys.stderr)
sys.exit(2)
client = ComfyClient(server)
# ---- upload ----
print(f"[1/3] Uploading {args.image.name} → {server} ...")
server_name = client.upload_image(args.image)
print(f" uploaded as: {server_name}")
# ---- detect ----
print(f"[2/3] Detecting age ...")
detect_wf = build_detect_workflow(
server_name,
model_id=args.detector_model,
device=args.detector_device,
dtype=args.detector_dtype,
)
if args.dump_workflow:
dump = args.dump_workflow.with_suffix(".detect.json")
dump.parent.mkdir(parents=True, exist_ok=True)
dump.write_text(json.dumps(detect_wf, indent=2, ensure_ascii=False))
print(f" wrote {dump}")
hist = client.run(detect_wf, timeout_s=args.timeout)
summary = extract_text(hist, "show_summary") or "(no summary)"
age_str = extract_text(hist, "show_age").strip()
try:
detected_age = int(float(age_str))
except (ValueError, TypeError):
detected_age = None
print(f" → {summary}")
if args.detect_only:
print("Done (detect only).")
return
# ---- shift ----
targets: List[int] = []
if args.target_age is not None:
targets.append(args.target_age)
if args.target_ages:
for s in args.target_ages.split(","):
s = s.strip()
if s:
try:
targets.append(int(s))
except ValueError:
print(f" ! skip invalid age: {s!r}", file=sys.stderr)
if not targets:
print(
"No --target-age / --target-ages given; nothing to generate.",
file=sys.stderr,
)
sys.exit(0)
# dedup, keep order
seen = set()
targets = [a for a in targets if not (a in seen or seen.add(a))]
print(f"[3/3] Generating {len(targets)} age-shifted image(s) ...")
for i, age in enumerate(targets, 1):
print(f" ({i}/{len(targets)}) target age = {age}")
seed = args.seed if args.seed else int(time.time() * 1000) % (2**31 - 1) + i
wf = build_shift_workflow(
server_name,
age,
flux_unet=args.flux_unet,
flux_clip_l=args.flux_clip_l,
flux_t5=args.flux_t5,
flux_vae=args.flux_vae,
pulid_weights=args.pulid_weights,
insightface_provider=args.insightface_provider,
width=args.width,
height=args.height,
steps=args.steps,
cfg=args.cfg,
guidance=args.guidance,
sampler=args.sampler,
scheduler=args.scheduler,
seed=seed,
pulid_weight=args.pulid_weight,
pulid_start=args.pulid_start,
pulid_end=args.pulid_end,
subject=args.subject,
gender=args.gender,
style=args.style,
extra_positive=args.extra_positive,
)
if args.dump_workflow:
dump = args.dump_workflow.with_suffix(f".shift_age{age}.json")
dump.write_text(json.dumps(wf, indent=2, ensure_ascii=False))
print(f" wrote {dump}")
try:
hist = client.run(wf, timeout_s=args.timeout)
except Exception as e:
print(f" ! failed: {e}", file=sys.stderr)
continue
prompt_summary = extract_text(hist, "show_prompt_summary").strip()
if prompt_summary:
print(f" prompt: {prompt_summary}")
imgs = extract_images(hist, "save")
if not imgs:
print(f" ! no output images for age {age}", file=sys.stderr)
continue
for img in imgs:
data = client.view(
img["filename"],
img.get("subfolder", ""),
img.get("type", "output"),
)
out_path = args.out / f"age_{age:03d}_{img['filename']}"
out_path.write_bytes(data)
print(f" → {out_path} ({len(data)//1024} KB)")
print("All done.")
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
{
"last_node_id": 5,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [40, 200],
"size": {"0": 315, "1": 314},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "IMAGE", "type": "IMAGE", "links": [1], "slot_index": 0},
{"name": "MASK", "type": "MASK", "links": null}
],
"properties": {"Node name for S&R": "LoadImage"},
"widgets_values": ["face.jpg", "image"]
},
{
"id": 2,
"type": "AgeDetectorLoader",
"pos": [40, 560],
"size": {"0": 315, "1": 130},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "detector", "type": "AGE_DETECTOR", "links": [2], "slot_index": 0}
],
"properties": {"Node name for S&R": "AgeDetectorLoader"},
"widgets_values": ["nateraw/vit-age-classifier", "auto", "fp32", false]
},
{
"id": 3,
"type": "AgeDetect",
"pos": [400, 200],
"size": {"0": 315, "1": 130},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{"name": "detector", "type": "AGE_DETECTOR", "link": 2},
{"name": "image", "type": "IMAGE", "link": 1}
],
"outputs": [
{"name": "age", "type": "INT", "links": [3], "slot_index": 0},
{"name": "age_band", "type": "STRING", "links": [4], "slot_index": 1},
{"name": "confidence", "type": "FLOAT", "links": null, "slot_index": 2},
{"name": "summary", "type": "STRING", "links": [5], "slot_index": 3}
],
"properties": {"Node name for S&R": "AgeDetect"},
"widgets_values": ["weighted_mean"]
},
{
"id": 4,
"type": "PreviewAny",
"pos": [760, 200],
"size": {"0": 320, "1": 76},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [{"name": "source", "type": "*", "link": 5}],
"outputs": [],
"properties": {"Node name for S&R": "PreviewAny"},
"title": "Summary",
"widgets_values": []
},
{
"id": 5,
"type": "PreviewAny",
"pos": [760, 320],
"size": {"0": 320, "1": 76},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [{"name": "source", "type": "*", "link": 3}],
"outputs": [],
"properties": {"Node name for S&R": "PreviewAny"},
"title": "Age (int)",
"widgets_values": []
},
{
"id": 6,
"type": "PreviewAny",
"pos": [760, 440],
"size": {"0": 320, "1": 76},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [{"name": "source", "type": "*", "link": 4}],
"outputs": [],
"properties": {"Node name for S&R": "PreviewAny"},
"title": "Age band",
"widgets_values": []
}
],
"links": [
[1, 1, 0, 3, 1, "IMAGE"],
[2, 2, 0, 3, 0, "AGE_DETECTOR"],
[3, 3, 0, 5, 0, "INT"],
[4, 3, 1, 6, 0, "STRING"],
[5, 3, 3, 4, 0, "STRING"]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}
+316
View File
@@ -0,0 +1,316 @@
{
"last_node_id": 20,
"last_link_id": 30,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [40, 40],
"size": {"0": 315, "1": 314},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "IMAGE", "type": "IMAGE", "links": [1], "slot_index": 0},
{"name": "MASK", "type": "MASK", "links": null}
],
"properties": {"Node name for S&R": "LoadImage"},
"title": "Source face",
"widgets_values": ["face.jpg", "image"]
},
{
"id": 2,
"type": "AgePromptBuilder",
"pos": [40, 400],
"size": {"0": 360, "1": 320},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "positive", "type": "STRING", "links": [2], "slot_index": 0},
{"name": "negative", "type": "STRING", "links": [3], "slot_index": 1},
{"name": "summary", "type": "STRING", "links": [4], "slot_index": 2},
{"name": "target_age", "type": "INT", "links": null, "slot_index": 3}
],
"properties": {"Node name for S&R": "AgePromptBuilder"},
"widgets_values": [
65,
"person",
"unspecified",
"photorealistic portrait, sharp focus, natural studio lighting, neutral background",
"same identity, same facial structure, same eye shape",
"cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, watermark, text",
true
]
},
{
"id": 3,
"type": "UNETLoader",
"pos": [440, 40],
"size": {"0": 315, "1": 82},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [{"name": "MODEL", "type": "MODEL", "links": [5], "slot_index": 0}],
"properties": {"Node name for S&R": "UNETLoader"},
"widgets_values": ["flux1-dev.safetensors", "default"]
},
{
"id": 4,
"type": "DualCLIPLoader",
"pos": [440, 160],
"size": {"0": 315, "1": 106},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [{"name": "CLIP", "type": "CLIP", "links": [6, 7], "slot_index": 0}],
"properties": {"Node name for S&R": "DualCLIPLoader"},
"widgets_values": ["clip_l.safetensors", "t5xxl_fp8_e4m3fn.safetensors", "flux"]
},
{
"id": 5,
"type": "VAELoader",
"pos": [440, 300],
"size": {"0": 315, "1": 58},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [{"name": "VAE", "type": "VAE", "links": [8], "slot_index": 0}],
"properties": {"Node name for S&R": "VAELoader"},
"widgets_values": ["ae.safetensors"]
},
{
"id": 6,
"type": "PulidFluxModelLoader",
"pos": [440, 400],
"size": {"0": 315, "1": 58},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [{"name": "PULIDFLUX", "type": "PULIDFLUX", "links": [9], "slot_index": 0}],
"properties": {"Node name for S&R": "PulidFluxModelLoader"},
"widgets_values": ["pulid_flux_v0.9.1.safetensors"]
},
{
"id": 7,
"type": "PulidFluxEvaClipLoader",
"pos": [440, 500],
"size": {"0": 315, "1": 26},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [{"name": "EVA_CLIP", "type": "EVA_CLIP", "links": [10], "slot_index": 0}],
"properties": {"Node name for S&R": "PulidFluxEvaClipLoader"},
"widgets_values": []
},
{
"id": 8,
"type": "PulidFluxInsightFaceLoader",
"pos": [440, 560],
"size": {"0": 315, "1": 58},
"flags": {},
"order": 7,
"mode": 0,
"inputs": [],
"outputs": [{"name": "FACEANALYSIS", "type": "FACEANALYSIS", "links": [11], "slot_index": 0}],
"properties": {"Node name for S&R": "PulidFluxInsightFaceLoader"},
"widgets_values": ["CUDA"]
},
{
"id": 9,
"type": "ApplyPulidFlux",
"pos": [800, 40],
"size": {"0": 315, "1": 290},
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 5},
{"name": "pulid_flux", "type": "PULIDFLUX", "link": 9},
{"name": "eva_clip", "type": "EVA_CLIP", "link": 10},
{"name": "face_analysis", "type": "FACEANALYSIS", "link": 11},
{"name": "image", "type": "IMAGE", "link": 1}
],
"outputs": [{"name": "MODEL", "type": "MODEL", "links": [12], "slot_index": 0}],
"properties": {"Node name for S&R": "ApplyPulidFlux"},
"widgets_values": [0.9, 0.0, 1.0, "mean", 1.0, 0.0, 1000, true]
},
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [800, 360],
"size": {"0": 315, "1": 96},
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 6},
{"name": "text", "type": "STRING", "link": 2, "widget": {"name": "text"}}
],
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [13], "slot_index": 0}],
"properties": {"Node name for S&R": "CLIPTextEncode"},
"title": "Positive (age-conditioned)",
"widgets_values": [""]
},
{
"id": 11,
"type": "CLIPTextEncode",
"pos": [800, 500],
"size": {"0": 315, "1": 96},
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 7},
{"name": "text", "type": "STRING", "link": 3, "widget": {"name": "text"}}
],
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [14], "slot_index": 0}],
"properties": {"Node name for S&R": "CLIPTextEncode"},
"title": "Negative",
"widgets_values": [""]
},
{
"id": 12,
"type": "FluxGuidance",
"pos": [1160, 360],
"size": {"0": 315, "1": 58},
"flags": {},
"order": 11,
"mode": 0,
"inputs": [{"name": "conditioning", "type": "CONDITIONING", "link": 13}],
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [15], "slot_index": 0}],
"properties": {"Node name for S&R": "FluxGuidance"},
"widgets_values": [3.5]
},
{
"id": 13,
"type": "EmptySD3LatentImage",
"pos": [1160, 460],
"size": {"0": 315, "1": 106},
"flags": {},
"order": 12,
"mode": 0,
"inputs": [],
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [16], "slot_index": 0}],
"properties": {"Node name for S&R": "EmptySD3LatentImage"},
"widgets_values": [1024, 1024, 1]
},
{
"id": 14,
"type": "KSampler",
"pos": [1520, 40],
"size": {"0": 315, "1": 262},
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 12},
{"name": "positive", "type": "CONDITIONING", "link": 15},
{"name": "negative", "type": "CONDITIONING", "link": 14},
{"name": "latent_image", "type": "LATENT", "link": 16}
],
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [17], "slot_index": 0}],
"properties": {"Node name for S&R": "KSampler"},
"widgets_values": [0, "randomize", 20, 1.0, "euler", "simple", 1.0]
},
{
"id": 15,
"type": "VAEDecode",
"pos": [1520, 340],
"size": {"0": 315, "1": 46},
"flags": {},
"order": 14,
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 17},
{"name": "vae", "type": "VAE", "link": 8}
],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [18], "slot_index": 0}],
"properties": {"Node name for S&R": "VAEDecode"},
"widgets_values": []
},
{
"id": 16,
"type": "SaveImage",
"pos": [1520, 420],
"size": {"0": 315, "1": 270},
"flags": {},
"order": 15,
"mode": 0,
"inputs": [{"name": "images", "type": "IMAGE", "link": 18}],
"outputs": [],
"properties": {},
"widgets_values": ["ageshift_age065"]
},
{
"id": 17,
"type": "PreviewAny",
"pos": [40, 760],
"size": {"0": 360, "1": 76},
"flags": {},
"order": 16,
"mode": 0,
"inputs": [{"name": "source", "type": "*", "link": 4}],
"outputs": [],
"properties": {"Node name for S&R": "PreviewAny"},
"title": "Prompt summary",
"widgets_values": []
}
],
"links": [
[1, 1, 0, 9, 4, "IMAGE"],
[2, 2, 0, 10, 1, "STRING"],
[3, 2, 1, 11, 1, "STRING"],
[4, 2, 2, 17, 0, "STRING"],
[5, 3, 0, 9, 0, "MODEL"],
[6, 4, 0, 10, 0, "CLIP"],
[7, 4, 0, 11, 0, "CLIP"],
[8, 5, 0, 15, 1, "VAE"],
[9, 6, 0, 9, 1, "PULIDFLUX"],
[10, 7, 0, 9, 2, "EVA_CLIP"],
[11, 8, 0, 9, 3, "FACEANALYSIS"],
[12, 9, 0, 14, 0, "MODEL"],
[13, 10, 0, 12, 0, "CONDITIONING"],
[14, 11, 0, 14, 2, "CONDITIONING"],
[15, 12, 0, 14, 1, "CONDITIONING"],
[16, 13, 0, 14, 3, "LATENT"],
[17, 14, 0, 15, 0, "LATENT"],
[18, 15, 0, 16, 0, "IMAGE"]
],
"groups": [
{
"title": "Inputs",
"bounding": [30, 0, 370, 740],
"color": "#3f789e",
"font_size": 24
},
{
"title": "Flux + PuLID loaders",
"bounding": [430, 0, 335, 640],
"color": "#a1309b",
"font_size": 24
},
{
"title": "Apply + condition",
"bounding": [790, 0, 335, 620],
"color": "#629930",
"font_size": 24
},
{
"title": "Sample → decode → save",
"bounding": [1510, 0, 335, 700],
"color": "#b58b2a",
"font_size": 24
}
],
"config": {},
"extra": {},
"version": 0.4
}
View File
+141
View File
@@ -0,0 +1,141 @@
"""Shared constants and helpers used by the AgeShift nodes."""
# nateraw/vit-age-classifier 标签 → 该区间的中位数(用于加权平均年龄)
AGE_BIN_MIDPOINTS = {
"0-2": 1.0,
"3-9": 6.0,
"10-19": 14.5,
"20-29": 24.5,
"30-39": 34.5,
"40-49": 44.5,
"50-59": 54.5,
"60-69": 64.5,
"more than 70": 75.0,
}
def age_band(age: int) -> str:
"""粗分年龄段标签。"""
if age < 3:
return "infant"
if age < 13:
return "child"
if age < 20:
return "teenager"
if age < 30:
return "young adult"
if age < 45:
return "adult"
if age < 60:
return "middle-aged"
if age < 75:
return "senior"
return "elderly"
def age_features(age: int) -> str:
"""Return a comma-separated string of physical features for a given age.
Concrete descriptors work much better than just numbers for Flux/SDXL —
e.g. 'deep nasolabial folds, crow's feet' beats 'a 60 year old'.
"""
if age < 3:
return (
"infant, round soft chubby face, large innocent eyes, very smooth skin, "
"tiny features, fine wispy hair"
)
elif age < 6:
return (
"young toddler, round face, big bright eyes, smooth soft skin, "
"baby teeth, innocent expression"
)
elif age < 10:
return (
"young child, round youthful face, bright clear eyes, perfectly smooth skin, "
"no facial hair, soft fine hair"
)
elif age < 13:
return (
"pre-teen child, slightly more defined features, clear smooth skin, "
"bright eyes, youthful softness, full thick hair"
)
elif age < 16:
return (
"early teenager, transitional features, fresh smooth skin, occasional minor blemishes, "
"youthful face shape, thick hair"
)
elif age < 20:
return (
"late teenager, defined youthful features, smooth clear skin, "
"full thick hair, sharp jawline beginning to form, no wrinkles"
)
elif age < 25:
return (
"young adult in early twenties, smooth taut skin, fully developed features, "
"full thick hair, vibrant complexion, no visible wrinkles"
)
elif age < 30:
return (
"young adult in late twenties, smooth healthy skin, mature defined features, "
"full hair, very faint laugh lines only when smiling"
)
elif age < 35:
return (
"adult in early thirties, slightly more mature skin, very subtle fine lines around eyes, "
"full hair, healthy clear complexion"
)
elif age < 40:
return (
"adult in late thirties, mature features, subtle fine lines around eyes and forehead, "
"slight nasolabial folds beginning, full hair perhaps thinning slightly"
)
elif age < 45:
return (
"early middle-aged adult, fine wrinkles around eyes and forehead, "
"defined nasolabial folds, possibly slight graying at temples"
)
elif age < 50:
return (
"middle-aged adult, visible fine wrinkles, deeper nasolabial folds, "
"gray strands appearing in hair, slight skin texture changes"
)
elif age < 55:
return (
"early fifties, clear wrinkles around eyes and mouth, salt-and-pepper hair, "
"slight jowl formation, mature skin texture"
)
elif age < 60:
return (
"late fifties, pronounced wrinkles, mostly graying hair, jowls forming, "
"mature skin with some age spots beginning"
)
elif age < 65:
return (
"early sixties, deep wrinkles, predominantly gray hair possibly receding, "
"sagging skin around jaw, visible age spots"
)
elif age < 70:
return (
"late sixties, deep facial wrinkles, gray or white hair often thinning, "
"sagging jowls, age spots, looser skin"
)
elif age < 75:
return (
"early seventies, deeply wrinkled face, white or thin gray hair, "
"pronounced sagging skin, age spots, hollowing around eyes"
)
elif age < 80:
return (
"late seventies, very deep wrinkles all over face, thin white hair, "
"significantly sagging skin, prominent age spots, frail features"
)
elif age < 85:
return (
"early eighties, profoundly weathered face, sparse white hair, "
"very loose sagging skin, hollow cheeks, frail elderly features"
)
else:
return (
"very elderly, deeply weathered and wrinkled face, sparse thin white hair, "
"extremely loose papery skin, hollow features, fragile appearance"
)
+82
View File
@@ -0,0 +1,82 @@
"""Run age classification on a ComfyUI IMAGE tensor."""
import numpy as np
from ._types import AGE_BIN_MIDPOINTS, age_band
class AgeDetect:
"""Estimate the age of the largest/dominant face in an image.
Note: this is whole-image classification — the input should be a portrait
cropped to the face. For arbitrary photos, run a face-detect crop step first
(e.g. via FaceCrop nodes or InsightFace).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"detector": ("AGE_DETECTOR",),
"image": ("IMAGE",),
},
"optional": {
"method": (["weighted_mean", "argmax_midpoint"], {"default": "weighted_mean"}),
},
}
RETURN_TYPES = ("INT", "STRING", "FLOAT", "STRING")
RETURN_NAMES = ("age", "age_band", "confidence", "summary")
FUNCTION = "detect"
CATEGORY = "🎂 AgeShift"
def detect(self, detector, image, method="weighted_mean"):
import torch
from PIL import Image
model = detector["model"]
processor = detector["processor"]
device = detector["device"]
id2label = detector["id2label"]
# ComfyUI IMAGE: torch tensor [B, H, W, 3] in 0..1
img = image[0]
if hasattr(img, "cpu"):
arr = (img.cpu().float().numpy() * 255.0).clip(0, 255).astype(np.uint8)
else:
arr = (np.asarray(img) * 255.0).clip(0, 255).astype(np.uint8)
pil = Image.fromarray(arr)
inputs = processor(images=pil, return_tensors="pt")
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
logits = model(**inputs).logits
probs = torch.softmax(logits.float(), dim=-1)[0].cpu().numpy()
if method == "weighted_mean":
total = 0.0
for idx, p in enumerate(probs):
label = id2label[idx]
mid = AGE_BIN_MIDPOINTS.get(label, 30.0)
total += float(p) * mid
estimated_age = int(round(total))
top_idx = int(np.argmax(probs))
confidence = float(probs[top_idx])
else: # argmax_midpoint
top_idx = int(np.argmax(probs))
label = id2label[top_idx]
estimated_age = int(round(AGE_BIN_MIDPOINTS.get(label, 30.0)))
confidence = float(probs[top_idx])
band = age_band(estimated_age)
top_label = id2label[int(np.argmax(probs))]
top3_idx = np.argsort(probs)[::-1][:3]
top3 = ", ".join(f"{id2label[i]}={probs[i]:.2f}" for i in top3_idx)
summary = (
f"Estimated age: {estimated_age} ({band}) | "
f"top bin: {top_label} ({confidence:.1%}) | "
f"top-3: {top3}"
)
return (estimated_age, band, confidence, summary)
+69
View File
@@ -0,0 +1,69 @@
"""Loader for the age-classification model (ViT)."""
_MODEL_CACHE = {}
class AgeDetectorLoader:
"""Load a HuggingFace age-classification model and processor.
Default model: nateraw/vit-age-classifier (~330MB, 9 age bins).
Other compatible HF image-classification models can be specified by ID.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_id": ("STRING", {"default": "nateraw/vit-age-classifier"}),
"device": (["auto", "cuda", "cuda:0", "cuda:1", "cpu"], {"default": "auto"}),
"dtype": (["fp32", "fp16", "bf16"], {"default": "fp32"}),
},
"optional": {
"force_reload": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("AGE_DETECTOR",)
RETURN_NAMES = ("detector",)
FUNCTION = "load"
CATEGORY = "🎂 AgeShift"
def load(self, model_id, device, dtype, force_reload=False):
cache_key = f"{model_id}|{device}|{dtype}"
if not force_reload and cache_key in _MODEL_CACHE:
return (_MODEL_CACHE[cache_key],)
# lazy imports — keep top-level light
import torch
from transformers import AutoImageProcessor, AutoModelForImageClassification
if device == "auto":
actual_device = "cuda" if torch.cuda.is_available() else "cpu"
else:
actual_device = device
dtype_map = {"fp32": torch.float32, "fp16": torch.float16, "bf16": torch.bfloat16}
torch_dtype = dtype_map[dtype]
if actual_device == "cpu":
# CPU 上 fp16/bf16 容易出问题,强制 fp32
torch_dtype = torch.float32
processor = AutoImageProcessor.from_pretrained(model_id)
model = AutoModelForImageClassification.from_pretrained(model_id, torch_dtype=torch_dtype)
model.to(actual_device)
model.eval()
bundle = {
"model": model,
"processor": processor,
"device": actual_device,
"dtype": dtype,
"id2label": model.config.id2label,
"model_id": model_id,
}
_MODEL_CACHE[cache_key] = bundle
return (bundle,)
@classmethod
def IS_CHANGED(cls, **kwargs):
return "|".join(f"{k}={v}" for k, v in sorted(kwargs.items()))
+167
View File
@@ -0,0 +1,167 @@
"""Build age-conditioned positive/negative prompts."""
import json
from ._types import age_band, age_features
class AgePromptBuilder:
"""Construct a (positive, negative) prompt pair for a target age.
Designed to feed CLIPTextEncode → KSampler in a Flux / SDXL workflow
combined with PuLID / InstantID for identity preservation.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"target_age": ("INT", {"default": 30, "min": 0, "max": 120, "step": 1}),
},
"optional": {
"subject": ("STRING", {"default": "person", "multiline": False}),
"gender": (
["unspecified", "male", "female", "non-binary"],
{"default": "unspecified"},
),
"style": (
"STRING",
{
"default": "photorealistic portrait, sharp focus, natural studio lighting, neutral background",
"multiline": True,
},
),
"extra_positive": (
"STRING",
{"default": "same identity, same facial structure, same eye shape", "multiline": True},
),
"negative_base": (
"STRING",
{
"default": (
"cartoon, illustration, painting, anime, 3d render, "
"deformed, distorted, blurry, low quality, plastic skin, "
"extra fingers, watermark, text"
),
"multiline": True,
},
),
"include_negative_age_drift": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "INT")
RETURN_NAMES = ("positive", "negative", "summary", "target_age")
FUNCTION = "build"
CATEGORY = "🎂 AgeShift"
def build(
self,
target_age,
subject="person",
gender="unspecified",
style="photorealistic portrait",
extra_positive="",
negative_base="",
include_negative_age_drift=True,
):
band = age_band(target_age)
features = age_features(target_age)
gender_part = "" if gender == "unspecified" else f"{gender} "
subject_str = f"a {target_age} year old {gender_part}{subject}"
positive_parts = [subject_str, features]
if style.strip():
positive_parts.append(style.strip())
if extra_positive.strip():
positive_parts.append(extra_positive.strip())
positive = ", ".join(positive_parts)
neg_parts = []
if negative_base.strip():
neg_parts.append(negative_base.strip())
if include_negative_age_drift:
anti = []
if band in ("young adult", "adult", "middle-aged", "senior", "elderly"):
anti.append("baby face, infant features, childish proportions")
if band in ("middle-aged", "senior", "elderly"):
anti.append("teenage features, youthful smooth skin")
if band in ("infant", "child", "teenager", "young adult"):
anti.append("deep wrinkles, gray hair, age spots, sagging skin")
if band in ("infant", "child"):
anti.append("facial hair, beard, mustache, adult features")
if anti:
neg_parts.append(", ".join(anti))
negative = ", ".join(neg_parts)
summary = f"target={target_age} ({band}) | pos_chars={len(positive)} | neg_chars={len(negative)}"
return (positive, negative, summary, int(target_age))
class AgeRangeBatch:
"""Emit a JSONL list of (age, band, prompt) tuples for a batch CLI workflow.
This node is mostly useful for the CLI / batch tooling — it doesn't drive a
KSampler directly, since ComfyUI evaluates one prompt per submission. The
CLI consumes this output to loop over ages.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"ages_csv": ("STRING", {"default": "5,15,25,45,65", "multiline": False}),
},
"optional": {
"subject": ("STRING", {"default": "person"}),
"gender": (
["unspecified", "male", "female", "non-binary"],
{"default": "unspecified"},
),
"style": (
"STRING",
{"default": "photorealistic portrait, sharp focus, natural lighting", "multiline": True},
),
"extra_positive": ("STRING", {"default": "same identity", "multiline": True}),
},
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("prompts_jsonl", "ages_csv", "count")
FUNCTION = "build"
CATEGORY = "🎂 AgeShift"
def build(
self,
ages_csv,
subject="person",
gender="unspecified",
style="photorealistic portrait",
extra_positive="same identity",
):
ages = []
for s in ages_csv.split(","):
s = s.strip()
if not s:
continue
try:
ages.append(int(s))
except ValueError:
continue
gender_part = "" if gender == "unspecified" else f"{gender} "
lines = []
for a in ages:
features = age_features(a)
band = age_band(a)
prompt = (
f"a {a} year old {gender_part}{subject}, {features}, "
f"{style}, {extra_positive}"
)
lines.append(
json.dumps(
{"age": a, "band": band, "prompt": prompt},
ensure_ascii=False,
)
)
return ("\n".join(lines), ",".join(str(a) for a in ages), len(ages))
+36
View File
@@ -0,0 +1,36 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "comfyui-ageshift"
version = "0.1.1"
description = "ComfyUI nodes for face age detection and age-conditioned prompt building"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "aadebuger" }]
keywords = ["comfyui", "age-detection", "age-progression", "face", "pulid", "flux"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"transformers>=4.40.0",
"pillow>=10.0.0",
"numpy>=1.24",
]
[project.urls]
Homepage = "https://github.com/aadebuger/ComfyUI-AgeShift"
Repository = "https://github.com/aadebuger/ComfyUI-AgeShift"
Issues = "https://github.com/aadebuger/ComfyUI-AgeShift/issues"
[tool.setuptools]
packages = ["nodes"]
+3
View File
@@ -0,0 +1,3 @@
transformers>=4.40.0
pillow>=10.0.0
numpy>=1.24