diff --git a/.github/ISSUE_TEMPLATE/DOCS_ISSUE.yml b/.github/ISSUE_TEMPLATE/DOCS_ISSUE.yml new file mode 100644 index 00000000000..4de7c5b4e21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/DOCS_ISSUE.yml @@ -0,0 +1,22 @@ +name: Documentation issue +description: Report a mistake or a gap on a documentation page +title: '[docs]: ' +labels: ['documentation'] + +body: + - type: input + id: page + attributes: + label: Page + description: The documentation page with the problem. The "Report an issue" link on each page fills this in. + placeholder: https://v7.invoke.ai/... + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What is wrong or missing? + description: Quote the passage if it helps, and tell us what you expected to find. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/SHOWCASE.yml b/.github/ISSUE_TEMPLATE/SHOWCASE.yml new file mode 100644 index 00000000000..db3392ce5f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/SHOWCASE.yml @@ -0,0 +1,55 @@ +name: Showcase submission +description: Submit artwork you made with Invoke for the showcase on the docs site +title: '[showcase]: ' + +body: + - type: markdown + attributes: + value: | + Thanks for sharing your work. A maintainer reviews each submission before it appears on the [showcase](https://v7.invoke.ai/showcase/), credited to you. + + - type: textarea + id: artwork + attributes: + label: Artwork + description: Drag your image into this box, or paste it. PNG, JPEG or WebP, ideally at least 2000 px on the long side. Upload the original, not a screenshot. + validations: + required: true + + - type: input + id: title + attributes: + label: Title + validations: + required: true + + - type: input + id: artist + attributes: + label: Artist name + description: The name to credit, exactly as it should appear. + validations: + required: true + + - type: input + id: artist-link + attributes: + label: Artist link + description: Optional. Your portfolio, website or social profile. + placeholder: https:// + + - type: textarea + id: made-with + attributes: + label: Model and workflow + description: Optional. The model you used, such as FLUX.2 Klein, and anything notable about how you made it. + + - type: checkboxes + id: permission + attributes: + label: Permission + options: + - label: I made this image. + required: true + - label: I grant Invoke permission to display it on the Invoke docs site with credit; I can ask for removal anytime. + required: true diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 2a80831aeea..557c316d78a 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -46,7 +46,14 @@ jobs: filters: | docs: - '.github/workflows/deploy-docs.yml' + - 'crowdin.yml' - 'docs/**' + # The docs color tokens are generated from the app's theme. + - 'invokeai/frontend/webv2/src/platform/ui/theme/themes.ts' + # docs/src/assets/v7-logo.webp must match the app's logo (docs/src/lib/app-assets.test.mjs). + - 'invokeai/frontend/webv2/src/assets/V7Logo.webp' + # The app's committed copy of the docs search index must match the build (check-app-search-index). + - 'invokeai/frontend/webv2/src/workbench/palette/docsSearchIndex.json' - 'scripts/generate_docs_json.py' - 'invokeai/app/**' - 'invokeai/backend/**' @@ -66,6 +73,9 @@ jobs: steps: - name: checkout uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1 + with: + # Full history: each page's "Updated" date comes from its last commit. + fetch-depth: 0 # Python (needed for generate-docs-data) - name: setup uv @@ -118,6 +128,10 @@ jobs: ENABLE_ANALYTICS: ${{ github.ref == 'refs/heads/main' }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: verify app search index + run: pnpm run check-app-search-index + working-directory: docs + - name: verify deploy output run: pnpm run check-deploy-output working-directory: docs diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000000..bca663c4471 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,42 @@ +# Crowdin's GitHub integration reads this file from main, uploads the English sources and opens a pull request with +# the translations. The credentials are only for running the Crowdin CLI locally. docs/README.md has the runbook. +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN +preserve_hierarchy: true + +# Only the user-facing docs trees. development/ and contributing/ stay English and show as fallbacks in every locale. +# A translation lands at docs/src/content/docs//; the site builds the locales that +# docs/src/config/locales.mjs lists. MDX pages use the MDX v2 parser; .md pages are plain Markdown, where braces are +# text. A tree has an .md entry only while it has .md pages: the Crowdin CLI fails on a pattern that matches nothing. +files: + - source: '/docs/src/content/docs/index.mdx' + translation: '/docs/src/content/docs/%two_letters_code%/%original_file_name%' + type: mdx_v2 + + - source: '/docs/src/content/docs/start-here/**/*.mdx' + translation: '/docs/src/content/docs/%two_letters_code%/start-here/**/%original_file_name%' + type: mdx_v2 + ignore: ['/docs/src/content/docs/**/assets/**'] + + - source: '/docs/src/content/docs/Users Guide/**/*.mdx' + translation: '/docs/src/content/docs/%two_letters_code%/Users Guide/**/%original_file_name%' + type: mdx_v2 + ignore: ['/docs/src/content/docs/**/assets/**'] + - source: '/docs/src/content/docs/Users Guide/**/*.md' + translation: '/docs/src/content/docs/%two_letters_code%/Users Guide/**/%original_file_name%' + ignore: ['/docs/src/content/docs/**/assets/**'] + + - source: '/docs/src/content/docs/configuration/**/*.mdx' + translation: '/docs/src/content/docs/%two_letters_code%/configuration/**/%original_file_name%' + type: mdx_v2 + ignore: ['/docs/src/content/docs/**/assets/**'] + + - source: '/docs/src/content/docs/troubleshooting/**/*.mdx' + translation: '/docs/src/content/docs/%two_letters_code%/troubleshooting/**/%original_file_name%' + type: mdx_v2 + ignore: ['/docs/src/content/docs/**/assets/**'] + + # The site's UI strings. `_one`/`_other` keys are i18next plural forms; Crowdin exports each language's own. + - source: '/docs/src/content/i18n/en.json' + translation: '/docs/src/content/i18n/%two_letters_code%.json' + type: i18next_json diff --git a/docs/README.md b/docs/README.md index 591a5c353f9..bedac6c23b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1 +1,107 @@ # Invoke AI Documentation + +The docs site at [v7.invoke.ai](https://v7.invoke.ai), built with Astro Starlight. Paths below are relative to `docs/`; `crowdin.yml` is at the repository root. + +Contributor guide: [Documentation](src/content/docs/development/Documentation/index.mdx) (dev server, adding pages, builds, tests). + +Community content: [showcase artwork](src/content/showcase/README.md), [weekly challenge winners](src/content/challenges/README.md) and [community node packs](src/content/community-nodes/README.md). + +Toolchain: Node 22.23.4 or newer (`engines` in `package.json`; Astro needs it) and pnpm 10, run from this directory. + +## Translations + +English is the source of truth. Translators work in the Crowdin project ([crowdin.com/project/invoke](https://crowdin.com/project/invoke), `CROWDIN_PROJECT_URL` in `src/config/locales.mjs`; confirm this URL once the project is set up, since `src/content/docs/contributing/translations.md` links it too). Crowdin's GitHub integration reads `crowdin.yml`, uploads the English sources from `main` and opens a pull request with the translations. There is no Crowdin workflow or repository secret. + +The site builds the locales in `src/config/locales.mjs`: English at the root, and `de`, `es` and `hi` under `//`. A page without a translation is served in English with Starlight's "not translated yet" notice. A translated page's footer link reads **Translate this page** and opens Crowdin instead of GitHub. + +### What Crowdin owns + +- `src/content/docs/{de,es,hi}/**`: translated pages at the English page's path. +- `src/content/i18n/{de,es,hi}.json`: translated interface strings. + +Don't edit these by hand: the next sync overwrites them. Fix the English source, or the translation in Crowdin. + +Crowdin gets the home page (`src/content/docs/index.mdx`), `start-here/`, `Users Guide/`, `configuration/`, `troubleshooting/` and `src/content/i18n/en.json`. `development/` and `contributing/` stay English and show as fallbacks in every locale. Releases, download and showcase pages exist only at the root, in English. + +Translations are not marked `linguist-generated` in `.gitattributes`: translated MDX compiles into the site, so its diffs stay expanded for review. + +### Adding pages and UI strings + +- **Pages.** A new `.mdx` page in a translated tree reaches Crowdin on the next sync. A tree's first `.md` page needs an `.md` entry in `crowdin.yml` (the Crowdin CLI fails on patterns that match nothing, so only `Users Guide/` has one). A new top-level docs directory goes into `ENGLISH_TREES` in `src/lib/translation-checks.mjs`, and into `crowdin.yml` if it should be translated. +- **UI strings.** Add the English string to `src/content/i18n/en.json` and read it with `Astro.locals.t('key')`, or with the `Trans` component (`src/lib/components/Trans.astro`) when it contains markup such as `…`. Placeholders are i18next `{{name}}`. Plurals are `key_one`/`key_other`, read with `t('key', { count })`, or in a client script with `pluralForms` (`src/lib/i18n.mjs`). `src/lib/i18n-keys.test.mjs` fails when code uses a key `en.json` lacks, or `en.json` has a key nothing uses. Give short or ambiguous strings a context note in Crowdin (below). + +### Adding a locale + +1. Add the language as a target in Crowdin. +2. Add a lowercase key to `locales` in `src/config/locales.mjs`, with the language's own name as `label` and its BCP 47 tag as `lang` (for example `'zh-cn': { label: '简体中文', lang: 'zh-CN' }`). +3. For a regional variant, map Crowdin's code to that lowercase directory, because `%two_letters_code%` drops the region. Add this to every `files` entry in `crowdin.yml`: + + ```yaml + languages_mapping: + two_letters_code: + zh-CN: zh-cn + ``` + +Nothing else changes: Starlight's routes, the language menu, `scripts/verify-deploy-output.mjs` and the translation checks all read `locales.mjs`. Check in a build that the site font covers the script (Inter has no Devanagari, for example). + +### Reviewing a Crowdin pull request + +Docs CI runs `pnpm test`, the build (links included) and the deploy checks on it. `src/lib/translation-checks.mjs` parses each translated page as the build does (Astro's frontmatter parser, then MDX or Markdown with the build's GitHub Flavored Markdown and Starlight directive plugins) and compares it with the English page at the same path. A translation may change prose, comments and code samples; anything else fails with the file and the offending key, element or value: + +- a directory in `src/content/docs/` or a file in `src/content/i18n/` for a locale `locales.mjs` doesn't configure; +- a file in a locale directory that isn't a page, or a page with no English page at the same path; +- a page that doesn't parse; +- `import`/`export` code that differs from English; +- a `{…}` expression, JSX element, aside or other container directive (`:::note`), attribute or attribute value the English page lacks. Prose attributes (`alt`, `aria-label`, `description`, `label`, `title`) may change but take no `<`, since Starlight renders card titles as HTML; +- a link, image or reference URL the English page lacks, autolinked URLs included (the `#fragment` may change); +- raw HTML the English page lacks (in `.md` pages); +- frontmatter beyond `title`, `description`, `hero.title`, `hero.tagline`, `sidebar.label` and `hero.actions[].text` that differs from English, such as `head`, `template` or a link, or any of those containing `<`; +- a string in `.json` with a key `en.json` lacks (plural messages may use any of `_zero`, `_one`, `_two`, `_few`, `_many`, `_other`), different `{{placeholders}}` or tags, or ``, `` and `` tags around the matching words. | +| `nav.siteNavLabel` | Landmark name for screen readers. | +| `sidebar.group.*` | Navigation section names. | +| `home.hero.announcement` | Keep "Invoke V7". | +| `home.*.eyebrow` | Short label above a home page section heading. | +| `home.directory.*.kicker` | One-word verbs. | +| `home.cta.platformsAndLicense` | Keep the OS names and "Apache 2.0". | +| `home.install.body` | Keep the three tags; their order may change. | +| `home.community.body` | Keep the `` tag and the maintainers' names. | +| `home.challenge.*` | The home page's teaser for the latest weekly art challenge on Discord. `{{week}}` is the challenge's number; the places label the winners' thumbnails. | +| `whatsNew.*.body` | Keep node and model names. | +| `modelFamilies.licenseTag`, `modelFamilies.vramTag` | `` text is read only by screen readers. Keep "GB+". | +| `modelFamilies.format.singleFile` | A model file format, listed beside GGUF and int8: one checkpoint file. | +| `imageCompare.valueText` | A slider's spoken value: `{{before}}` and `{{after}}` are percentages, `{{beforeLabel}}` and `{{afterLabel}}` the two images' labels, such as "Before" and "After". | +| `workflowDemo.field` | `{{field}}` and `{{node}}` stay English mock names. | +| `workflowDemo.connected`, `workflowDemo.disconnected` | `{{source}}` and `{{target}}` are filled with the `workflowDemo.field` message. | +| `communityNodes.manualInstallNote` | Keep the `` tags; `nodes` and `__init__.py` stay as they are. | +| `communityNodes.shown_*` | `{{shown}}` packs match the filters, of `{{count}}` in all; the plural form follows `{{count}}`, the total. | +| `communityNodes.copyDone`, `communityNodes.copyFailed` | Short button states. | +| `communityNodes.byAuthor` | `{{author}}` is a GitHub or GitLab user name. | +| `communityNodes.dependencies`, `communityNodes.manualInstall` | Short badge text on a node pack card. | diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 1bff499fe13..0f3ffdd88e7 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -1,23 +1,33 @@ // @ts-check import { fileURLToPath } from 'node:url'; import { defineConfig } from 'astro/config'; +import { unified } from '@astrojs/markdown-remark'; import starlight from '@astrojs/starlight'; // Plugins import starlightLinksValidator from 'starlight-links-validator'; import starlightLlmsText from 'starlight-llms-txt'; import starlightChangelogs from 'starlight-changelogs'; +import starlightSidebarTopics from 'starlight-sidebar-topics'; import { rehypePrefixBaseToRootLinks } from './plugins/rehype-prefix-base-to-root-links.mjs'; +import { remarkLocalizeAssets } from './plugins/remark-localize-assets.mjs'; import { remarkSortPrefixSlug } from './plugins/remark-sort-prefix-slug.mjs'; -import starlightContextualMenu from 'starlight-contextual-menu'; +import { rehypeTableScroll } from './plugins/rehype-table-scroll.mjs'; +import { themeTokens, THEME_TOKENS_MODULE_ID } from './plugins/theme-tokens.mjs'; +import starlightMarkdown from 'starlight-markdown'; // Configs +import { locales } from './src/config/locales.mjs'; import { createHeadConfig, createRedirects, - sidebarConfig, + sidebarTopics, + sidebarTopicsOptions, socialConfig, } from './src/config'; +import * as appThemes from '../invokeai/frontend/webv2/src/platform/ui/theme/themes.ts'; + +const appThemesPath = new URL('../invokeai/frontend/webv2/src/platform/ui/theme/themes.ts', import.meta.url); // Deployment target: 'custom' (default, custom domain at v7.invoke.ai) or 'ghpages' // (GitHub Pages project URL at invoke-ai.github.io/InvokeAI). Drive site/base from this @@ -28,69 +38,124 @@ const enableAnalytics = process.env.ENABLE_ANALYTICS === 'true'; const base = isGhPages ? '/InvokeAI' : ''; const site = isGhPages ? 'https://invoke-ai.github.io' : 'https://v7.invoke.ai'; +const docsDir = fileURLToPath(new URL('./src/content/docs', import.meta.url)); const redirects = createRedirects(base); const head = createHeadConfig({ base, enableAnalytics, isGhPages, site }); +/** + * Runs the docs route middleware before starlight-sidebar-topics narrows the sidebar to the current topic; the + * `routeMiddleware` option would run it after. + * @type {import('@astrojs/starlight/types').StarlightPlugin} + */ +const docsRouteData = { + name: 'invoke-docs-route-data', + hooks: { + 'config:setup'({ addRouteMiddleware }) { + addRouteMiddleware({ entrypoint: './src/route-data.ts', order: 'pre' }); + }, + }, +}; + +/** + * starlight-changelogs builds every release page once per locale, and has no option to limit that. Releases are English + * GitHub content, so its hooks see only the root locale: release pages exist at the root only (see `ROOT_ONLY_PATHS`). + * @type {() => import('@astrojs/starlight/types').StarlightPlugin} + */ +const starlightChangelogsAtRoot = () => { + const plugin = starlightChangelogs(); + return { + ...plugin, + hooks: { + ...plugin.hooks, + 'config:setup'(options) { + const config = { ...options.config, locales: { root: locales.root } }; + return plugin.hooks['config:setup']?.({ ...options, config }); + }, + }, + }; +}; + // https://astro.build/config export default defineConfig({ site, base: base || undefined, markdown: { - remarkPlugins: [[remarkSortPrefixSlug, { docsDir: fileURLToPath(new URL('./src/content/docs', import.meta.url)) }]], - rehypePlugins: [[rehypePrefixBaseToRootLinks, { base }]], + processor: unified({ + remarkPlugins: [ + [remarkSortPrefixSlug, { docsDir }], + [remarkLocalizeAssets, { docsDir }], + ], + rehypePlugins: [[rehypePrefixBaseToRootLinks, { base }], rehypeTableScroll], + }), }, integrations: [ starlight({ // Content + // Starlight shows the default language's title where a locale has none. title: { en: 'InvokeAI Documentation', }, - logo: { - src: './src/assets/invoke-icon-wide.svg', - alt: 'InvokeAI Logo', - replacesTitle: true, - }, favicon: 'favicon.svg', editLink: { baseUrl: 'https://github.com/invoke-ai/InvokeAI/edit/main/docs', }, head, + lastUpdated: true, defaultLocale: 'root', - locales: { - root: { - label: 'English', - lang: 'en', - }, - }, + locales, social: socialConfig, tableOfContents: { maxHeadingLevel: 4, }, customCss: [ + THEME_TOKENS_MODULE_ID, '@fontsource-variable/inter', '@fontsource-variable/roboto-mono', './src/styles/custom.css', ], - sidebar: sidebarConfig, - routeMiddleware: './src/route-data.ts', + expressiveCode: { + styleOverrides: { + // The inner radius: the frame's 1px border brings its outer radius to the content-container radius. + borderRadius: 'calc(var(--docs-radius-container) - 1px)', + focusBorder: 'var(--docs-accent)', + }, + }, components: { ThemeProvider: './src/lib/components/ForceDarkTheme.astro', ThemeSelect: './src/lib/components/EmptyComponent.astro', + Header: './src/lib/components/Header.astro', + Search: './src/lib/components/Search.astro', + // The mobile drawer's language picker (in Starlight's `MobileMenuFooter`); `Header` shows another copy. + LanguageSelect: './src/lib/components/LanguageMenu.astro', + Sidebar: './src/lib/components/Sidebar.astro', + PageTitle: './src/lib/components/PageTitle.astro', + MarkdownContent: './src/lib/components/MarkdownContent.astro', + PageSidebar: './src/lib/components/PageSidebar.astro', + Pagination: './src/lib/components/Pagination.astro', Footer: './src/lib/components/Footer.astro', PageFrame: './src/layouts/PageFrameExtended.astro', }, plugins: [ + // Before starlight-sidebar-topics: see `docsRouteData`. + docsRouteData, + starlightSidebarTopics(sidebarTopics, sidebarTopicsOptions), starlightLinksValidator({ errorOnRelativeLinks: false, errorOnLocalLinks: false, + // A translated page may link into its own locale (`/de/…`), where pages not translated yet are fallback + // routes. They are built, but the validator rejects them by default. + errorOnFallbackPages: false, }), - starlightLlmsText(), - starlightChangelogs(), - starlightContextualMenu({ - actions: ['copy', 'view', 'chatgpt', 'claude'], + starlightLlmsText({ + // Interactive controls rendered into a page, such as a filter's search field and chips, are noise as text. + customSelectors: { all: ['[data-llms-ignore]'] }, }), + starlightChangelogsAtRoot(), + // Each docs page's Markdown source at `/index.md`, for the page actions in the right rail. + starlightMarkdown(), ], }), + themeTokens({ themesModule: appThemes, themesPath: appThemesPath }), ], redirects, }); diff --git a/docs/package.json b/docs/package.json index dfe5a99445c..f00eb053d87 100644 --- a/docs/package.json +++ b/docs/package.json @@ -9,29 +9,38 @@ "check-docs-data": "pnpm run generate-docs-data && git diff --exit-code -- src/generated", "check-deploy-output": "node ./scripts/verify-deploy-output.mjs", "check-redirects": "node ./scripts/validate-redirect-targets.mjs", - "test": "node --test \"src/**/*.test.mjs\"", + "refresh-launcher-release": "node ./scripts/refresh-launcher-release.mjs", + "sync-app-search-index": "node ./scripts/sync-app-search-index.mjs", + "check-app-search-index": "node ./scripts/sync-app-search-index.mjs --check", + "test": "node --test \"src/**/*.test.mjs\" \"plugins/**/*.test.mjs\"", "build": "astro build", "preview": "astro preview", "astro": "astro" }, "dependencies": { - "@astrojs/markdown-remark": "^7.3.2", - "@astrojs/starlight": "^0.42.6", - "@fontsource-variable/inter": "^5.2.8", - "@fontsource-variable/roboto-mono": "^5.2.9", - "astro": "^7.3.8", + "@astrojs/markdown-remark": "^7.3.4", + "@astrojs/starlight": "^0.42.7", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/roboto-mono": "^5.3.0", + "@lucide/astro": "^1.61.0", + "astro": "^7.4.2", "github-slugger": "^2.0.0", - "mermaid": "^11.15.0", + "mermaid": "^12.2.0", "rehype-external-links": "^3.0.0", "sharp": "^0.35.5", - "starlight-changelogs": "^0.5.0", - "starlight-contextual-menu": "^0.1.5", + "starlight-changelogs": "^0.7.1", "starlight-links-validator": "^0.27.0", - "starlight-llms-txt": "^0.12.0" + "starlight-llms-txt": "^0.12.0", + "starlight-markdown": "^0.1.5", + "starlight-sidebar-topics": "^0.9.0" }, "devDependencies": { - "node-addon-api": "^8.8.0", - "node-gyp": "^12.3.0" + "@mdx-js/mdx": "3.1.1", + "node-addon-api": "^8.9.2", + "node-gyp": "^13.1.0" + }, + "engines": { + "node": ">=22.23.4" }, "packageManager": "pnpm@10.12.4" } diff --git a/docs/plugins/rehype-table-scroll.mjs b/docs/plugins/rehype-table-scroll.mjs new file mode 100644 index 00000000000..27da484a4bc --- /dev/null +++ b/docs/plugins/rehype-table-scroll.mjs @@ -0,0 +1,30 @@ +/** + * Wraps each table in a `div.table-scroll`. The wrapper carries the table's border and scrolls a table wider than the + * content column, so the table itself can fill the column with ordinary table layout and size its columns to their + * longest word or code token instead of breaking them. The wrapper's `data-label` names the table, from its caption + * or else the nearest heading before it, for the scroll region the page makes of a table that overflows + * (`src/lib/components/MarkdownContent.astro`, which also names a table that has neither). + */ +export function rehypeTableScroll() { + return (tree) => { + let lastHeading = ''; + const visit = (node) => { + if (!Array.isArray(node.children)) return; + node.children = node.children.map((child) => { + if (child.type !== 'element') return child; + if (/^h[1-6]$/.test(child.tagName)) lastHeading = textOf(child).trim(); + if (child.tagName !== 'table') { + visit(child); + return child; + } + const caption = child.children.find((cell) => cell.type === 'element' && cell.tagName === 'caption'); + const label = (caption && textOf(caption).trim()) || lastHeading || undefined; + return { type: 'element', tagName: 'div', properties: { className: ['table-scroll'], dataLabel: label }, children: [child] }; + }); + }; + visit(tree); + }; +} + +const textOf = (node) => + node.type === 'text' ? node.value : Array.isArray(node.children) ? node.children.map(textOf).join('') : ''; diff --git a/docs/plugins/rehype-table-scroll.test.mjs b/docs/plugins/rehype-table-scroll.test.mjs new file mode 100644 index 00000000000..789a7af0e28 --- /dev/null +++ b/docs/plugins/rehype-table-scroll.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { rehypeTableScroll } from './rehype-table-scroll.mjs'; + +const element = (tagName, children = []) => ({ type: 'element', tagName, properties: {}, children }); +const text = (value) => ({ type: 'text', value }); + +describe('rehypeTableScroll', () => { + it('wraps every table, nested ones included, and nothing else', () => { + const outer = element('table'); + const nested = element('table'); + const tree = { type: 'root', children: [element('p'), outer, element('aside', [nested])] }; + rehypeTableScroll()(tree); + + const [paragraph, wrapper, aside] = tree.children; + assert.equal(paragraph.tagName, 'p'); + assert.deepEqual([wrapper.tagName, wrapper.properties.className, wrapper.children], ['div', ['table-scroll'], [outer]]); + assert.deepEqual(aside.children[0].children, [nested]); + }); + + it('names each table by its caption, else the nearest heading before it, else leaves it unnamed', () => { + const tree = { + type: 'root', + children: [ + element('table'), + element('h2', [text('Environment '), element('code', [text('variables')])]), + element('aside', [element('table')]), + element('table', [element('caption', [text('Support tiers')])]), + ], + }; + rehypeTableScroll()(tree); + const labels = [tree.children[0], tree.children[2].children[0], tree.children[3]].map((w) => w.properties.dataLabel); + assert.deepEqual(labels, [undefined, 'Environment variables', 'Support tiers']); + }); +}); diff --git a/docs/plugins/remark-localize-assets.mjs b/docs/plugins/remark-localize-assets.mjs new file mode 100644 index 00000000000..8d64abed677 --- /dev/null +++ b/docs/plugins/remark-localize-assets.mjs @@ -0,0 +1,64 @@ +import { statSync } from 'node:fs'; +import path from 'node:path'; + +import { translatedLocales } from '../src/config/locales.mjs'; + +const relativePathRegEx = /^\.\.?\//; +const MODULE_SOURCE_TYPES = new Set(['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration']); + +/** @param {string} filePath */ +const isFile = (filePath) => statSync(filePath, { throwIfNoEntry: false })?.isFile() ?? false; + +/** + * Crowdin adds translated pages to `src/content/docs//` but not the images beside them, so a translated + * page's relative image paths (inline and reference-style) and MDX import and export sources, copied from the English + * page, would lead nowhere. Point each one at the English page's file, unless the translation ships its own. Paths + * that are not files, such as relative page links, stay as they are. Frontmatter is out of reach: a translated page's + * frontmatter cannot use relative image paths. + * @param {{ docsDir: string }} options + */ +export function remarkLocalizeAssets({ docsDir }) { + return (tree, file) => { + const filePath = file.history[0]; + if (!filePath) return; + const [locale, ...sourceSegments] = path.relative(docsDir, filePath).split(path.sep); + if (!translatedLocales.includes(locale) || sourceSegments.length === 0) return; + + const localizedDirectory = path.dirname(filePath); + const sourceDirectory = path.dirname(path.join(docsDir, ...sourceSegments)); + /** @param {string} url */ + const toSource = (url) => { + if (!relativePathRegEx.test(url)) return url; + const suffixIndex = url.search(/[?#]/); + const filePart = suffixIndex === -1 ? url : url.slice(0, suffixIndex); + const sourceTarget = path.resolve(sourceDirectory, filePart); + if (isFile(path.resolve(localizedDirectory, filePart)) || !isFile(sourceTarget)) return url; + const relative = path.relative(localizedDirectory, sourceTarget).split(path.sep).join('/'); + return `${relative}${suffixIndex === -1 ? '' : url.slice(suffixIndex)}`; + }; + + walk(tree, (node) => { + if (node.type === 'image' || node.type === 'definition') { + node.url = toSource(node.url); + } else if (node.type === 'mdxjsEsm') { + // MDX compiles imports and exports from the estree, not from the node's source text. + for (const statement of node.data?.estree?.body ?? []) { + if (!MODULE_SOURCE_TYPES.has(statement.type) || !statement.source) continue; + const specifier = toSource(statement.source.value); + if (specifier === statement.source.value) continue; + statement.source.value = specifier; + statement.source.raw = JSON.stringify(specifier); + } + } + }); + }; +} + +/** + * @param {{ type: string, children?: unknown[] } & Record} node + * @param {(node: any) => void} visitor + */ +function walk(node, visitor) { + visitor(node); + for (const child of node.children ?? []) walk(child, visitor); +} diff --git a/docs/plugins/remark-localize-assets.test.mjs b/docs/plugins/remark-localize-assets.test.mjs new file mode 100644 index 00000000000..fb10d70589e --- /dev/null +++ b/docs/plugins/remark-localize-assets.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { after, describe, it } from 'node:test'; + +import { remarkLocalizeAssets } from './remark-localize-assets.mjs'; + +const docsDir = mkdtempSync(path.join(tmpdir(), 'remark-localize-assets-')); +after(() => rmSync(docsDir, { recursive: true, force: true })); + +for (const file of [ + 'Users Guide/06.Gallery/assets/board.png', + 'Users Guide/assets/shared.webp', + 'de/Users Guide/06.Gallery/assets/board.png', +]) { + mkdirSync(path.dirname(path.join(docsDir, file)), { recursive: true }); + writeFileSync(path.join(docsDir, file), ''); +} + +/** + * @param {string} source + * @param {string} [type] the module statement + */ +const importNode = (source, type = 'ImportDeclaration') => ({ + type: 'mdxjsEsm', + data: { estree: { body: [{ type, source: { type: 'Literal', value: source, raw: `'${source}'` } }] } }, +}); + +/** + * @param {string} file the page, relative to the docs directory + * @param {object[]} children + */ +const transform = (file, children) => { + const tree = { type: 'root', children }; + remarkLocalizeAssets({ docsDir })(tree, { history: [path.join(docsDir, file)] }); + return tree.children; +}; + +describe('remarkLocalizeAssets', () => { + it("points a translated page's images, imports and exports at the English page's files", () => { + const [image, definition, esm, exportAll, exportNamed] = transform('es/Users Guide/06.Gallery/boards.mdx', [ + { type: 'paragraph', children: [{ type: 'image', url: './assets/board.png' }] }, + { type: 'definition', identifier: 'board', url: './assets/board.png#large' }, + importNode('../assets/shared.webp?url'), + importNode('../assets/shared.webp', 'ExportAllDeclaration'), + importNode('./assets/board.png', 'ExportNamedDeclaration'), + ]); + assert.equal(image.children[0].url, '../../../Users Guide/06.Gallery/assets/board.png'); + assert.equal(definition.url, '../../../Users Guide/06.Gallery/assets/board.png#large'); + assert.deepEqual(esm.data.estree.body[0].source, { + type: 'Literal', + value: '../../../Users Guide/assets/shared.webp?url', + raw: '"../../../Users Guide/assets/shared.webp?url"', + }); + assert.equal(exportAll.data.estree.body[0].source.value, '../../../Users Guide/assets/shared.webp'); + assert.equal(exportNamed.data.estree.body[0].source.value, '../../../Users Guide/06.Gallery/assets/board.png'); + }); + + it("keeps a translation's own files, and paths that are not files of the English page", () => { + const [own, link, directory, missing, localExport] = transform('de/Users Guide/06.Gallery/boards.mdx', [ + { type: 'image', url: './assets/board.png' }, + { type: 'definition', identifier: 'intro', url: '../introduction' }, + // A relative page link that names a directory. + { type: 'definition', identifier: 'assets', url: './assets/' }, + importNode('./assets/missing.png'), + // `export const`, without a source. + { type: 'mdxjsEsm', data: { estree: { body: [{ type: 'ExportNamedDeclaration', source: null }] } } }, + ]); + assert.equal(own.url, './assets/board.png'); + assert.equal(link.url, '../introduction'); + assert.equal(directory.url, './assets/'); + assert.equal(missing.data.estree.body[0].source.value, './assets/missing.png'); + assert.equal(localExport.data.estree.body[0].source, null); + }); + + it('leaves English pages and unknown locale directories alone', () => { + for (const file of ['Users Guide/06.Gallery/boards.mdx', 'it/Users Guide/06.Gallery/boards.mdx']) { + const [image] = transform(file, [{ type: 'image', url: './assets/board.png' }]); + assert.equal(image.url, './assets/board.png'); + } + }); +}); diff --git a/docs/plugins/theme-tokens.mjs b/docs/plugins/theme-tokens.mjs new file mode 100644 index 00000000000..2f12d773691 --- /dev/null +++ b/docs/plugins/theme-tokens.mjs @@ -0,0 +1,117 @@ +// Color tokens for the docs, generated from the app's Classic theme so the two never drift apart. The theme module is +// `invokeai/frontend/webv2/src/platform/ui/theme/themes.ts`; `astro.config.mjs` imports it and hands it to this +// integration, which serves its colors as `--invoke-*` custom properties from a virtual CSS module. +// `src/styles/custom.css` maps those onto Starlight's variables and the docs' semantic tokens. + +import { fileURLToPath } from 'node:url'; + +export const THEME_TOKENS_MODULE_ID = 'virtual:invoke-theme-tokens.css'; +const RESOLVED_MODULE_ID = `\0${THEME_TOKENS_MODULE_ID}`; + +const THEME_ID = 'classic'; +const NEUTRAL_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]; +const PAIRED_COLORS = ['brand', 'accent']; +const SINGLE_COLORS = ['danger', 'success', 'warning', 'inset', 'fill', 'grid', 'control']; +// A CSS color function or hex color: anything else would need a matching change here and in `custom.css`. +const COLOR_RE = /^(?:(?:oklch|oklab|lab|lch|rgba?|hsla?|color)\([^;{}]+\)|#[\da-f]{3,8})$/i; +const UPDATE_HINT = 'Update docs/plugins/theme-tokens.mjs and docs/src/styles/custom.css to match.'; + +/** + * Reads the Classic theme's colors as token name -> CSS color, e.g. `neutral-950`, `accent`, `accent-contrast`. + * Throws, failing the build, when the theme module no longer has the shape the docs styles are built on. + * @param {unknown} themesModule the namespace of the app's `themes.ts` + * @returns {Record} + */ +export const extractThemeTokens = (themesModule) => { + const themes = /** @type {{ THEMES?: unknown }} */ (themesModule)?.THEMES; + const theme = Array.isArray(themes) ? themes.find((candidate) => candidate?.id === THEME_ID) : undefined; + if (!theme?.colors) { + throw new Error(`themes.ts no longer exports a THEMES entry with id "${THEME_ID}" and colors. ${UPDATE_HINT}`); + } + + /** @type {Record} */ + const tokens = {}; + const problems = []; + /** @param {string} name @param {string} path @param {unknown} value */ + const add = (name, path, value) => { + if (typeof value === 'string' && COLOR_RE.test(value.trim())) tokens[name] = value.trim(); + else problems.push(`colors.${path} is ${JSON.stringify(value)}`); + }; + + const { colors } = theme; + for (const step of NEUTRAL_STEPS) add(`neutral-${step}`, `neutral.${step}`, colors.neutral?.[step]); + for (const name of PAIRED_COLORS) { + add(name, `${name}.solid`, colors[name]?.solid); + add(`${name}-contrast`, `${name}.contrast`, colors[name]?.contrast); + } + for (const name of SINGLE_COLORS) add(name, name, colors[name]); + + if (problems.length > 0) { + throw new Error(`The "${THEME_ID}" theme in themes.ts has an unexpected shape: ${problems.join('; ')}. ${UPDATE_HINT}`); + } + return tokens; +}; + +/** + * The app's derived interaction and status fills (`invokeai/frontend/webv2/src/platform/ui/theme/system.ts`), for a + * dark theme, written over the theme colors so they follow them. + */ +const DERIVED_TOKENS = { + // `bg.subtle`: the panel surface the fills below are mixed into. + surface: 'var(--invoke-neutral-900)', + // `gray.hoverTint`: the foreground pulled toward the accent, applied at low alpha for hover fills. + 'hover-tint': 'color-mix(in oklab, var(--invoke-accent) 55%, var(--invoke-neutral-50))', + // `bg.hover` (`gray.hoverTint/10`): the hovered fill of tabs, rows and ghost buttons. + 'bg-hover': 'color-mix(in oklab, var(--invoke-hover-tint) 10%, transparent)', + // The shown tab's fill: `gray.hoverTint/22`, the app's subtle button fill. The segment tabs' own `/15` + // (`SegmentTabs.tsx`) all but disappears on the docs' tinted asides; `/22` keeps a 1.7:1 step on every surface. + 'bg-shown': 'color-mix(in oklab, var(--invoke-hover-tint) 22%, transparent)', + // `border.emphasized`: segment tab dividers. + 'border-emphasized': 'var(--invoke-neutral-500)', +}; + +/** + * Declares the tokens on `::backdrop` too, which does not inherit from `:root` and is where Starlight resolves its + * dialog backdrop color. + * @param {Record} tokens + */ +export const themeTokensCss = (tokens) => + `:root,\n::backdrop {\n${Object.entries({ ...tokens, ...DERIVED_TOKENS }) + .map(([name, value]) => ` --invoke-${name}: ${value};\n`) + .join('')}}\n`; + +/** + * Vite plugin serving the tokens as `virtual:invoke-theme-tokens.css`. + * @param {unknown} themesModule the namespace of the app's `themes.ts` + * @param {string} [themesPath] the theme file, for the dev server to watch: it is outside the docs root + */ +export const themeTokensVitePlugin = (themesModule, themesPath) => { + // Extract eagerly so a theme change that breaks the contract fails as soon as the config loads. + const css = themeTokensCss(extractThemeTokens(themesModule)); + return { + name: 'invoke-theme-tokens', + resolveId: (id) => (id === THEME_TOKENS_MODULE_ID ? RESOLVED_MODULE_ID : undefined), + load: (id) => (id === RESOLVED_MODULE_ID ? css : undefined), + configureServer: (server) => { + if (themesPath) server.watcher.add(themesPath); + }, + }; +}; + +/** + * Astro integration adding the tokens' Vite plugin. `astro dev` restarts when the theme file changes (Astro restarts + * for files added with `addWatchFile`, once the dev server's watcher sees them), which reloads the config and with it + * a fresh copy of the theme module, so theme edits show without a manual restart. An edit to this file still needs + * one. + * @param {{ themesModule: unknown, themesPath: URL }} options the namespace of the app's `themes.ts`, and its location + * @returns {import('astro').AstroIntegration} + */ +export const themeTokens = ({ themesModule, themesPath }) => ({ + name: 'invoke-theme-tokens', + hooks: { + 'astro:config:setup': ({ addWatchFile, updateConfig }) => { + addWatchFile(themesPath); + updateConfig({ vite: { plugins: [themeTokensVitePlugin(themesModule, fileURLToPath(themesPath))] } }); + }, + }, +}); diff --git a/docs/plugins/theme-tokens.test.mjs b/docs/plugins/theme-tokens.test.mjs new file mode 100644 index 00000000000..5cc1284ae90 --- /dev/null +++ b/docs/plugins/theme-tokens.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { extractThemeTokens, THEME_TOKENS_MODULE_ID, themeTokensVitePlugin } from './theme-tokens.mjs'; + +const steps = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]; + +/** A theme module shaped like the app's `themes.ts`, with a distinct color per slot. */ +const makeThemes = () => { + const colors = { + neutral: Object.fromEntries(steps.map((step) => [step, `oklch(${step / 10}% 0 0)`])), + brand: { solid: '#e4fd1d', contrast: 'oklch(1% 0 0)' }, + accent: { solid: 'oklch(77% 0.1 231)', contrast: 'oklch(2% 0 0)' }, + danger: 'rgb(200 0 0)', + success: 'hsl(120 50% 50%)', + warning: 'oklch(70% 0.1 60)', + inset: 'oklch(3% 0 0)', + fill: 'oklch(4% 0 0)', + grid: 'oklch(5% 0 0)', + control: 'oklch(6% 0 0)', + }; + return { + THEMES: [ + { id: 'light', colors: { ...colors, accent: { solid: 'oklch(50% 0.2 260)', contrast: '#fff' } } }, + { id: 'classic', colors }, + ], + }; +}; + +describe('extractThemeTokens', () => { + it("accepts the app's current themes.ts", async () => { + // The docs require a Node version that runs TypeScript without a build step. + const themes = await import('../../invokeai/frontend/webv2/src/platform/ui/theme/themes.ts'); + assert.equal(Object.keys(extractThemeTokens(themes)).length, steps.length + 4 + 7); + }); + + it('reads every Classic color, whatever its position in the theme list', () => { + const tokens = extractThemeTokens(makeThemes()); + assert.equal(Object.keys(tokens).length, steps.length + 4 + 7); + assert.equal(tokens['neutral-950'], 'oklch(95% 0 0)'); + assert.equal(tokens.accent, 'oklch(77% 0.1 231)'); + assert.equal(tokens['accent-contrast'], 'oklch(2% 0 0)'); + assert.equal(tokens.brand, '#e4fd1d'); + assert.equal(tokens.control, 'oklch(6% 0 0)'); + }); + + it('fails when the Classic theme is gone', () => { + const themes = makeThemes(); + themes.THEMES = themes.THEMES.filter((theme) => theme.id !== 'classic'); + assert.throws(() => extractThemeTokens(themes), /id "classic"/); + assert.throws(() => extractThemeTokens({}), /id "classic"/); + }); + + it('names every missing or malformed color', () => { + const themes = makeThemes(); + const { colors } = themes.THEMES[1]; + delete colors.neutral[500]; + colors.accent = { solid: 'oklch(77% 0.1 231)' }; + colors.grid = 'oklch(5% 0 0); color: red'; + assert.throws( + () => extractThemeTokens(themes), + (error) => + ['colors.neutral.500', 'colors.accent.contrast', 'colors.grid'].every((path) => error.message.includes(path)) + ); + }); +}); + +describe('themeTokensVitePlugin', () => { + it('serves the tokens as custom properties from the virtual module', () => { + const plugin = themeTokensVitePlugin(makeThemes()); + const resolved = plugin.resolveId(THEME_TOKENS_MODULE_ID); + const css = plugin.load(resolved); + assert.match(css, /^:root,\n::backdrop \{\n/); + assert.match(css, /\n {2}--invoke-accent: oklch\(77% 0\.1 231\);\n/); + assert.match(css, /\n {2}--invoke-neutral-50: oklch\(5% 0 0\);\n/); + // The app's derived fills build on the theme colors rather than copying them. + assert.match(css, /\n {2}--invoke-bg-shown: color-mix\(in oklab, var\(--invoke-hover-tint\) 22%, transparent\);\n/); + assert.equal(plugin.resolveId('virtual:other.css'), undefined); + assert.equal(plugin.load('other'), undefined); + }); + + it('fails as soon as it is created from a broken theme', () => { + assert.throws(() => themeTokensVitePlugin({ THEMES: [] }), /id "classic"/); + }); +}); diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index c92bbfe0f49..c4a223e3c2b 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -9,26 +9,29 @@ importers: .: dependencies: '@astrojs/markdown-remark': - specifier: ^7.3.2 - version: 7.3.2 + specifier: ^7.3.4 + version: 7.3.4 '@astrojs/starlight': - specifier: ^0.42.6 - version: 0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + specifier: ^0.42.7 + version: 0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) '@fontsource-variable/inter': - specifier: ^5.2.8 - version: 5.2.8 + specifier: ^5.3.0 + version: 5.3.0 '@fontsource-variable/roboto-mono': - specifier: ^5.2.9 - version: 5.2.9 + specifier: ^5.3.0 + version: 5.3.0 + '@lucide/astro': + specifier: ^1.61.0 + version: 1.61.0(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) astro: - specifier: ^7.3.8 - version: 7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + specifier: ^7.4.2 + version: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) github-slugger: specifier: ^2.0.0 version: 2.0.0 mermaid: - specifier: ^11.15.0 - version: 11.15.0 + specifier: ^12.2.0 + version: 12.2.0 rehype-external-links: specifier: ^3.0.0 version: 3.0.0 @@ -36,114 +39,120 @@ importers: specifier: ^0.35.5 version: 0.35.5(@types/node@24.19.2) starlight-changelogs: - specifier: ^0.5.0 - version: 0.5.0(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) - starlight-contextual-menu: - specifier: ^0.1.5 - version: 0.1.5(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))(starlight-markdown@0.1.5(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))) + specifier: ^0.7.1 + version: 0.7.1(@astrojs/starlight@0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) starlight-links-validator: specifier: ^0.27.0 - version: 0.27.0(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + version: 0.27.0(@astrojs/starlight@0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) starlight-llms-txt: specifier: ^0.12.0 - version: 0.12.0(@astrojs/markdown-remark@7.3.2)(@astrojs/markdown-satteri@0.4.3)(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + version: 0.12.0(@astrojs/markdown-remark@7.3.4)(@astrojs/markdown-satteri@0.4.5)(@astrojs/starlight@0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + starlight-markdown: + specifier: ^0.1.5 + version: 0.1.5(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + starlight-sidebar-topics: + specifier: ^0.9.0 + version: 0.9.0(@astrojs/starlight@0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) devDependencies: + '@mdx-js/mdx': + specifier: 3.1.1 + version: 3.1.1 node-addon-api: - specifier: ^8.8.0 - version: 8.8.0 + specifier: ^8.9.2 + version: 8.9.2 node-gyp: - specifier: ^12.3.0 - version: 12.3.0 + specifier: ^13.1.0 + version: 13.1.0 packages: - '@antfu/install-pkg@1.1.0': - resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@antfu/install-pkg@2.1.0': + resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} '@ascorbic/loader-utils@1.0.2': resolution: {integrity: sha512-pg43g83gojVtEsAkXfjWuzJhuXneJp4wM/leBftGkCPV3yxKgB92EWA+nWu735BgbBMph3P7DrVqVc3ikt+dJA==} peerDependencies: astro: ^4.14.0 || ^5.0.0-beta.0 - '@astrojs/compiler-binding-android-arm64@0.5.1': - resolution: {integrity: sha512-nMb2nMIJtcWLNH+fV75Enmi+jp4tIOYOfsv4XtpgdOWkqc9P4uSSdK5u1xb340AzrXo5V18SxLl/nc3vcqtamQ==} + '@astrojs/compiler-binding-android-arm64@0.5.2': + resolution: {integrity: sha512-kckIxon6Rm/Bxzh9mGPYpRRqUbToUQoHptCmpMmE+Yt0Xumq8CqrKUidyYhrBsrHVO7bxuRzzWUUqvpf+iYceQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@astrojs/compiler-binding-darwin-arm64@0.5.1': - resolution: {integrity: sha512-5k4+nZ/WRt+dcVow4z7rBuI7iv9A58h/uQ91YJe1PrTivbcCkq3u2Ynx/AZJwII4CsZawBCeGpojkFjutZBhBQ==} + '@astrojs/compiler-binding-darwin-arm64@0.5.2': + resolution: {integrity: sha512-1ZG5Ar3dD8HtodX9XH1mRmE0qBXZlvjYXSFfpgFwocqfWnvhFuqtGUNseL8wgfImiRMztIjpQqlogQFQQtgiag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@astrojs/compiler-binding-darwin-x64@0.5.1': - resolution: {integrity: sha512-0Buw+YST9Ff2EqQyhRMQujuFq/dijLw3zXWhsvI0dJ2pGrT38iVoxTEca2eBX/PfhlwkVWSRJEKO5gMqKHyeHg==} + '@astrojs/compiler-binding-darwin-x64@0.5.2': + resolution: {integrity: sha512-+kl35S7hiHJ2cb7eujwEsPFYYuSgE11uEE2Z20lAJ+undpgulMcW+weQqFPOSSZccEKHOiLIY+kQHkb4e4092w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@astrojs/compiler-binding-linux-arm64-gnu@0.5.1': - resolution: {integrity: sha512-x5LRpyCoMKuu3INr18cn42YhwSOaRpp9b5Ny3eJJ/sWVeejC16Zeshu1JlvlSmEZACLeWxU5JM0RX6T6nI+YyA==} + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.2': + resolution: {integrity: sha512-BqlR0EHNQLPLwgL9+xogjsAc7zGf0cHho2CPdhv0P1yqbVMwGVaGAqrANP6VYAXCqSC43AF4Czcxc2n+oqdesg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@astrojs/compiler-binding-linux-arm64-musl@0.5.1': - resolution: {integrity: sha512-mL7tQEuDk3rGfaKmrO63nw6WBy1TPVioQGmVyjcAr+C+a/k3SVMYBytNSMqH9mZPT/Swj30JaZ2N842Z1NnOEQ==} + '@astrojs/compiler-binding-linux-arm64-musl@0.5.2': + resolution: {integrity: sha512-GRGyby3AZWw5d6JQzidyxPyvGgHWsSusrpgKyFVpDTKctABF7L3Pd5V1cRJbV36/rcu+Eh292/wL7pF7MkmGkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@astrojs/compiler-binding-linux-x64-gnu@0.5.1': - resolution: {integrity: sha512-uLGc9jtxwiDe4cQyhNPQim4ECiB/YzbWlXssfLAv8r374kArfTwBHjwR2+j+XVyF71yCx5mF9YNRclRqdNBgeg==} + '@astrojs/compiler-binding-linux-x64-gnu@0.5.2': + resolution: {integrity: sha512-9XAqGTMQAtIslMsjK3xdyY6OI9d59OZqcLBB6pvD/DcoR0daYswfrKx2azdDERDY4AqAnWPC7odVM1BmxYv3Vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@astrojs/compiler-binding-linux-x64-musl@0.5.1': - resolution: {integrity: sha512-G0cywinaQUc8v1MrytvYKUX7svTDMmYtOCZfxjwKPbJi59zIqM+Ll0rWgO0GQWLZH3XoPYo19ZwlP3Xt9ep9YA==} + '@astrojs/compiler-binding-linux-x64-musl@0.5.2': + resolution: {integrity: sha512-jOViewGeTh9bSfS8vK72ECxoy2yvmA+Pd/ZcEvBI5c7+7IcfHwZ5qA4L9fRYuOhSrAifvQ2dV4vm1XAtloaWBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@astrojs/compiler-binding-wasm32-wasi@0.5.1': - resolution: {integrity: sha512-yMhLL8asmLydoAdnEZxAw6eLfNDs3YJl6jqn0pjPt6zrb2U5qIDLnii4/twThpIFODKkQ6YDpBRTOz/vSfZCxw==} + '@astrojs/compiler-binding-wasm32-wasi@0.5.2': + resolution: {integrity: sha512-Z7p64doar1uvR5C9Xs6j1CV9lGlPVQX7OQ2aAtZ/xKC5aGS6GB13nyc2UfQXTQLvZrvVwsyaflTQy6ziY5Bekw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@astrojs/compiler-binding-win32-arm64-msvc@0.5.1': - resolution: {integrity: sha512-aClIG6poFt3Wf/bZykrEAX2nAnKvBHW6RXiKUa69QjBtWH0/FOlOAjnFcxRNlhej2AMWNYFx0o+LGjwMcH8P7Q==} + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.2': + resolution: {integrity: sha512-Tr13bh+ahTWsjXjzctK7pFpWV+dUU4FtBfETlR7gO+ueex0F+RDch7eRr0YfKysaRhk9tWm14lsCJ5uWyFPNHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@astrojs/compiler-binding-win32-x64-msvc@0.5.1': - resolution: {integrity: sha512-/E6uBCagY70YpWqssBC+/FuniNSloeQPRgj7l3LDWiqjxIyTLnIgX1sTt18FU7jrigetpSnC9cHAM6b6IxSgPw==} + '@astrojs/compiler-binding-win32-x64-msvc@0.5.2': + resolution: {integrity: sha512-SfKkI+jOQYLh6wojPvVPV0lFfQnxisweN9B+qCzRvZp7DHAuGif8Y0WcC5PUgN4jb8VKy57LggQDtggR46Du1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@astrojs/compiler-binding@0.5.1': - resolution: {integrity: sha512-oczyolLUl9mNrtuBuNjuvn5thpHrFLXAHAWGTRg0JDHQ8cGVsVeFekOu2uSoQGgainJIi+A3/9m0DyYwz3Nlnw==} + '@astrojs/compiler-binding@0.5.2': + resolution: {integrity: sha512-lEn76+0JcMQ5RDXU0wSMWJg2ZppiZvSULpghC29cfv4o9bhvWFDbQjsCtvjjXN9iVLFQ7fL3wTR2L2UU13mP8g==} engines: {node: ^20.19.0 || >=22.12.0} - '@astrojs/compiler-rs@0.5.1': - resolution: {integrity: sha512-1k1acnX7DepIiea9U9/Y+d1hdAFfylu1ZQQkkYgRSwBOBAsXInolyZuh/2tax+83vu/A+hOYP6P3+265RvMTvw==} + '@astrojs/compiler-rs@0.5.2': + resolution: {integrity: sha512-IvwYLB8t1M86rKO4oPdBi9GP4gzXcgrVlNgocjwos1jeeUuEIV8d9Crn1PvYD/5IKteWOxpIHomf7e8iYlkE+Q==} engines: {node: '>=22.12.0'} - '@astrojs/internal-helpers@0.12.0': - resolution: {integrity: sha512-pKPuwCDtO0L7zgmt1JGd9X4Aes1+0SX3IEBSqBNPHsScYdPMlfmed3Y7DTbXyF7E+8ylnb563o/0OzGQPOFehw==} + '@astrojs/internal-helpers@0.13.0': + resolution: {integrity: sha512-20jxQqnSmpskcGyad7N5l6Xf1gz0UCa6lNde44bEBdaXGpncEYfAZJ142r2ZgzBaG4dUyzGmhqZWv1INU2eiHQ==} - '@astrojs/markdown-remark@7.3.2': - resolution: {integrity: sha512-ubszw8SVEI4OHR5Y+0jTbkmzG8xatrW47iAkQNqH7wUM1bky7DOnr51FGgoUdXt/fmKpUl7sRRX9talmU64Y+g==} + '@astrojs/markdown-remark@7.3.4': + resolution: {integrity: sha512-AB8S4EQKSryzH624iMDtvC+1AD5nSPzYpCXxc7e1H5ydXhv9B5Td2QfCPA0JyEwqsJpsIsPlW0iOqiy1imdqYw==} - '@astrojs/markdown-satteri@0.4.3': - resolution: {integrity: sha512-PFbEHPnwUlLvpXd7RXrrKM+j12akF4bG+DSuNuoN+3eiwjOwp4HaPi+QQdbkBV7QEYXmzyKca50OJsmGxhNIUA==} + '@astrojs/markdown-satteri@0.4.5': + resolution: {integrity: sha512-0IeGdoFbv0f+442KJhYS44Wg8F3MuHmRsxIEYuFyqkjRubc6dPBFZ7azor5t4AeXLQoT5A5h9HyViKpd70Dl9w==} - '@astrojs/mdx@8.0.3': - resolution: {integrity: sha512-SmwnVrGZSohDG0B9LXiAPbR6fe9TOumli9Vw/9zo815xGTeB1T3Fqt6HEClmBsVwIKFg/ccos4TgjZAedvg7zA==} - engines: {node: '>=22.12.0'} + '@astrojs/mdx@8.1.1': + resolution: {integrity: sha512-jEJUJsO2KqmKfBXaKeDHDCCj+Kerj1Fj+XndHznF9wxahvs+1onYCBeKmieM28N2+uA6mW57DOQQw5YaDLdBnA==} + engines: {node: '>=22.23.4'} peerDependencies: '@astrojs/markdown-remark': ^7.3.0 '@astrojs/markdown-satteri': ^0.4.0 @@ -152,15 +161,15 @@ packages: '@astrojs/markdown-remark': optional: true - '@astrojs/prism@4.0.2': - resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} - engines: {node: '>=22.12.0'} + '@astrojs/prism@4.1.1': + resolution: {integrity: sha512-sXlAXJX6t4QfuLg+CuCDzpg7NoxWcyb4paFprgtFppLeisCBmh935h+tJ5CWXfsXEoW2IDy/ihbRMtiu28Chjw==} + engines: {node: '>=22.23.4'} '@astrojs/sitemap@3.7.4': resolution: {integrity: sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==} - '@astrojs/starlight@0.42.6': - resolution: {integrity: sha512-Cfs7GdgzgNRCauDxQVsFUVL1thrGHK/rQdbv/SURv/xhGBXx+j5ypOHW9ENPraQxbIzUC8KAnJV7L9mvEVRuyQ==} + '@astrojs/starlight@0.42.7': + resolution: {integrity: sha512-mlbit5iRjLx5eIcLf84Q2CwkrrsEgZrQwKKBK+vRf+ZGiSrwy1UGdT1hUfAIaHrMfcvkDpL+gw3aua7HobiKFg==} peerDependencies: '@astrojs/markdown-remark': ^7.3.0 astro: ^7.2.10 @@ -168,25 +177,25 @@ packages: '@astrojs/markdown-remark': optional: true - '@astrojs/telemetry@3.3.3': - resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@astrojs/telemetry@3.4.1': + resolution: {integrity: sha512-SMkvMsGOpM0FMAoT5pzhgI3LZC0JSPWf7Evwop+fZ3N6UkusPM7lI+sOJjMNI5OJrVoiei1/AGo1cY7sikTc5A==} + engines: {node: '>=22.23.4'} - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + '@babel/helper-string-parser@7.29.11': + resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==} engines: {node: '>=6.9.0'} '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.9': - resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + '@babel/parser@7.29.11': + resolution: {integrity: sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/types@7.29.8': - resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + '@babel/types@7.29.11': + resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==} engines: {node: '>=6.9.0'} '@braintree/sanitize-url@7.1.2': @@ -241,8 +250,20 @@ packages: resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} - '@chevrotain/types@11.1.2': - resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@chevrotain/cst-dts-gen@13.2.0': + resolution: {integrity: sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==} + + '@chevrotain/gast@13.2.0': + resolution: {integrity: sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==} + + '@chevrotain/regexp-to-ast@13.2.0': + resolution: {integrity: sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==} + + '@chevrotain/types@13.2.0': + resolution: {integrity: sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==} + + '@chevrotain/utils@13.2.0': + resolution: {integrity: sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==} '@clack/core@1.5.1': resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} @@ -430,29 +451,29 @@ packages: cpu: [x64] os: [win32] - '@expressive-code/core@0.44.2': - resolution: {integrity: sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==} + '@expressive-code/core@0.44.3': + resolution: {integrity: sha512-pffktF0TJoxC08dgNcutl/0LcSnTrjF6vXR39qVpwZ0EyxpwO9zjI7dbni6sO7w3yHpRBO0voLFvhzB+EPJRsA==} - '@expressive-code/plugin-frames@0.44.2': - resolution: {integrity: sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==} + '@expressive-code/plugin-frames@0.44.3': + resolution: {integrity: sha512-ysW4VXk9vXEliA99Ur6ApUepVtx74sPolp6o/j45BRjZQoL5O6kXKMhSP4koQLih8HbC5B3ImIvO+HKKhkv/oA==} - '@expressive-code/plugin-shiki@0.44.2': - resolution: {integrity: sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==} + '@expressive-code/plugin-shiki@0.44.3': + resolution: {integrity: sha512-jAibAZPG7lDmi61EFJADZjoSZzjHiwZZdbbgR/mTlWXM4ON/Pyo/9tB2vrqQNKylsci6jqtCi6idrwqQXF53xA==} - '@expressive-code/plugin-text-markers@0.44.2': - resolution: {integrity: sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==} + '@expressive-code/plugin-text-markers@0.44.3': + resolution: {integrity: sha512-4Ae2xRSDV5kdWPsi40xWbOtoihbzM0zLVjQKkDAnD5KzFXl+6/kd+VhuWOlTRVaJ5cqGk++vCbAaf0MR1XbZlw==} - '@fontsource-variable/inter@5.2.8': - resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} - '@fontsource-variable/roboto-mono@5.2.9': - resolution: {integrity: sha512-OzFO2AXlSGcXl/NcXS3CGjImb6rczCByPJ1C+Dzp9kkYOrUPyrGTuAtqPcmA/d+nZGX5oyOWKXLk5BrwVLYqkw==} + '@fontsource-variable/roboto-mono@5.3.0': + resolution: {integrity: sha512-h5r+KY/6qy8JEOj33JgcrFO81YAnvrc1EAwMpl/EPJLb2OIvOy0/jmB5M7qs7x/uXk5JUP5Ux4LrkeOK7faHkQ==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@3.1.0': - resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} @@ -607,24 +628,30 @@ packages: '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@lucide/astro@1.61.0': + resolution: {integrity: sha512-XoFZGoHjYtnDTuOQ4b105IO+WUCh8rzitIgBIrkOcdC1PRmsvLx+fwnrCRHiZ1JfbA36r/jAMxQL/HeyRqKJjQ==} + peerDependencies: + astro: ^4 || ^5 || ^6 || ^7 + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@mermaid-js/parser@1.1.1': - resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@mermaid-js/parser@2.0.1': + resolution: {integrity: sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==} + engines: {node: '>=22.12.0'} - '@napi-rs/wasm-runtime@1.2.5': - resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + '@napi-rs/wasm-runtime@1.2.6': + resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/core': ^1.7.1 || 2.0.0-beta.2 + '@emnapi/runtime': ^1.7.1 || 2.0.0-beta.2 '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} - '@oxc-project/types@0.153.0': - resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} '@pagefind/darwin-arm64@1.5.2': resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} @@ -664,92 +691,92 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-android-arm-eabi@1.2.13': - resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + '@rolldown/binding-android-arm-eabi@1.2.15': + resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.13': - resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + '@rolldown/binding-android-arm64@1.2.15': + resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.13': - resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + '@rolldown/binding-darwin-arm64@1.2.15': + resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.13': - resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + '@rolldown/binding-darwin-x64@1.2.15': + resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.13': - resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + '@rolldown/binding-freebsd-x64@1.2.15': + resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': - resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.13': - resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + '@rolldown/binding-linux-arm64-gnu@1.2.15': + resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.2.13': - resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + '@rolldown/binding-linux-arm64-musl@1.2.15': + resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.2.13': - resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.2.13': - resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + '@rolldown/binding-linux-s390x-gnu@1.2.15': + resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.2.13': - resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + '@rolldown/binding-linux-x64-gnu@1.2.15': + resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.2.13': - resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + '@rolldown/binding-linux-x64-musl@1.2.15': + resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.2.13': - resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + '@rolldown/binding-openharmony-arm64@1.2.15': + resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.13': - resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + '@rolldown/binding-win32-arm64-msvc@1.2.15': + resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.13': - resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + '@rolldown/binding-win32-x64-msvc@1.2.15': + resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -785,8 +812,8 @@ packages: resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==} engines: {node: '>=20'} - '@shikijs/vscode-textmate@10.0.2': - resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@shikijs/vscode-textmate@10.1.0': + resolution: {integrity: sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==} '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -836,8 +863,8 @@ packages: '@types/d3-format@3.0.4': resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} - '@types/d3-geo@3.1.0': - resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} '@types/d3-hierarchy@3.1.7': resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} @@ -854,8 +881,8 @@ packages: '@types/d3-quadtree@3.0.6': resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} - '@types/d3-random@3.0.3': - resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} '@types/d3-scale-chromatic@3.1.0': resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} @@ -863,11 +890,11 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - '@types/d3-selection@3.0.11': - resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} - '@types/d3-shape@3.1.8': - resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} '@types/d3-time-format@4.0.3': resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} @@ -881,8 +908,8 @@ packages: '@types/d3-transition@3.0.9': resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} - '@types/d3-zoom@3.0.8': - resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} @@ -899,9 +926,6 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -938,26 +962,23 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} - abbrev@4.0.0: - resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} - engines: {node: ^20.17.0 || >=22.9.0} + abbrev@5.0.0: + resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} hasBin: true @@ -990,14 +1011,14 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true - astro-expressive-code@0.44.2: - resolution: {integrity: sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==} + astro-expressive-code@0.44.3: + resolution: {integrity: sha512-LUpQmmvzr90JXP5QIAopgdjlOKLuT3x0wlPAUpc4OZtPtFS8sbflPcGPpL/lG3pQuUCQV2CXUFgYNBYNu9tphA==} peerDependencies: astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 - astro@7.3.8: - resolution: {integrity: sha512-LHzDHSaV+YzZiR3EFndDYTLLeNeuMtP1qX1FLcCNUUHOCqTTIWPczy2UV3zpA5VqvJe5cWZolqM9Z8JFqR2i9w==} - engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + astro@7.4.2: + resolution: {integrity: sha512-FAH4cdDlFaVnQGjazLTEIC5vJqGlo5XANc1wIbzQHIqAjJTll/RWTUs+SPNW1JdezG4YGxMZllaE5A41T2kzhA==} + engines: {node: '>=22.23.4', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true peerDependencies: '@astrojs/markdown-remark': ^7.3.0 @@ -1015,8 +1036,8 @@ packages: bcp-47-match@2.0.3: resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} - bcp-47@2.1.1: - resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} + bcp-47@2.1.2: + resolution: {integrity: sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -1040,6 +1061,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chevrotain@13.2.0: + resolution: {integrity: sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==} + engines: {node: '>=22.0.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -1066,21 +1091,18 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} - commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} - common-ancestor-path@2.0.0: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - cookie-es@1.2.3: resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} @@ -1134,8 +1156,8 @@ packages: peerDependencies: cytoscape: ^3.2.0 - cytoscape@3.33.2: - resolution: {integrity: sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==} + cytoscape@3.34.4: + resolution: {integrity: sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==} engines: {node: '>=0.10'} d3-array@2.12.1: @@ -1280,8 +1302,8 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} - dayjs@1.11.20: - resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -1312,8 +1334,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.9.4: - resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + devalue@6.0.3: + resolution: {integrity: sha512-3woMEITftG1wKeO/4d/QXH1DVKctTSZg3jNGPpjp9hNr5Hos8MCFF5McTML5ZtgKDObm2R591B0spRp2IVernA==} + engines: {node: '>=18'} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -1336,8 +1359,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.3.3: - resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dompurify@3.4.17: + resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -1346,6 +1369,9 @@ packages: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -1362,11 +1388,11 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} - es-module-lexer@3.0.3: - resolution: {integrity: sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==} + es-module-lexer@3.0.4: + resolution: {integrity: sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==} - es-toolkit@1.46.1: - resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + es-toolkit@1.53.0: + resolution: {integrity: sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==} esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -1410,8 +1436,8 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - expressive-code@0.44.2: - resolution: {integrity: sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==} + expressive-code@0.44.3: + resolution: {integrity: sha512-7RGslhJTNt01pdn5I3QmVh5C8ZIiSm0A+ORQ9j9r39oOgJVKjn/7dXl9rMT/SqmFLE6pJkTYI7UjW0BxJnW7OQ==} extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1438,14 +1464,13 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-proc@0.2.0: - resolution: {integrity: sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w==} - engines: {node: ^20.19.0 || >=22.12.0} - flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} + fnv1a-64@0.1.2: + resolution: {integrity: sha512-mJbcILTPybAR1jdOwt/lVv8N2cffeJFFP+gVbSAwLEx1ujXH27RpPd8Rokec/KX9c76UYIB6Co+H4lQzxPnJfA==} + fontace@0.4.1: resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} @@ -1532,8 +1557,8 @@ packages: hast-util-to-string@3.0.1: resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} - hast-util-to-text@4.0.2: - resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-to-text@4.0.3: + resolution: {integrity: sha512-5yrlWOvGFs6CqHriUpSsNrjzT/Tdo+IOQudbznxOn8K5qblp18MJyzOg8IgDOKQ8MYgQcJA7SusuCjSpDr8mSA==} hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -1553,8 +1578,8 @@ packages: http-cache-semantics@4.3.0: resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} - i18next@26.4.3: - resolution: {integrity: sha512-QIIueUYS2giFVIY3hu/X5Dz7/qTzwcXE06H29m1DKVhUGVPCyT0wnsh14wEn/cTy9nZ/B6VhbDItdXotOKSO8A==} + i18next@26.5.11: + resolution: {integrity: sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==} peerDependencies: typescript: ^5 || ^6 || ^7 peerDependenciesMeta: @@ -1565,6 +1590,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1626,8 +1654,8 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - katex@0.16.45: - resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + katex@0.18.10: + resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==} hasBin: true khroma@2.1.0: @@ -1747,11 +1775,8 @@ packages: mdast-util-directive@3.1.1: resolution: {integrity: sha512-Gqpa3MHorXYEZ0VfbroyvBlVKUfz371V6mIERqOYMcb0W1D3LwmFq1ZW4O2TDrbn+4ZmGLwMWeFRJgYZ+bc5fQ==} - mdast-util-find-and-replace@3.0.3: - resolution: {integrity: sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==} - - mdast-util-from-markdown@2.0.3: - resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + mdast-util-find-and-replace@3.0.4: + resolution: {integrity: sha512-z4B5c6jrnJ1ltOQemhWYPBSAo6y42Z8Gt2yEgfgNTMcLSU59B8bfj8Z8AnAlJrBGLnqkdAUZ7jdDEqQw3KuipQ==} mdast-util-from-markdown@2.1.0: resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} @@ -1792,9 +1817,6 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - mdast-util-to-markdown@2.2.0: resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==} @@ -1807,11 +1829,9 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - mermaid@11.15.0: - resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} - - micromark-core-commonmark@2.0.3: - resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + mermaid@12.2.0: + resolution: {integrity: sha512-w7nr/SFrLO7BaVTs3dQTvYSV6nYSBj0Yy9DjBhsIJtAcHqRzXSq4Pg32ujHZ/AdWsAMNEvvhbm5dt9E2tOiHzw==} + engines: {node: '>=22.12.0'} micromark-core-commonmark@2.0.4: resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} @@ -1864,9 +1884,6 @@ packages: micromark-factory-mdx-expression@2.0.3: resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} - micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - micromark-factory-space@2.1.0: resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} @@ -1921,15 +1938,9 @@ packages: micromark-util-symbol@2.0.1: resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - micromark-util-types@2.0.3: resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} - micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - micromark@4.0.3: resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} @@ -1945,9 +1956,6 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mlly@1.8.2: - resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -1967,24 +1975,24 @@ packages: nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} - node-addon-api@8.8.0: - resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} engines: {node: ^18 || ^20 || >= 21} node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - node-gyp@12.3.0: - resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} - engines: {node: ^20.17.0 || >=22.9.0} + node-gyp@13.1.0: + resolution: {integrity: sha512-FpNzOLFACKG0oRBYbZ5WOeWD1EqRTIWX90MaRIWTO18lVYPx8fqIjrdS3pihjnCkSmgop6zVGkSqg1vXZLksuw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true node-mock-http@1.0.5: resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} - nopt@9.0.0: - resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} - engines: {node: ^20.17.0 || >=22.9.0} + nopt@10.0.1: + resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true normalize-path@3.0.0: @@ -2001,9 +2009,6 @@ packages: ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} - ohash@2.0.12: - resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} - oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -2014,17 +2019,14 @@ packages: resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} engines: {node: '>=20'} - p-queue@9.3.3: - resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + p-queue@9.3.5: + resolution: {integrity: sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ==} engines: {node: '>=20'} p-timeout@7.0.2: resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==} engines: {node: '>=20'} - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - package-manager-detector@1.9.0: resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} @@ -2044,9 +2046,6 @@ packages: path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -2057,44 +2056,37 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} + postcss-nested@7.0.2: + resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} + engines: {node: '>=18.0'} peerDependencies: postcss: ^8.2.14 - postcss-selector-parser@6.1.4: - resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + postcss-selector-parser@7.1.7: + resolution: {integrity: sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==} engines: {node: '>=4'} - postcss@8.5.29: - resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} - proc-log@6.1.0: - resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} - engines: {node: ^20.17.0 || >=22.9.0} + proc-log@7.0.0: + resolution: {integrity: sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} process-ancestry@0.1.0: resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} @@ -2133,8 +2125,8 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - rehype-expressive-code@0.44.2: - resolution: {integrity: sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==} + rehype-expressive-code@0.44.3: + resolution: {integrity: sha512-vaVuQPobFXvmtcHXQ5uqXZlzuuPnLyIuZcX/03sX1U/Qfj9fZrPYI9w66OSr/PUx6alzRYmreEwgYBbsVx8HsA==} rehype-external-links@3.0.0: resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==} @@ -2197,8 +2189,8 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - rolldown@1.2.13: - resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + rolldown@1.2.15: + resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2218,11 +2210,6 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} - semver@7.8.1: - resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2261,21 +2248,18 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + source-map@0.8.0: + resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} + engines: {node: '>= 12'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - starlight-changelogs@0.5.0: - resolution: {integrity: sha512-bqBAHle6N4jApkLz8qlN+k8nmaJUXvYunrJeYWTZats15SeEmsT+1N7KdshODCky2AzLfIBy4Qa2v1YSQNrjxQ==} - engines: {node: '>=22.12.0'} + starlight-changelogs@0.7.1: + resolution: {integrity: sha512-JKuNEzq9HbiM+Uhu5vQmFuyM3LA6ZlrWRQn2DJ8ubFRvbbeexL9G8sAA7DZcjXxCrDwn6V/oK042uCD0PnNhJw==} peerDependencies: '@astrojs/starlight': '>=0.38.0' - starlight-contextual-menu@0.1.5: - resolution: {integrity: sha512-MYQ6eFDIBBnKrEh3XqR7RZ6YDJ641ADmrSjj93d+cVJGPvrCHrd6VYiKeehhczsrn6GqjaCCFAn4xUd69gcfcQ==} - peerDependencies: - astro: ^5.0.0 - starlight-markdown: ^0.1.5 - starlight-links-validator@0.27.0: resolution: {integrity: sha512-UvPl8L9hfWR+yWbLOEtljLmpJCGcM6AVseVy5IO9dmqdOjW4R+Y8FaYyVA5axCC8RvxM5xU7M1WTerpwU8rYbw==} peerDependencies: @@ -2293,6 +2277,16 @@ packages: peerDependencies: astro: ^5.0.0 + starlight-sidebar-topics@0.9.0: + resolution: {integrity: sha512-xFwMAAtTVyqsO5DZ8Ldk0vN+2QoPdtjtDi8fbrzvQATIJvIvfaBLL9JzfXAjNHI9vMkj35VMWMd8tTICeO6RIA==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@astrojs/starlight': '>=0.42.0' + astro: ^7.2.10 + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2302,8 +2296,8 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - stylis@4.3.6: - resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} @@ -2333,22 +2327,17 @@ packages: resolution: {integrity: sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==} engines: {node: ^16.14.0 || >= 17.3.0} - tinyexec@1.0.4: - resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} - engines: {node: '>=18'} - tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyps@0.5.1: + resolution: {integrity: sha512-B6Ykif5HflC3AdDkS4Zst/2muYVfeXoVMy+KorqH80s/1x4vunIORpNySH681X7fjj+dFM9MDKD6/Hsccjb87Q==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2362,16 +2351,13 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-dedent@2.2.0: - resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} engines: {node: '>=6.10'} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -2384,22 +2370,15 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@6.29.0: - resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==} - engines: {node: '>=18.17'} - - undici@8.11.2: - resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + undici@8.12.3: + resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==} engines: {node: '>=22.19.0'} unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - unifont@0.7.5: - resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} - - unist-util-find-after@5.0.0: - resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unifont@1.0.3: + resolution: {integrity: sha512-XGnEwj5sWdCbiBX01mWLSBncS0yMRjnLygau3IfdOlfjMacNIQvTGW4VH7pWBq+gGWArfSef/CensMLjMmIr/A==} unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -2425,14 +2404,14 @@ packages: unist-util-visit-children@3.0.0: resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + unist-util-visit-parents@6.1.0: + resolution: {integrity: sha512-vKXxz6O38sKDj1mtx/k2cWvkkFBuYGx6uGrlzBkVeWCRF/F8sdI0j3Wbkz/5rVNRRuVc8UrgRdbCxNhmYXgP3w==} - unist-util-visit@5.1.0: - resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unist-util-visit@5.2.0: + resolution: {integrity: sha512-fpsYs/VVoZxfGY9/j3FQpZmN8wdLJaV1Pt38AaJNZu9uKIHcFU3halCdBveEq1HGKO1OEfJY65w1zvoFV1b3qQ==} - unstorage@1.17.5: - resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + unstorage@1.17.6: + resolution: {integrity: sha512-gjKtcmn/KwZuJZO9o2sc0esAQnhmTw0LviDHwevTXbJMYs0dRTp8ZEEWXHU0vTpzbN3cA0yxlTeyXssMp3zDqw==} peerDependencies: '@azure/app-configuration': ^1.8.0 '@azure/cosmos': ^4.2.0 @@ -2442,7 +2421,7 @@ packages: '@azure/storage-blob': ^12.26.0 '@capacitor/preferences': ^6 || ^7 || ^8 '@deno/kv': '>=0.9.0' - '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 '@planetscale/database': ^1.19.0 '@upstash/redis': ^1.34.3 '@vercel/blob': '>=0.27.1' @@ -2500,8 +2479,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + uuid@14.0.3: + resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==} hasBin: true verkit@0.5.0: @@ -2517,13 +2496,13 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.3.4: - resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.7.1 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -2571,9 +2550,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - which@6.0.1: - resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} - engines: {node: ^20.17.0 || >=22.9.0} + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true xxhash-wasm@1.1.0: @@ -2596,75 +2575,75 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} - zod@4.6.5: - resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@antfu/install-pkg@1.1.0': + '@antfu/install-pkg@2.1.0': dependencies: - package-manager-detector: 1.6.0 - tinyexec: 1.0.4 + package-manager-detector: 1.9.0 + tinyexec: 1.3.1 - '@ascorbic/loader-utils@1.0.2(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))': + '@ascorbic/loader-utils@1.0.2(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))': dependencies: - astro: 7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + astro: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) - '@astrojs/compiler-binding-android-arm64@0.5.1': + '@astrojs/compiler-binding-android-arm64@0.5.2': optional: true - '@astrojs/compiler-binding-darwin-arm64@0.5.1': + '@astrojs/compiler-binding-darwin-arm64@0.5.2': optional: true - '@astrojs/compiler-binding-darwin-x64@0.5.1': + '@astrojs/compiler-binding-darwin-x64@0.5.2': optional: true - '@astrojs/compiler-binding-linux-arm64-gnu@0.5.1': + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.2': optional: true - '@astrojs/compiler-binding-linux-arm64-musl@0.5.1': + '@astrojs/compiler-binding-linux-arm64-musl@0.5.2': optional: true - '@astrojs/compiler-binding-linux-x64-gnu@0.5.1': + '@astrojs/compiler-binding-linux-x64-gnu@0.5.2': optional: true - '@astrojs/compiler-binding-linux-x64-musl@0.5.1': + '@astrojs/compiler-binding-linux-x64-musl@0.5.2': optional: true - '@astrojs/compiler-binding-wasm32-wasi@0.5.1': + '@astrojs/compiler-binding-wasm32-wasi@0.5.2': dependencies: '@emnapi/core': 1.11.3 '@emnapi/runtime': 1.11.3 - '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@napi-rs/wasm-runtime': 1.2.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optional: true - '@astrojs/compiler-binding-win32-arm64-msvc@0.5.1': + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.2': optional: true - '@astrojs/compiler-binding-win32-x64-msvc@0.5.1': + '@astrojs/compiler-binding-win32-x64-msvc@0.5.2': optional: true - '@astrojs/compiler-binding@0.5.1': + '@astrojs/compiler-binding@0.5.2': optionalDependencies: - '@astrojs/compiler-binding-android-arm64': 0.5.1 - '@astrojs/compiler-binding-darwin-arm64': 0.5.1 - '@astrojs/compiler-binding-darwin-x64': 0.5.1 - '@astrojs/compiler-binding-linux-arm64-gnu': 0.5.1 - '@astrojs/compiler-binding-linux-arm64-musl': 0.5.1 - '@astrojs/compiler-binding-linux-x64-gnu': 0.5.1 - '@astrojs/compiler-binding-linux-x64-musl': 0.5.1 - '@astrojs/compiler-binding-wasm32-wasi': 0.5.1 - '@astrojs/compiler-binding-win32-arm64-msvc': 0.5.1 - '@astrojs/compiler-binding-win32-x64-msvc': 0.5.1 + '@astrojs/compiler-binding-android-arm64': 0.5.2 + '@astrojs/compiler-binding-darwin-arm64': 0.5.2 + '@astrojs/compiler-binding-darwin-x64': 0.5.2 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.5.2 + '@astrojs/compiler-binding-linux-arm64-musl': 0.5.2 + '@astrojs/compiler-binding-linux-x64-gnu': 0.5.2 + '@astrojs/compiler-binding-linux-x64-musl': 0.5.2 + '@astrojs/compiler-binding-wasm32-wasi': 0.5.2 + '@astrojs/compiler-binding-win32-arm64-msvc': 0.5.2 + '@astrojs/compiler-binding-win32-x64-msvc': 0.5.2 - '@astrojs/compiler-rs@0.5.1': + '@astrojs/compiler-rs@0.5.2': dependencies: - '@astrojs/compiler-binding': 0.5.1 + '@astrojs/compiler-binding': 0.5.2 - '@astrojs/internal-helpers@0.12.0': + '@astrojs/internal-helpers@0.13.0': dependencies: '@types/hast': 3.0.5 '@types/mdast': 4.0.4 @@ -2675,17 +2654,17 @@ snapshots: unified: 11.0.5 yaml: 2.9.1 - '@astrojs/markdown-remark@7.3.2': + '@astrojs/markdown-remark@7.3.4': dependencies: - '@astrojs/internal-helpers': 0.12.0 - '@astrojs/prism': 4.0.2 + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/prism': 4.1.1 '@mdx-js/mdx': 3.1.1 - acorn: 8.16.0 + acorn: 8.19.0 estree-util-visit: 2.0.0 github-slugger: 2.0.0 hast-util-from-html: 2.0.3 hast-util-to-html: 9.0.5 - hast-util-to-text: 4.0.2 + hast-util-to-text: 4.0.3 mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 @@ -2693,58 +2672,58 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.2 remark-smartypants: 3.0.3 - source-map: 0.7.6 + source-map: 0.8.0 unified: 11.0.5 unist-util-remove-position: 5.0.0 - unist-util-visit: 5.1.0 - unist-util-visit-parents: 6.0.2 + unist-util-visit: 5.2.0 + unist-util-visit-parents: 6.1.0 vfile: 6.0.3 transitivePeerDependencies: - supports-color - '@astrojs/markdown-satteri@0.4.3': + '@astrojs/markdown-satteri@0.4.5': dependencies: - '@astrojs/internal-helpers': 0.12.0 - '@astrojs/prism': 4.0.2 + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/prism': 4.1.1 github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@8.0.3(@astrojs/markdown-remark@7.3.2)(@astrojs/markdown-satteri@0.4.3)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))': + '@astrojs/mdx@8.1.1(@astrojs/markdown-remark@7.3.4)(@astrojs/markdown-satteri@0.4.5)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))': dependencies: - '@astrojs/internal-helpers': 0.12.0 - '@astrojs/markdown-satteri': 0.4.3 - astro: 7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) - es-module-lexer: 3.0.3 + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/markdown-satteri': 0.4.5 + astro: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + es-module-lexer: 3.0.4 optionalDependencies: - '@astrojs/markdown-remark': 7.3.2 + '@astrojs/markdown-remark': 7.3.4 - '@astrojs/prism@4.0.2': + '@astrojs/prism@4.1.1': dependencies: prismjs: 1.30.0 '@astrojs/sitemap@3.7.4': dependencies: sitemap: 9.0.1 - zod: 4.6.5 + zod: 4.6.7 - '@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))': + '@astrojs/starlight@0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))': dependencies: - '@astrojs/markdown-satteri': 0.4.3 - '@astrojs/mdx': 8.0.3(@astrojs/markdown-remark@7.3.2)(@astrojs/markdown-satteri@0.4.3)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + '@astrojs/markdown-satteri': 0.4.5 + '@astrojs/mdx': 8.1.1(@astrojs/markdown-remark@7.3.4)(@astrojs/markdown-satteri@0.4.5)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) '@astrojs/sitemap': 3.7.4 '@pagefind/default-ui': 1.5.2 '@types/hast': 3.0.5 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) - astro-expressive-code: 0.44.2(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) - bcp-47: 2.1.1 + astro: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + astro-expressive-code: 0.44.3(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + bcp-47: 2.1.2 hast-util-format: 1.1.0 hast-util-select: 6.0.4 hast-util-to-html: 9.0.5 hast-util-to-string: 3.0.1 hastscript: 9.0.1 - i18next: 26.4.3 + i18next: 26.5.11 js-yaml: 4.3.2 klona: 2.0.6 magic-string: 1.4.3 @@ -2756,32 +2735,32 @@ snapshots: satteri: 0.10.5 ultrahtml: 1.7.0 unified: 11.0.5 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 vfile: 6.0.3 optionalDependencies: - '@astrojs/markdown-remark': 7.3.2 + '@astrojs/markdown-remark': 7.3.4 transitivePeerDependencies: - supports-color - typescript - '@astrojs/telemetry@3.3.3': + '@astrojs/telemetry@3.4.1': dependencies: ci-info: 4.4.0 dset: 3.1.4 is-docker: 4.0.0 package-manager-detector: 1.9.0 - '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@7.29.11': {} '@babel/helper-validator-identifier@7.29.7': {} - '@babel/parser@7.29.9': + '@babel/parser@7.29.11': dependencies: - '@babel/types': 7.29.8 + '@babel/types': 7.29.11 - '@babel/types@7.29.8': + '@babel/types@7.29.11': dependencies: - '@babel/helper-string-parser': 7.29.7 + '@babel/helper-string-parser': 7.29.11 '@babel/helper-validator-identifier': 7.29.7 '@braintree/sanitize-url@7.1.2': {} @@ -2808,7 +2787,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@bruits/satteri-win32-arm64-msvc@0.10.5': @@ -2821,7 +2800,20 @@ snapshots: dependencies: fontkitten: 1.0.3 - '@chevrotain/types@11.1.2': {} + '@chevrotain/cst-dts-gen@13.2.0': + dependencies: + '@chevrotain/gast': 13.2.0 + '@chevrotain/types': 13.2.0 + + '@chevrotain/gast@13.2.0': + dependencies: + '@chevrotain/types': 13.2.0 + + '@chevrotain/regexp-to-ast@13.2.0': {} + + '@chevrotain/types@13.2.0': {} + + '@chevrotain/utils@13.2.0': {} '@clack/core@1.5.1': dependencies: @@ -2947,42 +2939,42 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@expressive-code/core@0.44.2': + '@expressive-code/core@0.44.3': dependencies: '@ctrl/tinycolor': 4.2.1 hast-util-select: 6.0.4 hast-util-to-html: 9.0.5 - hast-util-to-text: 4.0.2 + hast-util-to-text: 4.0.3 hastscript: 9.0.1 - postcss: 8.5.29 - postcss-nested: 6.2.0(postcss@8.5.29) - unist-util-visit: 5.1.0 - unist-util-visit-parents: 6.0.2 + postcss: 8.5.31 + postcss-nested: 7.0.2(postcss@8.5.31) + unist-util-visit: 5.2.0 + unist-util-visit-parents: 6.1.0 - '@expressive-code/plugin-frames@0.44.2': + '@expressive-code/plugin-frames@0.44.3': dependencies: - '@expressive-code/core': 0.44.2 + '@expressive-code/core': 0.44.3 - '@expressive-code/plugin-shiki@0.44.2': + '@expressive-code/plugin-shiki@0.44.3': dependencies: - '@expressive-code/core': 0.44.2 + '@expressive-code/core': 0.44.3 shiki: 4.5.0 - '@expressive-code/plugin-text-markers@0.44.2': + '@expressive-code/plugin-text-markers@0.44.3': dependencies: - '@expressive-code/core': 0.44.2 + '@expressive-code/core': 0.44.3 - '@fontsource-variable/inter@5.2.8': {} + '@fontsource-variable/inter@5.3.0': {} - '@fontsource-variable/roboto-mono@5.2.9': {} + '@fontsource-variable/roboto-mono@5.3.0': {} '@iconify/types@2.0.0': {} - '@iconify/utils@3.1.0': + '@iconify/utils@3.1.7': dependencies: - '@antfu/install-pkg': 1.1.0 + '@antfu/install-pkg': 2.1.0 '@iconify/types': 2.0.0 - mlly: 1.8.2 + import-meta-resolve: 4.2.0 '@img/colour@1.1.0': {} @@ -3096,13 +3088,17 @@ snapshots: '@jridgewell/sourcemap-codec@1.6.0': {} + '@lucide/astro@1.61.0(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))': + dependencies: + astro: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdx': 2.0.14 - acorn: 8.16.0 + acorn: 8.19.0 collapse-white-space: 2.1.0 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 @@ -3111,7 +3107,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 - recma-jsx: 1.0.1(acorn@8.16.0) + recma-jsx: 1.0.1(acorn@8.19.0) recma-stringify: 1.0.0 rehype-recma: 1.0.0 remark-mdx: 3.1.1 @@ -3121,23 +3117,23 @@ snapshots: unified: 11.0.5 unist-util-position-from-estree: 2.0.0 unist-util-stringify-position: 4.0.0 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 vfile: 6.0.3 transitivePeerDependencies: - supports-color - '@mermaid-js/parser@1.1.1': + '@mermaid-js/parser@2.0.1': dependencies: - '@chevrotain/types': 11.1.2 + '@chevrotain/types': 13.2.0 - '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 '@tybys/wasm-util': 0.10.4 optional: true - '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + '@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.11.3 '@emnapi/runtime': 1.11.3 @@ -3146,7 +3142,7 @@ snapshots: '@oslojs/encoding@1.1.0': {} - '@oxc-project/types@0.153.0': {} + '@oxc-project/types@0.155.0': {} '@pagefind/darwin-arm64@1.5.2': optional: true @@ -3171,49 +3167,49 @@ snapshots: '@pagefind/windows-x64@1.5.2': optional: true - '@rolldown/binding-android-arm-eabi@1.2.13': + '@rolldown/binding-android-arm-eabi@1.2.15': optional: true - '@rolldown/binding-android-arm64@1.2.13': + '@rolldown/binding-android-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-arm64@1.2.13': + '@rolldown/binding-darwin-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-x64@1.2.13': + '@rolldown/binding-darwin-x64@1.2.15': optional: true - '@rolldown/binding-freebsd-x64@1.2.13': + '@rolldown/binding-freebsd-x64@1.2.15': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.13': + '@rolldown/binding-linux-arm64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.13': + '@rolldown/binding-linux-arm64-musl@1.2.15': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.13': + '@rolldown/binding-linux-ppc64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.13': + '@rolldown/binding-linux-s390x-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.13': + '@rolldown/binding-linux-x64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-musl@1.2.13': + '@rolldown/binding-linux-x64-musl@1.2.15': optional: true - '@rolldown/binding-openharmony-arm64@1.2.13': + '@rolldown/binding-openharmony-arm64@1.2.15': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.13': + '@rolldown/binding-win32-arm64-msvc@1.2.15': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.13': + '@rolldown/binding-win32-x64-msvc@1.2.15': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -3222,20 +3218,20 @@ snapshots: dependencies: '@shikijs/primitive': 4.5.0 '@shikijs/types': 4.5.0 - '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/vscode-textmate': 10.1.0 '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 '@shikijs/engine-javascript@4.5.0': dependencies: '@shikijs/types': 4.5.0 - '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/vscode-textmate': 10.1.0 oniguruma-to-es: 4.3.6 '@shikijs/engine-oniguruma@4.5.0': dependencies: '@shikijs/types': 4.5.0 - '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/vscode-textmate': 10.1.0 '@shikijs/langs@4.5.0': dependencies: @@ -3244,7 +3240,7 @@ snapshots: '@shikijs/primitive@4.5.0': dependencies: '@shikijs/types': 4.5.0 - '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/vscode-textmate': 10.1.0 '@types/hast': 3.0.5 '@shikijs/themes@4.5.0': @@ -3253,10 +3249,10 @@ snapshots: '@shikijs/types@4.5.0': dependencies: - '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/vscode-textmate': 10.1.0 '@types/hast': 3.0.5 - '@shikijs/vscode-textmate@10.0.2': {} + '@shikijs/vscode-textmate@10.1.0': {} '@tybys/wasm-util@0.10.4': dependencies: @@ -3269,11 +3265,11 @@ snapshots: '@types/d3-axis@3.0.6': dependencies: - '@types/d3-selection': 3.0.11 + '@types/d3-selection': 3.0.12 '@types/d3-brush@3.0.6': dependencies: - '@types/d3-selection': 3.0.11 + '@types/d3-selection': 3.0.12 '@types/d3-chord@3.0.6': {} @@ -3290,7 +3286,7 @@ snapshots: '@types/d3-drag@3.0.7': dependencies: - '@types/d3-selection': 3.0.11 + '@types/d3-selection': 3.0.12 '@types/d3-dsv@3.0.7': {} @@ -3304,7 +3300,7 @@ snapshots: '@types/d3-format@3.0.4': {} - '@types/d3-geo@3.1.0': + '@types/d3-geo@3.1.1': dependencies: '@types/geojson': 7946.0.16 @@ -3320,7 +3316,7 @@ snapshots: '@types/d3-quadtree@3.0.6': {} - '@types/d3-random@3.0.3': {} + '@types/d3-random@3.0.4': {} '@types/d3-scale-chromatic@3.1.0': {} @@ -3328,9 +3324,9 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 - '@types/d3-selection@3.0.11': {} + '@types/d3-selection@3.0.12': {} - '@types/d3-shape@3.1.8': + '@types/d3-shape@3.2.0': dependencies: '@types/d3-path': 3.1.1 @@ -3342,12 +3338,12 @@ snapshots: '@types/d3-transition@3.0.9': dependencies: - '@types/d3-selection': 3.0.11 + '@types/d3-selection': 3.0.12 - '@types/d3-zoom@3.0.8': + '@types/d3-zoom@3.0.9': dependencies: '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.11 + '@types/d3-selection': 3.0.12 '@types/d3@7.4.3': dependencies: @@ -3365,22 +3361,22 @@ snapshots: '@types/d3-fetch': 3.0.7 '@types/d3-force': 3.0.10 '@types/d3-format': 3.0.4 - '@types/d3-geo': 3.1.0 + '@types/d3-geo': 3.1.1 '@types/d3-hierarchy': 3.1.7 '@types/d3-interpolate': 3.0.4 '@types/d3-path': 3.1.1 '@types/d3-polygon': 3.0.2 '@types/d3-quadtree': 3.0.6 - '@types/d3-random': 3.0.3 + '@types/d3-random': 3.0.4 '@types/d3-scale': 4.0.9 '@types/d3-scale-chromatic': 3.1.0 - '@types/d3-selection': 3.0.11 - '@types/d3-shape': 3.1.8 + '@types/d3-selection': 3.0.12 + '@types/d3-shape': 3.2.0 '@types/d3-time': 3.0.4 '@types/d3-time-format': 4.0.3 '@types/d3-timer': 3.0.2 '@types/d3-transition': 3.0.9 - '@types/d3-zoom': 3.0.8 + '@types/d3-zoom': 3.0.9 '@types/debug@4.1.13': dependencies: @@ -3394,10 +3390,6 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -3435,8 +3427,6 @@ snapshots: '@types/unist@3.0.3': {} - '@ungap/structured-clone@1.3.0': {} - '@ungap/structured-clone@1.4.0': {} '@upsetjs/venn.js@2.0.0': @@ -3444,13 +3434,13 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - abbrev@4.0.0: {} + abbrev@5.0.0: {} - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: - acorn: 8.16.0 + acorn: 8.19.0 - acorn@8.16.0: {} + acorn@8.19.0: {} am-i-vibing@0.5.0: dependencies: @@ -3475,18 +3465,18 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.44.2(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): + astro-expressive-code@0.44.3(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): dependencies: - astro: 7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) - rehype-expressive-code: 0.44.2 + astro: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + rehype-expressive-code: 0.44.3 url-extras: 0.1.0 - astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1): + astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1): dependencies: - '@astrojs/compiler-rs': 0.5.1 - '@astrojs/internal-helpers': 0.12.0 - '@astrojs/markdown-satteri': 0.4.3 - '@astrojs/telemetry': 3.3.3 + '@astrojs/compiler-rs': 0.5.2 + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/markdown-satteri': 0.4.5 + '@astrojs/telemetry': 3.4.1 '@capsizecss/unpack': 4.0.1 '@clack/prompts': 1.8.1 '@oslojs/encoding': 1.1.0 @@ -3497,11 +3487,10 @@ snapshots: clsx: 2.1.1 common-ancestor-path: 2.0.0 cookie: 2.0.1 - devalue: 5.9.4 + devalue: 6.0.3 diff: 9.0.0 dset: 3.1.4 - es-module-lexer: 3.0.3 - find-proc: 0.2.0 + es-module-lexer: 3.0.4 flattie: 1.1.1 fontace: 0.4.1 get-tsconfig: 5.0.0-beta.6 @@ -3515,28 +3504,29 @@ snapshots: neotraverse: 1.0.1 obug: 3.0.0 p-limit: 7.3.3 - p-queue: 9.3.3 + p-queue: 9.3.5 package-manager-detector: 1.9.0 piccolore: 0.1.3 picomatch: 4.0.7 - rolldown: 1.2.13 + rolldown: 1.2.15 shiki: 4.5.0 smol-toml: 1.9.1 svgo: 4.1.0 tinyclip: 1.0.3 tinyexec: 1.3.1 tinyglobby: 0.2.17 + tinyps: 0.5.1 ultrahtml: 1.7.0 - unifont: 0.7.5 - unstorage: 1.17.5 + unifont: 1.0.3 + unstorage: 1.17.6 verkit: 0.5.0 - vite: 8.3.4(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) - vitefu: 1.1.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + vite: 8.3.5(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.5(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.6.5 + zod: 4.6.7 optionalDependencies: - '@astrojs/markdown-remark': 7.3.2 + '@astrojs/markdown-remark': 7.3.4 sharp: 0.35.5(@types/node@24.19.2) transitivePeerDependencies: - '@azure/app-configuration' @@ -3577,7 +3567,7 @@ snapshots: bcp-47-match@2.0.3: {} - bcp-47@2.1.1: + bcp-47@2.1.2: dependencies: is-alphabetical: 2.0.1 is-alphanumerical: 2.0.1 @@ -3599,6 +3589,14 @@ snapshots: character-reference-invalid@2.0.1: {} + chevrotain@13.2.0: + dependencies: + '@chevrotain/cst-dts-gen': 13.2.0 + '@chevrotain/gast': 13.2.0 + '@chevrotain/regexp-to-ast': 13.2.0 + '@chevrotain/types': 13.2.0 + '@chevrotain/utils': 13.2.0 + chokidar@5.0.0: dependencies: readdirp: 5.1.1 @@ -3615,14 +3613,12 @@ snapshots: commander@11.1.0: {} - commander@7.2.0: {} + commander@15.0.0: {} - commander@8.3.0: {} + commander@7.2.0: {} common-ancestor-path@2.0.0: {} - confbox@0.1.8: {} - cookie-es@1.2.3: {} cookie@2.0.1: {} @@ -3667,17 +3663,17 @@ snapshots: dependencies: css-tree: 2.2.1 - cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.2): + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.4): dependencies: cose-base: 1.0.3 - cytoscape: 3.33.2 + cytoscape: 3.34.4 - cytoscape-fcose@2.2.0(cytoscape@3.33.2): + cytoscape-fcose@2.2.0(cytoscape@3.34.4): dependencies: cose-base: 2.2.0 - cytoscape: 3.33.2 + cytoscape: 3.34.4 - cytoscape@3.33.2: {} + cytoscape@3.34.4: {} d3-array@2.12.1: dependencies: @@ -3851,7 +3847,7 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 - dayjs@1.11.20: {} + dayjs@1.11.23: {} debug@4.4.3: dependencies: @@ -3873,7 +3869,7 @@ snapshots: detect-libc@2.1.2: {} - devalue@5.9.4: {} + devalue@6.0.3: {} devlop@1.1.0: dependencies: @@ -3895,7 +3891,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.3.3: + dompurify@3.4.17: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -3907,6 +3903,8 @@ snapshots: dset@3.1.4: {} + elkjs@0.9.3: {} + entities@4.5.0: {} entities@6.0.1: {} @@ -3915,9 +3913,9 @@ snapshots: environment@1.1.0: {} - es-module-lexer@3.0.3: {} + es-module-lexer@3.0.4: {} - es-toolkit@1.46.1: {} + es-toolkit@1.53.0: {} esast-util-from-estree@2.0.0: dependencies: @@ -3929,7 +3927,7 @@ snapshots: esast-util-from-js@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - acorn: 8.16.0 + acorn: 8.19.0 esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 @@ -4002,12 +4000,12 @@ snapshots: exponential-backoff@3.1.3: {} - expressive-code@0.44.2: + expressive-code@0.44.3: dependencies: - '@expressive-code/core': 0.44.2 - '@expressive-code/plugin-frames': 0.44.2 - '@expressive-code/plugin-shiki': 0.44.2 - '@expressive-code/plugin-text-markers': 0.44.2 + '@expressive-code/core': 0.44.3 + '@expressive-code/plugin-frames': 0.44.3 + '@expressive-code/plugin-shiki': 0.44.3 + '@expressive-code/plugin-text-markers': 0.44.3 extend@3.0.2: {} @@ -4021,10 +4019,6 @@ snapshots: dependencies: fast-string-width: 3.0.2 - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -4033,10 +4027,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 - find-proc@0.2.0: {} - flattie@1.1.1: {} + fnv1a-64@0.1.2: {} + fontace@0.4.1: dependencies: fontkitten: 1.0.3 @@ -4085,7 +4079,7 @@ snapshots: hast-util-phrasing: 3.0.1 hast-util-whitespace: 3.0.0 html-whitespace-sensitive-tag-names: 3.0.1 - unist-util-visit-parents: 6.0.2 + unist-util-visit-parents: 6.1.0 hast-util-from-html@2.0.3: dependencies: @@ -4117,7 +4111,7 @@ snapshots: hast-util-is-element@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-minify-whitespace@1.0.1: dependencies: @@ -4150,7 +4144,7 @@ snapshots: mdast-util-to-hast: 13.2.1 parse5: 7.3.0 unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 vfile: 6.0.3 web-namespaces: 2.0.1 zwitch: 2.0.4 @@ -4170,7 +4164,7 @@ snapshots: nth-check: 2.1.1 property-information: 7.2.0 space-separated-tokens: 2.0.2 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 zwitch: 2.0.4 hast-util-to-estree@3.1.3: @@ -4235,7 +4229,7 @@ snapshots: '@ungap/structured-clone': 1.4.0 hast-util-phrasing: 3.0.1 hast-util-to-html: 9.0.5 - hast-util-to-text: 4.0.2 + hast-util-to-text: 4.0.3 hast-util-whitespace: 3.0.0 mdast-util-phrasing: 4.1.0 mdast-util-to-hast: 13.2.1 @@ -4243,7 +4237,7 @@ snapshots: rehype-minify-whitespace: 6.0.2 trim-trailing-lines: 2.1.0 unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 hast-util-to-parse5@8.0.1: dependencies: @@ -4259,12 +4253,9 @@ snapshots: dependencies: '@types/hast': 3.0.5 - hast-util-to-text@4.0.2: + hast-util-to-text@4.0.3: dependencies: '@types/hast': 3.0.5 - '@types/unist': 3.0.3 - hast-util-is-element: 3.0.0 - unist-util-find-after: 5.0.0 hast-util-whitespace@3.0.0: dependencies: @@ -4286,12 +4277,14 @@ snapshots: http-cache-semantics@4.3.0: {} - i18next@26.4.3: {} + i18next@26.5.11: {} iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + import-meta-resolve@4.2.0: {} + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -4332,9 +4325,9 @@ snapshots: jsonc-parser@3.3.1: {} - katex@0.16.45: + katex@0.18.10: dependencies: - commander: 8.3.0 + commander: 15.0.0 khroma@2.1.0: {} @@ -4405,8 +4398,8 @@ snapshots: magicast@0.5.5: dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 + '@babel/parser': 7.29.11 + '@babel/types': 7.29.11 source-map-js: 1.2.2 markdown-extensions@2.0.0: {} @@ -4419,7 +4412,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 mdast-util-directive@3.1.1: dependencies: @@ -4432,33 +4425,16 @@ snapshots: micromark-util-character: 2.1.1 parse-entities: 4.0.2 stringify-entities: 4.0.4 - unist-util-visit-parents: 6.0.2 + unist-util-visit-parents: 6.1.0 transitivePeerDependencies: - supports-color - mdast-util-find-and-replace@3.0.3: + mdast-util-find-and-replace@3.0.4: dependencies: '@types/mdast': 4.0.4 escape-string-regexp: 5.0.0 unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - mdast-util-from-markdown@2.0.3: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.2 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color + unist-util-visit-parents: 6.1.0 mdast-util-from-markdown@2.1.0: dependencies: @@ -4482,7 +4458,7 @@ snapshots: '@types/mdast': 4.0.4 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.3 + mdast-util-find-and-replace: 3.0.4 micromark-util-character: 2.1.1 mdast-util-gfm-footnote@2.1.0: @@ -4597,21 +4573,9 @@ snapshots: micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 vfile: 6.0.3 - mdast-util-to-markdown@2.1.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.1 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.1.0 - zwitch: 2.0.4 - mdast-util-to-markdown@2.2.0: dependencies: '@types/mdast': 4.0.4 @@ -4623,7 +4587,7 @@ snapshots: micromark-util-classify-character: 2.0.1 micromark-util-decode-string: 2.0.1 micromark-util-html-tag-name: 2.0.1 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 zwitch: 2.0.4 mdast-util-to-string@4.0.0: @@ -4634,48 +4598,31 @@ snapshots: mdn-data@2.27.1: {} - mermaid@11.15.0: + mermaid@12.2.0: dependencies: '@braintree/sanitize-url': 7.1.2 - '@iconify/utils': 3.1.0 - '@mermaid-js/parser': 1.1.1 + '@iconify/utils': 3.1.7 + '@mermaid-js/parser': 2.0.1 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 - cytoscape: 3.33.2 - cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.2) - cytoscape-fcose: 2.2.0(cytoscape@3.33.2) + chevrotain: 13.2.0 + cytoscape: 3.34.4 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.4) + cytoscape-fcose: 2.2.0(cytoscape@3.34.4) d3: 7.9.0 d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 - dayjs: 1.11.20 - dompurify: 3.3.3 - es-toolkit: 1.46.1 - katex: 0.16.45 + dayjs: 1.11.23 + dompurify: 3.4.17 + elkjs: 0.9.3 + es-toolkit: 1.53.0 + katex: 0.18.10 khroma: 2.1.0 marked: 16.4.2 roughjs: 4.6.6 - stylis: 4.3.6 - ts-dedent: 2.2.0 - uuid: 11.1.0 - - micromark-core-commonmark@2.0.3: - dependencies: - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-factory-destination: 2.0.1 - micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.1 - micromark-factory-whitespace: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.3 micromark-core-commonmark@2.0.4: dependencies: @@ -4807,8 +4754,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 @@ -4820,14 +4767,14 @@ snapshots: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-label@2.0.1: dependencies: devlop: 1.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-mdx-expression@2.0.3: dependencies: @@ -4841,11 +4788,6 @@ snapshots: unist-util-position-from-estree: 2.0.0 vfile-message: 4.0.3 - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 - micromark-factory-space@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -4853,22 +4795,22 @@ snapshots: micromark-factory-title@2.0.1: dependencies: - micromark-factory-space: 2.0.1 + micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-whitespace@2.0.1: dependencies: - micromark-factory-space: 2.0.1 + micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-chunked@2.0.1: dependencies: @@ -4878,12 +4820,12 @@ snapshots: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-combine-extensions@2.0.1: dependencies: micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-decode-numeric-character-reference@2.0.2: dependencies: @@ -4920,7 +4862,7 @@ snapshots: micromark-util-resolve-all@2.0.1: dependencies: - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-sanitize-uri@2.0.1: dependencies: @@ -4933,36 +4875,12 @@ snapshots: devlop: 1.1.0 micromark-util-chunked: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-symbol@2.0.1: {} - micromark-util-types@2.0.2: {} - micromark-util-types@2.0.3: {} - micromark@4.0.2: - dependencies: - '@types/debug': 4.1.13 - debug: 4.4.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - transitivePeerDependencies: - - supports-color - micromark@4.0.3: dependencies: '@types/debug': 4.1.13 @@ -4997,13 +4915,6 @@ snapshots: dependencies: minipass: 7.1.3 - mlly@1.8.2: - dependencies: - acorn: 8.16.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - mrmime@2.0.1: {} ms@2.1.3: {} @@ -5016,28 +4927,28 @@ snapshots: dependencies: '@types/nlcst': 2.0.3 - node-addon-api@8.8.0: {} + node-addon-api@8.9.2: {} node-fetch-native@1.6.7: {} - node-gyp@12.3.0: + node-gyp@13.1.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - nopt: 9.0.0 - proc-log: 6.1.0 - semver: 7.8.1 + nopt: 10.0.1 + proc-log: 7.0.0 + semver: 7.8.5 tar: 7.5.22 - tinyglobby: 0.2.16 - undici: 6.29.0 - which: 6.0.1 + tinyglobby: 0.2.17 + undici: 8.12.3 + which: 7.0.0 node-mock-http@1.0.5: {} - nopt@9.0.0: + nopt@10.0.1: dependencies: - abbrev: 4.0.0 + abbrev: 5.0.0 normalize-path@3.0.0: {} @@ -5053,8 +4964,6 @@ snapshots: node-fetch-native: 1.6.7 ufo: 1.6.4 - ohash@2.0.12: {} - oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -5067,15 +4976,13 @@ snapshots: dependencies: yocto-queue: 1.2.2 - p-queue@9.3.3: + p-queue@9.3.5: dependencies: eventemitter3: 5.0.4 p-timeout: 7.0.2 p-timeout@7.0.2: {} - package-manager-detector@1.6.0: {} - package-manager-detector@1.9.0: {} pagefind@1.5.2: @@ -5113,24 +5020,14 @@ snapshots: path-data-parser@0.1.0: {} - pathe@2.0.3: {} - piccolore@0.1.3: {} picocolors@1.1.1: {} picomatch@2.3.2: {} - picomatch@4.0.4: {} - picomatch@4.0.7: {} - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.2 - pathe: 2.0.3 - points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -5138,17 +5035,17 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - postcss-nested@6.2.0(postcss@8.5.29): + postcss-nested@7.0.2(postcss@8.5.31): dependencies: - postcss: 8.5.29 - postcss-selector-parser: 6.1.4 + postcss: 8.5.31 + postcss-selector-parser: 7.1.7 - postcss-selector-parser@6.1.4: + postcss-selector-parser@7.1.7: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.29: + postcss@8.5.31: dependencies: nanoid: 3.3.20 picocolors: 1.1.1 @@ -5156,7 +5053,7 @@ snapshots: prismjs@1.30.0: {} - proc-log@6.1.0: {} + proc-log@7.0.0: {} process-ancestry@0.1.0: {} @@ -5172,10 +5069,10 @@ snapshots: estree-util-build-jsx: 3.0.1 vfile: 6.0.3 - recma-jsx@1.0.1(acorn@8.16.0): + recma-jsx@1.0.1(acorn@8.19.0): dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) estree-util-to-js: 2.0.0 recma-parse: 1.0.0 recma-stringify: 1.0.0 @@ -5205,18 +5102,18 @@ snapshots: dependencies: regex-utilities: 2.3.0 - rehype-expressive-code@0.44.2: + rehype-expressive-code@0.44.3: dependencies: - expressive-code: 0.44.2 + expressive-code: 0.44.3 rehype-external-links@3.0.0: dependencies: - '@types/hast': 3.0.4 - '@ungap/structured-clone': 1.3.0 + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.4.0 hast-util-is-element: 3.0.0 is-absolute-url: 4.0.1 space-separated-tokens: 2.0.2 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 rehype-minify-whitespace@6.0.2: dependencies: @@ -5306,7 +5203,7 @@ snapshots: retext: 9.0.0 retext-smartypants: 6.2.0 unified: 11.0.5 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 remark-stringify@11.0.0: dependencies: @@ -5326,7 +5223,7 @@ snapshots: dependencies: '@types/nlcst': 2.0.3 nlcst-to-string: 4.0.0 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 retext-stringify@4.0.0: dependencies: @@ -5343,26 +5240,26 @@ snapshots: robust-predicates@3.0.3: {} - rolldown@1.2.13: + rolldown@1.2.15: dependencies: - '@oxc-project/types': 0.153.0 + '@oxc-project/types': 0.155.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.13 - '@rolldown/binding-android-arm64': 1.2.13 - '@rolldown/binding-darwin-arm64': 1.2.13 - '@rolldown/binding-darwin-x64': 1.2.13 - '@rolldown/binding-freebsd-x64': 1.2.13 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 - '@rolldown/binding-linux-arm64-gnu': 1.2.13 - '@rolldown/binding-linux-arm64-musl': 1.2.13 - '@rolldown/binding-linux-ppc64-gnu': 1.2.13 - '@rolldown/binding-linux-s390x-gnu': 1.2.13 - '@rolldown/binding-linux-x64-gnu': 1.2.13 - '@rolldown/binding-linux-x64-musl': 1.2.13 - '@rolldown/binding-openharmony-arm64': 1.2.13 - '@rolldown/binding-win32-arm64-msvc': 1.2.13 - '@rolldown/binding-win32-x64-msvc': 1.2.13 + '@rolldown/binding-android-arm-eabi': 1.2.15 + '@rolldown/binding-android-arm64': 1.2.15 + '@rolldown/binding-darwin-arm64': 1.2.15 + '@rolldown/binding-darwin-x64': 1.2.15 + '@rolldown/binding-freebsd-x64': 1.2.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 + '@rolldown/binding-linux-arm64-gnu': 1.2.15 + '@rolldown/binding-linux-arm64-musl': 1.2.15 + '@rolldown/binding-linux-ppc64-gnu': 1.2.15 + '@rolldown/binding-linux-s390x-gnu': 1.2.15 + '@rolldown/binding-linux-x64-gnu': 1.2.15 + '@rolldown/binding-linux-x64-musl': 1.2.15 + '@rolldown/binding-openharmony-arm64': 1.2.15 + '@rolldown/binding-win32-arm64-msvc': 1.2.15 + '@rolldown/binding-win32-x64-msvc': 1.2.15 roughjs@4.6.6: dependencies: @@ -5394,8 +5291,6 @@ snapshots: sax@1.6.1: {} - semver@7.8.1: {} - semver@7.8.5: {} sharp@0.35.5(@types/node@24.19.2): @@ -5439,7 +5334,7 @@ snapshots: '@shikijs/langs': 4.5.0 '@shikijs/themes': 4.5.0 '@shikijs/types': 4.5.0 - '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/vscode-textmate': 10.1.0 '@types/hast': 3.0.5 sisteransi@1.0.5: {} @@ -5457,31 +5352,28 @@ snapshots: source-map@0.7.6: {} + source-map@0.8.0: {} + space-separated-tokens@2.0.2: {} - starlight-changelogs@0.5.0(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): + starlight-changelogs@0.7.1(@astrojs/starlight@0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): dependencies: - '@ascorbic/loader-utils': 1.0.2(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) - '@astrojs/starlight': 0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + '@ascorbic/loader-utils': 1.0.2(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + '@astrojs/starlight': 0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) github-slugger: 2.0.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 mdast-util-to-string: 4.0.0 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 transitivePeerDependencies: - astro - supports-color - starlight-contextual-menu@0.1.5(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))(starlight-markdown@0.1.5(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1))): - dependencies: - astro: 7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) - starlight-markdown: 0.1.5(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) - - starlight-links-validator@0.27.0(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): + starlight-links-validator@0.27.0(@astrojs/starlight@0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): dependencies: - '@astrojs/markdown-satteri': 0.4.3 - '@astrojs/starlight': 0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) - astro: 7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + '@astrojs/markdown-satteri': 0.4.5 + '@astrojs/starlight': 0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + astro: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) github-slugger: 2.0.0 hast-util-from-html: 2.0.3 is-absolute-url: 5.0.0 @@ -5490,18 +5382,18 @@ snapshots: picomatch: 4.0.7 satteri: 0.10.5 terminal-link: 5.0.0 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 yaml: 2.9.1 transitivePeerDependencies: - supports-color - starlight-llms-txt@0.12.0(@astrojs/markdown-remark@7.3.2)(@astrojs/markdown-satteri@0.4.3)(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): + starlight-llms-txt@0.12.0(@astrojs/markdown-remark@7.3.4)(@astrojs/markdown-satteri@0.4.5)(@astrojs/starlight@0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): dependencies: - '@astrojs/mdx': 8.0.3(@astrojs/markdown-remark@7.3.2)(@astrojs/markdown-satteri@0.4.3)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) - '@astrojs/starlight': 0.42.6(@astrojs/markdown-remark@7.3.2)(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + '@astrojs/mdx': 8.1.1(@astrojs/markdown-remark@7.3.4)(@astrojs/markdown-satteri@0.4.5)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + '@astrojs/starlight': 0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) '@types/hast': 3.0.5 '@types/micromatch': 4.0.10 - astro: 7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + astro: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) github-slugger: 2.0.0 hast-util-select: 6.0.4 micromatch: 4.0.8 @@ -5516,9 +5408,17 @@ snapshots: - '@astrojs/markdown-satteri' - supports-color - starlight-markdown@0.1.5(astro@7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): + starlight-markdown@0.1.5(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): + dependencies: + astro: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + + starlight-sidebar-topics@0.9.0(@astrojs/starlight@0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)))(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): dependencies: - astro: 7.3.8(@astrojs/markdown-remark@7.3.2)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + '@astrojs/starlight': 0.42.7(@astrojs/markdown-remark@7.3.4)(astro@7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)) + astro: 7.4.2(@astrojs/markdown-remark@7.3.4)(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + picomatch: 4.0.7 + + std-env@4.3.0: {} stringify-entities@4.0.4: dependencies: @@ -5533,7 +5433,7 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylis@4.3.6: {} + stylis@4.4.0: {} supports-color@10.2.2: {} @@ -5569,20 +5469,15 @@ snapshots: tinyclip@1.0.3: {} - tinyexec@1.0.4: {} - tinyexec@1.3.1: {} - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyps@0.5.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5593,13 +5488,11 @@ snapshots: trough@2.2.0: {} - ts-dedent@2.2.0: {} + ts-dedent@2.3.0: {} tslib@2.8.1: optional: true - ufo@1.6.3: {} - ufo@1.6.4: {} ultrahtml@1.7.0: {} @@ -5608,9 +5501,7 @@ snapshots: undici-types@7.24.6: {} - undici@6.29.0: {} - - undici@8.11.2: {} + undici@8.12.3: {} unified@11.0.5: dependencies: @@ -5622,16 +5513,12 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unifont@0.7.5: + unifont@1.0.3: dependencies: css-tree: 3.2.1 - ohash: 2.0.12 - undici: 8.11.2 - - unist-util-find-after@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 + fnv1a-64: 0.1.2 + std-env: 4.3.0 + undici: 8.12.3 unist-util-is@6.0.1: dependencies: @@ -5653,13 +5540,13 @@ snapshots: unist-util-remove-position@5.0.0: dependencies: '@types/unist': 3.0.3 - unist-util-visit: 5.1.0 + unist-util-visit: 5.2.0 unist-util-remove@4.0.0: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 + unist-util-visit-parents: 6.1.0 unist-util-stringify-position@4.0.0: dependencies: @@ -5669,18 +5556,18 @@ snapshots: dependencies: '@types/unist': 3.0.3 - unist-util-visit-parents@6.0.2: + unist-util-visit-parents@6.1.0: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.1 - unist-util-visit@5.1.0: + unist-util-visit@5.2.0: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 + unist-util-visit-parents: 6.1.0 - unstorage@1.17.5: + unstorage@1.17.6: dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -5695,7 +5582,7 @@ snapshots: util-deprecate@1.0.2: {} - uuid@11.1.0: {} + uuid@14.0.3: {} verkit@0.5.0: {} @@ -5714,12 +5601,12 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.3.4(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1): + vite@8.3.5(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.29 - rolldown: 1.2.13 + postcss: 8.5.31 + rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.19.2 @@ -5728,13 +5615,13 @@ snapshots: jiti: 2.6.1 yaml: 2.9.1 - vitefu@1.1.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): + vitefu@1.1.3(vite@8.3.5(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1)): optionalDependencies: - vite: 8.3.4(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.5(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.9.1) web-namespaces@2.0.1: {} - which@6.0.1: + which@7.0.0: dependencies: isexe: 4.0.0 @@ -5748,6 +5635,6 @@ snapshots: yocto-queue@1.2.2: {} - zod@4.6.5: {} + zod@4.6.7: {} zwitch@2.0.4: {} diff --git a/docs/scripts/refresh-launcher-release.mjs b/docs/scripts/refresh-launcher-release.mjs new file mode 100644 index 00000000000..79042e4bbec --- /dev/null +++ b/docs/scripts/refresh-launcher-release.mjs @@ -0,0 +1,9 @@ +// Rewrites `src/data/launcher-release.json`, the download page's fallback when GitHub cannot be reached at build time, +// from the latest launcher release. Set GITHUB_TOKEN to avoid the unauthenticated rate limit. +import { writeFileSync } from 'node:fs'; + +import { fetchLauncherRelease } from '../src/lib/launcher-release.mjs'; + +const release = await fetchLauncherRelease({ token: process.env.GITHUB_TOKEN }); +writeFileSync(new URL('../src/data/launcher-release.json', import.meta.url), `${JSON.stringify(release, null, 2)}\n`); +console.log(`Wrote launcher release v${release.version} (${release.publishedAt}).`); diff --git a/docs/scripts/sync-app-search-index.mjs b/docs/scripts/sync-app-search-index.mjs new file mode 100644 index 00000000000..207fb85cf5e --- /dev/null +++ b/docs/scripts/sync-app-search-index.mjs @@ -0,0 +1,29 @@ +// Copies the built docs index (`dist/app-search.json`, from `src/pages/app-search.json.ts`) into the app, which +// commits it for its command palette. `--check` fails instead of writing when the committed copy is out of date. +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + +import { formatAppSearchIndex } from '../src/lib/app-search.mjs'; + +const REGENERATE = 'cd docs && pnpm build && pnpm run sync-app-search-index'; +const builtUrl = new URL('../dist/app-search.json', import.meta.url); +const committedPath = 'invokeai/frontend/webv2/src/workbench/palette/docsSearchIndex.json'; +const committedUrl = new URL(`../../${committedPath}`, import.meta.url); + +if (!existsSync(builtUrl)) { + console.error(`dist/app-search.json is missing. Build the docs first: ${REGENERATE}`); + process.exit(1); +} + +const expected = formatAppSearchIndex(JSON.parse(readFileSync(builtUrl, 'utf8'))); + +if (process.argv.includes('--check')) { + const committed = existsSync(committedUrl) ? readFileSync(committedUrl, 'utf8') : ''; + if (committed !== expected) { + console.error(`${committedPath} does not match the docs build. Regenerate and commit it: ${REGENERATE}`); + process.exit(1); + } + console.log('The app search index matches the docs build.'); +} else { + writeFileSync(committedUrl, expected); + console.log(`Wrote ${committedPath}.`); +} diff --git a/docs/scripts/verify-deploy-output.mjs b/docs/scripts/verify-deploy-output.mjs index 885d1f44baa..1b82fa03f4f 100644 --- a/docs/scripts/verify-deploy-output.mjs +++ b/docs/scripts/verify-deploy-output.mjs @@ -1,9 +1,15 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; + +import { translatedLocales } from '../src/config/locales.mjs'; const deployTarget = process.env.DEPLOY_TARGET ?? 'custom'; const base = deployTarget === 'ghpages' ? '/InvokeAI' : ''; +const site = deployTarget === 'ghpages' ? 'https://invoke-ai.github.io' : 'https://v7.invoke.ai'; const withBase = (path) => `${base}${path}`; +/** @param {string} file a path inside dist */ +const readDist = (file) => readFileSync(new URL(`../dist/${file}`, import.meta.url), 'utf8'); + const expectations = [ { file: 'index.html', @@ -11,12 +17,20 @@ const expectations = [ `href="${withBase('/_astro/')}`, `src="${withBase('/_astro/')}`, `href="${withBase('/start-here/installation/')}`, + // Header links: logo and version menu, main navigation, download. + `href="${withBase('/')}"`, + `href="${withBase('/showcase/')}"`, + `href="${withBase('/download/')}`, ], excludes: deployTarget === 'custom' ? ['href="/InvokeAI/', 'src="/InvokeAI/'] : ['href="/_astro/', 'src="/_astro/'], }, { file: 'contributing/index.html', - includes: [`href="${withBase('/contributing/new-contributor-guide/')}`], + includes: [ + `href="${withBase('/contributing/new-contributor-guide/')}`, + // The header's Docs link is current on every docs page. + `href="${withBase('/start-here/installation/')}" aria-current="true"`, + ], excludes: [ deployTarget === 'custom' ? 'href="/InvokeAI/contributing/new-contributor-guide/"' @@ -24,6 +38,18 @@ const expectations = [ 'newContributorChecklist.md', ], }, + { + // A locale's fallback page: content and header links stay in the locale, except to root-only releases. Search + // engines are pointed at the English page it shows. + file: 'de/contributing/index.html', + includes: [ + `href="${withBase('/de/contributing/new-contributor-guide/')}`, + `href="${withBase('/de/start-here/installation/')}" aria-current="true"`, + `href="${withBase('/releases/')}"`, + ``, + ], + excludes: [`href="${withBase('/contributing/new-contributor-guide/')}`], + }, { file: 'contributing/contribution_guides/newContributorChecklist/index.html', includes: [ @@ -48,7 +74,7 @@ const expectations = [ const errors = []; for (const { file, includes = [], excludes = [] } of expectations) { - const html = readFileSync(new URL(`../dist/${file}`, import.meta.url), 'utf8'); + const html = readDist(file); for (const expected of includes) { if (!html.includes(expected)) { @@ -63,6 +89,81 @@ for (const { file, includes = [], excludes = [] } of expectations) { } } +// starlight-markdown serves each docs page's Markdown source, which the page's "Copy page" and "View as Markdown" +// actions point at. +const markdownPage = '/start-here/installation/'; +const markdown = readDist(`${markdownPage.slice(1)}index.md`); +if (!markdown.startsWith('# ') || markdown.includes(' + readdirSync(new URL(`../dist/${directory}/`, import.meta.url), { recursive: true }) + .filter((file) => file.endsWith('.html')) + .map((file) => `${directory}/${file}`); + +// Language alternates list translations only, so pages that are never translated (the contributor docs stay English) +// and pages without locale routes list none. +const untranslatedFiles = [ + 'contributing/index.html', + 'de/contributing/index.html', + '404.html', + 'releases/index.html', + 'download/index.html', + // The showcase, the weekly challenges' archive and each challenge's page. + ...htmlFilesIn('showcase'), +]; +for (const file of untranslatedFiles) { + if (readDist(file).includes('} */ +const builtFiles = new Map(); +const isBuilt = (target) => { + if (!builtFiles.has(target)) builtFiles.set(target, existsSync(new URL(`../dist${target}`, import.meta.url))); + return builtFiles.get(target); +}; +const linkCheckedFiles = [ + 'index.html', + 'download/index.html', + // The showcase, the weekly challenges' archive and each challenge's page. + ...htmlFilesIn('showcase'), + ...translatedLocales.flatMap(htmlFilesIn), +]; +for (const file of linkCheckedFiles) { + const pageUrl = new URL(`${base}/${file.replace(/index\.html$/, '')}`, 'https://site.invalid'); + for (const [, href] of readDist(file).matchAll(/\shref="([^"]*)"/g)) { + const url = new URL(href.replaceAll('&', '&'), pageUrl); + if (url.origin !== pageUrl.origin || (url.pathname === pageUrl.pathname && url.hash)) continue; + if (!url.pathname.startsWith(`${base}/`)) { + errors.push(`${file} links to ${href}, outside the ${base || '/'} base path`); + continue; + } + const path = decodeURIComponent(url.pathname.slice(base.length)); + const target = path.endsWith('/') ? `${path}index.html` : /\.[^/]+$/.test(path) ? path : `${path}/index.html`; + if (!isBuilt(target)) { + errors.push(`${file} links to ${href}, but dist has no ${target.slice(1)}`); + } + } +} + if (errors.length > 0) { throw new Error(`${deployTarget} output validation failed:\n- ${errors.join('\n- ')}`); } diff --git a/docs/src/assets/invoke-icon-wide.svg b/docs/src/assets/invoke-icon-wide.svg deleted file mode 100644 index cfeff994147..00000000000 --- a/docs/src/assets/invoke-icon-wide.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/docs/src/assets/invoke-icon.svg b/docs/src/assets/invoke-icon.svg deleted file mode 100644 index 17cfdc77da7..00000000000 --- a/docs/src/assets/invoke-icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/src/assets/tour/canvas/sushi-after.webp b/docs/src/assets/tour/canvas/sushi-after.webp new file mode 100644 index 00000000000..176a14092bf Binary files /dev/null and b/docs/src/assets/tour/canvas/sushi-after.webp differ diff --git a/docs/src/assets/tour/canvas/sushi-before.webp b/docs/src/assets/tour/canvas/sushi-before.webp new file mode 100644 index 00000000000..0a5dff84b5d Binary files /dev/null and b/docs/src/assets/tour/canvas/sushi-before.webp differ diff --git a/docs/src/assets/tour/gallery/car.webp b/docs/src/assets/tour/gallery/car.webp new file mode 100644 index 00000000000..38f95fe097c Binary files /dev/null and b/docs/src/assets/tour/gallery/car.webp differ diff --git a/docs/src/assets/tour/gallery/espresso.webp b/docs/src/assets/tour/gallery/espresso.webp new file mode 100644 index 00000000000..d7718a5129f Binary files /dev/null and b/docs/src/assets/tour/gallery/espresso.webp differ diff --git a/docs/src/assets/tour/gallery/house.webp b/docs/src/assets/tour/gallery/house.webp new file mode 100644 index 00000000000..fadef2954c3 Binary files /dev/null and b/docs/src/assets/tour/gallery/house.webp differ diff --git a/docs/src/assets/tour/gallery/lineart.webp b/docs/src/assets/tour/gallery/lineart.webp new file mode 100644 index 00000000000..9a0af63acd8 Binary files /dev/null and b/docs/src/assets/tour/gallery/lineart.webp differ diff --git a/docs/src/assets/tour/gallery/market.webp b/docs/src/assets/tour/gallery/market.webp new file mode 100644 index 00000000000..4a98b7fa887 Binary files /dev/null and b/docs/src/assets/tour/gallery/market.webp differ diff --git a/docs/src/assets/tour/gallery/mech.webp b/docs/src/assets/tour/gallery/mech.webp new file mode 100644 index 00000000000..78a5cc95989 Binary files /dev/null and b/docs/src/assets/tour/gallery/mech.webp differ diff --git a/docs/src/assets/tour/gallery/mountains.webp b/docs/src/assets/tour/gallery/mountains.webp new file mode 100644 index 00000000000..52447202dca Binary files /dev/null and b/docs/src/assets/tour/gallery/mountains.webp differ diff --git a/docs/src/assets/tour/gallery/planet.webp b/docs/src/assets/tour/gallery/planet.webp new file mode 100644 index 00000000000..9088249473f Binary files /dev/null and b/docs/src/assets/tour/gallery/planet.webp differ diff --git a/docs/src/assets/tour/gallery/portrait.webp b/docs/src/assets/tour/gallery/portrait.webp new file mode 100644 index 00000000000..ba2c6a1060c Binary files /dev/null and b/docs/src/assets/tour/gallery/portrait.webp differ diff --git a/docs/src/assets/tour/gallery/rider.webp b/docs/src/assets/tour/gallery/rider.webp new file mode 100644 index 00000000000..acf22fca412 Binary files /dev/null and b/docs/src/assets/tour/gallery/rider.webp differ diff --git a/docs/src/assets/tour/gallery/scientist.webp b/docs/src/assets/tour/gallery/scientist.webp new file mode 100644 index 00000000000..82ed49b735e Binary files /dev/null and b/docs/src/assets/tour/gallery/scientist.webp differ diff --git a/docs/src/assets/tour/gallery/sushi.webp b/docs/src/assets/tour/gallery/sushi.webp new file mode 100644 index 00000000000..2d5be8bab94 Binary files /dev/null and b/docs/src/assets/tour/gallery/sushi.webp differ diff --git a/docs/src/assets/tour/gallery/wizard.webp b/docs/src/assets/tour/gallery/wizard.webp new file mode 100644 index 00000000000..5a6949904f1 Binary files /dev/null and b/docs/src/assets/tour/gallery/wizard.webp differ diff --git a/docs/src/assets/tour/workflow/compositor-background.webp b/docs/src/assets/tour/workflow/compositor-background.webp new file mode 100644 index 00000000000..d97da6cd4b2 Binary files /dev/null and b/docs/src/assets/tour/workflow/compositor-background.webp differ diff --git a/docs/src/assets/v7-logo.webp b/docs/src/assets/v7-logo.webp new file mode 100644 index 00000000000..e9353f3e5df Binary files /dev/null and b/docs/src/assets/v7-logo.webp differ diff --git a/docs/src/assets/whats-new-landscape.webp b/docs/src/assets/whats-new-landscape.webp new file mode 100644 index 00000000000..0a16e28b519 Binary files /dev/null and b/docs/src/assets/whats-new-landscape.webp differ diff --git a/docs/src/config/icons.ts b/docs/src/config/icons.ts new file mode 100644 index 00000000000..ab1deaa3552 --- /dev/null +++ b/docs/src/config/icons.ts @@ -0,0 +1,106 @@ +// The docs' icons, from Lucide like the app's. Where the app shows the same concept, the docs use the same icon (the +// app source is noted). Section icons are keyed by the section's directory as a URL path (the form `alsoListedIn` +// uses, e.g. `users-guide/workbench` for `Users Guide/01.Workbench`), so renumbering a directory keeps its icon. The +// build fails when a topic or sidebar section has no icon here. + +import AppWindow from '@lucide/astro/icons/app-window'; +import BookOpen from '@lucide/astro/icons/book-open'; +import BookOpenText from '@lucide/astro/icons/book-open-text'; +import Box from '@lucide/astro/icons/box'; +import Brush from '@lucide/astro/icons/brush'; +import Blocks from '@lucide/astro/icons/blocks'; +import Clapperboard from '@lucide/astro/icons/clapperboard'; +import Cloud from '@lucide/astro/icons/cloud'; +import CodeXml from '@lucide/astro/icons/code-xml'; +import Container from '@lucide/astro/icons/container'; +import Cpu from '@lucide/astro/icons/cpu'; +import FileText from '@lucide/astro/icons/file-text'; +import FlaskConical from '@lucide/astro/icons/flask-conical'; +import Folder from '@lucide/astro/icons/folder'; +import Gauge from '@lucide/astro/icons/gauge'; +import GitBranch from '@lucide/astro/icons/git-branch'; +import GitPullRequest from '@lucide/astro/icons/git-pull-request'; +import ImageIcon from '@lucide/astro/icons/image'; +import Layers from '@lucide/astro/icons/layers'; +import LifeBuoy from '@lucide/astro/icons/life-buoy'; +import MapIcon from '@lucide/astro/icons/map'; +import PanelsTopLeft from '@lucide/astro/icons/panels-top-left'; +import Puzzle from '@lucide/astro/icons/puzzle'; +import Rocket from '@lucide/astro/icons/rocket'; +import Settings from '@lucide/astro/icons/settings'; +import SlidersHorizontal from '@lucide/astro/icons/sliders-horizontal'; +import Tag from '@lucide/astro/icons/tag'; +import Users from '@lucide/astro/icons/users'; +import Workflow from '@lucide/astro/icons/workflow'; +import Wrench from '@lucide/astro/icons/wrench'; + +import { findSectionIcon } from '../lib/section-icon.mjs'; +import { docsTopics, type DocsTopic } from './topics'; + +export type IconComponent = typeof Folder; + +const topicIcons: Record = { + 'get-started': Rocket, + guide: BookOpen, + models: Box, // Launchpad: Models + configure: Settings, // Launchpad: Preferences + developers: CodeXml, + contribute: GitPullRequest, + troubleshooting: LifeBuoy, + releases: Tag, +}; + +const sectionIcons: Record = { + 'users-guide/workbench': PanelsTopLeft, // What's new: the interface + 'users-guide/projects': Folder, // Launchpad: Projects + 'users-guide/image-generation': SlidersHorizontal, // Generate widget + 'users-guide/image-generation/advanced-topics': FlaskConical, + 'users-guide/video-generation': Clapperboard, // Video widget, launchpad video intent + 'users-guide/gallery': ImageIcon, // Gallery widget + 'users-guide/canvas': Brush, // Launchpad canvas intent + 'users-guide/workflows': Workflow, // Workflow widget + 'users-guide/image-map': MapIcon, // Image Map widget + 'users-guide/models/local-models': Cpu, + 'users-guide/models/external-models': Cloud, // External provider keys + 'configuration/optimization': Gauge, + 'configuration/multi-user-mode': Users, // Launchpad: Users + 'development/architecture': Layers, + 'development/documentation': FileText, + 'development/front-end': AppWindow, // Open projects control + 'development/guides': BookOpenText, // Help menu: documentation + 'development/process': GitBranch, + 'development/setup': Wrench, +}; + +/** The few pages that need an icon of their own, apart from their section's or topic's, keyed by page path. */ +const pageIcons: Record = { + 'configuration/docker': Container, + 'development/guides/creating-nodes': Puzzle, + 'development/guides/creating-node-pack': Blocks, // Node pack library and detail +}; + +/** A topic's icon. */ +export const getTopicIcon = (topic: DocsTopic): IconComponent => { + const icon = topicIcons[topic.id]; + if (!icon) throw new Error(`The docs topic "${topic.id}" has no icon. Add one to src/config/icons.ts.`); + return icon; +}; + +/** A sidebar section's icon, by the section's directory as a URL path. */ +export const getSidebarSectionIcon = (directory: string, label: string): IconComponent => { + const icon = sectionIcons[directory]; + if (!icon) throw new Error(`The sidebar section "${label}" (${directory}) has no icon. Add one to src/config/icons.ts.`); + return icon; +}; + +/** + * A docs page's icon: its own if it has one, else its section's, else its topic's; `undefined` outside every topic. + * @param path the page's path without the deployment base path, e.g. `/configuration/docker/` + */ +export const getSectionIcon = (path: string): IconComponent | undefined => + findSectionIcon( + path, + pageIcons, + sectionIcons, + docsTopics.map((topic) => ({ directory: topic.directory, icon: getTopicIcon(topic) })) + ); diff --git a/docs/src/config/locales.mjs b/docs/src/config/locales.mjs new file mode 100644 index 00000000000..4b1d869984b --- /dev/null +++ b/docs/src/config/locales.mjs @@ -0,0 +1,76 @@ +// The docs locales. English is the root locale, served without a prefix; each translated locale is served under +// `//`. Crowdin adds translated pages to `src/content/docs//`, mirroring the English tree, and +// Starlight serves the English page wherever a translation is missing. Plain JavaScript so plugins, scripts and tests +// can import it. + +/** Starlight's `locales` option. Keys are lowercase, as the URL segments they become. */ +export const locales = { + root: { label: 'English', lang: 'en' }, + de: { label: 'Deutsch', lang: 'de' }, + es: { label: 'Español', lang: 'es' }, + hi: { label: 'हिन्दी', lang: 'hi' }, +}; + +export const translatedLocales = Object.keys(locales).filter((locale) => locale !== 'root'); + +/** + * Paths, without the base path, that only exist at the site root: pages that are not docs and so have no locale + * routes (releases are English GitHub content, see `astro.config.mjs`), and build output. Links to them never take a + * locale prefix. + */ +export const ROOT_ONLY_PATHS = [ + '/download', + '/showcase', + '/releases', + '/_astro', + '/pagefind', + '/settings-search.json', + '/app-search.json', +]; + +/** Translators work in the Crowdin project, which opens pull requests with their translations. */ +export const CROWDIN_PROJECT_URL = 'https://crowdin.com/project/invoke'; + +/** + * The translated locale a path without the base path, or a docs entry id, starts with; `undefined` for the root + * locale. + * @param {string} path + * @returns {string | undefined} + */ +export const getPathLocale = (path) => { + const segment = path.replace(/^\/+/, '').split(/[/?#]/)[0]; + return translatedLocales.includes(segment) ? segment : undefined; +}; + +/** + * A site URL path as its locale and the English page's path, both without the base path: `/InvokeAI/de/x/#y` with base + * `/InvokeAI` is `{ locale: 'de', path: '/x/#y' }`. `undefined` for a path outside the base path. + * @param {string} pathname a root-relative path, which may end in a query or fragment + * @param {string} base Astro's `BASE_URL`, such as `/` or `/InvokeAI` + * @returns {{ locale: string | undefined, path: string } | undefined} + */ +export const splitSitePath = (pathname, base) => { + const basePath = base.replace(/\/+$/, ''); + const rest = pathname.slice(basePath.length); + if (!pathname.startsWith(basePath) || !/^(?:$|[/?#])/.test(rest)) return undefined; + const path = rest.startsWith('/') ? rest : `/${rest}`; + const locale = getPathLocale(path); + if (!locale) return { locale, path }; + const englishPath = path.slice(locale.length + 1); + return { locale, path: englishPath.startsWith('/') ? englishPath : `/${englishPath}` }; +}; + +/** @param {string} path a path without the base path, which may end in a query or fragment */ +export const isRootOnlyPath = (path) => { + const pathname = path.split(/[?#]/)[0]; + return ROOT_ONLY_PATHS.some((rootPath) => pathname === rootPath || pathname.startsWith(`${rootPath}/`)); +}; + +/** + * A root-relative path without the base path, moved into `locale`. Paths that already carry a locale, and root-only + * paths, stay as they are. + * @param {string} path + * @param {string | undefined} locale a translated locale, or `undefined` for the root locale + */ +export const localizePath = (path, locale) => + !locale || getPathLocale(path) || isRootOnlyPath(path) ? path : `/${locale}${path}`; diff --git a/docs/src/config/locales.test.mjs b/docs/src/config/locales.test.mjs new file mode 100644 index 00000000000..5bb548590c3 --- /dev/null +++ b/docs/src/config/locales.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { splitSitePath } from './locales.mjs'; + +describe('splitSitePath', () => { + it('separates the locale from the English path, without the base path', () => { + assert.deepEqual(splitSitePath('/InvokeAI/de/users-guide/x/#y', '/InvokeAI'), { + locale: 'de', + path: '/users-guide/x/#y', + }); + assert.deepEqual(splitSitePath('/hi', '/'), { locale: 'hi', path: '/' }); + assert.deepEqual(splitSitePath('/es#top', '/'), { locale: 'es', path: '/#top' }); + assert.deepEqual(splitSitePath('/InvokeAI', '/InvokeAI/'), { locale: undefined, path: '/' }); + }); + + it('only treats a known locale as a locale segment', () => { + assert.deepEqual(splitSitePath('/development/x/', '/'), { locale: undefined, path: '/development/x/' }); + assert.deepEqual(splitSitePath('/it/x/', '/'), { locale: undefined, path: '/it/x/' }); + }); + + it('is undefined outside the base path', () => { + assert.equal(splitSitePath('/InvokeAIx/page/', '/InvokeAI'), undefined); + assert.equal(splitSitePath('/other/', '/InvokeAI'), undefined); + }); +}); diff --git a/docs/src/config/model-families.ts b/docs/src/config/model-families.ts new file mode 100644 index 00000000000..2220755cee1 --- /dev/null +++ b/docs/src/config/model-families.ts @@ -0,0 +1,183 @@ +// The model families on the home page, summarised from each family's guide and the hardware table in +// `start-here/system-requirements.mdx`, with each family's tagline in `src/content/i18n/en.json` +// (`modelFamilies..tagline`). Keep them in step when a guide changes. +// +// A card shows a cover image above its text when `src/assets/model-families/.webp` exists, and is text only +// otherwise. Covers are cropped to a 16:9 frame, so keep the subject near the centre. + +type LicenseKind = 'open' | 'community' | 'noncommercial' | 'research' | 'restricted' | 'mixed'; + +/** A name, such as a license or format name, or the UI string key of a generic label. */ +type Label = string | { labelKey: string }; + +type ModelFamily = { + slug: string; + name: string; + /** The family's guide, without the deployment base path. */ + link: string; + kind: 'image' | 'video'; + /** The headline model's license, or `mixed` when the family's headline models carry different licenses. */ + license: { kind: LicenseKind; label: Label }; + /** Minimum VRAM in GB for the smallest build in the hardware table, when the table lists the family. */ + vram?: number; + /** Quantized and single-file builds the guide documents, at most four. */ + formats: Label[]; +}; + +const variesByModel = { labelKey: 'modelFamilies.license.variesByModel' }; +const nonCommercial = { labelKey: 'modelFamilies.license.nonCommercial' }; +const singleFile = { labelKey: 'modelFamilies.format.singleFile' }; + +const localModels = '/users-guide/models/local-models'; +const video = '/users-guide/video-generation'; + +const modelFamilies: ModelFamily[] = [ + { + slug: 'flux-2', + name: 'FLUX.2', + link: `${localModels}/flux-2/`, + kind: 'image', + license: { kind: 'mixed', label: variesByModel }, + vram: 8, + formats: ['GGUF', 'FP8', 'int8', 'SDNQ'], + }, + { + slug: 'flux-1', + name: 'FLUX.1', + link: `${localModels}/flux-1/`, + kind: 'image', + license: { kind: 'mixed', label: variesByModel }, + vram: 10, + formats: ['NF4', 'GGUF', 'int8', 'SDNQ'], + }, + { + slug: 'z-image', + name: 'Z-Image', + link: `${localModels}/z-image/`, + kind: 'image', + license: { kind: 'open', label: 'Apache 2.0' }, + vram: 8, + formats: ['GGUF', 'NVFP4', 'SDNQ'], + }, + { + slug: 'qwen-image-2-1', + name: 'Qwen-Image-2.1', + link: `${localModels}/qwen-image-2-1/`, + kind: 'image', + license: { kind: 'research', label: 'Qwen Research' }, + vram: 16, + formats: ['GGUF', 'FP8', 'int8'], + }, + { + slug: 'qwen-image', + name: 'Qwen Image', + link: `${localModels}/qwen-image/`, + kind: 'image', + license: { kind: 'open', label: 'Apache 2.0' }, + formats: ['GGUF', 'FP8', 'NVFP4'], + }, + { + slug: 'krea-2', + name: 'Krea-2', + link: `${localModels}/krea-2/`, + kind: 'image', + license: { kind: 'community', label: 'Krea 2 Community' }, + vram: 16, + formats: ['NVFP4', 'GGUF', 'int8', 'FP8'], + }, + { + slug: 'ideogram4', + name: 'Ideogram 4', + link: `${localModels}/ideogram4/`, + kind: 'image', + license: { kind: 'noncommercial', label: nonCommercial }, + vram: 24, + formats: ['NF4', 'FP8', 'int8', 'GGUF'], + }, + { + slug: 'ernie-image', + name: 'ERNIE-Image', + link: `${localModels}/ernie-image/`, + kind: 'image', + license: { kind: 'open', label: 'Apache 2.0' }, + vram: 24, + formats: ['GGUF', singleFile], + }, + { + slug: 'cogview4', + name: 'CogView4', + link: `${localModels}/cogview4/`, + kind: 'image', + license: { kind: 'open', label: 'Apache 2.0' }, + formats: [], + }, + { + slug: 'anima', + name: 'Anima', + link: `${localModels}/anima/`, + kind: 'image', + license: { kind: 'noncommercial', label: nonCommercial }, + formats: [singleFile, 'int8'], + }, + { + slug: 'sd-3', + name: 'SD 3.5', + link: `${localModels}/sd-3/`, + kind: 'image', + license: { kind: 'community', label: 'Stability Community' }, + formats: [], + }, + { + slug: 'sdxl', + name: 'SDXL', + link: `${localModels}/sdxl/`, + kind: 'image', + license: { kind: 'open', label: 'OpenRAIL++-M' }, + vram: 8, + formats: [singleFile], + }, + { + slug: 'stable-diffusion-1-2', + name: 'SD 1.5 / 2.x', + link: `${localModels}/stable-diffusion-1-2/`, + kind: 'image', + license: { kind: 'open', label: 'OpenRAIL-M' }, + vram: 4, + formats: [singleFile], + }, + { + slug: 'wan-22', + name: 'Wan 2.2', + link: `${video}/wan-22/`, + kind: 'video', + license: { kind: 'open', label: 'Apache 2.0' }, + vram: 6, + formats: ['GGUF'], + }, + { + slug: 'ltx-2', + name: 'LTX-2', + link: `${video}/ltx-2/`, + kind: 'video', + license: { kind: 'community', label: 'LTX-2 Community' }, + formats: ['int8', 'NVFP4'], + }, + { + slug: 'minimax-h3', + name: 'MiniMax H3', + link: `${video}/minimax-h3/`, + kind: 'video', + license: { kind: 'restricted', label: 'MiniMax Community' }, + formats: ['int8'], + }, +]; + +const externalProviders = [ + { name: 'OpenAI', link: '/users-guide/models/external-models/openai/' }, + { name: 'Google Gemini', link: '/users-guide/models/external-models/gemini/' }, + { name: 'BytePlus Seedream', link: '/users-guide/models/external-models/seedream/' }, + { name: 'Alibaba Cloud', link: '/users-guide/models/external-models/alibabacloud/' }, +]; + +export { externalProviders, modelFamilies }; +export type { Label, LicenseKind, ModelFamily }; diff --git a/docs/src/config/sidebar.ts b/docs/src/config/sidebar.ts index d6672239d23..a4a08a0652f 100644 --- a/docs/src/config/sidebar.ts +++ b/docs/src/config/sidebar.ts @@ -1,73 +1,58 @@ +import { readdirSync } from 'node:fs'; + import type { StarlightUserConfig } from '@astrojs/starlight/types'; import { makeChangelogsSidebarLinks } from 'starlight-changelogs'; +import type starlightSidebarTopics from 'starlight-sidebar-topics'; -type SidebarConfig = StarlightUserConfig['sidebar']; +import { getSortPrefix, stripSortPrefix } from '../lib/sort-prefix.mjs'; +import { docsTopics, type DocsTopic } from './topics'; -const sidebar: SidebarConfig = [ - { - label: 'Start Here', - items: [ - { - autogenerate: { directory: 'start-here' }, - }, - ], - }, - { - label: 'Configuration', - items: [ - { - autogenerate: { directory: 'configuration', collapsed: true }, - }, - ], - collapsed: true, - }, - { - label: 'Users Guide', - items: [ - { - autogenerate: { directory: 'Users Guide', collapsed: true }, - }, - ], - }, - { - label: 'Development', - items: [ - { - autogenerate: { directory: 'development', collapsed: true }, - }, - ], - collapsed: true, - }, - { - label: 'Contributing', - items: [ - { - autogenerate: { directory: 'contributing' }, - }, - ], - collapsed: true, - }, - { - label: 'Troubleshooting & Help', - items: [ - { - autogenerate: { directory: 'troubleshooting' }, - }, - ], - collapsed: true, - }, - { - label: 'Releases', +type SidebarItems = NonNullable; +type SidebarTopicsConfig = Parameters[0]; +type SidebarTopicsOptions = Parameters[1]; + +const usersGuideDirectory = 'Users Guide'; +const modelsSection = '03.Models'; + +/** + * The Guide topic is the Users Guide without its Models section, which is a topic of its own. Each remaining section + * becomes a group, so a new section joins the Guide without a config change. + */ +const guideItems: SidebarItems = readdirSync(new URL(`../content/docs/${usersGuideDirectory}/`, import.meta.url), { + withFileTypes: true, +}) + .filter((entry) => entry.isDirectory() && entry.name !== 'assets' && entry.name !== modelsSection) + .map((entry) => entry.name) + .sort((a, b) => (getSortPrefix(a) ?? Infinity) - (getSortPrefix(b) ?? Infinity) || a.localeCompare(b)) + .map((name) => ({ + label: stripSortPrefix(name), collapsed: true, - items: [ - ...makeChangelogsSidebarLinks([ - { - type: 'recent', - base: 'releases', - }, - ]), - ], - }, -]; + items: [{ autogenerate: { directory: `${usersGuideDirectory}/${name}` } }], + })); + +const topicItems: Record = { + 'get-started': [{ autogenerate: { directory: 'start-here' } }], + guide: guideItems, + models: [{ autogenerate: { directory: `${usersGuideDirectory}/${modelsSection}` } }], + configure: [{ autogenerate: { directory: 'configuration' } }], + developers: [{ autogenerate: { directory: 'development' } }], + contribute: [{ autogenerate: { directory: 'contributing' } }], + troubleshooting: [{ autogenerate: { directory: 'troubleshooting' } }], + releases: makeChangelogsSidebarLinks([ + { type: 'all', label: 'All releases', base: 'releases' }, + { type: 'recent', base: 'releases' }, + ]), +}; + +const sidebarTopics: SidebarTopicsConfig = docsTopics.map(({ id, label, link }) => { + const items = topicItems[id]; + if (!items) throw new Error(`The docs topic "${id}" has no sidebar items.`); + return { id, label, link, items }; +}); + +const sidebarTopicsOptions: SidebarTopicsOptions = { + // Release pages beyond the recent ones the Releases sidebar links to. + topics: { releases: ['/releases/**'] }, +}; -export { sidebar as sidebarConfig }; +export { sidebarTopics, sidebarTopicsOptions }; diff --git a/docs/src/config/topics.ts b/docs/src/config/topics.ts new file mode 100644 index 00000000000..d054b9b6ed5 --- /dev/null +++ b/docs/src/config/topics.ts @@ -0,0 +1,95 @@ +// The docs are split into topics, each with its own sidebar (see `sidebar.ts`). The topic switcher at the top of the +// sidebar lists them in these groups. Their displayed names and descriptions are UI strings (`topics.*` keys in +// `src/content/i18n/en.json`). + +type TopicGroup = 'Use' | 'Run' | 'Build' | 'Help'; + +type DocsTopic = { + /** Stable identifier: the Pagefind `topic` filter value, also exposed through the `docs-topic` meta tag. */ + id: string; + /** + * Unique English label, by which starlight-sidebar-topics identifies the current topic. Pages show `topicLabel`. + */ + label: string; + group: TopicGroup; + /** The topic's landing page, without the deployment base path. */ + link: string; + /** The URL path its pages live under, for icon lookups (see `icons.ts`). */ + directory: string; +}; + +const topicGroups: TopicGroup[] = ['Use', 'Run', 'Build', 'Help']; + +const docsTopics: DocsTopic[] = [ + { + id: 'get-started', + label: 'Get Started', + group: 'Use', + link: '/start-here/installation/', + directory: 'start-here', + }, + { + id: 'guide', + label: 'Guide', + group: 'Use', + link: '/users-guide/workbench/introduction/', + directory: 'users-guide', + }, + { + id: 'models', + label: 'Models', + group: 'Use', + link: '/users-guide/models/introduction/', + directory: 'users-guide/models', + }, + { + id: 'configure', + label: 'Configure & Deploy', + group: 'Run', + link: '/configuration/invokeai-yaml/', + directory: 'configuration', + }, + { + id: 'developers', + label: 'Developers', + group: 'Build', + link: '/development/', + directory: 'development', + }, + { + id: 'contribute', + label: 'Contribute', + group: 'Build', + link: '/contributing/', + directory: 'contributing', + }, + { + id: 'troubleshooting', + label: 'Troubleshooting', + group: 'Help', + link: '/troubleshooting/faq/', + directory: 'troubleshooting', + }, + { + id: 'releases', + label: 'Releases', + group: 'Help', + link: '/releases/', + directory: 'releases', + }, +]; + +type Translate = App.Locals['t']; + +const topicLabel = (t: Translate, topic: DocsTopic) => t(`topics.${topic.id}.label`); +const topicDescription = (t: Translate, topic: DocsTopic) => t(`topics.${topic.id}.description`); +const topicGroupLabel = (t: Translate, group: TopicGroup) => t(`topics.group.${group.toLowerCase()}`); + +/** The topic of the page being rendered, or `undefined` for pages outside every topic (home, download, 404). */ +const getCurrentTopic = (locals: App.Locals): DocsTopic | undefined => { + const current = locals.starlightSidebarTopics?.topics.find((topic) => topic.isCurrent); + return current && docsTopics.find((topic) => topic.label === current.label); +}; + +export { docsTopics, getCurrentTopic, topicDescription, topicGroupLabel, topicGroups, topicLabel }; +export type { DocsTopic, TopicGroup }; diff --git a/docs/src/content.config.ts b/docs/src/content.config.ts index dc15607ef98..94d803b4d7d 100644 --- a/docs/src/content.config.ts +++ b/docs/src/content.config.ts @@ -1,12 +1,24 @@ import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; import { z } from 'astro/zod'; import { docsLoader, i18nLoader } from '@astrojs/starlight/loaders'; import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; import { changelogsLoader } from 'starlight-changelogs/loader'; +import en from './content/i18n/en.json'; +import { PLACES, placeProblems } from './lib/challenges.mjs'; +import { NODE_PACK_CATEGORIES } from './lib/community-nodes.mjs'; import { generateDocsId } from './lib/sort-prefix.mjs'; +// `en.json` declares the site's own UI strings (and the few Starlight strings it rewords), which Crowdin translates +// into each locale's file. Its keys are the schema, so a string is declared once. +const uiStrings = z.object( + Object.fromEntries(Object.keys(en).map((key) => [key, z.string().optional()])) as { + [Key in keyof typeof en]: z.ZodOptional; + } +); + export const collections = { docs: defineCollection({ loader: docsLoader({ generateId: generateDocsId }), @@ -18,7 +30,125 @@ export const collections = { }), }), }), - i18n: defineCollection({ loader: i18nLoader(), schema: i18nSchema() }), + i18n: defineCollection({ + loader: i18nLoader(), + schema: i18nSchema({ extend: uiStrings }), + }), + // Community artwork for `/showcase/` and the home page. `src/content/showcase/README.md` explains how to add one. + showcase: defineCollection({ + loader: glob({ pattern: '**/*.{yaml,yml,json}', base: './src/content/showcase' }), + schema: ({ image }) => + z.object({ + image: image(), + title: z.string(), + /** The artist's name as they asked to be credited. */ + artist: z.string(), + /** Only http(s): it becomes a link, and its value comes from a public submission form. */ + artistUrl: z.httpUrl().optional(), + /** Free text, such as "FLUX.2 Klein". */ + model: z.string().optional(), + /** A short note on how it was made, such as "Canvas, regional guidance". */ + createdWith: z.string().optional(), + date: z.coerce.date(), + }), + }), + // Winners of the weekly Discord art challenges, one folder per week with its `challenge.yaml` and the winners' + // images, shown on `/showcase/`, `/showcase/challenges/` and the home page. `src/content/challenges/README.md` + // explains how to publish one; `src/lib/challenges.test.mjs` checks the folders (stray files, image sizes, unique + // weeks and start dates). + challenges: defineCollection({ + loader: glob({ + pattern: '*/challenge.yaml', + base: './src/content/challenges', + // The folder name is the id, and the page's URL: `/showcase/challenges//`. + generateId: ({ entry }) => entry.split('/')[0], + }), + // Strict objects, so a misspelt field (`artistURL`, `winner`) fails the build instead of being dropped. + schema: ({ image }) => + z + .strictObject({ + /** The challenge's number, only if Discord announced one. */ + week: z.number().int().positive().optional(), + theme: z.string().min(1), + /** The prompt as announced, in a sentence or two. */ + brief: z.string().max(280).optional(), + /** + * The days the challenge and its winners were announced. YAML dates (`2026-10-21`, unquoted), which the YAML + * parser reads as dates; a quoted date is a string. + */ + startDate: z.date({ error: 'Write the date as YYYY-MM-DD, without quotes, such as 2026-10-21.' }), + endDate: z.date({ error: 'Write the date as YYYY-MM-DD, without quotes, such as 2026-10-27.' }), + /** The winners' announcement on Discord. */ + discordUrl: z.httpUrl().optional(), + winners: z + .array( + z.strictObject({ + /** A numbered place (ties repeat it), an unranked runner-up, or an honourable mention. */ + place: z.literal([...PLACES], { error: 'place must be 1, 2, 3, runner-up or honourable.' }), + image: image(), + /** The entrant's name exactly as they asked to be credited. */ + artist: z.string().min(1), + /** Only http(s): it becomes a link. */ + artistUrl: z.httpUrl().optional(), + title: z.string().min(1).optional(), + /** Free text, such as "FLUX.2 Klein". */ + model: z.string().min(1).optional(), + }) + ) + .min(1), + }) + .superRefine((challenge, context) => { + if (challenge.endDate < challenge.startDate) { + context.addIssue({ code: 'custom', message: '`endDate` is before `startDate`.', path: ['endDate'] }); + } + for (const message of placeProblems(challenge.winners)) { + context.addIssue({ code: 'custom', message, path: ['winners'] }); + } + }), + }), + // Node packs on the Community Nodes page. `src/content/community-nodes/README.md` explains how to add one. + communityNodes: defineCollection({ + loader: glob({ pattern: '*.yaml', base: './src/content/community-nodes' }), + // Strict objects, so a misspelt field (`dependency`, `thumbnial`) fails the build instead of being dropped. + schema: ({ image }) => + z + .strictObject({ + title: z.string(), + /** The repository owner's name, as on GitHub or GitLab. */ + author: z.string(), + /** The repository's web page. With `.git` appended it is the URL the Nodes manager and `git clone` take. */ + repo: z.httpUrl(), + /** One sentence for the card. */ + summary: z.string().max(120), + /** More about the pack: paragraphs separated by a blank line. `code` spans are the only markup. */ + description: z.string().optional(), + categories: z + .array(z.enum(NODE_PACK_CATEGORIES.map(({ id }) => id) as [string, ...string[]])) + .min(1), + /** `manual` when the repository is not a pack folder (no top-level `__init__.py`), so the manager can't install it. */ + install: z.enum(['manager', 'manual']).default('manager'), + /** The pack ships a `requirements.txt` or `pyproject.toml`, whose packages you install yourself. */ + dependencies: z.boolean().default(false), + /** The repository is archived: read-only, with no further updates. */ + archived: z.boolean().default(false), + nodes: z.array(z.strictObject({ name: z.string(), description: z.string().optional() })).default([]), + /** Further reading, such as example workflows. */ + links: z.array(z.strictObject({ label: z.string(), url: z.httpUrl() })).default([]), + /** + * The card's image: a small WebP of the first example beside this file (see the README), so the grid loads no + * remote images. The build fails when the file is missing. Its alt text is the first example's. + */ + thumbnail: image().optional(), + /** Hosted by the pack's author and loaded only when a reader opens the card's details. */ + images: z + .array(z.strictObject({ src: z.httpUrl(), alt: z.string().min(1), caption: z.string().optional() })) + .default([]), + }) + .refine((pack) => !pack.thumbnail || pack.images.length > 0, { + message: 'A thumbnail takes its alt text from the first image, so `images` needs at least one entry.', + path: ['thumbnail'], + }), + }), changelogs: defineCollection({ loader: changelogsLoader([ { diff --git a/docs/src/content/challenges/2026-08-18-pin-up/1-revenant.webp b/docs/src/content/challenges/2026-08-18-pin-up/1-revenant.webp new file mode 100644 index 00000000000..e946a8594b0 Binary files /dev/null and b/docs/src/content/challenges/2026-08-18-pin-up/1-revenant.webp differ diff --git a/docs/src/content/challenges/2026-08-18-pin-up/2-mickr777.webp b/docs/src/content/challenges/2026-08-18-pin-up/2-mickr777.webp new file mode 100644 index 00000000000..840d08eea4c Binary files /dev/null and b/docs/src/content/challenges/2026-08-18-pin-up/2-mickr777.webp differ diff --git a/docs/src/content/challenges/2026-08-18-pin-up/challenge.yaml b/docs/src/content/challenges/2026-08-18-pin-up/challenge.yaml new file mode 100644 index 00000000000..f1ef64b9a37 --- /dev/null +++ b/docs/src/content/challenges/2026-08-18-pin-up/challenge.yaml @@ -0,0 +1,22 @@ +theme: Pin-Up +brief: >- + Forget the classic beauty shot. Take the pin-up attitude and make it your own. +startDate: 2026-08-18 +endDate: 2026-08-24 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1541369389583171594 +winners: + - place: 1 + image: ./1-revenant.webp + artist: Revenant + - place: 2 + image: ./2-mickr777.webp + artist: mickr777 + - place: runner-up + image: ./runner-up-kylie.webp + artist: KYLIE + - place: runner-up + image: ./runner-up-mother-maiden-crow.webp + artist: Mother, Maiden, Crow + - place: runner-up + image: ./runner-up-subsector13.webp + artist: Subsector13 diff --git a/docs/src/content/challenges/2026-08-18-pin-up/runner-up-kylie.webp b/docs/src/content/challenges/2026-08-18-pin-up/runner-up-kylie.webp new file mode 100644 index 00000000000..6381f039f6a Binary files /dev/null and b/docs/src/content/challenges/2026-08-18-pin-up/runner-up-kylie.webp differ diff --git a/docs/src/content/challenges/2026-08-18-pin-up/runner-up-mother-maiden-crow.webp b/docs/src/content/challenges/2026-08-18-pin-up/runner-up-mother-maiden-crow.webp new file mode 100644 index 00000000000..2a596b1ce39 Binary files /dev/null and b/docs/src/content/challenges/2026-08-18-pin-up/runner-up-mother-maiden-crow.webp differ diff --git a/docs/src/content/challenges/2026-08-18-pin-up/runner-up-subsector13.webp b/docs/src/content/challenges/2026-08-18-pin-up/runner-up-subsector13.webp new file mode 100644 index 00000000000..826e5502d5d Binary files /dev/null and b/docs/src/content/challenges/2026-08-18-pin-up/runner-up-subsector13.webp differ diff --git a/docs/src/content/challenges/2026-08-24-fractured-reality/1-mother-maiden-crow.webp b/docs/src/content/challenges/2026-08-24-fractured-reality/1-mother-maiden-crow.webp new file mode 100644 index 00000000000..3655d330a1c Binary files /dev/null and b/docs/src/content/challenges/2026-08-24-fractured-reality/1-mother-maiden-crow.webp differ diff --git a/docs/src/content/challenges/2026-08-24-fractured-reality/challenge.yaml b/docs/src/content/challenges/2026-08-24-fractured-reality/challenge.yaml new file mode 100644 index 00000000000..e0503b71afd --- /dev/null +++ b/docs/src/content/challenges/2026-08-24-fractured-reality/challenge.yaml @@ -0,0 +1,19 @@ +theme: Fractured Reality +brief: >- + What happens when reality breaks? Break apart worlds, identities, memories, emotions, or perspectives, and explore what emerges from the fracture. +startDate: 2026-08-24 +endDate: 2026-08-31 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1543919018300149790 +winners: + - place: 1 + image: ./1-mother-maiden-crow.webp + artist: Mother, Maiden, Crow + - place: runner-up + image: ./runner-up-bob.webp + artist: Bob + - place: runner-up + image: ./runner-up-cakes6g.webp + artist: Cakes6G + - place: runner-up + image: ./runner-up-mrjohnnythunder.webp + artist: MrJohnnyThunder diff --git a/docs/src/content/challenges/2026-08-24-fractured-reality/runner-up-bob.webp b/docs/src/content/challenges/2026-08-24-fractured-reality/runner-up-bob.webp new file mode 100644 index 00000000000..caa33694a6d Binary files /dev/null and b/docs/src/content/challenges/2026-08-24-fractured-reality/runner-up-bob.webp differ diff --git a/docs/src/content/challenges/2026-08-24-fractured-reality/runner-up-cakes6g.webp b/docs/src/content/challenges/2026-08-24-fractured-reality/runner-up-cakes6g.webp new file mode 100644 index 00000000000..2c99bc7d356 Binary files /dev/null and b/docs/src/content/challenges/2026-08-24-fractured-reality/runner-up-cakes6g.webp differ diff --git a/docs/src/content/challenges/2026-08-24-fractured-reality/runner-up-mrjohnnythunder.webp b/docs/src/content/challenges/2026-08-24-fractured-reality/runner-up-mrjohnnythunder.webp new file mode 100644 index 00000000000..2f353959a30 Binary files /dev/null and b/docs/src/content/challenges/2026-08-24-fractured-reality/runner-up-mrjohnnythunder.webp differ diff --git a/docs/src/content/challenges/2026-08-31-watercolor/1-bob.webp b/docs/src/content/challenges/2026-08-31-watercolor/1-bob.webp new file mode 100644 index 00000000000..48335707668 Binary files /dev/null and b/docs/src/content/challenges/2026-08-31-watercolor/1-bob.webp differ diff --git a/docs/src/content/challenges/2026-08-31-watercolor/challenge.yaml b/docs/src/content/challenges/2026-08-31-watercolor/challenge.yaml new file mode 100644 index 00000000000..1d0ce563029 --- /dev/null +++ b/docs/src/content/challenges/2026-08-31-watercolor/challenge.yaml @@ -0,0 +1,22 @@ +theme: Watercolor +brief: >- + From soft washes and translucent layers to incredibly precise details, watercolor has a way of making even the simplest image feel special. +startDate: 2026-08-31 +endDate: 2026-09-07 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1546443751923261440 +winners: + - place: 1 + image: ./1-bob.webp + artist: Bob + - place: runner-up + image: ./runner-up-jintoku49.webp + artist: jintoku49 + - place: runner-up + image: ./runner-up-jpphoto.webp + artist: JPPhoto + - place: runner-up + image: ./runner-up-mickr777.webp + artist: mickr777 + - place: runner-up + image: ./runner-up-subsector13.webp + artist: Subsector13 diff --git a/docs/src/content/challenges/2026-08-31-watercolor/runner-up-jintoku49.webp b/docs/src/content/challenges/2026-08-31-watercolor/runner-up-jintoku49.webp new file mode 100644 index 00000000000..f9510329ba4 Binary files /dev/null and b/docs/src/content/challenges/2026-08-31-watercolor/runner-up-jintoku49.webp differ diff --git a/docs/src/content/challenges/2026-08-31-watercolor/runner-up-jpphoto.webp b/docs/src/content/challenges/2026-08-31-watercolor/runner-up-jpphoto.webp new file mode 100644 index 00000000000..5d1a87ebbea Binary files /dev/null and b/docs/src/content/challenges/2026-08-31-watercolor/runner-up-jpphoto.webp differ diff --git a/docs/src/content/challenges/2026-08-31-watercolor/runner-up-mickr777.webp b/docs/src/content/challenges/2026-08-31-watercolor/runner-up-mickr777.webp new file mode 100644 index 00000000000..a3a5a64d857 Binary files /dev/null and b/docs/src/content/challenges/2026-08-31-watercolor/runner-up-mickr777.webp differ diff --git a/docs/src/content/challenges/2026-08-31-watercolor/runner-up-subsector13.webp b/docs/src/content/challenges/2026-08-31-watercolor/runner-up-subsector13.webp new file mode 100644 index 00000000000..f33f0657384 Binary files /dev/null and b/docs/src/content/challenges/2026-08-31-watercolor/runner-up-subsector13.webp differ diff --git a/docs/src/content/challenges/2026-09-07-close-up/1-shanedk.webp b/docs/src/content/challenges/2026-09-07-close-up/1-shanedk.webp new file mode 100644 index 00000000000..efe47d14a24 Binary files /dev/null and b/docs/src/content/challenges/2026-09-07-close-up/1-shanedk.webp differ diff --git a/docs/src/content/challenges/2026-09-07-close-up/2-deepbluefrog.webp b/docs/src/content/challenges/2026-09-07-close-up/2-deepbluefrog.webp new file mode 100644 index 00000000000..a4814feb16d Binary files /dev/null and b/docs/src/content/challenges/2026-09-07-close-up/2-deepbluefrog.webp differ diff --git a/docs/src/content/challenges/2026-09-07-close-up/challenge.yaml b/docs/src/content/challenges/2026-09-07-close-up/challenge.yaml new file mode 100644 index 00000000000..d2627d33f0e --- /dev/null +++ b/docs/src/content/challenges/2026-09-07-close-up/challenge.yaml @@ -0,0 +1,22 @@ +theme: Close Up +brief: >- + Take a tiny detail, strange texture, intricate pattern, creature feature, mechanical part, or anything else that catches your eye, and make it the star of the image. +startDate: 2026-09-07 +endDate: 2026-09-14 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1548980879991509032 +winners: + - place: 1 + image: ./1-shanedk.webp + artist: ShaneDK + - place: 2 + image: ./2-deepbluefrog.webp + artist: deepbluefrog + - place: runner-up + image: ./runner-up-angry-silverbeard.webp + artist: Angry_Silverbeard + - place: runner-up + image: ./runner-up-bob.webp + artist: Bob + - place: runner-up + image: ./runner-up-skunkworxdark.webp + artist: SkunkWorxDark diff --git a/docs/src/content/challenges/2026-09-07-close-up/runner-up-angry-silverbeard.webp b/docs/src/content/challenges/2026-09-07-close-up/runner-up-angry-silverbeard.webp new file mode 100644 index 00000000000..0a0cc3cbb09 Binary files /dev/null and b/docs/src/content/challenges/2026-09-07-close-up/runner-up-angry-silverbeard.webp differ diff --git a/docs/src/content/challenges/2026-09-07-close-up/runner-up-bob.webp b/docs/src/content/challenges/2026-09-07-close-up/runner-up-bob.webp new file mode 100644 index 00000000000..eb29dc79554 Binary files /dev/null and b/docs/src/content/challenges/2026-09-07-close-up/runner-up-bob.webp differ diff --git a/docs/src/content/challenges/2026-09-07-close-up/runner-up-skunkworxdark.webp b/docs/src/content/challenges/2026-09-07-close-up/runner-up-skunkworxdark.webp new file mode 100644 index 00000000000..e815fc20ee5 Binary files /dev/null and b/docs/src/content/challenges/2026-09-07-close-up/runner-up-skunkworxdark.webp differ diff --git a/docs/src/content/challenges/2026-09-14-skull/1-skunkworxdark.webp b/docs/src/content/challenges/2026-09-14-skull/1-skunkworxdark.webp new file mode 100644 index 00000000000..11c79c023ac Binary files /dev/null and b/docs/src/content/challenges/2026-09-14-skull/1-skunkworxdark.webp differ diff --git a/docs/src/content/challenges/2026-09-14-skull/2-bob.webp b/docs/src/content/challenges/2026-09-14-skull/2-bob.webp new file mode 100644 index 00000000000..70937d25e79 Binary files /dev/null and b/docs/src/content/challenges/2026-09-14-skull/2-bob.webp differ diff --git a/docs/src/content/challenges/2026-09-14-skull/challenge.yaml b/docs/src/content/challenges/2026-09-14-skull/challenge.yaml new file mode 100644 index 00000000000..4edb4f80a90 --- /dev/null +++ b/docs/src/content/challenges/2026-09-14-skull/challenge.yaml @@ -0,0 +1,22 @@ +theme: Skull +brief: >- + From ancient symbols to contemporary art, skulls have taken countless forms and meanings throughout history and across cultures. Reimagine the skull. Take it somewhere unexpected. +startDate: 2026-09-14 +endDate: 2026-09-21 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1551507736951390208 +winners: + - place: 1 + image: ./1-skunkworxdark.webp + artist: SkunkWorxDark + - place: 2 + image: ./2-bob.webp + artist: Bob + - place: runner-up + image: ./runner-up-alz-heimer.webp + artist: Alz-Heimer + - place: runner-up + image: ./runner-up-brainslime.webp + artist: Brainslime + - place: runner-up + image: ./runner-up-mickr777.webp + artist: mickr777 diff --git a/docs/src/content/challenges/2026-09-14-skull/runner-up-alz-heimer.webp b/docs/src/content/challenges/2026-09-14-skull/runner-up-alz-heimer.webp new file mode 100644 index 00000000000..d0e6e953b05 Binary files /dev/null and b/docs/src/content/challenges/2026-09-14-skull/runner-up-alz-heimer.webp differ diff --git a/docs/src/content/challenges/2026-09-14-skull/runner-up-brainslime.webp b/docs/src/content/challenges/2026-09-14-skull/runner-up-brainslime.webp new file mode 100644 index 00000000000..b6238c23a2f Binary files /dev/null and b/docs/src/content/challenges/2026-09-14-skull/runner-up-brainslime.webp differ diff --git a/docs/src/content/challenges/2026-09-14-skull/runner-up-mickr777.webp b/docs/src/content/challenges/2026-09-14-skull/runner-up-mickr777.webp new file mode 100644 index 00000000000..8556e3800ea Binary files /dev/null and b/docs/src/content/challenges/2026-09-14-skull/runner-up-mickr777.webp differ diff --git a/docs/src/content/challenges/2026-09-21-lost/1-kahn.webp b/docs/src/content/challenges/2026-09-21-lost/1-kahn.webp new file mode 100644 index 00000000000..ba3c0bf573d Binary files /dev/null and b/docs/src/content/challenges/2026-09-21-lost/1-kahn.webp differ diff --git a/docs/src/content/challenges/2026-09-21-lost/2-alz-heimer.webp b/docs/src/content/challenges/2026-09-21-lost/2-alz-heimer.webp new file mode 100644 index 00000000000..0cffac51073 Binary files /dev/null and b/docs/src/content/challenges/2026-09-21-lost/2-alz-heimer.webp differ diff --git a/docs/src/content/challenges/2026-09-21-lost/2-xorpio.webp b/docs/src/content/challenges/2026-09-21-lost/2-xorpio.webp new file mode 100644 index 00000000000..9a1c83b8f9e Binary files /dev/null and b/docs/src/content/challenges/2026-09-21-lost/2-xorpio.webp differ diff --git a/docs/src/content/challenges/2026-09-21-lost/challenge.yaml b/docs/src/content/challenges/2026-09-21-lost/challenge.yaml new file mode 100644 index 00000000000..387590532e5 --- /dev/null +++ b/docs/src/content/challenges/2026-09-21-lost/challenge.yaml @@ -0,0 +1,25 @@ +theme: Lost +brief: >- + Sometimes being lost means not knowing where you are. Sometimes it means not knowing where you’re going. And sometimes, you don’t even realize what you’ve lost until it’s gone. +startDate: 2026-09-21 +endDate: 2026-09-28 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1554053507265462322 +winners: + - place: 1 + image: ./1-kahn.webp + artist: kahn + - place: 2 + image: ./2-alz-heimer.webp + artist: Alz-Heimer + - place: 2 + image: ./2-xorpio.webp + artist: Xorpio + - place: runner-up + image: ./runner-up-mickr777.webp + artist: mickr777 + - place: runner-up + image: ./runner-up-romeo.webp + artist: Romeo + - place: runner-up + image: ./runner-up-skunkworxdark.webp + artist: SkunkWorxDark diff --git a/docs/src/content/challenges/2026-09-21-lost/runner-up-mickr777.webp b/docs/src/content/challenges/2026-09-21-lost/runner-up-mickr777.webp new file mode 100644 index 00000000000..fbd7d130921 Binary files /dev/null and b/docs/src/content/challenges/2026-09-21-lost/runner-up-mickr777.webp differ diff --git a/docs/src/content/challenges/2026-09-21-lost/runner-up-romeo.webp b/docs/src/content/challenges/2026-09-21-lost/runner-up-romeo.webp new file mode 100644 index 00000000000..5eb3a664587 Binary files /dev/null and b/docs/src/content/challenges/2026-09-21-lost/runner-up-romeo.webp differ diff --git a/docs/src/content/challenges/2026-09-21-lost/runner-up-skunkworxdark.webp b/docs/src/content/challenges/2026-09-21-lost/runner-up-skunkworxdark.webp new file mode 100644 index 00000000000..e44a72ac592 Binary files /dev/null and b/docs/src/content/challenges/2026-09-21-lost/runner-up-skunkworxdark.webp differ diff --git a/docs/src/content/challenges/2026-09-28-animal-irony/1-mickr777.webp b/docs/src/content/challenges/2026-09-28-animal-irony/1-mickr777.webp new file mode 100644 index 00000000000..eec54508ba1 Binary files /dev/null and b/docs/src/content/challenges/2026-09-28-animal-irony/1-mickr777.webp differ diff --git a/docs/src/content/challenges/2026-09-28-animal-irony/2-muzeke.webp b/docs/src/content/challenges/2026-09-28-animal-irony/2-muzeke.webp new file mode 100644 index 00000000000..c3177b8dea8 Binary files /dev/null and b/docs/src/content/challenges/2026-09-28-animal-irony/2-muzeke.webp differ diff --git a/docs/src/content/challenges/2026-09-28-animal-irony/challenge.yaml b/docs/src/content/challenges/2026-09-28-animal-irony/challenge.yaml new file mode 100644 index 00000000000..2bd393b8adc --- /dev/null +++ b/docs/src/content/challenges/2026-09-28-animal-irony/challenge.yaml @@ -0,0 +1,25 @@ +theme: Animal Irony +brief: >- + What happens when animals completely break character? +startDate: 2026-09-28 +endDate: 2026-10-05 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1556588227513548840 +winners: + - place: 1 + image: ./1-mickr777.webp + artist: mickr777 + - place: 2 + image: ./2-muzeke.webp + artist: muzeke + - place: runner-up + image: ./runner-up-bob.webp + artist: Bob + - place: runner-up + image: ./runner-up-jordack.webp + artist: Jordack + - place: runner-up + image: ./runner-up-lontrastella.webp + artist: LontraStella + - place: runner-up + image: ./runner-up-xorpio.webp + artist: Xorpio diff --git a/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-bob.webp b/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-bob.webp new file mode 100644 index 00000000000..07ac3bd94c7 Binary files /dev/null and b/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-bob.webp differ diff --git a/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-jordack.webp b/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-jordack.webp new file mode 100644 index 00000000000..44263dd4e02 Binary files /dev/null and b/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-jordack.webp differ diff --git a/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-lontrastella.webp b/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-lontrastella.webp new file mode 100644 index 00000000000..cc8c613686c Binary files /dev/null and b/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-lontrastella.webp differ diff --git a/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-xorpio.webp b/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-xorpio.webp new file mode 100644 index 00000000000..3c9eb4a9f02 Binary files /dev/null and b/docs/src/content/challenges/2026-09-28-animal-irony/runner-up-xorpio.webp differ diff --git a/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/1-alz-heimer.webp b/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/1-alz-heimer.webp new file mode 100644 index 00000000000..b2b1373013c Binary files /dev/null and b/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/1-alz-heimer.webp differ diff --git a/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/challenge.yaml b/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/challenge.yaml new file mode 100644 index 00000000000..a656958c9ad --- /dev/null +++ b/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/challenge.yaml @@ -0,0 +1,16 @@ +theme: Art of the Ancestors +brief: >- + What if something left behind thousands of years ago was still waiting to be discovered? +startDate: 2026-10-05 +endDate: 2026-10-12 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1559139101368786986 +winners: + - place: 1 + image: ./1-alz-heimer.webp + artist: Alz-Heimer + - place: runner-up + image: ./runner-up-brainslime.webp + artist: Brainslime + - place: runner-up + image: ./runner-up-mother-maiden-crow.webp + artist: Mother, Maiden, Crow diff --git a/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/runner-up-brainslime.webp b/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/runner-up-brainslime.webp new file mode 100644 index 00000000000..141a594809b Binary files /dev/null and b/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/runner-up-brainslime.webp differ diff --git a/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/runner-up-mother-maiden-crow.webp b/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/runner-up-mother-maiden-crow.webp new file mode 100644 index 00000000000..f4febc34e69 Binary files /dev/null and b/docs/src/content/challenges/2026-10-05-art-of-the-ancestors/runner-up-mother-maiden-crow.webp differ diff --git a/docs/src/content/challenges/2026-10-12-gothic/1-jintoku49.webp b/docs/src/content/challenges/2026-10-12-gothic/1-jintoku49.webp new file mode 100644 index 00000000000..1245dd168cc Binary files /dev/null and b/docs/src/content/challenges/2026-10-12-gothic/1-jintoku49.webp differ diff --git a/docs/src/content/challenges/2026-10-12-gothic/2-bob.webp b/docs/src/content/challenges/2026-10-12-gothic/2-bob.webp new file mode 100644 index 00000000000..53efc379682 Binary files /dev/null and b/docs/src/content/challenges/2026-10-12-gothic/2-bob.webp differ diff --git a/docs/src/content/challenges/2026-10-12-gothic/2-mickr777.webp b/docs/src/content/challenges/2026-10-12-gothic/2-mickr777.webp new file mode 100644 index 00000000000..07df5b7a7b5 Binary files /dev/null and b/docs/src/content/challenges/2026-10-12-gothic/2-mickr777.webp differ diff --git a/docs/src/content/challenges/2026-10-12-gothic/challenge.yaml b/docs/src/content/challenges/2026-10-12-gothic/challenge.yaml new file mode 100644 index 00000000000..24705ea31fb --- /dev/null +++ b/docs/src/content/challenges/2026-10-12-gothic/challenge.yaml @@ -0,0 +1,25 @@ +theme: Gothic +brief: >- + Where darkness meets elegant beauty. Mysterious portraits, haunted castles, Victorian elegance, eerie settings, supernatural creatures, or dark romantic scenes. Explore your own interpretation of Gothic art. +startDate: 2026-10-12 +endDate: 2026-10-19 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1561672290301911102 +winners: + - place: 1 + image: ./1-jintoku49.webp + artist: jintoku49 + - place: 2 + image: ./2-bob.webp + artist: Bob + - place: 2 + image: ./2-mickr777.webp + artist: mickr777 + - place: runner-up + image: ./runner-up-cicekbonbon.webp + artist: Cicekbonbon + - place: runner-up + image: ./runner-up-jpphoto.webp + artist: JPPhoto + - place: runner-up + image: ./runner-up-seetruck-2000.webp + artist: SEETRUCK-2000 diff --git a/docs/src/content/challenges/2026-10-12-gothic/runner-up-cicekbonbon.webp b/docs/src/content/challenges/2026-10-12-gothic/runner-up-cicekbonbon.webp new file mode 100644 index 00000000000..4f74232ddb3 Binary files /dev/null and b/docs/src/content/challenges/2026-10-12-gothic/runner-up-cicekbonbon.webp differ diff --git a/docs/src/content/challenges/2026-10-12-gothic/runner-up-jpphoto.webp b/docs/src/content/challenges/2026-10-12-gothic/runner-up-jpphoto.webp new file mode 100644 index 00000000000..adcdde39f14 Binary files /dev/null and b/docs/src/content/challenges/2026-10-12-gothic/runner-up-jpphoto.webp differ diff --git a/docs/src/content/challenges/2026-10-12-gothic/runner-up-seetruck-2000.webp b/docs/src/content/challenges/2026-10-12-gothic/runner-up-seetruck-2000.webp new file mode 100644 index 00000000000..b888d07f3a6 Binary files /dev/null and b/docs/src/content/challenges/2026-10-12-gothic/runner-up-seetruck-2000.webp differ diff --git a/docs/src/content/challenges/2026-10-19-biomechanical-pets/1-subsector13.webp b/docs/src/content/challenges/2026-10-19-biomechanical-pets/1-subsector13.webp new file mode 100644 index 00000000000..d7bc54bc160 Binary files /dev/null and b/docs/src/content/challenges/2026-10-19-biomechanical-pets/1-subsector13.webp differ diff --git a/docs/src/content/challenges/2026-10-19-biomechanical-pets/challenge.yaml b/docs/src/content/challenges/2026-10-19-biomechanical-pets/challenge.yaml new file mode 100644 index 00000000000..9474a8c57ca --- /dev/null +++ b/docs/src/content/challenges/2026-10-19-biomechanical-pets/challenge.yaml @@ -0,0 +1,23 @@ +theme: Biomechanical Pets +brief: >- + What happens when living creatures and machinery merge? From familiar pets with a biomechanical twist to entirely new lifeforms, explore the possibilities and let your imagination run wild! +startDate: 2026-10-19 +endDate: 2026-10-26 +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1564197824751734795 +winners: + - place: 1 + image: ./1-subsector13.webp + artist: Subsector13 + - place: runner-up + image: ./runner-up-lontrastella.webp + artist: LontraStella + - place: runner-up + image: ./runner-up-loveless.webp + artist: Loveless + - place: runner-up + image: ./runner-up-med.webp + artist: med + artistUrl: https://www.instagram.com/medteraneo + - place: runner-up + image: ./runner-up-skunkworxdark.webp + artist: SkunkWorxDark diff --git a/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-lontrastella.webp b/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-lontrastella.webp new file mode 100644 index 00000000000..3fc1d692d12 Binary files /dev/null and b/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-lontrastella.webp differ diff --git a/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-loveless.webp b/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-loveless.webp new file mode 100644 index 00000000000..92927bf9d3b Binary files /dev/null and b/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-loveless.webp differ diff --git a/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-med.webp b/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-med.webp new file mode 100644 index 00000000000..c33797ff446 Binary files /dev/null and b/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-med.webp differ diff --git a/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-skunkworxdark.webp b/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-skunkworxdark.webp new file mode 100644 index 00000000000..dc9d9b5858e Binary files /dev/null and b/docs/src/content/challenges/2026-10-19-biomechanical-pets/runner-up-skunkworxdark.webp differ diff --git a/docs/src/content/challenges/README.md b/docs/src/content/challenges/README.md new file mode 100644 index 00000000000..f15eb3f7515 --- /dev/null +++ b/docs/src/content/challenges/README.md @@ -0,0 +1,154 @@ +# Weekly challenge winners + +Each folder in this directory is one week of the Invoke Discord's art challenge. The latest week appears at the top of +the docs site's `/showcase/` page and in the "Made with Invoke" section of the home page; every week has its own page +at `/showcase/challenges//` and a card on `/showcase/challenges/`. Weeks are ordered by `endDate`, newest +first. The collection is defined in `docs/src/content.config.ts`. + +`startDate` is the day the challenge was announced and `endDate` the day its winners were announced. The next +challenge is usually announced with the winners, so one week's `endDate` is often the next week's `startDate`; that is +expected. `discordUrl` links the winners' announcement, shown as "Winners on Discord". + +## Consent and credit + +Entrants agree, by entering a challenge on Discord, that their entry may be featured on the docs site. Publish only +entries from the challenge itself, and credit each winner by name exactly as they go by on Discord, or as they ask to be +credited. Add a link only if they gave you one. + +**Removing an entry on request.** If a winner asks to be removed, delete their item from `winners` in +`challenge.yaml` and delete their image file, then open a pull request. Leave the other winners' places as they are: +the page leads with the first winner left, which is the highest place. A week needs a numbered winner, so if they held +the only numbered place, or were the week's only winner, delete the whole folder. If they ask only for a different +credit or no link, edit `artist` or remove `artistUrl`. + +## Publishing a week + +1. **Make the folder.** Name it `YYYY-MM-DD-`, with `startDate` and the theme in lowercase letters, digits and + hyphens, such as `2026-10-19-biomechanical-pets`. Only if Discord announced a week number, name it + `-w-` instead, with the year of `startDate` and that number, such as + `2026-w44-bioluminescence`. The name becomes the page's address, so don't rename a folder once it is published; + links shared on Discord would break. +2. **Add the images** to the folder (see [Exporting images](#exporting-images)). Name them after the place and the + artist, such as `1-ana-example.webp`, `2-mira-k.webp`, `runner-up-theo.webp`, `honourable-lumen.webp`. +3. **Write `challenge.yaml`** in the folder, using the example below. +4. **Preview it** (see [Previewing locally](#previewing-locally)), then open a pull request with the folder. + +Folder layout: + +```text +src/content/challenges/ +└── 2026-10-21-bioluminescence/ + ├── challenge.yaml + ├── 1-ana-example.webp + ├── 2-mira-k.webp + ├── runner-up-theo.webp + └── honourable-lumen.webp +``` + +### `challenge.yaml` + +Every field, with the optional ones marked: + +```yaml +theme: Bioluminescence # as announced +brief: Living light in the deep, the forest floor or the city at night. # optional, as announced, at most 280 characters +startDate: 2026-10-21 # YYYY-MM-DD, without quotes: the day the challenge was announced +endDate: 2026-10-27 # YYYY-MM-DD, without quotes: the day the winners were announced +discordUrl: https://discord.com/channels/1020123559063990373/1020355799945580585/1564197824751734795 # optional +winners: # in the order the page shows them + - place: 1 # 1, 2, 3, runner-up or honourable + image: ./1-ana-example.webp # the file in this folder + artist: Ana Example # the credit, exactly as given + artistUrl: https://example.com/ana # optional, http:// or https:// only + title: Tidewater Lantern # optional + model: FLUX.2 Klein # optional, free text + - place: 2 + image: ./2-mira-k.webp + artist: mira_k + - place: runner-up # any number of runners-up, shown in this order + image: ./runner-up-theo.webp + artist: Theo + - place: honourable # any number of honourable mentions, shown in this order + image: ./honourable-lumen.webp + artist: lumen +``` + +A numbered week (folder `2026-w44-bioluminescence`) adds the announced number as the first line: `week: 44`. + +Rules the build and tests enforce: + +- `theme`, `startDate`, `endDate` and `winners` are required, and so are each winner's `place`, `image` and + `artist`. A misspelt field name, such as `artistURL`, fails the build rather than being ignored. +- Winners are listed in the order the page shows them: numbered places first, never decreasing (`1, 2, 2` for a tie + for 2nd; `2, 1` fails), then runners-up, then honourable mentions. At most three winners hold a numbered place, + ties included, and at least one does: the first winner leads the page and is the preview image when the page is + shared on Discord. +- Dates are written `YYYY-MM-DD` without quotes: `"2026-10-21"` in quotes is text, not a date, and fails the build. + `endDate` is on or after `startDate`. +- A dated folder is named `YYYY-MM-DD-` with its `startDate`, and has no `week`. A numbered folder is named + `-w-`, with the year `startDate` falls in and the same number as `week`. A week number is used + once per year, so the folders work whether Discord's numbering restarts each year or not, and a start date once + across all folders. +- The folder holds only `challenge.yaml` and the images it names: no leftover or unused files. +- Images are WebP or JPEG, each under 500 KB (500,000 bytes). + +Guidance the checks can't enforce: + +- Use the places, theme and brief as announced on Discord, and copy the announced text as written. Use `runner-up` + for winners announced as runners-up without a place, and `honourable` for honourable mentions. +- Never invent a week number, from a calendar or by counting folders: leave `week` out unless Discord announced one. + +An image without a `title` is described for screen readers by the theme and the place, such as "Bioluminescence, 1st +place by Ana Example" or "Lost, runner-up by Romeo"; with a title, as "Tidewater Lantern by Ana Example". + +## Exporting images + +- **WebP** (or JPEG), **at most 2400 pixels on the long side** (ideally 1600 or more; 2000 to 2400 is best), **under + 500 KB**. The site makes smaller sizes itself. +- Start from the original file the entrant posted. Never save Discord's preview of it: previews are scaled down, to + 718 pixels tall or less. +- Never upscale: export a smaller entry at its own size. Enlarging adds blur and bytes, not detail. +- Keep the artwork's own aspect ratio and don't crop it: the winners' layout shows every image whole. +- Convert PNG entries, without transparency; don't commit the PNG. + +With ImageMagick (`magick`, or `convert` on older versions). The `>` in `'2400x2400>'` only ever shrinks, so smaller +entries keep their size: + +```sh +magick entry.png -resize '2400x2400>' -quality 80 1-ana-example.webp +``` + +Or with `cwebp`, which has no shrink-only option. If the long side is over 2400 pixels, resize: `-resize 2400 0` sets +the width (for a portrait image, `-resize 0 2400` sets the height): + +```sh +cwebp -q 80 -resize 2400 0 entry.png -o 1-ana-example.webp +``` + +If the long side is 2400 pixels or less, leave out `-resize`: + +```sh +cwebp -q 80 entry.png -o 1-ana-example.webp +``` + +Check the size: `ls -l` shows bytes. If it is over, lower the quality to 70, or the long side to 2000 pixels, and +export again. + +## Previewing locally + +From the repository's `docs/` directory, with Node 22.23.4 or newer and pnpm 10 (see `docs/README.md`): + +```sh +pnpm install +pnpm dev +``` + +Open `http://localhost:4321/showcase/` and `http://localhost:4321/showcase/challenges//`. A mistake in +`challenge.yaml` shows as an error in the terminal and the browser. Then run the checks the pull request runs: + +```sh +pnpm test # folder contents and names, image formats and sizes, unique weeks and start dates +pnpm build # the schema: fields, dates and the winners' places and order +``` + +Challenge text stays in English, as written: the showcase pages are not translated. diff --git a/docs/src/content/community-nodes/README.md b/docs/src/content/community-nodes/README.md new file mode 100644 index 00000000000..16ad89e63be --- /dev/null +++ b/docs/src/content/community-nodes/README.md @@ -0,0 +1,82 @@ +# Community node packs + +Each YAML file in this directory is one card on the docs site's Community Nodes page +(`src/content/docs/Users Guide/08.Workflows/community-nodes.mdx`). Cards are sorted by title. The collection is +defined in `docs/src/content.config.ts`, and the build fails when a file breaks its schema. + +## Adding a pack + +Add one file, named after the pack's title in lowercase with hyphens, such as `curves.yaml`, and optionally its +thumbnail beside it (below). The name, without +`.yaml`, becomes the card's anchor: `/users-guide/workflows/community-nodes/#node-pack-curves`. + +```yaml +title: Curves +author: JPPhoto # the repository owner's name +repo: https://github.com/JPPhoto/curves-node # the repository's web page +summary: Adjust an image's curve from a user-defined string. # one sentence, at most 120 characters +categories: [image-effects] # one or more, see below +``` + +Optional fields: + +```yaml +description: >- # more about the pack; separate paragraphs with a blank line + Works with RGB images only. Wrap node and field names in backticks, as in `collect`; there is no other markup. +install: manual # only when the repository has no top-level __init__.py, so the Nodes manager can't install it +dependencies: true # the pack ships a requirements.txt or pyproject.toml whose packages users install themselves +archived: true # the repository is archived +nodes: # the nodes the pack adds + - name: Curves + description: Adjusts the curve. # optional +links: # example workflows, full documentation and other reading + - label: Example workflow + url: https://github.com/owner/repo/blob/main/example.json +thumbnail: ./curves.webp # the card's image, beside this file (see below); needs `images` +images: # examples, shown when a reader opens the card's details; the first one's alt text is the thumbnail's too + - src: https://raw.githubusercontent.com/owner/repo/main/example.png + alt: What the image shows, for people who can't see it # required + caption: Output # optional, shown under the image +``` + +### The thumbnail + +The card grid loads only local thumbnails, so it stays fast with dozens of packs; the examples stay in the pack's +repository and load when a reader opens the card's details. Make the thumbnail from your first example: a WebP named +after the YAML file (`curves.webp`), fitting inside 800 × 500 pixels (the card's 16:10 frame at twice its size), under +60 KB. The card shows it whole, letterboxed. For example, with ImageMagick: + +```sh +magick example.png -resize '800x500>' -quality 75 curves.webp +``` + +Lower the quality, or the size, for detailed images that come out larger. The build fails when the file is missing. +Without a thumbnail the card shows its first category's icon. + +`categories` takes these ids; the page shows their names in the reader's language: + +| Id | Name | +| --- | --- | +| `image-effects` | Image effects | +| `masks` | Masks & backgrounds | +| `compositing` | Compositing & grids | +| `prompts` | Prompt tools | +| `language-models` | Language models | +| `control` | ControlNet & adapters | +| `video-3d` | Video & 3D | +| `sizing` | Sizing & upscaling | +| `utility` | Utility | + +A new category goes into `NODE_PACK_CATEGORIES` in `src/lib/community-nodes.mjs`, with its name in +`src/content/i18n/en.json`. + +## Before you open the pull request + +- The repository is public, and InvokeAI loads it from a clone in its `nodes` folder: an `__init__.py` at the top level + imports the nodes. Otherwise set `install: manual`. +- `repo`, `links` and image URLs are `https://` and load. +- Example images stay in the pack's repository: use their `raw.githubusercontent.com` (or GitLab raw) URLs. +- The thumbnail is under 60 KB. +- Run `pnpm build` in `docs/` to check the file against the schema, or `pnpm dev` to see the card. + +Pack text stays in English, as its author wrote it: Crowdin translates the page around it, not the packs. diff --git a/docs/src/content/community-nodes/adapters-linked-nodes.yaml b/docs/src/content/community-nodes/adapters-linked-nodes.yaml new file mode 100644 index 00000000000..73a7eadd134 --- /dev/null +++ b/docs/src/content/community-nodes/adapters-linked-nodes.yaml @@ -0,0 +1,17 @@ +title: Adapters Linked Nodes +author: skunkworxdark +repo: https://github.com/skunkworxdark/adapters-linked-nodes +summary: Chain ControlNet, IP-Adapter and T2I-Adapter inputs without a Collect node. +description: >- + The adapters link to each other instead of going through a `collect` node, so they can be used inside an `iterate` + node without collecting on every iteration. + + The nodes inherit from the core nodes, so changes to the core nodes carry over to them. +categories: [control] +nodes: + - name: ControlNet-Linked + description: Collects ControlNet info to pass to other nodes. + - name: IP-Adapter-Linked + description: Collects IP-Adapter info to pass to other nodes. + - name: T2I-Adapter-Linked + description: Collects T2I-Adapter info to pass to other nodes. diff --git a/docs/src/content/community-nodes/autostereogram-nodes.webp b/docs/src/content/community-nodes/autostereogram-nodes.webp new file mode 100644 index 00000000000..9874b4f7f7e Binary files /dev/null and b/docs/src/content/community-nodes/autostereogram-nodes.webp differ diff --git a/docs/src/content/community-nodes/autostereogram-nodes.yaml b/docs/src/content/community-nodes/autostereogram-nodes.yaml new file mode 100644 index 00000000000..be69dd78b5d --- /dev/null +++ b/docs/src/content/community-nodes/autostereogram-nodes.yaml @@ -0,0 +1,21 @@ +title: Autostereogram Nodes +author: skunkworxdark +repo: https://github.com/skunkworxdark/autostereogram_nodes +summary: Generate autostereogram images from a depth map. +description: >- + In the author's words, not a very practically useful node, but more a 90s nostalgic indulgence. +categories: [video-3d] +thumbnail: ./autostereogram-nodes.webp +images: + - src: https://raw.githubusercontent.com/skunkworxdark/autostereogram_nodes/refs/heads/main/images/spider-dots.png + alt: A random-dot autostereogram hiding a spider + caption: Autostereogram with a random-dot pattern + - src: https://raw.githubusercontent.com/skunkworxdark/autostereogram_nodes/refs/heads/main/images/spider.png + alt: A photo of a spider + caption: Source image + - src: https://raw.githubusercontent.com/skunkworxdark/autostereogram_nodes/refs/heads/main/images/spider-depth.png + alt: Grayscale depth map of the spider + caption: Depth map + - src: https://raw.githubusercontent.com/skunkworxdark/autostereogram_nodes/refs/heads/main/images/spider-pattern.png + alt: A patterned autostereogram hiding a spider + caption: Autostereogram with a pattern image diff --git a/docs/src/content/community-nodes/average-images.yaml b/docs/src/content/community-nodes/average-images.yaml new file mode 100644 index 00000000000..3ef1857ffcb --- /dev/null +++ b/docs/src/content/community-nodes/average-images.yaml @@ -0,0 +1,7 @@ +title: Average Images +author: JPPhoto +repo: https://github.com/JPPhoto/average-images-node +summary: Average a collection of images of the same size into one image. +description: >- + Converts every image to RGB first. +categories: [compositing] diff --git a/docs/src/content/community-nodes/birefnet-background-removal.webp b/docs/src/content/community-nodes/birefnet-background-removal.webp new file mode 100644 index 00000000000..bd699f2fb75 Binary files /dev/null and b/docs/src/content/community-nodes/birefnet-background-removal.webp differ diff --git a/docs/src/content/community-nodes/birefnet-background-removal.yaml b/docs/src/content/community-nodes/birefnet-background-removal.yaml new file mode 100644 index 00000000000..839dbbd48b3 --- /dev/null +++ b/docs/src/content/community-nodes/birefnet-background-removal.yaml @@ -0,0 +1,18 @@ +title: BiRefNet Background Removal +author: veeliks +repo: https://github.com/veeliks/invoke_birefnet +summary: Remove image backgrounds with the BiRefNet segmentation model. +description: >- + BiRefNet (Bilateral Reference Network) is a high-quality segmentation model. The node supports several model + variants: standard, high-resolution, matting, portrait and others specialized for different uses. +categories: [masks] +dependencies: true +archived: true +thumbnail: ./birefnet-background-removal.webp +images: + - src: https://raw.githubusercontent.com/veeliks/invoke_birefnet/main/.readme/example_after_removal.png + alt: A subject cut out on a transparent background + caption: After background removal + - src: https://raw.githubusercontent.com/veeliks/invoke_birefnet/main/.readme/example_before_removal.png + alt: The original photo with its background + caption: Before background removal diff --git a/docs/src/content/community-nodes/briaai-remove-background.webp b/docs/src/content/community-nodes/briaai-remove-background.webp new file mode 100644 index 00000000000..3cfb3a1b0ea Binary files /dev/null and b/docs/src/content/community-nodes/briaai-remove-background.webp differ diff --git a/docs/src/content/community-nodes/briaai-remove-background.yaml b/docs/src/content/community-nodes/briaai-remove-background.yaml new file mode 100644 index 00000000000..bc6b342847e --- /dev/null +++ b/docs/src/content/community-nodes/briaai-remove-background.yaml @@ -0,0 +1,9 @@ +title: BriaAI Remove Background +author: blessedcoolant +repo: https://github.com/blessedcoolant/invoke_bria_rmbg +summary: One-click background removal with BriaAI's RMBG 1.4 model. +categories: [masks] +thumbnail: ./briaai-remove-background.webp +images: + - src: https://raw.githubusercontent.com/blessedcoolant/invoke_bria_rmbg/main/assets/preview.jpg + alt: The BriaAI Remove Background node and its result in the workflow editor diff --git a/docs/src/content/community-nodes/cinematic-effects-pack.yaml b/docs/src/content/community-nodes/cinematic-effects-pack.yaml new file mode 100644 index 00000000000..283d2b9bbb1 --- /dev/null +++ b/docs/src/content/community-nodes/cinematic-effects-pack.yaml @@ -0,0 +1,18 @@ +title: Cinematic Effects Pack +author: JPPhoto +repo: https://github.com/JPPhoto/cinematic-effects-pack +summary: Anamorphic lens effects, depth-based bokeh, tone curves, color cast correction and film grain. +description: >- + Replaces Anamorphic Tools (`anamorphic-tools`), Curves (`curves-node`), Film Grain (`film-grain-node`) and + Color Correction Nodes (`color-correction-nodes`). Remove the old packs from your `nodes` folder before installing + this pack; loading them together may register duplicate invocation IDs. +categories: [image-effects] +links: + - label: Replaced pack — Anamorphic Tools + url: https://github.com/JPPhoto/anamorphic-tools + - label: Replaced pack — Curves + url: https://github.com/JPPhoto/curves-node + - label: Replaced pack — Film Grain + url: https://github.com/JPPhoto/film-grain-node + - label: Replaced pack — Color Correction Nodes + url: https://github.com/JPPhoto/color-correction-nodes diff --git a/docs/src/content/community-nodes/clean-image-artifacts-after-cut.webp b/docs/src/content/community-nodes/clean-image-artifacts-after-cut.webp new file mode 100644 index 00000000000..1432ea39ec6 Binary files /dev/null and b/docs/src/content/community-nodes/clean-image-artifacts-after-cut.webp differ diff --git a/docs/src/content/community-nodes/clean-image-artifacts-after-cut.yaml b/docs/src/content/community-nodes/clean-image-artifacts-after-cut.yaml new file mode 100644 index 00000000000..09bdba72466 --- /dev/null +++ b/docs/src/content/community-nodes/clean-image-artifacts-after-cut.yaml @@ -0,0 +1,9 @@ +title: Clean Image Artifacts After Cut +author: VeyDlin +repo: https://github.com/VeyDlin/clean-artifact-after-cut-node +summary: Remove the artifacts left after an image is cut from its background. +categories: [masks] +thumbnail: ./clean-image-artifacts-after-cut.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/clean-artifact-after-cut-node/master/.readme/node.png + alt: The Clean Image Artifacts After Cut node diff --git a/docs/src/content/community-nodes/close-color-mask.webp b/docs/src/content/community-nodes/close-color-mask.webp new file mode 100644 index 00000000000..65edc8a62ef Binary files /dev/null and b/docs/src/content/community-nodes/close-color-mask.webp differ diff --git a/docs/src/content/community-nodes/close-color-mask.yaml b/docs/src/content/community-nodes/close-color-mask.yaml new file mode 100644 index 00000000000..8620ff0db42 --- /dev/null +++ b/docs/src/content/community-nodes/close-color-mask.yaml @@ -0,0 +1,9 @@ +title: Close Color Mask +author: VeyDlin +repo: https://github.com/VeyDlin/close-color-mask-node +summary: Mask the parts of an image close to a chosen color, for color-based selections. +categories: [masks] +thumbnail: ./close-color-mask.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/close-color-mask-node/master/.readme/node.png + alt: The Close Color Mask node diff --git a/docs/src/content/community-nodes/clothing-mask.webp b/docs/src/content/community-nodes/clothing-mask.webp new file mode 100644 index 00000000000..bf94b9873ad Binary files /dev/null and b/docs/src/content/community-nodes/clothing-mask.webp differ diff --git a/docs/src/content/community-nodes/clothing-mask.yaml b/docs/src/content/community-nodes/clothing-mask.yaml new file mode 100644 index 00000000000..c056fae7bc8 --- /dev/null +++ b/docs/src/content/community-nodes/clothing-mask.yaml @@ -0,0 +1,9 @@ +title: Clothing Mask +author: VeyDlin +repo: https://github.com/VeyDlin/clothing-mask-node +summary: Mask clothing with a U2NET network trained to segment clothing items. +categories: [masks] +thumbnail: ./clothing-mask.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/clothing-mask-node/master/.readme/node.png + alt: The Clothing Mask node diff --git a/docs/src/content/community-nodes/contrast-limited-adaptive-histogram-equalization.webp b/docs/src/content/community-nodes/contrast-limited-adaptive-histogram-equalization.webp new file mode 100644 index 00000000000..0c31c74fa60 Binary files /dev/null and b/docs/src/content/community-nodes/contrast-limited-adaptive-histogram-equalization.webp differ diff --git a/docs/src/content/community-nodes/contrast-limited-adaptive-histogram-equalization.yaml b/docs/src/content/community-nodes/contrast-limited-adaptive-histogram-equalization.yaml new file mode 100644 index 00000000000..cf710062f9b --- /dev/null +++ b/docs/src/content/community-nodes/contrast-limited-adaptive-histogram-equalization.yaml @@ -0,0 +1,9 @@ +title: Contrast Limited Adaptive Histogram Equalization +author: VeyDlin +repo: https://github.com/VeyDlin/clahe-node +summary: Enhance local contrast with adaptive histogram equalization and contrast limiting (CLAHE). +categories: [image-effects] +thumbnail: ./contrast-limited-adaptive-histogram-equalization.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/clahe-node/master/.readme/node.png + alt: The Contrast Limited Adaptive Histogram Equalization node diff --git a/docs/src/content/community-nodes/depth-map-from-wavefront-obj.webp b/docs/src/content/community-nodes/depth-map-from-wavefront-obj.webp new file mode 100644 index 00000000000..79d43981476 Binary files /dev/null and b/docs/src/content/community-nodes/depth-map-from-wavefront-obj.webp differ diff --git a/docs/src/content/community-nodes/depth-map-from-wavefront-obj.yaml b/docs/src/content/community-nodes/depth-map-from-wavefront-obj.yaml new file mode 100644 index 00000000000..02aaf482f33 --- /dev/null +++ b/docs/src/content/community-nodes/depth-map-from-wavefront-obj.yaml @@ -0,0 +1,16 @@ +title: Depth Map from Wavefront OBJ +author: dwringer +repo: https://github.com/dwringer/depth-from-obj-node +summary: Render depth maps from triangulated Wavefront .obj files. +description: >- + A simple 3D renderer that uses numpy and matplotlib to compute and color the scene, with parameters for the field of + view, camera position and model orientation. + + An .obj must use triangulated meshes, so enable that option when exporting from a 3D modeling program. Each triangle + gets one solid color from its average depth, so large triangles cause anomalies. In Blender, the Remesh modifier can + subdivide a mesh into small pieces that work well within these limits. +categories: [video-3d, control] +thumbnail: ./depth-map-from-wavefront-obj.webp +images: + - src: https://raw.githubusercontent.com/dwringer/depth-from-obj-node/main/depth_from_obj_usage.jpg + alt: A workflow rendering a depth map from an .obj model diff --git a/docs/src/content/community-nodes/enhance-detail.webp b/docs/src/content/community-nodes/enhance-detail.webp new file mode 100644 index 00000000000..75a511d1437 Binary files /dev/null and b/docs/src/content/community-nodes/enhance-detail.webp differ diff --git a/docs/src/content/community-nodes/enhance-detail.yaml b/docs/src/content/community-nodes/enhance-detail.yaml new file mode 100644 index 00000000000..20c04864583 --- /dev/null +++ b/docs/src/content/community-nodes/enhance-detail.yaml @@ -0,0 +1,16 @@ +title: Enhance Detail +author: skunkworxdark +repo: https://github.com/skunkworxdark/enhance-detail-node +summary: Increase or decrease the detail in an image with a guided filter. +description: >- + Uses a guided filter, as opposed to the Gaussian blur most sharpening filters use. Based on the Enhance Detail node + of ComfyUI-Image-Filters. +categories: [image-effects] +dependencies: true +links: + - label: ComfyUI-Image-Filters, the original node + url: https://github.com/spacepxl/ComfyUI-Image-Filters +thumbnail: ./enhance-detail.webp +images: + - src: https://raw.githubusercontent.com/skunkworxdark/enhance-detail-node/refs/heads/main/images/Comparison.png + alt: The same image with less and with more detail, side by side diff --git a/docs/src/content/community-nodes/flip-pose.yaml b/docs/src/content/community-nodes/flip-pose.yaml new file mode 100644 index 00000000000..79abff37799 --- /dev/null +++ b/docs/src/content/community-nodes/flip-pose.yaml @@ -0,0 +1,7 @@ +title: Flip Pose +author: JPPhoto +repo: https://github.com/JPPhoto/flip-pose-node +summary: Flip an OpenPose image horizontally, recoloring it so it faces the right way. +description: >- + Does not work with OpenPose hands. +categories: [control] diff --git a/docs/src/content/community-nodes/flux-ideal-size.yaml b/docs/src/content/community-nodes/flux-ideal-size.yaml new file mode 100644 index 00000000000..7f89080478f --- /dev/null +++ b/docs/src/content/community-nodes/flux-ideal-size.yaml @@ -0,0 +1,7 @@ +title: Flux Ideal Size +author: JPPhoto +repo: https://github.com/JPPhoto/flux-ideal-size +summary: The ideal size for the first stage of a FLUX generation. +description: >- + Generating at the right size helps limit duplication and odd subject placement. +categories: [sizing] diff --git a/docs/src/content/community-nodes/generative-grammar-based-prompt-nodes.webp b/docs/src/content/community-nodes/generative-grammar-based-prompt-nodes.webp new file mode 100644 index 00000000000..1487f67aa04 Binary files /dev/null and b/docs/src/content/community-nodes/generative-grammar-based-prompt-nodes.webp differ diff --git a/docs/src/content/community-nodes/generative-grammar-based-prompt-nodes.yaml b/docs/src/content/community-nodes/generative-grammar-based-prompt-nodes.yaml new file mode 100644 index 00000000000..a6b5ffd0eac --- /dev/null +++ b/docs/src/content/community-nodes/generative-grammar-based-prompt-nodes.yaml @@ -0,0 +1,19 @@ +title: Generative Grammar-Based Prompt Nodes +author: dwringer +repo: https://github.com/dwringer/generative-grammar-prompt-nodes +summary: Generate prompts from simple grammar rules that you define in YAML files. +description: >- + The prompts are made by recursively expanding a template string, replacing nonterminal "parts of speech" until none + remain. The repository includes example rule files. +categories: [prompts] +nodes: + - name: Lookup Table from File + description: Loads the "prompt" section of a YAML file, or of a whole folder of YAML files, into a JSON-ified dictionary (Lookups output). + - name: Lookups Entry from Prompt + description: Places a single entry in a new Lookups output under the specified heading. + - name: Prompt from Lookup Table + description: Uses a collection of Lookups as grammar rules from which to randomly generate prompts. +thumbnail: ./generative-grammar-based-prompt-nodes.webp +images: + - src: https://raw.githubusercontent.com/dwringer/generative-grammar-prompt-nodes/main/lookuptables_usage.jpg + alt: A workflow generating prompts from lookup tables diff --git a/docs/src/content/community-nodes/gpt2-random-prompt-maker.yaml b/docs/src/content/community-nodes/gpt2-random-prompt-maker.yaml new file mode 100644 index 00000000000..afce49fd7fd --- /dev/null +++ b/docs/src/content/community-nodes/gpt2-random-prompt-maker.yaml @@ -0,0 +1,7 @@ +title: GPT2RandomPromptMaker +author: mickr777 +repo: https://github.com/mickr777/GPT2RandomPromptMaker +summary: Generate random prompts with the GPT-2 language model from a seed and a context. +description: >- + Example output: "An enchanted weapon will be usable by any character regardless of their alignment." +categories: [prompts, language-models] diff --git a/docs/src/content/community-nodes/grid-to-gif.webp b/docs/src/content/community-nodes/grid-to-gif.webp new file mode 100644 index 00000000000..c47aa17e4d0 Binary files /dev/null and b/docs/src/content/community-nodes/grid-to-gif.webp differ diff --git a/docs/src/content/community-nodes/grid-to-gif.yaml b/docs/src/content/community-nodes/grid-to-gif.yaml new file mode 100644 index 00000000000..7b6b450b285 --- /dev/null +++ b/docs/src/content/community-nodes/grid-to-gif.yaml @@ -0,0 +1,17 @@ +title: Grid to Gif +author: mildmisery +repo: https://github.com/mildmisery/invokeai-GridToGifNode +summary: Turn a grid image into an image collection, and an image collection into a GIF. +categories: [video-3d, compositing] +install: manual +links: + - label: Example workflow + url: https://github.com/mildmisery/invokeai-GridToGifNode/blob/main/Grid%20to%20Gif%20Example%20Workflow.json +thumbnail: ./grid-to-gif.webp +images: + - src: https://raw.githubusercontent.com/mildmisery/invokeai-GridToGifNode/main/output.gif + alt: An animated GIF made from the frames of a grid + caption: Output GIF + - src: https://raw.githubusercontent.com/mildmisery/invokeai-GridToGifNode/main/input.png + alt: A grid of image frames + caption: Input grid diff --git a/docs/src/content/community-nodes/halftone.webp b/docs/src/content/community-nodes/halftone.webp new file mode 100644 index 00000000000..bc348cae44c Binary files /dev/null and b/docs/src/content/community-nodes/halftone.webp differ diff --git a/docs/src/content/community-nodes/halftone.yaml b/docs/src/content/community-nodes/halftone.yaml new file mode 100644 index 00000000000..25689dc6a16 --- /dev/null +++ b/docs/src/content/community-nodes/halftone.yaml @@ -0,0 +1,23 @@ +title: Halftone +author: JPPhoto +repo: https://github.com/JPPhoto/halftone-node +summary: Grayscale and CMYK halftoning, for a magazine or newspaper print look. +description: >- + Both nodes let you set the angles and the halftone dot spacing. +categories: [image-effects] +nodes: + - name: Halftone + description: Converts the image to grayscale, then halftones it. + - name: CMYK Halftone + description: Converts the image to CMYK and halftones each channel. +thumbnail: ./halftone.webp +images: + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/c59c578f-db8e-4d66-8c66-2851752d75ea + alt: A portrait rendered as a CMYK halftone + caption: CMYK Halftone output + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/fd5efb9f-4355-4409-a1c2-c1ca99e0cab4 + alt: The input portrait + caption: Input + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/7e606f29-e68f-4d46-b3d5-97f799a4ec2f + alt: The portrait rendered as a grayscale halftone + caption: Halftone output diff --git a/docs/src/content/community-nodes/hand-refiner-with-meshgraphormer.webp b/docs/src/content/community-nodes/hand-refiner-with-meshgraphormer.webp new file mode 100644 index 00000000000..027496aced5 Binary files /dev/null and b/docs/src/content/community-nodes/hand-refiner-with-meshgraphormer.webp differ diff --git a/docs/src/content/community-nodes/hand-refiner-with-meshgraphormer.yaml b/docs/src/content/community-nodes/hand-refiner-with-meshgraphormer.yaml new file mode 100644 index 00000000000..d9c4764dfe7 --- /dev/null +++ b/docs/src/content/community-nodes/hand-refiner-with-meshgraphormer.yaml @@ -0,0 +1,13 @@ +title: Hand Refiner with MeshGraphormer +author: blessedcoolant +repo: https://github.com/blessedcoolant/invoke_meshgraphormer +summary: Generate a fixed hand depth map and a hand mask, to repair hands with ControlNet. +description: >- + Takes your image and generates a corrected depth map for the hands along with a mask of the hand region, which you + can use with ControlNet to fix badly generated hands. +categories: [control, masks] +dependencies: true +thumbnail: ./hand-refiner-with-meshgraphormer.webp +images: + - src: https://raw.githubusercontent.com/blessedcoolant/invoke_meshgraphormer/main/assets/preview.jpg + alt: The Hand Refiner node with its depth map and mask outputs diff --git a/docs/src/content/community-nodes/image-and-mask-composition-pack.yaml b/docs/src/content/community-nodes/image-and-mask-composition-pack.yaml new file mode 100644 index 00000000000..bd31539a026 --- /dev/null +++ b/docs/src/content/community-nodes/image-and-mask-composition-pack.yaml @@ -0,0 +1,51 @@ +title: Image and Mask Composition Pack +author: dwringer +repo: https://github.com/dwringer/composition-nodes +summary: Compose images and masks, with Clipseg text masks, color separations, noise and wrapping offsets. +description: >- + The image and latent offsets wrap around, so they can be used with seamless tiling to progressively generate centered + on different parts of a seamless image. +categories: [compositing, masks] +nodes: + - name: 2D Noise Image + description: Creates an image of 2D noise approximating the desired characteristics. + - name: Add Noise (Flux) + description: Adds noise to a FLUX latents tensor using the appropriate ratio given the denoising schedule timestep. + - name: Clipseg Mask Hierarchy + description: Creates a segmentation hierarchy of mutually exclusive masks from Clipseg text prompts. + - name: CMYK Color Separation + description: Gets color images from a base color and two others that subtractively mix to obtain it. + - name: CMYK Merge + description: Merges subtractive color channels (CMYK and alpha). + - name: CMYK Split + description: Splits an image into subtractive color channels (CMYK and alpha). + - name: Flatten Histogram (Grayscale) + description: Scales the values of an L-mode image to the full range 0–255 in equal proportions. + - name: Frequency Spectrum Match Latents + description: Generates latent noise with the frequency spectrum of target latents, masked by a mask image. + - name: Image Quantize (Kohonen map) + description: Quantizes the pixel values of an image with a Kohonen self-organizing map. + - name: Image Search to Mask (Clipseg) + description: Generates an image mask from an image prompt with the Clipseg model. + - name: Latent Quantize (Kohonen map) + description: Quantizes the values of a latents tensor with a self-organizing map. + - name: Noise (Spectral characteristics) + description: Creates a latents tensor of 2D noise channels approximating the desired characteristics. + - name: Offset Image + description: Offsets an image by a percentage or pixel amount, wrapping it around. + - name: Offset Latents + description: Offsets a latents tensor by a percentage of its height and width, wrapping it around. + - name: RGB Merge + description: Merges RGB color channels and alpha. + - name: RGB Split + description: Splits an image into RGB color channels and alpha. + - name: Rotate/Flip Image + description: Rotates an image by an angle in degrees clockwise, or flips it. + - name: Shadows/Highlights/Midtones + description: Extracts shadows, midtones and highlights masks from an image. + - name: Text Mask + description: Renders a text mask in 2D from a given font. + - name: Text to Mask (Clipseg) + description: Generates an image mask from a text prompt with the Clipseg model. + - name: Text to Mask Advanced (Clipseg) + description: Generates image masks from several text prompts with the Clipseg model. diff --git a/docs/src/content/community-nodes/image-dominant-color.webp b/docs/src/content/community-nodes/image-dominant-color.webp new file mode 100644 index 00000000000..86648e04651 Binary files /dev/null and b/docs/src/content/community-nodes/image-dominant-color.webp differ diff --git a/docs/src/content/community-nodes/image-dominant-color.yaml b/docs/src/content/community-nodes/image-dominant-color.yaml new file mode 100644 index 00000000000..03672dc2219 --- /dev/null +++ b/docs/src/content/community-nodes/image-dominant-color.yaml @@ -0,0 +1,9 @@ +title: Image Dominant Color +author: VeyDlin +repo: https://github.com/VeyDlin/image-dominant-color-node +summary: Find the dominant color of an image with k-means clustering. +categories: [utility] +thumbnail: ./image-dominant-color.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/image-dominant-color-node/master/.readme/node.png + alt: The Image Dominant Color node diff --git a/docs/src/content/community-nodes/image-export.webp b/docs/src/content/community-nodes/image-export.webp new file mode 100644 index 00000000000..beb98a87653 Binary files /dev/null and b/docs/src/content/community-nodes/image-export.webp differ diff --git a/docs/src/content/community-nodes/image-export.yaml b/docs/src/content/community-nodes/image-export.yaml new file mode 100644 index 00000000000..4c4d562ccfa --- /dev/null +++ b/docs/src/content/community-nodes/image-export.yaml @@ -0,0 +1,25 @@ +title: Image Export +author: veeliks +repo: https://github.com/veeliks/invoke_image_export +summary: Save images as AVIF, JPEG, PNG, TIFF or WebP with format-specific compression and quality options. +categories: [utility] +dependencies: true +archived: true +nodes: + - name: Save Image as AVIF + - name: Save Image as JPEG + - name: Save Image as PNG + - name: Save Image as TIFF + - name: Save Image as WebP +thumbnail: ./image-export.webp +images: + - src: https://raw.githubusercontent.com/veeliks/invoke_image_export/main/.readme/node_webp.png + alt: The Save Image as WebP node + - src: https://raw.githubusercontent.com/veeliks/invoke_image_export/main/.readme/node_avif.png + alt: The Save Image as AVIF node + - src: https://raw.githubusercontent.com/veeliks/invoke_image_export/main/.readme/node_jpeg.png + alt: The Save Image as JPEG node + - src: https://raw.githubusercontent.com/veeliks/invoke_image_export/main/.readme/node_png.png + alt: The Save Image as PNG node + - src: https://raw.githubusercontent.com/veeliks/invoke_image_export/main/.readme/node_tiff.png + alt: The Save Image as TIFF node diff --git a/docs/src/content/community-nodes/image-picker.yaml b/docs/src/content/community-nodes/image-picker.yaml new file mode 100644 index 00000000000..6c798561724 --- /dev/null +++ b/docs/src/content/community-nodes/image-picker.yaml @@ -0,0 +1,8 @@ +title: Image Picker +author: JPPhoto +repo: https://github.com/JPPhoto/image-picker-node +summary: Pick one image at random from a collection. +description: >- + Useful when you have several poses to choose from for a ControlNet node, or several input images for another + purpose. +categories: [utility] diff --git a/docs/src/content/community-nodes/image-resize-plus.webp b/docs/src/content/community-nodes/image-resize-plus.webp new file mode 100644 index 00000000000..359200d1616 Binary files /dev/null and b/docs/src/content/community-nodes/image-resize-plus.webp differ diff --git a/docs/src/content/community-nodes/image-resize-plus.yaml b/docs/src/content/community-nodes/image-resize-plus.yaml new file mode 100644 index 00000000000..a81da82a5a7 --- /dev/null +++ b/docs/src/content/community-nodes/image-resize-plus.yaml @@ -0,0 +1,9 @@ +title: Image Resize Plus +author: VeyDlin +repo: https://github.com/VeyDlin/image-resize-plus-node +summary: Resize images to fill, stretch, fit, center or crop. +categories: [sizing] +thumbnail: ./image-resize-plus.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/image-resize-plus-node/master/.readme/node.png + alt: The Image Resize Plus node diff --git a/docs/src/content/community-nodes/image-to-character-art-image-nodes.webp b/docs/src/content/community-nodes/image-to-character-art-image-nodes.webp new file mode 100644 index 00000000000..baf4234ce70 Binary files /dev/null and b/docs/src/content/community-nodes/image-to-character-art-image-nodes.webp differ diff --git a/docs/src/content/community-nodes/image-to-character-art-image-nodes.yaml b/docs/src/content/community-nodes/image-to-character-art-image-nodes.yaml new file mode 100644 index 00000000000..844c385626c --- /dev/null +++ b/docs/src/content/community-nodes/image-to-character-art-image-nodes.yaml @@ -0,0 +1,15 @@ +title: Image to Character Art Image Nodes +author: mickr777 +repo: https://github.com/mickr777/imagetoasciiimage +summary: Convert an image into ASCII or Unicode character art. +categories: [image-effects] +thumbnail: ./image-to-character-art-image-nodes.webp +images: + - src: https://user-images.githubusercontent.com/115216705/271817646-8e061fcc-9a2c-4fa9-bcc7-c0f7b01e9056.png + alt: An image redrawn as character art + - src: https://github.com/mickr777/imagetoasciiimage/assets/115216705/3c4990eb-2f42-46b9-90f9-0088b939dc6a + alt: A second character art example + - src: https://github.com/mickr777/imagetoasciiimage/assets/115216705/fee7f800-a4a8-41e2-a66b-c66e4343307e + alt: A third character art example + - src: https://github.com/mickr777/imagetoasciiimage/assets/115216705/1d9c1003-a45f-45c2-aac7-46470bb89330 + alt: A fourth character art example diff --git a/docs/src/content/community-nodes/latent-upscale.yaml b/docs/src/content/community-nodes/latent-upscale.yaml new file mode 100644 index 00000000000..aba8b180951 --- /dev/null +++ b/docs/src/content/community-nodes/latent-upscale.yaml @@ -0,0 +1,7 @@ +title: Latent Upscale +author: gogurtenjoyer +repo: https://github.com/gogurtenjoyer/latent-upscale +summary: Upscale SD 1.5 and SDXL latents with a small model instead of interpolation. +description: >- + The model is about 2.4 MB. Upscaling with it avoids the usual downsides of the latent upscale technique. +categories: [sizing] diff --git a/docs/src/content/community-nodes/load-video-frame.webp b/docs/src/content/community-nodes/load-video-frame.webp new file mode 100644 index 00000000000..498d05dcf07 Binary files /dev/null and b/docs/src/content/community-nodes/load-video-frame.webp differ diff --git a/docs/src/content/community-nodes/load-video-frame.yaml b/docs/src/content/community-nodes/load-video-frame.yaml new file mode 100644 index 00000000000..1772336774e --- /dev/null +++ b/docs/src/content/community-nodes/load-video-frame.yaml @@ -0,0 +1,12 @@ +title: Load Video Frame +author: helix4u +repo: https://github.com/helix4u/load_video_frame +summary: Load and index video frames and make videos, for animation with iterators and ControlNet. +description: >- + A video frame image provider, an indexer and video creation nodes, for hooking up to iterators, ranges, ControlNets + and so on. Think animation and ControlNet outputs. +categories: [video-3d, control] +thumbnail: ./load-video-frame.webp +images: + - src: https://raw.githubusercontent.com/helix4u/load_video_frame/refs/heads/main/_git_assets/dance1736978273.gif + alt: An animated dance made frame by frame diff --git a/docs/src/content/community-nodes/make-3d.webp b/docs/src/content/community-nodes/make-3d.webp new file mode 100644 index 00000000000..9086df22fc7 Binary files /dev/null and b/docs/src/content/community-nodes/make-3d.webp differ diff --git a/docs/src/content/community-nodes/make-3d.yaml b/docs/src/content/community-nodes/make-3d.yaml new file mode 100644 index 00000000000..59e9d696d0c --- /dev/null +++ b/docs/src/content/community-nodes/make-3d.yaml @@ -0,0 +1,15 @@ +title: Make 3D +author: srcrr +repo: https://gitlab.com/srcrr/shift3d +summary: Create 3D stereo images from 2D originals. +categories: [video-3d] +install: manual +links: + - label: Example workflow + url: https://gitlab.com/srcrr/shift3d/-/raw/main/example-workflow.json?ref_type=heads&inline=false +thumbnail: ./make-3d.webp +images: + - src: https://gitlab.com/srcrr/shift3d/-/raw/main/example-1.png + alt: A stereo image pair made from a 2D image + - src: https://gitlab.com/srcrr/shift3d/-/raw/main/example-2.png + alt: A second stereo image pair diff --git a/docs/src/content/community-nodes/mask-operations.webp b/docs/src/content/community-nodes/mask-operations.webp new file mode 100644 index 00000000000..7aa1546c933 Binary files /dev/null and b/docs/src/content/community-nodes/mask-operations.webp differ diff --git a/docs/src/content/community-nodes/mask-operations.yaml b/docs/src/content/community-nodes/mask-operations.yaml new file mode 100644 index 00000000000..5d4fe3a71f5 --- /dev/null +++ b/docs/src/content/community-nodes/mask-operations.yaml @@ -0,0 +1,9 @@ +title: Mask Operations +author: VeyDlin +repo: https://github.com/VeyDlin/mask-operations-node +summary: Combine masks with the logical operations OR, SUB and AND. +categories: [masks] +thumbnail: ./mask-operations.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/mask-operations-node/master/.readme/node.png + alt: The Mask Operations node diff --git a/docs/src/content/community-nodes/match-histogram.webp b/docs/src/content/community-nodes/match-histogram.webp new file mode 100644 index 00000000000..66b97b7faa5 Binary files /dev/null and b/docs/src/content/community-nodes/match-histogram.webp differ diff --git a/docs/src/content/community-nodes/match-histogram.yaml b/docs/src/content/community-nodes/match-histogram.yaml new file mode 100644 index 00000000000..a83f444e0ea --- /dev/null +++ b/docs/src/content/community-nodes/match-histogram.yaml @@ -0,0 +1,14 @@ +title: Match Histogram +author: skunkworxdark +repo: https://github.com/skunkworxdark/match_histogram +summary: Match the histogram of one image to another's. +description: >- + Like the core `Color Correct` node, but it works in the YCbCr color space, handles images of different sizes and + needs no mask. It can transfer only the luminance channel, and save the output as grayscale. + + A good use is normalizing the colors of an image that went through the tiled upscaling workflow of the XYGrid nodes. +categories: [image-effects] +thumbnail: ./match-histogram.webp +images: + - src: https://github.com/skunkworxdark/match_histogram/assets/21961335/ed12f329-a0ef-444a-9bae-129ed60d6097 + alt: An image before and after its histogram was matched to a reference diff --git a/docs/src/content/community-nodes/metadata-linked-nodes.yaml b/docs/src/content/community-nodes/metadata-linked-nodes.yaml new file mode 100644 index 00000000000..931c5ea2c92 --- /dev/null +++ b/docs/src/content/community-nodes/metadata-linked-nodes.yaml @@ -0,0 +1,36 @@ +title: Metadata Linked Nodes +author: skunkworxdark +repo: https://github.com/skunkworxdark/metadata-linked-nodes +summary: Collect metadata inside an Iterate node, and extract metadata from an image. +categories: [utility] +nodes: + - name: Metadata Item Linked + description: Collects metadata inside an iterate node, with no collect node or conversion to a metadata node. + - name: Metadata From Image + description: Provides the metadata of an image. + - name: Metadata To String + description: Extracts a string value of a label from metadata. + - name: Metadata To Integer + description: Extracts an integer value of a label from metadata. + - name: Metadata To Float + description: Extracts a float value of a label from metadata. + - name: Metadata To Scheduler + description: Extracts a scheduler value of a label from metadata. + - name: Metadata To Bool + description: Extracts bool values from metadata. + - name: Metadata To Model + description: Extracts model types from metadata. + - name: Metadata To SDXL Model + description: Extracts SDXL model types from metadata. + - name: Metadata To LoRAs + description: Extracts LoRAs from metadata. + - name: Metadata To SDXL LoRAs + description: Extracts SDXL LoRAs from metadata. + - name: Metadata To ControlNets + description: Extracts ControlNets from metadata. + - name: Metadata To IP-Adapters + description: Extracts IP-Adapters from metadata. + - name: Metadata To T2I-Adapters + description: Extracts T2I-Adapters from metadata. + - name: Denoise Latents + Metadata + description: The core `Denoise Latents` node with a metadata input and output. diff --git a/docs/src/content/community-nodes/negative-image.webp b/docs/src/content/community-nodes/negative-image.webp new file mode 100644 index 00000000000..dc073f8855f Binary files /dev/null and b/docs/src/content/community-nodes/negative-image.webp differ diff --git a/docs/src/content/community-nodes/negative-image.yaml b/docs/src/content/community-nodes/negative-image.yaml new file mode 100644 index 00000000000..5ebc7fe05d4 --- /dev/null +++ b/docs/src/content/community-nodes/negative-image.yaml @@ -0,0 +1,9 @@ +title: Negative Image +author: VeyDlin +repo: https://github.com/VeyDlin/negative-image-node +summary: Invert an image into its negative, for visual effects and mask inversion. +categories: [image-effects, masks] +thumbnail: ./negative-image.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/negative-image-node/master/.readme/node.png + alt: The Negative Image node diff --git a/docs/src/content/community-nodes/nightmare-promptgen.yaml b/docs/src/content/community-nodes/nightmare-promptgen.yaml new file mode 100644 index 00000000000..05be23521d2 --- /dev/null +++ b/docs/src/content/community-nodes/nightmare-promptgen.yaml @@ -0,0 +1,8 @@ +title: Nightmare Promptgen +author: gogurtenjoyer +repo: https://github.com/gogurtenjoyer/nightmare-promptgen +summary: Generate imaginative, usually nightmarish, prompts with a local text generation model. +description: >- + By default it offers GPT-Neo models the author fine-tuned on more than 2,500 of their own InvokeAI prompts in Compel + format, and you can add your own. Troublesome words can be replaced with a random choice from a list you define. +categories: [prompts, language-models] diff --git a/docs/src/content/community-nodes/ollama-node.webp b/docs/src/content/community-nodes/ollama-node.webp new file mode 100644 index 00000000000..89ed6f33307 Binary files /dev/null and b/docs/src/content/community-nodes/ollama-node.webp differ diff --git a/docs/src/content/community-nodes/ollama-node.yaml b/docs/src/content/community-nodes/ollama-node.yaml new file mode 100644 index 00000000000..d9ff68488d2 --- /dev/null +++ b/docs/src/content/community-nodes/ollama-node.yaml @@ -0,0 +1,15 @@ +title: Ollama Node +author: Jonseed +repo: https://github.com/Jonseed/Ollama-Node +summary: Expand short prompts into detailed natural-language prompts with a local LLM through Ollama. +description: >- + Works well for expanding basic prompts into detailed prompts for FLUX. A toggle unloads the LLM right after + expanding, to free VRAM for the rest of the generation. +categories: [prompts, language-models] +links: + - label: Example FLUX workflow + url: https://github.com/Jonseed/Ollama-Node/blob/main/Ollama-Node-Flux-example.json +thumbnail: ./ollama-node.webp +images: + - src: https://raw.githubusercontent.com/Jonseed/Ollama-Node/a3e7cdc55e394cb89c1ea7ed54e106c212c85e8c/ollama-node-screenshot.png + alt: The Ollama node expanding a prompt diff --git a/docs/src/content/community-nodes/one-button-prompt.webp b/docs/src/content/community-nodes/one-button-prompt.webp new file mode 100644 index 00000000000..c4f109b0bdd Binary files /dev/null and b/docs/src/content/community-nodes/one-button-prompt.webp differ diff --git a/docs/src/content/community-nodes/one-button-prompt.yaml b/docs/src/content/community-nodes/one-button-prompt.yaml new file mode 100644 index 00000000000..ea05a41a545 --- /dev/null +++ b/docs/src/content/community-nodes/one-button-prompt.yaml @@ -0,0 +1,15 @@ +title: One Button Prompt +author: AIrjen +repo: https://github.com/AIrjen/OneButtonPrompt_X_InvokeAI +summary: An extensive suite of automatic prompt generation and prompt helper nodes. +description: >- + The main node generates prompts from a set of parameters. Other nodes include Auto Negative Prompt, One Button + Artify and Create Prompt Variant. +categories: [prompts] +thumbnail: ./one-button-prompt.webp +images: + - src: https://raw.githubusercontent.com/AIrjen/OneButtonPrompt_X_InvokeAI/refs/heads/main/images/background.png + alt: One Button Prompt banner + - src: https://raw.githubusercontent.com/AIrjen/OneButtonPrompt_X_InvokeAI/refs/heads/main/images/OBP_nodes_invokeai.png + alt: The One Button Prompt nodes in the workflow editor + caption: The nodes diff --git a/docs/src/content/community-nodes/prompt-tools.webp b/docs/src/content/community-nodes/prompt-tools.webp new file mode 100644 index 00000000000..eb36f3d47b6 Binary files /dev/null and b/docs/src/content/community-nodes/prompt-tools.webp differ diff --git a/docs/src/content/community-nodes/prompt-tools.yaml b/docs/src/content/community-nodes/prompt-tools.yaml new file mode 100644 index 00000000000..1bfc86192dd --- /dev/null +++ b/docs/src/content/community-nodes/prompt-tools.yaml @@ -0,0 +1,25 @@ +title: Prompt Tools +author: skunkworxdark +repo: https://github.com/skunkworxdark/prompt-tools +summary: Prompt (string) manipulation tools, to use with Prompts From File and other prompt generators. +description: >- + Prompt Join, Prompt Join Three, Prompt Replace and Prompt Split Neg moved into InvokeAI as `String Join`, + `String Join Three`, `String Replace` and `String Split Neg`, and are no longer in this pack. +categories: [prompts] +nodes: + - name: Prompt To File + description: Saves a prompt or collection of prompts to a file, one per line, appending or overwriting. + - name: PTFields Collect + description: Converts image generation fields into a JSON string that can be passed to Prompt To File. + - name: PTFields Expand + description: Converts a JSON string into individual generation parameters. Can be fed from Prompt To File. + - name: Prompt Strength + description: Formats a prompt with a strength, like Compel's weighted format. + - name: Prompt Strength Combine + description: Combines weighted prompts for `.and()` and `.blend()`. + - name: CSV To Index String + description: Gets a string from a CSV by index, with a random index option. +thumbnail: ./prompt-tools.webp +images: + - src: https://raw.githubusercontent.com/skunkworxdark/prompt-tools/refs/heads/main/images/CSVToIndexStringNode.png + alt: A workflow using the CSV To Index String node diff --git a/docs/src/content/community-nodes/remote-image.yaml b/docs/src/content/community-nodes/remote-image.yaml new file mode 100644 index 00000000000..d4082e282e9 --- /dev/null +++ b/docs/src/content/community-nodes/remote-image.yaml @@ -0,0 +1,10 @@ +title: Remote Image +author: fieldOfView +repo: https://github.com/fieldOfView/InvokeAI-remote_image +summary: Load images from, and post images to, websites and local servers. +categories: [utility] +nodes: + - name: Load Remote Image + description: Loads remote images, such as a live webcam image, an image of the day, or dynamically created images. + - name: Post Image to Remote Server + description: Uploads an image to a remote server with an HTTP POST request, for storage, display or further processing. diff --git a/docs/src/content/community-nodes/remove-background.webp b/docs/src/content/community-nodes/remove-background.webp new file mode 100644 index 00000000000..7b204a5eab4 Binary files /dev/null and b/docs/src/content/community-nodes/remove-background.webp differ diff --git a/docs/src/content/community-nodes/remove-background.yaml b/docs/src/content/community-nodes/remove-background.yaml new file mode 100644 index 00000000000..c7079d80cce --- /dev/null +++ b/docs/src/content/community-nodes/remove-background.yaml @@ -0,0 +1,9 @@ +title: Remove Background +author: VeyDlin +repo: https://github.com/VeyDlin/remove-background-node +summary: Remove image backgrounds with the rembg package and several U2NET models. +categories: [masks] +thumbnail: ./remove-background.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/remove-background-node/master/.readme/node.png + alt: The Remove Background node diff --git a/docs/src/content/community-nodes/retroize.webp b/docs/src/content/community-nodes/retroize.webp new file mode 100644 index 00000000000..23cb3c63def Binary files /dev/null and b/docs/src/content/community-nodes/retroize.webp differ diff --git a/docs/src/content/community-nodes/retroize.yaml b/docs/src/content/community-nodes/retroize.yaml new file mode 100644 index 00000000000..6e40e89e461 --- /dev/null +++ b/docs/src/content/community-nodes/retroize.yaml @@ -0,0 +1,11 @@ +title: Retroize +author: Ar7ific1al +repo: https://github.com/Ar7ific1al/invokeai-retroizeinode +summary: Pixelize, quantize, palettize and dither images for a retro look. +description: >- + Works on any image, from your gallery or from within the graph. Can also extract palettes from existing images. +categories: [image-effects] +thumbnail: ./retroize.webp +images: + - src: https://github.com/Ar7ific1al/InvokeAI_nodes_retroize/assets/2306586/de8b4fa6-324c-4c2d-b36c-297600c73974 + alt: Images retroized with pixelation and limited palettes diff --git a/docs/src/content/community-nodes/simple-skin-detection.webp b/docs/src/content/community-nodes/simple-skin-detection.webp new file mode 100644 index 00000000000..3aafa8bb4e2 Binary files /dev/null and b/docs/src/content/community-nodes/simple-skin-detection.webp differ diff --git a/docs/src/content/community-nodes/simple-skin-detection.yaml b/docs/src/content/community-nodes/simple-skin-detection.yaml new file mode 100644 index 00000000000..f01479f4035 --- /dev/null +++ b/docs/src/content/community-nodes/simple-skin-detection.yaml @@ -0,0 +1,9 @@ +title: Simple Skin Detection +author: VeyDlin +repo: https://github.com/VeyDlin/simple-skin-detection-node +summary: Detect skin in images from predefined color thresholds. +categories: [masks] +thumbnail: ./simple-skin-detection.webp +images: + - src: https://raw.githubusercontent.com/VeyDlin/simple-skin-detection-node/master/.readme/node.png + alt: The Simple Skin Detection node diff --git a/docs/src/content/community-nodes/size-stepper-nodes.webp b/docs/src/content/community-nodes/size-stepper-nodes.webp new file mode 100644 index 00000000000..2c29b7ee5fa Binary files /dev/null and b/docs/src/content/community-nodes/size-stepper-nodes.webp differ diff --git a/docs/src/content/community-nodes/size-stepper-nodes.yaml b/docs/src/content/community-nodes/size-stepper-nodes.yaml new file mode 100644 index 00000000000..65598745772 --- /dev/null +++ b/docs/src/content/community-nodes/size-stepper-nodes.yaml @@ -0,0 +1,20 @@ +title: Size Stepper Nodes +author: dwringer +repo: https://github.com/dwringer/size-stepper-nodes +summary: Calculate the intermediate sizes for multi-step upscaling workflows. +description: >- + Enter your full size and orientation (portrait, landscape or random) in Final Size & Orientation, then plug it and + your initial generation size into Ideal Size Stepper to get one, two or three intermediate sizes for upscaling. The + outputs are only the intermediate sizes, not the initial or full size. +categories: [sizing] +nodes: + - name: Final Size & Orientation + description: Your full size dimensions and orientation. + - name: Ideal Size Stepper + description: One, two or three intermediate pairs of dimensions between the initial and full size. + - name: Random Switch (Integers) + description: A generic version of Final Size & Orientation without the orientation choice. +thumbnail: ./size-stepper-nodes.webp +images: + - src: https://raw.githubusercontent.com/dwringer/size-stepper-nodes/main/size_nodes_usage.jpg + alt: A workflow using the size stepper nodes diff --git a/docs/src/content/community-nodes/stereogram-nodes.webp b/docs/src/content/community-nodes/stereogram-nodes.webp new file mode 100644 index 00000000000..8468e5faffb Binary files /dev/null and b/docs/src/content/community-nodes/stereogram-nodes.webp differ diff --git a/docs/src/content/community-nodes/stereogram-nodes.yaml b/docs/src/content/community-nodes/stereogram-nodes.yaml new file mode 100644 index 00000000000..52d3fd3e9e7 --- /dev/null +++ b/docs/src/content/community-nodes/stereogram-nodes.yaml @@ -0,0 +1,14 @@ +title: Stereogram Nodes +author: simonfuhrmann +repo: https://github.com/simonfuhrmann/invokeai-stereo +summary: Create cross-view or parallel-view stereograms. +description: >- + Stereograms are 2D images that reveal a 3D scene when viewed properly. +categories: [video-3d] +links: + - label: Viewing tutorials on r/CrossView + url: https://www.reddit.com/r/CrossView/ +thumbnail: ./stereogram-nodes.webp +images: + - src: https://raw.githubusercontent.com/simonfuhrmann/invokeai-stereo/refs/heads/main/docs/example_promo_03.jpg + alt: An example stereogram workflow and its cross-view output diff --git a/docs/src/content/community-nodes/text-font-to-image.yaml b/docs/src/content/community-nodes/text-font-to-image.yaml new file mode 100644 index 00000000000..3d4220af1c8 --- /dev/null +++ b/docs/src/content/community-nodes/text-font-to-image.yaml @@ -0,0 +1,9 @@ +title: Text font to Image +author: mickr777 +repo: https://github.com/mickr777/textfontimage +summary: Render text in a chosen font as an image. +description: >- + Downloads the font to use, or takes it from the font cache. The text always resizes to the image size; padding + controls the margin, and there is an optional second line. The author's examples use the output with a depth + ControlNet. +categories: [utility, control] diff --git a/docs/src/content/community-nodes/thresholding.webp b/docs/src/content/community-nodes/thresholding.webp new file mode 100644 index 00000000000..c203a70679f Binary files /dev/null and b/docs/src/content/community-nodes/thresholding.webp differ diff --git a/docs/src/content/community-nodes/thresholding.yaml b/docs/src/content/community-nodes/thresholding.yaml new file mode 100644 index 00000000000..b8ea4e6fdd7 --- /dev/null +++ b/docs/src/content/community-nodes/thresholding.yaml @@ -0,0 +1,30 @@ +title: Thresholding +author: JPPhoto +repo: https://github.com/JPPhoto/thresholding-node +summary: Masks for the highlights, midtones and shadows of an image. +description: >- + You can blur the lookup table used to make the masks from the source image. +categories: [masks] +thumbnail: ./thresholding.webp +images: + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/c88ada13-fb3d-484c-a4fe-947b44712632 + alt: The input image + caption: Input + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/727021c1-36ff-4ec8-90c8-105e00de986d + alt: Highlights mask + caption: Highlights + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/0b721bfc-f051-404e-b905-2f16b824ddfe + alt: Midtones mask + caption: Midtones + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/04c1297f-1c88-42b6-a7df-dd090b976286 + alt: Shadows mask + caption: Shadows + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/19aa718a-70c1-4668-8169-d68f4bd13771 + alt: Highlights mask with the lookup table blurred + caption: Highlights, LUT blur on + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/0a440e43-697f-4d17-82ee-f287467df0a5 + alt: Midtones mask with the lookup table blurred + caption: Midtones, LUT blur on + - src: https://github.com/invoke-ai/InvokeAI/assets/34005131/0701fd0f-2ca7-4fe2-8613-2b52547bafce + alt: Shadows mask with the lookup table blurred + caption: Shadows, LUT blur on diff --git a/docs/src/content/community-nodes/unsharp-mask.yaml b/docs/src/content/community-nodes/unsharp-mask.yaml new file mode 100644 index 00000000000..47e6ef309a3 --- /dev/null +++ b/docs/src/content/community-nodes/unsharp-mask.yaml @@ -0,0 +1,5 @@ +title: Unsharp Mask +author: JPPhoto +repo: https://github.com/JPPhoto/unsharp-mask-node +summary: Sharpen an image with an unsharp mask filter, keeping its alpha channel. +categories: [image-effects] diff --git a/docs/src/content/community-nodes/xy-image-to-grid-and-images-to-grids.webp b/docs/src/content/community-nodes/xy-image-to-grid-and-images-to-grids.webp new file mode 100644 index 00000000000..9af42f01145 Binary files /dev/null and b/docs/src/content/community-nodes/xy-image-to-grid-and-images-to-grids.webp differ diff --git a/docs/src/content/community-nodes/xy-image-to-grid-and-images-to-grids.yaml b/docs/src/content/community-nodes/xy-image-to-grid-and-images-to-grids.yaml new file mode 100644 index 00000000000..a44e0202ea4 --- /dev/null +++ b/docs/src/content/community-nodes/xy-image-to-grid-and-images-to-grids.yaml @@ -0,0 +1,23 @@ +title: XY Image to Grid and Images to Grids +author: skunkworxdark +repo: https://github.com/skunkworxdark/XYGrid_nodes +summary: Image grids, labeled XY parameter grids, and tiled upscaling with seamless recombining. +description: >- + Makes grids of images from several inputs, labeled XY grids from parameters, and overlapping tiles for + super-resolution workflows, which it recombines into one image, blending the seams. +categories: [compositing, sizing] +nodes: + - name: Images To Grids + description: Combines multiple images into a grid of images. + - name: XYImages To Grids + description: Takes X and Y parameters and creates a labeled image grid. + - name: XYImage Tiles + description: Super-resolution (embiggen) style tiled resizing. + - name: Image To XYImages + description: Cuts an image into a number of columns and rows. + - name: Supporting nodes + description: Helpers for data wrangling and building `XYImage` collections. +thumbnail: ./xy-image-to-grid-and-images-to-grids.webp +images: + - src: https://raw.githubusercontent.com/skunkworxdark/XYGrid_nodes/refs/heads/main/images/collage.png + alt: A collage of XY grids and tiled images diff --git a/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx b/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx index a59840a4415..bd7d06e92ca 100644 --- a/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx @@ -1,297 +1,223 @@ --- title: Managing media generation models -description: Introduction to the Model Manager +description: Install, add API keys for, edit and maintain image and video models in the Model Manager. +lastUpdated: 2026-10-26 --- -Models are the "secret sauce" that drive image and video -generation. This page provides a brief introduction to these models -and describes how you can manage them using InvokeAI. - -## Types of Models - -There are literally thousands of models available, each -specialized for a different task. These include: - -| Type | Description | -|---|---| -| **Text-to-Image** | Given a text prompt, generate a still image. | -| **Image-to-Image** | Given a starting image and a text prompt, generate a new image. | -| **Image Editing** | Use a text prompt to modify an image or combine several images according to instructions. | -| **Video Generation** | Generate a video from a text prompt (text-to-video), a starting image (image-to-video) or another video (video-to-video). These modes can be combined in multiple ways. | -| **Concept** | Small add-on models that modify a larger image or video generation model to achieve special effects, such as adding a particular style, theme or character. The most common of these are LoRA models. | -| **ControlNet** | Models that provide spatial guidance to an image generation model while it works, helping to precisely control where elements of the image appear. A typical example is a pose model that transfers the pose of a stick figure onto a photorealistic subject. | - -In addition to models directly involved in image and video -manipulation, InvokeAI supports several support models: - -| Type | Description | -|---|---| -| **Image Segmenters** | Identify and isolate individual objects in an image, which is often the first step in precisely editing it. | -| **Vision Models** | Describe what they "see" in an image. Used to classify, cluster and tag images, and to turn an image into a text prompt that can be reused. | -| **Large Language Models** | Generate text from text. InvokeAI uses them for prompt enhancement, which expands a simple prompt into one that achieves better results. | - -## Local vs External Models - -A model can be installed locally, in which case it executes on your -own hardware (CPU and graphics card), or it can be hosted -externally. In the latter case InvokeAI will send your request out to -the hosted image or video generation service and return the result. To -use an external model you will typically need to subscribe to the -hosting service. - -Locally-hosted models, also known as "open-weights" models, produce -excellent quality images and videos with decent performance. They cost -nothing to run beyond the initial hardware investment and the -electricity it uses. However, some of the more advanced models may -strain or exceed local hardware resources; some are 50-100 GB in -size. In this case using an externally-hosted model may be your best -choice. - -:::note[Model Licensing] - Note that some models carry license terms that limit their use in - commercial applications or on public servers. It's your responsibility - to adhere to the license terms. The [Model Families](/users-guide/models/model-families/#model-families) - table below summarizes each family's license, and each model's page - has a **License** section with the details. +import { Card, CardGrid, LinkCard, Steps } from '@astrojs/starlight/components'; +import Callout from '@components/annotated/Callout.astro'; +import ModelManagerSketch from '@components/annotated/ModelManagerSketch.astro'; + +Invoke generates with models: local models that run on your own hardware, and external models that run on a provider's servers. The **Model Manager** installs, configures and removes both. + +## Types of models + + + + Main models that turn a prompt into an image, change a starting image, or edit images by instruction. + + + Models that make a video from a prompt, a starting image or another video. + + + Small add-ons, mostly **LoRAs** and **embeddings**, that teach a main model a style, theme or character. + + + **ControlNets**, **T2I Adapters** and **Control LoRAs** guide where things go, for example by copying a pose. **IP Adapters** and **FLUX Redux** take a reference image. + + + Enlarge images. The Model Manager lists them as **Image-to-Image** models. + + + **VAEs** and text encoders, usually installed together with the main model that needs them. **LLaVA OneVision** describes images, and **Text LLMs** expand short prompts. + + + +Segmentation models (Segment Anything) are not installed here: they download the first time a tool uses them. + +## Local or external + +| | Local model | External model | +| --- | --- | --- | +| Runs on | Your GPU (or CPU) | The provider's servers | +| Cost | Free after the hardware and electricity | Billed by the provider for each request | +| Hardware | Enough VRAM and disk space; see [System Requirements](/start-here/system-requirements/) | None beyond Invoke itself | +| Privacy | Prompts and images stay on your machine | Prompts and input images are sent to the provider | +| Internet | Only to download the model | Needed for every generation | +| Setup | Install from the Model Manager | Add the provider's API key | +| Models | Open-weights families such as FLUX, SDXL, Z-Image and Wan | Closed models such as GPT Image, Gemini and Seedream | +| Features | Everything in Invoke | What the provider supports; see [External models](#external-models) | + +:::note[Model licensing] +Some models carry license terms that limit commercial use or use on public servers, and following them is your responsibility. The [Model Families](/users-guide/models/model-families/#model-families) table lists each family's license, and each model's page has a **License** section. ::: ---- +## Install your first model -## Model Manager - -The InvokeAI Model Manager is the central location for managing both -local and external models. For those just getting started with -InvokeAI, it provides a series of recommended starter models that are -organized into a series of bundles. - -To access the model manager, go to the InvokeAI icon at the upper -left of the screen, pull down its menu, and select "Model Manager." It -has two sections: a list of installed models on the left (initially -empty), and a panel on the right that allows you to search and install -bundles, individual starter models, and other locally-installable and -external models. - -If it isn't selected already, click **"+ Add Models"** to display the -list of starter models and bundles. - -### Local Bundles - -We suggest that you start by installing a bundle. You will see a -series of chips across the top of the panel, each one corresponding to -a different family (or "base") of generation model. Clicking on the -chip will show you all the starter models in that group. To install -all the recommended models for that family, click "Install bundle." -You may also cherry-pick individual models within the bundle. - -Some models are quite large and may take a while to download. During -download and installation, a status bar will appear at the bottom of -the right panel. Click on this bar in order to see the progress of -individual models. - -### External Models - -To add an externally-hosted model, select the **"API Keys"** section of -the Model Manager's right panel. This will display a list of model -providers. Where prompted, enter the API key for access to the -provider using your subscription. See your provider's account page for -information on how to generate these keys. Once a valid API key has -been provided, the list of models hosted by that service will appear -as selections in the Image Generation panel. - -Opening an external model in the Model Manager shows an **External -Provider** section where you can change the provider (one of the -registered providers) and provider model ID it routes to, the modes -(text to image, image to image, inpaint) and -inputs (seed, reference images, negative prompt) it advertises, its -request limits, and the size and image count the provider is asked for -by default. The Generate panel and the Canvas only offer what the -record advertises, so keep it in step with the provider's documentation. -The Canvas compiles only text to image and image to image for external -models and refuses inpaint, because its keep-mask does not match any -provider's edit-mask contract; an advertised inpaint mode serves the -workflow editor. A model whose source matches a starter catalog entry is -shown read-only, including its provider and provider model ID, because -Invoke applies that catalog entry's capabilities to it on every load, so -a changed route would keep the original entry's capabilities. Models -added outside the starter catalog get the full editor. - -:::note[Gated Downloads] - The API Keys section is also used to provide keys for services that - require authentication to download certain models, such as those - covered by restrictive licenses. If you try to install a model - hosted by HuggingFace or Civitai and you get a permission denied - error, you will need to enter an API key for your account on the - service. You may also need to accept the license terms for the - model in question on the host's download page. -::: +The Model Manager is for administrators: in [multi-user mode](/configuration/multi-user-mode/admin-guide/) other accounts don't see it. Open it from the Invoke menu at the top left (**Manage › Model Manager**) or from **Models** in the Launchpad. While no models are installed, the Launchpad's home page also shows the starter bundles. -## Installing Models by Hand - -There is a search box at the top of the **"+ Add Models"** section -that has multiple uses. You can: - -* Type a search term to find starter models that you can install - immediately. -* Paste in the download URL of a model you wish to install from - Civitai, HuggingFace, or another download site. -* Paste in the HuggingFace repository ID of a model you wish to install. -* If you've already downloaded the model and it is living on the - InvokeAI backend filesystem, type in or cut and paste the path to the - folder containing the model you wish to install. - -If you type in a URL or HuggingFace repository ID, a **Pull** button -will appear. Pressing it will initiate the download and install. If -you type in a file path, the button will change to **Scan**. Pressing -it will scan the indicated folder and list the installable -models. Click the model(s) you wish to install. - -To look through the server's own models folder, press **Scan models -folder**. It shows the folder's path and scans it in one step, with the -same **Stop** button and results. If the server's folder location could -not be loaded, a message says so; check that the server is reachable and -press **Retry**. - -### Recovering a failed installation - -InvokeAI retries temporary file permission errors during installation. If moving -a model fails, it attempts to restore files to their source folder. - -If the error reports that installation recovery is required, keep both the -source and destination folders named in the error. Some files may have moved -while others remain at the source. Restore a complete model folder without -overwriting files you need, then use **Scan** to register it. Downloaded files -retained for recovery are not automatically resumed or removed on restart. - -If all files moved successfully but registering the model failed, the complete -model remains in the destination folder. Resolve the reported registration -error, then use **Scan** on that folder. - -## Editing a Model - -Once a model is installed, it will appear in the left-hand panel. The -panel has controls that let you search and sort the models in various -ways, including by model type and architecture. - -Most models have default parameters that you can customize, for -example step count, image dimensions and CFG. When you click on a -model, an edit panel appears to the right where you can edit the -model's default parameters, change its name, add descriptive notes, -and even choose a preview image for the model. For models that take -prompt trigger terms, such as some LoRAs, you can add the terms so -that they appear as shortcuts in the Image generation prompt box. - -## Deleting a Model - -Right click on the model in the left panel and choose **"Delete -model"**. It will be removed from the list of models InvokeAI knows -about. If the model was downloaded and installed by the Model Manager, -its weights will be deleted from your file system. If, on the other -hand, you downloaded the model by hand and then imported it into -InvokeAI, the weights will be left alone. + + Your installed models, with their base and format. Search, filter and sort them above the list; click one to open it in the model's tab. + **Refresh model list**, **Clean up orphaned models…** and **Empty model cache**, which unloads every model from RAM and VRAM. + Starter models and every other way to install. It opens here while the library is empty. + Keys for external providers, a HuggingFace token and a Civitai key. + Type to search the starter models, or paste a URL, a HuggingFace repo ID or a path on the server. See [Other ways to install](#other-ways-to-install). + Lists the model files in the server's models folder, whose path is shown beside the button, so you can register files copied there. The button turns into **Stop** while the scan runs. If the server can't report the folder, a message says so with **Retry**. + **All** lists every starter model; each bundle chip lists the recommended models for one family. A check mark means the whole bundle is installed. The menu at the end filters and sorts the list. + Queues every model of the chosen bundle that isn't installed yet. + Installs one starter model and any models it depends on, such as its VAE or text encoder. + Download and install progress. Expand it to pause, resume, retry or cancel installs and to clear finished ones. + ---- + -## Model formats and families +1. Open the Model Manager. It shows **Add Models** when nothing is installed; otherwise open that tab. -[Model Formats and Families](/users-guide/models/model-families/) explains the [formats](/users-guide/models/model-families/#checkpoints-vs-diffusers-formats) models come in, the [components](/users-guide/models/model-families/#submodels) that make up a model, and the [model families](/users-guide/models/model-families/#model-families) InvokeAI supports, with their licenses and links to each family's page. +2. Choose the bundle chip for the model family you want, such as **FLUX.2**, **SDXL** or **Z-Image**. The [model family pages](/users-guide/models/model-families/#model-families) list what each family needs. ---- +3. Press **Install bundle**, or **Install** on single models. Dependencies are queued with them. -## Advanced Topics +4. Follow the downloads in the **Install queue**. Large models take a while. A finished model appears in the library and in every model picker. -This section covers several model installation issues that you may -encounter along the way. + -### HuggingFace repos that contain multiple models +## Other ways to install -HuggingFace repos can be structured in any way. Some model authors -include multiple models within the same folder. In this situation, you -may need to provide some additional information to identify the model -you want, by adding `:subfolder_name` to the repo ID. +Paste a source into **Search or add**. The field recognizes what you typed and offers the matching button: -:::note[Example] - Say you have a repo ID `monster-labs/control_v1p_sd15_qrcode_monster`, and the model you want is inside the `v2` subfolder. +| You enter | Example | Button | Result | +| --- | --- | --- | --- | +| A search term | `flux` | None | Filters the starter models. | +| A download URL | `https://civitai.com/api/download/models/…` | **Pull** | Downloads and installs the file. A key button beside the field attaches a one-off access token to this download. | +| A HuggingFace repo ID | `stabilityai/sdxl-turbo` | **Pull** | A diffusers repo installs whole and a repo with one model file installs that file. A repo with several files lists them so you can install one or all. | +| A file on the server, by absolute path | `/data/loras/style.safetensors` | **Pull** | Installs the file. | +| A folder on the server, by absolute path | `/data/loras/` | **Scan** | Lists the model files in the folder and which are installed, to install one or all. **Stop** cancels a slow scan. | - Add `:v2` to the repo ID and use that when installing the model: `monster-labs/control_v1p_sd15_qrcode_monster:v2` +Text starting with `./` or `../`, or ending in a model file extension such as `.safetensors`, is treated as a search term unless it is an absolute path or URL. Other text shaped like `owner/name` is taken as a HuggingFace repo ID, so use absolute paths for local files. Two options appear before an install: + +- **Install in place** (local files): registers the file where it is. It is on by default and always on inside the models folder. Turned off, Invoke moves the file into its models folder. +- **FP8 storage**: stores the weights in FP8 to roughly halve VRAM use, where the model's loader supports it. + +:::tip[A model in a repo subfolder] +Add `::` and the subfolder to the repo ID. For the model in the `v2` folder of `monster-labs/control_v1p_sd15_qrcode_monster`, enter `monster-labs/control_v1p_sd15_qrcode_monster::v2`. ::: -### How models are identified +:::note[Gated and login-only downloads] +Some HuggingFace repos are gated and some Civitai models need a login. Add a **HuggingFace** token (stored on the server and checked with HuggingFace) or a **Civitai** key (stored in this browser only, attached to civitai.com downloads) under **API Keys**, and accept the model's license on its download page if asked. An install that is missing a token offers **Add token** in the install queue. +::: -InvokeAI identifies a model from the file itself, and falls back to its name only where the contents are the same: +## External models -- **Standalone 16-channel VAEs.** FLUX.1 and SD3 use the same VAE network with different settings. A diffusers `vae/` folder is identified by its `config.json`, so FLUX.1-dev's folder is filed as FLUX.1 and SD3.5's as SD3. A single file such as `sd3_vae.safetensors` is identified by its name, folder or download source; if none of them names a model family, it is filed as FLUX.1. If a VAE lands under the wrong base, change **Base** in the model's **Edit** view. A `vae/` folder installed before this detection existed can be fixed with **Re-identify model**. -- **FLUX.2 Klein 9B vs. Klein 9B Base.** The two are identical in structure, so the file or repo name decides, and they ship different default step counts (4 against 28). Names that say "distilled" are read as Klein 9B even when they also contain "base". If you installed `Winnougan/Klein9b-Distilled-Base-INT8-Convrot` by repo ID before this was fixed, it was filed as Klein 9B Base; remove and reinstall it. +An external model has no weights to download. Saving a provider's API key adds that provider's models: + + + +1. Get an API key from the provider's account page. + +2. In the Model Manager, open **API Keys** and find the provider: Alibaba Cloud (Qwen), Atlas Cloud, Google Gemini, OpenAI or Seedream. In **Add Models**, **Configure** on an external starter model opens the same card. + +3. Paste the key and press **Save**. **Override base URL** is optional, for a proxy or regional endpoint. + +4. Invoke queues the provider's starter models right away. Once added, they show as **Installed** in Add Models and appear in the model pickers. -## Editing model metadata + -Every model has an editable **Source URL** field alongside its name -and description. Use it to record where a model came from — for -example a Civitai or HuggingFace page — independent of how it was -originally installed. The URL is editable from the model's **Edit** -view and appears as a clickable link in the model header once -set. Models without a URL simply hide the field. +Keys are stored on the server and used for every request to that provider. **Clear** removes the key and every model of that provider from the library. [Using external models](/users-guide/models/external-models/introduction/) covers the `api_keys.yaml` file, costs and each provider. -This is purely metadata: the URL has no effect on loading and is not -used to refresh or reinstall the model. It is mainly useful for going -back to the model's documentation, license, or example prompts later. +
+The External Provider settings of a model -## Bulk actions in the Model Manager +Opening an external model shows an **External Provider** section: -The Model Manager supports multi-selection for batch operations. +- **Provider** and **Provider Model ID** route the requests. **Supported modes** (Text to image, Image to image, Inpaint), whether it accepts a seed, reference images or a negative prompt, and the limits (images per request, max reference images, max width and height) describe what the provider accepts. **Default width**, **Default height** and **Default image count** are what it is asked for by default. **Save Provider Settings** stores them. +- The Generate panel and the Canvas offer only what the record advertises, so keep it in step with the provider's documentation. +- The Canvas uses text to image and image to image only: its mask does not match any provider's edit mask. An advertised Inpaint mode serves workflows. +- Models from the starter catalog are read-only, provider and model ID included, because Invoke applies the catalog entry to them on every load. Models added any other way can be edited. -- **Select multiple models** by clicking with **Ctrl** (Windows / Linux) or **Cmd** (macOS) held, or by using the checkboxes on each row. A sticky header at the top shows the current selection count and is always visible while you scroll. -- Select the bulk action you wish to execute. These actions appear as buttons at the top of the list when one or more models are selected. - - **Delete** — removes every selected model in a single confirmation step. Partial failures (e.g. permission issues) are reported per-model in the result toast. - - **Re-identify** — re-probes every selected model, updating fields that depend on the file contents (type, base, format, variant, etc.). This is the bulk version of the per-model reidentify action. +
-:::caution[Re-identify resets custom settings] -Reidentifying a model re-derives its configuration from the file on disk. Any custom settings you've adjusted on those models — default settings, descriptions, trigger phrases — may be overwritten. The confirmation modal warns you about this before running. +## Edit, delete and bulk actions + +Click a model in the library to open it. Its **⋯** menu, and right-clicking its row in the library, hold the actions that change the whole record. + +| Action | How | What happens | +| --- | --- | --- | +| Rename, describe, reclassify | **Edit** | Change the name, description, base, type, variant, format and **Source URL**. Changing the format reclassifies the model; invalid combinations are rejected. | +| Default settings | **Default Settings**, then **Save Defaults** (main models, LoRAs, control adapters) | Applied when the model is selected; a setting left off uses the app default. | +| Trigger phrases | **Trigger Phrases** (main models, LoRAs, embeddings) | Offered in the prompt editor. | +| Cover image | Click the model's image | Shown in the library and pickers. PNG, JPEG or WebP. | +| Related models | **Related Models** | Linked models are suggested together elsewhere in the app. | +| Run on CPU | **Run on CPU** (text encoders, VAEs, CLIP Vision, SigLIP, LLaVA OneVision, Text LLMs) | Saves VRAM; may be slower. | +| Moved file | **Update path** beside the path (models installed in place outside the models folder, or whose files are missing) | Points Invoke at the file's new location. Nothing is moved or copied. | +| Convert to diffusers | **Convert to diffusers** (checkpoint main models of supported families) | Replaces the checkpoint file with a diffusers folder. | +| Re-identify | **Re-identify model** in the menu | Re-reads the files to detect base, type, format and variant again. | +| Delete | **Delete model** in the menu, then confirm | Removes the model from Invoke. Its files are deleted only if they are inside Invoke's models folder, where everything the Model Manager downloads or moves goes; a model installed in place elsewhere keeps its files. | +| Act on several models | Tick their rows or **Select all**, then **Re-identify** or **Delete** | Asks once for all of them. Models that fail stay selected, and a notice gives the counts and the first error. | + +**Source URL** records where a model came from, such as its Civitai or HuggingFace page, and shows as a link in the model's details. It doesn't affect loading or updates. + +:::caution[Re-identify resets settings] +Re-identify keeps the name, description, cover image, trigger phrases, install source and the CPU and FP8 storage choices. It resets the custom variant and the other default settings, and clears the **Source URL**: note it first. Re-identifying several models asks for confirmation first; the single-model action runs at once. ::: -Both actions handle partial failures: if some models succeed and others fail, the toast lists succeeded and failed counts and the list view updates immediately for the ones that worked. +## Maintenance + +
+Recovering a failed installation + +Invoke retries temporary file permission errors during installation. If moving a model fails, it tries to restore the files to their source folder. -## Finding orphaned models +- **The error says installation recovery is required.** Keep both the source and destination folders named in the error: some files may have moved while others remain at the source. Restore one complete model folder without overwriting files you need, then use **Scan** on it to register it. Files kept for recovery are not resumed or removed when Invoke restarts. +- **All files moved but registering the model failed.** The complete model remains in the destination folder. Resolve the reported error, then use **Scan** on that folder. -Model files can stay in the models directory after their database entry is gone, for example when the -database was reset, or files were copied into the directory by hand. To find these orphaned models: +
-1. Open the Model Manager. -2. Click the "..." menu at the top right of the installed model list, and pick **"Clean up orphaned models..."** -3. If there are orphaned models, a dialogue box appears that lists their folders and offers to delete them. +
+Finding orphaned models -On an install without the web UI at hand, an administrator can do the same through the API: -`GET /api/v2/models/sync/orphaned` lists the folders and `DELETE /api/v2/models/sync/orphaned` deletes those given. +Model folders can stay in the models folder after their database entry is gone, for example after a failed install, a database reset, or files copied there by hand. -The opposite case, a database entry whose files are missing from disk, is marked in the Model Manager's -model list, and its filter can show only those models. + -## Exporting and Importing Model Settings +1. In the library's **⋯** menu, choose **Clean up orphaned models…**. -Each installed model has an **Export Settings** and **Import Settings** action in the Model Manager. Use these to back up a model's configuration, move it to another install, or share a curated setup with someone else. +2. The dialog lists every folder in the models folder without a database record, with its file count and size, or says the library is clean. -### What gets exported +3. Tick the folders to remove and press **Delete Selected**. -The exported `.json` file captures the configuration you have set on the model, not the model weights themselves: + -- `default_settings` — steps, CFG / guidance, scheduler, dimensions, FP8 storage toggle, VAE precision, etc. -- `trigger_phrases` — for LoRAs and similar. -- `cpu_only` — for encoder-type models. -- `name`, `description`, `source_url` — the model's identifying metadata. -- `cover_image` — the model's thumbnail, embedded as a base64 data URL. +Without the web UI, an administrator can use the API: `GET /api/v2/models/sync/orphaned` lists the folders and `DELETE /api/v2/models/sync/orphaned` deletes the paths it is given. + +The opposite case, a model whose files are missing from disk, is marked **Missing file** in the library. The type filter's **Missing files** entry shows only those models. + +
+ +
+How models are identified + +Invoke identifies a model from its files, and falls back to its name only where the contents are the same: + +- **Standalone 16-channel VAEs.** FLUX.1 and SD3 use the same VAE network with different settings. A diffusers `vae/` folder is identified by its `config.json`, so FLUX.1-dev's folder is filed as FLUX.1 and SD3.5's as SD3. A single file such as `sd3_vae.safetensors` is identified by its name, folder or download source; if none of them names a model family, it is filed as FLUX.1. If a VAE lands under the wrong base, change **Base** with **Edit**. A `vae/` folder installed before this detection existed can be fixed with **Re-identify model**. +- **FLUX.2 Klein 9B vs. Klein 9B Base.** The two are identical in structure, so the file or repo name decides, and they ship different default step counts (4 against 28). Names that say "distilled" are read as Klein 9B even when they also contain "base". If you installed `Winnougan/Klein9b-Distilled-Base-INT8-Convrot` by repo ID before this was fixed, it was filed as Klein 9B Base; remove and reinstall it. -Fields you have not set are omitted from the file. The format is forward and backward compatible: older clients ignore newer fields, and a file produced by a newer version still imports cleanly into an older one (it just skips the fields it does not understand). +
-### Importing +### Exporting and importing model settings -Importing applies the JSON to the currently selected model: +**Export settings…** and **Import settings…** in a model's **⋯** menu save its configuration to a `.json` file and apply one to the open model: to back up a tuned model before reinstalling it, copy settings to another install, or share a setup. The file holds settings, not weights: -- `default_settings`, `trigger_phrases`, `cpu_only`, `name`, `description`, and `source_url` are applied via the normal model update path. Any field that the target model type does not support (e.g. `cpu_only` on a model that has no such setting) is listed in a "skipped" toast — everything else still applies. -- `cover_image` is uploaded and set as the model's thumbnail. +| Field | Contents | +| --- | --- | +| `name`, `description`, `source_url` | The model's name, description and Source URL. | +| `default_settings` | Default steps, CFG or guidance, scheduler, size, FP8 storage, VAE precision and the other defaults. | +| `trigger_phrases` | Trigger phrases. | +| `cpu_only` | The **Run on CPU** choice. | +| `cover_image` | The cover image, embedded as a data URL. | -Imports are validated before they run. The file is rejected if `source_url` is not an `http(s)://` URL or if `cover_image` is not a valid image data URL — so a malformed or hand-edited file cannot quietly poison a model's configuration. +Fields you haven't set are left out. On import, top-level fields Invoke doesn't recognize are skipped, a field the model doesn't have is skipped and named in a notice, and the rest is applied. A default setting the receiving version doesn't know makes that model's import fail. A file is rejected if `source_url` is not an `http://` or `https://` URL or `cover_image` is not an image data URL. -### Typical workflows +## Formats and families -- **Back up a model you've spent time tuning** so you can restore its settings after a reinstall, or roll back after experimenting. -- **Copy settings between two installs of the same model** — e.g. between a desktop and a workstation. -- **Share a curated setup** (name, description, thumbnail, default steps / CFG / scheduler, trigger phrases) for a model you have configured well. + + diff --git a/docs/src/content/docs/Users Guide/03.Models/02.model-families.mdx b/docs/src/content/docs/Users Guide/03.Models/02.model-families.mdx index 01ae08cdd28..79e051a7723 100644 --- a/docs/src/content/docs/Users Guide/03.Models/02.model-families.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/02.model-families.mdx @@ -1,7 +1,7 @@ --- title: Model Formats and Families description: An overview of the models InvokeAI supports — checkpoint, Diffusers and quantized formats, the components that make up a model, and the model families with their licenses. -lastUpdated: 2026-10-18 +lastUpdated: 2026-10-26 sidebar: order: 2 --- @@ -45,7 +45,7 @@ page lists the builds it supports. InvokeAI recognizes the format from the file's contents, so you install them like any other model. Which format loads for which model is listed in [Model Format Support](/users-guide/models/model-format-support/). - **GGUF** (`.gguf`, llama.cpp quantization such as Q4_K or Q8_0): weights stay packed in memory and are unpacked layer by layer during generation. Supported for the FLUX.1, FLUX.2, Z-Image, Krea-2, Qwen-Image, Qwen-Image-2.1, Ideogram 4, ERNIE-Image and Wan transformers, and for the T5, Qwen3, Qwen3-VL, Mistral and Gemma 2 text encoders. ComfyUI-GGUF's `Q8_CR` files, which store int8 (`int8_convrot`) layers inside a `.gguf`, load for the Krea-2 transformer; for other models, and for ComfyUI-GGUF's `Q4_CR`, installation is refused with a message saying so. -- **NF4** (bitsandbytes): 4-bit, CUDA only. +- **NF4** (bitsandbytes): 4-bit. InvokeAI installs bitsandbytes on Windows and Linux, with ROCm binaries for AMD GPUs, so NF4 models load on NVIDIA and AMD GPUs; macOS installs have no bitsandbytes, and NF4 models don't load there. - **SDNQ**: see [SDNQ Quantization](/configuration/optimization/sdnq-quantization/). - **int8** (`int8_convrot`, from ComfyUI repackages): the weights stay int8 in memory on every device, including Apple Silicon, and need no setting to turn on. Supported for FLUX.1, FLUX.2 Klein, Z-Image, Krea-2, Qwen-Image-2.1, Ideogram 4, MiniMax H3, LTX-2 and the PiD decoders, and for the Qwen3 8B, Qwen3-VL and LTX-2 Gemma encoders. - **nvfp4** (4-bit, in ComfyUI's checkpoint format, such as Comfy-Org's builds; other nvfp4 exports such as AWQ are refused): the weights stay packed in memory and are unpacked per layer, so no special GPU is needed. Supported for the Z-Image, Krea-2, Qwen-Image and LTX-2 transformers and for the Qwen3, Qwen2.5-VL and FLUX.2 Mistral encoders; the Ideogram 4 and Qwen-Image-2.1 nvfp4 files are refused at install. diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/image-generation.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/image-generation.mdx index 0410b3a4299..087b34cf1c2 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/image-generation.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/image-generation.mdx @@ -18,7 +18,7 @@ Most image models in InvokeAI are **diffusion models**. A diffusion model starts **Prompt.** The text that describes the image you want. Many models also accept a **negative prompt** listing what you do not want. See the [Prompting Guide](/users-guide/image-generation/prompting-guide/). -**Steps.** How many denoising passes the model makes. More steps refine detail up to a point, after which they change little. Standard models typically use 25 to 50 steps; distilled "Turbo" and "Lightning" models are designed for 4 to 10. +**Steps.** How many denoising passes the model makes. More steps refine detail up to a point, after which they change little. Standard models typically use 25 to 50 steps; distilled "Turbo" and "Lightning" models are designed for 4 to 9. **CFG (classifier-free guidance) or Guidance.** How strictly the model follows the prompt. Higher values follow it more literally; too high looks harsh or oversaturated. Distilled models usually run at a CFG of 1. diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/intermediates.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/intermediates.mdx index bf2fc124698..6c0af95b930 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/intermediates.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/intermediates.mdx @@ -1,43 +1,76 @@ --- title: Intermediates description: Reclaim disk space by deleting the temporary images and videos generation leaves behind, per project and per account. +lastUpdated: 2026-10-26 sidebar: order: 7 --- -Generation produces intermediate images and videos: canvas composites, filter previews, staging candidates and other temporary files that were never saved to the gallery. The **Intermediates** section of Settings lists them per project, shows how much space deleting them would reclaim, and deletes them safely. It is also reachable from a project's actions menu, from the Users page (administrators) and from **Data & workspace**. +import { Steps } from '@astrojs/starlight/components'; +import Callout from '@components/annotated/Callout.astro'; +import IntermediatesSettingsSketch from '@components/annotated/IntermediatesSettingsSketch.astro'; -## What the page shows +Generation leaves temporary images and videos behind: canvas composites, filter previews, staging candidates and other files that never reached the gallery. These **intermediates** take up disk space. **Settings › Intermediates** shows how much space each project could reclaim and deletes only what nothing needs any more. -Each row is one of your projects, or **Unassigned** for intermediates without a known project. Deleted projects' leftovers appear under Unassigned. A row shows the project's cover, its **Used** count (intermediates a cleanup keeps) and its **Unused** count (intermediates nothing needs any more), with the measured reclaimable size underneath. Sizes are measured in the background after upgrading; the section says so while measurement is still running and never counts an unmeasured file as empty. +Open it from Settings, or from: -Select rows, or **Select all**, and press **Delete**. Select all covers every project the current search matches, including projects on other pages; untick a row to leave it out (up to 1,000 rows). A selection that matches more than 10,000 rows is refused; narrow the search. The confirmation explains why kept items are kept: referenced by a saved document, used by queued or running work, or created recently. +- a project's actions menu: **Delete intermediates…** opens the section filtered to that project. Remove the project tag (**Show all projects**) to see the others; +- **Data & workspace** in Settings: **Manage intermediates…**; +- the Users page (administrators): the brush button on an account's row opens the section on that account. -Administrators start on their own account. **Show everyone** expands the view to every account; they can search accounts as well as project names and can open one account from the Users page. Opening the section from a project action shows that project directly, even when it is outside the first results page. +## The Intermediates section -## Safe and force deletion + + Reloads the counts, and picks up a deletion that is running but not shown, for example one started in another tab. + Filters the rows by project name. Administrators can also search account names and emails. + Administrators only. The section starts on your own account; remove the filter (**Show everyone**) to list every account, and **My account** returns to yours. + Selects every row the search, account and project filters match, including rows on other pages; untick a row to leave it out. The text beside it estimates what a delete reclaims. + Opens the confirmation for the selected rows. It waits while a deletion is still running. + One row per project, with its cover, name and reclaimable size; with every account shown, the owner comes first. Files whose size is still being measured are counted separately, never as empty. + Intermediates a normal delete keeps: referenced by a saved document, used by queued or running work, held by an open editor, or created recently. + Intermediates nothing needs any more: what a normal delete removes. + Intermediates without a known project, marked **No project**. Leftovers of deleted projects appear here. + -A normal delete removes only safe items. It always keeps: +Rows are sorted by reclaimable size, 50 to a page. Sizes are measured in the background, for example after an upgrade; while that runs, a spinner shows beside **Delete** and the figures grow as measurement finishes. -- anything a pending, waiting or running job produced or uses as an input, including cached images and videos reused from an earlier job and completed child-workflow media until the root workflow finishes; -- anything created in the last 30 minutes, so an upload the browser is about to reference or enqueue is never collected, and cached media for 30 minutes after the job that reused it ends; -- anything the open webv2 editor still references, including unsaved edits and available undo history; the browser refreshes this temporary hold while it is open; -- anything a saved document still names: a project or library workflow (canvas staging candidates, cropped reference images), the legacy editor's saved canvas and reference images, or a project set aside for repair when project boards were introduced; -- anything that is no longer an intermediate. +## Clean up -The advanced option in the confirmation, **also delete items that saved projects or workflows still reference**, turns it into a force delete. The confirmation lists the documents it would break (the first 200, with a count of any others); those projects will show missing images and undo may no longer restore them. Confirming acknowledges exactly those documents: a document saved after the check keeps its media, while a document on the list may lose more of its references if it names further items while the deletion runs. A force delete never touches active, browser-held or recent work and cannot be undone; it asks for an acknowledgement and for the word `DELETE` to be typed. A regular user's force delete also keeps anything another account's document names, such as a shared workflow. The same applies when an administrator loses administrator rights while their force delete runs. A force delete that would break more than 10,000 documents is refused; narrow the selection. + -Every confirmation is based on a fresh server check that fixes the moment recency is judged at. Files created after the check are never included, and every batch re-checks its targets at the moment of deletion: an intermediate that was promoted, referenced or picked up by a job in the meantime is kept. Items that became unused after the check are collected too, so the deleted count can exceed the estimate. +1. Open **Settings › Intermediates**. -An open editor refreshes its browser hold every five minutes and when the tab becomes visible again. It releases names removed from the current projects and undo history, and releases the whole hold when the tab closes or you sign out. If the tab crashes instead, the hold expires 15 minutes after its last refresh. Each account holds media for at most eight open editors (tabs) at once; past that, the editor refreshed longest ago stops protecting its media until its next refresh. A single editor holds up to 50,000 images and 50,000 videos. Reopen an unsaved browser-only draft before deleting old intermediates. +2. Tick the projects to clean up, or **Select all**. -## Progress and results +3. Press **Delete**. The confirmation runs a fresh check on the server and shows how many images and videos it will delete, roughly how much space that frees, and how many in-use items it keeps and why. -Cleanup runs on the server, so you can navigate away while it works. The page reports deleted, kept and failed items separately, along with files whose records are gone but whose bytes could not be removed yet; those are purged on the next server start and are not counted as reclaimed. Reloading the page, or opening the section with no deletion shown, picks up a deletion that is still running. When a deletion stops early or leaves failed items, **Delete again** repeats the same request, in the same mode; the check runs afresh, so it picks up exactly what remains. +4. Press **Delete** in the confirmation. The deletion runs on the server, so you can leave the section; its progress shows above the list and picks up again when you come back or reload. -Running deletions live in the server's memory. If the server restarts while one runs, the section still showing it reports that its status is no longer known; after a reload the interrupted run is simply gone. Either way, refresh the list and delete again if anything remains. The server keeps each account's last few finished deletions until it restarts. +5. Read the result: **Deleted**, **Kept** (items that became needed after the check), **Failed** and **Reclaimed**. Files whose records are gone but whose bytes could not be removed yet show as **Awaiting removal from disk**; they are removed on the next server start and are not counted as reclaimed. -The server accepts one running deletion per account and at most eight at a time. Wait for one to finish before confirming another delete if a limit is reached. Cleanup is refused while [image storage maintenance](/users-guide/gallery/image-storage-maintenance/) is active. +6. If the deletion stopped early or some items failed, press **Delete again**. It opens the confirmation for the same selection and mode, with a fresh check, so it picks up exactly what remains. + + + +## What a normal delete always keeps + +| Kept | Why | +| --- | --- | +| Media a pending, waiting or running job produced or uses | Including cached images and videos a job reuses from an earlier job, and the results of finished steps of a workflow that is still running, until the whole workflow finishes. | +| Anything created in the last 30 minutes | An upload the browser is about to reference or enqueue must not disappear first. Cached media stays protected for 30 minutes after the job that reused it ends. | +| Media an open editor still uses | Unsaved edits and undo history in an open tab. The tab keeps a hold on them while it is open; see [Limits and edge cases](#limits-and-edge-cases). | +| Media a saved document names | Projects and their workspaces, library workflows (canvas staging candidates, cropped reference images), the legacy editor's saved canvas and reference images, and recovered projects set aside for repair when project boards were introduced. Only a force delete removes these. | +| Anything that is no longer an intermediate | It was saved to the gallery in the meantime. | + +The confirmation sums these up, for example "12 in-use items will be kept. 9 items referenced by saved documents and 3 items created recently." + +:::danger[Force delete breaks saved documents] +In the confirmation, **Also delete items that saved documents still reference (advanced)** turns the delete into a force delete. It also removes intermediates that saved projects, workflows, legacy editor state and recovered projects still name: those documents show missing images, and undo may no longer restore them. It cannot be undone. + +- The confirmation lists the documents that will lose references (the first 200, and a count of the rest). To confirm, tick the acknowledgement and type `DELETE`. +- It still keeps queued, running, browser-held and recent work. +- A regular user's force delete keeps anything another account's document names, such as a shared workflow. +::: ## Permissions @@ -48,10 +81,64 @@ The server accepts one running deletion per account and at most eight at a time. | Delete everyone's intermediates | No | Yes | | Force delete referenced intermediates | Own files that only your own documents name | Any authorized scope | -## Legacy endpoint +## Limits and edge cases + +
+Limits + +| Limit | Value | +| --- | --- | +| Recently created items, always kept | 30 minutes | +| A confirmation's check stays valid for | 10 minutes; after that, check again | +| Confirmations open at once, per account | 4; opening another makes the oldest one expire | +| Rows in one delete | Up to 1,000 ticked rows, or **Select all** with up to 1,000 rows left out | +| Rows **Select all** may match | 10,000; narrow the search | +| Documents one force delete may break | 10,000; narrow the selection | +| An open editor refreshes its hold | Every 5 minutes, and when its tab comes back to the foreground after missing a refresh | +| A hold after its tab crashes | Expires 15 minutes after its last refresh | +| Open editors (tabs) holding media, per account | 8; past that, the one refreshed longest ago stops protecting its media until its next refresh | +| Media one editor holds | 50,000 images and 50,000 videos | +| Running deletions | 1 per account, 8 on the server | +| Finished deletions the server remembers | Each account's last 5, until it restarts | + +
+ +
+What the check fixes, and what each batch re-checks + +The confirmation's check fixes the moment "recent" is judged at: files created after it are never included. Each batch then re-checks its items at the moment of deletion, so an intermediate that was saved to the gallery, referenced, or picked up by a job in the meantime is kept. Items that became unused after the check are deleted too, so the deleted count can exceed the estimate. + +A force delete acknowledges exactly the documents its confirmation listed. A document saved after the check keeps its media; a document on the list may lose further references if it names more items while the deletion runs. + +
+ +
+Open editors and browser-only drafts + +An open editor holds the media its unsaved content and undo history use. It releases names that leave the current projects and undo history, and releases the whole hold when the tab closes or you sign out. An unsaved draft that lives only in a closed tab is therefore not protected: reopen it before deleting old intermediates. + +
+ +
+When a deletion stops + +- A running deletion stops at the next batch if the server shuts down, if [image storage maintenance](/users-guide/gallery/image-storage-maintenance/) starts, or if the confirming account is deactivated. Cleanup is refused while that maintenance is active. +- An administrator who loses administrator rights during a deletion of other accounts' intermediates stops at the next batch. During a force delete of their own, they stop breaking other accounts' documents. +- Running deletions live in the server's memory. After a restart the section reports that the server no longer knows the deletion, or, after a reload, no longer shows it. Refresh the list and delete again if anything remains. +- One account runs one deletion at a time, and the server eight. If a limit is reached, wait for a deletion to finish before confirming another. + +
+ +
+Legacy endpoint + +The older `DELETE /api/v1/images/intermediates` route remains available to administrators. It applies the same rules as a normal delete across every account, so it keeps referenced, active and recent images, and returns the number of images it deleted. It does not touch videos. `GET /api/v1/images/intermediates` counts the images that route would delete, so the legacy button settles at zero after it runs; for a regular user it counts only their own. + +
-The older `DELETE /api/v1/images/intermediates` route remains available to administrators. It applies the same safety policy as a normal delete, so it keeps referenced, active and recent images and returns the number of images it deleted. It does not touch videos. `GET /api/v1/images/intermediates` counts exactly the images that route would delete, so the legacy button settles at zero after it runs. +
+Known limitations -## Limitations +- Canvas uploads made before the server accepted the project they belong to are stored without a project and are never re-associated with it. Saved references and editor holds still protect them, but they appear under **Unassigned**. -- Canvas uploads made before the server accepted the project they belong to are stored without a project and are never re-associated with it. They are still protected by saved references and browser holds, but they appear under **Unassigned**. +
diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/parameters.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/parameters.mdx index c7769ca777c..f214d6891fa 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/parameters.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/parameters.mdx @@ -18,7 +18,7 @@ Each model has recommended settings, which are shown as marks on the sliders and More steps refine an image up to a point; beyond it, an image changes little and generation only takes longer. -* **Distilled models** — Turbo, Lightning and Schnell variants, and models used with a Lightning or Turbo LoRA — are trained for a specific small number of steps, typically 4 to 10. Using many more does not help and can make results worse. +* **Distilled models** — Turbo, Lightning and Schnell variants, and models used with a Lightning or Turbo LoRA — are trained for a specific small number of steps, typically 4 to 9. Using many more does not help and can make results worse. * **Standard models** usually need 25 to 50 steps. SD 1.5 and SDXL work well at about 30. * To explore ideas quickly, generate a batch at fewer steps, then recall the seed of the image you like and generate it again at the full step count (see [Seeds](#seeds) below). diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/upscale.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/upscale.mdx index dd22730f4ea..4dde6575a0a 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/upscale.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/upscale.mdx @@ -1,92 +1,226 @@ --- title: Upscaling -lastUpdated: 2026-10-20 +description: Enlarge an image with an upscaler model, and optionally add new detail with a main model, from the Upscale panel or the gallery's image menu. +lastUpdated: 2026-10-26 sidebar: order: 8 --- -The **Upscale** panel enlarges an image. A Spandrel upscaling model (such as a Real-ESRGAN variant) enlarges it first. In **Refine** mode a main model then runs a short image-to-image pass over the result to invent detail that fits your prompt; in **Model only** mode the upscaler's result is the output. - -Open it from the left rail. It is available in the Compose, Edit and Video layouts. To upscale a gallery image without opening the panel, use its context menu (see [Upscaling from the image menu](#upscaling-from-the-image-menu)). +import { Card, CardGrid, Steps, TabItem, Tabs } from '@astrojs/starlight/components'; +import Callout from '@components/annotated/Callout.astro'; +import UpscalePanelSketch from '@components/annotated/UpscalePanelSketch.astro'; +import CompareImage from '@components/compare/CompareImage.astro'; +import ImageCompare from '@components/compare/ImageCompare.astro'; +import Pipeline from '@components/pipeline/Pipeline.astro'; +import PipelineBranch from '@components/pipeline/PipelineBranch.astro'; +import PipelineLane from '@components/pipeline/PipelineLane.astro'; +import PipelineStep from '@components/pipeline/PipelineStep.astro'; +import modelOnly from '../assets/upscale-model-only.webp'; +import refine from '../assets/upscale-refine.webp'; + +The **Upscale** panel enlarges an image. An upscaler model, run through Spandrel (such as a Real-ESRGAN variant), enlarges it first. In **Refine** mode a main model then runs a short image-to-image pass over the result to add detail that fits your prompt. + +Open the panel from the left region, where it sits behind Generate in the Compose and Edit layouts and behind Video in the Video layout. To upscale a gallery image without opening it, use the image menu (see [Upscaling from the image menu](#upscaling-from-the-image-menu)). + + + + + + + + + + + + + ## Refine or Model only -The **Mode** switch at the top of the panel picks how the image is upscaled. Projects open in **Refine**, including projects saved before the switch existed. +The **Mode** switch at the top of the panel picks how the image is upscaled. + + + + The upscaler, then a detail pass with a main model and your prompt. Adds new detail; needs a main model of a supported family. The default, also for projects saved before the switch existed. + + + The upscaler alone: no main model, prompt or detail pass. Faster and faithful to the source. Always queues once whatever **Iterations** says, because the upscaler gives the same image every time. + + + +Switching modes keeps your Refine settings, so switching back restores them. Model only does not need the model capability list Refine loads, so it works while that list is loading or could not be loaded. + + + + + A crop of the same 2× upscale of a 1024 × 1024 SDXL illustration with the RealESRGAN x4plus upscaler. Refine ran with the panel's defaults: the **Balanced** preset, 30 steps, CFG 2 and an SDXL Tile ControlNet. + + +## The Upscale panel + + + **Refine** or **Model only**. Model only hides everything below **Scale**. + Pick an image from the gallery, drag one in, or upload one. + The input size, **Scale**, output size and output area. Model only adds the upscaler's **Native scale**. From 50 megapixels the output is marked **Large output**: it takes much longer and needs more VRAM. + The upscaler. Choose one suited to your image: photography, illustration or linework. + The output size is the source size times this, from 1× to 16× in steps of 0.5. Default 4×. + + Refine only. + + - **Treatment preset** sets Creativity and Structure together: **Conservative** (−5, 5), **Balanced** (0, 0), **Creative** (5, −2) or **Artistic** (8, −5). + - **Creativity**, −10 to 10: higher values invent more detail and give the prompt more influence. + - **Structure**, −10 to 10: higher values keep the source composition more strictly. Shown only when a Tile ControlNet is in use. + + + Refine only, collapsed until you open them. + + - **Detail guidance**: the prompt, marked **Shared** because Generate uses the same project prompt. Describe the medium, texture and style rather than new content. + - **Generation**: the **Main model** for the detail pass, **Steps**, **CFG scale**, **Scheduler**, **Seed**, and concepts (LoRAs) that match the main model. + - **Advanced**: the Tile ControlNet and encoders the main model needs, **Tile size**, **Tile overlap**, **VAE**, **VAE precision**, and **CLIP skip** for SD1.5. + + + +The panel says what is missing before you can queue, for example "Select a compatible Tile or Union ControlNet." or "This model needs a T5 encoder selected." **Iterations** sits with the Invoke button in the top bar. -- **Refine** runs both passes: the upscaler, then the main model's detail pass. Everything below this section applies to it. -- **Model only** runs the upscaler alone, with no main model, prompt or detail pass. The panel keeps only the source image, the Spandrel model and **Scale**; the prompt, Generation and Advanced sections are hidden. Model only does not need the model capability list Refine loads, so it works while that list is loading or could not be loaded. +## What each model family needs -Switching modes keeps your Refine settings, so switching back restores them. A Model only run always queues once whatever the **Iterations** setting, because the upscaler gives the same image every time. +Refine works with **SD1.5**, **SDXL**, **FLUX.1**, **FLUX.2**, **Z-Image**, **Anima**, **Krea-2** and **Qwen Image** main models. + +| Family | Tile ControlNet | Negative prompt | Detail pass | +| --- | --- | --- | --- | +| SD1.5, SDXL | Required | Yes | Tile by tile | +| FLUX.1, FLUX.2 | – | – | Whole image | +| Z-Image | Optional | When CFG > 1 | Whole image | +| Anima, Krea-2, Qwen Image | – | When CFG > 1 | Whole image | + +Where a family has no Tile ControlNet, **Structure** is hidden. Where it ignores the negative prompt, the prompt is hidden and not recorded. + + + + - **Tile ControlNet**: required, a Tile or Union ControlNet of the same family, under **Advanced**. One is picked for you if installed. + - **CFG scale**: kept as you set it. It goes back to 2 when you switch from a family that sets its own. + - **Components**: taken from the main model. A **VAE** can replace the bundled one. + - **Scheduler**: any of the family's. + + + - **Tile ControlNet**: none. + - **CFG scale**: used as guidance, kept as you set it. It goes back to 2 when you switch from a family that sets its own. + - **Components**: a **T5 Encoder**, a **CLIP Embed** (CLIP-L) and a **VAE**, under **Advanced**. SDNQ pipelines bring their own. + - **Not supported**: FLUX Fill models. + + + - **Tile ControlNet**: none. + - **CFG scale**: used as guidance, and set when you pick the model: 3.5 for FLUX.2 [dev], 1 for Klein, which ignores it. At most 20. + - **Steps**: set when you pick the model: 4 for Klein 4B and 9B, 28 for Klein Base and [dev]. FLUX.2 runs all of **Steps** in the detail pass, so the panel's usual 30 would really run 30 times, more than seven times what Klein is distilled for. + - **Components**: Klein needs a **Qwen3 Encoder** of its size (4B for Klein 4B, 8B for Klein 9B), [dev] a **Mistral Encoder**, and both a FLUX.2 **VAE**. A Diffusers install brings its own; for Klein, a complete SDNQ install does too. + + + - **Tile ControlNet**: optional, a **Z-Image ControlNet Tile**, under **Advanced**. The Union ControlNet has no tile mode and is not offered. Adding it lets **Structure** pull the result back toward the enlarged image, at the cost of a second network's memory and time; the pass has no tile seams for it to hold together. + - **CFG scale**: set when you pick the model: 1 for Z-Image Turbo, 4 for Z-Image Base. + - **Components**: a **Qwen3 Encoder** (4B) and a FLUX **VAE**. A Diffusers or complete SDNQ install brings its own. + + + - **Tile ControlNet**: none. + - **CFG scale**: set when you pick the model: 4.5, or 6 for Anima-3.8B. + - **Components**: a **Qwen3 Encoder** (0.6B) and a **VAE** (the Wan 2.1 VAE, installed as an Anima, Qwen Image or Wan VAE). Anima-3.8B also needs a **Qwen3.5 Encoder**. + + + - **Tile ControlNet**: none. + - **CFG scale**: set when you pick the model: 1 for Turbo, 5.5 for Raw. + - **Components**: a **Qwen3-VL Encoder** (4B) and a **VAE** (the Wan 2.1 VAE, installed as a Qwen Image or Anima VAE). A Diffusers install brings its own. + + + - **Tile ControlNet**: none. + - **CFG scale**: set when you pick the model: 4. + - **Components**: a **Qwen VL Encoder** and a **VAE** (the Wan 2.1 VAE, installed as a Qwen Image or Anima VAE). A Diffusers install brings its own. + - **Not supported**: Qwen Image Edit models. Without a reference image they would carry a blank one the size of the output through every step, for about 2.5 to 3 times the time and nothing gained. + + + +A value "set when you pick the model" is the model's default setting, which you can change in the Model Manager. Switching from FLUX.2 to another family sets **Steps** back to 30. -### Native scale +:::note[Output size] +FLUX.1, FLUX.2, Z-Image, Krea-2 and Qwen Image need sizes that are a multiple of 16. When the enlarged image is not, the panel shrinks it slightly to the nearest fitting size before the detail pass: 2040 px becomes 2032 px. Other families, and Model only, round down to a multiple of 8. +::: -Each upscaler has a native scale, such as 4× for 4x-UltraSharp. In Model only mode the panel shows a **Native scale** row when the upscaler's native scale is known, and explains how it reaches your **Scale**: +## Creativity per family -- the upscaler runs until the image is at least the Scale you asked for, then resizes it down to that size, for example "Runs 4× once, then resizes to 2×" or "Runs 2× 2 times"; -- a 1× model (a restoration model) keeps the input size, so Scale has no effect. +**Creativity** sets how far down the noise schedule the detail pass starts: the higher, the noisier the start and the more the main model redraws. The panel offers only schedulers that start where Creativity puts the pass, and hides the field where only one is left. -The output size is rounded down to a multiple of 8. +
+Schedulers and steps for each family -Upscalers record their native scale when they are installed. Upscalers installed with an earlier version have no recorded scale until you re-identify them in the Model Manager; until then Model only shows no Native scale row, and a note under the Spandrel model says the native scale is unknown. Users who manage models can open the upscaler in the Model Manager from that note. +| Family | Schedulers | Steps the pass runs | +| --- | --- | --- | +| SD1.5, SDXL | All of the family's | The part of **Steps** after the start: about half at **Balanced** | +| FLUX.1 | Euler, Heun | The part of **Steps** after the start: **Balanced** at 30 steps runs about the last 8 from 1024 px up | +| FLUX.2 | Euler only | Starts where FLUX.1 does, then spreads all of **Steps** over the rest of the pass, so 30 steps really run 30 times | +| Z-Image | Euler, Heun | Starts at FLUX.1's noise level; **Balanced** at 30 steps on a 2048 px result runs the last 7 | +| Anima | Euler, DPM++ 2M, DPM++ 2M SDE, ER-SDE | As Z-Image; the last 8 | +| Krea-2 | Euler only | As Z-Image; the last 7. Turbo and Raw both keep your **Steps**: Turbo, distilled for 8 steps, runs the end of a 30-step schedule | +| Qwen Image | Euler only | As Z-Image; the last 9 | -To record it, open the upscaler's page in the Model Manager. A notice there says its native scale is unknown, and users who manage models can press **Re-identify model** and confirm; Invoke re-probes the file and the page then shows its native scale. Re-identifying also resets the model's custom variant and default settings. Other users are asked to contact an administrator. +Z-Image, Anima, Krea-2 and Qwen Image count the start in steps rather than in noise level, so the panel translates Creativity for them: it starts on the step whose noise level is closest to where FLUX.1 would start. A preset therefore means the same on every family. These families need at least 2 steps. -The result's metadata is the source image's metadata plus the upscaler and the Scale, so **Recall** on the result restores how the source was made. +LCM builds its own noise schedule, so it would treat the enlarged image as pure noise. Anima's Heun starts below Creativity's point and leaves noise in the image. -## Setting up an upscale +
-The panel is split into sections, top to bottom: +## Memory -- **Source & treatment** - - **Source image**: pick an image from the gallery, drag one in, or upload one. - - **Spandrel model**: the first-pass upscaler. Choose one suited to your image: photography, illustration or linework. - - **Scale**: the output size is the source size multiplied by this value. The panel shows the resulting input and output size and warns when the output is large enough to need much more time and VRAM. - - **Treatment preset**, or **Creativity** and **Structure** directly. Higher Creativity invents more detail and gives the prompt more influence. Higher Structure keeps the source composition more strictly; it is only shown when a Tile ControlNet is in use. -- **Detail guidance**: the prompt. It is the project prompt shared with Generate. For upscaling, describe the medium, texture and style rather than new content. -- **Generation**: the **Main model** for the detail pass (**SD1.5**, **SDXL**, **FLUX.1**, **FLUX.2**, **Z-Image**, **Anima**, **Krea-2** or **Qwen Image**), steps, CFG scale, scheduler, seed, and LoRAs that match the main model. The scheduler list only holds schedulers that work for upscaling (see below). -- **Advanced**: the components the main model needs (see below), tile size and tile overlap, VAE and VAE precision, and CLIP skip for SD1.5. +| | SD1.5, SDXL | Other families | +| --- | --- | --- | +| Detail pass | In tiles, so the output can be much larger than the model generates in one go | Over the whole image at once: memory grows with the output size | +| **Tile size** | The detail pass's tiles: larger tiles can improve continuity but need more VRAM | Only the VAE encode and decode tiles (Anima's decode picks its own) | +| **Tile overlap** | More overlap reduces seams but costs time and memory | No effect | -## What each model family needs +Encoding and decoding the enlarged image with the VAE runs in tiles for every family. If you still run out of memory, see [Low-VRAM mode](/configuration/optimization/low-vram-mode/). -| | SD1.5 / SDXL | FLUX.1 | FLUX.2 | Z-Image | Anima | Krea-2 | Qwen Image | -|---|---|---|---|---|---|---|---| -| Tile ControlNet | Required: a Tile or Union ControlNet for the same family, under **Advanced** | Not used; the Structure slider is hidden | Not used; the Structure slider is hidden | Optional: **Z-Image ControlNet Tile**, under **Advanced**. The Union ControlNet has no tile mode and is not offered | Not used; the Structure slider is hidden | Not used; the Structure slider is hidden | Not used; the Structure slider is hidden | -| Negative prompt | Available | Not used; FLUX.1 ignores it, so it is hidden and not recorded | Not used; FLUX.2 ignores it, so it is hidden and not recorded | Used only when CFG is greater than 1 | Used only when CFG is greater than 1 | Used only when CFG is greater than 1 | Used only when CFG is greater than 1 | -| CFG scale | Kept as you set it; back to 2 when you switch from a family that sets its own | Kept as you set it, and used as guidance; back to 2 when you switch from a family that sets its own | Set to the model's default when you pick the model, and used as guidance: 3.5 for FLUX.2 [dev], 1 for Klein, which ignores it | Set to the model's default when you pick the model: 1 for Z-Image Turbo, 4 for Z-Image Base | Set to the model's default when you pick the model: 4.5, or 6 for Anima-3.8B | Set to the model's default when you pick the model: 1 for Turbo, 5.5 for Raw | Set to the model's default when you pick the model: 4 | -| Other components | Taken from the main model | Every FLUX.1 model except an SDNQ pipeline needs a **T5 Encoder**, **CLIP Embed** and **VAE**, all under **Advanced**. SDNQ pipelines bring their own. FLUX Fill models cannot be used | Klein needs a **Qwen3 Encoder** of its size (the 4B for Klein 4B, the 8B for Klein 9B), [dev] a **Mistral Encoder**, and both a FLUX.2 **VAE**, unless the model is a Diffusers install that brings its own; for Klein a complete SDNQ install does too | A **Qwen3 Encoder** (the 4B) and a FLUX **VAE**, unless the model is a Diffusers or complete SDNQ install that brings its own | A **Qwen3 Encoder** (the 0.6B) and a Qwen-Image **VAE**; Anima-3.8B also a **Qwen3.5 Encoder** | A **Qwen3-VL Encoder** (the 4B) and a Qwen-Image **VAE**, unless the model is a Diffusers install that brings its own | A **Qwen VL Encoder** and a Qwen-Image **VAE**, unless the model is a Diffusers install that brings its own. Qwen Image Edit models cannot be used: without a reference image they would carry a blank one the size of the output through every step, for about 2.5 to 3 times the time and nothing gained | -| Detail pass | Tile by tile | One pass over the whole image | One pass over the whole image | One pass over the whole image | One pass over the whole image | One pass over the whole image | One pass over the whole image | +## Native scale -The panel says what is missing before you can queue, for example "Select a compatible Tile or Union ControlNet." or "This model needs a T5 encoder selected." +Each upscaler has a native scale, such as 4× for 4x-UltraSharp. In Model only mode the upscaler runs until the image is at least the **Scale** you asked for, then resizes it down to that size. The size preview shows the **Native scale** and a note on how it gets there: -:::note[Output size] -FLUX.1, FLUX.2, Z-Image, Krea-2 and Qwen Image need sizes that are a multiple of 16. When the enlarged image is not, the panel shrinks it slightly to the nearest fitting size before the detail pass — for example 2040 px becomes 2032 px. -::: +| Scale | 2× upscaler | 4× upscaler | +| --- | --- | --- | +| 1.5× | Runs 2× once, then resizes to 1.5× | Runs 4× once, then resizes to 1.5× | +| 2× | Runs 2× once | Runs 4× once, then resizes to 2× | +| 4× | Runs 2× 2 times | Runs 4× once | +| 8× | Runs 2× 3 times | Runs 4× 2 times, then resizes to 8× | -### Creativity on Z-Image, Anima, Krea-2 and Qwen Image +When **Scale** equals the native scale the note is left out. A 1× model (a restoration model) keeps the input size, so **Scale** has no effect. In Refine mode the panel shows no native scale. FLUX.1, FLUX.2, Z-Image, Anima, Krea-2 and Qwen Image resize the upscaler's result to **Scale** before the detail pass; SD1.5 and SDXL keep the upscaler's size, so with a 1× model their result stays at the source size. -Z-Image, Anima, Krea-2 and Qwen Image count the start of the detail pass in steps rather than in noise level, so the same Creativity setting would add much more noise there than on FLUX.1. The panel translates Creativity for them: it starts the pass on the step whose noise level is closest to where FLUX.1 would start. A preset therefore means the same thing on every family, and the pass runs fewer steps than the Steps setting suggests. For example, **Balanced** at 30 steps on a 2048 px result runs the last 7 to 9, and each needs at least 2 steps. Krea-2 Turbo and Raw both keep your Steps setting: Turbo, distilled for 8 steps, then runs the end of a 30-step schedule, and Raw, the undistilled model, gets the steps it needs. +A Model only result's metadata is the source image's metadata plus the upscaler and the **Scale**, so **Recall** on it restores how the source was made. -FLUX.2 starts where FLUX.1 does and then spreads all of your Steps over the rest of the pass, so 30 steps run 30 times. Picking a FLUX.2 model therefore sets its own step count: 4 for Klein 4B and 9B, 28 for Klein Base and [dev]. Switching to another family afterwards sets Steps back to 30. +### Upscalers with an unknown native scale -The panel offers only schedulers that start where Creativity puts the pass: **Euler** and **Heun** on FLUX.1 and Z-Image, and **Euler**, **DPM++ 2M**, **DPM++ 2M SDE** and **ER-SDE** on Anima. LCM builds its own noise schedule, so it would treat the enlarged image as pure noise. Anima's Heun starts below that point and leaves noise in the image. FLUX.2, Krea-2 and Qwen Image always run **Euler**, so they show no scheduler. +Upscalers record their native scale when they are installed. One installed with an earlier version has none until it is re-identified: Model only then shows no **Native scale** row, and a note under the Spandrel model says the native scale is unknown. To record it: -The Z-Image Tile ControlNet is optional because the detail pass runs over the whole image at once: there are no tile seams for it to hold together. Adding it lets **Structure** pull the result back toward the enlarged image, at the cost of a second network's memory and time. + -## Memory +1. Under the Spandrel model, press **Open in Model Manager**, or open the upscaler's page there yourself. -For SD1.5 and SDXL the detail pass works in tiles, so the output can be much larger than what the model generates in one go. Larger tiles can improve continuity but need more VRAM; more tile overlap reduces seams but costs time and memory. +2. Press **Re-identify model** and confirm. Invoke re-probes the file, and the page then shows the native scale. Re-identifying also resets the model's custom variant and default settings. -For FLUX.1, FLUX.2, Z-Image, Anima, Krea-2 and Qwen Image the detail pass runs over the whole image at once, so its memory use grows with the output size. There, **Tile size** only sets the tiles for the VAE encode and decode, and **Tile overlap** has no effect. Anima's decode picks its own tiles. + -For all model families, encoding and decoding the enlarged image with the VAE runs in tiles. See [Low-VRAM mode](/configuration/optimization/low-vram-mode/) if you still run out of memory. +Only users who manage models can re-identify; others are asked to contact an administrator. ## Upscaling from the image menu Right-click an image in the gallery and open **Upscale**: - **Send to Upscale** opens the Upscale panel with the image as its source. -- Under **Upscale now, model only**, each installed upscaler is listed by name with its native scale when it is known. Each one opens a list of factors: 1.5×, 2× and its native scale, marked "(native)". A factor offered twice is listed once, so a 2× upscaler offers 1.5× and 2×, and a 4× upscaler offers 1.5×, 2× and 4×. A 1× (restoration) model offers only 1×. Picking a factor queues a Model only upscale of the image straight away, without changing the Upscale panel or the current Invoke settings. Every factor runs as Model only mode runs it at that Scale, so the result has the same size as one from the Upscale panel (see [Native scale](#native-scale)): the native scale runs the upscaler once, and other factors resize the result to the factor. - -When no upscaler is installed, the model-only entry is disabled. An upscaler whose native scale is unknown offers **Native scale**, which runs it once at whatever size it produces, and a disabled **Other factors** entry with a note to re-identify it in the Model Manager. For other factors, or a fractional Scale such as 2.5×, use **Send to Upscale**. +- **Upscale now, model only** lists each installed upscaler by name, with its native scale when it is known. Picking one of its factors queues a Model only upscale straight away, without changing the Upscale panel or the current Invoke settings. + +| Upscaler's native scale | Factors offered | +| --- | --- | +| 1× (restoration) | 1× | +| 2× | 1.5×, 2× (native) | +| 3× | 1.5×, 2×, 3× (native) | +| 4× | 1.5×, 2×, 4× (native) | +| 8× | 1.5×, 2×, 8× (native) | +| Unknown | **Native scale**, which runs it once at whatever size it produces; **Other factors** is disabled with a note to re-identify it | + +Each factor runs as Model only runs it at that **Scale** (see [Native scale](#native-scale)), so the result has the same size as one from the panel. For other factors, or a fractional Scale such as 2.5×, use **Send to Upscale**. When no upscaler is installed, the entry is disabled. Results go to the gallery's auto-add board, wherever Invoke is currently set to send results. Their metadata is the source image's metadata plus the upscaler and its scale. If the source image's metadata cannot be read, nothing is queued and a notification says so. diff --git a/docs/src/content/docs/Users Guide/05.Video Generation/ltx-2.mdx b/docs/src/content/docs/Users Guide/05.Video Generation/ltx-2.mdx index 29d92d8b2a9..840f657e83a 100644 --- a/docs/src/content/docs/Users Guide/05.Video Generation/ltx-2.mdx +++ b/docs/src/content/docs/Users Guide/05.Video Generation/ltx-2.mdx @@ -22,7 +22,7 @@ What to know in practice: * **Image-to-video re-compresses your frame** as a single H.264 frame before encoding it. That is deliberate: the model was trained on frames that had been through a video codec, and a pristine PNG is far enough out of that distribution that the clip visibly drifts away from it over the first second. * **The negative prompt** only reaches the model through classifier-free guidance, so it is available on Dev and hidden on Distilled. It is pre-filled with the release's own default, a long list of artifact and audio-defect terms. -#### Advanced guidance (Dev only) +## Advanced guidance (Dev only) Beyond the usual **CFG**, LTX-2 exposes three more terms in a collapsed **Advanced guidance** section. Each one costs an extra model evaluation per step, so turning one off is a real saving: @@ -30,7 +30,7 @@ Beyond the usual **CFG**, LTX-2 exposes three more terms in a collapsed **Advanc * **STG** (default 1) — spatio-temporal guidance. Steers away from a pass with one attention block skipped, which sharpens motion. 0 turns it off. * **Modality guidance** (default 3) — steers away from a pass with the audio/video cross-attention disabled, tightening how well the two streams agree. 1 turns it off. -#### Two-stage presets +## Two-stage presets The **1024p** and **1536p** target resolutions are *two-stage*. LTX-2 generates at half the canvas, doubles the latent with a dedicated x2 upscaler, then runs a second, shorter denoise over it. The upscaler alone produces a soft latent; the refine pass is what resolves it, so the output is larger without going soft. The soundtrack skips the upscaler, having no spatial extent, but is carried through the second pass so the two streams stay on one trajectory. @@ -38,17 +38,17 @@ The refine pass is the expensive half: it runs at four times the base pass's tok A first frame is anchored on both passes, encoded once at each canvas. The refine pass re-noises every frame including the first, so an anchor that was only applied to the base pass would not survive into the output. The same holds for the opening of an extension. A [conditioning clip](#conditioning-clip), which holds a whole stream fixed, cannot run two-stage at all. -#### The Distilled LoRA (fast path on Dev) +## The Distilled LoRA (fast path on Dev) The **LTX-2.5 Distilled LoRA** turns the Dev transformer into an eight-step, guidance-free model like the Distilled build. With it installed, the Dev model's **Distilled** toggle defaults on: it adds the LoRA, sets eight steps and turns every guidance scale off, so each step is one model evaluation instead of four. Turn it off to return to the guided 30-step recipe. Why use it rather than the Distilled transformer? It keeps a single ~19 GB transformer on disk that can run either way. The LoRA is large for a LoRA (~8.9 GB) because distillation changes the whole network. The toggle is only offered on Dev: the Distilled build already is that model. -#### Last frames and keyframes +## Last frames and keyframes A **Last Frame** works alone or with a first frame, as on MiniMax H3. Unlike a first frame, which directly replaces the clip's opening, a last frame is added to the model's input as an extra *keyframe* positioned at the clip's final frame, and the generated frames are steered toward it. A last frame can also be combined with an **Initial Video** to steer where an extension lands. -#### Conditioning Clip +## Conditioning Clip With an LTX-2 model selected, the panel offers a **Conditioning Clip** slot. Drop a clip and choose, under **Use from this clip**, which half of it the model is given — it generates the other half: @@ -67,7 +67,7 @@ A few rules follow from how it works: Describe the scene you want in the prompt either way; for audio-to-video, saying who or what is making the sound ("a woman singing to the camera") is what lets the model tie picture to audio. -#### Prompt Enhancement +## Prompt Enhancement InvokeAI ships with an optional LTX-2 prompt enhancement model designed specifically to produce improved results with this video @@ -80,7 +80,7 @@ Text-to-Video". This will expand the prompt by adding cinematic instructions and colorful detail. You may edit the resulting prompt as you wish. -#### Auto duration +## Auto duration LTX-2.5 ships with a small **duration head** that reads your prompt, as the model will see it, and predicts how long the shot it describes should run. With a duration head in the **Duration head** slot of **Model components**, an **Auto duration** switch appears above Frames: @@ -92,7 +92,7 @@ LTX-2.5 ships with a small **duration head** that reads your prompt, as the mode The duration head is a separate, optional 3.6 MB install (**LTX-2.5 Duration Head** in the starter models). It is fetched from Lightricks' own repository, which is license-gated: the install needs a Hugging Face token from an account that has accepted the LTX-2.5 license. Once installed it is selected automatically; the switch stays off until you turn it on. -#### Extending with LTX-2 +## Extending with LTX-2 LTX-2 continues a clip from its **closing frames and closing sound**, not from a single still. The final *Context Frames* of the trimmed source (default **17**, about 0.7 s at 24 fps) are held fixed at the start of the new clip, picture and audio together, so the model sees the motion and sound it is continuing. The same span is then cross-faded to join the new clip to the source, so the join blends two copies of the same moment rather than two different ones. @@ -104,7 +104,7 @@ Context comes out of the frame budget: the extension adds **Frames − Context F Context Frames steps along the same 8·n + 1 grid as Frames (9, 17, 25, 33 …). -#### Audio-to-video and video-to-audio +## Audio-to-video and video-to-audio LTX-2 generates picture and soundtrack as one sequence, so it can hold either stream fixed and generate only the other — the same mechanism a first frame uses to hold one image fixed, applied to a whole stream. The panel exposes this as the [Conditioning Clip](#conditioning-clip): @@ -113,13 +113,7 @@ LTX-2 generates picture and soundtrack as one sequence, so it can hold either st Both run on the single-pass canvases only. Guidance applies as usual on Dev; in particular **Audio CFG** is the one that steers a video-to-audio soundtrack toward the prompt. -#### License - -LTX-2.5 is released under the **LTX-2.x Community License**: free for commercial and production use by entities with less than $10M in annual revenue, above which a paid commercial agreement is needed. The optional Duration Head and NVFP4 Distilled transformer come from Lightricks' gated repositories (see [Installing LTX-2](#installing-ltx-2)). The Gemma-4 E2B prompt enhancer is Apache 2.0. - -This is a summary, not legal advice. Read the full license on the model's HuggingFace page before using a model commercially. - -#### Installing LTX-2 +## Installing LTX-2 The **LTX-2.5** starter bundle installs the working set: @@ -135,7 +129,7 @@ Available separately in the starter models list: * **LTX-2.5 Text Encoder (Gemma-4 12B, GGUF Q4_K_M)** (~9.2 GB) — a community GGUF build of the encoder for the **Gemma-4 encoder** slot, lighter than int8 and further from the unquantized encoder (see the GGUF text encoders note below). * **LTX-2.5 Distilled Transformer (NVFP4)** (~18.7 GB) — Lightricks' official 4-bit build of the Distilled transformer, an alternative to the int8 one with the same eight-step schedule. It is barely smaller on disk than the int8 build (the last six transformer blocks and some projections are kept in bf16), so on a 16 GB card it still runs partially loaded — but a larger share of it fits on the card, which makes it the faster choice there: on an RTX 5060 Ti (16 GB) at 512×512×161 frames it ran at 6.5 s/step against 9.8 s/step for the int8 build. The 4-bit weights are unpacked layer by layer, so any GPU can run it; with enough VRAM to hold either build entirely, expect no speed advantage. License-gated: needs a Hugging Face token whose account has accepted the LTX-2.5 license. There is no NVFP4 build of the Dev transformer. * **LTX-2.5 Duration Head** (3.6 MB) — enables [Auto duration](#auto-duration). License-gated: needs a Hugging Face token whose account has accepted the LTX-2.5 license. -* **LTX-2.5 Prompt Enhancer (Gemma-4 E2B) (~10 GB) - Enables prompt enhancement. +* **LTX-2.5 Prompt Enhancer (Gemma-4 E2B)** (~10 GB) — enables [prompt enhancement](#prompt-enhancement). **GGUF builds.** Community GGUF conversions of the LTX-2.5 transformer, such as `vantagewithai/LTX-2.5-GGUF` (Dev and Distilled; its Q4_K_M builds are in the starter models list), `Abiray/LTX-2.5-Distilled-GGUF` or `realrebelai/LTX-2.5_GGUFs`, run like the int8 single files: select one in the Video panel, with the LTX-2.5 Components in the **Model components** slot and the Gemma-4 encoder in its slot. The text connectors each build carries beside the transformer are not used; the components folder supplies them. Dev or Distilled is read from the file name, so keep the published names, or set **Variant** in the model's **Edit** view after renaming a file. @@ -149,9 +143,8 @@ LTX-2 does not condition on the last hidden state of its text encoder the way mo Measured on a 48 GB card at 1248×704×121 frames, the int8 Dev transformer is resident at ~18 GiB and the denoise working set peaks at ~2.8 GiB, with all four guidance passes running. The Distilled build at the same size needs the same memory and about a fifteenth of the time. -:::caution[License] -LTX-2.5 is distributed under the **LTX-2.x Community License**, which is free for entities under $10M in annual revenue. Review the terms at [huggingface.co/Lightricks/LTX-2.5](https://huggingface.co/Lightricks/LTX-2.5) before installing. -::: +## License ---- +LTX-2.5 is released under the **LTX-2.x Community License**: free for commercial and production use by entities with less than $10M in annual revenue, above which a paid commercial agreement is needed. The optional Duration Head and NVFP4 Distilled transformer come from Lightricks' gated repositories (see [Installing LTX-2](#installing-ltx-2)). The Gemma-4 E2B prompt enhancer is Apache 2.0. +This is a summary, not legal advice. Read the full license on the model's Hugging Face page, [huggingface.co/Lightricks/LTX-2.5](https://huggingface.co/Lightricks/LTX-2.5), before using a model commercially. diff --git a/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx b/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx index ec8d93f46b3..1eaf48638bf 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx @@ -1,6 +1,7 @@ --- title: Image Storage Maintenance -description: Move existing images into the current image subfolder strategy with crash recovery. +description: Move existing images into the current folder layout, and clean up gallery records and image files that no longer match. +lastUpdated: 2026-10-26 --- InvokeAI can move existing images into the folder layout selected by [`image_subfolder_strategy`](/configuration/invokeai-yaml/#image-subfolder-strategy). This is useful after changing the image subfolder strategy from `flat` to `date`, `type`, or `hash`, or when reorganizing an older image library. @@ -38,3 +39,22 @@ Missing intermediate image files are treated as already cleaned up and do not ha For the `date` strategy, existing images are moved according to their original image creation timestamp stored in the database, not according to the time the maintenance job is run. Empty source directories left behind by successful moves are removed when safe. + +## Gallery maintenance + +Gallery maintenance repairs mismatches between the gallery's records and the image files on disk, for example after files were deleted by hand, or images were left behind by a [deleted user](/configuration/multi-user-mode/admin-guide/#deleting-a-user). Administrators open it under **Settings → Data & workspace → Gallery maintenance**. + +| Action | What it does | +| --- | --- | +| **Remove records for missing images** | Deletes gallery records whose image file is missing, with their image-search (semantic index) entries. Thumbnails left behind go to the archive. | +| **Archive untracked files** | Moves image files that have no gallery record, and thumbnails without an image, to the archive. | +| **Regenerate missing thumbnails** | Creates missing thumbnails. Original images are unchanged. | + +Each action scans first and shows what it would change; nothing changes until you confirm, and the dialog waits until the action finishes. If the gallery changed since the scan, InvokeAI asks you to scan again. The actions cover images of every account, including images on archived boards and intermediates. They do not cover videos. + +Before an action changes anything, it backs up the database, and the two archiving actions create an archive folder. The result shows both paths: + +- **Database backup:** `databases/backup/backup--gallery-maintenance-.db`. With a [MySQL or MariaDB database](/configuration/external-database/) no backup is made; back it up with the server's tools first. +- **Archive** (**Remove records for missing images** and **Archive untracked files** only): a new folder per run, `outputs/images-archive/gallery-maintenance/-/`. + +Keep both until you are sure you will not need the removed records or archived files. Maintenance does not import archived files back into the gallery. diff --git a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx index d83663f0917..fa548855716 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx @@ -96,7 +96,7 @@ Days can be written as `YYYY-MM-DD`, `today`, `yesterday`, or relative to today: ### Searching by content -When image indexing is turned on, the Gallery can also search by what media *shows*, whatever the prompt said: find "sunset on the beach" even in images that were never prompted with those words. This **semantic search** uses the same index as the [Image Map](/users-guide/image-map/introduction/). To turn indexing on, install the `DFN2B-CLIP-ViT-L-14-39B` model from the starter models and restart the backend; see [Activating the Image Map](/users-guide/image-map/introduction/#activating-the-image-map). When indexing is turned off, the semantic search button is not shown. +When image indexing is turned on, the Gallery can also search by what media *shows*, whatever the prompt said: find "sunset on the beach" even in images that were never prompted with those words. This **semantic search** uses the same index as the [Image Map](/users-guide/image-map/introduction/). To start indexing, install the `DFN2B-CLIP-ViT-L-14-39B` model from the starter models; no restart is needed. See [Activating the Image Map](/users-guide/image-map/introduction/#activating-the-image-map). When indexing is turned off, the semantic search button is not shown. A semantic search for "a wizard holding a torch", with the most similar images first. diff --git a/docs/src/content/docs/Users Guide/07.Canvas/01.introduction.mdx b/docs/src/content/docs/Users Guide/07.Canvas/01.introduction.mdx index b4c258b6586..7c7bbc103ba 100644 --- a/docs/src/content/docs/Users Guide/07.Canvas/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/07.Canvas/01.introduction.mdx @@ -18,7 +18,7 @@ The **Canvas** is where you build and refine an image in layers. You can paint, The Canvas lives in the **Edit** layout (**Alt+2**): the Canvas is in the center, the **Generate** panel on the left supplies the model, prompt and settings, and the **Layers** panel on the right lists the canvas's layers and their settings. -The Edit layout: the Generate panel on the left, the Canvas with a mountain landscape in the center, and the Layers panel with its color picker, layer list and Properties on the right. +The Edit layout: the Generate panel on the left, the Canvas with a glowing jellyfish over a city at dusk in the center, its inpaint masks and regional guidance areas visible, and the Layers panel with its color picker, layer list and Properties on the right. This page introduces the main ideas and tasks. The [Canvas guide](/users-guide/canvas/canvas-guide/) and the [Layers panel guide](/users-guide/canvas/layers-panel/) describe every tool, menu and setting. diff --git a/docs/src/content/docs/Users Guide/08.Workflows/community-nodes.mdx b/docs/src/content/docs/Users Guide/08.Workflows/community-nodes.mdx index 42eeccac107..17dfa804a72 100644 --- a/docs/src/content/docs/Users Guide/08.Workflows/community-nodes.mdx +++ b/docs/src/content/docs/Users Guide/08.Workflows/community-nodes.mdx @@ -1,717 +1,81 @@ --- title: Community Nodes +description: Node packs written by the InvokeAI community, and how to install them with the Nodes manager or Git. +lastUpdated: 2026-10-26 --- -These are nodes that have been developed by the community, for the community. If you're not sure what a node is, you can learn more about nodes [here](/users-guide/workflows/introduction/). +import { Steps, TabItem, Tabs } from '@astrojs/starlight/components'; +import CommunityNodes from '@components/CommunityNodes.astro'; -If you'd like to submit a node for the community, please refer to the [node creation overview](/development/guides/creating-nodes/). +Node packs add new nodes to the [Workflow Editor](/users-guide/workflows/introduction/). The packs below are written +and maintained by members of the community. -To use a node, add the node to the `nodes` folder found in your InvokeAI install location. +:::caution[Only install packs you trust] +A node pack runs Python code on your machine with InvokeAI's permissions. Invoke doesn't review or guarantee community +packs. Report problems with a pack to its author, on its repository. +::: -The suggested method is to use `git clone` to clone the repository the node is found in. This allows for easy updates of the node in the future. +## Install a node pack -If you'd prefer, you can also just download the whole node folder from the linked repository and add it to the `nodes` folder. + + -To use a community workflow, download the `.json` node graph file and load it into Invoke AI via the **Load Workflow** button in the Workflow Editor. + ---- - -### Adapters Linked Nodes - -**Description:** A set of nodes for linked adapters (ControlNet, IP-Adaptor & T2I-Adapter). This allows multiple adapters to be chained together without using a `collect` node which means it can be used inside an `iterate` node without any collecting on every iteration issues. - -- `ControlNet-Linked` - Collects ControlNet info to pass to other nodes. -- `IP-Adapter-Linked` - Collects IP-Adapter info to pass to other nodes. -- `T2I-Adapter-Linked` - Collects T2I-Adapter info to pass to other nodes. - -Note: These are inherited from the core nodes so any update to the core nodes should be reflected in these. - -**Node Link:** https://github.com/skunkworxdark/adapters-linked-nodes - ---- - -### Autostereogram Nodes - -**Description:** Generate autostereogram images from a depth map. This is not a very practically useful node but more a 90s nostalgic indulgence as I used to love these images as a kid. - -**Node Link:** https://github.com/skunkworxdark/autostereogram_nodes - -**Example Usage:** - - -> -> - ---- - -### Average Images - -**Description:** This node takes in a collection of images of the same size and averages them as output. It converts everything to RGB mode first. - -**Node Link:** https://github.com/JPPhoto/average-images-node - ---- - -### BiRefNet Background Removal - -**Description:** Remove image backgrounds using BiRefNet (Bilateral Reference Network), a high-quality segmentation model. Supports multiple model variants including standard, high-resolution, matting, portrait, and specialized models for different use cases. - -**Node Link:** https://github.com/veeliks/invoke_birefnet - -**Output Examples** - -
- Before background removal - After background removal -
- ---- - -### Cinematic Effects Pack - -**Description:** A set of nodes for anamorphic lens effects, depth-based bokeh, tone curves, color cast correction, and film grain. - -**Node Link:** https://github.com/JPPhoto/cinematic-effects-pack - -**Installation note:** This pack replaces [Anamorphic Tools](https://github.com/JPPhoto/anamorphic-tools), [Curves](https://github.com/JPPhoto/curves-node), [Film Grain](https://github.com/JPPhoto/film-grain-node), and [Color Correction Nodes](https://github.com/JPPhoto/color-correction-nodes). Remove the old packs from your `nodes` folder before installing this pack; loading them together may register duplicate invocation IDs. - ---- - -### Clean Image Artifacts After Cut - -Description: Removes residual artifacts after an image is separated from its background. - -Node Link: https://github.com/VeyDlin/clean-artifact-after-cut-node - -View: - - - ---- - -### Close Color Mask - -Description: Generates a mask for images based on a closely matching color, useful for color-based selections. - -Node Link: https://github.com/VeyDlin/close-color-mask-node - -View: - - - ---- - -### Clothing Mask - -Description: Employs a U2NET neural network trained for the segmentation of clothing items in images. - -Node Link: https://github.com/VeyDlin/clothing-mask-node - -View: - - - ---- - -### Contrast Limited Adaptive Histogram Equalization - -Description: Enhances local image contrast using adaptive histogram equalization with contrast limiting. - -Node Link: https://github.com/VeyDlin/clahe-node - -View: - - - ---- - -### Depth Map from Wavefront OBJ - -**Description:** Render depth maps from Wavefront .obj files (triangulated) using this simple 3D renderer utilizing numpy and matplotlib to compute and color the scene. There are simple parameters to change the FOV, camera position, and model orientation. - -To be imported, an .obj must use triangulated meshes, so make sure to enable that option if exporting from a 3D modeling program. This renderer makes each triangle a solid color based on its average depth, so it will cause anomalies if your .obj has large triangles. In Blender, the Remesh modifier can be helpful to subdivide a mesh into small pieces that work well given these limitations. - -**Node Link:** https://github.com/dwringer/depth-from-obj-node - -**Example Usage:** - - - ---- - -### Enhance Detail - -**Description:** A single node that can enhance the detail in an image. Increase or decrease details in an image using a guided filter (as opposed to the typical Gaussian blur used by most sharpening filters.) Based on the `Enhance Detail` ComfyUI node from https://github.com/spacepxl/ComfyUI-Image-Filters - -**Node Link:** https://github.com/skunkworxdark/enhance-detail-node - -**Example Usage:** - - - ---- - -### Flip Pose - -**Description:** This node will flip an openpose image horizontally, recoloring it to make sure that it isn't facing the wrong direction. Note that it does not work with openpose hands. - -**Node Link:** https://github.com/JPPhoto/flip-pose-node - ---- - -### Flux Ideal Size - -**Description:** This node returns an ideal size to use for the first stage of a Flux image generation pipeline. Generating at the right size helps limit duplication and odd subject placement. - -**Node Link:** https://github.com/JPPhoto/flux-ideal-size - ---- +1. Open **Nodes** in the Launchpad, or **Nodes Manager** in the app menu. When multi-user mode is on, only + administrators see it. -### Generative Grammar-Based Prompt Nodes +2. Click **Add Nodes**. -**Description:** This set of 3 nodes generates prompts from simple user-defined grammar rules (loaded from custom files - examples provided below). The prompts are made by recursively expanding a special template string, replacing nonterminal "parts-of-speech" until no nonterminal terms remain in the string. +3. Paste the pack's Git URL, which **Copy Git URL** on a card below puts on your clipboard, and click **Install**. -This includes 3 Nodes: -- *Lookup Table from File* - loads a YAML file "prompt" section (or of a whole folder of YAML's) into a JSON-ified dictionary (Lookups output) -- *Lookups Entry from Prompt* - places a single entry in a new Lookups output under the specified heading -- *Prompt from Lookup Table* - uses a Collection of Lookups as grammar rules from which to randomly generate prompts. + InvokeAI clones the repository into your nodes directory and loads its nodes without a restart. Workflows the pack + ships are added to your workflow library. -**Node Link:** https://github.com/dwringer/generative-grammar-prompt-nodes +4. If a **Dependencies required** notice appears, install the Python packages the pack's README lists into InvokeAI's + environment, then restart InvokeAI. -**Example Usage:** +
- - ---- - -### GPT2RandomPromptMaker - -**Description:** A node for InvokeAI utilizes the GPT-2 language model to generate random prompts based on a provided seed and context. - -**Node Link:** https://github.com/mickr777/GPT2RandomPromptMaker - -**Output Examples** - -Generated Prompt: An enchanted weapon will be usable by any character regardless of their alignment. - - - ---- - -### Grid to Gif - -**Description:** One node that turns a grid image into an image collection, one node that turns an image collection into a gif. - -**Node Link:** https://github.com/mildmisery/invokeai-GridToGifNode/blob/main/GridToGif.py - -**Example Node Graph:** https://github.com/mildmisery/invokeai-GridToGifNode/blob/main/Grid%20to%20Gif%20Example%20Workflow.json - -**Output Examples** - - - - ---- - -### Halftone - -**Description**: Halftone converts the source image to grayscale and then performs halftoning. CMYK Halftone converts the image to CMYK and applies a per-channel halftoning to make the source image look like a magazine or newspaper. For both nodes, you can specify angles and halftone dot spacing. - -**Node Link:** https://github.com/JPPhoto/halftone-node - -**Example** - -Input: - - - -Halftone Output: - - - -CMYK Halftone Output: - - - ---- - -### Hand Refiner with MeshGraphormer - -**Description**: Hand Refiner takes in your image and automatically generates a fixed depth map for the hands along with a mask of the hands region that will conveniently allow you to use them along with ControlNet to fix the wonky hands generated by Stable Diffusion - -**Node Link:** https://github.com/blessedcoolant/invoke_meshgraphormer - -**View** - - ---- - -### Image and Mask Composition Pack - -**Description:** This is a pack of nodes for composing masks and images, including a simple text mask creator and both image and latent offset nodes. The offsets wrap around, so these can be used in conjunction with the Seamless node to progressively generate centered on different parts of the seamless tiling. - -This includes 15 Nodes: - -- *Adjust Image Hue Plus* - Rotate the hue of an image in one of several different color spaces. -- *Blend Latents/Noise (Masked)* - Use a mask to blend part of one latents tensor [including Noise outputs] into another. Can be used to "renoise" sections during a multi-stage [masked] denoising process. -- *Enhance Image* - Boost or reduce color saturation, contrast, brightness, sharpness, or invert colors of any image at any stage with this simple wrapper for pillow [PIL]'s ImageEnhance module. -- *Equivalent Achromatic Lightness* - Calculates image lightness accounting for Helmholtz-Kohlrausch effect based on a method described by High, Green, and Nussbaum (2023). -- *Text to Mask (Clipseg)* - Input a prompt and an image to generate a mask representing areas of the image matched by the prompt. -- *Text to Mask Advanced (Clipseg)* - Output up to four prompt masks combined with logical "and", logical "or", or as separate channels of an RGBA image. -- *Image Layer Blend* - Perform a layered blend of two images using alpha compositing. Opacity of top layer is selectable, with optional mask and several different blend modes/color spaces. -- *Image Compositor* - Take a subject from an image with a flat backdrop and layer it on another image using a chroma key or flood select background removal. -- *Image Dilate or Erode* - Dilate or expand a mask (or any image!). This is equivalent to an expand/contract operation. -- *Image Value Thresholds* - Clip an image to pure black/white beyond specified thresholds. -- *Offset Latents* - Offset a latents tensor in the vertical and/or horizontal dimensions, wrapping it around. -- *Offset Image* - Offset an image in the vertical and/or horizontal dimensions, wrapping it around. -- *Rotate/Flip Image* - Rotate an image in degrees clockwise/counterclockwise about its center, optionally resizing the image boundaries to fit, or flipping it about the vertical and/or horizontal axes. -- *Shadows/Highlights/Midtones* - Extract three masks (with adjustable hard or soft thresholds) representing shadows, midtones, and highlights regions of an image. -- *Text Mask (simple 2D)* - create and position a white on black (or black on white) line of text using any font locally available to Invoke. - -**Node Link:** https://github.com/dwringer/composition-nodes - - - ---- - -### Image Dominant Color - -Description: Identifies and extracts the dominant color from an image using k-means clustering. - -Node Link: https://github.com/VeyDlin/image-dominant-color-node - -View: - - - ---- - -### Image Export - -**Description:** Export images in multiple formats (AVIF, JPEG, PNG, TIFF, WebP) with format-specific compression and quality options. - -**Node Link:** https://github.com/veeliks/invoke_image_export - -**Nodes:** - -
- Save Image as AVIF - Save Image as JPEG - Save Image as PNG - Save Image as TIFF - Save Image as WebP -
- ---- - -### Image to Character Art Image Nodes - -**Description:** Group of nodes to convert an input image into ascii/unicode art Image - -**Node Link:** https://github.com/mickr777/imagetoasciiimage - -**Output Examples** - - - - - - - - - ---- - -### Image Picker - -**Description:** This InvokeAI node takes in a collection of images and randomly chooses one. This can be useful when you have a number of poses to choose from for a ControlNet node, or a number of input images for another purpose. - -**Node Link:** https://github.com/JPPhoto/image-picker-node - ---- - -### Image Resize Plus - -Description: Provides various image resizing options such as fill, stretch, fit, center, and crop. - -Node Link: https://github.com/VeyDlin/image-resize-plus-node - -View: - - - ---- - -### Latent Upscale - -**Description:** This node uses a small (~2.4mb) model to upscale the latents used in a Stable Diffusion 1.5 or Stable Diffusion XL image generation, rather than the typical interpolation method, avoiding the traditional downsides of the latent upscale technique. - -**Node Link:** [https://github.com/gogurtenjoyer/latent-upscale](https://github.com/gogurtenjoyer/latent-upscale) - ---- +
+ -### Load Video Frame + -**Description:** This is a video frame image provider + indexer/video creation nodes for hooking up to iterators and ranges and ControlNets and such for invokeAI node experimentation. Think animation + ControlNet outputs. +1. Open a terminal in the `nodes` folder of your InvokeAI root directory. The **Add Nodes** view of the Nodes manager + shows its path under **Nodes Directory**; the `custom_nodes_dir` setting changes it. -**Node Link:** https://github.com/helix4u/load_video_frame +2. Clone the pack: -**Output Example:** - - - ---- -### Make 3D - -**Description:** Create compelling 3D stereo images from 2D originals. - -**Node Link:** [https://gitlab.com/srcrr/shift3d/-/raw/main/make3d.py](https://gitlab.com/srcrr/shift3d) - -**Example Node Graph:** https://gitlab.com/srcrr/shift3d/-/raw/main/example-workflow.json?ref_type=heads&inline=false - -**Output Examples** - - - - ---- - -### Mask Operations - -Description: Offers logical operations (OR, SUB, AND) for combining and manipulating image masks. - -Node Link: https://github.com/VeyDlin/mask-operations-node - -View: - - - ---- - -### Match Histogram - -**Description:** An InvokeAI node to match a histogram from one image to another. This is a bit like the `color correct` node in the main InvokeAI but this works in the YCbCr colourspace and can handle images of different sizes. Also does not require a mask input. -- Option to only transfer luminance channel. -- Option to save output as grayscale - -A good use case for this node is to normalize the colors of an image that has been through the tiled scaling workflow of my XYGrid Nodes. - -See full docs here: https://github.com/skunkworxdark/Prompt-tools-nodes/edit/main/README.md - -**Node Link:** https://github.com/skunkworxdark/match_histogram - -**Output Examples** - - - ---- - -### Metadata Linked Nodes - -**Description:** A set of nodes for Metadata. Collect Metadata from within an `iterate` node & extract metadata from an image. - -- `Metadata Item Linked` - Allows collecting of metadata while within an iterate node with no need for a collect node or conversion to metadata node -- `Metadata From Image` - Provides Metadata from an image -- `Metadata To String` - Extracts a String value of a label from metadata -- `Metadata To Integer` - Extracts an Integer value of a label from metadata -- `Metadata To Float` - Extracts a Float value of a label from metadata -- `Metadata To Scheduler` - Extracts a Scheduler value of a label from metadata -- `Metadata To Bool` - Extracts Bool types from metadata -- `Metadata To Model` - Extracts model types from metadata -- `Metadata To SDXL Model` - Extracts SDXL model types from metadata -- `Metadata To LoRAs` - Extracts Loras from metadata. -- `Metadata To SDXL LoRAs` - Extracts SDXL Loras from metadata -- `Metadata To ControlNets` - Extracts ControNets from metadata -- `Metadata To IP-Adapters` - Extracts IP-Adapters from metadata -- `Metadata To T2I-Adapters` - Extracts T2I-Adapters from metadata -- `Denoise Latents + Metadata` - This is an inherited version of the existing `Denoise Latents` node but with a metadata input and output. - -**Node Link:** https://github.com/skunkworxdark/metadata-linked-nodes - ---- - -### Negative Image - -Description: Creates a negative version of an image, effective for visual effects and mask inversion. - -Node Link: https://github.com/VeyDlin/negative-image-node - -View: - - - ---- - -### Nightmare Promptgen - -**Description:** Nightmare Prompt Generator - Uses a local text generation model to create unique imaginative (but usually nightmarish) prompts for InvokeAI. By default, it allows you to choose from some gpt-neo models I finetuned on over 2500 of my own InvokeAI prompts in Compel format, but you're able to add your own, as well. Offers support for replacing any troublesome words with a random choice from list you can also define. - -**Node Link:** [https://github.com/gogurtenjoyer/nightmare-promptgen](https://github.com/gogurtenjoyer/nightmare-promptgen) - ---- - -### Ollama Node - -**Description:** Uses Ollama API to expand text prompts for text-to-image generation using local LLMs. Works great for expanding basic prompts into detailed natural language prompts for Flux. Also provides a toggle to unload the LLM model immediately after expanding, to free up VRAM for Invoke to continue the image generation workflow. - -**Node Link:** https://github.com/Jonseed/Ollama-Node - -**Example Node Graph:** https://github.com/Jonseed/Ollama-Node/blob/main/Ollama-Node-Flux-example.json - -**View:** - -![ollama node](https://raw.githubusercontent.com/Jonseed/Ollama-Node/a3e7cdc55e394cb89c1ea7ed54e106c212c85e8c/ollama-node-screenshot.png) - ---- - -### One Button Prompt - - - -**Description:** an extensive suite of auto prompt generation and prompt helper nodes based on extensive logic. Get creative with the best prompt generator in the world. - -The main node generates interesting prompts based on a set of parameters. There are also some additional nodes such as Auto Negative Prompt, One Button Artify, Create Prompt Variant and other cool prompt toys to play around with. - -**Node Link:** [https://github.com/AIrjen/OneButtonPrompt_X_InvokeAI](https://github.com/AIrjen/OneButtonPrompt_X_InvokeAI) - -**Nodes:** - - - ---- - -### Oobabooga - -**Description:** asks a local LLM running in Oobabooga's Text-Generation-Webui to write a prompt based on the user input. - -**Link:** https://github.com/sammyf/oobabooga-node - -**Example:** - -"describe a new mystical creature in its natural environment" - -*can return* - -"The mystical creature I am describing to you is called the "Glimmerwing". It is a majestic, iridescent being that inhabits the depths of the most enchanted forests and glimmering lakes. Its body is covered in shimmering scales that reflect every color of the rainbow, and it has delicate, translucent wings that sparkle like diamonds in the sunlight. The Glimmerwing's home is a crystal-clear lake, surrounded by towering trees with leaves that shimmer like jewels. In this serene environment, the Glimmerwing spends its days swimming gracefully through the water, chasing schools of glittering fish and playing with the gentle ripples of the lake's surface. -As the sun sets, the Glimmerwing perches on a branch of one of the trees, spreading its wings to catch the last rays of light. The creature's scales glow softly, casting a rainbow of colors across the forest floor. The Glimmerwing sings a haunting melody, its voice echoing through the stillness of the night air. Its song is said to have the power to heal the sick and bring peace to troubled souls. Those who are lucky enough to hear the Glimmerwing's song are forever changed by its beauty and grace." - - - -**Requirement** - -a Text-Generation-Webui instance (might work remotely too, but I never tried it) and obviously InvokeAI 3.x - -**Note** - -This node works best with SDXL models, especially as the style can be described independently of the LLM's output. - ---- - -### Prompt Tools - -**Description:** A set of InvokeAI nodes that add general prompt (string) manipulation tools. Designed to accompany the `Prompts From File` node and other prompt generation nodes. - -1. `Prompt To File` - saves a prompt or collection of prompts to a file. one per line. There is an append/overwrite option. -2. `PTFields Collect` - Converts image generation fields into a Json format string that can be passed to Prompt to file. -3. `PTFields Expand` - Takes Json string and converts it to individual generation parameters. This can be fed from the Prompts to file node. -4. `Prompt Strength` - Formats prompt with strength like the weighted format of compel -5. `Prompt Strength Combine` - Combines weighted prompts for .and()/.blend() -6. `CSV To Index String` - Gets a string from a CSV by index. Includes a Random index option - -The following Nodes are now included in v3.2 of Invoke and are no longer in this set of tools. - -- `Prompt Join` -> `String Join` -- `Prompt Join Three` -> `String Join Three` -- `Prompt Replace` -> `String Replace` -- `Prompt Split Neg` -> `String Split Neg` - - -See full docs here: https://github.com/skunkworxdark/Prompt-tools-nodes/edit/main/README.md - -**Node Link:** https://github.com/skunkworxdark/Prompt-tools-nodes - -**Workflow Examples** - - - ---- - -### Remote Image - -**Description:** This is a pack of nodes to interoperate with other services, be they public websites or bespoke local servers. The pack consists of these nodes: - -- *Load Remote Image* - Lets you load remote images such as a realtime webcam image, an image of the day, or dynamically created images. -- *Post Image to Remote Server* - Lets you upload an image to a remote server using an HTTP POST request, eg for storage, display or further processing. - -**Node Link:** https://github.com/fieldOfView/InvokeAI-remote_image - ---- - -### BriaAI Remove Background - -**Description**: Implements one click background removal with BriaAI's new version 1.4 model which seems to be producing better results than any other previous background removal tool. - -**Node Link:** https://github.com/blessedcoolant/invoke_bria_rmbg - -**View** - - ---- - -### Remove Background - -Description: An integration of the rembg package to remove backgrounds from images using multiple U2NET models. - -Node Link: https://github.com/VeyDlin/remove-background-node - -View: - - - ---- - -### Retroize - -**Description:** Retroize is a collection of nodes for InvokeAI to "Retroize" images. Any image can be given a fresh coat of retro paint with these nodes, either from your gallery or from within the graph itself. It includes nodes to pixelize, quantize, palettize, and ditherize images; as well as to retrieve palettes from existing images. - -**Node Link:** https://github.com/Ar7ific1al/invokeai-retroizeinode/ - -**Retroize Output Examples** - - - ---- - -### Stereogram Nodes - -**Description:** A set of custom nodes for InvokeAI to create cross-view or parallel-view stereograms. Stereograms are 2D images that, when viewed properly, reveal a 3D scene. Check out [r/crossview](https://www.reddit.com/r/CrossView/) for tutorials. - -**Node Link:** https://github.com/simonfuhrmann/invokeai-stereo - -**Example Workflow and Output** - - - ---- - -### Simple Skin Detection - -Description: Detects skin in images based on predefined color thresholds. - -Node Link: https://github.com/VeyDlin/simple-skin-detection-node - -View: - - - ---- - -### Size Stepper Nodes - -**Description:** This is a set of nodes for calculating the necessary size increments for doing upscaling workflows. Use the *Final Size & Orientation* node to enter your full size dimensions and orientation (portrait/landscape/random), then plug that and your initial generation dimensions into the *Ideal Size Stepper* and get 1, 2, or 3 intermediate pairs of dimensions for upscaling. Note this does not output the initial size or full size dimensions: the 1, 2, or 3 outputs of this node are only the intermediate sizes. - -A third node is included, *Random Switch (Integers)*, which is just a generic version of Final Size with no orientation selection. - -**Node Link:** https://github.com/dwringer/size-stepper-nodes - -**Example Usage:** - - - ---- - -### Text font to Image - -**Description:** text font to text image node for InvokeAI, download a font to use (or if in font cache uses it from there), the text is always resized to the image size, but can control that with padding, optional 2nd line - -**Node Link:** https://github.com/mickr777/textfontimage - -**Output Examples** - - - -Results after using the depth controlnet - - - - - ---- - -### Thresholding - -**Description:** This node generates masks for highlights, midtones, and shadows given an input image. You can optionally specify a blur for the lookup table used in making those masks from the source image. - -**Node Link:** https://github.com/JPPhoto/thresholding-node - -**Examples** - -Input: - - - -Highlights/Midtones/Shadows: - - - - - -Highlights/Midtones/Shadows (with LUT blur enabled): - - - - - ---- - -### Unsharp Mask - -**Description:** Applies an unsharp mask filter to an image, preserving its alpha channel in the process. - -**Node Link:** https://github.com/JPPhoto/unsharp-mask-node - ---- - -### XY Image to Grid and Images to Grids nodes - -**Description:** These nodes add the following to InvokeAI: -- Generate grids of images from multiple input images -- Create XY grid images with labels from parameters -- Split images into overlapping tiles for processing (for super-resolution workflows) -- Recombine image tiles into a single output image blending the seams - -The nodes include: -1. `Images To Grids` - Combine multiple images into a grid of images -2. `XYImage To Grid` - Take X & Y params and creates a labeled image grid. -3. `XYImage Tiles` - Super-resolution (embiggen) style tiled resizing -4. `Image Tot XYImages` - Takes an image and cuts it up into a number of columns and rows. -5. Multiple supporting nodes - Helper nodes for data wrangling and building `XYImage` collections - -See full docs here: https://github.com/skunkworxdark/XYGrid_nodes/edit/main/README.md - -**Node Link:** https://github.com/skunkworxdark/XYGrid_nodes - -**Output Examples** - - - ---- + ```sh + git clone https://github.com/JPPhoto/cinematic-effects-pack.git + ``` -### Example Node Template + To update it, run `git pull` in the pack's folder and restart InvokeAI. -**Description:** This node allows you to do super cool things with InvokeAI. +3. Click **Reload** in the Nodes manager, or restart InvokeAI. -**Node Link:** https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/app/invocations/prompt.py + -**Example Workflow:** https://github.com/invoke-ai/InvokeAI/blob/docs/main/docs/workflows/Prompt_from_File.json +You can also download a pack's source as a ZIP and extract it into its own folder in `nodes`. InvokeAI loads a folder +only when it has an `__init__.py` at its top level. -**Output Examples** + +
- +The [Custom Node Manager](/users-guide/workflows/custom-node-manager/) page covers browsing, reloading and uninstalling +packs. A workflow that uses community nodes loads like any other: import its `.json` file into the workflow library. +## Browse node packs -## Disclaimer + -The nodes linked have been developed and contributed by members of the Invoke AI community. While we strive to ensure the quality and safety of these contributions, we do not guarantee the reliability or security of the nodes. If you have issues or concerns with any of the nodes below, please raise it on GitHub or in the Discord. +## Add your node pack +Each pack on this page is one YAML file in +[`docs/src/content/community-nodes/`](https://github.com/invoke-ai/InvokeAI/tree/main/docs/src/content/community-nodes). +To list yours, add a file there in a pull request; the +[README in that folder](https://github.com/invoke-ai/InvokeAI/blob/main/docs/src/content/community-nodes/README.md) +lists the fields. [Creating Nodes](/development/guides/creating-nodes/) explains how to build a pack. -## Help -If you run into any issues with a node, please post in the [InvokeAI Discord](https://discord.gg/ZmtBAhwWhy). +For help with a pack, ask in the [InvokeAI Discord](https://discord.gg/ZmtBAhwWhy). diff --git a/docs/src/content/docs/Users Guide/08.Workflows/custom-node-manager.mdx b/docs/src/content/docs/Users Guide/08.Workflows/custom-node-manager.mdx index ab42b73e5a6..04022826598 100644 --- a/docs/src/content/docs/Users Guide/08.Workflows/custom-node-manager.mdx +++ b/docs/src/content/docs/Users Guide/08.Workflows/custom-node-manager.mdx @@ -1,76 +1,77 @@ --- title: Custom Node Manager +description: Install, browse and uninstall community node packs from the Nodes manager, or add them to the nodes folder yourself. +lastUpdated: 2026-10-26 sidebar: order: 5 --- import { Steps } from '@astrojs/starlight/components'; -The Custom Node Manager installs, updates, and removes community node packs directly from the InvokeAI UI — no manual file copying, no restart required. +The Nodes manager installs node packs from a Git URL, lists the packs you have and the nodes each one adds, and +uninstalls them. Open **Nodes** in the Launchpad, or **Nodes Manager** in the app menu. When multi-user mode is on, +only administrators see it. [Community Nodes](/users-guide/workflows/community-nodes/) lists packs to install. -## Opening the Custom Node Manager - -Click the **Nodes** tab (circuit icon) in the left sidebar, between **Models** and **Queue**. - -The page is split into two panels: - -- **Left:** the list of installed node packs, with each pack's node count, type badges, and on-disk path. -- **Right:** the install UI, with tabs for **Git Repository URL** and **Scan Folder**, plus an install log at the bottom. +:::caution[Only install packs you trust] +A node pack runs Python code on your machine with InvokeAI's permissions. +::: -## Installing a node pack +## Install from a Git URL -1. On the right panel, choose the **Git Repository URL** tab. -2. Paste the Git URL of the pack, e.g. `https://github.com/user/my-node-pack.git`. -3. Click **Install**. - -What happens during install: +1. Click **Add Nodes**. -- The repo is cloned into your `nodes` directory. -- The nodes are loaded into the running InvokeAI process immediately — **no restart needed**. -- Any workflow `.json` files found in the repo are imported into your workflow library and tagged with `node-pack:` so you can filter for them. -- The install log at the bottom of the panel shows the result for each step. +2. Paste the pack's repository URL into **Git URL**, such as `https://github.com/JPPhoto/curves-node.git`, and click + **Install** or press Enter. The field rejects a pack you already have. -:::caution[Security] -Custom nodes execute arbitrary Python on your machine. **Only install node packs from authors you trust.** A malicious pack could harm your system or exfiltrate data. -::: +3. InvokeAI clones the repository into your nodes directory, in a folder named after the URL's last part, and loads + its nodes without a restart. Every workflow `.json` in the repository is added to the workflow library, shared with + all users and tagged `node-pack:`. -### Python dependencies + -The Custom Node Manager **does not** automatically run `pip install` for a pack's `requirements.txt` or `pyproject.toml`. Auto-installing into the running InvokeAI environment risks pulling in incompatible package versions and breaking the application. +Install fails when the repository has no `__init__.py` at its top level, because InvokeAI loads only folders that have +one. The activity bar at the bottom of the manager keeps each install's result and message. -If a pack ships extra dependencies, you'll see a warning toast after installation. Install them yourself — typically `pip install -r requirements.txt` from inside an activated InvokeAI environment, but check the pack's README first. After installing, click **Reload** so the new dependencies take effect. +InvokeAI doesn't install a pack's Python dependencies, since that could break its own environment. When the pack has a +`requirements.txt` or `pyproject.toml`, a **Dependencies required** notice stays on screen: install the packages into +InvokeAI's environment as the pack's README describes, then restart InvokeAI. -## Managing installed packs +## Install manually -Each entry in the left panel has actions for managing the pack: +The **Install manually** section of **Add Nodes** shows the **Nodes Directory** with a button that copies its path. It +is the `nodes` folder of your InvokeAI root unless the `custom_nodes_dir` setting changes it. -- **Reload** — re-scans the `nodes` directory. Use this after manually adding a pack via `git clone`, or after installing extra Python dependencies. -- **Uninstall** — removes the pack from disk, unregisters its nodes from the running process, and removes any workflows that were imported from the pack. No restart needed. + -## Scan Folder tab +1. Clone a pack there with `git clone `, or extract its ZIP into its own folder there. -The **Scan Folder** tab shows the path of your `nodes` directory. Anything placed there manually (for example, by `git clone`-ing a pack directly) is detected automatically at startup. Use **Reload** to pick up packs added at runtime. +2. Click **Reload** above the pack list, or restart InvokeAI. -## Troubleshooting + -### Install fails +To update a pack you cloned, run `git pull` in its folder and restart InvokeAI. -- Confirm the Git URL is correct and reachable. -- The repo must contain an `__init__.py` at its root. -- Read the install log — it surfaces the underlying error. +## Manage installed packs -### Nodes don't appear after install +| Where | What it does | +| --- | --- | +| **Node Packs** list | Each pack's name and node count. Search by name or path; filter to **Packs with no nodes**; sort by name, node count or path. | +| A pack's page | Its node count, path, **Uninstall**, and a preview of every node it adds, with each node's fields. | +| Right-click a pack | **Copy path** and **Uninstall**. | +| **Reload** | Scans the nodes directory again and loads packs added since startup. | -- Click **Reload**. -- Check that the pack's `__init__.py` imports the node classes. -- Check the server console for import errors. +A pack with no nodes usually failed to import: reload or restart, check its dependencies, and read the server console +for the error. -### Workflows show errors after uninstalling +## Uninstall a pack -User-created workflows that reference nodes from an uninstalled pack will show errors for the missing node types. Either reinstall the pack or remove the affected nodes from the workflow. +**Uninstall** deletes the pack's folder, removes its nodes from the editor, and deletes the workflows it imported. +Workflows you made yourself stay, but show errors for the pack's nodes until you reinstall it or remove those nodes. +Restart InvokeAI for the removal to take full effect: the pack's Python code stays loaded until then. -## Authoring a node pack +## Publish your own pack -If you want to publish your own pack so it can be installed by URL, see the [Creating a Node Pack](/development/guides/creating-nodes/) developer guide for the required repository layout, `__init__.py` requirements, and conventions for shipping workflows alongside your nodes. +[Creating Nodes](/development/guides/creating-nodes/) explains how to build a pack, and how to list it on +[Community Nodes](/users-guide/workflows/community-nodes/). diff --git a/docs/src/content/docs/Users Guide/assets/canvas-edit-layout.webp b/docs/src/content/docs/Users Guide/assets/canvas-edit-layout.webp index 9db28bf6c8f..8fdecc7d0bc 100644 Binary files a/docs/src/content/docs/Users Guide/assets/canvas-edit-layout.webp and b/docs/src/content/docs/Users Guide/assets/canvas-edit-layout.webp differ diff --git a/docs/src/content/docs/Users Guide/assets/upscale-model-only.webp b/docs/src/content/docs/Users Guide/assets/upscale-model-only.webp new file mode 100644 index 00000000000..b70cf77ada8 Binary files /dev/null and b/docs/src/content/docs/Users Guide/assets/upscale-model-only.webp differ diff --git a/docs/src/content/docs/Users Guide/assets/upscale-refine.webp b/docs/src/content/docs/Users Guide/assets/upscale-refine.webp new file mode 100644 index 00000000000..8758ecb40ee Binary files /dev/null and b/docs/src/content/docs/Users Guide/assets/upscale-refine.webp differ diff --git a/docs/src/content/docs/assets/invoke-webui-canvas.png b/docs/src/content/docs/assets/invoke-webui-canvas.png deleted file mode 100644 index 29dda3baf42..00000000000 Binary files a/docs/src/content/docs/assets/invoke-webui-canvas.png and /dev/null differ diff --git a/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx index b74da7f988c..4b66ab4bca3 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx @@ -1,236 +1,137 @@ --- title: Multi-User Administrator Guide -description: How to set up and manage a multi-user InvokeAI installation. +description: Turn on multi-user mode, manage accounts, and keep a shared InvokeAI server safe. +lastUpdated: 2026-10-26 sidebar: order: 4 --- -import { Steps } from '@astrojs/starlight/components'; +import { Steps, Tabs, TabItem, CardGrid, LinkCard } from '@astrojs/starlight/components'; -## Overview +Multi-user mode puts a sign-in in front of InvokeAI and gives every person their own account, projects, boards and workflows on one shared server. This guide is for the administrator who sets it up and runs it. For what everyone else sees, read the [Multi-User Guide](/configuration/multi-user-mode/user-guide/). -This guide is for administrators managing a multi-user InvokeAI -installation. It covers initial setup, user management, security best -practices, and troubleshooting. +## Roles -## Prerequisites +There are two roles. Any number of accounts can be administrators. -Before enabling multi-user support, ensure you have: +| | Administrator | User | +| --- | --- | --- | +| Generate with every installed model | Yes | Yes | +| Open the Launchpad **Models** and **Nodes** pages to install and edit models and custom nodes | Yes | No: these pages are hidden from users | +| Manage accounts (Launchpad **Users**) | Yes | No | +| Server settings and maintenance (**Settings → Server**, database and gallery maintenance) | Yes | No | +| Queue | Sees every item in full, cancels anyone's, pauses and resumes the queue | Sees their own items in full and other users' with details hidden; cancels only their own | +| Boards | Opens and manages every user's boards | Their own, and boards others share with them | +| Library workflows | Sees every user's workflows | Their own and public ones | -- InvokeAI installed and running -- Access to the server filesystem (for initial setup) -- Understanding of your deployment environment -- Backup of your existing data (recommended) +Roles are read from the database on every request, so promoting or demoting someone takes effect on their next action. See [When changes take effect](#when-changes-take-effect). -## Initial Setup - -### Activating Multiuser Mode - -To put InvokeAI into multiuser mode, you will need to add the option `multiuser: true` to its configuration file. This file is located at `INVOKEAI_ROOT/invokeai.yaml`. With the InvokeAI backend halted, add the new configuration option to the end of the file with a text editor so that it looks like this: - -```yaml -# Internal metadata - do not edit: -schema_version: 4.0.2 - -# Enable/disable multi-user mode -multiuser: true -``` - -Then restart the InvokeAI server backend from the command line or using the launcher. - -:::note[Reverting to single-user mode] -If at any time you wish to revert to single-user mode, simply comment out the `multiuser` line, or change "true" to "false". Then restart the server. Because of the way that browsers cache pages, users with open InvokeAI sessions may need to force-refresh their browsers. -::: - -### Queue Scheduling - -The image generation queue is processed by a single worker, so jobs run one at a time. The `session_queue_mode` option controls the order in which pending jobs are selected: - -| Mode | Behavior | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `round_robin` (default) | Interleaves jobs across users — each user is served one job before any user is served a second. A single user enqueuing a large batch can no longer monopolize the queue. | -| `FIFO` | Strict first-come, first-serve. Jobs run in the order they were enqueued (respecting priority), so a large batch drains completely before the next user's jobs start. | - -Set it in `invokeai.yaml`: - -```yaml -multiuser: true -session_queue_mode: round_robin # or FIFO -``` - -Or via the `INVOKEAI_SESSION_QUEUE_MODE` environment variable: - -```bash -INVOKEAI_SESSION_QUEUE_MODE=FIFO -``` - -:::note -`session_queue_mode` only applies in multiuser mode. In single-user mode the queue is always FIFO regardless of this setting, since all jobs belong to the same account. -::: - -Round-robin fairness is determined from when each user's jobs were last started. Retained terminal queue history (see [`max_queue_history`](#configuration-reference)) does not slow dequeue scheduling — each dequeue's cost scales with the number of users who currently have pending jobs, not with the size of the history. Administrators can change `max_queue_history` without editing the file: open **Settings → Server → Queue history**, where a blank value keeps every finished item and `0` prunes them all at the next start. - -### First Administrator Account - -When InvokeAI starts for the first time in multi-user mode, you'll see the **Administrator Setup** dialog. - -**Setup Steps:** +## Turn on multi-user mode -1. **Email Address**: Enter a valid email address (this becomes your username) - - Example: `admin@example.com` or `admin@localhost` for testing - - Must be a valid email format - - Cannot be changed later without database access +1. **Back up.** Stop InvokeAI and copy the `databases` folder in your root directory. See [Backups](#backups). + +2. **Edit `invokeai.yaml`** in your root directory and add `multiuser: true`. To let other computers on your network reach the server, also add `host: 0.0.0.0`: -2. **Display Name**: Enter a friendly name - - Example: "System Administrator" or your real name - - Can be changed later in your profile - - Visible to other users in shared contexts + ```yaml + multiuser: true + host: 0.0.0.0 # optional: listen on the network, not only this computer + strict_password_checking: true # optional: require strong passwords + ``` -3. **Password**: Create a strong administrator password - - **Minimum requirements:** - - At least 8 characters long - - Contains uppercase letters (A-Z) - - Contains lowercase letters (a-z) - - Contains numbers (0-9) +3. **Start InvokeAI** from the launcher or with `invokeai-web`. With `host: 0.0.0.0`, other computers open `http://:9090`. - - **Recommended:** - - Use 12+ characters - - Include special characters (!@#$%^&\*) - - Use a password manager to generate and store - - Don't reuse passwords from other services +4. **Create the first administrator.** The first browser to open InvokeAI shows **Set up Invoke**. Enter: + - **Email**: the sign-in name. It cannot be changed later. It must look like an email address, though `admin@localhost` also works; nothing is ever sent to it. + - **Display name** (optional): shown to other users instead of the email. + - **Password** and **Confirm password**. -4. **Confirm Password**: Re-enter the password + Select **Create administrator account**, then sign in. -5. Click **Create Administrator Account** +5. **Add the other users** from the Launchpad's **Users** page, or with `invoke-useradd`. See [Manage users](#manage-users). -:::caution[Important] -Store these credentials securely! The first administrator account can reset the password to something new, but cannot retrieve a lost one. +:::caution[Plain HTTP over the network] +Browsers turn off the clipboard and Web Locks for plain-HTTP pages at a network address such as `http://192.168.1.20:9090`, so copy buttons stop working and open tabs cannot be told apart. See [Known Bugs](/troubleshooting/known-bugs/#plain-http-access-another-tab-can-take-a-still-open-tabs-unsaved-changes). Serve InvokeAI over [HTTPS](#https-and-internet-access), or open it at `localhost` on the server itself. ::: -### Configuration - -InvokeAI can run in single-user or multi-user mode, controlled by the `multiuser` configuration option in `invokeai.yaml`: - -```yaml -# Enable/disable multi-user mode -multiuser: true # Enable multi-user mode (requires authentication) - -# Optional password policy -strict_password_checking: true # Enforce uppercase/lowercase/number requirements -``` - -JWT secrets are generated automatically and stored in the database. Session lifetimes default to 24 hours, or 7 days when the user selects "Remember me". See Secret Key Management below if you need to rotate the JWT secret. - -:::caution[Mode Switching Behavior] -**Switching to Single-User Mode:** If boards or images were created in multi-user mode, they will all be combined into a single unified view when switching to single-user mode. - -**Switching to Multi-User Mode:** Legacy boards and images created under single-user mode will be owned by an internal user named "system." Only the Administrator will have access to these legacy assets. A utility to migrate these legacy assets to another user will be part of a future release. +:::caution[Keep the administrator's password] +InvokeAI cannot recover a password. If you lose the last administrator's password, reset it from the command line with [`invoke-usermod`](#troubleshooting). ::: -### Migration from Single-User - -When upgrading from a single-user installation or switching modes: - - - -1. **Automatic Migration**: The database will automatically migrate to multi-user schema when multi-user mode is first enabled - -2. **Legacy Data Ownership**: Existing data (boards, images, workflows) created in single-user mode is assigned to an internal user named "system" +To go back to single-user mode, set `multiuser: false` (or remove the line) and restart. Browsers that had InvokeAI open may need a hard refresh. -3. **Administrator Access**: Only administrators will have access to legacy "system"-owned assets when in multi-user mode +### What happens to existing data -4. **No Data Loss**: All existing content is preserved +Switching modes changes no data. Every record keeps its owner, and the mode decides who can see it. In single-user mode everything you create belongs to an internal account named `system`, which cannot sign in. - - -**Migration Process:** - -```bash -# Backup your database first -cp databases/invokeai.db databases/invokeai.db.backup - -# Enable multi-user mode in invokeai.yaml -# multiuser: true +| Data | Single-user → multi-user | Multi-user → single-user | +| --- | --- | --- | +| Boards, images, videos | Owned by `system`: only administrators can open them | All visible: the single-user mode user is an administrator | +| Projects | Owned by `system`: no account can open them. They come back when you return to single-user mode | Only projects created in single-user mode are listed. Each account's projects come back when you return to multi-user mode | +| Library workflows | Public, owned by `system`: every user can open, duplicate or download them from **Shared** | All visible | +| Accounts | Kept. Created in multi-user mode only | Kept, unused until you switch back | -# Start InvokeAI (migration happens automatically) -invokeai-web +Content that accounts create in multi-user mode stays theirs across any number of switches. There is no tool yet to move `system`-owned content to another account. -# Complete the administrator setup dialog -# Legacy data will be owned by "system" user -``` - -:::note[Legacy Asset Migration] -A utility to migrate legacy "system"-owned assets to specific user accounts will be available in a future release. Until then, administrators can access and manage all legacy content. -::: +### Upgrading from v6 -### Upgrading to 7.0 +The first v7 start converts board sharing and projects in place, and logs a warning for anything it had to change beyond that: -Upgrading converts board sharing and projects in place. The upgrade logs a warning for anything it -had to change beyond that: +- **A project id held by more than one account.** Project ids are now unique across accounts. The oldest project keeps its id; each other one gets a new id, and the log names the account's id and the new project id. The project's document, boards, media, queue items and the account's open projects follow it. A browser tab still open on that project knows it by its old id, so its unsynced edits come back as a project-deleted conflict: export them first if you want them, then reopen the project from the Launchpad. +- **Boards in a project that no longer exists**, for example boards of an account deleted before the upgrade. They move out of the project, and the log says how many. -- **A project id held by more than one account.** Project ids are now unique across accounts. The - oldest project keeps its id; each other one gets a new id, and the log names the account's id and - the new project id. The project's document, boards, media, queue items and the account's open - projects follow it. A browser tab that was open on the project still knows it by its old id, so - the edits it had not yet synced come back as a project-deleted conflict: export them first if they - are wanted, then open the project again from the Launchpad. -- **Boards in a project that no longer exists.** A board can name a project its owner no longer has, - for example a board of an account deleted before the upgrade. The upgrade moves such boards out of - the project and logs how many there were. +Board visibility becomes general access: private boards become **Restricted**, shared boards **Everyone can view**, and public boards **Everyone can contribute**, so every user can add media to them and remove only what they added. Any per-person shares stored in the old `shared_boards` table become **Viewer** roles for those people, and the boards appear under their **Shared with me**. The interface cannot list or remove these per-person roles; changing the board's general access does not remove them either. Administrators find boards that other users have not opened to them under **Other users' boards** in the Gallery, and can manage them from their menus as before, except that only a board's owner can move it between the Library and their projects. -Board visibility becomes general access: private boards are **Restricted**, shared boards **Everyone -can view**, and public boards **Everyone can contribute**, so every user can add media to them and -remove only what they added. In the Gallery, an administrator sees boards other users have not -opened to them under **Other users' boards**, closed until opened, and can manage them from their -menus as before, -except that only a board's owner can move it between the Library and their projects. +## Manage users -## User Management + + -### Creating Users +Open the Launchpad's **Users** page, or choose **Manage users** in your account menu (your name in the Launchpad's top bar, or the app menu in a project). -Administrators can create and modify users (including other -administrators) via a built-in web interface or using command-line -scripts. +| To | Do this | +| --- | --- | +| Add a user | **Add user**: email, optional display name, password, and the **Administrator** switch. **Generate** fills in a random 16-character password; reveal it with the eye icon and pass it on securely. | +| Rename, reset a password, change the role | The pencil button. The email cannot be changed. Leave the password blank to keep it. | +| Block sign-in without deleting anything | Turn off the user's **Active** switch. Turn it back on to restore access. | +| Delete a user | The trash button. Read [Deleting a user](#deleting-a-user) first. | +| Clean up a user's intermediate files | The brush button, which opens the intermediates settings for that user. | -#### **Via the Web Frontend:** +You cannot change your own role, deactivate yourself or delete yourself. Ask another administrator. -Please see the Multi-User Guide's section on [Adding and Modifying Users](/configuration/multi-user-mode/user-guide/#adding-and-modifying-users) -for a walk-through. + + -#### **Via Command Line Scripts:** +Four commands manage accounts from a terminal. Run them in the InvokeAI environment: from the launcher, open its command line with the `>` button; from a terminal, activate your install's virtual environment first. They work whether or not the server is running. -##### Command-line User Management Scripts +| Command | Does | Example | +| --- | --- | --- | +| `invoke-useradd` | Adds a user. `--admin` makes them an administrator | `invoke-useradd --email ana@example.com --name "Ana" --password "Sunrise2026"` | +| `invoke-usermod` | Changes the display name, password or role (`--admin`, `--no-admin`) | `invoke-usermod --email ana@example.com --password "NewPassword7"` | +| `invoke-userdel` | Deletes a user. Asks for confirmation unless you pass `--force` | `invoke-userdel --email ana@example.com` | +| `invoke-userlist` | Lists every account. `--json` prints JSON | `invoke-userlist` | -Administrators can also use a series of command-line scripts to add, modify, or delete users. If you use the launcher, click the ">" icon to enter the command-line interface. Otherwise, if you are a native command-line user, activate the InvokeAI environment from your terminal. +- **Prompts.** `invoke-useradd` prompts for everything unless you pass `--email` or `--password`, and then needs both. `invoke-usermod` and `invoke-userdel` prompt for everything unless you pass `--email`; without it, other options such as `--name` are ignored. `invoke-userlist` never prompts. +- Passwords set here must always have at least 8 characters with an uppercase letter, a lowercase letter and a digit, whatever `strict_password_checking` says. +- The commands cannot change an email or deactivate an account. Use the web UI for deactivation. +- Every command takes `--root `. Without it the root is `$INVOKEAI_ROOT`, then the active virtual environment's parent folder, then `~/invokeai`. -All command-line arguments are optional. The scripts will prompt you to provide any missing arguments. +
+`--help` output -The commands are: - -| Name | Function | Example CLI Usage | -| ------------------- | -------------- | ------------------------------------------------------------------------------------------ | -| **invoke-useradd** | add a user | `invoke-useradd --email user@example.com --name "Example User" --password "badpassword"` | -| **invoke-usermod** | modify a user | `invoke-usermod --email user@example.com --name "Mr. Example User" --password "8adsf2**%"` | -| **invoke-userdel** | delete a user | `invoke-userdel --email user@example.com --force` | -| **invoke-userlist** | list all users | `invoke-userlist` | - -Pass the `--help` argument to get the usage of each script. For example: - -```bash -> invoke-useradd --help +```text usage: invoke-useradd [-h] [--root ROOT] [--email EMAIL] [--password PASSWORD] [--name NAME] [--admin] Add a user to the InvokeAI database options: -h, --help show this help message and exit - --root ROOT, -r ROOT Path to the InvokeAI root directory. If omitted, the root is resolved in this order: the $INVOKEAI_ROOT environment - variable, the active virtual environment's parent directory, or $HOME/invokeai. + --root ROOT, -r ROOT Path to the InvokeAI root directory. If omitted, the root is resolved in this order: + the $INVOKEAI_ROOT environment variable, the active virtual environment's parent + directory, or $HOME/invokeai. --email EMAIL, -e EMAIL User email address --password PASSWORD, -p PASSWORD @@ -241,225 +142,198 @@ options: If no arguments are provided, the script will run in interactive mode. ``` -:::danger[Data Loss] -Deleting a user removes the user record and cascades to their sessions, board memberships, sent -invitations, projects, and per-user client state. Their boards leave the deleted projects and stay -in no project. It does **not** delete the boards, images, videos, -workflows, queue items, or style presets they created — those rows remain in the database, owned by -a user_id that no longer exists, and will not appear in other (non-admin) users' galleries. -Administrators keep gallery visibility of the orphaned images and videos and can review or delete -them from there. Physical media files in `outputs/images` and `outputs/videos` are also left in -place. Administrators can use **Settings > Data & workspace > Gallery maintenance** to review and -remove database records whose image files are missing, archive image files and thumbnails that are -not associated with database records, or regenerate missing thumbnails. These actions cover images -across all accounts, including archived-board and intermediate images; they do not maintain videos. -Each action scans when requested, then requires confirmation before it runs. Execution is blocking, -so the request waits for the operation to finish. If the inventory changed since review, InvokeAI -asks the administrator to review again. - -Removing a missing image record also removes its related semantic-index entries through the -database's image-record relationship. The image record is deleted, and its thumbnail is archived -when present; other images and their embeddings are preserved. Archive files are placed in a unique run -directory under `/images-archive/gallery-maintenance/`, where `` is the configured -`Paths.outdir` (for example, `outputs/images-archive/gallery-maintenance/remove_missing-/`). -Before making changes, the application creates a consistent database backup through its active -database connection under `/backup/` (for example, -`databases/backup/backup--gallery-maintenance-.db`). The result shows the exact -backup and archive paths. Keep those files if you may need to recover database records or archived -media. Gallery maintenance is part of the application and does not require a standalone script or -a separate database connection. - -If you want their content gone as well, reassign or delete it before deleting the user. Back up the -database first if recovery might be needed. -::: - -### Viewing User Activity +```text +usage: invoke-usermod [-h] [--root ROOT] [--email EMAIL] [--name NAME] [--password PASSWORD] + [--admin | --no-admin] -**Queue Management:** +Modify a user in the InvokeAI database -There is no separate admin-only queue view. When signed in as an administrator, the regular queue -panel automatically shows every user's queue items (each item is labelled with the submitting user's -display name or email), and you can cancel or clear any of them. There is no built-in UI to filter -the queue by user; use your browser's find-in-page to scan by name if needed. +options: + -h, --help show this help message and exit + --root ROOT, -r ROOT Path to the InvokeAI root directory. If omitted, the root is resolved in this order: + the $INVOKEAI_ROOT environment variable, the active virtual environment's parent + directory, or $HOME/invokeai. + --email EMAIL, -e EMAIL + User email address + --name NAME, -n NAME New display name + --password PASSWORD, -p PASSWORD + New password + --admin, -a Grant administrator privileges + --no-admin Remove administrator privileges -## Model Management +If no arguments are provided, the script will run in interactive mode. +``` -As an administrator, you have full access to the [Model -Manager](/users-guide/models/introduction/) and can install, edit and delete -models just as in single-user mode. Unprivileged users, however, can -view the models previously installed, but cannot add or modify them. +```text +usage: invoke-userdel [-h] [--root ROOT] [--email EMAIL] [--force] -## Security +Delete a user from the InvokeAI database -### Download URL Restrictions +options: + -h, --help show this help message and exit + --root ROOT, -r ROOT Path to the InvokeAI root directory. If omitted, the root is resolved in this order: + the $INVOKEAI_ROOT environment variable, the active virtual environment's parent + directory, or $HOME/invokeai. + --email EMAIL, -e EMAIL + User email address + --force, -f Delete without confirmation prompt -These restrictions apply to every download the server performs on your behalf — -model installs from URLs are the main case. (The old general-purpose -`/api/v1/download_queue` REST endpoint, which accepted an arbitrary URL and -destination path, has been removed.) +If no arguments are provided, the script will run in interactive mode. +``` -Downloads are refused when the URL resolves to a loopback, link-local or -private-network address. This stops a download URL from being used to reach -services that only the server can see, such as an admin interface bound to -`127.0.0.1` or a cloud instance-metadata endpoint. Redirects are checked at -every hop, so a public URL cannot bounce the server onto a private address. +```text +usage: invoke-userlist [-h] [--root ROOT] [--json] -If you install models from a mirror on your own network, re-enable those -addresses in `invokeai.yaml`: +List users from the InvokeAI database -```yaml -allow_private_download_urls: true +options: + -h, --help show this help message and exit + --root ROOT, -r ROOT Path to the InvokeAI root directory. If omitted, the root is resolved in this order: + the $INVOKEAI_ROOT environment variable, the active virtual environment's parent + directory, or $HOME/invokeai. + --json Output users in JSON format instead of table + +Examples: + invoke-userlist + invoke-userlist --json ``` -For security, the guarded downloader ignores ambient `HTTP_PROXY`, `HTTPS_PROXY`, -and `ALL_PROXY` settings. It still honors `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, -and `.netrc`. If direct outbound access is unavailable, set `download_proxy` to an -explicit proxy whose destination policy blocks private addresses; proxy-side DNS -cannot be checked by InvokeAI. Do not enable `allow_private_download_urls` merely to -make an untrusted proxy work. The two settings are independent: `download_proxy` is -honored whether or not `allow_private_download_urls` is enabled. +
-:::note[Strict Password Checking] -It is recommended that you enable strict password checking. This will -force all users to select good passwords that follow the -"minimal requirements" below. Do this by adding `strict_password_checking` to -the `invokeai.yaml` configuration file: +
+
-``` -strict_password_checking: true -``` +Two accounts are protected everywhere: you cannot delete, deactivate or demote the **last active administrator** (promote someone else first), and you cannot delete, deactivate, promote or set a password for the internal `system` account, which owns everything created in single-user mode. + +### Deleting a user + +:::danger[Deleting a user is permanent] +**Removed:** the account, its projects, its board shares, wildcards, private fonts and saved interface settings. + +**Kept, without an owner:** the user's boards (moved out of their projects), images, videos, Library workflows, queue items and style presets. Boards keep their general access, so a board open to everyone stays open; the rest of the media is visible only to administrators. The files stay in `outputs/`. +To keep someone's work, move it or share it before you delete them. To remove it, delete it from the Gallery first. To only block access, deactivate the account instead. ::: -### Password Policies +When someone leaves, deactivate their account first, hand over anything others need, and delete it later, or never: a deactivated account keeps everything and cannot sign in. -**Minimal Requirements:** +Images left behind can be reviewed and deleted from the Gallery. To clean up image files and records that no longer match, see [Gallery maintenance](/users-guide/gallery/image-storage-maintenance/#gallery-maintenance). -- Minimum 8 characters -- Must contain uppercase letters -- Must contain lowercase letters -- Must contain numbers +## The shared queue -If `strict_password_checking` is active (recommended), then these -minimal requirements will be enforced and users will not be able to -proceed until they have picked a password that satisfies -them. Otherwise, the user will simply be warned when they use a weak -password. +Every user's jobs go into one queue. With the default `generation_devices: auto`, InvokeAI runs one job per GPU at the same time; a machine with one GPU runs one job at a time. See [Multi-GPU Generation](/configuration/invokeai-yaml/#multi-gpu-generation). -**Recommended Policies:** +`session_queue_mode` decides which pending job starts next: -- Require 12+ character passwords -- Include special characters -- Implement password rotation every 90 days -- Prevent password reuse +| Mode | Order | +| --- | --- | +| `round_robin` (default) | Users take turns: the next job comes from the user served longest ago. Priority orders each user's own jobs but never jumps another user's turn, so one large batch cannot hold up everyone else. | +| `FIFO` | Highest priority first, then oldest. A large batch runs to the end before the next user's jobs start. | -### Session Management +```yaml +session_queue_mode: FIFO # or round_robin +``` -**Session Security and Token Management:** +Turns between users apply only in multi-user mode. In single-user mode jobs run in submission order; on a multi-GPU machine `round_robin` lets a free GPU pick a job whose models it already has loaded, and `FIFO` turns that off. -This system uses stateless JWT tokens with HMAC signatures to identify users after they provide their initial credentials. The tokens will persist for 24 hours by default, or for 7 days if the user clicks the "Remember me" checkbox at login. Expired tokens are automatically rejected and the user will have to log in again. +As an administrator, the queue shows everyone's jobs in full, and each item's details name the user who queued it. You can cancel or clear anyone's jobs and pause or resume the queue. There is no filter by user. -At the client side, tokens are stored in browser localStorage. Logging out clears them. No server-side session storage is required. +Under **Settings → Server**, administrators can also choose **Generation devices** and the **Queue history** limit (finished items kept at startup; blank keeps all, `0` keeps none). Both take effect after a restart. -The tokens include the user's ID, email, admin status, and a revocation counter, along with an HMAC signature. +## Security -**Revocation:** A token proves _identity_ only — expiry is not the only thing that can end a session. Every authenticated request re-reads the account from the database, so administrative changes take effect on the target's next request, without waiting for their token to expire and without requiring them to log out: +| Topic | In short | +| --- | --- | +| [Passwords](#passwords) | Turn on `strict_password_checking` to require strong passwords. | +| [Sessions](#sessions) | A sign-in lasts 24 hours after the last change, or a week. Changing a password ends the account's other sessions. | +| [Signing key](#signing-key) | Anyone with a copy of the database can sign in as any user. Protect backups. | +| [Model downloads](#model-downloads) | The server refuses to download from private-network addresses. | +| [HTTPS and internet access](#https-and-internet-access) | Use HTTPS beyond `localhost`. Prefer a VPN to exposing InvokeAI to the internet. | -- **Role changes.** The admin status carried in a token is ignored; each request uses the role currently recorded for the account. A demoted administrator loses administrative endpoints immediately, and a promoted user gains them without logging in again. -- **Deactivation and deletion.** Requests from a deactivated or deleted account are rejected, its open connections are closed, and any of its queue items are canceled — a running one stops at the next node, or is canceled rather than recorded as completed if it was already on its last one, and pending ones are rejected when they reach the front of the queue. A generation that reports its progress (image generation does) stops part-way through the node instead of running it out. Work already completed is not undone. -- **Password changes.** Changing or resetting a password increments the account's revocation counter, which invalidates every token issued before it. All of that account's other sessions are signed out, including any stolen token. The session that performed the change is issued a replacement automatically and stays signed in. +### Passwords -Changes made through the web UI apply to open connections immediately. Changes made with the `invoke-usermod` and `invoke-userdel` commands run in a separate process from the server: everything the server derives per request (above) still changes the instant the command commits, but connections that are already open — and generations that are already running — are re-checked on a periodic sweep, so stopping them can take up to about a minute. The sweep covers a running generation whether or not its owner still has the browser open. +Set `strict_password_checking: true` to require at least 8 characters with an uppercase letter, a lowercase letter and a digit. It is off by default: then any non-empty password is accepted, and the setup, user and account forms show its strength as weak, moderate or strong. The command-line tools always require the strong form. Passwords are stored as bcrypt hashes. Users change their own password under **Account settings**, which asks for the current one. -If that sweep cannot read the database at all — the server is under enough load that its reads keep timing out, say — it does not assume the connections it cannot check are still entitled to what they hold. After a few consecutive failures, open connections lose their administrator privileges and stop receiving other users' events, while staying connected; the privilege comes back on its own once the record can be read again. An administrator who sees admin-only updates go quiet during a period of database trouble is seeing this, not a change to their account. +### Sessions -That safeguard covers administrator privileges specifically. Closing a deactivated or deleted account's connection still waits for a successful read, so while the database is unreadable such a connection keeps receiving that account's _own_ events until the sweep can confirm the change — every HTTP request it makes is rejected in the meantime, and it is closed as soon as a read succeeds. +A sign-in lasts 24 hours from the last change a user makes, or 7 days with **Keep me signed in for a week**. The session is a signed token (JWT) kept in the browser. Each action that changes something renews it; browsing alone does not. -Two limits are worth stating plainly. Revocation is enforced by this server, so a token remains cryptographically valid until it expires — anything that accepts these tokens without consulting the database would still honor it. And a session already in flight is stopped, not rewound: media already delivered to the client and outputs already written stay written. +Signing out deletes the token from that browser, but the server does not revoke it: to end every session of an account, change its password. Every request checks the account in the database, so the changes below apply without waiting for the token to expire. -### Secret Key Management +#### When changes take effect -**Important:** The JWT secret key must be kept confidential. +| Event | Effect | When | +| --- | --- | --- | +| Promote or demote | Gains or loses administrator access. The user stays signed in | Next request | +| Deactivate or delete | Requests are refused and open connections closed. Running jobs are canceled: they stop at the next step or node, and pending ones are dropped when they reach the front of the queue. Finished work stays | Immediately | +| Change or reset a password | Every other session of that account is signed out, including a stolen token. The session that made the change stays signed in | Immediately | +| Sign out | Removes the token from that browser only | Immediately | +| Rotate the signing key | Everyone is signed out | At restart | -To generate tokens, each InvokeAI instance has a distinct secret JWT -key that must be kept confidential. The key is stored in the -`app_settings` table of the InvokeAI database within a field value -named `jwt_secret`. +Changes made with `invoke-usermod` or `invoke-userdel` apply to new requests at once, but connections that are already open, and jobs already running, are re-checked about every 30 seconds, so stopping them can take up to about a minute. -The secret key is automatically generated during database creation or -migration. If you wish to change the key, you may generate a -replacement using either of these commands: +
+Limits of revocation -```bash -# Python -python -c "import secrets; print(secrets.token_urlsafe(32))" +- Revocation is enforced by this server. A revoked token stays cryptographically valid until it expires, so anything that accepts InvokeAI tokens without asking this server would still honor it. +- Stopping a job does not undo it: media already delivered and outputs already written stay. +- If the periodic check cannot read the database for three checks in a row, open connections lose administrator privileges and other users' events until it can read again. An administrator whose admin-only updates go quiet during database trouble is seeing this, not a change to their account. A deactivated or deleted account's connection still waits for a successful read before it closes, though every request it makes is already refused. -# OpenSSL -openssl rand -base64 32 -``` +
-Then cut and paste the printed secret into this Sqlite3 command: +### Signing key -```bash -sqlite3 INVOKE_ROOT/databases/invokeai.db 'update app_settings set value="THE_SECRET" where key="jwt_secret"' -``` +Tokens are signed with a random key stored in the database, in the `jwt_secret` row of the `app_settings` table, so anyone with a copy of the database can sign in as any user. Protect database backups like passwords. -(replace INVOKE_ROOT with your InvokeAI root directory and THE_SECRET with the new secret). With a [MySQL or MariaDB database](/configuration/external-database/), run the same `UPDATE` in the server's client instead, with the value in single quotes. +To rotate the key, which signs everyone out: -After this, restart the server. All logged in users will be logged out and will need to provide their usernames and passwords again. + -### Hosting a Shared InvokeAI Instance +1. Stop InvokeAI. -The multiuser feature allows you to run an InvokeAI backend that can be accessed by your friends and family across your home network. It is also possible to host a backend that is accessible over the Internet. +2. Generate a key: `python -c "import secrets; print(secrets.token_hex(32))"` or `openssl rand -hex 32`. -By default, InvokeAI runs on `localhost`, IP address `127.0.0.1`, which is only accessible to browsers running on the same machine as the backend. To make the backend accessible to any machine on your home or work LAN, add the line `host: 0.0.0.0` to the InvokeAI configuration file, usually stored at `INVOKE_ROOT/invokeai.yaml`. +3. Store it with this SQL, the key filled in. The backticks around `key` are needed in MySQL and MariaDB, where `KEY` is a reserved word; SQLite accepts them too. -Here is a minimal example. + ```sql + UPDATE app_settings SET value = '' WHERE `key` = 'jwt_secret'; + ``` -```yaml -# Internal metadata - do not edit: -schema_version: 4.0.2 + For SQLite, run it in `sqlite3 /databases/invokeai.db`. For a [MySQL or MariaDB database](/configuration/external-database/), run it in the server's client. -# Put user settings here - see https://invoke-ai.github.io/InvokeAI/configuration/: -multiuser: true -host: 0.0.0.0 -``` +4. Start InvokeAI. Everyone signs in again. -After relaunching the backend you will be able to reach the server from other machines on the LAN using the server machine's IP address or hostname and port 9090. + -#### Making InvokeAI Accessible to the Internet +### Model downloads -:::danger[Use at your own risk] -The InvokeAI team has done its best to make the software free of exploitable bugs, but the software has not undergone a rigorous security audit or intrusion testing. Use at your own risk. -::: +The server refuses to download from loopback, link-local and private-network addresses, so a model URL cannot reach services only the server can see. This covers every download the server makes for you, mainly model installs from a URL. Redirects are checked at every hop, so a public URL cannot bounce the server to a private address. -It is also possible to create a (semi) public server accessible from the Internet. The details of how to do this depend very much on your home or corporate router/firewall system and are beyond the scope of this document. +- **Local mirror:** set `allow_private_download_urls: true` to allow private addresses. +- **Proxy:** downloads ignore `HTTP_PROXY`, `HTTPS_PROXY` and `ALL_PROXY`. Set `download_proxy` to a proxy that itself blocks private addresses, because InvokeAI cannot check the addresses the proxy resolves. Don't turn on `allow_private_download_urls` just to make a proxy work; the two settings are independent. +- `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` and `.netrc` are still honored. -If you expose InvokeAI to the Internet, there are a number of precautions to take. Here is a brief list of recommended network security practices. +### HTTPS and internet access -**HTTPS Configuration:** +Sign-in sends passwords and tokens, so serve InvokeAI over HTTPS whenever people reach it at a network address. Browsers also turn off the clipboard and Web Locks for plain-HTTP pages at a network address; see [Known Bugs](/troubleshooting/known-bugs/). Two ways: -For internet deployments, always use HTTPS: +- **Built in:** set `ssl_certfile` and `ssl_keyfile` in `invokeai.yaml`. +- **Reverse proxy** such as nginx, Caddy or Traefik. To serve under a path such as `/invoke`, see `base_url` and `forwarded_allow_ips` in the [configuration reference](/configuration/invokeai-yaml/). -```nginx -# Use a reverse proxy like nginx or Traefik -# Example nginx configuration: +A minimal nginx site. The proxy must pass WebSocket upgrades, which carry the queue's live updates: +```nginx server { - listen 443 ssl http2; + listen 443 ssl; server_name invoke.example.com; - - ssl_certificate /path/to/cert.pem; + ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location / { - proxy_pass http://localhost:9090; + proxy_pass http://127.0.0.1:9090; proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - - # WebSocket support proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; @@ -467,310 +341,52 @@ server { } ``` -**Firewall Rules:** - -It is best to restrict access to trusted networks and remote IP addresses, or use a VPN to connect to your home network. Rate limit connections to InvokeAI's authentication endpoint `http://your.host:9090/api/v1/auth/login`. - -**Backup and Recovery:** - -It is always a good idea to periodically backup your InvokeAI database and images, but especially -so if the server is publicly accessible to the Internet. The commands below back up a SQLite database; back up a [MySQL or MariaDB database](/configuration/external-database/) with the server's own tools, such as `mysqldump` or `mariadb-dump`. - -**Manual Backup:** - -```bash -# Stop InvokeAI -# Copy database file -cd INVOKE_ROOT -cp databases/invokeai.db databases/invokeai.db.$(date +%Y%m%d) - -# Or create compressed backup -tar -czf invokeai_backup_$(date +%Y%m%d).tar.gz databases/ -``` - -**Automated Backup Script:** - -```bash -#!/bin/bash -# backup_invokeai.sh - -INVOKE_ROOT="/path/to/invoke_root" -BACKUP_DIR="/path/to/backups" -DB_PATH="$INVOKE_ROOT/databases/invokeai.db" -DATE=$(date +%Y%m%d_%H%M%S) - -# Create backup directory -mkdir -p "$BACKUP_DIR" - -# Copy database -cp "$DB_PATH" "$BACKUP_DIR/invokeai_$DATE.db" - -# Keep only last 30 days -find "$BACKUP_DIR" -name "invokeai_*.db" -mtime +30 -delete - -echo "Backup completed: invokeai_$DATE.db" -``` - -**Schedule with cron:** - -```bash -# Edit crontab -crontab -e - -# Add daily backup at 2 AM -0 2 * * * /path/to/backup_invokeai.sh -``` - -**Restore from Backup:** - -```bash -# Stop InvokeAI -# Replace current database with backup -cd INVOKE_ROOT -cp databases/invokeai.db databases/invokeai.db.old # Save current -cp databases/invokeai_backup.db databases/invokeai.db - -# Restart InvokeAI -invokeai-web -``` - -**Disaster Recovery — Complete System Backup:** - -Include these directories/files: - -- `databases/` — All database files -- `models/` — Installed models (if locally stored) -- `outputs/` — Generated images -- `invokeai.yaml` — Configuration file -- Any custom scripts or modifications - -**Recovery Process:** - - -1. Install InvokeAI on new system - -2. Restore configuration file - -3. Restore database directory - -4. Restore models and outputs - -5. Verify file permissions - -6. Start InvokeAI and test - - - -## Troubleshooting - -### User Cannot Login - -**Symptom:** User reports unable to log in - -**Diagnosis:** - -1. Verify account exists and is active - - ```bash - sqlite3 databases/invokeai.db "SELECT * FROM users WHERE email = 'user@example.com';" - ``` - -2. Check password (have user try resetting) -3. Verify account is active (`is_active = 1`) -4. Check for account lockout (if implemented) - -**Solutions:** - -- Reset user password -- Reactivate disabled account -- Verify email address is correct -- Check system logs for auth errors - -### Database Locked Errors - -**Symptom:** "Database is locked" errors - -**Causes:** - -- Concurrent write operations -- Long-running transactions -- Backup process accessing database -- File system issues - -**Solutions:** - -```bash -# Check for locks -fuser databases/invokeai.db - -# Increase timeout (in config) -# Or switch to WAL mode: -sqlite3 databases/invokeai.db "PRAGMA journal_mode=WAL;" -``` - -### Forgotten Admin Password - -**Recovery Process:** - - -1. Stop InvokeAI - -2. Direct database access: - - ```bash - sqlite3 databases/invokeai.db - ``` - -3. Reset admin password (requires password hash): - - ```sql - -- Generate hash first using Python (in the Invoke venv): - -- from invokeai.app.services.auth.password_utils import hash_password - -- print(hash_password("NewPassword123")) - - UPDATE users - SET password_hash = '$2b$12$...' - WHERE email = 'admin@example.com'; - ``` - -4. Restart InvokeAI - - - -:::note[Alternative Step 3] -Remove the admin from the database entirely in order -to trigger the setup process when InvokeAI restarts: - -```sql -DELETE FROM users -WHERE email = 'admin@example.com'; -``` +With the proxy on the same machine, keep InvokeAI's default `host: 127.0.0.1` so it is reachable only through the proxy. +:::danger[Exposing InvokeAI to the internet] +InvokeAI has had no security audit and has no login rate limiting or lockout. Prefer a VPN. If you must expose it, you do so at your own risk: serve it over HTTPS, limit access to trusted networks and addresses, and rate-limit `POST /api/v1/auth/login` at the proxy. Router and firewall setup is beyond this guide. ::: -### Performance Issues - -**Symptom:** Slow generation or UI - -**Diagnosis:** - - -1. Check active generation count - -2. Review resource usage (CPU/GPU/RAM) - -3. Check database size and performance - -4. Review network latency - - - -**Solutions:** - -- Limit concurrent generations -- Increase hardware resources -- Click Compact database in Settings > Data & workspace in webv2. This admin-only action can take a long time on large - databases and holds the shared SQLite lock until it completes. -- Use `ANALYZE` only when SQLite query-planning diagnostics call for it; the Compact database action does not expose this - operation. -- Add indexes for slow queries -- Consider load balancing - -### Migration Failures - -**Symptom:** Database migration fails on upgrade - -**Prevention:** - -- Always backup before upgrading -- Test migration on copy of database -- Review migration logs - -**Recovery:** - -```bash -# Restore backup -cp databases/invokeai.db.backup databases/invokeai.db - -# Try migration again with verbose logging -invokeai-web --log-level DEBUG -``` - -## Configuration Reference - -### Complete Configuration Example for a Public Site - -```yaml -# invokeai.yaml - Multi-user configuration +## Backups -# Internal metadata - do not edit: -schema_version: 4.0.2 +The `databases` folder holds the accounts, the signing key and every record; `outputs` holds the image and video files. -# Put user settings here -multiuser: true +- **What to copy:** `databases/`, `outputs/` and `invokeai.yaml`. `models/` is optional, since models can be downloaded again. +- **Stop InvokeAI first.** The database runs in write-ahead log mode, and a running server can hold recent changes in `invokeai.db-wal` beside the database file. Copy the whole folder, never the `.db` file alone while the server runs. +- **Automatic backups.** Before every database update, such as an upgrade, InvokeAI saves `databases/invokeai_backup_-.db`. Gallery maintenance saves its own in `databases/backup/`. InvokeAI never deletes either. +- **MySQL or MariaDB.** InvokeAI makes no backups of a server database. Use `mysqldump` or `mariadb-dump`. -# Server -host: '0.0.0.0' -port: 9090 +To restore, follow [Restore a backup](/start-here/upgrading/#restore-a-backup): it explains which file to pick and how to handle the `-wal` and `-shm` files. -# Performance -enable_partial_loading: true -precision: float16 -pytorch_cuda_alloc_conf: 'backend:cudaMallocAsync' -hashing_algorithm: blake3_multi -``` - -## Frequently Asked Questions - -### How many users can InvokeAI support? - -The backend will support dozens of concurrent users. However, because the image generation queue is single-threaded, only one job runs at a time. By default jobs are scheduled **round-robin** across users, so each user is served one job per turn and no single user can monopolize the queue with a large batch. You can switch to strict first-come, first-serve ordering with `session_queue_mode: FIFO` — see [Queue Scheduling](#queue-scheduling). - -A future version of InvokeAI may support concurrent execution on systems with multiple GPUs/graphics cards. - -### Can I integrate with existing authentication systems? - -OAuth2/OpenID Connect support is planned for a future release. Currently, InvokeAI uses its own authentication system. - -### How do I audit user actions? - -Full audit logging is planned for a future release. Currently, you can: - -- Monitor the generation queue -- Review database changes -- Check application logs - -### Can users have different model access? - -Currently all users can view and use all installed models. Per-user -model access is a possible enhancement. Please let the development -team know if you want this feature. - -### How do I handle user data when they leave? - -Best practice: - - -1. Deactivate the account first - -2. Transfer ownership of shared boards - -3. After transition period, delete the account - -4. Or keep the account deactivated for audit purposes - - - -### What's the licensing impact of multi-user mode? - -InvokeAI remains under its existing license. Multi-user mode does not change licensing terms. - -## Getting Help - -### Support +## Troubleshooting -- **General Documentation**: [InvokeAI Docs](https://invoke.ai/) -- **User Guide**: [For Users](/configuration/multi-user-mode/user-guide/) -- **API Guide**: [For Developers](/configuration/multi-user-mode/api-guide/) -- **Discord**: [Join Community](https://discord.gg/ZmtBAhwWhy) -- **GitHub Issues**: [Report Problems](https://github.com/invoke-ai/InvokeAI/issues) +| Symptom | Fix | +| --- | --- | +| "Incorrect email or password" | Check the address with `invoke-userlist`, then reset the password from **Users** or with `invoke-usermod --email --password `. | +| "User account is disabled" | Turn the user's **Active** switch back on in **Users**. | +| Lost the only administrator password | `invoke-usermod --email --password `. It works with the server stopped or running. | +| "Cannot remove the last administrator" | Promote another account first. | +| "The system user cannot be deleted, deactivated, promoted to administrator, or given a password" | `system` owns the content created in single-user mode. Leave it as it is. | +| **Set up Invoke** appears again | The server found no active administrator in its database. Check that it uses the same root and database as before (`--root`, `INVOKEAI_ROOT`, `db_dir`, `db_url`). | +| Other computers cannot connect | Set `host: 0.0.0.0`, restart, and allow port 9090 (or your `port`) through the server's firewall. | +| "Database is locked" | InvokeAI already uses write-ahead logging and waits 5 seconds for a lock. Another program is holding the database: an open `sqlite3` shell, a backup or sync tool. Find it with `fuser databases/invokeai.db` on Linux. Keep the database on a local disk: write-ahead logging may not engage on network file systems. | +| Database update fails at startup | Set `log_level: debug` in `invokeai.yaml` and start again to see the error. With SQLite, the database was backed up first and each migration rolls back when it fails; see [Upgrading](/start-here/upgrading/) to restore the backup. With MySQL or MariaDB there is no automatic backup, and a failed migration's changes are not rolled back: it runs again from the start at the next start. Restore your own backup if it keeps failing. | +| Slow on a large library | An administrator can run **Compact database** under **Settings → Data & workspace**. It can take a long time and pauses database activity while it runs. | + +## Not supported + +| Feature | Status | +| --- | --- | +| Single sign-on (OAuth, OpenID Connect, LDAP) | InvokeAI has its own accounts only. | +| Per-user model access | Every user can use every installed model. | +| Audit log | None. The queue records who queued each job, and **Users** shows each account's last sign-in. | +| Login rate limiting, lockout, password expiry | None. Add rate limiting at a reverse proxy. | + +## More + + + + + + +Ask on [Discord](https://discord.gg/ZmtBAhwWhy) or report problems on [GitHub](https://github.com/invoke-ai/InvokeAI/issues). diff --git a/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-add-user-1.png b/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-add-user-1.png deleted file mode 100644 index 706039d50cb..00000000000 Binary files a/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-add-user-1.png and /dev/null differ diff --git a/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-add-user-2.png b/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-add-user-2.png deleted file mode 100644 index 44bf2e180a3..00000000000 Binary files a/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-add-user-2.png and /dev/null differ diff --git a/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-add-user-3.png b/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-add-user-3.png deleted file mode 100644 index 708f9a85135..00000000000 Binary files a/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-add-user-3.png and /dev/null differ diff --git a/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-setup.png b/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-setup.png deleted file mode 100644 index fbe035e5c6e..00000000000 Binary files a/docs/src/content/docs/configuration/Multi-User Mode/assets/admin-setup.png and /dev/null differ diff --git a/docs/src/content/docs/configuration/Multi-User Mode/assets/user-login-1.png b/docs/src/content/docs/configuration/Multi-User Mode/assets/user-login-1.png deleted file mode 100644 index 8c4bec22943..00000000000 Binary files a/docs/src/content/docs/configuration/Multi-User Mode/assets/user-login-1.png and /dev/null differ diff --git a/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx index 97802a9d74e..ad4e206921c 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx @@ -1,399 +1,117 @@ --- title: Multi-User Guide -description: How to use InvokeAI in multi-user mode as an end user. +description: Sign in to a shared InvokeAI server, share boards and workflows, and work alongside other users. +lastUpdated: 2026-10-26 sidebar: order: 3 --- -import { Steps } from '@astrojs/starlight/components'; +import { LinkCard, Badge } from '@astrojs/starlight/components'; -## Overview +In multi-user mode, several people share one InvokeAI server. Everyone signs in with their own account and has their own projects, boards, workflows and settings. This guide is for everyone who uses such a server. -Multi-User mode is a recent feature (introduced in version 6.12), which allows multiple individuals to share a single InvokeAI server while keeping their work separate and organized. Each user has their own username and login password, images, assets, image boards, customization settings and workflows. + -Two types of users are recognized: +## Signing in -- A user with **Administrator** status can add, remove and modify other users, and can install models. They also have the ability to view the full session queue and pause or kill other users' jobs. -- **Non-administrator** users can modify their own profile but not others. They also do not have the ability to install or configure models, but must ask an Administrator to do this task. When viewing the generation queue, they can see the full details of their own jobs, but jobs owned by other users will have the user id, generation parameters, and other details redacted. +Your administrator gives you an email address and a password. Open the server's address in your browser and enter them on **Welcome to Invoke**. Select **Keep me signed in for a week** on a computer only you use. -Multiple users can be granted Administrator status. +You stay signed in for 24 hours after your last change, or a week with **Keep me signed in for a week**. You can sign in from several browsers and devices at once. ---- - -## Getting Started - -To activate Multi-User mode, open the `INVOKEAI_ROOT/invokeai.yaml` configuration file in a text editor. Add this line anywhere in the file: - -```yaml -multiuser: true -``` - -You may also wish to make InvokeAI available to other machines on your local LAN. Add an additional line to `invokeai.yaml`: - -```yaml -host: 0.0.0.0 -``` - -Restart the server. It will now be in multi-user mode. If you enabled the `host` option, other users on your home or office LAN will be able to reach it by browsing to the IP address of the machine the backend is running on (`http://host-ip-address:9090`). - -:::tip[Do not expose InvokeAI to the internet] -It is not recommended to expose the InvokeAI host to the internet due to security concerns. -::: - -### Initial Setup (First Time in Multi-User Mode) - -If you're the first person to access a fresh InvokeAI installation in multi-user mode, you'll see the **Administrator Setup** dialog: - -![Administrator Setup Screen](./assets/admin-setup.png) - -Now: - - - -1. Enter your email address (this will be your login name). - -2. Create a display name (this will be the name other users see). - -3. Choose a strong password. With `strict_password_checking: true`, it must have at least 8 characters, including uppercase letters, lowercase letters, and numbers. - -4. Confirm your password. - -5. Click **Create Administrator Account**. - - - -With `strict_password_checking` disabled, you'll be warned if you choose a -weak password, but not prevented from doing so. - -You'll now be taken to a login screen and can enter the credentials you just created. - -### Adding and Modifying Users - -If you are logged in as Administrator, you can add additional users. Click on the small "person silhouette" icon at the bottom left of the main Invoke screen and select "User Management" - -![Administrator Menu](./assets/admin-add-user-1.png) - -This will take you to the User Management screen... - -![User Management screen](./assets/admin-add-user-2.png) - -...where you can click "Create User" to add a new user. - -![Add User Screen](./assets/admin-add-user-3.png) - -The User Management screen also allows you to: - - - -1. Temporarily change a user's status to Inactive, preventing them from logging in to Invoke. - -2. Edit a user (by clicking on the pencil icon) to change the user's display name or password. - -3. Permanently delete a user. - -4. Grant a user Administrator privileges. +### Your account - +Open the account menu: your name in the Launchpad's top bar, or the app menu in a project. It has: ---- - -## Logging in as a Non-Administrative User - -If you are a registered user on the system, enter your email address and password to log in. The Administrator will be able to provide you with the values to use: - -![Login Screen](./assets/user-login-1.png) - -As an unprivileged user you can do pretty much anything that's allowed under single-user mode — generating images, using LoRAs, creating and running workflows, creating image boards — but you are restricted against installing new models, changing low-level server settings, or interfering with other users. More information on user roles is given below. - -### Changing your Profile - -To change your display name or profile, click on the person silhouette icon at the bottom left of the screen and choose "My Profile". This will take you to a screen that lets you change these values. At this time you can change your display name but not your login ID (ordinarily your contact email address). - ---- - -## Understanding User Roles - -In single-user mode, you have access to all features without restrictions. In multi-user mode, InvokeAI has two user roles: - -### Regular User - -As a regular user, you can: - -- Create and manage your own image boards -- Generate images using all AI tools (Linear, Canvas, Upscale, Workflows) -- Create, save, and load your own workflows -- View the full details of jobs you own on the session queue -- View redacted information for jobs being run by other users -- Customize your UI preferences (theme, hotkeys, etc.) -- View available models (read-only access to Model Manager) -- Open boards other users have given them access to, and add to the ones they can contribute to -- Browse public workflows in the **Shared** library tab, including your own public workflows - -You cannot: - -- Add, delete, or modify models -- View or modify other users' private boards, images, or workflows -- Manage user accounts -- Access system configuration -- Cancel other users' generation jobs - -:::tip[The generation queue] -When two or more users are accessing InvokeAI at the same time, their image generation jobs share a single session queue. By default the server schedules jobs **round-robin** across users: each user gets one turn before anyone gets a second turn. This means a user who enqueues a large batch can no longer monopolize the queue — your jobs are interleaved with theirs rather than waiting for their entire batch to drain first. (An administrator can switch the server back to strict first-come, first-serve ordering; see the admin guide.) +- **Account settings**: change your display name, which other users see instead of your email, and your password. Changing the password needs your current one, and signs out your other browsers and devices. Your email cannot be changed. +- **Manage users**: shown only to administrators, who also see an **Admin** badge under their name. +- **Sign out**. -While other users' jobs are running you will see the shared image generation progress bar, and the queue badge shows **`your jobs / all jobs`** — for example `2/5` means 2 of the 5 pending-or-in-progress jobs are yours. (In single-user mode the badge shows just a single total.) +Your preferences, such as theme and hotkeys, are saved to your account and follow you to other browsers. -Open the Queue tab to see where your job sits in relation to the other queued tasks. -::: +## What you can do -### Administrator +| | User | Administrator | +| --- | --- | --- | +| Create projects, boards and workflows; generate with every installed model | ✓ | ✓ | +| Open boards that others share with you | ✓ | ✓ | +| See your own queue items in full and cancel them | ✓ | ✓ | +| See other users' queue items | Status, GPU and times only | In full | +| Cancel other users' jobs, pause the queue | — | ✓ | +| Open every user's boards and Library workflows | — | ✓ | +| Install and edit models and custom nodes (Launchpad **Models** and **Nodes**) | — | ✓ | +| Manage users and server settings | — | ✓ | -Administrators have all regular user capabilities, plus: - -- Full model management (add, delete, configure models) -- Create and manage user accounts -- View and manage all users' generation queues -- View all users' workflows in the Library; rename, delete, and visibility controls are shown only to each workflow's owner -- Access system configuration -- Grant or revoke admin privileges - ---- +To become an administrator, ask an administrator to give you the role. ## Working with Your Content in Multi-User Mode ### Image Boards -In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit, in the Library or inside a project. What another user may do with your board depends on their **role** on it: - -| Role | Can | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| **Viewer** | Browse the board, open and download its images and videos, copy them to their own boards, and use them in their own generations. | -| **Contributor** | Everything a viewer can, and add their own images and videos to the board. They can remove, star or delete only what they added. | -| **Editor** | Everything a contributor can, and remove or star anyone's items, rename the board, set its cover and archive it. | -| **Owner** | Everything, including changing who has access, moving the board between the Library and a project, and deleting it. | - -A board's **general access** sets the role every other user gets: +Your boards are yours, in the Library or in a project. Other users can open one only if you give them access, and what they can do depends on their role: -- **Restricted** (default): no one else can open the board. -- **Everyone can view**: every user is a viewer. -- **Everyone can contribute**: every user is a contributor. +| Role | Shown as | Can | +| --- | --- | --- | +| **Viewer** | Can view | Browse the board, open and download its images and videos, copy them to their own boards, and use them in their own generations. | +| **Contributor** | Can add | Everything a viewer can, and add their own images and videos. They can remove, star or delete only what they added. | +| **Editor** | Can edit | Everything a contributor can, and remove or star anyone's items, rename the board, set its cover and archive it. Nothing in the interface grants this role yet. | +| **Owner** | | Everything, including changing who has access, moving the board between the Library and a project, and deleting it. | -This release does not yet let an owner give a role to one user at a time, so general access is how other users get access to a board. To change it, open the board's menu (**⋮** on the board, or right-click it) and choose **Access**, then **Restricted**, **Everyone can view** or **Everyone can contribute**. The change applies at once. +A board's **general access** sets the role every other user gets. Change it from the board's menu (**⋮** on the board, or right-click it): **Access**, then one of: -Boards other users let you open appear in the Gallery's board list under **Shared with me**, each with its owner and what you can do, for example **Ana · Can view**. Searching the board list also finds them by their owner's name. A shared board's menu starts with whose board it is and what you can do there, and it offers only the actions your role allows: on a board you can only view, you can browse and download, and the menu says whom to ask for contributor access. +- The default. Only you and administrators can open the board. +- Every signed-in user is a viewer. +- Every signed-in user is a contributor. -You cannot add media to a board you can only view. It is not offered when moving media, dropping media on it does nothing, and uploading to it is refused. If it is the selected board (or the auto-add board), new results go to your project's **Inbox** instead, which is marked **Auto**. +The change applies at once. You cannot yet give a role to one person at a time. If a database upgraded from v6 held per-person board shares, each of those people became a viewer and sees the board under **Shared with me**, even if its general access is **Restricted**. These per-person roles cannot be listed or removed in the interface yet. -If your access to a board changes while you have Invoke open, the board list catches up when it next refreshes: a board you can no longer open leaves it, and new results stop going to it. A change you make from the board's menu in the meantime is refused, and a notice says your access changed. Moving or uploading media to it is refused with the server's reason. +Boards others open to you appear in the Gallery under **Shared with me**, with their owner and your role, for example **Ana · Can view**. Searching the board list also finds them by owner name. -Only the person who added an image or video, or an administrator, can delete it permanently. When an owner or editor removes someone else's item from a board, it returns to that person's **Uncategorized** board. Deleting a board together with its media likewise deletes only your own media, whether or not you are an administrator; other people's items return to their Uncategorized boards. Only an administrator deleting another user's board deletes everything on it. +
+Edge cases -A project's **Inbox** stays restricted; its access follows the project. Other boards inside a project can have any general access, and they keep it when they move between the Library and a project. +- **View-only boards.** You cannot add media to a board you can only view: it is not offered when moving media, dropping media on it does nothing, and uploads to it are refused. Its menu says whom to ask for access. If it is your selected or auto-add board, new results go to your project's **Inbox** instead, marked **Auto**. +- **Access changes while you work.** The board list catches up at its next refresh: a board you can no longer open leaves it, and new results stop going to it. Until then, changes you make from its menu, moves and uploads are refused with a notice. A board you lose and later regain access to comes back as it was. +- **Deleting.** Only the person who added an image or video, or an administrator, can delete it permanently. When an owner or editor removes someone else's item from a board, it returns to that person's **Uncategorized** board. Deleting a board with its media deletes only your own media, even for an administrator; other people's items return to their Uncategorized boards. Only an administrator deleting another user's board deletes everything on it. +- **Projects.** A project's **Inbox** stays restricted, and its access follows the project. Other boards in a project can have any general access, and keep it when they move between the Library and a project. +- **Administrators** can open and manage every user's boards, and change any board's general access. They find boards that are not shared with them under **Other users' boards**, after **Shared with me**. -A board you cannot open is refused, not reported as missing, so a board whose access you lose and later regain comes back as it was. Administrators can open and manage every user's boards; a board's owner and administrators are the only ones who can change its general access. Administrators find other users' boards under **Other users' boards**, after **Shared with me**, closed until they open it; a board another user shares with everyone appears under **Shared with me** instead. - -### Going From Multi-User to Single-User Mode - -If an InvokeAI instance was in multiuser mode and then restarted in single user mode (by setting `multiuser: false` in the configuration file), all users' boards will be consolidated in one place. Any images that were in "Uncategorized" will be merged together into a single Uncategorized board. If, at a later date, the server is restarted in multi-user mode, the boards and images will be assigned to the internal 'system' user. Admins can access this legacy content, and will not be restored to original owners. +
### Workflows -In multi-user mode, new workflows saved to the Library are private by default. To change a saved workflow's visibility, open **Yours**, select it, and use **Visibility** in the detail panel to choose **Private** or **Public**. A public workflow is visible to signed-in users of this same InvokeAI instance; it is not published to the public internet. - -You can also choose **Public workflow** when using **Save to Library** to create a public Library copy of a project workflow. This does not make the project-only workflow public. Changing a Library record's visibility does not publish unsaved project edits; use **Update Template** to update the Library copy's content. **Update Template** preserves that copy's visibility. - -Public workflows appear in **Shared**, including your own public workflows. Callable public workflows can also appear in the **Call Saved Workflow** picker. +Library workflows have a visibility: -Other users can browse, open, duplicate, or download your public workflows. In the Library, only the workflow's owner is offered rename, delete, and visibility controls. +| Visibility | Who can see it | Who can change it | +| --- | --- | --- | +| **Private** (default for new workflows) | You and administrators | You | +| **Public** | Every signed-in user of this server, under **Shared**. It is not published to the internet | You; others can open, duplicate or download it | +| Saved in single-user mode, or before workflows had owners | Every signed-in user, under **Shared** | No one is offered rename, delete or visibility controls; anyone can open, duplicate or download it | -:::note[Preexisting Library workflows] -User workflows already in the Library before per-user workflow ownership was introduced are migrated as public records owned by the internal system account. They appear in **Shared**. The Library does not offer rename, delete, or visibility controls for these records to other accounts, but users can still open, duplicate, or download them. -::: +- To change visibility, select the workflow under **Yours** and use **Visibility** in its detail panel. +- **Save to library…** has a **Public workflow** option for the new Library copy. It does not make the project's own workflow public. +- Changing visibility does not publish unsaved project edits. **Update library template** writes them to the Library copy and keeps its visibility. Only the template's owner or an administrator can update it; anyone else can use **Save as new** to create their own. +- Public workflows that can be called from other workflows also appear in the **Call Saved Workflow** node's picker. ### The Generation Queue -The queue shows your pending and running generation tasks. - -**Queue Features:** - -- View your current and completed generations -- Cancel pending tasks -- Re-run previous generations -- Monitor progress in real-time - -**Queue Isolation:** - -- You will see your own queue items, as well as the items generated by other users, but the generation parameters (e.g. prompts) for other users' jobs are hidden for privacy reasons. -- Administrators can view all queues for troubleshooting. -- Your generations won't interfere with other users' tasks. - ---- - -## Customizing Your Experience - -### Personal Preferences +Everyone's jobs share one queue. By default, users take turns: each user gets one job before anyone gets a second, so a large batch from someone else does not hold yours up. Your administrator can change this; see [The shared queue](/configuration/multi-user-mode/admin-guide/#the-shared-queue). -Your UI preferences are saved to your account and are restored when you log in: +You see your own jobs in full and can cancel them. Other users' jobs show only their status, GPU and times: no prompts, images, or who queued them. -- **Hotkeys**: Customize keyboard shortcuts -- **Canvas Settings**: Default zoom, grid visibility, etc. -- **Generation Defaults**: Default values for width, height, steps, etc. +### Switching Between Single-User and Multi-User Mode -These settings are stored per-user and won't affect other users. - ---- +Switching modes does not change who owns what, but it changes who can see it. The administrator guide lists [what happens to existing data](/configuration/multi-user-mode/admin-guide/#what-happens-to-existing-data). ## Troubleshooting -### Cannot Log In - -**Issue:** Login fails with "Incorrect email or password" - -**Solutions:** - -- Verify you're entering the correct email address -- Check that Caps Lock is off -- Try typing the password slowly to avoid mistakes -- Contact your administrator if you've forgotten your password - -**Issue:** Login fails with "Account is disabled" - -**Solution:** Contact your administrator to reactivate your account - -### Session Expired - -**Issue:** You're suddenly logged out and see "Session expired" - -**Explanation:** Sessions expire after 24 hours (or 7 days with "remember me") - -**Solution:** Simply log in again with your credentials - -### Cannot Access Features - -**Issue:** Features like Model Manager show "Admin privileges required" - -**Explanation:** Some features are restricted to administrators - -**Solution:** - -- For model viewing: You can view but not modify models -- For user management: Contact an administrator -- For system configuration: Contact an administrator - -### Missing Boards or Images - -**Issue:** Boards or images you created are not visible - -**Possible Causes:** - - - -1. **Filter Applied:** Check if a filter is hiding content. - -2. **Wrong User:** Ensure you're logged in with the correct account. - -3. **Archived Board:** Check the "Show Archived" option. - - - -**Solution:** - -- Clear any active filters -- Verify you're logged in as the right user -- Check archived items - -### Slow Performance - -**Issue:** Generation or UI feels slower than expected - -**Possible Causes:** - -- Other users generating images simultaneously -- Server resource limits -- Network latency - -**Solutions:** - -- Check the queue to see if others are generating -- Wait for current generations to complete -- Contact administrator if persistent - -### Generation Stuck in Queue - -**Issue:** Your generation is queued but not starting - -**Possible Causes:** - -- Server is processing other users' generations -- Server resources are fully utilized -- Technical issue with the server - -**Solutions:** - -- Wait for your turn in the queue -- Check if your generation is paused -- Contact administrator if stuck for extended period - ---- - -## Frequently Asked Questions - -### Can other users see my images? - -Not unless you give them access. Boards start **restricted**: only you and administrators can open them. A board set to **Everyone can view** or **Everyone can contribute** is open to every user. - -### Can I share my workflows with others? - -Yes. In multi-user mode, make a saved Library workflow public from **Yours**, or choose **Public workflow** when saving a new Library copy. Public workflows are visible to signed-in users of the same InvokeAI instance and appear in **Shared**. Other users can open, duplicate, or download them; only the owner is offered rename, delete, and visibility controls in the Library. For a project copy linked to a public workflow, its owner or a ready administrator can use **Update Template**; other users can choose **Save as new** to create their own Library workflow. - -### How long do sessions last? - -- 24 hours by default -- 7 days if you check "Remember me" during login - -### Can I use the API with multi-user mode? - -Yes, but you'll need to authenticate with a JWT token. See the [API Guide](/configuration/multi-user-mode/api-guide/) for details. - -### What happens if I forget my password? - -Contact your administrator. They can reset your password for you. - -### Can I have multiple sessions? - -Yes, you can log in from multiple devices or browsers simultaneously. All sessions will use the same account and see the same content. - -### Why can't I see the Model Manager "Add Models" tab? - -Regular users can see the Models tab but with read-only access. Check that you're logged in and try refreshing the page. - -### How do I know if I'm an administrator? - -Click the user icon near the bottom of the left-hand navigation bar to open the user menu. If you are an administrator, an "Admin" badge appears under your name in that menu and a "User Management" item is shown alongside the usual Profile and Logout actions. - -### Can I request admin privileges? - -Yes, ask your current administrator to grant you admin privileges. Admin privileges will give you the ability to see all other users' boards and images, as well as to add models and change various server-wide settings. - -## Getting Help - -### Support Channels - -- **Administrator:** Contact your system administrator for account issues -- **Documentation:** Check the [FAQ](/troubleshooting/faq/) for common issues -- **Community:** Join the [Discord](https://discord.gg/ZmtBAhwWhy) for help -- **Bug Reports:** File issues on [GitHub](https://github.com/invoke-ai/InvokeAI/issues) - -### Reporting Issues - -When reporting an issue, include: - -- Your role (regular user or administrator) -- What you were trying to do -- What happened instead -- Any error messages you saw -- Your browser and operating system - -## Additional Resources - -- [Administrator Guide](/configuration/multi-user-mode/admin-guide/) — For administrators managing users and the system -- [API Guide](/configuration/multi-user-mode/api-guide/) — For developers using the InvokeAI API +| Symptom | Fix | +| --- | --- | +| "Incorrect email or password" | Check the email address and Caps Lock. If you forgot your password, ask an administrator to reset it. | +| "User account is disabled" | An administrator deactivated your account. Ask one to reactivate it. | +| "Your session expired. Sign in again to continue." | You were inactive for 24 hours (a week with **Keep me signed in for a week**), your password was changed, or your account was deactivated or deleted. Sign in again; a deactivated account shows "User account is disabled". | +| No **Models**, **Nodes** or **Users** in the Launchpad | They are for administrators. You can still generate with every installed model; ask an administrator to install or change one. | +| A board is missing | Open **Filter & sort boards** in the Gallery and turn on **Archived boards** or **Other projects' boards**. Check that you are signed in to the right account. | +| A shared board disappeared | Its owner changed its access or deleted it. | +| Your job waits in the queue | Other users' jobs are running, or an administrator paused the queue. Your turn comes in rotation. | + +To use the API from scripts, see the [Multi-User API Guide](/configuration/multi-user-mode/api-guide/). For other problems, ask your administrator, the [Discord](https://discord.gg/ZmtBAhwWhy) community, or [file an issue](https://github.com/invoke-ai/InvokeAI/issues). diff --git a/docs/src/content/docs/configuration/Optimization/fp8-compute.mdx b/docs/src/content/docs/configuration/Optimization/fp8-compute.mdx new file mode 100644 index 00000000000..03ca27de0eb --- /dev/null +++ b/docs/src/content/docs/configuration/Optimization/fp8-compute.mdx @@ -0,0 +1,74 @@ +--- +title: FP8 Compute +description: Run checkpoints published in FP8 on the FP8 tensor cores of RTX 40-series and newer GPUs, and what it means for repeatable images. +lastUpdated: 2026-10-26 +sidebar: + order: 7 +--- + +[FP8 Storage](/configuration/optimization/fp8-storage/) changes how weights are **stored**, while the math still runs in +BF16. `fp8_compute` is a separate, global setting that also does the **math** in FP8, on GPUs that have hardware for it. +That makes generation faster, not just smaller. + +It is for models that were **already saved in FP8** by whoever published them, often labelled "fp8" or "fp8_scaled" in +the filename. With `fp8_compute` on, their FP8 layers stay as they are and run directly on the GPU's FP8 tensor cores. +With only FP8 Storage on (which installing such a checkpoint does for you), they also stay in FP8, but each layer is +unpacked to BF16 before it runs. ComfyUI's int8 builds have a counterpart of their own, +[int8 Compute](/configuration/optimization/int8-compute/). + +## Requirements + +- **An NVIDIA GPU with compute capability 8.9 or newer**: RTX 40-series, RTX 50-series, and the datacenter cards of + those generations and later (such as the H100), **or an AMD GPU whose ROCm build supports FP8 matrix multiplication** + (such as the MI300). +- **A model whose weights are stored in fp8**: plain fp8 or scaled fp8. MXFP8 checkpoints are unpacked at load and + never run on the FP8 tensor cores. + +InvokeAI checks the card the first time it needs to, by running a small FP8 matrix multiplication rather than going by +the card's name, so a card that can't do it quietly takes the normal path instead of failing partway through a +generation. Intel GPUs, Apple silicon and the CPU always take the normal path. Layers whose input or output size is not +divisible by 16 take it too. + +## Turning it on + +Set it in your [`invokeai.yaml`](/configuration/invokeai-yaml/) and restart InvokeAI: + +```yaml +fp8_compute: true +``` + +The model's load line then reports the layers it kept for the tensor cores, for example +`Ideogram 4: kept 208 layer(s) in fp8 (scaled fp8 checkpoint, kept for fp8_compute, on the fp8 tensor cores)`. + +:::danger[The same seed only reproduces the same image if the model is fully in VRAM] +FP8 math only happens for the parts of the model that are on the GPU. Anything still in system RAM takes the normal +path instead, which gives slightly different numbers, and *which* parts that affects depends on how much of the model +happened to fit at that moment, which changes from run to run. + +Measured on a 24GB card with a ~12GB model at 88–95% loaded, two runs with an identical seed and identical settings +differed in **98.7% of all pixels**. With the model fully loaded, repeated runs came out **identical**. + +For repeatable output, check the model's load line in the log for `VRAM: … (100.0%)`. If it is below that, free up +VRAM (lower the resolution, load fewer models at once, use a smaller text encoder) or set +`enable_partial_loading: false`. +::: + +Keeping the whole model on the GPU pays off in speed as well: on the same 24GB card, streaming the last 5–12% of the +model over PCIe cost **47% more time per step** (1.03 → 1.51 s/it at 1024², 8 steps). + +Images also differ from those made without `fp8_compute` at the same seed, because the inputs of each layer are +quantized to FP8 too. + +## When the model asks for full precision + +Some FP8 checkpoints mark layers that should not use FP8 math (`full_precision_matrix_mult`). InvokeAI follows those +marks by default, so those layers run the slower way; on a model that marks many layers, that can eat most of the +speedup. + +```yaml +fp8_compute_full_precision_hints: false +``` + +ignores the marks and runs every quantized layer on the FP8 tensor cores. It is faster, but it overrides the model +author's judgement about which layers are sensitive: compare a few images before keeping it. It has no effect unless +`fp8_compute` is on. diff --git a/docs/src/content/docs/configuration/Optimization/fp8-storage.mdx b/docs/src/content/docs/configuration/Optimization/fp8-storage.mdx index e28e20695a9..2a2a25df8a2 100644 --- a/docs/src/content/docs/configuration/Optimization/fp8-storage.mdx +++ b/docs/src/content/docs/configuration/Optimization/fp8-storage.mdx @@ -1,205 +1,213 @@ --- -title: FP8 Storage & Compute +title: FP8 Storage +description: Halve a full-precision model's VRAM by keeping its weights in FP8 on the GPU, per model, from the Model Manager. +lastUpdated: 2026-10-26 sidebar: order: 3 --- -import { Steps } from '@astrojs/starlight/components'; +import { Steps, Tabs, TabItem } from '@astrojs/starlight/components'; -FP8 Storage cuts a model's VRAM footprint roughly in half by keeping weights on the GPU in 8-bit floating-point format (`float8_e4m3fn`). During inference, each layer's weights are cast on-the-fly up to the compute precision (FP16/BF16), and the stored FP8 weights are put back after the forward pass — so quality is largely preserved. +FP8 Storage cuts a model's VRAM roughly in half by keeping its weights on the GPU in 8-bit floating point +(`float8_e4m3fn`). Before each layer runs, its weights are cast up to the compute precision (FP16 or BF16), and the +stored FP8 weights are put back afterwards, so quality is largely preserved. Measured on Wan 2.2 TI2V-5B (fp16 single +file, 512², 20 steps, RTX 4090), the transformer drops from 9.5GB to 4.9GB in VRAM, at 21.4s per video instead of +18.4s. -It pairs well with [Low-VRAM mode](/configuration/optimization/low-vram-mode/): low-VRAM mode streams layers between RAM and VRAM, while FP8 Storage shrinks the layers themselves. +It is a per-model setting for full-precision models, and it pairs with [Low-VRAM mode](/configuration/optimization/low-vram-mode/): +low-VRAM mode streams layers between RAM and VRAM, FP8 Storage shrinks the layers themselves. How it compares with +quantized downloads is in the [format comparison](/configuration/optimization/memory-options/#format-comparison). +To also run the math in FP8 on the GPU's FP8 hardware, see [FP8 Compute](/configuration/optimization/fp8-compute/). -:::caution[For full precision models only] -FP8 Storage is meant for **full precision** checkpoints (FP16 / BF16 / FP32). It does nothing for **GGUF**, **NF4** and [**SDNQ**](/configuration/optimization/sdnq-quantization/) checkpoints, which carry their own storage precision, so the Model Manager does not show the toggle for them (see [Where the toggle is shown](#where-the-toggle-is-shown)). **int8** and **nvfp4** single-file checkpoints keep their quantized layers as they are — the loader returns a module type the FP8 layer cast does not touch — so there the toggle reaches only the layers the build ships in full precision, and on an **Ideogram 4** or **Qwen-Image-2.1** int8 file nothing at all. On single-file checkpoints that ship *scaled* fp8 layers, the toggle keeps those layers in FP8 with their own scale instead of unpacking them; that is what FLUX.1, FLUX.2, Z-Image, Anima, Krea-2, Qwen-Image, Qwen-Image-2.1 and Ideogram 4 all do. **MXFP8 is the exception, and there the toggle costs quality:** those files carry one exponent per 32-element block, which no GPU before Blackwell can apply, so the layers are unpacked at load and the toggle then re-encodes them as fp8 *without* any scale. Blocks whose scale sits far below 1 lose their weights to underflow. Leave it off for an MXFP8 checkpoint — and because installing switches it on for every checkpoint with FP8 weights, MXFP8 included, turn it off under **Default Settings** after installing one. If your model is already quantized, use its full-precision variant to get the full effect of FP8 Storage. Checkpoints that store FP8 weights are the exception: InvokeAI loads them at BF16 unless FP8 Storage is on, so they get it automatically when installed (see [When installing](#when-installing)). +:::caution[Turn it off for MXFP8 checkpoints] +Installing switches FP8 Storage on for every checkpoint that stores FP8 weights, MXFP8 included. For MXFP8 it costs +quality: see [By file format](#by-file-format). Turn it off under **Default Settings** after installing one. ::: -## Requirements +## What it applies to -- **A CUDA or Intel XPU device.** Before first use InvokeAI runs a small FP8 test on the device. If the device, driver or PyTorch build cannot store and upcast `float8_e4m3fn` (some older AMD ROCm GPUs, for example), the test fails, InvokeAI logs a warning and loads the model without FP8. CPU and MPS are never used for FP8 Storage. -- **Recent PyTorch.** The `float8_e4m3fn` dtype was added in PyTorch 2.1 — InvokeAI's bundled versions satisfy this. +The Model Manager shows the **FP8 storage** setting only for models where it changes something. A model installed +before this rule may still have it stored as on; it then does nothing, and the log says so once when the model loads: +`FP8 Storage is set for '' but does nothing here: `. -There is no hardware requirement for FP8 *compute* — InvokeAI casts back to FP16/BF16 for math. This means FP8 Storage works on GPUs that do not natively support FP8 matmul (e.g. RTX 30-series), at a small per-step throughput cost. +### By file format -## Hardware support tiers - -InvokeAI's FP8 path stores weights in FP8 and casts them back to BF16/FP16 on each forward pass via its own `register_forward_pre_hook` / `register_forward_hook` wrappers (the same skip list as diffusers' `apply_layerwise_casting`, but applied to every `nn.Module` — including diffusers `ModelMixin` subclasses — so it composes correctly with InvokeAI's `CustomLinear` and partial loading). The practical benefit of toggling FP8 Storage depends on what your GPU can do natively. There are three tiers: - -### RTX 30-series and older Ampere workstation cards — VRAM win only - -The toggle works as advertised: the UNet / transformer drops by roughly 50% on the GPU. Per-step latency is the same or marginally slower because every forward pass adds an FP8 → BF16 cast on entry. On exit the stored FP8 weights are simply put back, which costs no extra cast or allocation. This is the **largest target group**: 3090 owners squeezing FLUX into 24 GB benefit the most. - -### RTX 40-series, RTX 50-series, and Hopper — VRAM win, plus a compute win via a separate setting - -These GPUs have native FP8 tensor cores. FP8 *Storage* on its own still only buys the ~50% VRAM reduction, because the forward pass runs in BF16 — the hook casts weights back up to compute precision before each layer. To actually use the tensor cores you need the separate [FP8 Compute](#fp8-compute) setting. Note that on a checkpoint which already ships FP8 weights, FP8 Storage does something different from what this section describes — see [Checkpoints that are already FP8](#checkpoints-that-are-already-fp8). - -### Older CUDA cards — still a VRAM win - -`float8_e4m3fn` is a pure storage dtype in PyTorch and works on any CUDA device, so pre-Ampere cards (GTX 16-series, RTX 20-series, etc.) get the same ~50% VRAM reduction as Ampere. There are no native FP8 tensor cores on these GPUs, so the throughput trade-off is the same as on the 30-series: cast in, compute in BF16/FP16, restore the FP8 weights. - -### MPS and CPU — no-op - -FP8 Storage is only used on CUDA and Intel XPU devices. On CPU PyTorch *technically* supports FP8 dtypes, but the cast operations are software-emulated and end up costing more than the memory savings buy back. If you toggle it on CPU or MPS, the loader skips the cast and returns the model unchanged with no log line. On a CUDA or XPU device whose FP8 test fails, the log shows `FP8 storage probe failed on ... not using FP8.` once. - -## Enabling FP8 Storage - -FP8 Storage is a **per-model setting**, configured from the Model Manager: - - -1. Open the **Model Manager**. -2. Select a model (Main, ControlNet, or T2I-Adapter). The toggle only appears for models where it has an effect (see [Where the toggle is shown](#where-the-toggle-is-shown)). -3. Under **Default Settings**, toggle **FP8 storage**. -4. Click **Save Defaults**. - - -The setting takes effect on the next load. If the model is already in the cache, InvokeAI evicts the cached copy automatically so the new setting applies — even if a generation is currently using the model (the eviction is deferred until the generation finishes). - -### When installing - -- **Checkpoints that already store FP8 weights get FP8 Storage automatically.** InvokeAI reads the weight types from the file while identifying the model, not its name, so a full-precision file with "fp8" in its name is not affected. Only the denoiser counts: an all-in-one checkpoint with an FP8 text encoder beside a full-precision UNet or transformer is left alone. Without this, an FP8 checkpoint would be loaded at BF16 and take twice the VRAM. -- **"Scaled" FP8 checkpoints are included.** Their weights only mean something together with the per-tensor scale stored beside them, and the loader keeps them in exactly that form, so the setting costs no precision at all — it simply stops InvokeAI from unpacking the file to twice its size. Two exceptions: - - **MXFP8** checkpoints are switched on as well, but for them the setting costs quality (see the callout at the top). Turn it off after installing one. - - **Wan** checkpoints in ComfyUI's `fp8_scaled` format: the Wan loader cannot keep them packed yet. It declines the setting, logs why, and loads the model at BF16, even though the toggle shows as on. -- **For any other model, tick FP8 storage next to the install source** (a file path, URL or HuggingFace repo, and in folder scan results). It applies to the models you install from there that support it. For a model whose loader cannot use FP8 Storage the request is dropped, so the model is not recorded with a setting that does nothing. - -Either way the setting stays editable under **Default Settings** afterwards. Re-identifying a model resets its default settings, including this one, to what identification chooses. - -:::tip[When to enable] - Enable FP8 Storage on large models that don't fit comfortably in VRAM — FLUX dev/Klein, large SDXL checkpoints, ControlNet-XL adapters. For smaller SD1 / SD2 models, the savings are negligible and not worth the small precision trade-off. -::: +| Model file | FP8 Storage | +| --- | --- | +| Full precision (FP16, BF16, FP32) | **Yes**: what it is for. | +| fp8 or scaled fp8 checkpoint | **On after install.** The weights stay in fp8 with their own scale; without the setting they would be unpacked to BF16 at twice the size. See [Checkpoints that are already FP8](#checkpoints-that-are-already-fp8). | +| MXFP8 checkpoint | **Turn it off.** It stores one scale per block of 32 weights, which InvokeAI does not keep on any GPU: the layers are unpacked at load, and FP8 Storage then re-encodes them as fp8 *without* a scale: blocks whose scale is far below 1 lose their weights to underflow. If the file also has per-tensor fp8 layers, those stay fp8 and FP8 Storage changes nothing. | +| int8 and nvfp4 single files | **Partly.** Their quantized layers stay as they are; only the layers the build ships in full precision are stored in FP8. On an Ideogram 4 or Qwen-Image-2.1 int8 file, nothing at all. | +| GGUF, NF4 and [SDNQ](/configuration/optimization/sdnq-quantization/) | **No**: already quantized. The setting is not shown. | -## What FP8 Storage applies to +If your model is quantized, its full-precision variant gets the full effect of FP8 Storage. -FP8 Storage is **only** applied to layers where the precision trade-off is acceptable: +### By model -| Model type | FP8 applied? | +| Model | FP8 Storage | | --- | --- | | Main models: SD1, SD2, SDXL, SDXL Refiner, SD3, CogView4 | Yes | | FLUX.1, FLUX.2 (all variants) | Yes | | Z-Image, Anima, Krea-2, Qwen-Image, Qwen-Image-2.1, ERNIE-Image, MOSS-Image | Yes | -| Ideogram 4 (single-file safetensors) | Yes — both transformer branches | +| Ideogram 4, single-file safetensors | Yes, for each transformer branch's file | | LTX-2 | Yes | -| Wan (checkpoint and diffusers) | Yes — except ComfyUI `fp8_scaled` files, which load at BF16 | +| Wan (checkpoint and Diffusers) | Yes, except ComfyUI `fp8_scaled` files, which load at BF16 | | ControlNet (SD1, SD2, SDXL), T2I-Adapter | Yes | -| VAE | No — visible decode-quality regression | -| Text encoders, tokenizers | No — small models, no benefit | -| Anima ControlNet-LLLite | No — adapter is only tens of MB | -| FLUX and Z-Image ControlNet | No — not implemented yet | -| MiniMax H3 | No — not implemented yet | -| Ideogram 4 (diffusers folder) | No — published only as nf4 or fp8 | -| GGUF, NF4 and SDNQ models | No — already quantized | -| LoRA, ControlLoRA | No — patched into base, not run alone | - -Within a supported model, **norm layers, position/patch embeddings, and `proj_in`/`proj_out` are skipped** so precision-sensitive tiny learned scalars (e.g. FLUX `RMSNorm.scale`) aren't crushed to FP8. This mirrors the diffusers default skip list. - -**On top of that, each model's own declared exclusions are honored.** Architectures list their precision-sensitive modules themselves (diffusers calls these `_skip_layerwise_casting_patterns` and `_keep_in_fp32_modules`), and those layers stay at compute precision too. This matters where the generic patterns don't fit the architecture's naming, and it is not a nicety: Z-Image and Anima both name their timestep-embedding MLP `t_embedder`, which no generic pattern matches. Cast to FP8, Z-Image fails outright and Anima renders a heavily dithered image with no fine detail. +| VAE | No: visible decode-quality regression | +| Text encoders, tokenizers | No: small models, and FP8 rounding costs text quality. The FLUX.2 Mistral encoder keeps its scaled fp8 weights packed on any GPU that passes the FP8 test, with no setting. | +| Anima ControlNet-LLLite | No: the adapter is only tens of MB | +| FLUX.1, FLUX.2, Z-Image and Qwen-Image ControlNets | No: not implemented yet | +| MiniMax H3 | No: not implemented yet | +| Ideogram 4, Diffusers folder | No: published only as nf4 or fp8 | +| LoRA, ControlLoRA | No: patched into the base model, not run alone | + +
+Which layers inside a model stay at full precision + +**Norm layers, position and patch embeddings, and `proj_in`/`proj_out` are skipped**, so precision-sensitive tiny +learned scalars (such as FLUX's `RMSNorm.scale`) aren't crushed to FP8. This mirrors the diffusers default skip list. + +**Each model's own declared exclusions are honored too.** Architectures list their precision-sensitive modules +themselves (diffusers calls these `_skip_layerwise_casting_patterns` and `_keep_in_fp32_modules`), and those layers +stay at compute precision. This matters where the generic patterns don't fit the architecture's naming: Z-Image and +Anima both name their timestep-embedding MLP `t_embedder`, which no generic pattern matches. Cast to FP8, Z-Image fails +outright and Anima renders a heavily dithered image with no fine detail. The cost is a slightly smaller saving on the models that declare a lot. Measured against the generic defaults alone: -| Model | Weights kept at compute precision | Saving given up | -| ------------------------- | ------------------------------------------------ | --------------- | -| Krea-2 | 39 M (`time_embed`) | ~38 MiB | -| Anima | 19 M (`t_embedder`, `x_embedder`, `final_layer`) | ~18 MiB | -| Wan 14B | 232 M (`condition_embedder`, `patch_embedding`) | ~221 MiB | -| MOSS-Image | 486 M (every block's `proj.q`, the embedders, modulations and output head) | ~464 MiB | -| FLUX.1, Qwen-Image | 0 | 0 | - -Measured on Wan 2.2 TI2V-5B (fp16 single file, 512², 20 steps, RTX 4090), FP8 Storage takes the transformer from 9.5 GB to 4.9 GB resident, at 21.4 s instead of 18.4 s per video. - -MOSS-Image keeps each block's query projection at compute precision because its forward pass casts the -block's input to that layer's dtype; the rest of the block is stored in FP8. Measured on an RTX 4090, its -transformer takes 5.3 GB with FP8 Storage instead of 10.2 GB. - -### Where the toggle is shown +| Model | Weights kept at compute precision | Saving given up | +| --- | --- | --- | +| Krea-2 | 39 M (`time_embed`) | ~38 MiB | +| Anima | 19 M (`t_embedder`, `x_embedder`, `final_layer`) | ~18 MiB | +| Wan 14B | 232 M (`condition_embedder`, `patch_embedding`) | ~221 MiB | +| MOSS-Image | 486 M (every block's `proj.q`, the embedders, modulations and output head) | ~464 MiB | +| FLUX.1, Qwen-Image | 0 | 0 | + +MOSS-Image keeps each block's query projection at compute precision because its forward pass casts the block's input +to that layer's dtype; the rest of the block is stored in FP8. Measured on an RTX 4090, its transformer takes 5.3GB with +FP8 Storage instead of 10.2GB. + +InvokeAI applies the cast with its own forward hooks rather than diffusers' `apply_layerwise_casting`, on every +`nn.Module` including diffusers `ModelMixin` subclasses, so it works with InvokeAI's `CustomLinear` layers and with +partial loading. The FP8 cast happens on the CPU, before the weights move to VRAM. + +
+ +## By GPU + +FP8 Storage needs no FP8 hardware: the math still runs in FP16 or BF16. What your GPU adds is speed, through the +separate [FP8 Compute](/configuration/optimization/fp8-compute/). + + + +VRAM win only. The transformer or UNet drops by roughly 50% in VRAM. Each step is the same speed or marginally slower, +because every layer casts its FP8 weights up on entry; putting the stored weights back on exit costs nothing. This +covers the RTX 30 series and Ampere workstation cards, and older CUDA cards such as the RTX 20 and GTX 16 series: +`float8_e4m3fn` is a pure storage type that any CUDA device can hold. RTX 3090 owners fitting FLUX into 24GB benefit +the most. + + +The same ~50% VRAM win. These GPUs have FP8 tensor cores, but FP8 Storage alone does not use them: the weights are cast +up to BF16 before each layer. To use them for checkpoints published in FP8, turn on +[FP8 Compute](/configuration/optimization/fp8-compute/). + + +A ROCm build of PyTorch reports an AMD GPU as a CUDA device, so FP8 Storage works on AMD as it does on NVIDIA. Intel +GPUs (XPU) are supported too. Before the first use on a device, InvokeAI runs a small FP8 test on it; if the device, +driver or PyTorch build cannot store and upcast `float8_e4m3fn` (some older AMD GPUs, for example), the model loads +without FP8 and the log warns once: `FP8 storage probe failed on (…); not using FP8.` + + +Not used. PyTorch can hold FP8 on the CPU, but casting is emulated in software and costs more than the memory saves. +With the setting on, the loader returns the model unchanged, without a log line. Checkpoints that store FP8 weights are +unpacked to BF16 here. + + + +## Turning it on -The Model Manager shows the **FP8 Storage** toggle only for models in the "Yes" rows above. For the others it is hidden, because the setting would not change anything. A model installed before this rule may still have FP8 Storage stored as on. The setting stays in the database and does nothing; when such a model loads, the log says so once: `FP8 Storage is set for '' but does nothing here: `. - -## Quality trade-offs - -FP8 Storage is **near-lossless** for most workloads because: - -- Norms and embeddings (the precision-sensitive layers) are skipped. -- The actual matmul still happens in FP16/BF16 — FP8 is only the on-GPU storage format. -- On a checkpoint that is already FP8, the weights are not re-encoded at all: they stay exactly as published and are unpacked with their own scale before each layer, so there is no added rounding, and no re-quantization to undo. - -That said, some artifacts have been reported on: - -- **VAEs** — never cast (the toggle has no effect on VAE submodels). -- **Heavy LoRA stacks** — patching is unaffected, but very precision-sensitive LoRAs may show slight drift. Compare a side-by-side if your workflow depends on subtle LoRA behavior. - -If you see unexpected quality regressions, disable FP8 Storage on the affected model and re-run. - -## Combining with Low-VRAM mode - -**FP8 Storage + partial loading**: fully supported. FP8 Storage shrinks the layers; partial loading streams them between RAM and VRAM as needed. Use both on tight VRAM budgets. - -**FP8 Compute + partial loading** is a different story — it still works, but it costs both speed and reproducibility. See [FP8 Compute](#fp8-compute) below. - -(For how FP8 Storage interacts with GGUF / NF4 / SDNQ / int8 / nvfp4 checkpoints, see the callout at the top of this page.) + +1. Open the **Model Manager**. +2. Select a model (Main, ControlNet or T2I-Adapter). The setting only appears where it has an effect. +3. Under **Default Settings**, turn on **FP8 storage**. +4. Click **Save Defaults**. + -## FP8 Compute +It applies on the next load. If the model is already cached, InvokeAI evicts the cached copy so the new setting takes +effect; if a generation is using the model, the eviction waits until it finishes. -Everything above describes FP8 *Storage*, which changes how weights are **stored** while the math still runs in BF16. `fp8_compute` is a separate, global setting in `invokeai.yaml` that also does the **math** in FP8, on GPUs that have hardware for it. That makes generation faster, not just smaller. +Enable it on large models that don't fit comfortably in VRAM: FLUX dev or Klein, large SDXL checkpoints, ControlNet-XL +adapters. For small SD1 and SD2 models the savings are negligible and not worth the small precision trade-off. -It is for models that were **already saved in FP8** by whoever published them — you'll often see these labelled "fp8" or "fp8_scaled" in the filename. Without either setting InvokeAI unpacks them back to BF16 while loading, which is why such a checkpoint has FP8 Storage switched on for it when it is installed. With `fp8_compute` on they stay as they are and run directly on the GPU's FP8 hardware; with FP8 Storage on they also stay as they are, but are unpacked layer by layer during the forward pass instead. ComfyUI's int8 builds have a counterpart of their own, [int8 Compute](/configuration/optimization/int8-compute/). +### When installing -### Checkpoints that are already FP8 +| What you install | FP8 Storage | +| --- | --- | +| A checkpoint whose transformer or UNet stores FP8 weights | **On automatically.** InvokeAI reads the weight types from the file, not its name, so a full-precision file with "fp8" in its name is not affected. Only the denoiser counts: an all-in-one checkpoint with an FP8 text encoder beside a full-precision UNet or transformer is left alone. The log says `denoiser weights are stored in float8, enabling FP8 Storage by default`. | +| A scaled fp8 checkpoint | **On automatically**, and it costs no precision: the weights stay exactly as published, with their scale. | +| An MXFP8 checkpoint | **On automatically, but turn it off**: it costs quality (see [By file format](#by-file-format)). | +| A Wan checkpoint in ComfyUI's `fp8_scaled` format | Shows as on, but the Wan loader cannot keep these packed yet: it declines, logs `FP8 Storage not applied to …`, and loads the model at BF16. | +| Anything else, from a file path, URL or Hugging Face repo, or from folder scan results | **Tick FP8 storage** next to the install source. It applies to the models from there that support it; for the others the request is dropped, so no model is recorded with a setting that does nothing. Starter models don't offer it. | -For these, FP8 Storage does not re-encode anything — it simply stops InvokeAI from unpacking them. That matters because the file stores a per-tensor scale alongside each weight, and re-encoding the unpacked result would throw that scale away: on FLUX.2 Klein 4B roughly 3% of the weights would round to zero, for a file that was already one byte per weight. What it costs instead is a little speed, since each layer is unpacked on use. Measured on a 4090 with `fp8_compute` off, the Ideogram 4 fp8 pair generates 1024px in 40.6 s against 35.6 s on the FP8 tensor cores, and holds 8.7 GB per branch instead of the 17.3 GB the unpacked weights need — which is the difference between fitting on a 24 GB card and running out of memory during the first step. +Either way the setting stays editable under **Default Settings**, and re-identifying a model keeps it. -The loader reports what it kept, naming the setting that kept it — for example `Ideogram 4: kept 208 layer(s) in fp8 (scaled fp8 checkpoint, kept for FP8 Storage, dequantized per forward)`. Some loaders then hand the model to the layer cast anyway, which declines it and logs `FP8 storage skipped ... already fp8`; on FLUX.1, FLUX.2, Z-Image and Anima you therefore see both lines for the same model. Both are the intended outcome, not a failure. +## Checkpoints that are already FP8 -Not every part of a model stays in FP8 — the small, precision-sensitive pieces are always unpacked. Those are a tiny share of the weights, so you still get nearly the full VRAM saving. +For these, FP8 Storage re-encodes nothing: it stops InvokeAI from unpacking them. That matters because the file stores +a scale beside each weight, and re-encoding the unpacked result would throw that scale away: on FLUX.2 Klein 4B roughly +3% of the weights would round to zero, for a file that was already one byte per weight. The cost is a little speed, +since each layer is unpacked on use. Measured on an RTX 4090, the Ideogram 4 fp8 pair generates 1024px in 40.6s, against +35.6s with [FP8 Compute](/configuration/optimization/fp8-compute/), and holds 8.7GB per branch instead of the 17.3GB the +unpacked weights need: the difference between fitting on a 24GB card and running out of memory in the first step. -You need an FP8-capable GPU: RTX 40-series or newer, the datacenter cards of those generations, or an AMD GPU whose ROCm build supports FP8 matmul (MI300, for example). InvokeAI checks your card the first time it needs to — by actually trying a small FP8 operation rather than going by the model name — so a card that can't do it quietly falls back to the normal path instead of failing partway through a generation. +The small, precision-sensitive parts of the model are always unpacked. They are a tiny share of the weights, so you +still get nearly the full saving. -:::danger[Reproducibility requires the model to be fully in VRAM] -With `fp8_compute` enabled, **the same seed only reproduces the same image if the model is 100% resident in VRAM.** +
+What the log shows for these -FP8 math only happens for the parts of the model that are actually on the GPU. Anything still sitting in system RAM takes the normal path instead, which gives slightly different numbers — and *which* parts that affects depends on how much of the model happened to fit at that moment, which changes from run to run. +The loader reports what it kept and which setting kept it, for example +`Ideogram 4: kept 208 layer(s) in fp8 (scaled fp8 checkpoint, kept for FP8 Storage, dequantized per forward)`. On +FLUX.1, FLUX.2, Z-Image and Anima the model then goes to the layer cast, which declines it with +`FP8 storage skipped for : … weight(s) are already fp8, …`, so you see both lines for the same model. Both are +the intended outcome, not a failure. Qwen-Image keeps its fp8 layers without the first line. -Measured on a 24 GB card with a ~12 GB model at 88–95% loaded: two runs with an identical seed and identical settings differed in **98.7% of all pixels**. With the model fully loaded, repeated runs came out **identical**. +
-To get repeatable output, check the model's load line in the log for `VRAM: … (100.0%)`. If it is below that, free up VRAM (lower resolution, fewer models loaded at once, a smaller text encoder) or set `enable_partial_loading: false`. -::: +## Quality -Keeping the whole model on the GPU is worth it for speed as well: on the same 24 GB card, having to stream the last 5–12% of the model over PCIe cost **+47% per step** (1.03 → 1.51 s/it at 1024², 8 steps). +FP8 Storage is near-lossless for most workloads: -### When the model asks for full precision +- Norms and embeddings, the precision-sensitive layers, are skipped. +- The math still runs in FP16 or BF16; FP8 is only how the weights are stored on the GPU. +- On a checkpoint that is already FP8, the weights are not re-encoded at all. -Some FP8 models come with a note from whoever made them, marking certain layers as ones that should not use FP8 math. InvokeAI follows those notes by default, so those layers run the slower way. On a model that marks a lot of layers, this can eat much of the FP8 Compute speedup. +VAEs are never cast. Very precision-sensitive LoRAs may drift slightly: patching works as usual, but compare side by +side if your workflow depends on subtle LoRA behavior. If you see a regression, turn FP8 Storage off for that model and +run it again. -Setting `fp8_compute_full_precision_hints: false` ignores the notes and runs everything on the FP8 hardware. It is faster, but you are overriding the model author's judgement about which layers are sensitive — so compare a few images before sticking with it. +FP8 Storage and partial loading work together: use both when VRAM is tight. [FP8 Compute](/configuration/optimization/fp8-compute/) +with partial loading also works, but costs speed and reproducibility. ## Troubleshooting -### "I toggled FP8 Storage but VRAM usage didn't change" - -The cache eviction is immediate for idle models, but **deferred until the next unlock** if the model is mid-generation. Wait for the current generation to finish, then start a new one — the next load will use the new setting. - -If VRAM still hasn't dropped: - -- Check the InvokeAI log for `FP8 layerwise casting enabled for `. If the line isn't there, the model is on the exclusion list (VAE, text encoder, LoRA — see table above), or it is a Wan `fp8_scaled` checkpoint, for which the loader logs why it declined. For a checkpoint that is already FP8 the expected line is `FP8 storage skipped ... already fp8` instead: nothing needed re-encoding. -- Confirm you are on a CUDA or XPU device, and look for `FP8 storage probe failed` in the log. FP8 Storage is not used on CPU and MPS. - -### Quality regression on a specific model - -Disable FP8 Storage for that model in Model Manager and reload. If quality is restored, the model has FP8-sensitive layers that fall outside the default skip list. Please open an issue with the model name and a side-by-side comparison. - -### "RuntimeError: ... float8_e4m3fn ..." - -You're on a PyTorch version that predates FP8 support. Reinstall InvokeAI using the official launcher — the bundled torch version supports FP8. - -### Reporting an FP8 issue - -If FP8 Storage misbehaves — crash, quality regression, OOM that shouldn't happen — please [open a GitHub issue](https://github.com/invoke-ai/InvokeAI/issues/new/choose) and include: - -- **What you did**: the workflow / generation step that triggered the problem, and whether it reproduces every time. -- **Model**: exact name and variant (e.g. "FLUX.2 Klein 9B Diffusers", "SDXL Base 1.0 single-file"), and whether the file is a full-precision checkpoint or already quantized (GGUF / NF4 / SDNQ / int8 / nvfp4 / scaled fp8 / MXFP8). -- **LoRAs**: whether any LoRAs (or ControlLoRAs) are stacked on the model, and how many. -- **Other toggles**: Low-VRAM mode on/off, any `cpu_only` text encoder setting, configured VRAM limit. -- **GPU**: model and VRAM size (e.g. "RTX 3090 24 GB", "RTX 4070 Ti 12 GB"). -- **OS**: Windows or Linux, plus driver / CUDA version if you have it. -- **Logs**: the InvokeAI log around the failure — in particular the `FP8 layerwise casting enabled for ` line (or its absence) and any traceback. - -A side-by-side image comparison (FP8 on vs. FP8 off, same seed) is extremely useful for quality regressions. +| Symptom | What to do | +| --- | --- | +| VRAM didn't drop after turning it on | A model in use is evicted only when its generation finishes: start a new generation. If it still hasn't dropped, look in the log for `FP8 layerwise casting enabled for `. Without it, the model is one FP8 Storage skips ([What it applies to](#what-it-applies-to)), a Wan `fp8_scaled` checkpoint (the log says why), or already FP8 (`FP8 storage skipped … already fp8`: nothing needed re-encoding). | +| `FP8 storage probe failed` in the log | Your GPU, driver or PyTorch build can't store FP8; the model loads without it. FP8 Storage is never used on the CPU or Apple silicon. | +| Quality regression on one model | Turn FP8 Storage off for that model and reload. If quality comes back, the model has FP8-sensitive layers outside the skip list: please report it (below). | +| `RuntimeError: … float8_e4m3fn …` | Your PyTorch predates FP8 support (added in 2.1). Reinstall with the official launcher, whose PyTorch supports it. | + +### Reporting an issue + +If FP8 Storage misbehaves (a crash, a quality regression, an out-of-memory error that shouldn't happen), +[open a GitHub issue](https://github.com/invoke-ai/InvokeAI/issues/new/choose) with: + +- **What you did**: the workflow or generation step, and whether it happens every time. +- **Model**: exact name and variant (such as "FLUX.2 Klein 9B Diffusers" or "SDXL Base 1.0 single-file"), and whether the file is full precision or quantized (GGUF, NF4, SDNQ, int8, nvfp4, scaled fp8, MXFP8). +- **LoRAs**: whether any LoRAs or ControlLoRAs are stacked on it, and how many. +- **Other settings**: low-VRAM mode on or off, any `cpu_only` text encoder, a configured VRAM limit. +- **GPU**: model and VRAM (such as "RTX 3090 24GB"). +- **OS**: Windows or Linux, and the driver or CUDA version if you know it. +- **Logs**: the InvokeAI log around the failure, especially the `FP8 layerwise casting enabled for ` line (or its absence) and any traceback. +- For a quality regression: a side-by-side image, FP8 Storage on and off, same seed. diff --git a/docs/src/content/docs/configuration/Optimization/int8-compute.mdx b/docs/src/content/docs/configuration/Optimization/int8-compute.mdx index 2d9d215d2ff..f605917812e 100644 --- a/docs/src/content/docs/configuration/Optimization/int8-compute.mdx +++ b/docs/src/content/docs/configuration/Optimization/int8-compute.mdx @@ -1,51 +1,66 @@ --- title: int8 Compute +description: Run ComfyUI int8 builds on the int8 tensor cores of RTX 30-series and newer NVIDIA GPUs, up to twice as fast. +lastUpdated: 2026-10-26 sidebar: order: 6 --- +import BenchmarkChart from '@components/memory/BenchmarkChart.astro'; +import BenchmarkRow from '@components/memory/BenchmarkRow.astro'; + ComfyUI's **int8** builds (`int8_tensorwise`, see [Model Format Support](/users-guide/models/model-format-support/)) store every large layer as 8-bit integers. By default InvokeAI keeps those weights in int8 and unpacks each layer to -BF16 or FP16 just before it runs, which gives the memory saving but no speedup: each layer runs at 0.75–0.97× the speed -of a full-precision one. - -`int8_compute` is a global setting that does the **math** in int8 as well, on the GPU's int8 tensor cores, for int8 -builds **quantized with convrot** — the `comfy_quant` marker of each layer says `"convrot": true`, which is what the -published builds use. Each layer then rotates and quantizes its input to int8 per token and multiplies int8 by int8. -Measured on an RTX 4090 (BF16, model fully in VRAM), the denoising step takes: - -| Model (int8 convrot build) | Steps | 1024² | 1536² | 2048² | -| --- | --- | --- | --- | --- | -| Z-Image Turbo | 8 | 5.04 s → 2.93 s (1.72×) | 12.78 s → 8.95 s (1.43×) | 26.62 s → 20.71 s (1.29×) | -| FLUX.1 dev | 20 | 15.25 s → 7.85 s (1.94×) | 36.20 s → 23.88 s (1.52×) | | -| Krea-2 Turbo | 8 | 9.22 s → 4.51 s (2.04×) | 21.64 s → 12.32 s (1.76×) | | +BF16 or FP16 just before it runs: you get the memory saving, but each layer runs at 0.75–0.97× the speed of a +full-precision one. + +`int8_compute` is a global setting that does the **math** in int8 too, on the GPU's int8 tensor cores, for int8 builds +**quantized with convrot**: each layer's `comfy_quant` marker says `"convrot": true`, as in the published builds. Each +layer then rotates and quantizes its input to int8 per token and multiplies int8 by int8. + + + + + + + + + + The gain shrinks at higher resolutions because attention, which `int8_compute` does not touch, takes a larger share of -each step. Other int8 families use the same layers but have not been measured. A convrot int8 text encoder -(`qwen_3_8b_int8_convrot` with FLUX.2 Klein 9B) works with it as well; its speed has not been measured. +each step. **Partially loaded models gain the most.** A layer still in system RAM is copied to the GPU as int8 for each step, half -the bytes of the unpacked weight, and computed exactly like a resident one. With the Z-Image Turbo transformer fully -streamed (`max_cache_vram_gb: 3`, 0 % in VRAM), 8 steps at 1024² took 34.43 s by default and 9.04 s with -`int8_compute` (3.81×), and the image was bit-identical to the fully resident one. +the bytes of the unpacked weight, and computed exactly like a resident one. With the whole transformer streamed +(`max_cache_vram_gb: 3`, 0% in VRAM), the image was bit-identical to the fully resident one: + + + + + +Other int8 families use the same layers but have not been measured. A convrot int8 text encoder +(`qwen_3_8b_int8_convrot` with FLUX.2 Klein 9B) works with it as well; its speed has not been measured. -It is **opt-in**, because quantizing the inputs changes the numbers: images made with `int8_compute` are not +It is **opt-in** because quantizing the inputs changes the numbers: images made with `int8_compute` are not pixel-identical to the default path at the same seed. In the measurements above they were visually equivalent (PSNR -30–46 dB against the default path). Each path is bit-identical from run to run, and how much of the model is in VRAM -does not change the image. +30–46dB against the default path). Each path is bit-identical from run to run, and how much of the model is in VRAM does +not change the image. ## Requirements -- **An NVIDIA GPU with compute capability 8.0 or newer** — RTX 30-series, RTX 40-series, RTX 50-series, A100, H100. - Measured on an RTX 4090. -- **Triton**, which InvokeAI's NVIDIA install on Windows x64 and Linux includes (on Windows the `triton-windows` - package). A [manual install](/start-here/manual/) on Windows without the `cuda` extra has none; install the +- **An NVIDIA GPU with compute capability 8.0 or newer**: RTX 30-series, RTX 40-series, RTX 50-series, A100, + H100. Measured on an RTX 4090. +- **Triton.** InvokeAI's NVIDIA install on Windows x64 and Linux includes it (on Windows, the `triton-windows` + package). A [manual install](/start-here/manual/) on Windows without the `cuda` extra has none: install the `triton-windows` version matching InvokeAI's PyTorch as described under [SageAttention](/configuration/optimization/sage-attention/#windows). -- Everything else falls back to the default path automatically: AMD (ROCm), Apple Silicon (MPS), Intel (XPU), the CPU, - and older NVIDIA GPUs. +- **An int8 build quantized with convrot**, as every published build is. + +Everything else falls back to the default path automatically: AMD (ROCm), Apple silicon (MPS), Intel (XPU), the CPU, +older NVIDIA GPUs, and Windows on ARM64, whose install has no Triton. -## Enabling +## Turning it on Set it in your [`invokeai.yaml`](/configuration/invokeai-yaml/) and restart InvokeAI: @@ -59,43 +74,52 @@ The first generation that uses it logs, once per GPU: int8 compute: rotated int8_tensorwise layers on cuda:0 run on the int8 tensor cores. ``` -If it cannot be used on a device, the log says why once — for example that Triton is missing — and that device keeps -the default path. When a model loads with layers it does not cover, the log counts them, for example -`int8_compute: 2 of 264 int8 layer(s) keep dequantizing per forward (2 marked full_precision_matrix_mult, ...)`. - -### The first generation pauses +If a device can't use it, the log says why, once (for example that Triton is missing: +`int8_compute is enabled, but not used on : . int8 layers there dequantize.`), and that device keeps the +default path. When a model loads with layers it does not cover, the log counts them, for example +`int8_compute: 2 of 264 int8 layer(s) keep dequantizing per forward (2 marked full_precision_matrix_mult, 0 not rotated in groups of 256).` -The kernels are tuned once per layer shape and token-count bucket. The first image after enabling it takes about -**35 seconds** longer; a later size that falls into a new token-count bucket adds about 1–5 seconds. The results are -cached on disk in Triton's cache directory (`~/.triton/cache`, or `TRITON_CACHE_DIR`), so after a restart the tuning -comes from that cache, and other sizes in the same bucket do not pay it again. +:::note[The first image pauses] +The kernels are tuned once per layer shape and token-count bucket. The first image after turning it on takes about +**35 seconds** longer; a later size that falls into a new token-count bucket adds about 1–5 seconds. Triton caches the +results on disk (`~/.triton/cache`, or `TRITON_CACHE_DIR`), so after a restart the tuning comes from the cache, and other +sizes in the same bucket don't pay it again. +::: ## What stays on the default path -`int8_compute` only changes how rotated int8 layers multiply. These keep unpacking the weights: +`int8_compute` only changes how rotated int8 layers multiply. These keep unpacking their weights: -- **Layers quantized without convrot.** Without the rotation, a single outlier channel sets the scale for a whole - token, which is where per-token quantization loses the most; no such build has been measured. -- **Layers the checkpoint marks as full precision** (`full_precision_matrix_mult`). -- **Layers rotated in groups other than 256.** Every published build uses 256. -- **FP32 compute.** The kernels run in BF16 and FP16 only. +| Layer | Why | +| --- | --- | +| Quantized without convrot | Without the rotation, one outlier channel sets the scale for a whole token, where per-token quantization loses the most. No such build has been measured. | +| Marked full precision by the checkpoint (`full_precision_matrix_mult`) | The model's author flagged it as sensitive. | +| Rotated in groups other than 256 | The kernels are built for 256, which every published build uses. | +| Running in FP32 | The kernels run in BF16 and FP16 only. | -LoRAs work as usual: they are applied beside the int8 layer in the compute precision, never quantized, and do not -change which path the layer takes. +LoRAs work as usual: they are applied beside the int8 layer in the compute precision, never quantized, and do not change +which path the layer takes. DoRA patches don't load on int8 builds, with or without `int8_compute`. -## Memory +
+Memory Once tuned, the kernels never need more working memory than the default path already reserves for unpacking a layer: -they quantize the input in slices of at most twice the layer's output width, and copy a layer that is still in system -RAM to the GPU as int8, at half the size of the unpacked weight. The first call for each layer shape and token-count -bucket may briefly need a little more: tuning uses a buffer the size of the GPU's L2 cache (72 MB on an RTX 4090), and -the check against the default path afterwards keeps 256 rows of the layer's output from each path. +they quantize the input in slices of at most twice the layer's output width, and copy a layer still in system RAM to the +GPU as int8, at half the size of the unpacked weight. The first call for each layer shape and token-count bucket may +briefly need a little more: tuning uses a buffer the size of the GPU's L2 cache (72MB on an RTX 4090), and the check +against the default path afterwards keeps 256 rows of the layer's output from each path. -## When something goes wrong +
+ +
+When something goes wrong InvokeAI checks the first result for each layer shape and token-count bucket against the default path. If it does not match, or a kernel fails, the log shows one warning and that GPU uses the default path until restart. Usually the generation continues; a fault that leaves the GPU unusable (such as an illegal memory access) fails it, and the default -path cannot recover from that either. Running out of memory is not caught: once the kernels are tuned, the default path -would not fit either. If an out-of-memory error appears only on the first image at a new size, set `int8_compute: false` -to rule the tuning out. +path cannot recover from that either. + +Running out of memory is not caught: once the kernels are tuned, the default path would not fit either. If an +out-of-memory error appears only on the first image at a new size, set `int8_compute: false` to rule the tuning out. + +
diff --git a/docs/src/content/docs/configuration/Optimization/low-vram-mode.mdx b/docs/src/content/docs/configuration/Optimization/low-vram-mode.mdx index 9bb48faeb7e..68b41e94623 100644 --- a/docs/src/content/docs/configuration/Optimization/low-vram-mode.mdx +++ b/docs/src/content/docs/configuration/Optimization/low-vram-mode.mdx @@ -1,295 +1,412 @@ --- title: Low-VRAM mode +description: How Invoke runs models larger than your GPU's memory, the invokeai.yaml settings that tune it, and what to do when you still run out of memory. +lastUpdated: 2026-10-26 sidebar: order: 2 --- -As of v5.6.0, Invoke has a low-VRAM mode. It works on systems with dedicated GPUs (Nvidia and AMD GPUs on Windows/Linux). +import SetupPicker from '@components/SetupPicker.astro'; +import SetupSwitch from '@components/SetupSwitch.astro'; +import SetupVariant from '@components/SetupVariant.astro'; +import VramBudget from '@components/memory/VramBudget.astro'; -This allows you to generate even if your GPU doesn't have enough VRAM to hold full models. Most users should be able to run even the beefiest models - like the ~24GB unquantised FLUX dev model. +Low-VRAM mode lets you generate with models larger than your GPU's memory, such as the ~24GB unquantized FLUX.1 dev. +Invoke keeps as many of a model's layers in VRAM as fit and streams the rest from RAM as each step needs them. -## Enabling Low-VRAM mode +:::tip[On by default] +Low-VRAM mode (`enable_partial_loading: true`) is on in every install, so there is nothing to turn on. It works on +NVIDIA and AMD GPUs and on Intel GPUs, dedicated (Arc) or integrated. On Apple silicon and on the CPU, models load +whole into system memory and the VRAM settings on this page do not apply. -Low-VRAM mode is **enabled by default** via the `enable_partial_loading: true` setting in `invokeai.yaml`. No action is required to turn it on. - -**Windows users should also [disable the Nvidia sysmem fallback](#disabling-nvidia-sysmem-fallback-windows-only)**. - -It is possible to fine-tune the settings for best performance or if you still get out-of-memory errors (OOMs). +Not sure which memory option fits your GPU and model? Start with +[Which memory option do I need?](/configuration/optimization/memory-options/). +::: -If you want to disable partial loading (e.g. on systems with plenty of VRAM where full loading is faster), add this line to your `invokeai.yaml` and restart Invoke: +## Platform notes + +Some drivers move memory that does not fit in VRAM into much slower system memory instead of failing. Pick your setup +to see what to change for it. + + + + + +**Turn off the NVIDIA sysmem fallback.** When VRAM fills up, the Windows driver moves memory into system RAM. That +sounds like what Invoke does itself, but the driver's version is not made for workloads like Invoke's and slows +generation down massively. + +1. Open the **NVIDIA Control Panel**. +2. In the left panel, expand **3D Settings** and click **Manage 3D Settings**. +3. In the right panel, set **CUDA - Sysmem Fallback Policy** to **Prefer No Sysmem Fallback**. + +![The CUDA - Sysmem Fallback Policy setting in the NVIDIA Control Panel, set to Prefer No Sysmem Fallback](../assets/cuda-sysmem-fallback.png) + + +**AMD on Windows (ROCm).** With a ROCm build of PyTorch (see the [System Requirements](/start-here/system-requirements/)), +Windows behaves as if NVIDIA's sysmem fallback were always on: an allocation that does not fit is moved into much +slower system memory instead of failing, and stays there. Task Manager shows it as shared GPU memory. Invoke works +around it: + +- The model cache plans against the video-memory budget Windows grants Invoke, not the card's total VRAM. +- The PyTorch allocator defaults to `expandable_segments:True` (see `pytorch_cuda_alloc_conf` under + [Settings](#settings)), so fragmented VRAM does not push allocations out. +- Decodes too large for the card are [tiled](#tiled-vae-decode-and-encode) before they start: waiting for an + out-of-memory error does not work here, because none comes. +- When at least 512 MiB of Invoke's GPU memory stays in system memory after two generations in a row, the log says so + (`Windows kept … GiB of Invoke's GPU memory … in shared system memory`). Other programs on the same GPU, such as games + or browsers, can push Invoke's memory out too: close them. If it keeps happening, lower the image size, set + `max_cache_vram_gb` lower or `device_working_mem_gb` higher, and restart Invoke to get the memory back into VRAM. + + +Nothing to change for this GPU: the [settings](#settings) below apply as they are. + + +Nothing to change for this GPU: the [settings](#settings) below apply as they are. + + +Models load whole into system memory here, so partial loading and the VRAM settings below do not apply. To use less +memory, use a smaller build of the model: see the +[format comparison](/configuration/optimization/memory-options/#format-comparison). + + + +## Settings + +All of these go in [`invokeai.yaml`](/configuration/invokeai-yaml/) and take effect after a restart. Most users need +none of them. + +| Setting | Default | Change it when | Trade-off | +| --- | --- | --- | --- | +| `enable_partial_loading` | `true` | Every model you use fits in VRAM and you want them loaded whole. | Off, a model larger than free VRAM runs out of memory. | +| `device_working_mem_gb` | `3` | You get out-of-memory errors during generation. | More working memory leaves less VRAM for model layers, so more of them stream from RAM. | +| `reserve_vram_gb` | `0` | Another GPU program needs VRAM that Invoke should leave alone. | Models are offloaded or partially loaded sooner. | +| `max_cache_vram_gb` | unset: measured | You want Invoke to use less VRAM than it could. | Invoke stops measuring the GPU, so VRAM other programs use is not seen. | +| `max_cache_ram_gb` | unset: half your RAM minus 2 GB, within limits | You want more inactive models kept in RAM, or Invoke to use less RAM. | More: faster reloads, more RAM in use. | +| `keep_ram_copy_of_weights` | `true` | RAM is consistently tight. | Off: lower average RAM use, slower model switching and LoRA patching. | +| `offload_on_architecture_switch` | `true` | You alternate between model architectures that fit in VRAM together. | Off: other architectures' models stay in VRAM until a load needs the room. | +| `clear_vram_after_session` | `false` | A generation that runs fine once runs out of memory, or slows down sharply, when repeated. | A few seconds per queue item to reload models from RAM. | +| `pytorch_cuda_alloc_conf` | unset | You want to tune peak reserved VRAM, for example with `backend:cudaMallocAsync`. | The best value depends on the GPU, driver and CUDA version: test it. | +| `auto_tiled_decode` | `true` | You never want a decode tiled unless you ask for it. | Decodes too large for the card run out of memory. | +| `force_tiled_decode` | `false` | You want every supported decode and encode tiled. | Slower, and not pixel-identical to an untiled decode. | +| `wan_memory_optimization` | `false` | Wan videos run out of memory. | Much slower, and not bit-identical at the same seed. | +| `pid_memory_optimization` | `false` | PiD decodes run out of memory. | Not bit-identical at the same seed. | + +The details of each: + +
+`enable_partial_loading`: partial model loading + +Invoke streams a model's layers between RAM and VRAM as they are needed. When a step needs layers that are not in +VRAM and there is no room for them, inactive layers are moved to RAM. To load every model whole instead, for example +on a GPU with plenty of VRAM where that is faster: ```yaml enable_partial_loading: false ``` -:::tip[How to find `invokeai.yaml`] - The `invokeai.yaml` configuration file lives in your install directory. To access it, run the **Invoke Community Edition** launcher and click the install location. This will open your install directory in a file explorer window. - - You'll see `invokeai.yaml` there and can edit it with any text editor. After making changes, restart Invoke. - - If you don't see `invokeai.yaml`, launch Invoke once. It will create the file on its first startup. -::: - -## Details and fine-tuning - -Low-VRAM mode and related workload-specific optimizations include: - -- Partial model loading (`enable_partial_loading`) -- PyTorch CUDA allocator config (`pytorch_cuda_alloc_conf`) -- Dynamic RAM and VRAM cache sizes (`max_cache_ram_gb`, `max_cache_vram_gb`) -- Extra VRAM kept outside the model cache (`reserve_vram_gb`) -- Working memory (`device_working_mem_gb`) -- Keeping a RAM weight copy (`keep_ram_copy_of_weights`) -- Tiled VAE decode and encode (`auto_tiled_decode`, `force_tiled_decode`) -- Wan video memory optimization (`wan_memory_optimization`) -- PiD decode activation chunking (`pid_memory_optimization`) - -Read on to learn about these features and understand how to fine-tune them for your system and use-cases. +
-### Wan video memory optimization +
+`device_working_mem_gb`: working memory -Wan video generation has an additional opt-in memory optimization: +Invoke keeps some VRAM free as working memory for the computation itself: 3GB by default, enough for most uses. If +you still run out of memory, raise it: ```yaml -wan_memory_optimization: true +device_working_mem_gb: 4 ``` -This reserves VRAM so partial-load Wan transformer weights target about 2 GiB resident and stream remaining layers from RAM when partial model loading is enabled (the default). The explicit residency trim may be a no-op when cache admission already reaches that target. If `enable_partial_loading: false`, the activation, timestep, and VAE optimizations still apply, but transformer weights remain fully resident and the 2 GiB residency target is unavailable. It also chunks pointwise transformer activations, compacts TI2V per-token timestep conditioning, and streams untiled VAE decode chunks directly to MP4. It reduces peak VRAM during both denoise and decode, but generation can be substantially slower and requires enough system RAM for offloaded weights. Spatially tiled VAE decode continues to use its existing full-tile path. - -The optimized BF16 transformer path can produce small numerical differences because chunked matrix operations accumulate in a different order. Same-seed output is not guaranteed to be bit-identical; set `wan_memory_optimization: false` for the baseline path. - -Developers with a CUDA or ROCm device can validate Wan VAE memory estimates with `python scripts/calibrate_wan_vae_working_memory.py --vae `. The script reports allocated and reserved memory deltas; its implied scaling constant uses reserved memory, which is what the shipped estimator covers, because reserved memory is what the decode takes from the GPU. Pass `--no-streaming` to measure the default full decode. The shipped constant sits above what the script reports for most sizes, because it has to cover the highest measurement, an 832x480 decode inside a running server; `estimate covers` in the output says whether the estimate holds. Use `--tiling` to measure the spatially tiled full-decode fallback; it overrides streaming mode. Use `--tile-size ` to override the VAE's default tile size. - -Direct MP4 streaming keeps the VAE cache lock while chunks are decoded and written. Wan's causal decoder state and weights must remain live for the sequence; releasing the lock would require a separate bounded decode and encode queue. +Some operations know their needs in advance and ask for more. A VAE decode, which turns the generation's output into +an image, can need more than 3GB for a large image: Invoke calculates how much, and the model cache moves model layers +out of VRAM until that much is free. The larger reservation lasts only for that operation. `device_working_mem_gb` is +the floor: an operation never gets less. -### Partial model loading +
-Invoke's partial model loading works by streaming model "layers" between RAM and VRAM as they are needed. +
+`reserve_vram_gb`: VRAM kept outside the model cache -When an operation needs layers that are not in VRAM, but there isn't enough room to load them, inactive layers are offloaded to RAM to make room. - -#### Enabling partial model loading - -Partial model loading is enabled by default. The corresponding setting in `invokeai.yaml` is: +Keeps this much VRAM away from the model cache, for other GPU workloads the cache does not manage. It comes on top of +`device_working_mem_gb` and any operation's own request. To reserve 1GB: ```yaml -enable_partial_loading: true -``` - -Set it to `false` to disable partial loading. - -### PyTorch CUDA allocator config - -The PyTorch CUDA allocator's behavior can be configured using the `pytorch_cuda_alloc_conf` config. Tuning the allocator configuration can help to reduce the peak reserved VRAM. The optimal configuration is dependent on many factors (e.g. device type, VRAM, CUDA driver version, etc.), but switching from PyTorch's native allocator to using CUDA's built-in allocator works well on many systems. To try this, add the following line to your `invokeai.yaml` file: - -```yaml -pytorch_cuda_alloc_conf: "backend:cudaMallocAsync" +reserve_vram_gb: 1 ``` -When the setting is left unset, PyTorch's defaults apply, with one exception: with a ROCm build of PyTorch on Windows, Invoke uses `expandable_segments:True`. There, Windows does not fail an allocation it cannot fit into contiguous VRAM. It moves the allocation into much slower system memory instead, and fragmented VRAM makes that happen even when enough VRAM is free. Setting `pytorch_cuda_alloc_conf` yourself, or any of the `PYTORCH_ALLOC_CONF` / `PYTORCH_CUDA_ALLOC_CONF` / `PYTORCH_HIP_ALLOC_CONF` environment variables, replaces this default. +
-A more complete explanation of the available configuration options is [here](https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf). +
+`max_cache_vram_gb` and `max_cache_ram_gb`: cache sizes -### Dynamic RAM and VRAM cache sizes +Invoke caches models in RAM and VRAM so it rarely loads them from disk, and sizes both caches itself. -Loading models from disk is slow and can be a major bottleneck for performance. Invoke uses two model caches - RAM and VRAM - to reduce loading from disk to a minimum. +- **VRAM**: unless you set `max_cache_vram_gb`, Invoke measures the free VRAM before each load, taking into account + what other programs use (with a ROCm build on Windows, the budget Windows grants Invoke). +- **RAM**: unless you set `max_cache_ram_gb`, the cache gets half your system RAM minus 2GB. On a machine with an + NVIDIA, AMD or dedicated Intel GPU that is capped at the GPU's VRAM minus `device_working_mem_gb` (or at + `max_cache_vram_gb` if you set it), elsewhere at 32GB, and it is never below 4GB. The log shows the result at + startup: `Calculated model RAM cache size: … MB`. -By default, Invoke manages these caches' sizes dynamically for best performance. +For example, on a 32GB machine with no other heavy programs, `max_cache_ram_gb: 28` caches models aggressively, so +they reload faster. -#### Fine-tuning cache sizes +`max_cache_vram_gb` caps everything the cache plans for on the GPU: model weights, working memory and the reserve. See +[The VRAM budget](#the-vram-budget) before setting it. -Prior to v5.6.0, the cache sizes were static, and for best performance, many users needed to manually fine-tune the `ram` and `vram` settings in `invokeai.yaml`. +
-As of v5.6.0, the caches are dynamically sized. The `ram` and `vram` settings are no longer used, and new settings are added to configure the cache. +
+`keep_ram_copy_of_weights`: a RAM copy of loaded models -**Most users will not need to fine-tune the cache sizes.** - -But, if your GPU has enough VRAM to hold models fully, you might get a perf boost by manually setting the cache sizes in `invokeai.yaml`: +On by default: Invoke keeps a RAM copy of every model's weights while the model is in VRAM, which makes switching +models and patching LoRAs faster. Turning it off lowers the average RAM use (the peak likely stays the same), at the +cost of slower model switching and LoRA patching. On integrated Intel GPUs the setting is ignored, and the log says so +once. ```yaml -# The default max cache RAM size is logged on InvokeAI startup. It is determined based on your system RAM / VRAM. -# You can override the default value by setting `max_cache_ram_gb`. -# Increasing `max_cache_ram_gb` will increase the amount of RAM used to cache inactive models, resulting in faster model -# reloads for the cached models. -# As an example, if your system has 32GB of RAM and no other heavy processes, setting the `max_cache_ram_gb` to 28GB -# might be a good value to achieve aggressive model caching. -max_cache_ram_gb: 28 - -# The default max cache VRAM size is adjusted dynamically based on the amount of available VRAM (taking into -# consideration the VRAM used by other processes; with a ROCm build on Windows, the video-memory budget Windows grants -# Invoke, beyond which it would move allocations into system memory). -# You can override the default value by setting `max_cache_vram_gb`. -# CAUTION: Most users should not manually set this value. See warning below. -max_cache_vram_gb: 16 +keep_ram_copy_of_weights: false ``` -:::caution[Max safe value for `max_cache_vram_gb`] - Most users should not manually configure the `max_cache_vram_gb`. This configuration value caps model-cache residency; `device_working_mem_gb` and operation-specific reservations (e.g. VAE decode) are still subtracted from that cap for every model-cache operation, not only when Wan memory optimization is enabled. A cap below the active working-memory reservation can force aggressive model offloading. - - For users who wish to configure `max_cache_vram_gb`, estimate the max safe value by subtracting both `device_working_mem_gb` and `reserve_vram_gb` from your GPU's VRAM. The default for `device_working_mem_gb` is 3GB, and `reserve_vram_gb` defaults to 0GB. +
- For example, with a 12GB GPU and default settings, the max safe value is `12GB - 3GB - 0GB = 9GB`. +
+`offload_on_architecture_switch`: switching between model architectures - If you increase `device_working_mem_gb` to 4GB and set `reserve_vram_gb` to 1GB, the max safe value is `12GB - 4GB - 1GB = 7GB`. - - Most users who override `max_cache_vram_gb` are doing so because they wish to use significantly less VRAM, and should be setting `max_cache_vram_gb` to a value significantly less than the 'max safe value'. -::: - -### Reserving VRAM outside the model cache - -`reserve_vram_gb` keeps the configured amount of VRAM unavailable to the model cache. This can leave room for other GPU workloads that are not managed by the cache. The default is `0`, so the option has no effect unless configured. - -For example, add this to `invokeai.yaml` to reserve 1GB: +When a generation uses main models of another architecture than the previous one on the same GPU (say, FLUX.1 after +SDXL), Invoke first moves every model the new generation does not use from VRAM to RAM. Models both use, such as a +shared VAE or text encoder, stay. Moving models back costs a few seconds per switch, more with +`keep_ram_copy_of_weights: false`. If you alternate between architectures that fit in VRAM together, turn it off: ```yaml -reserve_vram_gb: 1 +offload_on_architecture_switch: false ``` -This reserve is in addition to `device_working_mem_gb` and any operation-specific working-memory requests. A larger reserve can cause models to be offloaded or partially loaded sooner. +
-### Working memory +
+`clear_vram_after_session`: clearing VRAM after every generation -Invoke cannot use _all_ of your VRAM for model caching and loading. It requires some VRAM to use as working memory for various operations. - -Invoke reserves 3GB VRAM as working memory by default, which is enough for most use-cases. However, it is possible to fine-tune this setting if you still get OOMs. - -#### Fine-tuning working memory - -You can increase the working memory size in `invokeai.yaml` to prevent OOMs: +After every queue item (every image of a batch), Invoke moves the models it holds in VRAM to RAM and hands the freed +memory back to the GPU driver. The models stay cached in RAM, so each queue item then costs a few seconds to reload +them, not a load from disk; with `keep_ram_copy_of_weights: false` also the time to copy them back to RAM. With several +GPUs, handing the memory back briefly holds up a generation running on another GPU. On the CPU, Apple silicon and +integrated Intel GPUs, whose VRAM is system memory, it changes nothing. ```yaml -# The default is 3GB - bump it up to 4GB to prevent OOMs. -device_working_mem_gb: 4 +clear_vram_after_session: true ``` -:::tip[Operations may request more working memory] - For some operations, we can determine VRAM requirements in advance and allocate additional working memory to prevent OOMs. - - VAE decoding is one such operation. This operation converts the generation process's output into an image. For large image outputs, this might use more than the default working memory size of 3GB. - - During this decoding step, Invoke calculates how much VRAM will be required to decode and requests that much VRAM from the model manager. If the amount exceeds the working memory size, the model manager will offload cached model layers from VRAM until there's enough VRAM to decode. - - Once decoding completes, the model manager "reclaims" the extra VRAM allocated as working memory for future model loading operations. -::: - -#### Calibrating a model's working memory - -Invoke's decode estimates are measured on a few reference GPUs. Your GPU, driver and settings can need more or less. A calibration measures what a model's VAE decode really takes on your GPU, and Invoke uses those measurements from then on. - -- **What it runs**: a few short text-to-image generations of the model at three sizes, up to the largest your GPU can decode in one piece with the VAE fully loaded in the memory free on it when the calibration is planned, in each VAE precision the model can decode in. Before each measured operation, Invoke moves the other models off the GPU, so the measured model is fully loaded. Each size runs once to warm up and twice more. The generations go through the normal queue, so they wait behind queued work and appear there like any other generation. Their images are intermediates: they never appear in the gallery or on a board, and Clear Intermediates removes them once the calibration has finished. -- **Who and where**: administrators only. It needs exactly one GPU set as the generation device; with several, it is not available. One calibration runs on the GPU at a time: starting another while one runs, from another browser tab for instance, is refused until it has finished or you stop it. That includes the moment between two of its sizes: another administrator's calibration whose page was closed holds the GPU for two more minutes. Your own interrupted calibration does not hold you back: starting a new one replaces it. If another calibration ran on the GPU in between all the same, the first one cannot go on: calibrate it again. If the model, or another model the calibration loads, is replaced or reinstalled while the calibration runs, the sizes still to come fail with a note to calibrate again, and the calibration counts as stopped: the sizes it already measured are discarded too, since they measured the model as it was before. -- **What it changes**: decodes up to the largest size measured use the measured values, plus the same 5% margin the built-in values carry. Measurements that a fragmented GPU memory pool inflated, or taken while the VAE was only partly on the GPU, are left out, and the Model Manager shows those sizes as not counted. Larger decodes use whichever of the measured and the built-in estimate is larger. Other models that decode the same way (for example, SD1.5 and SDXL models when you calibrate an SDXL model) use the measurements too. Because the estimate decides when a large decode is split into tiles up front, a calibration can change which large images are decoded in tiles. -- **Where it applies**: on this kind of GPU only (identical cards share it), and only while the software that affects memory stays the same: the operating system, the PyTorch and GPU runtime versions, the memory-related environment variables and Invoke's memory settings. After such a change Invoke goes back to the built-in values until you calibrate again. A driver update keeps the measurements but marks them as possibly out of date until you calibrate again. -- **Stopping it**: stopping a calibration started from the Model Manager, or clearing the queue while it runs, discards everything that calibration measured, the sizes that already finished included; the Model Manager shows those sizes as not counted because the calibration was stopped. Calibrate again to measure them. If a size fails for another reason, for example because the GPU runs out of memory at the largest size, the sizes that finished still count. A calibration run sent through the API without an `attempt_id` counts on its own instead: stopped, it keeps the sizes it measured. If they should not count, remove the run through the API (`DELETE /api/v2/models/working_memory/runs/{run_id}`), or reset the model's calibration in the Model Manager. -- **What it does not cover**: image-to-image and inpainting encodes keep the built-in values. -- **Reset**: remove a model's calibration from the Model Manager, or every calibration of this GPU; the change applies right away. Resetting one model removes only its own measurements: where other models' measurements cover the same decode, those still apply, and the Model Manager shows which models measured it. Otherwise the built-in values apply again. - -The measurements are stored next to the database in `working_memory/.json`. +
-### Keeping a RAM weight copy +
+`pytorch_cuda_alloc_conf`: the PyTorch allocator -Invoke has the option of keeping a RAM copy of all model weights, even when they are loaded onto the GPU. This optimization is _on_ by default, and enables faster model switching and LoRA patching. Disabling this feature will reduce the average RAM load while running Invoke (peak RAM likely won't change), at the cost of slower model switching and LoRA patching. If you have limited RAM, you can disable this optimization: +Configures PyTorch's CUDA memory allocator, which can lower peak reserved VRAM. Using CUDA's own allocator works well +on many systems: ```yaml -# Set to false to reduce the average RAM usage at the cost of slower model switching and LoRA patching. -keep_ram_copy_of_weights: false +pytorch_cuda_alloc_conf: "backend:cudaMallocAsync" ``` -### Switching between model architectures +Unset, PyTorch's defaults apply, with one exception: with a ROCm build of PyTorch on Windows, Invoke uses +`expandable_segments:True`, because there fragmented VRAM makes Windows move allocations into system memory even when +enough VRAM is free. Setting `pytorch_cuda_alloc_conf`, or any of the `PYTORCH_ALLOC_CONF`, `PYTORCH_CUDA_ALLOC_CONF` +and `PYTORCH_HIP_ALLOC_CONF` environment variables, replaces that default. If `PYTORCH_CUDA_ALLOC_CONF` is already set +to another value, it wins over `pytorch_cuda_alloc_conf` and the log warns about it. The options are explained in +[PyTorch's documentation](https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf). -When a generation uses main models of another architecture than the previous one on the same GPU (say, FLUX.1 after SDXL), Invoke first moves every model the new generation does not use from VRAM to RAM. Models both generations use, such as a shared VAE or text encoder, stay in VRAM. Moving models back from RAM costs a few seconds per switch, more with `keep_ram_copy_of_weights: false`, so if you alternate between architectures that would fit in VRAM together, you can turn this off: - -```yaml -# Keep other architectures' models in VRAM until a load needs the room. -offload_on_architecture_switch: false -``` +
-### Clearing VRAM after every generation +
+`wan_memory_optimization`: Wan video memory -If a generation runs fine the first time but runs out of memory when you repeat it, or slows down sharply because Windows moves part of it into shared GPU memory, you can have Invoke move the models it holds in VRAM to RAM after every queue item (every image of a batch) and hand the freed memory back to the GPU driver. The models stay cached in RAM, so each queue item then costs a few seconds for reloading them, not a full load from disk, and with `keep_ram_copy_of_weights: false` also the time to copy them back to RAM. On a machine with several GPUs, handing the memory back briefly holds up a generation running on another GPU. On CPU, Apple Silicon and integrated GPUs, whose VRAM is system memory, the setting changes nothing: +Lowers Wan's peak VRAM during denoising and decoding, at the cost of much slower generation and enough system RAM for +the offloaded weights: -```yaml -clear_vram_after_session: true -``` +- With partial loading on (the default) and an NVIDIA or AMD GPU, the Wan transformer keeps about 2GiB of its weights in + VRAM and streams the rest from RAM. That trim may change nothing when the model cache already loaded no more than + that. With `enable_partial_loading: false` the transformer stays fully in VRAM, but the other optimizations below + still apply. +- Pointwise transformer activations run in chunks, and the TI2V model's per-token timestep conditioning is compacted. +- An untiled VAE decode streams its chunks straight to the MP4. A spatially tiled decode keeps its usual full-tile path. -### Tiled VAE decode and encode - -Without tiling, the memory a VAE needs grows with the square of the image size. For FLUX.1, an untiled 3072² encode peaks at about 18.8 GiB on an RTX 4090, and a tiled one at 0.8 GiB, while tiled memory stays roughly flat as the image grows. Tiling costs some speed on smaller images. - -Invoke tiles large decodes by itself: when an untiled decode would claim most of the memory the GPU keeps resident, it decodes in tiles instead, for the FLUX.1, FLUX.2, Z-Image, Qwen-Image, Qwen-Image-2.1, Krea-2, Anima and Wan decodes, and for Qwen-Image-2.1's encodes as well. Because a tiled decode is not pixel-identical to an untiled one, this only kicks in for images too large for the card: for Z-Image on a 16GB GPU, for example, from about 1850x1850 on Nvidia and from about 1500x1500 on AMD, whose convolution library needs more memory. On a ROCm build under Windows the comparison uses the video-memory budget Windows grants Invoke, which is lower than the card's total and lower again while another program uses the GPU. Anima tiles a little earlier, because its untiled decode pushes the Anima model itself out of VRAM on 8GB cards. Wan videos are tiled from 832x480 on 8GB cards and from 720p on 16GB cards, where their untiled decodes need about as much memory as the card holds, or more. - -To always decode untiled unless you ask for tiling, turn this off. A decode too large for the card then runs out of memory: the FLUX.1, FLUX.2 and Z-Image decodes retry in tiles, the others fail, and on Windows the driver may move the decode into system memory and run it very slowly instead. Anima is not affected by this setting: there, tiling a large decode is what keeps the Anima model in VRAM, and turning it off would only make those decodes slower. +Chunked matrix operations add up in a different order, so the image is not guaranteed to be bit-identical at the same +seed. `wan_memory_optimization: false` is the baseline path. ```yaml -auto_tiled_decode: false +wan_memory_optimization: true ``` -- **Always tile:** set `force_tiled_decode: true` in `invokeai.yaml`. It covers the VAE decode and encode for SD1.5/SDXL, FLUX.1, FLUX.2, Z-Image, Qwen-Image and Qwen-Image-2.1, the Anima encode, the control image of a Z-Image ControlNet, the control and inpaint images of a Qwen Image ControlNet and of a FLUX.2 [dev] ControlNet, and the Krea-2 style reference. Other model families (for example SD3, Wan and LTX-2) ignore it. -- **Per node:** the SD, FLUX.1, FLUX.2, Z-Image, Qwen-Image and Qwen-Image-2.1 **Latents to Image** and **Image to Latents** nodes, and **Image to Latents - Anima**, have **Tiled** and **Tile Size** fields for workflows. So do the **Z-Image ControlNet**, **Qwen Image ControlNet** and **FLUX.2 [dev] ControlNet** nodes, for the images the denoise encodes. The [Upscale panel](/users-guide/image-generation/upscale/) turns tiling on by itself. -- **Automatic retry:** if a FLUX.1, FLUX.2, Z-Image or Anima decode, or a FLUX.1 or FLUX.2 encode, runs out of memory, InvokeAI retries it once with tiling and logs that it did. +Developers can check the Wan VAE memory estimate on their GPU: see +[Wan VAE Memory Calibration](/contributing/wan-vae-memory-calibration/). -MOSS-Image encodes and decodes with the FLUX.2 nodes, so everything this section says about FLUX.2 applies to it as well. +
-A tiled decode is not pixel-identical to a single-pass one. The VAE looks at the whole image at once, so the difference is spread over the image rather than limited to the tile seams; it is small (for a 1024x1024 FLUX.2 Klein image, 5.6 of 255 per pixel value on average, with no visible seams), but the same seed will not reproduce an untiled result exactly. +
+`pid_memory_optimization`: PiD decode memory -### PiD decode optimization +[PiD](/users-guide/image-generation/advanced-topics/pid-decode/) decodes straight into high-resolution pixels, so its +activations and sampler can need more than a normal VAE decode's working memory. Partial loading shrinks PiD's weights +but not these full-resolution intermediates. This setting processes parts of PiD's pixel pathway in chunks and uses +float32 instead of float64 sampler intermediates, for every PiD decoder. Measured on an RTX 4090, peak activation memory +drops from ~3.7GB to ~1.5GB for a 2048px decode, and from ~0.9GB to ~0.5GB at 1024px. Speed stays about the same: +chunking costs about 4%, which the cheaper sampler math mostly offsets. -PiD decodes directly into high-resolution pixels, so its activation and sampler memory can exceed the working memory needed by a normal VAE decode. Partial model loading reduces memory used by PiD's weights, but does not reduce these full-resolution intermediates. +`device_working_mem_gb` is the floor of the reservation, so to get the full saving at 1024–2048px, lower it as far as +your GPU allows. -To reduce PiD's peak VRAM use, enable its experimental memory optimization in `invokeai.yaml` and restart InvokeAI: +It is off by default because the output changes: the difference is small (around 43dB PSNR, indistinguishable side by +side), but the same seed and workflow will not reproduce an unoptimized decode. The setting is server-wide and not +recorded in image metadata, so each decode logs its resolution, patch-token count and whether chunking engaged +(`PiD memory optimization enabled for a … decode`). ```yaml pid_memory_optimization: true ``` -This setting processes parts of the PiD pixel pathway in chunks and uses float32 instead of float64 sampler intermediates. It applies to every supported PiD decoder. Measured on an RTX 4090, peak activation memory for a 2048px decode drops from ~3.7 GB to ~1.5 GB (1024px: ~0.9 GB to ~0.5 GB). - -`device_working_mem_gb` sets a floor for the reservation. To realize the full PiD savings at 1024px-2048px, lower that setting as needed for your GPU. - -It is disabled by default because it is **not output-preserving**. The combined optimized path is not guaranteed to be bit-exact: chunking can change GPU kernel and reduction choices, while the float32 sampler differs from the default float64 path. The few-step sampler amplifies the difference into a slightly different image. The delta is small — around 43 dB PSNR, visually indistinguishable in side-by-side comparisons — but it is real, so the same seed and workflow will not reproduce an unoptimized decode exactly. Decoding speed is roughly unchanged: chunking costs about 4%, which the cheaper sampler math largely offsets. - -When the setting is active, each decode logs the resolution, the patch-token count and whether chunking actually engaged — worth checking, since the option is server-wide and never recorded in image metadata. See [PiD Super-Resolution Decode](/users-guide/image-generation/advanced-topics/pid-decode/) for supported models and usage. - -### Disabling Nvidia sysmem fallback (Windows only) +
+ +## Calibrating a model's working memory + +In the Model Manager, select a model, open **Working memory** and choose **Calibrate**. + +Invoke's decode estimates are measured on a few reference GPUs. Your GPU, driver and settings can need more or less. A +calibration measures what a model's VAE decode really takes on your GPU, and Invoke uses those measurements from then +on. + +- **What it runs**: a few short text-to-image generations of the model at three sizes, up to the largest your GPU can + decode in one piece with the VAE fully loaded in the memory free on it when the calibration is planned, in each VAE + precision the model can decode in. Before each measured operation, Invoke moves the other models off the GPU, so the + measured model is fully loaded. Each size runs once to warm up and twice more. The generations go through the normal + queue, so they wait behind queued work and appear there like any other generation. Their images are intermediates: + they never appear in the gallery or on a board, and Clear Intermediates removes them once the calibration has + finished. +- **Who and where**: administrators only. It needs exactly one GPU set as the generation device; with several, it is not + available. One calibration runs on the GPU at a time: starting another while one runs, from another browser tab for + instance, is refused until it has finished or you stop it. That includes the moment between two of its sizes: another + administrator's calibration whose page was closed holds the GPU for two more minutes. Your own interrupted calibration + does not hold you back: starting a new one replaces it. If another calibration ran on the GPU in between all the same, + the first one cannot go on: calibrate it again. If the model, or another model the calibration loads, is replaced or + reinstalled while the calibration runs, the sizes still to come fail with a note to calibrate again, and the + calibration counts as stopped: the sizes it already measured are discarded too, since they measured the model as it + was before. +- **What it changes**: decodes up to the largest size measured use the measured values, plus the same 5% margin the + built-in values carry. Measurements that a fragmented GPU memory pool inflated, or taken while the VAE was only partly + on the GPU, are left out, and the Model Manager shows those sizes as not counted. Larger decodes use whichever of the + measured and the built-in estimate is larger. Other models that decode the same way (for example, SD1.5 and SDXL + models when you calibrate an SDXL model) use the measurements too. Because the estimate decides when a large decode is + split into tiles up front, a calibration can change which large images are decoded in tiles. +- **Where it applies**: on this kind of GPU only (identical cards share it), and only while the software that affects + memory stays the same: the operating system, the PyTorch and GPU runtime versions, the memory-related environment + variables and Invoke's memory settings. After such a change Invoke goes back to the built-in values until you + calibrate again. A driver update keeps the measurements but marks them as possibly out of date until you calibrate + again. +- **Stopping it**: stopping a calibration started from the Model Manager, or clearing the queue while it runs, discards + everything that calibration measured, the sizes that already finished included; the Model Manager shows those sizes as + not counted because the calibration was stopped. Calibrate again to measure them. If a size fails for another reason, + for example because the GPU runs out of memory at the largest size, the sizes that finished still count. A calibration + run sent through the API without an `attempt_id` counts on its own instead: stopped, it keeps the sizes it measured. + If they should not count, remove the run through the API (`DELETE /api/v2/models/working_memory/runs/{run_id}`), or + reset the model's calibration in the Model Manager. +- **What it does not cover**: image-to-image and inpainting encodes keep the built-in values. +- **Reset**: remove a model's calibration from the Model Manager, or every calibration of this GPU; the change applies + right away. Resetting one model removes only its own measurements: where other models' measurements cover the same + decode, those still apply, and the Model Manager shows which models measured it. Otherwise the built-in values apply + again. -On Windows, Nvidia GPUs are able to use system RAM when their VRAM fills up via **sysmem fallback**. While it sounds like a good idea on the surface, in practice it causes massive slowdowns during generation. +The measurements are stored next to the database in `working_memory/.json`. -It is strongly suggested to disable this feature: +## The VRAM budget + +Most users should leave `max_cache_vram_gb` unset. If you set it, it caps everything the model cache plans for on the +GPU, and the cache subtracts the working memory and the reserve from it before loading any model: + + + +- Set it no higher than the VRAM Invoke can actually use. A cap replaces the measurement of free VRAM, so memory other + programs take is no longer accounted for. +- A cap at or below `device_working_mem_gb` plus `reserve_vram_gb` leaves no room for models, which forces aggressive + offloading. With `device_working_mem_gb: 4` and `reserve_vram_gb: 1`, a 12GB cap leaves 7GB for models. +- Most people who set it want Invoke to use much less VRAM, and set it well below their GPU's VRAM. + +## Tiled VAE decode and encode + +Without tiling, the memory a VAE needs grows with the square of the image size: for FLUX.1, an untiled 3072² encode +peaks at about 18.8GiB on an RTX 4090, a tiled one at 0.8GiB, and tiled memory stays roughly flat as the image grows. +Tiling costs some speed on smaller images, and a tiled result is not pixel-identical to an untiled one. + +| Model | Automatic (`auto_tiled_decode`) | Always (`force_tiled_decode`) | Per node (**Tiled** field) | Retried tiled after out of memory | +| --- | --- | --- | --- | --- | +| SD 1.5, SDXL | – | decode, encode | **Latents to Image**, **Image to Latents** | – | +| FLUX.1 | decode | decode, encode | **Latents to Image**, **Image to Latents** | decode, encode | +| FLUX.2 | decode | decode, encode, [dev] ControlNet control and inpaint images | **Latents to Image**, **Image to Latents**, **FLUX.2 [dev] ControlNet** | decode, encode | +| Z-Image | decode | decode, encode, ControlNet control image | **Latents to Image**, **Image to Latents**, **Z-Image ControlNet** | decode | +| Qwen-Image | decode | decode, encode, ControlNet control and inpaint images | **Latents to Image**, **Image to Latents**, **Qwen Image ControlNet** | – | +| Qwen-Image-2.1 | decode, encode | decode, encode | **Latents to Image**, **Image to Latents** | – | +| Krea-2 | decode | decode, style reference | Qwen-Image's **Latents to Image** | – | +| Anima | decode, whatever the setting | encode | **Image to Latents - Anima** | decode | +| Wan 2.2 | video decode (**Latents to Video - Wan 2.2**) | – | – | – | +| SD 3, LTX-2 | – | – | – | – | + +- **Automatic** tiling starts only when an untiled decode would take most of the memory the GPU keeps resident, so it + kicks in only for images too large for the card: for Z-Image on a 16GB GPU, from about 1850x1850 on NVIDIA and from + about 1500x1500 on AMD, whose convolution library needs more memory. Anima tiles a little earlier, because its untiled + decode pushes the Anima model out of VRAM on 8GB cards. Wan videos tile from 832x480 on 8GB cards and from 720p on + 16GB cards. +- **Krea-2** decodes with the Qwen-Image **Latents to Image** node, so it follows that node's settings and field. +- **MOSS-Image** encodes and decodes with the FLUX.2 **Latents to Image** and **Image to Latents** nodes, so it + follows the FLUX.2 row for decodes and encodes. +- **LTX-2** always decodes its video in tiles, sized by the node's own **Tile Size** fields. +- The [Upscale panel](/users-guide/image-generation/upscale/) turns tiling on by itself. Nodes with a **Tiled** field + also have a **Tile Size** field. + +
+Turning automatic tiling off -- Open the **NVIDIA Control Panel** app. -- Expand **3D Settings** on the left panel. -- Click **Manage 3D Settings** in the left panel. -- Find **CUDA - Sysmem Fallback Policy** in the right panel and set it to **Prefer No Sysmem Fallback**. +```yaml +auto_tiled_decode: false +``` -![cuda-sysmem-fallback](../assets/cuda-sysmem-fallback.png) +Decodes then run untiled unless tiling is requested. A decode too large for the card runs out of memory: the FLUX.1, +FLUX.2 and Z-Image decodes retry tiled (the log says `VAE decode ran out of memory; retrying with tiling`), the others +fail, and on Windows the driver may move the decode into system memory and run it very slowly instead. On a ROCm build +under Windows, automatic tiling compares against the video-memory budget Windows grants Invoke, which is lower than the +card's total and lower again while another program uses the GPU. -:::tip[Invoke does the same thing, but better] - If the sysmem fallback feature sounds familiar, that's because Invoke's partial model loading strategy is conceptually very similar - use VRAM when there's room, else fall back to RAM. +Anima ignores this setting: there, tiling a large decode is what keeps the Anima model in VRAM, so untiled decodes would +only be slower. - Unfortunately, the Nvidia implementation is not optimized for applications like Invoke and does more harm than good. -::: +
-### AMD GPUs on Windows (ROCm) +
+How different is a tiled image? -With a ROCm build of PyTorch (see the [System Requirements](/start-here/system-requirements/)), Windows behaves as if the Nvidia sysmem fallback were always on: an allocation that does not fit is moved into much slower system memory instead of failing, and it stays there. Task Manager shows it as shared GPU memory. Invoke works around this where it can: +The VAE looks at the whole image at once, so the difference is spread over the image rather than limited to the tile +seams. It is small: for a 1024x1024 FLUX.2 Klein image, 5.6 of 255 per pixel value on average, with no visible seams. +But the same seed will not reproduce an untiled result exactly. -- The model cache plans against the video-memory budget Windows grants Invoke, not against the card's total VRAM. -- The PyTorch allocator defaults to `expandable_segments:True` (see [PyTorch CUDA allocator config](#pytorch-cuda-allocator-config)), so fragmented VRAM does not force allocations out. -- Decodes too large for the card are tiled before they start (see [Tiled VAE decode and encode](#tiled-vae-decode-and-encode)); waiting for an out-of-memory error does not work there, because none comes. -- When part of Invoke's GPU memory stays in system memory across two generations, the log says so. Other programs using the same GPU, like games or browsers, can push Invoke's memory out as well; close them. If it keeps happening, lower the image size, set `max_cache_vram_gb` lower or `device_working_mem_gb` higher, and restart Invoke to get the memory back into VRAM. +
## Troubleshooting -### Windows page file - -Invoke has high virtual memory (a.k.a. 'committed memory') requirements. This can cause issues on Windows if the page file size limits are hit. (See this issue for the technical details on why this happens: https://github.com/invoke-ai/InvokeAI/issues/7563). +| Symptom | What to do | +| --- | --- | +| Out-of-memory errors during generation | Raise `device_working_mem_gb` to 4 or more. Check that `max_cache_vram_gb` is not set too high (see [The VRAM budget](#the-vram-budget)). | +| Out of memory while decoding a large image | Check the [tiling table](#tiled-vae-decode-and-encode): set `force_tiled_decode: true`, or turn on **Tiled** in the node. SD 1.5 and SDXL are not tiled automatically. | +| A generation runs fine once, then runs out of memory or slows down sharply when repeated | `clear_vram_after_session: true`. | +| Generation is very slow and Task Manager shows shared GPU memory in use (Windows) | NVIDIA: turn off the sysmem fallback. AMD: close other programs using the GPU and see the AMD notes. Both under [Platform notes](#platform-notes). | +| Switching between, say, SDXL and FLUX.1 takes a few seconds every time | If both fit in VRAM together, `offload_on_architecture_switch: false`. | +| RAM use is consistently high | `keep_ram_copy_of_weights: false`, or a lower `max_cache_ram_gb`. | +| Invoke exits with error code `3221225477` on Windows, or crashes with `0xc0000005` in `eventvwr.msc` | The Windows page file ran out: see below. | -If you run out of page file space, InvokeAI may crash. Often, these crashes will happen with one of the following errors: - -- InvokeAI exits with Windows error code `3221225477` -- InvokeAI crashes without an error, but `eventvwr.msc` reveals an error with code `0xc0000005` (the hex equivalent of `3221225477`) +### Windows page file -If you are running out of page file space, try the following solutions: +Invoke needs a lot of virtual memory (also called committed memory). On Windows this can hit the page file's size +limit, and Invoke crashes; [issue #7563](https://github.com/invoke-ai/InvokeAI/issues/7563) has the technical details. +The crash usually comes with Windows error code `3221225477`, or with no error at all and an error `0xc0000005` (the same +code in hex) in `eventvwr.msc`. -- Make sure that you have sufficient disk space for the page file to grow. Watch your disk usage as Invoke runs. If it climbs near 100% leading up to the crash, then this is very likely the source of the issue. Clear out some disk space to resolve the issue. -- Make sure that your page file is set to "System managed size" (this is the default) rather than a custom size. Under the "System managed size" policy, the page file will grow dynamically as needed. +- Make sure the disk has room for the page file to grow. Watch disk usage while Invoke runs: if it climbs to near 100% + before the crash, this is very likely the cause. Free up disk space. +- Make sure the page file is set to **System managed size** (the default), not a custom size, so it grows as needed. diff --git a/docs/src/content/docs/configuration/Optimization/memory-options.mdx b/docs/src/content/docs/configuration/Optimization/memory-options.mdx new file mode 100644 index 00000000000..85334f88b76 --- /dev/null +++ b/docs/src/content/docs/configuration/Optimization/memory-options.mdx @@ -0,0 +1,127 @@ +--- +title: Which memory option do I need? +description: Pick your GPU, VRAM and model format to see which of Invoke's memory options apply, and compare GGUF, int8, nvfp4, SDNQ, NF4 and FP8 in one table. +lastUpdated: 2026-10-26 +sidebar: + label: Which option do I need? + order: 1 +--- + +import MemoryOption from '@components/memory/MemoryOption.astro'; +import MemoryPicker from '@components/memory/MemoryPicker.astro'; + +Invoke has several ways to run models larger than your GPU's memory. Most are automatic; a few are worth turning on +for your GPU and the model files you use. Pick your setup to see which apply, in the order to read them. Your OS and GPU +choice is shared with the install guides. + + + +On Apple silicon and on the CPU, models load whole into system memory. Partial loading, the VRAM settings, FP8 Storage +and the compute settings don't apply. What saves memory is a smaller build of the model, such as int8, GGUF, SDNQ or +nvfp4: see the [format comparison](#format-comparison). + + +NF4 models need bitsandbytes. The macOS install leaves it out, so NF4 doesn't load on Apple silicon; on Intel GPUs and +the CPU, NF4 is untested and not supported. Pick a GGUF, int8 or SDNQ build of the model instead. + + +The Windows driver moves memory that doesn't fit in VRAM into system RAM, which slows generation down massively. Set +**CUDA - Sysmem Fallback Policy** to **Prefer No Sysmem Fallback**: [steps](/configuration/optimization/low-vram-mode/#platform-notes). + + +With ROCm on Windows, an allocation that doesn't fit moves to slow shared memory instead of failing. Invoke plans +around it; close games and other GPU programs while generating, and read the +[AMD notes](/configuration/optimization/low-vram-mode/#platform-notes) if the log warns about shared memory. + + +On NVIDIA, AMD and Intel GPUs, Invoke streams the layers of a model too large for your VRAM from RAM as they are needed. +Nothing to turn on; [Low-VRAM mode](/configuration/optimization/low-vram-mode/) lists the settings that tune it. + + +On NVIDIA, AMD and Intel GPUs (not Apple silicon or the CPU), with a full-precision model: a per-model setting in the +Model Manager that halves the model's VRAM, with near-identical images. Worth it for large +models such as FLUX, large SDXL checkpoints and ControlNet-XL; not for small SD1 and SD2 models. +[FP8 Storage](/configuration/optimization/fp8-storage/). + + +On NVIDIA, AMD and Intel GPUs: installing a checkpoint that stores fp8 weights switches FP8 Storage on, so it stays at +fp8 size instead of being unpacked to twice that. Leave it on. (On Apple silicon and the CPU these checkpoints are +unpacked to BF16.) [Checkpoints that are already FP8](/configuration/optimization/fp8-storage/#checkpoints-that-are-already-fp8). + + +With an fp8 checkpoint, `fp8_compute: true` runs it on the GPU's FP8 tensor cores: NVIDIA RTX 40-series and newer, or +an AMD GPU that passes Invoke's FP8 test (it falls back by itself otherwise). Images change, and only repeat at the same seed while the +model is fully in VRAM. [FP8 Compute](/configuration/optimization/fp8-compute/). + + +With an MXFP8 checkpoint on an NVIDIA, AMD or Intel GPU: installing switches FP8 Storage on, but for MXFP8 it +re-encodes the weights without their scales and costs quality. Turn it off under **Default Settings** in the Model +Manager. [By file format](/configuration/optimization/fp8-storage/#by-file-format). + + +With an int8 build on an NVIDIA RTX 30-series or newer GPU, `int8_compute: true` runs it on the int8 tensor cores: up +to twice as fast fully in VRAM, nearly four times when streamed from RAM. Images change slightly but stay repeatable. +[int8 Compute](/configuration/optimization/int8-compute/). + + +int8 builds stay int8 in memory on every device, so they take about half the memory of full precision. The speedup of +[int8 Compute](/configuration/optimization/int8-compute/) needs an NVIDIA RTX 30-series or newer GPU with Triton; on +older NVIDIA GPUs, AMD, Intel, Apple silicon, the CPU and Windows on ARM64, each layer is unpacked before it runs, at +0.75–0.97× the speed of full precision. Nothing to turn on. + + +GGUF, SDNQ and nvfp4 files on every device, and NF4 on NVIDIA and AMD GPUs, stay packed in memory and are unpacked +layer by layer, so there is nothing extra to turn on: FP8 Storage does not apply to them. On a GPU, Low-VRAM mode still +streams them if they don't fit. + + +With a full-precision model on a GPU with 12GB or less, or on Apple silicon or the CPU: a FLUX-class model needs far +more memory than that. An int8, GGUF, SDNQ or nvfp4 build needs a half to +about a quarter of it, at a small cost in quality; each family's page lists the builds Invoke loads. Compare them in the +[format comparison](#format-comparison). + + +On a GPU with 16GB or less, Invoke tiles FLUX, MOSS-Image, Z-Image, Qwen-Image, Krea-2, Anima and Wan decodes by +itself when they would not fit. SD 1.5 and SDXL decodes are never tiled automatically: for large SD 1.5 or SDXL images, set +`force_tiled_decode: true`, or turn on **Tiled** in the node. +[Tiled VAE decode and encode](/configuration/optimization/low-vram-mode/#tiled-vae-decode-and-encode). + + +With 24GB or more: if you alternate between architectures that fit in VRAM together, `offload_on_architecture_switch: false` saves a few +seconds per switch. If every model you use fits, `enable_partial_loading: false` loads them whole. +[Settings](/configuration/optimization/low-vram-mode/#settings). + + +On a GPU, raise `device_working_mem_gb` to 4, or set `clear_vram_after_session: true` if only repeated generations fail. +[Troubleshooting by symptom](/configuration/optimization/low-vram-mode/#troubleshooting). + + +Invoke needs a lot of virtual memory. If Invoke exits with error `3221225477`, the page file ran out: +[Windows page file](/configuration/optimization/low-vram-mode/#windows-page-file). + + + +## Format comparison + +The same model often comes in several formats. The fewer bits per weight, the less memory its weights take, in VRAM and +in RAM. + +| Format | Bits per weight | In memory | LoRAs | Speed | Runs on | Same seed, same image | +| --- | --- | --- | --- | --- | --- | --- | +| Full precision (FP16, BF16) | 16 | Full size | Yes | Baseline | Every device | Yes | +| [FP8 Storage](/configuration/optimization/fp8-storage/), on a full-precision model | 8 on the GPU | About half | Yes | Same or slightly slower | NVIDIA, AMD and Intel GPUs | Yes | +| fp8, scaled fp8 | 8 | About half, with FP8 Storage (on after install) | Yes | Unpacked per layer; faster with [FP8 Compute](/configuration/optimization/fp8-compute/) on RTX 40 and newer | Every device; unpacked to BF16 on the CPU and Apple silicon | Yes; with FP8 Compute only while fully in VRAM | +| MXFP8 | 8.25 | Full size: unpacked at load | Yes | Baseline | Every device | Yes | +| int8 (`int8_convrot`) | 8 | About half | Yes, except DoRA | 0.75–0.97×; 1.3–2× with [int8 Compute](/configuration/optimization/int8-compute/) on RTX 30 and newer | Every device | Yes | +| nvfp4 | 4.5 | About 28% | Yes, except DoRA | Unpacked per layer; no FP4 math | Every device | Yes | +| GGUF | 4.5 (Q4_K) to 8.5 (Q8_0) | About 28–53% | Yes | Unpacked per layer | Every device | Yes | +| [SDNQ](/configuration/optimization/sdnq-quantization/) | 4–5, plus the SVD correction | About a third | Yes | Unpacked per layer | Every device | Yes | +| NF4 (bitsandbytes) | 4, plus block scales | About 28% | Yes, except DoRA | Unpacked per layer | NVIDIA and AMD GPUs; not macOS; untested on Intel GPUs and the CPU | Yes | + +- **Same seed, same image** means a format reproduces its own images. Every quantized format gives a slightly different + image than full precision at the same seed. +- **Which models come in which format** is listed in [Model Format Support](/users-guide/models/model-format-support/). + Sizes, starter models and measured speeds are on each model family's page. +- **LoRAs** run beside the quantized layers rather than being merged into them, so a LoRA works on every format; DoRA + patches fail on int8, nvfp4 and NF4. +- On a GPU, every format works with Low-VRAM mode, which streams from RAM whatever doesn't fit in VRAM. diff --git a/docs/src/content/docs/configuration/Optimization/sdnq-quantization.mdx b/docs/src/content/docs/configuration/Optimization/sdnq-quantization.mdx index 70a10fffe39..3efc8cd2795 100644 --- a/docs/src/content/docs/configuration/Optimization/sdnq-quantization.mdx +++ b/docs/src/content/docs/configuration/Optimization/sdnq-quantization.mdx @@ -1,159 +1,145 @@ --- title: SDNQ Quantization +description: Install and use SDNQ-quantized FLUX.1, FLUX.2 Klein and Z-Image pipelines, with their LoRA support and quality trade-offs. +lastUpdated: 2026-10-26 sidebar: order: 4 --- -import { Steps } from '@astrojs/starlight/components'; +import { Steps, Tabs, TabItem } from '@astrojs/starlight/components'; -SDNQ ([SD.Next Quantization Engine](https://github.com/Disty0/sdnq)) is a quantization scheme that stores model weights at 4–5 bits with an optional low-rank SVD correction. InvokeAI loads SDNQ-quantized models as full HuggingFace diffusers pipelines and dequantizes weights on the fly during inference, with no extra Python package required. +SDNQ ([SD.Next Quantization Engine](https://github.com/Disty0/sdnq)) is a quantization scheme with an optional low-rank +SVD correction. The published builds store most weights at 4 or 5 bits. InvokeAI loads SDNQ models, usually whole +Hugging Face diffusers pipelines, and unpacks the weights on the fly during generation, with no extra Python package. +How it compares with GGUF, int8, FP8 and the other formats is in the +[format comparison](/configuration/optimization/memory-options/#format-comparison). ## Supported models -| Model family | Status | Format(s) supported | -| ------------------ | ------ | ---------------------------------------------------- | -| **FLUX.1 schnell / dev** | ✅ | Diffusers pipeline (uint4 + SVD), single-file | -| **FLUX.2 Klein 4B / 9B** | ✅ | Diffusers pipeline (uint4 / int5 mixed, ± SVD), single-file | -| **Z-Image Turbo** | ✅ | Diffusers pipeline (uint4 + SVD), single-file | -| **T5 Encoder** | ✅ | Folder + standalone | -| **Qwen3 Encoder** | ✅ | Folder + standalone | -| **VAE (AutoencoderKL)** | ✅ | Folder | -| **SDXL / SD1 / SD2** | ❌ | Not yet — UNet pipeline conversion outstanding | - -:::caution[SDNQ vs SVDQuant / Nunchaku] -SDNQ ("SD.Next Quantization") and SVDQuant ("[Nunchaku](https://github.com/nunchaku-ai/nunchaku)") both apply SVD low-rank correction to 4-bit weights, but they use **different on-disk formats** and **different inference engines**. A file like `svdq-int4-flux.1-schnell.safetensors` from `mit-han-lab` is a Nunchaku checkpoint and will *not* load through InvokeAI's SDNQ path — it has keys like `qweight`, `wscales`, `smooth`, `lora_up` rather than SDNQ's `weight`, `scale`, `zero_point`, `svd_up`. Look for the `Disty0/...-SDNQ-...` repo prefix on HuggingFace to be sure you're picking up the right format. +| Model | Formats | Starter model | Download | Approx. VRAM at inference | BF16, for comparison | +| --- | --- | --- | --- | --- | --- | +| **FLUX.1** schnell, dev | Diffusers pipeline (uint4 + SVD), single file | `Disty0/FLUX.1-schnell-SDNQ-uint4-svd-r32` | ~15GB | ~12GB | ~33GB, whole pipeline | +| **FLUX.2 Klein** 4B | Diffusers pipeline (uint4 / int5 mixed), single file | `Disty0/FLUX.2-klein-4B-SDNQ-4bit-dynamic` | ~5GB | ~5GB | ~8GB, transformer | +| **FLUX.2 Klein** 9B | Diffusers pipeline (uint4 / int5 mixed, ± SVD), single file | `Disty0/FLUX.2-klein-9B-SDNQ-4bit-dynamic-svd-r32` | ~13GB | ~11GB | ~18GB, transformer | +| **Z-Image Turbo** | Diffusers pipeline (uint4 + SVD), single file | `Disty0/Z-Image-Turbo-SDNQ-uint4-svd-r32` | ~5GB | ~5GB | ~12GB, transformer | +| **T5 encoder** | Folder, standalone | – | – | – | – | +| **Qwen3 encoder** | Folder, standalone file | – | – | – | – | +| **VAE** | Folder (unpacked when it loads) | – | – | – | – | +| **FLUX.2 [dev]** | Diffusers pipeline: installs as FLUX.2 [dev], but its Mistral text encoder is not taken from the pipeline folder, so select a standalone one | – | – | – | – | +| **SD1, SD2, SDXL** | Not supported: the UNet pipeline conversion is outstanding | – | – | – | – | + +Peak VRAM also depends on resolution, batch size, attention backend and [Low-VRAM mode](/configuration/optimization/low-vram-mode/). + +:::caution[SDNQ is not SVDQuant (Nunchaku)] +Both apply an SVD low-rank correction to 4-bit weights, but their files and inference engines differ, and InvokeAI +loads only SDNQ. A file like `svdq-int4-flux.1-schnell.safetensors` from `mit-han-lab` is a Nunchaku checkpoint. + +| | SDNQ | SVDQuant ([Nunchaku](https://github.com/nunchaku-ai/nunchaku)) | +| --- | --- | --- | +| Tensor names | `weight`, `scale`, `zero_point`, `svd_up` | `qweight`, `wscales`, `smooth`, `lora_up` | +| Where to find it | `Disty0/…-SDNQ-…` repos on Hugging Face | `mit-han-lab` and Nunchaku repos, `svdq-…` files | +| Loads in InvokeAI | Yes | No | ::: -## Memory footprint - -Typical reductions vs. the bfloat16 baseline: - -| Model | bfloat16 | SDNQ uint4 + SVD | Approx. VRAM at inference | -| --------------------------- | -------- | ---------------- | ------------------------- | -| FLUX.1 schnell | ~33 GB | ~15 GB | ~12 GB | -| FLUX.2 Klein 4B (dynamic) | ~8 GB | ~5 GB | ~5 GB | -| FLUX.2 Klein 9B (dynamic + SVD) | ~18 GB | ~13 GB | ~11 GB | -| Z-Image Turbo | ~12 GB | ~5 GB | ~5 GB | - -The actual peak VRAM depends on resolution, batch size, attention backend, and whether [Low-VRAM mode](/configuration/optimization/low-vram-mode/) is enabled. - -## Installing SDNQ models - -The easiest way is via the **Starter Models** picker — search for "SDNQ": +## Installing + + -1. Open the **Model Manager** → **Starter Models** tab. +1. Open the **Model Manager** and its **Starter Models** tab. 2. Search for `SDNQ`. -3. Click **Install** on the variant you want (each entry shows the HuggingFace source). +3. Click **Install** on the variant you want. Each entry shows its Hugging Face source. - -To install a different SDNQ model from HuggingFace: - + + -1. Open the **Model Manager** → **Add Model** → **HuggingFace** tab. -2. Enter the repo, e.g. `Disty0/FLUX.2-klein-9B-SDNQ-4bit-dynamic-svd-r32`. -3. Click **Install**. The whole pipeline folder downloads (transformer + text encoder + tokenizer + VAE). +1. Open the **Model Manager**, then **Add Model** and the **HuggingFace** tab. +2. Enter the repo, for example `Disty0/FLUX.2-klein-9B-SDNQ-4bit-dynamic-svd-r32`. +3. Click **Install**. The whole pipeline folder downloads: transformer, text encoder, tokenizer and VAE. + + +For very large models, download the repo first, for example with `huggingface-cli`, then install the **pipeline root** +(the folder that contains `model_index.json`, not `transformer/`) with **Add Model** and **Folder**. + + -:::tip -For very large SDNQ models, you can also pre-download with `huggingface-cli` and then point InvokeAI at the local folder via **Add Model** → **Folder**. -::: - -InvokeAI auto-detects the SDNQ format from `transformer/quantization_config.json` (the `quant_method: "sdnq"` marker). Exports that ship no marker are still recognized, by the shape of the weights themselves — an SDNQ tensor stores a `.weight` alongside a matching `.scale`, and the pair is found even when sharding puts the two in different files. The Model Manager shows the format as **sdnq** in the model badge once installed. - -The transformer also has to hold weights the SDNQ reader can actually open (`.safetensors`). A folder with a config and a marker but no readable weight file is rejected at install rather than accepted and then failing on the first generation. - -## What gets quantized - -Inside a typical SDNQ pipeline folder: - -- **`transformer/`** — diffusion transformer weights (most of the savings come from here). Quantized to uint4 or, for dynamic-mixed exports, a per-layer mix of uint4 and int5. -- **`text_encoder/`** — for FLUX.1 this is T5 + CLIP (T5 is SDNQ'd, CLIP stays full precision); for FLUX.2 Klein and Z-Image, Qwen3 is SDNQ'd. -- **`vae/`** — left as bfloat16 in current Disty0 exports (the VAE is small enough that quantizing it isn't worth the quality risk). - -Layers in the producer's `modules_to_not_convert` list (typically embeddings, final projection, layer norms) stay full precision in all cases. +InvokeAI recognizes SDNQ from the `quant_method: "sdnq"` marker in `transformer/quantization_config.json`. Exports +without the marker are recognized by their weights: an SDNQ tensor stores `.weight` beside a matching +`.scale`, and the pair is found even when sharding puts the two in different files. The Model Manager then shows +the format as **SDNQ**. -## LoRA compatibility +The transformer must hold weights the SDNQ reader can open, which means `.safetensors`. A folder with a config and a +marker but no readable weight file is not accepted as SDNQ at install, rather than failing on the first generation. -LoRAs apply to SDNQ-quantized models via the **sidecar patching path**: instead of merging the LoRA delta into the quantized weight, InvokeAI keeps the LoRA as a separate residual that runs alongside each forward pass. - -- ✅ Standard LoRA, LoKr, DoRA, FluxControl-LoRA, FullLayer patches all work. -- ⚠️ Inference is slightly slower per step than non-quantized LoRA application (the sidecar adds an extra matmul per patched layer), but the loss is small in practice. -- ❌ LoRA training against SDNQ-quantized weights is **not supported**. - -If you stack many LoRAs on a heavily quantized model and notice quality drift, try lowering individual LoRA weights — the 4-bit base already eats some headroom for cumulative perturbations. - -## Quality trade-offs +## Using a pipeline without standalone components -The dynamic mixed-precision FLUX.2 Klein exports (`Disty0/FLUX.2-klein-{4B,9B}-SDNQ-4bit-dynamic-...`) let SDNQ promote individual layers from uint4 to int5 if the layer's quantization error exceeds a per-group budget. In practice this keeps the most sensitive attention projections at int5 while everything else stays uint4, with no user-visible quality regression vs. bfloat16 in most prompts. +A complete SDNQ pipeline brings its own encoders and VAE, so you can leave the standalone pickers empty: **T5 Encoder**, +**CLIP Embed** and **VAE** for FLUX.1, and the equivalent fields for FLUX.2 Klein and Z-Image. Select the pipeline as +the main model and generate; Invoke takes each component from the install. -The static uint4 + SVD exports (`...-SDNQ-uint4-svd-r32`) are slightly more aggressive but use rank-32 SVD residuals to recover the lost precision. The SVD correction adds ~3 % of the original weight size back to the file but largely closes the quality gap. +| What you installed | Encoder and VAE pickers | +| --- | --- | +| A complete SDNQ pipeline | **Optional.** A component you do select wins over the bundled one: that is how to swap, say, the pipeline's VAE for a full-precision one. | +| A partial or interrupted pipeline download | **Required** for the missing components. | +| A single-file SDNQ checkpoint, or a GGUF or BnB model | **Required.** FLUX.2 Klein can also take its components from a Diffusers model set as **Qwen3 Source**. | -You will most likely see SDNQ-specific quality issues at: +"Complete" means the install ships every component, not that its `model_index.json` lists them. At install, Invoke +checks each component folder and records only the ones that are there, hold weights their loader can read, and whose +own config names the class the index claims. A partial download is therefore not treated as self-contained: the +pickers for what it lacks stay required, instead of the generation failing. The **Generate** button names every missing +component at once. -- **Very high CFG values** (> 8) on Klein 4B dynamic — the 4-bit attention saturates faster than bfloat16. -- **Long generations with heavy LoRA stacks** — cumulative quantization noise becomes visible after dozens of steps. +## What gets quantized -If you need higher quality and have the VRAM, the static `Disty0/FLUX.2-klein-9B-SDNQ-uint4-svd-r32` (FLUX.2 Klein 9B) is the most faithful SDNQ option InvokeAI currently supports. Only the FLUX.2 Klein 4B / 9B variants are supported — FLUX.2 dev is not yet implemented, so FLUX.2-dev SDNQ exports cannot be installed correctly. +| Pipeline folder | In current Disty0 exports | +| --- | --- | +| `transformer/` | Most of the savings. uint4, or for the dynamic exports a per-layer mix of uint4 and int5. | +| `text_encoder/` | FLUX.1: T5 is SDNQ, CLIP stays full precision. FLUX.2 Klein and Z-Image: Qwen3 is SDNQ. | +| `vae/` | BF16: the VAE is small enough that quantizing it isn't worth the quality risk. | -## Comparison with other quantization formats +Layers in the producer's `modules_to_not_convert` list (typically embeddings, the final projection and layer norms) stay +full precision. InvokeAI's SDNQ reader also handles SDNQ's int8, uint8 and fp8 types. -| Format | Size | VRAM at inference | LoRA support | Loading path | -| ----------------- | ----- | ----------------- | ------------ | ---------------------------------------- | -| **SDNQ uint4 + SVD** | ~50 % | ~50 % | ✅ sidecar | Full diffusers pipeline | -| **GGUF Q4_K_M** | ~30 % | ~30 % | ✅ sidecar | Single-file transformer + separate encoders/VAE | -| **BnB NF4** | ~50 % | ~50 % | ✅ sidecar | Single-file transformer + separate encoders/VAE | -| **int8** (`int8_convrot`) | ~50 % | ~50 % | ✅ sidecar | Single-file transformer or encoder, stays int8 on every device | -| **nvfp4** | ~35 % | ~35 % | ✅ sidecar | Single-file transformer or encoder, stays packed on every GPU | -| **fp8 / fp8_scaled** checkpoint | ~50 % | ~50 % | ✅ sidecar | Single file; stays fp8 with FP8 Storage (on after install) or `fp8_compute` | -| **FP8 storage** | ~50 % | ~50 % | ✅ sidecar | Full-precision models whose loader supports it ([details](/configuration/optimization/fp8-storage/#where-the-toggle-is-shown)) | +## LoRAs -The headline differences: +LoRAs run beside the quantized weights: InvokeAI keeps each LoRA as a separate residual computed alongside the layer, +instead of merging it into the weight. -- **SDNQ models are pipeline-shaped**: one install pulls everything you need (transformer + encoders + VAE). GGUF and BnB usually need you to also install a T5 / Qwen3 / VAE separately. -- **SDNQ has the cleanest dynamic-precision story**: GGUF picks one bit-width per file; SDNQ dynamic-mixed exports tune precision per layer. -- **GGUF is more memory-efficient** at the same nominal bit-width because it uses smaller groups. SDNQ trades that for the SVD correction option. +- Standard LoRA, LoKr, DoRA, FluxControl LoRA and full-layer patches all work. +- Each step is slightly slower than with an unquantized model, because every patched layer adds a matmul. +- Training LoRAs against SDNQ weights is not supported. -For low VRAM (~6–8 GB), GGUF Q4 is still the best fit for most models; for Z-Image, the NVFP4 and int8 builds fit there too. For 12–16 GB cards that can host a FLUX-class model, SDNQ is the simplest "install one thing, get a working pipeline" option. +If many LoRAs stacked on a 4-bit model drift in quality, lower their weights: the 4-bit base already uses some of the +headroom for accumulated changes. -## Using a pipeline without standalone components +## Quality -A complete SDNQ pipeline supplies its own encoders and VAE, so you can leave the standalone model -pickers empty — the **T5 Encoder**, **CLIP Embed** and **VAE** fields for FLUX.1, and the equivalent -fields for FLUX.2 Klein and Z-Image. Select the pipeline as your main model and generate; Invoke -resolves each component from the install. +- **Dynamic exports** (`Disty0/FLUX.2-klein-{4B,9B}-SDNQ-4bit-dynamic…`) promote a layer from uint4 to int5 when its + quantization error exceeds a per-group budget. That keeps the most sensitive attention projections at int5, with no + visible regression against BF16 for most prompts. +- **Static uint4 + SVD exports** (`…-SDNQ-uint4-svd-r32`, published for FLUX.1 and Z-Image) quantize more + aggressively, but rank-32 SVD residuals recover the lost precision. The correction adds about 3% of the original + weight size and largely closes the quality gap. FLUX.2 Klein is published only as dynamic exports; the 9B one adds + the SVD correction. -"Complete" means the install actually ships every component, not that its `model_index.json` -advertises them. Invoke checks each component folder at install time and records only the ones that -are really there, hold weights their loader can read, and whose own config declares the class the -index claims. So a partial or interrupted download is *not* treated as self-contained, and the -standalone pickers stay required — which is the behaviour you want, because the alternative is a -failure at generation time. +SDNQ-specific issues show up mostly at **very high CFG** (above 8) on Klein 4B dynamic, where 4-bit attention saturates +sooner than BF16, and in **long generations with heavy LoRA stacks**, where quantization noise accumulates. -If you do select a standalone component, it wins over the bundled one. That is the way to override, -for example, an SDNQ pipeline's VAE with a full-precision one. +Compared with other formats: SDNQ models are pipeline-shaped, so one install brings everything (GGUF and BnB usually +need a T5, Qwen3 or VAE installed separately); dynamic exports tune precision per layer, where GGUF picks one bit width +per file; and GGUF is more memory-efficient at the same nominal bit width, because it uses smaller groups, which SDNQ +trades for the SVD correction. -Models that are not pipeline-shaped — single-file SDNQ checkpoints, GGUF, BnB — have nothing to -resolve from and still require the standalone selections. Invoke names every missing component at -once rather than reporting them one at a time. +**Which build for your VRAM:** at 6–8GB, GGUF Q4 is still the best fit for most models, and for Z-Image the nvfp4 and +int8 builds fit there too. On 12–16GB cards that can hold a FLUX-class model, SDNQ is the simplest option: install one +thing and get a working pipeline. ## Troubleshooting -### "Non-diffusers FLUX.2 Klein models require a standalone Qwen3 Encoder" (Invoke button greyed out) - -The Klein SDNQ pipeline carries its own Qwen3 encoder, so this readiness gate shouldn't fire — if it does, the install most likely happened before SDNQ-pipeline support was wired up and the model is cached with the wrong format in the database. Delete the model from the Model Manager and re-install it; the second install will pick up the correct `sdnq_quantized` format with the submodels populated. - -### Heavy high-frequency noise overlay on output - -If the structure of your image is recognizable (rough subject + composition) but a colored static is layered over it, you've hit a quantization-loader bug. Open an issue with: - -- The exact HuggingFace repo of the model. -- A side-by-side with a non-SDNQ variant of the same prompt (e.g. compare against GGUF Q4 of the same base model). - -Historically this has been caused by missing key-permutation steps in the diffusers→BFL state-dict conversion (e.g. `scale`/`shift` halves swapped). It's not a sign that the file itself is broken. - -### "no safetensors files found" or "size mismatch for weight" - -You probably pointed InvokeAI at the wrong subfolder — SDNQ pipelines are installed as the **whole repo root** (the folder that contains `model_index.json`), not at `transformer/` directly. The Model Manager's "Add Folder" flow expects the pipeline root. - -If the path is right, check that the quantized components actually ship `.safetensors`: Invoke's SDNQ reader only reads that format, so a `transformer/` or `text_encoder/` holding `.bin` or `.gguf` weights is not counted as present and the pipeline is reported as incomplete. Published SDNQ repos ship safetensors throughout; this normally only shows up on a hand-converted model. +| Symptom | Cause and fix | +| --- | --- | +| **Generate** says it needs a Qwen3 Encoder for a non-Diffusers FLUX.2 model, for a Klein SDNQ pipeline | The pipeline carries its own Qwen3 encoder, so this shouldn't appear. The model was most likely installed before SDNQ pipelines were supported and is recorded with the wrong format. Delete it in the Model Manager and install it again; it is then recorded as SDNQ with its components. | +| Colored static over a recognizable image | A quantization-loader bug, not a broken file. Historically it came from missing key permutations in the diffusers-to-BFL conversion (such as swapped `scale`/`shift` halves). Open an issue with the exact Hugging Face repo and a side-by-side with a non-SDNQ build of the same model and prompt (GGUF Q4, for example). | +| `no safetensors files found` or `size mismatch for weight` | You installed a subfolder. Install the **pipeline root**, the folder with `model_index.json`, not `transformer/`. | +| The pipeline is reported as incomplete | A quantized component holds `.bin` or `.gguf` weights: Invoke's SDNQ reader reads only `.safetensors`, so the component counts as missing. Published SDNQ repos ship safetensors throughout; this normally only happens with a hand-converted model. | diff --git a/docs/src/content/docs/contributing/translations.md b/docs/src/content/docs/contributing/translations.md index 53532312cb7..bc1f43f396b 100644 --- a/docs/src/content/docs/contributing/translations.md +++ b/docs/src/content/docs/contributing/translations.md @@ -2,19 +2,30 @@ title: Translations --- -InvokeAI uses [Weblate](https://weblate.org/) for translation. Weblate is a FOSS project providing a scalable translation service. Weblate automates the tedious parts of managing translation of a growing project, and the service is generously provided at no cost to FOSS projects like InvokeAI. +Invoke has two translation projects: the documentation is translated on [Crowdin](https://crowdin.com/project/invoke), and the app on [Weblate](https://hosted.weblate.org/engage/invokeai/). Both are free to join, and your work reaches the project through automated pull requests, so you don't need to touch the repository. -## Contributing +## Documentation -If you'd like to contribute by adding or updating a translation, please visit our [Weblate project](https://hosted.weblate.org/engage/invokeai/). You'll need to sign in with your GitHub account (a number of other accounts are supported, including Google). +The docs are translated into German, Spanish and Hindi in our [Crowdin project](https://crowdin.com/project/invoke). Sign in to Crowdin, join the project, pick a language and translate pages and interface strings there. -Once signed in, select a language and then the Web UI component. From here you can Browse and Translate strings from English to your chosen language. Zen mode offers a simpler translation experience. +- Pages that aren't translated yet show the English page with a notice, so every language has the full docs from the start. +- Every translated page has a **Translate this page** link with the page tools (in the right-hand column on wide screens, at the end of the page on small ones) that opens the Crowdin project. +- The guides and reference pages are translated. The developer and contributor docs stay in English. +- Translations come back as a pull request from Crowdin, which maintainers review before they go live. Please don't edit the `de`, `es` or `hi` directories in the repository by hand: Crowdin overwrites them on its next sync. -Your changes will be attributed to you in the automated PR process; you don't need to do anything else. +To suggest a new language, ask on [Discord](https://discord.gg/ZmtBAhwWhy). + +## App + +The app's interface is translated in our [Weblate project](https://hosted.weblate.org/engage/invokeai/). [Weblate](https://weblate.org/) is a free and open-source translation service, provided at no cost to open-source projects like Invoke. + +Sign in with your GitHub account (Google and several other accounts work too), select a language and then the Web UI component. From there you can browse and translate strings from English into your language; Zen mode offers a simpler translation view. + +Your changes are attributed to you in the automated pull request; you don't need to do anything else. ## Help & Questions -Please check Weblate's [documentation](https://docs.weblate.org/en/latest/index.html) or ping @Harvestor on [Discord](https://discord.com/channels/1020123559063990373/1049495067846524939) if you have any questions. +See the [Crowdin](https://support.crowdin.com/for-translators/) or [Weblate](https://docs.weblate.org/en/latest/index.html) documentation, or ask in the [translation channel on Discord](https://discord.com/channels/1020123559063990373/1049495067846524939). For the app's translations, you can also ping @Harvestor there. ## Thanks diff --git a/docs/src/content/docs/contributing/wan-vae-memory-calibration.md b/docs/src/content/docs/contributing/wan-vae-memory-calibration.md new file mode 100644 index 00000000000..8378c7849a9 --- /dev/null +++ b/docs/src/content/docs/contributing/wan-vae-memory-calibration.md @@ -0,0 +1,46 @@ +--- +title: Wan VAE Memory Calibration +lastUpdated: 2026-10-26 +--- + +Before a Wan VAE decode, Invoke estimates the working memory it needs and asks the model cache to keep that much +VRAM free (see [Low-VRAM mode](/configuration/optimization/low-vram-mode/)). The estimate scales with the video's +size through a constant shipped in the code. `scripts/calibrate_wan_vae_working_memory.py` measures a real decode so +you can check that constant on your own GPU. + +## Running it + +It needs a CUDA or ROCm device and a Wan VAE, as a Diffusers `AutoencoderKLWan` directory or a `.safetensors` +checkpoint: + +```sh +python scripts/calibrate_wan_vae_working_memory.py --vae +``` + +| Option | Default | Measures | +| --- | --- | --- | +| `--vae` | required | The VAE to load. | +| `--height`, `--width`, `--frames` | `704`, `1280`, `81` | The video's size in pixels and frames. | +| `--dtype` | `float16` | The VAE's dtype. | +| `--streaming` / `--no-streaming` | streaming | The chunked decode that `wan_memory_optimization` streams to MP4, or the default full decode. | +| `--tiling` | off | The spatially tiled full decode, the fallback for decodes too large for the card. Overrides `--streaming`. | +| `--tile-size ` | the VAE's tile size | The tile size. Requires `--tiling`. | + +## Reading the result + +The script prints the estimate, the measured allocated and reserved memory deltas, the scaling constant the +reserved delta implies, and `estimate covers: x of reserved`. + +- The implied constant uses **reserved** memory, which is what the shipped estimator covers: reserved memory is what + the decode takes from the GPU. +- The shipped constant sits above what the script reports for most sizes, because it has to cover the highest + measurement, an 832x480 decode inside a running server. `estimate covers` says whether the estimate holds: 1.00x or + more means it does. +- With `--tiling` the copies of each clip can exceed the whole measurement and leave a negative implied constant; + read `estimate covers` instead. + +## Why streaming holds the VAE lock + +Streaming to MP4 keeps the VAE cache lock while chunks are decoded and written. Wan's causal decoder state and weights +must stay live for the whole sequence; releasing the lock between chunks would need a separate, bounded decode and +encode queue. diff --git a/docs/src/content/docs/development/Documentation/index.mdx b/docs/src/content/docs/development/Documentation/index.mdx index 4c1af95c12e..e1f9a569712 100644 --- a/docs/src/content/docs/development/Documentation/index.mdx +++ b/docs/src/content/docs/development/Documentation/index.mdx @@ -40,7 +40,7 @@ The documentation is organized into a file tree structure. It should be very fam - lib/ utility functions and shared code - components/ reusable, custom components - pages/ non-documentation pages - - styles/ global styles and themes + - styles/ global styles; colors, tabs and buttons follow the app's Classic theme (`plugins/theme-tokens.mjs`) ## Development @@ -103,6 +103,8 @@ There are 2 main ways to get your development environment set up for documentati If there's another local server running on port `4321` prior to running this, then use the port specified in the output. +The docs' colors come from the app's theme, `invokeai/frontend/webv2/src/platform/ui/theme/themes.ts`. Saving that file restarts the dev server, so theme changes show up on their own; a change to `plugins/theme-tokens.mjs`, which turns the theme into the docs' tokens, needs a manual restart. + ### Adding Pages Located within the `src/content/docs/` directory, this is where the documentation pages are stored and organized by category. These categories are file-based and are mirrored to the sidebar navigation. @@ -132,39 +134,15 @@ For example, if we wanted to document a new feature called "Instant Bananas", we The way you organize your added pages dictates how the URL structure is generated for your documentation pages. In this example, the url for the `index.md` page would be `https://invoke.ai/features/instant-bananas/`, and the url for the `requirements.mdx` page would be `https://invoke.ai/features/instant-bananas/requirements/`. -If you wish to add a top-level category, then one additional step is required for the category to appear in the sidebar. - -Within the `src/config/sidebar.ts` file, you'll need to add a new sidebar category object to the array, since the fine-grained control over top-level categories needs to be a bit more explicit. +The sidebar is split into topics, such as Get Started, Guide and Models, and each page shows the sidebar of its own topic. `src/config/topics.ts` lists the topics with the label, description, icon and landing page that the topic switcher shows, and `src/config/sidebar.ts` gives each topic its sidebar items, usually a directory to autogenerate from. A new page in an existing directory joins its topic automatically. A new top-level directory needs a topic, or must be added to an existing topic's items, or the build fails with a message naming the page. -```diff lang="js" -const sidebar = [ +```diff lang="js" title="src/config/sidebar.ts" +const topicItems = { // ... - { - label: 'Concepts', - items: [ - { - autogenerate: { directory: 'concepts' }, - }, - ], - }, -+ { -+ label: 'A New Category', -+ items: [ -+ { -+ autogenerate: { directory: 'new-category' }, -+ }, -+ ], -+ }, - { - label: 'Features', - items: [ - { - autogenerate: { directory: 'features' }, - }, - ], - }, + configure: [{ autogenerate: { directory: 'configuration' } }], ++ 'new-topic': [{ autogenerate: { directory: 'new-category' } }], // ... -] +}; ``` ### Ordering Pages @@ -206,29 +184,14 @@ To render the image, you'd just use a relative path in your markdown. ![Alt text here](./assets/demonstration.webp) ``` -### Adding Translations - -Currently, the documentation is only available in English. If you wish to add translations for other languages, we've already laid the ground work for you to do so. - -Firstly, add a new folder within the `src/content/i18n` directory, and create your translated version of the markdown file into the same path as the original. +### Translations -For example: - - - - src/ - - content/ - - docs/ - - start-here/ - - installation.mdx - - i18n/ - - zh-CN Country code here - - start-here/ - - installation.mdx - +English is the source of truth: write and update the English pages and strings only. Translators work in [Crowdin](/contributing/translations/), which opens pull requests with their translations. -We recommend simply copy/pasting the file and rewriting the text from there. +- **Pages.** Translations of the user-facing trees (`start-here/`, `Users Guide/`, `configuration/`, `troubleshooting/` and the home page) land in `src/content/docs//`, mirroring the English paths. Crowdin owns those directories, so don't edit them by hand. A page without a translation shows the English page with a notice. A translation changes only prose: `pnpm test` fails one that adds or changes imports, components, `{…}` expressions, links or frontmatter other than titles and descriptions. +- **Interface strings.** Text in components and layouts comes from `src/content/i18n/en.json`. Add a key there and read it with `Astro.locals.t('key')`, or with the `Trans` component when the message contains markup such as a link. `pnpm test` fails when the code uses a key `en.json` lacks, or `en.json` has a key nothing uses. -Learn more about the intricacies of translating Astro Starlight docs [here](https://starlight.astro.build/guides/i18n). +Maintainers: the [docs README](https://github.com/invoke-ai/InvokeAI/blob/main/docs/README.md) covers Crowdin setup, adding a locale and reviewing a translation pull request. ## Running a Build @@ -297,6 +260,20 @@ The Invoke API is always evolving, and quite large. Documenting all this by hand These files are used for the [YAML Config](/configuration/invokeai-yaml) and [API Development](/development/guides/api-development) pages. If you're adding a feature that changes the yaml config, or the api then make sure to run `pnpm run generate-docs-data` to ensure tests pass, and that the docs are accurate in accordance to your updates. +### App Search Index + +The app's command palette searches the docs from a copy of the docs index committed at `invokeai/frontend/webv2/src/workbench/palette/docsSearchIndex.json`. The build serves the index at `/app-search.json` (`src/pages/app-search.json.ts`). It lists the topics outside the Build group, with their labels, descriptions and landing pages, and each of their published pages, with its title, description, topic, and h2 and h3 headings. Developer and contributor pages are left out. + +Resync the copy whenever a change reaches the index: adding, removing, moving or renaming a page; changing a page's title, description, `draft` or `pagefind` frontmatter; adding, removing or renaming an h2 or h3 heading; or changing a topic in `src/config/topics.ts`. Rebuild, copy the index into the app, and commit the result: + +```sh +cd docs +pnpm build +pnpm run sync-app-search-index +``` + +`pnpm run check-app-search-index` fails when the committed copy no longer matches a build. + ## Testing The docs contain tests for the following: @@ -306,6 +283,8 @@ The docs contain tests for the following: | Link Checker | Checks for invalid, malformed or misdirected internal link URLs | Dev Server, Build, Deploy | | Verify Deployment Output | Check to ensure the asset and page paths have the expected base paths dependent on deploy targets | Build, Deploy | | Check Docs Data | Checks to ensure the generated files are accurate | Deploy | +| Check App Search Index | Checks that the app's copy of the docs search index matches the build | Deploy | +| Translation Checks | Checks that translated pages and interface strings add no code, markup or links and keep their placeholders (`src/lib/translation-checks.mjs`, part of `pnpm test`) | Deploy | ## GitHub Actions diff --git a/docs/src/content/docs/development/Guides/creating-node-pack.mdx b/docs/src/content/docs/development/Guides/creating-node-pack.mdx index af50356596c..7b37db2b53c 100644 --- a/docs/src/content/docs/development/Guides/creating-node-pack.mdx +++ b/docs/src/content/docs/development/Guides/creating-node-pack.mdx @@ -157,7 +157,7 @@ class MyInvocation(BaseInvocation): return MyOutput(result=f"Processed: {self.input_text}") ``` -For full details on the invocation API, see the [Invocation API documentation](invocation-api.md). +For full details on the invocation API, see the [Invocation API documentation](/development/guides/api-development/). ## Best Practices diff --git a/docs/src/content/docs/development/Guides/creating-nodes.mdx b/docs/src/content/docs/development/Guides/creating-nodes.mdx index 20e80641eb3..2161c4d48fb 100644 --- a/docs/src/content/docs/development/Guides/creating-nodes.mdx +++ b/docs/src/content/docs/development/Guides/creating-nodes.mdx @@ -14,9 +14,7 @@ import { Steps, LinkCard } from '@astrojs/starlight/components'; 2. Make sure the node is contained in a new Python (.py) file. Preferably, the node is in a repo with a README detailing the nodes usage & examples to help others more easily use your node. Including the tag "invokeai-node" in your repository's README can also help other users find it more easily. - 3. Submit a pull request with a link to your node(s) repo in GitHub against the `main` branch to add the node to the [Community Nodes](/users-guide/workflows/community-nodes/) list - - Make sure you are following the template below and have provided all relevant details about the node and what it does. Example output images and workflows are very helpful for other users looking to use your node. + 3. Submit a pull request against the `main` branch that adds your pack to the [Community Nodes](/users-guide/workflows/community-nodes/) list, as described in [Listing your node pack](#listing-your-node-pack) below. 4. A maintainer will review the pull request and node. If the node is aligned with the direction of the project, you may be asked for permission to include it in the core project. @@ -55,22 +53,30 @@ Because the whole node is moved to another device, only mark a node `idle_gpu_of If a node that also runs the denoiser, VAE, or other session-GPU work is marked `idle_gpu_offloadable=True`, that work will be re-pinned to the wrong GPU and can misplace tensors or raise device-mismatch errors. When in doubt, leave it unset (the default is `False`) — the node will still work correctly, just without the offload optimization. ::: -### Community Node Template - -Append the following template to your pull request and the [Community Nodes](/users-guide/workflows/community-nodes/) page when submitting a node to be added to the community nodes list: - -```md ---- - -### Super Cool Node Template - -**Description:** This node allows you to do super cool things with InvokeAI. - -**Node Link:** https://github.com/invoke-ai/InvokeAI/fake_node.py - -**Example Node Graph:** https://github.com/invoke-ai/InvokeAI/fake_node_graph.json - -**Output Examples** - -![InvokeAI](https://invoke-ai.github.io/InvokeAI/assets/invoke_ai_banner.png) +### Listing your node pack + +Each pack on the [Community Nodes](/users-guide/workflows/community-nodes/) page is one YAML file in +`docs/src/content/community-nodes/`. Add a file for yours, such as `super-cool-nodes.yaml`: + +```yaml +title: Super Cool Nodes +author: your-github-name +repo: https://github.com/your-github-name/super-cool-nodes +summary: Does super cool things to images. +categories: [image-effects] +nodes: + - name: Super Cool Node + description: Makes an image super cool. +links: + - label: Example workflow + url: https://github.com/your-github-name/super-cool-nodes/blob/main/example-workflow.json +thumbnail: ./super-cool-nodes.webp +images: + - src: https://raw.githubusercontent.com/your-github-name/super-cool-nodes/main/example.png + alt: An image before and after the Super Cool Node ``` + +The [README in that folder](https://github.com/invoke-ai/InvokeAI/blob/main/docs/src/content/community-nodes/README.md) +lists every field and category, and how to make the card's small WebP thumbnail, which goes beside the YAML file. +Example images and workflows help others decide to use your pack. Users install a pack +by pasting its Git URL into the Nodes manager, which needs an `__init__.py` at the top level of your repository. diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 36b0cc46573..a86389dc769 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -1,143 +1,12 @@ --- -title: AI Image Generation for Creatives -description: A leading creative engine built to empower professionals and enthusiasts alike. +title: AI image generation for creatives +description: Invoke is a free, open-source creative engine for AI image and video generation that runs on your own hardware. template: splash hero: - title: AI Image Generation
for Creatives - tagline: Invoke is a free and open-source creative engine for AI-powered image generation. Built by creatives, for creatives. Self-hosted, fully customizable, and Apache 2.0 licensed. - actions: - - text: Get Started - link: start-here/installation - icon: right-arrow - variant: primary - - text: View on GitHub - link: https://github.com/invoke-ai/InvokeAI - icon: github - variant: minimal - - text: Become a Sponsor - link: https://github.com/sponsors/invoke-ai - icon: heart - variant: minimal + title: AI image generation for creatives + tagline: Invoke is a free, open-source creative engine that runs on your own hardware. Generate images and video, compose them on a layered canvas and build node workflows with the latest open models, all in one workbench. --- -import { Image } from 'astro:assets' -import { Card, CardGrid, LinkButton } from '@astrojs/starlight/components'; -import DownloadOptions from '@components/DownloadOptions.astro'; +import HomePage from '@components/HomePage.astro'; -import splashImage from './assets/invoke-webui-canvas.png'; -import blacksmithLogo from './assets/blacksmith-logo.png'; - -
- Invoke WebUI -
- -## The Creative Engine - -Invoke provides the most feature-complete and professional toolkit for AI image generation, built with production workflows in mind. - - - - Experience true **Layer-based Canvas Editing**. Invoke's powerful canvas allows you to draw, paint, sketch, and edit your creations with unhindered precision. Each layer can be independently manipulated—giving you the freedom to compose intricate scenes seamlessly without breaking a sweat. - - - Unlock limitless possibilities with **Advanced Node-based Workflows**. Build your own complex, reproducible pipelines via a completely visual graph backend. Expose custom UI parameters to share and update values easily without diving deep into the graph. - - - Stay on the cutting edge with out-of-the-box support for the latest foundational models, including **Flux, SDXL, SD 1.5**, and more. Manage checkpoints, LoRAs, Textual Inversions, and ControlNets with an intuitive Model Manager. - - - **Completely Local & Self-hosted**. Invoke runs locally on your own hardware. Your data, prompts, and creations belong entirely to you. Say goodbye to restrictive cloud services and privacy concerns—maintain absolute control over your art. - - - ---- - -## Built for Production - -Invoke is designed to keep your creative flow moving. Unlike other tools that feel like engineering experiments, Invoke is a polished, professional-grade application. - - - - A beautiful, clean interface that prioritizes your artwork. No cluttered menus—just the tools you need right where you expect them. - - - Extensive ControlNet implementation allows you to guide generations with depth maps, edges, poses, and more for exact composition control. - - - Rapidly iterate on concepts with batch generation, prompt wildcards, and high-resolution upscaling, all without leaving the app. - - - Actively developed by a passionate open-source community. Jump into the conversation, report bugs, or request features directly. - - - ---- - -## Join the Ecosystem - -Whether you are looking to install the app, get support, train your own models, or contribute to the project, the Invoke community has you covered. - - - - Ready to dive in? The [Invoke Launcher](/start-here/installation/) is the fastest way to get up and running on Windows, macOS, and Linux. For advanced setups, try [Docker](/configuration/docker/) or a [manual Python installation](/start-here/manual/). - - - Get Invoke - - - - - Want to train models on your own style? Invoke Training provides a dedicated UI for **Textual Inversion** and **LoRA training**. - - - Explore Invoke Training - - - - - Stuck? Check out our comprehensive [FAQ](/troubleshooting/faq/) for quick answers. If you still need a hand, our community is incredibly active and helpful. - - Join our Discord - - - - Invoke is open-source software made possible by [people across the world](/contributing/contributors/). We welcome code, documentation, and design contributions of any size! Read our [contributing guide](/contributing/) to start. - - - Read Contribution Guide - - - - ---- - -## Download Invoke - -Ready to unleash your creativity? Invoke is available for Windows, macOS, and Linux. Self-hosted, fully customizable, and Apache 2.0 licensed. - - - ---- - -:::note[About the former officially-hosted version] -The Invoke.ai hosted platform has been shut down as the founding team joined Adobe. However, Invoke lives on as a thriving open-source project maintained by the community. - -The open-source version offers the same powerful features you may have used in the hosted service, with the added benefit of complete control and privacy through self-hosting. - -Stewardship of the project has been passed to Lincoln Stein (lstein) and Vic (Blessedcoolant), who have been core maintainers since the project's inception and continue to drive development forward with the community. -::: - -## Sponsors - -InvokeAI development is powered by a generous in-kind donation from: - - Blacksmith - + diff --git a/docs/src/content/docs/start-here/manual.mdx b/docs/src/content/docs/start-here/manual.mdx index 3f6abf0b2e2..626921d47d3 100644 --- a/docs/src/content/docs/start-here/manual.mdx +++ b/docs/src/content/docs/start-here/manual.mdx @@ -1,10 +1,13 @@ --- title: Manual Installation -lastUpdated: 2026-10-19 +lastUpdated: 2026-10-25 --- -import { LinkCard, Tabs, TabItem, Steps, LinkButton } from '@astrojs/starlight/components' +import { LinkCard, Steps, LinkButton } from '@astrojs/starlight/components' import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro' +import SetupPicker from '@components/SetupPicker.astro' +import SetupVariant from '@components/SetupVariant.astro' +import SetupSwitch from '@components/SetupSwitch.astro' @@ -22,6 +25,10 @@ import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro' href="../../development" /> +## Your setup + + + ## Walkthrough We'll use [`uv`](https://github.com/astral-sh/uv) to install python and create a virtual environment, then install the `invokeai` package. `uv` is a modern, very fast alternative to `pip`. @@ -36,53 +43,59 @@ The following commands vary depending on the version of Invoke being installed a 2. Create a directory for your installation, typically in your home directory (e.g. `~/invokeai` or `$Home/invokeai`): - - - ```ps - mkdir $Home/invokeai - cd $Home/invokeai - ``` - - - ```bash - mkdir ~/invokeai - cd ~/invokeai - ``` - - + + + ```ps + mkdir $Home/invokeai + cd $Home/invokeai + ``` + + + ```bash + mkdir ~/invokeai + cd ~/invokeai + ``` + + 3. Create a virtual environment in that directory: - ```sh - uv venv --relocatable --prompt invoke --python 3.12 --python-preference only-managed .venv - ``` - - This command creates a portable virtual environment at `.venv` complete with a portable python 3.12. It doesn't matter if your system has no python installed, or has a different version - `uv` will handle everything. - - :::note[Windows on ARM64 (RTX Spark)] - `uv` installs an x86_64 python on ARM64 Windows unless asked for the native build, and an x86_64 python cannot load the ARM64 torch. Request the native interpreter explicitly: - + + ```sh - uv venv --relocatable --prompt invoke --python cpython-3.12-windows-aarch64-none --python-preference only-managed .venv + uv venv --relocatable --prompt invoke --python 3.12 --python-preference only-managed .venv + ``` + + + ```ps + uv venv --relocatable --prompt invoke ` + --python cpython-3.12-windows-aarch64-none --python-preference only-managed .venv ``` + + - Use the same `--python cpython-3.12-windows-aarch64-none` in place of `--python 3.12` in the install commands below. - ::: + This command creates a portable virtual environment at `.venv` complete with a portable python 3.12. It doesn't matter if your system has no python installed, or has a different version - `uv` will handle everything. + + + :::note[Windows on ARM64 (RTX Spark)] + `uv` installs an x86_64 python on ARM64 Windows unless asked for the native build, and an x86_64 python cannot load the ARM64 torch, so this command requests the native interpreter explicitly. The install command below uses the same `--python cpython-3.12-windows-aarch64-none` in place of `--python 3.12`. + ::: + 4. Activate the virtual environment: - - - ```ps - .venv\Scripts\activate - ``` - - - ```bash - source .venv/bin/activate - ``` - - + + + ```ps + .venv\Scripts\activate + ``` + + + ```bash + source .venv/bin/activate + ``` + + 5. Choose a version to install. @@ -97,10 +110,17 @@ The following commands vary depending on the version of Invoke being installed a 6. Determine the package specifier to use when installing: `invokeai`, plus any of these extras in brackets, separated by commas. - - `cuda`, if you use `--torch-backend=cu130` in the next step (Nvidia GPU on Windows x64 or Linux x86_64). It installs the PyTorch build Invoke is tested with and, on Windows, Triton. Add it only for Invoke 7 or newer, and never with another torch backend: it pins PyTorch's CUDA 13 build, which no other backend provides. - - `xformers`, if you have an Nvidia 16xx or 20xx series GPU. This is a performance optimization. + + + - `cuda`, if you use `--torch-backend=cu130` in the next step (Nvidia GPU on Windows x64 or Linux x86_64). It installs the PyTorch build Invoke is tested with and, on Windows, Triton. Add it only for Invoke 7 or newer, and never with another torch backend: it pins PyTorch's CUDA 13 build, which no other backend provides. + - `xformers`, if you have an Nvidia 16xx or 20xx series GPU. This is a performance optimization. - For example `invokeai[cuda]` for a 30xx series GPU or newer, `invokeai[cuda,xformers]` for a 16xx or 20xx series GPU, or `invokeai` without an Nvidia GPU. + For example `invokeai[cuda]` for a 30xx series GPU or newer, or `invokeai[cuda,xformers]` for a 16xx or 20xx series GPU. + + + Use `invokeai` without extras. The `cuda` extra is only for `--torch-backend=cu130` (Nvidia GPU on Windows x64 or Linux x86_64): it pins PyTorch's CUDA 13 build, which no other backend provides. The `xformers` extra is only for Nvidia 16xx or 20xx series GPUs. + + 7. Determine the torch backend to use for installation, if any. This is necessary to get the right version of torch installed. This is achieved by using [UV's built in torch support.](https://docs.astral.sh/uv/guides/integration/pytorch/#automatic-backend-selection) @@ -108,140 +128,201 @@ The following commands vary depending on the version of Invoke being installed a Pick a torch backend only when it applies to your system. In all other cases, do not use a torch backend. ::: - - - - - Use: - ```sh - --torch-backend=cu130 - ``` - - - Use: - ```sh - --torch-backend=xpu - ``` - - - Do not use a torch backend. AMD publishes its ROCm wheels on its own index, with the GPU kernels in a separate package per GPU target. In the next step, pin them for your GPU's target and point `uv` at that index instead: - ```sh - uv pip install == "torch[device-]==2.13.0+rocm10.0.0" "torchvision[device-]==0.28.0+rocm10.0.0" --index https://stable.repo.amd.com/rocm/whl-next/ --index-strategy unsafe-best-match --python 3.12 --python-preference only-managed --force-reinstall - ``` - `` is `gfx1201` for the RX 9070 series and Radeon AI PRO R9700, `gfx1200` for the RX 9060 series, `gfx1100` for the RX 7900 series, `gfx1101` for the RX 7700 and 7800 series, `gfx1102` for the RX 7600 series, and `gfx1151` for Ryzen AI Max. For other GPUs, see AMD's [GPU target table](https://github.com/ROCm/TheRock/blob/main/RELEASES.md#gfx-target-lookup-table). - - - Do not use a torch backend. torch comes from NVIDIA's own index, and three packages that have no ARM64 build on PyPI (OpenCV, blake3, tiktoken) come from wheels Invoke publishes. Use these two options instead of `--torch-backend`: - ```sh - --index https://pypi.nvidia.com/nvtorch_oot/ --find-links https://github.com/invoke-ai/InvokeAI/releases/download/win-arm64-wheels-v1/index.html - ``` - With or without an NVIDIA GPU: this is the only torch build for Windows ARM64. See the [system requirements](../system-requirements#windows-on-arm64-rtx-spark) for the platform's feature differences. - - - Do not use a torch backend. - - - - - - - - Use: - ```sh - --torch-backend=cu130 - ``` - - - On `x86_64`, use: - ```sh - --torch-backend=cpu - ``` - On ARM64 (`aarch64`, e.g. Raspberry Pi 5), do **not** use a torch backend. The default PyPI wheels are CPU-only on ARM64 and work out of the box. - - - Do not use a torch backend. Install AMD's ROCm 10 wheels from AMD's index, as the launcher does, with the GPU kernels pinned for your GPU's target: - ```sh - uv pip install == "torch[device-]==2.13.0+rocm10.0.0" "torchvision[device-]==0.28.0+rocm10.0.0" --index https://stable.repo.amd.com/rocm/whl-next/ --index-strategy unsafe-best-match --python 3.12 --python-preference only-managed --force-reinstall - ``` - `` is `gfx1201` for the RX 9070 series and Radeon AI PRO R9700, `gfx1200` for the RX 9060 series, `gfx1100` for the RX 7900 series, `gfx1101` for the RX 7700 and 7800 series, `gfx1102` for the RX 7600 series, and `gfx1151` for Ryzen AI Max. For other GPUs, see AMD's [GPU target table](https://github.com/ROCm/TheRock/blob/main/RELEASES.md#gfx-target-lookup-table). - - ROCm 10 has no kernels for Vega-based cards (gfx900/gfx906). For those, use PyTorch's ROCm 7.2 build instead: - ```sh - --torch-backend=rocm7.2 - ``` - - :::tip[ROCm and torch versions] - Prefer torch 2.13+ on the `rocm7.2` index: its bundled attention kernels (AOTriton - 0.12b) are around 3x faster on RDNA3 cards than the 2.10/`rocm7.1` generation. - torch 2.12.x on the older `rocm7.1` index was reported to break generation - ([#9410](https://github.com/invoke-ai/InvokeAI/issues/9410)); if you must stay on - `rocm7.1`, also pass `"torch<2.12"` to constrain the resolver. - ::: - - - Use: - ```sh - --torch-backend=xpu - ``` - - - Do not use a torch backend. - - - - + + + Use: + ```sh + --torch-backend=cu130 + ``` + + + Use: + ```sh + --torch-backend=xpu + ``` + + + Do not use a torch backend. AMD publishes its ROCm wheels on its own index, with the GPU kernels in a separate package per GPU target. In the next step, pin them for your GPU's target and point `uv` at that index instead. + + + Do not use a torch backend. In the next step, install AMD's ROCm 10 wheels from AMD's index, as the launcher does, with the GPU kernels pinned for your GPU's target. + + ROCm 10 has no kernels for Vega-based cards (gfx900/gfx906). For those, use PyTorch's ROCm 7.2 build instead: + ```sh + --torch-backend=rocm7.2 + ``` + + :::tip[ROCm and torch versions] + Prefer torch 2.13+ on the `rocm7.2` index: its bundled attention kernels (AOTriton + 0.12b) are around 3x faster on RDNA3 cards than the 2.10/`rocm7.1` generation. + torch 2.12.x on the older `rocm7.1` index was reported to break generation + ([#9410](https://github.com/invoke-ai/InvokeAI/issues/9410)); if you must stay on + `rocm7.1`, also pass `"torch<2.12"` to constrain the resolver. + ::: + + + Do not use a torch backend. torch comes from NVIDIA's own index, and three packages that have no ARM64 build on PyPI (OpenCV, blake3, tiktoken) come from wheels Invoke publishes. Use these two options instead of `--torch-backend`: + ```sh + --index https://pypi.nvidia.com/nvtorch_oot/ --find-links https://github.com/invoke-ai/InvokeAI/releases/download/win-arm64-wheels-v1/index.html + ``` + With or without an NVIDIA GPU: this is the only torch build for Windows ARM64. See the [system requirements](../system-requirements#windows-on-arm64-rtx-spark) for the platform's feature differences. + + + On `x86_64`, use: + ```sh + --torch-backend=cpu + ``` + On ARM64 (`aarch64`, e.g. Raspberry Pi 5), do **not** use a torch backend. The default PyPI wheels are CPU-only on ARM64 and work out of the box. + + + Do not use a torch backend. + + + Do not use a torch backend. + + 8. Install the `invokeai` package. Substitute the package specifier and version. - - - ```sh - uv pip install == --python 3.12 --python-preference only-managed --force-reinstall - ``` - - - ```sh - uv pip install "==" --python 3.12 --python-preference only-managed --torch-backend= --force-reinstall - ``` - - - ```sh - uv pip install == --python cpython-3.12-windows-aarch64-none --python-preference only-managed --index https://pypi.nvidia.com/nvtorch_oot/ --find-links https://github.com/invoke-ai/InvokeAI/releases/download/win-arm64-wheels-v1/index.html --force-reinstall - ``` - - - + + + ```ps + uv pip install "==" ` + --python 3.12 --python-preference only-managed ` + --torch-backend=cu130 --force-reinstall + ``` + + + ```sh + uv pip install "==" \ + --python 3.12 --python-preference only-managed \ + --torch-backend=cu130 --force-reinstall + ``` + + + ```ps + uv pip install "==" ` + --python 3.12 --python-preference only-managed ` + --torch-backend=xpu --force-reinstall + ``` + + + ```sh + uv pip install "==" \ + --python 3.12 --python-preference only-managed \ + --torch-backend=xpu --force-reinstall + ``` + + + Also substitute your GPU's `` (see below). + + ```ps + uv pip install == ` + "torch[device-]==2.13.0+rocm10.0.0" ` + "torchvision[device-]==0.28.0+rocm10.0.0" ` + --index https://stable.repo.amd.com/rocm/whl-next/ ` + --index-strategy unsafe-best-match ` + --python 3.12 --python-preference only-managed ` + --force-reinstall + ``` + + + Also substitute your GPU's `` (see below). + + ```sh + uv pip install == \ + "torch[device-]==2.13.0+rocm10.0.0" \ + "torchvision[device-]==0.28.0+rocm10.0.0" \ + --index https://stable.repo.amd.com/rocm/whl-next/ \ + --index-strategy unsafe-best-match \ + --python 3.12 --python-preference only-managed \ + --force-reinstall + ``` + + + ```ps + uv pip install == ` + --python cpython-3.12-windows-aarch64-none --python-preference only-managed ` + --index https://pypi.nvidia.com/nvtorch_oot/ ` + --find-links https://github.com/invoke-ai/InvokeAI/releases/download/win-arm64-wheels-v1/index.html ` + --force-reinstall + ``` + + + On `x86_64`: + + ```sh + uv pip install "==" \ + --python 3.12 --python-preference only-managed \ + --torch-backend=cpu --force-reinstall + ``` + + On ARM64 (`aarch64`), without a torch backend: + + ```sh + uv pip install == \ + --python 3.12 --python-preference only-managed \ + --force-reinstall + ``` + + + ```ps + uv pip install == ` + --python 3.12 --python-preference only-managed ` + --force-reinstall + ``` + + + ```sh + uv pip install == \ + --python 3.12 --python-preference only-managed \ + --force-reinstall + ``` + + + + | `` | GPUs | + | --- | --- | + | `gfx1201` | RX 9070 series and Radeon AI PRO R9700 | + | `gfx1200` | RX 9060 series | + | `gfx1100` | RX 7900 series | + | `gfx1101` | RX 7700 and 7800 series | + | `gfx1102` | RX 7600 series | + | `gfx1151` | Ryzen AI Max | + + For other GPUs, see AMD's [GPU target table](https://github.com/ROCm/TheRock/blob/main/RELEASES.md#gfx-target-lookup-table). + + + For a Vega-based card (gfx900/gfx906), install PyTorch's ROCm 7.2 build instead: + + ```sh + uv pip install "==" \ + --python 3.12 --python-preference only-managed \ + --torch-backend=rocm7.2 --force-reinstall + ``` + 9. Deactivate and reactivate your venv so that the invokeai-specific commands become available in the environment: - - - ```ps - deactivate - .venv\Scripts\activate - ``` - - - ```bash - deactivate && source .venv/bin/activate - ``` - - + + + ```ps + deactivate + .venv\Scripts\activate + ``` + + + ```bash + deactivate && source .venv/bin/activate + ``` + + 10. Run the application, specifying the directory you created earlier as the root directory: - - - ```ps - invokeai-web --root ~/invokeai - ``` - - - ```bash - invokeai-web --root ~/invokeai - ``` - - + ```sh + invokeai-web --root ~/invokeai + ``` To keep an existing install as it is, give v7 a directory of its own: see [Run v7 alongside v6](/start-here/upgrading/#run-v7-alongside-v6). diff --git a/docs/src/content/docs/start-here/system-requirements.mdx b/docs/src/content/docs/start-here/system-requirements.mdx index 59b55e1062d..6851117272e 100644 --- a/docs/src/content/docs/start-here/system-requirements.mdx +++ b/docs/src/content/docs/start-here/system-requirements.mdx @@ -248,7 +248,7 @@ the kernel is enough. For other distributions, follow the [ROCm Documentation] t An alternative to installing ROCm locally is to use a [ROCm docker container] to run the application in a container. -[Low VRAM Guide]: ../../configuration/low-vram-mode +[Low VRAM Guide]: /configuration/optimization/low-vram-mode/ [Nvidia Container Runtime]: https://developer.nvidia.com/container-runtime [an official installer]: https://www.python.org/downloads/ [using `uv` to manage your python installation]: https://docs.astral.sh/uv/concepts/python-versions/#installing-a-python-version diff --git a/docs/src/content/docs/troubleshooting/faq.mdx b/docs/src/content/docs/troubleshooting/faq.mdx index d8558c7fcac..5cd685aed35 100644 --- a/docs/src/content/docs/troubleshooting/faq.mdx +++ b/docs/src/content/docs/troubleshooting/faq.mdx @@ -25,7 +25,7 @@ If you find some models are missing after updating from v3, it's likely they wer You can use the `Scan Folder` tab in the Model Manager UI to fix this. The models will either be in the old, now-unused `autoimport` folder, or your `models` folder. -- Find and copy your install's old `autoimport` folder path, install the main install folder. +- Find and copy your install's old `autoimport` folder path, inside the main install folder. - Go to the Model Manager and click `Scan Folder`. - Paste the path and scan. - IMPORTANT: Uncheck `Inplace install`. @@ -43,7 +43,7 @@ Once v7 has run on an install, it has updated the database, and older versions c - Check the [system requirements] to ensure that your system is capable of generating images. - Follow the [Low VRAM mode guide] to optimize performance. -- Check that your generations are happening on your GPU (if you have one). Invoke will log what is being used for generation upon startup. If your GPU isn't used, re-install to and ensure you select the appropriate GPU option. +- Check that your generations are happening on your GPU (if you have one). Invoke will log what is being used for generation upon startup. If your GPU isn't used, reinstall and make sure you select the appropriate GPU option. - If you are on Windows with an Nvidia GPU, you may have exceeded your GPU's VRAM capacity and are triggering Nvidia's "sysmem fallback". There's a guide to opt out of this behaviour in the [Low VRAM mode guide]. ## Triton error on startup @@ -77,7 +77,7 @@ Invoke now draws its seeded noise in `float32`, because PyTorch 2.13 changed its ## Invalid configuration file -Everything seems to install ok, you get a `ValidationError` when starting up the app. +Everything seems to install OK, but you get a `ValidationError` when starting up the app. This is caused by an invalid setting in the `invokeai.yaml` configuration file. The error message should tell you what is wrong. @@ -89,7 +89,7 @@ The models are large, VRAM is expensive, and you may find yourself faced with Ou ## Memory Leak (Linux) -If you notice a memory leak, it could be caused to memory fragmentation as models are loaded and/or moved from CPU to GPU. +If you notice a memory leak, it could be caused by memory fragmentation as models are loaded and/or moved from CPU to GPU. A workaround is to tune memory allocation with an environment variable: @@ -110,13 +110,13 @@ MALLOC_MMAP_THRESHOLD_=1048576

Detailed Discussion

-Python (and PyTorch) relies on the memory allocator from the C Standard Library (`libc`). On linux, with the GNU C Standard Library implementation (`glibc`), our memory access patterns have been observed to cause severe memory fragmentation. +Python (and PyTorch) relies on the memory allocator from the C Standard Library (`libc`). On Linux, with the GNU C Standard Library implementation (`glibc`), our memory access patterns have been observed to cause severe memory fragmentation. This fragmentation results in large amounts of memory that has been freed but can't be released back to the OS. Loading models from disk and moving them between CPU/CUDA seem to be the operations that contribute most to the fragmentation. -This memory fragmentation issue can result in OOM crashes during frequent model switching, even if `ram` (the max RAM cache size) is set to a reasonable value (e.g. a OOM crash with `ram=16` on a system with 32GB of RAM). +This memory fragmentation issue can result in OOM crashes during frequent model switching, even if `max_cache_ram_gb` (the max RAM cache size) is set to a reasonable value (e.g. an OOM crash with `max_cache_ram_gb: 16` on a system with 32GB of RAM). -This problem may also exist on other OSes, and other `libc` implementations. But, at the time of writing, it has only been investigated on linux with `glibc`. +This problem may also exist on other OSes, and other `libc` implementations. But, at the time of writing, it has only been investigated on Linux with `glibc`. To better understand how the `glibc` memory allocator works, see these references: @@ -125,9 +125,9 @@ To better understand how the `glibc` memory allocator works, see these reference Note the differences between memory allocated as chunks in an arena vs. memory allocated with `mmap`. Under `glibc`'s default configuration, most model tensors get allocated as chunks in an arena making them vulnerable to the problem of fragmentation. -[model install docs]: ../../concepts/models +[model install docs]: /users-guide/models/introduction/ [system requirements]: ../../start-here/system-requirements -[Low VRAM mode guide]: ../../configuration/low-vram-mode +[Low VRAM mode guide]: /configuration/optimization/low-vram-mode/ [create an issue]: https://github.com/invoke-ai/InvokeAI/issues [discord]: https://discord.gg/ZmtBAhwWhy [configuration docs]: ../../configuration/invokeai-yaml diff --git a/docs/src/content/docs/troubleshooting/known-bugs.mdx b/docs/src/content/docs/troubleshooting/known-bugs.mdx index cc0a8b08503..41b59507b19 100644 --- a/docs/src/content/docs/troubleshooting/known-bugs.mdx +++ b/docs/src/content/docs/troubleshooting/known-bugs.mdx @@ -1,23 +1,13 @@ --- title: Known Bugs description: Places where the current release does not yet behave as the documentation describes, with workarounds. -lastUpdated: 2026-10-19 +lastUpdated: 2026-10-26 --- This page lists known cases where InvokeAI does not yet behave as this documentation describes, or no longer behaves as v6 did. Each entry explains what happens instead and how to work around it. Entries are removed once the fix is released. ## v7.0.0-alpha -### Multi-user mode: workflows cannot be shared - -**Documented behavior:** In multi-user mode, a workflow's owner can mark it as shared so that it appears in every user's workflow library. See [Working with Your Content in Multi-User Mode](/configuration/multi-user-mode/user-guide/#working-with-your-content-in-multi-user-mode). - -**Current behavior:** v7 has no control for sharing a workflow. Workflows you create stay private: only you and administrators can see them. Boards can be shared from their menu's **Access** item. - -**Workaround:** None in the v7 interface. Administrators can still view and manage all users' workflows. - -**Tracking:** [invoke-ai/InvokeAI#9673](https://github.com/invoke-ai/InvokeAI/issues/9673) - ### Plain-HTTP access: another tab can take a still-open tab's unsaved changes **Documented behavior:** If a change cannot be saved yet, your browser keeps it and the tab that made it keeps retrying until the save goes through. See [Saving and syncing](/users-guide/projects/introduction/#saving-and-syncing). diff --git a/docs/src/content/i18n/en.json b/docs/src/content/i18n/en.json index 69333e3a0b2..3a4ea65c7b7 100644 --- a/docs/src/content/i18n/en.json +++ b/docs/src/content/i18n/en.json @@ -1,45 +1,324 @@ { - "skipLink.label": "Skip to content", - "search.label": "Search", - "search.ctrlKey": "Ctrl", - "search.cancelLabel": "Cancel", - "search.devWarning": "Search is only available in production builds. \nTry building and previewing the site to test it out locally.", - "themeSelect.accessibleLabel": "Select theme", - "themeSelect.dark": "Dark", - "themeSelect.light": "Light", - "themeSelect.auto": "Auto", - "languageSelect.accessibleLabel": "Select language", - "menuButton.accessibleLabel": "Menu", - "sidebarNav.accessibleLabel": "Main", - "tableOfContents.onThisPage": "On this page", - "tableOfContents.overview": "Overview", - "i18n.untranslatedContent": "This content is not available in your language yet.", - "page.editLink": "Edit page", - "page.lastUpdated": "Last updated:", - "page.previousLink": "Previous", - "page.nextLink": "Next", - "page.draft": "This content is a draft and will not be included in production builds.", - "404.text": "Page not found. Check the URL or try using the search bar.", - "aside.note": "Note", - "aside.tip": "Tip", - "aside.caution": "Caution", - "aside.danger": "Danger", - "fileTree.directory": "Directory", - "builtWithStarlight.label": "Built with Starlight", - "heading.anchorLabel": "Section titled “{{title}}”", - - "expressiveCode.copyButtonCopied": "Copied!", - "expressiveCode.copyButtonTooltip": "Copy to clipboard", - "expressiveCode.terminalWindowFallbackTitle": "Terminal window", - - "pagefind.clear_search": "Clear", - "pagefind.load_more": "Load more results", - "pagefind.search_label": "Search this site", - "pagefind.filters_label": "Filters", - "pagefind.zero_results": "No results for [SEARCH_TERM]", - "pagefind.many_results": "[COUNT] results for [SEARCH_TERM]", - "pagefind.one_result": "[COUNT] result for [SEARCH_TERM]", - "pagefind.alt_search": "No results for [SEARCH_TERM]. Showing results for [DIFFERENT_TERM] instead", - "pagefind.search_suggestion": "No results for [SEARCH_TERM]. Try one of the following searches:", - "pagefind.searching": "Searching for [SEARCH_TERM]..." + "search.label": "Search docs", + "page.translateLink": "Translate this page", + "page.reportIssue": "Report an issue", + "page.updated": "Updated ", + "page.breadcrumb": "Breadcrumb", + + "nav.siteNavLabel": "Site", + "nav.home": "Invoke home", + "nav.docs": "Docs", + "nav.showcase": "Showcase", + "nav.download": "Download", + "footer.credit": "This site was designed and developed by Aether Fox Studio.", + + "languageMenu.accessibleLabel": "{{label}}: {{code}} ({{language}})", + + "versionMenu.label": "Docs version: {{version}}", + "versionMenu.currentRelease": "Current", + "versionMenu.previousRelease": "Previous release", + + "pageActions.copy": "Copy page", + "pageActions.copyDone": "Copied", + "pageActions.copyDoneStatus": "Page copied as Markdown.", + "pageActions.copyFailed": "Copy failed", + "pageActions.copyFailedStatus": "Could not copy the page.", + "pageActions.more": "More page actions", + "pageActions.viewMarkdown": "View as Markdown", + "pageActions.openInChatGPT": "Open in ChatGPT", + "pageActions.openInClaude": "Open in Claude", + "pageActions.aiPrompt": "Read from {{url}} so I can ask questions about it.", + + "pageFeedback.question": "Was this page helpful?", + "pageFeedback.answerYes": "Yes", + "pageFeedback.answerNo": "No", + "pageFeedback.thanks": "Thanks for your feedback.", + "pageFeedback.reportPrompt": "Tell us what was missing or wrong: Report an issue", + + "keyboardHint.moveSelection": "move", + "keyboardHint.openSelection": "open", + "keyboardHint.closePopup": "close", + + "topicSwitcher.trigger": "Topic: {{topic}}. Change topic", + "topicSwitcher.dialog": "Choose a docs topic", + "topicSwitcher.filterPlaceholder": "Filter topics", + "topicSwitcher.listLabel": "Topics", + "topicSwitcher.empty": "No topics match.", + + "topics.group.use": "Use", + "topics.group.run": "Run", + "topics.group.build": "Build", + "topics.group.help": "Help", + "topics.get-started.label": "Get Started", + "topics.get-started.description": "Install, requirements, upgrades", + "topics.guide.label": "Guide", + "topics.guide.description": "Workbench, canvas, gallery, workflows", + "topics.models.label": "Models", + "topics.models.description": "Families, installs, providers", + "topics.configure.label": "Configure & Deploy", + "topics.configure.description": "invokeai.yaml, Docker, multi-user", + "topics.developers.label": "Developers", + "topics.developers.description": "Architecture, nodes, APIs", + "topics.contribute.label": "Contribute", + "topics.contribute.description": "Code, translations, process", + "topics.troubleshooting.label": "Troubleshooting", + "topics.troubleshooting.description": "FAQ, known bugs, videos", + "topics.releases.label": "Releases", + "topics.releases.description": "Changelog for every version", + + "sidebar.group.workbench": "Workbench", + "sidebar.group.projects": "Projects", + "sidebar.group.local-models": "Local Models", + "sidebar.group.external-models": "External Models", + "sidebar.group.image-generation": "Image Generation", + "sidebar.group.advanced-topics": "Advanced Topics", + "sidebar.group.video-generation": "Video Generation", + "sidebar.group.gallery": "Gallery", + "sidebar.group.canvas": "Canvas", + "sidebar.group.workflows": "Workflows", + "sidebar.group.image-map": "Image Map", + "sidebar.group.multi-user-mode": "Multi-User Mode", + "sidebar.group.optimization": "Optimization", + "sidebar.group.architecture": "Architecture", + "sidebar.group.documentation": "Documentation", + "sidebar.group.front-end": "Front End", + "sidebar.group.guides": "Guides", + "sidebar.group.process": "Process", + "sidebar.group.setup": "Setup", + + "search.placeholder": "Search docs, settings, hotkeys, models…", + "search.scope": "Search scope", + "search.scopeChipsIntro": "In", + "search.allDocs": "All docs", + "search.results": "Search results", + "search.credit": "Search by Pagefind", + "search.recentSearches": "Recent", + "search.otherPages": "Other pages", + "search.settingsGroup": "Settings · invokeai.yaml", + "search.settingDefault": "default {{value}}", + "search.loadError": "Search couldn’t load. Check your connection, then try again.", + "search.searchingIn": "Searching in {{topic}}", + "search.searchingAll": "Searching all docs", + "search.scopeChangeStatus": "{{scope}}: {{results}}", + "search.noResults": "No results for “{{query}}”. Try fewer or different words.", + "search.noResultsInTopic": "No results for “{{query}}” in {{topic}}.", + "search.resultCount_one": "{{count}} result", + "search.resultCount_other": "{{count}} results", + "search.moreInAllDocs_one": "{{count}} more result in All docs", + "search.moreInAllDocs_other": "{{count}} more results in All docs", + "search.showInAllDocs_one": "Show {{count}} result in All docs", + "search.showInAllDocs_other": "Show {{count}} results in All docs", + + "markdown.tableLabel": "Table", + "mermaid.loading": "Loading diagram...", + "mermaid.sourceCode": "Source", + "systemRequirements.title": "System Requirements", + "systemRequirements.description": "Please check the system requirements page to make sure your hardware is capable of running the desired models.", + + "setupPicker.fallback": "Commands that differ by operating system or GPU are listed for every setup, each labeled with the setup it applies to.", + "setupPicker.os": "Operating system", + "setupPicker.gpu": "GPU", + "setupPicker.summary": "Showing commands for {{setup}}", + "setupPicker.fallbackNotes": "Notes that differ by operating system or GPU are listed for every setup, each labeled with the setup it applies to.", + "setupPicker.summaryNotes": "Showing notes for {{setup}}", + "setupPicker.option.otherOrNone": "Other / none", + "setupPicker.option.noneCpu": "None (CPU)", + "setupPicker.option.appleSilicon": "Apple silicon", + + "settingsReference.settingType": "Type", + "settingsReference.defaultValue": "Default", + "settingsReference.envVar": "Env", + "settingsReference.allowedValues": "Values:", + + "home.hero.announcement": "Invoke V7 Available Now!", + "home.hero.screenshotAlt": "The Invoke workbench in the Edit layout: the Generate panel with a Krea-2 model and a long prompt on the left; in the center, the Canvas with a glowing violet-and-cyan jellyfish over a city at dusk, its inpaint masks and regional guidance areas visible; and on the right, the color picker and a Layers panel of inpaint masks, regional guidance and raster layers.", + "home.cta.download": "Download Invoke", + "home.cta.readDocs": "Read the docs", + "home.cta.platformsAndLicense": "Windows · macOS · Linux · Apache 2.0", + "home.tour.label": "Product tour", + "home.tour.canvas.eyebrow": "Canvas", + "home.tour.canvas.title": "Paint exactly where it matters", + "home.tour.canvas.body": "Mask an area, describe what belongs there and accept the result to a layer. Inpainting, outpainting, regional prompts and control layers all work on one canvas.", + "home.tour.canvas.link": "Canvas introduction", + "home.tour.workflows.eyebrow": "Workflows", + "home.tour.workflows.title": "Wire up your own pipeline", + "home.tour.workflows.body": "Chain steps into a node graph and run it from a simple form. Add community node packs with the Custom Node Manager, or follow the ComfyUI migration guide to the nodes you already know.", + "home.tour.workflows.link": "Workflows guide", + "home.tour.gallery.eyebrow": "Gallery", + "home.tour.gallery.title": "Every image, easy to find", + "home.tour.gallery.body": "Boards and stars keep your images and videos in order. Each project collects its results in an Inbox and boards of its own, and Library boards are there from every project. Search by prompt or, with image indexing turned on, by what an image shows.", + "home.tour.gallery.link": "Gallery guide", + "home.showcase.eyebrow": "Community", + "home.showcase.title": "Made with Invoke", + "home.showcase.link": "View the showcase", + "home.showcase.pauseAnimation": "Pause showcase animation", + "home.challenge.title": "Latest challenge winners", + "home.challenge.week": "Week {{week}}", + "home.challenge.link": "See the winners", + "home.challenge.first": "1st place", + "home.challenge.second": "2nd place", + "home.challenge.third": "3rd place", + "home.challenge.runnerUp": "Runner-up", + "home.challenge.honourable": "Honourable mention", + "home.whatsNew.eyebrow": "What's new in 7", + "home.whatsNew.title": "One workbench for every way you create", + "home.models.eyebrow": "Models", + "home.models.title": "The latest open models, on your own hardware", + "home.models.intro": "Each model family has a guide to its variants, hardware needs and settings. External providers add hosted models such as GPT Image and Gemini, billed to your own account.", + "home.models.link": "Compare model families", + "home.directory.eyebrow": "Documentation", + "home.directory.title": "Find your way in", + "home.directory.create.kicker": "Create", + "home.directory.create.title": "Make images and video", + "home.directory.run.kicker": "Run", + "home.directory.run.title": "Configure and deploy", + "home.directory.build.kicker": "Build", + "home.directory.build.title": "Extend Invoke", + "home.directory.docker.label": "Docker", + "home.directory.docker.description": "Run Invoke in a container", + "home.directory.multiUser.label": "Multi-user mode", + "home.directory.multiUser.description": "Accounts on a shared server", + "home.directory.lowVram.label": "Low-VRAM mode", + "home.directory.lowVram.description": "Large models on smaller GPUs", + "home.directory.creatingNodes.label": "Creating nodes", + "home.directory.creatingNodes.description": "Write your own invocations", + "home.directory.nodePacks.label": "Node packs", + "home.directory.nodePacks.description": "Share nodes as a Git repository", + "home.community.eyebrow": "Community", + "home.community.title": "Built in the open", + "home.community.body": "Invoke is free software under the Apache 2.0 license, made by contributors around the world. The hosted Invoke.ai platform has shut down since its founding team joined Adobe. The open-source project carries on, stewarded by long-time core maintainers Lincoln Stein (lstein) and Vic (Blessedcoolant).", + "home.community.sponsorAlt": "CI powered by Blacksmith", + "home.community.showcaseLink": "Browse the community showcase", + "home.community.discordLink": "Join the Discord", + "home.community.githubLink": "Invoke on GitHub", + "home.community.sponsorLink": "Become a sponsor", + "home.community.trainingLink": "Train LoRAs with Invoke Training", + "home.install.title": "Install with the Invoke Launcher", + "home.install.body": "The Launcher installs, updates and launches Invoke on Windows, macOS and Linux. Check the system requirements first, or install manually or with Docker.", + "home.install.downloadLauncher": "Download the Launcher", + "home.install.guide": "Installation guide", + + "whatsNew.experimentalBadge": "Experimental", + "whatsNew.workbench.title": "The workbench", + "whatsNew.workbench.body": "One window built from panels. Arrange them on the rails, float the Preview over your canvas, and switch between saved layout presets in one click.", + "whatsNew.projects.title": "Projects", + "whatsNew.projects.body": "A project keeps its canvas, workflows, layout and boards together. It saves automatically and exports as one .invk file.", + "whatsNew.canvas.title": "A new canvas", + "whatsNew.canvas.body": "Pressure-sensitive brushes, gradients, shapes and text on layers with 16 blend modes, beside inpainting, regional guidance and control layers. Exports to PSD.", + "whatsNew.video.title": "Video with sound", + "whatsNew.video.body": "Short clips from Wan 2.2, or with a synchronized stereo soundtrack from LTX-2 and MiniMax H3.", + "whatsNew.imageMap.title": "The Image Map", + "whatsNew.imageMap.body": "Every image and video laid out by visual similarity, in labelled clusters. It also lets you search the gallery by what an image shows.", + "whatsNew.loops.title": "Loops in workflows", + "whatsNew.loops.body": "For and ForReturn nodes repeat a group of steps for each item in a collection, and can carry values from one iteration to the next.", + + "modelFamilies.videoTag": "Video", + "modelFamilies.licenseTag": "License: {{license}}", + "modelFamilies.vramTag": "{{gigabytes}} GB+ VRAM", + "modelFamilies.license.variesByModel": "Varies by model", + "modelFamilies.license.nonCommercial": "Non-commercial", + "modelFamilies.format.singleFile": "Single file", + "modelFamilies.externalProviders": "External providers, billed to your own account", + "modelFamilies.flux-2.tagline": "Multi-reference image editing", + "modelFamilies.flux-1.tagline": "Generate and edit, with Fill and ControlNets", + "modelFamilies.z-image.tagline": "Fast text-to-image in English and Chinese", + "modelFamilies.qwen-image-2-1.tagline": "Strong text rendering, transparent output", + "modelFamilies.qwen-image.tagline": "Edit with reference images", + "modelFamilies.krea-2.tagline": "Text-to-image with style reference", + "modelFamilies.ideogram4.tagline": "Structured prompts with placed regions", + "modelFamilies.ernie-image.tagline": "Text-to-image with a prompt enhancer", + "modelFamilies.cogview4.tagline": "Plain text-to-image from THUDM", + "modelFamilies.anima.tagline": "Anime, with regional prompting", + "modelFamilies.sd-3.tagline": "Stability AI's diffusion transformer", + "modelFamilies.sdxl.tagline": "1024px generation with ControlNet and Refiner", + "modelFamilies.stable-diffusion-1-2.tagline": "The lightest local image model", + "modelFamilies.wan-22.tagline": "Text- and image-to-video", + "modelFamilies.ltx-2.tagline": "Text-to-video with sound", + "modelFamilies.minimax-h3.tagline": "Video with sound from reference media", + + "home.tour.canvas.compare.label": "Before and after comparison", + "home.tour.canvas.compare.before": "Before", + "home.tour.canvas.compare.after": "After", + "home.tour.canvas.compare.beforeAlt": "Before: a plate with salmon nigiri, a yellow roll and, on the right, a plain maroon roll.", + "home.tour.canvas.compare.afterAlt": "After inpainting: the maroon roll, and only it, regenerated as a nori-wrapped roll topped with salmon roe.", + "imageCompare.valueText": "{{beforeLabel}} {{before}}%, {{afterLabel}} {{after}}%", + + "workflowDemo.label": "Interactive workflow demo", + "workflowDemo.hint": "Drag a node by its title, or focus it and use the arrow keys to move it. Drag from an output dot to a matching input to connect them.", + "workflowDemo.node": "{{title}} node", + "workflowDemo.collapseNode": "Collapse node", + "workflowDemo.expandNode": "Expand node", + "workflowDemo.field": "{{field}} of {{node}}", + "workflowDemo.connected": "Connected {{source}} to {{target}}.", + "workflowDemo.disconnected": "Removed the connection to {{target}}.", + "galleryDemo.label": "Interactive gallery demo", + "annotated.legend": "Callouts", + + "memoryPicker.fallback": "Every option is listed below, and each says which setups it applies to.", + "memoryPicker.row.series": "NVIDIA generation", + "memoryPicker.row.vram": "VRAM", + "memoryPicker.row.format": "Model format", + "memoryPicker.series.preAmpere": "GTX 16 / RTX 20 or older", + "memoryPicker.series.ampere": "RTX 30 / A100", + "memoryPicker.series.ada": "RTX 40 / RTX 50 / H100", + "memoryPicker.vram.6": "6 GB or less", + "memoryPicker.vram.8": "8 GB", + "memoryPicker.vram.12": "10–12 GB", + "memoryPicker.vram.16": "16 GB", + "memoryPicker.vram.24": "24 GB or more", + "memoryPicker.format.full": "Full precision", + "memoryPicker.count_one": "{{count}} option applies to this setup", + "memoryPicker.count_other": "{{count}} options apply to this setup", + "benchmarkChart.default": "Default", + "benchmarkChart.seconds": "{{value}} s", + "benchmarkChart.speedup": "{{ratio}}×", + "benchmarkChart.run": "Run", + "benchmarkChart.ratio": "Speedup", + "vramBudget.gigabytes": "{{value}} GB", + "vramBudget.cap": "The cap you set", + "vramBudget.working": "Working memory", + "vramBudget.reserve": "Reserve", + "vramBudget.result": "Left for models", + "vramBudget.resultHint": "What the model cache may fill", + "vramBudget.summary": "With max_cache_vram_gb at {{cap}}, device_working_mem_gb at {{working}} and reserve_vram_gb at {{reserve}}, the model cache may fill {{left}}.", + + "communityNodes.filtersLabel": "Find a node pack", + "communityNodes.searchLabel": "Search node packs", + "communityNodes.searchPlaceholder": "Search by name, node or author…", + "communityNodes.categoryLegend": "Category", + "communityNodes.allCategories": "All", + "communityNodes.category.imageEffects": "Image effects", + "communityNodes.category.masks": "Masks & backgrounds", + "communityNodes.category.compositing": "Compositing & grids", + "communityNodes.category.prompts": "Prompt tools", + "communityNodes.category.languageModels": "Language models", + "communityNodes.category.control": "ControlNet & adapters", + "communityNodes.category.video3d": "Video & 3D", + "communityNodes.category.sizing": "Sizing & upscaling", + "communityNodes.category.utility": "Utility", + "communityNodes.total_one": "{{count}} node pack", + "communityNodes.total_other": "{{count}} node packs", + "communityNodes.shown_one": "{{shown}} of {{count}} node pack", + "communityNodes.shown_other": "{{shown}} of {{count}} node packs", + "communityNodes.noResults": "No node packs match. Try other words or another category.", + "communityNodes.clearFilters": "Clear filters", + "communityNodes.listLabel": "Node packs", + "communityNodes.byAuthor": "by {{author}}", + "communityNodes.repository": "Repository", + "communityNodes.copyGitUrl": "Copy Git URL", + "communityNodes.copyDone": "Copied", + "communityNodes.copyDoneStatus": "Copied the Git URL of {{title}}.", + "communityNodes.copyFailed": "Copy failed", + "communityNodes.copyFailedStatus": "Could not copy the Git URL.", + "communityNodes.details": "Details", + "communityNodes.nodes": "Nodes", + "communityNodes.examples": "Examples", + "communityNodes.links": "Links", + "communityNodes.gitUrl": "Git URL", + "communityNodes.archived": "Archived", + "communityNodes.archivedNote": "The repository is archived, so the pack gets no more updates.", + "communityNodes.dependencies": "Extra packages", + "communityNodes.dependenciesNote": "The pack needs Python packages that InvokeAI doesn't install. Install them as its README describes, then restart InvokeAI.", + "communityNodes.manualInstall": "Manual setup", + "communityNodes.manualInstallNote": "Not a pack folder, so the Nodes manager can't install it. InvokeAI loads a folder in nodes only when it has an __init__.py: follow the repository's instructions." } diff --git a/docs/src/content/showcase/README.md b/docs/src/content/showcase/README.md new file mode 100644 index 00000000000..7dff81d2a2c --- /dev/null +++ b/docs/src/content/showcase/README.md @@ -0,0 +1,41 @@ +# Showcase entries + +Artwork in this directory appears on the docs site's `/showcase/` page, newest first, and the latest eight appear in the +"Made with Invoke" section of the home page. The collection is defined in `docs/src/content.config.ts`. + +Winners of the weekly Discord art challenges are published separately, from `src/content/challenges/`: see its +[README](../challenges/README.md). + +## Before you add an entry + +Add only artwork submitted through the [showcase issue form](https://github.com/invoke-ai/InvokeAI/issues/new?template=SHOWCASE.yml) +with both of its checkboxes ticked: the submitter made the image, and they let Invoke show it on the docs site with +credit. Link that issue in your pull request. If an artist asks for removal, delete both files. + +Credit the artist exactly as they wrote it in the form. + +## Adding an entry + +Each entry is two files with the same name: the image and a YAML file describing it. Name them after the date and the +title, in lowercase with hyphens, for example `2026-11-02-harbour-at-dusk.webp` and `2026-11-02-harbour-at-dusk.yaml`. +The file name, without its extension, becomes the entry's id and the anchor of its tile on `/showcase/`. + +```yaml +# 2026-11-02-harbour-at-dusk.yaml +image: ./2026-11-02-harbour-at-dusk.webp +title: Harbour at Dusk +artist: Ana Example # as the artist asked to be credited +artistUrl: https://example.com/ana # optional +model: FLUX.2 Klein # optional, free text +createdWith: Canvas, regional guidance # optional, a short note +date: 2026-11-02 # when the entry was added +``` + +`image`, `title`, `artist` and `date` are required. `artistUrl` must be an `http://` or `https://` URL. The build fails if a required field is missing or `artistUrl` is anything else. + +## Image guidance + +- WebP or JPEG. Convert PNG submissions to WebP unless they need lossless detail. +- About 2400 px on the long side. The site generates smaller sizes itself; larger files only slow down the build. +- Keep files under about 1.5 MB. +- Keep the artwork's own aspect ratio and don't crop it. The grid shows every image whole. diff --git a/docs/src/data/launcher-release.json b/docs/src/data/launcher-release.json new file mode 100644 index 00000000000..4bbf2158ed6 --- /dev/null +++ b/docs/src/data/launcher-release.json @@ -0,0 +1,16 @@ +{ + "version": "1.8.5", + "publishedAt": "2026-10-10T21:05:09Z", + "notesUrl": "https://github.com/invoke-ai/launcher/releases/tag/v1.8.5", + "assets": { + "windows": { + "size": 113202136 + }, + "macos": { + "size": 132949779 + }, + "linux": { + "size": 145780235 + } + } +} diff --git a/docs/src/lib/app-assets.test.mjs b/docs/src/lib/app-assets.test.mjs new file mode 100644 index 00000000000..358456368f3 --- /dev/null +++ b/docs/src/lib/app-assets.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it } from 'node:test'; + +// The home page shows the app's V7 logo. Vite's dev server refuses files outside the docs package, so the docs keep +// a copy; this keeps the copy identical to the app's file. +it('keeps the V7 logo identical to the app', () => { + const docsCopy = readFileSync(new URL('../assets/v7-logo.webp', import.meta.url)); + const appOriginal = readFileSync( + new URL('../../../invokeai/frontend/webv2/src/assets/V7Logo.webp', import.meta.url), + ); + assert.ok(docsCopy.equals(appOriginal), 'Copy invokeai/frontend/webv2/src/assets/V7Logo.webp to docs/src/assets/v7-logo.webp.'); +}); diff --git a/docs/src/lib/app-search.mjs b/docs/src/lib/app-search.mjs new file mode 100644 index 00000000000..ef552185576 --- /dev/null +++ b/docs/src/lib/app-search.mjs @@ -0,0 +1,121 @@ +import { fileDirectory } from './sidebar.mjs'; + +// The docs index the app's command palette searches (`src/pages/app-search.json.ts`). The app commits a copy +// (`scripts/sync-app-search-index.mjs`) and opens results on the deployed site, so the index holds page ids and heading +// anchors without the deployment base path: every deploy target produces the same file. Plain JavaScript so +// `node --test` can cover it. + +/** Deeper headings are too fine-grained to be useful palette results. */ +const HEADING_DEPTHS = new Set([2, 3]); + +/** + * Developer and contributor docs stay out of the app: palette results are capped, and their many API and checklist + * headings would crowd out the user docs. + */ +const EXCLUDED_TOPIC_GROUPS = new Set(['Build']); + +/** + * The app search index. Keys are short because the app bundles the whole index. The app mirrors this shape in + * `invokeai/frontend/webv2/src/workbench/palette/docsSearchProvider.ts`. + * @typedef {object} AppSearchIndex + * @property {AppSearchIndexTopic[]} topics The topics the index covers, in the topic switcher's order. + * @property {AppSearchPage[]} pages + */ + +/** + * @typedef {object} AppSearchIndexTopic + * @property {string} l The topic label. + * @property {string} d The topic description. + * @property {string} s The topic's landing page: its URL path without the base path or slashes. + */ + +/** + * One docs page in the app search index. + * @typedef {object} AppSearchPage + * @property {string} s The page id: its URL path without the base path or slashes, e.g. `users-guide/models/loras`. + * @property {string} t The page title. + * @property {string} [d] The page description. + * @property {number} [c] The index in `topics` of the topic the page belongs to. + * @property {[anchor: string, text: string][]} [h] The page's h2 and h3 headings, in document order. + */ + +/** + * @typedef {{ depth: number, slug: string, text: string }} MarkdownHeading + * @typedef {{ label: string, description: string, group: string, link: string, directory: string }} AppSearchTopic + * @typedef {object} AppSearchDoc + * @property {string} id + * @property {string} [filePath] + * @property {{ title: string, description?: string, draft?: boolean, pagefind?: boolean, template?: string }} data + */ + +/** + * Whether a docs page can be in the index at all: published, indexed by the site's own search, and documentation + * rather than a landing page. The splash template is the home page's, whose content is a component, not documentation. + * @param {AppSearchDoc} doc + */ +const isSearchablePage = (doc) => + doc.id !== '404' && !doc.data.draft && doc.data.pagefind !== false && doc.data.template !== 'splash'; + +/** + * The topic whose directory holds the page; the most specific one wins, as `users-guide/models` (Models) does over + * `users-guide` (Guide). This mirrors the autogenerated topic sidebars in `src/config/sidebar.ts`. + * @param {AppSearchDoc} doc + * @param {AppSearchTopic[]} topics + */ +const findTopic = (doc, topics) => { + const directory = fileDirectory(doc.filePath); + let match; + for (const topic of topics) { + const inTopic = directory === topic.directory || directory.startsWith(`${topic.directory}/`); + if (inTopic && (!match || topic.directory.length > match.directory.length)) match = topic; + } + return match; +}; + +/** @param {string} link */ +const toPageId = (link) => link.replace(/^\/+|\/+$/g, ''); + +/** + * Builds the app search index. Pages follow their topics' order, then page id, so the same docs always produce the + * same file and the app breaks ranking ties in favor of the topics readers meet first. + * @template {AppSearchDoc} Doc + * @param {Doc[]} docs + * @param {{ topics: AppSearchTopic[], getHeadings: (doc: Doc) => Promise }} options + * @returns {Promise} + */ +export const buildAppSearchIndex = async (docs, { topics, getHeadings }) => { + const included = topics.filter((topic) => !EXCLUDED_TOPIC_GROUPS.has(topic.group)); + const candidates = docs + .filter(isSearchablePage) + .map((doc) => ({ doc, topic: findTopic(doc, topics) })) + .filter(({ topic }) => !topic || included.includes(topic)) + .map(({ doc, topic }) => ({ doc, topicIndex: topic ? included.indexOf(topic) : included.length })) + .sort((a, b) => a.topicIndex - b.topicIndex || (a.doc.id < b.doc.id ? -1 : a.doc.id > b.doc.id ? 1 : 0)); + const pages = await Promise.all( + candidates.map(async ({ doc, topicIndex }) => { + const headings = (await getHeadings(doc)) + .filter((heading) => HEADING_DEPTHS.has(heading.depth)) + .map((heading) => /** @type {[string, string]} */ ([heading.slug, heading.text])); + /** @type {AppSearchPage} */ + const page = { s: doc.id, t: doc.data.title }; + if (doc.data.description) page.d = doc.data.description; + if (topicIndex < included.length) page.c = topicIndex; + if (headings.length > 0) page.h = headings; + return page; + }) + ); + return { + topics: included.map((topic) => ({ l: topic.label, d: topic.description, s: toPageId(topic.link) })), + pages, + }; +}; + +/** + * The committed copy's text: one topic or page per line, so a docs change shows up as a readable diff. + * @param {AppSearchIndex} index + */ +export const formatAppSearchIndex = ({ topics, pages }) => { + /** @param {unknown[]} items */ + const lines = (items) => (items.length === 0 ? '[]' : `[\n${items.map((item) => JSON.stringify(item)).join(',\n')}\n]`); + return `{"topics":${lines(topics)},\n"pages":${lines(pages)}}\n`; +}; diff --git a/docs/src/lib/app-search.test.mjs b/docs/src/lib/app-search.test.mjs new file mode 100644 index 00000000000..0a0fb1d5a74 --- /dev/null +++ b/docs/src/lib/app-search.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildAppSearchIndex, formatAppSearchIndex } from './app-search.mjs'; + +const topic = (label, group, directory, link = `/${directory}/`) => ({ + label, + description: `${label} docs`, + group, + link, + directory, +}); + +const topics = [ + topic('Get Started', 'Use', 'start-here', '/start-here/installation/'), + topic('Guide', 'Use', 'users-guide'), + topic('Models', 'Use', 'users-guide/models'), + topic('Developers', 'Build', 'development'), + topic('Troubleshooting', 'Help', 'troubleshooting', '/troubleshooting/faq/'), +]; + +const doc = (id, filePath, data = {}) => ({ + id, + filePath: `src/content/docs/${filePath}`, + data: { title: id, ...data }, +}); + +const build = (docs, headingsById = {}) => + buildAppSearchIndex(docs, { topics, getHeadings: async (entry) => headingsById[entry.id] ?? [] }); + +describe('buildAppSearchIndex', () => { + it('leaves out drafts, pages the site search skips, the 404 page, splash pages and developer topics', async () => { + const { pages } = await build([ + doc('start-here/installation', 'start-here/01.installation.mdx'), + doc('start-here/draft', 'start-here/draft.md', { draft: true }), + doc('start-here/hidden', 'start-here/hidden.md', { pagefind: false }), + doc('404', '404.md'), + doc('', 'index.mdx', { template: 'splash' }), + doc('development/architecture', 'development/architecture.md'), + ]); + assert.deepEqual( + pages.map((page) => page.s), + ['start-here/installation'], + ); + }); + + it('lists the included topics in order with their landing pages', async () => { + const index = await build([]); + assert.deepEqual(index.topics, [ + { l: 'Get Started', d: 'Get Started docs', s: 'start-here/installation' }, + { l: 'Guide', d: 'Guide docs', s: 'users-guide' }, + { l: 'Models', d: 'Models docs', s: 'users-guide/models' }, + { l: 'Troubleshooting', d: 'Troubleshooting docs', s: 'troubleshooting/faq' }, + ]); + }); + + it('keeps h2 and h3 headings in document order as [anchor, text]', async () => { + const { + pages: [page], + } = await build([doc('start-here/installation', 'start-here/01.installation.mdx')], { + 'start-here/installation': [ + { depth: 1, slug: 'installation', text: 'Installation' }, + { depth: 2, slug: 'requirements', text: 'Requirements' }, + { depth: 4, slug: 'linux-notes', text: 'Linux notes' }, + { depth: 3, slug: 'gpu', text: 'GPU' }, + { depth: 2, slug: 'launcher', text: 'Launcher' }, + ], + }); + assert.deepEqual(page.h, [ + ['requirements', 'Requirements'], + ['gpu', 'GPU'], + ['launcher', 'Launcher'], + ]); + }); + + it('points each page at the most specific topic of its directory, and none outside every topic', async () => { + const { topics: indexTopics, pages } = await build([ + doc('users-guide/models/loras', 'Users Guide/03.Models/02.loras.mdx'), + doc('users-guide/canvas/layers', 'Users Guide/02.Canvas/layers.mdx'), + doc('users-guide-extra', 'users-guide-extra/index.md'), + ]); + assert.deepEqual( + Object.fromEntries(pages.map((page) => [page.s, page.c === undefined ? undefined : indexTopics[page.c].l])), + { + 'users-guide-extra': undefined, + 'users-guide/canvas/layers': 'Guide', + 'users-guide/models/loras': 'Models', + }, + ); + }); + + it('orders pages by topic, then id, whatever the collection order, and omits empty optional fields', async () => { + const docs = [ + doc('troubleshooting/faq', 'troubleshooting/faq.md', { title: 'FAQ' }), + doc('users-guide/models/loras', 'Users Guide/03.Models/loras.md', { title: 'LoRAs' }), + doc('start-here/upgrading', 'start-here/upgrading.md', { title: 'Upgrading', description: 'Move to a release.' }), + doc('start-here/installation', 'start-here/installation.md', { title: 'Installation', description: '' }), + ]; + const forward = await build(docs); + const reversed = await build([...docs].reverse()); + assert.deepEqual(forward.pages, [ + { s: 'start-here/installation', t: 'Installation', c: 0 }, + { s: 'start-here/upgrading', t: 'Upgrading', d: 'Move to a release.', c: 0 }, + { s: 'users-guide/models/loras', t: 'LoRAs', c: 2 }, + { s: 'troubleshooting/faq', t: 'FAQ', c: 3 }, + ]); + assert.equal(formatAppSearchIndex(reversed), formatAppSearchIndex(forward)); + }); +}); + +describe('formatAppSearchIndex', () => { + it('writes one topic or page per line as valid JSON', () => { + const index = { + topics: [{ l: 'Guide', d: 'How to', s: 'guide' }], + pages: [ + { s: 'a', t: 'A' }, + { s: 'b', t: 'B', h: [['x', 'X']] }, + ], + }; + const text = formatAppSearchIndex(index); + assert.equal( + text, + '{"topics":[\n{"l":"Guide","d":"How to","s":"guide"}\n],\n"pages":[\n{"s":"a","t":"A"},\n{"s":"b","t":"B","h":[["x","X"]]}\n]}\n', + ); + assert.deepEqual(JSON.parse(text), index); + }); +}); diff --git a/docs/src/lib/base-path.ts b/docs/src/lib/base-path.ts index 23042d899a5..16c0df7f0c6 100644 --- a/docs/src/lib/base-path.ts +++ b/docs/src/lib/base-path.ts @@ -1,6 +1,12 @@ +import { localizePath } from '../config/locales.mjs'; + export const withBase = (path: string, baseUrl: string) => { const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; const normalizedPath = path.replace(/^\//, ''); return `${normalizedBase}${normalizedPath}`; }; + +/** A site path, without the base path, as a link from a page in `locale`: root-only paths stay at the root. */ +export const localizedHref = (path: string, { locale, base }: { locale: string | undefined; base: string }) => + withBase(localizePath(path, locale), base); diff --git a/docs/src/lib/challenges.mjs b/docs/src/lib/challenges.mjs new file mode 100644 index 00000000000..a00f18595cd --- /dev/null +++ b/docs/src/lib/challenges.mjs @@ -0,0 +1,174 @@ +// Weekly Discord art challenges: one folder per week in `src/content/challenges/`, shown on `/showcase/`, on +// `/showcase/challenges/` and on the home page. Challenge content and these labels stay English: the showcase pages +// are root-only, and the home page takes its own labels from `en.json` and formats dates in the page's language. + +/** A winner's `place`, in display order: `challenge.yaml` lists its winners in this order, ties side by side. */ +export const PLACES = /** @type {const} */ ([1, 2, 3, 'runner-up', 'honourable']); + +/** @typedef {(typeof PLACES)[number]} Place */ + +/** @type {Record} */ +const PLACE_LABELS = { + 1: '1st place', + 2: '2nd place', + 3: '3rd place', + 'runner-up': 'Runner-up', + honourable: 'Honourable mention', +}; + +/** @param {Place} place */ +export const placeLabel = (place) => PLACE_LABELS[place]; + +/** + * Newest first: by end date, then by week, then by id, so builds are stable. + * @template {{ id: string, data: { week?: number, endDate: Date } }} T + * @param {readonly T[]} challenges + * @returns {T[]} + */ +export const sortChallenges = (challenges) => + [...challenges].sort( + (a, b) => + b.data.endDate.getTime() - a.data.endDate.getTime() || + (b.data.week ?? 0) - (a.data.week ?? 0) || + a.id.localeCompare(b.id), + ); + +/** At most this many winners hold a numbered place, ties included; the podium has room for no more. */ +const MAX_NUMBERED = 3; + +/** + * What is wrong with a challenge's `winners`, one message per problem, for the schema. The pages show winners in file + * order, so the file lists them in `PLACES` order: numbered places first and never decreasing (a tie repeats a place, + * such as 1, 2, 2), then runners-up, then honourable mentions. At least one winner holds a numbered place: it leads the + * page and is its preview image. + * @param {readonly { place: Place, artist: string }[]} winners + * @returns {string[]} + */ +export const placeProblems = (winners) => { + const problems = []; + const numbered = winners.filter(({ place }) => typeof place === 'number').length; + if (numbered === 0) { + problems.push('No winner has a numbered place. Give the top winner `place: 1` (or 2 or 3), as announced.'); + } + if (numbered > MAX_NUMBERED) { + problems.push( + `${numbered} winners have a numbered place; at most ${MAX_NUMBERED} can. Use \`runner-up\` or \`honourable\` ` + + 'for the rest.', + ); + } + winners.forEach((winner, index) => { + const previous = winners[index - 1]; + if (previous && PLACES.indexOf(winner.place) < PLACES.indexOf(previous.place)) { + problems.push( + `Winner ${index + 1} (${winner.artist}, \`place: ${winner.place}\`) comes after ` + + `\`place: ${previous.place}\`. List winners in the order the page shows them: 1, 2, 3, runner-up, ` + + 'honourable, with ties side by side.', + ); + } + }); + return problems; +}; + +/** + * The challenges either side of `id` in `sorted` (newest first): `older` is the week before, `newer` the week after. + * @template {{ id: string }} T + * @param {readonly T[]} sorted + * @param {string} id + * @returns {{ older: T | undefined, newer: T | undefined }} + */ +export const adjacentChallenges = (sorted, id) => { + const index = sorted.findIndex((challenge) => challenge.id === id); + if (index < 0) throw new Error(`No challenge has the id "${id}".`); + return { older: sorted[index + 1], newer: sorted[index - 1] }; +}; + +/** + * Alt text for a winner's image: " by <artist>", or for an untitled entry the theme and the place, such as + * "Lost, runner-up by Romeo". + * @param {{ title?: string, artist: string, place: Place }} winner + * @param {string} theme + */ +export const winnerAlt = ({ title, artist, place }, theme) => + `${title ?? `${theme}, ${placeLabel(place).toLowerCase()}`} by ${artist}`; + +/** Frame shapes stay within these aspect ratios (width / height); a more extreme image shows whole inside its frame. */ +const MIN_ASPECT = 0.5; +const MAX_ASPECT = 2.2; +/** A lower place's frame is at most this fraction of the lead's height, and at most this fraction of its area. */ +const SIDE_HEIGHT = 0.7; +const SIDE_AREA = 0.6; +/** Runners-up and honourable mentions are at most this fraction of the lead's height, and shorter than any side. */ +const MENTION_HEIGHT = 0.55; + +/** + * A frame's aspect ratio: the image's own, within limits. + * @param {{ width: number, height: number }} image + */ +export const frameAspect = ({ width, height }) => Math.min(MAX_ASPECT, Math.max(MIN_ASPECT, width / height)); + +/** @param {number} value */ +const round = (value) => Math.round(value * 1e4) / 1e4; + +/** + * The podium's frames on wide screens: the numbered winners in one row, bottoms aligned, each frame its image's shape + * (`aspect`) and `scale` times the lead's height. Tied winners share a height, so they read as equal, at their own + * widths. A lower place is shorter than the lead and smaller in area, whatever its shape; tied lower places take the + * height that keeps the widest of them so. `span` is the row's width in lead heights, gaps aside, and `mentionScale` + * the most the runners-up' and honourable mentions' height may be, in lead heights. + * @param {readonly { place: Place, image: { width: number, height: number } }[]} podium the numbered winners, in order + * @returns {{ frames: { aspect: number, scale: number }[], span: number, mentionScale: number }} + */ +export const podiumLayout = (podium) => { + const aspects = podium.map(({ image }) => frameAspect(image)); + const leadPlace = podium[0]?.place; + /** @type {Map<Place, number>} the widest frame of each lower place */ + const widest = new Map(); + podium.forEach(({ place }, index) => { + if (place !== leadPlace) widest.set(place, Math.max(widest.get(place) ?? 0, aspects[index])); + }); + const frames = podium.map(({ place }, index) => { + const widestTied = widest.get(place); + const scale = + widestTied === undefined ? 1 : Math.min(SIDE_HEIGHT, Math.sqrt((SIDE_AREA * aspects[0]) / widestTied)); + return { aspect: round(aspects[index]), scale: round(scale) }; + }); + const sideScales = frames.filter(({ scale }) => scale < 1).map(({ scale }) => scale); + return { + frames, + span: round(frames.reduce((sum, { aspect, scale }) => sum + aspect * scale, 0)), + mentionScale: round(Math.min(MENTION_HEIGHT, 0.9 * Math.min(...sideScales))), + }; +}; + +/** + * For frames shown two to a row at one height per row, as on phones: each frame's row span, the sum of its row's + * aspect ratios, so a frame is `aspect / span` of the row's width. A last frame alone keeps the row above's height, + * or with no row above, the height a square beside it would give. + * @param {readonly number[]} aspects + * @returns {number[]} + */ +export const pairSpans = (aspects) => + aspects.map((aspect, index) => { + const start = index - (index % 2); + if (start + 1 < aspects.length) return round(aspects[start] + aspects[start + 1]); + return round(start > 0 ? aspects[start - 2] + aspects[start - 1] : aspect + 1); + }); + +/** + * A challenge's dates in the page's language, such as "October 21 – 27, 2026" in English. + * @param {Date} start + * @param {Date} end + * @param {string} [lang] a BCP 47 language tag; English by default + */ +export const formatChallengeDates = (start, end, lang = 'en') => + // Challenge dates are calendar days, which YAML gives as midnight UTC; another time zone could show the day before. + new Intl.DateTimeFormat(lang, { dateStyle: 'long', timeZone: 'UTC' }).formatRange(start, end); + +/** The archive of every challenge, a root-only page. */ +export const CHALLENGES_PATH = '/showcase/challenges/'; + +/** @param {string} id a challenge's folder name */ +export const challengePath = (id) => `${CHALLENGES_PATH}${id}/`; + +/** Where the challenges are announced and entered. */ +export const DISCORD_INVITE_URL = 'https://discord.gg/ZmtBAhwWhy'; diff --git a/docs/src/lib/challenges.test.mjs b/docs/src/lib/challenges.test.mjs new file mode 100644 index 00000000000..5cc709b70a3 --- /dev/null +++ b/docs/src/lib/challenges.test.mjs @@ -0,0 +1,497 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { createRequire } from 'node:module'; +import { join, posix } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + adjacentChallenges, + formatChallengeDates, + pairSpans, + placeProblems, + podiumLayout, + sortChallenges, + winnerAlt, +} from './challenges.mjs'; + +/** @param {string} id @param {number | undefined} week @param {string} endDate */ +const challenge = (id, week, endDate) => ({ id, data: { week, endDate: new Date(endDate) } }); + +describe('sortChallenges', () => { + it('puts the latest end date first, whatever the week numbers say', () => { + const challenges = [ + challenge('a', 12, '2026-10-06'), + challenge('b', 3, '2026-10-20'), + challenge('c', 13, '2026-10-13'), + ]; + assert.deepEqual( + sortChallenges(challenges).map(({ id }) => id), + ['b', 'c', 'a'], + ); + }); + + it('orders challenges ending the same day by week, highest first, then by id', () => { + const challenges = [ + challenge('x', 4, '2026-10-20'), + challenge('z', 5, '2026-10-20'), + challenge('y', 5, '2026-10-20'), + ]; + assert.deepEqual( + sortChallenges(challenges).map(({ id }) => id), + ['y', 'z', 'x'], + ); + }); + + it('sorts unnumbered challenges deterministically, with numbered challenges at the same end date', () => { + const challenges = [ + challenge('b', undefined, '2026-10-20'), + challenge('a', undefined, '2026-10-20'), + challenge('c', 3, '2026-10-20'), + ]; + assert.deepEqual( + sortChallenges(challenges).map(({ id }) => id), + ['c', 'a', 'b'], + ); + }); + + it('leaves the given array as it was', () => { + const challenges = [challenge('a', 1, '2026-01-01'), challenge('b', 2, '2026-02-01')]; + sortChallenges(challenges); + assert.deepEqual( + challenges.map(({ id }) => id), + ['a', 'b'], + ); + }); +}); + +describe('placeProblems', () => { + /** @param {...(number | string)} places */ + const winners = (...places) => places.map((place, index) => ({ place, artist: `artist${index + 1}` })); + + it('accepts winners listed in display order, with ties side by side', () => { + assert.deepEqual(placeProblems(winners(1, 2, 2, 'runner-up', 'runner-up', 'honourable')), []); + assert.deepEqual(placeProblems(winners(1, 1, 3)), []); + }); + + it('accepts a week that leads with a lower place, as after a removal', () => { + assert.deepEqual(placeProblems(winners(2, 'runner-up')), []); + }); + + it('rejects a numbered place listed after a lower one', () => { + assert.deepEqual(placeProblems(winners(2, 1)), [ + 'Winner 2 (artist2, `place: 1`) comes after `place: 2`. List winners in the order the page shows them: ' + + '1, 2, 3, runner-up, honourable, with ties side by side.', + ]); + }); + + it('rejects numbered places after a runner-up, and runners-up after an honourable mention', () => { + const problems = placeProblems(winners(1, 'runner-up', 2, 'honourable', 'runner-up')); + assert.equal(problems.length, 2); + assert.match(problems[0], /^Winner 3 \(artist3, `place: 2`\) comes after `place: runner-up`/); + assert.match(problems[1], /^Winner 5 \(artist5, `place: runner-up`\) comes after `place: honourable`/); + }); + + it('rejects more than three numbered places, ties included', () => { + assert.deepEqual(placeProblems(winners(1, 2, 2, 3)), [ + '4 winners have a numbered place; at most 3 can. Use `runner-up` or `honourable` for the rest.', + ]); + }); + + it('rejects a week without a numbered place, since one leads the page', () => { + assert.deepEqual(placeProblems(winners('runner-up', 'honourable')), [ + 'No winner has a numbered place. Give the top winner `place: 1` (or 2 or 3), as announced.', + ]); + }); +}); + +describe('podiumLayout', () => { + /** @param {number | string} place @param {number} width @param {number} height */ + const winner = (place, width, height) => ({ place, image: { width, height } }); + + it('gives a lone winner its own shape at full height', () => { + assert.deepEqual(podiumLayout([winner(1, 1344, 768)]), { + frames: [{ aspect: 1.75, scale: 1 }], + span: 1.75, + mentionScale: 0.55, + }); + }); + + it('gives tied places one height, each at its own shape', () => { + // Week 2026-10-12-gothic: a landscape 1st, then a portrait and a landscape tied for 2nd. + const { frames } = podiumLayout([winner(1, 1248, 832), winner(2, 888, 1184), winner(2, 1920, 1280)]); + assert.deepEqual( + frames.map(({ aspect }) => aspect), + [1.5, 0.75, 1.5], + ); + assert.equal(frames[1].scale, frames[2].scale); + // Sized for the wider of the two, which stays smaller than the lead in area. + assert.ok(frames[2].scale ** 2 * frames[2].aspect < frames[0].aspect); + }); + + it('keeps a lower place shorter than the lead and smaller in area, even when it is wider', () => { + // A portrait 1st beside a landscape 2nd, as in week 2026-08-18-pin-up. + const { frames, span, mentionScale } = podiumLayout([winner(1, 1728, 2304), winner(2, 1920, 1088)]); + const [lead, side] = frames; + assert.ok(side.scale <= 0.7); + assert.ok(side.scale ** 2 * side.aspect < lead.scale ** 2 * lead.aspect); + assert.equal(span, Math.round((lead.aspect + side.aspect * side.scale) * 1e4) / 1e4); + // Mentions are shorter than the shortest side frame. + assert.ok(mentionScale < side.scale); + }); + + it('gives tied leads full height', () => { + const { frames } = podiumLayout([winner(1, 1000, 1000), winner(1, 2000, 1000), winner(3, 1000, 1000)]); + assert.deepEqual( + frames.map(({ scale }) => scale), + [1, 1, 0.7], + ); + }); + + it('keeps extreme shapes within limits', () => { + const { frames } = podiumLayout([winner(1, 4000, 1000), winner(2, 300, 1500)]); + assert.equal(frames[0].aspect, 2.2); + assert.equal(frames[1].aspect, 0.5); + }); +}); + +describe('pairSpans', () => { + it("sums each pair's aspect ratios, so a row's frames share its height and fill its width", () => { + assert.deepEqual(pairSpans([1.5, 0.5, 1, 1]), [2, 2, 2, 2]); + }); + + it('keeps the row above\'s height for a last frame alone', () => { + assert.deepEqual(pairSpans([1.5, 0.5, 0.75]), [2, 2, 2]); + }); + + it('sizes a lone frame as if a square stood beside it', () => { + assert.deepEqual(pairSpans([0.5]), [1.5]); + }); +}); + +describe('adjacentChallenges', () => { + const sorted = [challenge('w3', 3, '2026-03-01'), challenge('w2', 2, '2026-02-01'), challenge('w1', 1, '2026-01-01')]; + + it('finds the week before and the week after', () => { + const { older, newer } = adjacentChallenges(sorted, 'w2'); + assert.equal(older?.id, 'w1'); + assert.equal(newer?.id, 'w3'); + }); + + it('has no newer challenge for the latest, and no older one for the first', () => { + assert.equal(adjacentChallenges(sorted, 'w3').newer, undefined); + assert.equal(adjacentChallenges(sorted, 'w1').older, undefined); + }); + + it('fails for an unknown id rather than linking the wrong weeks', () => { + assert.throws(() => adjacentChallenges(sorted, 'w9'), /w9/); + }); +}); + +describe('winnerAlt', () => { + it('names the title and the artist', () => { + assert.equal( + winnerAlt({ title: 'Tidewater Lantern', artist: 'Ana', place: 1 }, 'Bioluminescence'), + 'Tidewater Lantern by Ana', + ); + }); + + it('names the theme and the place for an untitled entry', () => { + assert.equal(winnerAlt({ artist: 'Revenant', place: 1 }, 'Pin-Up'), 'Pin-Up, 1st place by Revenant'); + assert.equal(winnerAlt({ artist: 'Romeo', place: 'runner-up' }, 'Lost'), 'Lost, runner-up by Romeo'); + assert.equal(winnerAlt({ artist: 'lumen', place: 'honourable' }, 'Gothic'), 'Gothic, honourable mention by lumen'); + }); +}); + +describe('formatChallengeDates', () => { + // ICU versions differ in the spaces around the dash. + /** @param {string} start @param {string} end @param {string} [lang] */ + const format = (start, end, lang) => formatChallengeDates(new Date(start), new Date(end), lang).replace(/\s+/g, ' '); + + it('shows the calendar days YAML dates name', () => { + // YAML dates are midnight UTC: a local-time format would show October 20 west of Greenwich. + assert.equal(format('2026-10-21', '2026-10-27'), 'October 21 – 27, 2026'); + }); + + it('shows both months, and both years when they differ', () => { + assert.equal(format('2026-10-28', '2026-11-03'), 'October 28 – November 3, 2026'); + assert.equal(format('2026-12-30', '2027-01-05'), 'December 30, 2026 – January 5, 2027'); + }); + + it("formats in the page's language", () => { + assert.equal(format('2026-10-19', '2026-10-26', 'de'), '19.–26. Oktober 2026'); + assert.equal(format('2026-08-24', '2026-08-31', 'es'), '24–31 de agosto de 2026'); + }); +}); + +// The challenge folders are written by hand, so these checks cover what the schema can't: files beside +// `challenge.yaml`, image formats and sizes, folder names, and week numbers across folders. + +// The YAML parser Astro's content loaders use, so `challenge.yaml` reads here as it does in the build. +const { parseYaml } = await import( + pathToFileURL(createRequire(import.meta.resolve('astro')).resolve('@astrojs/internal-helpers/yaml')).href +); + +const MAX_IMAGE_BYTES = 500_000; +const IMAGE_EXTENSION = /\.(webp|jpe?g)$/i; +/** `<year>-w<week>-<theme>`, such as `2026-w44-bioluminescence`: the folder name is the page's URL. */ +const NUMBERED_FOLDER_NAME = /^(\d{4})-w(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*$/; +const DATED_FOLDER_NAME = /^(\d{4}-\d{2}-\d{2})-[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * The file a winner's `image` names in its folder, as Astro resolves it (relative to `challenge.yaml`, with or without + * `./`), or a problem with it. + * @param {unknown} image + * @returns {{ file: string } | { problem: string }} + */ +const imageFile = (image) => { + if (typeof image !== 'string' || image === '') return { problem: 'a winner has no `image`.' }; + if (/^([a-z][a-z\d+.-]*:|\/)/i.test(image)) { + return { problem: `\`image: ${image}\` must name a file in this folder, such as ./1-artist.webp.` }; + } + const file = posix.normalize(image); + if (file.includes('/')) { + return { problem: `\`image: ${image}\` is outside this folder. Put the image beside challenge.yaml.` }; + } + return { file }; +}; + +/** + * What is wrong with a challenges directory, one message per problem. Hidden files, such as `.DS_Store`, are ignored. + * @param {string} directory + */ +const challengeFolderProblems = (directory) => { + const problems = []; + /** @type {Map<string, string[]>} folders by `<start year> week <week>` */ + const foldersByWeek = new Map(); + /** @type {Map<string, string[]>} folders by start date */ + const foldersByDate = new Map(); + const visible = (name) => !name.startsWith('.'); + for (const entry of readdirSync(directory, { withFileTypes: true }).filter(({ name }) => visible(name))) { + if (entry.isFile() && entry.name === 'README.md') continue; + if (!entry.isDirectory()) { + problems.push(`${entry.name}: only challenge folders and the README belong here.`); + continue; + } + const folder = entry.name; + const [, folderYear, folderWeek] = NUMBERED_FOLDER_NAME.exec(folder) ?? []; + const [, folderDate] = DATED_FOLDER_NAME.exec(folder) ?? []; + if (!folderWeek && !folderDate) { + problems.push( + `${folder}: name the folder YYYY-MM-DD-<theme> or <year>-w<week>-<theme>, in lowercase letters, digits ` + + 'and hyphens.', + ); + } + const files = readdirSync(join(directory, folder)).filter(visible); + if (!files.includes('challenge.yaml')) { + problems.push(`${folder}: has no challenge.yaml, so the site skips it.`); + continue; + } + let challenge; + try { + challenge = parseYaml(readFileSync(join(directory, folder, 'challenge.yaml'), 'utf8')) ?? {}; + } catch (error) { + problems.push(`${folder}/challenge.yaml: ${error.message}`); + continue; + } + const { week, startDate, winners } = challenge; + const startYear = startDate instanceof Date ? startDate.getUTCFullYear() : undefined; + if (folderDate && week !== undefined) { + problems.push(`${folder}: a numbered challenge must use <year>-w<week>-<theme>.`); + } + if (folderWeek && week === undefined) { + problems.push(`${folder}: an unnumbered challenge must use YYYY-MM-DD-<theme>.`); + } + if (folderWeek && Number.isInteger(week) && Number(folderWeek) !== week) { + problems.push( + `${folder}: the folder says week ${Number(folderWeek)}, but challenge.yaml says \`week: ${week}\`.`, + ); + } + if (folderYear && startYear && Number(folderYear) !== startYear) { + problems.push(`${folder}: the folder says ${folderYear}, but the challenge started in ${startYear}.`); + } + if (startYear) { + const start = startDate.toISOString().slice(0, 10); + if (folderDate && folderDate !== start) { + problems.push(`${folder}: the folder date differs from \`startDate: ${start}\`.`); + } + foldersByDate.set(start, [...(foldersByDate.get(start) ?? []), folder]); + } + if (Number.isInteger(week) && startYear) { + const key = `${startYear} week ${week}`; + foldersByWeek.set(key, [...(foldersByWeek.get(key) ?? []), folder]); + } + + const named = new Set(); + for (const winner of Array.isArray(winners) ? winners : []) { + const result = imageFile(winner?.image); + if ('problem' in result) problems.push(`${folder}: ${result.problem}`); + else if (!files.includes(result.file)) problems.push(`${folder}: no file named ${result.file} in this folder.`); + else named.add(result.file); + } + for (const file of files) { + if (file === 'challenge.yaml') continue; + if (!named.has(file)) { + problems.push(`${folder}/${file}: no winner names this file. Remove it, or fix the winner's \`image\`.`); + continue; + } + if (!IMAGE_EXTENSION.test(file)) problems.push(`${folder}/${file}: export winners' images as WebP or JPEG.`); + const bytes = statSync(join(directory, folder, file)).size; + if (bytes > MAX_IMAGE_BYTES) { + problems.push( + `${folder}/${file}: ${Math.ceil(bytes / 1000)} KB, over the 500 KB limit. Export it smaller or at a lower ` + + 'quality (see src/content/challenges/README.md).', + ); + } + } + } + for (const [key, folders] of foldersByWeek) { + if (folders.length > 1) problems.push(`${key} appears in more than one folder: ${folders.join(', ')}.`); + } + for (const [date, folders] of foldersByDate) { + if (folders.length > 1) problems.push(`${date} starts more than one challenge: ${folders.join(', ')}.`); + } + return problems; +}; + +describe('challengeFolderProblems', () => { + const root = mkdtempSync(join(tmpdir(), 'docs-challenges-')); + after(() => rmSync(root, { recursive: true, force: true })); + let count = 0; + /** @param {Record<string, Record<string, string | number>>} folders file contents, or a size in bytes */ + const directory = (folders) => { + const path = join(root, String(++count)); + for (const [folder, files] of Object.entries(folders)) { + mkdirSync(join(path, folder), { recursive: true }); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(path, folder, name), typeof content === 'number' ? Buffer.alloc(content) : content); + } + } + return path; + }; + /** A `challenge.yaml` naming each image as written, in block style. */ + const yaml = (week, start, ...images) => + `week: ${week}\ntheme: Test\nstartDate: ${start}\nwinners:\n` + + images.map((image) => ` - place: honourable\n artist: A\n image: ${image}\n`).join(''); + + it('accepts folders holding challenge.yaml and the images it names, however the YAML writes the paths', () => { + const path = directory({ + '2026-w1-a': { + 'challenge.yaml': yaml(1, '2026-01-01', './a.webp', 'b.jpg', '"./c d.jpeg"'), + 'a.webp': 10, + 'b.jpg': MAX_IMAGE_BYTES, + 'c d.jpeg': 10, + '.DS_Store': 10, + }, + '2026-w2-b': { + 'challenge.yaml': 'week: 2\nstartDate: 2026-01-08\nwinners: [{ place: 1, artist: A, image: ./e.webp }]\n', + 'e.webp': 10, + }, + }); + writeFileSync(join(path, 'README.md'), ''); + writeFileSync(join(path, '.DS_Store'), ''); + assert.deepEqual(challengeFolderProblems(path), []); + }); + + it('uses the start date for unnumbered challenges and rejects duplicate dates or mismatched folder metadata', () => { + const yaml = 'startDate: 2026-08-18\nwinners: []\n'; + assert.deepEqual(challengeFolderProblems(directory({ '2026-08-18-pin-up': { 'challenge.yaml': yaml } })), []); + assert.deepEqual( + challengeFolderProblems( + directory({ + '2026-08-19-pin-up': { 'challenge.yaml': yaml }, + '2026-w34-pin-up': { 'challenge.yaml': yaml }, + }), + ), + [ + '2026-08-19-pin-up: the folder date differs from `startDate: 2026-08-18`.', + '2026-w34-pin-up: an unnumbered challenge must use YYYY-MM-DD-<theme>.', + '2026-08-18 starts more than one challenge: 2026-08-19-pin-up, 2026-w34-pin-up.', + ], + ); + assert.deepEqual( + challengeFolderProblems( + directory({ + '2026-08-18-pin-up': { 'challenge.yaml': 'week: 34\n' + yaml }, + }), + ), + ['2026-08-18-pin-up: a numbered challenge must use <year>-w<week>-<theme>.'], + ); + }); + + it('reports stray files, PNGs, oversized images and folders without challenge.yaml', () => { + const path = directory({ + '2026-w1-a': { + 'challenge.yaml': yaml(1, '2026-01-01', './big.webp', './art.png'), + 'big.webp': MAX_IMAGE_BYTES + 1, + 'art.png': 10, + 'old.webp': 10, + }, + '2026-w2-b': { 'challenge.yml': yaml(2, '2026-01-08') }, + }); + writeFileSync(join(path, 'notes.txt'), ''); + const problems = challengeFolderProblems(path).join('\n'); + assert.match(problems, /^2026-w1-a\/big\.webp: 501 KB, over the 500 KB limit/m); + assert.match(problems, /^2026-w1-a\/art\.png: export winners' images as WebP or JPEG/m); + assert.match(problems, /^2026-w1-a\/old\.webp: no winner names this file/m); + assert.match(problems, /^2026-w2-b: has no challenge\.yaml/m); + assert.match(problems, /^notes\.txt: only challenge folders/m); + }); + + it('reports images that are missing or outside the folder', () => { + const path = directory({ + '2026-w1-a': { + 'challenge.yaml': yaml(1, '2026-01-01', './gone.webp', '../2026-w2-b/x.webp', 'https://x.test/a.webp'), + }, + }); + assert.deepEqual(challengeFolderProblems(path), [ + '2026-w1-a: no file named gone.webp in this folder.', + '2026-w1-a: `image: ../2026-w2-b/x.webp` is outside this folder. Put the image beside challenge.yaml.', + '2026-w1-a: `image: https://x.test/a.webp` must name a file in this folder, such as ./1-artist.webp.', + ]); + }); + + it('reports YAML that does not parse', () => { + const path = directory({ '2026-w1-a': { 'challenge.yaml': 'week: [1\n' } }); + assert.match(challengeFolderProblems(path).join('\n'), /^2026-w1-a\/challenge\.yaml: /m); + }); + + it('reports a week used twice in the same year, and allows it in another year', () => { + const path = directory({ + '2026-w7-a': { 'challenge.yaml': yaml(7, '2026-02-11', './a.webp'), 'a.webp': 10 }, + '2026-w7-b': { 'challenge.yaml': yaml(7, '2026-02-18', './b.webp'), 'b.webp': 10 }, + '2025-w7-c': { 'challenge.yaml': yaml(7, '2025-02-12', './c.webp'), 'c.webp': 10 }, + }); + assert.deepEqual(challengeFolderProblems(path), [ + '2026 week 7 appears in more than one folder: 2026-w7-a, 2026-w7-b.', + ]); + }); + + it('reports folder names outside YYYY-MM-DD-<theme> and <year>-w<week>-<theme>, since they become URLs', () => { + const path = directory({ + 'Week 3': { 'challenge.yaml': yaml(3, '2026-01-14') }, + '2026-w3': { 'challenge.yaml': yaml(3, '2026-01-14') }, + }); + const problems = challengeFolderProblems(path).join('\n'); + assert.match(problems, /^Week 3: name the folder YYYY-MM-DD-<theme> or <year>-w<week>-<theme>/m); + assert.match(problems, /^2026-w3: name the folder/m); + }); + + it("reports a folder whose week or year differs from its challenge.yaml's", () => { + const path = directory({ + '2026-w45-glass': { 'challenge.yaml': yaml(44, '2026-10-28') }, + '2027-w1-frost': { 'challenge.yaml': yaml(1, '2026-12-30') }, + }); + assert.deepEqual(challengeFolderProblems(path), [ + '2026-w45-glass: the folder says week 45, but challenge.yaml says `week: 44`.', + '2027-w1-frost: the folder says 2027, but the challenge started in 2026.', + ]); + }); +}); + +describe('src/content/challenges', () => { + it('holds only well-formed challenge folders', () => { + assert.deepEqual(challengeFolderProblems(fileURLToPath(new URL('../content/challenges/', import.meta.url))), []); + }); +}); diff --git a/docs/src/lib/community-nodes.mjs b/docs/src/lib/community-nodes.mjs new file mode 100644 index 00000000000..0c199fbbb14 --- /dev/null +++ b/docs/src/lib/community-nodes.mjs @@ -0,0 +1,118 @@ +// @ts-check +// The community node packs on the Community Nodes page (`components/CommunityNodes.astro`). Each pack is a YAML file in +// `src/content/community-nodes/`, whose README.md explains the fields. Plain JavaScript so `node --test` can cover it +// and `content.config.ts` can share the category ids. + +/** + * The categories a pack can list, in the order the filter shows them. A pack's first category picks its placeholder + * icon when it has no images. `labelKey` is the category's UI string in `src/content/i18n/en.json`. + */ +export const NODE_PACK_CATEGORIES = /** @type {const} */ ([ + { id: 'image-effects', labelKey: 'communityNodes.category.imageEffects' }, + { id: 'masks', labelKey: 'communityNodes.category.masks' }, + { id: 'compositing', labelKey: 'communityNodes.category.compositing' }, + { id: 'prompts', labelKey: 'communityNodes.category.prompts' }, + { id: 'language-models', labelKey: 'communityNodes.category.languageModels' }, + { id: 'control', labelKey: 'communityNodes.category.control' }, + { id: 'video-3d', labelKey: 'communityNodes.category.video3d' }, + { id: 'sizing', labelKey: 'communityNodes.category.sizing' }, + { id: 'utility', labelKey: 'communityNodes.category.utility' }, +]); + +/** @typedef {(typeof NODE_PACK_CATEGORIES)[number]['id']} NodePackCategory */ + +/** The filter value that shows every category. */ +export const ALL_CATEGORIES = 'all'; + +/** + * Alphabetical by title, ignoring case and accents, then by id, so builds are stable. + * @template {{ id: string, data: { title: string } }} T + * @param {readonly T[]} packs + * @returns {T[]} + */ +export const sortNodePacks = (packs) => + [...packs].sort( + (a, b) => a.data.title.localeCompare(b.data.title, 'en', { sensitivity: 'base' }) || a.id.localeCompare(b.id) + ); + +/** + * The URL to paste into the Nodes manager or `git clone`: the repository page's URL with `.git`, as the manager's own + * example shows. The manager names the pack's folder after the URL's last segment, without `.git`. + * @param {string} repo the repository's web page, such as `https://github.com/owner/name` + */ +export const gitCloneUrl = (repo) => { + const url = repo.replace(/\/+$/, ''); + return url.endsWith('.git') ? url : `${url}.git`; +}; + +/** + * Splits text at Markdown code spans, so descriptions can name nodes and fields as `code` without allowing any other + * markup. An unpaired backtick stays text. + * @param {string} text + * @returns {{ code: boolean, text: string }[]} + */ +export const codeSpans = (text) => { + /** @type {{ code: boolean, text: string }[]} */ + const parts = []; + let last = 0; + for (const match of text.matchAll(/`([^`]+)`/g)) { + if (match.index > last) parts.push({ code: false, text: text.slice(last, match.index) }); + parts.push({ code: true, text: match[1] }); + last = match.index + match[0].length; + } + if (last < text.length) parts.push({ code: false, text: text.slice(last) }); + return parts; +}; + +/** + * A description's paragraphs: blocks separated by a blank line, each with its line breaks folded into spaces. + * @param {string | undefined} text + */ +export const paragraphs = (text) => + (text ?? '') + .split(/\n\s*\n/) + .map((block) => block.replace(/\s+/g, ' ').trim()) + .filter(Boolean); + +/** + * Lowercase words without accents or punctuation, one space apart, for matching searches. + * @param {string} text + */ +export const normalizeSearchText = (text) => + text + .normalize('NFKD') + .replace(/\p{M}/gu, '') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim(); + +/** + * What a search matches a pack against: its title, author, summary, description, node names and descriptions, and + * the names of its categories in the page's language. + * @param {{ title: string, author: string, summary: string, description?: string, + * nodes?: readonly { name: string, description?: string }[] }} pack + * @param {readonly string[]} categoryLabels + */ +export const searchText = (pack, categoryLabels) => + normalizeSearchText( + [ + pack.title, + pack.author, + pack.summary, + pack.description ?? '', + ...(pack.nodes ?? []).flatMap((node) => [node.name, node.description ?? '']), + ...categoryLabels, + ].join(' ') + ); + +/** + * Whether a pack shows for a search and a category. Every word of the query must appear somewhere in the pack's + * search text, in any order and as part of a longer word ("mask" finds "masks"); an empty query matches every pack. + * @param {{ text: string, categories: readonly string[] }} pack `text` from `searchText` + * @param {{ query: string, category: string }} filter `category` is a category id or `ALL_CATEGORIES` + */ +export const packMatches = (pack, { query, category }) => { + if (category !== ALL_CATEGORIES && !pack.categories.includes(category)) return false; + const words = normalizeSearchText(query).split(' ').filter(Boolean); + return words.every((word) => pack.text.includes(word)); +}; diff --git a/docs/src/lib/community-nodes.test.mjs b/docs/src/lib/community-nodes.test.mjs new file mode 100644 index 00000000000..8cd9268a713 --- /dev/null +++ b/docs/src/lib/community-nodes.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { describe, it } from 'node:test'; + +import { + ALL_CATEGORIES, + codeSpans, + gitCloneUrl, + packMatches, + paragraphs, + searchText, + sortNodePacks, +} from './community-nodes.mjs'; + +/** @param {string} id @param {string} title */ +const entry = (id, title) => ({ id, data: { title } }); + +describe('sortNodePacks', () => { + it('sorts by title ignoring case, then by id', () => { + const packs = [entry('c', 'curves'), entry('b', 'BiRefNet'), entry('a2', 'Average'), entry('a1', 'average')]; + assert.deepEqual( + sortNodePacks(packs).map(({ id }) => id), + ['a1', 'a2', 'b', 'c'] + ); + }); + + it('leaves the given array as it was', () => { + const packs = [entry('z', 'Zeta'), entry('a', 'Alpha')]; + sortNodePacks(packs); + assert.deepEqual( + packs.map(({ id }) => id), + ['z', 'a'] + ); + }); +}); + +describe('gitCloneUrl', () => { + it('appends .git to a repository page URL', () => { + assert.equal(gitCloneUrl('https://github.com/JPPhoto/curves-node'), 'https://github.com/JPPhoto/curves-node.git'); + }); + + it('drops a trailing slash and keeps an existing .git', () => { + assert.equal(gitCloneUrl('https://gitlab.com/srcrr/shift3d/'), 'https://gitlab.com/srcrr/shift3d.git'); + assert.equal(gitCloneUrl('https://github.com/o/r.git'), 'https://github.com/o/r.git'); + }); +}); + +describe('codeSpans', () => { + it('marks backtick spans as code', () => { + assert.deepEqual(codeSpans('Use `collect` inside `iterate`.'), [ + { code: false, text: 'Use ' }, + { code: true, text: 'collect' }, + { code: false, text: ' inside ' }, + { code: true, text: 'iterate' }, + { code: false, text: '.' }, + ]); + }); + + it('keeps an unpaired backtick and markup as plain text', () => { + assert.deepEqual(codeSpans('a ` b <i>c</i>'), [{ code: false, text: 'a ` b <i>c</i>' }]); + }); +}); + +describe('paragraphs', () => { + it('splits at blank lines and folds line breaks', () => { + assert.deepEqual(paragraphs('One\nline.\n\n Two. \n'), ['One line.', 'Two.']); + }); + + it('is empty for a missing description', () => { + assert.deepEqual(paragraphs(undefined), []); + }); +}); + +describe('packMatches', () => { + const pack = { + text: searchText( + { + title: 'Clothing Mask', + author: 'VeyDlin', + summary: 'Segment clothing with a U²-Net model.', + nodes: [{ name: 'Clothing Mask', description: 'Masks garments' }], + }, + ['Masks & backgrounds'] + ), + categories: ['masks'], + }; + + it('shows every pack for an empty query in all categories', () => { + assert.equal(packMatches(pack, { query: ' ', category: ALL_CATEGORIES }), true); + }); + + it('needs every word, in any order, ignoring case and punctuation', () => { + assert.equal(packMatches(pack, { query: 'MASK, clothing', category: ALL_CATEGORIES }), true); + assert.equal(packMatches(pack, { query: 'clothing pose', category: ALL_CATEGORIES }), false); + }); + + it('matches the author, node descriptions and category names', () => { + assert.equal(packMatches(pack, { query: 'veydlin', category: ALL_CATEGORIES }), true); + assert.equal(packMatches(pack, { query: 'garments', category: ALL_CATEGORIES }), true); + assert.equal(packMatches(pack, { query: 'backgrounds', category: ALL_CATEGORIES }), true); + }); + + it('ignores accents in the query and the text', () => { + const accented = { text: searchText({ title: 'Café Filter', author: 'x', summary: '' }, []), categories: [] }; + assert.equal(packMatches(accented, { query: 'cafe', category: ALL_CATEGORIES }), true); + }); + + it('shows only packs in the chosen category', () => { + assert.equal(packMatches(pack, { query: '', category: 'masks' }), true); + assert.equal(packMatches(pack, { query: 'clothing', category: 'prompts' }), false); + }); +}); + +describe('src/content/community-nodes', () => { + // The collection loads `*.yaml` only, so a `.yml` pack would vanish from the page without an error. + // A thumbnail its pack doesn't name would sit in the repository unused. + it('holds only packs, the thumbnails they name and its README', () => { + const directory = new URL('../content/community-nodes/', import.meta.url); + const files = readdirSync(directory); + const thumbnailOf = (id) => + /^thumbnail:\s*\.\/(\S+)\s*$/m.exec(readFileSync(new URL(`${id}.yaml`, directory), 'utf8'))?.[1]; + assert.deepEqual( + files.filter((name) => { + if (name === 'README.md') return false; + const match = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.(yaml|webp)$/.exec(name); + if (!match) return true; + return match[2] === 'webp' && (!files.includes(`${match[1]}.yaml`) || thumbnailOf(match[1]) !== name); + }), + [] + ); + }); +}); diff --git a/docs/src/lib/components/ChallengeStrip.astro b/docs/src/lib/components/ChallengeStrip.astro new file mode 100644 index 00000000000..b5a161a84e8 --- /dev/null +++ b/docs/src/lib/components/ChallengeStrip.astro @@ -0,0 +1,186 @@ +--- +// The home page's teaser for the latest weekly challenge: its first three winners as thumbnails (the numbered places, +// then runners-up, in the file's order), and a link to the challenge's page. +// The labels are UI strings and the dates follow the page's language; the theme and the artists' names stay as the +// challenge's file gives them. +import ArrowRight from '@lucide/astro/icons/arrow-right'; +import type { CollectionEntry } from 'astro:content'; +import { Image } from 'astro:assets'; + +import { withBase } from '../base-path'; +import { challengePath, formatChallengeDates } from '../challenges.mjs'; +import { pageLang } from './memory/page-lang.mjs'; + +type Props = { + challenge: CollectionEntry<'challenges'>; +}; + +const { challenge } = Astro.props; +const { t } = Astro.locals; +const { week, theme, startDate, endDate } = challenge.data; +const placeLabels: Record<string | number, string> = { + 1: t('home.challenge.first'), + 2: t('home.challenge.second'), + 3: t('home.challenge.third'), + 'runner-up': t('home.challenge.runnerUp'), + honourable: t('home.challenge.honourable'), +}; +// The schema keeps winners in display order. +const top = challenge.data.winners.slice(0, 3); +const when = + week === undefined + ? formatChallengeDates(startDate, endDate, pageLang(Astro.locals)) + : t('home.challenge.week', { week }); +--- + +<div class="strip not-content"> + <div class="strip__text"> + <h3>{t('home.challenge.title')}</h3> + <p> + {when} · <span lang="en">{theme}</span> + </p> + <a class="more" href={withBase(challengePath(challenge.id), import.meta.env.BASE_URL)}> + {t('home.challenge.link')} + <ArrowRight class="nudge" size={14} aria-hidden="true" /> + </a> + </div> + <ul class="strip__winners" role="list"> + { + top.map((winner) => ( + <li> + {/* The caption credits the artwork; the challenge's page describes it. */} + <Image + src={winner.image} + alt="" + widths={[160, 320, 480].filter((width) => width < winner.image.width).concat(winner.image.width)} + sizes="(min-width: 50rem) 10rem, 30vw" + /> + <span class:list={['place', { 'place--first': winner.place === 1 }]}>{placeLabels[winner.place]}</span> + <span class="artist" lang="en">{winner.artist}</span> + </li> + )) + } + </ul> +</div> + +<style> + .strip { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.25rem 2rem; + padding: 1rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + } + + .strip__text { + display: flex; + flex: 1 1 14rem; + flex-direction: column; + gap: 0.375rem; + padding-inline: 0.5rem; + + h3 { + margin: 0; + color: var(--docs-text-strong); + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.3; + } + + p { + margin: 0; + color: var(--docs-text-muted); + font-size: 0.9375rem; + } + } + + .more { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 0.375rem; + margin-top: 0.25rem; + color: var(--docs-accent); + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + + &:hover { + color: var(--docs-accent-text); + } + } + + @media (prefers-reduced-motion: no-preference) { + .nudge { + transition: translate 150ms ease; + } + + .more:is(:hover, :focus-visible) > .nudge { + translate: 2px 0; + } + } + + .strip__winners { + display: grid; + flex: 2 1 24rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; + + li { + position: relative; + display: flex; + flex-direction: column; + gap: 0.375rem; + min-width: 0; + } + + /* Thumbnails crop to a common shape; the challenge's page shows each artwork whole. */ + img { + display: block; + width: 100%; + height: auto; + aspect-ratio: 4 / 3; + object-fit: cover; + border-radius: var(--docs-radius-lg); + background-color: var(--docs-control); + } + } + + .place { + position: absolute; + top: 0.375rem; + left: 0.375rem; + display: inline-flex; + align-items: center; + height: 1.25rem; + padding: 0 0.375rem; + border: 1px solid var(--docs-border-strong); + border-radius: var(--docs-radius); + background-color: var(--docs-control); + color: var(--docs-text-strong); + font-size: 0.6875rem; + font-weight: 600; + white-space: nowrap; + } + + .place--first { + border-color: var(--docs-accent); + background-color: var(--docs-accent); + color: var(--docs-accent-contrast); + } + + .artist { + overflow: hidden; + color: var(--docs-text-muted); + font-size: 0.8125rem; + text-overflow: ellipsis; + white-space: nowrap; + } +</style> diff --git a/docs/src/lib/components/ChallengeWinners.astro b/docs/src/lib/components/ChallengeWinners.astro new file mode 100644 index 00000000000..742c7d2a083 --- /dev/null +++ b/docs/src/lib/components/ChallengeWinners.astro @@ -0,0 +1,399 @@ +--- +// A weekly challenge's winners, each frame its image's shape. On wide screens the numbered places stand in one centred +// row, bottoms aligned like a podium: the lead (1st, unless a winner was removed) tallest, lower places shorter, tied +// places at one height (see `podiumLayout`). Runners-up and honourable mentions follow in centred rows of shorter +// frames at one height. On phones the lead spans the column; lower places and mentions sit two to a row at one height +// per row, a lone lower place spanning it. Every image shows whole and links to its largest size. The place is a text +// badge. +import type { CollectionEntry } from 'astro:content'; +import { getImage, Image } from 'astro:assets'; + +import { frameAspect, pairSpans, placeLabel, podiumLayout, winnerAlt } from '../challenges.mjs'; + +type Props = { + challenge: CollectionEntry<'challenges'>; + /** + * Images in view when the page opens, which load eagerly: `lead`, the top-placed image (with high priority), or + * `podium`, every numbered place. The rest load lazily. + */ + eager?: 'lead' | 'podium'; +}; + +const { challenge, eager } = Astro.props; +const { theme, winners } = challenge.data; +// The schema keeps winners in display order, numbered places first, and requires a numbered place. +const podium = winners.filter(({ place }) => typeof place === 'number'); +const mentions = winners.filter(({ place }) => typeof place !== 'number'); +const { frames, span, mentionScale } = podiumLayout(podium); +const leadPlace = podium[0].place; +const sideCount = podium.filter(({ place }) => place !== leadPlace).length; + +const maxWidth = 2400; +/** Candidate widths up to the image's own. */ +const widthsUpTo = (candidates: number[], width: number) => [ + ...new Set(candidates.filter((candidate) => candidate < width).concat(Math.min(width, maxWidth))), +]; +// The frame links to the largest size, as the showcase grid's tiles do. +const fullImages = await Promise.all( + winners.map(({ image }) => getImage({ src: image, width: Math.min(image.width, maxWidth) })), +); +const fullSrc = (winner: (typeof winners)[number]) => fullImages[winners.indexOf(winner)].src; + +const round = (value: number) => Math.round(value * 10) / 10; +// Phones: leads and a lone last lower place span the column; other lower places sit two to a row, as mentions do. +const isFull = (winner: (typeof podium)[number], index: number) => + winner.place === leadPlace || (sideCount % 2 === 1 && index === podium.length - 1); +const halfSpans = pairSpans(frames.filter((_, index) => !isFull(podium[index], index)).map(({ aspect }) => aspect)); +const mentionAspects = mentions.map(({ image }) => frameAspect(image)); +const mentionSpans = pairSpans(mentionAspects); +const mentionSpan = mentionAspects.reduce((sum, aspect) => sum + aspect, 0); + +// Display widths, matching the styles below. Wide screens: a podium frame is its share of the row, at most +// `--lead-max` (36rem) times its scale tall; mentions are at most 12rem tall. Phones: a frame two to a row is its +// share of its row. +let half = 0; +const podiumItems = podium.map((winner, index) => { + const { aspect, scale } = frames[index]; + const isLead = winner.place === leadPlace; + const full = isFull(winner, index); + const pairSpan = full ? undefined : halfSpans[half++]; + const wide = `(min-width: 50rem) min(${round(36 * scale * aspect)}rem, ${round((96 * scale * aspect) / span)}vw)`; + return { + winner, + isLead, + phone: full ? 'full' : 'half', + style: `--aspect: ${aspect}; --scale: ${scale}${pairSpan ? `; --pair-span: ${pairSpan}` : ''}`, + widths: widthsUpTo(isLead ? [640, 960, 1280, 1600, 1920] : [400, 640, 960, 1280], winner.image.width), + sizes: `${wide}, ${pairSpan ? `${round((100 * aspect) / pairSpan)}vw` : '100vw'}`, + }; +}); +const mentionItems = mentions.map((winner, index) => { + const aspect = mentionAspects[index]; + return { + winner, + style: `--aspect: ${aspect}; --pair-span: ${mentionSpans[index]}`, + widths: widthsUpTo([320, 480, 640, 960], winner.image.width), + sizes: `(min-width: 50rem) ${round(12 * aspect)}rem, ${round((100 * aspect) / mentionSpans[index])}vw`, + }; +}); +const groups = [ + { name: 'podium', items: podiumItems }, + { name: 'mentions', items: mentionItems }, +].filter((group) => group.items.length > 0); +--- + +<div + class="winners not-content" + style={[ + `--count: ${podium.length}; --span: ${span}; --mention-scale: ${mentionScale}`, + `--mention-count: ${mentions.length}; --mention-span: ${Math.round(mentionSpan * 1e4) / 1e4 || 1}`, + ].join('; ')} +> + { + groups.map(({ name, items }) => ( + <ul class={name} role="list"> + {items.map((item) => { + const { winner } = item; + const isLead = 'isLead' in item && item.isLead; + const eagerLoad = (eager === 'podium' && name === 'podium') || (eager && isLead); + return ( + <li + class:list={['winner', { 'winner--lead': isLead }]} + style={item.style} + data-phone={'phone' in item ? item.phone : undefined} + > + <figure> + <a class="frame" href={fullSrc(winner)}> + <Image + src={winner.image} + alt={winnerAlt(winner, theme)} + widths={item.widths} + sizes={item.sizes} + loading={eagerLoad ? 'eager' : 'lazy'} + fetchpriority={eager && isLead ? 'high' : undefined} + /> + </a> + <figcaption> + <span + class:list={[ + 'place', + { 'place--first': winner.place === 1 }, + typeof winner.place === 'string' && `place--${winner.place}`, + ]} + > + {placeLabel(winner.place)} + </span> + <span class="credit"> + {winner.title && <span class="credit__title">{winner.title}</span>} + <span class="credit__artist"> + by{' '} + {winner.artistUrl ? ( + <a href={winner.artistUrl} target="_blank" rel="nofollow ugc noopener noreferrer"> + {winner.artist} + </a> + ) : ( + winner.artist + )} + </span> + {winner.model && <span class="credit__model">{winner.model}</span>} + </span> + </figcaption> + </figure> + </li> + ); + })} + </ul> + )) + } +</div> + +<style> + .winners { + /* The podium's height comes from this element's width. */ + container-type: inline-size; + display: flex; + flex-direction: column; + gap: 2rem; + --gap-x: 1rem; + /* + * The lead frame's height on wide screens: at most --lead-max, and low enough for the whole row (--span lead + * heights wide, plus the gaps) to fit. + */ + --lead-max: min(36rem, 72svh); + --lead-height: min(var(--lead-max), (100cqi - (var(--count) - 1) * var(--gap-x)) / var(--span)); + /* + * Mentions' height: low enough for them all to fit one row (--mention-span heights wide, plus the gaps), at most + * the podium allows, and at least 7rem, below which they wrap onto more rows instead. + */ + --mention-height: clamp( + 7rem, + (100cqi - (var(--mention-count) - 1) * var(--gap-x)) / var(--mention-span), + min(12rem, var(--lead-height) * var(--mention-scale)) + ); + } + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + .winner, + figure { + min-width: 0; + margin: 0; + } + + figure { + display: flex; + flex-direction: column; + gap: 0.625rem; + } + + /* The frame is the image's link: the strong border and the control fill on hover. */ + /* Each frame takes its image's shape. */ + .frame { + position: relative; + display: block; + aspect-ratio: var(--aspect); + overflow: hidden; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + transition: var(--docs-control-transition); + + &:hover { + border-color: var(--docs-border-strong); + background-color: var(--docs-control); + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + } + + /* Whole inside the frame, which only differs from the image's shape for extreme shapes. */ + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + } + } + + /* + * Wide screens: one row, each frame --scale lead heights tall and --aspect times as wide. Frames share the first grid + * row, aligned to its bottom, and captions the second. + */ + @media (min-width: 50rem) { + .podium { + display: grid; + grid-auto-flow: column; + grid-template-rows: auto auto; + justify-content: center; + gap: 0.625rem var(--gap-x); + } + + .podium .winner, + .podium figure { + display: grid; + grid-row: span 2; + grid-template-rows: subgrid; + } + + .podium .winner { + width: calc(var(--lead-height) * var(--scale) * var(--aspect)); + } + + .podium .frame { + align-self: end; + } + } + + /* + * Runners-up and honourable mentions: centred rows of frames at one height. Frames align to the top, so a caption + * that wraps lengthens only its own item. + */ + .mentions { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + gap: 1.25rem var(--gap-x); + + .winner { + flex: none; + width: calc(var(--mention-height) * var(--aspect)); + } + + figcaption { + flex-direction: column; + align-items: flex-start; + } + } + + /* + * Phones: the lead and a lone lower place across the column, at most three quarters of the screen tall. Other lower + * places and the mentions sit two to a row at one height per row, each at its own width; a last one alone keeps the + * row above's height, centred. + */ + @media (max-width: 49.99rem) { + .podium { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + gap: 1.5rem var(--gap-x); + } + + [data-phone='full'] { + flex: 0 0 100%; + + figure { + width: min(100%, 75svh * var(--aspect)); + margin-inline: auto; + } + } + + [data-phone='half'], + .mentions .winner { + /* The row's height: two frames, --pair-span heights wide; the pixel keeps rounding from wrapping them. */ + --pair-height: calc((100cqi - var(--gap-x) - 1px) / var(--pair-span)); + flex: none; + width: min(100%, var(--pair-height) * var(--aspect)); + + figcaption { + flex-direction: column; + align-items: flex-start; + } + } + } + + figcaption { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.375rem 0.75rem; + font-size: var(--sl-text-sm); + line-height: 1.4; + } + + /* + * The place in words: 1st place takes the solid accent, 2nd and 3rd the control fill, a runner-up the strong outline + * and an honourable mention the quiet one. + */ + .place { + display: inline-flex; + flex: none; + align-items: center; + min-height: 1.375rem; + padding: 0 0.5rem; + border: 1px solid var(--docs-border-strong); + border-radius: var(--docs-radius); + background-color: var(--docs-control); + color: var(--docs-text-strong); + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; + } + + .place--first { + border-color: var(--docs-accent); + background-color: var(--docs-accent); + color: var(--docs-accent-contrast); + } + + .place--runner-up, + .place--honourable { + background-color: transparent; + font-weight: 500; + } + + .place--honourable { + border-color: var(--docs-border); + color: var(--docs-text-muted); + } + + .credit { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.125rem 0.5rem; + min-width: 0; + } + + .credit__title { + color: var(--docs-text-strong); + font-weight: 600; + } + + .winner--lead .credit__title { + font-size: 1.125rem; + } + + .credit__artist { + color: var(--docs-text-muted); + + a { + color: var(--docs-accent); + text-decoration: none; + + &:hover { + color: var(--docs-accent-text); + text-decoration: underline; + } + } + } + + .credit__model { + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + } + + @media (prefers-reduced-motion: reduce) { + .frame { + transition: none; + } + } +</style> diff --git a/docs/src/lib/components/CommunityNodes.astro b/docs/src/lib/components/CommunityNodes.astro new file mode 100644 index 00000000000..b18b23edaf7 --- /dev/null +++ b/docs/src/lib/components/CommunityNodes.astro @@ -0,0 +1,914 @@ +--- +/* + * The Community Nodes page's catalog: every pack in `src/content/community-nodes/` as a card with a thumbnail, its + * categories, a repository link and a button that copies its Git URL, with nodes, examples and links in a disclosure. + * A search field and category chips filter the cards; the count of shown packs is announced. Without JavaScript the + * controls and copy buttons stay hidden and every card shows. Pack text is English, as its authors wrote it; the + * interface strings are translated. + * + * Thumbnails are small local WebPs that astro:assets resizes, so the grid fetches nothing remote; a pack without one + * shows its first category's icon. The example images in a disclosure are hot-linked from the packs' repositories and + * load only when it first opens (without JavaScript, a `<noscript>` copy loads lazily); one that fails to load hides, + * leaving its caption. + */ +import ArrowUpRight from '@lucide/astro/icons/arrow-up-right'; +import Blocks from '@lucide/astro/icons/blocks'; +import ChevronDown from '@lucide/astro/icons/chevron-down'; +import Clapperboard from '@lucide/astro/icons/clapperboard'; +import Copy from '@lucide/astro/icons/copy'; +import Layers from '@lucide/astro/icons/layers'; +import MessageSquareText from '@lucide/astro/icons/message-square-text'; +import PersonStanding from '@lucide/astro/icons/person-standing'; +import Scaling from '@lucide/astro/icons/scaling'; +import Search from '@lucide/astro/icons/search'; +import Sparkles from '@lucide/astro/icons/sparkles'; +import SquareDashed from '@lucide/astro/icons/square-dashed'; +import TextCursorInput from '@lucide/astro/icons/text-cursor-input'; +import Wrench from '@lucide/astro/icons/wrench'; +import { Image } from 'astro:assets'; +import { getCollection } from 'astro:content'; + +import { + ALL_CATEGORIES, + NODE_PACK_CATEGORIES, + codeSpans, + gitCloneUrl, + paragraphs, + searchText, + sortNodePacks, +} from '../community-nodes.mjs'; +import { pluralForms } from '../i18n.mjs'; +import Trans from './Trans.astro'; + +const { t } = Astro.locals; +// The plural rules' language. `llms-full.txt` renders this page outside a Starlight route, in English. +let lang = 'en'; +try { + lang = Astro.locals.starlightRoute.lang; +} catch {} +const packs = sortNodePacks(await getCollection('communityNodes')); +const categoryLabels = new Map<string, string>(NODE_PACK_CATEGORIES.map(({ id, labelKey }) => [id, t(labelKey)])); +const placeholderIcons: Record<string, typeof Blocks> = { + 'image-effects': Sparkles, + masks: SquareDashed, + compositing: Layers, + prompts: TextCursorInput, + 'language-models': MessageSquareText, + control: PersonStanding, + 'video-3d': Clapperboard, + sizing: Scaling, + utility: Wrench, +}; +const packId = (id: string) => `node-pack-${id}`; +const total = pluralForms(t, 'communityNodes.total'); +// The script's strings. Templates keep their `{{placeholders}}` for the script to fill (`../i18n.mjs`). +const i18n = { + total, + shown: pluralForms(t, 'communityNodes.shown'), + copy: t('communityNodes.copyGitUrl'), + copied: t('communityNodes.copyDone'), + copiedStatus: t('communityNodes.copyDoneStatus'), + copyFailed: t('communityNodes.copyFailed'), + copyFailedStatus: t('communityNodes.copyFailedStatus'), +}; +const totalText = t('communityNodes.total', { count: packs.length }); +--- + +<div class="community-nodes not-content" data-community-nodes data-i18n={JSON.stringify(i18n)} data-lang={lang}> + {/* Marks the catalog before first paint, so readers with JavaScript never see it without its controls. */} + <script is:inline> + document.currentScript.parentElement.dataset.js = ''; + </script> + <div class="controls" role="search" aria-label={t('communityNodes.filtersLabel')} data-pagefind-ignore data-llms-ignore> + <div class="field"> + <Search class="field__icon" size={16} aria-hidden="true" /> + <input + type="search" + aria-label={t('communityNodes.searchLabel')} + aria-controls="community-node-packs" + placeholder={t('communityNodes.searchPlaceholder')} + autocomplete="off" + spellcheck="false" + data-query + /> + </div> + <fieldset class="chips"> + <legend class="sr-only">{t('communityNodes.categoryLegend')}</legend> + { + [{ id: ALL_CATEGORIES, label: t('communityNodes.allCategories') }, ...NODE_PACK_CATEGORIES.map(({ id }) => ({ id, label: categoryLabels.get(id) }))].map( + ({ id, label }) => ( + <label class="chip"> + <input type="radio" name="community-node-category" value={id} checked={id === ALL_CATEGORIES} /> + {label} + </label> + ) + ) + } + </fieldset> + </div> + <p class="count" role="status" data-count data-pagefind-ignore data-llms-ignore>{totalText}</p> + + <ul class="grid" role="list" id="community-node-packs" aria-label={t('communityNodes.listLabel')}> + { + packs.map(({ id, data: pack }) => { + const Placeholder = placeholderIcons[pack.categories[0]] ?? Blocks; + const labels = pack.categories.map((category) => categoryLabels.get(category) ?? category); + const gitUrl = gitCloneUrl(pack.repo); + return ( + <li class="card" data-pack data-categories={pack.categories.join(' ')} data-search={searchText(pack, labels)}> + <div class="thumb"> + {pack.thumbnail ? ( + <Image + src={pack.thumbnail} + alt={pack.images[0].alt} + widths={[400, 800].filter((width) => width < pack.thumbnail.width).concat(pack.thumbnail.width)} + sizes="(min-width: 72rem) 24rem, (min-width: 50rem) 45vw, calc(100vw - 2rem)" + /> + ) : ( + <Placeholder class="thumb__placeholder" size={28} aria-hidden="true" /> + )} + </div> + + <div class="main"> + <h3 class="title" id={packId(id)}> + {pack.title} + </h3> + <p class="author">{t('communityNodes.byAuthor', { author: pack.author, interpolation: { escapeValue: false } })}</p> + <p class="summary">{codeSpans(pack.summary).map((part) => (part.code ? <code>{part.text}</code> : part.text))}</p> + <ul class="tags" role="list"> + {labels.map((label) => ( + <li class="tag">{label}</li> + ))} + {pack.archived && <li class="tag tag--warning">{t('communityNodes.archived')}</li>} + {pack.install === 'manual' && <li class="tag tag--warning">{t('communityNodes.manualInstall')}</li>} + {pack.dependencies && <li class="tag tag--outline">{t('communityNodes.dependencies')}</li>} + </ul> + </div> + + <div class="actions"> + {/* Each card's controls name its pack for screen readers, which list 48 of each otherwise alike. */} + <a class="docs-button docs-button--secondary" href={pack.repo}> + {t('communityNodes.repository')} + <span class="sr-only">: {pack.title}</span> + <ArrowUpRight aria-hidden="true" /> + </a> + {pack.install === 'manager' && ( + <button type="button" class="copy docs-button docs-button--ghost" data-copy={gitUrl} data-title={pack.title} data-llms-ignore> + <Copy aria-hidden="true" /> + <span data-label>{i18n.copy}</span> + <span class="sr-only">: {pack.title}</span> + </button> + )} + </div> + + <details class="details"> + <summary> + {t('communityNodes.details')} + <span class="sr-only">: {pack.title}</span> + <ChevronDown class="details__chevron" size={14} aria-hidden="true" /> + </summary> + <div class="details__body"> + {paragraphs(pack.description).map((paragraph) => ( + <p>{codeSpans(paragraph).map((part) => (part.code ? <code>{part.text}</code> : part.text))}</p> + ))} + {pack.install === 'manual' ? ( + <p class="note note--warning"> + <Trans message={t('communityNodes.manualInstallNote')}>{(text: string) => <code>{text}</code>}</Trans> + </p> + ) : ( + <div class="git-url"> + {/* The separators keep label and value apart for screen readers and llms-full.txt. */} + <span class="label">{t('communityNodes.gitUrl')}</span> + <span class="sr-only">: </span> + <code>{gitUrl}</code> + </div> + )} + {pack.archived && <p class="note note--warning">{t('communityNodes.archivedNote')}</p>} + {pack.dependencies && <p class="note">{t('communityNodes.dependenciesNote')}</p>} + {pack.nodes.length > 0 && ( + <section> + <h4>{t('communityNodes.nodes')}</h4> + <ul class="nodes" role="list"> + {pack.nodes.map((node) => ( + <li> + <span class="nodes__name">{node.name}</span> + {node.description && ( + <span class="nodes__description"> + <span class="sr-only">: </span> + {codeSpans(node.description).map((part) => (part.code ? <code>{part.text}</code> : part.text))} + </span> + )} + </li> + ))} + </ul> + </section> + )} + {pack.links.length > 0 && ( + <section> + <h4>{t('communityNodes.links')}</h4> + <ul class="links" role="list"> + {pack.links.map((link) => ( + <li> + <a href={link.url}>{link.label}</a> + </li> + ))} + </ul> + </section> + )} + {pack.images.length > 0 && ( + <section> + <h4>{t('communityNodes.examples')}</h4> + <ul class="gallery" role="list"> + {pack.images.map((image) => ( + <li> + <figure> + <a class="shot" href={image.src}> + <img data-src={image.src} alt={image.alt} decoding="async" referrerpolicy="no-referrer" data-llms-ignore /> + <noscript> + <img src={image.src} alt={image.alt} loading="lazy" decoding="async" referrerpolicy="no-referrer" /> + </noscript> + </a> + {image.caption && <figcaption>{image.caption}</figcaption>} + </figure> + </li> + ))} + </ul> + </section> + )} + </div> + </details> + </li> + ); + }) + } + </ul> + + <div class="empty" data-empty hidden data-llms-ignore> + <p>{t('communityNodes.noResults')}</p> + <button type="button" class="docs-button docs-button--secondary" data-clear>{t('communityNodes.clearFilters')}</button> + </div> + <span class="sr-only" role="status" data-copy-status></span> +</div> + +<script> + import { ALL_CATEGORIES, packMatches } from '../community-nodes.mjs'; + import { formatPlural, interpolate } from '../i18n.mjs'; + + const RESET_DELAY_MS = 2000; + + const mount = (root: HTMLElement) => { + const i18n = JSON.parse(root.dataset.i18n!) as { + total: Record<string, string>; + shown: Record<string, string>; + copy: string; + copied: string; + copiedStatus: string; + copyFailed: string; + copyFailedStatus: string; + }; + const lang = root.dataset.lang!; + const query = root.querySelector<HTMLInputElement>('[data-query]')!; + const radios = [...root.querySelectorAll<HTMLInputElement>('input[name="community-node-category"]')]; + const count = root.querySelector<HTMLElement>('[data-count]')!; + const empty = root.querySelector<HTMLElement>('[data-empty]')!; + const copyStatus = root.querySelector<HTMLElement>('[data-copy-status]')!; + const cards = [...root.querySelectorAll<HTMLElement>('[data-pack]')].map((element) => ({ + element, + text: element.dataset.search!, + categories: element.dataset.categories!.split(' '), + })); + + const apply = () => { + const filter = { + query: query.value, + category: radios.find((radio) => radio.checked)?.value ?? ALL_CATEGORIES, + }; + let shown = 0; + for (const card of cards) { + const matches = packMatches(card, filter); + card.element.hidden = !matches; + if (matches) shown += 1; + } + empty.hidden = shown > 0; + const text = + shown === cards.length + ? formatPlural(i18n.total, cards.length, lang) + : interpolate(formatPlural(i18n.shown, cards.length, lang), { shown }); + // Rewriting the same text would make some screen readers announce it again. + if (count.textContent !== text) count.textContent = text; + }; + + const clear = () => { + query.value = ''; + radios.forEach((radio) => (radio.checked = radio.value === ALL_CATEGORIES)); + apply(); + }; + + // Marks the edges of the chip row that have more chips past them, for its fades on narrow screens. + const chips = root.querySelector<HTMLElement>('.chips')!; + const updateChipFades = () => { + const end = chips.scrollWidth - chips.clientWidth; + chips.toggleAttribute('data-more-start', chips.scrollLeft > 1); + chips.toggleAttribute('data-more-end', chips.scrollLeft < end - 1); + }; + chips.addEventListener('scroll', updateChipFades, { passive: true }); + new ResizeObserver(updateChipFades).observe(chips); + + query.addEventListener('input', apply); + for (const radio of radios) radio.addEventListener('change', apply); + root.querySelector('[data-clear]')?.addEventListener('click', () => { + clear(); + query.focus(); + }); + + // A link to a pack (`#node-pack-<id>`, as search results use) shows it even when the filters hid it. + const reveal = () => { + // A malformed hash such as `#%` names no pack; decoding it would throw and stop the rest of the setup. + let id = ''; + try { + id = decodeURIComponent(location.hash.slice(1)); + } catch {} + const target = id ? document.getElementById(id) : null; + const card = target?.closest<HTMLElement>('[data-pack]'); + if (!target || !card?.hidden) return; + clear(); + target.scrollIntoView(); + }; + window.addEventListener('hashchange', reveal); + + // A browser that restores the search or the chosen chip on Back filters to match them, unless the URL names a pack + // they would hide. + if (query.value || !radios.find((radio) => radio.value === ALL_CATEGORIES)?.checked) { + apply(); + reveal(); + } + + for (const button of root.querySelectorAll<HTMLButtonElement>('[data-copy]')) { + const label = button.querySelector<HTMLElement>('[data-label]')!; + let resetTimer: number | undefined; + const report = (text: string, announcement: string) => { + label.textContent = text; + copyStatus.textContent = announcement; + clearTimeout(resetTimer); + resetTimer = window.setTimeout(() => { + label.textContent = i18n.copy; + copyStatus.textContent = ''; + }, RESET_DELAY_MS); + }; + button.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(button.dataset.copy!); + report(i18n.copied, interpolate(i18n.copiedStatus, { title: button.dataset.title! })); + } catch (error) { + console.error('Could not copy the Git URL:', error); + report(i18n.copyFailed, i18n.copyFailedStatus); + } + }); + } + + // A disclosure's hot-linked examples load when it first opens. One that fails hides, leaving its caption. + for (const details of root.querySelectorAll<HTMLDetailsElement>('details')) { + details.addEventListener('toggle', () => { + if (!details.open) return; + for (const image of details.querySelectorAll<HTMLImageElement>('img[data-src]')) { + image.addEventListener('error', () => image.closest('li')?.setAttribute('data-broken', ''), { once: true }); + image.src = image.dataset.src!; + image.removeAttribute('data-src'); + } + }); + } + }; + + for (const root of document.querySelectorAll<HTMLElement>('[data-community-nodes]')) mount(root); +</script> + +<style> + .community-nodes { + --card-pad: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1.5rem; + } + + /* Without JavaScript the filters and copy buttons can't work, so they stay hidden and every card shows. */ + .community-nodes:not([data-js]) { + .controls, + .count, + .copy, + /* The `<noscript>` copy beside it shows instead. */ + .shot img[data-src] { + display: none; + } + } + + /* Controls */ + + .controls { + display: flex; + flex-direction: column; + gap: 0.625rem; + } + + .field { + position: relative; + display: flex; + align-items: center; + + input { + width: 100%; + height: var(--docs-header-control-height); + padding: 0 0.75rem 0 2.25rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-header-control-radius); + background-color: var(--docs-surface); + color: var(--docs-text-strong); + font: inherit; + font-size: var(--sl-text-sm); + transition: var(--docs-control-transition); + + &::placeholder { + color: var(--docs-text-subtle); + } + + &:hover { + border-color: var(--docs-border-strong); + } + + &:focus-visible { + border-color: var(--docs-accent); + outline: none; + box-shadow: 0 0 0 1px var(--docs-accent); + } + } + } + + .field__icon { + position: absolute; + inset-inline-start: 0.75rem; + color: var(--docs-text-subtle); + pointer-events: none; + } + + .chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + /* A fieldset is at least as wide as its content by default, which would stop the phone row from scrolling. */ + min-inline-size: 0; + margin: 0; + padding: 0; + border: 0; + } + + /* + * On narrow screens the chips stay in one row that scrolls sideways, fading out at an edge with more chips past it. + * The padding leaves room for the focus ring, which the scrolling would clip; arrow keys scroll the chosen chip in. + */ + @media (max-width: 49.99rem) { + .chips { + --fade-start: 0px; + --fade-end: 0px; + flex-wrap: nowrap; + margin: -0.25rem; + padding: 0.25rem; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; + mask-image: linear-gradient( + to right, + transparent, + #000 var(--fade-start), + #000 calc(100% - var(--fade-end)), + transparent + ); + + &::-webkit-scrollbar { + display: none; + } + + &[data-more-start] { + --fade-start: 2.5rem; + } + + &[data-more-end] { + --fade-end: 2.5rem; + } + } + + .chip { + flex: none; + } + } + + /* Hover: the strong border and the control fill. Chosen: the solid accent fill. */ + .chip { + position: relative; + display: inline-flex; + align-items: center; + height: var(--docs-tab-height); + padding: 0 var(--docs-tab-pad-x); + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius); + color: var(--docs-text-muted); + font-size: var(--docs-control-font-size); + font-weight: 500; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: var(--docs-control-transition); + + /* The radio is the control: invisible, but covering the chip so it takes the clicks. */ + input { + position: absolute; + inset: 0; + margin: 0; + opacity: 0; + cursor: inherit; + } + + &:hover { + border-color: var(--docs-border-strong); + background-color: var(--docs-control); + color: var(--docs-text-strong); + } + + &:has(input:checked) { + border-color: var(--docs-accent); + background-color: var(--docs-accent); + color: var(--docs-accent-contrast); + } + + &:has(input:focus-visible) { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + } + } + + .count { + margin: 0; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + font-variant-numeric: tabular-nums; + } + + /* Cards */ + + /* + * Each card spans four rows of the grid as a subgrid, so the thumbnails, texts, buttons and disclosures of a row + * line up whatever each card's text length. An open disclosure grows only its row's last track. + */ + .grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; + } + + .card { + display: grid; + grid-row: span 4; + grid-template-rows: subgrid; + row-gap: 0; + min-width: 0; + overflow: clip; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + + &[hidden] { + display: none; + } + } + + .thumb { + position: relative; + display: grid; + place-items: center; + aspect-ratio: 16 / 10; + border-bottom: 1px solid var(--docs-border-subtle); + background-color: var(--docs-bg); + + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + /* Covers the dot grid, which would show through a transparent image. */ + background-color: var(--docs-bg); + } + } + + /* Without a thumbnail: the category's icon on a tile over the page's dot grid. */ + .thumb { + background-image: radial-gradient(circle, var(--docs-grid-dot) 1px, transparent 1px); + background-size: 16px 16px; + } + + .thumb__placeholder { + box-sizing: content-box; + padding: 0.875rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-lg); + background-color: var(--docs-surface); + color: var(--docs-text-subtle); + } + + .main { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.875rem var(--card-pad) 0; + } + + .title { + margin: 0; + color: var(--docs-text-strong); + font-size: 1rem; + font-weight: 600; + line-height: 1.35; + letter-spacing: -0.01em; + overflow-wrap: anywhere; + scroll-margin-top: calc(var(--sl-nav-height) + 1rem); + } + + .author { + margin: 0; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + } + + .summary { + margin: 0.25rem 0 0; + color: var(--docs-text-muted); + font-size: 0.8125rem; + line-height: 1.5; + } + + .tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; + } + + .tag { + padding: 0.125rem 0.375rem; + border-radius: var(--docs-radius); + background-color: var(--docs-control); + color: var(--docs-text-muted); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.4; + white-space: nowrap; + } + + .tag--warning { + background-color: color-mix(in oklab, var(--invoke-warning) 14%, transparent); + color: var(--invoke-warning); + } + + .tag--outline { + box-shadow: inset 0 0 0 1px var(--docs-border-strong); + background-color: transparent; + } + + .actions { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.375rem; + padding: 0.875rem var(--card-pad); + } + + /* Disclosure */ + + .details { + border-top: 1px solid var(--docs-border-subtle); + + summary { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem var(--card-pad); + color: var(--docs-text-muted); + font-size: var(--docs-control-font-size); + font-weight: 500; + list-style: none; + cursor: pointer; + transition: var(--docs-control-transition); + + &::-webkit-details-marker { + display: none; + } + + &:hover { + background-color: var(--docs-control); + color: var(--docs-text-strong); + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + } + } + + &[open] .details__chevron { + rotate: 180deg; + } + } + + .details__chevron { + margin-inline-start: auto; + } + + @media (prefers-reduced-motion: no-preference) { + .details__chevron { + transition: rotate 150ms ease-out; + } + } + + @media (prefers-reduced-motion: reduce) { + .field input, + .chip, + .details summary { + transition: none; + } + } + + .details__body { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.25rem var(--card-pad) var(--card-pad); + color: var(--docs-text-muted); + font-size: 0.8125rem; + line-height: 1.5; + + p { + margin: 0; + } + + h4 { + margin: 0 0 0.375rem; + color: var(--docs-text-subtle); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + a { + color: var(--docs-accent); + + &:hover { + color: var(--docs-accent-text); + } + } + + code { + overflow-wrap: anywhere; + } + } + + .summary code, + .details__body code { + padding: 0.0625rem 0.25rem; + border-radius: 3px; + background-color: var(--docs-control); + color: var(--docs-text); + font-family: var(--__sl-font-mono); + font-size: 0.9em; + } + + .note { + padding-inline-start: 0.625rem; + border-inline-start: 2px solid var(--docs-border-strong); + } + + .note--warning { + border-inline-start-color: var(--invoke-warning); + } + + .git-url { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .label { + color: var(--docs-text-subtle); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + code { + align-self: start; + user-select: all; + } + } + + .nodes, + .links { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; + } + + .nodes li { + display: flex; + flex-direction: column; + } + + .nodes__name { + color: var(--docs-text-strong); + font-weight: 500; + } + + .gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + + figure { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0; + } + + figcaption { + color: var(--docs-text-subtle); + font-size: 0.6875rem; + line-height: 1.35; + } + + li[data-broken] .shot { + display: none; + } + } + + .shot { + display: block; + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius); + background-color: var(--docs-bg); + transition: var(--docs-control-transition); + + &:hover { + border-color: var(--docs-border-strong); + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + } + + img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + color: var(--docs-text-subtle); + font-size: 0.6875rem; + } + } + + /* Empty state */ + + .empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 2rem 1rem; + border: 1px dashed var(--docs-border); + border-radius: var(--docs-radius-container); + color: var(--docs-text-muted); + font-size: var(--sl-text-sm); + text-align: center; + + &[hidden] { + display: none; + } + + p { + margin: 0; + } + } +</style> diff --git a/docs/src/lib/components/DownloadOptions.astro b/docs/src/lib/components/DownloadOptions.astro deleted file mode 100644 index 95124322fd9..00000000000 --- a/docs/src/lib/components/DownloadOptions.astro +++ /dev/null @@ -1,246 +0,0 @@ ---- -import { CardGrid, LinkCard, Icon, LinkButton } from '@astrojs/starlight/components'; -import { type StarlightIcon } from '@astrojs/starlight/types'; -import { withBase } from '../base-path'; - -type LauncherDownloadOption = { - icon: StarlightIcon; - headline: string; - note: string; - launcherDownloadLink: string; - launcherDownloadLabel?: string; -}; -const launcherDownloadOptions: Record<string, LauncherDownloadOption> = { - windows: { - icon: 'seti:windows', - headline: 'Download for Windows', - note: 'Requires Windows 10 or later, and NVIDIA or AMD GPU.', - launcherDownloadLink: - 'https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition.Setup.latest.exe', - launcherDownloadLabel: 'Download EXE', - }, - macos: { - icon: 'apple', - headline: 'Download for MacOS', - note: 'Requires Apple Silicon (M-Series). Not compatible with Intel.', - launcherDownloadLink: - 'https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition-latest-arm64.dmg', - launcherDownloadLabel: 'Download DMG', - }, - linux: { - icon: 'linux', - headline: 'Download for Linux', - note: 'Requires NVIDIA or AMD GPU. Compatible with most distributions.', - launcherDownloadLink: - 'https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition-latest.AppImage', - launcherDownloadLabel: 'Download AppImage', - }, -}; - -const manualDownloadOptions = { - github: { - headline: 'Download from GitHub', - description: 'For advanced users who want to set up Invoke manually or contribute to the project.', - href: 'https://github.com/invoke-ai/InvokeAI/releases', - }, - docker: { - headline: 'Run with Docker', - description: 'For users who want to run Invoke without installing dependencies directly on their system.', - href: withBase('/configuration/docker/', import.meta.env.BASE_URL), - }, -}; - -const hostedOptions = { - aibadgr: { - headline: 'Run on AI Badgr', - description: 'Run on the AI Badgr hosted GPU service', - href: 'https://aibadgr.com/gpu/launch?template=invokeai', - }, - runpod: { - headline: 'Run on RunPod', - description: 'Run on the RunPod hosted GPU service', - href: 'https://www.runpod.io/blog/invoke-ai-stable-diffusion-runpod-nfz18', - }, - railway: { - headline: 'Run on Railway', - description: 'Run on the Railway hosted GPU service', - href: 'https://railway.com/deploy/invokeai', - }, -}; ---- - -<div class="download-options"> - <!-- Launcher Options --> - <div class="download-options__grid download-options--launcher"> - { - Object.entries(launcherDownloadOptions).map( - ([key, { icon, headline, note, launcherDownloadLink, launcherDownloadLabel }]) => ( - <div class="download-option" data-platform={key}> - <Icon name={icon} size="2rem" /> - <h3>{headline}</h3> - <p>{note}</p> - - <div class="download-option__actions"> - <LinkButton - href={launcherDownloadLink} - icon="download" - iconPlacement="start" - title={headline} - variant="secondary" - > - {launcherDownloadLabel} - </LinkButton> - </div> - </div> - ), - ) - } - </div> - - <!-- OR --> - <div class="download-options__separator"> - <span>OR</span> - </div> - - <!-- Manual Options --> - <div class="download-option__grid download-options--manual"> - { - Object.entries(manualDownloadOptions).map(([key, { headline, href, description }]) => ( - <LinkCard title={headline} {href} {description} /> - )) - } - </div> - - <!-- Hosted Options --> - <div class="download-options__hosted"> - <h3>Hosted Options</h3> - <p>For users who want to run Invoke on a hosted GPU service instead of their own hardware.</p> - - <CardGrid> - { - Object.entries(hostedOptions).map(([key, { headline, href, description }]) => ( - <LinkCard title={headline} {href} {description} /> - )) - } - </CardGrid> - </div> -</div> - -<style is:global> - .download-options { - display: flex; - flex-direction: column; - gap: 1rem; - } - - .download-options__separator { - display: flex; - align-items: center; - justify-content: center; - position: relative; - - > span { - border-radius: 999px; - border: 1px solid var(--sl-color-hairline); - background: var(--sl-color-bg); - width: 2.5rem; - aspect-ratio: 1 / 1; - display: flex; - align-items: center; - justify-content: center; - z-index: 1; - font-size: var(--sl-text-2xs); - font-family: var(--__sl-font-mono); - line-height: 1; - } - - &::before { - content: ''; - position: absolute; - top: 50%; - left: 0; - right: 0; - height: 1px; - background: var(--sl-color-hairline); - z-index: 0; - } - } - - .download-options__hosted { - margin: 0; - border-radius: var(--radius); - background: var(--sl-color-black); - border: 1px solid var(--sl-color-gray-5); - padding: 1.5rem; - - h3 { - margin-top: 0; - } - - > p { - margin-top: 0.5rem; - color: var(--sl-color-gray-2); - } - } - - .download-options--launcher { - display: grid; - gap: 1.5rem; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - } - - .download-options--manual { - display: flex; - flex-direction: column; - } - - .download-option { - display: flex; - flex-direction: column; - margin: 0; - border-radius: var(--radius); - background: var(--sl-color-black); - border: 1px solid var(--sl-color-gray-5); - padding: 1.5rem; - - h3 { - margin-top: 1rem; - } - } - - .download-option__actions { - margin-top: auto; - margin-bottom: 0; - - > * { - margin-bottom: 0; - } - } -</style> - -<script is:inline> - // Highlight the preferred platform's launcher button - function applyPreferredPlatform() { - const platform = detectPlatform(); - if (!platform) return; - - const option = document.querySelector(`.download-option[data-platform="${platform}"]`); - const link = option?.querySelector('a'); - if (!link) return; - - link.classList.remove('secondary'); - link.classList.add('primary'); - } - - function detectPlatform() { - const uaDataPlatform = navigator.userAgentData?.platform?.toLowerCase(); - const ua = (uaDataPlatform || navigator.userAgent).toLowerCase(); - - if (ua.includes('mac')) return 'macos'; - if (ua.includes('win')) return 'windows'; - if (ua.includes('linux')) return 'linux'; - return null; - } - - applyPreferredPlatform(); -</script> diff --git a/docs/src/lib/components/DownloadPage.astro b/docs/src/lib/components/DownloadPage.astro new file mode 100644 index 00000000000..5be23d08af7 --- /dev/null +++ b/docs/src/lib/components/DownloadPage.astro @@ -0,0 +1,1055 @@ +--- +// The download page below its title: the launcher for the visitor's platform, whether their hardware runs Invoke, +// what follows the download and the other ways to run it. An inline script picks the platform; without it, or on an +// unknown platform, the hero shows all three downloads. +import AppWindow from '@lucide/astro/icons/app-window'; +import Apple from '@lucide/astro/icons/apple'; +import ArrowRight from '@lucide/astro/icons/arrow-right'; +import ArrowUpRight from '@lucide/astro/icons/arrow-up-right'; +import Boxes from '@lucide/astro/icons/boxes'; +import Check from '@lucide/astro/icons/check'; +import Cloud from '@lucide/astro/icons/cloud'; +import CodeXml from '@lucide/astro/icons/code-xml'; +import Container from '@lucide/astro/icons/container'; +import Copy from '@lucide/astro/icons/copy'; +import Cpu from '@lucide/astro/icons/cpu'; +import Download from '@lucide/astro/icons/download'; +import Microchip from '@lucide/astro/icons/microchip'; +import Monitor from '@lucide/astro/icons/monitor'; +import Package from '@lucide/astro/icons/package'; +import Scale from '@lucide/astro/icons/scale'; +import Smartphone from '@lucide/astro/icons/smartphone'; +import Terminal from '@lucide/astro/icons/terminal'; +import UserRoundX from '@lucide/astro/icons/user-round-x'; +import WifiOff from '@lucide/astro/icons/wifi-off'; + +import cachedRelease from '../../data/launcher-release.json'; +import { withBase } from '../base-path'; +import { formatSize, loadLauncherRelease } from '../launcher-release.mjs'; +import LauncherCards, { type LauncherPlatform } from './LauncherCards.astro'; + +const href = (path: string) => withBase(path, import.meta.env.BASE_URL); + +// Labels only: the buttons download GitHub's latest release whatever this says, so the cached copy may lag behind. +const release = await loadLauncherRelease({ fallback: cachedRelease, token: process.env.GITHUB_TOKEN }); +const releasedOn = new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: 'UTC', +}).format(new Date(release.publishedAt)); + +const latest = 'https://github.com/invoke-ai/launcher/releases/latest/download/'; +const platforms = [ + { + id: 'windows', + name: 'Windows', + icon: 'seti:windows', + file: 'EXE', + fileLabel: 'EXE installer', + note: 'Windows 10 or later, with an NVIDIA, AMD or Intel Arc GPU.', + href: `${latest}Invoke.Community.Edition.Setup.latest.exe`, + }, + { + id: 'macos', + name: 'macOS', + qualifier: 'Apple Silicon', + icon: 'apple', + file: 'DMG', + fileLabel: 'DMG for Apple Silicon', + note: 'Apple Silicon (M-series) only; not Intel. macOS 14 or later.', + href: `${latest}Invoke.Community.Edition-latest-arm64.dmg`, + }, + { + id: 'linux', + name: 'Linux', + icon: 'linux', + file: 'AppImage', + fileLabel: 'AppImage', + note: 'NVIDIA, AMD or Intel Arc GPU. Works on most distributions.', + href: `${latest}Invoke.Community.Edition-latest.AppImage`, + }, +].map((platform) => { + const asset = release.assets[platform.id as keyof typeof release.assets]; + const size = asset && formatSize(asset.size); + return { + ...platform, + meta: [size, `v${release.version}`].filter(Boolean).join(' · '), + primaryMeta: [platform.fileLabel, size, `v${release.version}`, releasedOn].filter(Boolean).join(' · '), + } as LauncherPlatform & { fileLabel: string; primaryMeta: string }; +}); + +const hardware = [ + { Icon: Apple, label: 'Mac', text: 'Any Apple Silicon Mac; 16 GB+ memory recommended' }, + { Icon: Cpu, label: 'NVIDIA', text: 'GTX 16xx / RTX 20xx and newer' }, + { Icon: Cpu, label: 'AMD', text: 'Through ROCm, on Windows and Linux' }, + { Icon: Cpu, label: 'Intel Arc', text: 'Alchemist, Battlemage and newer' }, + { + Icon: Microchip, + label: 'ARM64 Linux', + text: 'CPU only, for API models such as GPT Image, through a', + link: { text: 'manual install', href: '/start-here/manual/' }, + }, + { + Icon: Microchip, + label: 'Windows on ARM64', + text: 'Developer preview with an NVIDIA RTX Spark GPU, through a manual install; see the', + link: { text: 'requirements', href: '/start-here/system-requirements/#windows-on-arm64-rtx-spark' }, + }, +]; + +// The minimum VRAM of the families in the hardware requirements table, rounded up to these four tiers. +const vramTiers = [ + { gb: 8, models: 'SDXL, Z-Image Turbo' }, + { gb: 12, models: 'FLUX.1, FLUX.2 Klein' }, + { gb: 16, models: 'Qwen-Image-2.1' }, + { gb: 24, models: 'Krea-2, Ideogram 4' }, +]; + +const steps = [ + { + Icon: Download, + title: 'Install the launcher', + text: 'One click, with no Python to set up.', + link: '/start-here/installation/', + }, + { + Icon: Package, + title: 'Install Invoke from the launcher', + text: 'It downloads Invoke and manages its updates.', + link: '/start-here/installation/', + }, + { + Icon: Boxes, + title: 'Pick starter models on first run', + text: 'Models need tens of GB of disk, so start with a few.', + link: '/users-guide/models/introduction/', + }, +]; + +const ways = [ + { id: 'launcher', label: 'Launcher', Icon: AppWindow }, + { id: 'manual', label: 'Manual', Icon: Terminal }, + { id: 'docker', label: 'Docker', Icon: Container }, + { id: 'cloud', label: 'Cloud', Icon: Cloud }, +]; + +const cloudProviders = [ + { + title: 'AI Badgr', + description: 'Launch the Invoke template on AI Badgr’s hosted GPUs.', + href: 'https://aibadgr.com/gpu/launch?template=invokeai', + }, + { + title: 'RunPod', + description: 'RunPod’s guide to running Invoke on its GPU cloud.', + href: 'https://www.runpod.io/blog/invoke-ai-stable-diffusion-runpod-nfz18', + }, + { + title: 'Railway', + description: 'Deploy the Invoke template on Railway.', + href: 'https://railway.com/deploy/invokeai', + }, +]; +--- + +<div class="download not-content"> + <div class="dl-hero"> + <section class="dl-hero__main" aria-label="Download"> + <p class="dl-hero__lede">The Invoke Launcher installs, updates and runs Invoke on your own machine.</p> + <ul class="chips"> + <li class="chip">Launcher v{release.version}</li> + <li class="chip">Released <time datetime={release.publishedAt}>{releasedOn}</time></li> + <li> + <a class="chip chip--link" href={release.notesUrl}> + Release notes <ArrowUpRight aria-hidden="true" /> + </a> + </li> + <li> + <a class="chip chip--link" href={href('/start-here/upgrading/')}> + Upgrading from v6 <ArrowRight aria-hidden="true" /> + </a> + </li> + </ul> + + <div class="phone"> + <Smartphone class="phone__icon" aria-hidden="true" /> + <div class="phone__body"> + <h2 class="phone__title">Invoke runs on desktop</h2> + <p>Open this page on a Windows, macOS or Linux computer to download the launcher.</p> + <button type="button" class="docs-button docs-button--secondary phone__copy" data-copy-link hidden> + <Copy class="phone__copy-idle" aria-hidden="true" /> + <Check class="phone__copy-done" aria-hidden="true" /> + <span data-copy-label>Copy link</span> + </button> + <span class="sr-only" aria-live="polite" data-copy-status></span> + </div> + </div> + + <div class="dl-primary"> + { + platforms.map((platform) => ( + <div class="dl-primary__option" data-for={platform.id}> + <a class="docs-button docs-button--primary docs-button--xl" href={platform.href}> + <Download aria-hidden="true" /> + Download for {platform.name} + </a> + <p class="dl-primary__meta">{platform.primaryMeta}</p> + {platform.id === 'macos' && ( + <p class="dl-primary__caveat">Apple Silicon (M-series) only; not Intel Macs.</p> + )} + <p class="dl-primary__also"> + Also for{' '} + {platforms + .filter((other) => other.id !== platform.id) + .map((other, index) => ( + <> + {index > 0 && <span aria-hidden="true"> · </span>} + <a + href={other.href} + aria-label={`Download for ${other.name}${other.qualifier ? ` (${other.qualifier})` : ''}`} + > + {other.name} + {other.qualifier && ` (${other.qualifier})`} + </a> + </> + ))} + </p> + </div> + )) + } + </div> + + <div class="dl-hero__cards"> + <LauncherCards platforms={platforms} level={2} /> + </div> + </section> + + <section class="req" aria-labelledby="will-it-run"> + <h2 id="will-it-run" class="req__title">Will it run?</h2> + <p class="req__os"> + <Monitor aria-hidden="true" /> + Windows 10+, macOS 14+ and Linux (Ubuntu 20.04+ is well-tested). + </p> + <ul class="req__list"> + { + hardware.map(({ Icon, label, text, link }) => ( + <li> + <Icon aria-hidden="true" /> + <span> + <strong>{label}</strong> {text} + {link && ( + <> + {' '} + <a href={href(link.href)}>{link.text}</a> + </> + )} + </span> + </li> + )) + } + </ul> + <h3 class="docs-eyebrow req__scale-title">GPU memory (VRAM) by model</h3> + <ol class="vram"> + { + vramTiers.map(({ gb, models }) => ( + <li class="vram__tier"> + <span class="vram__bar" aria-hidden="true" /> + <strong class="vram__gb">{gb} GB</strong> + <span class="vram__models">{models}</span> + </li> + )) + } + </ol> + <p class="req__note">Less VRAM often works too, just slower.</p> + <p class="req__links"> + <a href={href('/start-here/system-requirements/')}> + Full hardware requirements <ArrowRight aria-hidden="true" /> + </a> + <a href={href('/configuration/optimization/low-vram-mode/')}> + Low-VRAM mode <ArrowRight aria-hidden="true" /> + </a> + </p> + </section> + + <section class="next" aria-labelledby="what-happens-next"> + <h2 id="what-happens-next" class="section-title">What happens next</h2> + <ol class="steps"> + { + steps.map(({ Icon, title, text, link }, index) => ( + <li> + <a class="docs-link-card step" href={href(link)}> + <span class="step__head"> + <span class="step__number" aria-hidden="true"> + {index + 1} + </span> + <Icon class="docs-link-card__icon" aria-hidden="true" /> + </span> + <span class="step__text"> + <span class="docs-link-card__title">{title}</span> + <span class="docs-link-card__description">{text}</span> + </span> + </a> + </li> + )) + } + </ol> + </section> + + <script is:inline> + (() => { + const hero = document.currentScript.parentElement; + const data = navigator.userAgentData; + const ua = navigator.userAgent; + const source = (data?.platform || navigator.platform || ua || '').toLowerCase(); + // Phones and tablets get the three downloads and, with no platform picked, the copy-link card. iPads report a + // Mac platform; a touch-first Windows tablet keeps its platform. + const mobile = + data?.mobile ?? + (/android|iphone|ipad|ipod|mobile/i.test(ua) || (source.includes('mac') && navigator.maxTouchPoints > 1)); + if (mobile || matchMedia('(pointer: coarse)').matches) hero.dataset.handheld = ''; + // The Windows and Linux installers are x64 only, so ARM visitors choose from the three cards. Chromium on + // Windows on ARM reports x64 everywhere but in the high-entropy architecture hint. + const arm = /\b(aarch64|arm)/i.test(`${navigator.platform} ${ua}`); + const platform = mobile + ? null + : source.includes('mac') + ? 'macos' + : arm + ? null + : source.includes('win') + ? 'windows' + : source.includes('linux') + ? 'linux' + : null; + if (platform) hero.dataset.platform = platform; + if (platform && platform !== 'macos') { + data?.getHighEntropyValues?.(['architecture']).then( + ({ architecture }) => { + if (architecture === 'arm') delete hero.dataset.platform; + }, + () => {}, + ); + } + + const copy = hero.querySelector('[data-copy-link]'); + if (!navigator.clipboard?.writeText) return; + const label = copy.querySelector('[data-copy-label]'); + const status = hero.querySelector('[data-copy-status]'); + let reset; + copy.hidden = false; + copy.addEventListener('click', async () => { + clearTimeout(reset); + try { + await navigator.clipboard.writeText(location.href); + copy.dataset.copied = ''; + label.textContent = 'Copied'; + status.textContent = 'Link copied to the clipboard'; + } catch { + label.textContent = 'Could not copy'; + status.textContent = 'Could not copy the link'; + } + reset = setTimeout(() => { + delete copy.dataset.copied; + label.textContent = 'Copy link'; + status.textContent = ''; + }, 2000); + }); + })(); + </script> + </div> + + <section class="ways" aria-labelledby="other-ways"> + <h2 id="other-ways" class="section-title">Other ways to run it</h2> + <fieldset class="ways__picker"> + <legend class="sr-only">Way to run Invoke</legend> + <div class="docs-segment-tabs"> + { + ways.map(({ id, label, Icon }) => ( + <label class="docs-segment-tab"> + <input type="radio" name="download-way" value={id} checked={id === 'launcher'} /> + <Icon size={14} aria-hidden="true" /> + {label} + </label> + )) + } + </div> + </fieldset> + + <div class="ways__panel" data-way="launcher"> + <h3 class="ways__heading">Install with the launcher</h3> + <p class="ways__lede">The recommended way: the launcher sets up Python, Invoke and its updates for you.</p> + <LauncherCards platforms={platforms} level={4} /> + </div> + + <div class="ways__panel" data-way="manual"> + <h3 class="ways__heading">Install manually</h3> + <p class="ways__lede">For developers and anyone who wants to manage the Python environment themselves.</p> + <div class="ways__cards"> + <a class="docs-link-card way-card" href={href('/start-here/manual/')}> + <Terminal class="docs-link-card__icon" aria-hidden="true" /> + <span class="docs-link-card__title">Manual installation guide</span> + <span class="docs-link-card__description">Install Invoke into a Python environment with uv.</span> + </a> + <a class="docs-link-card way-card" href="https://github.com/invoke-ai/InvokeAI/releases"> + <ArrowUpRight class="docs-link-card__icon" aria-hidden="true" /> + <span class="docs-link-card__title">Invoke releases on GitHub</span> + <span class="docs-link-card__description">Release notes and source for every version.</span> + </a> + </div> + </div> + + <div class="ways__panel" data-way="docker"> + <h3 class="ways__heading">Run with Docker</h3> + <p class="ways__lede">Run Invoke in a container, without installing its dependencies on your system.</p> + <pre class="ways__code"><code>docker run --runtime=nvidia --gpus=all --publish 9090:9090 ghcr.io/invoke-ai/invokeai</code></pre> + <div class="ways__cards"> + <a class="docs-link-card way-card" href={href('/configuration/docker/')}> + <Container class="docs-link-card__icon" aria-hidden="true" /> + <span class="docs-link-card__title">Docker guide</span> + <span class="docs-link-card__description">AMD GPUs, building your own image and Docker Compose.</span> + </a> + </div> + </div> + + <div class="ways__panel" data-way="cloud"> + <h3 class="ways__heading">No GPU? Run it on a rented one</h3> + <p class="ways__lede">These services run Invoke on their GPUs and bill you for the time you use.</p> + <div class="ways__cards ways__cards--three"> + { + cloudProviders.map(({ title, description, href: providerHref }) => ( + <a class="docs-link-card way-card" href={providerHref}> + <ArrowUpRight class="docs-link-card__icon" aria-hidden="true" /> + <span class="docs-link-card__title">{title}</span> + <span class="docs-link-card__description">{description}</span> + </a> + )) + } + </div> + </div> + </section> + + <ul class="trust" aria-label="About Invoke"> + <li><Scale aria-hidden="true" /> Apache 2.0 licensed</li> + <li><WifiOff aria-hidden="true" /> Runs offline</li> + <li><UserRoundX aria-hidden="true" /> No account needed</li> + <li> + <a href="https://github.com/invoke-ai/InvokeAI"><CodeXml aria-hidden="true" /> Source on GitHub</a> + </li> + </ul> +</div> + +<style> + .download { + display: flex; + flex-direction: column; + gap: 3.5rem; + } + + /* Hero */ + + /* + * The hero, the hardware card and the next steps share one grid. Two columns from 56rem: the steps run as a card + * strip under both, or, once the script has picked a platform and the hero is just a button, as a list in the + * space under it beside the taller hardware card. + */ + .dl-hero { + display: grid; + grid-template-areas: 'main' 'req' 'next'; + gap: 2.5rem 2rem; + align-items: start; + } + + .dl-hero__main { + grid-area: main; + } + + .req { + grid-area: req; + } + + .next { + grid-area: next; + } + + @media (min-width: 56rem) { + .dl-hero { + grid-template-columns: minmax(0, 1fr) minmax(0, 25rem); + grid-template-areas: 'main req' 'next next'; + column-gap: 2.5rem; + } + + .dl-hero[data-platform] { + grid-template-areas: 'main req' 'next req'; + grid-template-rows: auto 1fr; + row-gap: 2rem; + + .section-title { + margin-bottom: 0.5rem; + color: var(--docs-text-muted); + font-size: var(--sl-text-sm); + letter-spacing: 0; + } + + .steps { + grid-template-columns: minmax(0, 1fr); + gap: 0; + } + + .step { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + column-gap: 0.75rem; + margin-inline: -0.5rem; + padding: 0.375rem 0.5rem; + border-color: transparent; + border-radius: var(--docs-radius-lg); + background-color: transparent; + + &:hover { + border-color: transparent; + background-color: var(--docs-hover); + } + } + + .step__head { + gap: 0.5rem; + margin-bottom: 0; + } + + .step__text { + display: block; + } + + .step__text .docs-link-card__description { + margin-inline-start: 0.375rem; + } + } + } + + .dl-hero__main { + display: flex; + flex-direction: column; + gap: 1.5rem; + min-width: 0; + } + + .dl-hero__lede { + max-width: 34rem; + margin: 0; + color: var(--docs-text-muted); + font-size: var(--sl-text-lg); + line-height: 1.5; + } + + .chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: -0.5rem 0 0; + padding: 0; + list-style: none; + } + + .chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + height: 1.5rem; + padding: 0 0.625rem; + border: 1px solid var(--docs-border); + border-radius: 999px; + background-color: var(--docs-surface); + color: var(--docs-text-muted); + font-size: var(--sl-text-xs); + font-variant-numeric: tabular-nums; + white-space: nowrap; + + :global(svg) { + width: 0.875rem; + height: 0.875rem; + color: var(--docs-text-subtle); + } + } + + .chip--link { + color: var(--docs-text-strong); + text-decoration: none; + transition: + border-color 150ms ease, + background-color 150ms ease; + + &:hover { + border-color: var(--docs-border-strong); + background-color: var(--docs-control); + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + } + } + + /* + * The detected platform's button, or (without the script, or on a platform it does not know) all three cards. + */ + .dl-primary, + .dl-primary__option { + display: none; + } + + .dl-hero[data-platform] { + .dl-primary { + display: block; + } + .dl-hero__cards { + display: none; + } + } + + .dl-hero[data-platform='windows'] .dl-primary__option[data-for='windows'], + .dl-hero[data-platform='macos'] .dl-primary__option[data-for='macos'], + .dl-hero[data-platform='linux'] .dl-primary__option[data-for='linux'] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .dl-primary__option p { + margin: 0; + } + + .dl-primary__meta { + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + font-variant-numeric: tabular-nums; + } + + .dl-primary__caveat { + color: var(--docs-text-muted); + font-size: var(--sl-text-xs); + } + + .dl-primary__option .dl-primary__also { + margin-top: 0.5rem; + color: var(--docs-text-subtle); + font-size: var(--sl-text-sm); + } + + a:not(.docs-button, .chip, .docs-link-card) { + color: var(--docs-accent); + text-decoration: none; + + &:hover { + color: var(--docs-accent-text); + text-decoration: underline; + text-underline-offset: 0.2em; + } + } + + /* Phones: say Invoke is for desktops, and offer to copy the link, before the downloads. */ + .phone { + display: none; + gap: 0.75rem; + padding: 1rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + + :global(.phone__icon) { + flex: none; + width: 1.25rem; + height: 1.25rem; + margin-top: 0.125rem; + color: var(--docs-text-muted); + } + } + + .dl-hero[data-handheld]:not([data-platform]) .phone { + display: flex; + } + + /* Without the script, a narrow screen is most likely a phone. */ + @media (scripting: none) and (max-width: 40rem) { + .phone { + display: flex; + } + } + + .phone__body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + + p { + margin: 0 0 0.5rem; + color: var(--docs-text-muted); + font-size: var(--sl-text-sm); + } + } + + .phone__title { + margin: 0; + color: var(--docs-text-strong); + font-size: var(--sl-text-base); + font-weight: 600; + } + + .phone__copy { + &[hidden] { + display: none; + } + + :global(.phone__copy-done) { + display: none; + color: var(--invoke-success); + } + + &[data-copied] { + :global(.phone__copy-idle) { + display: none; + } + :global(.phone__copy-done) { + display: block; + } + } + } + + /* Will it run? */ + + .req { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 1.25rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + + :global(svg) { + flex: none; + width: 1rem; + height: 1rem; + } + + p { + margin: 0; + } + } + + .req__title { + margin: 0; + color: var(--docs-text-strong); + font-size: var(--sl-text-lg); + font-weight: 600; + } + + .req__os { + display: flex; + gap: 0.5rem; + color: var(--docs-text-muted); + font-size: var(--sl-text-sm); + line-height: 1.5; + + :global(svg) { + margin-top: 0.1875rem; + color: var(--docs-text-subtle); + } + } + + .req__list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding: 0.875rem 0 0; + border-top: 1px solid var(--docs-border-subtle); + list-style: none; + + li { + display: flex; + gap: 0.5rem; + margin: 0; + color: var(--docs-text-subtle); + font-size: var(--sl-text-sm); + line-height: 1.5; + } + + :global(svg) { + margin-top: 0.1875rem; + color: var(--docs-text-subtle); + } + + strong { + color: var(--docs-text-strong); + font-weight: 600; + } + } + + .req__scale-title { + margin: 0.5rem 0 0; + } + + /* Four tiers on one bar, brighter as they grow. */ + .vram { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 2px; + margin: 0; + padding: 0; + list-style: none; + } + + .vram__tier { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0; + padding-inline-end: 0.375rem; + min-width: 0; + } + + .vram__bar { + height: 0.375rem; + margin-bottom: 0.25rem; + background-color: color-mix(in oklab, var(--docs-accent) 35%, var(--docs-control)); + } + + .vram__tier:nth-child(2) .vram__bar { + background-color: color-mix(in oklab, var(--docs-accent) 57%, var(--docs-control)); + } + + .vram__tier:nth-child(3) .vram__bar { + background-color: color-mix(in oklab, var(--docs-accent) 78%, var(--docs-control)); + } + + .vram__tier:nth-child(4) .vram__bar { + background-color: var(--docs-accent); + } + + .vram__tier:first-child .vram__bar { + border-start-start-radius: 999px; + border-end-start-radius: 999px; + } + + .vram__tier:last-child .vram__bar { + border-start-end-radius: 999px; + border-end-end-radius: 999px; + } + + .vram__gb { + color: var(--docs-text-strong); + font-size: var(--sl-text-sm); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + .vram__models { + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + line-height: 1.4; + overflow-wrap: anywhere; + } + + .req__note { + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + } + + .req__links { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; + padding-top: 0.875rem; + border-top: 1px solid var(--docs-border-subtle); + font-size: var(--sl-text-sm); + + a { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-weight: 500; + } + + :global(svg) { + width: 0.875rem; + height: 0.875rem; + } + } + + /* Sections */ + + .section-title { + margin: 0 0 1rem; + color: var(--docs-text-strong); + font-size: var(--sl-text-h3); + font-weight: 600; + letter-spacing: -0.01em; + } + + .steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; + + li { + display: flex; + margin: 0; + } + } + + .step, + .way-card { + display: flex; + flex: 1; + flex-direction: column; + } + + .step__text { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .step__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + } + + .step__number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border: 1px solid var(--docs-border); + border-radius: 999px; + color: var(--docs-text-muted); + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-xs); + } + + /* Other ways to run it: radio chips pick a panel; without `:has()` the chips hide and every panel shows. */ + .ways__picker { + display: none; + min-width: 0; + margin: 0 0 1.25rem; + padding: 0; + border: 0; + + .docs-segment-tabs { + flex-wrap: wrap; + } + } + + .ways__panel + .ways__panel { + margin-top: 2rem; + } + + @supports selector(:has(*)) { + .ways__picker { + display: block; + } + + .ways__panel { + display: none; + } + + .ways__panel + .ways__panel { + margin-top: 0; + } + + .ways:has(input[value='launcher']:checked) [data-way='launcher'], + .ways:has(input[value='manual']:checked) [data-way='manual'], + .ways:has(input[value='docker']:checked) [data-way='docker'], + .ways:has(input[value='cloud']:checked) [data-way='cloud'] { + display: block; + } + } + + .ways__heading { + margin: 0; + color: var(--docs-text-strong); + font-size: var(--sl-text-base); + font-weight: 600; + } + + .ways__lede { + margin: 0.25rem 0 1rem; + color: var(--docs-text-subtle); + font-size: var(--sl-text-sm); + } + + .ways__cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); + gap: 0.75rem; + } + + .ways__cards--three { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + } + + .way-card :global(.docs-link-card__icon) { + margin-bottom: 0.5rem; + } + + .ways__code { + margin: 0 0 0.75rem; + padding: 0.75rem 1rem; + overflow-x: auto; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + color: var(--docs-text); + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-xs); + } + + /* Trust strip */ + + .trust { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem 1.75rem; + margin: 0; + padding: 1.25rem 0 0; + border-top: 1px solid var(--docs-border-subtle); + list-style: none; + + li, + a { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin: 0; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + } + + a:hover { + color: var(--docs-text-strong); + text-decoration: none; + } + + :global(svg) { + width: 0.875rem; + height: 0.875rem; + } + } + + @media (prefers-reduced-motion: reduce) { + .chip--link { + transition: none; + } + } +</style> diff --git a/docs/src/lib/components/Footer.astro b/docs/src/lib/components/Footer.astro index 65137dec3b9..5d487034aa5 100644 --- a/docs/src/lib/components/Footer.astro +++ b/docs/src/lib/components/Footer.astro @@ -1,28 +1,106 @@ --- -import PageFooter from '@astrojs/starlight/components/Footer.astro'; ---- +import { Icon } from '@astrojs/starlight/components'; +import Pagination from 'virtual:starlight/components/Pagination'; + +import { socialConfig } from '../../config/social'; +import { withBase } from '../base-path'; +import PageTools from './PageTools.astro'; +import Trans from './Trans.astro'; -<PageFooter {...Astro.props} /> +const { entry, toc } = Astro.locals.starlightRoute; +const { t } = Astro.locals; +// Splash pages (home, download) are not documentation pages, so they get no page tools. +const showPageTools = entry.data.template !== 'splash'; +--- -<div class="built-by"> - This site was designed and developed by <a - target="_blank" - rel="noreferrer noopener" - href="https://aetherfox.studio">Aether Fox Studio</a - >. -</div> +<footer class="docs-footer"> + { + showPageTools && ( + // The right rail shows the page tools on wide screens. + <div class:list={['tools', { 'lg:sl-hidden': Boolean(toc) }]}> + <PageTools instance="footer" /> + </div> + ) + } + <Pagination /> + <div class="bottom"> + <p> + <Trans message={t('footer.credit')}> + {(text: string) => <a href="https://aetherfox.studio" target="_blank" rel="noreferrer noopener">{text}</a>} + </Trans> + </p> + <ul class="social"> + <li> + <a href={withBase('/showcase/', import.meta.env.BASE_URL)}> + <Icon name="star" size="16px" /> + {t('nav.showcase')} + </a> + </li> + { + (socialConfig ?? []).map(({ label, href, icon }) => ( + <li> + <a href={href} rel="me"> + <Icon name={icon} size="16px" /> + {label} + </a> + </li> + )) + } + </ul> + </div> +</footer> <style> - /*footer { + /* At the bottom of a short page (see the full-height layout in custom.css), at least 3rem below the content. */ + .docs-footer { + display: flex; + flex-direction: column; + gap: 1.5rem; margin-top: auto; - }*/ + padding-top: 3rem; + } + + .tools { + max-width: 22rem; + } - .built-by { - text-align: center; + .bottom { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1.5rem; + padding-top: 1.25rem; + border-top: 1px solid var(--docs-border-subtle); + color: var(--docs-text-subtle); font-size: var(--sl-text-2xs); + } + + p { + margin: 0; + } + + a { + color: var(--docs-text-muted); + text-decoration: none; + + &:hover { + color: var(--docs-accent-text); + } + } + + .social { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; a { - color: #edc69c; + display: flex; + align-items: center; + gap: 0.375rem; } } </style> diff --git a/docs/src/lib/components/Header.astro b/docs/src/lib/components/Header.astro new file mode 100644 index 00000000000..7a1b6a69c3e --- /dev/null +++ b/docs/src/lib/components/Header.astro @@ -0,0 +1,160 @@ +--- +import { Icon } from '@astrojs/starlight/components'; +import Download from '@lucide/astro/icons/download'; +import Search from 'virtual:starlight/components/Search'; + +import { socialConfig } from '../../config/social'; +import { localizedHref, withBase } from '../base-path'; +import { isLocalizablePage } from '../docs-page'; +import LanguageMenu from './LanguageMenu.astro'; +import MainNav from './MainNav.astro'; +import VersionMenu from './VersionMenu.astro'; + +const base = import.meta.env.BASE_URL; +// Without a sidebar drawer, the main navigation stays in the header on phones. +const { entry, hasSidebar, locale } = Astro.locals.starlightRoute; +const { t } = Astro.locals; +// Matches the language menu's own condition (`LanguageMenu.astro`). +const hasLanguageMenu = await isLocalizablePage(entry.id); +// YouTube is in the footer. +const headerSocial = (socialConfig ?? []).filter(({ icon }) => icon === 'github' || icon === 'discord'); +--- + +<div + class:list={[ + 'docs-header', + { 'docs-header--nav-on-phones': !hasSidebar, 'docs-header--has-language-menu': hasLanguageMenu }, + ]} +> + <a href={localizedHref('/', { locale, base })} class="home" aria-label={t('nav.home')}> + <svg class="logomark" width="22" height="22" viewBox="0 0 44 44" fill="none" aria-hidden="true"> + <path d="M29.1951 10.6667H42V2H2V10.6667H14.8049L29.1951 33.3333H42V42H2V33.3333H14.8049" stroke-width="3.6" + ></path> + </svg> + <span class="wordmark">Invoke</span> + </a> + + <MainNav placement="header" /> + + <div class="search print:hidden"> + <Search /> + </div> + + <div class="actions print:hidden"> + <LanguageMenu placement="header" /> + <div class="version"> + <VersionMenu /> + </div> + { + headerSocial.map(({ label, href, icon }) => ( + <a class="social docs-button docs-button--ghost docs-button--icon docs-button--lg" href={href} aria-label={label} title={label}> + <Icon name={icon} size="18px" /> + </a> + )) + } + <a class="download docs-button docs-button--primary docs-button--lg" href={withBase('/download/', base)}> + <Download size={14} /> + <span class="download__label">{t('nav.download')}</span> + </a> + </div> +</div> + +<style> + .docs-header { + display: flex; + align-items: center; + gap: 0.5rem; + height: 100%; + font-size: var(--sl-text-xs); + } + + .home { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 0.625rem; + color: var(--docs-text-strong); + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: -0.01em; + text-decoration: none; + } + + .logomark path { + stroke: var(--docs-brand); + } + + .search { + display: flex; + flex: 1 1 auto; + justify-content: flex-end; + min-width: 0; + + /* Starlight's `print:hidden` loses to this rule's display, so hide it in print here. */ + @media print { + display: none; + } + } + + .actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 0.375rem; + + @media print { + display: none; + } + } + + /* The social links show from `md` up; on phones they are in the sidebar's footer. */ + .social { + display: none; + } + + .version { + display: contents; + } + + /* + * Phones: the logomark, search, version and an icon-only download link fit beside the menu button, whose drawer + * holds the language menu. Pages without one keep the main navigation in the header, which needs the wordmark's + * room, and the version menu's too where an icon-only language menu shows. + */ + @media (max-width: 29.99rem) { + .docs-header--nav-on-phones .wordmark, + .docs-header--nav-on-phones.docs-header--has-language-menu .version { + display: none; + } + + /* An icon-only button here. */ + .download { + width: var(--docs-button-height); + padding: 0; + } + .download__label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + } + + @media (min-width: 50rem) { + .docs-header { + gap: 0.75rem; + } + + /* Centre the search field between the logo and the actions. */ + .search { + flex: 0 1 26.25rem; + margin-inline: auto; + } + + .social { + display: inline-flex; + } + } +</style> diff --git a/docs/src/lib/components/HomePage.astro b/docs/src/lib/components/HomePage.astro new file mode 100644 index 00000000000..dbee0d5eaf9 --- /dev/null +++ b/docs/src/lib/components/HomePage.astro @@ -0,0 +1,1176 @@ +--- +// The marketing home page below Starlight's hero, which renders the H1 and the tagline from the frontmatter of +// `src/content/docs/index.mdx`, and the announcement pill (`src/lib/home-announcement.mjs`). The mock app UI in the +// tour demos (`tour/`) and the What's new motifs (`whats-new/`) stays English on purpose: node, field and board names, +// buttons and the gallery's own labels mirror the English app, and their widths are tuned to that text. What speaks to +// the reader about a demo, such as its region label, keyboard hint and announcements, is a UI string. +import ArrowRight from '@lucide/astro/icons/arrow-right'; +import Download from '@lucide/astro/icons/download'; +import { Image } from 'astro:assets'; +import { getCollection } from 'astro:content'; + +import { getSectionIcon, getTopicIcon } from '../../config/icons'; +import { docsTopics, topicDescription, topicLabel } from '../../config/topics'; +import { withBase } from '../base-path'; +import { sortChallenges } from '../challenges.mjs'; +import { sortShowcase } from '../showcase.mjs'; +import ChallengeStrip from './ChallengeStrip.astro'; +import ModelFamilies from './ModelFamilies.astro'; +import ShowcaseMarquee from './ShowcaseMarquee.astro'; +import CompareImage from './compare/CompareImage.astro'; +import ImageCompare from './compare/ImageCompare.astro'; +import GalleryDemo from './tour/GalleryDemo.astro'; +import WorkflowDemo from './tour/WorkflowDemo.astro'; +import Trans from './Trans.astro'; +import WhatsNewBento from './WhatsNewBento.astro'; +// A copy of the app's `invokeai/frontend/webv2/src/assets/V7Logo.webp`; `src/lib/app-assets.test.mjs` keeps them equal. +import sushiAfter from '../../assets/tour/canvas/sushi-after.webp'; +import sushiBefore from '../../assets/tour/canvas/sushi-before.webp'; +import v7Logo from '../../assets/v7-logo.webp'; +import blacksmithLogo from '../../content/docs/assets/blacksmith-logo.png'; +import workbenchImage from '../../content/docs/Users Guide/assets/canvas-edit-layout.webp'; + +const base = import.meta.env.BASE_URL; +const href = (path: string) => withBase(path, base); +const { t } = Astro.locals; +// The tour's slider spans the wide home page column. +const compareSizes = '(min-width: 72rem) 66rem, calc(100vw - 2rem)'; + +const topic = (id: string) => { + const found = docsTopics.find((candidate) => candidate.id === id); + if (!found) throw new Error(`The home page links to the unknown docs topic "${id}".`); + return { + label: topicLabel(t, found), + description: topicDescription(t, found), + link: found.link, + Icon: getTopicIcon(found), + }; +}; + +/** A directory row for a page, with the icon of the sidebar section it belongs to. */ +const page = (label: string, description: string, link: string) => { + const Icon = getSectionIcon(link); + if (!Icon) throw new Error(`The home page directory links to ${link}, which belongs to no docs topic.`); + return { label, description, link, Icon }; +}; + +const paths = [ + { + kicker: t('home.directory.create.kicker'), + title: t('home.directory.create.title'), + links: [topic('get-started'), topic('guide'), topic('models'), topic('troubleshooting'), topic('releases')], + }, + { + kicker: t('home.directory.run.kicker'), + title: t('home.directory.run.title'), + links: [ + topic('configure'), + page(t('home.directory.docker.label'), t('home.directory.docker.description'), '/configuration/docker/'), + page( + t('home.directory.multiUser.label'), + t('home.directory.multiUser.description'), + '/configuration/multi-user-mode/admin-guide/' + ), + page( + t('home.directory.lowVram.label'), + t('home.directory.lowVram.description'), + '/configuration/optimization/low-vram-mode/' + ), + ], + }, + { + kicker: t('home.directory.build.kicker'), + title: t('home.directory.build.title'), + links: [ + topic('developers'), + topic('contribute'), + page( + t('home.directory.creatingNodes.label'), + t('home.directory.creatingNodes.description'), + '/development/guides/creating-nodes/' + ), + page( + t('home.directory.nodePacks.label'), + t('home.directory.nodePacks.description'), + '/development/guides/creating-node-pack/' + ), + ], + }, +]; + +// The header has no topic links, so this directory is how the home page leads into every topic. +const listedTopics = new Set(paths.flatMap(({ links }) => links.map(({ link }) => link))); +const unlistedTopics = docsTopics.filter(({ link }) => !listedTopics.has(link)).map(({ label }) => label); +if (unlistedTopics.length > 0) { + throw new Error(`The home page directory does not link to these docs topics: ${unlistedTopics.join(', ')}.`); +} + +/* + * The hero glow, as data for the styles below. Lengths are multiples of `--glow-unit`, which is 1px on wide screens and + * shrinks on narrow ones, so the glow keeps its proportions to the frame. + */ +const px = (value: number) => `calc(${+value.toFixed(1)} * var(--glow-unit))`; +const alpha = (value: number) => `rgb(0 0 0 / ${value.toFixed(3)})`; +const steps = (count: number) => Array.from({ length: count + 1 }, (_, i) => i / count); + +/** + * How light from the frame's edge fades with distance: a soft shoulder (a Gaussian, so the edge has no sharp, neon-like + * peak) over a long exponential tail, together windowed so they reach zero, with a flat slope, at `reach`. + */ +type Falloff = { reach: number; near: number; far: number; nearShare: number }; +const falloff = + ({ reach, near, far, nearShare }: Falloff) => + (distance: number) => + (1 - (distance / reach) ** 2) ** 2 * + (nearShare * Math.exp(-((distance / near) ** 2)) + (1 - nearShare) * Math.exp(-distance / far)); + +/** + * A glow's shape: the frame blurred outwards, as a vertical and a horizontal gradient whose product is the mask. The + * top edge has full strength. Down the frame the light weakens towards `bottomLevel`, and across the outer quarters of + * the width it eases to `sideLevel`, so the sides are dimmer than the top without a seam at the corners. + */ +const glowShape = (top: Falloff, side: Falloff, bottom: Falloff, sideLevel: number, bottomLevel: number) => { + const [topAt, sideAt, bottomAt] = [falloff(top), falloff(side), falloff(bottom)]; + const down = (f: number) => bottomLevel + (1 - bottomLevel) * (1 - f) ** 2; + const across = (f: number) => sideLevel + (1 - sideLevel) * (3 * f ** 2 - 2 * f ** 3); + // Stops crowd towards the frame, where the light changes fastest. + const fade = steps(16).map((f) => f ** 1.6); + const frameTop = (f: number) => `calc(${px(top.reach)} + (100% - ${px(top.reach + bottom.reach)}) * ${f.toFixed(3)})`; + const frameLeft = (f: number) => `calc(${px(side.reach)} + (100% - ${px(2 * side.reach)}) * ${f.toFixed(3)})`; + const vertical = [ + ...fade.map((f) => `${alpha(topAt(top.reach * (1 - f)))} ${px(top.reach * f)}`), + ...steps(8) + .slice(1, -1) + .map((f) => `${alpha(down(f))} ${frameTop(f)}`), + ...fade + .map((f) => `${alpha(bottomLevel * bottomAt(bottom.reach * f))} calc(100% - ${px(bottom.reach * (1 - f))})`) + .reverse(), + ]; + const left = [ + ...fade.map((f) => [alpha(sideLevel * sideAt(side.reach * (1 - f))), px(side.reach * f)]), + ...steps(6) + .slice(1) + .map((f) => [alpha(across(f)), frameLeft(f / 4)]), + ]; + const horizontal = [ + ...left.map(([a, at]) => `${a} ${at}`), + ...left.map(([a, at]) => `${a} calc(100% - ${at})`).reverse(), + ]; + return [ + `--glow-mask: linear-gradient(to bottom, ${vertical.join(', ')}), + linear-gradient(to right, ${horizontal.join(', ')})`, + `--glow-inset: ${px(-top.reach)} ${px(-side.reach)} ${px(-bottom.reach)}`, + ].join('; '); +}; + +// The light near the frame, where the moving bands show, and a wide, dim, still halo around it. +const glowNear = glowShape( + { reach: 110, near: 18, far: 40, nearShare: 0.6 }, + { reach: 80, near: 13, far: 28, nearShare: 0.6 }, + { reach: 60, near: 10, far: 20, nearShare: 0.6 }, + 0.6, + 0.2 +); +const glowHalo = glowShape( + { reach: 240, near: 60, far: 90, nearShare: 0.5 }, + { reach: 170, near: 45, far: 60, nearShare: 0.5 }, + { reach: 120, near: 32, far: 45, nearShare: 0.5 }, + 0.7, + 0.3 +); + +/** + * The moving light seen through the near mask: soft, slanted bands of light with dark gaps between them, like the light + * around a door as people pass in front of it. A band layer repeats every `period` along its gradient and slides + * sideways by exactly one repeat per `drift` cycle, while its opacity swells between `low` and 1 on its own beat. Its + * brightness follows cosines with whole-number frequencies, so the repeat is seamless and no band has an edge; raising + * it to a power narrows the bright bands and widens the gaps. The layers slant both ways and slide in both directions + * at different speeds, so bright patches drift along the top and up and down the sides, and the cycles share no + * common beat. + */ +type BandLayer = { + period: number; + angle: number; + harmonics: [amplitude: number, frequency: number, phase: number][]; + peak: number; + drift: number; + reverse?: boolean; + swell: number; + low: number; +}; +const bandLayer = ({ period, angle, harmonics, peak, drift, reverse, swell, low }: BandLayer) => { + const wave = (t: number) => + harmonics.reduce((sum, [size, f, phase]) => sum + size * Math.cos(2 * Math.PI * (f * t + phase)), 0); + const samples = steps(40).map(wave); + const [min, max] = [Math.min(...samples), Math.max(...samples)]; + const stops = samples.map((value, i) => { + const level = peak * ((value - min) / (max - min)) ** 3; + return `color-mix(in oklab, var(--docs-brand) ${(level * 100).toFixed(1)}%, transparent) ${px((period * i) / 40)}`; + }); + // Sliding sideways by this much moves the slanted pattern along its gradient by one period. + const shift = period / Math.sin((angle * Math.PI) / 180); + return [ + `--light: repeating-linear-gradient(${angle}deg, ${stops.join(', ')})`, + `--shift: ${px(shift)}`, + `--drift: ${drift}s; --drift-direction: ${reverse ? 'reverse' : 'normal'}`, + `--swell: ${swell}s; --swell-low: ${low}`, + ].join('; '); +}; +const glowBands = ( + [ + { + period: 980, + angle: 118, + harmonics: [[1, 1, 0], [0.6, 2, 0.31], [0.35, 3, 0.62]], + peak: 0.42, + drift: 47, + swell: 9.7, + low: 0.45, + }, + { + period: 1620, + angle: 63, + harmonics: [[1, 1, 0.18], [0.7, 2, 0.77], [0.3, 4, 0.4]], + peak: 0.4, + drift: 61, + reverse: true, + swell: 13.1, + low: 0.4, + }, + { + period: 610, + angle: 97, + harmonics: [[1, 1, 0.5], [0.5, 2, 0.05], [0.25, 3, 0.9]], + peak: 0.34, + drift: 23, + reverse: true, + swell: 6.9, + low: 0.3, + }, + ] satisfies BandLayer[] +).map(bandLayer); + +const showcase = sortShowcase(await getCollection('showcase')).slice(0, 12); +const [challenge] = sortChallenges(await getCollection('challenges')); + +const community = [ + { label: t('home.community.showcaseLink'), link: href('/showcase/') }, + { label: t('home.community.discordLink'), link: 'https://discord.gg/ZmtBAhwWhy' }, + { label: t('home.community.githubLink'), link: 'https://github.com/invoke-ai/InvokeAI' }, + { label: t('home.community.sponsorLink'), link: 'https://github.com/sponsors/invoke-ai' }, + { label: t('home.community.trainingLink'), link: 'https://github.com/invoke-ai/invoke-training' }, +]; + +/** The pages the install paragraph links, by the tag around each link's text in `home.install.body`. */ +const installLinks: Record<string, string> = { + requirements: '/start-here/system-requirements/', + manual: '/start-here/manual/', + docker: '/configuration/docker/', +}; + +--- + +<div class="home not-content"> + <div class="intro"> + <div class="cta"> + <div class="cta__buttons"> + <a class="docs-button docs-button--primary docs-button--xl" href={href('/download/')}> + <Download aria-hidden="true" /> + {t('home.cta.download')} + </a> + <a class="docs-button docs-button--secondary docs-button--xl" href={href(topic('get-started').link)}> + {t('home.cta.readDocs')} + <ArrowRight class="nudge" aria-hidden="true" /> + </a> + </div> + <p class="cta__platforms">{t('home.cta.platformsAndLicense')}</p> + </div> + + <figure class="hero-shot" data-hero-shot> + <span class="hero-glow hero-glow--halo" style={glowHalo} aria-hidden="true"></span> + <span class="hero-glow hero-glow--near" style={glowNear} aria-hidden="true"> + {glowBands.map((style) => <span class="hero-glow__light" style={style} />)} + </span> + <div class="frame frame--hero"> + <Image + src={workbenchImage} + alt={t('home.hero.screenshotAlt')} + widths={[640, 960, 1280, 1600, 2000, 2560, workbenchImage.width]} + sizes="(min-width: 50rem) min(calc(100vw - 3rem), 90rem), calc(100vw - 2rem)" + loading="eager" + fetchpriority="high" + /> + </div> + </figure> + </div> + + <section class="tour" aria-label={t('home.tour.label')}> + <div class="tour__item"> + <div class="tour__intro"> + <div class="heading"> + <p class="eyebrow">{t('home.tour.canvas.eyebrow')}</p> + <h2>{t('home.tour.canvas.title')}</h2> + </div> + <div class="tour__text"> + <p>{t('home.tour.canvas.body')}</p> + <a class="more" href={href('/users-guide/canvas/introduction/')}> + {t('home.tour.canvas.link')} + <ArrowRight class="nudge" size={14} aria-hidden="true" /> + </a> + </div> + </div> + {/* + One inpainting edit: the third roll was masked and regenerated as a salmon roe roll. Both images are crops of + `Users Guide/assets/canvas-inpaint-result.webp` at the same size and offset; the inpaint mask's hatch lines were + removed from the before crop by interpolating each from its unmasked neighbours, leaving the original roll. + */} + <ImageCompare label={t('home.tour.canvas.compare.label')}> + <CompareImage + slot="before" + src={sushiBefore} + alt={t('home.tour.canvas.compare.beforeAlt')} + label={t('home.tour.canvas.compare.before')} + sizes={compareSizes} + /> + <CompareImage + slot="after" + src={sushiAfter} + alt={t('home.tour.canvas.compare.afterAlt')} + label={t('home.tour.canvas.compare.after')} + sizes={compareSizes} + /> + </ImageCompare> + </div> + + <!-- The workflow demo's nodes can be dragged anywhere in this row, over its copy too. --> + <div class="tour__item tour__item--split tour__item--workflow" data-workflow-bounds> + <div class="tour__text"> + <div class="heading"> + <p class="eyebrow">{t('home.tour.workflows.eyebrow')}</p> + <h2>{t('home.tour.workflows.title')}</h2> + </div> + <p>{t('home.tour.workflows.body')}</p> + <a class="more" href={href('/users-guide/workflows/introduction/')}> + {t('home.tour.workflows.link')} + <ArrowRight class="nudge" size={14} aria-hidden="true" /> + </a> + </div> + <WorkflowDemo /> + </div> + + <div class="tour__item tour__item--split tour__item--reverse tour__item--gallery"> + <div class="tour__text"> + <div class="heading"> + <p class="eyebrow">{t('home.tour.gallery.eyebrow')}</p> + <h2>{t('home.tour.gallery.title')}</h2> + </div> + <p>{t('home.tour.gallery.body')}</p> + <a class="more" href={href('/users-guide/gallery/introduction/')}> + {t('home.tour.gallery.link')} + <ArrowRight class="nudge" size={14} aria-hidden="true" /> + </a> + </div> + <GalleryDemo class="gallery-demo-slot" /> + </div> + </section> + + { + (showcase.length > 0 || challenge) && ( + <section class="section" aria-labelledby="home-showcase"> + <div class="section__top"> + <div class="heading"> + <p class="eyebrow">{t('home.showcase.eyebrow')}</p> + <h2 id="home-showcase">{t('home.showcase.title')}</h2> + </div> + <a class="more" href={href('/showcase/')}> + {t('home.showcase.link')} + <ArrowRight class="nudge" size={14} aria-hidden="true" /> + </a> + </div> + {showcase.length > 0 && <ShowcaseMarquee entries={showcase} />} + {challenge && <ChallengeStrip challenge={challenge} />} + </section> + ) + } + + <section class="section" aria-labelledby="home-new"> + <WhatsNewBento> + <div class="section__header"> + <Image class="logo" src={v7Logo} alt="" widths={[96, 128, 256]} sizes="(min-width: 50rem) 7rem, 5rem" /> + <div class="heading"> + <p class="eyebrow">{t('home.whatsNew.eyebrow')}</p> + <h2 id="home-new">{t('home.whatsNew.title')}</h2> + </div> + </div> + </WhatsNewBento> + </section> + + <section class="section" aria-labelledby="home-models"> + <ModelFamilies> + <div class="section__top"> + <div class="heading"> + <p class="eyebrow">{t('home.models.eyebrow')}</p> + <h2 id="home-models">{t('home.models.title')}</h2> + <p class="section__intro">{t('home.models.intro')}</p> + </div> + <a class="more" href={href('/users-guide/models/model-families/')}> + {t('home.models.link')} + <ArrowRight class="nudge" size={14} aria-hidden="true" /> + </a> + </div> + </ModelFamilies> + </section> + + <section class="section" aria-labelledby="home-directory"> + <div class="heading"> + <p class="eyebrow">{t('home.directory.eyebrow')}</p> + <h2 id="home-directory">{t('home.directory.title')}</h2> + </div> + <div class="paths"> + { + paths.map(({ kicker, title, links }) => ( + <div class="card path"> + <div class="path__heading"> + <p class="label">{kicker}</p> + <h3>{title}</h3> + </div> + <ul class="path__links" role="list"> + {links.map(({ label, description, link, Icon }) => ( + <li> + <a href={href(link)}> + <span class="path__icon"> + <Icon size={16} aria-hidden="true" /> + </span> + <span class="path__label">{label}</span> + <span class="path__description">{description}</span> + <ArrowRight class="path__arrow nudge" size={16} aria-hidden="true" /> + </a> + </li> + ))} + </ul> + </div> + )) + } + </div> + </section> + + <section class="card community" aria-labelledby="home-community"> + <div class="community__text"> + <div class="heading"> + <p class="eyebrow">{t('home.community.eyebrow')}</p> + <h2 id="home-community">{t('home.community.title')}</h2> + </div> + <p> + <Trans message={t('home.community.body')}> + {(text: string) => <a href={href('/contributing/contributors/')}>{text}</a>} + </Trans> + </p> + <a class="sponsor" href="https://www.blacksmith.sh/"> + <Image src={blacksmithLogo} alt={t('home.community.sponsorAlt')} width={240} densities={[1, 2]} /> + </a> + </div> + <ul class="community__links" role="list"> + { + community.map(({ label, link }) => ( + <li> + <a href={link}> + {label} + <ArrowRight class="nudge" size={16} aria-hidden="true" /> + </a> + </li> + )) + } + </ul> + </section> + + <section class="card install" aria-labelledby="home-install"> + <div class="install__text"> + <h2 id="home-install">{t('home.install.title')}</h2> + <p> + <Trans message={t('home.install.body')}> + {(text: string, tag: string) => (installLinks[tag] ? <a href={href(installLinks[tag])}>{text}</a> : text)} + </Trans> + </p> + </div> + <div class="install__buttons"> + <a class="docs-button docs-button--primary docs-button--lg" href={href('/download/')}> + {t('home.install.downloadLauncher')} + </a> + <a class="docs-button docs-button--secondary docs-button--lg" href={href('/start-here/installation/')}> + {t('home.install.guide')} + </a> + </div> + </section> +</div> + +<style> + /* + * Starlight's hero lays out an image beside the copy. Here its image slot holds the beta pill, so stack and centre + * everything; the spacing below the hero belongs to this component. + */ + :global(.hero:has(.home-announcement)) { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + padding-bottom: 0; + text-align: center; + + > :global(.hero-html) { + order: 0; + width: auto; + max-width: 100%; + margin: 0; + } + + :global(.stack) { + align-items: center; + gap: 1.25rem; + text-align: center; + } + + :global(.copy) { + align-items: center; + gap: 1.25rem; + } + + :global(.tagline) { + max-width: 40rem; + font-size: clamp(1rem, 0.9rem + 0.5vw, 1.125rem); + line-height: 1.6; + } + } + + :global(.home-announcement) { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.25rem 0.5rem; + padding: 0.25rem 0.75rem; + border: 1px solid color-mix(in oklab, var(--docs-accent) 35%, transparent); + border-radius: 1rem; + background-color: var(--docs-accent-tint); + color: var(--docs-accent-text); + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.5; + text-decoration: none; + + &:hover { + border-color: color-mix(in oklab, var(--docs-accent) 60%, transparent); + background-color: color-mix(in oklab, var(--docs-accent) 22%, transparent); + color: var(--docs-text-strong); + } + + > :global(span) { + display: inline-flex; + align-items: center; + gap: 0.5rem; + } + } + + /* Narrow screens put the call to action on a line of its own, without a separator to start it. */ + @media (max-width: 23.99rem) { + :global(.home-announcement__separator) { + display: none; + } + } + + @media (prefers-reduced-motion: no-preference) { + :global(.home-announcement__arrow) { + transition: translate 150ms ease; + } + + :global(.home-announcement:is(:hover, :focus-visible) .home-announcement__arrow) { + translate: 2px 0; + } + } + + :global(.home-announcement__dot) { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background-color: var(--docs-accent); + } + + .home { + display: flex; + flex-direction: column; + gap: clamp(4.5rem, 2.5rem + 6vw, 7rem); + padding-bottom: clamp(1rem, 4vw, 3rem); + color: var(--docs-text-strong); + font-size: 1rem; + line-height: 1.6; + } + + p { + margin: 0; + } + + h2, + h3 { + margin: 0; + color: var(--docs-text-strong); + } + + h2 { + font-size: clamp(1.75rem, 1.35rem + 1.6vw, 2.25rem); + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.15; + } + + .heading { + display: flex; + flex-direction: column; + gap: 0.625rem; + max-width: 40rem; + } + + .eyebrow { + color: var(--docs-accent); + font-size: 0.8125rem; + font-weight: 600; + } + + .label { + color: var(--docs-text-subtle); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1.4; + text-transform: uppercase; + } + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + /* Buttons take their colors from the shared button styles in `custom.css`. */ + a:not(.docs-button) { + color: var(--docs-accent); + text-decoration: none; + + &:hover { + color: var(--docs-accent-text); + } + } + + p a { + text-decoration: underline; + text-decoration-color: color-mix(in oklab, var(--docs-accent) 40%, transparent); + text-underline-offset: 0.2em; + } + + .more { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + } + + /* Arrows in links move a little towards their destination on hover and focus. */ + @media (prefers-reduced-motion: no-preference) { + .nudge { + transition: translate 150ms ease; + } + + a:is(:hover, :focus-visible) > .nudge { + translate: 2px 0; + } + } + + .card { + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + } + + /* Hero actions and screenshot */ + + .intro { + display: flex; + flex-direction: column; + gap: clamp(2.5rem, 2rem + 2vw, 3.5rem); + } + + .cta { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.25rem; + } + + .cta__buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + } + + .cta__platforms { + color: var(--docs-text-subtle); + font-size: 0.8125rem; + text-align: center; + } + + /* The screenshot sits inset by the padding and the border, with corners that nest inside the frame's. */ + .frame { + --frame-inset: 0.375rem; + --frame-border: 1px; + margin: 0; + padding: var(--frame-inset); + border: var(--frame-border) solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + + img { + display: block; + width: 100%; + height: auto; + border-radius: calc(var(--docs-radius-container) - var(--frame-inset) - var(--frame-border)); + } + } + + /* + * The workbench screenshot is wider than the splash column: up to 90rem, centred, keeping the page's gutters. Its + * edges stay inside the viewport, so it never adds horizontal scrolling. + */ + .hero-shot { + --hero-width: min(100vw - 2 * var(--sl-content-pad-x), 90rem); + /* The glow's length unit: 1px on wide screens, down to 0.45px on phones. */ + --glow-unit: clamp(0.45px, 0.1px + 0.0625vw, 1px); + position: relative; + isolation: isolate; + width: var(--hero-width); + margin: 0 calc((100% - var(--hero-width)) / 2); + border-radius: var(--docs-radius-container); + } + + /* + * The glow reaches past the viewport's sides on most screens. The page clips it there, at the screen's own edge, + * instead of a box near the frame clipping it, and `clip` adds no scroll container. + */ + :global(main:has([data-hero-shot])) { + overflow-x: clip; + } + + /* + * Light from behind the frame, strongest along the top. Each layer's mask is its shape: the frame blurred outwards, + * fading to nothing well inside the layer's box. The near layer shows a faint steady light and slanted bands of light + * that slide past at different speeds and swell on their own beats, so bright patches wander around the frame. Only + * the bands' `transform` and `opacity` animate; the masks and gradients are painted once. + */ + .hero-glow { + position: absolute; + z-index: -1; + inset: var(--glow-inset); + overflow: clip; + pointer-events: none; + mask-image: var(--glow-mask); + mask-composite: intersect; + } + + /* The wide halo is still and lower in chroma, so the far glow reads as lit air rather than color. */ + .hero-glow--halo { + background-color: color-mix(in oklab, color-mix(in oklab, var(--docs-brand) 70%, var(--docs-bg)) 16%, transparent); + } + + .hero-glow--near { + background-color: color-mix(in oklab, var(--docs-brand) 3%, transparent); + } + + .hero-glow__light { + position: absolute; + inset: 0 auto 0 0; + width: calc(100% + var(--shift)); + background-image: var(--light); + animation: + hero-glow-drift var(--drift) linear infinite var(--drift-direction), + hero-glow-swell var(--swell) ease-in-out infinite alternate; + } + + @keyframes hero-glow-drift { + to { + transform: translateX(calc(-1 * var(--shift))); + } + } + + @keyframes hero-glow-swell { + from { + opacity: var(--swell-low); + } + to { + opacity: 1; + } + } + + /* The script marks the hero while it is off screen; without it the glow simply keeps moving. */ + .hero-shot[data-paused] .hero-glow__light { + animation-play-state: paused; + } + + /* Without motion, a steady, even light takes the bands' place. */ + @media (prefers-reduced-motion: reduce) { + .hero-glow--near { + background-color: color-mix(in oklab, var(--docs-brand) 30%, transparent); + } + + .hero-glow__light { + display: none; + } + } + + /* + * A brand ring right around the screenshot that fades out down the frame. The gradient fills the whole border box, so + * the image's antialiased corners blend into the ring, not a dark seam. It is twice the frame's height and drifts up + * and down, so the bright part slides along the sides. + */ + .frame--hero { + --frame-inset: 0px; + --frame-border: 2px; + border-color: transparent; + background: linear-gradient(var(--docs-brand), transparent 80%) border-box; + background-size: 100% 200%; + + /* Until the screenshot paints, the image box shows the surface instead of the gradient behind it. */ + img { + background-color: var(--docs-surface); + } + } + + @media (prefers-reduced-motion: no-preference) { + .frame--hero { + animation: hero-ring-drift 6s ease-in-out infinite; + } + } + + @keyframes hero-ring-drift { + 50% { + background-position: 0 100%; + } + } + + .hero-shot[data-paused] .frame--hero { + animation-play-state: paused; + } + + /* The gallery demo is framed like the app's panel and keeps that panel's width, centred in its column. */ + .gallery-demo-slot { + align-self: center; + justify-self: center; + } + + /* Product tour */ + + .tour { + display: flex; + flex-direction: column; + gap: clamp(4.5rem, 2.5rem + 6vw, 7rem); + } + + .tour__item { + display: flex; + flex-direction: column; + gap: 1.75rem; + } + + .tour__intro { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 1rem 3rem; + + > .heading { + flex: 1 1 22rem; + } + + > .tour__text { + flex: 1 1 20rem; + } + } + + .tour__text { + display: flex; + flex-direction: column; + gap: 0.875rem; + + > p { + color: var(--docs-text-muted); + } + } + + @media (min-width: 56rem) { + .tour__item--split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); + align-items: center; + gap: 4rem; + } + + /* The workflow graph is drawn 680px wide; it gets up to that, and the text keeps at least 18rem. */ + .tour__item--workflow { + grid-template-columns: minmax(18rem, 1fr) minmax(0, 680px); + } + + /* The text stays first in reading order and moves to the right. */ + .tour__item--reverse { + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + + > .tour__text { + grid-column: 2; + grid-row: 1; + } + } + + /* The gallery panel is 450px wide at most. */ + .tour__item--gallery { + grid-template-columns: minmax(0, 28.125rem) minmax(0, 1fr); + } + } + + /* What's new */ + + .section { + display: flex; + flex-direction: column; + gap: 2rem; + } + + /* The V7 logo leads the heading, as in the app's What's new dialog. */ + /* A heading with its link at the end of the row, or below it on narrow screens. */ + .section__top { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 1rem 2rem; + + > .more { + align-self: auto; + } + } + + .section__intro { + color: var(--docs-text-muted); + font-size: 0.9375rem; + } + + .section__header { + display: flex; + align-items: center; + gap: 1rem 1.5rem; + } + + .logo { + flex-shrink: 0; + width: 5rem; + height: auto; + } + + @media (min-width: 50rem) { + .logo { + width: 7rem; + } + } + + /* Directory */ + + .paths { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: 1rem; + } + + .path { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + + h3 { + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.3; + } + } + + .path__heading { + display: flex; + flex-direction: column; + gap: 0.375rem; + } + + .path__links { + border-top: 1px solid var(--docs-border); + + li { + border-bottom: 1px solid var(--docs-border-subtle); + } + + li:last-child { + border-bottom: 0; + } + + a { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + column-gap: 0.75rem; + padding-block: 0.75rem; + color: var(--docs-text-strong); + + &:hover { + color: var(--docs-accent-text); + + .path__icon, + .path__arrow { + color: var(--docs-accent); + } + } + } + } + + /* The topic's or section's icon, in a tile like the topic switcher's. */ + .path__icon { + display: flex; + grid-row: 1 / span 2; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--docs-radius); + background-color: var(--docs-control); + color: var(--docs-text-muted); + } + + .path__arrow { + grid-column: 3; + grid-row: 1 / span 2; + color: var(--sl-color-gray-4); + } + + .path__label { + grid-column: 2; + font-size: 0.875rem; + font-weight: 500; + } + + .path__description { + grid-column: 2; + color: var(--docs-text-subtle); + font-size: 0.8125rem; + } + + /* Community and install */ + + .community, + .install { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1.5rem 3rem; + padding: clamp(1.5rem, 1rem + 2vw, 2.5rem); + } + + .community__text { + display: flex; + flex: 1 1 24rem; + flex-direction: column; + gap: 1rem; + max-width: 40rem; + + > p { + color: var(--docs-text-muted); + font-size: 0.9375rem; + } + } + + .sponsor { + align-self: flex-start; + + img { + display: block; + max-width: 100%; + height: auto; + border-radius: var(--docs-radius); + } + } + + .community__links { + display: flex; + flex: 0 1 18rem; + flex-direction: column; + + a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-block: 0.625rem; + border-bottom: 1px solid var(--docs-border-subtle); + color: var(--docs-text-strong); + font-size: 0.875rem; + font-weight: 500; + + &:hover { + color: var(--docs-accent-text); + } + } + + li:last-child a { + border-bottom: 0; + } + + svg { + flex-shrink: 0; + color: var(--sl-color-gray-4); + } + } + + .install__text { + display: flex; + flex: 1 1 22rem; + flex-direction: column; + gap: 0.5rem; + max-width: 36rem; + + h2 { + font-size: clamp(1.5rem, 1.3rem + 0.8vw, 1.75rem); + } + + > p { + color: var(--docs-text-muted); + font-size: 0.9375rem; + } + } + + .install__buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } +</style> + +<script> + // The hero glow pauses while the hero and the glow around it are off screen. It runs unless marked, so a late report + // (as in a background tab) never leaves it still. Each visit starts the light at a random point in its cycles. + const heroShot = document.querySelector<HTMLElement>('[data-hero-shot]'); + if (heroShot) { + new IntersectionObserver(([entry]) => heroShot.toggleAttribute('data-paused', !entry.isIntersecting), { + rootMargin: '120px 0px', + }).observe(heroShot); + for (const animation of heroShot.getAnimations({ subtree: true })) { + const duration = Number(animation.effect?.getTiming().duration); + if (duration > 0) animation.currentTime = Math.random() * duration; + } + } +</script> diff --git a/docs/src/lib/components/LanguageMenu.astro b/docs/src/lib/components/LanguageMenu.astro new file mode 100644 index 00000000000..fe8f87b08b4 --- /dev/null +++ b/docs/src/lib/components/LanguageMenu.astro @@ -0,0 +1,68 @@ +--- +import Languages from '@lucide/astro/icons/languages'; + +import { locales, splitSitePath } from '../../config/locales.mjs'; +import { localizedHref } from '../base-path'; +import { isLocalizablePage } from '../docs-page'; +import LinkMenu from './LinkMenu.astro'; + +type Props = { + /** `drawer`, the default, is the copy Starlight renders as its `LanguageSelect` in the mobile drawer's footer. */ + placement?: 'header' | 'drawer'; +}; + +const { placement = 'drawer' } = Astro.props; +const { entry, hasSidebar, locale } = Astro.locals.starlightRoute; +const base = import.meta.env.BASE_URL; + +// Every docs page exists in every locale, translated or not, so each language links to the current page. +const isShown = await isLocalizablePage(entry.id); +const path = splitSitePath(Astro.url.pathname, base)?.path ?? '/'; +const current = locales[(locale ?? 'root') as keyof typeof locales]; +const code = current.lang.toUpperCase(); +const items = Object.entries(locales).map(([key, { label, lang }]) => { + const itemLocale = key === 'root' ? undefined : key; + return { label, lang, href: localizedHref(path, { locale: itemLocale, base }), isCurrent: itemLocale === locale }; +}); +--- + +{ + isShown && ( + <div + class:list={[ + 'language-menu', + // Pages with a sidebar show the drawer's copy on narrow screens. + { 'language-menu--in-drawer-when-narrow': placement === 'header' && hasSidebar }, + ]} + > + <LinkMenu + id={`docs-language-menu-${placement}`} + label={code} + accessibleLabel={Astro.locals.t('languageMenu.accessibleLabel', { + label: Astro.locals.t('languageSelect.accessibleLabel'), + code, + language: current.label, + interpolation: { escapeValue: false }, + })} + items={items} + direction={placement === 'drawer' ? 'up' : 'down'} + iconOnlyWhenNarrow={placement === 'header'} + > + <Languages slot="icon" size={14} /> + </LinkMenu> + </div> + ) +} + +<style> + .language-menu { + display: flex; + align-items: center; + } + + @media (max-width: 49.99rem) { + .language-menu--in-drawer-when-narrow { + display: none; + } + } +</style> diff --git a/docs/src/lib/components/LauncherCards.astro b/docs/src/lib/components/LauncherCards.astro new file mode 100644 index 00000000000..a5a02667398 --- /dev/null +++ b/docs/src/lib/components/LauncherCards.astro @@ -0,0 +1,119 @@ +--- +// The download page's three launcher downloads as equal cards: its no-JS and unknown-platform hero, and the +// Launcher tab of "Other ways to run it". +import { Icon } from '@astrojs/starlight/components'; +import type { StarlightIcon } from '@astrojs/starlight/types'; +import Download from '@lucide/astro/icons/download'; + +export type LauncherPlatform = { + id: string; + name: string; + /** Shown after the name, such as "Apple Silicon". */ + qualifier?: string; + icon: StarlightIcon; + /** The file type on the button, such as "EXE". */ + file: string; + note: string; + /** File size, version and date. */ + meta: string; + href: string; +}; + +interface Props { + platforms: LauncherPlatform[]; + /** The card titles' heading level in the page outline. */ + level: 2 | 3 | 4; +} + +const { platforms, level } = Astro.props; +const Heading = `h${level}` as const; +--- + +<ul class="launcher-cards"> + { + platforms.map((platform) => ( + <li class="launcher-card"> + <Icon name={platform.icon} size="1.5rem" class="launcher-card__icon" /> + <Heading class="launcher-card__title"> + {platform.name} + {platform.qualifier && <span class="launcher-card__qualifier">{platform.qualifier}</span>} + </Heading> + <p class="launcher-card__note">{platform.note}</p> + <p class="launcher-card__meta">{platform.meta}</p> + <a + class="docs-button docs-button--secondary launcher-card__button" + href={platform.href} + aria-label={`Download ${platform.file} for ${platform.name}`} + > + <Download aria-hidden="true" /> + Download {platform.file} + </a> + </li> + )) + } +</ul> + +<style> + .launcher-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; + } + + .launcher-card { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin: 0; + padding: 1rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + } + + .launcher-card :global(.launcher-card__icon) { + margin-bottom: 0.375rem; + color: var(--docs-text-muted); + } + + .launcher-card__title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.375rem; + margin: 0; + color: var(--docs-text-strong); + font-size: var(--sl-text-base); + font-weight: 600; + line-height: 1.3; + } + + .launcher-card__qualifier { + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + font-weight: 500; + } + + .launcher-card__note { + margin: 0; + color: var(--docs-text-muted); + font-size: var(--sl-text-sm); + line-height: 1.5; + } + + .launcher-card__meta { + margin: 0 0 0.75rem; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + font-variant-numeric: tabular-nums; + } + + /* The buttons line up at the bottom of a row of cards. */ + .launcher-card__button { + align-self: stretch; + margin-top: auto; + } +</style> diff --git a/docs/src/lib/components/LinkMenu.astro b/docs/src/lib/components/LinkMenu.astro new file mode 100644 index 00000000000..ee4032db2aa --- /dev/null +++ b/docs/src/lib/components/LinkMenu.astro @@ -0,0 +1,128 @@ +--- +import Check from '@lucide/astro/icons/check'; +import ChevronDown from '@lucide/astro/icons/chevron-down'; + +type Props = { + /** The popup's id, unique on the page. */ + id: string; + /** The trigger's text, after the optional `icon` slot. */ + label: string; + accessibleLabel: string; + items: { label: string; note?: string; href: string; isCurrent: boolean; lang?: string }[]; + /** `up` opens the popup above the trigger, for a menu at the bottom of the mobile drawer. */ + direction?: 'down' | 'up'; + /** Shows only the `icon` slot below 55rem, where the header has no room for the text beside the search field. */ + iconOnlyWhenNarrow?: boolean; +}; + +// A header button that opens a list of links, such as the docs versions or languages. +const { id, label, accessibleLabel, items, direction = 'down', iconOnlyWhenNarrow = false } = Astro.props; +--- + +<docs-link-menu class="link-menu"> + <button + type="button" + class:list={[ + 'trigger docs-button docs-button--secondary docs-button--lg', + { 'trigger--icon-when-narrow': iconOnlyWhenNarrow }, + ]} + aria-expanded="false" + aria-controls={id} + aria-label={accessibleLabel} + > + <slot name="icon" /> + <span class="trigger__label">{label}</span> + <ChevronDown class="trigger__chevron" size={12} /> + </button> + <ul id={id} class:list={['popup docs-popup', { 'popup--up': direction === 'up' }]} hidden> + { + items.map(({ label, note, href, isCurrent, lang }) => ( + <li> + <a href={href} hreflang={lang} aria-current={isCurrent ? 'true' : undefined}> + <span class="label" lang={lang}> + {label} + </span> + {note && <span class="note">{note}</span>} + {isCurrent && <Check class="check" size={14} />} + </a> + </li> + )) + } + </ul> +</docs-link-menu> + +<script> + import { bindDisclosure } from '../disclosure'; + + class DocsLinkMenu extends HTMLElement { + connectedCallback() { + bindDisclosure(this, this.querySelector('button')!, this.querySelector('ul')!); + } + } + customElements.define('docs-link-menu', DocsLinkMenu); +</script> + +<style> + .link-menu { + position: relative; + } + + .popup { + position: absolute; + inset-inline-end: 0; + top: calc(100% + 0.375rem); + z-index: 1; + min-width: 12rem; + margin: 0; + padding: 0.25rem; + list-style: none; + border: 1px solid var(--docs-border-strong); + border-radius: var(--docs-radius-lg); + background: var(--docs-control); + box-shadow: var(--docs-shadow-popup); + } + + @media (max-width: 54.99rem) { + .trigger--icon-when-narrow { + width: var(--docs-button-height); + padding: 0; + + :is(.trigger__label, .trigger__chevron) { + display: none; + } + } + } + + .popup--up { + --docs-popup-offset: 4px; + top: auto; + bottom: calc(100% + 0.375rem); + } + + a { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + border-radius: var(--docs-radius); + color: var(--docs-text-strong); + text-decoration: none; + + &:hover { + background: var(--docs-hover); + } + } + + .label { + font-weight: 600; + } + + .note { + color: var(--docs-text-subtle); + } + + .check { + margin-inline-start: auto; + color: var(--docs-accent); + } +</style> diff --git a/docs/src/lib/components/MainNav.astro b/docs/src/lib/components/MainNav.astro new file mode 100644 index 00000000000..f47a07fe51f --- /dev/null +++ b/docs/src/lib/components/MainNav.astro @@ -0,0 +1,104 @@ +--- +import BookOpenText from '@lucide/astro/icons/book-open-text'; +import Images from '@lucide/astro/icons/images'; + +import { docsTopics, getCurrentTopic } from '../../config/topics'; +import { localizedHref, withBase } from '../base-path'; + +type Props = { + /** `drawer` renders the copy shown in the mobile sidebar drawer, which stands in for the header's below `md`. */ + placement: 'header' | 'drawer'; +}; + +const { placement } = Astro.props; +const { hasSidebar, locale } = Astro.locals.starlightRoute; +const { t } = Astro.locals; +const base = import.meta.env.BASE_URL; +const trimSlash = (path: string) => path.replace(/\/$/, ''); +const pathname = trimSlash(Astro.url.pathname); + +const docsLanding = docsTopics.find((topic) => topic.id === 'get-started'); +if (!docsLanding) throw new Error('The header links to the "get-started" docs topic, which no longer exists.'); +const docsHref = localizedHref(docsLanding.link, { locale, base }); +const showcaseHref = withBase('/showcase/', base); + +const items = [ + { + label: t('nav.docs'), + href: docsHref, + // The app's help menu links its documentation with this icon. + icon: BookOpenText, + // Every page in a docs topic belongs to the docs; only the landing page is the link's own page. + current: getCurrentTopic(Astro.locals) ? (pathname === trimSlash(docsHref) ? 'page' : 'true') : undefined, + }, + { + label: t('nav.showcase'), + href: showcaseHref, + icon: Images, + // The weekly challenges' pages belong to the showcase. + current: + pathname === trimSlash(showcaseHref) ? 'page' : pathname.startsWith(showcaseHref) ? 'true' : undefined, + }, +]; + +const Wrapper = placement === 'header' ? 'nav' : 'div'; +const wrapperAttrs = placement === 'header' ? { 'aria-label': t('nav.siteNavLabel') } : {}; +--- + +{/* The header copy is the site navigation landmark. The drawer copy sits inside Starlight's sidebar landmark, so it +is a plain list, and only one of the two is displayed at any width. */} +<Wrapper + {...wrapperAttrs} + class:list={[ + 'main-nav', + `main-nav--${placement}`, + // Pages with a sidebar move the links into its drawer on narrow screens. + { 'main-nav--in-drawer-when-narrow': placement === 'header' && hasSidebar }, + ]} +> + <ul class="docs-segment-tabs docs-segment-tabs--lg"> + { + items.map(({ label, href, icon: Icon, current }) => ( + <li> + <a class="docs-segment-tab" href={href} aria-current={current}> + <Icon size={14} /> + {label} + </a> + </li> + )) + } + </ul> +</Wrapper> + +<style> + .main-nav { + flex-shrink: 0; + + @media print { + display: none; + } + } + + .main-nav--drawer { + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--docs-border-subtle); + } + + @media (max-width: 49.99rem) { + .main-nav--in-drawer-when-narrow { + display: none; + } + + /* Splash pages have no drawer: their header keeps the links, tightened to fit beside the other controls. */ + .main-nav--header .docs-segment-tabs .docs-segment-tab { + padding-inline: 0.5rem; + } + } + + /* On the narrowest phones the header links drop their icons to fit. */ + @media (max-width: 29.99rem) { + .main-nav--header :global(svg) { + display: none; + } + } +</style> diff --git a/docs/src/lib/components/MarkdownContent.astro b/docs/src/lib/components/MarkdownContent.astro new file mode 100644 index 00000000000..76420304035 --- /dev/null +++ b/docs/src/lib/components/MarkdownContent.astro @@ -0,0 +1,43 @@ +--- +import '@astrojs/starlight/style/markdown.css'; + +import { localizeHtmlLinks } from '../locale-links.mjs'; + +// On a locale's routes, links into the docs stay in that locale, also on pages that show the English content. +const { locale } = Astro.locals.starlightRoute; +const localizedHtml = + locale && localizeHtmlLinks(await Astro.slots.render('default'), { base: import.meta.env.BASE_URL, locale }); +--- + +{/* Starlight's own markup, plus the name the script below gives a table that has none. */} +<div class="sl-markdown-content" data-table-label={Astro.locals.t('markdown.tableLabel')}> + {localizedHtml ? <Fragment set:html={localizedHtml} /> : <slot />} +</div> + +<script> + // A table wider than the content column scrolls in its wrapper (`plugins/rehype-table-scroll.mjs`). Make that wrapper + // a named, focusable region so it can be scrolled from the keyboard, as Safari does not focus scroll boxes; tables + // that fit stay out of the tab order. + const sync = (wrapper: HTMLElement) => { + if (wrapper.scrollWidth > wrapper.clientWidth) { + wrapper.tabIndex = 0; + wrapper.setAttribute('role', 'region'); + const content = wrapper.closest<HTMLElement>('[data-table-label]')!; + wrapper.setAttribute('aria-label', wrapper.dataset.label || content.dataset.tableLabel!); + } else { + wrapper.removeAttribute('tabindex'); + wrapper.removeAttribute('role'); + wrapper.removeAttribute('aria-label'); + } + }; + const wrappers = [...document.querySelectorAll<HTMLElement>('.table-scroll')]; + // Watch the table too: it can widen (a web font swapping in) without its wrapper changing size. + const observer = new ResizeObserver((entries) => { + for (const entry of entries) sync(entry.target.closest<HTMLElement>('.table-scroll')!); + }); + for (const wrapper of wrappers) { + observer.observe(wrapper); + const table = wrapper.querySelector('table'); + if (table) observer.observe(table); + } +</script> diff --git a/docs/src/lib/components/Mermaid.astro b/docs/src/lib/components/Mermaid.astro index 18a59eba203..38f2fc7acd7 100644 --- a/docs/src/lib/components/Mermaid.astro +++ b/docs/src/lib/components/Mermaid.astro @@ -8,7 +8,41 @@ const { title = '' } = Astro.props as Props; <script> import mermaid from 'mermaid'; - mermaid.initialize({ startOnLoad: false }); + + /** + * Resolves a docs color token to an sRGB hex color. Mermaid derives shades from its theme colors and cannot parse + * the OKLCH values the tokens hold, so let a canvas convert them. + */ + const resolveColor = (token: string) => { + const context = document.createElement('canvas').getContext('2d', { willReadFrequently: true })!; + context.fillStyle = getComputedStyle(document.documentElement).getPropertyValue(token).trim(); + context.fillRect(0, 0, 1, 1); + const [r, g, b] = context.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, '0')).join('')}`; + }; + + const getThemeVariables = () => { + const surface = resolveColor('--invoke-neutral-900'); + const control = resolveColor('--invoke-neutral-800'); + const border = resolveColor('--invoke-neutral-700'); + const text = resolveColor('--invoke-neutral-50'); + return { + darkMode: true, + fontFamily: getComputedStyle(document.body).fontFamily, + background: resolveColor('--invoke-neutral-950'), + primaryColor: control, + primaryTextColor: text, + primaryBorderColor: resolveColor('--invoke-accent'), + secondaryColor: surface, + tertiaryColor: surface, + lineColor: resolveColor('--invoke-neutral-300'), + textColor: text, + clusterBkg: surface, + clusterBorder: border, + titleColor: resolveColor('--invoke-neutral-200'), + edgeLabelBackground: surface, + }; + }; function extractAndCleanMermaidCode() { const mermaidElements = document.querySelectorAll('figure.expandable-diagram'); @@ -42,17 +76,18 @@ const { title = '' } = Astro.props as Props; // Wait for the DOM to be fully loaded document.addEventListener('DOMContentLoaded', async () => { extractAndCleanMermaidCode(); - mermaid.initialize({ startOnLoad: true }); + mermaid.initialize({ startOnLoad: false, theme: 'base', themeVariables: getThemeVariables() }); + await mermaid.run({ querySelector: 'figure.expandable-diagram pre.mermaid' }); }); </script> <figure class="expandable-diagram"> <figcaption>{title}</figcaption> - <div class="diagram-content">Loading diagram...</div> + <div class="diagram-content">{Astro.locals.t('mermaid.loading')}</div> <details> - <summary>Source</summary> + <summary>{Astro.locals.t('mermaid.sourceCode')}</summary> <slot /> </details> </figure> diff --git a/docs/src/lib/components/ModelFamilies.astro b/docs/src/lib/components/ModelFamilies.astro new file mode 100644 index 00000000000..ebaad721e06 --- /dev/null +++ b/docs/src/lib/components/ModelFamilies.astro @@ -0,0 +1,327 @@ +--- +/* + * The home page's model families as a grid of link cards, with the external providers below. The section heading goes + * in the slot, so it shares the grid's width and left edge. Cards follow the site's link-card recipe. A card shows its + * family's cover, `src/assets/model-families/<slug>.webp`, above the text when that file exists, and is text only + * otherwise. Cards in a row stretch to the tallest and keep their text at the bottom, so text lines up across a row + * that mixes cards with and without covers. + */ +import ArrowRight from '@lucide/astro/icons/arrow-right'; +import type { ImageMetadata } from 'astro'; +import { Image } from 'astro:assets'; + +import { externalProviders, modelFamilies, type Label } from '../../config/model-families'; +import { withBase } from '../base-path'; +import Trans from './Trans.astro'; + +const base = import.meta.env.BASE_URL; +const { t } = Astro.locals; +const label = (value: Label) => (typeof value === 'string' ? value : t(value.labelKey)); + +const covers = import.meta.glob<ImageMetadata>('../../assets/model-families/*.webp', { eager: true, import: 'default' }); +const coverFor = (slug: string) => covers[`../../assets/model-families/${slug}.webp`]; +--- + +<div class="models"> + <slot /> + <ul class="grid" role="list"> + { + modelFamilies.map(({ slug, name, link, kind, license, vram, formats }) => { + const cover = coverFor(slug); + return ( + <li> + <a + class="card" + href={withBase(link, base)} + aria-labelledby={`model-${slug}`} + aria-describedby={`model-${slug}-tagline`} + > + {cover && ( + <Image + class="cover" + src={cover} + alt="" + widths={[400, 640, 960]} + sizes="(min-width: 84rem) 20rem, (min-width: 48rem) 34vw, (min-width: 32rem) 50vw, 100vw" + /> + )} + <span class="body"> + <span class="title"> + <span class="name" id={`model-${slug}`}> + {name} + </span> + {kind === 'video' && <span class="tag tag--video">{t('modelFamilies.videoTag')}</span>} + <ArrowRight class="arrow" size={16} aria-hidden="true" /> + </span> + <span class="tagline" id={`model-${slug}-tagline`}> + {t(`modelFamilies.${slug}.tagline`)} + </span> + <span class="tags"> + <span class:list={['tag', `tag--${license.kind}`]}> + <Trans + message={t('modelFamilies.licenseTag', { + license: label(license.label), + interpolation: { escapeValue: false }, + })} + > + {(text: string) => <span class="sr-only">{text}</span>} + </Trans> + </span> + {vram !== undefined && ( + <span class="tag tag--plain"> + <Trans message={t('modelFamilies.vramTag', { gigabytes: vram })}> + {(text: string) => <span class="sr-only">{text}</span>} + </Trans> + </span> + )} + {formats.map((format) => ( + <span class="tag tag--plain">{label(format)}</span> + ))} + </span> + </span> + </a> + </li> + ); + }) + } + </ul> + <div class="external"> + <p class="external__label" id="home-models-external">{t('modelFamilies.externalProviders')}</p> + <ul class="external__links" role="list" aria-labelledby="home-models-external"> + { + externalProviders.map(({ name, link }) => ( + <li> + <a href={withBase(link, base)}>{name}</a> + </li> + )) + } + </ul> + </div> +</div> + +<style> + /* As wide as the What's new bento: up to 80rem, centred, inside the page gutters. */ + .models { + --models-width: min(100vw - 2 * var(--sl-content-pad-x), 80rem); + container: models / inline-size; + display: flex; + flex-direction: column; + gap: 2rem; + width: var(--models-width); + margin-inline: calc((100% - var(--models-width)) / 2); + } + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + .grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + } + + @container models (min-width: 28rem) { + .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @container models (min-width: 44rem) { + .grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + @container models (min-width: 64rem) { + .grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + + /* Stretched to the row's tallest card, with the text at the bottom. */ + .card { + display: flex; + flex-direction: column; + justify-content: flex-end; + height: 100%; + overflow: hidden; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + color: var(--docs-text-strong); + text-decoration: none; + transition: + border-color 150ms ease, + background-color 150ms ease; + + &:is(:hover, :focus-visible) { + border-color: var(--docs-border-strong); + background-color: var(--docs-control); + color: var(--docs-text-strong); + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + } + } + + /* Covers are cropped to a wide frame above the text. */ + .cover { + display: block; + flex: none; + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + margin-bottom: auto; + border-bottom: 1px solid var(--docs-border); + object-fit: cover; + object-position: center; + } + + .body { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 1rem; + } + + .title { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .name { + min-width: 0; + overflow: hidden; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; + } + + .arrow { + flex-shrink: 0; + margin-inline-start: auto; + color: var(--docs-text-subtle); + transition: color 150ms ease; + } + + .card:is(:hover, :focus-visible) .arrow { + color: var(--docs-text-strong); + } + + .tagline { + overflow: hidden; + color: var(--docs-text-muted); + font-size: 0.8125rem; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* One line of tags; a row too long for the card fades out over its last 2.5rem, to nothing at the edge. */ + .tags { + display: flex; + gap: 0.375rem; + margin-top: 0.5rem; + overflow: hidden; + mask-image: linear-gradient(to right, #000 calc(100% - 2.5rem), transparent); + } + + .tag { + flex: none; + padding: 0.125rem 0.375rem; + border-radius: var(--docs-radius); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.4; + white-space: nowrap; + } + + .tag--open { + --tag: var(--invoke-success); + } + + .tag--community { + --tag: var(--docs-accent); + } + + .tag--noncommercial, + .tag--research, + .tag--restricted { + --tag: var(--invoke-warning); + } + + .tag:not(.tag--plain, .tag--mixed, .tag--video) { + background-color: color-mix(in oklab, var(--tag) 14%, transparent); + color: var(--tag); + } + + /* Not a license, so outlined rather than filled. */ + .tag--video { + border: 1px solid var(--docs-border-strong); + color: var(--docs-text-muted); + } + + .tag--plain, + .tag--mixed { + background-color: var(--docs-control); + color: var(--docs-text-muted); + } + + /* External providers */ + + .external { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; + } + + .external__label { + margin: 0; + color: var(--docs-text-subtle); + font-size: 0.8125rem; + } + + .external__links { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + a { + display: block; + padding: 0.375rem 0.75rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius); + background-color: var(--docs-surface); + color: var(--docs-text-strong); + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.4; + text-decoration: none; + transition: + border-color 150ms ease, + background-color 150ms ease; + + &:hover { + border-color: var(--docs-border-strong); + background-color: var(--docs-control); + color: var(--docs-text-strong); + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + } + } + } +</style> diff --git a/docs/src/lib/components/PageActions.astro b/docs/src/lib/components/PageActions.astro new file mode 100644 index 00000000000..7fff6f7cd56 --- /dev/null +++ b/docs/src/lib/components/PageActions.astro @@ -0,0 +1,182 @@ +--- +import ArrowUpRight from '@lucide/astro/icons/arrow-up-right'; +import ChevronDown from '@lucide/astro/icons/chevron-down'; +import Copy from '@lucide/astro/icons/copy'; + +import { splitSitePath } from '../../config/locales.mjs'; +import { withBase } from '../base-path'; +import { isDocsPage } from '../docs-page'; + +type Props = { + /** Distinguishes the element ids of the copies in the right rail and the footer. */ + instance: string; +}; + +const menuId = `docs-page-actions-menu-${Astro.props.instance}`; + +// starlight-markdown serves each docs page's Markdown source at `<page>/index.md`. Pages generated by other plugins, +// such as the changelogs, have none. +const { entry, isFallback } = Astro.locals.starlightRoute; +const { t } = Astro.locals; +const base = import.meta.env.BASE_URL; +const hasMarkdown = await isDocsPage(entry.id); +// A locale's page without a translation shows the English entry, whose source is at the English path. +const pagePath = isFallback ? withBase(splitSitePath(Astro.url.pathname, base)!.path, base) : Astro.url.pathname; +const markdownHref = `${pagePath.replace(/\/?$/, '/')}index.md`; +const prompt = encodeURIComponent( + t('pageActions.aiPrompt', { + url: new URL(Astro.url.pathname, Astro.site).href, + interpolation: { escapeValue: false }, + }) +); +const links = [ + { label: t('pageActions.viewMarkdown'), href: markdownHref }, + { label: t('pageActions.openInChatGPT'), href: `https://chatgpt.com/?q=${prompt}` }, + { label: t('pageActions.openInClaude'), href: `https://claude.ai/new?q=${prompt}` }, +]; +// The copy button's feedback, which the script below shows. +const i18n = { + copy: t('pageActions.copy'), + copied: t('pageActions.copyDone'), + copiedStatus: t('pageActions.copyDoneStatus'), + copyFailed: t('pageActions.copyFailed'), + copyFailedStatus: t('pageActions.copyFailedStatus'), +}; +--- + +{ + hasMarkdown && ( + <docs-page-actions class="page-actions" data-markdown-href={markdownHref} data-i18n={JSON.stringify(i18n)}> + <div class="buttons"> + <button type="button" class="copy docs-button docs-button--secondary"> + <Copy size={14} /> + <span data-label>{i18n.copy}</span> + </button> + <button + type="button" + class="toggle docs-button docs-button--secondary docs-button--icon" + aria-expanded="false" + aria-controls={menuId} + aria-label={t('pageActions.more')} + > + <ChevronDown size={12} /> + </button> + </div> + <ul id={menuId} class="menu docs-popup" hidden> + {links.map(({ label, href }) => ( + <li> + <a href={href} target="_blank" rel="noopener noreferrer"> + {label} + <ArrowUpRight size={12} /> + </a> + </li> + ))} + </ul> + <span class="sr-only" role="status" /> + </docs-page-actions> + ) +} + +<script> + import { bindDisclosure } from '../disclosure'; + + const RESET_DELAY_MS = 2000; + + class DocsPageActions extends HTMLElement { + connectedCallback() { + const copy = this.querySelector<HTMLButtonElement>('.copy')!; + const label = copy.querySelector<HTMLElement>('[data-label]')!; + const status = this.querySelector<HTMLElement>('[role="status"]')!; + const menu = bindDisclosure(this, this.querySelector('.toggle')!, this.querySelector('ul')!); + const i18n: Record<string, string> = JSON.parse(this.dataset.i18n!); + let resetTimer: number | undefined; + + const report = (text: string, announcement: string) => { + label.textContent = text; + status.textContent = announcement; + clearTimeout(resetTimer); + resetTimer = window.setTimeout(() => { + label.textContent = i18n.copy; + status.textContent = ''; + }, RESET_DELAY_MS); + }; + + copy.addEventListener('click', async () => { + menu.close(); + try { + // Hand the clipboard a pending blob right away: Safari only allows clipboard writes during the click. + const markdown = fetch(this.dataset.markdownHref!).then(async (response) => { + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + return new Blob([await response.text()], { type: 'text/plain' }); + }); + await navigator.clipboard.write([new ClipboardItem({ 'text/plain': markdown })]); + report(i18n.copied, i18n.copiedStatus); + } catch (error) { + console.error('Could not copy the page as Markdown:', error); + report(i18n.copyFailed, i18n.copyFailedStatus); + } + }); + for (const link of this.querySelectorAll('ul a')) link.addEventListener('click', () => menu.close()); + } + } + customElements.define('docs-page-actions', DocsPageActions); +</script> + +<style> + .page-actions { + position: relative; + display: block; + } + + .buttons { + display: flex; + } + + /* Two buttons joined into one split button. */ + .copy { + flex: 1; + justify-content: flex-start; + border-start-end-radius: 0; + border-end-end-radius: 0; + } + + .toggle { + border-inline-start: 0; + border-start-start-radius: 0; + border-end-start-radius: 0; + } + + .menu { + position: absolute; + inset-inline: 0; + top: calc(100% + 0.375rem); + z-index: 1; + margin: 0; + padding: 0.25rem; + list-style: none; + border: 1px solid var(--docs-border-strong); + border-radius: var(--docs-radius-lg); + background: var(--docs-control); + box-shadow: var(--docs-shadow-popup); + } + + a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + border-radius: var(--docs-radius); + color: var(--docs-text-strong); + font-size: var(--sl-text-xs); + text-decoration: none; + + svg { + color: var(--docs-text-subtle); + } + + &:hover { + background: var(--docs-hover); + } + } +</style> diff --git a/docs/src/lib/components/PageFeedback.astro b/docs/src/lib/components/PageFeedback.astro new file mode 100644 index 00000000000..521c291a1b9 --- /dev/null +++ b/docs/src/lib/components/PageFeedback.astro @@ -0,0 +1,122 @@ +--- +import ThumbsDown from '@lucide/astro/icons/thumbs-down'; +import ThumbsUp from '@lucide/astro/icons/thumbs-up'; + +import { getReportIssueUrl } from '../report-issue'; +import Trans from './Trans.astro'; + +type Props = { + /** Distinguishes the element ids of the copies in the right rail and the footer. */ + instance: string; +}; + +const labelId = `docs-feedback-label-${Astro.props.instance}`; + +const { entry } = Astro.locals.starlightRoute; +const { t } = Astro.locals; +const reportIssueUrl = getReportIssueUrl(entry.data.title, new URL(Astro.url.pathname, Astro.site).href); +--- + +<docs-page-feedback class="feedback"> + <div class="question" role="group" aria-labelledby={labelId}> + <p id={labelId}>{t('pageFeedback.question')}</p> + <div class="answers"> + <button type="button" class="docs-button docs-button--secondary" data-helpful="yes"> + <ThumbsUp size={14} /> + {t('pageFeedback.answerYes')} + </button> + <button type="button" class="docs-button docs-button--secondary" data-helpful="no"> + <ThumbsDown size={14} /> + {t('pageFeedback.answerNo')} + </button> + </div> + </div> + <div class="thanks" tabindex="-1" hidden> + <p>{t('pageFeedback.thanks')}</p> + <p data-unhelpful hidden> + <Trans message={t('pageFeedback.reportPrompt')}> + {(text: string) => <a href={reportIssueUrl}>{text}</a>} + </Trans> + </p> + </div> +</docs-page-feedback> + +<script> + type Plausible = (event: string, options: { props: Record<string, string> }) => void; + + class DocsPageFeedback extends HTMLElement { + connectedCallback() { + const question = this.querySelector<HTMLElement>('.question')!; + const thanks = this.querySelector<HTMLElement>('.thanks')!; + for (const button of this.querySelectorAll<HTMLButtonElement>('button[data-helpful]')) { + button.addEventListener('click', () => { + const helpful = button.dataset.helpful!; + // Plausible loads only when the site is built with analytics enabled. + const { plausible } = window as Window & { plausible?: Plausible }; + if (typeof plausible === 'function') { + plausible('Docs feedback', { props: { page: location.pathname, helpful } }); + } + question.hidden = true; + thanks.hidden = false; + thanks.querySelector<HTMLElement>('[data-unhelpful]')!.hidden = helpful !== 'no'; + // The answer buttons are gone, so move focus to the reply rather than letting it drop to the page. + thanks.focus(); + }); + } + } + } + customElements.define('docs-page-feedback', DocsPageFeedback); +</script> + +<style> + .feedback { + display: block; + padding-top: 1rem; + border-top: 1px solid var(--docs-border); + font-size: var(--sl-text-xs); + } + + p { + margin: 0; + } + + .question { + display: flex; + flex-direction: column; + gap: 0.5rem; + + &[hidden] { + display: none; + } + + > p { + color: var(--docs-text-muted); + font-size: var(--sl-text-2xs); + font-weight: 600; + } + } + + .answers { + display: flex; + gap: 0.375rem; + } + + button { + flex: 1; + } + + .thanks { + display: flex; + flex-direction: column; + gap: 0.375rem; + color: var(--docs-text-muted); + + &[hidden] { + display: none; + } + } + + a { + color: var(--docs-accent); + } +</style> diff --git a/docs/src/lib/components/PageMeta.astro b/docs/src/lib/components/PageMeta.astro new file mode 100644 index 00000000000..d00e109897d --- /dev/null +++ b/docs/src/lib/components/PageMeta.astro @@ -0,0 +1,72 @@ +--- +import { Icon } from '@astrojs/starlight/components'; +import Pencil from '@lucide/astro/icons/pencil'; + +import { getReportIssueUrl } from '../report-issue'; +import Trans from './Trans.astro'; + +const { editUrl, entry, isFallback, lang, lastUpdated, locale } = Astro.locals.starlightRoute; +// Translated pages link to Crowdin instead of their file (`src/route-data.ts`). +const { t } = Astro.locals; +const editLabel = locale && !isFallback ? t('page.translateLink') : t('page.editLink'); +const reportIssueUrl = getReportIssueUrl(entry.data.title, new URL(Astro.url.pathname, Astro.site).href); +--- + +<div class="page-meta"> + { + editUrl && ( + <a href={editUrl} class="print:hidden"> + <Pencil size={14} /> + {editLabel} + </a> + ) + } + <a href={reportIssueUrl} class="print:hidden"> + <Icon name="github" size="14px" /> + {t('page.reportIssue')} + </a> + { + lastUpdated && ( + <p> + <Trans + message={t('page.updated', { + date: lastUpdated.toLocaleDateString(lang, { dateStyle: 'medium', timeZone: 'UTC' }), + interpolation: { escapeValue: false }, + })} + > + {(text: string) => <time datetime={lastUpdated.toISOString()}>{text}</time>} + </Trans> + </p> + ) + } +</div> + +<style> + .page-meta { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: var(--sl-text-xs); + } + + a { + display: flex; + align-items: center; + gap: 0.375rem; + color: var(--docs-text-muted); + text-decoration: none; + + &:hover { + color: var(--docs-accent-text); + } + + @media print { + display: none; + } + } + + p { + margin: 0; + color: var(--docs-text-subtle); + } +</style> diff --git a/docs/src/lib/components/PageSidebar.astro b/docs/src/lib/components/PageSidebar.astro new file mode 100644 index 00000000000..5f2efff9f2f --- /dev/null +++ b/docs/src/lib/components/PageSidebar.astro @@ -0,0 +1,36 @@ +--- +import MobileTableOfContents from 'virtual:starlight/components/MobileTableOfContents'; +import TableOfContents from 'virtual:starlight/components/TableOfContents'; + +import PageTools from './PageTools.astro'; + +// Starlight's page sidebar with the page tools around its table of contents. Below the `lg` breakpoint the rail is +// hidden, the table of contents collapses into the mobile control, and the footer shows the page tools instead. +--- + +{ + Astro.locals.starlightRoute.toc && ( + <> + <div class="lg:sl-hidden"> + <MobileTableOfContents /> + </div> + <div class="right-sidebar-panel sl-hidden lg:sl-block"> + <div class="sl-container"> + <PageTools instance="rail"> + <TableOfContents /> + </PageTools> + </div> + </div> + </> + ) +} + +<style> + .right-sidebar-panel { + padding: 2rem 1.25rem; + } + + .sl-container { + width: calc(var(--docs-rail-width) - 2.5rem); + } +</style> diff --git a/docs/src/lib/components/PageTitle.astro b/docs/src/lib/components/PageTitle.astro new file mode 100644 index 00000000000..70017a10212 --- /dev/null +++ b/docs/src/lib/components/PageTitle.astro @@ -0,0 +1,78 @@ +--- +import { getCurrentTopic, topicLabel } from '../../config/topics'; +import { localizedHref } from '../base-path'; +import { findCurrentTrail } from '../sidebar.mjs'; + +const { entry, locale, sidebar } = Astro.locals.starlightRoute; +const { t } = Astro.locals; +const topic = getCurrentTopic(Astro.locals); +const trail = topic && findCurrentTrail(sidebar); +// The breadcrumb carries the Pagefind `topic` filter that scopes search to a topic, on pages Pagefind indexes; its +// text is not indexed. +const topicFilter = topic && entry.data.pagefind !== false ? `topic:${topic.id}` : undefined; +--- + +<div class="page-heading"> + { + topic && ( + <nav aria-label={t('page.breadcrumb')} data-pagefind-ignore data-pagefind-filter={topicFilter}> + <ol> + <li> + <a href={localizedHref(topic.link, { locale, base: import.meta.env.BASE_URL })}>{topicLabel(t, topic)}</a> + </li> + {trail?.groups.map((group) => <li>{group}</li>)} + {trail && <li aria-current="page">{trail.link.label}</li>} + </ol> + </nav> + ) + } + {/* Starlight links to the page title as `#_top`. */} + <h1 id="_top">{entry.data.title}</h1> +</div> + +<style> + .page-heading { + display: flex; + flex-direction: column; + gap: 1.25rem; + } + + ol { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + } + + li + li::before { + content: '/'; + content: '/' / ''; + margin-inline-end: 0.375rem; + } + + a { + color: inherit; + text-decoration: none; + + &:hover { + color: var(--docs-text-strong); + } + } + + [aria-current='page'] { + color: var(--docs-text-muted); + } + + h1 { + margin: 0; + color: var(--docs-text-strong); + font-size: var(--sl-text-h1); + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.1; + } +</style> diff --git a/docs/src/lib/components/PageTools.astro b/docs/src/lib/components/PageTools.astro new file mode 100644 index 00000000000..2f7b044827a --- /dev/null +++ b/docs/src/lib/components/PageTools.astro @@ -0,0 +1,39 @@ +--- +import PageActions from './PageActions.astro'; +import PageFeedback from './PageFeedback.astro'; +import PageMeta from './PageMeta.astro'; + +type Props = { + /** Distinguishes the element ids of the copies in the right rail and the footer. */ + instance: string; +}; + +const { instance } = Astro.props; +--- + +<div class="page-tools"> + <PageActions instance={instance} /> + <slot /> + <PageFeedback instance={instance} /> + <div class="meta"> + <PageMeta /> + </div> +</div> + +<style> + .page-tools { + display: flex; + flex-direction: column; + gap: 1.25rem; + + /* Page actions, feedback and links are for the screen. */ + @media print { + display: none; + } + } + + .meta { + padding-top: 1rem; + border-top: 1px solid var(--docs-border); + } +</style> diff --git a/docs/src/lib/components/Pagination.astro b/docs/src/lib/components/Pagination.astro new file mode 100644 index 00000000000..68da97e903c --- /dev/null +++ b/docs/src/lib/components/Pagination.astro @@ -0,0 +1,66 @@ +--- +import ArrowLeft from '@lucide/astro/icons/arrow-left'; +import ArrowRight from '@lucide/astro/icons/arrow-right'; + +const { dir, pagination } = Astro.locals.starlightRoute; +const { prev, next } = pagination; +--- + +{ + (prev || next) && ( + <div class="pagination-links print:hidden" dir={dir}> + {prev && ( + <a class="docs-link-card" href={prev.href} rel="prev"> + <span class="label docs-link-card__description"> + <ArrowLeft class="docs-link-card__icon" size={14} /> + {Astro.locals.t('page.previousLink')} + </span> + <span class="docs-link-card__title">{prev.label}</span> + </a> + )} + {next && ( + <a class="docs-link-card" href={next.href} rel="next"> + <span class="label docs-link-card__description"> + {Astro.locals.t('page.nextLink')} + <ArrowRight class="docs-link-card__icon" size={14} /> + </span> + <span class="docs-link-card__title">{next.label}</span> + </a> + )} + </div> + ) +} + +<style> + .pagination-links { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); + gap: 0.75rem; + margin-top: 2rem; + + /* Starlight's `print:hidden` loses to this rule's display, so hide it in print here. */ + @media print { + display: none; + } + } + + /* The card itself is the shared `.docs-link-card` (custom.css); this lays out its label row and title. */ + a { + display: flex; + flex-direction: column; + gap: 0.25rem; + overflow-wrap: anywhere; + } + + [rel='next'] { + grid-column: -2; + align-items: flex-end; + text-align: end; + } + + .label { + display: flex; + align-items: center; + gap: 0.375rem; + } +</style> diff --git a/docs/src/lib/components/Search.astro b/docs/src/lib/components/Search.astro new file mode 100644 index 00000000000..fe3b6479f2b --- /dev/null +++ b/docs/src/lib/components/Search.astro @@ -0,0 +1,987 @@ +--- +// Replaces Starlight's search with a command palette over the Pagefind index that Starlight builds: results scoped to +// the current topic or all docs, grouped by topic, plus matching `invokeai.yaml` settings from a compact index +// (`src/pages/settings-search.json.ts`). Pagefind and the settings index load on first use, not with the page. +import { docsTopics, topicLabel } from '../../config/topics'; +import { pluralForms } from '../i18n.mjs'; + +const { t } = Astro.locals; +const label = t('search.label'); +// The script's strings. Templates keep their `{{placeholders}}` for the script to fill (`../i18n.mjs`). +const i18n = { + // Results carry their topic's id (`PageTitle.astro`); groups show its title. + topicTitles: docsTopics.map((topic) => [topic.id, topicLabel(t, topic)]), + otherPages: t('search.otherPages'), + recent: t('search.recentSearches'), + settingsGroup: t('search.settingsGroup'), + settingDefault: t('search.settingDefault'), + loadError: t('search.loadError'), + devNotice: t('search.devWarning'), + searchingIn: t('search.searchingIn'), + searchingAll: t('search.searchingAll'), + scopeChangeStatus: t('search.scopeChangeStatus'), + noResults: t('search.noResults'), + noResultsInTopic: t('search.noResultsInTopic'), + resultCount: pluralForms(t, 'search.resultCount'), + moreInAllDocs: pluralForms(t, 'search.moreInAllDocs'), + showInAllDocs: pluralForms(t, 'search.showInAllDocs'), +}; +--- + +<docs-search data-i18n={JSON.stringify(i18n)} data-locale={Astro.locals.starlightRoute.locale}> + <button type="button" class="trigger" aria-label={label} aria-haspopup="dialog" aria-keyshortcuts="Control+K /"> + <svg class="trigger__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" + ><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path></svg + > + <span class="trigger__label" aria-hidden="true">{label}</span> + <kbd class="trigger__shortcut" aria-hidden="true" style="display: none;"><kbd>{t('search.ctrlKey')}</kbd><kbd>K</kbd></kbd> + </button> + + <dialog class="modal" aria-label={label}> + <div class="field"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" + ><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path></svg + > + <input + type="text" + role="combobox" + aria-label={label} + aria-autocomplete="list" + aria-controls="docs-search-results" + aria-expanded="false" + autocomplete="off" + autocapitalize="off" + spellcheck="false" + enterkeyhint="go" + placeholder={t('search.placeholder')} + /> + <kbd class="field__esc" aria-hidden="true">esc</kbd> + <button type="button" class="field__cancel">{t('search.cancelLabel')}</button> + </div> + + <div class="scopes" role="group" aria-label={t('search.scope')}> + <span class="scopes__label" aria-hidden="true">{t('search.scopeChipsIntro')}</span> + <div class="docs-segment-tabs"> + <button type="button" class="chip docs-segment-tab" data-scope="topic" aria-pressed="false" hidden></button> + <button type="button" class="chip docs-segment-tab" data-scope="all" aria-pressed="true">{t('search.allDocs')}</button> + </div> + </div> + + <div class="body"> + <div class="results" id="docs-search-results" role="listbox" aria-label={t('search.results')}></div> + <p class="notice" hidden></p> + <button type="button" class="widen" hidden></button> + </div> + + <div class="hints" aria-hidden="true"> + <span><kbd>↑</kbd><kbd>↓</kbd> {t('keyboardHint.moveSelection')}</span> + <span><kbd>↵</kbd> {t('keyboardHint.openSelection')}</span> + <span class="hints__credit">{t('search.credit')}</span> + </div> + <p class="sr-only" role="status"></p> + </dialog> +</docs-search> + +{ + /* Inlined so the shortcut hint never shows the wrong modifier. `navigator.platform` stays truthful with devtools + open. */ +} +<script is:inline> + (() => { + const trigger = document.querySelector('docs-search .trigger'); + const shortcut = trigger?.querySelector('.trigger__shortcut'); + if (!trigger || !(shortcut instanceof HTMLElement)) return; + if (/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)) { + shortcut.firstElementChild.textContent = '⌘'; + trigger.setAttribute('aria-keyshortcuts', 'Meta+K /'); + } + shortcut.style.display = ''; + })(); +</script> + +<script> + import { localizedHref } from '../base-path'; + import { formatPlural, interpolate } from '../i18n.mjs'; + import { + RECENT_STORAGE_KEY, + addRecent, + groupByTopic, + matchSettings, + parseRecent, + resolveScope, + toPageRows, + } from './search.mjs'; + import { settingAnchorId } from './settings-reference.mjs'; + + type RecentEntry = { title: string; section?: string; url: string }; + type PageRow = { url: string; page: string; section?: string; excerpt: string; topic?: string }; + type SettingEntry = { key: string; type: string; default: string; description: string; env?: string }; + type SettingsIndex = { page: string; settings: SettingEntry[] }; + type PagefindResponse = { + results: { data: () => Promise<Parameters<typeof toPageRows>[0]> }[]; + unfilteredResultCount: number; + }; + type Pagefind = { + options: (options: Record<string, unknown>) => Promise<void>; + preload: (term: string, options?: object) => Promise<void>; + search: (term: string, options?: object) => Promise<PagefindResponse | null>; + }; + type Section = { label: string; rows: { element: HTMLAnchorElement; recent: RecentEntry }[] }; + /** The frontmatter's `i18n`. */ + type Strings = { + topicTitles: [string, string][]; + resultCount: Record<string, string>; + moreInAllDocs: Record<string, string>; + showInAllDocs: Record<string, string>; + } & Record< + | 'otherPages' + | 'recent' + | 'settingsGroup' + | 'settingDefault' + | 'loadError' + | 'devNotice' + | 'searchingIn' + | 'searchingAll' + | 'scopeChangeStatus' + | 'noResults' + | 'noResultsInTopic', + string + >; + + const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/'); + const DEBOUNCE_MS = 120; + const MAX_PAGES = 8; + const MAX_SECTIONS_PER_PAGE = 2; + const MAX_SETTINGS = 4; + + const ICON_PAGE = + '<svg class="row__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8zM14 3v5h5M9 13h6M9 17h4"/></svg>'; + const ICON_RECENT = + '<svg class="row__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 7v5l3 2M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/></svg>'; + + let pagefindPromise: Promise<Pagefind> | undefined; + /** Pagefind's API, from the index Starlight builds under the site base. A failed load retries on the next search. */ + const loadPagefind = () => + (pagefindPromise ??= (async () => { + const pagefind: Pagefind = await import(/* @vite-ignore */ `${BASE}pagefind/pagefind.js`); + await pagefind.options({ baseUrl: BASE }); + return pagefind; + })().catch((error) => { + pagefindPromise = undefined; + throw error; + })); + + let settingsPromise: Promise<SettingsIndex | null> | undefined; + /** Settings results are extras: without the index, search still shows pages. */ + const loadSettings = () => + (settingsPromise ??= fetch(`${BASE}settings-search.json`) + .then((response) => (response.ok ? (response.json() as Promise<SettingsIndex>) : null)) + .catch(() => null)); + + const readRecent = (): RecentEntry[] | undefined => { + try { + return parseRecent(localStorage.getItem(RECENT_STORAGE_KEY)); + } catch { + return undefined; + } + }; + const saveRecent = (entry: RecentEntry) => { + try { + localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(addRecent(readRecent() ?? [], entry))); + } catch { + // Storage is unavailable; recent searches are a convenience. + } + }; + + const make = <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string) => { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + }; + + /** Setting descriptions are plain text with Markdown code spans. */ + const appendWithCode = (parent: HTMLElement, text: string) => { + text.split('`').forEach((part, index) => { + if (part) parent.append(index % 2 === 1 ? make('code', undefined, part) : part); + }); + }; + + const makeTitle = (title: string, section?: string) => { + const element = make('span', 'row__title', title); + if (section) element.append(' ', make('span', 'row__section', `› ${section}`)); + return element; + }; + + const makeRow = (url: string, kind: string) => { + const row = make('a', `row row--${kind}`); + row.href = url; + row.tabIndex = -1; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', 'false'); + return row; + }; + + const pageRow = (row: PageRow) => { + const element = makeRow(row.url, 'page'); + element.insertAdjacentHTML('afterbegin', ICON_PAGE); + const text = make('span', 'row__text'); + const excerpt = make('span', 'row__excerpt'); + // Pagefind escapes the excerpt and marks the matches with <mark>. + excerpt.innerHTML = row.excerpt; + text.append(makeTitle(row.page, row.section), excerpt); + element.append(text); + return { element, recent: { title: row.page, section: row.section, url: row.url } }; + }; + + /** `defaultLabel` is the `{{value}}` template of the default value's pill. */ + const settingRow = (setting: SettingEntry, page: string, defaultLabel: string) => { + const url = `${page}#${settingAnchorId(setting.key)}`; + const element = makeRow(url, 'setting'); + const meta = make('span', 'row__meta'); + meta.append(make('code', 'row__key', setting.key), make('span', 'pill', setting.type)); + meta.append(make('span', 'pill', interpolate(defaultLabel, { value: setting.default }))); + if (setting.env) meta.append(make('span', 'row__env', setting.env)); + const description = make('span', 'row__excerpt'); + appendWithCode(description, setting.description); + element.append(meta, description); + return { element, recent: { title: setting.key, section: 'invokeai.yaml', url } }; + }; + + const recentRow = (entry: RecentEntry) => { + const element = makeRow(entry.url, 'recent'); + element.insertAdjacentHTML('afterbegin', ICON_RECENT); + element.append(makeTitle(entry.title, entry.section)); + return { element, recent: entry }; + }; + + const isEditable = (target: EventTarget | null) => + target instanceof HTMLElement && (target.isContentEditable || !!target.closest('input, textarea, select')); + + /** + * The search modal. Focus stays in the query field, a combobox that points at the active result with + * `aria-activedescendant`. A new query or scope supersedes any search still in flight, so results never arrive out + * of order. + */ + class DocsSearch extends HTMLElement { + connectedCallback() { + const trigger = this.querySelector<HTMLButtonElement>('.trigger')!; + const dialog = this.querySelector('dialog')!; + const input = dialog.querySelector('input')!; + const listbox = dialog.querySelector<HTMLElement>('[role="listbox"]')!; + const notice = dialog.querySelector<HTMLElement>('.notice')!; + const widen = dialog.querySelector<HTMLButtonElement>('.widen')!; + const status = dialog.querySelector<HTMLElement>('[role="status"]')!; + const chips = [...dialog.querySelectorAll<HTMLButtonElement>('.chip')]; + const topicChip = chips.find((chip) => chip.dataset.scope === 'topic')!; + + const i18n: Strings = JSON.parse(this.dataset.i18n!); + const topicTitles = new Map(i18n.topicTitles); + const locale = this.dataset.locale; + const lang = document.documentElement.lang; + const topicMeta = document.querySelector<HTMLMetaElement>('meta[name="docs-topic"]'); + const topic = topicMeta?.dataset.title ? { id: topicMeta.content, title: topicMeta.dataset.title } : undefined; + if (topic) topicChip.textContent = topic.title; + let state = resolveScope(topic); + /** The chips and every search read the scope from `state`. */ + const applyScope = (next = state.scope) => { + state = resolveScope(topic, next); + for (const chip of chips) { + const chipState = state.chips.find((candidate) => candidate.scope === chip.dataset.scope)!; + chip.hidden = chipState.hidden; + chip.setAttribute('aria-pressed', String(chipState.pressed)); + } + }; + applyScope(); + + let options: { element: HTMLAnchorElement; recent: RecentEntry }[] = []; + let activeIndex = -1; + let searchId = 0; + let returnFocus: HTMLElement = trigger; + // Set when a control changes the scope, and announced with the results the change brings. + let scopeAnnouncement = ''; + + const setActive = (index: number, { scroll = true } = {}) => { + options[activeIndex]?.element.setAttribute('aria-selected', 'false'); + activeIndex = index; + const option = options[index]?.element; + if (!option) { + input.removeAttribute('aria-activedescendant'); + return; + } + option.setAttribute('aria-selected', 'true'); + input.setAttribute('aria-activedescendant', option.id); + if (scroll) option.scrollIntoView({ block: 'nearest' }); + }; + + const render = ( + sections: Section[], + { message = '', widenLabel = '', announcement = '' }: Record<string, string> = {}, + ) => { + options = sections.flatMap((section) => section.rows); + listbox.replaceChildren( + ...sections.map((section, sectionIndex) => { + const group = make('div', 'group'); + group.setAttribute('role', 'group'); + group.setAttribute('aria-label', section.label); + const heading = make('span', 'group__label', section.label); + heading.setAttribute('aria-hidden', 'true'); + group.append(heading); + for (const [rowIndex, row] of section.rows.entries()) { + row.element.id = `docs-search-option-${sectionIndex}-${rowIndex}`; + group.append(row.element); + } + return group; + }), + ); + listbox.hidden = options.length === 0; + listbox.removeAttribute('aria-busy'); + input.setAttribute('aria-expanded', String(options.length > 0)); + activeIndex = -1; + setActive(options.length > 0 ? 0 : -1); + notice.textContent = message; + notice.hidden = !message; + widen.textContent = widenLabel; + widen.hidden = !widenLabel; + status.textContent = + scopeAnnouncement && announcement + ? interpolate(i18n.scopeChangeStatus, { scope: scopeAnnouncement, results: announcement }) + : scopeAnnouncement || announcement; + scopeAnnouncement = ''; + }; + + const renderRecent = () => { + const recent = readRecent() ?? []; + render(recent.length > 0 ? [{ label: i18n.recent, rows: recent.map(recentRow) }] : []); + }; + + const search = async ({ debounce }: { debounce: boolean }) => { + const id = ++searchId; + // Pagefind's index only exists after a build. + if (import.meta.env.DEV) return render([], { message: i18n.devNotice }); + const query = input.value.trim(); + if (!query) return renderRecent(); + + listbox.setAttribute('aria-busy', 'true'); + const { scoped, filters, showSettings } = state; + const searchOptions = filters ? { filters } : {}; + try { + const pagefindReady = loadPagefind(); + if (debounce) { + pagefindReady.then((pagefind) => pagefind.preload(query, searchOptions)).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_MS)); + if (id !== searchId) return; + } + const [pagefind, settingsIndex] = await Promise.all([pagefindReady, loadSettings()]); + const response = id === searchId ? await pagefind.search(query, searchOptions) : null; + if (id !== searchId || !response) return; + const pages = await Promise.all(response.results.slice(0, MAX_PAGES).map((result) => result.data())); + if (id !== searchId) return; + + const settings = settingsIndex ? matchSettings(settingsIndex.settings, query) : []; + const sections: Section[] = []; + if (showSettings && settings.length > 0) { + sections.push({ + label: i18n.settingsGroup, + rows: settings + .slice(0, MAX_SETTINGS) + .map((setting) => + settingRow(setting, localizedHref(settingsIndex!.page, { locale, base: BASE }), i18n.settingDefault), + ), + }); + } + const rows = pages.flatMap((page) => toPageRows(page, MAX_SECTIONS_PER_PAGE)); + for (const group of groupByTopic(rows, topicTitles, i18n.otherPages)) { + sections.push({ label: group.label, rows: group.rows.map(pageRow) }); + } + + const found = response.results.length + (showSettings ? settings.length : 0); + const elsewhere = scoped + ? response.unfilteredResultCount - response.results.length + (showSettings ? 0 : settings.length) + : 0; + if (sections.length > 0) { + render(sections, { + widenLabel: elsewhere > 0 ? formatPlural(i18n.moreInAllDocs, elsewhere, lang) : '', + announcement: formatPlural(i18n.resultCount, found, lang), + }); + } else { + const message = scoped + ? interpolate(i18n.noResultsInTopic, { query, topic: scoped.title }) + : interpolate(i18n.noResults, { query }); + render([], { + message, + widenLabel: elsewhere > 0 ? formatPlural(i18n.showInAllDocs, elsewhere, lang) : '', + announcement: message, + }); + } + } catch (error) { + if (id !== searchId) return; + console.error('[docs-search]', error); + render([], { message: i18n.loadError, announcement: i18n.loadError }); + } + }; + + const setScope = (next: typeof state.scope) => { + applyScope(next); + scopeAnnouncement = state.scoped + ? interpolate(i18n.searchingIn, { topic: state.scoped.title }) + : i18n.searchingAll; + search({ debounce: false }); + }; + + const open = () => { + if (dialog.open) return; + const focused = document.activeElement; + returnFocus = focused instanceof HTMLElement && focused !== document.body ? focused : trigger; + dialog.showModal(); + document.body.toggleAttribute('data-search-modal-open', true); + input.select(); + search({ debounce: false }); + }; + const close = () => dialog.close(); + + trigger.addEventListener('click', open); + // Start loading on intent, so the first search does not wait for the index. + trigger.addEventListener('pointerenter', () => !import.meta.env.DEV && loadPagefind().catch(() => {}), { + once: true, + }); + dialog.querySelector('.field__cancel')!.addEventListener('click', close); + dialog.addEventListener('close', () => { + searchId++; + document.body.toggleAttribute('data-search-modal-open', false); + returnFocus.focus({ preventScroll: true }); + }); + // Close on a click on the backdrop, which targets the dialog itself; the press must start there too, so a text + // selection dragged out of the field does not close it. + let pressedBackdrop = false; + dialog.addEventListener('pointerdown', (event) => (pressedBackdrop = event.target === dialog)); + dialog.addEventListener('click', (event) => { + if (pressedBackdrop && event.target === dialog) close(); + }); + + window.addEventListener('keydown', (event) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === 'k') { + event.preventDefault(); + if (dialog.open) close(); + else open(); + } else if ( + event.key === '/' && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !dialog.open && + !isEditable(event.target) + ) { + event.preventDefault(); + open(); + } + }); + + input.addEventListener('input', () => search({ debounce: true })); + input.addEventListener('keydown', (event) => { + if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && options.length > 0) { + event.preventDefault(); + const step = event.key === 'ArrowDown' ? 1 : -1; + setActive((activeIndex + step + options.length) % options.length); + } else if (event.key === 'Enter' && options[activeIndex] && !event.isComposing) { + event.preventDefault(); + options[activeIndex].element.click(); + } + }); + + // Keep Tab cycling through the dialog's controls instead of leaving for the browser's own. + dialog.addEventListener('keydown', (event) => { + if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) return; + const focusable = [...dialog.querySelectorAll<HTMLElement>('input, button')].filter( + (element) => !element.hidden && element.getClientRects().length > 0, + ); + const edge = event.shiftKey ? focusable[0] : focusable.at(-1); + if (document.activeElement !== edge) return; + event.preventDefault(); + (event.shiftKey ? focusable.at(-1) : focusable[0])?.focus(); + }); + + listbox.addEventListener('pointermove', (event) => { + const option = (event.target as Element).closest('[role="option"]'); + const index = options.findIndex((candidate) => candidate.element === option); + if (index !== -1 && index !== activeIndex) setActive(index, { scroll: false }); + }); + listbox.addEventListener('click', (event) => { + const option = (event.target as Element).closest('[role="option"]'); + const opened = options.find((candidate) => candidate.element === option); + if (!opened) return; + saveRecent(opened.recent); + // A link opened in a new tab or window leaves the modal open here. + if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0)) close(); + }); + + for (const chip of chips) { + chip.addEventListener('click', (event) => { + setScope(chip.dataset.scope as typeof state.scope); + // A pointer returns to typing; a key press (no click count) keeps focus where the user tabbed to. + if (event.detail > 0) input.focus(); + }); + } + // The button hides once the scope widens, so focus moves to the field rather than being lost. + widen.addEventListener('click', () => { + setScope('all'); + input.focus(); + }); + } + } + customElements.define('docs-search', DocsSearch); +</script> + +<style> + docs-search { + display: contents; + } + + /* Header trigger */ + + .trigger { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: var(--docs-header-control-height); + height: var(--docs-header-control-height); + padding: 0; + border: 0; + border-radius: var(--docs-header-control-radius); + background: transparent; + color: var(--docs-text-subtle); + font: inherit; + cursor: pointer; + transition: var(--docs-control-transition); + + /* An icon button on narrow screens, like the header's other ghost icon buttons. */ + &:hover { + background: var(--docs-hover); + color: var(--docs-text-strong); + } + } + + .trigger__icon { + flex: none; + } + + .trigger__label, + .trigger__shortcut { + display: none; + } + + /* + * Wide screens show a search field, styled as the app's search inputs (`InputShell`): a 1px border that emphasizes + * on hover, 8px start padding, 6px gaps, a 14px muted icon and 12px text. Its height and radius are the header + * controls' (`--docs-header-control-height`, 36px, the app's `control.xl`; `--docs-header-control-radius`, 7px). + */ + @media (min-width: 50rem) { + .trigger { + justify-content: flex-start; + gap: 0.375rem; + width: 100%; + height: var(--docs-header-control-height); + padding-inline: 0.5rem 0.375rem; + border: 1px solid var(--invoke-neutral-600); + border-radius: var(--docs-header-control-radius); + background: transparent; + font-size: var(--docs-control-font-size); + transition: var(--docs-control-transition); + + &:hover { + border-color: var(--docs-border-strong); + background: transparent; + } + } + + .trigger__icon { + width: 0.875rem; + height: 0.875rem; + color: var(--invoke-neutral-400); + } + + .trigger__label { + display: block; + } + + .trigger__shortcut { + display: flex; + gap: 0.125rem; + margin-inline-start: auto; + } + } + + kbd { + font: 500 0.6875rem var(--__sl-font-mono); + } + + .trigger__shortcut, + .field__esc { + padding: 0.125rem 0.375rem; + border: 1px solid var(--docs-border); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--docs-control); + color: var(--docs-text-muted); + + kbd { + font: inherit; + } + } + + /* Modal */ + + .modal { + flex-direction: column; + width: 100%; + max-width: 100%; + height: 100%; + max-height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + border: 0; + background: var(--docs-surface); + color: var(--docs-text-strong); + font-size: 0.8125rem; + + &[open] { + display: flex; + } + + &::backdrop { + background: var(--sl-color-backdrop-overlay); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + } + } + + :global([data-search-modal-open]) { + overflow: hidden; + } + + .field { + display: flex; + flex: none; + align-items: center; + gap: 0.625rem; + height: 3.25rem; + padding-inline: 1rem; + border-bottom: 1px solid var(--docs-border); + color: var(--docs-text-subtle); + + input { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + outline: none; + background: transparent; + color: var(--docs-text-strong); + /* 16px keeps iOS from zooming into the field. */ + font: 400 1rem var(--__sl-font); + + &::placeholder { + color: var(--docs-text-subtle); + } + } + } + + .field__esc { + display: none; + } + + .field__cancel { + flex: none; + height: 2rem; + padding-inline: 0.5rem; + margin-inline-end: -0.5rem; + border: 0; + border-radius: var(--docs-radius); + background: transparent; + color: var(--docs-accent); + font: 500 0.8125rem var(--__sl-font); + cursor: pointer; + + &:hover { + background: var(--docs-hover); + } + } + + .scopes { + display: flex; + flex: none; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--docs-border); + } + + .scopes__label { + color: var(--docs-text-subtle); + font-size: var(--docs-control-font-size); + } + + /* The chips are buttons: clear the browser's button look under the shared tab styles, which set no background. */ + :global(:where(docs-search .chip)) { + background-color: transparent; + font-family: inherit; + } + + /* The strip shows every child, so a page without a topic drops its chip and the divider after it here. */ + .chip[hidden] { + display: none; + } + .chip[hidden] + .chip::before { + content: none; + } + + .body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.375rem; + + &:has(.results[hidden]):has(.notice[hidden]):has(.widen[hidden]) { + display: none; + } + } + + .results { + display: flex; + flex-direction: column; + gap: 0.25rem; + + &[hidden] { + display: none; + } + } + + .results :global(.group) { + display: flex; + flex-direction: column; + gap: 1px; + } + + .results :global(.group__label) { + padding: 0.625rem 0.625rem 0.25rem; + color: var(--docs-text-subtle); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .results :global(.row) { + display: flex; + gap: 0.75rem; + padding: 0.625rem; + border-radius: var(--docs-radius); + color: var(--docs-text-strong); + text-decoration: none; + } + + .results :global(.row[aria-selected='true']) { + background: var(--docs-border); + } + + .results :global(.row--setting) { + flex-direction: column; + gap: 0.25rem; + } + + .results :global(.row--recent) { + align-items: center; + gap: 0.625rem; + padding-block: 0.5rem; + } + + .results :global(.row__icon) { + flex: none; + color: var(--docs-text-subtle); + } + + .results :global(.row--page .row__icon) { + margin-top: 0.125rem; + } + + .results :global(.row__text) { + display: flex; + flex-direction: column; + gap: 0.1875rem; + min-width: 0; + } + + .results :global(.row__title) { + min-width: 0; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.4; + overflow-wrap: anywhere; + } + + .results :global(.row--recent .row__title) { + font-weight: 400; + } + + .results :global(.row__section) { + color: var(--docs-text-subtle); + font-weight: 400; + } + + .results :global(.row__excerpt) { + display: -webkit-box; + overflow: hidden; + color: var(--docs-text-muted); + font-size: 0.8125rem; + line-height: 1.5; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + } + + .results :global(.row__excerpt:empty) { + display: none; + } + + .results :global(mark) { + background: transparent; + color: var(--docs-accent-text); + font-weight: 600; + } + + .results :global(.row__excerpt code) { + padding: 0 0.25rem; + border-radius: 3px; + background: var(--docs-control); + font: 0.75rem var(--__sl-font-mono); + } + + .results :global(.row__meta) { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + min-width: 0; + } + + .results :global(.row__key) { + min-width: 0; + color: var(--docs-accent); + font: 500 0.8125rem var(--__sl-font-mono); + overflow-wrap: anywhere; + } + + .results :global(.pill) { + max-width: 100%; + overflow: hidden; + padding: 0.0625rem 0.375rem; + border-radius: 4px; + background: var(--docs-control); + color: var(--docs-text-muted); + font: 500 0.6875rem var(--__sl-font-mono); + text-overflow: ellipsis; + white-space: nowrap; + } + + .results :global(.row__env) { + color: var(--docs-text-subtle); + font: 0.6875rem var(--__sl-font-mono); + } + + .notice { + margin: 0; + padding: 1.5rem 0.625rem 0.5rem; + color: var(--docs-text-muted); + text-align: center; + white-space: pre-line; + overflow-wrap: anywhere; + } + + .widen { + align-self: flex-start; + flex: none; + height: 1.75rem; + margin: 0.5rem 0.625rem; + padding-inline: 0.625rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius); + background: var(--docs-control); + color: var(--docs-text-strong); + font: 500 0.75rem var(--__sl-font); + cursor: pointer; + + &:hover { + border-color: var(--docs-border-strong); + } + + &[hidden] { + display: none; + } + } + + .notice:not([hidden]) + .widen { + align-self: center; + } + + .hints { + display: none; + flex: none; + flex-wrap: wrap; + gap: 0.875rem; + padding: 0.5rem 1rem; + border-top: 1px solid var(--docs-border); + color: var(--docs-text-subtle); + font-size: 0.6875rem; + + kbd { + font-family: var(--__sl-font-mono); + } + } + + .hints__credit { + margin-inline-start: auto; + } + + @media (min-width: 50rem) { + .modal { + width: min(42.5rem, calc(100% - 2rem)); + height: auto; + max-height: min(35rem, calc(100dvh - 8rem)); + margin: 4rem auto auto; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-lg); + box-shadow: var(--docs-shadow-popup); + } + + .field__esc { + display: block; + } + + .field__cancel { + display: none; + } + + .hints { + display: flex; + } + } + + @media (prefers-reduced-motion: no-preference) and (min-width: 50rem) { + .modal[open] { + transition: + opacity var(--docs-popup-duration) var(--docs-popup-easing), + translate var(--docs-popup-duration) var(--docs-popup-easing); + + @starting-style { + opacity: 0; + translate: 0 var(--docs-popup-offset); + } + } + } +</style> diff --git a/docs/src/lib/components/SettingsDocs.astro b/docs/src/lib/components/SettingsDocs.astro index c539b27cd46..374d1873cba 100644 --- a/docs/src/lib/components/SettingsDocs.astro +++ b/docs/src/lib/components/SettingsDocs.astro @@ -1,6 +1,8 @@ --- import settingsData from '../../generated/settings.json'; +import { formatSettingType, formatSettingValue, settingAnchorId } from './settings-reference.mjs'; +const { t } = Astro.locals; const groupedSettings = Object.entries( settingsData.settings.reduce((groups, setting) => { const category = setting.category || 'OTHER'; @@ -12,32 +14,6 @@ const groupedSettings = Object.entries( }, {}), ); -const formatValue = (value) => { - if (value === null) { - return 'null'; - } - if (Array.isArray(value) || typeof value === 'object') { - return JSON.stringify(value); - } - return String(value); -}; - -/** Clean up Python type representations for display */ -const formatType = (typeStr) => { - // Strip <class '...'> wrapper - const classMatch = typeStr.match(/^<class '(.+)'>$/); - if (classMatch) { - const inner = classMatch[1]; - // Strip module paths (e.g. pathlib.Path -> Path) - return inner.split('.').pop(); - } - // Strip typing. prefix - let cleaned = typeStr.replace(/typing\./g, ''); - // Strip module paths inside brackets - cleaned = cleaned.replace(/[a-z_][a-z0-9_.]*\.([A-Z][A-Za-z]*)/g, '$1'); - return cleaned; -}; - /** Format category name: "MODEL INSTALL" -> "Model Install" */ const formatCategoryName = (category) => { return category @@ -55,22 +31,22 @@ const formatCategoryName = (category) => { </summary> <dl class="settings-ref-list"> {settings.map((setting) => ( - <div class="settings-ref-entry"> + <div class="settings-ref-entry" id={settingAnchorId(setting.name)}> <!-- Setting name --> <dt class="settings-ref-name-row"> <code class="settings-ref-name">{setting.name}</code> </dt> <!-- Granular details --> <dd class="settings-ref-col settings-ref-type-col"> - <span class="settings-ref-label">Type</span> - <code>{formatType(setting.type)}</code> + <span class="settings-ref-label">{t('settingsReference.settingType')}</span> + <code>{formatSettingType(setting.type)}</code> </dd> <dd class="settings-ref-col settings-ref-default-col"> - <span class="settings-ref-label">Default</span> - <code>{formatValue(setting.default)}</code> + <span class="settings-ref-label">{t('settingsReference.defaultValue')}</span> + <code>{formatSettingValue(setting.default)}</code> </dd> <dd class="settings-ref-col settings-ref-env-col"> - <span class="settings-ref-label">Env</span> + <span class="settings-ref-label">{t('settingsReference.envVar')}</span> <code>{setting.env_var}</code> </dd> <!-- Description --> @@ -78,13 +54,13 @@ const formatCategoryName = (category) => { {setting.description} {setting.literal_values.length > 0 && ( <span class="settings-ref-values"> - Values: {setting.literal_values.map((v) => <code>{formatValue(v)}</code>).reduce((prev, curr) => [prev, ' ', curr])} + {t('settingsReference.allowedValues')} {setting.literal_values.map((v) => <code>{formatSettingValue(v)}</code>).reduce((prev, curr) => [prev, ' ', curr])} </span> )} {Object.keys(setting.validation).length > 0 && ( <span class="settings-ref-constraints"> {Object.entries(setting.validation).map(([key, value]) => ( - <code>{key}={formatValue(value)}</code> + <code>{key}={formatSettingValue(value)}</code> )).reduce((prev, curr) => [prev, ' ', curr])} </span> )} @@ -95,13 +71,28 @@ const formatCategoryName = (category) => { </details> ))} +<script> + // Search links to a setting by its id. Browsers that don't open a closed <details> for a fragment target would + // otherwise scroll to nothing. + const revealTarget = () => { + const id = decodeURIComponent(location.hash.slice(1)); + const target = id ? document.getElementById(id) : null; + const details = target?.closest('details.settings-accordion'); + if (!target || !(details instanceof HTMLDetailsElement) || details.open) return; + details.open = true; + target.scrollIntoView(); + }; + revealTarget(); + window.addEventListener('hashchange', revealTarget); +</script> + <style> .settings-accordion { padding: 0; display: flex; flex-direction: column; border: 1px solid var(--sl-color-hairline); - border-radius: var(--radius, 0.35rem); + border-radius: var(--docs-radius-container); margin-bottom: 0.5rem; background: transparent; } @@ -173,6 +164,10 @@ const formatCategoryName = (category) => { background: var(--sl-color-bg-sidebar); } + .settings-ref-entry:target { + background: var(--docs-accent-tint); + } + .settings-ref-name-row { grid-column: 1 / -1; margin: 0; diff --git a/docs/src/lib/components/SetupPicker.astro b/docs/src/lib/components/SetupPicker.astro new file mode 100644 index 00000000000..551d514b23b --- /dev/null +++ b/docs/src/lib/components/SetupPicker.astro @@ -0,0 +1,162 @@ +--- +import { + localizeSetupOptions, + mountSetupPicker, + resolveSetup, + SETUP_OPTIONS, + SETUP_STORAGE_KEY, + setupVisibilityCss, +} from './setup-picker.mjs'; +import Trans from './Trans.astro'; + +type Props = { + /** + * What the setup variants below the picker hold, for its fallback and summary: install `commands` (the default) or + * platform `notes`. + */ + shows?: 'commands' | 'notes'; + /** + * For a component that builds the picker into its own panel and words its own fallback and summary: drops the + * picker's border, fallback and summary. + */ + embedded?: boolean; +}; + +const { shows = 'commands', embedded = false } = Astro.props as Props; +const { t } = Astro.locals; +const fallback = shows === 'notes' ? t('setupPicker.fallbackNotes') : t('setupPicker.fallback'); +const summary = shows === 'notes' ? t('setupPicker.summaryNotes', { setup: '' }) : t('setupPicker.summary', { setup: '' }); +const options = localizeSetupOptions(SETUP_OPTIONS, t); + +// One radio per GPU id; its label text and availability follow the selected OS. +const gpus = [...new Set(options.flatMap((os) => os.gpus.map((gpu) => gpu.id)))].map((id) => ({ + id, + labels: options.flatMap((os) => os.gpus.filter((gpu) => gpu.id === id).map((gpu) => ({ os: os.id, label: gpu.label }))), +})); + +// The shared segment tabs draw a divider before every chip but the first. GPU chips hidden for the selected OS still +// count as siblings, so drop the divider of each OS's first shown GPU when hidden chips precede it. +const dividerCss = options.map((os) => ({ os: os.id, first: gpus.find((gpu) => gpu.labels.some((l) => l.os === os.id)) })) + .filter(({ first }) => first && first !== gpus[0]) + .map(({ os, first }) => `:root[data-setup-os="${os}"] .docs-segment-tab:has(> input[value="${first?.id}"])::before{display:none}`) + .join('\n'); +const visibilityCss = `${setupVisibilityCss(options)}\n${dividerCss}`; +// Escaping `<` keeps translated labels from closing the script. +const optionsJson = JSON.stringify(options).replaceAll('<', '\\u003c'); +const mountScript = `(${mountSetupPicker})(document.currentScript.parentElement, ${optionsJson}, ${resolveSetup}, ${JSON.stringify(SETUP_STORAGE_KEY)});`; +--- + +<Fragment set:html={`<style>${visibilityCss}</style>`} /> + +<div class:list={['setup-picker not-content', embedded && 'setup-picker--embedded']} data-pagefind-ignore> + {!embedded && <p class="setup-picker__fallback">{fallback}</p>} + <div class="setup-picker__panel"> + <fieldset class="setup-picker__row"> + <legend class="docs-eyebrow">{t('setupPicker.os')}</legend> + <div class="setup-picker__segments docs-segment-tabs"> + { + options.map((os) => ( + <label class="docs-segment-tab"> + <input type="radio" name="setup-os" value={os.id} /> + {os.label} + </label> + )) + } + </div> + </fieldset> + <fieldset class="setup-picker__row"> + <legend class="docs-eyebrow">{t('setupPicker.gpu')}</legend> + <div class="setup-picker__segments docs-segment-tabs"> + { + gpus.map((gpu) => ( + <label class="docs-segment-tab" data-setup-for-os={gpu.labels.map(({ os }) => os).join(' ')}> + <input type="radio" name="setup-gpu" value={gpu.id} /> + {new Set(gpu.labels.map(({ label }) => label)).size === 1 ? ( + gpu.labels[0].label + ) : ( + gpu.labels.map(({ os, label }) => <span data-setup-for-os={os}>{label}</span>) + )} + </label> + )) + } + </div> + </fieldset> + { + !embedded && ( + <p class="setup-picker__summary" aria-live="polite" aria-atomic="true"> + {/* The script fills in the setup. */} + <Trans message={summary}> + {(text: string) => <strong data-setup-summary>{text}</strong>} + </Trans> + </p> + ) + } + </div> + <script is:inline set:html={mountScript} /> +</div> + +<style> + /* + * A bordered panel like the content tabs' container: the OS and GPU segment tabs are inset 6px, and the labels and + * summary start 16px in, in line with the chips' text (6px inset + 10px chip padding). + */ + .setup-picker__panel { + display: none; + flex-direction: column; + gap: 0.25rem; + padding: 6px; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + } + + /* The host's panel draws the border; the rows keep their insets so they line up with the host's own rows. */ + .setup-picker--embedded .setup-picker__panel { + padding: 0; + border: 0; + border-radius: 0; + background: none; + } + + .setup-picker__fallback { + margin: 0; + color: var(--docs-text-subtle); + } + + :global(:root[data-setup-os]) .setup-picker__panel { + display: flex; + } + + :global(:root[data-setup-os]) .setup-picker__fallback { + display: none; + } + + .setup-picker__row { + min-width: 0; + margin: 0; + padding: 0; + border: 0; + } + + /* Labels start 16px in, in line with the chips' text. */ + .setup-picker__row legend { + padding: 0.375rem var(--docs-tab-pad-x) 0.25rem; + } + + /* Five Windows GPU chips can outgrow a phone-width panel; the shared strip does not wrap. */ + .setup-picker__segments { + flex-wrap: wrap; + } + + .setup-picker__summary { + margin: 0; + padding: 0.25rem var(--docs-tab-pad-x) 0.375rem; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + } + + .setup-picker__summary strong { + color: var(--docs-text-muted); + font-weight: 500; + } +</style> diff --git a/docs/src/lib/components/SetupSwitch.astro b/docs/src/lib/components/SetupSwitch.astro new file mode 100644 index 00000000000..6e24ae0bf24 --- /dev/null +++ b/docs/src/lib/components/SetupSwitch.astro @@ -0,0 +1,24 @@ +--- +// Groups SetupVariant alternatives of which exactly one shows for each setup. Fails the build when a +// setup the picker offers would see none of them, or more than one. Content that only adds to one +// setup (a note, a table) belongs outside the switch. +import { findCoverageGaps, localizeSetupOptions, SETUP_OPTIONS, variantsInHtml } from './setup-picker.mjs'; + +const html = await Astro.slots.render('default'); +const variants = variantsInHtml(html); +const { missing, repeated } = findCoverageGaps(localizeSetupOptions(SETUP_OPTIONS, Astro.locals.t), variants); +if (missing.length || repeated.length) { + throw new Error( + // Astro.url names the route being rendered (possibly llms.txt), not the source page, so name the variants. + `SetupSwitch with variants ${variants.map((v) => `[${v.os?.join(' ') ?? 'any'} / ${v.gpu?.join(' ') ?? 'any'}]`).join(' ')}: ` + + [ + missing.length ? `no variant for ${missing.join(', ')}` : '', + repeated.length ? `several variants for ${repeated.join(', ')}` : '', + ] + .filter(Boolean) + .join('; ') + ); +} +--- + +<Fragment set:html={html} /> diff --git a/docs/src/lib/components/SetupVariant.astro b/docs/src/lib/components/SetupVariant.astro new file mode 100644 index 00000000000..59bb2a5c3a0 --- /dev/null +++ b/docs/src/lib/components/SetupVariant.astro @@ -0,0 +1,47 @@ +--- +import { describeVariant, localizeSetupOptions, SETUP_OPTIONS } from './setup-picker.mjs'; + +type Props = { + /** Space-separated OS ids this content applies to; omit for every OS. */ + os?: string; + /** Space-separated GPU ids this content applies to; omit for every GPU. */ + gpu?: string; + /** Replaces the generated label shown when no setup is picked. */ + label?: string; +}; + +const { os, gpu, label } = Astro.props as Props; +const variant = describeVariant(localizeSetupOptions(SETUP_OPTIONS, Astro.locals.t), { os, gpu }); +--- + +<div + class="setup-variant" + data-setup-variant + data-setup-for-os={variant.os?.join(' ')} + data-setup-for-gpu={variant.gpu?.join(' ')} +> + <p class="setup-variant__label" data-pagefind-ignore>{label ?? variant.label}</p> + <slot /> +</div> + +<style> + .setup-variant__label { + display: inline-flex; + padding: 0.25rem var(--docs-tab-pad-x); + border-radius: var(--docs-radius); + background-color: var(--docs-accent-tint); + color: var(--docs-accent-text); + font-size: var(--docs-control-font-size); + font-weight: 500; + line-height: 1; + } + + /* With a setup picked, only the matching variant shows, so its label adds nothing. */ + :global(:root[data-setup-os]) .setup-variant__label { + display: none; + } + + :global(:root[data-setup-os]) .setup-variant__label + :global(*) { + margin-top: 0; + } +</style> diff --git a/docs/src/lib/components/ShowcaseGrid.astro b/docs/src/lib/components/ShowcaseGrid.astro new file mode 100644 index 00000000000..868dda2d19d --- /dev/null +++ b/docs/src/lib/components/ShowcaseGrid.astro @@ -0,0 +1,407 @@ +--- +// Showcase artwork in justified rows that keep each image's aspect ratio. A tile opens the artwork in a dialog with its +// full credit, and so does a link to the tile's anchor, `/showcase/#showcase-<entry id>`. +import ArrowLeft from '@lucide/astro/icons/arrow-left'; +import ArrowRight from '@lucide/astro/icons/arrow-right'; +import X from '@lucide/astro/icons/x'; +import type { CollectionEntry } from 'astro:content'; +import { getImage, Image } from 'astro:assets'; + +type Props = { + entries: CollectionEntry<'showcase'>[]; +}; + +const { entries } = Astro.props; +const tileId = (entry: CollectionEntry<'showcase'>) => `showcase-${entry.id}`; +const dateFormat = new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }); +const maxWidth = 2400; +const lightboxWidths = (width: number) => [960, 1600, maxWidth].filter((candidate) => candidate < width).concat(Math.min(width, maxWidth)); +// Without JavaScript, or on a modified click, a tile opens its largest size. Astro emits only the sizes it is asked for. +const fullImages = await Promise.all( + entries.map(({ data: { image } }) => getImage({ src: image, width: Math.min(image.width, maxWidth) })) +); +--- + +<div class="showcase not-content"> + <ul class="grid" role="list"> + { + entries.map((entry, index) => { + const { image, title, artist, model } = entry.data; + return ( + <li class="tile" id={tileId(entry)} style={`--ratio: ${image.width / image.height}`}> + <a href={fullImages[index].src} data-showcase-index={index}> + {/* The caption names the artwork; the lightbox gives its full credit. */} + <Image + src={image} + alt="" + widths={[320, 480, 720, 960].filter((width) => width < image.width).concat(image.width)} + sizes={`(min-width: 50rem) ${Math.ceil((image.width / image.height) * 22)}rem, 100vw`} + /> + <span class="caption"> + <span class="caption__title">{title}</span> + <span class="caption__meta"> + {artist} + {model && ` · ${model}`} + </span> + </span> + </a> + </li> + ); + }) + } + </ul> + + {entries.map(({ data: { image, title, artist, artistUrl, model, createdWith, date } }) => ( + <template data-showcase-credit> + <figure class="artwork"> + <div class="artwork__image"> + <Image + src={image} + alt={title} + widths={lightboxWidths(image.width)} + sizes="min(90rem, 100vw)" + loading="eager" + /> + </div> + <figcaption class="credit"> + <h2 id="showcase-lightbox-title">{title}</h2> + <p class="credit__artist"> + by{' '} + {artistUrl ? ( + <a href={artistUrl} target="_blank" rel="nofollow ugc noopener noreferrer"> + {artist} + </a> + ) : ( + artist + )} + </p> + <dl> + {model && ( + <div> + <dt>Model</dt> + <dd>{model}</dd> + </div> + )} + {createdWith && ( + <div> + <dt>Made with</dt> + <dd>{createdWith}</dd> + </div> + )} + <div> + <dt>Added</dt> + <dd> + <time datetime={date.toISOString().slice(0, 10)}>{dateFormat.format(date)}</time> + </dd> + </div> + </dl> + </figcaption> + </figure> + </template> + ))} + + <dialog class="lightbox" aria-labelledby="showcase-lightbox-title" data-showcase-lightbox> + <div class="lightbox__frame"> + <div class="lightbox__toolbar"> + <p class="lightbox__counter" data-lightbox-counter /> + <div class="lightbox__buttons"> + {entries.length > 1 && ( + <> + <button type="button" class="docs-button docs-button--ghost docs-button--icon docs-button--lg" data-lightbox-step="-1" aria-label="Previous artwork"> + <ArrowLeft aria-hidden="true" /> + </button> + <button type="button" class="docs-button docs-button--ghost docs-button--icon docs-button--lg" data-lightbox-step="1" aria-label="Next artwork"> + <ArrowRight aria-hidden="true" /> + </button> + </> + )} + <button type="button" class="docs-button docs-button--ghost docs-button--icon docs-button--lg" data-lightbox-close aria-label="Close" autofocus> + <X aria-hidden="true" /> + </button> + </div> + </div> + <div class="lightbox__content" data-lightbox-content /> + </div> + </dialog> +</div> + +<script> + const dialog = document.querySelector<HTMLDialogElement>('[data-showcase-lightbox]'); + if (dialog) { + const tiles = [...document.querySelectorAll<HTMLAnchorElement>('[data-showcase-index]')]; + const credits = [...document.querySelectorAll<HTMLTemplateElement>('template[data-showcase-credit]')]; + const content = dialog.querySelector<HTMLElement>('[data-lightbox-content]')!; + const counter = dialog.querySelector<HTMLElement>('[data-lightbox-counter]')!; + let current = 0; + + const show = (index: number) => { + current = (index + tiles.length) % tiles.length; + content.replaceChildren(credits[current].content.cloneNode(true)); + counter.textContent = `${current + 1} of ${tiles.length}`; + }; + + const open = (index: number) => { + show(index); + if (!dialog.open) dialog.showModal(); + }; + + // A link to a tile's anchor (`#showcase-<entry id>`) opens its artwork. Nothing here adds history entries, so + // Back and Forward leave the page as usual; a hash that names no artwork closes the dialog. + const tileIndexFromHash = () => { + const id = decodeURIComponent(location.hash.slice(1)); + return id ? tiles.findIndex((tile) => tile.parentElement?.id === id) : -1; + }; + const openFromHash = () => { + const index = tileIndexFromHash(); + if (index >= 0) open(index); + else if (dialog.open) dialog.close(); + }; + + tiles.forEach((tile, index) => { + tile.addEventListener('click', (event) => { + // Modified clicks still open the full-size image the link points at. + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + open(index); + }); + }); + + dialog.querySelectorAll<HTMLButtonElement>('[data-lightbox-step]').forEach((button) => { + button.addEventListener('click', () => show(current + Number(button.dataset.lightboxStep))); + }); + dialog.querySelector('[data-lightbox-close]')?.addEventListener('click', () => dialog.close()); + + dialog.addEventListener('keydown', (event) => { + if (tiles.length < 2 || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return; + event.preventDefault(); + show(current + (event.key === 'ArrowLeft' ? -1 : 1)); + }); + // The frame fills the dialog, so a click on the dialog itself is a click on the backdrop. + dialog.addEventListener('click', (event) => { + if (event.target === dialog) dialog.close(); + }); + dialog.addEventListener('close', () => { + content.replaceChildren(); + // Leave the URL clean, replacing the current entry rather than adding one. + if (location.hash) history.replaceState(history.state, '', location.pathname + location.search); + tiles[current].focus(); + }); + + window.addEventListener('hashchange', openFromHash); + openFromHash(); + } +</script> + +<style> + .showcase { + --row-height: 11rem; + } + + @media (min-width: 50rem) { + .showcase { + --row-height: 16rem; + } + } + + .grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + + /* Takes the free space in the last row, so its tiles keep the row height instead of stretching. */ + &::after { + content: ''; + flex-grow: 1000000; + } + } + + /* Growing and shrinking in proportion to the aspect ratio keeps every tile in a row at the same height. */ + .tile { + flex: var(--ratio) 1 calc(var(--ratio) * var(--row-height)); + min-width: 0; + } + + .tile a { + position: relative; + display: block; + overflow: hidden; + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + color: var(--docs-text-strong); + text-decoration: none; + } + + .tile img { + display: block; + width: 100%; + height: auto; + aspect-ratio: var(--ratio); + } + + .caption { + position: absolute; + inset: auto 0 0; + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 2rem 0.75rem 0.625rem; + background: linear-gradient(transparent, color-mix(in oklab, var(--docs-bg) 92%, transparent)); + font-size: 0.8125rem; + line-height: 1.35; + opacity: 0; + transition: opacity 150ms ease; + } + + .tile a:hover .caption, + .tile a:focus-visible .caption { + opacity: 1; + } + + /* Touch screens have no hover, so the caption always shows. */ + @media (hover: none) { + .caption { + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + .caption { + transition: none; + } + } + + .caption__title { + font-weight: 600; + } + + .caption__meta { + color: var(--docs-text-muted); + } + + /* Lightbox */ + + /* Starlight's reset removes every margin, which would pin the modal to the top-left corner. */ + .lightbox { + position: fixed; + inset: 0; + width: min(96vw, 100rem); + max-width: none; + height: fit-content; + max-height: 92dvh; + margin: auto; + padding: 0; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + color: var(--docs-text); + box-shadow: var(--docs-shadow-popup); + + &::backdrop { + background-color: color-mix(in oklab, var(--docs-bg) 85%, transparent); + } + } + + .lightbox__frame { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + } + + .lightbox__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + } + + .lightbox__counter { + margin: 0; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + font-variant-numeric: tabular-nums; + } + + .lightbox__buttons { + display: flex; + gap: 0.25rem; + } + + .artwork { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 0; + } + + /* Leaves room in the dialog for the toolbar and the credit, so the whole artwork fits without scrolling. */ + .artwork__image img { + display: block; + width: auto; + max-width: 100%; + height: auto; + max-height: calc(92dvh - 10rem); + margin-inline: auto; + object-fit: contain; + border-radius: var(--docs-radius); + } + + @media (max-width: 49.99rem) { + .artwork__image img { + max-height: calc(92dvh - 13rem); + } + } + + .credit { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25rem 1.5rem; + padding: 0 0.25rem 0.25rem; + + h2 { + margin: 0; + color: var(--docs-text-strong); + font-size: 1.125rem; + font-weight: 600; + } + + a { + color: var(--docs-accent); + + &:hover { + color: var(--docs-accent-text); + } + } + } + + .credit__artist { + margin: 0; + color: var(--docs-text-muted); + } + + .credit dl { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1.25rem; + margin: 0; + font-size: var(--sl-text-sm); + + div { + display: flex; + gap: 0.375rem; + } + + dt { + color: var(--docs-text-subtle); + } + + dd { + margin: 0; + color: var(--docs-text); + } + } +</style> diff --git a/docs/src/lib/components/ShowcaseMarquee.astro b/docs/src/lib/components/ShowcaseMarquee.astro new file mode 100644 index 00000000000..814b2076154 --- /dev/null +++ b/docs/src/lib/components/ShowcaseMarquee.astro @@ -0,0 +1,406 @@ +--- +// Showcase artwork in a slowly drifting strip, each tile linking to the artwork on `/showcase/`. Scrolling the page +// speeds the drift up and turns it around (`src/lib/marquee.mjs`). The real tiles come once, first; the copies that +// make the loop seamless are hidden from assistive technology and from the keyboard. With reduced motion, while a tile +// has focus, or with too few entries to fill the strip, it is a still row instead. A button at its corner pauses and +// resumes it, remembered for the session. Without JavaScript it drifts at a steady speed. +import Pause from '@lucide/astro/icons/pause'; +import Play from '@lucide/astro/icons/play'; +import type { CollectionEntry } from 'astro:content'; +import { Image } from 'astro:assets'; + +import { withBase } from '../base-path'; + +type Props = { + /** In display order. */ + entries: CollectionEntry<'showcase'>[]; +}; + +const { entries } = Astro.props; +const base = import.meta.env.BASE_URL; + +/** Row heights in CSS pixels; keep in step with `--row-height` below. */ +const rowHeight = { narrow: 160, wide: 240 }; +const tileGap = 12; +/** Wide enough to cover a large desktop window with one loop unit. */ +const coverWidth = 3200; +/** Fewer entries than this would repeat the same few images across the strip, so they stay still. */ +const minAnimatedEntries = 4; + +const ratio = (entry: CollectionEntry<'showcase'>) => entry.data.image.width / entry.data.image.height; +const setWidth = entries.reduce((total, entry) => total + ratio(entry) * rowHeight.wide + tileGap, 0); +const animated = entries.length >= minAnimatedEntries; +// The loop unit is the real set plus as many hidden repeats as it takes to cover the strip; the track holds it twice +// and moves by half its width, so the second unit takes the first one's place. +const unitRepeats = animated ? Math.max(1, Math.ceil(coverWidth / setWidth)) : 1; +const hiddenSets = animated ? unitRepeats * 2 - 1 : 0; +/** The drift's speed in pixels a second; the script and the no-JavaScript keyframe share it. */ +const driftSpeed = 50; +const duration = (setWidth * unitRepeats) / driftSpeed; + +const tiles = entries.map((entry) => { + const { image, title, artist } = entry.data; + const widths = [rowHeight.narrow, rowHeight.wide, rowHeight.wide * 2] + .map((height) => Math.round(ratio(entry) * height)) + .filter((width) => width < image.width) + .concat(image.width); + return { + id: entry.id, + image, + title, + artist, + ratio: ratio(entry), + widths: [...new Set(widths)], + sizes: `(min-width: 50rem) ${Math.round(ratio(entry) * rowHeight.wide)}px, ${Math.round(ratio(entry) * rowHeight.narrow)}px`, + }; +}); +--- + +<div class="marquee-wrap not-content"> + <div + class:list={['marquee', animated ? 'marquee--animated' : 'marquee--still']} + style={`--duration: ${duration.toFixed(1)}s`} + data-showcase-marquee + data-drift-speed={driftSpeed} + > + <div class="track"> + { + Array.from({ length: hiddenSets + 1 }, (_, setIndex) => ( + <ul class:list={['set', { 'set--copy': setIndex > 0 }]} role="list" aria-hidden={setIndex > 0 ? 'true' : undefined} inert={setIndex > 0}> + {tiles.map((tile) => ( + <li class="tile" style={`--ratio: ${tile.ratio}`}> + <a href={withBase(`/showcase/#showcase-${tile.id}`, base)}> + {/* The caption names the artwork. */} + <Image src={tile.image} alt="" widths={tile.widths} sizes={tile.sizes} /> + <span class="caption"> + <span class="caption__title">{tile.title}</span> + <span class="caption__artist">{tile.artist}</span> + </span> + </a> + </li> + ))} + </ul> + )) + } + </div> + </div> + {/* Shown by the script, which is what it controls. */} + { + animated && ( + <button + type="button" + class="marquee-toggle docs-button docs-button--ghost docs-button--icon docs-button--md" + aria-pressed="false" + aria-label={Astro.locals.t('home.showcase.pauseAnimation')} + data-marquee-toggle + hidden + > + <Pause class="marquee-toggle__pause" aria-hidden="true" /> + <Play class="marquee-toggle__play" aria-hidden="true" /> + </button> + ) + } +</div> + +<script> + import { stepSpring, velocityFactor, wrapOffset } from '../marquee.mjs'; + + const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); + + const pausedKey = 'docs-showcase-marquee-paused'; + const readPaused = () => { + try { + return sessionStorage.getItem(pausedKey) === 'true'; + } catch { + return false; + } + }; + + for (const marquee of document.querySelectorAll<HTMLElement>('[data-showcase-marquee].marquee--animated')) { + const track = marquee.querySelector<HTMLElement>('.track')!; + const toggle = marquee.parentElement!.querySelector<HTMLButtonElement>('[data-marquee-toggle]')!; + let paused = readPaused(); + const driftSpeed = Number(marquee.dataset.driftSpeed); + // The track holds the loop unit twice. + let unit = track.offsetWidth / 2; + let offset = 0; + let direction = 1; + let pace = 1; // eases to 0 under the pointer + let paceTarget = 1; + const scrollSpring = { value: 0, velocity: 0 }; + // On screen until the observer says otherwise, so a late report never leaves the strip still. + let onScreen = true; + let frame = 0; + let lastTime = 0; + let lastScrollY = 0; + + const tick = (time: number) => { + // Long gaps (a hidden tab) count as one ordinary frame. + const dt = Math.min(Math.max((time - lastTime) / 1000, 0), 1 / 15) || 1 / 60; + const scrollY = window.scrollY; + stepSpring(scrollSpring, (scrollY - lastScrollY) / dt, dt); + lastTime = time; + lastScrollY = scrollY; + + const factor = velocityFactor(scrollSpring.value); + // The drift keeps the direction of the last scroll: up runs it backwards. + if (factor < -0.01) direction = -1; + else if (factor > 0.01) direction = 1; + pace += (paceTarget - pace) * (1 - Math.exp(-dt * 8)); + + offset = wrapOffset(offset - direction * driftSpeed * (1 + Math.abs(factor)) * pace * dt, unit); + track.style.transform = `translate3d(${offset.toFixed(2)}px, 0, 0)`; + frame = requestAnimationFrame(tick); + }; + + const stop = () => { + cancelAnimationFrame(frame); + frame = 0; + }; + + // Runs only on screen, with motion allowed, not paused and no tile focused; the still row needs no transform. + const update = () => { + const run = onScreen && !paused && !reducedMotion.matches && !marquee.matches(':focus-within'); + marquee.toggleAttribute('data-driven', !reducedMotion.matches); + marquee.toggleAttribute('data-user-paused', paused); + toggle.hidden = reducedMotion.matches; + // The name stays "Pause showcase animation"; pressed means paused. + toggle.setAttribute('aria-pressed', String(paused)); + if (run && !frame) { + lastTime = performance.now(); + lastScrollY = window.scrollY; + scrollSpring.value = scrollSpring.velocity = 0; + frame = requestAnimationFrame(tick); + } else if (!run && frame) { + stop(); + } + if (!run && (reducedMotion.matches || marquee.matches(':focus-within'))) track.style.transform = ''; + }; + + new IntersectionObserver(([entry]) => { + onScreen = entry.isIntersecting; + update(); + }).observe(marquee); + new ResizeObserver(([entry]) => { + unit = entry.borderBoxSize[0].inlineSize / 2; + }).observe(track); + // A mouse over the strip slows it to a stop; a touch has no matching leave, so it does not. + marquee.addEventListener('pointerenter', (event) => event.pointerType === 'mouse' && (paceTarget = 0)); + marquee.addEventListener('pointerleave', () => (paceTarget = 1)); + toggle.addEventListener('click', () => { + paused = !paused; + try { + sessionStorage.setItem(pausedKey, String(paused)); + } catch { + // Without storage the choice lasts for this page only. + } + update(); + }); + marquee.addEventListener('focusin', update); + marquee.addEventListener('focusout', () => requestAnimationFrame(update)); + reducedMotion.addEventListener('change', update); + update(); + } +</script> + +<style> + /* + * Nearly full-bleed: the strip spans the viewport less the page gutters, so a classic scrollbar cannot push it past + * the edge. Vertical padding leaves room for focus rings. + */ + .marquee-wrap { + --strip-width: calc(100vw - 2 * var(--sl-content-pad-x)); + position: relative; + width: var(--strip-width); + margin-inline: calc((100% - var(--strip-width)) / 2); + } + + .marquee { + --row-height: 160px; + padding-block: 0.375rem; + overflow: hidden; + } + + /* Over the strip's faded top-right corner, outside the fade itself. */ + .marquee-toggle { + position: absolute; + z-index: 1; + top: 0.75rem; + right: 0.75rem; + + &:not(:hover, :active) { + background-color: color-mix(in oklab, var(--docs-bg) 80%, transparent); + } + + &[hidden] { + display: none; + } + } + + .marquee-toggle[aria-pressed='false'] .marquee-toggle__play, + .marquee-toggle[aria-pressed='true'] .marquee-toggle__pause { + display: none; + } + + @media (min-width: 50rem) { + .marquee { + --row-height: 240px; + } + } + + .track { + display: flex; + width: max-content; + } + + .set { + display: flex; + margin: 0; + padding: 0; + list-style: none; + } + + /* The gap sits inside each tile, so half the track is exactly one loop unit. */ + .tile { + flex: none; + padding-inline-end: 0.75rem; + } + + .tile a { + position: relative; + display: block; + height: var(--row-height); + aspect-ratio: var(--ratio); + overflow: hidden; + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + color: var(--docs-text-strong); + text-decoration: none; + } + + .tile img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + .caption { + position: absolute; + inset: auto 0 0; + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 2rem 0.75rem 0.625rem; + background: linear-gradient(transparent, color-mix(in oklab, var(--docs-bg) 92%, transparent)); + font-size: 0.8125rem; + line-height: 1.35; + opacity: 0; + } + + .tile a:is(:hover, :focus-visible) .caption { + opacity: 1; + } + + /* Touch screens have no hover, so the caption always shows. */ + @media (hover: none) { + .caption { + opacity: 1; + } + } + + .caption__title { + font-weight: 600; + } + + .caption__artist { + color: var(--docs-text-muted); + } + + /* + * Moving: the strip fades out at both ends. The script moves it; without the script it drifts on a keyframe and + * pauses under the pointer. + */ + @media (prefers-reduced-motion: no-preference) { + .marquee--animated { + mask-image: linear-gradient(to right, transparent, black 6%, black 94%, transparent); + + .track { + will-change: transform; + } + + &:not([data-driven]) .track { + animation: showcase-marquee var(--duration) linear infinite; + } + + &:not([data-driven]):is(:hover, [data-user-paused]) .track { + animation-play-state: paused; + } + } + + .caption { + transition: opacity 150ms ease; + } + } + + @keyframes showcase-marquee { + to { + translate: -50% 0; + } + } + + /* + * Still: the real tiles only, in a row that scrolls sideways when it overflows and is centred when it does not. A + * moving strip becomes one while a tile has focus, so the focused tile scrolls into view. + */ + .marquee--still, + .marquee--animated:focus-within { + overflow-x: auto; + scroll-snap-type: x proximity; + mask-image: none; + + .track { + width: auto; + min-width: 100%; + justify-content: safe center; + animation: none; + } + + .set--copy { + display: none; + } + + .tile { + scroll-snap-align: start; + } + + .tile:last-child { + padding-inline-end: 0; + } + } + + @media (prefers-reduced-motion: reduce) { + .marquee--animated { + overflow-x: auto; + scroll-snap-type: x proximity; + + .track { + width: auto; + min-width: 100%; + justify-content: safe center; + } + + .set--copy { + display: none; + } + + .tile { + scroll-snap-align: start; + } + + .tile:last-child { + padding-inline-end: 0; + } + } + } +</style> diff --git a/docs/src/lib/components/Sidebar.astro b/docs/src/lib/components/Sidebar.astro new file mode 100644 index 00000000000..11ebb0ee241 --- /dev/null +++ b/docs/src/lib/components/Sidebar.astro @@ -0,0 +1,36 @@ +--- +import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter'; + +import { getCurrentTopic } from '../../config/topics'; +import { mountSidebarState, resolveOpen, sidebarStateKey, visibleScrollTop } from '../sidebar-state.mjs'; +import MainNav from './MainNav.astro'; +import SidebarTree from './SidebarTree.astro'; +import TopicSwitcher from './TopicSwitcher.astro'; + +// Starlight's sidebar, with the topic switcher in place of starlight-sidebar-topics' topic list (its route middleware +// has already narrowed the sidebar to the topic), section icons (`SidebarTree.astro`) and the section state kept per +// topic (see `sidebar-state.mjs`) instead of by Starlight's persister. +const { sidebar } = Astro.locals.starlightRoute; +const topic = getCurrentTopic(Astro.locals); +const mountScript = `(${mountSidebarState})(document.currentScript.previousElementSibling, ${JSON.stringify( + sidebarStateKey(topic?.id ?? 'docs') +)}, ${resolveOpen}, ${visibleScrollTop});`; +--- + +<div class="md:sl-hidden"> + <MainNav placement="drawer" /> +</div> +{topic && <TopicSwitcher current={topic} />} +<docs-sidebar-tree> + <SidebarTree sublist={sidebar} /> +</docs-sidebar-tree> +<script is:inline set:html={mountScript} /> +<div class="md:sl-hidden"> + <MobileMenuFooter /> +</div> + +<style> + docs-sidebar-tree { + display: contents; + } +</style> diff --git a/docs/src/lib/components/SidebarTree.astro b/docs/src/lib/components/SidebarTree.astro new file mode 100644 index 00000000000..7f2dbcd079e --- /dev/null +++ b/docs/src/lib/components/SidebarTree.astro @@ -0,0 +1,199 @@ +--- +import { Badge, Icon } from '@astrojs/starlight/components'; +import type { StarlightRouteData } from '@astrojs/starlight/route-data'; +import { getCollection } from 'astro:content'; + +import { getSidebarSectionIcon } from '../../config/icons'; +import { getPathLocale } from '../../config/locales.mjs'; +import { fileDirectory, flattenLinks, makeHrefToId, sectionDirectory } from '../sidebar.mjs'; + +type SidebarEntry = StarlightRouteData['sidebar'][number]; +type SidebarGroup = Extract<SidebarEntry, { type: 'group' }>; + +type Props = { + sublist: SidebarEntry[]; + nested?: boolean; + /** Page id -> file directory; the top level reads it once for the whole tree. */ + directories?: Map<string, string>; +}; + +// Starlight's `SidebarSublist`, with an icon on each section heading (from `config/icons.ts`). +const { sublist, nested = false } = Astro.props; +// English pages only, as `hrefToId` maps every locale's links to English ids. +const directories = + Astro.props.directories ?? + new Map( + (await getCollection('docs', ({ id }) => !getPathLocale(id))).map((doc) => [doc.id, fileDirectory(doc.filePath)]) + ); +const hrefToId = makeHrefToId(import.meta.env.BASE_URL); + +const sectionDirectoryOf = (group: SidebarGroup) => { + const directory = sectionDirectory(group, directories, hrefToId); + if (!directory) throw new Error(`The sidebar section "${group.label}" has no pages to place it by.`); + return directory; +}; +const hasCurrentPage = (group: SidebarGroup) => flattenLinks(group.entries).some((link) => link.isCurrent); +--- + +<ul class:list={{ 'top-level': !nested }}> + { + sublist.map((entry) => { + if (entry.type === 'link') { + const { class: className, ...attrs } = entry.attrs; + return ( + <li> + <a + {...attrs} + href={entry.href} + aria-current={entry.isCurrent ? 'page' : undefined} + class:list={['link', className]} + > + <span>{entry.label}</span> + {entry.badge && <Badge variant={entry.badge.variant} class={entry.badge.class} text={entry.badge.text} />} + </a> + </li> + ); + } + const directory = sectionDirectoryOf(entry); + const SectionIcon = getSidebarSectionIcon(directory, entry.label); + return ( + <li> + {/* The section's directory keys its open state in every locale (`sidebar-state.mjs`). */} + <details open={hasCurrentPage(entry) || !entry.collapsed} data-section={directory}> + <summary class="section"> + <span class="section-icon"><SectionIcon size={14} /></span> + <span class:list={['label', { 'docs-eyebrow': !nested }]}>{entry.label}</span> + {entry.badge && <Badge variant={entry.badge.variant} class={entry.badge.class} text={entry.badge.text} />} + <Icon name="right-caret" class="caret" size="1rem" /> + </summary> + <Astro.self sublist={entry.entries} nested directories={directories} /> + </details> + </li> + ); + }) + } +</ul> + +<style> + ul { + margin: 0; + padding: 0; + list-style: none; + font-size: var(--sl-text-xs); + } + + li { + overflow-wrap: anywhere; + } + + .top-level > li + li { + margin-top: 1px; + } + /* Space around a topic's sections sits between items, not inside their headings. */ + .top-level > li:has(> details):not(:first-child), + .top-level > li:has(> details) + li { + margin-top: 0.75rem; + } + + /* A section's pages are indented, without tree guide lines. */ + ul ul { + padding-inline-start: 0.75rem; + } + + .link, + .section { + border-radius: var(--docs-radius); + line-height: 1.35; + transition: var(--docs-control-transition); + + &:hover, + &:focus-visible { + background-color: var(--docs-hover); + } + } + + .link { + display: block; + padding: 0.375rem 0.5rem; + color: var(--docs-text-muted); + text-decoration: none; + + &:hover, + &:focus-visible { + color: var(--docs-text-strong); + } + } + + /* The current page is the app's active row (`rowRecipe`'s `accent` tone): a solid accent fill with dark text that + * keeps its fill when pointed at. */ + .link[aria-current='page'], + .link[aria-current='page']:hover, + .link[aria-current='page']:focus-visible { + background-color: var(--docs-accent); + color: var(--docs-accent-contrast); + font-weight: 600; + } + + .section { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.5rem; + color: var(--docs-text-muted); + font-weight: 500; + cursor: pointer; + user-select: none; + list-style: none; + + &::-webkit-details-marker { + display: none; + } + } + + .section-icon { + display: flex; + flex: none; + color: var(--docs-text-subtle); + } + + .label { + flex: 1; + min-width: 0; + } + + + .caret { + flex: none; + color: var(--docs-text-subtle); + transition: transform 180ms ease-out; + } + [open] > .section .caret { + transform: rotate(90deg); + } + + /* Sections open and close by animating their height; browsers without `::details-content` toggle instantly. */ + details { + interpolate-size: allow-keywords; + + &::details-content { + block-size: 0; + opacity: 0; + overflow: clip; + transition: + block-size 180ms ease-out, + opacity 180ms ease-out, + content-visibility 180ms ease-out allow-discrete; + } + &[open]::details-content { + block-size: auto; + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + .caret, + details::details-content { + transition: none; + } + } +</style> diff --git a/docs/src/lib/components/SplashGallery.astro b/docs/src/lib/components/SplashGallery.astro deleted file mode 100644 index a0293e48ff6..00000000000 --- a/docs/src/lib/components/SplashGallery.astro +++ /dev/null @@ -1,263 +0,0 @@ ---- -// Community Gallery Marquee - -import {Image} from 'astro:assets'; - -import linearview from '../../content/docs/workflows/assets/linearview.png'; -import workflowLibrary from '../../content/docs/workflows/assets/workflow_library.png'; -import groupsMultigenSeeding from '../../content/docs/workflows/assets/groupsmultigenseeding.png'; -import groupsImgVae from '../../content/docs/workflows/assets/groupsimgvae.png'; -import groupsConditioning from '../../content/docs/workflows/assets/groupsconditioning.png'; -import groupsControl from '../../content/docs/workflows/assets/groupscontrol.png'; -import groupsLora from '../../content/docs/workflows/assets/groupslora.png'; -import groupsNoise from '../../content/docs/workflows/assets/groupsnoise.png'; -import groupsIterate from '../../content/docs/workflows/assets/groupsiterate.png'; - -const placeholderSocials = [ - { label: 'Instagram', href: 'https://example.com/instagram' }, - { label: 'X', href: 'https://example.com/x' }, - { label: 'ArtStation', href: 'https://example.com/artstation' }, -]; - -const rows = [ - [ - { - id: 'linearview', - image: linearview, - alt: 'Linear workflow editor view', - socials: placeholderSocials, - }, - { - id: 'workflow-library', - image: workflowLibrary, - alt: 'Workflow library browser view', - socials: placeholderSocials, - }, - { - id: 'conditioning', - image: groupsConditioning, - alt: 'Workflow conditioning group', - socials: placeholderSocials, - }, - { - id: 'img-vae', - image: groupsImgVae, - alt: 'Workflow image and VAE group', - socials: placeholderSocials, - }, - { - id: 'multigen', - image: groupsMultigenSeeding, - alt: 'Workflow multigen seeding group', - socials: placeholderSocials, - }, - ], - [ - { - id: 'control', - image: groupsControl, - alt: 'Workflow control group', - socials: placeholderSocials, - }, - { - id: 'lora', - image: groupsLora, - alt: 'Workflow LoRA group', - socials: placeholderSocials, - }, - { - id: 'noise', - image: groupsNoise, - alt: 'Workflow noise group', - socials: placeholderSocials, - }, - { - id: 'iterate', - image: groupsIterate, - alt: 'Workflow iterate group', - socials: placeholderSocials, - }, - ], -]; ---- - -<div class="splash-gallery not-content" aria-label="Workflow gallery preview"> - {rows.map((row, rowIndex) => ( - <div class="gallery-row"> - <div class:list={['gallery-track', rowIndex === 1 && 'gallery-track--reverse']}> - <!-- 2 segments, 1 real and 1 duplicate --> - {[...Array(2)].map((_, segmentIndex) => ( - <div - class:list={['gallery-segment', segmentIndex === 1 && 'gallery-segment--duplicate']} - aria-hidden={segmentIndex === 1} - > - {row.map((tile) => ( - <figure - class="gallery-tile" - style={`--tile-aspect-ratio:${tile.image.width}/${tile.image.height};`} - > - <Image - src={tile.image} - alt={segmentIndex === 0 ? tile.alt : ''} - decoding="async" - /> - <figcaption class="gallery-tile__overlay"> - <span class="gallery-tile__label">Submitted by</span> - <div class="gallery-tile__socials"> - {tile.socials.map((social) => ( - <a href={social.href} tabindex={segmentIndex === 0 ? '0' : '-1'}>{social.label}</a> - ))} - </div> - </figcaption> - </figure> - ))} - </div> - ))} - </div> - </div> - ))} -</div> - -<style> - @keyframes splash-gallery-marquee { - from { - transform: translateX(0); - } - - to { - transform: translateX(calc(-50% - (var(--gallery-gap) / 2))); - } - } - - .splash-gallery { - --gallery-gap: 0.5rem; - --gallery-row-height: clamp(7.5rem, 18vw, 13rem); - - display: grid; - gap: var(--gallery-gap); - position: relative; - padding: 5rem 0; - } - - .gallery-row { - overflow: hidden; - position: relative; - mask-image: linear-gradient( - to right, - transparent, - black 4rem, - black calc(100% - 4rem), - transparent - ); - } - - .gallery-track { - display: flex; - width: max-content; - gap: var(--gallery-gap); - animation: splash-gallery-marquee 40s linear infinite; - will-change: transform; - } - - .gallery-track--reverse { - animation-direction: reverse; - } - - .gallery-row:hover .gallery-track, - .gallery-row:focus-within .gallery-track { - animation-play-state: paused; - } - - .gallery-segment { - display: flex; - flex-shrink: 0; - gap: var(--gallery-gap); - min-width: max-content; - } - - .gallery-tile { - position: relative; - flex: 0 0 auto; - height: var(--gallery-row-height); - aspect-ratio: var(--tile-aspect-ratio); - margin: 0; - overflow: hidden; - border-radius: calc(var(--radius) - 0.05rem); - border: 1px solid var(--sl-color-hairline-light); - background: var(--sl-color-bg-sidebar); - } - - .gallery-tile img { - display: block; - width: 100%; - height: 100%; - object-fit: cover; - } - - .gallery-tile__overlay { - position: absolute; - inset-inline: 0; - inset-block-end: 0; - display: grid; - gap: 0.3rem; - padding: 0.65rem 0.75rem; - background: linear-gradient(to top, rgba(17, 18, 21, 0.94), rgba(17, 18, 21, 0.65), transparent); - opacity: 0; - transform: translateY(12%); - transition: - opacity 120ms ease, - transform 120ms ease; - pointer-events: none; - } - - .gallery-tile:hover .gallery-tile__overlay, - .gallery-tile:focus-within .gallery-tile__overlay { - opacity: 1; - transform: translateY(0); - pointer-events: auto; - } - - .gallery-tile__label { - font-size: 0.7rem; - letter-spacing: 0.04em; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.72); - } - - .gallery-tile__socials { - display: flex; - flex-wrap: wrap; - gap: 0.35rem 0.75rem; - } - - .gallery-tile__socials a { - color: white; - font-size: 0.85rem; - text-decoration: none; - } - - .gallery-tile__socials a:hover, - .gallery-tile__socials a:focus-visible { - color: var(--sl-color-text-accent); - } - - @media (hover: none), (pointer: coarse) { - .gallery-tile__overlay { - opacity: 1; - transform: translateY(0); - pointer-events: auto; - background: linear-gradient(to top, rgba(17, 18, 21, 0.94), rgba(17, 18, 21, 0.56), transparent); - } - } - - @media (prefers-reduced-motion: reduce) { - .gallery-track { - animation: none; - transform: none; - } - - .gallery-segment--duplicate { - display: none; - } - } -</style> diff --git a/docs/src/lib/components/SystemRequirmentsLink.astro b/docs/src/lib/components/SystemRequirmentsLink.astro index 16455b8e749..84b46356764 100644 --- a/docs/src/lib/components/SystemRequirmentsLink.astro +++ b/docs/src/lib/components/SystemRequirmentsLink.astro @@ -4,7 +4,7 @@ import { withBase } from '../base-path'; --- <LinkCard - title="System Requirements" - description="Please check the system requirements page to make sure your hardware is capable of running the desired models." + title={Astro.locals.t('systemRequirements.title')} + description={Astro.locals.t('systemRequirements.description')} href={withBase('/start-here/system-requirements/', import.meta.env.BASE_URL)} /> diff --git a/docs/src/lib/components/TopicSwitcher.astro b/docs/src/lib/components/TopicSwitcher.astro new file mode 100644 index 00000000000..497e4bc6763 --- /dev/null +++ b/docs/src/lib/components/TopicSwitcher.astro @@ -0,0 +1,433 @@ +--- +import Check from '@lucide/astro/icons/check'; +import ChevronsUpDown from '@lucide/astro/icons/chevrons-up-down'; +import Search from '@lucide/astro/icons/search'; + +import { getTopicIcon } from '../../config/icons'; +import { + docsTopics, + topicDescription, + topicGroupLabel, + topicGroups, + topicLabel, + type DocsTopic, +} from '../../config/topics'; +import { localizedHref } from '../base-path'; + +type Props = { + current: DocsTopic; +}; + +const { current } = Astro.props; +const base = import.meta.env.BASE_URL; +const { locale } = Astro.locals.starlightRoute; +const { t } = Astro.locals; +const display = (topic: DocsTopic) => ({ + ...topic, + label: topicLabel(t, topic), + description: topicDescription(t, topic), + Icon: getTopicIcon(topic), +}); +const currentDisplay = display(current); +const { Icon: CurrentIcon } = currentDisplay; +const groups = topicGroups.map((group) => ({ + id: `docs-topic-group-${group.toLowerCase()}`, + label: topicGroupLabel(t, group), + topics: docsTopics.filter((topic) => topic.group === group).map(display), +})); +--- + +<docs-topic-switcher class="topic-switcher"> + <button + type="button" + class="trigger" + aria-expanded="false" + aria-controls="docs-topic-switcher-popup" + aria-haspopup="dialog" + aria-label={t('topicSwitcher.trigger', { topic: currentDisplay.label, interpolation: { escapeValue: false } })} + > + <span class="tile tile--selected"><CurrentIcon size={16} /></span> + <span class="text"> + <span class="title">{currentDisplay.label}</span> + <span class="description">{currentDisplay.description}</span> + </span> + <ChevronsUpDown class="chevrons" size={14} /> + </button> + + <div id="docs-topic-switcher-popup" class="popup docs-popup" role="dialog" aria-label={t('topicSwitcher.dialog')} hidden> + <div class="filter"> + <Search size={14} /> + <input + type="text" + role="combobox" + aria-label={t('topicSwitcher.filterPlaceholder')} + aria-controls="docs-topic-switcher-list" + aria-expanded="true" + aria-autocomplete="list" + autocomplete="off" + spellcheck="false" + placeholder={t('topicSwitcher.filterPlaceholder')} + /> + </div> + <div id="docs-topic-switcher-list" class="list" role="listbox" aria-label={t('topicSwitcher.listLabel')}> + { + groups.map(({ id, label, topics }) => ( + <div class="group" role="group" aria-labelledby={id}> + <div class="group-label docs-eyebrow" id={id}> + {label} + </div> + {topics.map((topic) => { + const isCurrent = topic.id === current.id; + const { Icon: TopicIcon } = topic; + return ( + <a + id={`docs-topic-option-${topic.id}`} + class="option" + role="option" + href={localizedHref(topic.link, { locale, base })} + tabindex="-1" + aria-selected="false" + aria-current={isCurrent ? 'page' : undefined} + data-filter-text={`${topic.label} ${topic.description}`.toLowerCase()} + > + <span class="tile"> + <TopicIcon size={16} /> + </span> + <span class="text"> + <span class="title">{topic.label}</span> + <span class="description">{topic.description}</span> + </span> + {isCurrent && ( + <Check class="check" size={14} /> + )} + </a> + ); + })} + </div> + )) + } + </div> + <p class="empty" role="status" data-empty-text={t('topicSwitcher.empty')}></p> + <div class="hints" aria-hidden="true"> + <span><kbd>↑</kbd><kbd>↓</kbd> {t('keyboardHint.moveSelection')}</span> + <span><kbd>↵</kbd> {t('keyboardHint.openSelection')}</span> + <span><kbd>esc</kbd> {t('keyboardHint.closePopup')}</span> + </div> + </div> +</docs-topic-switcher> + +<script> + import { bindDisclosure } from '../disclosure'; + + const VIEWPORT_MARGIN_PX = 16; + const MIN_HEIGHT_PX = 240; + + /** + * A filterable list of topics. Focus stays in the filter field, which points at the active option with + * `aria-activedescendant`; the active option is the selected one, the arrow keys move it and Enter follows its + * link. The current topic is marked with `aria-current` and a check. + */ + class DocsTopicSwitcher extends HTMLElement { + connectedCallback() { + const input = this.querySelector('input')!; + const options = [...this.querySelectorAll<HTMLAnchorElement>('[role="option"]')]; + const groups = [...this.querySelectorAll<HTMLElement>('[role="group"]')]; + const empty = this.querySelector<HTMLElement>('.empty')!; + let active: HTMLAnchorElement | undefined; + + const visibleOptions = () => options.filter((option) => !option.hidden); + /** `byKeyboard` rings the option, as the app's menus ring a keyboard-focused item. */ + const setActive = (option: HTMLAnchorElement | undefined, byKeyboard = false) => { + active?.setAttribute('aria-selected', 'false'); + active?.removeAttribute('data-keyboard'); + active = option; + if (!option) { + input.removeAttribute('aria-activedescendant'); + return; + } + option.setAttribute('aria-selected', 'true'); + option.toggleAttribute('data-keyboard', byKeyboard); + input.setAttribute('aria-activedescendant', option.id); + option.scrollIntoView({ block: 'nearest' }); + }; + const filter = () => { + const query = input.value.trim().toLowerCase(); + for (const option of options) option.hidden = !option.dataset.filterText!.includes(query); + for (const group of groups) group.hidden = !group.querySelector('[role="option"]:not([hidden])'); + const matches = visibleOptions(); + empty.textContent = matches.length > 0 ? '' : empty.dataset.emptyText!; + setActive(matches.find((option) => option.getAttribute('aria-current') === 'page') ?? matches[0]); + }; + + const popup = this.querySelector<HTMLElement>('[role="dialog"]')!; + // Let the popup reach down to the bottom of the viewport, so every topic shows whenever there is room. Below a + // minimum, the list scrolls inside the popup and the sidebar scrolls to reveal the rest. + const fitToViewport = () => { + // From the layout position, not the box, which the opening animation shifts. + const top = this.getBoundingClientRect().top + popup.offsetTop; + const available = window.innerHeight - top - VIEWPORT_MARGIN_PX; + popup.style.maxHeight = `${Math.max(available, MIN_HEIGHT_PX)}px`; + }; + const disclosure = bindDisclosure(this, this.querySelector('button')!, popup, { + onOpen: () => { + input.value = ''; + filter(); + fitToViewport(); + input.focus(); + }, + }); + window.addEventListener('resize', () => { + if (disclosure.isOpen()) fitToViewport(); + }); + + input.addEventListener('input', filter); + input.addEventListener('keydown', (event) => { + const matches = visibleOptions(); + if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && matches.length > 0) { + event.preventDefault(); + const step = event.key === 'ArrowDown' ? 1 : -1; + const index = active ? matches.indexOf(active) : -1; + setActive(matches[(index + step + matches.length) % matches.length], true); + } else if (event.key === 'Enter' && active) { + event.preventDefault(); + active.click(); + } + }); + for (const option of options) option.addEventListener('pointermove', () => setActive(option)); + } + } + customElements.define('docs-topic-switcher', DocsTopicSwitcher); +</script> + +<style> + .topic-switcher { + position: relative; + display: block; + } + + .trigger, + .option { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + padding: 0.5rem; + border-radius: 7px; + color: var(--docs-text-strong); + font-family: inherit; + text-align: start; + text-decoration: none; + } + + /* + * Unfilled like a secondary button, with its hover fill. Tighter vertical padding than the rows: the two-line label + * is taller than the icon tile, and this leaves the tile as far from the top and bottom borders as from the left. + */ + .trigger { + padding-block: 0.4375rem; + border: 1px solid var(--docs-border); + background: transparent; + cursor: pointer; + transition: var(--docs-control-transition); + + &:is(:hover, [aria-expanded='true']) { + background: var(--docs-hover); + } + &[aria-expanded='true'] { + border-color: var(--docs-accent); + } + } + + .tile { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 6px; + background: var(--docs-surface); + color: var(--docs-text-muted); + } + .trigger .tile { + width: 1.75rem; + height: 1.75rem; + border-radius: var(--docs-radius); + } + .tile--selected { + background: var(--docs-accent); + color: var(--docs-accent-contrast); + } + + .text { + display: flex; + flex: 1; + flex-direction: column; + gap: 1px; + min-width: 0; + } + .title { + font-size: var(--sl-text-xs); + font-weight: 600; + line-height: 1.3; + } + .description { + color: var(--docs-text-subtle); + font-size: 0.6875rem; + line-height: 1.3; + } + /* The trigger's label sits tighter, so the two lines read as one block centred on the tile. */ + .trigger :is(.title, .description) { + line-height: 1.2; + } + .option .description { + font-size: 0.75rem; + } + + .chevrons { + flex: none; + color: var(--docs-text-subtle); + } + + .popup { + position: absolute; + inset-inline: 0; + top: calc(100% + 0.375rem); + z-index: 2; + display: flex; + flex-direction: column; + overflow: hidden; + /* The app's dropdown surface (`dropdownContent` in its theme recipes). */ + border: 1px solid var(--docs-border-strong); + border-radius: 10px; + background: var(--docs-control); + box-shadow: var(--docs-shadow-popup); + + &[hidden] { + display: none; + } + } + + .filter { + display: flex; + flex: none; + align-items: center; + gap: 0.5rem; + height: 2.5rem; + padding-inline: 0.625rem; + border-bottom: 1px solid var(--docs-border); + color: var(--docs-text-subtle); + + input { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + outline: none; + background: transparent; + color: var(--docs-text-strong); + font: 400 var(--sl-text-xs) var(--__sl-font); + } + &:focus-within { + box-shadow: inset 0 -1px 0 var(--docs-accent); + } + } + + .list { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow-y: auto; + padding: 0.375rem; + } + + .group { + display: flex; + flex-direction: column; + gap: 1px; + padding-bottom: 0.25rem; + + &[hidden] { + display: none; + } + } + + .group-label { + padding: 0.5rem 0.5rem 0.25rem; + } + + /* + * As the app's list rows and menu items: the option the pointer or keyboard is on gets the hover fill (`bg.hover`), + * ringed when reached by keyboard; the current topic is the active row, a solid accent fill with dark text that + * keeps its fill when pointed at. + */ + .option { + transition: + background-color 120ms ease, + color 120ms ease; + + &[hidden] { + display: none; + } + &[aria-selected='true'] { + background: var(--docs-hover); + } + &[data-keyboard] { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + } + &[aria-current='page'] { + background: var(--docs-accent); + color: var(--docs-accent-contrast); + + .description { + color: var(--docs-accent-contrast); + opacity: 0.85; + } + .tile { + background: var(--docs-bg); + color: var(--docs-accent); + } + .check { + color: var(--docs-accent-contrast); + } + } + &[aria-current='page'][data-keyboard] { + outline-color: var(--docs-accent-contrast); + } + } + + .check { + flex: none; + color: var(--docs-accent); + } + + .empty { + margin: 0; + padding: 0 0.875rem; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + + &:empty { + display: none; + } + &:not(:empty) { + padding-block: 0.25rem 1rem; + } + } + + .hints { + display: flex; + flex: none; + gap: 0.875rem; + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--docs-border); + color: var(--docs-text-subtle); + font-size: 0.6875rem; + + kbd { + font-family: var(--__sl-font-mono); + } + } +</style> diff --git a/docs/src/lib/components/Trans.astro b/docs/src/lib/components/Trans.astro new file mode 100644 index 00000000000..317844d2c7a --- /dev/null +++ b/docs/src/lib/components/Trans.astro @@ -0,0 +1,20 @@ +--- +import { parseTags } from '../i18n.mjs'; + +type Props = { + /** A translated message with `<name>…</name>` tags around the text that markup wraps. */ + message: string; +}; + +// Renders a translated sentence with markup inside it, react-i18next style. The default slot is a function of each +// tag's text and name that returns its markup, so the caller owns the element and its scoped styles: +// <Trans message={t('footer.credit')}>{(text: string) => <a href="…">{text}</a>}</Trans> +// Text is always escaped: `set:html` only receives the slot's rendered output, never the message. +const parts = await Promise.all( + parseTags(Astro.props.message).map(async (part) => + 'tag' in part ? { html: await Astro.slots.render('default', [part.text, part.tag]) } : part + ) +); +--- + +{parts.map((part) => ('html' in part ? <Fragment set:html={part.html} /> : part.text))} diff --git a/docs/src/lib/components/VersionMenu.astro b/docs/src/lib/components/VersionMenu.astro new file mode 100644 index 00000000000..92f1bfe4a46 --- /dev/null +++ b/docs/src/lib/components/VersionMenu.astro @@ -0,0 +1,25 @@ +--- +import { localizedHref } from '../base-path'; +import LinkMenu from './LinkMenu.astro'; + +// v7 is this site; the v6 docs stay at invoke.ai, deployed from the v6-bugfixes branch. +const { locale } = Astro.locals.starlightRoute; +const { t } = Astro.locals; +const current = 'v7'; +const versions = [ + { + label: current, + note: t('versionMenu.currentRelease'), + href: localizedHref('/', { locale, base: import.meta.env.BASE_URL }), + isCurrent: true, + }, + { label: 'v6', note: t('versionMenu.previousRelease'), href: 'https://invoke.ai/', isCurrent: false }, +]; +--- + +<LinkMenu + id="docs-version-menu-list" + label={current} + accessibleLabel={t('versionMenu.label', { version: current, interpolation: { escapeValue: false } })} + items={versions} +/> diff --git a/docs/src/lib/components/WhatsNewBento.astro b/docs/src/lib/components/WhatsNewBento.astro new file mode 100644 index 00000000000..4794e2eff09 --- /dev/null +++ b/docs/src/lib/components/WhatsNewBento.astro @@ -0,0 +1,374 @@ +--- +/* + * The home page's "What's new in 7" features as a bento grid. Each tile links to the guide that documents the feature + * and shows a small animated motif of it (`whats-new/`). The section heading goes in the slot, so it shares the grid's + * width and left edge. Motifs animate only while their tile is on screen, and hold one still frame under reduced + * motion. + */ +import { withBase } from '../base-path'; +import CanvasMotif from './whats-new/CanvasMotif.astro'; +import ImageMapMotif from './whats-new/ImageMapMotif.astro'; +import LoopMotif from './whats-new/LoopMotif.astro'; +import ProjectsMotif from './whats-new/ProjectsMotif.astro'; +import VideoMotif from './whats-new/VideoMotif.astro'; +import WorkbenchMotif from './whats-new/WorkbenchMotif.astro'; + +const base = import.meta.env.BASE_URL; +const { t } = Astro.locals; + +// In reading order, which the grid areas below follow at every width. +const tiles = [ + { + id: 'workbench', + title: t('whatsNew.workbench.title'), + body: t('whatsNew.workbench.body'), + link: '/users-guide/workbench/introduction/', + Motif: WorkbenchMotif, + }, + { + id: 'projects', + title: t('whatsNew.projects.title'), + body: t('whatsNew.projects.body'), + link: '/users-guide/projects/introduction/', + Motif: ProjectsMotif, + }, + { + id: 'canvas', + title: t('whatsNew.canvas.title'), + body: t('whatsNew.canvas.body'), + link: '/users-guide/canvas/canvas-guide/', + Motif: CanvasMotif, + }, + { + id: 'video', + title: t('whatsNew.video.title'), + badge: t('whatsNew.experimentalBadge'), + body: t('whatsNew.video.body'), + link: '/users-guide/video-generation/introduction/', + Motif: VideoMotif, + }, + { + id: 'map', + title: t('whatsNew.imageMap.title'), + body: t('whatsNew.imageMap.body'), + link: '/users-guide/image-map/introduction/', + Motif: ImageMapMotif, + }, + { + id: 'loops', + title: t('whatsNew.loops.title'), + body: t('whatsNew.loops.body'), + link: '/users-guide/workflows/loop-nodes/', + Motif: LoopMotif, + }, +]; +--- + +<div class="bento" data-bento> + <slot /> + <ul class="grid" role="list"> + { + tiles.map(({ id, title, badge, body, link, Motif }) => ( + <li class:list={['tile', `tile--${id}`]} data-bento-tile> + <a + class="link" + href={withBase(link, base)} + aria-labelledby={`whats-new-${id}`} + aria-describedby={`whats-new-${id}-body`} + > + <div class="text"> + <div class="heading"> + <h3 id={`whats-new-${id}`}> + {title} + {badge && <span class="badge">{badge}</span>} + </h3> + <svg + class="arrow" + width="16" + height="16" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + aria-hidden="true" + > + <path d="M5 12h14M13 6l6 6-6 6" /> + </svg> + </div> + <p class="body" id={`whats-new-${id}-body`}> + {body} + </p> + </div> + <div class="visual" aria-hidden="true"> + <Motif /> + </div> + </a> + </li> + )) + } + </ul> +</div> + +<script> + const bento = document.querySelector<HTMLElement>('[data-bento]'); + + // Motifs run by default; this only pauses a tile's motif while it is off screen. A script that fails, loads late or + // never hears from the observer therefore leaves them running, never frozen. + if (bento) { + const observer = new IntersectionObserver((entries) => { + for (const { target, isIntersecting } of entries) target.toggleAttribute('data-paused', !isIntersecting); + }); + for (const tile of bento.querySelectorAll('[data-bento-tile]')) observer.observe(tile); + } +</script> + +<style> + /* + * Wider than the splash column, like the hero screenshot: up to 80rem, centred, inside the page gutters. The grid + * follows the bento's own width: one column, then two, then four. + */ + .bento { + --bento-width: min(100vw - 2 * var(--sl-content-pad-x), 80rem); + --tile-pad: 1.25rem; + container: bento / inline-size; + display: flex; + flex-direction: column; + gap: 2rem; + width: var(--bento-width); + margin-inline: calc((100% - var(--bento-width)) / 2); + } + + .grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'workbench' 'projects' 'canvas' 'video' 'map' 'loops'; + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; + } + + @container bento (min-width: 36rem) { + .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: minmax(28rem, auto) repeat(2, minmax(19rem, auto)) repeat(2, minmax(18rem, auto)); + grid-template-areas: + 'workbench workbench' + 'projects canvas' + 'video canvas' + 'map map' + 'loops loops'; + } + } + + @container bento (min-width: 68rem) { + .grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(20rem, auto)) minmax(19rem, auto); + grid-template-areas: + 'workbench workbench projects canvas' + 'workbench workbench video canvas' + 'map map loops loops'; + } + } + + .tile { + display: flex; + min-width: 0; + } + + .tile--workbench { + grid-area: workbench; + } + + .tile--projects { + grid-area: projects; + } + + .tile--canvas { + grid-area: canvas; + } + + .tile--video { + grid-area: video; + } + + .tile--map { + grid-area: map; + } + + .tile--loops { + grid-area: loops; + } + + /* Hovers like the site's link cards: a stronger border over the control fill. */ + .link { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + overflow: hidden; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + color: var(--docs-text-strong); + text-decoration: none; + transition: + border-color 150ms ease, + background-color 150ms ease; + + &:is(:hover, :focus-visible) { + border-color: var(--docs-border-strong); + background-color: var(--docs-control); + color: var(--docs-text-strong); + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + } + } + + .text { + display: flex; + flex: none; + flex-direction: column; + gap: 0.5rem; + padding: 1.375rem var(--tile-pad) 0; + } + + .heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + } + + h3 { + margin: 0; + color: var(--docs-text-strong); + font-size: 1.0625rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.35; + text-wrap: balance; + } + + .tile--workbench h3 { + font-size: clamp(1.1875rem, 1rem + 0.6vw, 1.375rem); + } + + .badge { + display: inline-block; + margin-inline-start: 0.5rem; + padding: 0.125rem 0.375rem; + border-radius: var(--docs-radius); + background-color: var(--sl-color-orange-low); + color: var(--sl-color-orange-high); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.4; + vertical-align: 0.15em; + } + + .arrow { + flex-shrink: 0; + margin-top: 0.25rem; + color: var(--sl-color-gray-4); + transition: + color 150ms ease-out, + translate 200ms ease-out; + } + + .link:is(:hover, :focus-visible) .arrow { + color: var(--docs-accent); + translate: 2px 0; + } + + .body { + max-width: 34rem; + margin: 0; + color: var(--docs-text-muted); + font-size: 0.875rem; + line-height: 1.55; + text-wrap: pretty; + } + + /* + * The motif sits on the tile's own surface, in line with the copy. The visual is a size container, so a motif's + * stage fits its content box either way; a stage wider than that box (wide motifs on a phone) bleeds into the + * padding and is clipped at the tile's edge. One column gives each tile a fixed height; in the grid the visual + * fills whatever its row leaves. + */ + .visual { + position: relative; + display: flex; + flex: none; + align-items: center; + justify-content: center; + height: var(--visual-height, 12rem); + overflow: hidden; + container-type: size; + padding: 0 var(--tile-pad) var(--tile-pad); + } + + .tile--workbench { + --visual-height: min(24rem, 72cqw); + } + + .tile--canvas { + --visual-height: 22rem; + } + + /* Wide motifs would shrink to illegible on a phone; there they fill the height and crop at the sides instead. */ + .tile--map, + .tile--loops { + --visual-height: 12rem; + + :global(.wn-stage) { + --stage-width: calc(100cqh * var(--stage-w) / var(--stage-h)); + } + } + + @container bento (min-width: 36rem) { + .visual { + flex: 1 1 0; + height: auto; + min-height: 9rem; + } + + .tile--map, + .tile--loops { + :global(.wn-stage) { + --stage-width: initial; + } + } + } + + @media (prefers-reduced-motion: no-preference) { + /* Tiles rise into place as they scroll in, where scroll-driven animations exist. */ + @supports ((animation-timeline: view()) and (animation-range: entry)) { + /* Longhands only: the minifier would fold `animation-timeline` into the shorthand, which only resets it. */ + .tile { + animation-name: bento-enter; + animation-fill-mode: both; + animation-timing-function: linear; + animation-timeline: view(); + animation-range: entry 5% entry 65%; + } + } + } + + @keyframes bento-enter { + from { + opacity: 0; + translate: 0 1.5rem; + } + } + + /* Off screen, a tile's motif pauses where it is. */ + .tile[data-paused] .visual :global(*) { + animation-play-state: paused; + } +</style> diff --git a/docs/src/lib/components/annotated/Annotated.astro b/docs/src/lib/components/annotated/Annotated.astro new file mode 100644 index 00000000000..3deab2b41ec --- /dev/null +++ b/docs/src/lib/components/annotated/Annotated.astro @@ -0,0 +1,215 @@ +--- +/* + * A screenshot or app sketch with numbered pins over it and a numbered legend under it. Each `Callout` child is one + * legend entry, and its pin is drawn over the media at the callout's `x`/`y` (percent of the media's width/height): + * + * <Annotated src={screenshot} alt="…"> + * <Callout x={12} y={30} label="Used">Intermediates that a delete keeps…</Callout> + * </Annotated> + * + * A sketch passes its drawing in the `sketch` slot instead, with its stage size (`Stage.astro` units) and a `label` + * naming the picture; see `IntermediatesSettingsSketch.astro`. + * + * The legend is the accessible content: each entry's pin is a link to the entry, named by the callout's label, so + * keyboard users can tab through the pins and screen readers read each entry once. Pins live inside their entries, + * which links them without script: pointing at or focusing either highlights both (`:hover` and `:focus-within` on + * the entry). Without JavaScript a pin jumps to its entry and `:target` keeps it highlighted. With it, one entry at + * most is chosen (`data-active`): a click or tap on a pin or entry, keyboard focus on a pin, or the URL's fragment on + * load chooses it; Escape, or a click outside the figure's entries, clears it. A chosen pin keeps the page still and + * scrolls its entry into view only if needed, which is what a tap on a touchscreen needs. + * + * Place pins in empty space touching the control they name, with `labelSide` pointing the hover label away from it. + * Keep pin centres at least 8% of the media's width apart (measure `y` distances in width units: times the media's + * height over its width): below a 550px-wide media the tap targets shrink to 8% of the width, so closer pins would + * share tap area on a phone. + * + * Pins are absolutely positioned against the media, which fills the top of the size container `.annotated__body`, so + * their offsets are `cqw` multiples: `x` of the width, and `y` of the height, which is the width times the media's + * aspect ratio. + */ +import type { ImageMetadata } from 'astro'; +import { Image } from 'astro:assets'; + +import Stage from '../whats-new/Stage.astro'; +import { nextFigureIndex, numberCallouts } from './callout-ids.mjs'; + +type Props = { + class?: string; +} & ( + | { /** A screenshot. */ src: ImageMetadata; alt: string; label?: never; width?: never; height?: never } + | { + src?: never; + alt?: never; + /** Names the sketch in the `sketch` slot for screen readers. */ + label: string; + /** The sketch's stage size, in `Stage.astro` units. */ + width: number; + height: number; + } +); + +const { class: className, src, alt, label, width, height } = Astro.props; +const { t } = Astro.locals; + +const mediaWidth = src ? src.width : width; +const mediaHeight = src ? src.height : height; +if (!mediaWidth || !mediaHeight) throw new Error('Annotated needs an image `src`, or a sketch `width` and `height`.'); +if (src && !alt) throw new Error('Annotated needs `alt` text for its image.'); +if (!src && !label) throw new Error('Annotated needs a `label` naming its sketch.'); + +// Entry ids count figures and entries in page order, so links to them survive rebuilds. +const figure = nextFigureIndex(Astro.locals); +const legend = numberCallouts(await Astro.slots.render('default'), figure); +--- + +<figure class:list={['annotated not-content', className]} data-annotated> + {/* Marks the figure before first paint, so the no-script highlight rules (`:target`, `:focus-within`) never apply. */} + <script is:inline> + document.currentScript.parentElement.dataset.js = ''; + </script> + <div class="annotated__body" style={`--annotated-ratio: ${mediaHeight / mediaWidth}`}> + { + src ? ( + <Image + class="annotated__media" + src={src} + alt={alt} + widths={[640, src.width]} + sizes="(min-width: 72rem) 52rem, calc(100vw - 2rem)" + loading="lazy" + decoding="async" + /> + ) : ( + <div class="annotated__media annotated__media--sketch" role="img" aria-label={label}> + <Stage width={width!} height={height!}> + <slot name="sketch" /> + </Stage> + </div> + ) + } + {/* `role="list"`: Safari drops list semantics from a list without list markers. */} + <ol class="annotated__legend" role="list" aria-label={t('annotated.legend')}> + <Fragment set:html={legend} /> + </ol> + </div> +</figure> + +<script> + /* + * The chosen entry (`data-active`), at most one per page: listeners sit on the document, so Escape and clicks + * outside a figure clear it wherever focus is. + */ + const ENTRY = '[data-annotated-entry]'; + const PIN = '[data-annotated-pin]'; + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + + const choose = (entry: HTMLElement | null) => { + for (const other of document.querySelectorAll<HTMLElement>(`${ENTRY}[data-active]`)) { + if (other !== entry) delete other.dataset.active; + } + if (entry) entry.dataset.active = ''; + }; + + const entryOf = (target: EventTarget | null) => + target instanceof Element ? target.closest<HTMLElement>(`[data-annotated] ${ENTRY}`) : null; + + document.addEventListener('click', (event) => { + const target = event.target as Element; + const entry = entryOf(target); + // A link in an entry's description keeps its own behaviour. + if (entry && target.closest(`a:not(${PIN})`)) return; + choose(entry); + if (entry && target.closest(PIN)) { + event.preventDefault(); + entry.scrollIntoView({ block: 'nearest', behavior: reducedMotion.matches ? 'auto' : 'smooth' }); + } + }); + + // Keyboard focus on a pin chooses its entry; focus leaving the pins clears it, unless a click chose an entry. + document.addEventListener('focusin', (event) => { + if ((event.target as Element).matches?.(`[data-annotated] ${PIN}`)) choose(entryOf(event.target)); + }); + document.addEventListener('focusout', (event) => { + if ((event.target as Element).matches?.(PIN) && !(event.relatedTarget as Element | null)?.matches?.(PIN)) { + const entry = entryOf(event.target); + // A click inside an entry lands after focus leaves, and chooses again. + if (entry) delete entry.dataset.active; + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || !document.querySelector(`${ENTRY}[data-active]`)) return; + choose(null); + const focused = document.activeElement; + if (focused instanceof HTMLElement && focused.matches(PIN)) focused.blur(); + }); + + // A link to an entry chooses it, as `:target` does without script. + const fromHash = () => { + let id = location.hash.slice(1); + try { + id = decodeURIComponent(id); + } catch { + // A malformed fragment names no entry. + } + const entry = id ? entryOf(document.getElementById(id)) : null; + if (entry) choose(entry); + }; + fromHash(); + window.addEventListener('hashchange', fromHash); +</script> + +<style> + .annotated { + /* The tour frames' shell: see `.compare` in compare/ImageCompare.astro. */ + --frame-inset: 0.375rem; + --inner-radius: calc(var(--docs-radius-container) - var(--frame-inset) - 1px); + margin: 0; + /* A hover label near an edge must not widen the page. */ + overflow: clip; + padding: var(--frame-inset); + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + } + + .annotated__body { + position: relative; + container-type: inline-size; + } + + .annotated__media { + display: block; + overflow: hidden; + width: 100%; + height: auto; + border-radius: var(--inner-radius); + } + + .annotated__media--sketch { + --stage-width: 100%; + background-color: var(--docs-bg); + } + + /* + * Two columns where each gets 20rem, one on narrower screens. Entries flow down the first column and on into the + * second, as text does, so entries of different heights leave no holes, and the numbers read down each column in + * DOM order. An entry never splits across columns. + */ + .annotated__legend { + columns: 2 20rem; + column-gap: 0.5rem; + margin: 0; + padding: 0.375rem 0 0; + list-style: none; + counter-reset: annotated; + + > :global(*) { + break-inside: avoid; + } + + > :global(* + *) { + margin-top: 0.125rem; + } + } +</style> diff --git a/docs/src/lib/components/annotated/Callout.astro b/docs/src/lib/components/annotated/Callout.astro new file mode 100644 index 00000000000..526677fa632 --- /dev/null +++ b/docs/src/lib/components/annotated/Callout.astro @@ -0,0 +1,261 @@ +--- +/* + * One numbered entry of an `Annotated` figure: a pin at `x`/`y` over the media and a legend entry with `label` as its + * term and the children as its description. Numbers come from the entries' order. See `Annotated.astro`. + */ +import { calloutMarker } from './callout-ids.mjs'; + +type Props = { + /** Pin position, in percent of the media's width from the left. */ + x: number; + /** Pin position, in percent of the media's height from the top. */ + y: number; + /** The control's name: the entry's term, the pin's accessible name and its hover label. */ + label: string; + /** + * Which side of the pin its hover label opens on: away from the control it names, into empty space. Defaults to + * the side facing the middle of the media, so the label never runs off an edge. + */ + labelSide?: 'left' | 'right' | 'top' | 'bottom'; +}; + +const { x, y, label, labelSide = x > 60 ? 'left' : 'right' } = Astro.props; +for (const [name, value] of Object.entries({ x, y })) { + if (!Number.isFinite(value) || value < 0 || value > 100) { + throw new Error(`Callout "${label}": ${name} must be a percentage from 0 to 100, got ${value}.`); + } +} + +// The figure replaces the marker with the entry's id (`callout-ids.mjs`). +const id = calloutMarker(); +--- + +<li class="entry" id={id} style={`--x: ${x}; --y: ${y}`} data-annotated-entry> + <a class:list={['pin', `pin--label-${labelSide}`]} href={`#${id}`} aria-label={label} data-annotated-pin> + <span class="pin__label" aria-hidden="true">{label}</span> + </a> + <span class="badge" aria-hidden="true"></span> + <div class="text"> + {/* The pin carries the same words as its accessible name; read once. */} + <strong class="term" aria-hidden="true">{label}</strong> + <div class="description"><slot /></div> + </div> +</li> + +<style> + .entry { + display: flex; + gap: 0.625rem; + padding: 0.5rem 0.625rem; + border: 1px solid transparent; + border-radius: var(--docs-radius-lg); + counter-increment: annotated; + scroll-margin-block: 1rem; + transition: var(--docs-control-transition); + } + + .badge, + .pin { + display: grid; + flex: none; + place-items: center; + width: 1.375rem; + height: 1.375rem; + border-radius: 999px; + font-size: var(--docs-control-font-size); + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + transition: var(--docs-control-transition); + + &::before { + content: counter(annotated); + } + } + + .badge { + margin-top: 0.0625rem; + border: 1px solid var(--docs-border); + background-color: var(--docs-control); + color: var(--docs-text-strong); + } + + /* + * High contrast on the app's dark surfaces and on screenshots, with a ring of page background around it. 22px over + * a full-width figure, shrinking to 16px on a phone, where the media is drawn at about half size and a 22px pin + * would cover the control beside it. The tap target (`--hit`) is 44px where the media is at least 550px wide, and + * 8% of the media's width below that, so pins at least 8% of the width apart never share tap area (see + * `Annotated.astro`). + */ + .pin { + --pin: clamp(1rem, 2.75cqw + 0.25rem, 1.375rem); + --hit: clamp(var(--pin), 8cqw, 2.75rem); + width: var(--pin); + height: var(--pin); + font-size: calc(var(--pin) * 0.55); + position: absolute; + top: calc(var(--y) * var(--annotated-ratio) * 1cqw); + left: calc(var(--x) * 1cqw); + z-index: 1; + translate: -50% -50%; + background-color: var(--docs-text-strong); + box-shadow: 0 0 0 2px var(--docs-bg); + color: var(--docs-bg); + text-decoration: none; + -webkit-tap-highlight-color: transparent; + + &::after { + content: ''; + position: absolute; + inset: calc((var(--pin) - var(--hit)) / 2); + border-radius: inherit; + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 3px; + } + } + + .pin__label { + position: absolute; + top: 50%; + left: calc(100% + 0.5rem); + translate: 0 -50%; + padding: 0.25rem 0.5rem; + border: 1px solid var(--docs-border-strong); + border-radius: var(--docs-radius); + background-color: var(--docs-surface); + box-shadow: var(--docs-shadow-popup); + color: var(--docs-text-strong); + font-size: var(--docs-control-font-size); + font-weight: 600; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity var(--docs-popup-duration) var(--docs-popup-easing); + } + + .pin--label-left .pin__label { + right: calc(100% + 0.5rem); + left: auto; + } + + .pin--label-top .pin__label, + .pin--label-bottom .pin__label { + left: 50%; + translate: -50% 0; + } + + .pin--label-top .pin__label { + top: auto; + bottom: calc(100% + 0.5rem); + } + + .pin--label-bottom .pin__label { + top: calc(100% + 0.5rem); + } + + .text { + min-width: 0; + font-size: var(--sl-text-sm); + line-height: 1.55; + } + + .term { + display: block; + color: var(--docs-text-strong); + font-weight: 600; + } + + .description { + color: var(--docs-text-muted); + + > :global(* + *) { + margin-top: 0.5rem; + } + + :global(a) { + color: var(--docs-accent-text); + text-decoration: underline; + text-decoration-color: color-mix(in oklab, var(--docs-accent) 40%, transparent); + text-underline-offset: 0.2em; + + &:hover { + text-decoration-color: currentColor; + } + } + + :global(code) { + padding: 0.0625rem 0.25rem; + border-radius: 4px; + background-color: var(--docs-control); + font-size: 0.875em; + } + + :global(strong) { + color: var(--docs-text); + font-weight: 600; + } + } + + /* + * Linked: the entry and its pin highlight together, whichever is pointed at, or chosen. The pin is inside the entry, + * so pointing at it is pointing at the entry. Without script, focus and the URL's `:target` choose; with it, + * `data-active` alone does (the script sets it on focus and on load from the URL), so one entry at most is chosen. + */ + :global(.annotated:not([data-js])) .entry:is(:hover, :focus-within, :target), + :global(.annotated[data-js]) .entry:is(:hover, [data-active]) { + border-color: var(--docs-border-strong); + background-color: var(--docs-control); + + .badge, + .pin { + background-color: var(--docs-accent); + color: var(--docs-accent-contrast); + } + + .badge { + border-color: var(--docs-accent); + } + + .pin { + z-index: 2; + } + + .pin__label { + opacity: 1; + } + } + + /* Forced colors drop backgrounds and shadows: outline the pins and badges, and mark the chosen ones. */ + @media (forced-colors: active) { + .badge, + .pin { + border: 1px solid CanvasText; + background-color: Canvas; + color: CanvasText; + } + + :global(.annotated:not([data-js])) .entry:is(:hover, :focus-within, :target), + :global(.annotated[data-js]) .entry:is(:hover, [data-active]) { + border-color: Highlight; + + .badge, + .pin { + border-color: Highlight; + background-color: Highlight; + color: HighlightText; + } + } + } + + @media (prefers-reduced-motion: reduce) { + .entry, + .badge, + .pin, + .pin__label { + transition: none; + } + } +</style> diff --git a/docs/src/lib/components/annotated/IntermediatesSettingsSketch.astro b/docs/src/lib/components/annotated/IntermediatesSettingsSketch.astro new file mode 100644 index 00000000000..9782f35d5e4 --- /dev/null +++ b/docs/src/lib/components/annotated/IntermediatesSettingsSketch.astro @@ -0,0 +1,398 @@ +--- +/* + * Settings › Intermediates as an annotated app sketch: the Settings dialog's section list on the left and the + * Intermediates section (`features/intermediates/ui/IntermediatesManager.tsx`) on the right, as an administrator + * filtered to one account sees it. Text is drawn as skeleton bars, so the sketch needs no translation; the callouts + * passed as children name the controls. + * + * Layout follows the app at 1 stage unit ≈ 1px: a 150-unit section list (`SettingsDialog.tsx`); the section's 16-unit + * padding; a header row with an outline Refresh button; the search field and the account filter tag; the select-all + * bar (`ListSelectionBar.tsx`) with its estimate and the red ghost Delete button; and 52-unit comfortable list rows + * (`ListItem.tsx`, `IntermediatesList.tsx`) with checkbox, 36-unit cover, name and size, and the Used and Unused + * figures. The last row is Unassigned, with its folder cover and "No project" badge. + */ +import BrushCleaning from '@lucide/astro/icons/brush-cleaning'; +import Check from '@lucide/astro/icons/check'; +import CodeXml from '@lucide/astro/icons/code-xml'; +import Database from '@lucide/astro/icons/database'; +import Folder from '@lucide/astro/icons/folder'; +import Info from '@lucide/astro/icons/info'; +import Keyboard from '@lucide/astro/icons/keyboard'; +import Minus from '@lucide/astro/icons/minus'; +import Palette from '@lucide/astro/icons/palette'; +import RefreshCw from '@lucide/astro/icons/refresh-cw'; +import Search from '@lucide/astro/icons/search'; +import Server from '@lucide/astro/icons/server'; +import SlidersHorizontal from '@lucide/astro/icons/sliders-horizontal'; +// Lucide 1 renamed Trash2, the app's Delete icon, to Trash; lucide-react still exports it under both names. +import Trash from '@lucide/astro/icons/trash'; +import X from '@lucide/astro/icons/x'; +import { Image } from 'astro:assets'; + +import landscape from '../../../assets/whats-new-landscape.webp'; +import { box } from '../whats-new/box'; +import Annotated from './Annotated.astro'; + +type Props = { + /** Names the sketch for screen readers. */ + label: string; +}; + +const { label } = Astro.props; + +const WIDTH = 640; +const HEIGHT = 400; +const sk = (width: number) => `--sk: ${width}`; + +// The section list: group headings and their sections; Intermediates is the current one. +const nav = [ + { heading: 48, items: [{ Icon: Palette, w: 44 }, { Icon: SlidersHorizontal, w: 40 }, { Icon: Keyboard, w: 38 }] }, + { heading: 36, items: [{ Icon: Folder, w: 34 }] }, + { + heading: 40, + items: [ + { Icon: CodeXml, w: 46 }, + { Icon: Server, w: 32 }, + { Icon: Database, w: 70 }, + { Icon: BrushCleaning, w: 58, current: true }, + { Icon: Info, w: 52 }, + ], + }, +]; +type NavRow = { y: number; w: number; Icon?: typeof Palette; current?: boolean }; +const navRows: NavRow[] = []; +let navY = 40; +for (const { heading, items } of nav) { + navRows.push({ y: navY + 8, w: heading }); + navY += 20; + for (const item of items) { + navRows.push({ y: navY, ...item }); + navY += 20; + } + navY += 6; +} + +// Section body: 16 units of padding inside the right-hand column. +const left = 166; +const right = 624; +const rowTop = (index: number) => 150 + index * 54; +const rows = [ + { name: 104, size: 58, used: 12, unused: 148, checked: true, crop: 'object-position: 20% 50%; scale: 1.8' }, + { name: 82, size: 52, used: 3, unused: 64, crop: 'object-position: 70% 50%; scale: 2.4' }, + { name: 120, size: 46, used: 0, unused: 21, crop: 'object-position: 45% 50%' }, + { name: 64, size: 40, used: 0, unused: 9, unassigned: true }, +]; +const thumbProps = { src: landscape, alt: '', width: 96 }; +--- + +<Annotated label={label} width={WIDTH} height={HEIGHT}> + <Fragment slot="sketch"> + <div class="window wn-abs" style={box(0, 0, WIDTH, HEIGHT)}> + <!-- Section list --> + <div class="wn-abs rule" style={`${box(0, 0, 150, HEIGHT)}; border-right-width: 1px`}></div> + <div class="wn-abs field" style={box(10, 10, 130, 22)}><Search /><i class="sk" style={sk(52)}></i></div> + { + navRows.map(({ y, w, Icon, current }) => + Icon ? ( + <div class:list={['wn-abs row nav-item', { 'nav-item--current': current }]} style={box(10, y, 130, 18)}> + <Icon /> + <i class="sk" style={sk(w)} /> + </div> + ) : ( + <i class="wn-abs sk sk--heading" style={box(18, y, w, 3)} /> + ) + ) + } + + <!-- Section header --> + <div class="wn-abs rule" style={`${box(150, 0, 490, 36)}; border-bottom-width: 1px`}></div> + <div class="wn-abs row panel-head" style={box(left, 0, 300, 36)}> + <BrushCleaning /><i class="sk sk--strong" style={sk(62)}></i> + </div> + <div class="wn-abs row" style={box(606, 0, 20, 36)}><X /></div> + + <!-- Title, description and Refresh --> + <i class="wn-abs sk sk--title" style={box(left, 50, 92, 6)}></i> + <i class="wn-abs sk" style={box(left, 64, 240, 3.5)}></i> + <div class="wn-abs icon-button" style={box(right - 26, 48, 26, 26)}><RefreshCw /></div> + + <!-- Search and the account filter --> + <div class="wn-abs field" style={box(left, 84, 336, 26)}><Search /><i class="sk" style={sk(96)}></i></div> + <div class="wn-abs row tag" style={box(right - 112, 87, 112, 20)}> + <i class="sk sk--strong" style={sk(72)}></i><i class="spacer"></i><X /> + </div> + + <!-- Select-all bar --> + <div class="wn-abs row select-bar" style={box(left, 118, right - left, 30)}> + <span class="checkbox checkbox--on"><Minus /></span> + <i class="sk" style={sk(30)}></i> + <i class="spacer"></i> + <i class="sk" style={sk(120)}></i> + <i class="separator"></i> + <span class="row delete"><Trash /><i class="sk" style={sk(24)}></i></span> + </div> + + <!-- Rows --> + { + rows.map(({ name, size, used, unused, checked, crop, unassigned }, index) => ( + <div + class:list={['wn-abs row list-row', { 'list-row--selected': checked }]} + style={box(left + 12, rowTop(index), right - left - 24, 52)} + > + <span class:list={['checkbox', { 'checkbox--on': checked }]}>{checked && <Check />}</span> + <span class="cover"> + {unassigned ? <Folder /> : <Image {...thumbProps} class="fill" style={crop} />} + </span> + <span class="body"> + <span class="row"> + <i class="sk sk--strong" style={sk(name)} /> + {unassigned && ( + <span class="badge"> + <i class="sk" style={sk(34)} /> + </span> + )} + </span> + <i class="sk" style={sk(size)} /> + </span> + <span class="figure"> + <b class:list={{ zero: used === 0 }}>{used}</b> + <i class="sk" style={sk(16)} /> + </span> + <span class="figure"> + <b class:list={{ zero: unused === 0 }}>{unused}</b> + <i class="sk" style={sk(22)} /> + </span> + </div> + )) + } + </div> + </Fragment> + <slot /> +</Annotated> + +<style> + .window { + overflow: hidden; + border-radius: inherit; + background-color: var(--wn-panel); + color: var(--wn-ink); + font-size: calc(var(--u) * 8); + } + + /* Lucide icons size from `--i`, in stage units, set by the context that holds them. */ + .window :global(.lucide) { + flex: none; + width: calc(var(--u) * var(--i, 9)); + height: calc(var(--u) * var(--i, 9)); + stroke-width: 2; + } + + .rule { + border: 0 solid var(--wn-line); + } + + .row { + display: flex; + flex: none; + align-items: center; + gap: calc(var(--u) * 6); + white-space: nowrap; + } + + .spacer { + flex: 1; + } + + /* Skeleton text, `--sk` units wide. */ + .sk { + flex: none; + width: calc(var(--u) * var(--sk)); + height: calc(var(--u) * 3.5); + border-radius: calc(var(--u) * 2); + background-color: var(--wn-faint); + } + + .wn-abs.sk { + width: calc(var(--u) * var(--w)); + height: calc(var(--u) * var(--h)); + } + + .sk--strong { + background-color: color-mix(in oklab, var(--wn-strong) 45%, transparent); + } + + .sk--title { + background-color: color-mix(in oklab, var(--wn-strong) 55%, transparent); + } + + .sk--heading { + background-color: color-mix(in oklab, var(--wn-ink) 35%, transparent); + } + + .field { + --i: 8; + display: flex; + align-items: center; + gap: calc(var(--u) * 6); + padding-inline: calc(var(--u) * 8); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 4); + } + + /* Section list: ghost buttons; the current section is the subtle variant. */ + .nav-item { + --i: 8; + padding-inline: calc(var(--u) * 8); + border-radius: calc(var(--u) * 4); + } + + .nav-item--current { + background-color: var(--wn-shown); + color: var(--wn-strong); + + .sk { + background-color: color-mix(in oklab, var(--wn-strong) 45%, transparent); + } + } + + .panel-head { + --i: 10; + color: var(--wn-strong); + } + + .icon-button { + --i: 10; + display: grid; + place-items: center; + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 4); + color: var(--wn-strong); + } + + /* The account filter: a removable tag. */ + .tag { + --i: 7; + gap: calc(var(--u) * 4); + padding-inline: calc(var(--u) * 8) calc(var(--u) * 5); + border-radius: calc(var(--u) * 4); + background-color: var(--wn-raised); + } + + .select-bar { + --i: 7; + gap: calc(var(--u) * 8); + padding-inline: calc(var(--u) * 20) calc(var(--u) * 12); + border-bottom: 1px solid var(--docs-border-subtle); + } + + .separator { + flex: none; + width: 1px; + height: calc(var(--u) * 16); + background-color: var(--docs-border-subtle); + } + + .delete { + --i: 9; + gap: calc(var(--u) * 4); + color: var(--invoke-danger, var(--docs-text-strong)); + + .sk { + background-color: color-mix(in oklab, currentColor 55%, transparent); + } + } + + .checkbox { + --i: 9; + display: grid; + flex: none; + place-items: center; + width: calc(var(--u) * 14); + height: calc(var(--u) * 14); + border: 1px solid var(--docs-border-strong); + border-radius: calc(var(--u) * 3); + } + + .checkbox--on { + border-color: var(--docs-accent); + background-color: var(--docs-accent); + color: var(--docs-accent-contrast); + + :global(.lucide) { + stroke-width: 3; + } + } + + .list-row { + gap: calc(var(--u) * 10); + padding-inline: calc(var(--u) * 8) calc(var(--u) * 12); + border-radius: calc(var(--u) * 4); + } + + .list-row--selected { + background-color: color-mix(in oklab, var(--wn-raised) 60%, transparent); + } + + .cover { + --i: 14; + display: grid; + flex: none; + overflow: hidden; + place-items: center; + width: calc(var(--u) * 36); + height: calc(var(--u) * 36); + border: 1px solid var(--docs-border-subtle); + border-radius: calc(var(--u) * 5); + background-color: var(--wn-raised); + } + + .fill { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + .body { + display: flex; + flex: 1; + flex-direction: column; + gap: calc(var(--u) * 7); + } + + .badge { + display: flex; + align-items: center; + height: calc(var(--u) * 13); + padding-inline: calc(var(--u) * 5); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 3); + background-color: var(--wn-raised); + } + + /* The app's figures sit 16 apart (`gap="4"`), 6 more than the row's gap. */ + .figure + .figure { + margin-left: calc(var(--u) * 6); + } + + .figure { + display: flex; + flex: none; + flex-direction: column; + align-items: flex-end; + gap: calc(var(--u) * 4); + width: calc(var(--u) * 52); + + b { + color: var(--wn-strong); + font-size: calc(var(--u) * 11); + font-weight: 600; + line-height: 1; + } + + .zero { + color: var(--wn-ink); + font-weight: 500; + } + } +</style> diff --git a/docs/src/lib/components/annotated/ModelManagerSketch.astro b/docs/src/lib/components/annotated/ModelManagerSketch.astro new file mode 100644 index 00000000000..133aac2cd4c --- /dev/null +++ b/docs/src/lib/components/annotated/ModelManagerSketch.astro @@ -0,0 +1,478 @@ +--- +/* + * The Model Manager (`invokeai/frontend/webv2/src/features/models/ui/ModelManagerView.tsx`) as an annotated app + * sketch, on its Add Models tab with one starter bundle chosen. Text is drawn as skeleton bars, so the sketch needs no + * translation; the callouts passed as children name the controls. + * + * Layout follows the app at roughly 1 stage unit per pixel of a narrow window: the library column + * (`manager/LibraryColumn.tsx`) with its header and maintenance menu, search and filter bar, select-all bar and model + * rows (cover, name, base and format badges, size); the detail pane (`manager/DetailPane.tsx`) with its three tabs; + * the Add Models view (`add-models/AddModelsView.tsx`): the search-or-add field, the models-folder scan row, the + * bundle chips with the starter filter menu, the selected bundle's Install bundle bar and starter rows with Install + * buttons or an Installed badge; and the collapsed install queue (`install-queue/InstallQueueBar.tsx`) under the + * detail pane. + */ +import Box from '@lucide/astro/icons/box'; +import Check from '@lucide/astro/icons/check'; +import ChevronUp from '@lucide/astro/icons/chevron-up'; +import Download from '@lucide/astro/icons/download'; +import Ellipsis from '@lucide/astro/icons/ellipsis'; +import Folder from '@lucide/astro/icons/folder'; +import FolderSearch from '@lucide/astro/icons/folder-search'; +import KeyRound from '@lucide/astro/icons/key-round'; +import Pause from '@lucide/astro/icons/pause'; +import Plus from '@lucide/astro/icons/plus'; +import Search from '@lucide/astro/icons/search'; +import SlidersHorizontal from '@lucide/astro/icons/sliders-horizontal'; +import X from '@lucide/astro/icons/x'; +import { Image } from 'astro:assets'; + +import landscape from '../../../assets/whats-new-landscape.webp'; +import { box } from '../whats-new/box'; +import Annotated from './Annotated.astro'; + +type Props = { + /** Names the sketch for screen readers. */ + label: string; +}; + +const { label } = Astro.props; + +const WIDTH = 640; +const HEIGHT = 400; +const sk = (width: number) => `--sk: ${width}`; + +// Library column. +const LIBRARY = 224; +const libraryRows = [ + { name: 84, hue: 265, crop: 'object-position: 20% 50%; scale: 1.8' }, + { name: 70, hue: 42, crop: 'object-position: 70% 50%; scale: 2.4' }, + { name: 96, hue: 180 }, + { name: 62, hue: 265, crop: 'object-position: 45% 50%' }, + { name: 78, hue: 317 }, + { name: 88, hue: 42, crop: 'object-position: 85% 40%; scale: 2' }, +]; +const libraryRowTop = (index: number) => 98 + index * 46; + +// Detail pane: 12 units of padding inside it. +const left = LIBRARY + 12; +const right = WIDTH - 12; +const chips = [ + { w: 18, count: 14 }, + { w: 30, count: 10 }, + { w: 24, count: 10 }, + { w: 30, count: 12, selected: true }, + { w: 34, count: 8 }, +]; +const starterRows = [ + { name: 112, description: 220, installed: false }, + { name: 86, description: 196, installed: true }, + { name: 128, description: 236, installed: false }, + { name: 74, description: 180, installed: false }, +]; +const starterRowTop = (index: number) => 166 + index * 46; +const QUEUE_TOP = 356; +--- + +<Annotated label={label} width={WIDTH} height={HEIGHT}> + <Fragment slot="sketch"> + <div class="window wn-abs" style={box(0, 0, WIDTH, HEIGHT)}> + <!-- Library column --> + <div class="wn-abs rule" style={`${box(0, 0, LIBRARY, HEIGHT)}; border-right-width: 1px`}></div> + <div class="wn-abs rule" style={`${box(0, 0, LIBRARY, 34)}; border-bottom-width: 1px`}></div> + <div class="wn-abs row" style={box(12, 0, 120, 34)}> + <i class="sk sk--title" style={sk(44)}></i><i class="sk" style={sk(12)}></i> + </div> + <div class="wn-abs icon-button icon-button--ghost" style={box(196, 7, 20, 20)}><Ellipsis /></div> + + <div class="wn-abs field" style={box(12, 42, 174, 22)}><Search /><i class="sk" style={sk(64)}></i></div> + <div class="wn-abs icon-button" style={box(192, 42, 22, 22)}><SlidersHorizontal /></div> + + <div class="wn-abs row select-bar" style={box(12, 70, LIBRARY - 24, 22)}> + <span class="checkbox"></span><i class="sk" style={sk(34)}></i> + </div> + + { + libraryRows.map(({ name, hue, crop }, index) => ( + <div + class:list={['wn-abs row list-row', { 'list-row--active': index === 0 }]} + style={box(8, libraryRowTop(index), LIBRARY - 16, 44)} + > + <span class="cover">{crop ? <Image src={landscape} alt="" width={96} class="fill" style={crop} /> : <Box />}</span> + <span class="body"> + <i class="sk sk--strong" style={sk(name)} /> + <span class="row badges"> + <span class="badge" style={`--hue: ${hue}`}> + <i class="sk" style={sk(22)} /> + </span> + <span class="badge"> + <i class="sk" style={sk(18)} /> + </span> + </span> + </span> + <i class="sk" style={sk(24)} /> + </div> + )) + } + + <!-- Detail pane tabs --> + <div class="wn-abs rule" style={`${box(LIBRARY, 0, WIDTH - LIBRARY, 34)}; border-bottom-width: 1px`}></div> + <div class="wn-abs row tab" style={box(left, 0, 64, 34)}><Box /><i class="sk" style={sk(44)}></i></div> + <div class="wn-abs row tab tab--active" style={box(312, 0, 68, 34)}> + <Plus /><i class="sk sk--strong" style={sk(48)}></i> + </div> + <div class="wn-abs row tab" style={box(416, 0, 52, 34)}><KeyRound /><i class="sk" style={sk(34)}></i></div> + + <!-- Search or add --> + <div class="wn-abs field field--large" style={box(left, 46, 364, 26)}> + <Search /><i class="sk" style={sk(220)}></i> + </div> + + <!-- Models folder scan --> + <div class="wn-abs row outline-button" style={box(left, 80, 98, 20)}> + <FolderSearch /><i class="sk" style={sk(62)}></i> + </div> + <i class="wn-abs sk sk--mono" style={box(left + 106, 88, 120, 3.5)}></i> + + <!-- Bundle chips and the starter filter menu --> + <div class="wn-abs row chips" style={box(left, 108, 320, 22)}> + { + chips.map(({ w, count, selected }) => ( + <span class:list={['row chip', { 'chip--selected': selected }]}> + <Folder /> + <i class="sk" style={sk(w)} /> + <span class="count">{count}</span> + </span> + )) + } + </div> + <div class="wn-abs icon-button" style={box(right - 22, 108, 22, 22)}><SlidersHorizontal /></div> + + <!-- Selected bundle bar --> + <i class="wn-abs sk" style={box(left, 146, 150, 3.5)}></i> + <div class="wn-abs row outline-button" style={box(right - 80, 138, 80, 20)}> + <Download /><i class="sk sk--strong" style={sk(48)}></i> + </div> + + <!-- Starter rows --> + { + starterRows.map(({ name, description, installed }, index) => ( + <div class="wn-abs row starter-row" style={box(left, starterRowTop(index), right - left, 46)}> + <span class="body"> + <span class="row"> + <i class="sk sk--strong" style={sk(name)} /> + <span class="badge" style="--hue: 265"> + <i class="sk" style={sk(22)} /> + </span> + <span class="badge"> + <i class="sk" style={sk(26)} /> + </span> + </span> + <i class="sk" style={sk(description)} /> + </span> + {installed ? ( + <span class="row installed"> + <Check /> + <i class="sk" style={sk(30)} /> + </span> + ) : ( + <span class="row outline-button"> + <Download /> + <i class="sk sk--strong" style={sk(26)} /> + </span> + )} + </div> + )) + } + + <!-- Install queue, collapsed --> + <div class="wn-abs rule queue" style={`${box(LIBRARY, QUEUE_TOP, WIDTH - LIBRARY, HEIGHT - QUEUE_TOP)}; border-top-width: 1px`}></div> + <div class="wn-abs row queue-row" style={box(left, QUEUE_TOP, right - left, HEIGHT - QUEUE_TOP)}> + <ChevronUp /><i class="sk sk--title" style={sk(62)}></i> + <i class="sk" style={sk(70)}></i> + <span class="progress"><span></span></span> + <i class="spacer"></i> + <Pause /><X /> + </div> + </div> + </Fragment> + <slot /> +</Annotated> + +<style> + .window { + overflow: hidden; + border-radius: inherit; + background-color: var(--wn-panel); + color: var(--wn-ink); + font-size: calc(var(--u) * 8); + } + + /* Lucide icons size from `--i`, in stage units, set by the context that holds them. */ + .window :global(.lucide) { + flex: none; + width: calc(var(--u) * var(--i, 9)); + height: calc(var(--u) * var(--i, 9)); + stroke-width: 2; + } + + .rule { + border: 0 solid var(--wn-line); + } + + .row { + display: flex; + flex: none; + align-items: center; + gap: calc(var(--u) * 6); + white-space: nowrap; + } + + .spacer { + flex: 1; + } + + /* Skeleton text, `--sk` units wide. */ + .sk { + flex: none; + width: calc(var(--u) * var(--sk)); + height: calc(var(--u) * 3.5); + border-radius: calc(var(--u) * 2); + background-color: var(--wn-faint); + } + + .wn-abs.sk { + width: calc(var(--u) * var(--w)); + height: calc(var(--u) * var(--h)); + } + + .sk--strong { + background-color: color-mix(in oklab, var(--wn-strong) 45%, transparent); + } + + .sk--title { + background-color: color-mix(in oklab, var(--wn-strong) 55%, transparent); + } + + .sk--mono { + border-radius: 0; + background-image: repeating-linear-gradient( + 90deg, + var(--wn-faint) 0 calc(var(--u) * 5), + transparent 0 calc(var(--u) * 6) + ); + background-color: transparent; + } + + .field { + --i: 8; + display: flex; + align-items: center; + gap: calc(var(--u) * 6); + padding-inline: calc(var(--u) * 8); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 4); + } + + .field--large { + --i: 9; + padding-inline: calc(var(--u) * 10); + } + + .icon-button { + --i: 9; + display: grid; + place-items: center; + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 4); + color: var(--wn-strong); + } + + .icon-button--ghost { + border-color: transparent; + } + + .outline-button { + --i: 8; + gap: calc(var(--u) * 5); + justify-content: center; + padding-inline: calc(var(--u) * 7); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 4); + color: var(--wn-strong); + } + + .select-bar { + gap: calc(var(--u) * 8); + padding-inline: calc(var(--u) * 8); + border-bottom: 1px solid var(--docs-border-subtle); + } + + .checkbox { + flex: none; + width: calc(var(--u) * 12); + height: calc(var(--u) * 12); + border: 1px solid var(--docs-border-strong); + border-radius: calc(var(--u) * 3); + } + + .list-row { + gap: calc(var(--u) * 8); + padding-inline: calc(var(--u) * 6) calc(var(--u) * 8); + border-radius: calc(var(--u) * 4); + } + + .list-row--active { + background-color: var(--wn-shown); + } + + .cover { + --i: 13; + display: grid; + flex: none; + overflow: hidden; + place-items: center; + width: calc(var(--u) * 30); + height: calc(var(--u) * 30); + border: 1px solid var(--docs-border-subtle); + border-radius: calc(var(--u) * 5); + background-color: var(--wn-raised); + } + + .fill { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + .body { + display: flex; + flex: 1; + flex-direction: column; + gap: calc(var(--u) * 7); + min-width: 0; + } + + .badges { + gap: calc(var(--u) * 3); + } + + /* A base badge takes its family's hue; a format or type badge stays grey. */ + .badge { + display: flex; + flex: none; + align-items: center; + height: calc(var(--u) * 11); + padding-inline: calc(var(--u) * 4); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 3); + background-color: var(--wn-raised); + } + + .badge[style] { + border-color: oklch(65% 0.12 var(--hue) / 0.45); + background-color: oklch(65% 0.12 var(--hue) / 0.18); + + .sk { + background-color: oklch(65% 0.12 var(--hue) / 0.7); + } + } + + .tab { + --i: 8; + gap: calc(var(--u) * 5); + justify-content: center; + } + + .tab--active { + box-shadow: inset 0 calc(var(--u) * -2) 0 var(--docs-accent); + color: var(--wn-strong); + } + + .chips { + gap: calc(var(--u) * 5); + overflow: hidden; + } + + .chip { + --i: 7; + gap: calc(var(--u) * 4); + height: calc(var(--u) * 22); + padding-inline: calc(var(--u) * 7) calc(var(--u) * 4); + border-radius: calc(var(--u) * 4); + background-color: var(--wn-raised); + } + + .chip--selected { + background-color: var(--wn-strong); + color: var(--wn-panel); + + .sk { + background-color: color-mix(in oklab, var(--wn-panel) 70%, transparent); + } + } + + .count { + display: grid; + place-items: center; + height: calc(var(--u) * 13); + padding-inline: calc(var(--u) * 3); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 3); + background-color: var(--wn-panel); + color: var(--wn-ink); + font-size: calc(var(--u) * 7); + font-weight: 600; + } + + .starter-row { + gap: calc(var(--u) * 10); + border-bottom: 1px solid var(--docs-border-subtle); + + .body .row { + gap: calc(var(--u) * 4); + } + + .outline-button { + height: calc(var(--u) * 20); + } + } + + .installed { + --i: 7; + gap: calc(var(--u) * 3); + height: calc(var(--u) * 14); + padding-inline: calc(var(--u) * 4); + border-radius: calc(var(--u) * 3); + background-color: oklch(65% 0.14 150 / 0.18); + color: oklch(65% 0.14 150); + + .sk { + background-color: oklch(65% 0.14 150 / 0.7); + } + } + + .queue-row { + --i: 9; + gap: calc(var(--u) * 8); + color: var(--wn-strong); + } + + .progress { + display: block; + flex: none; + overflow: hidden; + width: calc(var(--u) * 100); + height: calc(var(--u) * 4); + border-radius: calc(var(--u) * 2); + background-color: var(--wn-raised); + + span { + display: block; + width: 62%; + height: 100%; + background-color: var(--docs-accent); + } + } +</style> diff --git a/docs/src/lib/components/annotated/UpscalePanelSketch.astro b/docs/src/lib/components/annotated/UpscalePanelSketch.astro new file mode 100644 index 00000000000..b37c3c9c7f8 --- /dev/null +++ b/docs/src/lib/components/annotated/UpscalePanelSketch.astro @@ -0,0 +1,361 @@ +--- +/* + * The Upscale panel in Refine mode as an annotated app sketch: the left region with the Upscale panel + * (`features/upscale/ui/UpscaleWidgetView.tsx`) beside the Preview, which shows a result. Text is drawn as skeleton + * bars, so the sketch needs no translation; the callouts passed as children name the controls. + * + * Layout follows the panel top to bottom: the Mode segmented control above everything; the open Source & treatment + * section (`GenerateCollapsibleSection`) with the source image slot, the size preview (Input, Scale, Output, Output + * area), the Spandrel model field, the Scale, Creativity and Structure scrubbers (label left, value right, fill behind) + * and the four treatment presets; then the collapsed Detail guidance (with its Shared badge), Generation and Advanced + * sections. Scrubbers sit at the panel's defaults: Scale 4 of 1-16, Creativity and Structure 0 of -10-10, Balanced. + * + * Pins go in the gutter right of the panel (x 299 of 640), touching the right edge of what they name, at least 52 + * units (8% of the width) apart: rows are spaced for that, and the treatment controls and the three collapsed + * sections each take one callout. + */ +import ChevronDown from '@lucide/astro/icons/chevron-down'; +import ChevronRight from '@lucide/astro/icons/chevron-right'; +import Eye from '@lucide/astro/icons/eye'; +import ImageUpscale from '@lucide/astro/icons/image-upscale'; +import SlidersHorizontal from '@lucide/astro/icons/sliders-horizontal'; +import X from '@lucide/astro/icons/x'; +import { Image } from 'astro:assets'; + +import result from '../../../content/docs/Users Guide/assets/upscale-refine.webp'; +import { box } from '../whats-new/box'; +import Annotated from './Annotated.astro'; + +type Props = { + /** Names the sketch for screen readers. */ + label: string; +}; + +const { label } = Astro.props; + +const WIDTH = 640; +const HEIGHT = 544; +const sk = (width: number) => `--sk: ${width}`; + +// The panel's content column. The left region runs to 316, leaving a gutter right of the panel for the pins. +const left = 16; +const width = 268; +const region = 316; + +/** A scrubber: its frame, the fill up to the value and the thumb line, with the label and value as skeletons. */ +const scrubbers = [ + { y: 312, label: 30, value: 12, fill: (4 - 1) / 15 }, + { y: 380, label: 48, value: 8, fill: 0.5 }, + { y: 408, label: 44, value: 8, fill: 0.5 }, +]; +const presets = [58, 46, 40, 40]; +const collapsed = [ + { y: 446, w: 84, badge: true }, + { y: 476, w: 64 }, + { y: 506, w: 56 }, +]; +const preflight = [ + { label: 22, value: 52 }, + { label: 24, value: 14 }, + { label: 30, value: 52, strong: true }, + { label: 46, value: 34, strong: true }, +]; +--- + +<Annotated label={label} width={WIDTH} height={HEIGHT}> + <Fragment slot="sketch"> + <div class="window wn-abs" style={box(0, 0, WIDTH, HEIGHT)}> + <!-- Left region: its widget tabs and the Upscale panel --> + <div class="wn-abs rule" style={`${box(0, 0, region, HEIGHT)}; border-right-width: 1px`}></div> + <div class="wn-abs rule" style={`${box(0, 0, region, 32)}; border-bottom-width: 1px`}></div> + <div class="wn-abs row tab" style={box(8, 5, 74, 22)}><SlidersHorizontal /><i class="sk" style={sk(40)}></i></div> + <div class="wn-abs row tab tab--current" style={box(86, 5, 70, 22)}> + <ImageUpscale /><i class="sk sk--strong" style={sk(36)}></i> + </div> + + <!-- Mode --> + <i class="wn-abs sk sk--label" style={box(10, 42, 22, 3.5)}></i> + <div class="wn-abs segments" style={box(10, 52, 274, 24)}> + <span class="segment segment--on"><i class="sk sk--strong" style={sk(30)}></i></span> + <span class="segment"><i class="sk" style={sk(46)}></i></span> + </div> + <i class="wn-abs sk" style={box(10, 82, 210, 3.5)}></i> + + <!-- Source & treatment, open --> + <div class="wn-abs section-open" style={box(6, 94, 284, 342)}></div> + <div class="wn-abs row section-head" style={box(12, 94, 272, 28)}> + <ChevronDown /><i class="sk sk--heading" style={sk(84)}></i> + </div> + + <div class="wn-abs row slot" style={box(left, 128, width, 62)}> + <span class="thumb"><Image src={result} alt="" width={192} class="fill" /></span> + <span class="body"> + <i class="sk sk--strong" style={sk(92)}></i> + <i class="sk" style={sk(56)}></i> + </span> + <span class="icon-button"><X /></span> + </div> + + <div class="wn-abs preflight" style={box(left, 196, width, 44)}> + { + preflight.map(({ label, value, strong }) => ( + <span class="row preflight__row"> + <i class="sk" style={sk(label)} /> + <i class="spacer" /> + <i class:list={['sk', { 'sk--strong': strong }]} style={sk(value)} /> + </span> + )) + } + </div> + + <i class="wn-abs sk sk--label" style={box(left, 252, 58, 3.5)}></i> + <div class="wn-abs row select" style={box(left, 260, width, 22)}> + <i class="sk sk--strong" style={sk(110)}></i><i class="spacer"></i><ChevronDown /> + </div> + <i class="wn-abs sk" style={box(left, 288, 226, 3.5)}></i> + + <i class="wn-abs sk" style={box(left, 340, 190, 3.5)}></i> + { + scrubbers.map(({ y, label, value, fill }) => ( + <div class="wn-abs row scrubber" style={`${box(left, y, width, 22)}; --fill: ${fill}`}> + <i class="scrubber__fill" /> + <i class="sk sk--label" style={sk(label)} /> + <i class="spacer" /> + <i class="sk sk--strong" style={sk(value)} /> + </div> + )) + } + + <div class="wn-abs segments" style={box(left, 352, width, 22)}> + { + presets.map((w, index) => ( + <span class:list={['segment', { 'segment--on': index === 1 }]}> + <i class:list={['sk', { 'sk--strong': index === 1 }]} style={sk(w)} /> + </span> + )) + } + </div> + + <!-- Detail guidance, Generation and Advanced, collapsed --> + { + collapsed.map(({ y, w, badge }) => ( + <div class="wn-abs row section-head" style={box(12, y, 272, 28)}> + <ChevronRight /> + <i class="sk sk--heading" style={sk(w)} /> + {badge && ( + <> + <i class="spacer" /> + <span class="badge"> + <i class="sk" style={sk(26)} /> + </span> + </> + )} + </div> + )) + } + + <!-- Preview, showing a result --> + <div class="wn-abs rule" style={`${box(region, 0, WIDTH - region, 32)}; border-bottom-width: 1px`}></div> + <div class="wn-abs row tab tab--current" style={box(region + 8, 5, 66, 22)}> + <Eye /><i class="sk sk--strong" style={sk(36)}></i> + </div> + <Image src={result} alt="" width={640} class="wn-abs preview" style={box(region + 16, 175, 292, 195)} /> + </div> + </Fragment> + <slot /> +</Annotated> + +<style> + .window { + overflow: hidden; + border-radius: inherit; + background-color: var(--wn-panel); + color: var(--wn-ink); + font-size: calc(var(--u) * 8); + } + + /* Lucide icons size from `--i`, in stage units, set by the context that holds them. */ + .window :global(.lucide) { + flex: none; + width: calc(var(--u) * var(--i, 9)); + height: calc(var(--u) * var(--i, 9)); + stroke-width: 2; + } + + .rule { + border: 0 solid var(--wn-line); + } + + .row { + display: flex; + flex: none; + align-items: center; + gap: calc(var(--u) * 6); + white-space: nowrap; + } + + .spacer { + flex: 1; + } + + /* Skeleton text, `--sk` units wide. */ + .sk { + flex: none; + width: calc(var(--u) * var(--sk)); + height: calc(var(--u) * 3.5); + border-radius: calc(var(--u) * 2); + background-color: var(--wn-faint); + } + + .wn-abs.sk { + width: calc(var(--u) * var(--w)); + height: calc(var(--u) * var(--h)); + } + + .sk--strong { + background-color: color-mix(in oklab, var(--wn-strong) 45%, transparent); + } + + .sk--label { + background-color: color-mix(in oklab, var(--wn-ink) 45%, transparent); + } + + .sk--heading { + background-color: color-mix(in oklab, var(--wn-ink) 35%, transparent); + } + + .tab { + --i: 8; + padding-inline: calc(var(--u) * 7); + border-radius: calc(var(--u) * 4); + } + + .tab--current { + background-color: var(--wn-shown); + color: var(--wn-strong); + } + + /* Segmented controls: Mode and the treatment presets. */ + .segments { + display: flex; + gap: calc(var(--u) * 2); + padding: calc(var(--u) * 2); + border-radius: calc(var(--u) * 5); + background-color: var(--wn-raised); + } + + .segment { + display: grid; + flex: 1; + place-items: center; + border-radius: calc(var(--u) * 4); + } + + .segment--on { + background-color: var(--wn-panel); + box-shadow: 0 0 0 1px var(--wn-line); + } + + .section-open { + border-radius: calc(var(--u) * 6); + background-color: color-mix(in oklab, var(--wn-raised) 45%, transparent); + } + + .section-head { + --i: 9; + padding-inline: calc(var(--u) * 6); + } + + .slot { + gap: calc(var(--u) * 10); + padding: calc(var(--u) * 6); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 5); + } + + .thumb { + display: block; + flex: none; + overflow: hidden; + width: calc(var(--u) * 75); + height: 100%; + border-radius: calc(var(--u) * 3); + } + + .fill { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + .body { + display: flex; + flex: 1; + flex-direction: column; + gap: calc(var(--u) * 7); + } + + .icon-button { + --i: 9; + display: grid; + align-self: flex-start; + place-items: center; + width: calc(var(--u) * 18); + height: calc(var(--u) * 18); + border-radius: calc(var(--u) * 4); + color: var(--wn-strong); + } + + .preflight { + display: flex; + flex-direction: column; + justify-content: center; + gap: calc(var(--u) * 5.5); + padding-inline: calc(var(--u) * 10); + border-radius: calc(var(--u) * 5); + background-color: var(--wn-raised); + } + + .select { + --i: 9; + padding-inline: calc(var(--u) * 8); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 4); + } + + /* A scrubber: the label and value inside the frame, the fill behind them up to the thumb line. */ + .scrubber { + position: absolute; + overflow: hidden; + padding-inline: calc(var(--u) * 8); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 4); + + .sk { + position: relative; + } + } + + .scrubber__fill { + position: absolute; + inset: 0 auto 0 0; + width: calc(var(--fill) * 100%); + border-right: calc(var(--u) * 2) solid var(--docs-accent); + background-color: color-mix(in oklab, var(--docs-accent) 22%, transparent); + } + + .badge { + display: flex; + align-items: center; + height: calc(var(--u) * 13); + padding-inline: calc(var(--u) * 5); + border: 1px solid var(--wn-line); + border-radius: calc(var(--u) * 3); + background-color: var(--wn-raised); + } + + .preview { + border-radius: calc(var(--u) * 4); + } +</style> diff --git a/docs/src/lib/components/annotated/callout-ids.mjs b/docs/src/lib/components/annotated/callout-ids.mjs new file mode 100644 index 00000000000..380cb22d289 --- /dev/null +++ b/docs/src/lib/components/annotated/callout-ids.mjs @@ -0,0 +1,39 @@ +// @ts-check + +// Ids of an `Annotated` figure's legend entries, which their pins link to. They depend only on the page's own +// content (`callout-<figure>-<entry>`, both counted from 1 in page order), so a deep link to an entry keeps working +// across builds and dev reloads. A callout cannot know its figure or its place in it, so it writes a unique marker; +// the figure numbers the markers in its rendered legend. + +const MARKER = /annotated-callout-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g; + +/** A placeholder for one callout's id: write it everywhere the id goes (the entry's `id`, the pin's `href`). */ +export const calloutMarker = () => `annotated-callout-${crypto.randomUUID()}`; + +/** @type {WeakMap<object, number>} */ +const figuresOnPage = new WeakMap(); + +/** + * The figure's number on its page, from 1. `Astro.locals` is created for each page render, so it keys the count. + * @param {object} locals the page's `Astro.locals` + */ +export const nextFigureIndex = (locals) => { + const index = (figuresOnPage.get(locals) ?? 0) + 1; + figuresOnPage.set(locals, index); + return index; +}; + +/** + * Replaces each callout marker in a figure's rendered legend with its id: the n-th distinct marker becomes + * `callout-<figure>-<n>`, and repeats of a marker get the same id. + * @param {string} html + * @param {number} figure + */ +export const numberCallouts = (html, figure) => { + /** @type {Map<string, string>} */ + const ids = new Map(); + return html.replace(MARKER, (marker) => { + if (!ids.has(marker)) ids.set(marker, `callout-${figure}-${ids.size + 1}`); + return /** @type {string} */ (ids.get(marker)); + }); +}; diff --git a/docs/src/lib/components/annotated/callout-ids.test.mjs b/docs/src/lib/components/annotated/callout-ids.test.mjs new file mode 100644 index 00000000000..6b0c6f11f71 --- /dev/null +++ b/docs/src/lib/components/annotated/callout-ids.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { calloutMarker, nextFigureIndex, numberCallouts } from './callout-ids.mjs'; + +const entry = (marker) => `<li id="${marker}"><a href="#${marker}"></a></li>`; + +describe('numberCallouts', () => { + it('numbers callouts in order, giving an entry and its pin the same id', () => { + const html = entry(calloutMarker()) + entry(calloutMarker()); + assert.equal( + numberCallouts(html, 2), + '<li id="callout-2-1"><a href="#callout-2-1"></a></li><li id="callout-2-2"><a href="#callout-2-2"></a></li>' + ); + }); + + it('gives the same content the same ids on every render', () => { + const render = () => numberCallouts(entry(calloutMarker()), 1); + assert.equal(render(), render()); + }); +}); + +describe('nextFigureIndex', () => { + it('counts figures per page render', () => { + const page = {}; + assert.equal(nextFigureIndex(page), 1); + assert.equal(nextFigureIndex(page), 2); + assert.equal(nextFigureIndex({}), 1); + }); +}); diff --git a/docs/src/lib/components/compare/CompareImage.astro b/docs/src/lib/components/compare/CompareImage.astro new file mode 100644 index 00000000000..8c81a1ff808 --- /dev/null +++ b/docs/src/lib/components/compare/CompareImage.astro @@ -0,0 +1,58 @@ +--- +/* + * One side of an `ImageCompare` slider, passed in its `before` or `after` slot: the image and its label, which sits in + * the image's top corner on that side so the divider clips it with the image as it passes. See `ImageCompare.astro`. + */ +import type { ImageMetadata } from 'astro'; +import { Image } from 'astro:assets'; + +type Props = { + src: ImageMetadata; + alt: string; + /** A short name for this side, such as "Before"; it also names the side in the slider's spoken value. */ + label: string; + /** The image's `sizes`; the default fits the docs content column. */ + sizes?: string; +}; + +const { src, alt, label, sizes = '(min-width: 72rem) 52rem, calc(100vw - 2rem)' } = Astro.props; +if (!alt) throw new Error('CompareImage needs `alt` text.'); +--- + +<Image src={src} alt={alt} widths={[640, src.width]} sizes={sizes} loading="lazy" decoding="async" /> +<span class="compare__label" aria-hidden="true" data-compare-label>{label}</span> + +<style> + img { + display: block; + width: 100%; + height: auto; + -webkit-user-drag: none; + pointer-events: none; + } + + .compare__label { + position: absolute; + top: 0.75rem; + display: inline-flex; + align-items: center; + height: var(--docs-tab-height); + padding-inline: var(--docs-tab-pad-x); + border-radius: var(--docs-radius); + background-color: color-mix(in oklab, var(--docs-surface) 82%, transparent); + box-shadow: inset 0 0 0 1px var(--docs-border-translucent); + backdrop-filter: blur(8px); + color: var(--docs-text-strong); + font-size: var(--docs-control-font-size); + font-weight: 500; + line-height: 1; + } + + :global([data-compare-pane='before']) > .compare__label { + left: 0.75rem; + } + + :global([data-compare-pane='after']) > .compare__label { + right: 0.75rem; + } +</style> diff --git a/docs/src/lib/components/compare/ImageCompare.astro b/docs/src/lib/components/compare/ImageCompare.astro new file mode 100644 index 00000000000..ac3a63d377e --- /dev/null +++ b/docs/src/lib/components/compare/ImageCompare.astro @@ -0,0 +1,347 @@ +--- +/* + * A before/after slider over two images of the same size. The before image shows left of the divider and the after + * image right of it; drag the divider, click or tap anywhere on the image, or focus the handle and use the arrow keys, + * Page Up/Down, Home and End. Without JavaScript the two images sit side by side. The default slot, if given, is the + * figure's caption. + * + * The images are `CompareImage` children in the `before` and `after` slots, so their alt text and labels are string + * attributes that Crowdin can translate in a docs page: + * + * <ImageCompare label="Model only and Refine compared"> + * <CompareImage slot="before" src={modelOnly} alt="…" label="Model only" /> + * <CompareImage slot="after" src={refine} alt="…" label="Refine" /> + * </ImageCompare> + * + * Both images must have the same size and framing, so they line up pixel for pixel. + */ +import ChevronsLeftRight from '@lucide/astro/icons/chevrons-left-right'; + +type Props = { + /** Names the slider for screen readers. */ + label: string; + /** Where the divider starts, in percent of the width from the left. */ + start?: number; + class?: string; +}; + +const { label, start = 50, class: className } = Astro.props; +const { t } = Astro.locals; +--- + +{/* `not-content` opts out of Starlight's Markdown spacing, which would push the stacked layers apart. */} +<figure + class:list={['compare not-content', className]} + data-image-compare + data-value-text={t('imageCompare.valueText')} + style={`--pos: ${start}%`} +> + {/* Marks the figure before first paint, so readers with JavaScript never see the side-by-side layout. */} + <script is:inline> + document.currentScript.parentElement.dataset.js = ''; + </script> + <div class="compare__view" data-compare-view> + <div class="compare__pane compare__pane--before" data-compare-pane="before"> + <slot name="before" /> + </div> + <div class="compare__pane compare__pane--after" data-compare-pane="after"> + <slot name="after" /> + </div> + <div class="compare__track"> + {/* The script names the value with the images' labels; without script the slider is hidden. */} + <div + class="compare__handle" + role="slider" + tabindex="0" + aria-label={label} + aria-orientation="horizontal" + aria-valuemin="0" + aria-valuemax="100" + aria-valuenow={start} + data-compare-handle + > + <ChevronsLeftRight aria-hidden="true" size={16} /> + </div> + </div> + </div> + { + Astro.slots.has('default') && ( + <figcaption class="compare__caption"> + <slot /> + </figcaption> + ) + } +</figure> + +<script> + import { + clampPercent, + nudgeAt, + percentFromKey, + percentFromPointer, + touchGesture, + valueText, + } from './image-compare.mjs'; + + const NUDGE_TO = 64; + const NUDGE_MS = 1400; + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + + const mount = (root: HTMLElement) => { + const view = root.querySelector<HTMLElement>('[data-compare-view]'); + const handle = root.querySelector<HTMLElement>('[data-compare-handle]'); + if (!view || !handle) return; + + const labelOf = (pane: 'before' | 'after') => + root.querySelector(`[data-compare-pane="${pane}"] [data-compare-label]`)?.textContent?.trim() ?? ''; + const labels = { before: labelOf('before'), after: labelOf('after') }; + let position = clampPercent(Number(handle.getAttribute('aria-valuenow'))); + let nudgeFrame = 0; + let touched = false; + + const set = (value: number) => { + position = clampPercent(value); + // One custom property drives the clip and the divider's translate: no layout reads or writes per move. + root.style.setProperty('--pos', `${position}%`); + handle.setAttribute('aria-valuenow', String(Math.round(position))); + handle.setAttribute('aria-valuetext', valueText(position, root.dataset.valueText!, labels)); + }; + set(position); + + const stopNudge = () => { + touched = true; + if (nudgeFrame) cancelAnimationFrame(nudgeFrame); + nudgeFrame = 0; + }; + + /* + * Mouse: press anywhere to jump there, keep dragging to scrub. Touch and pen: the image also scrolls the page + * (`touch-action: pan-y`), so a press waits until it is a tap (released in place: jump there) or a sideways drag + * (scrub); a vertical swipe hands over to the scroll with `pointercancel` and leaves the slider as it was. A press + * on the handle scrubs at once for every pointer. The rect is read once per press. + */ + let press: { id: number; x: number; y: number; rect: DOMRect; grab: number; scrubbing: boolean } | null = null; + + const scrubTo = (clientX: number) => { + if (press) set(percentFromPointer(clientX - press.grab, press.rect)); + }; + const startScrub = (event: PointerEvent) => { + if (!press) return; + press.scrubbing = true; + stopNudge(); + view.setPointerCapture(event.pointerId); + root.dataset.dragging = ''; + scrubTo(event.clientX); + }; + + view.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || press) return; + const rect = view.getBoundingClientRect(); + const onHandle = handle.contains(event.target as Node); + // Grabbing the handle off-centre keeps that offset, so the divider does not jump under the pointer. + const grab = onHandle ? event.clientX - (rect.left + (rect.width * position) / 100) : 0; + press = { id: event.pointerId, x: event.clientX, y: event.clientY, rect, grab, scrubbing: false }; + if (event.pointerType === 'mouse' || onHandle) startScrub(event); + }); + view.addEventListener('pointermove', (event) => { + if (!press || event.pointerId !== press.id) return; + if (press.scrubbing) scrubTo(event.clientX); + else if (touchGesture(event.clientX - press.x, event.clientY - press.y) === 'scrub') startScrub(event); + }); + view.addEventListener('pointerup', (event) => { + if (!press || event.pointerId !== press.id) return; + // A touch or pen press released before it moved is a tap. + if (!press.scrubbing && touchGesture(event.clientX - press.x, event.clientY - press.y) === 'pending') { + stopNudge(); + scrubTo(event.clientX); + } + release(); + }); + const release = () => { + press = null; + delete root.dataset.dragging; + }; + view.addEventListener('pointercancel', release); + // Only the view's own capture: a touch's implicit capture on the pressed child ends, and bubbles, when the scrub + // moves capture to the view. + view.addEventListener('lostpointercapture', (event) => { + if (event.target === view && press?.scrubbing && event.pointerId === press.id) release(); + }); + + handle.addEventListener('keydown', (event) => { + const next = percentFromKey(event.key, position); + if (next === null) return; + event.preventDefault(); + stopNudge(); + set(next); + }); + + // A one-time hint when the slider first comes into view: the divider sweeps right and back. + if (reducedMotion.matches || !('IntersectionObserver' in window)) return; + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + observer.disconnect(); + if (touched || reducedMotion.matches) return; + const from = position; + const begin = performance.now(); + const step = (now: number) => { + const t = (now - begin) / NUDGE_MS; + set(nudgeAt(t, from, NUDGE_TO)); + if (t < 1) nudgeFrame = requestAnimationFrame(step); + else nudgeFrame = 0; + }; + nudgeFrame = requestAnimationFrame(step); + }, + { threshold: 0.6 } + ); + observer.observe(view); + }; + + for (const root of document.querySelectorAll<HTMLElement>('[data-image-compare]')) mount(root); +</script> + +<style> + .compare { + /* The home page tour frames' shell: see `.frame` in HomePage.astro. */ + --frame-inset: 0.375rem; + --inner-radius: calc(var(--docs-radius-container) - var(--frame-inset) - 1px); + --line: oklch(1 0 0 / 0.92); + margin: 0; + padding: var(--frame-inset); + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + } + + .compare__view { + position: relative; + overflow: hidden; + border-radius: var(--inner-radius); + /* Horizontal drags scrub the slider; vertical swipes still scroll the page. */ + touch-action: pan-y; + cursor: ew-resize; + user-select: none; + -webkit-tap-highlight-color: transparent; + } + + .compare__pane--after { + position: absolute; + inset: 0; + clip-path: inset(0 0 0 var(--pos)); + } + + .compare__caption { + padding: 0.5rem 0.375rem 0.125rem; + color: var(--docs-text-muted); + font-size: var(--sl-text-sm); + line-height: 1.55; + + > :global(* + *) { + margin-top: 0.5rem; + } + + :global(strong) { + color: var(--docs-text); + font-weight: 600; + } + } + + /* + * The track is as wide as the view and carries the line and the handle on its left edge, so `translate` in percent + * moves them by the view's width: a composited move that follows resizes without measuring. + */ + .compare__track { + position: absolute; + inset: 0; + translate: var(--pos) 0; + pointer-events: none; + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -1px; + width: 2px; + background-color: var(--line); + box-shadow: 0 0 0 1px oklch(0 0 0 / 0.18); + } + } + + .compare__handle { + position: absolute; + top: 50%; + left: 0; + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + margin: -1.125rem 0 0 -1.125rem; + border-radius: 999px; + background-color: var(--docs-surface); + box-shadow: + inset 0 0 0 2px var(--line), + 0 4px 14px oklch(0 0 0 / 0.35); + color: var(--docs-text-strong); + cursor: ew-resize; + pointer-events: auto; + transition: + scale 120ms ease-out, + background-color 100ms; + touch-action: none; + + &:hover { + background-color: var(--docs-control); + } + + /* A 44px target around the 36px knob. */ + &::after { + content: ''; + position: absolute; + inset: -0.25rem; + border-radius: inherit; + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + } + } + + .compare[data-dragging] .compare__handle { + scale: 0.96; + } + + @media (prefers-reduced-motion: reduce) { + .compare__handle { + transition: none; + } + } + + /* Without JavaScript: the two images side by side, each with its label, and no slider. */ + .compare:not([data-js]) { + .compare__view { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: var(--frame-inset); + border-radius: 0; + cursor: auto; + } + + .compare__pane { + position: relative; + overflow: hidden; + border-radius: var(--inner-radius); + } + + .compare__pane--after { + inset: auto; + clip-path: none; + } + + .compare__track { + display: none; + } + } +</style> diff --git a/docs/src/lib/components/compare/image-compare.mjs b/docs/src/lib/components/compare/image-compare.mjs new file mode 100644 index 00000000000..d80f0b174d8 --- /dev/null +++ b/docs/src/lib/components/compare/image-compare.mjs @@ -0,0 +1,99 @@ +// @ts-check +import { interpolate } from '../../i18n.mjs'; + +// Position maths for the before/after slider (`ImageCompare.astro`). Positions are percentages of the image width, from +// the left edge: the before image shows left of the divider, the after image right of it. + +/** One arrow-key press, in percent. */ +export const KEY_STEP = 5; +/** One Page Up/Down press, in percent. */ +export const PAGE_STEP = 20; + +/** + * Clamps a position to 0–100, rounded to a tenth of a percent so drags do not write long fractions into styles. + * @param {number} value + */ +export const clampPercent = (value) => { + if (!Number.isFinite(value)) return 0; + return Math.round(Math.min(100, Math.max(0, value)) * 10) / 10; +}; + +/** + * The position under a pointer, from its client x and the image's client rect. + * @param {number} clientX + * @param {{ left: number, width: number }} rect + */ +export const percentFromPointer = (clientX, rect) => + rect.width > 0 ? clampPercent(((clientX - rect.left) / rect.width) * 100) : 50; + +/** + * The position after a key press, or `null` when the key does not move the slider. Right and Up move the divider + * right, matching a horizontal slider's value growing to the right. + * @param {string} key a `KeyboardEvent.key` + * @param {number} value the current position + * @returns {number | null} + */ +export const percentFromKey = (key, value) => { + switch (key) { + case 'ArrowRight': + case 'ArrowUp': + return clampPercent(value + KEY_STEP); + case 'ArrowLeft': + case 'ArrowDown': + return clampPercent(value - KEY_STEP); + case 'PageUp': + return clampPercent(value + PAGE_STEP); + case 'PageDown': + return clampPercent(value - PAGE_STEP); + case 'Home': + return 0; + case 'End': + return 100; + default: + return null; + } +}; + +/** How far a touch or pen press moves before it counts as a scrub or a scroll, in CSS pixels. */ +export const GESTURE_THRESHOLD = 6; + +/** + * What a touch or pen press on the image has become after moving `dx`, `dy` from where it started: still `pending` + * inside the threshold, a horizontal `scrub` of the slider, or a vertical `scroll` of the page, which leaves the + * slider alone. A press released while pending is a tap. + * @param {number} dx + * @param {number} dy + * @returns {'pending' | 'scrub' | 'scroll'} + */ +export const touchGesture = (dx, dy) => { + const x = Math.abs(dx); + const y = Math.abs(dy); + if (Math.max(x, y) < GESTURE_THRESHOLD) return 'pending'; + return x > y ? 'scrub' : 'scroll'; +}; + +/** + * The slider's spoken value: how much of each image shows, named by the images' labels. + * @param {number} value + * @param {string} template the `imageCompare.valueText` UI string, with `{{before}}` and `{{after}}` percentages and + * `{{beforeLabel}}` and `{{afterLabel}}` names + * @param {{ before: string, after: string }} labels + */ +export const valueText = (value, template, labels) => { + const before = Math.round(clampPercent(value)); + return interpolate(template, { before, after: 100 - before, beforeLabel: labels.before, afterLabel: labels.after }); +}; + +/** + * The hint's position at time `t` (0–1): out from `from` to `to` and back, eased in and out, so it ends where it + * started. + * @param {number} t + * @param {number} from + * @param {number} to + */ +export const nudgeAt = (t, from, to) => { + const clamped = Math.min(1, Math.max(0, t)); + // 0 → 1 → 0 over the run, with zero velocity at both ends and at the turn. + const swing = (1 - Math.cos(clamped * 2 * Math.PI)) / 2; + return clampPercent(from + (to - from) * swing); +}; diff --git a/docs/src/lib/components/compare/image-compare.test.mjs b/docs/src/lib/components/compare/image-compare.test.mjs new file mode 100644 index 00000000000..8958039695b --- /dev/null +++ b/docs/src/lib/components/compare/image-compare.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + clampPercent, + nudgeAt, + percentFromKey, + percentFromPointer, + touchGesture, + valueText, +} from './image-compare.mjs'; + +describe('clampPercent', () => { + it('keeps positions inside the image and rounds to a tenth', () => { + assert.equal(clampPercent(-12), 0); + assert.equal(clampPercent(140), 100); + assert.equal(clampPercent(33.333333), 33.3); + }); + + it('treats a non-number as the left edge', () => { + assert.equal(clampPercent(Number.NaN), 0); + assert.equal(clampPercent(Number.POSITIVE_INFINITY), 0); + }); +}); + +describe('percentFromPointer', () => { + const rect = { left: 100, width: 400 }; + + it('maps the pointer to the share of the image left of it', () => { + assert.equal(percentFromPointer(100, rect), 0); + assert.equal(percentFromPointer(250, rect), 37.5); + assert.equal(percentFromPointer(500, rect), 100); + }); + + it('clamps a pointer dragged past either edge', () => { + assert.equal(percentFromPointer(40, rect), 0); + assert.equal(percentFromPointer(900, rect), 100); + }); + + it('falls back to the middle for an image with no width yet', () => { + assert.equal(percentFromPointer(120, { left: 0, width: 0 }), 50); + }); +}); + +describe('percentFromKey', () => { + it('moves 5% per arrow key, in both orientations', () => { + assert.equal(percentFromKey('ArrowRight', 50), 55); + assert.equal(percentFromKey('ArrowUp', 50), 55); + assert.equal(percentFromKey('ArrowLeft', 50), 45); + assert.equal(percentFromKey('ArrowDown', 50), 45); + }); + + it('stops at the ends', () => { + assert.equal(percentFromKey('ArrowRight', 98), 100); + assert.equal(percentFromKey('ArrowLeft', 3), 0); + assert.equal(percentFromKey('PageDown', 10), 0); + }); + + it('jumps to the ends with Home and End', () => { + assert.equal(percentFromKey('Home', 64), 0); + assert.equal(percentFromKey('End', 64), 100); + }); + + it('ignores keys that do not move a slider', () => { + assert.equal(percentFromKey('Enter', 50), null); + assert.equal(percentFromKey('a', 50), null); + }); +}); + +describe('nudgeAt', () => { + it('swings out to the target at the midpoint and returns to the start', () => { + assert.equal(nudgeAt(0, 50, 64), 50); + assert.equal(nudgeAt(0.5, 50, 64), 64); + assert.equal(nudgeAt(1, 50, 64), 50); + }); + + it('eases: a quarter of the way through it has covered half the swing', () => { + assert.equal(nudgeAt(0.25, 50, 64), 57); + }); +}); + +describe('valueText', () => { + const labels = { before: 'Model only', after: 'Refine' }; + + it('names the share of each image in view by its label', () => { + const template = '{{beforeLabel}} {{before}}%, {{afterLabel}} {{after}}%'; + assert.equal(valueText(50, template, labels), 'Model only 50%, Refine 50%'); + assert.equal(valueText(62.4, template, labels), 'Model only 62%, Refine 38%'); + assert.equal(valueText(0, template, labels), 'Model only 0%, Refine 100%'); + }); + + it('follows a translation that orders the parts its own way', () => { + assert.equal( + valueText(30, '{{before}} % {{beforeLabel}}, {{after}} % {{afterLabel}}', { before: 'Vorher', after: 'Nachher' }), + '30 % Vorher, 70 % Nachher' + ); + }); +}); + +describe('touchGesture', () => { + it('waits while the press stays within 6px', () => { + assert.equal(touchGesture(0, 0), 'pending'); + assert.equal(touchGesture(5, -5), 'pending'); + }); + + it('scrubs once the press moves further sideways than up or down', () => { + assert.equal(touchGesture(6, 2), 'scrub'); + assert.equal(touchGesture(-20, 12), 'scrub'); + }); + + it('leaves the slider to a vertical swipe, including a diagonal one', () => { + assert.equal(touchGesture(1, 6), 'scroll'); + assert.equal(touchGesture(-14, 30), 'scroll'); + assert.equal(touchGesture(8, -8), 'scroll'); + }); +}); diff --git a/docs/src/lib/components/memory/BenchmarkChart.astro b/docs/src/lib/components/memory/BenchmarkChart.astro new file mode 100644 index 00000000000..53939269e8c --- /dev/null +++ b/docs/src/lib/components/memory/BenchmarkChart.astro @@ -0,0 +1,125 @@ +--- +/* + * A before/after timing chart: one `BenchmarkRow` per measured run, each with a muted bar for the default path and an + * accent bar for the setting, drawn to one scale (the slowest time in the chart), so give runs measured under very + * different conditions a chart of their own. Static HTML and CSS: it reads the same without JavaScript. The bars are + * hidden from screen readers, which get the same data as a table. + * + * <BenchmarkChart title="Denoising time on an RTX 4090" setting="int8_compute"> + * <BenchmarkRow label="Z-Image Turbo, 8 steps, 1024²" before={5.04} after={2.93} /> + * </BenchmarkChart> + */ +import { chartScale, formatMeasure, rowsInHtml, speedup } from './benchmark-chart.mjs'; +import { pageLang } from './page-lang.mjs'; + +type Props = { + /** The caption: what was measured, and on what. */ + title: string; + /** The setting the accent bars show, as written in `invokeai.yaml`. */ + setting: string; +}; + +const { title, setting } = Astro.props as Props; +const { t } = Astro.locals; +const lang = pageLang(Astro.locals); +const html = await Astro.slots.render('default'); +const rows = rowsInHtml(html); +const scale = chartScale(rows); +const seconds = (value: number) => t('benchmarkChart.seconds', { value: formatMeasure(value, lang) }); +--- + +<figure class="bench not-content" style={`--bench-max: ${scale}`}> + <figcaption class="bench__caption"> + <span class="bench__title">{title}</span> + <span class="bench__legend" aria-hidden="true"> + <span class="bench__key bench__key--before">{t('benchmarkChart.default')}</span> + <span class="bench__key bench__key--after"><code>{setting}</code></span> + </span> + </figcaption> + <div class="bench__rows"> + <Fragment set:html={html} /> + </div> + <table class="sr-only"> + <thead> + <tr> + <th scope="col">{t('benchmarkChart.run')}</th> + <th scope="col">{t('benchmarkChart.default')}</th> + <th scope="col"><code>{setting}</code></th> + <th scope="col">{t('benchmarkChart.ratio')}</th> + </tr> + </thead> + <tbody> + { + rows.map((row) => ( + <tr> + <th scope="row">{row.label}</th> + <td>{seconds(row.before)}</td> + <td>{seconds(row.after)}</td> + <td>{t('benchmarkChart.speedup', { ratio: formatMeasure(speedup(row.before, row.after), lang) })}</td> + </tr> + )) + } + </tbody> + </table> +</figure> + +<style> + .bench { + margin: 0; + padding: 0.75rem 1rem 0.375rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + } + + .bench__caption { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.25rem 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--docs-border); + } + + .bench__title { + color: var(--docs-text-strong); + font-size: var(--sl-text-sm); + font-weight: 600; + } + + .bench__legend { + display: flex; + gap: 0.875rem; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + } + + .bench__key { + display: inline-flex; + align-items: center; + gap: 0.375rem; + + &::before { + content: ''; + width: 0.625rem; + height: 0.625rem; + border-radius: 2px; + } + + code { + padding: 0; + background: none; + color: var(--docs-text-strong); + font-size: inherit; + } + } + + .bench__key--before::before { + background-color: var(--docs-border-strong); + } + + .bench__key--after::before { + background-color: var(--docs-accent); + } +</style> diff --git a/docs/src/lib/components/memory/BenchmarkRow.astro b/docs/src/lib/components/memory/BenchmarkRow.astro new file mode 100644 index 00000000000..9a0265789a2 --- /dev/null +++ b/docs/src/lib/components/memory/BenchmarkRow.astro @@ -0,0 +1,122 @@ +--- +// One measured run in a `BenchmarkChart`: its time with the setting off and on, in seconds, drawn to the chart's +// scale. The bars are for sighted readers; the chart reads the row's data attributes and gives screen readers a table. +import { formatMeasure, speedup } from './benchmark-chart.mjs'; +import { pageLang } from './page-lang.mjs'; + +type Props = { + /** What was measured: the model, steps and size. */ + label: string; + /** Seconds with the setting off. */ + before: number; + /** Seconds with the setting on. */ + after: number; +}; + +const { label, before, after } = Astro.props as Props; +const { t } = Astro.locals; +const lang = pageLang(Astro.locals); +const seconds = (value: number) => t('benchmarkChart.seconds', { value: formatMeasure(value, lang) }); +const ratio = formatMeasure(speedup(before, after), lang); +--- + +<div + class="bench-row" + aria-hidden="true" + data-bench-row + data-bench-label={label} + data-bench-before={before} + data-bench-after={after} + style={`--before: ${before}; --after: ${after}`} +> + <p class="bench-row__label">{label}</p> + <div class="bench-row__bars"> + <div class="bench-row__track"> + <span class="bench-row__bar bench-row__bar--before"></span> + <span class="bench-row__value">{seconds(before)}</span> + </div> + <div class="bench-row__track"> + <span class="bench-row__bar bench-row__bar--after"></span> + <span class="bench-row__value bench-row__value--after">{seconds(after)}</span> + </div> + </div> + <p class="bench-row__speedup">{t('benchmarkChart.speedup', { ratio })}</p> +</div> + +<style> + .bench-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'label speedup' + 'bars bars'; + align-items: baseline; + gap: 0.25rem 0.75rem; + padding-block: 0.625rem; + border-top: 1px solid var(--docs-border-subtle); + } + + .bench-row:first-child { + border-top: 0; + } + + .bench-row__label { + grid-area: label; + margin: 0; + color: var(--docs-text); + font-size: var(--sl-text-sm); + line-height: 1.4; + } + + .bench-row__speedup { + grid-area: speedup; + margin: 0; + color: var(--docs-accent-text); + font-size: var(--sl-text-sm); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + .bench-row__bars { + grid-area: bars; + display: grid; + gap: 3px; + } + + /* 100% of the scale is the track minus room for the value after the longest bar. */ + .bench-row__track { + --value-room: 4.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + height: 0.875rem; + } + + .bench-row__bar { + flex: none; + height: 100%; + border-radius: 3px; + } + + .bench-row__bar--before { + width: calc((100% - var(--value-room)) * var(--before) / var(--bench-max)); + background-color: var(--docs-border-strong); + } + + .bench-row__bar--after { + width: calc((100% - var(--value-room)) * var(--after) / var(--bench-max)); + background-color: var(--docs-accent); + } + + .bench-row__value { + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + font-variant-numeric: tabular-nums; + line-height: 1; + white-space: nowrap; + } + + .bench-row__value--after { + color: var(--docs-text-strong); + } +</style> diff --git a/docs/src/lib/components/memory/MemoryOption.astro b/docs/src/lib/components/memory/MemoryOption.astro new file mode 100644 index 00000000000..38327bd0d67 --- /dev/null +++ b/docs/src/lib/components/memory/MemoryOption.astro @@ -0,0 +1,49 @@ +--- +// One recommendation of the `MemoryPicker`. Its `id` ties it to a rule in `memory-picker.mjs`, which decides when it +// shows; the content says what to do and links to the page that explains it. Without JavaScript every option shows, +// so the content should also say when it applies. +type Props = { + /** The rule id in `MEMORY_RULES`. */ + id: string; + /** What to do, in a few words. */ + title: string; +}; + +const { id, title } = Astro.props as Props; +--- + +<article class="memory-option" data-memory-option={id}> + <p class="memory-option__title">{title}</p> + <div class="memory-option__body"><slot /></div> +</article> + +<style> + /* The app's launchpad tile, as `article.card` in custom.css. */ + .memory-option { + --docs-wrap-pad: calc(var(--docs-text-offset, 13px) - 1px); + padding: 0.75rem var(--docs-wrap-pad); + border: 1px solid var(--invoke-neutral-600); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + font-size: var(--sl-text-sm); + } + + .memory-option[hidden] { + display: none; + } + + .memory-option__title { + margin: 0; + color: var(--docs-text-strong); + font-weight: 600; + } + + .memory-option__body { + margin-top: 0.25rem; + color: var(--docs-text-muted); + + > :global(:first-child) { + margin-top: 0; + } + } +</style> diff --git a/docs/src/lib/components/memory/MemoryPicker.astro b/docs/src/lib/components/memory/MemoryPicker.astro new file mode 100644 index 00000000000..d1e141ae878 --- /dev/null +++ b/docs/src/lib/components/memory/MemoryPicker.astro @@ -0,0 +1,169 @@ +--- +/* + * "Which memory option do I need?": the shared setup picker (OS and GPU, saved across pages), then the NVIDIA + * generation, VRAM and model format, and below them the `MemoryOption`s that apply, in page order. The + * rules are in `memory-picker.mjs`. Every option is rendered; the inline script after them hides the others before + * they paint. Without JavaScript the rows stay hidden and every option shows. + * + * <MemoryPicker> + * <MemoryOption id="low-vram-mode" title="…">…</MemoryOption> + * </MemoryPicker> + */ +import { pluralForms } from '../../i18n.mjs'; +import SetupPicker from '../SetupPicker.astro'; +import { + checkOptions, + localizeChoices, + MEMORY_ROWS, + MEMORY_RULES, + MEMORY_STORAGE_KEY, + mountMemoryPicker, + recommend, + resolveMemoryRows, + ROW_VISIBILITY, +} from './memory-picker.mjs'; +import { pageLang } from './page-lang.mjs'; + +const { t } = Astro.locals; +const lang = pageLang(Astro.locals); + +const options = await Astro.slots.render('default'); +const { missing, unknown } = checkOptions(options); +if (missing.length || unknown.length) { + throw new Error( + `MemoryPicker: ${[ + missing.length ? `no MemoryOption for ${missing.join(', ')}` : '', + unknown.length ? `unknown or repeated MemoryOption ${unknown.join(', ')}` : '', + ] + .filter(Boolean) + .join('; ')}` + ); +} + +const legends = { + series: t('memoryPicker.row.series'), + vram: t('memoryPicker.row.vram'), + format: t('memoryPicker.row.format'), +}; +const rows = (Object.keys(MEMORY_ROWS) as (keyof typeof MEMORY_ROWS)[]).map((id) => ({ + id, + legend: legends[id], + choices: localizeChoices(MEMORY_ROWS[id].options, t), +})); + +const config = { + rows: MEMORY_ROWS, + rules: MEMORY_RULES, + visibility: ROW_VISIBILITY, + storageKey: MEMORY_STORAGE_KEY, + countForms: pluralForms(t, 'memoryPicker.count'), + lang, +}; +// Escaping `<` keeps translated strings from closing the script. +const configJson = JSON.stringify(config).replaceAll('<', '\\u003c'); +const mountScript = + `(() => { const root = document.currentScript.parentElement; ` + + `(${mountMemoryPicker})(root, root.querySelector('[data-memory-options]'), ${configJson}, ${recommend}, ${resolveMemoryRows}); })();`; +--- + +<div class="memory-picker" data-memory-picker> + <div class="memory-picker__controls not-content" data-pagefind-ignore> + <p class="memory-picker__fallback">{t('memoryPicker.fallback')}</p> + <div class="memory-picker__panel"> + <SetupPicker embedded /> + { + rows.map((row) => ( + <fieldset class="memory-picker__row" data-memory-row={row.id}> + <legend class="docs-eyebrow">{row.legend}</legend> + <div class="memory-picker__segments docs-segment-tabs"> + {row.choices.map((choice) => ( + <label class="docs-segment-tab"> + <input type="radio" name={`memory-${row.id}`} value={choice.id} data-memory-input /> + {choice.label} + </label> + ))} + </div> + </fieldset> + )) + } + <p class="memory-picker__summary" aria-live="polite" aria-atomic="true" data-memory-summary></p> + </div> + </div> + <div class="memory-picker__options" data-memory-options> + <Fragment set:html={options} /> + </div> + <script is:inline set:html={mountScript} /> +</div> + +<style> + .memory-picker__controls { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + /* + * One panel for the setup picker's rows and these: the setup picker's border, inset and label offsets (see + * SetupPicker.astro), which drops its own border when embedded. + */ + .memory-picker__panel { + display: none; + flex-direction: column; + gap: 0.25rem; + padding: 6px; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + } + + :global(:root[data-setup-os]) .memory-picker__panel { + display: flex; + } + + .memory-picker__fallback { + margin: 0; + color: var(--docs-text-subtle); + } + + :global(:root[data-setup-os]) .memory-picker__fallback { + display: none; + } + + .memory-picker__row { + min-width: 0; + margin: 0; + padding: 0; + border: 0; + } + + .memory-picker__row[hidden] { + display: none; + } + + .memory-picker__row legend { + padding: 0.375rem var(--docs-tab-pad-x) 0.25rem; + } + + .memory-picker__segments { + flex-wrap: wrap; + } + + .memory-picker__summary { + margin: 0; + padding: 0.25rem var(--docs-tab-pad-x) 0.375rem; + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + } + + .memory-picker__options { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; + + /* The gap spaces the options; Starlight's sibling margin would also count options hidden before them. */ + > :global(*) { + margin: 0; + } + } +</style> diff --git a/docs/src/lib/components/memory/VramBudget.astro b/docs/src/lib/components/memory/VramBudget.astro new file mode 100644 index 00000000000..3fb5bbacf57 --- /dev/null +++ b/docs/src/lib/components/memory/VramBudget.astro @@ -0,0 +1,153 @@ +--- +/* + * The VRAM budget formula as a card: with `max_cache_vram_gb` set, the model cache gets that cap minus the working + * memory and the reserve (`ModelCache._get_vram_available` in invokeai/backend/model_manager/load/model_cache). The + * example row works it through for the given values. + */ +import { formatMeasure } from './benchmark-chart.mjs'; +import { pageLang } from './page-lang.mjs'; + +type Props = { + /** The card's heading. */ + title: string; + /** Example `max_cache_vram_gb`, in GB. */ + cap: number; + /** Example `device_working_mem_gb`, in GB. */ + working?: number; + /** Example `reserve_vram_gb`, in GB. */ + reserve?: number; +}; + +const { title, cap, working = 3, reserve = 0 } = Astro.props as Props; +const { t } = Astro.locals; +const lang = pageLang(Astro.locals); +const gb = (value: number) => + t('vramBudget.gigabytes', { value: Number.isInteger(value) ? value : formatMeasure(value, lang) }); +const left = Math.max(0, cap - working - reserve); + +const terms = [ + { setting: 'max_cache_vram_gb', label: t('vramBudget.cap'), value: gb(cap) }, + { setting: 'device_working_mem_gb', label: t('vramBudget.working'), value: gb(working), operator: '−' }, + { setting: 'reserve_vram_gb', label: t('vramBudget.reserve'), value: gb(reserve), operator: '−' }, +]; +--- + +<figure class="budget not-content"> + <figcaption class="budget__title">{title}</figcaption> + <div class="budget__formula" aria-hidden="true"> + { + terms.map(({ setting, label, value, operator }) => ( + <> + {operator && <span class="budget__operator">{operator}</span>} + <div class="budget__term"> + <code>{setting}</code> + <span class="budget__label">{label}</span> + <span class="budget__value">{value}</span> + </div> + </> + )) + } + <span class="budget__operator">=</span> + <div class="budget__term budget__term--result"> + <span class="budget__result-name">{t('vramBudget.result')}</span> + <span class="budget__label">{t('vramBudget.resultHint')}</span> + <span class="budget__value">{gb(left)}</span> + </div> + </div> + {/* The formula reads as one sentence to screen readers. */} + <p class="sr-only"> + {t('vramBudget.summary', { cap: gb(cap), working: gb(working), reserve: gb(reserve), left: gb(left) })} + </p> +</figure> + +<style> + .budget { + container-type: inline-size; + margin: 0; + padding: 6px; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + } + + .budget__title { + padding: 0.375rem var(--docs-tab-pad-x) 0.5rem; + color: var(--docs-text-strong); + font-size: var(--sl-text-sm); + font-weight: 600; + } + + /* Terms in a row with their operators between; labels wrap, the setting names do not. */ + .budget__formula { + display: grid; + grid-template-columns: repeat(3, minmax(min-content, 1fr) auto) minmax(min-content, 1fr); + gap: 0.375rem; + } + + .budget__term { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + padding: 0.5rem var(--docs-tab-pad-x); + border-radius: var(--docs-radius-lg); + background-color: var(--docs-control); + + code { + padding: 0; + background: none; + color: var(--docs-text-strong); + font-size: var(--sl-text-xs); + white-space: nowrap; + } + } + + /* The answer takes the app's selected-row look: solid accent, dark text. */ + .budget__term--result { + background-color: var(--docs-accent); + color: var(--docs-accent-contrast); + + .budget__label, + .budget__value { + color: inherit; + } + } + + .budget__result-name { + font-size: var(--sl-text-xs); + font-weight: 600; + } + + .budget__label { + color: var(--docs-text-subtle); + font-size: var(--sl-text-xs); + line-height: 1.35; + } + + .budget__value { + margin-top: auto; + padding-top: 0.25rem; + color: var(--docs-text-strong); + font-size: var(--sl-text-lg); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + .budget__operator { + display: grid; + place-items: center; + color: var(--docs-text-subtle); + font-size: var(--sl-text-lg); + } + + /* Too narrow for the terms in a row (a phone, or a narrow content column): one term per line. */ + @container (width < 40rem) { + .budget__formula { + grid-template-columns: minmax(0, 1fr); + } + + .budget__operator { + line-height: 1; + } + } +</style> diff --git a/docs/src/lib/components/memory/benchmark-chart.mjs b/docs/src/lib/components/memory/benchmark-chart.mjs new file mode 100644 index 00000000000..76ff13680e8 --- /dev/null +++ b/docs/src/lib/components/memory/benchmark-chart.mjs @@ -0,0 +1,67 @@ +// @ts-check +// Numbers for the before/after timing chart (`BenchmarkChart.astro`, `BenchmarkRow.astro`): each row is one measured +// run, timed with a setting off (`before`) and on (`after`). Bars share one scale, the slowest time in the chart. + +/** + * How many times as fast the run is with the setting on, to two decimals: 5.04 s → 2.93 s is 1.72. + * @param {number} before seconds with the setting off + * @param {number} after seconds with the setting on + */ +export const speedup = (before, after) => { + assertTime(before); + assertTime(after); + return Math.round((before / after) * 100) / 100; +}; + +/** + * The rows of a rendered chart: each `BenchmarkRow` carries its label and times in `data-bench-label`, + * `data-bench-before` and `data-bench-after`. A chart without rows, or with a broken time, is an authoring mistake and + * fails the build. + * @param {string} html the chart's rendered rows + * @returns {{ label: string, before: number, after: number }[]} + */ +export const rowsInHtml = (html) => { + const rows = [...html.matchAll(/<div\b[^>]*\bdata-bench-row\b[^>]*>/g)].map(([tag]) => { + const attribute = (/** @type {string} */ name) => + unescapeHtml(tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] ?? ''); + const row = { + label: attribute('data-bench-label'), + before: Number(attribute('data-bench-before')), + after: Number(attribute('data-bench-after')), + }; + assertTime(row.before); + assertTime(row.after); + return row; + }); + if (!rows.length) throw new Error('A BenchmarkChart needs at least one BenchmarkRow.'); + return rows; +}; + +/** + * The chart's scale: the slowest time any row shows. + * @param {{ before: number, after: number }[]} rows + */ +export const chartScale = (rows) => Math.max(...rows.flatMap((row) => [row.before, row.after])); + +/** @param {string} text an HTML attribute value as rendered */ +const unescapeHtml = (text) => + text + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); + +/** + * A time or ratio in the page's language with exactly two decimals, as measured: `5.04`, `1.70`, or `5,04` in German. + * @param {number} value + * @param {string} lang a BCP 47 tag + */ +export const formatMeasure = (value, lang) => + new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); + +/** @param {number} value */ +function assertTime(value) { + if (!Number.isFinite(value) || value <= 0) throw new Error(`A benchmark time must be a positive number, not ${value}.`); +} diff --git a/docs/src/lib/components/memory/benchmark-chart.test.mjs b/docs/src/lib/components/memory/benchmark-chart.test.mjs new file mode 100644 index 00000000000..2d846b57b0f --- /dev/null +++ b/docs/src/lib/components/memory/benchmark-chart.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { chartScale, formatMeasure, rowsInHtml, speedup } from './benchmark-chart.mjs'; + +describe('speedup', () => { + it('matches the ratios measured for int8 Compute', () => { + assert.equal(speedup(5.04, 2.93), 1.72); + assert.equal(speedup(26.62, 20.71), 1.29); + assert.equal(speedup(34.43, 9.04), 3.81); + }); + + it('rejects a time that is missing, zero or negative', () => { + assert.throws(() => speedup(5, 0), /positive/); + assert.throws(() => speedup(Number.NaN, 2), /positive/); + assert.throws(() => speedup(-1, 2), /positive/); + }); +}); + +describe('rowsInHtml and chartScale', () => { + const html = + '<div class="bench-row" data-bench-row data-bench-label="FLUX.1 "dev", 20 steps & 1024²" ' + + 'data-bench-before="15.25" data-bench-after="7.85"><p>…</p></div>' + + '<div data-bench-row data-bench-label="Krea-2" data-bench-before="21.64" data-bench-after="12.32"></div>'; + + it('reads each row with its label unescaped', () => { + assert.deepEqual(rowsInHtml(html), [ + { label: 'FLUX.1 "dev", 20 steps & 1024²', before: 15.25, after: 7.85 }, + { label: 'Krea-2', before: 21.64, after: 12.32 }, + ]); + }); + + it('scales to the slowest time of any row, before or after', () => { + assert.equal(chartScale(rowsInHtml(html)), 21.64); + }); + + it('fails the build for a chart without rows or with a broken time', () => { + assert.throws(() => rowsInHtml('<p>No rows</p>'), /at least one/); + assert.throws( + () => rowsInHtml('<div data-bench-row data-bench-label="x" data-bench-before="" data-bench-after="2"></div>'), + /positive/ + ); + }); +}); + +describe('formatMeasure', () => { + it('keeps two decimals in the page language', () => { + assert.equal(formatMeasure(1.7, 'en'), '1.70'); + assert.equal(formatMeasure(5.04, 'de'), '5,04'); + }); +}); diff --git a/docs/src/lib/components/memory/memory-picker.mjs b/docs/src/lib/components/memory/memory-picker.mjs new file mode 100644 index 00000000000..2f21a89e79a --- /dev/null +++ b/docs/src/lib/components/memory/memory-picker.mjs @@ -0,0 +1,248 @@ +// @ts-check +// The "Which memory option do I need?" picker (`MemoryPicker.astro`): from the reader's setup (the shared OS and GPU +// picker, `setup-picker.mjs`), NVIDIA generation, VRAM and model format, which of the page's options apply. Every +// option is rendered at build time, in the page's order; the picker only hides the others, so readers without +// JavaScript and the search index see every option. +// +// The rules follow the code: +// - Partial loading runs on CUDA (NVIDIA, AMD through ROCm) and Intel XPU; CPU and Apple MPS load models whole +// (`ModelCache`, invokeai/backend/model_manager/load/model_cache/model_cache.py). +// - FP8 Storage is used on CUDA and XPU devices that pass its probe, never on CPU or MPS +// (`_device_supports_fp8_storage`, invokeai/backend/model_manager/load/load_default.py). +// - fp8_compute needs compute capability 8.9 (RTX 40) or newer on NVIDIA, or a ROCm GPU that passes an fp8 matmul +// probe; never XPU (invokeai/backend/quantization/fp8_scaled.py). +// - int8_compute needs NVIDIA compute capability 8.0 (RTX 30) or newer and Triton +// (invokeai/backend/quantization/int8_matmul.py). +// - NF4 needs bitsandbytes, which the install leaves out on macOS (pyproject.toml); its loader has no device check, and +// NF4 on Intel GPUs or the CPU is untested, so the picker does not present it as working there. +// - Windows on ARM64 means an NVIDIA RTX Spark: a CUDA device. Its install has no Triton (pyproject.toml), so +// int8_compute falls back there; fp8_compute is left to its own probe. + +/** @typedef {{ os: string, gpu: string, series: string, vram: string, format: string }} MemoryChoice */ +/** @typedef {'series' | 'vram' | 'format'} MemoryRow */ +/** @typedef {{ id: string, label?: string, labelKey?: string }} ChoiceOption */ +/** + * An option applies when any of its conditions holds; a condition holds when the choice's value for each key it + * names is in that key's list. + * @typedef {{ id: string, when: Partial<Record<keyof MemoryChoice, string[]>>[] }} MemoryRule + */ + +export const MEMORY_STORAGE_KEY = 'invoke-docs:memory-picker'; + +/** + * The rows below the setup picker, in the order their chips show, and the choice each starts on. + * @type {Record<MemoryRow, { options: ChoiceOption[], initial: string }>} + */ +export const MEMORY_ROWS = { + series: { + initial: 'ada', + options: [ + { id: 'pre-ampere', labelKey: 'memoryPicker.series.preAmpere' }, + { id: 'ampere', labelKey: 'memoryPicker.series.ampere' }, + { id: 'ada', labelKey: 'memoryPicker.series.ada' }, + ], + }, + vram: { + initial: '8', + options: [ + { id: '6', labelKey: 'memoryPicker.vram.6' }, + { id: '8', labelKey: 'memoryPicker.vram.8' }, + { id: '12', labelKey: 'memoryPicker.vram.12' }, + { id: '16', labelKey: 'memoryPicker.vram.16' }, + { id: '24', labelKey: 'memoryPicker.vram.24' }, + ], + }, + format: { + initial: 'full', + options: [ + { id: 'full', labelKey: 'memoryPicker.format.full' }, + { id: 'fp8', label: 'fp8 / fp8 scaled' }, + { id: 'mxfp8', label: 'MXFP8' }, + { id: 'int8', label: 'int8' }, + { id: 'gguf', label: 'GGUF' }, + { id: 'sdnq', label: 'SDNQ' }, + { id: 'nf4', label: 'NF4' }, + { id: 'nvfp4', label: 'nvfp4' }, + ], + }, +}; + +/** GPUs whose models live in system memory: no VRAM to budget, no partial loading. */ +const SYSTEM_MEMORY = ['apple', 'none']; +/** GPUs with VRAM of their own that Invoke manages: CUDA (NVIDIA, AMD ROCm, RTX Spark) and Intel XPU. */ +const GPUS = ['nvidia', 'amd', 'intel', 'arm64']; + +/** + * Every option the page renders. The page decides their order. + * @type {MemoryRule[]} + */ +export const MEMORY_RULES = [ + { id: 'system-memory', when: [{ gpu: SYSTEM_MEMORY }] }, + { id: 'nf4-support', when: [{ gpu: ['apple', 'intel', 'none'], format: ['nf4'] }] }, + { id: 'sysmem-fallback', when: [{ os: ['windows'], gpu: ['nvidia'] }] }, + { id: 'rocm-windows', when: [{ os: ['windows'], gpu: ['amd'] }] }, + { id: 'low-vram-mode', when: [{ gpu: GPUS }] }, + { id: 'fp8-storage', when: [{ gpu: GPUS, format: ['full'] }] }, + { id: 'fp8-storage-installed', when: [{ gpu: GPUS, format: ['fp8'] }] }, + { + id: 'fp8-compute', + when: [ + { gpu: ['nvidia'], series: ['ada'], format: ['fp8'] }, + { gpu: ['amd', 'arm64'], format: ['fp8'] }, + ], + }, + { id: 'mxfp8-off', when: [{ gpu: GPUS, format: ['mxfp8'] }] }, + { id: 'int8-compute', when: [{ gpu: ['nvidia'], series: ['ampere', 'ada'], format: ['int8'] }] }, + { + id: 'int8-unpacked', + when: [ + { gpu: ['amd', 'intel', 'arm64', 'apple', 'none'], format: ['int8'] }, + { gpu: ['nvidia'], series: ['pre-ampere'], format: ['int8'] }, + ], + }, + { + id: 'already-quantized', + when: [{ format: ['gguf', 'sdnq', 'nvfp4'] }, { gpu: ['nvidia', 'amd', 'arm64'], format: ['nf4'] }], + }, + { + id: 'quantized-build', + when: [ + { gpu: SYSTEM_MEMORY, format: ['full'] }, + { gpu: GPUS, vram: ['6', '8', '12'], format: ['full'] }, + ], + }, + { id: 'tiled-decode', when: [{ gpu: GPUS, vram: ['6', '8', '12', '16'] }] }, + { id: 'fits-in-vram', when: [{ gpu: GPUS, vram: ['24'] }] }, + { id: 'still-oom', when: [{ gpu: GPUS }] }, + { id: 'page-file', when: [{ os: ['windows'] }] }, +]; + +/** + * Which rows matter for a GPU: the NVIDIA generation only for NVIDIA, VRAM only where the GPU has its own. + * @type {{ row: MemoryRow, gpus: string[] }[]} + */ +export const ROW_VISIBILITY = [ + { row: 'series', gpus: ['nvidia'] }, + { row: 'vram', gpus: GPUS }, +]; + +/** + * The ids of the options that apply. Runs in the page's inline script as `toString()`, so + * it must only use its arguments. + * @param {MemoryRule[]} rules + * @param {MemoryChoice} choice + * @returns {string[]} + */ +export function recommend(rules, choice) { + return rules + .filter((rule) => + rule.when.some((condition) => + Object.entries(condition).every(([key, values]) => + values.includes(choice[/** @type {keyof MemoryChoice} */ (key)]) + ) + ) + ) + .map((rule) => rule.id); +} + +/** + * Turns stored (untrusted) row values into valid ones; anything unknown takes the row's initial choice. Runs in the + * page's inline script as `toString()`, so it must only use its arguments. + * @param {typeof MEMORY_ROWS} rows + * @param {unknown} candidate + * @returns {Record<MemoryRow, string>} + */ +export function resolveMemoryRows(rows, candidate) { + const value = candidate && typeof candidate === 'object' ? /** @type {Record<string, unknown>} */ (candidate) : {}; + /** @type {Record<string, string>} */ + const resolved = {}; + for (const [row, { options, initial }] of Object.entries(rows)) { + resolved[row] = options.some((option) => option.id === value[row]) ? String(value[row]) : initial; + } + return /** @type {Record<MemoryRow, string>} */ (resolved); +} + +/** + * Compares the options a page renders with the rules, so a typo or a forgotten option fails the build instead of + * leaving a recommendation that never shows. + * @param {string} html the picker's rendered options + * @returns {{ missing: string[], unknown: string[] }} + */ +export function checkOptions(html) { + const ids = [...html.matchAll(/\bdata-memory-option="([^"]*)"/g)].map(([, id]) => id); + const known = MEMORY_RULES.map((rule) => rule.id); + return { + missing: known.filter((id) => !ids.includes(id)), + unknown: ids.filter((id, index) => !known.includes(id) || ids.indexOf(id) !== index), + }; +} + +/** + * A row's chips with their labels in the page's language. + * @param {ChoiceOption[]} options + * @param {(key: string) => string} t + */ +export const localizeChoices = (options, t) => + options.map(({ id, label, labelKey }) => ({ id, label: labelKey ? t(labelKey) : String(label) })); + +/** + * Applies the stored rows and the shared setup, and re-applies them on every change. Runs as an inline script right + * after the options, before they paint, so the page never shows options that do not apply. Serialized with + * `toString()`: it must only use its arguments and globals. + * + * @param {HTMLElement} root the picker: the setup picker's radios and the rows' radios + * @param {HTMLElement} list the options' container + * @param {{ rows: typeof MEMORY_ROWS, rules: MemoryRule[], visibility: typeof ROW_VISIBILITY, storageKey: string, + * countForms: Record<string, string>, lang: string }} config + * @param {typeof recommend} recommendFn + * @param {typeof resolveMemoryRows} resolveFn + */ +export function mountMemoryPicker(root, list, config, recommendFn, resolveFn) { + let stored = null; + try { + stored = JSON.parse(localStorage.getItem(config.storageKey) || 'null'); + } catch { + // Storage blocked or holding junk: start from the initial choices. + } + let rows = resolveFn(config.rows, stored); + /** @type {NodeListOf<HTMLElement>} */ + const options = list.querySelectorAll('[data-memory-option]'); + const summary = root.querySelector('[data-memory-summary]'); + + const apply = () => { + const html = document.documentElement; + const gpu = html.dataset.setupGpu || ''; + const choice = { os: html.dataset.setupOs || '', gpu, ...rows }; + const ids = recommendFn(config.rules, choice); + for (const option of options) option.hidden = !ids.includes(option.dataset.memoryOption || ''); + for (const { row, gpus } of config.visibility) { + const fieldset = root.querySelector(`[data-memory-row="${row}"]`); + if (fieldset instanceof HTMLElement) fieldset.hidden = !gpus.includes(gpu); + } + for (const input of root.querySelectorAll('input[data-memory-input]')) { + const radio = /** @type {HTMLInputElement} */ (input); + radio.checked = rows[/** @type {MemoryRow} */ (radio.name.replace('memory-', ''))] === radio.value; + } + if (summary) { + const count = ids.length; + const form = config.countForms[new Intl.PluralRules(config.lang).select(count)] ?? config.countForms.other ?? ''; + summary.textContent = form.replace(/\{\{\s*count\s*\}\}/g, String(count)); + } + }; + + apply(); + root.dataset.memoryReady = ''; + // The setup picker's own listener sits on an element inside the root, so it has updated the setup by now. + root.addEventListener('change', (event) => { + const input = /** @type {HTMLInputElement} */ (event.target); + if (input.dataset.memoryInput !== undefined) { + rows = resolveFn(config.rows, { ...rows, [input.name.replace('memory-', '')]: input.value }); + try { + localStorage.setItem(config.storageKey, JSON.stringify(rows)); + } catch { + // Storage blocked: the choice still applies to this page. + } + } + apply(); + }); +} diff --git a/docs/src/lib/components/memory/memory-picker.test.mjs b/docs/src/lib/components/memory/memory-picker.test.mjs new file mode 100644 index 00000000000..bdf7bbf88b4 --- /dev/null +++ b/docs/src/lib/components/memory/memory-picker.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { checkOptions, MEMORY_ROWS, MEMORY_RULES, recommend as recommendWith, resolveMemoryRows } from './memory-picker.mjs'; + +const recommend = (choice) => recommendWith(MEMORY_RULES, choice); + +const choice = (overrides) => ({ os: 'linux', gpu: 'nvidia', series: 'ada', vram: '16', format: 'full', ...overrides }); + +describe('recommend', () => { + it('sends a Windows NVIDIA card short on VRAM to sysmem fallback, FP8 Storage and a smaller build', () => { + assert.deepEqual(recommend(choice({ os: 'windows', vram: '8' })), [ + 'sysmem-fallback', + 'low-vram-mode', + 'fp8-storage', + 'quantized-build', + 'tiled-decode', + 'still-oom', + 'page-file', + ]); + }); + + it('offers FP8 Compute for fp8 checkpoints only from RTX 40 on, or on AMD where the probe decides', () => { + assert.ok(recommend(choice({ format: 'fp8', series: 'ada' })).includes('fp8-compute')); + assert.ok(!recommend(choice({ format: 'fp8', series: 'ampere' })).includes('fp8-compute')); + assert.ok(recommend(choice({ format: 'fp8', gpu: 'amd' })).includes('fp8-compute')); + assert.ok(!recommend(choice({ format: 'fp8', gpu: 'intel' })).includes('fp8-compute')); + }); + + it('offers int8 Compute for int8 builds on NVIDIA from RTX 30 on', () => { + assert.ok(recommend(choice({ format: 'int8', series: 'ampere' })).includes('int8-compute')); + assert.ok(!recommend(choice({ format: 'int8', series: 'pre-ampere' })).includes('int8-compute')); + assert.ok(!recommend(choice({ format: 'int8', gpu: 'amd' })).includes('int8-compute')); + }); + + it('keeps VRAM settings away from Apple silicon and the CPU', () => { + for (const gpu of ['apple', 'none']) { + const ids = recommend(choice({ gpu, os: gpu === 'apple' ? 'macos' : 'linux' })); + assert.equal(ids[0], 'system-memory'); + for (const id of ['low-vram-mode', 'fp8-storage', 'tiled-decode', 'still-oom']) assert.ok(!ids.includes(id), id); + } + }); + + it('presents NF4 as working only where bitsandbytes is installed and runs: NVIDIA and AMD', () => { + for (const gpu of ['apple', 'intel', 'none']) { + const ids = recommend(choice({ gpu, format: 'nf4' })); + assert.ok(ids.includes('nf4-support') && !ids.includes('already-quantized'), gpu); + } + for (const gpu of ['nvidia', 'amd']) { + const ids = recommend(choice({ gpu, format: 'nf4' })); + assert.ok(!ids.includes('nf4-support') && ids.includes('already-quantized'), gpu); + } + }); + + it('gives every int8 setup a card: int8 Compute where it runs, unpacked layers elsewhere', () => { + const cases = [ + [{ series: 'ampere' }, 'int8-compute'], + [{ series: 'pre-ampere' }, 'int8-unpacked'], + [{ gpu: 'amd' }, 'int8-unpacked'], + [{ os: 'windows', gpu: 'arm64' }, 'int8-unpacked'], + [{ os: 'macos', gpu: 'apple' }, 'int8-unpacked'], + ]; + for (const [overrides, expected] of cases) { + const ids = recommend(choice({ format: 'int8', ...overrides })); + assert.ok(ids.includes(expected), JSON.stringify(overrides)); + assert.ok(!ids.includes(expected === 'int8-compute' ? 'int8-unpacked' : 'int8-compute')); + } + }); + + it('points to tiled decoding up to 16 GB, where SD 1.5 and SDXL decodes are still not tiled automatically', () => { + assert.ok(recommend(choice({ vram: '16' })).includes('tiled-decode')); + assert.ok(!recommend(choice({ vram: '24' })).includes('tiled-decode')); + }); + + it('tells MXFP8 owners to switch FP8 Storage off instead of offering it', () => { + const ids = recommend(choice({ format: 'mxfp8' })); + assert.ok(ids.includes('mxfp8-off')); + assert.ok(!ids.includes('fp8-storage')); + }); + + it('treats Windows on ARM64 as the NVIDIA RTX Spark GPU it runs on', () => { + const ids = recommend(choice({ os: 'windows', gpu: 'arm64', format: 'fp8' })); + assert.ok(ids.includes('low-vram-mode') && ids.includes('fp8-storage-installed') && ids.includes('fp8-compute')); + assert.ok(!ids.includes('sysmem-fallback')); + }); +}); + +describe('resolveMemoryRows', () => { + it('keeps valid stored choices and replaces junk with each row default', () => { + assert.deepEqual(resolveMemoryRows(MEMORY_ROWS, { series: 'ampere', vram: '24', format: 'gguf' }), { + series: 'ampere', + vram: '24', + format: 'gguf', + }); + assert.deepEqual(resolveMemoryRows(MEMORY_ROWS, { vram: '7', format: 1 }), { series: 'ada', vram: '8', format: 'full' }); + assert.deepEqual(resolveMemoryRows(MEMORY_ROWS, 'junk'), { series: 'ada', vram: '8', format: 'full' }); + }); +}); + +describe('checkOptions', () => { + const rendered = (ids) => ids.map((id) => `<article data-memory-option="${id}"></article>`).join(''); + + it('accepts a page that renders every option once', () => { + assert.deepEqual(checkOptions(rendered(MEMORY_RULES.map((rule) => rule.id))), { missing: [], unknown: [] }); + }); + + it('names forgotten, misspelled and repeated options', () => { + const ids = MEMORY_RULES.map((rule) => rule.id).filter((id) => id !== 'page-file'); + assert.deepEqual(checkOptions(rendered([...ids, 'pagefile', 'still-oom'])), { + missing: ['page-file'], + unknown: ['pagefile', 'still-oom'], + }); + }); +}); diff --git a/docs/src/lib/components/memory/page-lang.mjs b/docs/src/lib/components/memory/page-lang.mjs new file mode 100644 index 00000000000..e666ff724f8 --- /dev/null +++ b/docs/src/lib/components/memory/page-lang.mjs @@ -0,0 +1,14 @@ +// @ts-check + +/** + * The page's language, for number and date formatting. Routes outside Starlight's pages, such as `llms-full.txt`, also + * render page content but have no `starlightRoute`, whose getter throws there; they are English. + * @param {App.Locals} locals + */ +export function pageLang(locals) { + try { + return locals.starlightRoute.lang; + } catch { + return 'en'; + } +} diff --git a/docs/src/lib/components/pipeline/Pipeline.astro b/docs/src/lib/components/pipeline/Pipeline.astro new file mode 100644 index 00000000000..b30f8ffdebf --- /dev/null +++ b/docs/src/lib/components/pipeline/Pipeline.astro @@ -0,0 +1,99 @@ +--- +/* + * A static "how it works" diagram: steps in order, left to right with arrows between them, stacked top to bottom + * where the figure is narrower than 36rem. It is an ordered list, so screen readers read the steps in order, and it + * needs no script. Labels are string attributes and children, so a translation can change them. + * + * <Pipeline label="How an upscale runs"> + * <PipelineStep title="Source image" /> + * <PipelineBranch> + * <PipelineLane label="Model only" /> + * <PipelineLane label="Refine"> + * <PipelineStep title="Detail pass" description="Main model and prompt" /> + * </PipelineLane> + * </PipelineBranch> + * <PipelineStep title="Result" /> + * </Pipeline> + * + * `PipelineBranch` is one step whose lanes are alternatives; a lane without steps goes straight on. + */ +type Props = { + /** The diagram's caption, which names the figure. */ + label: string; + class?: string; +}; + +const { label, class: className } = Astro.props; +--- + +<figure class:list={['pipeline not-content', className]}> + <figcaption class="pipeline__caption">{label}</figcaption> + <ol class="pipeline__steps" role="list"> + <slot /> + </ol> +</figure> + +<style> + .pipeline { + margin: 0; + padding: 0.75rem; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background-color: var(--docs-surface); + container-type: inline-size; + } + + .pipeline__caption { + margin-bottom: 0.625rem; + color: var(--docs-text-muted); + font-size: var(--sl-text-sm); + font-weight: 600; + } + + /* + * Step lists: the diagram's own and each lane's (`PipelineLane`). `role="list"` on them: Safari drops list semantics + * from a list without list markers. + */ + .pipeline :global(:is(.pipeline__steps, .pipeline__lane-steps)) { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; + } + + /* Each item after the first draws the arrow from the one before it. */ + .pipeline :global(.pipeline__item) { + display: flex; + flex: 1 1 0; + align-items: center; + min-width: 0; + } + + .pipeline :global(.pipeline__item + .pipeline__item)::before { + content: ''; + flex: none; + width: 1.75rem; + height: 0.875rem; + background-color: var(--docs-text-subtle); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 14'%3E%3Cpath d='M3 7h20m-5-5 5 5-5 5' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") + center / contain no-repeat; + } + + /* Narrow: top to bottom, arrows pointing down. */ + @container (width < 36rem) { + .pipeline :global(:is(.pipeline__steps, .pipeline__lane-steps)), + .pipeline :global(.pipeline__item) { + flex-direction: column; + align-items: stretch; + } + + .pipeline :global(.pipeline__item + .pipeline__item)::before { + align-self: center; + height: 1.5rem; + rotate: 90deg; + } + } +</style> diff --git a/docs/src/lib/components/pipeline/PipelineBranch.astro b/docs/src/lib/components/pipeline/PipelineBranch.astro new file mode 100644 index 00000000000..bf0d47054f4 --- /dev/null +++ b/docs/src/lib/components/pipeline/PipelineBranch.astro @@ -0,0 +1,28 @@ +--- +/* + * One step of a `Pipeline` that splits into alternative `PipelineLane`s, one above the other, which join again at the + * next step. Read as a nested list: each lane's label, then its steps. See `Pipeline.astro`. + */ +--- + +{/* Its lanes hold steps side by side, so it takes more room than a single step. */} +<li class="pipeline__item" style="flex-grow: 3"> + <ul class="branch" role="list"> + <slot /> + </ul> +</li> + +<style> + .branch { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.5rem; + min-width: 0; + margin: 0; + padding: 0.5rem; + border: 1px dashed var(--docs-border-strong); + border-radius: var(--docs-radius-container); + list-style: none; + } +</style> diff --git a/docs/src/lib/components/pipeline/PipelineLane.astro b/docs/src/lib/components/pipeline/PipelineLane.astro new file mode 100644 index 00000000000..c729c8ab2cb --- /dev/null +++ b/docs/src/lib/components/pipeline/PipelineLane.astro @@ -0,0 +1,60 @@ +--- +/* + * One alternative of a `PipelineBranch`: a label above the lane's `PipelineStep`s. A lane without steps goes straight + * on to the step after the branch, drawn as a dashed line. See `Pipeline.astro`. + */ +type Props = { + /** Names the alternative, such as a mode. */ + label: string; +}; + +const { label } = Astro.props; +const hasSteps = Astro.slots.has('default'); +--- + +<li class="lane"> + <span class="lane__label">{label}</span> + { + hasSteps ? ( + <ol class="pipeline__lane-steps" role="list"> + <slot /> + </ol> + ) : ( + <span class="lane__through" aria-hidden="true" /> + ) + } +</li> + +<style> + /* The label sits above the lane's steps, so the steps get the lane's full width. */ + .lane { + display: flex; + flex-direction: column; + gap: 0.375rem; + min-width: 0; + } + + .lane__label { + align-self: flex-start; + padding: 0.125rem 0.5rem; + border-radius: var(--docs-radius); + background-color: var(--docs-tab-shown); + color: var(--docs-text-strong); + font-size: var(--sl-text-xs); + font-weight: 600; + white-space: nowrap; + } + + .lane__through { + min-height: 1.5rem; + background: linear-gradient(var(--docs-border-strong), var(--docs-border-strong)) center / 100% 1px no-repeat; + mask: repeating-linear-gradient(90deg, #000 0 6px, transparent 6px 10px); + } + + @container (width < 36rem) { + .lane__through { + background-size: 1px 100%; + mask: repeating-linear-gradient(180deg, #000 0 6px, transparent 6px 10px); + } + } +</style> diff --git a/docs/src/lib/components/pipeline/PipelineStep.astro b/docs/src/lib/components/pipeline/PipelineStep.astro new file mode 100644 index 00000000000..acfe803b6ba --- /dev/null +++ b/docs/src/lib/components/pipeline/PipelineStep.astro @@ -0,0 +1,47 @@ +--- +/* One step of a `Pipeline` or a `PipelineLane`: a title and an optional short description. See `Pipeline.astro`. */ +type Props = { + title: string; + description?: string; +}; + +const { title, description } = Astro.props; +--- + +<li class="pipeline__item"> + <div class="step"> + <strong class="step__title">{title}</strong> + {description && <span class="step__description">{description}</span>} + </div> +</li> + +<style> + .step { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + gap: 0.125rem; + min-width: 0; + min-height: 3.25rem; + padding: 0.5rem 0.625rem; + border: 1px solid var(--docs-border-strong); + border-radius: var(--docs-radius-lg); + background-color: var(--docs-control); + text-align: center; + text-wrap: balance; + } + + .step__title { + color: var(--docs-text-strong); + font-size: var(--sl-text-sm); + font-weight: 600; + line-height: 1.3; + } + + .step__description { + color: var(--docs-text-muted); + font-size: var(--sl-text-xs); + line-height: 1.35; + } +</style> diff --git a/docs/src/lib/components/search.mjs b/docs/src/lib/components/search.mjs new file mode 100644 index 00000000000..4a25f9f3005 --- /dev/null +++ b/docs/src/lib/components/search.mjs @@ -0,0 +1,184 @@ +// The search modal's data handling (`Search.astro`): matching the settings index, shaping Pagefind results into rows, +// and the recent searches list. Plain JavaScript so `node --test` can cover it. + +/** Settings results show in the All docs scope, and in this topic's scope, which documents the settings. */ +export const SETTINGS_TOPIC_ID = 'configure'; +export const RECENT_STORAGE_KEY = 'invoke-docs:recent-searches'; +export const RECENT_LIMIT = 5; + +const ENV_PREFIX = 'invokeai_'; +// A description match on a shorter query mostly finds noise. +const MIN_DESCRIPTION_QUERY_LENGTH = 3; + +/** + * @typedef {{ id: string, title: string }} SearchTopic + * @typedef {'topic' | 'all'} SearchScope + */ + +/** + * Everything that follows from the search scope, so the chips, the Pagefind filter and the settings results never + * disagree. Without a scope, the current topic's is the default; without a topic, only All docs exists. + * @param {SearchTopic | undefined} topic the page's topic, from its `docs-topic` meta tag + * @param {SearchScope} [requested] + */ +export const resolveScope = (topic, requested = topic ? 'topic' : 'all') => { + const scoped = requested === 'topic' && topic ? topic : undefined; + /** @type {SearchScope} */ + const scope = scoped ? 'topic' : 'all'; + return { + scope, + /** The topic results are limited to, if any. */ + scoped, + filters: scoped ? { topic: scoped.id } : undefined, + showSettings: !scoped || scoped.id === SETTINGS_TOPIC_ID, + chips: [ + { scope: 'topic', hidden: !topic, pressed: scope === 'topic' }, + { scope: 'all', hidden: false, pressed: scope === 'all' }, + ], + }; +}; + +/** + * Lowercases text and treats runs of spaces, underscores and hyphens as one `_`, so `max cache`, `max-cache` and + * `max_cache` read the same. + * @param {string} text + */ +export const normalizeTerm = (text) => + text + .toLowerCase() + .trim() + .replace(/[\s_-]+/g, '_'); + +/** + * @typedef {import('./settings-reference.mjs').SettingsSearchEntry} SettingsSearchEntry + */ + +/** + * The settings that match a query, best first: an exact key, then keys starting with the query, keys containing it, + * keys containing every word of it, and last keys and descriptions with words starting with every word of it. + * `INVOKEAI_PORT` finds `port`. + * @param {SettingsSearchEntry[]} settings + * @param {string} query + * @returns {SettingsSearchEntry[]} + */ +export const matchSettings = (settings, query) => { + let term = normalizeTerm(query).replace(/^_+|_+$/g, ''); + if (term.startsWith(ENV_PREFIX) && term.length > ENV_PREFIX.length) term = term.slice(ENV_PREFIX.length); + if (term.length < 2) return []; + const words = term.split('_').filter(Boolean); + + /** @param {SettingsSearchEntry} setting */ + const rank = (setting) => { + const key = normalizeTerm(setting.key); + if (key === term) return 0; + if (key.startsWith(term)) return 1; + if (key.includes(term)) return 2; + if (words.length > 1 && words.every((word) => key.includes(word))) return 3; + if (term.length < MIN_DESCRIPTION_QUERY_LENGTH) return undefined; + // Words match at word starts, so `ram` does not find `vram`. + const textWords = normalizeTerm(`${setting.key} ${setting.description}`).split(/[^a-z0-9]+/); + return words.every((word) => textWords.some((textWord) => textWord.startsWith(word))) ? 4 : undefined; + }; + + return settings + .map((setting, index) => ({ setting, index, rank: rank(setting) })) + .filter((match) => match.rank !== undefined) + .sort((a, b) => a.rank - b.rank || a.setting.key.length - b.setting.key.length || a.index - b.index) + .map((match) => match.setting); +}; + +/** + * @typedef {{ title: string, url: string, excerpt: string, anchor?: unknown, locations?: number[] }} PagefindSubResult + * @typedef {{ + * url: string, + * excerpt: string, + * meta?: { title?: string }, + * filters?: Record<string, string[]>, + * sub_results?: PagefindSubResult[], + * }} PagefindResultData + * @typedef {{ url: string, page: string, section?: string, excerpt: string, topic?: string }} PageRow `topic` is the + * page's topic id, from its Pagefind `topic` filter + */ + +/** + * One row per matching section of a Pagefind result, for at most `maxSections` sections: those with the most matches, + * in page order. A match outside every heading's section links to the page itself. + * @param {PagefindResultData} data + * @param {number} maxSections + * @returns {PageRow[]} + */ +export const toPageRows = (data, maxSections) => { + const page = data.meta?.title || data.url; + const topic = data.filters?.topic?.[0]; + const sections = data.sub_results ?? []; + if (sections.length === 0) return [{ url: data.url, page, excerpt: data.excerpt, topic }]; + + const best = new Set( + [...sections].sort((a, b) => (b.locations?.length ?? 0) - (a.locations?.length ?? 0)).slice(0, maxSections), + ); + return sections + .filter((section) => best.has(section)) + .map((section) => ({ + url: section.url, + page, + section: section.anchor && section.title !== page ? section.title : undefined, + excerpt: section.excerpt, + topic, + })); +}; + +/** + * Groups rows under their topic's title, keeping the rank order: groups are ordered by their best row. + * @param {PageRow[]} rows + * @param {Map<string, string>} titles topic id -> title + * @param {string} untitled the group label for pages outside every topic + * @returns {{ label: string, rows: PageRow[] }[]} + */ +export const groupByTopic = (rows, titles, untitled) => { + /** @type {Map<string, PageRow[]>} */ + const groups = new Map(); + for (const row of rows) { + const label = (row.topic && titles.get(row.topic)) || untitled; + const group = groups.get(label); + if (group) group.push(row); + else groups.set(label, [row]); + } + return [...groups].map(([label, groupRows]) => ({ label, rows: groupRows })); +}; + +/** + * @typedef {{ title: string, section?: string, url: string }} RecentEntry + */ + +/** + * The recent searches list from its stored JSON, dropping anything malformed. + * @param {string | null} raw + * @returns {RecentEntry[]} + */ +export const parseRecent = (raw) => { + let value; + try { + value = JSON.parse(raw ?? '[]'); + } catch { + return []; + } + if (!Array.isArray(value)) return []; + return value + .filter( + (entry) => + typeof entry?.title === 'string' && + typeof entry.url === 'string' && + (entry.section === undefined || typeof entry.section === 'string'), + ) + .map(({ title, section, url }) => (section === undefined ? { title, url } : { title, section, url })) + .slice(0, RECENT_LIMIT); +}; + +/** + * Puts an opened result first, without duplicates. + * @param {RecentEntry[]} list + * @param {RecentEntry} entry + * @returns {RecentEntry[]} + */ +export const addRecent = (list, entry) => + [entry, ...list.filter((existing) => existing.url !== entry.url)].slice(0, RECENT_LIMIT); diff --git a/docs/src/lib/components/search.test.mjs b/docs/src/lib/components/search.test.mjs new file mode 100644 index 00000000000..6caadfe5229 --- /dev/null +++ b/docs/src/lib/components/search.test.mjs @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + RECENT_LIMIT, + addRecent, + groupByTopic, + matchSettings, + parseRecent, + resolveScope, + toPageRows, +} from './search.mjs'; + +describe('resolveScope', () => { + const models = { id: 'models', title: 'Models' }; + const pressed = (state) => state.chips.filter((chip) => chip.pressed).map((chip) => chip.scope); + + it('starts in the page topic, with its chip pressed and its filter applied', () => { + const state = resolveScope(models); + assert.equal(state.scope, 'topic'); + assert.deepEqual(pressed(state), ['topic']); + assert.deepEqual(state.filters, { topic: 'models' }); + assert.equal(state.showSettings, false); + }); + + it('widens to All docs without a filter, showing settings', () => { + const state = resolveScope(models, 'all'); + assert.deepEqual(pressed(state), ['all']); + assert.equal(state.filters, undefined); + assert.equal(state.showSettings, true); + }); + + it('offers only All docs on pages outside every topic, whatever was asked for', () => { + for (const state of [resolveScope(undefined), resolveScope(undefined, 'topic')]) { + assert.equal(state.scope, 'all'); + assert.deepEqual(pressed(state), ['all']); + assert.deepEqual( + state.chips.filter((chip) => !chip.hidden).map((chip) => chip.scope), + ['all'], + ); + assert.equal(state.filters, undefined); + } + }); + + it('shows settings in the scope of the topic that documents them', () => { + const state = resolveScope({ id: 'configure', title: 'Configure & Deploy' }); + assert.deepEqual(state.filters, { topic: 'configure' }); + assert.equal(state.showSettings, true); + }); +}); + +const setting = (key, description = '') => ({ key, type: 'bool', default: 'false', description }); + +describe('matchSettings', () => { + const settings = [ + setting('fp8_compute_full_precision_hints', 'Honor per-layer full-precision flags.'), + setting('ram', 'Maximum memory amount used by the model cache.'), + setting('fp8_compute', 'Run scaled-fp8 checkpoints on fp8 tensor cores.'), + setting('max_cache_ram_gb', 'The maximum amount of CPU RAM to use for model caching.'), + setting('lazy_offload', 'Keep models in VRAM until their space is needed.'), + setting('port', 'Port to bind to.'), + ]; + const keys = (query) => matchSettings(settings, query).map((match) => match.key); + + it('ranks an exact key, then key prefixes, then keys containing the query', () => { + assert.deepEqual(keys('ram'), ['ram', 'max_cache_ram_gb']); + assert.deepEqual(keys('fp8_compute'), ['fp8_compute', 'fp8_compute_full_precision_hints']); + assert.deepEqual(keys('cache_ram'), ['max_cache_ram_gb', 'ram']); + }); + + it('treats spaces, hyphens and underscores alike, in any case', () => { + assert.deepEqual(keys('FP8 compute'), keys('fp8_compute')); + assert.deepEqual(keys('lazy-offload'), ['lazy_offload']); + }); + + it('finds a setting from its environment variable', () => { + assert.deepEqual(keys('INVOKEAI_PORT'), ['port']); + }); + + it('matches every word of the query in the key before falling back to descriptions', () => { + assert.deepEqual(keys('ram max'), ['max_cache_ram_gb', 'ram']); + assert.deepEqual(keys('vram'), ['lazy_offload']); + assert.deepEqual(keys('scaled fp8'), ['fp8_compute']); + assert.deepEqual(keys('tensor cores'), ['fp8_compute']); + }); + + it('ignores queries too short to be useful and words found nowhere', () => { + assert.deepEqual(keys(' '), []); + assert.deepEqual(keys('p'), []); + // Two characters match keys, not descriptions. + assert.deepEqual(keys('to'), []); + assert.deepEqual(keys('ram banana'), []); + }); +}); + +describe('toPageRows', () => { + const page = { + url: '/fp8/', + excerpt: 'page excerpt', + meta: { title: 'FP8 Storage' }, + filters: { topic: ['configure'] }, + sub_results: [ + { title: 'FP8 Storage', url: '/fp8/', excerpt: 'intro', locations: [1] }, + { title: 'Hardware', url: '/fp8/#hardware', excerpt: 'hw', anchor: { id: 'hardware' }, locations: [5, 6, 7] }, + { title: 'Enabling', url: '/fp8/#enabling', excerpt: 'on', anchor: { id: 'enabling' }, locations: [9, 10] }, + ], + }; + + it('keeps the sections with the most matches, in page order, linking to their anchors', () => { + assert.deepEqual(toPageRows(page, 2), [ + { url: '/fp8/#hardware', page: 'FP8 Storage', section: 'Hardware', excerpt: 'hw', topic: 'configure' }, + { url: '/fp8/#enabling', page: 'FP8 Storage', section: 'Enabling', excerpt: 'on', topic: 'configure' }, + ]); + }); + + it('shows a match before the first heading as the page itself', () => { + const [intro] = toPageRows({ ...page, sub_results: page.sub_results.slice(0, 1) }, 2); + assert.deepEqual(intro, { url: '/fp8/', page: 'FP8 Storage', section: undefined, excerpt: 'intro', topic: 'configure' }); + }); + + it('falls back to the page when Pagefind has no sections, and leaves pages outside every topic untagged', () => { + assert.deepEqual(toPageRows({ url: '/', excerpt: 'home', meta: { title: 'Invoke' } }, 2), [ + { url: '/', page: 'Invoke', excerpt: 'home', topic: undefined }, + ]); + }); +}); + +describe('groupByTopic', () => { + it('orders groups by their best row, keeps row order within each group and titles them by topic', () => { + const titles = new Map([ + ['configure', 'Configure & Deploy'], + ['models', 'Models'], + ]); + const rows = [ + { url: '/a/', topic: 'configure' }, + { url: '/b/', topic: 'models' }, + { url: '/c/' }, + { url: '/d/', topic: 'configure' }, + ]; + assert.deepEqual( + groupByTopic(rows, titles, 'Other pages').map(({ label, rows: groupRows }) => [ + label, + groupRows.map((row) => row.url), + ]), + [ + ['Configure & Deploy', ['/a/', '/d/']], + ['Models', ['/b/']], + ['Other pages', ['/c/']], + ], + ); + }); +}); + +describe('recent searches', () => { + it('puts the latest opened result first, without duplicates, up to the limit', () => { + let list = []; + for (let index = 0; index <= RECENT_LIMIT; index++) list = addRecent(list, { title: `Page ${index}`, url: `/${index}/` }); + list = addRecent(list, { title: 'Page 3', section: 'Setup', url: '/3/' }); + assert.deepEqual( + list.map((entry) => entry.url), + ['/3/', '/5/', '/4/', '/2/', '/1/'], + ); + assert.equal(list[0].section, 'Setup'); + }); + + it('reads back what it stores and drops malformed storage', () => { + const stored = [{ title: 'Low VRAM mode', url: '/low-vram/' }, { title: 'ram', section: 'invokeai.yaml', url: '/y/' }]; + assert.deepEqual(parseRecent(JSON.stringify(stored)), stored); + for (const junk of [null, '', '{', '{"title":"x"}', '[1, {"title": 2, "url": "/"}, {"url": "/"}]']) { + assert.deepEqual(parseRecent(junk), []); + } + assert.deepEqual(parseRecent(JSON.stringify([{ title: 'A', url: '/a/', extra: 1 }, 'x'])), [{ title: 'A', url: '/a/' }]); + }); +}); diff --git a/docs/src/lib/components/settings-reference.mjs b/docs/src/lib/components/settings-reference.mjs new file mode 100644 index 00000000000..6f1c302263f --- /dev/null +++ b/docs/src/lib/components/settings-reference.mjs @@ -0,0 +1,70 @@ +// Shared by the `invokeai.yaml` settings reference (`SettingsDocs.astro`) and the compact settings index that the +// search modal loads (`src/pages/settings-search.json.ts`), so both show a setting the same way and agree on its +// anchor. + +const DESCRIPTION_MAX_LENGTH = 160; + +/** + * The id of a setting's entry in the settings reference. + * @param {string} name + */ +export const settingAnchorId = (name) => `setting-${name}`; + +/** + * A setting's default value as shown to readers. + * @param {unknown} value + */ +export const formatSettingValue = (value) => { + if (value === null) return 'null'; + if (Array.isArray(value) || typeof value === 'object') return JSON.stringify(value); + return String(value); +}; + +/** + * Cleans up a Python type representation: `<class 'pathlib.Path'>` -> `Path`, + * `typing.Optional[pathlib.Path]` -> `Optional[Path]`. + * @param {string} typeStr + */ +export const formatSettingType = (typeStr) => { + const classMatch = typeStr.match(/^<class '(.+)'>$/); + if (classMatch) return classMatch[1].split('.').pop(); + return typeStr.replace(/typing\./g, '').replace(/[a-z_][a-z0-9_.]*\.([A-Z][A-Za-z]*)/g, '$1'); +}; + +/** + * The first sentence of a description, cut at a word boundary when it is still long. + * @param {string} text + */ +export const summarizeDescription = (text) => { + const trimmed = text.trim(); + // A sentence ends at `.`, `!` or `?` followed by the end or by a space and what starts the next sentence, so + // "e.g. `/invoke`" and version numbers stay intact. + const sentence = trimmed.match(/^.*?(?<!\b(?:e\.g|i\.e))[.!?](?=$|\s+[A-Z('"])/s)?.[0] ?? trimmed; + if (sentence.length <= DESCRIPTION_MAX_LENGTH) return sentence; + const cut = sentence.slice(0, DESCRIPTION_MAX_LENGTH - 1); + return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 1)).replace(/(?:[\s,;:]|\b(?:e\.g|i\.e)\.)+$/, '')}…`; +}; + +/** + * @typedef {{ name: string, type: string, default: unknown, description: string, env_var?: string | null }} Setting + * @typedef {{ key: string, type: string, default: string, description: string, env?: string }} SettingsSearchEntry + */ + +/** + * The compact settings index the search modal fetches: the page that documents the settings, and what each search + * result row shows. + * @param {Setting[]} settings + * @param {string} page the settings reference page's path, without the deployment base path: search links to the + * page in the reader's locale + * @returns {{ page: string, settings: SettingsSearchEntry[] }} + */ +export const buildSettingsSearchIndex = (settings, page) => ({ + page, + settings: settings.map((setting) => ({ + key: setting.name, + type: formatSettingType(setting.type), + default: formatSettingValue(setting.default), + description: summarizeDescription(setting.description ?? ''), + ...(setting.env_var ? { env: setting.env_var } : {}), + })), +}); diff --git a/docs/src/lib/components/settings-reference.test.mjs b/docs/src/lib/components/settings-reference.test.mjs new file mode 100644 index 00000000000..627d5addb13 --- /dev/null +++ b/docs/src/lib/components/settings-reference.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildSettingsSearchIndex, formatSettingType, summarizeDescription } from './settings-reference.mjs'; + +describe('formatSettingType', () => { + it('drops Python class wrappers, typing prefixes and module paths', () => { + assert.equal(formatSettingType("<class 'pathlib.Path'>"), 'Path'); + assert.equal(formatSettingType("<class 'bool'>"), 'bool'); + assert.equal(formatSettingType('typing.Optional[pathlib.Path]'), 'Optional[Path]'); + assert.equal( + formatSettingType('typing.Optional[list[invokeai.app.services.config.config_default.URLRegexTokenPair]]'), + 'Optional[list[URLRegexTokenPair]]', + ); + }); +}); + +describe('summarizeDescription', () => { + it('keeps the first sentence without splitting abbreviations or code', () => { + assert.equal(summarizeDescription('IP address to bind to. Use `0.0.0.0` to serve to your network.'), 'IP address to bind to.'); + assert.equal( + summarizeDescription('Public base path, e.g. `/invoke`. Required when the proxy keeps it.'), + 'Public base path, e.g. `/invoke`.', + ); + assert.equal(summarizeDescription('No full stop'), 'No full stop'); + }); + + it('cuts a long sentence at a word boundary', () => { + const summary = summarizeDescription(`${'word '.repeat(60)}end.`); + assert.ok(summary.length <= 160, summary); + assert.match(summary, /word…$/); + }); +}); + +describe('buildSettingsSearchIndex', () => { + it('keeps what a search result shows and the page the results link to', () => { + const index = buildSettingsSearchIndex( + [ + { + name: 'allow_methods', + type: 'list[str]', + default: ['*'], + description: 'Methods allowed for CORS. More detail.', + env_var: 'INVOKEAI_ALLOW_METHODS', + category: 'WEB', + literal_values: [], + validation: {}, + }, + { name: 'ssl_keyfile', type: 'typing.Optional[pathlib.Path]', default: null, description: 'SSL key file.' }, + ], + '/configuration/invokeai-yaml/', + ); + assert.deepEqual(index, { + page: '/configuration/invokeai-yaml/', + settings: [ + { + key: 'allow_methods', + type: 'list[str]', + default: '["*"]', + description: 'Methods allowed for CORS.', + env: 'INVOKEAI_ALLOW_METHODS', + }, + { key: 'ssl_keyfile', type: 'Optional[Path]', default: 'null', description: 'SSL key file.' }, + ], + }); + }); +}); diff --git a/docs/src/lib/components/setup-picker.mjs b/docs/src/lib/components/setup-picker.mjs new file mode 100644 index 00000000000..e77787d00d9 --- /dev/null +++ b/docs/src/lib/components/setup-picker.mjs @@ -0,0 +1,222 @@ +// @ts-check +// The "Your setup" picker: which operating system and GPU the install commands are shown for. +// Every variant is rendered at build time; the picker only toggles visibility, so readers +// without JavaScript and the search index still see every command. + +/** @typedef {{ id: string, label: string }} SetupGpu */ +/** @typedef {{ id: string, label: string, gpus: SetupGpu[] }} SetupOs */ +/** @typedef {{ os: string, gpu: string }} Setup */ +/** + * A brand name as its `label`, or the UI string key of its label. + * @typedef {{ id: string, label?: string, labelKey?: string }} SetupGpuOption + */ +/** @typedef {{ id: string, label: string, gpus: SetupGpuOption[] }} SetupOsOption */ + +export const SETUP_STORAGE_KEY = 'invoke-docs:setup'; + +/** + * The first GPU of each OS is its default. Pages use these through `localizeSetupOptions`. + * @type {SetupOsOption[]} + */ +export const SETUP_OPTIONS = [ + { + id: 'windows', + label: 'Windows', + gpus: [ + { id: 'nvidia', label: 'NVIDIA' }, + { id: 'amd', label: 'AMD' }, + { id: 'intel', label: 'Intel Arc' }, + { id: 'none', labelKey: 'setupPicker.option.otherOrNone' }, + { id: 'arm64', label: 'ARM64' }, + ], + }, + { id: 'macos', label: 'macOS', gpus: [{ id: 'apple', labelKey: 'setupPicker.option.appleSilicon' }] }, + { + id: 'linux', + label: 'Linux', + gpus: [ + { id: 'nvidia', label: 'NVIDIA' }, + { id: 'amd', label: 'AMD' }, + { id: 'intel', label: 'Intel Arc' }, + { id: 'none', labelKey: 'setupPicker.option.noneCpu' }, + ], + }, +]; + +/** + * The setup options with every label in the page's language. + * @param {SetupOsOption[]} options + * @param {(key: string) => string} t + * @returns {SetupOs[]} + */ +export const localizeSetupOptions = (options, t) => + options.map((os) => ({ + ...os, + gpus: os.gpus.map(({ id, label, labelKey }) => ({ id, label: labelKey ? t(labelKey) : String(label) })), + })); + +/** + * Turns a stored (untrusted) value into a valid setup. A missing or unknown OS falls back to the + * one detected from `platform`, then to the first option; a GPU the OS does not offer falls back to + * that OS's default. + * + * Runs in the page's inline script as `resolveSetup.toString()`, so it must only use its arguments + * and globals. + * + * @param {SetupOs[]} options + * @param {unknown} candidate + * @param {string | undefined} platform `navigator.userAgentData.platform` or `navigator.platform` + * @returns {Setup} + */ +export function resolveSetup(options, candidate, platform) { + const value = candidate && typeof candidate === 'object' ? /** @type {Partial<Setup>} */ (candidate) : {}; + const name = String(platform || '').toLowerCase(); + const detected = name.includes('mac') ? 'macos' : name.includes('linux') ? 'linux' : 'windows'; + const os = + options.find((option) => option.id === value.os) ?? + options.find((option) => option.id === detected) ?? + options[0]; + const gpu = os.gpus.find((option) => option.id === value.gpu) ?? os.gpus[0]; + return { os: os.id, gpu: gpu.id }; +} + +/** + * Validates the space-separated OS and GPU ids a variant applies to and builds its label. + * Unknown ids throw, so a typo fails the build instead of hiding a command. + * + * @param {SetupOs[]} options + * @param {{ os?: string, gpu?: string }} props + * @returns {{ os?: string[], gpu?: string[], label: string }} + */ +export function describeVariant(options, { os, gpu }) { + const osIds = os?.split(/\s+/).filter(Boolean); + const gpuIds = gpu?.split(/\s+/).filter(Boolean); + if (!osIds?.length && !gpuIds?.length) throw new Error('A setup variant needs an `os` or a `gpu`.'); + + const scope = osIds?.length + ? osIds.map((id) => { + const match = options.find((option) => option.id === id); + if (!match) throw new Error(`Unknown setup OS "${id}".`); + return match; + }) + : options; + const gpuLabels = (gpuIds ?? []).map((id) => { + const labels = scope.flatMap((option) => option.gpus.filter((g) => g.id === id).map((g) => g.label)); + if (!labels.length) throw new Error(`No setup OS in "${os ?? 'any'}" offers GPU "${id}".`); + return labels[0]; + }); + + const label = [osIds?.length ? scope.map((option) => option.label).join(', ') : '', gpuLabels.join(', ')] + .filter(Boolean) + .join(' · '); + return { os: osIds?.length ? osIds : undefined, gpu: gpuIds?.length ? gpuIds : undefined, label }; +} + +/** + * Lists the valid setups a set of alternatives misses or covers more than once. A `SetupSwitch` + * must show exactly one alternative for every setup the picker offers. + * + * @param {SetupOs[]} options + * @param {{ os?: string[], gpu?: string[] }[]} variants + * @returns {{ missing: string[], repeated: string[] }} + */ +export function findCoverageGaps(options, variants) { + /** @type {{ missing: string[], repeated: string[] }} */ + const gaps = { missing: [], repeated: [] }; + for (const os of options) { + for (const gpu of os.gpus) { + const matches = variants.filter( + (variant) => (!variant.os || variant.os.includes(os.id)) && (!variant.gpu || variant.gpu.includes(gpu.id)) + ).length; + const label = `${os.label} · ${gpu.label}`; + if (matches === 0) gaps.missing.push(label); + if (matches > 1) gaps.repeated.push(label); + } + } + return gaps; +} + +/** + * Reads the OS and GPU ids of the `SetupVariant` elements in rendered HTML. + * + * @param {string} html + * @returns {{ os?: string[], gpu?: string[] }[]} + */ +export function variantsInHtml(html) { + return [...html.matchAll(/<div\b[^>]*\bdata-setup-variant\b[^>]*>/g)].map(([tag]) => { + const ids = (/** @type {string} */ name) => tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1].split(' '); + return { os: ids('data-setup-for-os'), gpu: ids('data-setup-for-gpu') }; + }); +} + +/** + * Hides every element marked for another OS or GPU once a setup is chosen. Without JavaScript the + * root never gets `data-setup-os`, so nothing is hidden. + * + * @param {SetupOs[]} options + */ +export function setupVisibilityCss(options) { + const osIds = options.map((option) => option.id); + const gpuIds = [...new Set(options.flatMap((option) => option.gpus.map((gpu) => gpu.id)))]; + return [ + ...osIds.map( + (id) => `:root[data-setup-os="${id}"] [data-setup-for-os]:not([data-setup-for-os~="${id}"])` + ), + ...gpuIds.map( + (id) => `:root[data-setup-gpu="${id}"] [data-setup-for-gpu]:not([data-setup-for-gpu~="${id}"])` + ), + ] + .map((selector) => `${selector}{display:none}`) + .join('\n'); +} + +/** + * Applies the stored or detected setup and wires the picker's radios. Runs as an inline script at + * the end of the picker, before the commands below it are parsed, so the page never paints the + * wrong variant. Serialized with `toString()`: it must only use its arguments and globals. + * + * @param {HTMLElement} picker + * @param {SetupOs[]} options + * @param {typeof resolveSetup} resolve + * @param {string} storageKey + */ +export function mountSetupPicker(picker, options, resolve, storageKey) { + let stored = null; + try { + stored = JSON.parse(localStorage.getItem(storageKey) || 'null'); + } catch { + // Storage blocked or holding junk: fall back to detection. + } + const nav = /** @type {Navigator & { userAgentData?: { platform?: string } }} */ (navigator); + const platform = nav.userAgentData?.platform || nav.platform; + const summary = picker.querySelector('[data-setup-summary]'); + /** @type {NodeListOf<HTMLInputElement>} */ + const inputs = picker.querySelectorAll('input[type="radio"]'); + + /** @param {Setup} setup */ + const apply = (setup) => { + const os = options.find((option) => option.id === setup.os) ?? options[0]; + const gpu = os.gpus.find((option) => option.id === setup.gpu) ?? os.gpus[0]; + document.documentElement.dataset.setupOs = os.id; + document.documentElement.dataset.setupGpu = gpu.id; + for (const input of inputs) { + if (input.name === 'setup-gpu') input.disabled = !os.gpus.some((option) => option.id === input.value); + input.checked = input.value === (input.name === 'setup-os' ? os.id : gpu.id); + } + if (summary) summary.textContent = `${os.label} · ${gpu.label}`; + }; + + let current = resolve(options, stored, platform); + apply(current); + picker.addEventListener('change', (event) => { + const input = /** @type {HTMLInputElement} */ (event.target); + const next = input.name === 'setup-os' ? { os: input.value, gpu: current.gpu } : { os: current.os, gpu: input.value }; + current = resolve(options, next, platform); + apply(current); + try { + localStorage.setItem(storageKey, JSON.stringify(current)); + } catch { + // Storage blocked: the choice still applies to this page. + } + }); +} diff --git a/docs/src/lib/components/setup-picker.test.mjs b/docs/src/lib/components/setup-picker.test.mjs new file mode 100644 index 00000000000..397495deb04 --- /dev/null +++ b/docs/src/lib/components/setup-picker.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import en from '../../content/i18n/en.json' with { type: 'json' }; +import { + describeVariant, + findCoverageGaps, + localizeSetupOptions, + resolveSetup, + SETUP_OPTIONS, + variantsInHtml, +} from './setup-picker.mjs'; + +// The English page's options. +const options = localizeSetupOptions(SETUP_OPTIONS, (key) => en[key]); + +describe('resolveSetup', () => { + it('keeps a valid stored setup regardless of the platform', () => { + assert.deepEqual(resolveSetup(options, { os: 'linux', gpu: 'amd' }, 'macOS'), { os: 'linux', gpu: 'amd' }); + assert.deepEqual(resolveSetup(options, { os: 'windows', gpu: 'arm64' }, 'Linux'), { + os: 'windows', + gpu: 'arm64', + }); + }); + + it("falls back to the OS's default GPU when the OS does not offer the GPU", () => { + assert.deepEqual(resolveSetup(options, { os: 'macos', gpu: 'nvidia' }), { os: 'macos', gpu: 'apple' }); + assert.deepEqual(resolveSetup(options, { os: 'windows', gpu: 'apple' }), { os: 'windows', gpu: 'nvidia' }); + assert.deepEqual(resolveSetup(options, { os: 'linux', gpu: 'arm64' }), { os: 'linux', gpu: 'nvidia' }); + }); + + it('detects the OS from the platform on a first visit', () => { + assert.deepEqual(resolveSetup(options, null, 'MacIntel'), { os: 'macos', gpu: 'apple' }); + assert.deepEqual(resolveSetup(options, null, 'macOS'), { os: 'macos', gpu: 'apple' }); + assert.deepEqual(resolveSetup(options, null, 'Linux x86_64'), { os: 'linux', gpu: 'nvidia' }); + assert.deepEqual(resolveSetup(options, null, 'Win32'), { os: 'windows', gpu: 'nvidia' }); + assert.deepEqual(resolveSetup(options, null, undefined), { os: 'windows', gpu: 'nvidia' }); + }); + + it('ignores stored junk and unknown ids', () => { + for (const junk of ['macos', 42, [], { os: 'beos' }]) { + assert.deepEqual(resolveSetup(options, junk, 'Linux'), { os: 'linux', gpu: 'nvidia' }); + } + assert.deepEqual(resolveSetup(options, { os: 'beos', gpu: 'amd' }, 'Linux'), { os: 'linux', gpu: 'amd' }); + }); +}); + +describe('describeVariant', () => { + it('labels a variant with its OS and GPU names', () => { + assert.deepEqual(describeVariant(options, { os: 'windows linux', gpu: 'nvidia' }), { + os: ['windows', 'linux'], + gpu: ['nvidia'], + label: 'Windows, Linux · NVIDIA', + }); + assert.equal(describeVariant(options, { os: 'linux', gpu: 'none' }).label, 'Linux · None (CPU)'); + assert.deepEqual(describeVariant(options, { os: 'macos' }), { os: ['macos'], gpu: undefined, label: 'macOS' }); + }); + + it('rejects typos and GPUs the chosen OS does not offer, so no command is silently hidden', () => { + assert.throws(() => describeVariant(options, { os: 'window' }), /Unknown setup OS "window"/); + assert.throws(() => describeVariant(options, { gpu: 'nvida' }), /GPU "nvida"/); + assert.throws(() => describeVariant(options, { os: 'macos', gpu: 'nvidia' }), /GPU "nvidia"/); + assert.throws(() => describeVariant(options, {}), /needs an `os` or a `gpu`/); + }); +}); + +describe('findCoverageGaps', () => { + it('accepts alternatives that show exactly one variant for every setup', () => { + const variants = [ + { os: ['windows', 'linux'], gpu: ['nvidia'] }, + { gpu: ['amd', 'intel', 'none', 'apple'] }, + { os: ['windows'], gpu: ['arm64'] }, + ]; + assert.deepEqual(findCoverageGaps(options, variants), { missing: [], repeated: [] }); + }); + + it('names setups that see no variant or more than one', () => { + const variants = [{ os: ['windows'] }, { os: ['windows', 'linux'], gpu: ['nvidia'] }]; + assert.deepEqual(findCoverageGaps(options, variants), { + missing: ['macOS · Apple silicon', 'Linux · AMD', 'Linux · Intel Arc', 'Linux · None (CPU)'], + repeated: ['Windows · NVIDIA'], + }); + }); +}); + +describe('variantsInHtml', () => { + it('reads the ids of rendered variants, treating a missing attribute as any', () => { + const html = + '<div class="setup-variant" data-setup-variant data-setup-for-os="windows linux" data-setup-for-gpu="amd">' + + '<p>x</p></div><div data-setup-variant class="setup-variant" data-setup-for-gpu="arm64"></div>' + + '<div class="other" data-setup-for-os="macos"></div>'; + assert.deepEqual(variantsInHtml(html), [ + { os: ['windows', 'linux'], gpu: ['amd'] }, + { os: undefined, gpu: ['arm64'] }, + ]); + }); +}); diff --git a/docs/src/lib/components/tour/GalleryDemo.astro b/docs/src/lib/components/tour/GalleryDemo.astro new file mode 100644 index 00000000000..5ac9f4b1f6b --- /dev/null +++ b/docs/src/lib/components/tour/GalleryDemo.astro @@ -0,0 +1,1259 @@ +--- +// An interactive miniature of the app's Gallery widget in its stacked layout, for the home page tour. It follows +// `invokeai/frontend/webv2/src/features/gallery/ui/`: the widget header with its board breadcrumb +// (`GalleryWidgetChrome.tsx`), the board search and the board list grouped into the open project and the Library +// (`GalleryBoardsPanel.tsx`, `galleryBoardGroups.ts`), the Media and Uploads tabs with the starred filter, sort and item +// search (`GalleryStackedLayout.tsx`), and the grid with its pinned Starred strip (`GalleryImageGrid.tsx`). +// +// The page renders the initial state, which is what shows without JavaScript; the script below applies the rules in +// `gallery-demo.mjs` to every change. Controls the demo does not model (board filters, uploads, the media filter, +// semantic search, settings) are drawn but are not focusable. +import type { ImageMetadata } from 'astro'; +import { Image } from 'astro:assets'; +import ChevronDown from '@lucide/astro/icons/chevron-down'; +import ChevronRight from '@lucide/astro/icons/chevron-right'; +import ChevronsDownUp from '@lucide/astro/icons/chevrons-down-up'; +import ChevronsUpDown from '@lucide/astro/icons/chevrons-up-down'; +import CircleQuestionMark from '@lucide/astro/icons/circle-question-mark'; +import ImageIcon from '@lucide/astro/icons/image'; +import Layers from '@lucide/astro/icons/layers'; +import Plus from '@lucide/astro/icons/plus'; +import Search from '@lucide/astro/icons/search'; +import Settings from '@lucide/astro/icons/settings'; +import SlidersHorizontal from '@lucide/astro/icons/sliders-horizontal'; +import Sparkles from '@lucide/astro/icons/sparkles'; +import Star from '@lucide/astro/icons/star'; +import Upload from '@lucide/astro/icons/upload'; +import X from '@lucide/astro/icons/x'; + +import car from '../../../assets/tour/gallery/car.webp'; +import espresso from '../../../assets/tour/gallery/espresso.webp'; +import house from '../../../assets/tour/gallery/house.webp'; +import lineart from '../../../assets/tour/gallery/lineart.webp'; +import market from '../../../assets/tour/gallery/market.webp'; +import mech from '../../../assets/tour/gallery/mech.webp'; +import mountains from '../../../assets/tour/gallery/mountains.webp'; +import planet from '../../../assets/tour/gallery/planet.webp'; +import portrait from '../../../assets/tour/gallery/portrait.webp'; +import rider from '../../../assets/tour/gallery/rider.webp'; +import scientist from '../../../assets/tour/gallery/scientist.webp'; +import sushi from '../../../assets/tour/gallery/sushi.webp'; +import wizard from '../../../assets/tour/gallery/wizard.webp'; +import GalleryDemoBoardRow from './GalleryDemoBoardRow.astro'; +import GalleryDemoTile from './GalleryDemoTile.astro'; +import { + GALLERY_DEMO_BOARDS, + GALLERY_DEMO_INITIAL_BOARD_ID, + GALLERY_DEMO_ITEMS, + GALLERY_DEMO_PROJECT, + getBoardCounts, + getBoardGroups, + getGridSections, +} from './gallery-demo.mjs'; + +interface Props { + /** Prefix for the demo's element ids; give each instance on a page its own. */ + id?: string; + class?: string; +} + +const { id = 'gallery-demo', class: className } = Astro.props; + +const IMAGES: Record<string, ImageMetadata> = { + car, + espresso, + house, + lineart, + market, + mech, + mountains, + planet, + portrait, + rider, + scientist, + sushi, + wizard, +}; + +const groups = getBoardGroups(GALLERY_DEMO_BOARDS, GALLERY_DEMO_PROJECT.id, ''); +const selectedBoard = GALLERY_DEMO_BOARDS.find((board) => board.id === GALLERY_DEMO_INITIAL_BOARD_ID)!; +const selectedCounts = getBoardCounts(GALLERY_DEMO_ITEMS, selectedBoard.id); +const { starred, listing } = getGridSections(GALLERY_DEMO_ITEMS, { + boardId: selectedBoard.id, + view: 'images', + starredOnly: false, + searchTerm: '', + order: 'DESC', +}); +const listedIds = new Set(listing.map((entry) => entry.id)); +// Every item has a tile in the listing, hidden until its board, view and the filters show it; the listed ones first, +// in order. The Starred strip shows copies of the listing's starred tiles. +const pool = [...listing, ...GALLERY_DEMO_ITEMS.filter((entry) => !listedIds.has(entry.id))]; +const tabStopId = (starred[0] ?? listing[0])?.id; +const ids = { + boards: `${id}-boards`, + project: `${id}-project`, + library: `${id}-library`, + strip: `${id}-starred`, + panel: `${id}-panel`, + tab: (view: string) => `${id}-tab-${view}`, +}; +--- + +<section + class:list={['gallery-demo', 'not-content', className]} + aria-label={Astro.locals.t('galleryDemo.label')} + data-gallery-demo + data-project-id={GALLERY_DEMO_PROJECT.id} + data-selected-board={selectedBoard.id} +> + <header class="gd-header"> + <span class="gd-identity" aria-hidden="true"><ImageIcon size={12} /></span> + <span class="gd-title">Gallery</span> + <span class="gd-slash" aria-hidden="true">/</span> + <button + type="button" + class="gd-crumb" + data-crumb + aria-expanded="true" + aria-controls={ids.boards} + aria-label={`Toggle boards, currently ${selectedBoard.name}`} + > + <span class="gd-cover" data-crumb-cover> + { + selectedBoard.cover ? ( + <Image src={IMAGES[selectedBoard.cover]} alt="" width={40} height={40} decoding="async" /> + ) : ( + <ImageIcon size={12} aria-hidden="true" /> + ) + } + </span> + <span class="gd-crumb-name" data-crumb-name>{selectedBoard.name}</span> + <ChevronsDownUp size={12} class="gd-crumb-open" aria-hidden="true" /> + <ChevronsUpDown size={12} class="gd-crumb-closed" aria-hidden="true" /> + </button> + <span class="gd-icon-button gd-icon-button--sm gd-header-action" aria-hidden="true"><Settings size={14} /></span> + </header> + + <div class="gd-body"> + <div class="gd-boards" id={ids.boards} data-boards-panel> + <div class="gd-board-filters"> + <div class="gd-input-group"> + <Search size={14} class="gd-input-icon" aria-hidden="true" /> + <input + class="gd-input" + type="text" + data-board-search + aria-label="Search or create boards" + placeholder="Search or create boards" + autocomplete="off" + spellcheck="false" + /> + <button type="button" class="gd-icon-button gd-icon-button--sm gd-clear" data-clear-board-search aria-label="Clear search" hidden> + <X size={14} aria-hidden="true" /> + </button> + </div> + <span class="gd-icon-button" aria-hidden="true"><SlidersHorizontal size={14} /></span> + </div> + + <div class="gd-board-list gd-scroll"> + { + ( + [ + { tier: 'project', label: GALLERY_DEMO_PROJECT.name, listId: ids.project, rows: groups.projectBoards, count: groups.projectCount }, + { tier: 'library', label: 'Library', listId: ids.library, rows: groups.libraryBoards, count: groups.libraryCount }, + ] as const + ).map(({ tier, label, listId, rows, count }) => ( + <div class="gd-section" data-section={tier}> + <div class="gd-section-head"> + <button type="button" class="gd-section-trigger" data-section-trigger aria-expanded="true" aria-controls={listId}> + <ChevronRight size={12} class="gd-disclosure" aria-hidden="true" /> + <span class="gd-section-label">{label}</span> + <span class="gd-section-count" data-section-count>{count}</span> + </button> + <span class="gd-icon-button gd-icon-button--sm" aria-hidden="true"><Plus size={14} /></span> + </div> + <div class="gd-section-rows" id={listId} data-section-rows> + {rows.map((board) => ( + <GalleryDemoBoardRow + board={board} + cover={board.cover ? IMAGES[board.cover] : undefined} + counts={getBoardCounts(GALLERY_DEMO_ITEMS, board.id)} + selected={board.id === selectedBoard.id} + /> + ))} + <div class="gd-row" data-create-row={tier} hidden> + <button type="button" class="gd-row-button"> + <span class="gd-cover"><Plus size={12} aria-hidden="true" /></span> + <span class="gd-row-label gd-row-label--strong" data-create-label /> + </button> + </div> + </div> + </div> + )) + } + <p class="gd-no-boards" data-no-boards hidden>No boards match this search.</p> + </div> + </div> + + <div class="gd-resize" data-resize aria-hidden="true"><span></span></div> + + <div class="gd-controls"> + <div class="gd-controls-row"> + <div class="docs-segment-tabs gd-tabs" role="tablist" aria-label="View" data-tabs> + { + ( + [ + { view: 'images', label: 'Media', count: selectedCounts.media }, + { view: 'assets', label: 'Uploads', count: selectedCounts.uploads }, + ] as const + ).map(({ view, label, count }) => ( + <button + type="button" + role="tab" + class="docs-segment-tab" + id={ids.tab(view)} + data-view={view} + aria-selected={view === 'images' ? 'true' : 'false'} + aria-controls={ids.panel} + tabindex={view === 'images' ? 0 : -1} + > + {label} + <span class="gd-tab-count" data-view-count>{count}</span> + </button> + )) + } + </div> + <div class="gd-tools"> + <button type="button" class="gd-icon-button gd-toggle" data-starred-only aria-pressed="false" aria-label="Starred only"> + <Star size={14} aria-hidden="true" /> + </button> + <span class="gd-icon-button" aria-hidden="true"><Layers size={14} /></span> + <button type="button" class="gd-sort" data-sort aria-label="Image sort: Newest"> + <span data-sort-label>Newest</span> + <ChevronDown size={12} aria-hidden="true" /> + </button> + <span class="gd-icon-button" aria-hidden="true"><Upload size={14} /></span> + </div> + </div> + <div class="gd-input-group"> + <Search size={14} class="gd-input-icon" aria-hidden="true" /> + <input + class="gd-input" + type="text" + data-item-search + aria-label="Search gallery items" + placeholder="Search items" + autocomplete="off" + spellcheck="false" + /> + <span class="gd-input-end"> + <button type="button" class="gd-icon-button gd-icon-button--sm" data-clear-item-search aria-label="Clear search" hidden> + <X size={14} aria-hidden="true" /> + </button> + <span class="gd-icon-button gd-icon-button--sm" aria-hidden="true"><Sparkles size={14} /></span> + <span class="gd-icon-button gd-icon-button--sm gd-subtle" aria-hidden="true"><CircleQuestionMark size={14} /></span> + </span> + </div> + </div> + + <div class="gd-grid-scroll gd-scroll" role="tabpanel" id={ids.panel} aria-labelledby={ids.tab('images')} data-grid> + <div class="gd-strip" data-strip hidden={starred.length === 0}> + <div class="gd-strip-head"> + <button type="button" class="gd-section-trigger" data-strip-trigger aria-expanded="true" aria-controls={ids.strip}> + <ChevronRight size={12} class="gd-disclosure" aria-hidden="true" /> + <Star size={12} class="gd-filled" aria-hidden="true" /> + <span class="gd-section-label">Starred</span> + <span class="gd-section-count" data-strip-count>{starred.length}</span> + </button> + </div> + <div class="gd-grid" role="list" aria-label="Starred" id={ids.strip} data-strip-list> + {starred.map((entry) => <GalleryDemoTile item={entry} image={IMAGES[entry.image]} tabStop={entry.id === tabStopId} />)} + </div> + </div> + <div class="gd-grid" role="list" aria-label="Gallery items" data-listing> + { + pool.map((entry) => ( + <GalleryDemoTile + item={entry} + image={IMAGES[entry.image]} + hidden={!listedIds.has(entry.id)} + tabStop={starred.length === 0 && entry.id === tabStopId} + /> + )) + } + </div> + <p class="gd-empty" data-empty hidden></p> + <div class="gd-dropzone" data-dropzone hidden> + <Upload size={16} aria-hidden="true" /> + <span>Drop media here or click to upload</span> + </div> + </div> + </div> + + <template data-new-row> + <GalleryDemoBoardRow + board={{ id: '', name: '', kind: 'board', projectId: null, cover: null }} + counts={{ media: 0, uploads: 0 }} + /> + </template> +</section> + +<script> + import { + createBoard, + GALLERY_DEMO_BOARDS, + GALLERY_DEMO_ITEMS, + getBoardCounts, + getBoardGroups, + getGridEmptyState, + getGridSections, + toggleStarred, + } from './gallery-demo.mjs'; + + type Section = 'strip' | 'listing'; + + const mount = (root: HTMLElement) => { + const $ = <T extends Element = HTMLElement>(selector: string) => root.querySelector<T>(selector)!; + const $$ = <T extends Element = HTMLElement>(selector: string, scope: ParentNode = root) => [ + ...scope.querySelectorAll<T>(selector), + ]; + + const projectId = root.dataset.projectId!; + const state = { + boards: [...GALLERY_DEMO_BOARDS], + items: [...GALLERY_DEMO_ITEMS], + boardId: root.dataset.selectedBoard!, + boardSearch: '', + view: 'images' as 'images' | 'assets', + starredOnly: false, + itemSearch: '', + order: 'DESC' as 'DESC' | 'ASC', + selectedItemId: null as string | null, + }; + + const boardSearch = $<HTMLInputElement>('[data-board-search]'); + const itemSearch = $<HTMLInputElement>('[data-item-search]'); + const grid = $('[data-grid]'); + const listing = $('[data-listing]'); + const strip = $('[data-strip]'); + const stripList = $('[data-strip-list]'); + const tabs = $$<HTMLButtonElement>('[role="tab"]'); + const poolTiles = new Map($$('[data-item-id]', listing).map((tile) => [tile.dataset.itemId!, tile])); + /** The tile holding the grid's one Tab stop. */ + let activeTile: HTMLElement | null = $('[data-tile-select][tabindex="0"]')?.closest<HTMLElement>('[data-item-id]') ?? null; + + const sectionOf = (tile: Element): Section => (strip.contains(tile) ? 'strip' : 'listing'); + const rowOf = (boardId: string) => root.querySelector<HTMLElement>(`[data-board-row="${CSS.escape(boardId)}"]`); + + // Boards + + const renderBoards = () => { + const groups = getBoardGroups(state.boards, projectId, state.boardSearch); + const shown = new Set([...groups.projectBoards, ...groups.libraryBoards].map((board) => board.id)); + for (const row of $$('[data-board-row]')) { + const isSelected = row.dataset.boardRow === state.boardId; + row.hidden = !shown.has(row.dataset.boardRow!); + row.toggleAttribute('data-selected', isSelected); + const button = row.querySelector('button')!; + if (isSelected) button.setAttribute('aria-current', 'true'); + else button.removeAttribute('aria-current'); + } + $('[data-section="project"] [data-section-count]').textContent = String(groups.projectCount); + $('[data-section="library"] [data-section-count]').textContent = String(groups.libraryCount); + const term = state.boardSearch.trim(); + for (const createRow of $$('[data-create-row]')) { + createRow.hidden = !groups.canCreateFromSearch; + const destination = createRow.dataset.createRow === 'project' ? $('[data-section="project"] .gd-section-label').textContent : 'Library'; + createRow.querySelector('[data-create-label]')!.textContent = `Create board "${term}" in ${destination}`; + } + $('[data-no-boards]').hidden = groups.hasAnyMatch || groups.canCreateFromSearch; + $('[data-clear-board-search]').hidden = state.boardSearch === ''; + }; + + const renderHeader = () => { + const board = state.boards.find((entry) => entry.id === state.boardId)!; + $('[data-crumb-name]').textContent = board.name; + $('[data-crumb]').setAttribute('aria-label', `Toggle boards, currently ${board.name}`); + $('[data-crumb-cover]').replaceChildren(...[...rowOf(board.id)!.querySelector('.gd-cover')!.childNodes].map((node) => node.cloneNode(true))); + const counts = getBoardCounts(state.items, state.boardId); + for (const tab of tabs) { + const isSelected = tab.dataset.view === state.view; + tab.setAttribute('aria-selected', String(isSelected)); + tab.tabIndex = isSelected ? 0 : -1; + tab.querySelector('[data-view-count]')!.textContent = String(tab.dataset.view === 'images' ? counts.media : counts.uploads); + if (isSelected) grid.setAttribute('aria-labelledby', tab.id); + } + }; + + const selectBoard = (boardId: string) => { + state.boardId = boardId; + // As in the app, picking a board ends the board search. + state.boardSearch = ''; + boardSearch.value = ''; + activeTile = null; + renderBoards(); + renderHeader(); + renderGrid(); + grid.scrollTop = 0; + }; + + const createFromSearch = (tier: 'project' | 'library') => { + const name = state.boardSearch.trim(); + if (!name) return; + const { boards, board } = createBoard(state.boards, name, tier === 'project' ? projectId : null); + state.boards = boards; + const template = $<HTMLTemplateElement>('template[data-new-row]'); + const row = template.content.firstElementChild!.cloneNode(true) as HTMLElement; + row.dataset.boardRow = board.id; + row.querySelector('[data-board-label]')!.textContent = board.name; + $(`[data-create-row="${tier}"]`).before(row); + selectBoard(board.id); + row.querySelector('button')!.focus(); + }; + + // Grid + + const setTabStop = (tile: HTMLElement | null) => { + activeTile = tile; + for (const button of $$<HTMLButtonElement>('[data-tile-select], [data-tile-star]', grid)) { + button.tabIndex = tile !== null && button.closest('[data-item-id]') === tile ? 0 : -1; + } + }; + + const visibleTiles = () => $$('[data-item-id]', grid).filter((tile) => !tile.hidden && !tile.closest('[hidden]')); + + const renderGrid = () => { + const focused = document.activeElement?.closest<HTMLElement>('[data-item-id]'); + const restore = + focused && grid.contains(focused) + ? { + id: focused.dataset.itemId!, + section: sectionOf(focused), + part: document.activeElement!.matches('[data-tile-star]') ? '[data-tile-star]' : '[data-tile-select]', + } + : null; + const active = activeTile ? { id: activeTile.dataset.itemId!, section: sectionOf(activeTile) } : null; + + const filter = { boardId: state.boardId, view: state.view, starredOnly: state.starredOnly, searchTerm: state.itemSearch, order: state.order }; + const sections = getGridSections(state.items, filter); + const listed = new Set(sections.listing.map((entry) => entry.id)); + + for (const entry of state.items) { + const tile = poolTiles.get(entry.id)!; + tile.hidden = !listed.has(entry.id); + tile.toggleAttribute('data-starred', entry.starred); + tile.toggleAttribute('data-selected', entry.id === state.selectedItemId); + tile.querySelector('[data-tile-select]')!.setAttribute('aria-pressed', String(entry.id === state.selectedItemId)); + tile.querySelector('[data-tile-star]')!.setAttribute('aria-label', `${entry.starred ? 'Unstar' : 'Star'} ${entry.name}`); + } + // Moving a node drops its focus, so the listing is reordered only when its order changed. + const order = sections.listing.map((entry) => poolTiles.get(entry.id)!); + const current = [...listing.children].filter((tile) => !(tile as HTMLElement).hidden); + if (order.some((tile, index) => current[index] !== tile)) listing.prepend(...order); + + stripList.replaceChildren(...sections.starred.map((entry) => poolTiles.get(entry.id)!.cloneNode(true) as HTMLElement)); + strip.hidden = sections.starred.length === 0; + $('[data-strip-count]').textContent = String(sections.starred.length); + + const empty = getGridEmptyState(sections.listing.length, filter); + const message = $('[data-empty]'); + message.hidden = empty?.kind !== 'message'; + message.textContent = empty?.kind === 'message' ? empty.text : ''; + $('[data-dropzone]').hidden = empty?.kind !== 'upload'; + + const find = (id: string, section: Section) => + visibleTiles().find((tile) => tile.dataset.itemId === id && sectionOf(tile) === section) ?? + visibleTiles().find((tile) => tile.dataset.itemId === id) ?? + null; + setTabStop((active && find(active.id, active.section)) ?? visibleTiles()[0] ?? null); + if (restore && !grid.contains(document.activeElement)) { + const tile = find(restore.id, restore.section) ?? activeTile; + tile?.querySelector<HTMLElement>(restore.part)?.focus(); + } + }; + + const toggleStar = (itemId: string) => { + state.items = toggleStarred(state.items, itemId); + renderGrid(); + }; + + const selectItem = (itemId: string) => { + state.selectedItemId = itemId; + for (const tile of $$('[data-item-id]', grid)) { + const isSelected = tile.dataset.itemId === itemId; + tile.toggleAttribute('data-selected', isSelected); + tile.querySelector('[data-tile-select]')!.setAttribute('aria-pressed', String(isSelected)); + } + }; + + /** The nearest visible tile in an arrow's direction, by layout rather than DOM order; null at an edge. */ + const tileInDirection = (from: HTMLElement, key: string): HTMLElement | null => { + const origin = from.getBoundingClientRect(); + const horizontal = key === 'ArrowLeft' || key === 'ArrowRight'; + const forward = key === 'ArrowRight' || key === 'ArrowDown'; + let best: { tile: HTMLElement; score: number } | null = null; + for (const tile of visibleTiles()) { + if (tile === from) continue; + const rect = tile.getBoundingClientRect(); + const gap = horizontal + ? forward ? rect.left - origin.right : origin.left - rect.right + : forward ? rect.top - origin.bottom : origin.top - rect.bottom; + if (gap < -1) continue; + const overlaps = horizontal + ? rect.top < origin.bottom - 1 && rect.bottom > origin.top + 1 + : rect.left < origin.right - 1 && rect.right > origin.left + 1; + const offset = horizontal + ? Math.abs(rect.top + rect.height / 2 - (origin.top + origin.height / 2)) + : Math.abs(rect.left + rect.width / 2 - (origin.left + origin.width / 2)); + // Aligned tiles first, then the nearest with the perpendicular offset weighed in. + const score = (overlaps ? 0 : 1e6) + gap + offset * (overlaps ? 0.001 : 2); + if (!best || score < best.score) best = { tile, score }; + } + return best?.tile ?? null; + }; + + // Events + + boardSearch.addEventListener('input', () => { + state.boardSearch = boardSearch.value; + renderBoards(); + }); + boardSearch.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && boardSearch.value) { + event.preventDefault(); + boardSearch.value = ''; + state.boardSearch = ''; + renderBoards(); + } else if (event.key === 'Enter') { + // As in the app, Enter creates only when nothing matches, in the project. + event.preventDefault(); + if (!getBoardGroups(state.boards, projectId, state.boardSearch).hasAnyMatch) createFromSearch('project'); + } + }); + itemSearch.addEventListener('input', () => { + state.itemSearch = itemSearch.value; + $('[data-clear-item-search]').hidden = state.itemSearch === ''; + renderGrid(); + }); + itemSearch.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && itemSearch.value) { + event.preventDefault(); + itemSearch.value = ''; + itemSearch.dispatchEvent(new Event('input')); + } + }); + + root.addEventListener('click', (event) => { + const target = event.target as Element; + const control = target.closest<HTMLElement>('button'); + if (!control || !root.contains(control)) return; + + const boardRow = control.closest<HTMLElement>('[data-board-row]'); + const createRow = control.closest<HTMLElement>('[data-create-row]'); + const tile = control.closest<HTMLElement>('[data-item-id]'); + if (boardRow) { + selectBoard(boardRow.dataset.boardRow!); + } else if (createRow) { + createFromSearch(createRow.dataset.createRow as 'project' | 'library'); + } else if (tile && control.matches('[data-tile-star]')) { + toggleStar(tile.dataset.itemId!); + } else if (tile) { + selectItem(tile.dataset.itemId!); + } else if (control.matches('[role="tab"]')) { + state.view = control.dataset.view as 'images' | 'assets'; + activeTile = null; + renderHeader(); + renderGrid(); + } else if (control.matches('[data-starred-only]')) { + state.starredOnly = !state.starredOnly; + control.setAttribute('aria-pressed', String(state.starredOnly)); + renderGrid(); + } else if (control.matches('[data-sort]')) { + state.order = state.order === 'DESC' ? 'ASC' : 'DESC'; + const label = state.order === 'DESC' ? 'Newest' : 'Oldest'; + $('[data-sort-label]').textContent = label; + control.setAttribute('aria-label', `Image sort: ${label}`); + renderGrid(); + } else if (control.matches('[data-section-trigger], [data-strip-trigger]')) { + const isOpen = control.getAttribute('aria-expanded') !== 'true'; + control.setAttribute('aria-expanded', String(isOpen)); + document.getElementById(control.getAttribute('aria-controls')!)!.hidden = !isOpen; + if (control.matches('[data-strip-trigger]')) setTabStop(activeTile?.isConnected && !activeTile.closest('[hidden]') ? activeTile : visibleTiles()[0] ?? null); + } else if (control.matches('[data-crumb]')) { + const isOpen = control.getAttribute('aria-expanded') !== 'true'; + control.setAttribute('aria-expanded', String(isOpen)); + $('[data-boards-panel]').hidden = !isOpen; + $('[data-resize]').hidden = !isOpen; + } else if (control.matches('[data-clear-board-search]')) { + boardSearch.value = ''; + state.boardSearch = ''; + renderBoards(); + boardSearch.focus(); + } else if (control.matches('[data-clear-item-search]')) { + itemSearch.value = ''; + itemSearch.dispatchEvent(new Event('input')); + itemSearch.focus(); + } + }); + + // The segment tabs' roving focus: arrows cycle, Home and End jump; Enter or Space selects. + $('[data-tabs]').addEventListener('keydown', (event) => { + const index = tabs.indexOf(document.activeElement as HTMLButtonElement); + if (index === -1 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const next = + event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length; + tabs[next]!.focus(); + }); + + grid.addEventListener('focusin', (event) => { + const tile = (event.target as Element).closest<HTMLElement>('[data-item-id]'); + if (tile && tile !== activeTile) setTabStop(tile); + }); + grid.addEventListener('keydown', (event) => { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + const target = event.target as HTMLElement; + const tile = target.closest<HTMLElement>('[data-item-id]'); + if (!tile) return; + if (event.key === '.') { + // The app's star hotkey. + event.preventDefault(); + toggleStar(tile.dataset.itemId!); + return; + } + if (!target.matches('[data-tile-select]') || !event.key.startsWith('Arrow')) return; + const next = tileInDirection(tile, event.key); + if (!next) return; + event.preventDefault(); + setTabStop(next); + const button = next.querySelector<HTMLElement>('[data-tile-select]')!; + button.focus({ preventScroll: true }); + button.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); + }; + + for (const root of document.querySelectorAll<HTMLElement>('[data-gallery-demo]')) mount(root); +</script> + +<style is:global> + .gallery-demo { + /* The app's semantic colors (`platform/ui/theme/system.ts`) on the docs' copies of its Classic theme. */ + --gd-bg: var(--invoke-neutral-950); + --gd-panel: var(--invoke-neutral-900); + --gd-fg: var(--invoke-neutral-50); + --gd-fg-muted: var(--invoke-neutral-300); + --gd-fg-subtle: var(--invoke-neutral-400); + --gd-border: var(--invoke-neutral-600); + --gd-border-emphasized: var(--invoke-border-emphasized); + --gd-hover: var(--invoke-bg-hover); + --gd-control: var(--invoke-control); + --gd-fill: var(--invoke-fill); + --gd-accent: var(--invoke-accent); + --gd-accent-contrast: var(--invoke-accent-contrast); + /* Chakra's `yellow.solid` and `yellow.contrast`, the starred button's fill; the theme has no yellow of its own. */ + --gd-star: #fde047; + --gd-star-contrast: #000; + --gd-radius-control: 5px; + --gd-radius-sm: 4px; + --gd-radius-md: 6px; + --gd-motion-fast: 0s; + --gd-motion-medium: 0s; + + container-type: inline-size; + display: flex; + flex-direction: column; + box-sizing: border-box; + inline-size: 100%; + max-inline-size: 28.125rem; + block-size: 38.5rem; + overflow: hidden; + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-container); + background: var(--gd-panel); + color: var(--gd-fg); + font: 400 0.75rem / 1rem var(--__sl-font, 'Inter Variable', system-ui, sans-serif); + text-align: start; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (prefers-reduced-motion: no-preference) { + --gd-motion-fast: 120ms; + --gd-motion-medium: 150ms; + } + + *, + *::before, + *::after { + box-sizing: border-box; + } + + [hidden] { + display: none !important; + } + + svg { + flex: none; + display: block; + } + + /* Not the segment tabs, which take the docs' own chip styles. */ + :where(button:not(.docs-segment-tab)) { + margin: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--gd-accent); + outline-offset: -2px; + } + } + + .gd-sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + /* Ghost icon buttons: 28px (`md`) or 24px (`sm`), muted, with the app's translucent hover fill. */ + .gd-icon-button { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--gd-radius-control); + color: var(--gd-fg-muted); + transition: background-color var(--gd-motion-fast), color var(--gd-motion-fast); + } + .gd-icon-button--sm { + width: 1.5rem; + height: 1.5rem; + } + .gd-subtle { + color: var(--gd-fg-subtle); + } + button.gd-icon-button:hover { + background: var(--gd-hover); + color: var(--gd-fg); + } + + .gd-badge { + display: inline-flex; + flex: none; + align-items: center; + min-height: 1rem; + padding: 0 0.25rem; + border-radius: var(--gd-radius-sm); + background: var(--gd-fill); + color: var(--gd-fg); + font-size: 0.6875rem; + font-weight: 500; + line-height: 1rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .gd-badge--solid { + background: var(--gd-fg); + color: var(--gd-bg); + } + + .gd-cover { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + overflow: hidden; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + background: var(--gd-control); + color: var(--gd-fg-subtle); + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + } + + /* Header: `PanelHeader` with the widget identity and the board breadcrumb. */ + .gd-header { + display: flex; + flex: none; + align-items: center; + gap: 0.25rem; + height: 2.5rem; + padding: 0 0.5rem 0 0.75rem; + border-bottom: 1px solid var(--gd-border); + } + .gd-identity { + color: var(--gd-fg); + margin-inline-end: 0.125rem; + } + .gd-title { + flex: none; + padding-inline-end: 0.5rem; + font-weight: 700; + } + .gd-slash { + flex: none; + color: var(--gd-fg-subtle); + } + .gd-crumb { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-width: 0; + max-width: 14rem; + height: 1.5rem; + padding: 0 0.5rem 0 0.125rem; + border-radius: var(--gd-radius-control); + transition: background-color var(--gd-motion-fast); + + &:hover { + background: var(--gd-hover); + } + > svg { + color: var(--gd-fg-subtle); + } + &[aria-expanded='true'] .gd-crumb-closed, + &[aria-expanded='false'] .gd-crumb-open { + display: none; + } + } + .gd-crumb-name { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + .gd-header-action { + margin-inline-start: auto; + } + + .gd-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.5rem; + min-height: 0; + padding: 0.5rem; + } + + /* Boards */ + .gd-boards { + display: flex; + flex: none; + flex-direction: column; + gap: 0.25rem; + height: 15.75rem; + min-height: 0; + } + .gd-board-filters { + display: flex; + gap: 0.25rem; + } + .gd-input-group { + position: relative; + display: flex; + flex: 1; + align-items: center; + min-width: 0; + } + .gd-input-icon { + position: absolute; + inset-inline-start: 0.5rem; + color: var(--gd-fg-subtle); + pointer-events: none; + } + .gd-input { + width: 100%; + height: 1.75rem; + margin: 0; + padding: 0 1.75rem 0 1.75rem; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-control); + outline: none; + background: transparent; + color: var(--gd-fg); + font: inherit; + transition: border-color var(--gd-motion-fast); + + &::placeholder { + color: var(--gd-fg-muted); + opacity: 1; + } + &:hover { + border-color: var(--gd-border-emphasized); + } + &:focus-visible, + &:focus { + border-color: var(--gd-accent); + } + } + [data-item-search] { + padding-inline-end: 3.5rem; + } + .gd-clear, + .gd-input-end { + position: absolute; + inset-inline-end: 0.125rem; + } + .gd-input-end { + display: flex; + } + .gd-input-group:has([data-clear-item-search]:not([hidden])) [data-item-search] { + padding-inline-end: 5rem; + } + + .gd-scroll { + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--gd-border) transparent; + } + .gd-board-list { + flex: 1; + min-height: 0; + padding-block: 0.25rem; + } + .gd-section-head { + display: flex; + align-items: center; + gap: 0.25rem; + min-height: 1.5rem; + padding: 0 0.25rem; + } + .gd-section-trigger { + display: flex; + flex: 1; + align-items: center; + gap: 0.25rem; + min-width: 0; + align-self: stretch; + border-radius: var(--gd-radius-sm); + color: var(--gd-fg-muted); + transition: color var(--gd-motion-fast); + + &:hover { + color: var(--gd-fg); + } + .gd-disclosure { + transition: transform var(--gd-motion-medium); + } + &[aria-expanded='true'] .gd-disclosure { + transform: rotate(90deg); + } + } + .gd-section-label { + overflow: hidden; + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.025em; + line-height: 1; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + .gd-section-count { + font-size: 0.625rem; + line-height: 1; + font-variant-numeric: tabular-nums; + } + .gd-filled { + fill: currentColor; + } + .gd-section-rows { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding-bottom: 0.25rem; + } + .gd-no-boards { + margin: 0; + padding: 0.75rem 0; + color: var(--gd-fg-muted); + font-size: 0.625rem; + text-align: center; + } + + .gd-row { + display: flex; + align-items: center; + padding-inline-end: 0.25rem; + border-radius: var(--gd-radius-sm); + transition: background-color var(--gd-motion-fast); + + &:hover { + background: var(--gd-hover); + } + &[data-selected] { + background: var(--gd-accent); + color: var(--gd-accent-contrast); + } + &:is(:hover, :focus-within) .gd-row-actions { + opacity: 1; + } + } + .gd-row-button { + display: flex; + flex: 1; + align-items: center; + gap: 0.5rem; + min-width: 0; + min-height: 1.75rem; + padding: 0.25rem; + border-radius: var(--gd-radius-sm); + text-align: start; + } + /* The accent ring would vanish on the selected row's accent fill. */ + .gd-row[data-selected] .gd-row-button:focus-visible { + outline-color: var(--gd-accent-contrast); + } + .gd-row-label { + flex: 1; + min-width: 0; + overflow: hidden; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + } + .gd-row[data-selected] .gd-row-label, + .gd-row-label--strong { + font-weight: 600; + } + .gd-row-actions { + color: inherit; + opacity: 0; + transition: opacity var(--gd-motion-medium); + } + + .gd-resize { + position: relative; + flex: none; + height: 1px; + background: var(--gd-border); + + span { + position: absolute; + top: -4px; + left: calc(50% - 12px); + width: 24px; + height: 9px; + border: 1px solid var(--gd-border-emphasized); + border-radius: 999px; + background: var(--gd-panel); + } + } + + /* View tabs, filters and item search */ + .gd-controls { + display: flex; + flex: none; + flex-direction: column; + gap: 0.5rem; + } + .gd-controls-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.25rem; + } + .gd-tabs { + flex: none; + } + /* The docs' chips are links and labels elsewhere; a button chip needs the browser's fill taken off. */ + :where(.gd-tabs .docs-segment-tab) { + background-color: transparent; + font-family: inherit; + cursor: pointer; + } + .gd-tab-count { + opacity: 0.8; + font-variant-numeric: tabular-nums; + } + .gd-tools { + display: flex; + align-items: center; + gap: 0.25rem; + margin-inline-start: auto; + } + .gd-toggle[aria-pressed='true'] { + background: var(--gd-fg); + color: var(--gd-bg); + + &:hover { + background: var(--gd-fg); + color: var(--gd-bg); + } + } + .gd-sort { + display: inline-flex; + align-items: center; + gap: 0.25rem; + height: 1.75rem; + padding: 0 0.625rem; + border-radius: var(--gd-radius-control); + color: var(--gd-fg-muted); + font-weight: 500; + transition: background-color var(--gd-motion-fast), color var(--gd-motion-fast); + + &:hover { + background: var(--gd-hover); + color: var(--gd-fg); + } + } + + /* Grid */ + .gd-grid-scroll { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + .gd-strip { + flex: none; + margin-bottom: 0.5rem; + border-bottom: 1px solid var(--gd-border); + } + .gd-strip-head { + display: flex; + align-items: center; + height: 1.5rem; + padding: 0 0.25rem; + } + .gd-grid { + display: grid; + flex: none; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.25rem; + } + .gd-strip .gd-grid { + margin-bottom: 0.25rem; + } + @container (max-width: 22rem) { + .gd-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + .gd-tile { + position: relative; + aspect-ratio: 1; + min-width: 0; + overflow: hidden; + border: 2px solid var(--gd-border); + border-radius: var(--gd-radius-md); + background: var(--gd-bg); + + &:focus-within { + outline: 2px solid var(--gd-accent); + outline-offset: -2px; + } + &[data-selected] { + border-color: var(--gd-accent); + + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + border-radius: var(--gd-radius-sm); + box-shadow: inset 0 0 0 2px var(--gd-accent); + pointer-events: none; + } + } + &:is(:hover, :focus-within) .gd-overlay { + opacity: 1; + } + } + .gd-tile-select { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + + &:focus-visible { + outline: none; + } + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + } + .gd-overlay { + position: absolute; + z-index: 2; + opacity: 0; + transition: opacity var(--gd-motion-medium); + } + .gd-tile-dims { + bottom: 0.25rem; + inset-inline-start: 0.25rem; + font-size: 0.625rem; + pointer-events: none; + } + .gd-tile-star { + top: 0.25rem; + inset-inline-end: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--gd-radius-control); + background: var(--gd-fg); + color: var(--gd-bg); + + &:focus-visible { + outline: 2px solid var(--gd-accent); + outline-offset: 1px; + } + } + .gd-tile[data-starred] .gd-tile-star { + opacity: 1; + background: var(--gd-star); + color: var(--gd-star-contrast); + + svg { + fill: currentColor; + } + } + + .gd-empty, + .gd-dropzone { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 8rem; + margin: 0; + color: var(--gd-fg-muted); + text-align: center; + } + .gd-dropzone { + flex-direction: column; + gap: 0.25rem; + border: 1px dashed var(--gd-border-emphasized); + border-radius: var(--gd-radius-md); + + svg { + color: var(--gd-fg-subtle); + } + } + } +</style> diff --git a/docs/src/lib/components/tour/GalleryDemoBoardRow.astro b/docs/src/lib/components/tour/GalleryDemoBoardRow.astro new file mode 100644 index 00000000000..735d3cfc451 --- /dev/null +++ b/docs/src/lib/components/tour/GalleryDemoBoardRow.astro @@ -0,0 +1,43 @@ +--- +// One board row of the Gallery demo, after the app's `GalleryBoardRowShell.tsx` and `GalleryBoardRow.tsx`: a cover, +// the name and the `media | uploads` count badge in one button, and the board menu's trigger shown on hover. The +// selected row takes the solid accent fill. +import type { ImageMetadata } from 'astro'; +import { Image } from 'astro:assets'; +import EllipsisVertical from '@lucide/astro/icons/ellipsis-vertical'; +import ImageIcon from '@lucide/astro/icons/image'; +import Inbox from '@lucide/astro/icons/inbox'; + +import type { GalleryDemoBoard } from './gallery-demo.mjs'; + +interface Props { + board: GalleryDemoBoard; + cover?: ImageMetadata; + counts: { media: number; uploads: number }; + selected?: boolean; +} + +const { board, cover, counts, selected = false } = Astro.props; +--- + +<div class="gd-row" data-board-row={board.id} data-selected={selected ? '' : undefined}> + <button type="button" class="gd-row-button" aria-current={selected ? 'true' : undefined}> + <span class="gd-cover"> + { + cover ? ( + <Image src={cover} alt="" width={40} height={40} loading="lazy" decoding="async" /> + ) : board.isInbox ? ( + <Inbox size={12} aria-hidden="true" /> + ) : ( + <ImageIcon size={12} aria-hidden="true" /> + ) + } + </span> + <span class="gd-row-label" data-board-label>{board.name}</span> + <span class="gd-badge gd-count" aria-hidden="true" data-board-count>{counts.media} | {counts.uploads}</span> + <span class="gd-sr-only" data-board-count-text>, {counts.media} media, {counts.uploads} uploads</span> + </button> + <span class="gd-icon-button gd-icon-button--sm gd-row-actions" aria-hidden="true"> + <EllipsisVertical size={16} /> + </span> +</div> diff --git a/docs/src/lib/components/tour/GalleryDemoTile.astro b/docs/src/lib/components/tour/GalleryDemoTile.astro new file mode 100644 index 00000000000..e848f19ce40 --- /dev/null +++ b/docs/src/lib/components/tour/GalleryDemoTile.astro @@ -0,0 +1,44 @@ +--- +// One thumbnail of the Gallery demo, after the app's `GalleryTileFrame.tsx` and `GalleryThumbnail.tsx`: the image is +// the select button, the size badge and star button show on hover or focus, and a starred item keeps its star shown. +import type { ImageMetadata } from 'astro'; +import { Image } from 'astro:assets'; +import Star from '@lucide/astro/icons/star'; + +import type { GalleryDemoItem } from './gallery-demo.mjs'; + +interface Props { + item: GalleryDemoItem; + image: ImageMetadata; + hidden?: boolean; + /** The grid's one Tab stop. */ + tabStop?: boolean; +} + +const { item, image, hidden = false, tabStop = false } = Astro.props; +const size = Math.min(image.width, 208); +--- + +<div role="listitem" class="gd-tile" data-item-id={item.id} data-starred={item.starred ? '' : undefined} hidden={hidden}> + <button + type="button" + class="gd-tile-select" + data-tile-select + aria-pressed="false" + aria-label={`Select ${item.name} for preview`} + aria-keyshortcuts="." + tabindex={tabStop ? 0 : -1} + > + <Image src={image} alt="" width={size} height={size} loading="lazy" decoding="async" draggable="false" /> + </button> + <span class="gd-badge gd-badge--solid gd-tile-dims gd-overlay" aria-hidden="true">{item.width}x{item.height}</span> + <button + type="button" + class="gd-tile-star gd-overlay" + data-tile-star + aria-label={`${item.starred ? 'Unstar' : 'Star'} ${item.name}`} + tabindex={tabStop ? 0 : -1} + > + <Star size={14} aria-hidden="true" /> + </button> +</div> diff --git a/docs/src/lib/components/tour/WorkflowDemo.astro b/docs/src/lib/components/tour/WorkflowDemo.astro new file mode 100644 index 00000000000..e005a70f538 --- /dev/null +++ b/docs/src/lib/components/tour/WorkflowDemo.astro @@ -0,0 +1,928 @@ +--- +// An interactive miniature of the app's workflow editor for the home page tour: three nodes and their edges, floating +// on the page's own dotted background with no canvas, toolbar or frame of their own. Nodes drag by their headers (or +// move with the arrow keys) and collapse, their field controls work, and edges can be rewired by dragging from an +// output handle. Built at page build time from `workflow-demo.mjs`, so without JavaScript it renders the same graph, +// static. +import ChevronDown from '@lucide/astro/icons/chevron-down'; +import ChevronRight from '@lucide/astro/icons/chevron-right'; +import FileText from '@lucide/astro/icons/file-text'; +import Info from '@lucide/astro/icons/info'; +import Pin from '@lucide/astro/icons/pin'; +import PinOff from '@lucide/astro/icons/pin-off'; +import RotateCcw from '@lucide/astro/icons/rotate-ccw'; +import { Image } from 'astro:assets'; + +import backgroundImage from '../../../assets/tour/workflow/compositor-background.webp'; +import { + WORLD, + createInitialGraph, + getConnectedInputs, + getEdgeId, + getEdgePath, + getFieldTypeColor, + getFieldTypeLabel, + getHandlePoint, + isAngularFieldType, + layoutNode, +} from './workflow-demo.mjs'; + +interface Props { + /** Prefixes the ids of the demo's form controls; give each demo on a page its own. */ + id?: string; + /** The accessible name of the demo's region. */ + label?: string; + class?: string; +} + +const { t } = Astro.locals; +const { id = 'workflow-demo', label = t('workflowDemo.label'), class: className } = Astro.props; +// Reader-facing copy, which the client finishes (`workflow-demo-client.ts`). Node and field names are mock app UI. +const i18n = { + collapseNode: t('workflowDemo.collapseNode'), + expandNode: t('workflowDemo.expandNode'), + connected: t('workflowDemo.connected'), + disconnected: t('workflowDemo.disconnected'), + field: t('workflowDemo.field'), +}; + +const { nodes, edges } = createInitialGraph(); +const layouts = new Map(nodes.map((node) => [node.id, layoutNode(node, getConnectedInputs(edges, node.id))])); +const nodeById = new Map(nodes.map((node) => [node.id, node])); + +const edgeViews = edges.map((edge) => { + const source = nodeById.get(edge.source)!; + const target = nodeById.get(edge.target)!; + const type = source.outputs.find((output) => output.name === edge.sourceHandle)!.type; + return { + ...edge, + id: getEdgeId(edge), + color: getFieldTypeColor(type), + title: getFieldTypeLabel(type), + d: getEdgePath( + getHandlePoint(source, layouts.get(source.id)!, 'output', edge.sourceHandle)!, + getHandlePoint(target, layouts.get(target.id)!, 'input', edge.targetHandle)!, + ), + }; +}); + +const fieldId = (nodeId: string, name: string) => `${id}-${nodeId}-${name}`; + +// Fields shown as pinned to the workflow's form, as in the reference graph. +const pinnedFields = new Set(['compositor.background', 'compositor.threshold']); +const boardOptions = ['Auto', 'None (Uncategorized)', 'Landscapes', 'Concept Art']; +--- + +<section + class:list={['wf', 'not-content', className]} + aria-label={label} + aria-describedby={`${id}-hint`} + data-workflow-demo + data-i18n={JSON.stringify(i18n)} + style={`--wf-world-width: ${WORLD.width}px; --wf-world-height: ${WORLD.height}px;`} +> + <div class="wf__stage" data-stage> + <svg + class="wf__edges" + width={WORLD.width} + height={WORLD.height} + viewBox={`0 0 ${WORLD.width} ${WORLD.height}`} + aria-hidden="true" + > + <g data-edges> + { + edgeViews.map((edge) => ( + <g + class="wf-edge" + data-edge={edge.id} + data-source={edge.source} + data-source-handle={edge.sourceHandle} + data-target={edge.target} + data-target-handle={edge.targetHandle} + style={`--wf-edge-color: ${edge.color}`} + > + <title>{edge.title} + + + + )) + } + + + + + { + nodes.map((node) => { + const layout = layouts.get(node.id)!; + const connected = getConnectedInputs(edges, node.id); + return ( +
+
+ + {node.title} +
+ +
+ {node.outputs.map((output) => ( +
+ {output.label} +
+ ))} + {node.inputs.map((input) => { + const controlId = fieldId(node.id, input.name); + const pinned = pinnedFields.has(`${node.id}.${input.name}`); + return ( +
+
+ {input.widget && input.widget !== 'image' ? ( + + ) : ( + + {input.label} + {input.required && *} + + )} + +
+ {input.widget === 'switch' && ( +
+ +
+ )} + {input.widget === 'number' && ( +
+ +
+ )} + {input.widget === 'select' && ( +
+ +
+ )} + {input.widget === 'image' && ( +
+
+ A mountain landscape + 704×640 +
+ +
+ )} +
+ ); + })} +
+ + {node.footer && ( + + )} + + {node.outputs.map((output) => ( +
+ ); + }) + } + + + + +

{t('workflowDemo.hint')}

+

+ + + + + diff --git a/docs/src/lib/components/tour/gallery-demo.mjs b/docs/src/lib/components/tour/gallery-demo.mjs new file mode 100644 index 00000000000..245312adddb --- /dev/null +++ b/docs/src/lib/components/tour/gallery-demo.mjs @@ -0,0 +1,195 @@ +// The Gallery tour demo's sample data and the pure rules it shows them by, after the app's gallery +// (`invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.ts`, `GalleryBoardsPanel.tsx` and +// `GalleryWidgetView.tsx`). `GalleryDemo.astro` renders the initial state from these and its script applies them on +// every change, so the page without JavaScript and the page with it agree. + +/** @typedef {'board' | 'uncategorized'} GalleryDemoBoardKind */ +/** + * @typedef {object} GalleryDemoBoard + * @property {string} id + * @property {string} name + * @property {GalleryDemoBoardKind} kind + * @property {string | null} projectId + * @property {boolean} [isInbox] + * @property {string | null} cover the image id of its cover, or none for an icon + */ +/** @typedef {'images' | 'assets'} GalleryDemoView */ +/** + * @typedef {object} GalleryDemoItem + * @property {string} id + * @property {string} boardId + * @property {GalleryDemoView} view `images` lists under Media, `assets` under Uploads, as in the app + * @property {string} image the image id + * @property {string} name + * @property {string} prompt what the item search matches besides the name + * @property {number} width + * @property {number} height + * @property {number} createdAt higher is newer + * @property {boolean} starred + */ + +export const GALLERY_DEMO_PROJECT = { id: 'coastal-series', name: 'Coastal Series' }; + +/** @type {readonly GalleryDemoBoard[]} */ +export const GALLERY_DEMO_BOARDS = [ + { id: 'inbox', name: 'Inbox', kind: 'board', projectId: GALLERY_DEMO_PROJECT.id, isInbox: true, cover: null }, + { id: 'concept-art', name: 'Concept Art', kind: 'board', projectId: GALLERY_DEMO_PROJECT.id, cover: 'car' }, + { id: 'portraits', name: 'Portraits', kind: 'board', projectId: GALLERY_DEMO_PROJECT.id, cover: 'portrait' }, + { id: 'landscapes', name: 'Landscapes', kind: 'board', projectId: GALLERY_DEMO_PROJECT.id, cover: 'mountains' }, + { id: 'uncategorized', name: 'Uncategorized', kind: 'uncategorized', projectId: null, cover: null }, + { id: 'architecture', name: 'Architecture', kind: 'board', projectId: null, cover: 'house' }, + { id: 'product-shots', name: 'Product Shots', kind: 'board', projectId: null, cover: 'espresso' }, +]; + +export const GALLERY_DEMO_INITIAL_BOARD_ID = 'concept-art'; + +/** @param {string} id @param {string} boardId @param {GalleryDemoView} view @param {string} image */ +const item = (id, boardId, view, image, name, prompt, createdAt, starred = false, width = 1024, height = 1024) => ({ + id, + boardId, + view, + image, + name, + prompt, + width, + height, + createdAt, + starred, +}); + +/** @type {readonly GalleryDemoItem[]} */ +export const GALLERY_DEMO_ITEMS = [ + item('i1', 'inbox', 'images', 'rider', 'Night courier', 'masked rider neon city night', 30), + item('i2', 'inbox', 'images', 'scientist', 'Lab sketch', 'cartoon scientist flasks experiment', 29), + + item('c1', 'concept-art', 'images', 'car', 'Copper coupe', 'orange concept car street', 28, false, 1216, 832), + item('c2', 'concept-art', 'images', 'mech', 'Brass sentinel', 'gold mech robot city', 27, true), + item('c3', 'concept-art', 'images', 'wizard', 'Torchbearer', 'goblin wizard holding a torch', 26, true, 832, 1216), + item('c4', 'concept-art', 'images', 'rider', 'Courier helmet study', 'masked rider neon', 25), + item('c5', 'concept-art', 'images', 'planet', 'Ember planet', 'impasto painting planet sky', 24), + item('c6', 'concept-art', 'assets', 'lineart', 'Jacket sketch', 'line art man jacket reference', 23, false, 640, 640), + + item('p1', 'portraits', 'images', 'portrait', 'Studio smile', 'portrait woman smiling studio', 22, true), + item('p2', 'portraits', 'images', 'lineart', 'Ink portrait', 'line art man jacket', 21, false, 640, 640), + item('p3', 'portraits', 'images', 'scientist', 'Professor', 'cartoon scientist flasks', 20), + item('p4', 'portraits', 'images', 'wizard', 'Goblin mage', 'goblin wizard torch', 19, false, 832, 1216), + + item('l1', 'landscapes', 'images', 'mountains', 'Alpine meadow', 'mountains meadow sunset rock', 18, true), + item('l2', 'landscapes', 'images', 'planet', 'Red giant', 'impasto painting planet', 17), + item('l3', 'landscapes', 'images', 'market', 'Harbor market', 'market stalls fruit crowd', 16), + + item('u1', 'uncategorized', 'images', 'market', 'Market morning', 'market stalls fruit', 15), + item('u2', 'uncategorized', 'assets', 'portrait', 'Headshot reference', 'portrait woman', 14), + item('u3', 'uncategorized', 'assets', 'lineart', 'Outline reference', 'line art man', 13, false, 640, 640), + + item('a1', 'architecture', 'images', 'house', 'Cedar house', 'modern wooden house autumn', 12, true, 1216, 832), + item('a2', 'architecture', 'images', 'mountains', 'Site survey', 'mountains meadow', 11), + + item('s1', 'product-shots', 'images', 'espresso', 'Espresso machine', 'coffee machine product shot', 10, true), + item('s2', 'product-shots', 'images', 'sushi', 'Sushi plate', 'sushi plate salmon roe', 9), + item('s3', 'product-shots', 'images', 'car', 'Coupe, three quarter', 'orange concept car', 8, false, 1216, 832), + item('s4', 'product-shots', 'assets', 'espresso', 'Machine reference', 'coffee machine photo', 7), +]; + +/** The name a board row shows. */ +export const getBoardLabel = (/** @type {GalleryDemoBoard} */ board) => board.name; + +/** + * The board list's two tiers, as the app groups them: the open project's boards with its inbox first, then the + * Library with Uncategorized first. A search keeps the boards whose name contains it, and offers to create a board + * when no board on screen has exactly that name. + * @param {readonly GalleryDemoBoard[]} boards + * @param {string} projectId + * @param {string} searchTerm + */ +export const getBoardGroups = (boards, projectId, searchTerm) => { + const term = searchTerm.trim().toLowerCase(); + const matches = (/** @type {GalleryDemoBoard} */ board) => !term || getBoardLabel(board).toLowerCase().includes(term); + const projectBoards = boards.filter((board) => board.kind === 'board' && board.projectId === projectId && matches(board)); + projectBoards.sort((left, right) => Number(Boolean(right.isInbox)) - Number(Boolean(left.isInbox))); + const libraryBoards = [ + ...boards.filter((board) => board.kind === 'uncategorized' && matches(board)), + ...boards.filter((board) => board.kind === 'board' && board.projectId === null && matches(board)), + ]; + const shown = [...projectBoards, ...libraryBoards]; + // Each tier counts its boards, the inbox among them; Uncategorized is where media on no board shows, not a board. + const countBoards = (/** @type {GalleryDemoBoard[]} */ list) => list.filter((board) => board.kind === 'board').length; + + return { + projectBoards, + libraryBoards, + projectCount: countBoards(projectBoards), + libraryCount: countBoards(libraryBoards), + hasAnyMatch: shown.length > 0, + canCreateFromSearch: term.length > 0 && !shown.some((board) => getBoardLabel(board).toLowerCase() === term), + }; +}; + +/** + * A board's media and uploads, as its count badge (`media | uploads`) and the view tabs show them. + * @param {readonly GalleryDemoItem[]} items + * @param {string} boardId + */ +export const getBoardCounts = (items, boardId) => { + let media = 0; + let uploads = 0; + for (const entry of items) { + if (entry.boardId !== boardId) continue; + if (entry.view === 'images') media += 1; + else uploads += 1; + } + return { media, uploads }; +}; + +/** + * What the grid shows: the listing for the board, view and search in the chosen order, and the pinned Starred strip + * above it. With the starred filter on, the listing holds only starred items and there is no strip, which would + * repeat it. + * @param {readonly GalleryDemoItem[]} items + * @param {{ boardId: string, view: GalleryDemoView, starredOnly: boolean, searchTerm: string, order: 'DESC' | 'ASC' }} filter + */ +export const getGridSections = (items, { boardId, view, starredOnly, searchTerm, order }) => { + const term = searchTerm.trim().toLowerCase(); + const matches = (/** @type {GalleryDemoItem} */ entry) => + !term || `${entry.name} ${entry.prompt}`.toLowerCase().includes(term); + const listed = items + .filter((entry) => entry.boardId === boardId && entry.view === view && matches(entry)) + .sort((left, right) => (order === 'DESC' ? right.createdAt - left.createdAt : left.createdAt - right.createdAt)); + const starred = listed.filter((entry) => entry.starred); + + return starredOnly ? { starred: [], listing: starred } : { starred, listing: listed }; +}; + +/** + * The grid's empty state: `upload` for a board with nothing in the view, or the message the app shows when a filter + * or search hides everything. Null when the listing has items. + * @param {number} listingCount + * @param {{ starredOnly: boolean, searchTerm: string }} filter + */ +export const getGridEmptyState = (listingCount, { starredOnly, searchTerm }) => { + if (listingCount > 0) return null; + if (starredOnly) return { kind: 'message', text: 'No starred items match this board, tab, or search.' }; + if (searchTerm.trim()) return { kind: 'message', text: 'No items match this board, tab, or search.' }; + return { kind: 'upload', text: 'Drop media here or click to upload' }; +}; + +/** + * The boards with a new, empty one named `name` in the open project or the Library, and the new board's id. + * @param {readonly GalleryDemoBoard[]} boards + * @param {string} name + * @param {string | null} projectId + */ +export const createBoard = (boards, name, projectId) => { + const id = `created-${boards.length}`; + /** @type {GalleryDemoBoard} */ + const board = { id, name: name.trim(), kind: 'board', projectId, cover: null }; + return { boards: [...boards, board], board }; +}; + +/** + * The items with one item's star flipped. + * @param {readonly GalleryDemoItem[]} items + * @param {string} itemId + */ +export const toggleStarred = (items, itemId) => + items.map((entry) => (entry.id === itemId ? { ...entry, starred: !entry.starred } : entry)); diff --git a/docs/src/lib/components/tour/gallery-demo.test.mjs b/docs/src/lib/components/tour/gallery-demo.test.mjs new file mode 100644 index 00000000000..ef944ce4e45 --- /dev/null +++ b/docs/src/lib/components/tour/gallery-demo.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createBoard, + GALLERY_DEMO_BOARDS, + GALLERY_DEMO_ITEMS, + GALLERY_DEMO_PROJECT, + getBoardCounts, + getBoardGroups, + getGridEmptyState, + getGridSections, + toggleStarred, +} from './gallery-demo.mjs'; + +const PROJECT = GALLERY_DEMO_PROJECT.id; +const ids = (/** @type {{ id: string }[]} */ list) => list.map((entry) => entry.id); +const filter = (overrides = {}) => ({ + boardId: 'concept-art', + view: /** @type {const} */ ('images'), + starredOnly: false, + searchTerm: '', + order: /** @type {const} */ ('DESC'), + ...overrides, +}); + +describe('getBoardGroups', () => { + it('puts the project inbox first and Uncategorized first in the Library, whatever the list order', () => { + const shuffled = [...GALLERY_DEMO_BOARDS].reverse(); + const groups = getBoardGroups(shuffled, PROJECT, ''); + assert.deepEqual(ids(groups.projectBoards), ['inbox', 'landscapes', 'portraits', 'concept-art']); + assert.deepEqual(ids(groups.libraryBoards), ['uncategorized', 'product-shots', 'architecture']); + }); + + it('counts boards per tier, the inbox included and Uncategorized not', () => { + const groups = getBoardGroups(GALLERY_DEMO_BOARDS, PROJECT, ''); + assert.equal(groups.projectCount, 4); + assert.equal(groups.libraryCount, 2); + }); + + it('filters by name, ignoring case and surrounding space, and recounts', () => { + const groups = getBoardGroups(GALLERY_DEMO_BOARDS, PROJECT, ' PORT '); + assert.deepEqual(ids(groups.projectBoards), ['portraits']); + assert.deepEqual(ids(groups.libraryBoards), []); + assert.equal(groups.projectCount, 1); + assert.equal(groups.libraryCount, 0); + assert.equal(groups.hasAnyMatch, true); + }); + + it('offers to create a board unless a shown board already has that exact name', () => { + assert.equal(getBoardGroups(GALLERY_DEMO_BOARDS, PROJECT, '').canCreateFromSearch, false); + assert.equal(getBoardGroups(GALLERY_DEMO_BOARDS, PROJECT, 'portraits').canCreateFromSearch, false); + assert.equal(getBoardGroups(GALLERY_DEMO_BOARDS, PROJECT, 'portrait').canCreateFromSearch, true); + const none = getBoardGroups(GALLERY_DEMO_BOARDS, PROJECT, 'Storyboards'); + assert.equal(none.hasAnyMatch, false); + assert.equal(none.canCreateFromSearch, true); + }); +}); + +describe('createBoard', () => { + it('adds an empty board to the chosen tier that the groups then list', () => { + const { boards, board } = createBoard(GALLERY_DEMO_BOARDS, ' Storyboards ', null); + assert.equal(board.name, 'Storyboards'); + assert.equal(boards.length, GALLERY_DEMO_BOARDS.length + 1); + assert.deepEqual(getBoardCounts(GALLERY_DEMO_ITEMS, board.id), { media: 0, uploads: 0 }); + const groups = getBoardGroups(boards, PROJECT, 'story'); + assert.deepEqual(ids(groups.libraryBoards), [board.id]); + assert.equal(groups.canCreateFromSearch, true); + + const second = createBoard(boards, 'Moodboards', PROJECT); + assert.notEqual(second.board.id, board.id); + assert.ok(ids(getBoardGroups(second.boards, PROJECT, '').projectBoards).includes(second.board.id)); + }); +}); + +describe('getBoardCounts', () => { + it('splits a board into media and uploads', () => { + assert.deepEqual(getBoardCounts(GALLERY_DEMO_ITEMS, 'concept-art'), { media: 5, uploads: 1 }); + assert.deepEqual(getBoardCounts(GALLERY_DEMO_ITEMS, 'uncategorized'), { media: 1, uploads: 2 }); + assert.deepEqual(getBoardCounts(GALLERY_DEMO_ITEMS, 'nowhere'), { media: 0, uploads: 0 }); + }); +}); + +describe('getGridSections', () => { + it('lists the board and view newest first, and pins its starred items above', () => { + const { starred, listing } = getGridSections(GALLERY_DEMO_ITEMS, filter()); + assert.deepEqual(ids(listing), ['c1', 'c2', 'c3', 'c4', 'c5']); + assert.deepEqual(ids(starred), ['c2', 'c3']); + }); + + it('reverses for oldest first and switches to uploads', () => { + assert.deepEqual(ids(getGridSections(GALLERY_DEMO_ITEMS, filter({ order: 'ASC' })).listing), [ + 'c5', + 'c4', + 'c3', + 'c2', + 'c1', + ]); + assert.deepEqual(ids(getGridSections(GALLERY_DEMO_ITEMS, filter({ view: 'assets' })).listing), ['c6']); + }); + + it('shows only starred items, without a strip, under the starred filter', () => { + assert.deepEqual(getGridSections(GALLERY_DEMO_ITEMS, filter({ starredOnly: true })), { + starred: [], + listing: getGridSections(GALLERY_DEMO_ITEMS, filter()).starred, + }); + }); + + it('searches names and prompts, applying to the strip too', () => { + const { starred, listing } = getGridSections(GALLERY_DEMO_ITEMS, filter({ searchTerm: 'Torch' })); + assert.deepEqual(ids(listing), ['c3']); + assert.deepEqual(ids(starred), ['c3']); + assert.deepEqual(ids(getGridSections(GALLERY_DEMO_ITEMS, filter({ searchTerm: 'robot' })).listing), ['c2']); + }); + + it('follows a toggled star', () => { + const items = toggleStarred(toggleStarred(GALLERY_DEMO_ITEMS, 'c2'), 'c1'); + assert.deepEqual(ids(getGridSections(items, filter()).starred), ['c1', 'c3']); + assert.equal(GALLERY_DEMO_ITEMS.find((entry) => entry.id === 'c1')?.starred, false, 'the input is not mutated'); + }); +}); + +describe('getGridEmptyState', () => { + it('invites an upload only when nothing filters the board', () => { + assert.equal(getGridEmptyState(3, { starredOnly: true, searchTerm: 'x' }), null); + assert.equal(getGridEmptyState(0, { starredOnly: false, searchTerm: '' })?.kind, 'upload'); + assert.match(getGridEmptyState(0, { starredOnly: true, searchTerm: '' })?.text ?? '', /No starred items/); + assert.match(getGridEmptyState(0, { starredOnly: false, searchTerm: ' car ' })?.text ?? '', /No items match/); + }); +}); diff --git a/docs/src/lib/components/tour/workflow-demo-client.ts b/docs/src/lib/components/tour/workflow-demo-client.ts new file mode 100644 index 00000000000..d0c17c60771 --- /dev/null +++ b/docs/src/lib/components/tour/workflow-demo-client.ts @@ -0,0 +1,401 @@ +// The browser half of `WorkflowDemo.astro`: node dragging and keyboard moves, collapsing, and rewiring edges. The page +// already holds the initial render; this keeps it in step with the graph state as it changes. +import { interpolate } from '../../i18n.mjs'; +import { + EDGE_INSET, + WORLD, + WORLD_BOUNDS, + canConnect, + clampPosition, + connectEdge, + createInitialGraph, + findNearest, + getConnectedInputs, + getEdgeId, + getEdgePath, + getFieldTypeColor, + getFieldTypeLabel, + getHandlePoint, + layoutNode, + toGraphBounds, +} from './workflow-demo.mjs'; + +type Graph = ReturnType; +type GraphNode = Graph['nodes'][number]; +type GraphEdge = Graph['edges'][number]; +type NodeLayout = ReturnType; +type Point = { x: number; y: number }; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const KEY_STEP = 10; +const KEY_STEP_LARGE = 50; +/** Movement in screen pixels before a press on a header becomes a drag. */ +const DRAG_THRESHOLD = 3; +/** How near, in graph pixels, a dropped connection snaps to a handle (xyflow's default connection radius). */ +const CONNECTION_RADIUS = 20; +const ARROW_DELTAS: Record = { + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, +}; + +export const mountWorkflowDemo = (root: HTMLElement) => { + const stage = root.querySelector('[data-stage]'); + const edgeLayer = root.querySelector('[data-edges]'); + const connectionPath = root.querySelector('[data-connection]'); + const announcer = root.querySelector('[data-announcer]'); + if (!stage || !edgeLayer || !connectionPath || !announcer) return; + const i18n: Record<'collapseNode' | 'expandNode' | 'connected' | 'disconnected' | 'field', string> = JSON.parse( + root.dataset.i18n!, + ); + + const { nodes, edges: initialEdges } = createInitialGraph(); + let edges: GraphEdge[] = initialEdges; + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const nodeElements = new Map(); + for (const node of nodes) { + const element = root.querySelector(`[data-node="${node.id}"]`); + if (!element) return; + nodeElements.set(node.id, element); + } + const layouts = new Map(); + let selectedId: string | null = null; + let topZ = nodes.length; + + const handleElement = (nodeId: string, kind: 'input' | 'output', name: string) => + nodeElements.get(nodeId)!.querySelector(`[data-handle][data-kind="${kind}"][data-name="${name}"]`); + + const edgeElements = () => [...edgeLayer.querySelectorAll('.wf-edge')]; + + // Nodes may roam the whole element marked `data-workflow-bounds` (the home page's Workflows row, copy included), + // or just the graph's own box without one. + const boundsElement = root.closest('[data-workflow-bounds]'); + + /** + * Where the scaled graph sits on screen and the area nodes may move in, in graph coordinates. Measured once per press + * and reused for the whole gesture, so moves only write styles; a resize or scroll invalidates it. + */ + let geometry: { stage: DOMRect; scale: number; bounds: typeof WORLD_BOUNDS } | null = null; + const getGeometry = () => { + const stageRect = geometry ? null : stage.getBoundingClientRect(); + // Not laid out yet (hidden): keep to the graph's own box until a resize measures it again. + if (stageRect && stageRect.width === 0) return { stage: stageRect, scale: 1, bounds: WORLD_BOUNDS }; + if (stageRect) { + geometry = { + stage: stageRect, + scale: stageRect.width / WORLD.width, + bounds: boundsElement ? toGraphBounds(boundsElement.getBoundingClientRect(), stageRect) : WORLD_BOUNDS, + }; + } + return geometry; + }; + + /** Graph coordinates of a pointer event: the stage is scaled to fit its container. */ + const toGraphPoint = (event: PointerEvent): Point => { + const { stage: rect, scale } = getGeometry(); + return { x: (event.clientX - rect.left) / scale, y: (event.clientY - rect.top) / scale }; + }; + + const placeNode = (node: GraphNode, position: Point) => { + const layout = layouts.get(node.id)!; + const size = { width: node.width, height: layout.height }; + const clamped = clampPosition(position, size, getGeometry().bounds, EDGE_INSET); + node.x = clamped.x; + node.y = clamped.y; + nodeElements.get(node.id)!.style.translate = `${node.x}px ${node.y}px`; + }; + + /** Recomputes a node's height and handle positions after its fields or open state change. */ + const relayoutNode = (node: GraphNode) => { + const connected = getConnectedInputs(edges, node.id); + const layout = layoutNode(node, connected); + layouts.set(node.id, layout); + const element = nodeElements.get(node.id)!; + for (const row of element.querySelectorAll('[data-input]')) { + row.dataset.connected = String(connected.has(row.dataset.input!)); + } + for (const [kind, positions] of [ + ['input', layout.inputs], + ['output', layout.outputs], + ] as const) { + for (const [name, y] of Object.entries(positions)) { + const handle = handleElement(node.id, kind, name); + if (handle) handle.style.top = `${y}px`; + } + } + // A node that grew past the bottom edge moves up to stay inside its bounds. + placeNode(node, node); + }; + + const edgePath = (edge: GraphEdge) => { + const source = nodeById.get(edge.source)!; + const target = nodeById.get(edge.target)!; + const from = getHandlePoint(source, layouts.get(source.id)!, 'output', edge.sourceHandle); + const to = getHandlePoint(target, layouts.get(target.id)!, 'input', edge.targetHandle); + return from && to ? getEdgePath(from, to) : ''; + }; + + const updateEdgePaths = (nodeId?: string) => { + for (const element of edgeElements()) { + if (nodeId && element.dataset.source !== nodeId && element.dataset.target !== nodeId) continue; + const edge = edges.find((candidate) => getEdgeId(candidate) === element.dataset.edge); + if (!edge) continue; + const d = edgePath(edge); + for (const path of element.querySelectorAll('path')) path.setAttribute('d', d); + } + }; + + const createEdgeElement = (edge: GraphEdge) => { + const type = nodeById.get(edge.source)!.outputs.find((output) => output.name === edge.sourceHandle)!.type; + const group = document.createElementNS(SVG_NS, 'g'); + group.classList.add('wf-edge'); + group.dataset.edge = getEdgeId(edge); + group.dataset.source = edge.source; + group.dataset.sourceHandle = edge.sourceHandle; + group.dataset.target = edge.target; + group.dataset.targetHandle = edge.targetHandle; + group.style.setProperty('--wf-edge-color', getFieldTypeColor(type)); + const title = document.createElementNS(SVG_NS, 'title'); + title.textContent = getFieldTypeLabel(type); + const path = document.createElementNS(SVG_NS, 'path'); + path.classList.add('wf-edge__path'); + const hit = document.createElementNS(SVG_NS, 'path'); + hit.classList.add('wf-edge__hit'); + group.append(title, path, hit); + return group; + }; + + /** Brings the edge elements in line with `edges`, keeping the elements of edges that did not change. */ + const syncEdges = () => { + const ids = new Set(edges.map(getEdgeId)); + for (const element of edgeElements()) if (!ids.has(element.dataset.edge!)) element.remove(); + const existing = new Set(edgeElements().map((element) => element.dataset.edge)); + for (const edge of edges) if (!existing.has(getEdgeId(edge))) edgeLayer.append(createEdgeElement(edge)); + markSelectedEdges(); + updateEdgePaths(); + }; + + const markSelectedEdges = () => { + for (const element of edgeElements()) { + const selected = selectedId !== null && (element.dataset.source === selectedId || element.dataset.target === selectedId); + element.toggleAttribute('data-selected', selected); + } + }; + + const select = (nodeId: string | null) => { + if (selectedId === nodeId) return; + if (selectedId) nodeElements.get(selectedId)!.removeAttribute('data-selected'); + selectedId = nodeId; + if (nodeId) { + const element = nodeElements.get(nodeId)!; + element.setAttribute('data-selected', ''); + // The selected node rises above the others, as in the editor. + element.style.zIndex = String(++topZ); + } + markSelectedEdges(); + }; + + const describeHandle = (nodeId: string, kind: 'input' | 'output', name: string) => { + const node = nodeById.get(nodeId)!; + const field = (kind === 'input' ? node.inputs : node.outputs).find((candidate) => candidate.name === name); + return interpolate(i18n.field, { field: field?.label ?? name, node: node.title }); + }; + + const announce = (message: string) => { + announcer.textContent = message; + }; + + for (const node of nodes) relayoutNode(node); + syncEdges(); + + for (const node of nodes) { + const element = nodeElements.get(node.id)!; + const header = element.querySelector('[data-drag-handle]')!; + const collapse = element.querySelector('[data-collapse]')!; + + element.addEventListener('focusin', () => select(node.id)); + element.addEventListener('focusout', (event) => { + if (!element.contains(event.relatedTarget as Node | null)) select(null); + }); + + element.addEventListener('keydown', (event) => { + const delta = ARROW_DELTAS[event.key]; + if (!delta || event.target !== element || event.altKey || event.ctrlKey || event.metaKey) return; + event.preventDefault(); + const step = event.shiftKey ? KEY_STEP_LARGE : KEY_STEP; + placeNode(node, { x: node.x + delta.x * step, y: node.y + delta.y * step }); + updateEdgePaths(node.id); + }); + + collapse.addEventListener('click', () => { + node.open = !node.open; + element.dataset.open = String(node.open); + collapse.setAttribute('aria-expanded', String(node.open)); + collapse.setAttribute('aria-label', node.open ? i18n.collapseNode : i18n.expandNode); + relayoutNode(node); + updateEdgePaths(node.id); + }); + + let drag: { origin: Point; screen: Point; moved: boolean } | null = null; + + header.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || (event.target as Element).closest('button')) return; + event.preventDefault(); + element.focus({ preventScroll: true }); + header.setPointerCapture(event.pointerId); + geometry = null; + getGeometry(); + drag = { origin: { x: node.x, y: node.y }, screen: { x: event.clientX, y: event.clientY }, moved: false }; + }); + + header.addEventListener('pointermove', (event) => { + if (!drag || !header.hasPointerCapture(event.pointerId)) return; + const dx = event.clientX - drag.screen.x; + const dy = event.clientY - drag.screen.y; + if (!drag.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + drag.moved = true; + element.setAttribute('data-dragging', ''); + } + const { scale } = getGeometry(); + placeNode(node, { x: drag.origin.x + dx / scale, y: drag.origin.y + dy / scale }); + updateEdgePaths(node.id); + }); + + header.addEventListener('lostpointercapture', () => { + drag = null; + element.removeAttribute('data-dragging'); + }); + } + + // Hovering a handle highlights the edges attached to it. + for (const handle of root.querySelectorAll('[data-handle]')) { + const nodeId = handle.closest('[data-node]')!.dataset.node!; + const attached = () => + edgeElements().filter((element) => + handle.dataset.kind === 'output' + ? element.dataset.source === nodeId && element.dataset.sourceHandle === handle.dataset.name + : element.dataset.target === nodeId && element.dataset.targetHandle === handle.dataset.name, + ); + handle.addEventListener('pointerenter', () => attached().forEach((element) => element.setAttribute('data-hover', ''))); + handle.addEventListener('pointerleave', () => attached().forEach((element) => element.removeAttribute('data-hover'))); + } + + // Connecting: drag from an output to a matching input. Dragging from a connected input picks its edge up again, + // to move it to another input or drop it on the canvas to remove it. + let connection: { + source: { node: string; handle: string }; + from: Point; + lifted: GraphEdge | null; + target: { node: string; handle: string; x: number; y: number } | null; + candidates: { node: string; handle: string; x: number; y: number; element: HTMLElement }[]; + } | null = null; + + const endConnection = (commit: boolean) => { + if (!connection) return; + const { lifted, source, target, candidates } = connection; + connection = null; + root.removeAttribute('data-connecting'); + connectionPath.setAttribute('d', ''); + for (const candidate of candidates) candidate.element.removeAttribute('data-valid'); + root.querySelector('[data-snapped]')?.removeAttribute('data-snapped'); + if (lifted) edgeLayer.querySelector(`[data-edge="${getEdgeId(lifted)}"]`)?.removeAttribute('data-pending'); + if (!commit) return; + + const before = edges; + if (lifted) edges = edges.filter((edge) => edge !== lifted); + if (target) { + edges = connectEdge(edges, { + source: source.node, + sourceHandle: source.handle, + target: target.node, + targetHandle: target.handle, + }); + announce( + interpolate(i18n.connected, { + source: describeHandle(source.node, 'output', source.handle), + target: describeHandle(target.node, 'input', target.handle), + }), + ); + } else if (lifted) { + announce(interpolate(i18n.disconnected, { target: describeHandle(lifted.target, 'input', lifted.targetHandle) })); + } + if (edges === before) return; + const touched = new Set([...before, ...edges].map((edge) => edge.target)); + for (const nodeId of touched) relayoutNode(nodeById.get(nodeId)!); + syncEdges(); + }; + + const drawConnection = (pointer: Point) => { + if (!connection) return; + const snapped = findNearest(connection.candidates, pointer, CONNECTION_RADIUS); + root.querySelector('[data-snapped]')?.removeAttribute('data-snapped'); + snapped?.element.setAttribute('data-snapped', ''); + connection.target = snapped; + connectionPath.setAttribute('d', getEdgePath(connection.from, snapped ?? pointer)); + }; + + for (const handle of root.querySelectorAll('[data-handle]')) { + const nodeId = handle.closest('[data-node]')!.dataset.node!; + const name = handle.dataset.name!; + const kind = handle.dataset.kind as 'input' | 'output'; + + handle.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || !nodeById.get(nodeId)!.open) return; + const lifted = + kind === 'input' ? (edges.find((edge) => edge.target === nodeId && edge.targetHandle === name) ?? null) : null; + if (kind === 'input' && !lifted) return; + event.preventDefault(); + event.stopPropagation(); + geometry = null; + const source = lifted ? { node: lifted.source, handle: lifted.sourceHandle } : { node: nodeId, handle: name }; + const sourceNode = nodeById.get(source.node)!; + const candidates = nodes + .filter((node) => node.open) + .flatMap((node) => + node.inputs + .filter((input) => canConnect(nodes, source, { node: node.id, handle: input.name })) + .map((input) => { + const point = getHandlePoint(node, layouts.get(node.id)!, 'input', input.name)!; + const element = handleElement(node.id, 'input', input.name)!; + element.setAttribute('data-valid', ''); + return { node: node.id, handle: input.name, ...point, element }; + }), + ); + connection = { + source, + from: getHandlePoint(sourceNode, layouts.get(sourceNode.id)!, 'output', source.handle)!, + lifted, + target: null, + candidates, + }; + if (lifted) edgeLayer.querySelector(`[data-edge="${getEdgeId(lifted)}"]`)?.setAttribute('data-pending', ''); + root.setAttribute('data-connecting', ''); + handle.setPointerCapture(event.pointerId); + drawConnection(toGraphPoint(event)); + }); + + handle.addEventListener('pointermove', (event) => { + if (connection && handle.hasPointerCapture(event.pointerId)) drawConnection(toGraphPoint(event)); + }); + handle.addEventListener('pointerup', () => endConnection(true)); + handle.addEventListener('pointercancel', () => endConnection(false)); + handle.addEventListener('lostpointercapture', () => endConnection(false)); + } + + // A resize changes the graph's scale and the area around it: measure again and pull nodes back inside. + const invalidate = () => { + geometry = null; + }; + addEventListener('scroll', invalidate, { capture: true, passive: true }); + const resizeObserver = new ResizeObserver(() => { + invalidate(); + for (const node of nodes) placeNode(node, node); + updateEdgePaths(); + }); + resizeObserver.observe(root); + if (boundsElement) resizeObserver.observe(boundsElement); + + root.setAttribute('data-ready', ''); +}; diff --git a/docs/src/lib/components/tour/workflow-demo.mjs b/docs/src/lib/components/tour/workflow-demo.mjs new file mode 100644 index 00000000000..893a3c241f1 --- /dev/null +++ b/docs/src/lib/components/tour/workflow-demo.mjs @@ -0,0 +1,273 @@ +// @ts-check +// The home page's interactive workflow editor demo: its graph, and the geometry both the build-time render and the +// browser script draw it with. Row heights are fixed (the component's CSS uses the same numbers), so handle positions +// and edge paths are computed here instead of measured, and the page renders identically without JavaScript. +// Sizes, colors and the edge curve follow the app's node editor: +// `invokeai/frontend/webv2/src/features/workflow/ui/{nodeChrome.tsx,editor/InvocationFlowNode.tsx,editor/WorkflowEdge.tsx}`. + +/** + * @typedef {'switch' | 'number' | 'select' | 'image'} WidgetKind + * `connectionOnly` inputs take no value of their own (the app's `input: 'connection'`), so they cannot be pinned to the + * workflow's form; `handle: false` inputs take no connection. + * @typedef {{ name: string, label: string, type: string, required?: boolean, connectionOnly?: boolean, handle?: boolean, + * widget?: WidgetKind }} InputField + * @typedef {{ name: string, label: string, type: string }} OutputField + * @typedef {{ id: string, title: string, x: number, y: number, width: number, open: boolean, footer: boolean, + * outputs: OutputField[], inputs: InputField[] }} GraphNode + * @typedef {{ source: string, sourceHandle: string, target: string, targetHandle: string }} GraphEdge + * @typedef {{ x: number, y: number }} Point + * @typedef {{ height: number, inputs: Record, outputs: Record }} NodeLayout + * @typedef {{ left: number, top: number, right: number, bottom: number }} Bounds + */ + +/** The graph's coordinate space. The component scales it down to fit narrower containers. */ +export const WORLD = { width: 680, height: 440 }; + +/** The graph's own box, for clamping when no larger area is given. */ +export const WORLD_BOUNDS = { left: 0, top: 0, right: WORLD.width, bottom: WORLD.height }; + +/** How far nodes keep inside their bounds: the handles on a node's sides reach 6px past it. */ +export const EDGE_INSET = 6; + +/** Pixel sizes of the node chrome, shared with `WorkflowDemo.astro`'s CSS. */ +export const SIZES = { + border: 1, + // A 24px icon button (`size="sm"`) with 6px of padding above and below. + header: 36, + bodyPad: 4, + // A 20px label line with 2px of padding above and below. + row: 24, + widgetGap: 2, + footer: 32, + // Collapsed nodes keep their handles mounted 14px above the header's bottom edge, as `HiddenHandles` does. + collapsedHandleOffset: 14, +}; + +/** Control heights below the label row: md inputs and selects are 28px, the image well is 90px plus a 24px button row. */ +export const WIDGET_HEIGHTS = { switch: 18, number: 28, select: 28, image: 118 }; + +/** The app's handle and edge colors (`features/workflow/core/fields.ts`), for the field types this graph uses. */ +export const FIELD_TYPE_COLORS = { + BooleanField: '#4ade80', + ImageField: '#c4b5fd', + IntegerField: '#f87171', + LatentsField: '#f9a8d4', + VAEField: '#2563eb', +}; + +/** Model field types get square handles. */ +const ANGULAR_FIELD_TYPES = new Set(['VAEField']); + +/** @param {string} type */ +export const getFieldTypeColor = (type) => FIELD_TYPE_COLORS[/** @type {keyof typeof FIELD_TYPE_COLORS} */ (type)] ?? '#9ca3af'; + +/** @param {string} type */ +export const isAngularFieldType = (type) => ANGULAR_FIELD_TYPES.has(type); + +/** `ImageField` reads "Image", as the app's handle tooltips and edge titles word it. */ +export const getFieldTypeLabel = (/** @type {string} */ type) => (type === 'VAEField' ? 'VAE' : type.replace(/Field$/, '')); + +/** The graph the demo starts with: a decoded image composited over a background, with its threshold from a primitive. */ +export const createInitialGraph = () => { + /** @type {GraphNode[]} */ + const nodes = [ + { + id: 'l2i', + title: 'Latents to Image - SD1.5, SDXL', + x: 10, + y: 10, + width: 256, + open: true, + footer: true, + outputs: [ + { name: 'image', label: 'Image', type: 'ImageField' }, + { name: 'width', label: 'Width', type: 'IntegerField' }, + { name: 'height', label: 'Height', type: 'IntegerField' }, + ], + inputs: [ + { name: 'latents', label: 'Latents', type: 'LatentsField', required: true, connectionOnly: true }, + { name: 'vae', label: 'Vae', type: 'VAEField', required: true, connectionOnly: true }, + { name: 'tiled', label: 'Tiled', type: 'BooleanField', widget: 'switch' }, + ], + }, + { + id: 'integer', + title: 'Integer Primitive', + x: 40, + y: 294, + width: 180, + open: true, + footer: false, + outputs: [{ name: 'value', label: 'Value', type: 'IntegerField' }], + inputs: [{ name: 'value', label: 'Value', type: 'IntegerField', widget: 'number' }], + }, + { + id: 'compositor', + title: 'Image Compositor', + x: 422, + y: 8, + width: 248, + open: true, + footer: true, + outputs: [ + { name: 'image', label: 'Image', type: 'ImageField' }, + { name: 'width', label: 'Width', type: 'IntegerField' }, + { name: 'height', label: 'Height', type: 'IntegerField' }, + ], + inputs: [ + { name: 'board', label: 'Board', type: 'BoardField', handle: false, widget: 'select' }, + { name: 'subject', label: 'Image Subject', type: 'ImageField', required: true }, + { name: 'background', label: 'Image Background', type: 'ImageField', required: true, widget: 'image' }, + { name: 'threshold', label: 'Threshold', type: 'IntegerField', widget: 'number' }, + ], + }, + ]; + /** @type {GraphEdge[]} */ + const edges = [ + { source: 'l2i', sourceHandle: 'image', target: 'compositor', targetHandle: 'subject' }, + { source: 'integer', sourceHandle: 'value', target: 'compositor', targetHandle: 'threshold' }, + ]; + return { nodes, edges }; +}; + +/** @param {GraphEdge} edge */ +export const getEdgeId = (edge) => `${edge.source}.${edge.sourceHandle}-${edge.target}.${edge.targetHandle}`; + +/** The input names of a node that an edge feeds. @param {GraphEdge[]} edges @param {string} nodeId */ +export const getConnectedInputs = (edges, nodeId) => + new Set(edges.filter((edge) => edge.target === nodeId).map((edge) => edge.targetHandle)); + +/** A connected input shows no control: its value comes from the edge. @param {InputField} input @param {boolean} connected */ +export const getInputRowHeight = (input, connected) => + SIZES.row + (input.widget && !connected ? SIZES.widgetGap + WIDGET_HEIGHTS[input.widget] : 0); + +/** + * The node's height and its handles' vertical centers, measured from the node's top edge. Outputs list first, then + * inputs, as in the app. A collapsed node is its header alone, with every handle stacked behind it. + * @param {GraphNode} node + * @param {Set} connectedInputs + * @returns {NodeLayout} + */ +export const layoutNode = (node, connectedInputs) => { + /** @type {NodeLayout} */ + const layout = { height: 0, inputs: {}, outputs: {} }; + if (!node.open) { + const y = SIZES.border + SIZES.header - SIZES.collapsedHandleOffset; + for (const output of node.outputs) layout.outputs[output.name] = y; + for (const input of node.inputs) if (input.handle !== false) layout.inputs[input.name] = y; + layout.height = SIZES.border * 2 + SIZES.header; + return layout; + } + + let top = SIZES.border + SIZES.header + SIZES.bodyPad; + const center = SIZES.row / 2; + for (const output of node.outputs) { + layout.outputs[output.name] = top + center; + top += SIZES.row; + } + for (const input of node.inputs) { + if (input.handle !== false) layout.inputs[input.name] = top + center; + top += getInputRowHeight(input, connectedInputs.has(input.name)); + } + layout.height = top + SIZES.bodyPad + (node.footer ? SIZES.footer : 0) + SIZES.border; + return layout; +}; + +/** + * A handle's center in graph coordinates: inputs sit on the node's left edge, outputs on its right. + * @param {GraphNode} node @param {NodeLayout} layout @param {'input' | 'output'} kind @param {string} name + * @returns {Point | null} + */ +export const getHandlePoint = (node, layout, kind, name) => { + const y = (kind === 'input' ? layout.inputs : layout.outputs)[name]; + if (y === undefined) return null; + return { x: kind === 'input' ? node.x : node.x + node.width, y: node.y + y }; +}; + +/** xyflow's control-point offset for a bezier edge (`@xyflow/system`, curvature 0.25). @param {number} distance */ +const controlOffset = (distance) => (distance >= 0 ? 0.5 * distance : 0.25 * 25 * Math.sqrt(-distance)); + +const round = (/** @type {number} */ value) => Math.round(value * 100) / 100; + +/** + * The SVG path of a bezier edge leaving a right-hand source handle and entering a left-hand target handle, the + * curve xyflow's `getBezierPath` draws for the app's default edges. A target behind its source loops out and back. + * @param {Point} source @param {Point} target + */ +export const getEdgePath = (source, target) => { + const sourceControlX = source.x + controlOffset(target.x - source.x); + const targetControlX = target.x - controlOffset(target.x - source.x); + return ( + `M${round(source.x)},${round(source.y)} ` + + `C${round(sourceControlX)},${round(source.y)} ${round(targetControlX)},${round(target.y)} ${round(target.x)},${round(target.y)}` + ); +}; + +/** + * Keeps a node of the given size inside the bounds, `inset` pixels in from each edge. A node taller or wider than that + * space pins to the top or left, so its header stays reachable. + * @param {Point} position @param {{ width: number, height: number }} size @param {Bounds} bounds @param {number} [inset] + * @returns {Point} + */ +export const clampPosition = (position, size, bounds, inset = 0) => ({ + x: Math.round(Math.max(bounds.left + inset, Math.min(position.x, bounds.right - inset - size.width))), + y: Math.round(Math.max(bounds.top + inset, Math.min(position.y, bounds.bottom - inset - size.height))), +}); + +/** + * An on-screen area in graph coordinates. The graph is drawn at `WORLD` size and scaled to its box on the page, so an + * element around it (the whole tour section) maps to a larger area that may start at negative coordinates. + * @param {Bounds} area the area's client rect @param {Bounds & { width: number }} stage the graph's client rect + * @returns {Bounds} + */ +export const toGraphBounds = (area, stage) => { + const scale = stage.width / WORLD.width; + return { + left: (area.left - stage.left) / scale, + top: (area.top - stage.top) / scale, + right: (area.right - stage.left) / scale, + bottom: (area.bottom - stage.top) / scale, + }; +}; + +/** + * Whether an output may feed an input: matching field types, between two different nodes. + * @param {GraphNode[]} nodes + * @param {{ node: string, handle: string }} source an output + * @param {{ node: string, handle: string }} target an input + */ +export const canConnect = (nodes, source, target) => { + if (source.node === target.node) return false; + const output = nodes.find((node) => node.id === source.node)?.outputs.find((field) => field.name === source.handle); + const input = nodes.find((node) => node.id === target.node)?.inputs.find((field) => field.name === target.handle); + return Boolean(output && input && input.handle !== false && output.type === input.type); +}; + +/** + * Adds an edge. An input takes one connection, so an edge already feeding the same input is replaced. + * @param {GraphEdge[]} edges @param {GraphEdge} edge + * @returns {GraphEdge[]} + */ +export const connectEdge = (edges, edge) => [ + ...edges.filter((existing) => !(existing.target === edge.target && existing.targetHandle === edge.targetHandle)), + edge, +]; + +/** + * The candidate nearest the pointer within `radius`, like xyflow's connection radius: a drop near a handle snaps to it. + * @template {Point} T + * @param {T[]} candidates @param {Point} pointer @param {number} radius + * @returns {T | null} + */ +export const findNearest = (candidates, pointer, radius) => { + let best = null; + let bestDistance = radius; + for (const candidate of candidates) { + const distance = Math.hypot(candidate.x - pointer.x, candidate.y - pointer.y); + if (distance <= bestDistance) { + best = candidate; + bestDistance = distance; + } + } + return best; +}; diff --git a/docs/src/lib/components/tour/workflow-demo.test.mjs b/docs/src/lib/components/tour/workflow-demo.test.mjs new file mode 100644 index 00000000000..bca41d9f934 --- /dev/null +++ b/docs/src/lib/components/tour/workflow-demo.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + EDGE_INSET, + WORLD, + WORLD_BOUNDS, + canConnect, + clampPosition, + connectEdge, + createInitialGraph, + findNearest, + getConnectedInputs, + getEdgePath, + getHandlePoint, + layoutNode, + toGraphBounds, +} from './workflow-demo.mjs'; + +const nodeById = (id) => createInitialGraph().nodes.find((node) => node.id === id); + +describe('getEdgePath', () => { + it('bends forward edges halfway across the gap, as xyflow does', () => { + assert.equal(getEdgePath({ x: 100, y: 50 }, { x: 300, y: 150 }), 'M100,50 C200,50 200,150 300,150'); + }); + + it('loops a target behind its source out and back by 6.25 * sqrt(distance)', () => { + // 0.25 curvature * 25 * sqrt(200) = 88.39 + assert.equal(getEdgePath({ x: 300, y: 50 }, { x: 100, y: 150 }), 'M300,50 C388.39,50 11.61,150 100,150'); + }); +}); + +describe('clampPosition', () => { + const size = { width: 200, height: 100 }; + + it('keeps a position that is already inside', () => { + assert.deepEqual(clampPosition({ x: 40, y: 30 }, size, WORLD_BOUNDS), { x: 40, y: 30 }); + }); + + it('pulls a node back inside every edge', () => { + assert.deepEqual(clampPosition({ x: -20, y: -5 }, size, WORLD_BOUNDS), { x: 0, y: 0 }); + assert.deepEqual(clampPosition({ x: 900, y: 900 }, size, WORLD_BOUNDS), { x: 480, y: 340 }); + }); + + it('pins a node larger than the bounds to the top left, keeping its header reachable', () => { + assert.deepEqual(clampPosition({ x: 50, y: 50 }, { width: 800, height: 500 }, WORLD_BOUNDS), { x: 0, y: 0 }); + }); + + it('accepts bounds that extend past the graph on every side', () => { + const section = { left: -400, top: -60, right: 680, bottom: 500 }; + assert.deepEqual(clampPosition({ x: -900, y: -900 }, size, section, 6), { x: -394, y: -54 }); + assert.deepEqual(clampPosition({ x: -250, y: 20 }, size, section, 6), { x: -250, y: 20 }); + }); + + it('keeps the inset clear on every side', () => { + assert.deepEqual(clampPosition({ x: -20, y: -5 }, size, WORLD_BOUNDS, 6), { x: 6, y: 6 }); + assert.deepEqual(clampPosition({ x: 900, y: 900 }, size, WORLD_BOUNDS, 6), { x: 474, y: 334 }); + }); +}); + +describe('toGraphBounds', () => { + it('maps a surrounding area into graph coordinates at the scale the graph is drawn at', () => { + // The graph is drawn at half size (340px wide) 200px right of and 30px below the section's top left corner. + const stage = { left: 300, top: 130, right: 640, bottom: 350, width: 340 }; + const section = { left: 100, top: 100, right: 660, bottom: 400 }; + assert.deepEqual(toGraphBounds(section, stage), { left: -400, top: -60, right: 720, bottom: 540 }); + }); +}); + +describe('layoutNode', () => { + it('stacks outputs, then inputs, with connected inputs hiding their controls', () => { + const { edges } = createInitialGraph(); + const layout = layoutNode(nodeById('compositor'), getConnectedInputs(edges, 'compositor')); + // 1px border + 36px header + 4px body padding, then 24px rows centered at 12px. + assert.deepEqual(layout.outputs, { image: 53, width: 77, height: 101 }); + // Board (no handle) takes 24 + 2 + 28; Image Subject is connected; Image Background shows its 118px image control. + assert.deepEqual(layout.inputs, { subject: 179, background: 203, threshold: 347 }); + // Threshold is connected (24) + 4px padding + 32px footer + 1px border. + assert.equal(layout.height, 396); + }); + + it('grows by the control height when an input is disconnected', () => { + const connected = layoutNode(nodeById('compositor'), new Set(['subject', 'threshold'])); + const disconnected = layoutNode(nodeById('compositor'), new Set(['subject'])); + assert.equal(disconnected.height - connected.height, 30); + }); + + it('collapses to the header and gathers every handle behind it', () => { + const node = { ...nodeById('l2i'), open: false }; + const layout = layoutNode(node, new Set()); + assert.equal(layout.height, 38); + assert.deepEqual(new Set([...Object.values(layout.inputs), ...Object.values(layout.outputs)]), new Set([23])); + assert.deepEqual(Object.keys(layout.inputs), ['latents', 'vae', 'tiled']); + }); + + it('places inputs on the left edge and outputs on the right edge of the node', () => { + const node = nodeById('integer'); + const layout = layoutNode(node, new Set()); + assert.deepEqual(getHandlePoint(node, layout, 'input', 'value'), { x: node.x, y: node.y + 77 }); + assert.deepEqual(getHandlePoint(node, layout, 'output', 'value'), { x: node.x + node.width, y: node.y + 53 }); + assert.equal(getHandlePoint(node, layout, 'input', 'missing'), null); + }); +}); + +describe('the initial graph', () => { + it('fits on the canvas with every input disconnected, so rewiring never pushes a node off it', () => { + for (const node of createInitialGraph().nodes) { + const { height } = layoutNode(node, new Set()); + const position = clampPosition(node, { width: node.width, height }, WORLD_BOUNDS, EDGE_INSET); + assert.deepEqual(position, { x: node.x, y: node.y }, node.id); + } + }); + + it('connects only fields that accept each other', () => { + const { nodes, edges } = createInitialGraph(); + for (const edge of edges) { + assert.ok(canConnect(nodes, { node: edge.source, handle: edge.sourceHandle }, { node: edge.target, handle: edge.targetHandle })); + } + }); +}); + +describe('canConnect', () => { + const { nodes } = createInitialGraph(); + + it('accepts an output feeding an input of the same field type on another node', () => { + assert.ok(canConnect(nodes, { node: 'l2i', handle: 'width' }, { node: 'compositor', handle: 'threshold' })); + }); + + it('rejects mismatched types, inputs without a handle and loops onto the same node', () => { + assert.equal(canConnect(nodes, { node: 'l2i', handle: 'image' }, { node: 'compositor', handle: 'threshold' }), false); + assert.equal(canConnect(nodes, { node: 'l2i', handle: 'image' }, { node: 'compositor', handle: 'board' }), false); + assert.equal(canConnect(nodes, { node: 'integer', handle: 'value' }, { node: 'integer', handle: 'value' }), false); + }); +}); + +describe('connectEdge', () => { + it('replaces the edge already feeding the input and keeps the others', () => { + const { edges } = createInitialGraph(); + const edge = { source: 'l2i', sourceHandle: 'height', target: 'compositor', targetHandle: 'threshold' }; + assert.deepEqual(connectEdge(edges, edge), [edges[0], edge]); + }); +}); + +describe('findNearest', () => { + const candidates = [ + { id: 'a', x: 0, y: 0 }, + { id: 'b', x: 30, y: 0 }, + ]; + + it('snaps to the nearest candidate within the radius', () => { + assert.equal(findNearest(candidates, { x: 18, y: 4 }, 20)?.id, 'b'); + }); + + it('finds nothing when every candidate is beyond the radius', () => { + assert.equal(findNearest(candidates, { x: 15, y: 30 }, 20), null); + }); +}); diff --git a/docs/src/lib/components/whats-new/CanvasMotif.astro b/docs/src/lib/components/whats-new/CanvasMotif.astro new file mode 100644 index 00000000000..89807c0dcf4 --- /dev/null +++ b/docs/src/lib/components/whats-new/CanvasMotif.astro @@ -0,0 +1,414 @@ +--- +/* + * The canvas as a stack of five layers, drawn apart: an image, a Canny control layer (its edge map as line art), a + * gradient in the Screen blend mode, text and a pressure-sensitive brush stroke. The gradient sweeps in, the control + * layer traces its edges, the stroke draws itself thin to thick to thin, the text appears, and the layers settle into + * one composite that exports a `.psd` before lifting apart again. + * The still frame is the full stack, drawn apart and labelled. + */ +import File from '@lucide/astro/icons/file'; +import { Image } from 'astro:assets'; + +import landscape from '../../../assets/whats-new-landscape.webp'; +import { box } from './box'; +import Stage from './Stage.astro'; + +// Top to bottom. `lift` is each layer's offset from the composite position, in stage units; `delay` staggers the +// settle so the stack closes from the top. +const layers = [ + { id: 'brush', label: 'Brush', labelWidth: 40 }, + { id: 'text', label: 'Text', labelWidth: 36 }, + { id: 'gradient', label: 'Gradient · Screen', labelWidth: 84 }, + { id: 'canny', label: 'Control · Canny', labelWidth: 78 }, + { id: 'image', label: 'Layer 1', labelWidth: 46 }, +].map((layer, index) => ({ ...layer, lift: (index - 2) * 66, delay: `${index * 100}ms` })); + +const plane = box(40, 142, 136, 136); +const word = 'COAST'; +// A tapered fill along an S-shaped centreline: the width follows the pressure as it builds and releases. +const stroke = + 'M14.2 112.1L17.5 106.5L20.9 102L24.3 98.6L27.7 96.2L31 94.7L34.2 94L37.3 93.7L40.4 94L43.6 94.5L46.9 95.4L50.4 96.5' + + 'L54 97.7L57.9 99L62 100.3L66.3 101.5L70.7 102.6L75.4 103.3L80.2 103.6L85.2 103.4L90.3 102.5L95.3 100.9L100.2 98.5' + + 'L104.9 95.3L109.3 91.3L113.4 86.4L117.3 80.6L120.9 73.9L124.2 66.1L123.8 65.9L118.5 72.3L113.5 77.6L108.6 81.7' + + 'L104.1 84.8L99.7 87L95.7 88.5L91.9 89.3L88.2 89.7L84.6 89.7L81 89.5L77.3 89L73.5 88.3L69.6 87.5L65.5 86.7L61.4 85.8' + + 'L57.2 85L52.9 84.4L48.6 84.1L44.3 84.1L40 84.5L35.8 85.5L31.8 87.1L28.1 89.5L24.7 92.4L21.6 96.1L18.8 100.5' + + 'L16.2 105.8L13.8 111.9Z'; +// The landscape's edges as the image is drawn in the 136-unit plane: ridge, a peak's flank, the hill lines, the +// meadow horizon, the rock and two grass contours. +const edges = [ + 'M0 19.5L8 15.6L19.5 23L25 19.8L35 23L47 21L53 16.8L59.3 9.7L65.5 15L71 23L78 12.4L82.3 8L88.5 15L93 13.3' + + 'L101 18.6L111.5 23L120 18.6L131 17.7L136 19.5', + 'M59.3 9.7L52 22L42 32L31 40', + 'M60 50C84 42 108 32 136 25', + 'M0 46C14 47 30 52 42 61', + 'M0 78C22 72 46 68 70 66S116 60 136 57', + 'M4.4 120.4L10.6 113.3L19.5 106.2L31 101.8L35.4 97L46 98.2L53 104.4L55.8 113.3L53 124L44 130L26.6 132.8' + + 'L10.6 132L5.3 126.6Z', + 'M64 98C86 92 108 88 136 82', + 'M68 124C90 116 112 112 136 108', +]; +--- + + + { + layers.toReversed().map(({ id, lift, delay }) => ( + <> + {id === 'canny' &&
} +
+ {id === 'image' && } + {id === 'canny' && ( + + )} + {id === 'gradient' &&
} + {id === 'text' && ( +
+ {[...word].map((letter, index) => ( + {letter} + ))} +
+ )} + {id === 'brush' && ( + + )} +
+ + )) + } + { + layers.map(({ label, lift, labelWidth, delay }) => ( +
+ {label} +
+ )) + } +
+
+ + + diff --git a/docs/src/lib/components/whats-new/ImageMapMotif.astro b/docs/src/lib/components/whats-new/ImageMapMotif.astro new file mode 100644 index 00000000000..cb91fa22ec5 --- /dev/null +++ b/docs/src/lib/components/whats-new/ImageMapMotif.astro @@ -0,0 +1,139 @@ +--- +/* + * The Image Map: points start scattered, drift into colored clusters by similarity and take their autotag labels; a + * few stay unclustered in gray, as on the map. Points stay visible throughout, so the map is never empty, even paused + * at its first frame. The still frame is the clustered, labelled map. The labels are tags from the Image Map + * screenshot in the guide. + */ +import Stage from './Stage.astro'; + +const width = 560; +const height = 200; + +// A seeded generator keeps the layout identical between builds. +let seed = 7; +const random = () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; +const gaussian = () => Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random()); + +const clusters = [ + { label: 'beach', x: 128, y: 124, color: 'var(--invoke-warning)', count: 11 }, + { label: 'formal wear', x: 262, y: 74, color: 'var(--docs-accent)', count: 12 }, + { label: 'dog', x: 386, y: 134, color: 'var(--invoke-success)', count: 10 }, + { label: 'street', x: 476, y: 76, color: 'var(--invoke-danger)', count: 9 }, +]; + +const scatter = (x: number, y: number) => ({ + dx: (24 + random() * (width - 48) - x).toFixed(1), + dy: (18 + random() * (height - 36) - y).toFixed(1), + // A small negative delay staggers the drift without hiding a point before its cycle starts. + delay: (random() * -0.8).toFixed(2), +}); + +const points = [ + ...clusters.flatMap(({ x, y, color, count }) => + Array.from({ length: count }, () => { + const px = x + gaussian() * 13; + const py = y + gaussian() * 9; + return { x: px, y: py, r: 3.2, color, ...scatter(px, py) }; + }), + ), + ...Array.from({ length: 10 }, () => { + const px = 30 + random() * (width - 60); + const py = 20 + random() * (height - 40); + return { x: px, y: py, r: 2.4, color: 'var(--invoke-neutral-500)', ...scatter(px, py) }; + }), +]; + +// Inter at 9 units is about 5 units per character. +const labelWidth = (label: string) => label.length * 5 + 12; +--- + + + + + + diff --git a/docs/src/lib/components/whats-new/LoopMotif.astro b/docs/src/lib/components/whats-new/LoopMotif.astro new file mode 100644 index 00000000000..2ab5608f196 --- /dev/null +++ b/docs/src/lib/components/whats-new/LoopMotif.astro @@ -0,0 +1,343 @@ +--- +/* + * Loop nodes, as the workflow editor draws them: a For node takes a collection, an Image Processor body sits inside + * the dashed loop boundary, and a ForReturn closes each iteration. The dashed loop_linkage edge pairs the two, and + * For.output_collection leads out of the loop. Each item takes its turn through the body and lands in the output + * collection, four iterations and a pause. The still frame is the second iteration, its value halfway through. + */ +import { Image } from 'astro:assets'; + +import landscape from '../../../assets/whats-new-landscape.webp'; +import { box } from './box'; +import Stage from './Stage.astro'; + +const items = [0, 1, 2, 3]; +const itemY = (index: number) => 34 + index * 38; +const still = 1; +// Each iteration lasts 2s of the 10s cycle; a negative delay starts an element's cycle at its own iteration. +const iterationDelay = (index: number) => `${index * 2 - 10}s`; +const thumbProps = { src: landscape, alt: '', width: 96 }; +// Each item is a different part of the image; its result is the same part, processed. +const crop = (index: number) => `object-position: ${index * 30}% 50%`; +const ports = { item: 78, index: 96, outputCollection: 114, loopLinkage: 140 }; +--- + + + + + { + items.map((index) => ( + + )) + } +
+ { + items.map((index) => ( + + )) + } +
+
+ + diff --git a/docs/src/lib/components/whats-new/ProjectsMotif.astro b/docs/src/lib/components/whats-new/ProjectsMotif.astro new file mode 100644 index 00000000000..afded1b88dd --- /dev/null +++ b/docs/src/lib/components/whats-new/ProjectsMotif.astro @@ -0,0 +1,428 @@ +--- +/* + * Projects: a "Coastal Series.invk" folder opens and what the project holds rises out of it, a canvas, workflows, + * images, boards and a workbench layout, then sinks back as the folder closes. The first frame is the closed, labelled + * folder. The still frame is the open folder with its contents out and labelled. + */ +import Folder from '@lucide/astro/icons/folder'; +import FolderOpen from '@lucide/astro/icons/folder-open'; +import { Image } from 'astro:assets'; + +import landscape from '../../../assets/whats-new-landscape.webp'; +import { box } from './box'; +import Stage from './Stage.astro'; + +// Every miniature of the landscape uses the Video motif's width, so the page fetches no extra variant. +const imageWidth = 160; +const folder = { x: 48, w: 204 }; +const card = { y: 8, size: 40 }; +// Closed, the cards sit side by side behind the flap: this far below their row, and this far apart. +const hidden = { drop: 98, pitch: 34 }; + +const items = [ + { id: 'canvas', label: 'Canvas' }, + { id: 'workflows', label: 'Workflows' }, + { id: 'images', label: 'Images' }, + { id: 'boards', label: 'Boards' }, + { id: 'layout', label: 'Layout' }, +].map((item, index) => { + const x = 30 + index * 50; + const hiddenX = 150 + (index - 2) * hidden.pitch - card.size / 2; + return { ...item, x, fromX: hiddenX - x, delay: index * 0.12 }; +}); + +const imageCrops = [ + { at: [3, 3], crop: '10% 30%' }, + { at: [21, 3], crop: '90% 20%' }, + { at: [3, 21], crop: '30% 85%' }, + { at: [21, 21], crop: '75% 65%' }, +]; + +// The board list: a thumbnail and a name bar per board, the middle one selected. +const boardRows = [ + { y: 3.5, crop: '20% 40%' }, + { y: 15.5, crop: '80% 30%', selected: true }, + { y: 27.5, crop: '50% 90%' }, +]; + +// The workbench tile in miniature, in the card's own units: the top bar with its Invoke button, two rails with a +// panel button each, two side panels, and the darker centre view with an image in it. +const layoutBlocks = [ + { tone: 'faint', at: [2, 2, 36, 4] }, + { tone: 'brand', at: [31, 2.7, 5, 2.6] }, + { tone: 'faint', at: [2, 8, 1.5, 30] }, + { tone: 'faint', at: [36.5, 8, 1.5, 30] }, + { tone: 'shown', at: [2.2, 10, 1.1, 1.1] }, + { tone: 'shown', at: [36.7, 10, 1.1, 1.1] }, + { tone: 'faint', at: [5, 8, 7, 30] }, + { tone: 'faint', at: [28, 8, 7, 30] }, + { tone: 'floor', at: [13.5, 8, 13, 30] }, + { tone: 'faint', at: [16, 19, 8, 8] }, +]; +--- + + +
+
+ + { + items.map(({ id, label, x, fromX, delay }) => ( +
+
+ {id === 'canvas' && ( + <> + + + + )} + {id === 'workflows' && ( + + )} + {id === 'images' && + imageCrops.map(({ at: [ix, iy], crop }) => ( + + ))} + {id === 'boards' && + boardRows.map(({ y, crop, selected }) => ( + + + + + ))} + {id === 'layout' && + layoutBlocks.map(({ tone, at: [bx, by, bw, bh] }) => ( + + ))} +
+ {label} +
+ )) + } + +
+
+ + + + + Coastal Series.invk +
+
Saved
+
+
+ + diff --git a/docs/src/lib/components/whats-new/Stage.astro b/docs/src/lib/components/whats-new/Stage.astro new file mode 100644 index 00000000000..71a4ae3dbbc --- /dev/null +++ b/docs/src/lib/components/whats-new/Stage.astro @@ -0,0 +1,70 @@ +--- +/* + * The drawing surface of a What's new motif: `width` × `height` units, scaled to fit the tile's visual area, which + * must be a size container. Children are placed with `box()` and the `wn-abs` class, and sized in stage units with + * `var(--u)`; an SVG overlay with the `wn-svg` class takes the same numbers as its viewBox. A motif therefore keeps its + * proportions at every tile size. + */ +type Props = { width: number; height: number }; + +const { width, height } = Astro.props; +--- + +
+ +
+ + diff --git a/docs/src/lib/components/whats-new/VideoMotif.astro b/docs/src/lib/components/whats-new/VideoMotif.astro new file mode 100644 index 00000000000..83fbc7dc3d2 --- /dev/null +++ b/docs/src/lib/components/whats-new/VideoMotif.astro @@ -0,0 +1,149 @@ +--- +/* + * Video with sound: a timeline of frames above a soundtrack waveform. The playhead sweeps across both; the frame + * under it lights up and each bar of the waveform peaks as it passes. The still frame stops the playhead mid-clip. + */ +import { Image } from 'astro:assets'; + +import landscape from '../../../assets/whats-new-landscape.webp'; +import { box } from './box'; +import Stage from './Stage.astro'; + +const track = { x: 20, width: 260 }; +// The playhead's position in the still frame, as a fraction of the track. +const still = 0.45; +const at = (x: number) => (x - track.x) / track.width; + +// Five frames of a slow pan across the image. +const frameWidth = 48.8; +const frames = [0, 1, 2, 3, 4].map((index) => { + const x = track.x + index * (frameWidth + 4); + return { x, start: at(x), lit: at(x) <= still && still < at(x + frameWidth), pan: index * 25 }; +}); + +// A speech-like envelope; `peak` is a bar's height as the playhead passes. +const bars = Array.from({ length: 40 }, (_, index) => { + const x = 26 + index * 6.2; + const level = 0.18 + 0.82 * Math.abs(Math.sin(index * 0.7) * Math.sin(index * 0.23 + 0.6)); + return { x, at: at(x + 1.5), level: level.toFixed(3), peak: Math.min(1, level * 1.5 + 0.15).toFixed(3) }; +}); + +const seconds = 6; +// A negative delay starts each element's cycle partway through, so its moment lines up with the playhead. +const delay = (fraction: number) => `${((fraction - 1) * seconds).toFixed(3)}s`; +--- + + + { + frames.map(({ x, start, lit, pan }) => ( + + )) + } +
+ { + bars.map(({ x, at: position, level, peak }) => ( +
+ )) + } +
+ + + diff --git a/docs/src/lib/components/whats-new/WorkbenchMotif.astro b/docs/src/lib/components/whats-new/WorkbenchMotif.astro new file mode 100644 index 00000000000..ff68662c01a --- /dev/null +++ b/docs/src/lib/components/whats-new/WorkbenchMotif.astro @@ -0,0 +1,922 @@ +--- +/* + * The workbench as a calm wireframe of the app: the real top bar, rails and view chrome, with the panels' contents + * drawn as skeleton bars. It switches from the Compose preset (Preview and Gallery) to Edit (Canvas and Layers), floats + * the Preview out of its rail slot over the canvas, docks it again and switches back. + * The still frame is Edit with the Preview floating; its rail slot turns dashed, as the app marks a floating panel. + */ +import ArrowRight from '@lucide/astro/icons/arrow-right'; +import Bell from '@lucide/astro/icons/bell'; +import Blend from '@lucide/astro/icons/blend'; +import Brush from '@lucide/astro/icons/brush'; +import ChevronDown from '@lucide/astro/icons/chevron-down'; +import ChevronRight from '@lucide/astro/icons/chevron-right'; +import ChevronUp from '@lucide/astro/icons/chevron-up'; +import ChevronsUpDown from '@lucide/astro/icons/chevrons-up-down'; +import Clapperboard from '@lucide/astro/icons/clapperboard'; +import Ellipsis from '@lucide/astro/icons/ellipsis'; +import Eraser from '@lucide/astro/icons/eraser'; +import Eye from '@lucide/astro/icons/eye'; +import Frame from '@lucide/astro/icons/frame'; +import Hand from '@lucide/astro/icons/hand'; +import ImageIcon from '@lucide/astro/icons/image'; +import Lasso from '@lucide/astro/icons/lasso'; +import Layers from '@lucide/astro/icons/layers'; +import ListOrdered from '@lucide/astro/icons/list-ordered'; +import MapIcon from '@lucide/astro/icons/map'; +import Maximize from '@lucide/astro/icons/maximize'; +import Move from '@lucide/astro/icons/move'; +import PictureInPicture2 from '@lucide/astro/icons/picture-in-picture-2'; +import Play from '@lucide/astro/icons/play'; +import Plus from '@lucide/astro/icons/plus'; +import Redo2 from '@lucide/astro/icons/redo-2'; +import Rotate3d from '@lucide/astro/icons/rotate-3d'; +import Save from '@lucide/astro/icons/save'; +import Scaling from '@lucide/astro/icons/scaling'; +import Settings2 from '@lucide/astro/icons/settings-2'; +import SlidersHorizontal from '@lucide/astro/icons/sliders-horizontal'; +import Square from '@lucide/astro/icons/square'; +import SquareDashed from '@lucide/astro/icons/square-dashed'; +import Star from '@lucide/astro/icons/star'; +import TypeIcon from '@lucide/astro/icons/type'; +import Undo2 from '@lucide/astro/icons/undo-2'; +import WandSparkles from '@lucide/astro/icons/wand-sparkles'; +import Workflow from '@lucide/astro/icons/workflow'; +import X from '@lucide/astro/icons/x'; +import { Image } from 'astro:assets'; + +import landscape from '../../../assets/whats-new-landscape.webp'; +import { box } from './box'; +import Stage from './Stage.astro'; + +// Columns, in stage units: rails, Generate panel, center view, right panel. +const leftPanel = { x: 24, width: 160 }; +const center = { x: 184, width: 202 }; +const rightPanel = { x: 386, width: 150 }; +const rightRailX = 538; +const slotY = (index: number) => 36 + index * 22; +// A skeleton bar's width, in stage units. +const sk = (width: number) => `--sk: ${width}`; + +// Compose and Edit leave room for the chevron the active tab carries; the indicator moves between their boxes. +const tabs = [ + { label: 'Compose', Icon: TypeIcon, x: 172, width: 58 }, + { label: 'Edit', Icon: Layers, x: 232, width: 44 }, + { label: 'Video', Icon: Clapperboard, x: 278, width: 44 }, + { label: 'Workflow', Icon: Workflow, x: 324, width: 56 }, +]; +const tools = [Hand, Move, Rotate3d, SquareDashed, Brush, Eraser, Square, Blend, TypeIcon, Lasso]; +// Collapsed sections of the Generate panel: title and summary-chip widths. +const collapsed = [ + { title: 24, summary: 36 }, + { title: 38, summary: 0 }, + { title: 30, summary: 0 }, + { title: 52, summary: 14 }, +]; +const boards = [ + { name: 22, count: 22 }, + { name: 36, count: 16, crop: 'object-position: 10% 50%; scale: 2.2' }, + { name: 44, count: 16, crop: 'object-position: 50% 50%' }, + { name: 46, count: 16, crop: 'object-position: 90% 50%; scale: 1.8', selected: true }, + { name: 52, count: 18, crop: 'object-position: 30% 50%; scale: 2.6' }, +]; +// Different crops of the one image: a pan, then a zoom about a point. The first seven are starred. +const thumbs = Array.from({ length: 9 }, (_, index) => + [ + `object-position: ${(index * 37) % 100}% 50%`, + `scale: ${1 + (index % 3) * 0.8}`, + `transform-origin: ${(index * 53) % 100}% ${(index * 29) % 100}%`, + ].join('; ') +); +const starred = 7; +const imageProps = { src: landscape, alt: '', widths: [320, 640], sizes: '(min-width: 36rem) 14rem, 45vw' }; +const thumbProps = { src: landscape, alt: '', width: 96 }; +--- + + +
+
+
+
+
+
+
+ + + +
Coastal Series
+
+ { + tabs.map(({ label, Icon, x, width }) => ( +
+ + {label} +
+ )) + } +
+
+ 1 + Invoke + +
+
+ 0 +
+ + +
+
+
+ +
+ Generate +
+
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+
+ +
+ +
+
+ { + collapsed.map(({ title, summary }, index) => ( + + )) + } +
+ + +
+
+ Compose +
+
+ Preview +
+ + +
+
+
+
+ +
+ Gallery +
+
+
+ + { + boards.map(({ name, count, crop, selected }) => ( +
+ {crop && } + + + +
+ )) + } + +
+ +
+
+ +
+ { + thumbs.map((crop, index) => ( + + + {index < starred && ( + + + + )} + + )) + } +
+
+
+ + +
+
+ Edit +
+
+ Canvas +
+
+ 81% + + + +
+
+ { + tools.map((Tool, index) => ( + + + + )) + } +
+ +
+ +
+
+
+
+ + +
+ +
+ Layers +
+
+
+ +
+
+
+
+
+ +
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+ + + +
+
+
+ + +
+ + + +
+
+ +
+ + {[30, 28, 48, 42].map((width) => )} + + +
+ +
+
+ Preview +
+ +
+
+
+ + diff --git a/docs/src/lib/components/whats-new/box.ts b/docs/src/lib/components/whats-new/box.ts new file mode 100644 index 00000000000..55e33d7a8e2 --- /dev/null +++ b/docs/src/lib/components/whats-new/box.ts @@ -0,0 +1,3 @@ +/** Places a `wn-abs` element on a motif stage (`Stage.astro`): position and size in the stage's units. */ +export const box = (x: number, y: number, width: number, height: number) => + `--x: ${x}; --y: ${y}; --w: ${width}; --h: ${height}`; diff --git a/docs/src/lib/crowdin-config.test.mjs b/docs/src/lib/crowdin-config.test.mjs new file mode 100644 index 00000000000..16b558d817b --- /dev/null +++ b/docs/src/lib/crowdin-config.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { globSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +// The Crowdin CLI fails on a `crowdin.yml` source pattern that matches no file, and the `.md` entry rests on a single +// page. + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)); +const config = readFileSync(path.join(repoRoot, 'crowdin.yml'), 'utf8'); +const sources = [...config.matchAll(/^\s*-?\s*source:\s*'([^']+)'/gm)].map((match) => match[1]); + +describe('crowdin.yml', () => { + it('has a source pattern for every entry', () => { + assert.equal(sources.length, [...config.matchAll(/^\s*-?\s*source:/gm)].length); + }); + + for (const source of sources) { + it(`matches pages with ${source}`, () => { + const matches = globSync(source.replace(/^\//, ''), { cwd: repoRoot }).filter((file) => !file.includes('/assets/')); + assert.ok(matches.length > 0, `${source} matches no file`); + }); + } +}); diff --git a/docs/src/lib/disclosure.ts b/docs/src/lib/disclosure.ts new file mode 100644 index 00000000000..4194a5ddea2 --- /dev/null +++ b/docs/src/lib/disclosure.ts @@ -0,0 +1,44 @@ +type DisclosureOptions = { + /** Runs after the popup opens, e.g. to move focus into it. */ + onOpen?: () => void; +}; + +/** + * Wires a button that shows and hides a popup inside `root`: the button toggles it and keeps `aria-expanded` in sync; + * Escape closes it and returns focus to the button; a click outside `root` or focus leaving `root` closes it. + */ +export const bindDisclosure = ( + root: HTMLElement, + button: HTMLButtonElement, + popup: HTMLElement, + { onOpen }: DisclosureOptions = {} +) => { + const isOpen = () => !popup.hidden; + const setOpen = (open: boolean) => { + popup.hidden = !open; + button.setAttribute('aria-expanded', String(open)); + if (open) onOpen?.(); + }; + const close = ({ restoreFocus = false } = {}) => { + if (!isOpen()) return; + setOpen(false); + if (restoreFocus) button.focus(); + }; + + button.addEventListener('click', () => (isOpen() ? close() : setOpen(true))); + root.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || !isOpen()) return; + // Cancelling the key press keeps it from also closing an enclosing popover, such as the mobile sidebar. + event.preventDefault(); + event.stopPropagation(); + close({ restoreFocus: true }); + }); + document.addEventListener('click', (event) => { + if (!root.contains(event.target as Node)) close(); + }); + root.addEventListener('focusout', (event) => { + if (event.relatedTarget instanceof Node && !root.contains(event.relatedTarget)) close(); + }); + + return { close, isOpen }; +}; diff --git a/docs/src/lib/docs-page.ts b/docs/src/lib/docs-page.ts new file mode 100644 index 00000000000..41c3a5df572 --- /dev/null +++ b/docs/src/lib/docs-page.ts @@ -0,0 +1,19 @@ +import { getCollection } from 'astro:content'; + +import { isRootOnlyPath } from '../config/locales.mjs'; + +/** + * Whether a route renders a docs collection page. Its entry id is the page's own for a translation, and the English + * page's for a locale's fallback route. Other routes, such as the 404 page, release pages and hand-written + * `StarlightPage`s, carry entries outside the collection. + */ +export const isDocsPage = async (entryId: string) => { + // Starlight's route entries give the home page the id `''`, which is `index` in the collection. + const id = entryId || 'index'; + // `getEntry` would log a warning for every page outside the collection. + return (await getCollection('docs', (doc) => doc.id === id)).length > 0; +}; + +/** Whether a route exists in every locale, so it can link to its translations: docs pages that are not root-only. */ +export const isLocalizablePage = async (entryId: string) => + !isRootOnlyPath(`/${entryId}`) && (await isDocsPage(entryId)); diff --git a/docs/src/lib/home-announcement.mjs b/docs/src/lib/home-announcement.mjs new file mode 100644 index 00000000000..a7397160898 --- /dev/null +++ b/docs/src/lib/home-announcement.mjs @@ -0,0 +1,19 @@ +// The announcement pill above the home page's title. It fills the image slot of Starlight's hero, which takes raw +// HTML, so the route middleware (`src/route-data.ts`) builds it here with its text from the UI strings rather than the +// page's frontmatter, where translators would see markup. `HomePage.astro` styles it. + +/** @param {string} text */ +const escapeHtml = (text) => + text.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`); + +/** Lucide's `arrow-right`. */ +const ARROW = + ''; + +/** + * The pill's markup, with `text` and `href` escaped. + * @param {{ text: string, href: string }} announcement + */ +export const announcementHtml = ({ text, href }) => + `` + + `${escapeHtml(text)}${ARROW}`; diff --git a/docs/src/lib/home-announcement.test.mjs b/docs/src/lib/home-announcement.test.mjs new file mode 100644 index 00000000000..245348caaf2 --- /dev/null +++ b/docs/src/lib/home-announcement.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { announcementHtml } from './home-announcement.mjs'; + +describe('announcementHtml', () => { + it('links the text, escaped, so a translation cannot add markup', () => { + const html = announcementHtml({ text: 'V7 "jetzt" & mehr', href: '/v7/de/start-here/upgrading/' }); + assert.match(html, /^V7 <b>"jetzt"</b> & mehr')); + assert.ok(!html.includes('')); + }); +}); diff --git a/docs/src/lib/i18n-keys.test.mjs b/docs/src/lib/i18n-keys.test.mjs new file mode 100644 index 00000000000..a2a96754cec --- /dev/null +++ b/docs/src/lib/i18n-keys.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; + +import { i18nSchema } from '@astrojs/starlight/schema'; + +import { translatedLocales } from '../config/locales.mjs'; +import { modelFamilies } from '../config/model-families.ts'; +import { docsTopics, topicGroups } from '../config/topics.ts'; +import en from '../content/i18n/en.json' with { type: 'json' }; +import { PLURAL_SUFFIX } from './i18n.mjs'; +import { fileDirectory, sidebarGroupKey } from './sidebar.mjs'; +import { stripSortPrefix } from './sort-prefix.mjs'; + +// `src/content/i18n/en.json` is what Crowdin translates. These tests keep it in step with the code. + +const builtInKeys = new Set(Object.keys(i18nSchema().shape)); +const { default: builtInEnglish } = await import( + new URL('./translations/en.js', import.meta.resolve('@astrojs/starlight/schema')).href +); +const srcDirectory = new URL('../', import.meta.url); + +/** Every source file's text, tests and content aside. */ +const sources = readdirSync(srcDirectory, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.(astro|ts|mjs)$/.test(entry.name) && !entry.name.endsWith('.test.mjs')) + .filter((entry) => !entry.parentPath.includes('/src/content')) + .map((entry) => readFileSync(`${entry.parentPath}/${entry.name}`, 'utf8')); + +/** The keys code names literally: `t('key')`, `pluralForms(t, 'key')` and setup options' `labelKey: 'key'`. */ +const usedKeys = new Set( + sources.flatMap((source) => + [...source.matchAll(/(?:\bt\(\s*|\bpluralForms\(\s*t,\s*|\blabelKey:\s*)(['"])([^'"]+)\1/g)].map( + (match) => match[2] + ) + ) +); + +/** The English labels of the sidebar groups made from docs directories: every directory inside a topic's own. */ +const sidebarGroupLabels = () => { + const topicDirectories = docsTopics.map((topic) => topic.directory); + return readdirSync(new URL('../content/docs/', import.meta.url), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ name: entry.name, directory: fileDirectory(`${entry.parentPath}/${entry.name}/page.md`) })) + .filter(({ directory }) => !translatedLocales.includes(directory.split('/')[0])) + .filter(({ directory }) => !directory.split('/').includes('assets')) + .filter( + ({ directory }) => + !topicDirectories.includes(directory) && topicDirectories.some((topic) => directory.startsWith(`${topic}/`)) + ) + .map(({ name }) => stripSortPrefix(name)); +}; + +describe('en.json', () => { + it('has every key the code names', () => { + const missing = [...usedKeys].filter( + (key) => !(key in en) && !builtInKeys.has(key) && !(`${key}_other` in en) + ); + assert.deepEqual(missing, []); + }); + + it('has the label, description and group of every topic, and no others', () => { + const expected = [ + ...docsTopics.flatMap(({ id }) => [`topics.${id}.label`, `topics.${id}.description`]), + ...topicGroups.map((group) => `topics.group.${group.toLowerCase()}`), + ]; + assert.deepEqual(Object.keys(en).filter((key) => key.startsWith('topics.')).sort(), expected.sort()); + }); + + // starlight-sidebar-topics identifies topics by their English label in `topics.ts`; pages show the UI string. + it('labels each topic as its sidebar-topics label does', () => { + const mismatched = docsTopics.filter(({ id, label }) => en[`topics.${id}.label`] !== label).map(({ id }) => id); + assert.deepEqual(mismatched, []); + }); + + it('has the label of every sidebar group made from a docs directory, and no others', () => { + const expected = sidebarGroupLabels().map(sidebarGroupKey); + assert.ok(expected.includes('sidebar.group.image-generation'), 'nested Users Guide sections are groups'); + const keys = Object.keys(en).filter((key) => key.startsWith('sidebar.group.')); + assert.deepEqual(keys.sort(), [...new Set(expected)].sort()); + }); + + it('has the tagline of every model family on the home page, and no others', () => { + const keys = Object.keys(en).filter((key) => /^modelFamilies\.[^.]+\.tagline$/.test(key)); + assert.deepEqual(keys.sort(), modelFamilies.map(({ slug }) => `modelFamilies.${slug}.tagline`).sort()); + }); + + it('has no key the code does not use', () => { + // Keys the code builds from ids, which the tests above match to those ids. + const builtByCode = /^(topics\.|sidebar\.group\.|modelFamilies\.[^.]+\.tagline$)/; + const unused = Object.keys(en) + .map((key) => key.replace(PLURAL_SUFFIX, '')) + .filter((key) => !usedKeys.has(key) && !builtByCode.test(key)); + assert.deepEqual(unused, []); + }); + + // A built-in string in en.json comes back as English in every locale Crowdin has not translated it for, in place of + // Starlight's own translation. + it('overrides a built-in Starlight string only to reword it', () => { + const copies = Object.keys(en).filter((key) => builtInKeys.has(key) && en[key] === builtInEnglish[key]); + const notInStarlightEnglish = Object.keys(en).filter((key) => builtInKeys.has(key) && !(key in builtInEnglish)); + assert.deepEqual({ copies, notInStarlightEnglish }, { copies: [], notInStarlightEnglish: [] }); + }); +}); diff --git a/docs/src/lib/i18n.mjs b/docs/src/lib/i18n.mjs new file mode 100644 index 00000000000..44dc0424cf7 --- /dev/null +++ b/docs/src/lib/i18n.mjs @@ -0,0 +1,70 @@ +// UI string helpers beyond `Astro.locals.t`: messages with markup tags (`components/Trans.astro`) and messages that +// client scripts finish themselves. Plain JavaScript so `node --test` can cover it. + +const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other']; + +/** The CLDR plural-category suffix of an i18next plural form's key, as in `search.resultCount_one`. */ +export const PLURAL_SUFFIX = new RegExp(`_(${PLURAL_CATEGORIES.join('|')})$`); + +/** An i18next `{{name}}` placeholder; the first group is the name. Global, so use it with `matchAll` or `replace`. */ +export const PLACEHOLDER = /\{\{\s*(\w+)\s*\}\}/g; + +/** + * @typedef {{ text: string } | { tag: string, text: string }} MessagePart + * @typedef {{ (key: string): string, exists: (key: string) => boolean }} Translate + */ + +/** + * Splits a message into plain text and `text` parts, react-i18next style, so translators can place markup + * such as a link anywhere in the sentence. Tags hold plain text; unclosed and mismatched tags stay literal text. + * @param {string} message + * @returns {MessagePart[]} + */ +export const parseTags = (message) => { + /** @type {MessagePart[]} */ + const parts = []; + let last = 0; + for (const match of message.matchAll(/<([A-Za-z][\w-]*)>([^<]*)<\/\1>/g)) { + const [whole, tag, text] = match; + if (match.index > last) parts.push({ text: message.slice(last, match.index) }); + parts.push({ tag, text }); + last = match.index + whole.length; + } + if (last < message.length) parts.push({ text: message.slice(last) }); + return parts; +}; + +/** + * The plural forms of `key` (`key_one`, `key_other`, …) for a client script to pick from with `formatPlural`, keyed + * by plural category. Forms the locale lacks come from English, as with `t`. + * @param {Translate} t + * @param {string} key + * @returns {Record} + */ +export const pluralForms = (t, key) => + Object.fromEntries( + PLURAL_CATEGORIES.filter((category) => t.exists(`${key}_${category}`)).map((category) => [ + category, + t(`${key}_${category}`), + ]) + ); + +/** + * Fills i18next-style `{{name}}` placeholders. Placeholders without a value stay as they are. + * @param {string} template + * @param {Record} values + */ +export const interpolate = (template, values) => + template.replace(PLACEHOLDER, (placeholder, name) => + Object.hasOwn(values, name) ? String(values[name]) : placeholder + ); + +/** + * The form of `forms` (from `pluralForms`) that `lang` uses for `count`, with `{{count}}` filled in. Falls back to + * `other`, the one form every language has. + * @param {Record} forms + * @param {number} count + * @param {string} lang + */ +export const formatPlural = (forms, count, lang) => + interpolate(forms[new Intl.PluralRules(lang).select(count)] ?? forms.other ?? '', { count }); diff --git a/docs/src/lib/i18n.test.mjs b/docs/src/lib/i18n.test.mjs new file mode 100644 index 00000000000..4a0d315c8cb --- /dev/null +++ b/docs/src/lib/i18n.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { formatPlural, interpolate, parseTags, pluralForms } from './i18n.mjs'; + +describe('parseTags', () => { + it('splits a message around its tags, wherever the translation puts them', () => { + assert.deepEqual(parseTags('Built by Aether Fox Studio.'), [ + { text: 'Built by ' }, + { tag: 'link', text: 'Aether Fox Studio' }, + { text: '.' }, + ]); + assert.deepEqual(parseTags('Aether Fox Studio ने बनाया'), [ + { tag: 'link', text: 'Aether Fox Studio' }, + { text: ' ने बनाया' }, + ]); + assert.deepEqual(parseTags('Aktualisiert am '), [ + { text: 'Aktualisiert am ' }, + { tag: 'time', text: '1. Okt.' }, + ]); + }); + + it('keeps an empty tag, which a script fills in later', () => { + assert.deepEqual(parseTags('Showing commands for '), [ + { text: 'Showing commands for ' }, + { tag: 'setup', text: '' }, + ]); + }); + + it('leaves unclosed and mismatched tags as literal text', () => { + for (const message of [ + 'Report an issue', + 'Report an issue', + 'a < b > c', + ]) { + assert.deepEqual(parseTags(message), [{ text: message }]); + } + }); +}); + +describe('interpolate', () => { + it('fills known placeholders and leaves the others', () => { + assert.equal( + interpolate('No results for “{{ query }}” in {{topic}}.', { query: '', topic: 'Models' }), + 'No results for “” in Models.' + ); + assert.equal(interpolate('default {{value}} of {{missing}}', { value: 0 }), 'default 0 of {{missing}}'); + }); +}); + +describe('plurals', () => { + const english = { results_one: '{{count}} result', results_other: '{{count}} results' }; + /** A `t` over a locale's strings with the English fallback, as Starlight's i18next instance resolves them. */ + const translate = (strings) => { + const lookup = (key) => strings[key] ?? english[key]; + return Object.assign(lookup, { exists: (key) => lookup(key) !== undefined }); + }; + + it("collects the locale's forms, including categories English lacks", () => { + const spanish = { + results_one: '{{count}} resultado', + results_many: '{{count}} de resultados', + results_other: '{{count}} resultados', + }; + assert.deepEqual(pluralForms(translate(spanish), 'results'), { + one: '{{count}} resultado', + many: '{{count}} de resultados', + other: '{{count}} resultados', + }); + assert.deepEqual(pluralForms(translate({}), 'results'), { one: '{{count}} result', other: '{{count}} results' }); + }); + + it("picks the form the language's plural rules call for", () => { + const forms = { one: '{{count}} result', other: '{{count}} results' }; + assert.equal(formatPlural(forms, 1, 'en'), '1 result'); + assert.equal(formatPlural(forms, 0, 'en'), '0 results'); + // Hindi treats 0 as singular. + assert.equal(formatPlural({ one: '{{count}} परिणाम', other: '{{count}} परिणामों' }, 0, 'hi'), '0 परिणाम'); + const spanish = { one: '{{count}} resultado', many: '{{count}} de resultados', other: '{{count}} resultados' }; + assert.equal(formatPlural(spanish, 1000000, 'es'), '1000000 de resultados'); + assert.equal(formatPlural(spanish, 3, 'es'), '3 resultados'); + }); + + it('falls back to the other form when the translation lacks the chosen category', () => { + // Russian picks `few` for 2. + const russian = { one: '{{count}} результат', other: '{{count}} результатов' }; + assert.equal(formatPlural(russian, 2, 'ru'), '2 результатов'); + }); +}); diff --git a/docs/src/lib/launcher-release.mjs b/docs/src/lib/launcher-release.mjs new file mode 100644 index 00000000000..0fab8d9f3bf --- /dev/null +++ b/docs/src/lib/launcher-release.mjs @@ -0,0 +1,72 @@ +// The latest Invoke Launcher release, for the download page's labels: version, date and file sizes. The download +// buttons use GitHub's `releases/latest/download/` URLs, so a stale fallback never breaks a download. + +export const LAUNCHER_RELEASE_API = 'https://api.github.com/repos/invoke-ai/launcher/releases/latest'; + +/** Each platform's installer, by file name suffix; `.blockmap` and `.yml` update metadata never match. */ +const ASSET_SUFFIXES = { + windows: '.exe', + macos: '-arm64.dmg', + linux: '.AppImage', +}; + +/** + * Picks each platform's installer from a GitHub release; a platform without one is `null`. Releases carry each + * installer twice, as a versioned file and as a `latest` alias of the same size. + * + * @param {{ tag_name: string, published_at: string, html_url: string, assets: { name: string, size: number }[] }} json + */ +export function parseLauncherRelease(json) { + const assets = {}; + for (const [platform, suffix] of Object.entries(ASSET_SUFFIXES)) { + const asset = json.assets.find((candidate) => candidate.name.endsWith(suffix)); + assets[platform] = asset ? { size: asset.size } : null; + } + return { + version: json.tag_name.replace(/^v/, ''), + publishedAt: json.published_at, + notesUrl: json.html_url, + assets, + }; +} + +/** Bytes as the binary units Windows Explorer and GitHub show: `108 MB`, `1.2 GB`. */ +export function formatSize(bytes) { + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`; +} + +/** + * Fetches and parses the latest release. A token lifts GitHub's 60 requests/hour unauthenticated limit, which CI + * runners share per IP. Throws on an HTTP error (a rate limit included) or after 5 seconds without an answer. + */ +export async function fetchLauncherRelease({ token, fetch = globalThis.fetch } = {}) { + const headers = { Accept: 'application/vnd.github+json' }; + if (token) headers.Authorization = `Bearer ${token}`; + // A stalled connection would otherwise hold up the build, or a dev render, until the socket gives up. + const response = await fetch(LAUNCHER_RELEASE_API, { headers, signal: AbortSignal.timeout(5000) }); + if (!response.ok) throw new Error(`GitHub answered ${response.status} ${response.statusText}`); + return parseLauncherRelease(await response.json()); +} + +let latest; + +/** + * The latest release, fetched once per build (or dev server), or `fallback` with one warning when the fetch fails. + * The fallback is `src/data/launcher-release.json`; `pnpm run refresh-launcher-release` rewrites it from GitHub. + */ +export function loadLauncherRelease({ fallback, token, fetch = globalThis.fetch }) { + latest ??= fetchLauncherRelease({ token, fetch }).catch((error) => { + console.warn( + `[download] Could not fetch the latest launcher release (${error.message}); using cached v${fallback.version}.`, + ); + return fallback; + }); + return latest; +} diff --git a/docs/src/lib/launcher-release.test.mjs b/docs/src/lib/launcher-release.test.mjs new file mode 100644 index 00000000000..149445439a4 --- /dev/null +++ b/docs/src/lib/launcher-release.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; + +import { fetchLauncherRelease, formatSize, loadLauncherRelease, parseLauncherRelease } from './launcher-release.mjs'; + +// Trimmed from the v1.8.5 release: installers next to update metadata (`.blockmap`, `.yml`) and the macOS update zip. +const release = { + tag_name: 'v1.8.5', + published_at: '2026-10-10T21:05:09Z', + html_url: 'https://github.com/invoke-ai/launcher/releases/tag/v1.8.5', + assets: [ + { name: 'Invoke-Community-Edition-Setup-1.8.5.exe.blockmap', size: 118432 }, + { name: 'Invoke-Community-Edition-Setup-1.8.5.exe', size: 113202136 }, + { name: 'Invoke-Community-Edition-1.8.5-arm64-mac.zip', size: 128353194 }, + { name: 'Invoke-Community-Edition-1.8.5-arm64.dmg.blockmap', size: 139373 }, + { name: 'Invoke-Community-Edition-1.8.5-arm64.dmg', size: 132949779 }, + { name: 'Invoke.Community.Edition-latest.AppImage', size: 145780235 }, + { name: 'latest-linux.yml', size: 400 }, + ], +}; + +const respond = (body) => async () => ({ ok: true, json: async () => body }); + +/** The value's structure with each leaf replaced by its type, to compare data shapes. */ +const shape = (value) => + value && typeof value === 'object' + ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, shape(entry)])) + : typeof value; + +describe('parseLauncherRelease', () => { + it('picks each platform installer, skipping update metadata', () => { + assert.deepEqual(parseLauncherRelease(release), { + version: '1.8.5', + publishedAt: '2026-10-10T21:05:09Z', + notesUrl: 'https://github.com/invoke-ai/launcher/releases/tag/v1.8.5', + assets: { windows: { size: 113202136 }, macos: { size: 132949779 }, linux: { size: 145780235 } }, + }); + }); + + it('leaves a platform without an installer null', () => { + const withoutMac = { ...release, assets: release.assets.filter((a) => !a.name.includes('arm64')) }; + const parsed = parseLauncherRelease(withoutMac); + assert.equal(parsed.assets.macos, null); + assert.deepEqual(parsed.assets.windows, { size: 113202136 }); + }); + + it('matches the shape of the cached fallback the page reads', () => { + const cached = JSON.parse(readFileSync(new URL('../data/launcher-release.json', import.meta.url), 'utf8')); + assert.deepEqual(shape(cached), shape(parseLauncherRelease(release))); + }); +}); + +describe('formatSize', () => { + it('uses binary units, whole numbers from 10 up and one decimal below', () => { + assert.equal(formatSize(113202136), '108 MB'); + assert.equal(formatSize(118432), '116 KB'); + assert.equal(formatSize(5 * 1024 * 1024 + 300 * 1024), '5.3 MB'); + assert.equal(formatSize(1.25 * 1024 ** 3), '1.3 GB'); + }); +}); + +describe('fetchLauncherRelease', () => { + it('sends the token as a bearer header, with a timeout, and parses the response', async () => { + let request; + const fetch = async (url, init) => { + request = { url, init }; + return { ok: true, json: async () => release }; + }; + const parsed = await fetchLauncherRelease({ token: 'secret', fetch }); + assert.equal(request.url, 'https://api.github.com/repos/invoke-ai/launcher/releases/latest'); + assert.equal(request.init.headers.Authorization, 'Bearer secret'); + assert.ok(request.init.signal instanceof AbortSignal); + assert.equal(parsed.version, '1.8.5'); + }); + + it('rejects on a rate limit so the caller can fall back', async () => { + const fetch = async () => ({ ok: false, status: 403, statusText: 'rate limit exceeded' }); + await assert.rejects(fetchLauncherRelease({ fetch }), /403/); + }); +}); + +describe('loadLauncherRelease', () => { + // The release is loaded once per process, so this one test covers the fallback and the memoisation. + it('falls back with one warning on a failed fetch, and keeps that result', async (t) => { + const warn = t.mock.method(console, 'warn', () => {}); + const fallback = { version: '1.0.0' }; + const failing = async () => { + throw new Error('The operation was aborted due to timeout'); + }; + + assert.equal(await loadLauncherRelease({ fallback, fetch: failing }), fallback); + assert.equal(warn.mock.callCount(), 1); + assert.match(warn.mock.calls[0].arguments[0], /timeout.*cached v1\.0\.0/); + + const fetch = t.mock.fn(respond(release)); + assert.equal(await loadLauncherRelease({ fallback, fetch }), fallback); + assert.equal(fetch.mock.callCount(), 0); + assert.equal(warn.mock.callCount(), 1); + }); +}); diff --git a/docs/src/lib/locale-links.mjs b/docs/src/lib/locale-links.mjs new file mode 100644 index 00000000000..c22dfd41dae --- /dev/null +++ b/docs/src/lib/locale-links.mjs @@ -0,0 +1,46 @@ +import { localizePath, splitSitePath } from '../config/locales.mjs'; + +// Keeps readers in their language: Markdown links are root-relative English paths, and a page without a translation +// renders the English page's HTML, so the links are moved into the page's locale after rendering +// (`components/MarkdownContent.astro`). Plain JavaScript so `node --test` can cover it. + +/** + * Comments, script and style elements (whose text may hold markup-like strings), and start tags, whose quoted + * attribute values may hold `>` and `<`, as Expressive Code's `data-code` does. Consuming every start tag keeps an + * `"']|"[^"]*"|'[^']*')*`; +const tokenRegEx = new RegExp( + [ + '', + `<(script|style)\\b${TAG_BODY}>[\\s\\S]*?`, + `<([a-z][^\\s/>]*)(${TAG_BODY})>`, + ].join('|'), + 'gi' +); +/** One attribute of a start tag; the double-quoted value, as Astro writes `href`, is the second group. */ +const attributeRegEx = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'[^']*'|[^\s"'=<>`]+))?/g; + +/** + * Moves the root-relative `href` values of anchors in rendered HTML into `locale`. Hrefs already carry the base path + * (`plugins/rehype-prefix-base-to-root-links.mjs`); links outside it or already in a locale, and relative, + * protocol-relative and external links, stay as they are. + * @param {string} html + * @param {{ base: string, locale: string }} options `base` is Astro's `BASE_URL`, such as `/` or `/InvokeAI` + */ +export const localizeHtmlLinks = (html, { base, locale }) => { + const basePath = base.replace(/\/+$/, ''); + /** @param {string} href */ + const localizeHref = (href) => { + if (!href.startsWith('/') || href.startsWith('//')) return href; + const site = splitSitePath(href, base); + return site && !site.locale ? `${basePath}${localizePath(site.path, locale)}` : href; + }; + return html.replace(tokenRegEx, (token, _rawText, tagName, attributes) => + tagName?.toLowerCase() === 'a' + ? `<${tagName}${attributes.replace(attributeRegEx, (attribute, name, value) => + name.toLowerCase() === 'href' && value !== undefined ? `${name}="${localizeHref(value)}"` : attribute + )}>` + : token + ); +}; diff --git a/docs/src/lib/locale-links.test.mjs b/docs/src/lib/locale-links.test.mjs new file mode 100644 index 00000000000..22277e6be16 --- /dev/null +++ b/docs/src/lib/locale-links.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { localizeHtmlLinks } from './locale-links.mjs'; + +/** @param {string} href */ +const anchor = (href) => `text`; + +describe('localizeHtmlLinks', () => { + it('moves root-relative docs links into the locale, keeping fragments and other attributes', () => { + const html = `

See ${anchor('/users-guide/gallery/introduction/#boards')} and home.

`; + assert.equal( + localizeHtmlLinks(html, { base: '/', locale: 'de' }), + `

See ${anchor('/de/users-guide/gallery/introduction/#boards')} and home.

` + ); + }); + + it('keeps the base path in front of the locale', () => { + assert.equal( + localizeHtmlLinks(anchor('/InvokeAI/start-here/manual/'), { base: '/InvokeAI', locale: 'es' }), + anchor('/InvokeAI/es/start-here/manual/') + ); + assert.equal(localizeHtmlLinks(anchor('/InvokeAI'), { base: '/InvokeAI/', locale: 'es' }), anchor('/InvokeAI/es/')); + }); + + it('leaves root-only, localized, relative, external, protocol-relative and fragment links alone', () => { + const hrefs = [ + '/download/', + '/showcase/#submit', + '/_astro/image.png', + '/hi/configuration/docker/', + 'start-here/upgrading/', + '../assets/a.png', + 'https://github.com/invoke-ai/InvokeAI', + '//cdn.example.com/x.js', + '#settings', + ]; + const html = hrefs.map(anchor).join(''); + assert.equal(localizeHtmlLinks(html, { base: '/', locale: 'de' }), html); + }); + + it('leaves links outside the base path alone', () => { + const html = anchor('/InvokeAIx/page/') + anchor('/other/page/'); + assert.equal(localizeHtmlLinks(html, { base: '/InvokeAI', locale: 'de' }), html); + }); + + it('reads quoted attribute values whole, whatever they contain', () => { + // Expressive Code keeps `<` in `data-code` and escapes `"` only. + const code = ``; + assert.equal( + localizeHtmlLinks(code, { base: '/', locale: 'de' }), + `` + ); + const lookalike = `y`; + assert.equal( + localizeHtmlLinks(lookalike, { base: '/', locale: 'de' }), + `y` + ); + }); + + it('leaves script and style text alone', () => { + const html = ''; + assert.equal(localizeHtmlLinks(html, { base: '/', locale: 'de' }), html); + }); + + it('only rewrites the href of anchors', () => { + const html = 'x'; + assert.equal( + localizeHtmlLinks(html, { base: '/', locale: 'de' }), + 'x' + ); + }); +}); diff --git a/docs/src/lib/marquee.mjs b/docs/src/lib/marquee.mjs new file mode 100644 index 00000000000..5d630a4600f --- /dev/null +++ b/docs/src/lib/marquee.mjs @@ -0,0 +1,35 @@ +// The showcase marquee's motion (`ShowcaseMarquee.astro`): a steady drift that page scrolling speeds up and turns +// around, after reactbits' Scroll Velocity. + +/** + * An offset in the loop, from just above `-unit` to 0. The track holds two copies of the loop unit, so any offset in + * that range shows a seamless strip; wrapping keeps it there whichever way the strip moves. + * @param {number} offset in pixels + * @param {number} unit the loop unit's width in pixels + */ +export const wrapOffset = (offset, unit) => (unit > 0 ? (((offset % unit) - unit) % unit) + 0 : 0); + +/** + * Extra speed from scroll velocity, as a multiple of the drift: `full` pixels a second of scrolling adds `max` times + * the drift. Its sign is the scroll direction; it is clamped to `±max`. + * @param {number} velocity in pixels a second, positive when scrolling down + * @param {{ full?: number, max?: number }} [options] + */ +export const velocityFactor = (velocity, { full = 1000, max = 5 } = {}) => + Math.max(-max, Math.min(max, (velocity / full) * max)); + +/** + * Advances a damped spring (unit mass) towards `target` by `dt` seconds, in steps short enough to stay stable. + * @param {{ value: number, velocity: number }} spring updated in place + * @param {number} target + * @param {number} dt in seconds + * @param {{ stiffness?: number, damping?: number }} [options] + */ +export const stepSpring = (spring, target, dt, { stiffness = 400, damping = 50 } = {}) => { + for (let remaining = dt; remaining > 0; remaining -= 1 / 120) { + const step = Math.min(remaining, 1 / 120); + spring.velocity += (stiffness * (target - spring.value) - damping * spring.velocity) * step; + spring.value += spring.velocity * step; + } + return spring; +}; diff --git a/docs/src/lib/marquee.test.mjs b/docs/src/lib/marquee.test.mjs new file mode 100644 index 00000000000..1119070c0ca --- /dev/null +++ b/docs/src/lib/marquee.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { stepSpring, velocityFactor, wrapOffset } from './marquee.mjs'; + +describe('wrapOffset', () => { + it('keeps offsets within one loop unit to the left of the start', () => { + assert.equal(wrapOffset(-250, 1000), -250); + assert.equal(wrapOffset(-1250, 1000), -250); + assert.equal(wrapOffset(-3250, 1000), -250); + }); + + it('wraps offsets past the start when the strip runs backwards', () => { + assert.equal(wrapOffset(250, 1000), -750); + assert.equal(wrapOffset(1250, 1000), -750); + }); + + it('maps both ends of the unit to the start', () => { + assert.equal(wrapOffset(0, 1000), 0); + assert.equal(wrapOffset(-1000, 1000), 0); + assert.ok(Object.is(wrapOffset(-1000, 1000), 0), 'not -0, which would write "-0px"'); + }); + + it('stays at the start while the unit has no width', () => { + assert.equal(wrapOffset(-250, 0), 0); + }); +}); + +describe('velocityFactor', () => { + it('scales with scroll velocity and keeps its direction', () => { + assert.equal(velocityFactor(0), 0); + assert.equal(velocityFactor(500), 2.5); + assert.equal(velocityFactor(-200), -1); + }); + + it('is clamped to the maximum either way', () => { + assert.equal(velocityFactor(9000), 5); + assert.equal(velocityFactor(-9000), -5); + assert.equal(velocityFactor(9000, { max: 3 }), 3); + }); +}); + +describe('stepSpring', () => { + it('settles on its target', () => { + const spring = { value: 0, velocity: 0 }; + for (let frame = 0; frame < 120; frame += 1) stepSpring(spring, 800, 1 / 60); + assert.ok(Math.abs(spring.value - 800) < 1, `value ${spring.value}`); + assert.ok(Math.abs(spring.velocity) < 5, `velocity ${spring.velocity}`); + }); + + it('eases back to rest without overshooting far past it', () => { + const spring = { value: 1000, velocity: 0 }; + let lowest = Infinity; + for (let frame = 0; frame < 120; frame += 1) lowest = Math.min(lowest, stepSpring(spring, 0, 1 / 60).value); + assert.ok(Math.abs(spring.value) < 1, `value ${spring.value}`); + assert.ok(lowest > -100, `overshoot ${lowest}`); + }); + + it('stays stable across a long frame', () => { + const spring = { value: 0, velocity: 0 }; + stepSpring(spring, 1000, 0.5); + assert.ok(Number.isFinite(spring.value) && Math.abs(spring.value - 1000) < 50, `value ${spring.value}`); + }); +}); diff --git a/docs/src/lib/report-issue.ts b/docs/src/lib/report-issue.ts new file mode 100644 index 00000000000..267b1d14d28 --- /dev/null +++ b/docs/src/lib/report-issue.ts @@ -0,0 +1,12 @@ +/** + * A new-issue link for a documentation page, using the `DOCS_ISSUE.yml` issue form with the page URL filled in. + * @param title the page title + * @param pageUrl the page's absolute URL + */ +export const getReportIssueUrl = (title: string, pageUrl: string) => { + const url = new URL('https://github.com/invoke-ai/InvokeAI/issues/new'); + url.searchParams.set('template', 'DOCS_ISSUE.yml'); + url.searchParams.set('title', `[docs]: ${title}`); + url.searchParams.set('page', pageUrl); + return url.href; +}; diff --git a/docs/src/lib/section-icon.mjs b/docs/src/lib/section-icon.mjs new file mode 100644 index 00000000000..4b3ba7b4cfb --- /dev/null +++ b/docs/src/lib/section-icon.mjs @@ -0,0 +1,22 @@ +/** + * A docs page's icon: its own, if it has one, else that of the section it belongs to, else of its topic, else + * `undefined`. Sections and topics match by the deepest of the page's directories that has an icon, a section's + * icon winning over a topic's for the same directory. + * @template Icon + * @param {string} path the page's path without the deployment base path, e.g. `/configuration/docker/` + * @param {Record} pageIcons page path (as in a URL, without slashes at the ends) -> icon + * @param {Record} sectionIcons section directory (as a URL path) -> icon + * @param {{ directory: string, icon: Icon }[]} topicIcons + * @returns {Icon | undefined} + */ +export const findSectionIcon = (path, pageIcons, sectionIcons, topicIcons) => { + const page = path.replace(/^\/+|\/+$/g, ''); + if (pageIcons[page]) return pageIcons[page]; + const segments = page.split('/'); + for (let length = segments.length; length > 0; length--) { + const directory = segments.slice(0, length).join('/'); + const icon = sectionIcons[directory] ?? topicIcons.find((topic) => topic.directory === directory)?.icon; + if (icon) return icon; + } + return undefined; +}; diff --git a/docs/src/lib/section-icon.test.mjs b/docs/src/lib/section-icon.test.mjs new file mode 100644 index 00000000000..cf37dac6c29 --- /dev/null +++ b/docs/src/lib/section-icon.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { findSectionIcon } from './section-icon.mjs'; + +const sections = { + 'configuration/optimization': 'gauge', + 'guide/images/advanced': 'flask', + 'guide/models/local': 'cpu', +}; +const topics = [ + { directory: 'configuration', icon: 'settings' }, + { directory: 'guide', icon: 'book' }, + { directory: 'guide/models', icon: 'box' }, +]; +const pages = { 'configuration/docker': 'container' }; +const find = (path) => findSectionIcon(path, pages, sections, topics); + +describe('findSectionIcon', () => { + it('prefers a page\'s own icon', () => { + assert.equal(find('/configuration/docker/'), 'container'); + assert.equal(find('/configuration/invokeai-yaml/'), 'settings'); + }); + + it("uses the icon of the page's section, however deep the page is", () => { + assert.equal(find('/configuration/optimization/low-vram-mode/'), 'gauge'); + assert.equal(find('guide/images/advanced/hidiffusion'), 'flask'); + // A section's index page is the section itself. + assert.equal(find('/configuration/optimization/'), 'gauge'); + }); + + it("falls back to the page's topic, the closest one first", () => { + assert.equal(find('/configuration/external-database/'), 'settings'); + assert.equal(find('/guide/models/introduction/'), 'box'); + assert.equal(find('/guide/images/intro/'), 'book'); + }); + + it('has no icon for pages outside every topic', () => { + assert.equal(find('/download/'), undefined); + assert.equal(find('/'), undefined); + }); +}); diff --git a/docs/src/lib/showcase.mjs b/docs/src/lib/showcase.mjs new file mode 100644 index 00000000000..0a688769b66 --- /dev/null +++ b/docs/src/lib/showcase.mjs @@ -0,0 +1,18 @@ +// The community showcase: entries live in `src/content/showcase/` and arrive through a GitHub issue form. + +/** The issue form for showcase submissions, `.github/ISSUE_TEMPLATE/SHOWCASE.yml`. */ +export const SHOWCASE_SUBMIT_URL = 'https://github.com/invoke-ai/InvokeAI/issues/new?template=SHOWCASE.yml'; + +/** + * Newest first. Entries from the same date are ordered by title, then id, so builds are stable. + * @template {{ id: string, data: { date: Date, title: string } }} T + * @param {readonly T[]} entries + * @returns {T[]} + */ +export const sortShowcase = (entries) => + [...entries].sort( + (a, b) => + b.data.date.getTime() - a.data.date.getTime() || + a.data.title.localeCompare(b.data.title) || + a.id.localeCompare(b.id), + ); diff --git a/docs/src/lib/showcase.test.mjs b/docs/src/lib/showcase.test.mjs new file mode 100644 index 00000000000..7f8f19473ac --- /dev/null +++ b/docs/src/lib/showcase.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { sortShowcase } from './showcase.mjs'; + +/** @param {string} id @param {string} date @param {string} title */ +const entry = (id, date, title) => ({ id, data: { date: new Date(date), title } }); + +describe('sortShowcase', () => { + it('puts the newest entries first', () => { + const entries = [entry('a', '2026-01-05', 'A'), entry('b', '2026-03-01', 'B'), entry('c', '2025-12-31', 'C')]; + assert.deepEqual( + sortShowcase(entries).map(({ id }) => id), + ['b', 'a', 'c'], + ); + }); + + it('orders entries from the same date by title, then id', () => { + const entries = [ + entry('z', '2026-02-02', 'Harbour'), + entry('y', '2026-02-02', 'Atlas'), + entry('x', '2026-02-02', 'Harbour'), + ]; + assert.deepEqual( + sortShowcase(entries).map(({ id }) => id), + ['y', 'x', 'z'], + ); + }); + + it('leaves the given array as it was', () => { + const entries = [entry('a', '2026-01-01', 'A'), entry('b', '2026-02-01', 'B')]; + sortShowcase(entries); + assert.deepEqual( + entries.map(({ id }) => id), + ['a', 'b'], + ); + }); +}); diff --git a/docs/src/lib/sidebar-state.mjs b/docs/src/lib/sidebar-state.mjs new file mode 100644 index 00000000000..e5a1c2cdd96 --- /dev/null +++ b/docs/src/lib/sidebar-state.mjs @@ -0,0 +1,74 @@ +// Keeps the sidebar's sections open or closed as the reader left them, across pages of the session. Starlight's own +// persistence keeps one state for one sidebar and only restores it on wide screens, so every topic switch, and every +// page on a phone, reset the sections; this keys the state by topic and section directory instead, which every locale +// shares. Plain JavaScript so `node --test` can exercise it; `Sidebar.astro` runs `mountSidebarState` as an inline +// script. + +/** @param {string} topicId */ +export const sidebarStateKey = (topicId) => `docs-sidebar:${topicId}`; + +/** + * Whether a section opens on arrival: the section holding the current page always does; otherwise the reader's last + * choice wins over the sidebar's default. + * @param {{ rendered: boolean, containsCurrent: boolean, stored: unknown }} section + */ +export const resolveOpen = ({ rendered, containsCurrent, stored }) => + containsCurrent || (typeof stored === 'boolean' ? stored : rendered); + +/** + * The scroll position to remember, or `undefined` while the sidebar is hidden (the closed mobile drawer), whose + * position of 0 would overwrite the one kept from a wider window. + * @param {{ clientHeight: number, scrollTop: number } | null} scroller + */ +export const visibleScrollTop = (scroller) => + scroller && scroller.clientHeight > 0 ? scroller.scrollTop : undefined; + +/** + * Restores the sections and scroll position of the sidebar tree, then records the reader's changes. Runs inline right + * after the tree, before it paints, so sections never animate into place. Serialized with `toString()`: it must only + * use its arguments and globals. Storage may be unavailable; the sidebar then keeps its defaults. + * @param {Element} tree + * @param {string} key + * @param {typeof resolveOpen} resolve + * @param {typeof visibleScrollTop} scrollTopToKeep + */ +export const mountSidebarState = (tree, key, resolve, scrollTopToKeep) => { + const scroller = document.getElementById('starlight__sidebar'); + /** A section's directory (`SidebarTree.astro`), the same in every locale; state kept under other keys is ignored. */ + const sectionOf = (/** @type {HTMLElement} */ details) => details.dataset.section ?? ''; + + /** @type {{ open: Record, scroll?: number }} */ + let state = { open: {} }; + try { + const stored = JSON.parse(sessionStorage.getItem(key) || '{}'); + if (stored && typeof stored.open === 'object' && stored.open) state = stored; + } catch {} + const save = () => { + try { + sessionStorage.setItem(key, JSON.stringify(state)); + } catch {} + }; + + for (const details of tree.querySelectorAll('details')) { + details.open = resolve({ + rendered: details.open, + containsCurrent: details.querySelector('[aria-current="page"]') !== null, + stored: state.open[sectionOf(details)], + }); + } + if (scroller && typeof state.scroll === 'number') scroller.scrollTop = state.scroll; + + // A click on a summary toggles its section after the event, so record the state it is about to take. + tree.addEventListener('click', (event) => { + const details = event.target instanceof Element ? event.target.closest('summary')?.parentElement : null; + if (!(details instanceof HTMLDetailsElement)) return; + state.open[sectionOf(details)] = !details.open; + save(); + }); + addEventListener('pagehide', () => { + const scroll = scrollTopToKeep(scroller); + if (scroll === undefined) return; + state.scroll = scroll; + save(); + }); +}; diff --git a/docs/src/lib/sidebar-state.test.mjs b/docs/src/lib/sidebar-state.test.mjs new file mode 100644 index 00000000000..a06942d05ed --- /dev/null +++ b/docs/src/lib/sidebar-state.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { resolveOpen, visibleScrollTop } from './sidebar-state.mjs'; + +describe('resolveOpen', () => { + it("prefers the reader's last choice to the sidebar's default", () => { + assert.equal(resolveOpen({ rendered: false, containsCurrent: false, stored: true }), true); + assert.equal(resolveOpen({ rendered: true, containsCurrent: false, stored: false }), false); + }); + + it('keeps the default for a section the reader never toggled', () => { + assert.equal(resolveOpen({ rendered: true, containsCurrent: false, stored: undefined }), true); + assert.equal(resolveOpen({ rendered: false, containsCurrent: false, stored: 'yes' }), false); + }); + + it('always opens the section holding the current page', () => { + assert.equal(resolveOpen({ rendered: true, containsCurrent: true, stored: false }), true); + }); +}); + +describe('visibleScrollTop', () => { + it('keeps the position of a visible sidebar', () => { + assert.equal(visibleScrollTop({ clientHeight: 800, scrollTop: 240 }), 240); + }); + + it('keeps nothing while the sidebar is hidden, so a closed drawer does not reset the position', () => { + assert.equal(visibleScrollTop({ clientHeight: 0, scrollTop: 0 }), undefined); + assert.equal(visibleScrollTop(null), undefined); + }); +}); diff --git a/docs/src/lib/sidebar.mjs b/docs/src/lib/sidebar.mjs index acb8383d3ff..aa2410a4cd9 100644 --- a/docs/src/lib/sidebar.mjs +++ b/docs/src/lib/sidebar.mjs @@ -1,3 +1,6 @@ +import { slug } from 'github-slugger'; + +import { splitSitePath } from '../config/locales.mjs'; import { generateDocsId, getSortPrefix, stripSortPrefix } from './sort-prefix.mjs'; // Sidebar post-processing for the Starlight route middleware (`src/route-data.ts`): sort-prefix ordering, @@ -22,17 +25,12 @@ export const trimSlashes = (path) => path.replace(/^\/+|\/+$/g, ''); export const parentDirectory = (id) => id.slice(0, Math.max(id.lastIndexOf('/'), 0)); /** - * Maps a sidebar link's href to a docs page id, removing the deployment base path. + * Maps a sidebar link's href to the English docs page id, removing the deployment base path and any locale: a + * translation mirrors its English file's name and directory, so English data drives every locale's sidebar. * @param {string} baseUrl * @returns {(href: string) => string} */ -export const makeHrefToId = (baseUrl) => { - const basePath = trimSlashes(baseUrl); - return (href) => { - const path = trimSlashes(href); - return basePath && path.startsWith(`${basePath}/`) ? path.slice(basePath.length + 1) : path; - }; -}; +export const makeHrefToId = (baseUrl) => (href) => trimSlashes(splitSitePath(href, baseUrl)?.path ?? href); /** * A docs file's directory as a URL path, e.g. `src/content/docs/Users Guide/03.Models/x.mdx` -> `users-guide/models`. @@ -66,10 +64,8 @@ export const selectCrossListings = (docs, isProduction) => * @param {SidebarEntry[]} entries * @param {Map} fileNames page id -> file name, which keeps the sorting prefix the id drops * @param {(href: string) => string} hrefToId - * @returns {boolean} whether anything moved */ export const sortAndRelabel = (entries, fileNames, hrefToId) => { - let changed = false; /** @param {SidebarEntry} entry */ const sortKey = (entry) => getSortPrefix(entry.type === 'group' ? entry.label : (fileNames.get(hrefToId(entry.href)) ?? '')) ?? @@ -84,7 +80,6 @@ export const sortAndRelabel = (entries, fileNames, hrefToId) => { for (const slots of slotsByDirectory.values()) { const sorted = slots.map((index) => entries[index]).sort((a, b) => sortKey(a) - sortKey(b)); slots.forEach((slot, i) => { - if (entries[slot] !== sorted[i]) changed = true; entries[slot] = sorted[i]; }); } @@ -92,9 +87,29 @@ export const sortAndRelabel = (entries, fileNames, hrefToId) => { for (const entry of entries) { if (entry.type !== 'group') continue; if ('autogenerate' in entry && entry.autogenerate) entry.label = stripSortPrefix(entry.label); - if (sortAndRelabel(entry.entries, fileNames, hrefToId)) changed = true; + sortAndRelabel(entry.entries, fileNames, hrefToId); + } +}; + +/** + * The UI string key of a sidebar group's label: the label's slug, which is also the last segment of the URL path of + * the directory a group is generated from, e.g. `sidebar.group.image-generation`. + * @param {string} label the English label + */ +export const sidebarGroupKey = (label) => `sidebar.group.${slug(label)}`; + +/** + * Translates the labels of groups at every depth, directory-derived ones included. A label without a UI string stays. + * @param {SidebarEntry[]} entries + * @param {{ (key: string): string, exists: (key: string) => boolean }} t + */ +export const translateGroupLabels = (entries, t) => { + for (const entry of entries) { + if (entry.type !== 'group') continue; + const key = sidebarGroupKey(entry.label); + if (t.exists(key)) entry.label = t(key); + translateGroupLabels(entry.entries, t); } - return changed; }; /** @@ -133,10 +148,8 @@ export const findDirectoryGroup = (entries, directory, directories, hrefToId) => * @param {CrossListing[]} crossListings * @param {Map} directories page id -> file directory * @param {(href: string) => string} hrefToId - * @returns {boolean} whether anything was added */ export const addCrossListings = (sidebar, crossListings, directories, hrefToId) => { - if (crossListings.length === 0) return false; const linksById = new Map(flattenLinks(sidebar).map((link) => [hrefToId(link.href), link])); for (const { id, directory } of crossListings) { const link = linksById.get(id); @@ -155,7 +168,6 @@ export const addCrossListings = (sidebar, crossListings, directories, hrefToId) // The page's own link carries the current-page state, so the copy never does. group.splice(index, 0, { ...link, isCurrent: false, attrs: { ...link.attrs, [CROSS_LISTED_ATTR]: '' } }); } - return true; }; /** @@ -190,3 +202,43 @@ export const paginate = (sidebar, data) => { next: applyPrevNextConfig(links[current + 1], data.next), }; }; + +/** + * The labels of the groups enclosing the current page's link, outermost first, and the link itself, for a breadcrumb. + * `undefined` when the current page is not in the sidebar. + * @param {SidebarEntry[]} entries + * @returns {{ groups: string[], link: SidebarLink } | undefined} + */ +export const findCurrentTrail = (entries) => { + for (const entry of entries) { + if (entry.type === 'link') { + if (entry.isCurrent) return { groups: [], link: entry }; + continue; + } + const trail = findCurrentTrail(entry.entries); + if (trail) return { groups: [entry.label, ...trail.groups], link: trail.link }; + } + return undefined; +}; + +/** + * The docs directory, as a URL path, that a sidebar section was generated from: the deepest directory holding all of + * its pages. Cross-listed copies belong to other directories and are ignored. `undefined` for a section without pages. + * @param {Extract} group + * @param {Map} directories page id -> file directory + * @param {(href: string) => string} hrefToId + */ +export const sectionDirectory = (group, directories, hrefToId) => { + const paths = flattenLinks(group.entries) + .filter((link) => !(CROSS_LISTED_ATTR in link.attrs)) + .map((link) => directories.get(hrefToId(link.href))) + .filter((directory) => directory !== undefined) + .map((directory) => directory.split('/')); + if (paths.length === 0) return undefined; + const [first, ...rest] = paths; + let length = first.length; + for (const path of rest) { + while (length > 0 && path.slice(0, length).join('/') !== first.slice(0, length).join('/')) length--; + } + return first.slice(0, length).join('/'); +}; diff --git a/docs/src/lib/sidebar.test.mjs b/docs/src/lib/sidebar.test.mjs index 11873e02ebf..64d79144622 100644 --- a/docs/src/lib/sidebar.test.mjs +++ b/docs/src/lib/sidebar.test.mjs @@ -5,10 +5,13 @@ import { addCrossListings, CROSS_LISTED_ATTR, fileDirectory, + findCurrentTrail, makeHrefToId, paginate, + sectionDirectory, selectCrossListings, sortAndRelabel, + translateGroupLabels, } from './sidebar.mjs'; import { generateDocsId } from './sort-prefix.mjs'; @@ -43,6 +46,26 @@ describe('generateDocsId', () => { }); }); +describe('translateGroupLabels', () => { + it('translates group labels at every depth by their slug, keeping labels without a string and link labels', () => { + const german = { + 'sidebar.group.image-generation': 'Bildgenerierung', + 'sidebar.group.advanced-topics': 'Fortgeschritten', + }; + const t = Object.assign((key) => german[key], { exists: (key) => key in german }); + const sidebar = [ + group('Image Generation', 'image-generation', [ + link('image-generation/introduction', 'Introduction'), + group('Advanced Topics', 'image-generation/advanced-topics', [link('image-generation/advanced-topics/x')]), + ]), + group('Gallery', 'gallery', []), + ]; + translateGroupLabels(sidebar, t); + assert.deepEqual(labels(sidebar), ['Bildgenerierung', 'Gallery']); + assert.deepEqual(labels(sidebar[0].entries), ['Introduction', 'Fortgeschritten']); + }); +}); + describe('sortAndRelabel', () => { it('orders prefixed siblings by prefix, keeps unprefixed ones after them in order, and strips group labels', () => { const fileNames = new Map([ @@ -57,7 +80,7 @@ describe('sortAndRelabel', () => { { ...link('guide/a'), autogenerate: { directory: 'guide' } }, { ...link('guide/intro'), autogenerate: { directory: 'guide' } }, ]; - assert.equal(sortAndRelabel(entries, fileNames, hrefToId), true); + sortAndRelabel(entries, fileNames, hrefToId); assert.deepEqual(labels(entries), ['intro', 'setup', 'b', 'a']); }); @@ -76,11 +99,6 @@ describe('sortAndRelabel', () => { assert.deepEqual(labels(entries), ['Workbench', 'Models']); assert.deepEqual(labels(models.entries), ['introduction', 'zoo']); }); - - it('reports no change when the order is already right', () => { - const entries = [{ ...link('x/a'), autogenerate: { directory: 'x' } }]; - assert.equal(sortAndRelabel(entries, new Map([['x/a', '01.a.mdx']]), hrefToId), false); - }); }); describe('fileDirectory', () => { @@ -134,7 +152,7 @@ describe('addCrossListings', () => { { id: 'video/wan', directory: 'models/local' }, { id: 'video/ltx', directory: 'models/local' }, ]; - assert.equal(addCrossListings(sidebar, listings, directories, hrefToId), true); + addCrossListings(sidebar, listings, directories, hrefToId); const models = sidebar[0].entries; assert.deepEqual(labels(models), ['Anima', 'LTX-2', 'SDXL', 'Wan 2.2', 'Nested']); const ltxCopy = models[1]; @@ -157,7 +175,9 @@ describe('addCrossListings', () => { }); it('does nothing without listings', () => { - assert.equal(addCrossListings(makeSidebar(), [], directories, hrefToId), false); + const sidebar = makeSidebar(); + addCrossListings(sidebar, [], directories, hrefToId); + assert.deepEqual(sidebar, makeSidebar()); }); it('fails loudly on misconfiguration', () => { @@ -186,6 +206,45 @@ describe('addCrossListings', () => { }); }); +describe('locale routes', () => { + /** @param {string} id @param {string} label */ + const localeLink = (id, label) => ({ ...link(id, label), href: `/InvokeAI/de/${id}/` }); + const localeHrefToId = makeHrefToId('/InvokeAI/'); + + it("maps a translated or fallback page's href to the English page id", () => { + assert.equal(localeHrefToId('/InvokeAI/de/users-guide/gallery/boards/'), 'users-guide/gallery/boards'); + assert.equal(localeHrefToId('/InvokeAI/hi/'), ''); + // Only a known locale is a locale segment. + assert.equal(localeHrefToId('/InvokeAI/development/x/'), 'development/x'); + assert.equal(localeHrefToId('/InvokeAI/it/x/'), 'it/x'); + }); + + it('orders and cross-lists a locale sidebar from the English file names and directories', () => { + const sidebar = [ + group('Models', 'models', [localeLink('models/local/sdxl', 'SDXL')]), + group('Video', 'video', [ + { ...localeLink('video/wan', 'Wan'), autogenerate: { directory: 'video' } }, + { ...localeLink('video/ltx', 'LTX'), autogenerate: { directory: 'video' } }, + ]), + ]; + const fileNames = new Map([ + ['video/wan', '02.wan.mdx'], + ['video/ltx', '01.ltx.mdx'], + ]); + const directories = new Map([ + ['models/local/sdxl', 'models/local'], + ['video/wan', 'video'], + ['video/ltx', 'video'], + ]); + sortAndRelabel(sidebar, fileNames, localeHrefToId); + addCrossListings(sidebar, [{ id: 'video/wan', directory: 'models/local' }], directories, localeHrefToId); + assert.deepEqual(labels(sidebar[1].entries), ['LTX', 'Wan']); + assert.deepEqual(labels(sidebar[0].entries), ['SDXL', 'Wan']); + assert.equal(sidebar[0].entries[1].href, '/InvokeAI/de/video/wan/'); + assert.equal(sectionDirectory(sidebar[1], directories, localeHrefToId), 'video'); + }); +}); + describe('paginate', () => { const sidebar = () => { const entries = [ @@ -219,3 +278,57 @@ describe('paginate', () => { assert.equal(paginate([group('Models', 'models', [link('models/a')])], {}), null); }); }); + +describe('findCurrentTrail', () => { + it('lists the groups enclosing the current page, outermost first', () => { + const sidebar = [ + link('models/intro', 'Overview'), + group('Local Models', 'models', [group('FLUX', 'models/flux', [link('models/flux/two', 'FLUX.2', true)])]), + ]; + const trail = findCurrentTrail(sidebar); + assert.deepEqual(trail.groups, ['Local Models', 'FLUX']); + assert.equal(trail.link.label, 'FLUX.2'); + }); + + it('follows the page itself, not a cross-listed copy of it', () => { + const page = link('video/wan', 'Wan', true); + const sidebar = [ + group('Models', 'models', [{ ...page, isCurrent: false, attrs: { [CROSS_LISTED_ATTR]: '' } }]), + group('Video', 'video', [page]), + ]; + assert.deepEqual(findCurrentTrail(sidebar).groups, ['Video']); + }); + + it('is undefined when the current page is not in the sidebar', () => { + assert.equal(findCurrentTrail([group('Models', 'models', [link('models/a')])]), undefined); + }); +}); + +describe('sectionDirectory', () => { + const directories = new Map([ + ['guide/images/intro', 'guide/images'], + ['guide/images/advanced/hidiffusion', 'guide/images/advanced'], + ['guide/video/wan', 'guide/video'], + ]); + + it("is the deepest directory holding all of a section's pages, nested ones included", () => { + const section = group('Images', 'guide', [ + link('guide/images/intro'), + group('Advanced', 'guide', [link('guide/images/advanced/hidiffusion')]), + ]); + assert.equal(sectionDirectory(section, directories, hrefToId), 'guide/images'); + assert.equal(sectionDirectory(section.entries[1], directories, hrefToId), 'guide/images/advanced'); + }); + + it('ignores cross-listed copies, which live in other directories', () => { + const section = group('Images', 'guide', [ + link('guide/images/intro'), + { ...link('guide/video/wan'), attrs: { [CROSS_LISTED_ATTR]: '' } }, + ]); + assert.equal(sectionDirectory(section, directories, hrefToId), 'guide/images'); + }); + + it('is undefined for a section without pages', () => { + assert.equal(sectionDirectory(group('Empty', 'guide', []), directories, hrefToId), undefined); + }); +}); diff --git a/docs/src/lib/translation-checks.mjs b/docs/src/lib/translation-checks.mjs new file mode 100644 index 00000000000..77184eba4c7 --- /dev/null +++ b/docs/src/lib/translation-checks.mjs @@ -0,0 +1,379 @@ +// Checks on what Crowdin writes into the repository: translated pages under `src/content/docs//` and UI strings +// in `src/content/i18n/.json`. Translated MDX is compiled into the site like any page, Markdown pages pass raw +// HTML through, and Starlight renders the hero and card titles as HTML, so a translation may change words but not +// code, markup or destinations. Pages are parsed as the build parses them and compared with their English source. +// README.md explains what a reviewer still has to read. + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; + +import { parseFrontmatter } from '@astrojs/markdown-remark'; +import { createProcessor } from '@mdx-js/mdx'; + +import { translatedLocales } from '../config/locales.mjs'; +import { PLACEHOLDER, PLURAL_SUFFIX, parseTags } from './i18n.mjs'; + +/** + * A remark plugin from the copy `owner` (a package the build uses) depends on, so these checks parse with the plugins + * the build parses with. + * @param {string} name + * @param {string} owner + */ +const loadBuildPlugin = async (name, owner) => { + try { + return (await import(pathToFileURL(createRequire(import.meta.resolve(owner)).resolve(name)).href)).default; + } catch (error) { + throw new Error(`translation-checks.mjs parses pages with ${owner}'s ${name}, which it could not load.`, { + cause: error, + }); + } +}; + +// The syntax extensions of the build's Markdown and MDX pipelines, in its order: Astro's GitHub Flavored Markdown +// (autolinked URLs, footnotes, tables), then Starlight's directives (`:::note` asides). The build's other remark +// plugins, ours included, only transform the tree. +const remarkPlugins = [ + await loadBuildPlugin('remark-gfm', '@astrojs/markdown-remark'), + await loadBuildPlugin('remark-directive', '@astrojs/starlight/schema'), +]; +const processors = { + md: createProcessor({ format: 'md', remarkPlugins }), + mdx: createProcessor({ format: 'mdx', remarkPlugins }), +}; + +const DOCS = 'src/content/docs'; +const I18N = 'src/content/i18n'; + +/** + * The English directories in `src/content/docs/`. Anything else there must be a configured locale, so a language + * Crowdin exports but `locales.mjs` lacks fails here rather than becoming English pages under its prefix. A new docs + * tree goes here, and into `crowdin.yml` too if translators should see it. + */ +const ENGLISH_TREES = [ + 'assets', + 'configuration', + 'contributing', + 'development', + 'start-here', + 'troubleshooting', + 'Users Guide', +]; + +/** The page extensions Starlight's docs loader reads. */ +const PAGE_EXTENSION = /\.(md|mdx|markdown|mdown|mkdn|mkd|mdwn)$/; + +/** Component attributes that hold prose, which translations may change. Starlight renders card titles as HTML. */ +const PROSE_ATTRIBUTES = new Set(['alt', 'aria-label', 'description', 'label', 'title']); + +/** Frontmatter that holds prose. Every other value is copied from English; the hero renders as HTML. */ +const PROSE_FRONTMATTER = /^(title|description|hero\.title|hero\.tagline|sidebar\.label|hero\.actions\.\d+\.text)$/; + +/** What UI strings never contain. They render as text, but a tag such as `` wraps a real link. */ +const UNSAFE_MESSAGE_PATTERNS = [ + { label: '