Initial commit: 25 scripts Python sauvegardés
Date: 2026-08-28 11:43:34 EDT
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
---
|
||||
name: kanban-orchestrator
|
||||
description: Decomposition playbook + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill is the deeper playbook when you're specifically playing the orchestrator role.
|
||||
version: 3.0.0
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [kanban, multi-agent, orchestration, routing]
|
||||
related_skills: [kanban-worker]
|
||||
---
|
||||
|
||||
# Kanban Orchestrator — Decomposition Playbook
|
||||
|
||||
> The **core worker lifecycle** (including the `kanban_create` fan-out pattern and the "decompose, don't execute" rule) is auto-injected into every kanban process via the `KANBAN_GUIDANCE` system-prompt block. This skill is the deeper playbook when you're an orchestrator profile whose whole job is routing.
|
||||
|
||||
## Profiles are user-configured — not a fixed roster
|
||||
|
||||
Hermes setups vary widely. Some users run a single profile that does everything; some run a small fleet (`docker-worker`, `cron-worker`); some run a curated specialist team they've named themselves. There is **no default specialist roster** — the orchestrator skill does not know what profiles exist on this machine.
|
||||
|
||||
Before fanning out, you must ground the decomposition in the profiles that actually exist. The dispatcher silently fails to spawn unknown assignee names — it doesn't autocorrect, doesn't suggest, doesn't fall back. So a card assigned to `researcher` on a setup that only has `docker-worker` just sits in `ready` forever.
|
||||
|
||||
**Step 0: discover available profiles before planning.**
|
||||
|
||||
Use one of these:
|
||||
|
||||
- `hermes profile list` — prints the table of profiles configured on this machine. Run it through your terminal tool if you have one; otherwise ask the user.
|
||||
- `kanban_list(assignee="<some-name>")` — sanity-check a single name. Returns an empty list (rather than an error) for an unknown assignee, so this only confirms a name you're already considering.
|
||||
- **Just ask the user.** "What profiles do you have set up?" is a fine first turn when the goal needs more than one specialist.
|
||||
|
||||
Cache the result in your working memory for the rest of the conversation. Re-asking every turn wastes a tool call.
|
||||
|
||||
## When to use the board (vs. just doing the work)
|
||||
|
||||
Create Kanban tasks when any of these are true:
|
||||
|
||||
1. **Multiple specialists are needed.** Research + analysis + writing is three profiles.
|
||||
2. **The work should survive a crash or restart.** Long-running, recurring, or important.
|
||||
3. **The user might want to interject.** Human-in-the-loop at any step.
|
||||
4. **Multiple subtasks can run in parallel.** Fan-out for speed.
|
||||
5. **Review / iteration is expected.** A reviewer profile loops on drafter output.
|
||||
6. **The audit trail matters.** Board rows persist in SQLite forever.
|
||||
|
||||
If *none* of those apply — it's a small one-shot reasoning task — use `delegate_task` instead or answer the user directly.
|
||||
|
||||
## The anti-temptation rules
|
||||
|
||||
Your job description says "route, don't execute." The rules that enforce that:
|
||||
|
||||
- **Do not execute the work yourself.** Your restricted toolset usually doesn't even include terminal/file/code/web for implementation. If you find yourself "just fixing this quickly" — stop and create a task for the right specialist.
|
||||
- **For any concrete task, create a Kanban task and assign it.** Every single time.
|
||||
- **Split multi-lane requests before creating cards.** A user prompt can contain several independent workstreams. Extract those lanes first, then create one card per lane instead of bundling unrelated work into a single implementer card.
|
||||
- **Run independent lanes in parallel.** If two cards do not need each other's output, leave them unlinked so the dispatcher can fan them out. Link only true data dependencies.
|
||||
- **Never create dependent work as independent ready cards.** If a card must wait for another card, pass `parents=[...]` in the original `kanban_create` call. Do not create it first and link it later, and do not rely on prose like "wait for T1" inside the body.
|
||||
- **If no specialist fits the available profiles, ask the user which profile to create or which existing profile to use.** Do not invent profile names; the dispatcher will silently drop unknown assignees.
|
||||
- **Decompose, route, and summarize — that's the whole job.**
|
||||
|
||||
## Decomposition playbook
|
||||
|
||||
### Step 1 — Understand the goal
|
||||
|
||||
Ask clarifying questions if the goal is ambiguous. Cheap to ask; expensive to spawn the wrong fleet.
|
||||
|
||||
### Step 2 — Sketch the task graph
|
||||
|
||||
Before creating anything, draft the graph out loud (in your response to the user). Treat every concrete workstream as a candidate card:
|
||||
|
||||
1. Extract the lanes from the request.
|
||||
2. Map each lane to one of the profiles you discovered in Step 0. If a lane doesn't fit any existing profile, ask the user which to use or create.
|
||||
3. Decide whether each lane is independent or gated by another lane.
|
||||
4. Create independent lanes as parallel cards with no parent links.
|
||||
5. Create synthesis/review/integration cards with parent links to the lanes they depend on. A child created with unfinished parents starts in `todo`; the dispatcher promotes it to `ready` only after every parent is done.
|
||||
|
||||
Examples of prompts that should fan out (using placeholder profile names — substitute whatever exists on the user's setup):
|
||||
|
||||
- "Build an app" → one card to a design-oriented profile for product/UI direction, one or two cards to engineering profiles for implementation, plus a later integration/review card if the user has a reviewer profile.
|
||||
- "Fix blockers and check model variants" → one implementation card for the blocker fixes plus one discovery/research card for config/source verification. A final reviewer card can depend on both.
|
||||
- "Research docs and implement" → a docs-research card can run in parallel with a codebase-discovery card; implementation waits only if it truly needs those findings.
|
||||
- "Analyze this screenshot and find the related code" → one card to a vision-capable profile for the visual analysis while another searches the codebase.
|
||||
|
||||
Words like "also," "finally," or "and" do not automatically imply a dependency. They often mean "make sure this is covered before reporting back." Only link tasks when one card cannot start until another card's output exists.
|
||||
|
||||
Show the graph to the user before creating cards. Let them correct it — including which actual profile name should own each lane.
|
||||
|
||||
### Step 3 — Create tasks and link
|
||||
|
||||
Use the profile names from Step 0. The example below uses placeholders `<profile-A>`, `<profile-B>`, `<profile-C>` — replace them with what the user actually has.
|
||||
|
||||
```python
|
||||
t1 = kanban_create(
|
||||
title="research: Postgres cost vs current",
|
||||
assignee="<profile-A>", # whichever profile handles research on this setup
|
||||
body="Compare estimated infrastructure costs, migration costs, and ongoing ops costs over a 3-year window. Sources: AWS/GCP pricing, team time estimates, current Postgres bills from peers.",
|
||||
tenant=os.environ.get("HERMES_TENANT"),
|
||||
)["task_id"]
|
||||
|
||||
t2 = kanban_create(
|
||||
title="research: Postgres performance vs current",
|
||||
assignee="<profile-A>", # same profile, run in parallel
|
||||
body="Compare query latency, throughput, and scaling characteristics at our expected data volume (~500GB, 10k QPS peak). Sources: benchmark papers, public case studies, pgbench results if easy.",
|
||||
)["task_id"]
|
||||
|
||||
t3 = kanban_create(
|
||||
title="synthesize migration recommendation",
|
||||
assignee="<profile-B>", # whichever profile does synthesis/analysis
|
||||
body="Read the findings from T1 (cost) and T2 (performance). Produce a 1-page recommendation with explicit trade-offs and a go/no-go call.",
|
||||
parents=[t1, t2],
|
||||
)["task_id"]
|
||||
|
||||
t4 = kanban_create(
|
||||
title="draft decision memo",
|
||||
assignee="<profile-C>", # whichever profile drafts user-facing prose
|
||||
body="Turn the analyst's recommendation into a 2-page memo for the CTO. Match the tone of previous decision memos in the team's knowledge base.",
|
||||
parents=[t3],
|
||||
)["task_id"]
|
||||
```
|
||||
|
||||
`parents=[...]` gates promotion — children stay in `todo` until every parent reaches `done`, then auto-promote to `ready`. No manual coordination needed; the dispatcher and dependency engine handle it.
|
||||
|
||||
If the task graph has dependencies, create the parent cards first, capture their returned ids, and include those ids in the child card's `parents` list during the child `kanban_create` call. Avoid creating all cards in parallel and linking them afterward; that creates a window where the dispatcher can claim a child before its inputs exist.
|
||||
|
||||
### Step 4 — Complete your own task
|
||||
|
||||
If you were spawned as a task yourself (e.g. a planner profile was assigned `T0: "investigate Postgres migration"`), mark it done with a summary of what you created:
|
||||
|
||||
```python
|
||||
kanban_complete(
|
||||
summary="decomposed into T1-T4: 2 research lanes in parallel, 1 synthesis on their outputs, 1 prose draft on the recommendation",
|
||||
metadata={
|
||||
"task_graph": {
|
||||
"T1": {"assignee": "<profile-A>", "parents": []},
|
||||
"T2": {"assignee": "<profile-A>", "parents": []},
|
||||
"T3": {"assignee": "<profile-B>", "parents": ["T1", "T2"]},
|
||||
"T4": {"assignee": "<profile-C>", "parents": ["T3"]},
|
||||
},
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Step 5 — Report back to the user
|
||||
|
||||
Tell them what you created in plain prose, naming the actual profiles you used:
|
||||
|
||||
> I've queued 4 tasks:
|
||||
> - **T1** (`<profile-A>`): cost comparison
|
||||
> - **T2** (`<profile-A>`): performance comparison, in parallel with T1
|
||||
> - **T3** (`<profile-B>`): synthesizes T1 + T2 into a recommendation
|
||||
> - **T4** (`<profile-C>`): turns T3 into a CTO memo
|
||||
>
|
||||
> The dispatcher will pick up T1 and T2 now. T3 starts when both finish. You'll get a gateway ping when T4 completes. Use the dashboard or `hermes kanban tail <id>` to follow along.
|
||||
|
||||
## Common patterns
|
||||
|
||||
**Fan-out + fan-in (research → synthesize):** N research-style cards with no parents, one synthesis card with all of them as parents.
|
||||
|
||||
**Parallel implementation + validation:** one implementer card makes the change while one explorer/researcher card verifies config, docs, or source mapping. A reviewer card can depend on both. Do not make the implementer own unrelated verification just because the user mentioned both in one sentence.
|
||||
|
||||
**Pipeline with gates:** `planner → implementer → reviewer`. Each stage's `parents=[previous_task]`. Reviewer blocks or completes; if reviewer blocks, the operator unblocks with feedback and respawns.
|
||||
|
||||
**Same-profile queue:** N tasks, all assigned to the same profile, no dependencies between them. Dispatcher serializes — that profile processes them in priority order, accumulating experience in its own memory.
|
||||
|
||||
**Human-in-the-loop:** Any task can `kanban_block()` to wait for input. Dispatcher respawns after `/unblock`. The comment thread carries the full context.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
**Inventing profile names that don't exist.** The dispatcher silently fails to spawn unknown assignees — the card just sits in `ready` forever. Always assign to a profile from your Step 0 discovery; ask the user if you're unsure.
|
||||
|
||||
**Unassigned tasks (assignee is None).** Same symptom as above — the dispatcher only picks up tasks with a valid assignee matching an active profile. A task created without `assignee=`, or one whose assignee was cleared to `None`, will never leave `ready`. Always verify with `kanban_show` or a direct DB query (`SELECT assignee FROM tasks WHERE id=?`) that the task actually has a non-null assignee. See `references/cli-recovery.md` for the fix recipe.
|
||||
|
||||
**CLI `kanban assign` can fail silently.** Outside the hermes-agent venv, `python3 -m hermes_cli.kanban assign <id> <profile>` may return exit 0 with no output and no effect — the module import fails but the error is swallowed. Prefer using `execute_code` with a direct `kanban_db.assign_task()` call, setting `HERMES_KANBAN_DB` explicitly. See `references/cli-recovery.md` for the canonical snippet.
|
||||
|
||||
**Bundling independent lanes into one card.** If the user asks for two independent outcomes, create two cards. Example: "fix blockers and check model variants" is not one fixer task; create a fixer/engineer card for the fixes and an explorer/researcher card for the variant check, then optionally gate review on both.
|
||||
|
||||
**Over-linking because of wording.** "Finally check X" may still be parallel with implementation if X is static config, docs, or source discovery. Link it after implementation only when the check depends on the implementation result.
|
||||
|
||||
**Forgetting dependency links.** If the task graph says `research -> implement -> review`, do not create all tasks as independent ready cards. Use parent links so implement/review cannot run before their inputs exist.
|
||||
|
||||
**Reassignment vs. new task.** If a reviewer blocks with "needs changes," create a NEW task linked from the reviewer's task — don't re-run the same task with a stern look. The new task is assigned to the original implementer profile.
|
||||
|
||||
**Argument order for links.** `kanban_link(parent_id=..., child_id=...)` — parent first. Mixing them up demotes the wrong task to `todo`.
|
||||
|
||||
**Don't pre-create the whole graph if the shape depends on intermediate findings.** If T3's structure depends on what T1 and T2 find, let T3 exist as a "synthesize findings" task whose own first step is to read parent handoffs and plan the rest. Orchestrators can spawn orchestrators.
|
||||
|
||||
**Tenant inheritance.** If `HERMES_TENANT` is set in your env, pass `tenant=os.environ.get("HERMES_TENANT")` on every `kanban_create` call so child tasks stay in the same namespace.
|
||||
|
||||
## Recovering stuck workers
|
||||
|
||||
When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions:
|
||||
|
||||
1. **Reclaim** (or `hermes kanban reclaim <task_id>`) — abort the running worker immediately and reset the task to `ready`. The existing claim TTL is ~15 min; this is the fast path out.
|
||||
2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile (one that exists on this setup) and let the dispatcher pick it up with a fresh worker.
|
||||
3. **Change profile model** — the dashboard prints a copy-paste hint for `hermes -p <profile> model` since profile config lives on disk; edit it in a terminal, then Reclaim to retry with the new model.
|
||||
|
||||
Hallucination warnings appear on tasks where a worker's `kanban_complete(created_cards=[...])` claim included card ids that don't exist or weren't created by the worker's profile (the gate blocks the completion), or where the free-form summary references `t_<hex>` ids that don't resolve (advisory prose scan, non-blocking). Both produce audit events that persist even after recovery actions — the trail stays for debugging.
|
||||
@@ -0,0 +1,326 @@
|
||||
# Instructions for *ACL Proceedings
|
||||
|
||||
The following instructions are for authors of papers submitted for review to ACL conferences (hereafter, "review version") or paper accepted for publication in its proceedings (hereafter, "final version").
|
||||
All authors are required to adhere to these specifications.
|
||||
|
||||
## Style Files
|
||||
|
||||
*ACL provides style files for LaTeX and Microsoft Word that meet these requirements. They can be found at:
|
||||
|
||||
> https://acl-org.github.io/ACLPUB/
|
||||
|
||||
We strongly recommend the use of these style files, which have been appropriately tailored for the *ACL proceedings.
|
||||
|
||||
## Paper Length
|
||||
|
||||
The conference accepts submissions of long papers and short papers.
|
||||
Review versions of long papers may have up to eight (8) pages of content plus unlimited pages for references.
|
||||
Upon acceptance, final versions of long papers will be given one additional page -- up to nine (9) pages of content plus unlimited pages for acknowledgements and references -- so that reviewers' comments can be taken into account.
|
||||
Review versions of short papers may have up to four (4) pages of content, plus unlimited pages for references.
|
||||
Final versions of short papers may have up to five (5) pages, plus unlimited pages for acknowledgements and references.
|
||||
For both long and short papers, all figures and tables that are part of the main text must fit within these page limits.
|
||||
|
||||
The conference encourages submission of appendices and supplementary material, which are not required to fit within these page limits. However, review versions of papers must be self-contained: it is optional for reviewers to look at appendices or supplementary material. Please see [Appendices](#Appendices) and [Supplementary](#Supplementary Material) for more information.
|
||||
|
||||
Review versions should not refer, for further detail, to documents, code or data resources that are not available to the reviewers.
|
||||
|
||||
Papers that do not conform to these requirements may be rejected without review.
|
||||
|
||||
Workshop chairs may have different rules for allowed length and whether appendices or supplementary materials are welcome.
|
||||
As always, the respective call for papers is the authoritative source.
|
||||
|
||||
## Anonymity
|
||||
|
||||
As reviewing will be double-blind, review versions must not include any identifying information about the authors (such as names, affiliations, or URLs).
|
||||
Self-references that reveal the author's identity, e.g.,
|
||||
|
||||
> We previously showed (Gusfield, 1997)...
|
||||
|
||||
must be avoided, and anonymous citations, e.g.,
|
||||
|
||||
> We previously showed (Anonymous, 1997)...
|
||||
|
||||
should also be avoided. Instead, use citations such as
|
||||
|
||||
> Gusfield (1997) previously showed...
|
||||
|
||||
Review versions must not include acknowledgements.
|
||||
|
||||
**Papers that do not conform to these requirements may be rejected without review.**
|
||||
|
||||
Any preliminary non-archival versions of submitted papers should be listed in the submission form but not in the review version of the paper.
|
||||
Reviewers are generally aware that authors may present preliminary versions of their work in other venues, but will not be provided the list of previous presentations from the submission form.
|
||||
|
||||
Once a paper has been accepted to the conference, the final version should include the author's names and affiliations, and is allowed to use self-references.
|
||||
|
||||
## Multiple Submission
|
||||
|
||||
Papers that have been or will be submitted to other meetings or publications must indicate this at submission time in the START submission form, and must be withdrawn from the other venues if accepted by *ACL.
|
||||
Authors of papers accepted for presentation at *ACL must notify the program chairs by the deadline for final versions ("camera-ready deadline") whether the paper will be presented.
|
||||
We will not accept for publication or presentation any papers that overlap significantly in content or results with papers that will be (or have been) published elsewhere.
|
||||
|
||||
Authors submitting more than one paper to *ACL must ensure that submissions do not overlap significantly (>25%) with each other in content or results.
|
||||
|
||||
## Formatting Instructions
|
||||
|
||||
### File Format
|
||||
|
||||
Papers must be in Adobe Portable Document Format (PDF).
|
||||
Please make sure that your PDF file embeds all necessary fonts (especially for tree diagrams, symbols, and Asian languages).
|
||||
When you print or create the PDF file, there is usually an option in your printer setup to include none, all or just non-standard fonts.
|
||||
Please make sure that you select the option of including *all* the fonts.
|
||||
**Before sending it, test your PDF by printing it from a computer different from the one where it was created.**
|
||||
|
||||
Some word processors may generate very large PDF files, where each page is rendered as an image.
|
||||
Such images may reproduce poorly.
|
||||
In this case, try alternative ways to obtain the PDF.
|
||||
|
||||
All papers must use **A4 paper format** (21 cm x 29.7 cm).
|
||||
Papers must not be submitted with any other paper size.
|
||||
|
||||
If you cannot meet the above requirements, please contact the publication chairs as soon as possible.
|
||||
|
||||
### Layout
|
||||
|
||||
All text except for page numbers must fit within the margins.
|
||||
|
||||
Review versions should have page numbers, centered in the bottom margin, but **pages should not be numbered in the final version.**
|
||||
|
||||
Manuscripts must be set in two columns.
|
||||
Exceptions to the two-column format include the title, authors' names and complete addresses, which must be centered at the top of the first page, and any full-width figures or tables.
|
||||
|
||||
The exact dimensions for a page on A4 paper are:
|
||||
|
||||
* Left margin: 2.5 cm
|
||||
* Right margin: 2.5 cm
|
||||
* Top margin: 2.5 cm
|
||||
* Bottom margin: 2.5 cm
|
||||
* Column width: 7.7 cm
|
||||
* Column height: 24.7 cm
|
||||
* Gap between columns: 0.6 cm
|
||||
|
||||
In the review version, a ruler (line numbers in the left and right margins of the article) should be printed, so that reviewers may comment on particular lines in the paper.
|
||||
The ruler should not change the appearance of any other content on the page.
|
||||
The final version should not contain a ruler.
|
||||
|
||||
### Fonts
|
||||
|
||||
All text (except non-Latin scripts and mathematical formulas) should be set in **Times Roman**.
|
||||
If Times Roman is unavailable, you may use **Times New Roman** or **Computer Modern Roman.**
|
||||
|
||||
The following table specifies what font sizes and styles must be used for each type of text in the manuscript.
|
||||
|
||||
| Type of Text | Font Size | Style |
|
||||
| --------------------- | --------- | ----- |
|
||||
| paper title | 15 pt | bold |
|
||||
| author names | 12 pt | bold |
|
||||
| author affiliation | 12 pt | |
|
||||
| the word ``Abstract'' | 12 pt | bold |
|
||||
| section titles | 12 pt | bold |
|
||||
| subsection titles | 11 pt | bold |
|
||||
| document text | 11 pt | |
|
||||
| captions | 10 pt | |
|
||||
| abstract text | 10 pt | |
|
||||
| bibliography | 10 pt | |
|
||||
| footnotes | 9 pt | |
|
||||
|
||||
### Title and Authors
|
||||
|
||||
Center the title, author's name(s) and affiliation(s) across both columns.
|
||||
|
||||
Place the title centered at the top of the first page, in 15-point bold.
|
||||
Long titles should be typed on two lines without a blank line intervening.
|
||||
Put the title 2.5 cm from the top of the page.
|
||||
Write the title in [title case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case); do not write the title in all capital letters, except for acronyms (e.g., "BLEU") or proper nouns ("English") that are normally uppercased or capitalized.
|
||||
|
||||
Place the author name(s) and affiliation(s) under the title.
|
||||
Write authors' full names; do not abbreviate given names to initials, unless they are normally written as initials ("Margaret Mitchell", not "M. Mitchell").
|
||||
Do not format surnames in all capitals ("Mitchell", not "MITCHELL").
|
||||
|
||||
Do not use footnotes for affiliations.
|
||||
The affiliation should contain the author's complete address, and if possible, an electronic mail address.
|
||||
|
||||
The title, author names and addresses should be completely identical to those entered to the paper submission website in order to maintain the consistency of author information among all publications of the conference.
|
||||
If they are different, the publication chairs may resolve the difference without consulting with you; so it is in your own interest to double-check that the information is consistent.
|
||||
|
||||
Start the body of the first page 7.5 cm from the top of the page.
|
||||
**Even in the review version of the paper, you should maintain space for names and addresses so that they will fit in the final version.**
|
||||
|
||||
### Abstract
|
||||
|
||||
Type the abstract at the beginning of the first column.
|
||||
Center the word **Abstract** in 12 point bold above the body of the abstract.
|
||||
The width of the abstract should be smaller than the
|
||||
normal column width by 0.6 cm on each side.
|
||||
The abstract text should be 10 point roman, single-spaced.
|
||||
|
||||
The abstract should be a concise summary of the general thesis and conclusions of the paper.
|
||||
It should be no longer than 200 words.
|
||||
|
||||
### Text
|
||||
|
||||
Begin typing the main body of the text immediately after the abstract, continuing in two columns.
|
||||
The text should be 11 point roman, single-spaced.
|
||||
|
||||
Indent 0.4 cm when starting a new paragraph, except for the first paragraph in a section.
|
||||
|
||||
### Sections
|
||||
|
||||
Use numbered sections (Arabic numerals) to facilitate cross references.
|
||||
Number subsections with the section number and the subsection number separated by a dot, in Arabic numerals, e.g.,
|
||||
|
||||
> 1 Introduction
|
||||
|
||||
or
|
||||
|
||||
> 6.1 File Format
|
||||
|
||||
### Footnotes
|
||||
Put footnotes at the bottom of the page and use 9 point font.
|
||||
They may be numbered or referred to by asterisks or other symbols.
|
||||
Footnotes should be separated from the text by a line.
|
||||
|
||||
### Figures and tables
|
||||
|
||||
Place figures and tables in the paper near where they are first discussed, rather than at the end, if possible.
|
||||
Wide figures/tables may run across both columns.
|
||||
|
||||
To accommodate people who are color-blind (as well as those printing with black-and-white printers), grayscale readability is strongly encouraged.
|
||||
Color is not forbidden, but authors should ensure that tables and figures do not rely solely on color to convey critical distinctions.
|
||||
|
||||
**Captions:**
|
||||
Provide a caption for every figure/table; number each one sequentially in the form:
|
||||
|
||||
> Figure 1: Caption of the Figure.
|
||||
|
||||
and
|
||||
|
||||
> Table 1: Caption of the Table.
|
||||
|
||||
Captions should be placed below figures/tables, in 10 point roman type.
|
||||
Captions that are one line are centered.
|
||||
Captions longer than one line are left-aligned.
|
||||
|
||||
### Hyperlinks
|
||||
|
||||
Within-document and external hyperlinks should be dark blue (hex #000099), not underlined or boxed.
|
||||
|
||||
### Non-English Text
|
||||
|
||||
Text in languages other than English should be accompanied by translations into English, and text in scripts other than Latin should \emph{also} be accompanied by transliterations into Latin script, since not all readers can recognize non-Latin characters easily.
|
||||
|
||||
For example, παράδειγμα *paradeigma* ‘example’ is a Greek word, and this is a Greek sentence:
|
||||
|
||||
> Αυτό είναι ένα παράδειγμα.
|
||||
> auto einai ena paradeigma.
|
||||
> ‘This is an example.’
|
||||
|
||||
### Citations
|
||||
|
||||
Citations within the text appear in parentheses (Gusfield, 1997), or, if the author's name appears in the text itself: Gusfield (1997).
|
||||
Append lowercase letters to the year in cases of ambiguities.
|
||||
Cite papers with two authors using both authors' names (Aho and Ullman, 1972), but cite papers with more than two authors by the first author's name and ``et al.'' (Chandra et al., 1981).
|
||||
Collapse multiple citations into a single pair of parentheses (Gusfield, 1997; Aho and Ullman, 1972).
|
||||
|
||||
Refrain from using full citations as sentence constituents.
|
||||
Instead of
|
||||
|
||||
> (Gusfield, 1997) showed that ...
|
||||
> In (Gusfield, 1997), ...''
|
||||
|
||||
write
|
||||
|
||||
> Gusfield (1997) showed that ...
|
||||
> In Gusfield (1997), ...
|
||||
|
||||
Submissions should accurately reference prior and related work, including code and data.
|
||||
If a piece of prior work appeared in multiple venues, the version that appeared in a refereed, archival venue should be referenced.
|
||||
If multiple versions of a piece of prior work exist, the one used by the authors should be referenced.
|
||||
|
||||
### Acknowledgments
|
||||
|
||||
The acknowledgments should go immediately before the references.
|
||||
Do not number the acknowledgments section.
|
||||
Do not include this section in the review version.
|
||||
|
||||
### References
|
||||
|
||||
Gather the full set of references together under the unnumbered section heading **References**.
|
||||
Place the References section before any Appendices.
|
||||
Arrange the references alphabetically by first author, rather than by order of occurrence in the text.
|
||||
|
||||
Provide as complete a citation as possible, using a consistent format, such as the [one for Computational Linguistics](http://cljournal.org/style_guide_refs.html) or the one in the [Publication Manual of the American Psychological Association](https://apastyle.apa.org/products/publication-manual-7th-edition).
|
||||
Use full names for authors, not just initials.
|
||||
Authors should not rely on automated citation indices to provide accurate references for prior and related work.
|
||||
|
||||
As part of our work to make ACL materials more widely used and cited outside of our discipline, ACL has registered as a CrossRef member, as a registrant of Digital Object Identifiers (DOIs), the standard for registering permanent URNs for referencing scholarly materials.
|
||||
|
||||
All references are required to contain DOIs of all cited works when possible, or, as a second resort, links to ACL Anthology pages.
|
||||
Appropriate records should be found for most materials in the current [ACL Anthology](https://aclweb.org/anthology/).
|
||||
|
||||
Example article in a journal:
|
||||
|
||||
> Rie Kubota Ando and Tong Zhang. 2005. [A framework for learning predictive structures from multiple tasks and unlabeled data](https://www.jmlr.org/papers/v6/ando05a.html). *Journal of Machine Learning Research*, 6:1817–1853.
|
||||
|
||||
Example paper in non-ACL proceedings, with DOI:
|
||||
|
||||
> Galen Andrew and Jianfeng Gao. 2007. [Scalable training of L1-regularized log-linear models](https://doi.org/10.1145/1273496.1273501). In *Proceedings of the 24th International Conference on Machine Learning*, pages 33–40.
|
||||
|
||||
Example ACL Anthology paper with DOI:
|
||||
|
||||
> James Goodman, Andreas Vlachos, and Jason Naradowsky. 2016. [Noise reduction and targeted exploration in imitation learning for Abstract Meaning Representation parsing](http://dx.doi.org/10.18653/v1/P16-1001). In *Proceedings of the 54th Annual Meeting of the Association for Computational Linguistics (Volume 1: Long Papers)*, pages 1–45711, Berlin, Germany. Association for Computational Linguistics.
|
||||
|
||||
Example ACL Anthology paper without DOI:
|
||||
|
||||
> Benjamin Börschinger and Mark Johnson. 2011. [A particle filter algorithm for Bayesian word segmentation](https://www.aclweb.org/anthology/U11-1004/). In *Proceedings of the Australasian Language Technology Association Workshop 2011*, pages 10–44718, Canberra, Australia.
|
||||
|
||||
Example arXiv paper:
|
||||
|
||||
> Mohammad Sadegh Rasooli and Joel R. Tetreault. 2015. [Yara parser: A fast and accurate dependency parser](http://arxiv.org/abs/1503.06733). *Computing Research Repository*, arXiv:1503.06733. Version 2.
|
||||
|
||||
## Appendices
|
||||
|
||||
Appendices are material that can be read, and include lemmas, formulas, proofs, and tables that are not critical to the reading and understanding of the paper.
|
||||
Letter them in sequence and provide an informative title:
|
||||
|
||||
> Appendix A. Title of Appendix
|
||||
|
||||
The appendices come after the references.
|
||||
|
||||
Review versions of appendices must follow the same anonymity guidelines as the main paper.
|
||||
|
||||
## Supplementary Material
|
||||
|
||||
Submissions may include non-readable supplementary material used in the work and described in the paper.
|
||||
Any accompanying software and/or data should include licenses and documentation of research review as appropriate.
|
||||
Supplementary material may report preprocessing decisions, model parameters, and other details necessary for the replication of the experiments reported in the paper.
|
||||
Seemingly small preprocessing decisions can sometimes make a large difference in performance, so it is crucial to record such decisions to precisely characterize state-of-the-art methods.
|
||||
|
||||
Nonetheless, supplementary material should be supplementary (rather than central) to the paper.
|
||||
**Submissions that misuse the supplementary material may be rejected without review.**
|
||||
Supplementary material may include explanations or details of proofs or derivations that do not fit into the paper, lists of features or feature templates, sample inputs and outputs for a system, pseudo-code or source code, and data.
|
||||
(Source code and data should be separate uploads, rather than part of the paper).
|
||||
|
||||
The paper should not rely on the supplementary material: while the paper may refer to and cite the supplementary material and the supplementary material will be available to the reviewers, they will not be asked to review the supplementary material.
|
||||
|
||||
Review versions of supplementary material must follow the same anonymity guidelines as the main paper.
|
||||
|
||||
## Credits
|
||||
|
||||
This document has been adapted from the instructions for earlier ACL and NAACL proceedings, including those for
|
||||
ACL 2020 by Steven Bethard, Ryan Cotterell and Rui Yan,
|
||||
ACL 2019 by Douwe Kiela and Ivan Ivan Vulić,
|
||||
NAACL 2019 by Stephanie Lukin and Alla Roskovskaya,
|
||||
ACL 2018 by Shay Cohen, Kevin Gimpel, and Wei Lu,
|
||||
NAACL 2018 by Margaret Mitchell and Stephanie Lukin,
|
||||
BibTeX suggestions for (NA)ACL 2017/2018 from Jason Eisner,
|
||||
ACL 2017 by Dan Gildea and Min-Yen Kan,
|
||||
NAACL 2017 by Margaret Mitchell,
|
||||
ACL 2012 by Maggie Li and Michael White,
|
||||
ACL 2010 by Jing-Shin Chang and Philipp Koehn,
|
||||
ACL 2008 by Johanna D. Moore, Simone Teufel, James Allan, and Sadaoki Furui,
|
||||
ACL 2005 by Hwee Tou Ng and Kemal Oflazer,
|
||||
ACL 2002 by Eugene Charniak and Dekang Lin,
|
||||
and earlier ACL and EACL formats written by several people, including
|
||||
John Chen, Henry S. Thompson and Donald Walker.
|
||||
Additional elements were taken from the formatting instructions of the *International Joint Conference on Artificial Intelligence* and the *Conference on Computer Vision and Pattern Recognition*.
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
check_deps.py — Verify a ComfyUI workflow's dependencies (custom nodes, models,
|
||||
embeddings) against a running server.
|
||||
|
||||
Improvements over v1:
|
||||
- Cloud-aware endpoint mapping (handles `/api/experiment/models/{folder}` and
|
||||
`/api/object_info` variants verified against live cloud API)
|
||||
- Distinguishes 200-empty (genuinely no models in folder) vs 404
|
||||
(folder doesn't exist) vs 403 (auth/tier issue) — no silent passes
|
||||
- Outputs concrete remediation commands (e.g. `comfy node install <name>`)
|
||||
when nodes are missing
|
||||
- Detects embedding references inside prompt strings as model deps
|
||||
- Skips check on cloud free tier `/api/object_info` (403) without false alarm
|
||||
- Accepts API key from CLI flag OR $COMFY_CLOUD_API_KEY env var
|
||||
|
||||
Usage:
|
||||
python3 check_deps.py workflow_api.json
|
||||
python3 check_deps.py workflow_api.json --host 127.0.0.1 --port 8188
|
||||
python3 check_deps.py workflow_api.json --host https://cloud.comfy.org
|
||||
|
||||
Stdlib-only. Python 3.10+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from _common import ( # noqa: E402
|
||||
DEFAULT_LOCAL_HOST, ENV_API_KEY,
|
||||
emit_json, folder_aliases_for, http_get, is_cloud_host,
|
||||
iter_embedding_refs, iter_model_deps, iter_nodes, parse_model_list,
|
||||
resolve_api_key, resolve_url, unwrap_workflow,
|
||||
)
|
||||
|
||||
|
||||
# Known node → custom-node-package map. When a workflow needs a node we don't
|
||||
# recognize, suggesting the right `comfy node install ...` makes the difference
|
||||
# between a working agent and a stuck one.
|
||||
NODE_TO_PACKAGE: dict[str, str] = {
|
||||
# rgthree (Reroute is JS-only and doesn't appear in /object_info)
|
||||
"Power Lora Loader (rgthree)": "rgthree-comfy",
|
||||
"Image Comparer (rgthree)": "rgthree-comfy",
|
||||
"Seed (rgthree)": "rgthree-comfy",
|
||||
"Display Any (rgthree)": "rgthree-comfy",
|
||||
"Display Int (rgthree)": "rgthree-comfy",
|
||||
# Impact pack
|
||||
"FaceDetailer": "comfyui-impact-pack",
|
||||
"DetailerForEach": "comfyui-impact-pack",
|
||||
"BboxDetectorSEGS": "comfyui-impact-pack",
|
||||
"SAMLoader": "comfyui-impact-pack",
|
||||
"ImpactWildcardProcessor": "comfyui-impact-pack",
|
||||
# Impact subpack (separate package)
|
||||
"UltralyticsDetectorProvider": "comfyui-impact-subpack",
|
||||
# Was Node Suite
|
||||
"Image Save": "was-node-suite-comfyui",
|
||||
"Number Counter": "was-node-suite-comfyui",
|
||||
"Text String": "was-node-suite-comfyui",
|
||||
# easy-use
|
||||
"easy fullLoader": "comfyui-easy-use",
|
||||
"easy positive": "comfyui-easy-use",
|
||||
"easy negative": "comfyui-easy-use",
|
||||
"easy seed": "comfyui-easy-use",
|
||||
"easy imageSave": "comfyui-easy-use",
|
||||
# Video Helper Suite
|
||||
"VHS_VideoCombine": "comfyui-videohelpersuite",
|
||||
"VHS_LoadVideo": "comfyui-videohelpersuite",
|
||||
"VHS_LoadAudio": "comfyui-videohelpersuite",
|
||||
# AnimateDiff
|
||||
"ADE_AnimateDiffLoaderWithContext": "comfyui-animatediff-evolved",
|
||||
"ADE_AnimateDiffLoaderGen1": "comfyui-animatediff-evolved",
|
||||
"ADE_LoadAnimateDiffModel": "comfyui-animatediff-evolved",
|
||||
# ControlNet aux preprocessors (full class names)
|
||||
"CannyEdgePreprocessor": "comfyui_controlnet_aux",
|
||||
"DWPreprocessor": "comfyui_controlnet_aux",
|
||||
"OpenposePreprocessor": "comfyui_controlnet_aux",
|
||||
"DepthAnythingPreprocessor": "comfyui_controlnet_aux",
|
||||
"Zoe_DepthAnythingPreprocessor": "comfyui_controlnet_aux",
|
||||
"AnimalPosePreprocessor": "comfyui_controlnet_aux",
|
||||
# IPAdapter Plus
|
||||
"IPAdapterAdvanced": "comfyui_ipadapter_plus",
|
||||
"IPAdapterUnifiedLoader": "comfyui_ipadapter_plus",
|
||||
"IPAdapterModelLoader": "comfyui_ipadapter_plus",
|
||||
"IPAdapterInsightFaceLoader": "comfyui_ipadapter_plus",
|
||||
# InstantID
|
||||
"InstantIDModelLoader": "comfyui_instantid",
|
||||
"ApplyInstantID": "comfyui_instantid",
|
||||
# Comfy essentials (note: registry slug uses underscore, not hyphen)
|
||||
"GetImageSize+": "comfyui_essentials",
|
||||
"ImageBatchMultiple+": "comfyui_essentials",
|
||||
# pysssss
|
||||
"ShowText|pysssss": "comfyui-custom-scripts",
|
||||
"PreviewImage|pysssss": "comfyui-custom-scripts",
|
||||
# SUPIR
|
||||
"SUPIR_Upscale": "comfyui-supir",
|
||||
"SUPIR_first_stage": "comfyui-supir",
|
||||
# GGUF (case-sensitive registry slug)
|
||||
"UNETLoaderGGUF": "ComfyUI-GGUF",
|
||||
"DualCLIPLoaderGGUF": "ComfyUI-GGUF",
|
||||
# Florence2
|
||||
"Florence2Run": "comfyui-florence2",
|
||||
# WAS
|
||||
"Image Filter Adjustments": "was-node-suite-comfyui",
|
||||
# Photomaker (case-sensitive)
|
||||
"PhotoMakerLoader": "ComfyUI-PhotoMaker-Plus",
|
||||
# Wan video (case-sensitive)
|
||||
"WanVideoSampler": "ComfyUI-WanVideoWrapper",
|
||||
"WanVideoModelLoader": "ComfyUI-WanVideoWrapper",
|
||||
}
|
||||
|
||||
# Nodes whose package isn't on the comfy registry — need git-URL install via
|
||||
# ComfyUI-Manager. We surface a helpful hint instead of an unrunnable command.
|
||||
NODE_TO_GIT_URL: dict[str, str] = {
|
||||
"HunyuanVideoSampler": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper",
|
||||
"HunyuanVideoModelLoader": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper",
|
||||
}
|
||||
|
||||
|
||||
def fetch_object_info(url: str, headers: dict) -> tuple[set[str] | None, dict | None]:
|
||||
"""Returns (installed_node_set, error_info). Error info is a dict if we
|
||||
couldn't query (e.g. cloud free tier), else None.
|
||||
"""
|
||||
r = http_get(url, headers=headers, retries=2, timeout=30)
|
||||
if r.status == 200:
|
||||
try:
|
||||
data = r.json()
|
||||
if isinstance(data, dict):
|
||||
return set(data.keys()), None
|
||||
except Exception:
|
||||
pass
|
||||
return None, {"http_status": 200, "reason": "non-dict response"}
|
||||
if r.status == 403:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {"raw": r.text()[:200]}
|
||||
return None, {"http_status": 403, "reason": "forbidden", "body": body}
|
||||
if r.status == 404:
|
||||
return None, {"http_status": 404, "reason": "endpoint not found"}
|
||||
return None, {"http_status": r.status, "reason": "unexpected", "body": r.text()[:200]}
|
||||
|
||||
|
||||
def _fetch_one_folder(
|
||||
base: str, folder: str, headers: dict, *, is_cloud: bool,
|
||||
) -> tuple[set[str] | None, dict | None]:
|
||||
"""Single-folder fetch, no aliasing. Returns (installed_set, error_info)."""
|
||||
url = resolve_url(base, f"/models/{folder}", is_cloud=is_cloud)
|
||||
r = http_get(url, headers=headers, retries=2, timeout=30)
|
||||
if r.status == 200:
|
||||
try:
|
||||
return parse_model_list(r.json()), None
|
||||
except Exception:
|
||||
return set(), {"http_status": 200, "reason": "non-list response"}
|
||||
if r.status == 404:
|
||||
body_text = r.text()
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {"raw": body_text[:200]}
|
||||
code = body.get("code") if isinstance(body, dict) else None
|
||||
if code == "folder_not_found":
|
||||
# Folder is genuinely empty/missing on server — not the same as
|
||||
# "endpoint missing". Return empty set with informational error.
|
||||
return set(), {"http_status": 404, "reason": "folder_empty_or_unknown", "body": body}
|
||||
return None, {"http_status": 404, "reason": "endpoint not found", "body": body}
|
||||
if r.status == 403:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
return None, {"http_status": 403, "reason": "forbidden", "body": body}
|
||||
return None, {"http_status": r.status, "reason": "unexpected"}
|
||||
|
||||
|
||||
def fetch_models_for_folder(
|
||||
base: str, folder: str, headers: dict, *, is_cloud: bool,
|
||||
) -> tuple[set[str] | None, dict | None]:
|
||||
"""Fetch installed models for a folder, trying aliases.
|
||||
|
||||
Folder renames over time (e.g. unet → diffusion_models, clip → text_encoders)
|
||||
mean a workflow asking for a model in `unet` may need to look in
|
||||
`diffusion_models`. We union models from every reachable alias.
|
||||
|
||||
Returns (combined_set | None, last_error | None).
|
||||
"""
|
||||
aliases = folder_aliases_for(folder)
|
||||
combined: set[str] = set()
|
||||
any_success = False
|
||||
last_err: dict | None = None
|
||||
for alias in aliases:
|
||||
models, err = _fetch_one_folder(base, alias, headers, is_cloud=is_cloud)
|
||||
if models is not None:
|
||||
combined.update(models)
|
||||
any_success = True
|
||||
last_err = None
|
||||
else:
|
||||
last_err = err
|
||||
if not any_success:
|
||||
return None, last_err
|
||||
return combined, None
|
||||
|
||||
|
||||
def fetch_embeddings(base: str, headers: dict, *, is_cloud: bool) -> tuple[set[str] | None, dict | None]:
|
||||
"""Local ComfyUI exposes /embeddings; cloud uses /experiment/models/embeddings."""
|
||||
if is_cloud:
|
||||
return fetch_models_for_folder(base, "embeddings", headers, is_cloud=True)
|
||||
# Local: dedicated /embeddings returns a flat list of names
|
||||
r = http_get(resolve_url(base, "/embeddings", is_cloud=False), headers=headers, retries=2)
|
||||
if r.status == 200:
|
||||
try:
|
||||
data = r.json()
|
||||
if isinstance(data, list):
|
||||
# Strip extensions from the registered names since prompt syntax
|
||||
# usually omits them ("embedding:goodvibes" vs "goodvibes.pt")
|
||||
names = set()
|
||||
for n in data:
|
||||
if isinstance(n, str):
|
||||
names.add(n)
|
||||
# Also store stem for fuzzy matching
|
||||
names.add(Path(n).stem)
|
||||
return names, None
|
||||
except Exception:
|
||||
pass
|
||||
return None, {"http_status": r.status, "reason": "unexpected"}
|
||||
|
||||
|
||||
def normalize_for_match(name: str) -> set[str]:
|
||||
"""Generate matching variants of a model name (with/without extension, slashes, etc.)"""
|
||||
s = {name}
|
||||
s.add(Path(name).stem)
|
||||
s.add(Path(name).name)
|
||||
# ComfyUI sometimes strips/keeps the leading folder
|
||||
if "/" in name or "\\" in name:
|
||||
flat = name.replace("\\", "/").split("/")[-1]
|
||||
s.add(flat)
|
||||
s.add(Path(flat).stem)
|
||||
return {x for x in s if x}
|
||||
|
||||
|
||||
def model_present(needed: str, installed: set[str]) -> bool:
|
||||
if not installed:
|
||||
return False
|
||||
needed_variants = normalize_for_match(needed)
|
||||
installed_norm: set[str] = set()
|
||||
for inst in installed:
|
||||
installed_norm.update(normalize_for_match(inst))
|
||||
return bool(needed_variants & installed_norm)
|
||||
|
||||
|
||||
def suggest_install_command(node_class: str) -> str | None:
|
||||
pkg = NODE_TO_PACKAGE.get(node_class)
|
||||
if pkg:
|
||||
return f"comfy node install {pkg}"
|
||||
return None
|
||||
|
||||
|
||||
def suggest_git_url(node_class: str) -> str | None:
|
||||
"""For nodes not on the registry, return a git URL the user can hand to
|
||||
ComfyUI-Manager's `/manager/queue/install` endpoint."""
|
||||
return NODE_TO_GIT_URL.get(node_class)
|
||||
|
||||
|
||||
def check_deps(
|
||||
workflow: dict, host: str, *, api_key: str | None = None,
|
||||
) -> dict:
|
||||
headers: dict[str, str] = {}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
|
||||
is_cloud = is_cloud_host(host)
|
||||
base = host.rstrip("/")
|
||||
|
||||
# ---- 1. Required nodes ----
|
||||
required_nodes: set[str] = set()
|
||||
for _, node in iter_nodes(workflow):
|
||||
required_nodes.add(node["class_type"])
|
||||
|
||||
object_info_url = resolve_url(base, "/object_info", is_cloud=is_cloud)
|
||||
installed_nodes, obj_err = fetch_object_info(object_info_url, headers)
|
||||
|
||||
missing_nodes: list[dict] = []
|
||||
node_check_skipped = False
|
||||
if installed_nodes is None:
|
||||
# Couldn't query (e.g. cloud free tier). Don't false-alarm; mark skipped.
|
||||
node_check_skipped = True
|
||||
else:
|
||||
for cls in sorted(required_nodes):
|
||||
if cls not in installed_nodes:
|
||||
entry = {"class_type": cls}
|
||||
cmd = suggest_install_command(cls)
|
||||
git_url = suggest_git_url(cls)
|
||||
if cmd:
|
||||
entry["fix_command"] = cmd
|
||||
elif git_url:
|
||||
entry["fix_git_url"] = git_url
|
||||
entry["fix_hint"] = (
|
||||
f"Not on registry. Install via Manager with this git URL: {git_url}"
|
||||
)
|
||||
else:
|
||||
entry["fix_hint"] = (
|
||||
"Search https://registry.comfy.org or "
|
||||
"use ComfyUI-Manager UI to find the package providing this node."
|
||||
)
|
||||
missing_nodes.append(entry)
|
||||
|
||||
# ---- 2. Required models ----
|
||||
model_cache: dict[str, tuple[set[str] | None, dict | None]] = {}
|
||||
missing_models: list[dict] = []
|
||||
folder_errors: dict[str, dict] = {}
|
||||
|
||||
for dep in iter_model_deps(workflow):
|
||||
folder = dep["folder"]
|
||||
if folder not in model_cache:
|
||||
model_cache[folder] = fetch_models_for_folder(
|
||||
base, folder, headers, is_cloud=is_cloud,
|
||||
)
|
||||
installed, err = model_cache[folder]
|
||||
if installed is None:
|
||||
# Couldn't enumerate this folder — record once
|
||||
folder_errors.setdefault(folder, err or {})
|
||||
# Don't flag as missing (we don't know); the folder_errors block surfaces this
|
||||
continue
|
||||
if not model_present(dep["value"], installed):
|
||||
entry = dict(dep)
|
||||
entry["fix_hint"] = (
|
||||
f"comfy model download --url <URL> --relative-path models/{folder} "
|
||||
f"--filename {dep['value']!r}"
|
||||
)
|
||||
missing_models.append(entry)
|
||||
|
||||
# ---- 3. Embedding refs in prompts ----
|
||||
emb_installed, emb_err = fetch_embeddings(base, headers, is_cloud=is_cloud)
|
||||
missing_embeddings: list[dict] = []
|
||||
seen_emb: set[tuple[str, str]] = set()
|
||||
for nid, emb_name in iter_embedding_refs(workflow):
|
||||
if (nid, emb_name) in seen_emb:
|
||||
continue
|
||||
seen_emb.add((nid, emb_name))
|
||||
if emb_installed is None:
|
||||
# Couldn't enumerate — skip silently here, surface the error in the
|
||||
# folder_errors block
|
||||
continue
|
||||
if not model_present(emb_name, emb_installed):
|
||||
missing_embeddings.append({
|
||||
"node_id": nid,
|
||||
"embedding_name": emb_name,
|
||||
"folder": "embeddings",
|
||||
"fix_hint": (
|
||||
f"Download {emb_name}.pt or .safetensors and place in "
|
||||
f"models/embeddings/, or `comfy model download --url <URL> "
|
||||
f"--relative-path models/embeddings`"
|
||||
),
|
||||
})
|
||||
|
||||
if emb_err and emb_installed is None:
|
||||
folder_errors.setdefault("embeddings", emb_err)
|
||||
|
||||
is_ready = (
|
||||
not node_check_skipped
|
||||
and not missing_nodes
|
||||
and not missing_models
|
||||
and not missing_embeddings
|
||||
)
|
||||
|
||||
return {
|
||||
"is_ready": is_ready,
|
||||
"node_check_skipped": node_check_skipped,
|
||||
"node_check_skip_reason": obj_err if node_check_skipped else None,
|
||||
"missing_nodes": missing_nodes,
|
||||
"missing_models": missing_models,
|
||||
"missing_embeddings": missing_embeddings,
|
||||
"folder_errors": folder_errors,
|
||||
# 0 is a legitimate count (e.g. empty server). Use None only when not queried.
|
||||
"installed_node_count": len(installed_nodes) if installed_nodes is not None else None,
|
||||
"required_node_count": len(required_nodes),
|
||||
"required_nodes": sorted(required_nodes),
|
||||
"host": base,
|
||||
"is_cloud": is_cloud,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Check ComfyUI workflow dependencies against a running server")
|
||||
p.add_argument("workflow", help="Path to workflow API JSON file")
|
||||
p.add_argument("--host", default=DEFAULT_LOCAL_HOST, help="ComfyUI server URL")
|
||||
p.add_argument("--port", type=int, help="Server port (overrides --host port)")
|
||||
p.add_argument("--api-key", help=f"API key for cloud (or set ${ENV_API_KEY} env var)")
|
||||
p.add_argument("--strict", action="store_true",
|
||||
help="Exit non-zero if node check is skipped (e.g. on cloud free tier)")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
host = args.host
|
||||
if args.port is not None:
|
||||
# Strip any port from host and append --port
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
parsed = urlparse(host if "://" in host else f"http://{host}")
|
||||
new_netloc = f"{parsed.hostname}:{args.port}"
|
||||
host = urlunparse(parsed._replace(netloc=new_netloc))
|
||||
|
||||
api_key = resolve_api_key(args.api_key)
|
||||
|
||||
wf_path = Path(args.workflow).expanduser()
|
||||
if not wf_path.exists():
|
||||
emit_json({"error": f"Workflow file not found: {args.workflow}"})
|
||||
return 1
|
||||
try:
|
||||
with wf_path.open() as f:
|
||||
payload = json.load(f)
|
||||
workflow = unwrap_workflow(payload)
|
||||
except ValueError as e:
|
||||
emit_json({"error": str(e)})
|
||||
return 1
|
||||
except json.JSONDecodeError as e:
|
||||
emit_json({"error": f"Invalid JSON: {e}"})
|
||||
return 1
|
||||
|
||||
try:
|
||||
result = check_deps(workflow, host=host, api_key=api_key)
|
||||
except Exception as e:
|
||||
emit_json({"error": f"Dep check failed: {e}", "host": host})
|
||||
return 1
|
||||
|
||||
emit_json(result)
|
||||
|
||||
if not result["is_ready"]:
|
||||
return 1
|
||||
if args.strict and result["node_check_skipped"]:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
extract_schema.py — Analyze a ComfyUI API-format workflow and extract
|
||||
controllable parameters.
|
||||
|
||||
Improvements over v1:
|
||||
- Catalogs live in `_common.py`, shared with `check_deps.py`
|
||||
- Coverage expanded for Flux / SD3 / Wan / Hunyuan / LTX / IPAdapter / rgthree
|
||||
- Symmetric duplicate-name resolution: ALL duplicates get a node-id suffix
|
||||
(instead of "first wins, second renamed"), so callers see consistent names
|
||||
- Negative prompt detected by tracing `KSampler.negative` connections back to
|
||||
the source CLIPTextEncode (more reliable than meta-title heuristic)
|
||||
- Embedding references in prompt text are extracted as model dependencies
|
||||
- Detects Primitive nodes that drive other nodes' inputs (and surfaces them
|
||||
as the user-facing parameter)
|
||||
- Reroutes are followed when tracing connections
|
||||
|
||||
Usage:
|
||||
python3 extract_schema.py workflow_api.json
|
||||
python3 extract_schema.py workflow_api.json --output schema.json
|
||||
|
||||
Stdlib-only. Python 3.10+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from _common import ( # noqa: E402
|
||||
OUTPUT_NODES, PARAM_PATTERNS, PROMPT_FIELDS,
|
||||
is_link, iter_embedding_refs, iter_model_deps, iter_nodes, unwrap_workflow,
|
||||
)
|
||||
|
||||
|
||||
# Sampler nodes whose `positive` / `negative` connections we trace
|
||||
SAMPLER_NODE_FAMILY = {
|
||||
"KSampler", "KSamplerAdvanced",
|
||||
"SamplerCustom", "SamplerCustomAdvanced",
|
||||
"BasicGuider", "CFGGuider", "DualCFGGuider",
|
||||
}
|
||||
|
||||
|
||||
def infer_type(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "bool"
|
||||
if isinstance(value, int):
|
||||
return "int"
|
||||
if isinstance(value, float):
|
||||
return "float"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "link"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def trace_to_node(workflow: dict, link: list, *, max_hops: int = 8) -> str | None:
|
||||
"""Follow a [node_id, slot] link, hopping through Reroute / Primitive nodes
|
||||
if needed, to find the *upstream* node id that holds the actual value/input.
|
||||
|
||||
Bounded by both `max_hops` AND a visited-set to prevent infinite loops on
|
||||
pathological graphs.
|
||||
"""
|
||||
if not is_link(link):
|
||||
return None
|
||||
nid: str | None = link[0]
|
||||
visited: set[str] = set()
|
||||
for _ in range(max_hops):
|
||||
if nid is None or nid in visited:
|
||||
return nid
|
||||
visited.add(nid)
|
||||
node = workflow.get(nid)
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
cls = node.get("class_type", "")
|
||||
# Reroute / Primitive / passthrough wrappers
|
||||
if cls in {"Reroute", "PrimitiveNode", "Note", "easy showAnything"}:
|
||||
inputs = node.get("inputs", {}) or {}
|
||||
# Find first link-shaped input and follow it
|
||||
next_link = next((v for v in inputs.values() if is_link(v)), None)
|
||||
if next_link is None:
|
||||
return nid
|
||||
nid = next_link[0]
|
||||
continue
|
||||
return nid
|
||||
return nid
|
||||
|
||||
|
||||
def find_negative_prompt_node(workflow: dict) -> str | None:
|
||||
"""Trace `negative` input of a sampler back to the source text encoder."""
|
||||
for nid, node in iter_nodes(workflow):
|
||||
if node["class_type"] not in SAMPLER_NODE_FAMILY:
|
||||
continue
|
||||
inputs = node.get("inputs", {}) or {}
|
||||
neg = inputs.get("negative")
|
||||
if not is_link(neg):
|
||||
continue
|
||||
src = trace_to_node(workflow, neg)
|
||||
if src and isinstance(workflow.get(src), dict):
|
||||
cls = workflow[src].get("class_type", "")
|
||||
if cls.startswith("CLIPTextEncode") or cls in {"smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"}:
|
||||
return src
|
||||
return None
|
||||
|
||||
|
||||
def find_positive_prompt_node(workflow: dict) -> str | None:
|
||||
for nid, node in iter_nodes(workflow):
|
||||
if node["class_type"] not in SAMPLER_NODE_FAMILY:
|
||||
continue
|
||||
inputs = node.get("inputs", {}) or {}
|
||||
pos = inputs.get("positive")
|
||||
if not is_link(pos):
|
||||
continue
|
||||
src = trace_to_node(workflow, pos)
|
||||
if src and isinstance(workflow.get(src), dict):
|
||||
cls = workflow[src].get("class_type", "")
|
||||
if cls.startswith("CLIPTextEncode") or cls in {"smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"}:
|
||||
return src
|
||||
return None
|
||||
|
||||
|
||||
def extract_schema(workflow: dict) -> dict:
|
||||
"""Extract controllable parameters from a workflow.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"parameters": { friendly_name: {node_id, field, type, value, ...} },
|
||||
"output_nodes": [node_id, ...],
|
||||
"model_dependencies": [{node_id, class_type, field, value, folder}],
|
||||
"embedding_dependencies": [{node_id, embedding_name, found_in_field, value_excerpt}],
|
||||
"summary": {...}
|
||||
}
|
||||
"""
|
||||
output_nodes: list[str] = []
|
||||
|
||||
# First pass: identify positive / negative prompt nodes via connection tracing
|
||||
pos_node = find_positive_prompt_node(workflow)
|
||||
neg_node = find_negative_prompt_node(workflow)
|
||||
|
||||
# ----- collect raw parameter candidates -----
|
||||
# Each candidate = (friendly_name, node_id, field, value)
|
||||
# We resolve duplicate friendly_names AFTER the loop so dedup is symmetric.
|
||||
raw_params: list[dict] = []
|
||||
|
||||
for node_id, node in iter_nodes(workflow):
|
||||
cls = node["class_type"]
|
||||
inputs = node.get("inputs", {}) or {}
|
||||
|
||||
if cls in OUTPUT_NODES:
|
||||
output_nodes.append(node_id)
|
||||
|
||||
# Match this node against PARAM_PATTERNS
|
||||
for p_class, p_field, friendly in PARAM_PATTERNS:
|
||||
if cls != p_class:
|
||||
continue
|
||||
if p_field not in inputs:
|
||||
continue
|
||||
value = inputs[p_field]
|
||||
t = infer_type(value)
|
||||
if t == "link":
|
||||
continue # connections aren't directly controllable
|
||||
|
||||
actual_name = friendly
|
||||
|
||||
# Disambiguate prompt vs negative_prompt by connection tracing
|
||||
if friendly == "prompt":
|
||||
if node_id == neg_node and pos_node != neg_node:
|
||||
actual_name = "negative_prompt"
|
||||
elif node_id == pos_node:
|
||||
actual_name = "prompt"
|
||||
else:
|
||||
# Fallback: use _meta.title hints if present
|
||||
meta_title = (node.get("_meta") or {}).get("title", "").lower()
|
||||
if any(t_ in meta_title for t_ in ("negative", "neg", "-prompt", "anti")):
|
||||
actual_name = "negative_prompt"
|
||||
|
||||
raw_params.append({
|
||||
"name_hint": actual_name,
|
||||
"node_id": node_id,
|
||||
"field": p_field,
|
||||
"type": t,
|
||||
"value": value,
|
||||
"class_type": cls,
|
||||
})
|
||||
|
||||
# ----- symmetric duplicate-name resolution -----
|
||||
# Group by name_hint. If a hint appears once, keep it. If multiple, suffix
|
||||
# ALL with their node_id. Always-stable, always-uniquely-addressable.
|
||||
by_name: dict[str, list[dict]] = {}
|
||||
for r in raw_params:
|
||||
by_name.setdefault(r["name_hint"], []).append(r)
|
||||
|
||||
parameters: dict[str, dict] = {}
|
||||
for name, entries in by_name.items():
|
||||
if len(entries) == 1:
|
||||
r = entries[0]
|
||||
parameters[name] = {
|
||||
"node_id": r["node_id"], "field": r["field"],
|
||||
"type": r["type"], "value": r["value"],
|
||||
"class_type": r["class_type"],
|
||||
}
|
||||
else:
|
||||
# Sort by node_id (string-natural) for stability
|
||||
entries.sort(key=lambda x: (str(x["node_id"]).zfill(8), x["field"]))
|
||||
for r in entries:
|
||||
full_name = f"{name}_{r['node_id']}"
|
||||
parameters[full_name] = {
|
||||
"node_id": r["node_id"], "field": r["field"],
|
||||
"type": r["type"], "value": r["value"],
|
||||
"class_type": r["class_type"],
|
||||
"alias_of": name,
|
||||
}
|
||||
|
||||
# ----- model dependencies -----
|
||||
model_deps = list(iter_model_deps(workflow))
|
||||
|
||||
# ----- embedding dependencies (in prompt text) -----
|
||||
embedding_deps: list[dict] = []
|
||||
seen_emb: set[tuple[str, str]] = set()
|
||||
for nid, emb_name in iter_embedding_refs(workflow):
|
||||
key = (nid, emb_name)
|
||||
if key in seen_emb:
|
||||
continue
|
||||
seen_emb.add(key)
|
||||
# Find which field had the reference, for context
|
||||
node = workflow.get(nid, {})
|
||||
inputs = node.get("inputs", {}) or {}
|
||||
found_field = None
|
||||
excerpt = None
|
||||
for fname, fval in inputs.items():
|
||||
if isinstance(fval, str) and fname in PROMPT_FIELDS and emb_name in fval:
|
||||
found_field = fname
|
||||
excerpt = fval[:120]
|
||||
break
|
||||
embedding_deps.append({
|
||||
"node_id": nid,
|
||||
"embedding_name": emb_name,
|
||||
"field": found_field,
|
||||
"value_excerpt": excerpt,
|
||||
"folder": "embeddings",
|
||||
})
|
||||
|
||||
# ----- summary -----
|
||||
summary = {
|
||||
"parameter_count": len(parameters),
|
||||
"output_node_count": len(output_nodes),
|
||||
"model_dep_count": len(model_deps),
|
||||
"embedding_dep_count": len(embedding_deps),
|
||||
"has_negative_prompt": "negative_prompt" in parameters,
|
||||
"has_seed": "seed" in parameters or any(p.startswith("seed_") for p in parameters),
|
||||
"is_video_workflow": any(
|
||||
workflow.get(n, {}).get("class_type", "") in {
|
||||
"VHS_VideoCombine", "SaveVideo", "SaveAnimatedWEBP", "SaveAnimatedPNG",
|
||||
} for n in output_nodes
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"parameters": parameters,
|
||||
"output_nodes": output_nodes,
|
||||
"model_dependencies": model_deps,
|
||||
"embedding_dependencies": embedding_deps,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Extract controllable parameters from a ComfyUI workflow")
|
||||
p.add_argument("workflow", help="Path to workflow API JSON file")
|
||||
p.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||
p.add_argument("--summary-only", action="store_true",
|
||||
help="Only print the summary block")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
wf_path = Path(args.workflow).expanduser()
|
||||
if not wf_path.exists():
|
||||
print(f"Error: {wf_path} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
with wf_path.open() as f:
|
||||
payload = json.load(f)
|
||||
workflow = unwrap_workflow(payload)
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: invalid JSON — {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
schema = extract_schema(workflow)
|
||||
|
||||
if args.summary_only:
|
||||
out = json.dumps(schema["summary"], indent=2)
|
||||
else:
|
||||
out = json.dumps(schema, indent=2, default=str)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(out)
|
||||
print(f"Schema written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(out)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fetch_logs.py — Retrieve workflow execution diagnostics from a ComfyUI server.
|
||||
|
||||
When a workflow errors, the server's /history (local) or /jobs (cloud) entry
|
||||
contains the full Python traceback. This script makes it easy to fetch by
|
||||
prompt_id, with sensible formatting.
|
||||
|
||||
Usage:
|
||||
python3 fetch_logs.py <prompt_id>
|
||||
python3 fetch_logs.py <prompt_id> --host https://cloud.comfy.org
|
||||
python3 fetch_logs.py --tail-queue # show currently queued/running jobs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from _common import ( # noqa: E402
|
||||
DEFAULT_LOCAL_HOST, ENV_API_KEY, emit_json, http_get, is_cloud_host,
|
||||
resolve_api_key, resolve_url,
|
||||
)
|
||||
|
||||
|
||||
def fetch_history_entry(host: str, headers: dict, prompt_id: str, *, is_cloud: bool) -> dict:
|
||||
if is_cloud:
|
||||
# Try /jobs/{id} first
|
||||
url = resolve_url(host, f"/jobs/{prompt_id}", is_cloud=True)
|
||||
r = http_get(url, headers=headers, retries=2, timeout=30)
|
||||
if r.status == 200:
|
||||
try:
|
||||
return {"ok": True, "entry": r.json(), "source": "/api/jobs"}
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback to history_v2
|
||||
url = resolve_url(host, f"/history/{prompt_id}", is_cloud=True)
|
||||
r = http_get(url, headers=headers, retries=2, timeout=30)
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
data = None
|
||||
if r.status == 200 and data:
|
||||
return {"ok": True, "entry": data, "source": "/api/history_v2"}
|
||||
return {"ok": False, "http_status": r.status, "body": r.text()[:500]}
|
||||
|
||||
url = resolve_url(host, f"/history/{prompt_id}", is_cloud=False)
|
||||
r = http_get(url, headers=headers, retries=2, timeout=30)
|
||||
if r.status != 200:
|
||||
return {"ok": False, "http_status": r.status, "body": r.text()[:500]}
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
return {"ok": False, "reason": "non-JSON response"}
|
||||
if not isinstance(data, dict) or prompt_id not in data:
|
||||
return {"ok": False, "reason": "prompt_id not found in history",
|
||||
"history_keys": list(data.keys())[:5] if isinstance(data, dict) else []}
|
||||
return {"ok": True, "entry": data[prompt_id], "source": "/history"}
|
||||
|
||||
|
||||
def fetch_queue(host: str, headers: dict) -> dict:
|
||||
url = resolve_url(host, "/queue")
|
||||
r = http_get(url, headers=headers, retries=2, timeout=15)
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
data = {"raw": r.text()[:500]}
|
||||
return {"http_status": r.status, "data": data}
|
||||
|
||||
|
||||
def extract_diagnostics(entry: dict) -> dict:
|
||||
"""Pull out the parts a human cares about: status, errors, traceback, timing."""
|
||||
diag: dict = {}
|
||||
status = entry.get("status") or {}
|
||||
diag["status_str"] = status.get("status_str")
|
||||
diag["completed"] = status.get("completed")
|
||||
|
||||
messages = status.get("messages") or []
|
||||
diag["execution_log"] = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, list) and len(msg) >= 2:
|
||||
mtype, mdata = msg[0], msg[1]
|
||||
diag["execution_log"].append({"type": mtype, "data": mdata})
|
||||
else:
|
||||
diag["execution_log"].append(msg)
|
||||
|
||||
# Look for execution_error inside messages
|
||||
errors = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, list) and len(msg) >= 2 and msg[0] == "execution_error":
|
||||
errors.append(msg[1])
|
||||
if errors:
|
||||
diag["errors"] = errors
|
||||
|
||||
# Cloud's /jobs response shape: top-level outputs / status / etc.
|
||||
if "outputs" in entry:
|
||||
out = entry["outputs"] or {}
|
||||
if isinstance(out, dict):
|
||||
diag["output_node_ids"] = list(out.keys())
|
||||
# Count file refs across all output buckets (images / video / etc.)
|
||||
total = 0
|
||||
for node_output in out.values():
|
||||
if not isinstance(node_output, dict):
|
||||
continue
|
||||
for v in node_output.values():
|
||||
if isinstance(v, list):
|
||||
total += len(v)
|
||||
diag["output_count"] = total
|
||||
else:
|
||||
diag["output_node_ids"] = []
|
||||
diag["output_count"] = 0
|
||||
return diag
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Fetch workflow execution diagnostics")
|
||||
p.add_argument("prompt_id", nargs="?", help="prompt_id to look up")
|
||||
p.add_argument("--host", default=DEFAULT_LOCAL_HOST)
|
||||
p.add_argument("--api-key", help=f"or set ${ENV_API_KEY}")
|
||||
p.add_argument("--raw", action="store_true",
|
||||
help="Print the full history entry instead of the digest")
|
||||
p.add_argument("--tail-queue", action="store_true",
|
||||
help="Show currently running/pending jobs instead")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
api_key = resolve_api_key(args.api_key)
|
||||
headers = {"X-API-Key": api_key} if api_key else {}
|
||||
is_cloud = is_cloud_host(args.host)
|
||||
|
||||
if args.tail_queue:
|
||||
emit_json(fetch_queue(args.host, headers))
|
||||
return 0
|
||||
|
||||
if not args.prompt_id:
|
||||
print("Error: prompt_id is required (or use --tail-queue)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
res = fetch_history_entry(args.host, headers, args.prompt_id, is_cloud=is_cloud)
|
||||
if not res.get("ok"):
|
||||
emit_json(res)
|
||||
return 1
|
||||
|
||||
if args.raw:
|
||||
emit_json(res)
|
||||
return 0
|
||||
|
||||
diag = extract_diagnostics(res["entry"])
|
||||
diag["source"] = res.get("source")
|
||||
diag["prompt_id"] = args.prompt_id
|
||||
emit_json(diag)
|
||||
return 0 if diag.get("status_str") not in {"error",} else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
# Design System: Revolut
|
||||
|
||||
|
||||
> **Hermes Agent — Implementation Notes**
|
||||
>
|
||||
> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes:
|
||||
> - **Primary:** `Inter` | **Mono:** `system monospace stack`
|
||||
> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;`
|
||||
> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;`
|
||||
> ```html
|
||||
> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
> ```
|
||||
> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel).
|
||||
> Verify visual accuracy with `browser_vision` after generating.
|
||||
|
||||
## 1. Visual Theme & Atmosphere
|
||||
|
||||
Revolut's website is fintech confidence distilled into pixels — a design system that communicates "your money is in capable hands" through massive typography, generous whitespace, and a disciplined neutral palette. The visual language is built on Aeonik Pro, a geometric grotesque that creates billboard-scale headlines at 136px with weight 500 and aggressive negative tracking (-2.72px). This isn't subtle branding; it's fintech at stadium scale.
|
||||
|
||||
The color system is built on a comprehensive `--rui-*` (Revolut UI) token architecture with semantic naming for every state: danger (`#e23b4a`), warning (`#ec7e00`), teal (`#00a87e`), blue (`#494fdf`), deep-pink (`#e61e49`), and more. But the marketing surface itself is remarkably restrained — near-black (`#191c1f`) and pure white (`#ffffff`) dominate, with the colorful semantic tokens reserved for the product interface, not the marketing page.
|
||||
|
||||
What distinguishes Revolut is its pill-everything button system. Every button uses 9999px radius — primary dark (`#191c1f`), secondary light (`#f4f4f4`), outlined (`transparent + 2px solid`), and ghost on dark (`rgba(244,244,244,0.1) + 2px solid`). The padding is generous (14px 32px–34px), creating large, confident touch targets. Combined with Inter for body text at various weights and positive letter-spacing (0.16px–0.24px), the result is a design that feels both premium and accessible — banking for the modern era.
|
||||
|
||||
**Key Characteristics:**
|
||||
- Aeonik Pro display at 136px weight 500 — billboard-scale fintech headlines
|
||||
- Near-black (`#191c1f`) + white binary with comprehensive `--rui-*` semantic tokens
|
||||
- Universal pill buttons (9999px radius) with generous padding (14px 32px)
|
||||
- Inter for body text with positive letter-spacing (0.16px–0.24px)
|
||||
- Rich semantic color system: blue, teal, pink, yellow, green, brown, danger, warning
|
||||
- Zero shadows detected — depth through color contrast only
|
||||
- Tight display line-heights (1.00) with relaxed body (1.50–1.56)
|
||||
|
||||
## 2. Color Palette & Roles
|
||||
|
||||
### Primary
|
||||
- **Revolut Dark** (`#191c1f`): Primary dark surface, button background, near-black text
|
||||
- **Pure White** (`#ffffff`): `--rui-color-action-label`, primary light surface
|
||||
- **Light Surface** (`#f4f4f4`): Secondary button background, subtle surface
|
||||
|
||||
### Brand / Interactive
|
||||
- **Revolut Blue** (`#494fdf`): `--rui-color-blue`, primary brand blue
|
||||
- **Action Blue** (`#4f55f1`): `--rui-color-action-photo-header-text`, header accent
|
||||
- **Blue Text** (`#376cd5`): `--website-color-blue-text`, link blue
|
||||
|
||||
### Semantic
|
||||
- **Danger Red** (`#e23b4a`): `--rui-color-danger`, error/destructive
|
||||
- **Deep Pink** (`#e61e49`): `--rui-color-deep-pink`, critical accent
|
||||
- **Warning Orange** (`#ec7e00`): `--rui-color-warning`, warning states
|
||||
- **Yellow** (`#b09000`): `--rui-color-yellow`, attention
|
||||
- **Teal** (`#00a87e`): `--rui-color-teal`, success/positive
|
||||
- **Light Green** (`#428619`): `--rui-color-light-green`, secondary success
|
||||
- **Green Text** (`#006400`): `--website-color-green-text`, green text
|
||||
- **Light Blue** (`#007bc2`): `--rui-color-light-blue`, informational
|
||||
- **Brown** (`#936d62`): `--rui-color-brown`, warm neutral accent
|
||||
- **Red Text** (`#8b0000`): `--website-color-red-text`, dark red text
|
||||
|
||||
### Neutral Scale
|
||||
- **Mid Slate** (`#505a63`): Secondary text
|
||||
- **Cool Gray** (`#8d969e`): Muted text, tertiary
|
||||
- **Gray Tone** (`#c9c9cd`): `--rui-color-grey-tone-20`, borders/dividers
|
||||
|
||||
## 3. Typography Rules
|
||||
|
||||
### Font Families
|
||||
- **Display**: `Aeonik Pro` — geometric grotesque, no detected fallbacks
|
||||
- **Body / UI**: `Inter` — standard system sans
|
||||
- **Fallback**: `Arial` for specific button contexts
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|
||||
|------|------|------|--------|-------------|----------------|-------|
|
||||
| Display Mega | Aeonik Pro | 136px (8.50rem) | 500 | 1.00 (tight) | -2.72px | Stadium-scale hero |
|
||||
| Display Hero | Aeonik Pro | 80px (5.00rem) | 500 | 1.00 (tight) | -0.8px | Primary hero |
|
||||
| Section Heading | Aeonik Pro | 48px (3.00rem) | 500 | 1.21 (tight) | -0.48px | Feature sections |
|
||||
| Sub-heading | Aeonik Pro | 40px (2.50rem) | 500 | 1.20 (tight) | -0.4px | Sub-sections |
|
||||
| Card Title | Aeonik Pro | 32px (2.00rem) | 500 | 1.19 (tight) | -0.32px | Card headings |
|
||||
| Feature Title | Aeonik Pro | 24px (1.50rem) | 400 | 1.33 | normal | Light headings |
|
||||
| Nav / UI | Aeonik Pro | 20px (1.25rem) | 500 | 1.40 | normal | Navigation, buttons |
|
||||
| Body Large | Inter | 18px (1.13rem) | 400 | 1.56 | -0.09px | Introductions |
|
||||
| Body | Inter | 16px (1.00rem) | 400 | 1.50 | 0.24px | Standard reading |
|
||||
| Body Semibold | Inter | 16px (1.00rem) | 600 | 1.50 | 0.16px | Emphasized body |
|
||||
| Body Bold Link | Inter | 16px (1.00rem) | 700 | 1.50 | 0.24px | Bold links |
|
||||
|
||||
### Principles
|
||||
- **Weight 500 as display default**: Aeonik Pro uses medium (500) for ALL headings — no bold. This creates authority through size and tracking, not weight.
|
||||
- **Billboard tracking**: -2.72px at 136px is extremely compressed — text designed to be read at a glance, like airport signage.
|
||||
- **Positive tracking on body**: Inter uses +0.16px to +0.24px, creating airy, well-spaced reading text that contrasts with the compressed headings.
|
||||
|
||||
## 4. Component Stylings
|
||||
|
||||
### Buttons
|
||||
|
||||
**Primary Dark Pill**
|
||||
- Background: `#191c1f`
|
||||
- Text: `#ffffff`
|
||||
- Padding: 14px 32px
|
||||
- Radius: 9999px (full pill)
|
||||
- Hover: opacity 0.85
|
||||
- Focus: `0 0 0 0.125rem` ring
|
||||
|
||||
**Secondary Light Pill**
|
||||
- Background: `#f4f4f4`
|
||||
- Text: `#000000`
|
||||
- Padding: 14px 34px
|
||||
- Radius: 9999px
|
||||
- Hover: opacity 0.85
|
||||
|
||||
**Outlined Pill**
|
||||
- Background: transparent
|
||||
- Text: `#191c1f`
|
||||
- Border: `2px solid #191c1f`
|
||||
- Padding: 14px 32px
|
||||
- Radius: 9999px
|
||||
|
||||
**Ghost on Dark**
|
||||
- Background: `rgba(244, 244, 244, 0.1)`
|
||||
- Text: `#f4f4f4`
|
||||
- Border: `2px solid #f4f4f4`
|
||||
- Padding: 14px 32px
|
||||
- Radius: 9999px
|
||||
|
||||
### Cards & Containers
|
||||
- Radius: 12px (small), 20px (cards)
|
||||
- No shadows — flat surfaces with color contrast
|
||||
- Dark and light section alternation
|
||||
|
||||
### Navigation
|
||||
- Aeonik Pro 20px weight 500
|
||||
- Clean header, hamburger toggle at 12px radius
|
||||
- Pill CTAs right-aligned
|
||||
|
||||
## 5. Layout Principles
|
||||
|
||||
### Spacing System
|
||||
- Base unit: 8px
|
||||
- Scale: 4px, 6px, 8px, 14px, 16px, 20px, 24px, 32px, 40px, 48px, 80px, 88px, 120px
|
||||
- Large section spacing: 80px–120px
|
||||
|
||||
### Border Radius Scale
|
||||
- Standard (12px): Navigation, small buttons
|
||||
- Card (20px): Feature cards
|
||||
- Pill (9999px): All buttons
|
||||
|
||||
## 6. Depth & Elevation
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|-------|-----------|-----|
|
||||
| Flat (Level 0) | No shadow | Everything — Revolut uses zero shadows |
|
||||
| Focus | `0 0 0 0.125rem` ring | Accessibility focus |
|
||||
|
||||
**Shadow Philosophy**: Revolut uses ZERO shadows. Depth comes entirely from the dark/light section contrast and the generous whitespace between elements.
|
||||
|
||||
## 7. Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Use Aeonik Pro weight 500 for all display headings
|
||||
- Apply 9999px radius to all buttons — pill shape is universal
|
||||
- Use generous button padding (14px 32px)
|
||||
- Keep the palette to near-black + white for marketing surfaces
|
||||
- Apply positive letter-spacing on Inter body text
|
||||
|
||||
### Don't
|
||||
- Don't use shadows — Revolut is flat by design
|
||||
- Don't use bold (700) for Aeonik Pro headings — 500 is the weight
|
||||
- Don't use small buttons — the generous padding is intentional
|
||||
- Don't apply semantic colors to marketing surfaces — they're for the product
|
||||
|
||||
## 8. Responsive Behavior
|
||||
|
||||
### Breakpoints
|
||||
| Name | Width | Key Changes |
|
||||
|------|-------|-------------|
|
||||
| Mobile Small | <400px | Compact, single column |
|
||||
| Mobile | 400–720px | Standard mobile |
|
||||
| Tablet | 720–1024px | 2-column layouts |
|
||||
| Desktop | 1024–1280px | Standard desktop |
|
||||
| Large | 1280–1920px | Full layout |
|
||||
|
||||
## 9. Agent Prompt Guide
|
||||
|
||||
### Quick Color Reference
|
||||
- Dark: Revolut Dark (`#191c1f`)
|
||||
- Light: White (`#ffffff`)
|
||||
- Surface: Light (`#f4f4f4`)
|
||||
- Blue: Revolut Blue (`#494fdf`)
|
||||
- Danger: Red (`#e23b4a`)
|
||||
- Success: Teal (`#00a87e`)
|
||||
|
||||
### Example Component Prompts
|
||||
- "Create a hero: white background. Headline at 136px Aeonik Pro weight 500, line-height 1.00, letter-spacing -2.72px, #191c1f text. Dark pill CTA (#191c1f, 9999px, 14px 32px). Outlined pill secondary (transparent, 2px solid #191c1f)."
|
||||
- "Build a pill button: #191c1f background, white text, 9999px radius, 14px 32px padding, 20px Aeonik Pro weight 500. Hover: opacity 0.85."
|
||||
|
||||
### Iteration Guide
|
||||
1. Aeonik Pro 500 for headings — never bold
|
||||
2. All buttons are pills (9999px) with generous padding
|
||||
3. Zero shadows — flat is the Revolut identity
|
||||
4. Near-black + white for marketing, semantic colors for product
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
description: Skills for searching, downloading, and working with GIFs and short-form animated media.
|
||||
---
|
||||
@@ -0,0 +1,19 @@
|
||||
# inference.sh
|
||||
|
||||
Run 150+ AI applications in the cloud via the [inference.sh](https://inference.sh) platform.
|
||||
|
||||
**One API key for everything** — access image generation, video creation, LLMs, search, 3D, and more through a single account. No need to manage separate API keys for each provider.
|
||||
|
||||
## Available Skills
|
||||
|
||||
- **cli**: Use the inference.sh CLI (`infsh`) via the terminal tool
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Image Generation**: FLUX, Reve, Seedream, Grok Imagine, Gemini
|
||||
- **Video Generation**: Veo, Wan, Seedance, OmniHuman, HunyuanVideo
|
||||
- **LLMs**: Claude, Gemini, Kimi, GLM-4 (via OpenRouter)
|
||||
- **Search**: Tavily, Exa
|
||||
- **3D**: Rodin
|
||||
- **Social**: Twitter/X automation
|
||||
- **Audio**: TTS, voice cloning
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ws_monitor.py — Real-time ComfyUI WebSocket monitor.
|
||||
|
||||
Connects to /ws and pretty-prints execution events: node start/finish, sampling
|
||||
progress, cached nodes, errors. Optionally writes preview frames to disk.
|
||||
|
||||
Useful for:
|
||||
- Watching a long-running job in real time without parsing JSON yourself
|
||||
- Saving in-progress preview frames for video / animation workflows
|
||||
- Debugging "why is this hanging?" — see exactly which node is stuck
|
||||
|
||||
Usage:
|
||||
# Local — watch all jobs from this client_id
|
||||
python3 ws_monitor.py
|
||||
|
||||
# Cloud — watch a specific prompt_id
|
||||
python3 ws_monitor.py --host https://cloud.comfy.org \
|
||||
--prompt-id abc-123-def
|
||||
|
||||
# Save preview frames to ./previews/
|
||||
python3 ws_monitor.py --previews ./previews
|
||||
|
||||
Requires: websocket-client (`pip install websocket-client`).
|
||||
Falls back to a clear error message when not installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from _common import ( # noqa: E402
|
||||
DEFAULT_LOCAL_HOST, ENV_API_KEY, log, new_client_id, resolve_api_key, is_cloud_host,
|
||||
)
|
||||
|
||||
|
||||
# Binary frame types from ComfyUI WebSocket protocol
|
||||
BINARY_PREVIEW_IMAGE = 1
|
||||
BINARY_TEXT = 3
|
||||
BINARY_PREVIEW_IMAGE_WITH_METADATA = 4
|
||||
|
||||
# Image type codes inside PREVIEW_IMAGE
|
||||
IMAGE_TYPE_JPEG = 1
|
||||
IMAGE_TYPE_PNG = 2
|
||||
|
||||
# ANSI escape codes (works on most modern terminals)
|
||||
RESET = "\033[0m"
|
||||
DIM = "\033[2m"
|
||||
BOLD = "\033[1m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
RED = "\033[31m"
|
||||
CYAN = "\033[36m"
|
||||
|
||||
|
||||
def fmt_color(s: str, color: str, *, color_on: bool = True) -> str:
|
||||
return f"{color}{s}{RESET}" if color_on else s
|
||||
|
||||
|
||||
def parse_binary_frame(data: bytes) -> dict | None:
|
||||
if len(data) < 8:
|
||||
return None
|
||||
type_code = struct.unpack(">I", data[0:4])[0]
|
||||
if type_code == BINARY_PREVIEW_IMAGE:
|
||||
image_type = struct.unpack(">I", data[4:8])[0]
|
||||
ext = "jpg" if image_type == IMAGE_TYPE_JPEG else "png" if image_type == IMAGE_TYPE_PNG else "bin"
|
||||
return {
|
||||
"kind": "preview",
|
||||
"image_type": image_type,
|
||||
"ext": ext,
|
||||
"image_bytes": data[8:],
|
||||
}
|
||||
if type_code == BINARY_PREVIEW_IMAGE_WITH_METADATA:
|
||||
if len(data) < 12:
|
||||
return None
|
||||
meta_len = struct.unpack(">I", data[4:8])[0]
|
||||
meta_end = 8 + meta_len
|
||||
if len(data) < meta_end:
|
||||
return None
|
||||
try:
|
||||
meta = json.loads(data[8:meta_end].decode("utf-8"))
|
||||
except Exception:
|
||||
meta = {"raw": data[8:meta_end][:200].decode("utf-8", "replace")}
|
||||
return {
|
||||
"kind": "preview_with_metadata",
|
||||
"metadata": meta,
|
||||
"image_bytes": data[meta_end:],
|
||||
"ext": "png",
|
||||
}
|
||||
if type_code == BINARY_TEXT:
|
||||
if len(data) < 8:
|
||||
return None
|
||||
nid_len = struct.unpack(">I", data[4:8])[0]
|
||||
nid_end = 8 + nid_len
|
||||
if len(data) < nid_end:
|
||||
return None
|
||||
return {
|
||||
"kind": "text",
|
||||
"node_id": data[8:nid_end].decode("utf-8", "replace"),
|
||||
"text": data[nid_end:].decode("utf-8", "replace"),
|
||||
}
|
||||
return {"kind": "unknown", "type_code": type_code, "size": len(data)}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Real-time ComfyUI WebSocket monitor")
|
||||
p.add_argument("--host", default=DEFAULT_LOCAL_HOST, help="ComfyUI server URL")
|
||||
p.add_argument("--api-key", help=f"API key for cloud (or set ${ENV_API_KEY} env var)")
|
||||
p.add_argument("--client-id", default=None, help="Client ID (default: random UUID)")
|
||||
p.add_argument("--prompt-id", default=None,
|
||||
help="Filter to a specific prompt_id (default: all jobs)")
|
||||
p.add_argument("--previews", default=None,
|
||||
help="Directory to save in-progress preview frames")
|
||||
p.add_argument("--no-color", action="store_true", help="Disable ANSI colour")
|
||||
p.add_argument("--timeout", type=float, default=600.0,
|
||||
help="Hard cap on monitor duration (default 600s)")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
try:
|
||||
import websocket # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
print(json.dumps({
|
||||
"error": "websocket-client not installed",
|
||||
"install": "pip install websocket-client",
|
||||
}))
|
||||
return 1
|
||||
|
||||
api_key = resolve_api_key(args.api_key)
|
||||
cloud = is_cloud_host(args.host)
|
||||
client_id = args.client_id or new_client_id()
|
||||
|
||||
# Build WS URL preserving any base-path component (e.g. behind reverse proxy).
|
||||
parsed = urlparse(args.host if "://" in args.host else f"http://{args.host}")
|
||||
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||
netloc = parsed.netloc
|
||||
base_path = parsed.path.rstrip("/")
|
||||
ws_url = f"{scheme}://{netloc}{base_path}/ws?clientId={client_id}"
|
||||
if cloud and api_key:
|
||||
ws_url += f"&token={api_key}"
|
||||
|
||||
color_on = not args.no_color and sys.stdout.isatty()
|
||||
|
||||
preview_dir = Path(args.previews).expanduser() if args.previews else None
|
||||
if preview_dir:
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
log(f"Saving previews to {preview_dir}")
|
||||
|
||||
log(f"Connecting to {ws_url} (client_id={client_id})")
|
||||
if args.prompt_id:
|
||||
log(f"Filtering messages to prompt_id={args.prompt_id}")
|
||||
|
||||
ws = websocket.create_connection(ws_url, timeout=args.timeout)
|
||||
ws.settimeout(args.timeout)
|
||||
|
||||
preview_counter = 0
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = ws.recv()
|
||||
except websocket.WebSocketTimeoutException:
|
||||
log(f"Idle for {args.timeout}s — exiting")
|
||||
return 0
|
||||
if isinstance(msg, bytes):
|
||||
parsed = parse_binary_frame(msg)
|
||||
if parsed is None:
|
||||
continue
|
||||
if parsed["kind"] in {"preview", "preview_with_metadata"} and preview_dir:
|
||||
img_bytes = parsed.get("image_bytes", b"")
|
||||
if img_bytes:
|
||||
ext = parsed.get("ext", "png")
|
||||
out = preview_dir / f"preview_{preview_counter:05d}.{ext}"
|
||||
out.write_bytes(img_bytes)
|
||||
preview_counter += 1
|
||||
log(f" [preview] saved {out.name} ({len(img_bytes)} bytes)")
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(msg)
|
||||
except Exception:
|
||||
continue
|
||||
mtype = payload.get("type", "")
|
||||
mdata = payload.get("data", {}) or {}
|
||||
pid = mdata.get("prompt_id")
|
||||
|
||||
if args.prompt_id and pid and pid != args.prompt_id:
|
||||
continue
|
||||
|
||||
if mtype == "status":
|
||||
qr = mdata.get("status", {}).get("exec_info", {}).get("queue_remaining", "?")
|
||||
print(fmt_color(f"[status] queue_remaining={qr}", DIM, color_on=color_on))
|
||||
elif mtype == "execution_start":
|
||||
print(fmt_color(f"[start] prompt_id={pid}", BOLD, color_on=color_on))
|
||||
elif mtype == "executing":
|
||||
node = mdata.get("node")
|
||||
if node:
|
||||
print(fmt_color(f" [executing] node={node}", CYAN, color_on=color_on))
|
||||
else:
|
||||
print(fmt_color(f" [executing] (workflow done) prompt_id={pid}", DIM, color_on=color_on))
|
||||
elif mtype == "progress":
|
||||
v, m = mdata.get("value", 0), mdata.get("max", 0)
|
||||
pct = (v / m * 100) if m else 0
|
||||
print(f" [progress] {v}/{m} ({pct:5.1f}%) node={mdata.get('node')}")
|
||||
elif mtype == "progress_state":
|
||||
# Newer extended progress message
|
||||
nodes = mdata.get("nodes") or {}
|
||||
running = [k for k, v in nodes.items() if v.get("running")]
|
||||
if running:
|
||||
print(fmt_color(f" [progress_state] running={running}", DIM, color_on=color_on))
|
||||
elif mtype == "executed":
|
||||
node = mdata.get("node")
|
||||
out = mdata.get("output") or {}
|
||||
summary_parts = []
|
||||
for key in ("images", "video", "videos", "gifs", "audio", "files"):
|
||||
if out.get(key):
|
||||
summary_parts.append(f"{key}={len(out[key])}")
|
||||
summary = ", ".join(summary_parts) if summary_parts else "(no files)"
|
||||
print(fmt_color(f" [executed] node={node} {summary}", GREEN, color_on=color_on))
|
||||
elif mtype == "execution_cached":
|
||||
cached = mdata.get("nodes") or []
|
||||
if cached:
|
||||
print(fmt_color(f" [cached] {len(cached)} nodes skipped", DIM, color_on=color_on))
|
||||
elif mtype == "execution_success":
|
||||
print(fmt_color(f"[success] prompt_id={pid}", GREEN + BOLD, color_on=color_on))
|
||||
if args.prompt_id:
|
||||
return 0
|
||||
elif mtype == "execution_error":
|
||||
exc_type = mdata.get("exception_type", "?")
|
||||
exc_msg = mdata.get("exception_message", "?")
|
||||
print(fmt_color(f"[error] {exc_type}: {exc_msg}", RED + BOLD, color_on=color_on))
|
||||
tb = mdata.get("traceback")
|
||||
if tb:
|
||||
if isinstance(tb, list):
|
||||
for line in tb:
|
||||
print(fmt_color(f" {line}", RED, color_on=color_on))
|
||||
else:
|
||||
print(fmt_color(f" {tb}", RED, color_on=color_on))
|
||||
if args.prompt_id:
|
||||
return 1
|
||||
elif mtype == "execution_interrupted":
|
||||
print(fmt_color(f"[interrupted] prompt_id={pid}", YELLOW, color_on=color_on))
|
||||
if args.prompt_id:
|
||||
return 1
|
||||
elif mtype == "notification":
|
||||
v = mdata.get("value", "")
|
||||
print(fmt_color(f"[notification] {v}", DIM, color_on=color_on))
|
||||
else:
|
||||
# Unknown / lightly-used types: print compactly
|
||||
print(fmt_color(f"[{mtype}] {json.dumps(mdata, default=str)[:200]}", DIM, color_on=color_on))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log("Interrupted")
|
||||
return 130
|
||||
finally:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pytest configuration for the comfyui skill test suite.
|
||||
|
||||
Adds `scripts/` to sys.path so tests can `from _common import ...`, and
|
||||
provides a few common fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
WORKFLOWS = ROOT / "workflows"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd15_workflow() -> dict:
|
||||
return json.loads((WORKFLOWS / "sd15_txt2img.json").read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flux_workflow() -> dict:
|
||||
return json.loads((WORKFLOWS / "flux_dev_txt2img.json").read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video_workflow() -> dict:
|
||||
return json.loads((WORKFLOWS / "wan_video_t2v.json").read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workflows_dir() -> Path:
|
||||
return WORKFLOWS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scripts_dir() -> Path:
|
||||
return SCRIPTS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cloud_key() -> str | None:
|
||||
"""Cloud API key if set, otherwise None.
|
||||
|
||||
Tests that need cloud connectivity should skip when this is None.
|
||||
"""
|
||||
return os.environ.get("COMFY_CLOUD_API_KEY")
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Auto-skip cloud tests when no API key is set."""
|
||||
if os.environ.get("COMFY_CLOUD_API_KEY"):
|
||||
return
|
||||
skip_cloud = pytest.mark.skip(reason="Set COMFY_CLOUD_API_KEY to run cloud tests")
|
||||
for item in items:
|
||||
if "cloud" in item.keywords:
|
||||
item.add_marker(skip_cloud)
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
name: dogfood
|
||||
description: "Exploratory QA of web apps: find bugs, evidence, reports."
|
||||
version: 1.0.0
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [qa, testing, browser, web, dogfood]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# Dogfood: Systematic Web Application QA Testing
|
||||
|
||||
## Overview
|
||||
|
||||
This skill guides you through systematic exploratory QA testing of web applications using the browser toolset. You will navigate the application, interact with elements, capture evidence of issues, and produce a structured bug report.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Browser toolset must be available (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_vision`, `browser_console`, `browser_scroll`, `browser_back`, `browser_press`)
|
||||
- A target URL and testing scope from the user
|
||||
|
||||
## Inputs
|
||||
|
||||
The user provides:
|
||||
1. **Target URL** — the entry point for testing
|
||||
2. **Scope** — what areas/features to focus on (or "full site" for comprehensive testing)
|
||||
3. **Output directory** (optional) — where to save screenshots and the report (default: `./dogfood-output`)
|
||||
|
||||
## Workflow
|
||||
|
||||
Follow this 5-phase systematic workflow:
|
||||
|
||||
### Phase 1: Plan
|
||||
|
||||
1. Create the output directory structure:
|
||||
```
|
||||
{output_dir}/
|
||||
├── screenshots/ # Evidence screenshots
|
||||
└── report.md # Final report (generated in Phase 5)
|
||||
```
|
||||
2. Identify the testing scope based on user input.
|
||||
3. Build a rough sitemap by planning which pages and features to test:
|
||||
- Landing/home page
|
||||
- Navigation links (header, footer, sidebar)
|
||||
- Key user flows (sign up, login, search, checkout, etc.)
|
||||
- Forms and interactive elements
|
||||
- Edge cases (empty states, error pages, 404s)
|
||||
|
||||
### Phase 2: Explore
|
||||
|
||||
For each page or feature in your plan:
|
||||
|
||||
1. **Navigate** to the page:
|
||||
```
|
||||
browser_navigate(url="https://example.com/page")
|
||||
```
|
||||
|
||||
2. **Take a snapshot** to understand the DOM structure:
|
||||
```
|
||||
browser_snapshot()
|
||||
```
|
||||
|
||||
3. **Check the console** for JavaScript errors:
|
||||
```
|
||||
browser_console(clear=true)
|
||||
```
|
||||
Do this after every navigation and after every significant interaction. Silent JS errors are high-value findings.
|
||||
|
||||
4. **Take an annotated screenshot** to visually assess the page and identify interactive elements:
|
||||
```
|
||||
browser_vision(question="Describe the page layout, identify any visual issues, broken elements, or accessibility concerns", annotate=true)
|
||||
```
|
||||
The `annotate=true` flag overlays numbered `[N]` labels on interactive elements. Each `[N]` maps to ref `@eN` for subsequent browser commands.
|
||||
|
||||
5. **Test interactive elements** systematically:
|
||||
- Click buttons and links: `browser_click(ref="@eN")`
|
||||
- Fill forms: `browser_type(ref="@eN", text="test input")`
|
||||
- Test keyboard navigation: `browser_press(key="Tab")`, `browser_press(key="Enter")`
|
||||
- Scroll through content: `browser_scroll(direction="down")`
|
||||
- Test form validation with invalid inputs
|
||||
- Test empty submissions
|
||||
|
||||
6. **After each interaction**, check for:
|
||||
- Console errors: `browser_console()`
|
||||
- Visual changes: `browser_vision(question="What changed after the interaction?")`
|
||||
- Expected vs actual behavior
|
||||
|
||||
### Phase 3: Collect Evidence
|
||||
|
||||
For every issue found:
|
||||
|
||||
1. **Take a screenshot** showing the issue:
|
||||
```
|
||||
browser_vision(question="Capture and describe the issue visible on this page", annotate=false)
|
||||
```
|
||||
Save the `screenshot_path` from the response — you will reference it in the report.
|
||||
|
||||
2. **Record the details**:
|
||||
- URL where the issue occurs
|
||||
- Steps to reproduce
|
||||
- Expected behavior
|
||||
- Actual behavior
|
||||
- Console errors (if any)
|
||||
- Screenshot path
|
||||
|
||||
3. **Classify the issue** using the issue taxonomy (see `references/issue-taxonomy.md`):
|
||||
- Severity: Critical / High / Medium / Low
|
||||
- Category: Functional / Visual / Accessibility / Console / UX / Content
|
||||
|
||||
### Phase 4: Categorize
|
||||
|
||||
1. Review all collected issues.
|
||||
2. De-duplicate — merge issues that are the same bug manifesting in different places.
|
||||
3. Assign final severity and category to each issue.
|
||||
4. Sort by severity (Critical first, then High, Medium, Low).
|
||||
5. Count issues by severity and category for the executive summary.
|
||||
|
||||
### Phase 5: Report
|
||||
|
||||
Generate the final report using the template at `templates/dogfood-report-template.md`.
|
||||
|
||||
The report must include:
|
||||
1. **Executive summary** with total issue count, breakdown by severity, and testing scope
|
||||
2. **Per-issue sections** with:
|
||||
- Issue number and title
|
||||
- Severity and category badges
|
||||
- URL where observed
|
||||
- Description of the issue
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Screenshot references (use `MEDIA:<screenshot_path>` for inline images)
|
||||
- Console errors if relevant
|
||||
3. **Summary table** of all issues
|
||||
4. **Testing notes** — what was tested, what was not, any blockers
|
||||
|
||||
Save the report to `{output_dir}/report.md`.
|
||||
|
||||
## Tools Reference
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `browser_navigate` | Go to a URL |
|
||||
| `browser_snapshot` | Get DOM text snapshot (accessibility tree) |
|
||||
| `browser_click` | Click an element by ref (`@eN`) or text |
|
||||
| `browser_type` | Type into an input field |
|
||||
| `browser_scroll` | Scroll up/down on the page |
|
||||
| `browser_back` | Go back in browser history |
|
||||
| `browser_press` | Press a keyboard key |
|
||||
| `browser_vision` | Screenshot + AI analysis; use `annotate=true` for element labels |
|
||||
| `browser_console` | Get JS console output and errors |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Always check `browser_console()` after navigating and after significant interactions.** Silent JS errors are among the most valuable findings.
|
||||
- **Use `annotate=true` with `browser_vision`** when you need to reason about interactive element positions or when the snapshot refs are unclear.
|
||||
- **Test with both valid and invalid inputs** — form validation bugs are common.
|
||||
- **Scroll through long pages** — content below the fold may have rendering issues.
|
||||
- **Test navigation flows** — click through multi-step processes end-to-end.
|
||||
- **Check responsive behavior** by noting any layout issues visible in screenshots.
|
||||
- **Don't forget edge cases**: empty states, very long text, special characters, rapid clicking.
|
||||
- When reporting screenshots to the user, include `MEDIA:<screenshot_path>` so they can see the evidence inline.
|
||||
@@ -0,0 +1,226 @@
|
||||
# Citation Management & Hallucination Prevention
|
||||
|
||||
This reference provides a complete workflow for managing citations programmatically, preventing AI-generated citation hallucinations, and maintaining clean bibliographies.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [Why Citation Verification Matters](#why-citation-verification-matters)
|
||||
- [Citation APIs Overview](#citation-apis-overview)
|
||||
- [Verified Citation Workflow](#verified-citation-workflow)
|
||||
- [Python Implementation](#python-implementation)
|
||||
- [BibTeX Management](#bibtex-management)
|
||||
- [Common Citation Formats](#common-citation-formats)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Why Citation Verification Matters
|
||||
|
||||
### The Hallucination Problem
|
||||
|
||||
Research has documented significant issues with AI-generated citations:
|
||||
- **~40% error rate** in AI-generated citations (Enago Academy research)
|
||||
- NeurIPS 2025 found **100+ hallucinated citations** slipped through review
|
||||
- Common errors include:
|
||||
- Fabricated paper titles with real author names
|
||||
- Wrong publication venues or years
|
||||
- Non-existent papers with plausible metadata
|
||||
- Incorrect DOIs or arXiv IDs
|
||||
|
||||
### Consequences
|
||||
|
||||
- Desk rejection at some venues
|
||||
- Loss of credibility with reviewers
|
||||
- Potential retraction if published
|
||||
- Wasted time chasing non-existent sources
|
||||
|
||||
### Solution
|
||||
|
||||
**Never generate citations from memory—always verify programmatically.**
|
||||
|
||||
---
|
||||
|
||||
## Citation APIs Overview
|
||||
|
||||
### Primary APIs
|
||||
|
||||
| API | Coverage | Rate Limits | Best For |
|
||||
|-----|----------|-------------|----------|
|
||||
| **Semantic Scholar** | 214M papers | 1 RPS (free key) | ML/AI papers, citation graphs |
|
||||
| **CrossRef** | 140M+ DOIs | Polite pool with mailto | DOI lookup, BibTeX retrieval |
|
||||
| **arXiv** | Preprints | 3-second delays | ML preprints, PDF access |
|
||||
| **OpenAlex** | 240M+ works | 100K/day, 10 RPS | Open alternative to MAG |
|
||||
|
||||
### API Selection Guide
|
||||
|
||||
```
|
||||
Need ML paper search? → Semantic Scholar
|
||||
Have DOI, need BibTeX? → CrossRef content negotiation
|
||||
Looking for preprint? → arXiv API
|
||||
Need open data, bulk access? → OpenAlex
|
||||
```
|
||||
|
||||
### No Official Google Scholar API
|
||||
|
||||
Google Scholar has no official API. Scraping violates ToS. Use SerpApi ($75-275/month) only if Semantic Scholar coverage is insufficient.
|
||||
|
||||
---
|
||||
|
||||
## Verified Citation Workflow
|
||||
|
||||
### 5-Step Process
|
||||
|
||||
```
|
||||
1. SEARCH → Query Semantic Scholar with specific keywords
|
||||
↓
|
||||
2. VERIFY → Confirm paper exists in 2+ sources
|
||||
↓
|
||||
3. RETRIEVE → Get BibTeX via DOI content negotiation
|
||||
↓
|
||||
4. VALIDATE → Confirm the claim appears in source
|
||||
↓
|
||||
5. ADD → Add verified entry to .bib file
|
||||
```
|
||||
|
||||
### Step 1: Search
|
||||
|
||||
Use Semantic Scholar for ML/AI papers:
|
||||
|
||||
```python
|
||||
from semanticscholar import SemanticScholar
|
||||
|
||||
sch = SemanticScholar()
|
||||
results = sch.search_paper("transformer attention mechanism", limit=10)
|
||||
|
||||
for paper in results:
|
||||
print(f"Title: {paper.title}")
|
||||
print(f"Year: {paper.year}")
|
||||
print(f"DOI: {paper.externalIds.get('DOI', 'N/A')}")
|
||||
print(f"arXiv: {paper.externalIds.get('ArXiv', 'N/A')}")
|
||||
print(f"Citation count: {paper.citationCount}")
|
||||
print("---")
|
||||
```
|
||||
|
||||
### Step 2: Verify Existence
|
||||
|
||||
Confirm paper exists in at least two sources:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
def verify_paper(doi=None, arxiv_id=None, title=None):
|
||||
"""Verify paper exists in multiple sources."""
|
||||
sources_found = []
|
||||
|
||||
# Check Semantic Scholar
|
||||
sch = SemanticScholar()
|
||||
if doi:
|
||||
paper = sch.get_paper(f"DOI:{doi}")
|
||||
if paper:
|
||||
sources_found.append("Semantic Scholar")
|
||||
|
||||
# Check CrossRef (via DOI)
|
||||
if doi:
|
||||
resp = requests.get(f"https://api.crossref.org/works/{doi}")
|
||||
if resp.status_code == 200:
|
||||
sources_found.append("CrossRef")
|
||||
|
||||
# Check arXiv
|
||||
if arxiv_id:
|
||||
resp = requests.get(
|
||||
f"http://export.arxiv.org/api/query?id_list={arxiv_id}"
|
||||
)
|
||||
if "<entry>" in resp.text:
|
||||
sources_found.append("arXiv")
|
||||
|
||||
return len(sources_found) >= 2, sources_found
|
||||
```
|
||||
|
||||
### Step 3: Retrieve BibTeX
|
||||
|
||||
Use DOI content negotiation for guaranteed accuracy:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
def doi_to_bibtex(doi: str) -> str:
|
||||
"""Get verified BibTeX from DOI via CrossRef content negotiation."""
|
||||
response = requests.get(
|
||||
f"https://doi.org/{doi}",
|
||||
headers={"Accept": "application/x-bibtex"},
|
||||
allow_redirects=True
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
# Example: "Attention Is All You Need"
|
||||
bibtex = doi_to_bibtex("10.48550/arXiv.1706.03762")
|
||||
print(bibtex)
|
||||
```
|
||||
|
||||
### Step 4: Validate Claims
|
||||
|
||||
Before citing a paper for a specific claim, verify the claim exists:
|
||||
|
||||
```python
|
||||
def get_paper_abstract(doi):
|
||||
"""Get abstract to verify claims."""
|
||||
sch = SemanticScholar()
|
||||
paper = sch.get_paper(f"DOI:{doi}")
|
||||
return paper.abstract if paper else None
|
||||
|
||||
# Verify claim appears in abstract
|
||||
abstract = get_paper_abstract("10.48550/arXiv.1706.03762")
|
||||
claim = "attention mechanism"
|
||||
if claim.lower() in abstract.lower():
|
||||
print("Claim appears in paper")
|
||||
```
|
||||
|
||||
### Step 5: Add to Bibliography
|
||||
|
||||
Add verified entry to your .bib file with consistent key format:
|
||||
|
||||
```python
|
||||
def generate_citation_key(bibtex: str) -> str:
|
||||
"""Generate consistent citation key: author_year_firstword."""
|
||||
import re
|
||||
|
||||
# Extract author
|
||||
author_match = re.search(r'author\s*=\s*\{([^}]+)\}', bibtex, re.I)
|
||||
if author_match:
|
||||
first_author = author_match.group(1).split(',')[0].split()[-1]
|
||||
else:
|
||||
first_author = "unknown"
|
||||
|
||||
# Extract year
|
||||
year_match = re.search(r'year\s*=\s*\{?(\d{4})\}?', bibtex, re.I)
|
||||
year = year_match.group(1) if year_match else "0000"
|
||||
|
||||
# Extract title first word
|
||||
title_match = re.search(r'title\s*=\s*\{([^}]+)\}', bibtex, re.I)
|
||||
if title_match:
|
||||
first_word = title_match.group(1).split()[0].lower()
|
||||
first_word = re.sub(r'[^a-z]', '', first_word)
|
||||
else:
|
||||
first_word = "paper"
|
||||
|
||||
return f"{first_author.lower()}_{year}_{first_word}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Python Implementation
|
||||
|
||||
### Complete Citation Manager Class
|
||||
|
||||
{% raw %}
|
||||
```python
|
||||
"""
|
||||
Citation Manager - Verified citation workflow for ML papers.
|
||||
"""
|
||||
|
||||
import requests
|
||||
import time
|
||||
from typing import Opt
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: domain-intel
|
||||
description: Passive domain reconnaissance using Python stdlib. Use this skill for subdomain discovery, SSL certificate inspection, WHOIS lookups, DNS records, domain availability checks, and bulk multi-domain analysis. No API keys required. Triggers on requests like "find subdomains", "check ssl cert", "whois lookup", "is this domain available", "bulk check these domains".
|
||||
license: MIT
|
||||
---
|
||||
|
||||
Passive domain intelligence using only Python stdlib and public data sources.
|
||||
Zero dependencies. Zero API keys. Works out of the box.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Subdomain discovery via crt.sh certificate transparency logs
|
||||
- Live SSL/TLS certificate inspection (expiry, cipher, SANs, TLS version)
|
||||
- WHOIS lookup — supports 100+ TLDs via direct TCP queries
|
||||
- DNS records: A, AAAA, MX, NS, TXT, CNAME
|
||||
- Domain availability check (DNS + WHOIS + SSL signals)
|
||||
- Bulk multi-domain analysis in parallel (up to 20 domains)
|
||||
|
||||
## Data Sources
|
||||
|
||||
- crt.sh — Certificate Transparency logs
|
||||
- WHOIS servers — Direct TCP to 100+ authoritative TLD servers
|
||||
- Google DNS-over-HTTPS — MX/NS/TXT/CNAME resolution
|
||||
- System DNS — A/AAAA records
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Tests for extract_schema.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from extract_schema import (
|
||||
extract_schema,
|
||||
find_negative_prompt_node,
|
||||
find_positive_prompt_node,
|
||||
trace_to_node,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Connection tracing
|
||||
# =============================================================================
|
||||
|
||||
class TestConnectionTracing:
|
||||
def test_direct_link(self):
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}},
|
||||
"2": {"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["1", 0]}},
|
||||
}
|
||||
assert trace_to_node(wf, ["1", 0]) == "1"
|
||||
|
||||
def test_through_reroute(self):
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}},
|
||||
"2": {"class_type": "Reroute", "inputs": {"input": ["1", 0]}},
|
||||
"3": {"class_type": "Reroute", "inputs": {"input": ["2", 0]}},
|
||||
}
|
||||
assert trace_to_node(wf, ["3", 0]) == "1"
|
||||
|
||||
def test_circular_safe(self):
|
||||
wf = {
|
||||
"1": {"class_type": "Reroute", "inputs": {"input": ["2", 0]}},
|
||||
"2": {"class_type": "Reroute", "inputs": {"input": ["1", 0]}},
|
||||
}
|
||||
# Should hit max_hops without infinite loop
|
||||
result = trace_to_node(wf, ["1", 0], max_hops=5)
|
||||
assert result in {"1", "2"} # any node, just don't hang
|
||||
|
||||
|
||||
class TestPositiveNegativeDetection:
|
||||
def test_basic(self, sd15_workflow):
|
||||
# In sd15_workflow.json node 6 is positive, node 7 is negative
|
||||
assert find_positive_prompt_node(sd15_workflow) == "6"
|
||||
assert find_negative_prompt_node(sd15_workflow) == "7"
|
||||
|
||||
def test_swapped_order(self):
|
||||
wf = {
|
||||
"3": {"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["7", 0], "negative": ["6", 0],
|
||||
"model": ["4", 0], "latent_image": ["5", 0],
|
||||
"seed": 1, "steps": 20, "cfg": 7.5,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
||||
}},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
|
||||
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}},
|
||||
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": "ugly", "clip": ["4", 1]}},
|
||||
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "beautiful", "clip": ["4", 1]}},
|
||||
}
|
||||
# Now 7 is the positive (despite higher node ID)
|
||||
assert find_positive_prompt_node(wf) == "7"
|
||||
assert find_negative_prompt_node(wf) == "6"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema extraction
|
||||
# =============================================================================
|
||||
|
||||
class TestExtractSchema:
|
||||
def test_basic_sd15(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
params = schema["parameters"]
|
||||
assert "prompt" in params
|
||||
assert "negative_prompt" in params
|
||||
assert "seed" in params
|
||||
assert "steps" in params
|
||||
assert "cfg" in params
|
||||
assert "width" in params
|
||||
assert "height" in params
|
||||
|
||||
def test_prompt_value_correct(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
# The positive prompt in the example is the landscape one
|
||||
assert "landscape" in schema["parameters"]["prompt"]["value"]
|
||||
assert "ugly" in schema["parameters"]["negative_prompt"]["value"]
|
||||
|
||||
def test_model_dependencies(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
deps = schema["model_dependencies"]
|
||||
ckpts = [d["value"] for d in deps if d["folder"] == "checkpoints"]
|
||||
assert "v1-5-pruned-emaonly.safetensors" in ckpts
|
||||
|
||||
def test_output_nodes(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
assert "9" in schema["output_nodes"]
|
||||
|
||||
def test_summary(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
s = schema["summary"]
|
||||
assert s["has_negative_prompt"] is True
|
||||
assert s["has_seed"] is True
|
||||
assert s["is_video_workflow"] is False
|
||||
assert s["parameter_count"] > 5
|
||||
|
||||
def test_flux_workflow(self, flux_workflow):
|
||||
schema = extract_schema(flux_workflow)
|
||||
# Flux uses RandomNoise for seed
|
||||
assert schema["summary"]["has_seed"] is True
|
||||
# Flux has only positive prompt (no negative encoder)
|
||||
assert schema["summary"]["has_negative_prompt"] is False
|
||||
|
||||
def test_video_detected(self, video_workflow):
|
||||
schema = extract_schema(video_workflow)
|
||||
assert schema["summary"]["is_video_workflow"] is True
|
||||
|
||||
|
||||
class TestEmbeddingDeps:
|
||||
def test_extract_from_prompt(self):
|
||||
wf = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
|
||||
"5": {"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1}},
|
||||
"6": {"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a cat, embedding:goodvibes, embedding:art:1.2",
|
||||
"clip": ["1", 1]
|
||||
}},
|
||||
"7": {"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "ugly, embedding:badhands",
|
||||
"clip": ["1", 1]
|
||||
}},
|
||||
"3": {"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["6", 0], "negative": ["7", 0],
|
||||
"model": ["1", 0], "latent_image": ["5", 0],
|
||||
"seed": 1, "steps": 20, "cfg": 7.5,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
||||
}},
|
||||
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x", "images": ["3", 0]}},
|
||||
}
|
||||
schema = extract_schema(wf)
|
||||
names = [d["embedding_name"] for d in schema["embedding_dependencies"]]
|
||||
assert sorted(names) == ["art", "badhands", "goodvibes"]
|
||||
|
||||
|
||||
class TestDuplicateDeduplication:
|
||||
def test_two_ksamplers_get_unique_names(self):
|
||||
wf = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
|
||||
"5": {"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1}},
|
||||
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": "a", "clip": ["1", 1]}},
|
||||
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "b", "clip": ["1", 1]}},
|
||||
"3": {"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["6", 0], "negative": ["7", 0],
|
||||
"model": ["1", 0], "latent_image": ["5", 0],
|
||||
"seed": 42, "steps": 20, "cfg": 7.5,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
||||
}},
|
||||
"4": {"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["6", 0], "negative": ["7", 0],
|
||||
"model": ["1", 0], "latent_image": ["5", 0],
|
||||
"seed": 99, "steps": 30, "cfg": 8.0,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 0.6,
|
||||
}},
|
||||
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x", "images": ["3", 0]}},
|
||||
}
|
||||
schema = extract_schema(wf)
|
||||
params = schema["parameters"]
|
||||
# Both seeds present with disambiguated names
|
||||
seed_keys = [k for k in params if "seed" in k]
|
||||
# Symmetric: both renamed (no bare "seed")
|
||||
assert "seed" not in params
|
||||
assert "seed_3" in params and "seed_4" in params
|
||||
assert params["seed_3"]["value"] == 42
|
||||
assert params["seed_4"]["value"] == 99
|
||||
@@ -0,0 +1,36 @@
|
||||
FIGURES_FOLDER := figures
|
||||
PDFS := \
|
||||
$(filter-out $(wildcard $(FIGURES_FOLDER)/*-crop.pdf),$(wildcard $(FIGURES_FOLDER)/*.pdf)) \
|
||||
$(filter-out $(wildcard $(FIGURES_FOLDER)/**/*-crop.pdf),$(wildcard $(FIGURES_FOLDER)/**/*.pdf))
|
||||
CROPPED_PDFS := $(PDFS:.pdf=-crop.pdf)
|
||||
|
||||
all: main.pdf
|
||||
|
||||
%.pdf: %.tex Makefile $(CROPPED_PDFS)
|
||||
pdflatex -synctex=1 -interaction=nonstopmode $<
|
||||
-bibtex $*.aux
|
||||
pdflatex -synctex=1 -interaction=nonstopmode $<
|
||||
pdflatex -synctex=1 -interaction=nonstopmode $<
|
||||
|
||||
.PHONY: figures
|
||||
figures: $(CROPPED_PDFS)
|
||||
|
||||
.PRECIOUS: $(CROPPED_PDFS)
|
||||
%-crop.pdf: %.pdf Makefile
|
||||
pdfcrop $<
|
||||
|
||||
.PHONY: clean upgrade
|
||||
clean:
|
||||
find . -maxdepth 1 \
|
||||
\( -name "*.aux" -o -name "*.bbl" -o -name "*.blg" -o \
|
||||
-name "*.log" -o -name "*.out" -o -name "*.pdf" -o \
|
||||
-name "*.synctex.gz" \) | xargs $(RM)
|
||||
find $(FIGURES_FOLDER) -name "*-crop.pdf" | xargs $(RM)
|
||||
|
||||
YEAR := 2025
|
||||
|
||||
upgrade:
|
||||
curl -O https://media.neurips.cc/Conferences/NeurIPS$(YEAR)/Styles.zip
|
||||
unzip -u Styles.zip
|
||||
mv Styles/neurips_${YEAR}.sty neurips.sty
|
||||
$(RM) -r Styles.zip Styles
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tests for extract_schema.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from extract_schema import (
|
||||
extract_schema,
|
||||
find_negative_prompt_node,
|
||||
find_positive_prompt_node,
|
||||
trace_to_node,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Connection tracing
|
||||
# =============================================================================
|
||||
|
||||
class TestConnectionTracing:
|
||||
def test_direct_link(self):
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}},
|
||||
"2": {"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["1", 0]}},
|
||||
}
|
||||
assert trace_to_node(wf, ["1", 0]) == "1"
|
||||
|
||||
def test_through_reroute(self):
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}},
|
||||
"2": {"class_type": "Reroute", "inputs": {"input": ["1", 0]}},
|
||||
"3": {"class_type": "Reroute", "inputs": {"input": ["2", 0]}},
|
||||
}
|
||||
assert trace_to_node(wf, ["3", 0]) == "1"
|
||||
|
||||
def test_circular_safe(self):
|
||||
wf = {
|
||||
"1": {"class_type": "Reroute", "inputs": {"input": ["2", 0]}},
|
||||
"2": {"class_type": "Reroute", "inputs": {"input": ["1", 0]}},
|
||||
}
|
||||
# Should hit max_hops without infinite loop
|
||||
result = trace_to_node(wf, ["1", 0], max_hops=5)
|
||||
assert result in {"1", "2"} # any node, just don't hang
|
||||
|
||||
|
||||
class TestPositiveNegativeDetection:
|
||||
def test_basic(self, sd15_workflow):
|
||||
# In sd15_workflow.json node 6 is positive, node 7 is negative
|
||||
assert find_positive_prompt_node(sd15_workflow) == "6"
|
||||
assert find_negative_prompt_node(sd15_workflow) == "7"
|
||||
|
||||
def test_swapped_order(self):
|
||||
wf = {
|
||||
"3": {"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["7", 0], "negative": ["6", 0],
|
||||
"model": ["4", 0], "latent_image": ["5", 0],
|
||||
"seed": 1, "steps": 20, "cfg": 7.5,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
||||
}},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
|
||||
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}},
|
||||
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": "ugly", "clip": ["4", 1]}},
|
||||
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "beautiful", "clip": ["4", 1]}},
|
||||
}
|
||||
# Now 7 is the positive (despite higher node ID)
|
||||
assert find_positive_prompt_node(wf) == "7"
|
||||
assert find_negative_prompt_node(wf) == "6"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema extraction
|
||||
# =============================================================================
|
||||
|
||||
class TestExtractSchema:
|
||||
def test_basic_sd15(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
params = schema["parameters"]
|
||||
assert "prompt" in params
|
||||
assert "negative_prompt" in params
|
||||
assert "seed" in params
|
||||
assert "steps" in params
|
||||
assert "cfg" in params
|
||||
assert "width" in params
|
||||
assert "height" in params
|
||||
|
||||
def test_prompt_value_correct(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
# The positive prompt in the example is the landscape one
|
||||
assert "landscape" in schema["parameters"]["prompt"]["value"]
|
||||
assert "ugly" in schema["parameters"]["negative_prompt"]["value"]
|
||||
|
||||
def test_model_dependencies(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
deps = schema["model_dependencies"]
|
||||
ckpts = [d["value"] for d in deps if d["folder"] == "checkpoints"]
|
||||
assert "v1-5-pruned-emaonly.safetensors" in ckpts
|
||||
|
||||
def test_output_nodes(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
assert "9" in schema["output_nodes"]
|
||||
|
||||
def test_summary(self, sd15_workflow):
|
||||
schema = extract_schema(sd15_workflow)
|
||||
s = schema["summary"]
|
||||
assert s["has_negative_prompt"] is True
|
||||
assert s["has_seed"] is True
|
||||
assert s["is_video_workflow"] is False
|
||||
assert s["parameter_count"] > 5
|
||||
|
||||
def test_flux_workflow(self, flux_workflow):
|
||||
schema = extract_schema(flux_workflow)
|
||||
# Flux uses RandomNoise for seed
|
||||
assert schema["summary"]["has_seed"] is True
|
||||
# Flux has only positive prompt (no negative encoder)
|
||||
assert schema["summary"]["has_negative_prompt"] is False
|
||||
|
||||
def test_video_detected(self, video_workflow):
|
||||
schema = extract_schema(video_workflow)
|
||||
assert schema["summary"]["is_video_workflow"] is True
|
||||
|
||||
|
||||
class TestEmbeddingDeps:
|
||||
def test_extract_from_prompt(self):
|
||||
wf = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
|
||||
"5": {"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1}},
|
||||
"6": {"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a cat, embedding:goodvibes, embedding:art:1.2",
|
||||
"clip": ["1", 1]
|
||||
}},
|
||||
"7": {"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "ugly, embedding:badhands",
|
||||
"clip": ["1", 1]
|
||||
}},
|
||||
"3": {"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["6", 0], "negative": ["7", 0],
|
||||
"model": ["1", 0], "latent_image": ["5", 0],
|
||||
"seed": 1, "steps": 20, "cfg": 7.5,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
||||
}},
|
||||
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x", "images": ["3", 0]}},
|
||||
}
|
||||
schema = extract_schema(wf)
|
||||
names = [d["embedding_name"] for d in schema["embedding_dependencies"]]
|
||||
assert sorted(names) == ["art", "badhands", "goodvibes"]
|
||||
|
||||
|
||||
class TestDuplicateDeduplication:
|
||||
def test_two_ksamplers_get_unique_names(self):
|
||||
wf = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
|
||||
"5": {"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1}},
|
||||
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": "a", "clip": ["1", 1]}},
|
||||
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "b", "clip": ["1", 1]}},
|
||||
"3": {"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["6", 0], "negative": ["7", 0],
|
||||
"model": ["1", 0], "latent_image": ["5", 0],
|
||||
"seed": 42, "steps": 20, "cfg": 7.5,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
||||
}},
|
||||
"4": {"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["6", 0], "negative": ["7", 0],
|
||||
"model": ["1", 0], "latent_image": ["5", 0],
|
||||
"seed": 99, "steps": 30, "cfg": 8.0,
|
||||
"sampler_name": "euler", "scheduler": "normal", "denoise": 0.6,
|
||||
}},
|
||||
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x", "images": ["3", 0]}},
|
||||
}
|
||||
schema = extract_schema(wf)
|
||||
params = schema["parameters"]
|
||||
# Both seeds present with disambiguated names
|
||||
seed_keys = [k for k in params if "seed" in k]
|
||||
# Symmetric: both renamed (no bare "seed")
|
||||
assert "seed" not in params
|
||||
assert "seed_3" in params and "seed_4" in params
|
||||
assert params["seed_3"]["value"] == 42
|
||||
assert params["seed_4"]["value"] == 99
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Named RGB palettes for pixel_art() and pixel_art_video().
|
||||
|
||||
Palette RGB values sourced from pixel-art-studio (MIT License)
|
||||
https://github.com/Synero/pixel-art-studio — see ATTRIBUTION.md.
|
||||
"""
|
||||
|
||||
PALETTES = {
|
||||
# ── Hardware palettes ───────────────────────────────────────────────
|
||||
"NES": [
|
||||
(0, 0, 0), (124, 124, 124), (0, 0, 252), (0, 0, 188), (68, 40, 188),
|
||||
(148, 0, 132), (168, 0, 32), (168, 16, 0), (136, 20, 0), (0, 116, 0),
|
||||
(0, 148, 0), (0, 120, 0), (0, 88, 0), (0, 64, 88), (188, 188, 188),
|
||||
(0, 120, 248), (0, 88, 248), (104, 68, 252), (216, 0, 204), (228, 0, 88),
|
||||
(248, 56, 0), (228, 92, 16), (172, 124, 0), (0, 184, 0), (0, 168, 0),
|
||||
(0, 168, 68), (0, 136, 136), (248, 248, 248), (60, 188, 252),
|
||||
(104, 136, 252), (152, 120, 248), (248, 120, 248), (248, 88, 152),
|
||||
(248, 120, 88), (252, 160, 68), (248, 184, 0), (184, 248, 24),
|
||||
(88, 216, 84), (88, 248, 152), (0, 232, 216), (120, 120, 120),
|
||||
(252, 252, 252), (164, 228, 252), (184, 184, 248), (216, 184, 248),
|
||||
(248, 184, 248), (248, 164, 192), (240, 208, 176), (252, 224, 168),
|
||||
(248, 216, 120), (216, 248, 120), (184, 248, 184), (184, 248, 216),
|
||||
(0, 252, 252), (216, 216, 216),
|
||||
],
|
||||
"C64": [
|
||||
(0, 0, 0), (255, 255, 255), (161, 77, 67), (106, 191, 199),
|
||||
(161, 87, 164), (92, 172, 95), (64, 64, 223), (191, 206, 137),
|
||||
(161, 104, 60), (108, 80, 21), (203, 126, 117), (98, 98, 98),
|
||||
(137, 137, 137), (154, 226, 155), (124, 124, 255), (173, 173, 173),
|
||||
],
|
||||
"COMMODORE_64": [
|
||||
(0, 0, 0), (255, 255, 255), (161, 77, 67), (106, 192, 200),
|
||||
(161, 87, 165), (92, 172, 95), (64, 68, 227), (203, 214, 137),
|
||||
(163, 104, 58), (110, 84, 11), (204, 127, 118), (99, 99, 99),
|
||||
(139, 139, 139), (154, 227, 157), (139, 127, 205), (175, 175, 175),
|
||||
],
|
||||
"ZX_SPECTRUM": [
|
||||
(0, 0, 0), (0, 39, 251), (252, 48, 22), (255, 63, 252),
|
||||
(0, 249, 44), (0, 252, 254), (255, 253, 51), (255, 255, 255),
|
||||
],
|
||||
"APPLE_II_LO": [
|
||||
(0, 0, 0), (133, 59, 81), (80, 71, 137), (234, 93, 240),
|
||||
(0, 104, 82), (146, 146, 146), (0, 168, 241), (202, 195, 248),
|
||||
(81, 92, 15), (235, 127, 35), (146, 146, 146), (246, 185, 202),
|
||||
(0, 202, 41), (203, 211, 155), (155, 220, 203), (255, 255, 255),
|
||||
],
|
||||
"APPLE_II_HI": [
|
||||
(0, 0, 0), (255, 0, 255), (0, 255, 0), (255, 255, 255),
|
||||
(0, 175, 255), (255, 80, 0),
|
||||
],
|
||||
"GAMEBOY_ORIGINAL": [
|
||||
(0, 63, 0), (46, 115, 32), (140, 191, 10), (160, 207, 10),
|
||||
],
|
||||
"GAMEBOY_POCKET": [
|
||||
(0, 0, 0), (85, 85, 85), (170, 170, 170), (255, 255, 255),
|
||||
],
|
||||
"GAMEBOY_VIRTUALBOY": [
|
||||
(239, 0, 0), (164, 0, 0), (85, 0, 0), (0, 0, 0),
|
||||
],
|
||||
"PICO_8": [
|
||||
(0, 0, 0), (29, 43, 83), (126, 37, 83), (0, 135, 81), (171, 82, 54),
|
||||
(95, 87, 79), (194, 195, 199), (255, 241, 232), (255, 0, 77),
|
||||
(255, 163, 0), (255, 236, 39), (0, 228, 54), (41, 173, 255),
|
||||
(131, 118, 156), (255, 119, 168), (255, 204, 170),
|
||||
],
|
||||
"TELETEXT": [
|
||||
(0, 0, 0), (255, 0, 0), (0, 128, 0), (255, 255, 0),
|
||||
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
|
||||
],
|
||||
"CGA_MODE4_PAL1": [
|
||||
(0, 0, 0), (255, 255, 255), (0, 255, 255), (255, 0, 255),
|
||||
],
|
||||
"MSX": [
|
||||
(0, 0, 0), (62, 184, 73), (116, 208, 125), (89, 85, 224),
|
||||
(128, 118, 241), (185, 94, 81), (101, 219, 239), (219, 101, 89),
|
||||
(255, 137, 125), (204, 195, 94), (222, 208, 135), (58, 162, 65),
|
||||
(183, 102, 181), (204, 204, 204), (255, 255, 255),
|
||||
],
|
||||
"MICROSOFT_WINDOWS_16": [
|
||||
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128),
|
||||
(128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128),
|
||||
(255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255),
|
||||
(255, 0, 255), (0, 255, 255), (255, 255, 255),
|
||||
],
|
||||
"MICROSOFT_WINDOWS_PAINT": [
|
||||
(0, 0, 0), (255, 255, 255), (123, 123, 123), (189, 189, 189),
|
||||
(123, 12, 2), (255, 37, 0), (123, 123, 2), (255, 251, 2),
|
||||
(0, 123, 2), (2, 249, 2), (0, 123, 122), (2, 253, 254),
|
||||
(2, 19, 122), (5, 50, 255), (123, 25, 122), (255, 64, 254),
|
||||
(122, 57, 2), (255, 122, 57), (123, 123, 56), (255, 252, 122),
|
||||
(2, 57, 57), (5, 250, 123), (0, 123, 255), (255, 44, 123),
|
||||
],
|
||||
"MONO_BW": [(0, 0, 0), (255, 255, 255)],
|
||||
"MONO_AMBER": [(40, 40, 40), (255, 176, 0)],
|
||||
"MONO_GREEN": [(40, 40, 40), (51, 255, 51)],
|
||||
|
||||
# ── Artistic palettes ───────────────────────────────────────────────
|
||||
"PASTEL_DREAM": [
|
||||
(255, 218, 233), (255, 229, 204), (255, 255, 204), (204, 255, 229),
|
||||
(204, 229, 255), (229, 204, 255), (255, 204, 229), (204, 255, 255),
|
||||
(255, 245, 220), (230, 230, 250),
|
||||
],
|
||||
"NEON_CYBER": [
|
||||
(0, 0, 0), (255, 0, 128), (0, 255, 255), (255, 0, 255),
|
||||
(0, 255, 128), (255, 255, 0), (128, 0, 255), (255, 128, 0),
|
||||
(0, 128, 255), (255, 255, 255),
|
||||
],
|
||||
"RETRO_WARM": [
|
||||
(62, 39, 35), (139, 69, 19), (210, 105, 30), (244, 164, 96),
|
||||
(255, 218, 185), (255, 245, 238), (178, 34, 34), (205, 92, 92),
|
||||
(255, 99, 71), (255, 160, 122),
|
||||
],
|
||||
"OCEAN_DEEP": [
|
||||
(0, 25, 51), (0, 51, 102), (0, 76, 153), (0, 102, 178),
|
||||
(0, 128, 204), (51, 153, 204), (102, 178, 204), (153, 204, 229),
|
||||
(204, 229, 255), (229, 245, 255),
|
||||
],
|
||||
"FOREST_MOSS": [
|
||||
(34, 51, 34), (51, 76, 51), (68, 102, 51), (85, 128, 68),
|
||||
(102, 153, 85), (136, 170, 102), (170, 196, 136), (204, 221, 170),
|
||||
(238, 238, 204), (245, 245, 220),
|
||||
],
|
||||
"SUNSET_FIRE": [
|
||||
(51, 0, 0), (102, 0, 0), (153, 0, 0), (204, 0, 0), (255, 0, 0),
|
||||
(255, 51, 0), (255, 102, 0), (255, 153, 0), (255, 204, 0),
|
||||
(255, 255, 51),
|
||||
],
|
||||
"ARCTIC_ICE": [
|
||||
(0, 0, 51), (0, 0, 102), (0, 51, 153), (0, 102, 153),
|
||||
(51, 153, 204), (102, 204, 255), (153, 229, 255), (204, 242, 255),
|
||||
(229, 247, 255), (255, 255, 255),
|
||||
],
|
||||
"VINTAGE_ROSE": [
|
||||
(103, 58, 63), (137, 72, 81), (170, 91, 102), (196, 113, 122),
|
||||
(219, 139, 147), (232, 168, 175), (240, 196, 199), (245, 215, 217),
|
||||
(249, 232, 233), (255, 245, 245),
|
||||
],
|
||||
"EARTH_CLAY": [
|
||||
(62, 39, 35), (89, 56, 47), (116, 73, 59), (143, 90, 71),
|
||||
(170, 107, 83), (197, 124, 95), (210, 155, 126), (222, 186, 160),
|
||||
(235, 217, 196), (248, 248, 232),
|
||||
],
|
||||
"ELECTRIC_VIOLET": [
|
||||
(26, 0, 51), (51, 0, 102), (76, 0, 153), (102, 0, 204),
|
||||
(128, 0, 255), (153, 51, 255), (178, 102, 255), (204, 153, 255),
|
||||
(229, 204, 255), (245, 229, 255),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_palette_image(palette_name):
|
||||
"""Build a 1x1 PIL 'P'-mode image with the named palette for Image.quantize(palette=...)."""
|
||||
from PIL import Image
|
||||
|
||||
if palette_name not in PALETTES:
|
||||
raise ValueError(
|
||||
f"Unknown palette {palette_name!r}. "
|
||||
f"Choose from: {sorted(PALETTES)}"
|
||||
)
|
||||
flat = []
|
||||
for (r, g, b) in PALETTES[palette_name]:
|
||||
flat.extend([r, g, b])
|
||||
# Pad to 768 bytes (256 colors) as PIL requires
|
||||
while len(flat) < 768:
|
||||
flat.append(0)
|
||||
pal_img = Image.new("P", (1, 1))
|
||||
pal_img.putpalette(flat)
|
||||
return pal_img
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
description: Skills for academic research, paper discovery, literature review, domain reconnaissance, market data, content monitoring, and scientific knowledge retrieval.
|
||||
---
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Pixel art video — overlay procedural animations onto a source image.
|
||||
|
||||
Takes any image (typically pre-processed with pixel_art()) and overlays
|
||||
animated pixel effects (stars, rain, fireflies, etc.), then encodes to MP4
|
||||
(and optionally GIF) via ffmpeg.
|
||||
|
||||
Scene animations ported from pixel-art-studio (MIT) — see ATTRIBUTION.md.
|
||||
The generative/Pollinations code is intentionally dropped — Hermes uses
|
||||
`image_generate` + `pixel_art()` for base frames instead.
|
||||
|
||||
Usage (import):
|
||||
from pixel_art_video import pixel_art_video
|
||||
pixel_art_video("frame.png", "out.mp4", scene="night", duration=6)
|
||||
|
||||
Usage (CLI):
|
||||
python pixel_art_video.py frame.png out.mp4 --scene night --duration 6 --gif
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
# ── Pixel drawing helpers ──────────────────────────────────────────────
|
||||
|
||||
def _px(draw, x, y, color, size=2):
|
||||
x, y = int(x), int(y)
|
||||
W, H = draw.im.size
|
||||
if 0 <= x < W and 0 <= y < H:
|
||||
draw.rectangle([x, y, x + size - 1, y + size - 1], fill=color)
|
||||
|
||||
|
||||
def _pixel_cross(draw, x, y, color, arm=2):
|
||||
x, y = int(x), int(y)
|
||||
for i in range(-arm, arm + 1):
|
||||
_px(draw, x + i, y, color, 1)
|
||||
_px(draw, x, y + i, color, 1)
|
||||
|
||||
|
||||
# ── Animation init/draw pairs ──────────────────────────────────────────
|
||||
|
||||
def init_stars(rng, W, H):
|
||||
return [(rng.randint(0, W), rng.randint(0, H // 2)) for _ in range(15)]
|
||||
|
||||
def draw_stars(draw, stars, t, W, H):
|
||||
for i, (sx, sy) in enumerate(stars):
|
||||
if math.sin(t * 2.0 + i * 0.7) > 0.65:
|
||||
_pixel_cross(draw, sx, sy, (255, 255, 220), arm=2)
|
||||
|
||||
|
||||
def init_fireflies(rng, W, H):
|
||||
return [{"x": rng.randint(20, W - 20), "y": rng.randint(H // 4, H - 20),
|
||||
"phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.3, 0.8)}
|
||||
for _ in range(10)]
|
||||
|
||||
def draw_fireflies(draw, ff, t, W, H):
|
||||
for f in ff:
|
||||
if math.sin(t * 1.5 + f["phase"]) < 0.15:
|
||||
continue
|
||||
_px(draw,
|
||||
f["x"] + math.sin(t * f["speed"] + f["phase"]) * 3,
|
||||
f["y"] + math.cos(t * f["speed"] * 0.7) * 2,
|
||||
(200, 255, 100), 2)
|
||||
|
||||
|
||||
def init_leaves(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),
|
||||
"speed": rng.uniform(0.5, 1.5), "wobble": rng.uniform(0.02, 0.05),
|
||||
"phase": rng.uniform(0, 6.28),
|
||||
"color": rng.choice([(180, 120, 50), (160, 100, 40), (200, 140, 60)])}
|
||||
for _ in range(12)]
|
||||
|
||||
def draw_leaves(draw, leaves, t, W, H):
|
||||
for leaf in leaves:
|
||||
_px(draw,
|
||||
leaf["x"] + math.sin(t * leaf["wobble"] + leaf["phase"]) * 15,
|
||||
(leaf["y"] + t * leaf["speed"] * 20) % (H + 40) - 20,
|
||||
leaf["color"], 2)
|
||||
|
||||
|
||||
def init_dust_motes(rng, W, H):
|
||||
return [{"x": rng.randint(30, W - 30), "y": rng.randint(30, H - 30),
|
||||
"phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.2, 0.5),
|
||||
"amp": rng.uniform(2, 6)} for _ in range(20)]
|
||||
|
||||
def draw_dust_motes(draw, motes, t, W, H):
|
||||
for m in motes:
|
||||
if math.sin(t * 2.0 + m["phase"]) > 0.3:
|
||||
_px(draw,
|
||||
m["x"] + math.sin(t * 0.3 + m["phase"]) * m["amp"],
|
||||
m["y"] - (m["speed"] * t * 15) % H,
|
||||
(255, 210, 100), 1)
|
||||
|
||||
|
||||
def init_sparkles(rng, W, H):
|
||||
return [(rng.randint(W // 4, 3 * W // 4), rng.randint(H // 4, 3 * H // 4),
|
||||
rng.uniform(0, 6.28),
|
||||
rng.choice([(180, 200, 255), (255, 220, 150), (200, 180, 255)]))
|
||||
for _ in range(10)]
|
||||
|
||||
def draw_sparkles(draw, sparkles, t, W, H):
|
||||
for sx, sy, phase, color in sparkles:
|
||||
if math.sin(t * 1.8 + phase) > 0.6:
|
||||
_pixel_cross(draw, sx, sy, color, arm=2)
|
||||
|
||||
|
||||
def init_rain(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(0, H),
|
||||
"speed": rng.uniform(4, 8)} for _ in range(30)]
|
||||
|
||||
def draw_rain(draw, rain, t, W, H):
|
||||
for r in rain:
|
||||
y = (r["y"] + t * r["speed"] * 20) % H
|
||||
_px(draw, r["x"], y, (120, 150, 200), 1)
|
||||
_px(draw, r["x"], y + 4, (100, 130, 180), 1)
|
||||
|
||||
|
||||
def init_lightning(rng, W, H):
|
||||
return {"timer": 0, "flash": False, "rng": rng}
|
||||
|
||||
def draw_lightning(draw, state, t, W, H):
|
||||
state["timer"] += 1
|
||||
if state["timer"] > 45 and state["rng"].random() < 0.04:
|
||||
state["flash"] = True
|
||||
state["timer"] = 0
|
||||
if state["flash"]:
|
||||
for x in range(0, W, 4):
|
||||
for y in range(0, H // 3, 3):
|
||||
if state["rng"].random() < 0.12:
|
||||
_px(draw, x, y, (255, 255, 240), 2)
|
||||
state["flash"] = False
|
||||
|
||||
|
||||
def init_bubbles(rng, W, H):
|
||||
return [{"x": rng.randint(20, W - 20), "y": rng.randint(H, H * 2),
|
||||
"speed": rng.uniform(0.3, 0.8), "size": rng.choice([1, 2, 2])}
|
||||
for _ in range(15)]
|
||||
|
||||
def draw_bubbles(draw, bubbles, t, W, H):
|
||||
for b in bubbles:
|
||||
x = b["x"] + math.sin(t * 0.5 + b["x"]) * 3
|
||||
y = b["y"] - (t * b["speed"] * 20) % (H + 40)
|
||||
if 0 < y < H:
|
||||
_px(draw, x, y, (150, 200, 255), b["size"])
|
||||
|
||||
|
||||
def init_embers(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(0, H),
|
||||
"speed": rng.uniform(0.3, 0.9), "phase": rng.uniform(0, 6.28),
|
||||
"color": rng.choice([(255, 150, 30), (255, 100, 20), (255, 200, 50)])}
|
||||
for _ in range(18)]
|
||||
|
||||
def draw_embers(draw, embers, t, W, H):
|
||||
for e in embers:
|
||||
x = e["x"] + math.sin(t * 0.4 + e["phase"]) * 5
|
||||
y = e["y"] - (t * e["speed"] * 15) % H
|
||||
if math.sin(t * 2.5 + e["phase"]) > 0.2:
|
||||
_px(draw, x, y, e["color"], 2)
|
||||
|
||||
|
||||
def init_snowflakes(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),
|
||||
"speed": rng.uniform(0.3, 0.6), "wobble": rng.uniform(0.04, 0.09),
|
||||
"size": rng.choice([2, 2, 3])}
|
||||
for _ in range(40)]
|
||||
|
||||
def draw_snowflakes(draw, flakes, t, W, H):
|
||||
for f in flakes:
|
||||
x = f["x"] + math.sin(t * f["wobble"] + f["x"]) * 20
|
||||
y = (f["y"] + t * f["speed"] * 8) % (H + 20) - 10
|
||||
if f["size"] >= 3:
|
||||
_pixel_cross(draw, x, y, (230, 235, 255), arm=1)
|
||||
else:
|
||||
_px(draw, x, y, (230, 235, 255), 2)
|
||||
|
||||
|
||||
def init_neon_pulse(rng, W, H):
|
||||
return [(rng.randint(0, W), rng.randint(0, H), rng.uniform(0, 6.28),
|
||||
rng.choice([(255, 0, 200), (0, 255, 255), (255, 50, 150)]))
|
||||
for _ in range(8)]
|
||||
|
||||
def draw_neon_pulse(draw, points, t, W, H):
|
||||
for x, y, phase, color in points:
|
||||
if math.sin(t * 2.5 + phase) > 0.5:
|
||||
_pixel_cross(draw, x, y, color, arm=3)
|
||||
|
||||
|
||||
def init_heat_shimmer(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(H // 2, H),
|
||||
"phase": rng.uniform(0, 6.28)} for _ in range(12)]
|
||||
|
||||
def draw_heat_shimmer(draw, points, t, W, H):
|
||||
for p in points:
|
||||
x = p["x"] + math.sin(t * 0.8 + p["phase"]) * 2
|
||||
y = p["y"] + math.sin(t * 1.2 + p["phase"]) * 1
|
||||
if abs(math.sin(t * 1.5 + p["phase"])) > 0.6:
|
||||
_px(draw, x, y, (255, 200, 100), 1)
|
||||
|
||||
|
||||
# ── Scene → animation mapping ──────────────────────────────────────────
|
||||
|
||||
SCENES = {
|
||||
"night": ["stars", "fireflies", "leaves"],
|
||||
"dusk": ["fireflies", "sparkles"],
|
||||
"tavern": ["dust_motes", "sparkles"],
|
||||
"indoor": ["dust_motes"],
|
||||
"urban": ["rain", "neon_pulse"],
|
||||
"nature": ["leaves", "fireflies"],
|
||||
"magic": ["sparkles", "fireflies"],
|
||||
"storm": ["rain", "lightning"],
|
||||
"underwater": ["bubbles", "sparkles"],
|
||||
"fire": ["embers", "sparkles"],
|
||||
"snow": ["snowflakes", "sparkles"],
|
||||
"desert": ["heat_shimmer", "dust_motes"],
|
||||
}
|
||||
|
||||
# Map scene layer name to (init_fn, draw_fn).
|
||||
_LAYERS = {
|
||||
"stars": (init_stars, draw_stars),
|
||||
"fireflies": (init_fireflies, draw_fireflies),
|
||||
"leaves": (init_leaves, draw_leaves),
|
||||
"dust_motes": (init_dust_motes, draw_dust_motes),
|
||||
"sparkles": (init_sparkles, draw_sparkles),
|
||||
"rain": (init_rain, draw_rain),
|
||||
"lightning": (init_lightning, draw_lightning),
|
||||
"bubbles": (init_bubbles, draw_bubbles),
|
||||
"embers": (init_embers, draw_embers),
|
||||
"snowflakes": (init_snowflakes, draw_snowflakes),
|
||||
"neon_pulse": (init_neon_pulse, draw_neon_pulse),
|
||||
"heat_shimmer": (init_heat_shimmer, draw_heat_shimmer),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_ffmpeg():
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise RuntimeError(
|
||||
"ffmpeg not found on PATH. Install via your package manager or "
|
||||
"download from https://ffmpeg.org/"
|
||||
)
|
||||
|
||||
|
||||
def pixel_art_video(
|
||||
base_image,
|
||||
output_path,
|
||||
scene="night",
|
||||
duration=6,
|
||||
fps=15,
|
||||
seed=None,
|
||||
export_gif=False,
|
||||
):
|
||||
"""Overlay pixel animations onto a base image and encode to MP4.
|
||||
|
||||
Args:
|
||||
base_image: path to source image (ideally already pixel-art styled)
|
||||
output_path: path to MP4 output (GIF sibling written if export_gif=True)
|
||||
scene: key from SCENES (night, urban, storm, snow, fire, ...)
|
||||
duration: seconds of animation
|
||||
fps: frames per second (default 15 for retro feel)
|
||||
seed: optional int for reproducible animation placement
|
||||
export_gif: also write a GIF alongside the MP4
|
||||
|
||||
Returns:
|
||||
(mp4_path, gif_path_or_None)
|
||||
"""
|
||||
if scene not in SCENES:
|
||||
raise ValueError(
|
||||
f"Unknown scene {scene!r}. Choose from: {sorted(SCENES)}"
|
||||
)
|
||||
_ensure_ffmpeg()
|
||||
|
||||
base = Image.open(base_image).convert("RGB")
|
||||
W, H = base.size
|
||||
|
||||
rng = random.Random(seed if seed is not None else 42)
|
||||
layers = []
|
||||
for name in SCENES[scene]:
|
||||
init_fn, draw_fn = _LAYERS[name]
|
||||
layers.append((draw_fn, init_fn(rng, W, H)))
|
||||
|
||||
n_frames = fps * duration
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="pixelart_frames_") as frames_dir:
|
||||
for frame_idx in range(n_frames):
|
||||
canvas = base.copy()
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
t = frame_idx / fps
|
||||
for draw_fn, state in layers:
|
||||
draw_fn(draw, state, t, W, H)
|
||||
canvas.save(os.path.join(frames_dir, f"frame_{frame_idx:04d}.png"))
|
||||
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error",
|
||||
"-framerate", str(fps),
|
||||
"-i", os.path.join(frames_dir, "frame_%04d.png"),
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",
|
||||
output_path],
|
||||
check=True,
|
||||
)
|
||||
|
||||
gif_path = None
|
||||
if export_gif:
|
||||
gif_path = output_path.rsplit(".", 1)[0] + ".gif"
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error",
|
||||
"-framerate", str(fps),
|
||||
"-i", os.path.join(frames_dir, "frame_%04d.png"),
|
||||
"-vf",
|
||||
"scale=320:-1:flags=neighbor,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
|
||||
"-loop", "0",
|
||||
gif_path],
|
||||
check=True,
|
||||
)
|
||||
|
||||
return output_path, gif_path
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="Overlay pixel animations onto an image → MP4.")
|
||||
p.add_argument("base_image")
|
||||
p.add_argument("output")
|
||||
p.add_argument("--scene", default="night", choices=sorted(SCENES))
|
||||
p.add_argument("--duration", type=int, default=6)
|
||||
p.add_argument("--fps", type=int, default=15)
|
||||
p.add_argument("--seed", type=int, default=None)
|
||||
p.add_argument("--gif", action="store_true")
|
||||
args = p.parse_args()
|
||||
mp4, gif = pixel_art_video(
|
||||
args.base_image, args.output,
|
||||
scene=args.scene, duration=args.duration,
|
||||
fps=args.fps, seed=args.seed, export_gif=args.gif,
|
||||
)
|
||||
print(f"Wrote {mp4}")
|
||||
if gif:
|
||||
print(f"Wrote {gif}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user