Compare commits

..
Author SHA1 Message Date
Brandon Thomas bf2a3ad56f update pricing table 2026-01-27 05:34:18 -05:00
bflatastic 7b2b71eaf0 pricing stuff updates 2026-01-27 05:24:32 -05:00
bflatastic 4883185ad8 updates 2026-01-27 05:24:31 -05:00
bflatastic a3b9cd71ad sign up download flow 2026-01-27 05:24:27 -05:00
Brandon Thomas b1965a6019 "what year is it?!" 2026-01-27 04:45:31 -05:00
Brandon Thomas 29b1468e1a website copy improvements (#1017) 2026-01-27 03:17:41 -05:00
Brandon Thomas 1008b6f69a press kit - more videos (#1016)
if you're reading this please help us market this. idk what I'm doing. I
just want to build stuff and make movies.
2026-01-27 02:20:02 -05:00
BFlat f797d6f769 Feature/website press kit page (#1012)
website press kit page for downloadable media
2026-01-27 00:06:31 -05:00
Brandon Thomas 3cb2c31aa7 rename 2d and 3d pages; change icon (#1015) 2026-01-27 00:05:00 -05:00
Brandon Thomas 6f64086834 fix how spz files are manually uploaded in tauri (#1014) 2026-01-26 19:15:01 -05:00
bflatastic 170ee9b069 generation count tooltip for non subbed users 2026-01-26 13:18:18 +07:00
Brandon Thomas 75ab5887f7 dedicated spz upload handler (#1011) 2026-01-25 20:13:37 -05:00
Brandon Thomas 8e67654b9d nano banana text-to-image pane fixes (#1010)
fix reference images, resolution controls, etc.
2026-01-25 19:05:20 -05:00
Tanish Manku 76172e3956 Merge 2D and Edit/Inpainting pages (#981)
Needs more testing, do not merge.
2026-01-24 21:14:07 -05:00
Brandon Thomas affc19abc5 Update social media links in README
Added LinkedIn badge to the README and removed old social media section.
2026-01-24 21:13:13 -05:00
Brandon Thomas 15a04d5f52 Enhance README with video and badges
Added video and social media links to README.
2026-01-24 19:52:46 -05:00
Brandon Thomas 49a5f67dc8 Change video source in README.md
Updated video source link in README.
2026-01-24 11:30:05 -05:00
Brandon Thomas 0db259b64a video tag 2026-01-24 11:18:45 -05:00
Brandon Thomas 363e3fee37 github link for video 2026-01-24 11:11:22 -05:00
Brandon Thomas 5cead36b18 add video to readme 2026-01-24 11:06:02 -05:00
Brandon Thomas f435e9fb7e cleanup dead shared_service_components crate 2026-01-24 09:31:53 -05:00
Brandon Thomas 58ebe1f836 cleanup container binary includes (#1006) 2026-01-24 09:26:47 -05:00
dependabot[bot] 2cddead7a2 Bump @remix-run/react from 2.17.2 to 2.17.3 in /frontend (#970)
Bumps
[@remix-run/react](https://github.com/remix-run/remix/tree/HEAD/packages/remix-react)
from 2.17.2 to 2.17.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/remix-run/remix/releases"><code>@​remix-run/react</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v2.17.3</h2>
<p>See the changelog for the release notes: <a
href="https://github.com/remix-run/remix/blob/v2/CHANGELOG.md#v2173">https://github.com/remix-run/remix/blob/v2/CHANGELOG.md#v2173</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/remix-run/remix/blob/remix@2.17.3/CHANGELOG.md"><code>@​remix-run/react</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>v2.17.3</h2>
<p>Date: 2026-01-06</p>
<h3>Patch Changes</h3>
<ul>
<li><code>@remix-run/dev</code> - Update <code>valibot</code> to address
CVE (<a
href="https://github.com/advisories/GHSA-vqpr-j7v3-hqw9">GHSA-vqpr-j7v3-hqw9</a>)</li>
<li><code>@remix-run/react</code> - Escape HTML during SSR for in
<code>&lt;ScrollRestoration&gt;</code> keys</li>
<li><code>@remix-run/server-runtime</code> - Add additional layer of
CSRF protection by rejecting submissions to UI routes from external
origins</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/remix-run/remix/compare/remix@2.17.2...remix@2.17.3"><code>v2.17.2...v2.17.3</code></a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/remix-run/remix/commit/b1d29d9a3092c05fd3ca1d92b91720987bd6b648"><code>b1d29d9</code></a>
Version 2.17.3</li>
<li><a
href="https://github.com/remix-run/remix/commit/5c87c08d7590b1916c864e549e2726de0a575567"><code>5c87c08</code></a>
Escape HTML in scroll restoration keys (<a
href="https://github.com/remix-run/remix/tree/HEAD/packages/remix-react/issues/10925">#10925</a>)</li>
<li><a
href="https://github.com/remix-run/remix/commit/4a5ffd112b83abad387d0e682ff292a0741d230d"><code>4a5ffd1</code></a>
Revert &quot;Version 2.17.3&quot;</li>
<li><a
href="https://github.com/remix-run/remix/commit/98c89fa02d3891ff7ba9de0fb59f538b050a8559"><code>98c89fa</code></a>
Version 2.17.3</li>
<li>See full diff in <a
href="https://github.com/remix-run/remix/commits/remix@2.17.3/packages/remix-react">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by [GitHub Actions](<a
href="https://www.npmjs.com/~GitHub">https://www.npmjs.com/~GitHub</a>
Actions), a new releaser for <code>@​remix-run/react</code> since your
current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@remix-run/react&package-manager=npm_and_yarn&previous-version=2.17.2&new-version=2.17.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/storytold/artcraft/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-24 08:56:26 -05:00
dependabot[bot] d353cc94e7 Bump @remix-run/router from 1.23.0 to 1.23.2 in /frontend (#976)
Bumps
[@remix-run/router](https://github.com/remix-run/react-router/tree/HEAD/packages/router)
from 1.23.0 to 1.23.2.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/remix-run/react-router/blob/@remix-run/router@1.23.2/packages/router/CHANGELOG.md"><code>@​remix-run/router</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>1.23.2</h2>
<h3>Patch Changes</h3>
<ul>
<li>Validate redirect locations (<a
href="https://redirect.github.com/remix-run/react-router/pull/14707">#14707</a>)</li>
</ul>
<h2>1.23.1</h2>
<h3>Patch Changes</h3>
<ul>
<li>Normalize double-slashes in <code>resolvePath</code> (<a
href="https://redirect.github.com/remix-run/react-router/pull/14537">#14537</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/remix-run/react-router/commit/c662ca366a414bf42624dd6cd20a7c414b2602e3"><code>c662ca3</code></a>
chore: Update version for release (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/router/issues/14713">#14713</a>)</li>
<li><a
href="https://github.com/remix-run/react-router/commit/98ad6912daec8df0d911f786f18006048efd7ade"><code>98ad691</code></a>
chore: Update version for release (pre-v6) (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/router/issues/14710">#14710</a>)</li>
<li><a
href="https://github.com/remix-run/react-router/commit/2fbb84c83dae3695a0435beb0c3c0b467b7c2da2"><code>2fbb84c</code></a>
Validate redirect locations (v6) (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/router/issues/14707">#14707</a>)</li>
<li><a
href="https://github.com/remix-run/react-router/commit/26b5d4581fb2829dc7eaeaad413de4735173a6eb"><code>26b5d45</code></a>
chore: Update version for release (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/router/issues/14541">#14541</a>)</li>
<li><a
href="https://github.com/remix-run/react-router/commit/919f8a86b95f0c8956e3820743503d5609f572cd"><code>919f8a8</code></a>
chore: Update version for release (pre-v6) (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/router/issues/14540">#14540</a>)</li>
<li><a
href="https://github.com/remix-run/react-router/commit/69bf7056f1540918a0324e0af215978cdaf5d486"><code>69bf705</code></a>
Normalize double slashes in resolvePath (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/router/issues/14537">#14537</a>)</li>
<li>See full diff in <a
href="https://github.com/remix-run/react-router/commits/@remix-run/router@1.23.2/packages/router">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by [GitHub Actions](<a
href="https://www.npmjs.com/~GitHub">https://www.npmjs.com/~GitHub</a>
Actions), a new releaser for <code>@​remix-run/router</code> since your
current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@remix-run/router&package-manager=npm_and_yarn&previous-version=1.23.0&new-version=1.23.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/storytold/artcraft/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-24 08:52:11 -05:00
dependabot[bot] 4dcb036491 Bump pnpm from 10.24.0 to 10.27.0 in /frontend (#966)
Bumps [pnpm](https://github.com/pnpm/pnpm/tree/HEAD/pnpm) from 10.24.0
to 10.27.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pnpm/pnpm/releases">pnpm's
releases</a>.</em></p>
<blockquote>
<h2>pnpm 10.27</h2>
<h2>Minor Changes</h2>
<ul>
<li>
<p>Adding <code>trustPolicyIgnoreAfter</code> allows you to ignore trust
policy checks for packages published more than a specified time ago<a
href="https://redirect.github.com/pnpm/pnpm/issues/10352">#10352</a>.</p>
</li>
<li>
<p>Added project registry for global virtual store prune support.</p>
<p>Projects using the store are now registered via symlinks in
<code>{storeDir}/v10/projects/</code>. This enables <code>pnpm store
prune</code> to track which packages are still in use by active projects
and safely remove unused packages from the global virtual store.</p>
</li>
<li>
<p><strong>Semi-breaking.</strong> Changed the location of unscoped
packages in the virtual global store. They will now be stored under a
directory named <code>@</code> to maintain a uniform 4-level directory
depth.</p>
</li>
<li>
<p>Added mark-and-sweep garbage collection for global virtual store.</p>
<p><code>pnpm store prune</code> now removes unused packages from the
global virtual store's <code>links/</code> directory. The algorithm:</p>
<ol>
<li>Scans all registered projects for symlinks pointing to the
store</li>
<li>Walks transitive dependencies to mark reachable packages</li>
<li>Removes any package directories not marked as reachable</li>
</ol>
<p>This includes support for workspace monorepos - all
<code>node_modules</code> directories within a project (including those
in workspace packages) are scanned.</p>
</li>
</ul>
<h2>Patch Changes</h2>
<ul>
<li>Throw an error if the value of the <code>tokenHelper</code> or
<code>&lt;url&gt;:tokenHelper</code> setting contains an environment
variable.</li>
<li>Git dependencies with build scripts should respect the
<code>dangerouslyAllowAllBuilds</code> settings <a
href="https://redirect.github.com/pnpm/pnpm/issues/10376">#10376</a>.</li>
<li>Skip the package manager check when running with --global and a
project packageManager is configured, and warn that the check is
skipped.</li>
<li><code>pnpm store prune</code> should not fail if the dlx cache
directory has files, not only directories <a
href="https://redirect.github.com/pnpm/pnpm/pull/10384">#10384</a></li>
<li>Fixed a bug (<a
href="https://redirect.github.com/pnpm/pnpm/issues/9759">#9759</a>)
where <code>pnpm add</code> would incorrectly modify a catalog entry in
<code>pnpm-workspace.yaml</code> to its exact version.</li>
</ul>
<h2>Platinum Sponsors</h2>
<!-- raw HTML omitted -->
<h2>Gold Sponsors</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pnpm/pnpm/blob/v10.27.0/pnpm/CHANGELOG.md">pnpm's
changelog</a>.</em></p>
<blockquote>
<h2>10.27.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p>Adding <code>trustPolicyIgnoreAfter</code> allows you to ignore trust
policy checks for packages published more than a specified time ago<a
href="https://redirect.github.com/pnpm/pnpm/issues/10352">#10352</a>.</p>
</li>
<li>
<p>Added project registry for global virtual store prune support.</p>
<p>Projects using the store are now registered via symlinks in
<code>{storeDir}/v10/projects/</code>. This enables <code>pnpm store
prune</code> to track which packages are still in use by active projects
and safely remove unused packages from the global virtual store.</p>
</li>
<li>
<p><strong>Semi-breaking.</strong> Changed the location of unscoped
packages in the virtual global store. They will now be stored under a
directory named <code>@</code> to maintain a uniform 4-level directory
depth.</p>
</li>
<li>
<p>Added mark-and-sweep garbage collection for global virtual store.</p>
<p><code>pnpm store prune</code> now removes unused packages from the
global virtual store's <code>links/</code> directory. The algorithm:</p>
<ol>
<li>Scans all registered projects for symlinks pointing to the
store</li>
<li>Walks transitive dependencies to mark reachable packages</li>
<li>Removes any package directories not marked as reachable</li>
</ol>
<p>This includes support for workspace monorepos - all
<code>node_modules</code> directories within a project (including those
in workspace packages) are scanned.</p>
</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>Throw an error if the value of the <code>tokenHelper</code> or
<code>&lt;url&gt;:tokenHelper</code> setting contains an environment
variable.</li>
<li>Git dependencies with build scripts should respect the
<code>dangerouslyAllowAllBuilds</code> settings <a
href="https://redirect.github.com/pnpm/pnpm/issues/10376">#10376</a>.</li>
<li>Skip the package manager check when running with --global and a
project packageManager is configured, and warn that the check is
skipped.</li>
<li><code>pnpm store prune</code> should not fail if the dlx cache
directory has files, not only directories <a
href="https://redirect.github.com/pnpm/pnpm/pull/10384">#10384</a></li>
<li>Fixed a bug (<a
href="https://redirect.github.com/pnpm/pnpm/issues/9759">#9759</a>)
where <code>pnpm add</code> would incorrectly modify a catalog entry in
<code>pnpm-workspace.yaml</code> to its exact version.</li>
</ul>
<h2>10.26.2</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p>Improve error message when a package version exists but does not meet
the <code>minimumReleaseAge</code> constraint. The error now clearly
states that the version exists and shows a human-readable time since
release (e.g., &quot;released 6 hours ago&quot;) <a
href="https://redirect.github.com/pnpm/pnpm/issues/10307">#10307</a>.</p>
</li>
<li>
<p>Fix installation of Git dependencies using annotated tags <a
href="https://redirect.github.com/pnpm/pnpm/issues/10335">#10335</a>.</p>
<p>Previously, pnpm would store the annotated tag object's SHA in the
lockfile instead of the actual commit SHA. This caused
<code>ERR_PNPM_GIT_CHECKOUT_FAILED</code> errors because the checked-out
commit hash didn't match the stored tag object hash.</p>
</li>
<li>
<p>Binaries of runtime engines (Node.js, Deno, Bun) are written to
<code>node_modules/.bin</code> before lifecycle scripts (install,
postinstall, prepare) are executed <a
href="https://redirect.github.com/pnpm/pnpm/issues/10244">#10244</a>.</p>
</li>
<li>
<p>Try to avoid making network calls with preferOffline <a
href="https://redirect.github.com/pnpm/pnpm/pull/10334">#10334</a>.</p>
</li>
</ul>
<h2>10.26.1</h2>
<h3>Patch Changes</h3>
<ul>
<li>Don't fail on <code>pnpm add</code>, when
<code>blockExoticSubdeps</code> is set to <code>true</code> <a
href="https://redirect.github.com/pnpm/pnpm/issues/10324">#10324</a>.</li>
<li>Always resolve git references to full commits and ensure
<code>HEAD</code> points to the commit after checkout <a
href="https://redirect.github.com/pnpm/pnpm/pull/10310">#10310</a>.</li>
</ul>
<h2>10.26.0</h2>
<h3>Minor Changes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pnpm/pnpm/commit/6bdba72ad31e4d6b79821405e09c6bdcc93894ee"><code>6bdba72</code></a>
chore(release): 10.27.0</li>
<li><a
href="https://github.com/pnpm/pnpm/commit/512f18817d610ae83646c4f864089710e1a28f17"><code>512f188</code></a>
fix: git dependencies respect <code>dangerouslyAllowAllBuilds</code> (<a
href="https://github.com/pnpm/pnpm/tree/HEAD/pnpm/issues/10387">#10387</a>)</li>
<li><a
href="https://github.com/pnpm/pnpm/commit/c23bdf7311cdc5763e2c9527e1b0a130448301c4"><code>c23bdf7</code></a>
feat(cli): skip package manager check when using --global option (<a
href="https://github.com/pnpm/pnpm/tree/HEAD/pnpm/issues/10368">#10368</a>)</li>
<li><a
href="https://github.com/pnpm/pnpm/commit/3f2c5f4d3958e6a150f734917eabd6ad88452e98"><code>3f2c5f4</code></a>
feat: add <code>trustPolicyIgnoreAfter</code> (<a
href="https://github.com/pnpm/pnpm/tree/HEAD/pnpm/issues/10359">#10359</a>)</li>
<li><a
href="https://github.com/pnpm/pnpm/commit/8ec7939657b78167c2753f05dacd62feabd59180"><code>8ec7939</code></a>
chore(release): 10.26.2</li>
<li><a
href="https://github.com/pnpm/pnpm/commit/4986c46b48f48ae7c5aeef2aca90820ab7b3775c"><code>4986c46</code></a>
chore(release): 10.26.1</li>
<li><a
href="https://github.com/pnpm/pnpm/commit/244e33b4e963b257952dadf192d829bfec858fb9"><code>244e33b</code></a>
chore(release): 10.26.0</li>
<li><a
href="https://github.com/pnpm/pnpm/commit/40775391d54d400c7d516bffaca667215a5e472d"><code>4077539</code></a>
fix(git-fetcher): block git dependencies from running prepare scripts
unless ...</li>
<li><a
href="https://github.com/pnpm/pnpm/commit/b0cd2dea489e28162a233cf02af3646cdd57bb8c"><code>b0cd2de</code></a>
chore(release): 10.25.0</li>
<li><a
href="https://github.com/pnpm/pnpm/commit/fa82ec393a2627fee47956b639b6ce57a4679318"><code>fa82ec3</code></a>
docs: update sponsors</li>
<li>Additional commits viewable in <a
href="https://github.com/pnpm/pnpm/commits/v10.27.0/pnpm">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pnpm&package-manager=npm_and_yarn&previous-version=10.24.0&new-version=10.27.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/storytold/artcraft/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-24 08:51:19 -05:00
Brandon Thomas 190f0f7f21 remove old, irrelevant tools 2026-01-24 08:49:45 -05:00
Brandon Thomas 14cbd928d5 free users nano banana limits (#1005)
Only allow free users one Nano Banana (Pro) or GPT Image at a time as
this can lead to high costs.

Users can bring their own subscription or API key, or they can subscribe
to ArtCraft to bypass this.
2026-01-24 08:44:58 -05:00
Brandon Thomas 91096001cd hero video update 2026-01-23 22:37:53 -05:00
Brandon Thomas e15fbb1269 update website links 2026-01-23 14:38:08 -05:00
Brandon Thomas 358a676869 sound for delete (#1004) 2026-01-23 09:12:32 -05:00
Brandon Thomas 6fe01074f9 update models 2026-01-23 06:27:39 -05:00
Brandon Thomas efdc9b5197 artcraft version 0.4.0 -> 0.5.0 2026-01-23 05:31:37 -05:00
Brandon Thomas ab4feb524d fix the production build issues (#1001) 2026-01-23 05:12:56 -05:00
151 changed files with 5199 additions and 1436 deletions
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: tokens::tokens::user_subscriptions::UserSubscriptionToken`,\n user_token as `user_token: tokens::tokens::users::UserToken`,\n subscription_namespace as `subscription_namespace: enums::common::payments_namespace::PaymentsNamespace`,\n subscription_product_slug,\n maybe_stripe_customer_id,\n maybe_stripe_product_id,\n maybe_stripe_subscription_id,\n maybe_stripe_subscription_status,\n maybe_stripe_invoice_is_paid,\n subscription_start_at,\n subscription_expires_at,\n current_billing_period_end_at,\n maybe_cancel_at,\n maybe_canceled_at\n\nFROM user_subscriptions\n\nWHERE\n user_token = ?\n AND subscription_namespace = ?\n AND maybe_stripe_customer_id IS NOT NULL\n AND maybe_stripe_subscription_id IS NOT NULL\n AND maybe_stripe_subscription_status IS NOT NULL\n AND maybe_stripe_subscription_status NOT IN ('canceled', 'incomplete_expired')\n AND subscription_expires_at > CURRENT_TIMESTAMP\n ORDER BY id ASC\n LIMIT 1\n ",
"query": "\nSELECT\n token as `token: tokens::tokens::user_subscriptions::UserSubscriptionToken`,\n user_token as `user_token: tokens::tokens::users::UserToken`,\n subscription_namespace as `subscription_namespace: enums::common::payments_namespace::PaymentsNamespace`,\n subscription_product_slug,\n maybe_stripe_customer_id,\n maybe_stripe_product_id,\n maybe_stripe_subscription_id,\n maybe_stripe_subscription_status as `maybe_stripe_subscription_status: enums::common::stripe_subscription_status::StripeSubscriptionStatus`,\n maybe_stripe_invoice_is_paid,\n subscription_start_at,\n subscription_expires_at,\n current_billing_period_end_at,\n maybe_cancel_at,\n maybe_canceled_at\n\nFROM user_subscriptions\n\nWHERE\n user_token = ?\n AND subscription_namespace = ?\n AND maybe_stripe_customer_id IS NOT NULL\n AND maybe_stripe_subscription_id IS NOT NULL\n AND maybe_stripe_subscription_status IS NOT NULL\n AND maybe_stripe_subscription_status NOT IN ('canceled', 'incomplete_expired')\n AND subscription_expires_at > CURRENT_TIMESTAMP\n ORDER BY id ASC\n LIMIT 1\n ",
"describe": {
"columns": [
{
@@ -75,7 +75,7 @@
},
{
"ordinal": 7,
"name": "maybe_stripe_subscription_status",
"name": "maybe_stripe_subscription_status: enums::common::stripe_subscription_status::StripeSubscriptionStatus",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
@@ -164,5 +164,5 @@
true
]
},
"hash": "1e08676857e999a58d3c85474c2a2acec38a413e53ca4207aade690e0bfffe25"
"hash": "dbf85f29c614d243a50e4259218f21c446c6ab17de35c8a8da08fe02822e8f58"
}
Generated
+1 -14
View File
@@ -652,7 +652,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "artcraft"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"artcraft_api_defs",
@@ -10215,19 +10215,6 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared_service_components"
version = "0.0.1"
dependencies = [
"anyhow",
"errors",
"log",
"openai_sora_client",
"r2d2_redis",
"reqwest 0.12.23",
"workspace-build-acceleration",
]
[[package]]
name = "shlex"
version = "1.3.0"
-1
View File
@@ -80,7 +80,6 @@ members = [
"crates/lib/migration",
"crates/lib/password",
"crates/lib/server_environment",
"crates/lib/shared_service_components",
"crates/lib/sqlx_mysql_helpers",
"crates/lib/storyteller_root",
"crates/lib/subprocess_common",
+15 -8
View File
@@ -1,3 +1,17 @@
<p align="center">
<video src="https://github.com/user-attachments/assets/b4e24c27-d87d-4fd1-8599-dc0d0b8af48d" width="100%" autoplay="true" loop controls>
</p>
<p align="center">The IDE for artists.</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Discord" src="https://img.shields.io/discord/1359579021108842617?style=for-the-badge&label=discord&color=ffffff&logo=discord&logoColor=ffffff" /></a>
<a href="https://www.youtube.com/@OfficialArtCraftStudios"><img alt="YouTube" src="https://img.shields.io/youtube/channel/subscribers/UCdjY4VG0ntoGwFsKZO4sVWA?style=for-the-badge&logo=YouTube" /></a>
<a href="https://x.com/intent/follow?screen_name=get_artcraft"><img alt="X" src="https://img.shields.io/twitter/follow/get_artcraft?style=for-the-badge&label=follow&logo=x&logoColor=ffffff&color=ffffff" /></a>
<a href="https://www.linkedin.com/company/artcraft-ai"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin--0A66C2?style=for-the-badge"></a>
</p>
---
ArtCraft
========
ArtCraft is the IDE for interactive AI image and video creation.
@@ -41,7 +55,7 @@ We haven't abandoned text-to-asset generation for quick prototyping and ideation
| Provider | Features |
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d |
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
| Grok | Grok Imagine, Grok Video |
| Midjourney | Image Gen (all versions) |
| Sora | Sora 1, Sora 2, GPT-Image-1 |
@@ -66,10 +80,3 @@ those providers, for example: OpenArt, FreePik, etc.
- [license](./LICENSE.md)
- [roadmap](./ROADMAP.md)
## Social
[![discord](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/discord.svg)](https://discord.gg/artcraft)
[![youtube](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/youtube.svg)](https://www.youtube.com/@OfficialArtCraftStudios)
[![x](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/linkedin.svg)](https://www.linkedin.com/company/artcraft-ai)
[![x](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/twitter-x.svg)](https://x.com/get_artcraft)
-6
View File
@@ -1,6 +0,0 @@
# Secrets
db_secrets.toml
# Python Virtual Env
python/
@@ -1,88 +0,0 @@
#!/usr/bin/env python
# FIXME: Needs packages 'toml' and 'mysql-connector-python'. Put in requirements.txt.
import time
import toml
import mysql.connector
DB_SECRETS_FILE = 'db_secrets.toml'
# TODO: Share db-related setup when more scripts are added.
def read_database_secrets(secrets_file = DB_SECRETS_FILE):
secrets = None
with open(secrets_file, "r") as f:
secrets = toml.load(f)
required_keys = ['database', 'hostname', 'port', 'password', 'username']
for key in required_keys:
if key not in secrets:
raise Exception(f"Required key '#{key}' not present")
return secrets
def connect_to_mysql():
secrets = read_database_secrets()
connection = mysql.connector.connect(
host = secrets['hostname'],
port = secrets['port'],
user = secrets['username'],
password = secrets['password'],
database = secrets['database'],
)
return connection
connection = connect_to_mysql()
cursor = connection.cursor()
SAFE_ID = 76000000 # Reasonably completed on 2022-06-24
LIMIT = 100000
#def get_record_count():
# query = f"SELECT COUNT(*) FROM tts_inference_jobs"
# cursor.execute(query)
# result = cursor.fetchall()
# print(result)
# return result[0]
#
##total_count = get_record_count()
#total_count = 75082057
total_count = 21925602
def get_first_record(cursor):
query = f"select * from tts_inference_jobs order by id limit 1"
cursor.execute(query)
result = cursor.fetchall()
print(result)
return result
total_deleted = 0
keep_deleting = True
while keep_deleting:
query = f"DELETE FROM tts_inference_jobs WHERE id < {SAFE_ID} ORDER BY ID ASC LIMIT {LIMIT}"
query = f"DELETE FROM tts_inference_jobs WHERE status = 'complete_success' ORDER BY ID ASC LIMIT {LIMIT}"
#print(query)
cursor.execute(query)
connection.commit() # NB: Python does not autocommit changes!
#result = cursor.fetchall()
count = cursor.rowcount
total_deleted += count
percent_deleted = (total_deleted / total_count) * 100.0
print(f'Deleted {count}; total deleted = {total_deleted} ({percent_deleted:.4f})')
if count < LIMIT - 1:
keep_deleting = False
time.sleep(0.5)
#record = get_first_record(cursor)
#time.sleep(2)
@@ -1,100 +0,0 @@
#!/usr/bin/env python
# FIXME: Needs packages 'toml' and 'mysql-connector-python'. Put in requirements.txt.
import time
import toml
import mysql.connector
DB_SECRETS_FILE = 'db_secrets.toml'
# TODO: Share db-related setup when more scripts are added.
def read_database_secrets(secrets_file = DB_SECRETS_FILE):
secrets = None
with open(secrets_file, "r") as f:
secrets = toml.load(f)
required_keys = ['database', 'hostname', 'port', 'password', 'username']
for key in required_keys:
if key not in secrets:
raise Exception(f"Required key '#{key}' not present")
return secrets
def connect_to_mysql():
secrets = read_database_secrets()
connection = mysql.connector.connect(
host = secrets['hostname'],
port = secrets['port'],
user = secrets['username'],
password = secrets['password'],
database = secrets['database'],
)
return connection
connection = connect_to_mysql()
cursor = connection.cursor()
SAFE_ID = 76000000 # Reasonably completed on 2022-06-24
LIMIT = 100000
#def get_record_count():
# query = f"SELECT COUNT(*) FROM tts_inference_jobs"
# cursor.execute(query)
# result = cursor.fetchall()
# print(result)
# return result[0]
#
##total_count = get_record_count()
#total_count = 75082057
total_count = 21925602
def get_user_tokens(cursor):
query = f"""
SELECT
r.maybe_creator_user_token, r.creator_ip_address
FROM
tts_results as r
LEFT OUTER JOIN
users as u
ON
r.maybe_creator_user_token = u.token
WHERE
r.raw_inference_text LIKE '%spidermant%'
AND r.created_at > ( CURDATE() - INTERVAL 1 HOUR )
"""
cursor.execute(query)
results = cursor.fetchall()
user_tokens = [row[0] for row in results]
user_tokens = [token for token in user_tokens if token is not None]
user_tokens = set(user_tokens)
ip_addresses = [row[1] for row in results]
ip_addresses = [ip for ip in ip_addresses if ip is not None]
ip_addresses = set(ip_addresses)
return (user_tokens, ip_addresses)
(user_tokens, ip_addresses) = get_user_tokens(cursor)
print(user_tokens)
print(len(user_tokens))
print(len(ip_addresses))
with open('101_user_tokens.txt', 'w') as f:
for user_token in user_tokens:
f.write(user_token)
f.write("\n")
with open('101_ip_addresses.txt', 'w') as f:
for ip in ip_addresses:
f.write(ip)
f.write("\n")
-3
View File
@@ -1,3 +0,0 @@
mysql-connector-python==8.0.32
protobuf==3.20.3
toml==0.10.2
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "artcraft"
version = "0.4.0"
version = "0.5.0"
description = "Artcraft"
authors = ["you"]
license = ""
@@ -1,8 +1,8 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common aspect ratios.
/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation.
/// Not every model will support every aspect ratio.
/// In the case a model doesn't support the aspect ratio, pick the nearest option.
/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonAspectRatio {
@@ -1 +1,2 @@
pub mod aspect_ratio;
pub mod aspect_ratio;
pub mod resolution;
@@ -0,0 +1,17 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation.
/// Not every model will support every resolution
/// In the case a model doesn't support the resolution, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonResolution {
/// Models: Nano Banana Pro
OneK,
/// Models: Nano Banana Pro
TwoK,
/// Models: (None)
ThreeK,
/// Models: Nano Banana Pro
FourK,
}
@@ -0,0 +1 @@
pub mod resolution_to_artcraft_nano_banana_pro;
@@ -0,0 +1,12 @@
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::NanoBananaProMultiFunctionImageGenImageResolution;
pub fn resolution_to_artcraft_nano_banana_pro(resolution: CommonResolution) -> NanoBananaProMultiFunctionImageGenImageResolution {
match resolution {
CommonResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
CommonResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
// NB: ThreeK doesn't exist in Nano Banana Pro, use FourK as nearest higher resolution
CommonResolution::ThreeK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
CommonResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
}
}
@@ -0,0 +1,2 @@
pub mod common_resolution;
pub mod convert;
@@ -18,6 +18,9 @@ pub struct AppPreferencesPayload {
/// Play sounds on events.
pub play_sounds: bool,
/// Key pointing to file; defined in the frontend code.
pub delete_file_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
/// Defined for enqueue since image enqueue can be async
pub enqueue_success_sound: Option<String>,
@@ -61,6 +64,7 @@ async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult<AppPrefere
Ok(AppPreferencesPayload {
preferred_download_directory: prefs.preferred_download_directory,
play_sounds: prefs.play_sounds,
delete_file_sound: prefs.delete_file_sound,
enqueue_success_sound: prefs.enqueue_success_sound,
enqueue_failure_sound: prefs.enqueue_failure_sound,
generation_success_sound: prefs.generation_success_sound,
@@ -28,6 +28,7 @@ pub enum ValueType {
pub enum PreferenceName {
PreferredDownloadDirectory,
PlaySounds,
DeleteFileSound,
EnqueueSuccessSound,
EnqueueFailureSound,
GenerationSuccessSound,
@@ -85,6 +86,11 @@ async fn update_prefs(
return Err(anyhow!("Invalid value: {:?}", request.value)),
}
}
PreferenceName::DeleteFileSound => {
prefs.delete_file_sound = request.value
.map(|val| string_value(&val))
.transpose()?;
}
PreferenceName::EnqueueSuccessSound => {
prefs.enqueue_success_sound = request.value
.map(|val| string_value(&val))
@@ -62,8 +62,7 @@ pub async fn handle_artcraft_gpt_image_1p5_text_to_image(
prompt: request.prompt.clone(),
image_size: aspect_ratio,
num_images,
// Not provided for text-to-image
image_media_tokens: None,
image_media_tokens: request.image_media_tokens.clone(),
mask_image_token: None,
input_fidelity: None,
// Not provided
@@ -1,5 +1,7 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_nano_banana_pro::aspect_ratio_to_artcraft_nano_banana_pro;
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use crate::core::api_adapters::resolution::convert::resolution_to_artcraft_nano_banana_pro::resolution_to_artcraft_nano_banana_pro;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason};
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageResolution, EditImageSize};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
@@ -61,18 +63,12 @@ pub async fn handle_artcraft_nano_banana_pro_text_to_image(
};
let aspect_ratio = get_aspect_ratio(request);
let resolution = request.image_resolution
.map(|image_resolution| match image_resolution {
TextToImageResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
TextToImageResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
TextToImageResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
});
let resolution = get_resolution(request);
let request = NanoBananaProMultiFunctionImageGenRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
image_media_tokens: None,
image_media_tokens: request.image_media_tokens.clone(),
num_images,
resolution,
aspect_ratio,
@@ -126,3 +122,20 @@ fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option<NanoBananaPro
None
}
fn get_resolution(request: &EnqueueTextToImageRequest) -> Option<NanoBananaProMultiFunctionImageGenImageResolution> {
if let Some(common_resolution) = request.common_resolution {
return Some(resolution_to_artcraft_nano_banana_pro(common_resolution));
}
if let Some(resolution) = request.image_resolution {
// Deprecated resolution
return Some(match resolution {
TextToImageResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
TextToImageResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
TextToImageResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
})
}
None
}
@@ -1,3 +1,5 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
use crate::core::commands::enqueue::image_edit::image_edit_models::image_edit_model_to_model_type;
@@ -16,6 +18,8 @@ use crate::core::events::generation_events::common::{GenerationAction, Generatio
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::core::state::task_database::TaskDatabase;
@@ -34,9 +38,6 @@ use serde::{Deserialize, Serialize};
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
use tauri::{AppHandle, State};
use tokens::tokens::media_files::MediaFileToken;
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
@@ -88,15 +89,20 @@ pub struct EnqueueTextToImageRequest {
/// Text prompt for the image generation. Required.
pub prompt: Option<String>,
/// Aspect ratio.
#[deprecated(note="use common_aspect_ratio")]
pub aspect_ratio: Option<TextToImageSize>,
/// New field for aspect ratio.
/// Not all models support each of these aspect ratios, but we can choose or interpolate sensibly.
pub common_aspect_ratio: Option<CommonAspectRatio>,
/// New field for resolution.
/// Not all models support each of these resolutions, but we can choose or interpolate sensibly.
pub common_resolution: Option<CommonResolution>,
/// Aspect ratio.
#[deprecated(note="use common_aspect_ratio")]
pub aspect_ratio: Option<TextToImageSize>,
/// Aspect ratio.
#[deprecated(note="use common_resolution")]
pub image_resolution: Option<TextToImageResolution>,
/// The number of images to generate.
@@ -10,6 +10,9 @@ pub struct AppPreferences {
/// Play sounds on events.
pub play_sounds: bool,
/// Key pointing to file; defined in the frontend code.
pub delete_file_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
/// Defined for enqueue since image enqueue can be async
pub enqueue_success_sound: Option<String>,
@@ -40,6 +43,7 @@ impl Default for AppPreferences {
generation_success_sound: Some("special_flower".to_string()),
generation_failure_sound: Some("crumble".to_string()),
generation_enqueue_sound: Some("done".to_string()),
delete_file_sound: Some("trash".to_string()),
}
}
}
@@ -5,7 +5,9 @@ use errors::AnyhowResult;
use serde_derive::{Deserialize, Serialize};
/// Vector clock versioning string rather than semver.
const CURRENT_VERSION: &str = "1";
/// - Version 1 - initial version.
/// - Version 2 - added "delete_file_sound", marked optionals "skip_serializing_if"
const CURRENT_VERSION: &str = "2";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppPreferencesSerializable {
@@ -16,15 +18,23 @@ pub struct AppPreferencesSerializable {
pub preferred_download_directory: Option<PreferredDownloadDirectory>,
/// Play sounds on events.
#[serde(skip_serializing_if = "Option::is_none")]
pub play_sounds: Option<bool>,
/// Key pointing to file; defined in the frontend code.
#[serde(skip_serializing_if = "Option::is_none")]
pub delete_file_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_success_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_enqueue_sound: Option<String>,
}
@@ -45,6 +55,7 @@ impl AppPreferencesSerializable {
version: CURRENT_VERSION.to_string(),
preferred_download_directory: Some(preferences.preferred_download_directory.clone()),
play_sounds: Some(preferences.play_sounds),
delete_file_sound: preferences.delete_file_sound.clone(),
generation_success_sound: preferences.generation_success_sound.clone(),
generation_failure_sound: preferences.generation_failure_sound.clone(),
generation_enqueue_sound: preferences.generation_enqueue_sound.clone(),
@@ -61,11 +72,12 @@ impl AppPreferencesSerializable {
if let Some(play_sounds) = self.play_sounds {
preferences.play_sounds = play_sounds;
}
preferences.delete_file_sound = self.delete_file_sound.clone();
preferences.generation_success_sound = self.generation_success_sound.clone();
preferences.generation_failure_sound = self.generation_failure_sound.clone();
preferences.generation_enqueue_sound = self.generation_enqueue_sound.clone();
preferences
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.4.0";
pub const ARTCRAFT_VERSION: &str = "0.5.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.4.0",
"version": "0.5.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.4.0",
"version": "0.5.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/dictionary_children_terms.txt");
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_children_terms.txt");
pub (crate) fn contains_children_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS: &str = include_str!("../../../../../../includes/binary_includes/dictionary_potential_minor_terms.txt");
const TERMS: &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_potential_minor_terms.txt");
pub (crate) fn contains_potential_minor_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY: Lazy<HashSet<String>> = Lazy::new(|| {
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/dictionary_racist_terms.txt");
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_racist_terms.txt");
pub (crate) fn contains_racist_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/dictionary_sex_terms.txt");
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_sex_terms.txt");
pub (crate) fn contains_sex_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
TERMS.lines()
@@ -1,33 +0,0 @@
[package]
name = "shared_service_components"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "shared_service_components"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
openai_sora_client = { workspace = true }
errors = { workspace = true }
# External
anyhow = { workspace = true }
log = { workspace = true }
r2d2_redis = "0.14.0"
reqwest = { workspace = true, features = ["multipart", "gzip"] }
[dev-dependencies]
# None yet
@@ -1,4 +0,0 @@
//! This library contains code shared between `storyteller-web` and jobs.
pub mod sora_redis_credentials;
pub mod sora_redis_sentinel_refresh;
@@ -1,33 +0,0 @@
use crate::sora_redis_credentials::keys::{BEARER_SUBKEY, COOKIE_SUBKEY, SENTINEL_SUBKEY, SORA_SECRET_REDIS_KEY};
use anyhow::anyhow;
use errors::AnyhowResult;
use openai_sora_client::creds::sora_credential_builder::SoraCredentialBuilder;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use r2d2_redis::r2d2::PooledConnection;
use r2d2_redis::redis::Commands;
use r2d2_redis::RedisConnectionManager;
use std::collections::HashMap;
pub fn get_sora_credentials_from_redis(
redis: &mut PooledConnection<RedisConnectionManager>
) -> AnyhowResult<SoraCredentialSet> {
let values : HashMap<String, String> = redis.hgetall(SORA_SECRET_REDIS_KEY)
.map_err(|e| anyhow!("Failed to get Sora credentials from Redis: {}", e))?;
let bearer = values.get(BEARER_SUBKEY);
let cookie = values.get(COOKIE_SUBKEY);
let sentinel = values.get(SENTINEL_SUBKEY);
match (bearer, cookie, sentinel) {
(Some(b), Some(c), Some(s)) => {
let creds = SoraCredentialBuilder::new()
.with_jwt_bearer_token(&b)
.with_cookies(&c)
.with_sora_sentinel(&s)
.build()?;
Ok(creds)
}
_ => Err(anyhow!("redis sora credential values not present")),
}
}
@@ -1,29 +0,0 @@
/// Redis key
pub (crate) const SORA_SECRET_REDIS_KEY : &str = "sora_secret";
/// HKEY field for the bearer token
pub (crate) const BEARER_SUBKEY : &str = "bearer";
/// HKEY field for the cookie
pub (crate) const COOKIE_SUBKEY : &str = "cookie";
/// HKEY field for the sentinel
pub (crate) const SENTINEL_SUBKEY : &str = "sentinel";
/// Which subkey to write
#[derive(Clone,Copy,Debug)]
pub enum RedisSoraCredentialSubkey {
Bearer,
Cookie,
Sentinel,
}
impl RedisSoraCredentialSubkey {
pub (crate) fn to_str(&self) -> &'static str {
match self {
RedisSoraCredentialSubkey::Bearer => BEARER_SUBKEY,
RedisSoraCredentialSubkey::Cookie => COOKIE_SUBKEY,
RedisSoraCredentialSubkey::Sentinel => SENTINEL_SUBKEY,
}
}
}
@@ -1,3 +0,0 @@
pub mod keys;
pub mod set_sora_credential_field_in_redis;
pub mod get_sora_credentials_from_redis;
@@ -1,18 +0,0 @@
use anyhow::anyhow;
use r2d2_redis::r2d2::PooledConnection;
use r2d2_redis::redis::Commands;
use r2d2_redis::RedisConnectionManager;
use errors::AnyhowResult;
use crate::sora_redis_credentials::keys::{RedisSoraCredentialSubkey, SORA_SECRET_REDIS_KEY};
pub fn set_sora_credential_field_in_redis(
redis: &mut PooledConnection<RedisConnectionManager>,
subkey: RedisSoraCredentialSubkey,
value: &str,
) -> AnyhowResult<()> {
redis.hset(SORA_SECRET_REDIS_KEY, subkey.to_str(), value)
.map_err(|e| anyhow!("Failed to set Sora credentials in Redis: {}", e))?;
Ok(())
}
@@ -1 +0,0 @@
pub mod refresh;
@@ -1,19 +0,0 @@
/// Send a POST request to env variable SORA_SENTINEL_REFRESH_URL
use errors::AnyhowResult;
use reqwest::Client;
use std::env;
const SORA_SENTINEL_REFRESH_URL: &str = "http://oai-token-server/refresh_token";
/// Refresh the Sora sentinel
/// The response body does _not_ contain the new sentinel
/// We should read it again from redis or generate a new one here
/// For now we refresh in the server handler
pub async fn refresh_sentinel() -> AnyhowResult<String> {
let client = Client::new();
let url = env::var("SORA_SENTINEL_REFRESH_URL").unwrap_or(SORA_SENTINEL_REFRESH_URL.to_string());
let response = client.post(url).send().await?;
let body = response.text().await?;
Ok(body)
}
+1 -1
View File
@@ -8,7 +8,7 @@
#![forbid(unused_variables)]
#![forbid(warnings)]
pub (crate) const BANNED_SLURS : &str = include_str!("../../../../includes/binary_includes/banned_slurs.txt");
pub (crate) const BANNED_SLURS : &str = include_str!("../../../../includes/binary_includes/safety/banned_slurs.txt");
pub mod check_for_slurs;
pub mod latin_alphabet;
@@ -7,6 +7,7 @@ use sqlx;
use sqlx::mysql::MySqlRow;
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use tokens::tokens::user_subscriptions::UserSubscriptionToken;
use tokens::tokens::users::UserToken;
@@ -21,7 +22,7 @@ pub struct UserSubscription {
pub stripe_customer_id: String,
pub stripe_product_id: String,
pub stripe_subscription_id: String,
pub stripe_subscription_status: String,
pub stripe_subscription_status: StripeSubscriptionStatus,
pub stripe_invoice_is_paid: bool,
/// When the subscription was created (Stripe's POV, not our db record),
@@ -125,7 +126,7 @@ SELECT
maybe_stripe_customer_id,
maybe_stripe_product_id,
maybe_stripe_subscription_id,
maybe_stripe_subscription_status,
maybe_stripe_subscription_status as `maybe_stripe_subscription_status: enums::common::stripe_subscription_status::StripeSubscriptionStatus`,
maybe_stripe_invoice_is_paid,
subscription_start_at,
subscription_expires_at,
@@ -164,7 +165,7 @@ struct RawUserSubscription {
maybe_stripe_customer_id: Option<String>,
maybe_stripe_product_id: Option<String>,
maybe_stripe_subscription_id: Option<String>,
maybe_stripe_subscription_status: Option<String>,
maybe_stripe_subscription_status: Option<StripeSubscriptionStatus>,
maybe_stripe_invoice_is_paid: Option<i8>,
subscription_start_at: DateTime<Utc>,
+5 -2
View File
@@ -4,8 +4,11 @@ pub mod generation_provider;
pub mod job_status;
pub mod job_status_plus;
pub mod model_type;
pub mod sqlite;
pub mod payments_namespace;
pub mod stripe_subscription_status;
pub mod view_as;
pub mod visibility;
pub mod vocoder_type;
pub mod vocoder_type;
// Sqlite specific types.
pub mod sqlite;
@@ -0,0 +1,248 @@
//! This is copied from the stripe crate's `SubscriptionStatus`,
//! and then from `reusable_types`.
// There are three changes from the source create:
// - Renamed the struct from SubscriptionStatus to StripeSubscriptionStatus
// - Added StripeSubscriptionStatus::from_str()
// - Added derive sqlx::Type and sqlx(rename_all)
// - Added Deref impl
// - Added tests
//
// Then, moving into enums:
// - Changed derives
// - as_st() -> to_str() (gross! but convention as of now...)
// - from_str() : changed error from string to EnumError
// - Added all_variants()
// - commented out all other impls.
//
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
// NB: Added "sqlx::Type".
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum StripeSubscriptionStatus {
Active,
Canceled,
Incomplete,
IncompleteExpired,
PastDue,
Trialing,
Unpaid,
Paused,
}
impl_enum_display_and_debug_using_to_str!(StripeSubscriptionStatus);
impl_mysql_enum_coders!(StripeSubscriptionStatus);
impl_mysql_from_row!(StripeSubscriptionStatus);
impl StripeSubscriptionStatus {
pub fn to_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Canceled => "canceled",
Self::Incomplete => "incomplete",
Self::IncompleteExpired => "incomplete_expired",
Self::PastDue => "past_due",
Self::Trialing => "trialing",
Self::Unpaid => "unpaid",
Self::Paused => "paused"
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"active" => Ok(Self::Active),
"canceled" => Ok(Self::Canceled),
"incomplete" => Ok(Self::Incomplete),
"incomplete_expired" => Ok(Self::IncompleteExpired),
"past_due" => Ok(Self::PastDue),
"trialing" => Ok(Self::Trialing),
"unpaid" => Ok(Self::Unpaid),
"paused" => Ok(Self::Paused),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::Active,
Self::Canceled,
Self::Incomplete,
Self::IncompleteExpired,
Self::PastDue,
Self::Trialing,
Self::Unpaid,
Self::Paused,
])
}
}
//impl AsRef<str> for StripeSubscriptionStatus {
// fn as_ref(&self) -> &str {
// self.as_str()
// }
//}
//
//// NB: Added by us.
//impl std::ops::Deref for StripeSubscriptionStatus {
// type Target = str;
//
// fn deref(&self) -> &Self::Target {
// self.as_str()
// }
//}
//
//impl std::fmt::Display for StripeSubscriptionStatus {
// fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
// self.as_str().fmt(f)
// }
//}
//
//impl std::default::Default for StripeSubscriptionStatus {
// fn default() -> Self {
// Self::Active
// }
//}
#[cfg(test)]
mod tests {
use crate::error::enum_error::EnumError;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::common::stripe_subscription_status::StripeSubscriptionStatus;
#[test]
fn test_serialization() {
assert_serialization(StripeSubscriptionStatus::Active, "active");
assert_serialization(StripeSubscriptionStatus::Canceled, "canceled");
assert_serialization(StripeSubscriptionStatus::Incomplete, "incomplete");
assert_serialization(StripeSubscriptionStatus::IncompleteExpired, "incomplete_expired");
assert_serialization(StripeSubscriptionStatus::PastDue, "past_due");
assert_serialization(StripeSubscriptionStatus::Trialing, "trialing");
assert_serialization(StripeSubscriptionStatus::Unpaid, "unpaid");
assert_serialization(StripeSubscriptionStatus::Paused, "paused");
}
#[test]
fn to_str() {
assert_eq!(StripeSubscriptionStatus::Active.to_str(), "active");
assert_eq!(StripeSubscriptionStatus::Canceled.to_str(), "canceled");
assert_eq!(StripeSubscriptionStatus::Incomplete.to_str(), "incomplete");
assert_eq!(StripeSubscriptionStatus::IncompleteExpired.to_str(), "incomplete_expired");
assert_eq!(StripeSubscriptionStatus::PastDue.to_str(), "past_due");
assert_eq!(StripeSubscriptionStatus::Trialing.to_str(), "trialing");
assert_eq!(StripeSubscriptionStatus::Unpaid.to_str(), "unpaid");
assert_eq!(StripeSubscriptionStatus::Paused.to_str(), "paused");
}
#[test]
fn from_str() {
assert_eq!(StripeSubscriptionStatus::from_str("active").unwrap(), StripeSubscriptionStatus::Active);
assert_eq!(StripeSubscriptionStatus::from_str("canceled").unwrap(), StripeSubscriptionStatus::Canceled);
assert_eq!(StripeSubscriptionStatus::from_str("incomplete").unwrap(), StripeSubscriptionStatus::Incomplete);
assert_eq!(StripeSubscriptionStatus::from_str("incomplete_expired").unwrap(), StripeSubscriptionStatus::IncompleteExpired);
assert_eq!(StripeSubscriptionStatus::from_str("past_due").unwrap(), StripeSubscriptionStatus::PastDue);
assert_eq!(StripeSubscriptionStatus::from_str("trialing").unwrap(), StripeSubscriptionStatus::Trialing);
assert_eq!(StripeSubscriptionStatus::from_str("unpaid").unwrap(), StripeSubscriptionStatus::Unpaid);
assert_eq!(StripeSubscriptionStatus::from_str("paused").unwrap(), StripeSubscriptionStatus::Paused);
}
#[test]
fn from_str_err() {
let result = StripeSubscriptionStatus::from_str("asdf");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "asdf");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = StripeSubscriptionStatus::all_variants();
assert_eq!(variants.len(), 8);
assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Active));
assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Canceled));
assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Incomplete));
assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::IncompleteExpired));
assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::PastDue));
assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Trialing));
assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Unpaid));
assert_eq!(variants.pop_first(), Some(StripeSubscriptionStatus::Paused));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use crate::common::stripe_subscription_status::StripeSubscriptionStatus;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(StripeSubscriptionStatus::all_variants().len(), StripeSubscriptionStatus::iter().len());
}
#[test]
fn round_trip() {
for variant in StripeSubscriptionStatus::all_variants() {
// Test to_str(), from_str(), Display, and Debug.
assert_eq!(variant, StripeSubscriptionStatus::from_str(variant.to_str()).unwrap());
assert_eq!(variant, StripeSubscriptionStatus::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, StripeSubscriptionStatus::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH : usize = 32;
for variant in StripeSubscriptionStatus::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
}
}
}
}
@@ -73,7 +73,6 @@ redis_common = { path = "../../../schema/database/redis_common" }
redis_schema = { path = "../../../schema/database/redis_schema" }
reusable_types = { path = "../../../schema/public/reusable_types" }
server_environment = { path = "../../../lib/server_environment" }
#shared_service_components.workspace = true
thumbnail_generator = { path = "../../../api_clients/thumbnail_generator" }
tokens.workspace = true
tts_common = { path = "../../../lib/tts_common" }
@@ -1,6 +1,6 @@
/// Full string matches that can not be used as usernames.
pub const RESERVED_USERNAMES : &str = include_str!("../../../../../../includes/binary_includes/reserved_usernames.txt");
pub const RESERVED_USERNAMES : &str = include_str!("../../../../../../includes/binary_includes/usernames/reserved_usernames.txt");
/// Substrings that are reserved and cannot be used as part of a username.
pub const RESERVED_SUBSTRINGS : &str = include_str!("../../../../../../includes/binary_includes/reserved_usernames_including.txt");
pub const RESERVED_SUBSTRINGS : &str = include_str!("../../../../../../includes/binary_includes/usernames/reserved_usernames_including.txt");
@@ -1,65 +1,5 @@
use utoipa::OpenApi;
use artcraft_api_defs::common::responses::job_details::JobDetailsLipsyncRequest;
use artcraft_api_defs::common::responses::job_details::JobDetailsLivePortraitRequest;
use artcraft_api_defs::common::responses::media_links::*;
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::*;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageAspectRatio;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageNumImages;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageResponse;
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageAspectRatio;
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageNumImages;
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageResponse;
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageAspectRatio;
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageNumImages;
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageResponse;
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageAspectRatio;
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageNumImages;
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageResponse;
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageImageQuality;
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageImageSize;
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageNumImages;
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageRequest;
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageResponse;
use artcraft_api_defs::generate::image::remove_image_background::RemoveImageBackgroundRequest;
use artcraft_api_defs::generate::image::remove_image_background::RemoveImageBackgroundResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dResponse;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProAspectRatio;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProDuration;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoResponse;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterAspectRatio;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterDuration;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterImageToVideoResponse;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProAspectRatio;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProDuration;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProImageToVideoResponse;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteDuration;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoResponse;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteResolution;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2AspectRatio;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2Duration;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoResponse;
use artcraft_api_defs::jobs::list_session_jobs::*;
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFilePathInfo;
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFileRequest;
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_api_defs::prompts::create_prompt::CreatePromptResponse;
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_error::CreateCheckoutSessionError;
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_json_handler::*;
use billing_component::users::http_endpoints::list_active_user_subscriptions_handler::*;
use crate::http_server::common_requests::auto_product_category::AutoProductCategory;
use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo;
use crate::http_server::common_responses::media::cover_image_links::CoverImageLinks;
@@ -137,6 +77,7 @@ use crate::http_server::endpoints::media_files::upload::upload_new_scene_media_f
use crate::http_server::endpoints::media_files::upload::upload_pmx::upload_pmx_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_saved_scene_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_scene_snapshot_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_spz_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::*;
use crate::http_server::endpoints::media_files::upload::upload_video_old::upload_video_media_file_handler::*;
@@ -148,8 +89,8 @@ use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_fea
use crate::http_server::endpoints::prompts::get_prompt_handler::*;
use crate::http_server::endpoints::service::status_alert_handler::*;
use crate::http_server::endpoints::stats::get_unified_queue_stats_handler::*;
use crate::http_server::endpoints::studio_gen2::enqueue_studio_gen2_handler::*;
use crate::http_server::endpoints::studio_gen2::enqueue_studio_gen2_handler::EnqueueStudioGen2Request;
use crate::http_server::endpoints::studio_gen2::enqueue_studio_gen2_handler::*;
use crate::http_server::endpoints::tags::list_tags_for_entity_handler::*;
use crate::http_server::endpoints::tags::set_tags_for_entity_handler::*;
use crate::http_server::endpoints::tts::enqueue_infer_tts_handler::enqueue_infer_tts_handler::*;
@@ -181,6 +122,66 @@ use crate::http_server::endpoints::weights::list::list_weights_by_user_handler::
use crate::http_server::endpoints::weights::search::search_model_weights_impl::*;
use crate::http_server::endpoints::weights::update::set_model_weight_cover_image_handler::*;
use crate::http_server::endpoints::weights::update::update_weight_handler::*;
use artcraft_api_defs::common::responses::job_details::JobDetailsLipsyncRequest;
use artcraft_api_defs::common::responses::job_details::JobDetailsLivePortraitRequest;
use artcraft_api_defs::common::responses::media_links::*;
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
use artcraft_api_defs::generate::image::bg_removal::remove_image_background::RemoveImageBackgroundRequest;
use artcraft_api_defs::generate::image::bg_removal::remove_image_background::RemoveImageBackgroundResponse;
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::*;
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageAspectRatio;
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageNumImages;
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageRequest;
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageResponse;
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageAspectRatio;
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageNumImages;
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageRequest;
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageResponse;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageAspectRatio;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageNumImages;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageRequest;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageResponse;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageAspectRatio;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageNumImages;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageRequest;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageResponse;
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageImageQuality;
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageImageSize;
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageNumImages;
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageRequest;
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dResponse;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProAspectRatio;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProDuration;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoResponse;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterAspectRatio;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterDuration;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterImageToVideoResponse;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProAspectRatio;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProDuration;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProImageToVideoResponse;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteDuration;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoResponse;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteResolution;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2AspectRatio;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2Duration;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoResponse;
use artcraft_api_defs::jobs::list_session_jobs::*;
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFilePathInfo;
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFileRequest;
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_api_defs::prompts::create_prompt::CreatePromptResponse;
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_error::CreateCheckoutSessionError;
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_json_handler::*;
use billing_component::users::http_endpoints::list_active_user_subscriptions_handler::*;
use enums::by_table::beta_keys::beta_key_product::BetaKeyProduct;
use enums::by_table::comments::comment_entity_type::CommentEntityType;
use enums::by_table::featured_items::featured_item_entity_type::FeaturedItemEntityType;
@@ -285,6 +286,7 @@ use tokens::tokens::zs_voice_datasets::*;
crate::http_server::endpoints::media_files::upload::upload_image_media_file_handler::upload_image_media_file_handler,
crate::http_server::endpoints::media_files::upload::upload_new_engine_asset_media_file_handler::upload_new_engine_asset_media_file_handler,
crate::http_server::endpoints::media_files::upload::upload_new_scene_media_file_handler::upload_new_scene_media_file_handler,
crate::http_server::endpoints::media_files::upload::upload_spz_media_file_handler::upload_spz_media_file_handler,
crate::http_server::endpoints::media_files::upload::upload_pmx::upload_pmx_media_file_handler::upload_pmx_media_file_handler,
crate::http_server::endpoints::media_files::upload::upload_saved_scene_media_file_handler::upload_saved_scene_media_file_handler,
crate::http_server::endpoints::media_files::upload::upload_scene_snapshot_media_file_handler::upload_scene_snapshot_media_file_handler,
@@ -753,6 +755,8 @@ use tokens::tokens::zs_voice_datasets::*;
UploadNewEngineAssetSuccessResponse,
UploadNewSceneMediaFileForm,
UploadNewSceneMediaFileSuccessResponse,
UploadSpzMediaFileForm,
UploadSpzMediaFileSuccessResponse,
UploadNewVideoMediaFileForm,
UploadNewVideoMediaFileSuccessResponse,
UploadPmxFileForm,
@@ -14,6 +14,8 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{enqueue_gemini_25_flash_edit_webhook, Gemini25FlashEditArgs, Gemini25FlashEditNumImages};
@@ -29,6 +31,7 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::Acquire;
use utoipa::ToSchema;
@@ -52,7 +55,11 @@ pub async fn gemini_25_flash_edit_image_handler(
) -> Result<Json<Gemini25FlashEditImageResponse>, CommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await?;
@@ -66,6 +73,10 @@ pub async fn gemini_25_flash_edit_image_handler(
CommonWebError::ServerError
})?;
let maybe_user_token = maybe_user_session
.as_ref()
.map(|s| &s.user_token);
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
@@ -80,10 +91,30 @@ pub async fn gemini_25_flash_edit_image_handler(
// }
//};
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
let mut downgrade_for_free_user = true;
if let Some(user_token) = maybe_user_token.as_ref() {
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
const CAN_SEE_DELETED: bool = false;
let tokens = match request.image_media_tokens.as_ref() {
@@ -142,7 +173,7 @@ pub async fn gemini_25_flash_edit_image_handler(
info!("Fal webhook URL: {}", server_state.fal.webhook_url);
let num_images = match request.num_images {
let mut num_images = match request.num_images {
Some(Gemini25FlashEditImageNumImages::One) => Gemini25FlashEditNumImages::One,
Some(Gemini25FlashEditImageNumImages::Two) => Gemini25FlashEditNumImages::Two,
Some(Gemini25FlashEditImageNumImages::Three) => Gemini25FlashEditNumImages::Three,
@@ -150,6 +181,10 @@ pub async fn gemini_25_flash_edit_image_handler(
None => Gemini25FlashEditNumImages::One, // Default to One
};
if downgrade_for_free_user {
num_images = Gemini25FlashEditNumImages::One;
}
let args = Gemini25FlashEditArgs {
image_urls,
prompt: request.prompt.as_deref().unwrap_or(""),
@@ -188,9 +223,7 @@ pub async fn gemini_25_flash_edit_image_handler(
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: maybe_user_session
.as_ref()
.map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_model_type: Some(ModelType::Gemini25Flash),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
@@ -233,7 +266,7 @@ pub async fn gemini_25_flash_edit_image_handler(
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
@@ -14,6 +14,8 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::enqueue_gpt_image_1_edit_image_webhook;
@@ -28,6 +30,7 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::Acquire;
use utoipa::ToSchema;
@@ -51,6 +54,10 @@ pub async fn gpt_image_1_edit_image_handler(
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await?;
@@ -67,6 +74,10 @@ pub async fn gpt_image_1_edit_image_handler(
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let maybe_user_token = maybe_user_session
.as_ref()
.map(|s| &s.user_token);
// TODO: Limit usage for new accounts. Billing, free credits metering, etc.
@@ -78,10 +89,30 @@ pub async fn gpt_image_1_edit_image_handler(
// }
//};
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
let mut downgrade_for_free_user = true;
if let Some(user_token) = maybe_user_token.as_ref() {
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
const CAN_SEE_DELETED: bool = false;
let tokens = match request.image_media_tokens.as_ref() {
@@ -147,7 +178,7 @@ pub async fn gpt_image_1_edit_image_handler(
None => GptEditImageSize::Square, // Default to Square
};
let num_images = match request.num_images {
let mut num_images = match request.num_images {
Some(GptImage1EditImageNumImages::One) => GptEditImageNumImages::One,
Some(GptImage1EditImageNumImages::Two) => GptEditImageNumImages::Two,
Some(GptImage1EditImageNumImages::Three) => GptEditImageNumImages::Three,
@@ -167,6 +198,10 @@ pub async fn gpt_image_1_edit_image_handler(
let openai_api_key = OpenAiApiKey::from_str(&server_state.openai.api_key);
if downgrade_for_free_user {
num_images = GptEditImageNumImages::One;
}
let args = GptEditImageByokArgs {
image_urls,
prompt: request.prompt.as_deref().unwrap_or(""),
@@ -207,9 +242,7 @@ pub async fn gpt_image_1_edit_image_handler(
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: maybe_user_session
.as_ref()
.map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_model_type: Some(ModelType::GptImage1),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
@@ -252,7 +285,7 @@ pub async fn gpt_image_1_edit_image_handler(
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
@@ -14,6 +14,8 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::webhook::image::enqueue_gpt_image_1_text_to_image_webhook::{enqueue_gpt_image_1_text_to_image_webhook, GptTextToImageByokArgs, GptTextToImageNumImages, GptTextToImageQuality, GptTextToImageSize};
@@ -26,6 +28,7 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::Acquire;
use utoipa::ToSchema;
@@ -48,6 +51,10 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
) -> Result<Json<GenerateGptImage1TextToImageResponse>, CommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
let mut mysql_connection = server_state.mysql_pool
.acquire()
@@ -66,6 +73,10 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let maybe_user_token = maybe_user_session
.as_ref()
.map(|s| &s.user_token);
// TODO: Limit usage for new accounts. Billing, free credits metering, etc.
//let user_session = match maybe_user_session {
@@ -75,11 +86,31 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
// return Err(CommonWebError::NotAuthorized);
// }
//};
let mut downgrade_for_free_user = true;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
if let Some(user_token) = maybe_user_token.as_ref() {
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
const CAN_SEE_DELETED: bool = false;
let media_domain = get_media_domain(&http_request);
@@ -100,7 +131,7 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
None => GptTextToImageSize::Square, // Default to Square
};
let num_images = match request.num_images {
let mut num_images = match request.num_images {
Some(GenerateGptImage1TextToImageNumImages::One) => GptTextToImageNumImages::One,
Some(GenerateGptImage1TextToImageNumImages::Two) => GptTextToImageNumImages::Two,
Some(GenerateGptImage1TextToImageNumImages::Three) => GptTextToImageNumImages::Three,
@@ -117,6 +148,10 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
},
None => GptTextToImageQuality::High, // Default to High
};
if downgrade_for_free_user {
num_images = GptTextToImageNumImages::One;
}
let openai_api_key = OpenAiApiKey::from_str(&server_state.openai.api_key);
@@ -159,9 +194,7 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: maybe_user_session
.as_ref()
.map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_model_type: Some(ModelType::GptImage1),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
@@ -186,7 +219,7 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
@@ -17,6 +17,8 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1p5_edit_image_webhook::{enqueue_gpt_image_1p5_image_edit_webhook, EnqueueGptImage1p5EditImageArgs, EnqueueGptImage1p5EditImageBackground, EnqueueGptImage1p5EditImageInputFidelity, EnqueueGptImage1p5EditImageNumImages, EnqueueGptImage1p5EditImageQuality, EnqueueGptImage1p5EditImageSize};
@@ -30,6 +32,7 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use server_environment::ServerEnvironment;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
@@ -55,6 +58,10 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
) -> Result<Json<GptImage1p5MultiFunctionImageGenResponse>, CommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
let mut mysql_connection = server_state.mysql_pool
.acquire()
@@ -73,6 +80,10 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let maybe_user_token = maybe_user_session
.as_ref()
.map(|s| &s.user_token);
// TODO: Limit usage for new accounts. Billing, free credits metering, etc.
//let user_session = match maybe_user_session {
@@ -83,10 +94,30 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
// }
//};
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
let mut downgrade_for_free_user = true;
if let Some(user_token) = maybe_user_token.as_ref() {
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
let mut query_media_tokens = None;
if let Some(media_tokens) = request.image_media_tokens.as_ref() {
@@ -148,7 +179,7 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
if let Some(input_image_urls) = maybe_image_urls.as_deref() {
info!("gpt image 1.5 edit image");
let num_images = match request.num_images {
let mut num_images = match request.num_images {
Some(GptImage1p5MultiFunctionImageGenNumImages::One) => EnqueueGptImage1p5EditImageNumImages::One,
Some(GptImage1p5MultiFunctionImageGenNumImages::Two) => EnqueueGptImage1p5EditImageNumImages::Two,
Some(GptImage1p5MultiFunctionImageGenNumImages::Three) => EnqueueGptImage1p5EditImageNumImages::Three,
@@ -183,6 +214,10 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
None => EnqueueGptImage1p5EditImageInputFidelity::High,
};
if downgrade_for_free_user {
num_images = EnqueueGptImage1p5EditImageNumImages::One;
}
let args = EnqueueGptImage1p5EditImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
image_urls: input_image_urls.to_owned(),
@@ -207,7 +242,7 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
} else {
info!("gpt image 1.5 text-to-image");
let num_images = match request.num_images {
let mut num_images = match request.num_images {
Some(GptImage1p5MultiFunctionImageGenNumImages::One) => EnqueueGptImage1p5TextToImageNumImages::One,
Some(GptImage1p5MultiFunctionImageGenNumImages::Two) => EnqueueGptImage1p5TextToImageNumImages::Two,
Some(GptImage1p5MultiFunctionImageGenNumImages::Three) => EnqueueGptImage1p5TextToImageNumImages::Three,
@@ -236,6 +271,10 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
None => EnqueueGptImage1p5TextToImageQuality::High,
};
if downgrade_for_free_user {
num_images = EnqueueGptImage1p5TextToImageNumImages::One;
}
let args = EnqueueGptImage1p5TextToImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
num_images,
@@ -277,9 +316,7 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: maybe_user_session
.as_ref()
.map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_model_type: Some(ModelType::GptImage1p5),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
@@ -324,7 +361,7 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
@@ -18,6 +18,8 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{enqueue_gemini_25_flash_edit_webhook, Gemini25FlashEditArgs, Gemini25FlashEditAspectRatio, Gemini25FlashEditNumImages};
@@ -33,6 +35,7 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use server_environment::ServerEnvironment;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
@@ -58,6 +61,10 @@ pub async fn nano_banana_multi_function_image_gen_handler(
) -> Result<Json<NanoBananaMultiFunctionImageGenResponse>, CommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
let mut mysql_connection = server_state.mysql_pool
.acquire()
@@ -76,6 +83,10 @@ pub async fn nano_banana_multi_function_image_gen_handler(
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let maybe_user_token = maybe_user_session
.as_ref()
.map(|s| &s.user_token);
// TODO: Limit usage for new accounts. Billing, free credits metering, etc.
//let user_session = match maybe_user_session {
@@ -86,9 +97,29 @@ pub async fn nano_banana_multi_function_image_gen_handler(
// }
//};
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
let mut downgrade_for_free_user = true;
if let Some(user_token) = maybe_user_token.as_ref() {
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
let image_urls = match request.image_media_tokens.as_ref() {
Some(media_tokens) => {
@@ -117,7 +148,7 @@ pub async fn nano_banana_multi_function_image_gen_handler(
if let Some(input_image_urls) = image_urls.as_deref() {
info!("nano banana edit image");
let num_images = match request.num_images {
let mut num_images = match request.num_images {
Some(NanoBananaMultiFunctionImageGenNumImages::One) => Gemini25FlashEditNumImages::One,
Some(NanoBananaMultiFunctionImageGenNumImages::Two) => Gemini25FlashEditNumImages::Two,
Some(NanoBananaMultiFunctionImageGenNumImages::Three) => Gemini25FlashEditNumImages::Three,
@@ -140,6 +171,10 @@ pub async fn nano_banana_multi_function_image_gen_handler(
None => Gemini25FlashEditAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = Gemini25FlashEditNumImages::One;
}
let args = Gemini25FlashEditArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
image_urls: input_image_urls.to_owned(),
@@ -159,7 +194,7 @@ pub async fn nano_banana_multi_function_image_gen_handler(
} else {
info!("nano banana text-to-image");
let num_images = match request.num_images {
let mut num_images = match request.num_images {
Some(NanoBananaMultiFunctionImageGenNumImages::One) => Gemini25FlashTextToImageNumImages::One,
Some(NanoBananaMultiFunctionImageGenNumImages::Two) => Gemini25FlashTextToImageNumImages::Two,
Some(NanoBananaMultiFunctionImageGenNumImages::Three) => Gemini25FlashTextToImageNumImages::Three,
@@ -182,6 +217,10 @@ pub async fn nano_banana_multi_function_image_gen_handler(
None => Gemini25FlashTextToImageAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = Gemini25FlashTextToImageNumImages::One;
}
let args = Gemini25FlashTextToImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
num_images,
@@ -220,9 +259,7 @@ pub async fn nano_banana_multi_function_image_gen_handler(
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: maybe_user_session
.as_ref()
.map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_model_type: Some(ModelType::NanoBanana),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
@@ -267,7 +304,7 @@ pub async fn nano_banana_multi_function_image_gen_handler(
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
@@ -17,6 +17,8 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::{enqueue_nano_banana_pro_image_edit_webhook, EnqueueNanoBananaProEditImageArgs, EnqueueNanoBananaProEditImageAspectRatio, EnqueueNanoBananaProEditImageNumImages, EnqueueNanoBananaProEditImageResolution};
@@ -30,6 +32,7 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use server_environment::ServerEnvironment;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
@@ -55,7 +58,11 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
) -> Result<Json<NanoBananaProMultiFunctionImageGenResponse>, CommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await?;
@@ -73,6 +80,10 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let maybe_user_token = maybe_user_session
.as_ref()
.map(|s| &s.user_token);
// TODO: Limit usage for new accounts. Billing, free credits metering, etc.
//let user_session = match maybe_user_session {
@@ -83,10 +94,30 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
// }
//};
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
let mut downgrade_for_free_user = true;
if let Some(user_token) = maybe_user_token.as_ref() {
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
let image_urls = match request.image_media_tokens.as_ref() {
Some(media_tokens) => {
info!("Looking up image media tokens: {:?}", media_tokens);
@@ -114,7 +145,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
if let Some(input_image_urls) = image_urls.as_deref() {
info!("nano banana pro edit image");
let num_images = match request.num_images {
let mut num_images = match request.num_images {
Some(NanoBananaProMultiFunctionImageGenNumImages::One) => EnqueueNanoBananaProEditImageNumImages::One,
Some(NanoBananaProMultiFunctionImageGenNumImages::Two) => EnqueueNanoBananaProEditImageNumImages::Two,
Some(NanoBananaProMultiFunctionImageGenNumImages::Three) => EnqueueNanoBananaProEditImageNumImages::Three,
@@ -122,7 +153,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
None => EnqueueNanoBananaProEditImageNumImages::One, // Default to One
};
let resolution = match request.resolution {
let mut resolution = match request.resolution {
Some(NanoBananaProMultiFunctionImageGenImageResolution::OneK) => EnqueueNanoBananaProEditImageResolution::OneK,
Some(NanoBananaProMultiFunctionImageGenImageResolution::TwoK) => EnqueueNanoBananaProEditImageResolution::TwoK,
Some(NanoBananaProMultiFunctionImageGenImageResolution::FourK) => EnqueueNanoBananaProEditImageResolution::FourK,
@@ -144,6 +175,11 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
None => EnqueueNanoBananaProEditImageAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = EnqueueNanoBananaProEditImageNumImages::One;
resolution = EnqueueNanoBananaProEditImageResolution::OneK;
}
let args = EnqueueNanoBananaProEditImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
image_urls: input_image_urls.to_owned(),
@@ -164,7 +200,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
} else {
info!("nano banana pro text-to-image");
let num_images = match request.num_images {
let mut num_images = match request.num_images {
Some(NanoBananaProMultiFunctionImageGenNumImages::One) => EnqueueNanoBananaProTextToImageNumImages::One,
Some(NanoBananaProMultiFunctionImageGenNumImages::Two) => EnqueueNanoBananaProTextToImageNumImages::Two,
Some(NanoBananaProMultiFunctionImageGenNumImages::Three) => EnqueueNanoBananaProTextToImageNumImages::Three,
@@ -172,7 +208,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
None => EnqueueNanoBananaProTextToImageNumImages::One, // Default to One
};
let resolution = match request.resolution {
let mut resolution = match request.resolution {
Some(NanoBananaProMultiFunctionImageGenImageResolution::OneK) => EnqueueNanoBananaProTextToImageResolution::OneK,
Some(NanoBananaProMultiFunctionImageGenImageResolution::TwoK) => EnqueueNanoBananaProTextToImageResolution::TwoK,
Some(NanoBananaProMultiFunctionImageGenImageResolution::FourK) => EnqueueNanoBananaProTextToImageResolution::FourK,
@@ -194,6 +230,11 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
None => EnqueueNanoBananaProTextToImageAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = EnqueueNanoBananaProTextToImageNumImages::One;
resolution = EnqueueNanoBananaProTextToImageResolution::OneK;
}
let args = EnqueueNanoBananaProTextToImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
num_images,
@@ -233,9 +274,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: maybe_user_session
.as_ref()
.map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_model_type: Some(ModelType::NanoBananaPro),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
@@ -280,7 +319,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
fal_category: FalCategory::ImageGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_creator_user_token: maybe_user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
@@ -5,6 +5,7 @@ pub mod upload_generic;
pub mod upload_image_media_file_handler;
pub mod upload_new_engine_asset_media_file_handler;
pub mod upload_new_scene_media_file_handler;
pub mod upload_spz_media_file_handler;
pub mod upload_pmx;
pub mod upload_saved_scene_media_file_handler;
pub mod upload_scene_snapshot_media_file_handler;
@@ -0,0 +1,273 @@
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use actix_multipart::form::tempfile::TempFile;
use actix_multipart::form::text::Text;
use actix_multipart::form::MultipartForm;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use utoipa::ToSchema;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use http_server_common::request::get_request_ip::get_request_ip;
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
const GZIP_MIME_TYPE: &str = "application/gzip";
const SPZ_EXTENSION: &str = ".spz";
const CERAMIC_SPZ_EXTENSION: &str = ".ceramic.spz";
/// Form-multipart request fields for SPZ (Gaussian Splat) upload.
///
/// IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `UploadSpzMediaFileForm` (Under "Schema") FOR DETAILS ON FIELDS AND NULLABILITY.
#[derive(MultipartForm, ToSchema)]
#[multipart(duplicate_field = "deny")]
pub struct UploadSpzMediaFileForm {
/// UUID for request idempotency
#[multipart(limit = "2 KiB")]
#[schema(value_type = String, format = Binary)]
uuid_idempotency_token: Text<String>,
/// The uploaded SPZ file
#[multipart(limit = "512 MiB")]
#[schema(value_type = Vec<u8>, format = Binary)]
file: TempFile,
/// Optional: Title (name) of the Gaussian splat
#[multipart(limit = "2 KiB")]
#[schema(value_type = Option<String>, format = Binary)]
maybe_title: Option<Text<String>>,
/// Optional: Visibility of the Gaussian splat
#[multipart(limit = "2 KiB")]
#[schema(value_type = Option<String>, format = Binary)]
maybe_visibility: Option<Text<Visibility>>,
}
#[derive(Serialize, ToSchema)]
pub struct UploadSpzMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
/// This endpoint is for uploading SPZ (Gaussian Splat) files.
///
/// SPZ files are compressed Gaussian splat files used for 3D scene representation.
/// The file must have a `.spz` extension and be gzip-compressed.
#[utoipa::path(
post,
tag = "Media Files (Upload)",
path = "/v1/media_files/upload/spz",
responses(
(status = 200, description = "Success", body = UploadSpzMediaFileSuccessResponse),
(status = 400, description = "Bad input", body = MediaFileUploadError),
(status = 401, description = "Not authorized", body = MediaFileUploadError),
(status = 429, description = "Too many requests", body = MediaFileUploadError),
(status = 500, description = "Server error", body = MediaFileUploadError),
),
params(
(
"request" = UploadSpzMediaFileForm,
description = "IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `UploadSpzMediaFileForm` (Under 'Schema') FOR DETAILS ON FIELDS AND NULLABILITY."
),
)
)]
pub async fn upload_spz_media_file_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
MultipartForm(mut form): MultipartForm<UploadSpzMediaFileForm>,
) -> Result<Json<UploadSpzMediaFileSuccessResponse>, MediaFileUploadError> {
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await
.map_err(|err| {
error!("MySql pool error: {:?}", err);
MediaFileUploadError::ServerError
})?;
// ==================== READ SESSION ==================== //
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
error!("Session checker error: {:?}", e);
MediaFileUploadError::ServerError
})?;
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== BANNED USERS ==================== //
if let Some(ref user) = maybe_user_session {
if user.is_banned {
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
}
}
// ==================== RATE LIMIT ==================== //
let rate_limiter = match maybe_user_session {
None => &server_state.redis_rate_limiters.file_upload_logged_out,
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
};
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
return Err(MediaFileUploadError::RateLimited);
}
// ==================== HANDLE IDEMPOTENCY ==================== //
let uuid_idempotency_token = form.uuid_idempotency_token.as_ref();
if let Err(reason) = validate_idempotency_token_format(uuid_idempotency_token) {
return Err(MediaFileUploadError::BadInput(reason));
}
insert_idempotency_token(uuid_idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
MediaFileUploadError::BadInput("invalid idempotency token".to_string())
})?;
// ==================== UPLOAD METADATA ==================== //
let maybe_title = form.maybe_title
.map(|title| title.trim().to_string())
.filter(|title| !title.is_empty());
let creator_set_visibility = form.maybe_visibility
.map(|visibility| visibility.0)
.or_else(|| {
maybe_user_session
.as_ref()
.map(|user_session| user_session.preferred_tts_result_visibility)
})
.unwrap_or(Visibility::default());
// ==================== USER DATA ==================== //
let ip_address = get_request_ip(&http_request);
// ==================== FILE VALIDATION ==================== //
let maybe_filename = form.file.file_name.as_deref()
.as_deref()
.map(|filename| PathBuf::from(filename));
let filename_lowercase = maybe_filename
.as_ref()
.and_then(|f| f.to_str())
.map(|s| s.to_ascii_lowercase())
.unwrap_or_default();
// Validate file extension
if !filename_lowercase.ends_with(".spz") {
return Err(MediaFileUploadError::BadInput(
"File must have .spz extension".to_string()
));
}
let mut file_bytes = Vec::new();
form.file.file.read_to_end(&mut file_bytes)
.map_err(|e| {
error!("Problem reading file: {:?}", e);
MediaFileUploadError::ServerError
})?;
// Validate mimetype (SPZ files are gzip-compressed)
let detected_mimetype = get_mimetype_for_bytes(file_bytes.as_ref());
if detected_mimetype != Some(GZIP_MIME_TYPE) {
return Err(MediaFileUploadError::BadInput(
"SPZ file must be gzip-compressed".to_string()
));
}
let file_size_bytes = file_bytes.len();
let hash = sha256_hash_bytes(&file_bytes)
.map_err(|io_error| {
error!("Problem hashing bytes: {:?}", io_error);
MediaFileUploadError::ServerError
})?;
// ==================== UPLOAD AND SAVE ==================== //
// Check for WorldLabs ceramic SPZ files
let is_world_labs_spz = filename_lowercase.contains("ceramic") && filename_lowercase.ends_with(".spz");
let extension = if is_world_labs_spz {
CERAMIC_SPZ_EXTENSION
} else {
SPZ_EXTENSION
};
const PREFIX: Option<&str> = Some("artcraft_");
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(extension));
info!("Uploading SPZ media to bucket path: {}", public_upload_path.get_full_object_path_str());
server_state.public_bucket_client.upload_file_with_content_type(
public_upload_path.get_full_object_path_str(),
file_bytes.as_ref(),
GZIP_MIME_TYPE)
.await
.map_err(|e| {
warn!("Upload SPZ bytes to bucket error: {:?}", e);
MediaFileUploadError::ServerError
})?;
let media_token = MediaFileInsertBuilder::new()
.media_file_class(MediaFileClass::Dimensional)
.media_file_type(MediaFileType::Spz)
.maybe_creator_user(maybe_user_token.as_ref())
.maybe_creator_anonymous_visitor(maybe_avt_token.as_ref())
.creator_ip_address(&ip_address)
.creator_set_visibility(creator_set_visibility)
.media_file_origin_category(MediaFileOriginCategory::Upload)
.mime_type(GZIP_MIME_TYPE)
.file_size_bytes(file_size_bytes as u64)
.checksum_sha2(&hash)
.maybe_title(maybe_title.as_deref())
.is_intermediate_system_file(false)
.public_bucket_directory_hash(&public_upload_path)
.maybe_origin_filename(form.file.file_name.as_deref())
.insert_pool(&server_state.mysql_pool)
.await
.map_err(|err| {
warn!("New SPZ file creation DB error: {:?}", err);
MediaFileUploadError::ServerError
})?;
info!("new SPZ media file token: {:?}", &media_token);
Ok(Json(UploadSpzMediaFileSuccessResponse {
success: true,
media_file_token: media_token,
}))
}
@@ -29,6 +29,7 @@ use crate::http_server::endpoints::media_files::upload::upload_new_scene_media_f
use crate::http_server::endpoints::media_files::upload::upload_pmx::upload_pmx_media_file_handler::upload_pmx_media_file_handler;
use crate::http_server::endpoints::media_files::upload::upload_saved_scene_media_file_handler::upload_saved_scene_media_file_handler;
use crate::http_server::endpoints::media_files::upload::upload_scene_snapshot_media_file_handler::upload_scene_snapshot_media_file_handler;
use crate::http_server::endpoints::media_files::upload::upload_spz_media_file_handler::upload_spz_media_file_handler;
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::upload_studio_shot_media_file_handler;
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::upload_new_video_media_file_handler;
use crate::http_server::endpoints::media_files::upload::upload_video_old::upload_video_media_file_handler::upload_video_media_file_handler;
@@ -149,6 +150,10 @@ pub fn add_media_file_routes<T, B> (app: App<T>) -> App<T>
.route(web::post().to(upload_new_scene_media_file_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/upload/spz")
.route(web::post().to(upload_spz_media_file_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/upload/scene_snapshot")
.route(web::post().to(upload_scene_snapshot_media_file_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -16,6 +16,10 @@
/news/ /index.html 200
/news/* /index.html 200
/press-kit /index.html 200
/press-kit/ /index.html 200
/press-kit/* /index.html 200
# Redirects for growth measurement
/reddit /?utm_source=reddit 301
+19 -9
View File
@@ -1,19 +1,30 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"></script>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-VKW89949V0');
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-VKW89949V0");
</script>
<meta charset="utf-8" />
<title>ArtCraft. AI Video and Images. Fast and Open Desktop App.</title>
<meta name="description" content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!" />
<meta name="keywords" content="ArtCraft, AI, AI Video, AI Images, Open Source" />
<meta
name="description"
content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
/>
<meta
name="keywords"
content="ArtCraft, AI, AI Video, AI Images, Open Source"
/>
<base href="/" />
@@ -23,10 +34,9 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
href="https://fonts.googleapis.com/css2?family=Outfit:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+27 -2
View File
@@ -1,13 +1,21 @@
import { Route, Routes } from "react-router-dom";
import { Route, Routes, Navigate } from "react-router-dom";
import Download from "../pages/download";
import Media from "../pages/media";
import PressKit from "../pages/press-kit";
import Navbar from "../components/navbar";
import Landing2 from "../pages/landing2";
import TutorialsPage from "../pages/tutorials";
import TutorialsArticle from "../pages/tutorials/article";
import FaqIndex from "../pages/faq/index";
import FaqArticle from "../pages/faq/article";
import { NewsIndex, NewsPost } from "@storyteller/markdown-content";
import NewsIndex from "../pages/news/news-index";
import NewsPost from "../pages/news/news-post";
import Pricing from "../pages/pricing";
import Login from "../pages/login";
import Signup from "../pages/signup";
import ForgotPassword from "../pages/forgot-password";
import Welcome from "../pages/welcome";
import { CheckoutSuccess, CheckoutCancel } from "../pages/checkout";
export function App() {
return (
@@ -19,12 +27,29 @@ export function App() {
<Route path="/download" element={<Download />} />
<Route path="/media" element={<Media />} />
<Route path="/media/:id" element={<Media />} />
<Route path="/press-kit" element={<PressKit />} />
<Route path="/tutorials" element={<TutorialsPage />} />
<Route path="/tutorials/:slug" element={<TutorialsArticle />} />
<Route path="/faq" element={<FaqIndex />} />
<Route path="/faq/:slug" element={<FaqArticle />} />
<Route path="/news" element={<NewsIndex basePath="/news" />} />
<Route path="/news/:slug" element={<NewsPost basePath="/news" />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/welcome" element={<Welcome />} />
<Route path="/checkout/success" element={<CheckoutSuccess />} />
<Route path="/checkout/cancel" element={<CheckoutCancel />} />
{/* Redirects for underscore-based URLs (legacy Stripe config) */}
<Route
path="/checkout_success"
element={<Navigate to="/checkout/success" replace />}
/>
<Route
path="/checkout_cancel"
element={<Navigate to="/checkout/cancel" replace />}
/>
</Routes>
</div>
);
@@ -0,0 +1 @@
export * from "./signup-form";
@@ -0,0 +1,153 @@
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faEye, faEyeSlash, faSpinner } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { UsersApi } from "@storyteller/api";
interface SignupFormProps {
onSuccess: () => void;
signupSource: string;
className?: string;
showGoogleButton?: boolean;
autoFocus?: boolean;
}
export const SignupForm = ({
onSuccess,
signupSource,
className = "",
showGoogleButton = true,
autoFocus = false,
}: SignupFormProps) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSignup = async () => {
setError(null);
if (!email || !password) {
setError("All fields are required");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setIsLoading(true);
const api = new UsersApi();
// Generate a username from the email
const emailPrefix = email.split("@")[0].replace(/[^a-zA-Z0-9]/g, "");
// Ensure we don't exceed max length (7 chars from email + 3 digits = 10 total)
const truncatedPrefix = emailPrefix.substring(0, 7);
const randomSuffix = Math.floor(Math.random() * 900) + 100; // 100-999
const generatedUsername = `${truncatedPrefix}${randomSuffix}`;
const response = await api.Signup({
username: generatedUsername,
email,
password,
passwordConfirmation: password,
signupSource,
});
setIsLoading(false);
if (response.success) {
window.dispatchEvent(new Event("auth-change"));
onSuccess();
} else {
setError(response.errorMessage || "Failed to create account");
}
};
return (
<div className={`space-y-4 ${className}`}>
{showGoogleButton && (
<>
<Button
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
onClick={() => {}}
>
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
Continue with Google
</Button>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10"></div>
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">
or
</span>
</div>
</>
)}
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-sm text-center animate-in fade-in slide-in-from-top-2">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Password
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleSignup}
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinner} spin />
) : (
"Create Account"
)}
</Button>
</div>
);
};
@@ -17,7 +17,7 @@ export const DiscordButton = ({
return (
<Button
className={`relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg hover:shadow-blue-500/25 ${className}`}
className={`relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg bg-white text-black hover:bg-gray-200 ${className}`}
icon={faDiscord}
as="link"
href={SOCIAL_LINKS.DISCORD}
@@ -0,0 +1,153 @@
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
import {
faTimes,
faEnvelope,
faArrowLeft,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@storyteller/ui-button";
import { useNavigate } from "react-router-dom";
import { SignupForm } from "./auth";
interface DownloadModalProps {
isOpen: boolean;
onClose: () => void;
}
export const DownloadModal = ({ isOpen, onClose }: DownloadModalProps) => {
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [view, setView] = useState<"menu" | "signup">("menu");
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
setIsVisible(true);
document.body.style.overflow = "hidden";
} else {
const timer = setTimeout(() => {
setIsVisible(false);
// Reset state after animation
setView("menu");
}, 300);
document.body.style.overflow = "unset";
return () => clearTimeout(timer);
}
}, [isOpen]);
if (!isMounted) return null;
if (!isVisible && !isOpen) return null;
return createPortal(
<div
className={`fixed inset-0 z-50 flex items-center justify-center px-4 transition-all duration-300 ${
isOpen
? "opacity-100 backdrop-blur-lg"
: "opacity-0 backdrop-blur-none pointer-events-none"
}`}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/80 transition-opacity duration-300"
onClick={onClose}
/>
{/* Modal Content */}
<div
className={`relative w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl transform transition-all duration-300 ${
isOpen ? "scale-100 translate-y-0" : "scale-95 translate-y-8"
}`}
>
<button
onClick={onClose}
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition-colors z-20"
>
<FontAwesomeIcon icon={faTimes} />
</button>
{view === "signup" && (
<button
onClick={() => setView("menu")}
className="absolute top-4 left-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition-colors z-20"
>
<FontAwesomeIcon icon={faArrowLeft} />
</button>
)}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary/20 rounded-2xl flex items-center justify-center mx-auto mb-6 text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8 animate-bounce"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Downloading ArtCraft...
</h2>
<p className="text-white/60 text-sm">
Your download has started.
<br />
While you wait, create an account to store your creations.
</p>
</div>
{view === "menu" ? (
<div className="space-y-3">
<Button
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
onClick={() => {}} // Dummy op
>
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
Continue with Google
</Button>
<Button
className="w-full bg-white/5 hover:bg-white/10 text-white border-white/10 justify-center gap-3 font-medium h-12"
onClick={() => setView("signup")}
>
<FontAwesomeIcon icon={faEnvelope} className="text-lg" />
Sign up with Email
</Button>
</div>
) : (
<SignupForm
onSuccess={() => {
onClose();
navigate("/welcome");
}}
signupSource="artcraft"
showGoogleButton={false}
autoFocus={true}
/>
)}
<div className="mt-8 text-center pt-6 border-t border-white/5">
<button
onClick={onClose}
className="text-white/40 text-xs hover:text-white/60 transition-colors font-medium"
>
I already have an account
</button>
</div>
</div>
</div>,
document.body,
);
};
@@ -4,6 +4,8 @@ import {
faDiscord,
faYoutube,
faGithubAlt,
faLinkedinIn,
faInstagram,
} from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
import { getFaqItems, getTutorialItems } from "@storyteller/markdown-content";
@@ -39,7 +41,16 @@ const navigation = {
href: SOCIAL_LINKS.GITHUB,
icon: (props: any) => <FontAwesomeIcon icon={faGithubAlt} {...props} />,
},
{
name: "Instagram",
href: SOCIAL_LINKS.INSTAGRAM,
icon: (props: any) => <FontAwesomeIcon icon={faInstagram} {...props} />,
},
{
name: "LinkedIn",
href: SOCIAL_LINKS.LINKEDIN,
icon: (props: any) => <FontAwesomeIcon icon={faLinkedinIn} {...props} />,
},
],
};
@@ -94,6 +105,9 @@ export default function Example() {
<Link to="/news" className="text-gray-400 hover:text-white">
News
</Link>
<Link to="/press-kit" className="text-gray-400 hover:text-white">
Press Kit
</Link>
</div>
<div className="flex flex-col items-center sm:items-start gap-2">
<div className="text-gray-200 mb-1">Tutorials</div>
@@ -121,7 +135,9 @@ export default function Example() {
{getFaqItems().map((item) => {
if (!item.isPublished) return null;
const truncated =
item.title.length > 36 ? item.title.slice(0, 33) + "…" : item.title;
item.title.length > 36
? item.title.slice(0, 33) + "…"
: item.title;
return (
<Link
key={item.slug}
@@ -135,7 +151,7 @@ export default function Example() {
</div>
</div>
<p className="text-center text-sm/6 text-gray-400">
&copy; 2025 ArtCraft. All rights reserved.
&copy; 2026 ArtCraft. All rights reserved.
</p>
</div>
</div>
@@ -1,3 +1,4 @@
export * from "./navbar";
export * from "./footer";
export * from "./download-button";
export * from "./pricing-table";
@@ -1,20 +1,68 @@
import { Disclosure } from "@headlessui/react";
import {
Disclosure,
Menu,
MenuButton,
MenuItem,
MenuItems,
Transition,
} from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { useEffect, useState } from "react";
import { useEffect, useState, Fragment } from "react";
import { Link, useLocation } from "react-router-dom";
import { DiscordButton } from "../discord-button";
import { Button } from "@storyteller/ui-button";
import { UsersApi, UserInfo } from "@storyteller/api";
const NAV_ITEMS = [
{ name: "Home", href: "/" },
{ name: "Tutorials", href: "/tutorials" },
{ name: "News", href: "/news" },
{ name: "FAQ", href: "/faq" },
{ name: "Press Kit", href: "/press-kit" },
{ name: "Download", href: "/download" },
];
export default function Navbar() {
const [scrolled, setScrolled] = useState(false);
const location = useLocation();
// Underline hover handled purely via CSS
const [user, setUser] = useState<UserInfo | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
// Check session on mount
// Check session on mount and when auth changes or location changes
useEffect(() => {
const checkSession = async () => {
const api = new UsersApi();
const response = await api.GetSession();
if (
response.success &&
response.data &&
response.data.loggedIn &&
response.data.user
) {
setUser(response.data.user);
} else {
setUser(undefined);
}
setIsLoading(false);
};
checkSession();
const handleAuthChange = () => {
setIsLoading(true);
checkSession();
};
window.addEventListener("auth-change", handleAuthChange);
return () => window.removeEventListener("auth-change", handleAuthChange);
}, [location.pathname]);
const handleLogout = async () => {
const api = new UsersApi();
await api.Logout();
// Reload to clear state/cache
window.location.href = "/";
};
useEffect(() => {
const handleScroll = () => {
@@ -32,10 +80,10 @@ export default function Navbar() {
"z-20 fixed top-0 left-0 w-full transition-colors duration-200 bg-transparent",
scrolled
? "bg-[#1b1b1f]/70 backdrop-blur-lg lg:bg-transparent lg:backdrop-blur-none"
: "bg-transparent"
: "bg-transparent",
)}
>
<div className="mx-auto max-w-screen sm:px-6 lg:px-8 px-6 md:px-16 xl:px-4">
<div className="mx-auto max-w-screen sm:px-6 lg:px-8 px-4 md:px-16 xl:px-4">
<div className="flex h-16 justify-between">
<div className="flex">
<div className="flex shrink-0 items-center">
@@ -64,21 +112,21 @@ export default function Navbar() {
isCurrent
? "text-white"
: "text-white/60 hover:text-white",
"relative rounded-md text-[15px] font-semibold transition-all"
"relative rounded-md text-[15px] font-semibold transition-all",
)}
>
<span className="relative z-10">{item.name}</span>
<span
className={twMerge(
"pointer-events-none absolute left-0 right-0 -bottom-1 h-[2px] overflow-hidden",
isCurrent ? "" : ""
isCurrent ? "" : "",
)}
aria-hidden="true"
>
<span
className={twMerge(
"link-underline block h-full w-full bg-primary/90",
isCurrent ? "visible-line" : ""
isCurrent ? "visible-line" : "",
)}
/>
</span>
@@ -88,21 +136,102 @@ export default function Navbar() {
</div>
</div>
<div className="flex items-center">
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center">
{/* <Button as="link" href="/download">
Download
</Button> */}
<DiscordButton
small
className="bg-white text-black hover:bg-white/90"
/>
</div>
<div className="-ml-2 flex items-center md:hidden">
{/* Mobile menu button */}
<DiscordButton
className="text-sm bg-white text-black hover:bg-white/90"
small
/>
{isLoading ? (
// Loading placeholder
<div className="hidden md:ml-4 md:flex items-center gap-6 opacity-0"></div>
) : user ? (
// Logged In State
<div className="hidden md:ml-4 md:flex items-center gap-6">
<Link
to="/pricing"
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
>
Pricing
</Link>
<Menu as="div" className="relative ml-3">
<div>
<MenuButton className="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
<span className="sr-only">Open user menu</span>
<img
className="h-8 w-8 rounded-full border border-white/10"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
modal={false}
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10"
>
<div className="px-4 py-3 border-b border-white/10">
<p className="text-sm text-white font-semibold truncate">
{user.display_name || user.username}
</p>
</div>
<MenuItem>
{({ active }) => (
<button
onClick={handleLogout}
className={twMerge(
active ? "bg-white/5" : "",
"block w-full text-left px-4 py-2 text-sm text-white/70",
)}
>
Sign out
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</div>
) : (
// Logged Out State
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center gap-6">
<Link
to="/pricing"
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
>
Pricing
</Link>
<Button
as="link"
href="/signup"
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 h-auto rounded-lg shadow-md"
>
Sign up
</Button>
</div>
)}
{/* Mobile Menu Button - simplified for now */}
<div className="-ml-2 flex items-center md:hidden gap-3">
{!user && (
<Button
as="link"
href="/signup"
className="bg-white text-black hover:bg-white/90 text-xs font-semibold px-3 py-1.5 h-auto rounded-lg"
>
Sign up
</Button>
)}
{user && (
<img
className="h-8 w-8 rounded-full border border-white/10"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
)}
</div>
</div>
</div>
@@ -16,7 +16,11 @@ export const OwnershipComparison = () => {
<span>From Websites</span>
</h2>
<p className="md:text-xl text-lg text-white/80 max-w-2xl mx-auto">
Take back control of your tools.
We're artists, not landlords. We're going to give you ArtCraft to have and to hold <em>forever</em>. It's yours.
</p>
<br />
<p className="md:text-xl text-lg text-white/80 max-w-2xl mx-auto">
So you can dump that aggregator <s>subscription</s> rent payment.
</p>
</div>
@@ -58,7 +62,7 @@ export const OwnershipComparison = () => {
</h3>
<p className="text-white/80 mb-8 leading-relaxed opacity-100">
You're paying for permission, not a product. If they shut down, you lose everything.
You're paying for permission, not a product. If they shut down, you lose everything. (Ask them about downloading your LoRAs! 🤫)
</p>
<div className="flex flex-wrap justify-center gap-3 text-xs sm:text-sm font-bold tracking-wide uppercase">
@@ -96,7 +100,7 @@ export const OwnershipComparison = () => {
<p className="text-white/80 mb-8 leading-relaxed opacity-100">
Download ArtCraft. You own the code, the keys, and the output. You don't even have to pay us to use it.
<br />
<div className="text-xs text-white/50 pt-3">(Lots of 3rd party logins and API keys supported.)</div>
<div className="text-xs text-white/50 pt-3">(Lots of 3rd party logins and API keys supported. Also please pay us. We turn that into coffee and development and movies.)</div>
</p>
<div className="flex flex-wrap justify-center gap-3 text-xs sm:text-sm font-bold tracking-wide uppercase">
@@ -0,0 +1 @@
export { default as PricingTable } from "./pricing-table";
@@ -0,0 +1,412 @@
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import {
SUBSCRIPTION_PLANS,
SubscriptionPlanDetails,
} from "@storyteller/subscription";
import { twMerge } from "tailwind-merge";
import { useState, useEffect } from "react";
import { TabSelector } from "@storyteller/ui-tab-selector";
import {
UsersApi,
BillingApi,
UserInfo,
StorytellerApiHostStore,
} from "@storyteller/api";
import { useNavigate } from "react-router-dom";
const billingTabs = [
{ id: "yearly", label: "Yearly" },
{ id: "monthly", label: "Monthly" },
];
// Mapping from our plan slugs to API plan slugs
const PLAN_SLUG_MAP: Record<string, string> = {
artcraft_basic: "artcraft_basic",
artcraft_pro: "artcraft_pro",
artcraft_max: "artcraft_max",
};
interface PricingTableProps {
includeFree?: boolean;
showHeader?: boolean;
title?: string;
subtitle?: string;
className?: string;
compact?: boolean;
}
const PricingTable = ({
includeFree = false,
showHeader = true,
title = "Choose Your Plan",
subtitle = "Support open-source development. Your subscription keeps ArtCraft free and open for everyone.",
className = "",
compact = false,
}: PricingTableProps) => {
const navigate = useNavigate();
const [billingType, setBillingType] = useState("yearly");
const isYearly = billingType === "yearly";
// User and subscription state
const [user, setUser] = useState<UserInfo | undefined>(undefined);
const [activePlanSlug, setActivePlanSlug] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [processingPlan, setProcessingPlan] = useState<string | null>(null);
// Fetch user session and subscriptions on mount
useEffect(() => {
const fetchUserData = async () => {
try {
const usersApi = new UsersApi();
const sessionResponse = await usersApi.GetSession();
if (
sessionResponse.success &&
sessionResponse.data?.loggedIn &&
sessionResponse.data?.user
) {
setUser(sessionResponse.data.user);
// Fetch active subscriptions
const billingApi = new BillingApi();
const subscriptionsResponse =
await billingApi.ListActiveSubscriptions();
if (
subscriptionsResponse.success &&
subscriptionsResponse.data?.active_subscriptions
) {
// Find ArtCraft subscription
const artcraftSub =
subscriptionsResponse.data.active_subscriptions.find(
(sub) => sub.namespace === "artcraft",
);
if (artcraftSub) {
setActivePlanSlug(artcraftSub.product_slug);
}
}
}
} catch (error) {
console.error("Error fetching user data:", error);
} finally {
setIsLoading(false);
}
};
fetchUserData();
}, []);
const plans = includeFree
? SUBSCRIPTION_PLANS
: SUBSCRIPTION_PLANS.filter((plan) => plan.slug !== "free");
const getColorSchemeClasses = (
colorScheme: SubscriptionPlanDetails["colorScheme"],
) => {
const baseClasses =
"relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md";
switch (colorScheme) {
case "dark":
return twMerge(
baseClasses,
"bg-[#1C1C20] border-white/10 hover:border-white/20",
);
case "green":
return twMerge(
baseClasses,
"bg-gradient-to-b from-[#002D23]/80 via-[#006B54]/50 to-[#00D28B]/10 border-[#00a873]/50",
"hover:border-[#00a873] hover:shadow-[0_0_30px_rgba(0,210,139,0.2)]",
);
case "purple":
return twMerge(
baseClasses,
"bg-gradient-to-b from-[#2D004D]/80 via-[#6400A8]/50 to-[#C03FFF]/10 border-[#9D4CFF]/50",
"hover:border-[#9D4CFF] hover:shadow-[0_0_30px_rgba(192,63,255,0.2)]",
);
case "orange":
return twMerge(
baseClasses,
"bg-gradient-to-b from-[#332100]/80 via-[#B35C00]/50 to-[#FFB347]/10 border-[#FF8C00]/50",
"hover:border-[#FF8C00] hover:shadow-[0_0_30px_rgba(255,179,71,0.2)]",
);
default:
return twMerge(
baseClasses,
"bg-white/5 border-white/10 hover:border-white/20",
);
}
};
const isCurrentPlan = (planSlug: string) => {
return planSlug === activePlanSlug;
};
const getButtonText = (planSlug: string) => {
if (planSlug === "free") return "Get Started";
if (isCurrentPlan(planSlug)) {
return "Current Plan";
}
// User has an active paid subscription - show "Switch Plan"
if (activePlanSlug && activePlanSlug !== "free") {
return "Switch Plan";
}
// No subscription - show "Select Plan"
return "Select Plan";
};
const handlePlanClick = async (planSlug: string) => {
// Free plan - redirect to download
if (planSlug === "free") {
navigate("/download");
return;
}
// If current plan, do nothing
if (isCurrentPlan(planSlug)) {
return;
}
// Not logged in - redirect to signup
if (!user) {
navigate("/signup");
return;
}
// Set loading state for this plan
setProcessingPlan(planSlug);
try {
const cadence = isYearly ? "yearly" : "monthly";
const apiPlanSlug = PLAN_SLUG_MAP[planSlug] || planSlug;
// Determine if we need checkout (new subscription) or portal (switch plan)
const hasActiveSub = activePlanSlug && activePlanSlug !== "free";
const endpoint = hasActiveSub
? "/v1/stripe_artcraft/portal/switch_plan"
: "/v1/stripe_artcraft/checkout/subscription";
const apiHost =
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const response = await fetch(`${apiHost}${endpoint}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
plan: apiPlanSlug,
cadence: cadence,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Redirect to Stripe
const redirectUrl =
data.stripe_checkout_redirect_url || data.stripe_portal_url;
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
console.error("No redirect URL returned from API");
}
} catch (error) {
console.error("Error initiating checkout:", error);
} finally {
setProcessingPlan(null);
}
};
const formatPrice = (plan: SubscriptionPlanDetails) => {
if (plan.monthlyPrice === 0) {
return { current: 0, original: null };
}
if (isYearly) {
const val = Math.round(plan.yearlyPrice / 12);
const original = plan.originalYearlyPrice
? Math.round(plan.originalYearlyPrice / 12)
: null;
return { current: val, original };
} else {
const val = plan.originalMonthlyPrice || plan.monthlyPrice;
return { current: val, original: null };
}
};
// Determine grid columns based on number of plans
const gridCols =
plans.length === 3
? "grid-cols-1 md:grid-cols-3"
: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4";
return (
<div className={twMerge("w-full", className)}>
{showHeader && (
<div className={`text-center mx-auto ${compact ? "mb-6" : "mb-10"}`}>
<h2
className={`font-bold mb-4 ${compact ? "text-2xl md:text-3xl" : "text-3xl sm:text-4xl md:text-5xl"}`}
>
{title}
</h2>
<p
className={`text-white/70 leading-relaxed ${compact ? "text-base mb-4" : "text-xl mb-8"}`}
>
{subtitle}
</p>
</div>
)}
{/* Billing Toggle */}
<div
className={`flex items-center justify-center gap-4 relative w-fit mx-auto ${compact ? "mb-14" : "mb-14"}`}
>
<TabSelector
tabs={billingTabs}
activeTab={billingType}
onTabChange={setBillingType}
className="w-fit border border-white/20 rounded-lg bg-white/5"
tabClassName="w-24 text-md"
indicatorClassName="bg-primary/30 border border-primary"
selectedTabClassName="text-white"
/>
<span className="bg-primary text-white px-3 py-0.5 rounded-full text-sm font-medium -top-3 -right-10 md:-left-6 md:right-auto absolute pointer-events-none transform md:-rotate-12 rotate-12">
-20%
</span>
</div>
<div className={`max-w-7xl mx-auto grid ${gridCols} gap-4 md:gap-6`}>
{plans.map((plan) => {
const isPopular = plan.slug === "artcraft_pro";
const isCurrent = isCurrentPlan(plan.slug);
const { current: price, original: originalPrice } = formatPrice(plan);
const isProcessing = processingPlan === plan.slug;
return (
<div
key={plan.slug}
className={
getColorSchemeClasses(plan.colorScheme) +
(isPopular ? " transform md:-translate-y-4 shadow-2xl" : "") +
(isCurrent ? " ring-2 ring-white/50" : "")
}
>
{isPopular && !isCurrent && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap">
MOST POPULAR
</div>
)}
{isCurrent && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white text-black px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap">
CURRENT PLAN
</div>
)}
<h3 className="text-xl md:text-2xl font-bold mb-2 text-white">
{plan.name}
</h3>
<div className="mb-1 flex items-baseline gap-2">
{originalPrice !== null && (
<span className="text-white/40 line-through text-lg md:text-xl decoration-white/40">
${originalPrice}
</span>
)}
<span className="text-3xl md:text-4xl font-bold">${price}</span>
<span className="text-white/60">/month</span>
</div>
<div className="text-xs text-white/40 mb-4 md:mb-6 uppercase tracking-wider font-semibold min-h-[1rem]">
{plan.monthlyPrice === 0
? "Free forever"
: isYearly
? "Billed yearly"
: "Billed monthly"}
</div>
<Button
className={`w-full justify-center border-transparent mb-6 md:mb-8 ${
isCurrent
? "bg-white/20 cursor-default"
: isPopular
? "bg-primary hover:bg-primary-600"
: "bg-white hover:bg-white/80 text-black"
}`}
onClick={() => handlePlanClick(plan.slug)}
disabled={isCurrent || isProcessing || isLoading}
>
{isProcessing ? (
<span className="flex items-center gap-2">
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Processing...
</span>
) : (
getButtonText(plan.slug)
)}
</Button>
<ul className="space-y-3 md:space-y-4 flex-1">
{plan.features.map((feature, idx) => (
<Feature
key={idx}
text={feature.text}
highlighted={isPopular}
/>
))}
</ul>
</div>
);
})}
</div>
</div>
);
};
const Feature = ({
text,
highlighted = false,
}: {
text: string;
highlighted?: boolean;
}) => (
<li className="flex items-start gap-3">
<div
className={`mt-1 w-5 h-5 rounded-full flex items-center justify-center shrink-0 ${highlighted ? "bg-white/20 text-white" : "bg-white/10 text-white/70"}`}
>
<FontAwesomeIcon icon={faCheck} className="text-xs" />
</div>
<span className={`text-sm ${highlighted ? "text-white" : "text-white/80"}`}>
{text}
</span>
</li>
);
export default PricingTable;
@@ -0,0 +1,64 @@
import { DiscordButton } from "../discord-button";
export const ReallyAI = () => {
return (
<>
<div className="relative flex overflow-visible xl:items-center xl:pt-0 px-2 sm:px-4 md:px-0 md:-mt-36 min-[1980px]:-mt-64">
{/* Gradient Orb for Section */}
<div className="absolute left-[-200px] top-[-100px] w-[600px] h-[600px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-15 blur-[80px] md:blur-[120px] z-0 pointer-events-none transform-gpu" />
<div
className="w-full flex flex-col items-center justify-center text-center pt-16 sm:pt-24 md:pt-32 px-2 sm:px-6 md:px-10"
data-animate
>
<div className="relative">
<h1 className="relative mb-4 sm:mb-6 md:mb-10 font-bold text-3xl sm:text-4xl md:text-[3rem] lg:text-[4rem] xl:text-[5rem] !leading-none text-shadow-lg">
<span
className="text-primary"
style={{ textShadow: "0 0 15px var(--color-primary)" }}
>
"
</span>
Is this really AI?
<span
className="text-primary"
style={{ textShadow: "0 0 15px var(--color-primary)" }}
>
"
</span>
</h1>
<span className="absolute -right-6 sm:-right-5 -bottom-1 sm:bottom-4 md:-right-16 md:bottom-10 text-lg sm:text-xl md:text-3xl font-bold opacity-40 italic hover:opacity-80 transition-opacity duration-300">
— You
</span>
</div>
</div>
</div>
<div className="relative z-10 mx-auto w-full max-w-[1200px] sm:px-4 md:px-16 lg:px-12 xl:px-32 pb-8 sm:pb-16 md:pb-24 lg:pb-32 overflow-visible px-4">
{/* Gradient Orb for Section */}
<div className="absolute right-[-250px] top-[-150px] w-[700px] h-[700px] rounded-full bg-gradient-to-br from-blue-700 via-[#00AABA] to-pink-500 opacity-[0.1] blur-[100px] md:blur-[140px] z-0 pointer-events-none transform-gpu" />
<div
className="items-center gap-8 sm:gap-12 md:gap-16 mb-8 sm:mb-12 p-2 sm:p-4 bg-white/10 backdrop-blur-md rounded-[24px] sm:rounded-[40px] shadow-xl"
data-animate
>
<div className="rounded-[16px] sm:rounded-[24px] overflow-hidden bg-[#1C1C20] shadow-inner">
<video
muted
autoPlay
loop
playsInline
className="w-full aspect-[16/9] object-cover"
>
<source src="/videos/hero-video.mp4" type="video/mp4" />
</video>
</div>
</div>
<div
className="flex justify-center"
data-animate
>
<DiscordButton />
</div>
</div>
</>
);
}
@@ -32,10 +32,11 @@
// MACOS: `${R2_BASE}/mac/ArtCraft_0.0.1_universal_${MAC_VERSION}.dmg`,
// } as const;
export const DOWNLOAD_LINKS = {
// WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.2.0/ArtCraft_0.2.0_x64-setup.exe',
WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.4.0/ArtCraft_0.4.0_x64-setup.exe',
// MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.2.0/ArtCraft_0.2.0_universal.dmg',
MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.4.0/ArtCraft_0.4.0_universal.dmg',
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.2.0/ArtCraft_0.2.0_x64-setup.exe',
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.4.0/ArtCraft_0.4.0_x64-setup.exe', // BROKEN (NX)
WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.5.0/ArtCraft_0.5.0_x64-setup.exe',
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.2.0/ArtCraft_0.2.0_universal.dmg',
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.4.0/ArtCraft_0.4.0_universal.dmg', // BROKEN (NX)
MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.5.0/ArtCraft_0.5.0_universal.dmg',
} as const;
@@ -1,6 +1,8 @@
export const SOCIAL_LINKS = {
DISCORD: "https://discord.gg/75svZP2Vje",
DISCORD: "https://discord.gg/artcraft", // Previously: "https://discord.gg/75svZP2Vje"
YOUTUBE: "https://www.youtube.com/@OfficialArtCraftStudios",
TIKTOK: "https://www.tiktok.com/@artcraft.studios",
GITHUB: "https://github.com/storytold/artcraft",
INSTAGRAM: "https://www.instagram.com/get_artcraft",
LINKEDIN: "https://www.linkedin.com/company/artcraft-ai",
} as const;
@@ -0,0 +1,98 @@
import { faXmarkCircle, faArrowLeft } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Link } from "react-router-dom";
import Seo from "../../components/seo";
const CheckoutCancel = () => {
return (
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
<Seo
title="Checkout Cancelled - ArtCraft"
description="Your checkout was cancelled. No payment was made."
/>
{/* Background gradient - subtle/neutral */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-gray-500/20 via-gray-600/10 to-gray-700/5 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
{/* Cancel Card */}
<div className="max-w-lg w-full">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
{/* Cancel Icon */}
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-white/10 flex items-center justify-center">
<FontAwesomeIcon
icon={faXmarkCircle}
className="text-5xl text-white/50"
/>
</div>
</div>
{/* Header */}
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-white">
Checkout Cancelled
</h1>
<p className="text-lg text-white/60 mb-8 max-w-md mx-auto">
No worries! Your checkout was cancelled and no payment was made.
You can try again whenever you're ready.
</p>
{/* Info Box */}
<div className="bg-[#252529] rounded-2xl p-5 mb-8 text-left">
<p className="text-white/70 text-sm">
<span className="text-white font-medium">
Changed your mind?
</span>{" "}
No problem. You can return to the pricing page to complete your
purchase at any time.
</p>
</div>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
as="link"
href="/pricing"
className="bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
>
View Plans Again
</Button>
<Button
as="link"
href="/"
className="bg-white/10 hover:bg-white/20 px-8 py-3 text-sm font-bold rounded-xl justify-center border-transparent"
>
<FontAwesomeIcon icon={faArrowLeft} />
Back to Home
</Button>
</div>
</div>
{/* Footer Links */}
<div className="text-center mt-8 flex justify-center gap-6">
<Link
to="/faq"
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
>
FAQ
</Link>
<span className="text-white/20">•</span>
<a
href="https://discord.gg/artcraft"
target="_blank"
rel="noopener noreferrer"
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
>
Get Help
</a>
</div>
</div>
</main>
</div>
);
};
export default CheckoutCancel;
@@ -0,0 +1,2 @@
export { default as CheckoutSuccess } from "./success";
export { default as CheckoutCancel } from "./cancel";
@@ -0,0 +1,149 @@
import {
faCheckCircle,
faDownload,
faDesktop,
faRocket,
faArrowLeft,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Link } from "react-router-dom";
import { isMobile, isMacOs } from "react-device-detect";
import { DOWNLOAD_LINKS } from "../../config/downloads";
import Seo from "../../components/seo";
const CheckoutSuccess = () => {
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
return (
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
<Seo
title="Payment Successful - ArtCraft"
description="Your ArtCraft subscription is now active. Download and start creating!"
/>
{/* Background gradient - celebratory green/purple */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
{/* Success Card */}
<div className="max-w-2xl w-full">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
{/* Success Icon */}
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
<FontAwesomeIcon
icon={faCheckCircle}
className="text-5xl text-green-400"
/>
</div>
</div>
{/* Header */}
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-white">
Thank You for Your Support!
</h1>
<p className="text-lg text-white/70 mb-8 max-w-md mx-auto">
Your subscription is now active. Thank you for helping keep
ArtCraft open-source and free for everyone. You're now ready to
create amazing AI-generated art!
</p>
{/* Next Steps */}
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<FontAwesomeIcon
icon={faRocket}
className="text-primary text-sm"
/>
</div>
<h2 className="text-lg font-bold text-white">Next Steps</h2>
</div>
<div className="space-y-3">
{[
"Download ArtCraft if you haven't already",
"Log in with your account",
"Your subscription is automatically activated",
"Start creating with all premium features!",
].map((step, idx) => (
<div key={idx} className="flex items-start gap-3">
<div className="w-6 h-6 shrink-0 rounded-full bg-primary/50 flex items-center justify-center text-xs font-bold text-white mt-0.5">
{idx + 1}
</div>
<span className="text-white/80">{step}</span>
</div>
))}
</div>
</div>
{/* CTA Buttons */}
{!isMobile ? (
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
as="link"
href={downloadUrl}
className="bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
>
<FontAwesomeIcon icon={faDownload} className="mr-2" />
Download ArtCraft
</Button>
<Button
as="link"
href="/"
className="bg-white/10 hover:bg-white/20 px-8 py-3 text-sm font-bold rounded-xl justify-center border-transparent"
>
<FontAwesomeIcon icon={faArrowLeft} />
Back to Home
</Button>
</div>
) : (
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
<div className="flex items-center justify-center mb-3 text-orange-400">
<FontAwesomeIcon icon={faDesktop} className="text-2xl" />
</div>
ArtCraft is a desktop application. <br />
Please head to your computer to download and start creating.
</div>
)}
{/* Download Links */}
{!isMobile && (
<div className="mt-6 flex justify-center gap-6 text-sm font-medium text-white/40">
<a
href={DOWNLOAD_LINKS.WINDOWS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
Windows
</a>
<a
href={DOWNLOAD_LINKS.MACOS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
Mac
</a>
</div>
)}
</div>
{/* Footer Link */}
<div className="text-center mt-8">
<Link
to="/pricing"
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
>
View Plan Details
</Link>
</div>
</div>
</main>
</div>
);
};
export default CheckoutSuccess;
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { DOWNLOAD_LINKS } from "../../config/downloads";
import { isMobile } from "react-device-detect";
import Seo from "../../components/seo";
const Download = () => {
const systemRequirements = [
@@ -29,6 +30,10 @@ const Download = () => {
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
<Seo
title="Download ArtCraft - Windows and macOS"
description="Download ArtCraft for Windows and macOS. Start creating AI artwork today."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50" />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-30 blur-[120px]"></div>
@@ -0,0 +1,85 @@
import { faArrowLeft, faEnvelope } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { Link } from "react-router-dom";
import Seo from "../../components/seo";
const ForgotPassword = () => {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
title="Reset Password - ArtCraft"
description="Reset your ArtCraft password."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Reset Password</h1>
<p className="text-white/60 text-sm">
Enter your email to receive reset instructions
</p>
</div>
{!submitted ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={() => setSubmitted(true)}
>
Send Reset Link
</Button>
</div>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
<FontAwesomeIcon icon={faEnvelope} className="text-2xl" />
</div>
<h3 className="text-xl font-bold mb-2">Check your email</h3>
<p className="text-white/60 text-sm mb-8">
We've sent a password reset link to <br />
<span className="text-white font-medium">{email}</span>
</p>
<Button
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-12"
onClick={() => setSubmitted(false)}
>
Try another email
</Button>
</div>
)}
<div className="mt-8 text-center text-sm">
<Link
to="/login"
className="text-white/40 hover:text-white transition-colors flex items-center justify-center gap-2"
>
<FontAwesomeIcon icon={faArrowLeft} /> Back to Log in
</Link>
</div>
</div>
</main>
</div>
);
};
export default ForgotPassword;
@@ -0,0 +1,2 @@
import ForgotPassword from "./forgot-password";
export default ForgotPassword;
@@ -1,6 +1,6 @@
import { DiscordButton } from "../../components/discord-button";
import { Button } from "@storyteller/ui-button";
import { isMobile } from "react-device-detect";
import { isMobile, isMacOs } from "react-device-detect";
import { faWindows, faApple, faGithub } from "@fortawesome/free-brands-svg-icons";
import {
faVolumeMute,
@@ -25,9 +25,14 @@ import ModelBadgeGrid from "../../components/model-badge-grid";
import Seo from "../../components/seo";
import { DOWNLOAD_LINKS } from "../../config/downloads";
import { OwnershipComparison } from "../../components/ownership-comparison/ownership-comparison";
import { DownloadModal } from "../../components/download-modal";
import { UsersApi } from "@storyteller/api";
gsap.registerPlugin(ScrollTrigger);
//const HERO_VIDEO_URL = "https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_commercial.mp4";
const HERO_VIDEO_URL = "https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_website_v2.mp4";
// Versions and links are now centralized in downloads config - BFlat
const MAC_LINK = DOWNLOAD_LINKS.MACOS;
const WINDOWS_LINK = DOWNLOAD_LINKS.WINDOWS;
@@ -49,8 +54,33 @@ const Landing = () => {
const [isHovering, setIsHovering] = useState(false);
const [activeFeature, setActiveFeature] = useState(0);
const [activeVideo, setActiveVideo] = useState<number | null>(null);
const [showDownloadModal, setShowDownloadModal] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const downloadUrl = isMacOs ? MAC_LINK : WINDOWS_LINK;
useEffect(() => {
const checkSession = async () => {
const api = new UsersApi();
const response = await api.GetSession();
if (response.success && response.data?.loggedIn) {
setIsLoggedIn(true);
}
};
checkSession();
}, []);
const onDownloadClick = (e: React.MouseEvent) => {
if (isLoggedIn) {
return;
}
setShowDownloadModal(true);
localStorage.setItem("artcraft_download_initiated", "true");
};
const features = [
{
icon: faMapMarkerAlt,
@@ -243,8 +273,8 @@ const Landing = () => {
className="text-base mt-4 sm:text-xl md:text-2xl lg:text-2xl text-white/70 mb-8 sm:mb-14 max-w-2xl mx-auto font-medium drop-shadow-[0_2px_12px_rgba(80,80,255,0.10)]"
data-animate
>
Text prompting sucks. Bring your true vision to life with
unparalleled control and precision.
Artists need and deserve unparalleled control and precision.
ArtCraft&rsquo;s got you covered.
</p>
{/* CTA Buttons */}
@@ -260,26 +290,17 @@ const Landing = () => {
Download on a desktop
</Button>
) : (
<>
<Button
className="text-md px-4 py-3 font-semibold rounded-xl shadow-lg gap-3"
as="link"
href={WINDOWS_LINK}
>
<FontAwesomeIcon icon={faWindows} />
Download Windows
</Button>
<Button
className="text-md px-4 py-3 font-semibold rounded-xl shadow-lg gap-3"
as="link"
href={MAC_LINK}
>
<FontAwesomeIcon icon={faApple} />
Download Mac
</Button>
</>
<Button
className="text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/25"
as="link"
href={downloadUrl}
onClick={onDownloadClick}
>
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
Download for {isMacOs ? "Mac" : "Windows"}
</Button>
)}
<img src="/images/try-free.png" alt="Try Free" draggable={false} className="absolute -left-[45%] -top-5 h-40 pointer-events-none select-none hidden md:block" />
<img src="/images/try-free.png" alt="Try Free" draggable={false} className="absolute -left-[58%] -top-2 h-40 pointer-events-none select-none hidden md:block" />
</div>
{/* Glassy Video Mockup */}
@@ -301,10 +322,10 @@ const Landing = () => {
preload="none"
poster="/images/hero-poster.jpg"
className="w-full h-full object-cover rounded-2xl bg-white"
src="https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_commercial.mp4"
src={HERO_VIDEO_URL}
>
<source
src="https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_commercial.mp4"
src={HERO_VIDEO_URL}
type="video/mp4"
/>
Your browser does not support the video tag.
@@ -348,7 +369,20 @@ const Landing = () => {
We're Pulling You <br /> <span className="text-white">Out of Prompting</span>
</h1>
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
Text-to-image is great, but artists <em>need control</em>. Achieve consistency and repeatability with our advanced toolset.
Text prompting is neat, but artists <em>crave control</em>.
</p>
<br />
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
Before you type "rotate arm thirty degrees", try ArtCraft and let our advanced
toolset help you achieve consistency and repeatability.
</p>
<br />
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
ArtCraft is the control that mere words cannot buy.
</p>
</div>
</div>
@@ -475,62 +509,7 @@ const Landing = () => {
</div>
</div>
<div className="relative flex overflow-visible xl:items-center xl:pt-0 px-2 sm:px-4 md:px-0 md:-mt-36 min-[1980px]:-mt-64">
{/* Gradient Orb for Section */}
<div className="absolute left-[-200px] top-[-100px] w-[600px] h-[600px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-15 blur-[80px] md:blur-[120px] z-0 pointer-events-none transform-gpu" />
<div
className="w-full flex flex-col items-center justify-center text-center pt-16 sm:pt-24 md:pt-32 px-2 sm:px-6 md:px-10"
data-animate
>
<div className="relative">
<h1 className="relative mb-4 sm:mb-6 md:mb-10 font-bold text-3xl sm:text-4xl md:text-[3rem] lg:text-[4rem] xl:text-[5rem] !leading-none text-shadow-lg">
<span
className="text-primary"
style={{ textShadow: "0 0 15px var(--color-primary)" }}
>
"
</span>
Is this really AI?
<span
className="text-primary"
style={{ textShadow: "0 0 15px var(--color-primary)" }}
>
"
</span>
</h1>
<span className="absolute -right-6 sm:-right-5 -bottom-1 sm:bottom-4 md:-right-16 md:bottom-10 text-lg sm:text-xl md:text-3xl font-bold opacity-40 italic hover:opacity-80 transition-opacity duration-300">
— You
</span>
</div>
</div>
</div>
<div className="relative z-10 mx-auto w-full max-w-[1200px] sm:px-4 md:px-16 lg:px-12 xl:px-32 pb-8 sm:pb-16 md:pb-24 lg:pb-32 overflow-visible px-4">
{/* Gradient Orb for Section */}
<div className="absolute right-[-250px] top-[-150px] w-[700px] h-[700px] rounded-full bg-gradient-to-br from-blue-700 via-[#00AABA] to-pink-500 opacity-[0.1] blur-[100px] md:blur-[140px] z-0 pointer-events-none transform-gpu" />
<div
className="items-center gap-8 sm:gap-12 md:gap-16 mb-8 sm:mb-12 p-2 sm:p-4 bg-white/10 backdrop-blur-md rounded-[24px] sm:rounded-[40px] shadow-xl"
data-animate
>
<div className="rounded-[16px] sm:rounded-[24px] overflow-hidden bg-[#1C1C20] shadow-inner">
<video
muted
autoPlay
loop
playsInline
className="w-full aspect-[16/9] object-cover"
>
<source src="/videos/hero-video.mp4" type="video/mp4" />
</video>
</div>
</div>
<div
className="flex justify-center"
data-animate
>
<DiscordButton />
</div>
</div>
<OwnershipComparison />
<div className="relative flex overflow-visible xl:items-center xl:pt-0 mb-4 md:mb-12 px-2 sm:px-4 md:px-0">
{/* Gradient Orb for Section */}
@@ -745,7 +724,6 @@ const Landing = () => {
</div>
</div>
<OwnershipComparison />
<div className="relative z-10 mx-auto w-full max-w-screen py-10 sm:py-20 md:py-32 sm:px-8 lg:px-32 overflow-visible px-4">
{/* Gradient Orb for Section */}
@@ -813,13 +791,13 @@ const Landing = () => {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
{/* Discord CTA Section */}
<div
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:p-16 rounded-[24px] sm:rounded-[40px] bg-gradient-to-br from-primary/30 to-primary/50 backdrop-blur-lg border-[3px] sm:border-[6px] border-primary/70 shadow-2xl shadow-primary/30"
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:p-16 rounded-[24px] sm:rounded-[40px] bg-[#1a1b36] backdrop-blur-lg border-[3px] sm:border-[6px] border-[#3f415c] shadow-2xl"
data-animate
>
<h2 className="relative mb-4 sm:mb-6 font-bold text-2xl sm:text-3xl lg:text-4xl max-w-lg !leading-tight">
<h2 className="relative mb-4 sm:mb-6 font-bold text-2xl sm:text-3xl lg:text-4xl max-w-lg !leading-tight text-white">
Join the Discord
</h2>
<p className="text-base sm:text-lg text-gray-200 mb-6 sm:mb-8 max-w-md leading-relaxed">
<p className="text-base sm:text-lg text-[#aab3c9] mb-6 sm:mb-8 max-w-md leading-relaxed">
Connect with other creators, share your work, get feedback, and stay updated.
</p>
<DiscordButton />
@@ -847,9 +825,41 @@ const Landing = () => {
Star on GitHub
</Button>
</div>
{/* Download CTA Section - New */}
<div
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:col-span-2 rounded-[24px] sm:rounded-[40px] bg-gradient-to-br from-primary/30 to-primary/50 backdrop-blur-lg border-[3px] sm:border-[6px] border-primary/70 shadow-2xl shadow-primary/30 overflow-hidden relative"
data-animate
>
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-purple-500/20 opacity-30 pointer-events-none" />
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
<h2 className="relative mb-4 sm:mb-6 font-bold text-3xl sm:text-4xl lg:text-5xl max-w-2xl !leading-tight text-white z-10">
Ready to Create?
</h2>
<p className="text-base sm:text-l md:text-xl text-white/90 mb-8 sm:mb-10 max-w-xl leading-relaxed z-10">
Join thousands of artists and filmmakers using ArtCraft to bring their vision to life.
</p>
<div className="relative z-10">
<Button
className="text-md px-10 py-5 text-xl font-semibold rounded-2xl shadow-xl gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/40 bg-white text-black hover:bg-white"
as="link"
href={downloadUrl}
onClick={onDownloadClick}
>
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
Download for {isMacOs ? "Mac" : "Windows"}
</Button>
<div className="mt-4 text-sm text-white/80 font-medium">Free to use.</div>
<div className="text-xs text-white/80 mt-1 font-medium">Unlimited Nano Banana Pro for a limited time!</div>
</div>
</div>
</div>
</div>
<DownloadModal isOpen={showDownloadModal} onClose={() => setShowDownloadModal(false)} />
<Footer />
</div>
);
@@ -0,0 +1,2 @@
import LogIn from "./login";
export default LogIn;
@@ -0,0 +1,154 @@
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faEye, faEyeSlash, faSpinner } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import Seo from "../../components/seo";
const Login = () => {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => {
setError(null);
setIsLoading(true);
const api = new UsersApi();
const response = await api.Login({
usernameOrEmail: email,
password: password,
});
setIsLoading(false);
if (response.success) {
window.dispatchEvent(new Event("auth-change"));
navigate("/");
} else {
setError(response.errorMessage || "Invalid credentials");
}
};
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
title="Login - ArtCraft"
description="Login to your ArtCraft account."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Welcome Back</h1>
<p className="text-white/60 text-sm">Log in to your account</p>
</div>
<div className="space-y-4">
<Button
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
onClick={() => {}}
>
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
Continue with Google
</Button>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10"></div>
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">
or
</span>
</div>
<div className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email or Username
</label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide">
Password
</label>
<Link
to="/forgot-password"
className="text-xs text-primary hover:text-primary-400 transition-colors"
>
Forgot password?
</Link>
</div>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinner} spin />
) : (
"Log in"
)}
</Button>
</div>
</div>
<div className="mt-8 text-center text-sm text-white/60">
Don't have an account?{" "}
<Link
to="/signup"
className="text-primary hover:text-primary-400 font-semibold transition-colors"
>
Sign up
</Link>
</div>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
);
};
export default Login;
@@ -30,14 +30,15 @@ import {
getProviderDisplayName,
getProviderIconByName,
} from "@storyteller/model-list";
import Seo from "../../components/seo";
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'];
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
const COPY_FEEDBACK_DURATION = 1500;
const SHARE_URL_BASE = 'https://getartcraft.com/media/';
const SHARE_URL_BASE = "https://getartcraft.com/media/";
const isVideoUrl = (url: string): boolean => {
const urlLower = url.toLowerCase();
return VIDEO_EXTENSIONS.some(ext => urlLower.includes(ext));
return VIDEO_EXTENSIONS.some((ext) => urlLower.includes(ext));
};
interface ContextImage {
@@ -78,7 +79,7 @@ const EMPTY_PROMPT_DATA: PromptData = {
const createPromptData = (
data: any,
hasToken: boolean,
loading: boolean = false
loading: boolean = false,
): PromptData => ({
text: data?.maybe_positive_prompt || null,
loading,
@@ -114,7 +115,13 @@ const useCopyFeedback = () => {
return { copied, triggerCopy };
};
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
const InfoRow = ({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) => (
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
<span className="text-sm text-white/60 font-medium">{label}</span>
<span className="text-sm text-white font-medium flex items-center gap-2">
@@ -155,7 +162,7 @@ export default function MediaPage() {
if (mediaResponse.success && mediaResponse.data) {
const file = mediaResponse.data;
const url = file.media_links?.cdn_url || null;
setMedia((prev) => {
const isSameUrl = prev.url === url;
return {
@@ -171,8 +178,8 @@ export default function MediaPage() {
});
if (file.maybe_prompt_token) {
setPromptData(prev => ({ ...prev, hasToken: true, loading: true }));
setPromptData((prev) => ({ ...prev, hasToken: true, loading: true }));
try {
const promptsApi = new PromptsApi();
const promptResponse = await promptsApi.GetPromptsByToken({
@@ -182,17 +189,31 @@ export default function MediaPage() {
const data = promptResponse.success ? promptResponse.data : null;
setPromptData(createPromptData(data, true, false));
} catch {
setPromptData(prev => ({ ...prev, loading: false }));
setPromptData((prev) => ({ ...prev, loading: false }));
}
} else {
setPromptData(EMPTY_PROMPT_DATA);
}
} else {
setMedia({ url: null, token: null, createdAt: null, isVideo: false, isLoaded: false, creator: null });
setMedia({
url: null,
token: null,
createdAt: null,
isVideo: false,
isLoaded: false,
creator: null,
});
toast.error("Media not found");
}
} catch {
setMedia({ url: null, token: null, createdAt: null, isVideo: false, isLoaded: false, creator: null });
setMedia({
url: null,
token: null,
createdAt: null,
isVideo: false,
isLoaded: false,
creator: null,
});
toast.error("Failed to load media");
} finally {
setMediaRecordLoading(false);
@@ -205,8 +226,6 @@ export default function MediaPage() {
}
}, [mediaIdParam, loadMedia]);
const handleCopyPrompt = useCallback(async () => {
if (!promptData.text) return;
try {
@@ -231,10 +250,12 @@ export default function MediaPage() {
const shareButtonIcon = shareCopy.copied ? faCheck : faLink;
return (
<div className="relative min-h-screen w-full p-4 pt-16 bg-dots flex items-start lg:items-center justify-center">
<Seo
title="Shared Media - ArtCraft"
description="View shared media from ArtCraft."
/>
<div className="mx-auto max-w-[1920px] w-full h-auto lg:h-[calc(100vh-100px)] min-h-[500px]">
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden rounded-xl border border-white/[2%]">
{/* Media Preview Area */}
@@ -254,14 +275,14 @@ export default function MediaPage() {
loop
muted
playsInline
onError={() => console.error('Video failed to load')}
onError={() => console.error("Video failed to load")}
onLoadedData={(e) => {
const el = e.currentTarget;
setMedia(prev => ({
...prev,
setMedia((prev) => ({
...prev,
isLoaded: true,
width: el.videoWidth,
height: el.videoHeight
height: el.videoHeight,
}));
}}
/>
@@ -273,18 +294,22 @@ export default function MediaPage() {
className="h-full w-full object-contain transition-opacity duration-300"
style={{ opacity: media.isLoaded ? 1 : 0 }}
onError={(e) => {
(e.currentTarget as HTMLImageElement).src = PLACEHOLDER_IMAGES.DEFAULT;
(e.currentTarget as HTMLImageElement).style.opacity = "0.3";
(e.currentTarget as HTMLImageElement).dataset.brokenurl = media.url || "";
setMedia(prev => ({ ...prev, isLoaded: true }));
(e.currentTarget as HTMLImageElement).src =
PLACEHOLDER_IMAGES.DEFAULT;
(e.currentTarget as HTMLImageElement).style.opacity =
"0.3";
(
e.currentTarget as HTMLImageElement
).dataset.brokenurl = media.url || "";
setMedia((prev) => ({ ...prev, isLoaded: true }));
}}
onLoad={(e) => {
const el = e.currentTarget;
setMedia(prev => ({
...prev,
setMedia((prev) => ({
...prev,
isLoaded: true,
width: el.naturalWidth,
height: el.naturalHeight
height: el.naturalHeight,
}));
}}
/>
@@ -327,7 +352,10 @@ export default function MediaPage() {
<div className="h-4 w-32 bg-white/10 rounded" />
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="aspect-square w-full bg-white/10 rounded-lg" />
<div
key={i}
className="aspect-square w-full bg-white/10 rounded-lg"
/>
))}
</div>
</div>
@@ -341,8 +369,12 @@ export default function MediaPage() {
size={36}
username={media.creator.username}
email_hash={media.creator.email_gravatar_hash}
avatarIndex={media.creator.core_info.default_avatar.image_index}
backgroundIndex={media.creator.core_info.default_avatar.color_index}
avatarIndex={
media.creator.core_info.default_avatar.image_index
}
backgroundIndex={
media.creator.core_info.default_avatar.color_index
}
className="rounded-xl border-white/10"
/>
) : (
@@ -351,8 +383,12 @@ export default function MediaPage() {
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-white text-sm font-semibold leading-none">{media.creator.display_name}</span>
<span className="text-white/60 text-xs font-medium">Author</span>
<span className="text-white text-sm font-semibold leading-none">
{media.creator.display_name}
</span>
<span className="text-white/60 text-xs font-medium">
Author
</span>
</div>
</div>
)}
@@ -381,11 +417,13 @@ export default function MediaPage() {
onClick={handleCopyPrompt}
className="flex items-center gap-1.5 text-xs text-white/60 hover:text-white transition-colors"
>
<FontAwesomeIcon icon={promptCopy.copied ? faCheck : faCopy} />
<FontAwesomeIcon
icon={promptCopy.copied ? faCheck : faCopy}
/>
<span>{promptCopy.copied ? "Copied" : "Copy"}</span>
</button>
</div>
<div className="relative text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
{promptData.loading ? (
<div className="flex items-center gap-2">
@@ -404,52 +442,59 @@ export default function MediaPage() {
</div>
</div>
{promptData.contextImages && promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faImage} />
<span>Reference Images</span>
{promptData.contextImages &&
promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faImage} />
<span>Reference Images</span>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map(
(contextImage, index) => {
const { thumbnail } =
getContextImageThumbnail(contextImage, {
size: THUMBNAIL_SIZES.SMALL,
});
return (
<div
key={contextImage.media_token}
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors"
>
<img
src={thumbnail}
alt={`Reference image ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
);
},
)}
</div>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map((contextImage, index) => {
const { thumbnail } = getContextImageThumbnail(
contextImage,
{ size: THUMBNAIL_SIZES.SMALL }
);
return (
<div
key={contextImage.media_token}
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors"
>
<img
src={thumbnail}
alt={`Reference image ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
);
})}
</div>
</div>
)}
)}
{(promptData.provider || promptData.modelType || media.createdAt) && (
{(promptData.provider ||
promptData.modelType ||
media.createdAt) && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faCircleInfo} />
<span>Information</span>
</div>
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
{promptData.modelType && (
<InfoRow
label="Model"
value={
<>
{getModelCreatorIcon(promptData.modelType)}
<span>{getModelDisplayName(promptData.modelType)}</span>
<span>
{getModelDisplayName(
promptData.modelType,
)}
</span>
</>
}
/>
@@ -460,8 +505,15 @@ export default function MediaPage() {
label="Provider"
value={
<>
{getProviderIconByName(promptData.provider, "h-4 w-4 invert")}
<span>{getProviderDisplayName(promptData.provider)}</span>
{getProviderIconByName(
promptData.provider,
"h-4 w-4 invert",
)}
<span>
{getProviderDisplayName(
promptData.provider,
)}
</span>
</>
}
/>
@@ -473,11 +525,13 @@ export default function MediaPage() {
value={`${media.width} × ${media.height}`}
/>
)}
{media.createdAt && (
<InfoRow
label="Created"
value={dayjs(media.createdAt).format("MMMM D, YYYY")}
value={dayjs(media.createdAt).format(
"MMMM D, YYYY",
)}
/>
)}
</div>
@@ -504,11 +558,15 @@ export default function MediaPage() {
"w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border",
media.url
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none",
)}
href={media.url ? addCorsParam(media.url) || media.url : undefined}
href={
media.url ? addCorsParam(media.url) || media.url : undefined
}
download={
media.url ? `artcraft-${media.token || (media.isVideo ? "video" : "image")}` : undefined
media.url
? `artcraft-${media.token || (media.isVideo ? "video" : "image")}`
: undefined
}
aria-disabled={!media.url}
target="_blank"
@@ -0,0 +1,16 @@
import { NewsIndex as LibNewsIndex } from "@storyteller/markdown-content";
import Seo from "../../components/seo";
const NewsIndex = ({ basePath }: { basePath: string }) => {
return (
<>
<Seo
title="News & Updates - ArtCraft"
description="Latest updates, features, and announcements from the ArtCraft team."
/>
<LibNewsIndex basePath={basePath} />
</>
);
};
export default NewsIndex;
@@ -0,0 +1,25 @@
import {
NewsPost as LibNewsPost,
getNewsPostBySlug,
} from "@storyteller/markdown-content";
import Seo from "../../components/seo";
import { useParams } from "react-router-dom";
const NewsPost = ({ basePath }: { basePath: string }) => {
const { slug } = useParams();
const post = slug ? getNewsPostBySlug(slug) : null;
const title = post
? `${post.title} - ArtCraft`
: "Article Not Found - ArtCraft";
const desc = post ? post.description : "";
return (
<>
<Seo title={title} description={desc} />
<LibNewsPost basePath={basePath} />
</>
);
};
export default NewsPost;
@@ -0,0 +1 @@
export { default } from "./press-kit";
@@ -0,0 +1,151 @@
// ============================================================================
// PRESS KIT ASSET DATA
// ============================================================================
//
// To add a new asset, copy one of the existing objects and modify:
// - type: "video" | "image" | "embed" | "link"
// - title: Display title for the asset
// - description: Optional short description
// - thumbnail: URL to thumbnail image (can be YouTube thumbnail or custom)
// - embedUrl: For YouTube embeds (use embed format: youtube.com/embed/VIDEO_ID)
// - videoUrl: Direct video URL for type="video" (uses downloadUrl if not provided)
// - downloadUrl: Direct link to the downloadable file (e.g., Cloudflare R2 URL)
// - downloadLabel: Optional custom label for download button (default: "Download")
// - fileSize: Optional file size display (e.g., "1.2 GB")
// - containThumbnail: If true, thumbnail uses object-contain with padding (good for logos)
//
// ============================================================================
export interface PressKitAsset {
type: "video" | "image" | "embed" | "link";
title: string;
description?: string;
thumbnail?: string;
embedUrl?: string;
/** Direct video URL for type="video" - uses downloadUrl if not provided */
videoUrl?: string;
downloadUrl: string;
downloadLabel?: string;
fileSize?: string;
/** If true, thumbnail uses object-contain with padding (good for logos) */
containThumbnail?: boolean;
}
export interface PressKitCategory {
name: string;
description?: string;
assets: PressKitAsset[];
}
// ============================================================================
// EDIT THIS DATA TO ADD PRESS KIT ASSETS
// ============================================================================
export const PRESS_KIT_CATEGORIES: PressKitCategory[] = [
{
name: "Promotional Videos",
description: "High-quality promotional videos for press coverage",
assets: [
// ArtCraft Commercial / Trailer (direct video, same as homepage)
{
type: "video",
title: "ArtCraft Commercial",
description: "Official ArtCraft commercial showcasing the app",
thumbnail: "/images/video-thumbnails/artcraft-commercial.png",
downloadUrl:
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_website_v2.mp4",
fileSize: "125 MB",
},
// Sci-Fi Horror Posing Demo
{
type: "video",
title: "ArtCraft 3D Sci-Fi Animation",
description: "3D Pre-viz Workflow demonstrates precision control",
thumbnail: "/images/video-thumbnails/artcraft-sci-fi.png",
downloadUrl:
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_SciFi_Horror.mp4",
fileSize: "14.5 MB",
},
// WorldLabs Pirate Demo
{
type: "video",
title: "ArtCraft x WorldLabs Pirate Demo",
description: "ArtCraft with Gaussian Splats can be used to quickly create sets",
thumbnail: "/images/video-thumbnails/artcraft-pirate.png",
downloadUrl:
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_World_Pirate.mp4",
fileSize: "88.5 MB",
},
// Pose Ad
{
type: "video",
title: "ArtCraft Posing and Blocking",
description: "More examples of detailed posing and blocking in 3D",
thumbnail: "/images/video-thumbnails/artcraft-knight-pose.png",
downloadUrl:
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_Pose_Ad.mp4",
fileSize: "60.6 MB",
},
// WorldLabs Ad
{
type: "video",
title: "ArtCraft x WorldLabs Ad",
description: "Several shots crafted using WorldLabs' advanced Marble model",
thumbnail: "/images/video-thumbnails/artcraft-worldlabs-ad.png",
downloadUrl:
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_WorldLabs_Ad.mp4",
fileSize: "23.7 MB",
},
// Grinch: The Anime
{
type: "embed",
title: "Grinch: The Anime",
description: "Made using ArtCraft",
thumbnail: "https://img.youtube.com/vi/oqoCWdOwr2U/maxresdefault.jpg",
embedUrl: "https://www.youtube.com/embed/oqoCWdOwr2U",
downloadUrl: "https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_Grinch_Anime.mp4",
fileSize: "56.3 MB",
},
],
},
{
name: "Logos & Branding",
description: "Official ArtCraft logos and branding assets",
assets: [
{
type: "image",
title: "ArtCraft Logo (PNG)",
thumbnail: "/images/artcraft-logo.png",
downloadUrl: "/images/artcraft-logo.png",
containThumbnail: true,
},
// Add more logo variants here:
// {
// type: "link",
// title: "Full Logo Pack (ZIP)",
// description: "All logos in PNG, SVG, and EPS formats",
// downloadUrl: "https://your-r2-bucket.r2.dev/artcraft-logo-pack.zip",
// fileSize: "12 MB",
// },
],
},
{
name: "Screenshots & Media",
description: "High-resolution screenshots and promotional images",
assets: [
// Add screenshots here:
// {
// type: "image",
// title: "Editor Interface",
// thumbnail: "/images/screenshot-editor.jpg",
// downloadUrl: "https://your-r2-bucket.r2.dev/screenshot-editor-hires.png",
// },
],
},
// Add more categories as needed:
// {
// name: "Tutorial Videos",
// description: "Step-by-step tutorials",
// assets: [],
// },
];
@@ -0,0 +1,311 @@
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faPlay,
faImage,
faVideo,
faFileZipper,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import Footer from "../../components/footer";
import Seo from "../../components/seo";
import { PRESS_KIT_CATEGORIES, type PressKitAsset } from "./press-kit-data";
// ============================================================================
// ASSET CARD COMPONENT
// ============================================================================
const AssetCard = ({
asset,
onOpenMedia,
}: {
asset: PressKitAsset;
onOpenMedia: (asset: PressKitAsset) => void;
}) => {
const getTypeIcon = () => {
switch (asset.type) {
case "video":
case "embed":
return faVideo;
case "image":
return faImage;
case "link":
return faFileZipper;
default:
return faImage;
}
};
const handleThumbnailClick = () => {
if (asset.type === "embed" && asset.embedUrl) {
onOpenMedia(asset);
} else if (asset.type === "video") {
onOpenMedia(asset);
}
};
return (
<div className="group relative flex flex-col bg-[#28282C] rounded-2xl overflow-hidden border border-white/5 transition-all duration-300 hover:border-white/10 hover:shadow-xl hover:shadow-primary/5">
{/* Thumbnail */}
<div
className={`relative aspect-video bg-black/40 overflow-hidden ${
asset.type === "embed" || asset.type === "video"
? "cursor-pointer"
: ""
}`}
onClick={handleThumbnailClick}
>
{asset.thumbnail ? (
<img
src={asset.thumbnail}
alt={asset.title}
className={`w-full h-full transition-transform duration-300 group-hover:scale-105 ${
asset.containThumbnail
? "object-contain p-6 bg-[#1a1a1e]"
: "object-cover"
}`}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/20 to-primary/5">
<FontAwesomeIcon
icon={getTypeIcon()}
className="text-4xl text-white/30"
/>
</div>
)}
{/* Play button overlay for videos and embeds */}
{(asset.type === "embed" || asset.type === "video") && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 group-hover:bg-black/40">
<div className="w-16 h-16 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center border border-white/20 transition-transform duration-300 group-hover:scale-110">
<FontAwesomeIcon
icon={faPlay}
className="text-white text-xl ml-1"
/>
</div>
</div>
)}
{/* Type badge - show "Video" for both video and embed types */}
<div className="absolute top-3 left-3 px-3 py-1 rounded-full bg-black/60 backdrop-blur-sm text-xs font-medium text-white/80 flex items-center gap-1.5">
<FontAwesomeIcon icon={getTypeIcon()} className="text-[10px]" />
<span className="capitalize">
{asset.type === "embed" ? "Video" : asset.type}
</span>
</div>
</div>
{/* Content */}
<div className="flex flex-col flex-1 p-5">
<h3 className="text-lg font-semibold text-white mb-1 line-clamp-2">
{asset.title}
</h3>
{asset.description && (
<p className="text-sm text-white/60 mb-4 line-clamp-2">
{asset.description}
</p>
)}
{/* Spacer to push button to bottom */}
<div className="flex-1" />
{/* Download button */}
{asset.downloadUrl ? (
<a
href={asset.downloadUrl}
download
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 w-full mt-3 px-4 py-2.5 rounded-xl bg-white hover:bg-white/90 text-black font-medium text-sm transition-all duration-200"
>
<FontAwesomeIcon icon={faArrowDownToLine} />
<span>{asset.downloadLabel || "Download"}</span>
{asset.fileSize && (
<span className="text-black/50 ml-1">({asset.fileSize})</span>
)}
</a>
) : (
<div className="inline-flex items-center justify-center gap-2 w-full mt-3 px-4 py-2.5 rounded-xl bg-white/10 text-white/40 font-medium text-sm cursor-not-allowed">
<FontAwesomeIcon icon={faArrowDownToLine} />
<span>Download Coming Soon</span>
</div>
)}
</div>
</div>
);
};
// ============================================================================
// VIDEO MODAL COMPONENT
// ============================================================================
const VideoModal = ({
asset,
onClose,
}: {
asset: PressKitAsset;
onClose: () => void;
}) => {
const isEmbed = asset.type === "embed" && asset.embedUrl;
const videoSrc = asset.videoUrl || asset.downloadUrl;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative w-full max-w-5xl aspect-video rounded-2xl overflow-hidden bg-black shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{isEmbed ? (
<iframe
src={`${asset.embedUrl}?autoplay=1`}
title={asset.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="w-full h-full"
/>
) : (
<video
src={videoSrc}
className="w-full h-full"
controls
autoPlay
playsInline
/>
)}
<button
onClick={onClose}
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-black/60 hover:bg-black/80 text-white flex items-center justify-center transition-colors"
aria-label="Close video"
>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
</div>
);
};
// ============================================================================
// PRESS KIT PAGE
// ============================================================================
export default function PressKitPage() {
const [activeMedia, setActiveMedia] = useState<PressKitAsset | null>(null);
const handleOpenMedia = (asset: PressKitAsset) => {
setActiveMedia(asset);
};
const handleCloseMedia = () => {
setActiveMedia(null);
};
// Filter out empty categories
const categoriesWithAssets = PRESS_KIT_CATEGORIES.filter(
(cat) => cat.assets.length > 0,
);
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-x-hidden bg-dots">
<Seo
title="Press Kit | ArtCraft"
description="Download ArtCraft press assets including logos, promotional videos, screenshots, and branding materials for media coverage."
/>
{/* Hero Section */}
<main className="relative pt-24 sm:pt-32 pb-12 sm:pb-16 px-4 sm:px-6 lg:px-8">
{/* Glowing Gradient Orb Background */}
<div className="absolute inset-0 flex items-start justify-center pointer-events-none z-0 overflow-hidden">
<div className="w-[800px] h-[600px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[100px] md:blur-[150px] transform-gpu -translate-y-1/2" />
</div>
<div className="relative z-10 max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-12 sm:mb-16">
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold mb-6 leading-tight">
Press Kit
</h1>
<p className="text-lg sm:text-xl text-white/70 max-w-2xl mx-auto leading-relaxed">
Everything you need for press coverage, reviews, and content
creation. Download high-quality assets and promotional materials
about the world's only open source precision AI tool for artists.
</p>
</div>
{/* Categories */}
{categoriesWithAssets.length > 0 ? (
<div className="space-y-16">
{categoriesWithAssets.map((category) => (
<section key={category.name}>
<div className="mb-6">
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-2">
{category.name}
</h2>
{category.description && (
<p className="text-white/60">{category.description}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{category.assets.map((asset, index) => (
<AssetCard
key={`${category.name}-${index}`}
asset={asset}
onOpenMedia={handleOpenMedia}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="text-center py-20 px-4">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-white/5 flex items-center justify-center">
<FontAwesomeIcon
icon={faFileZipper}
className="text-3xl text-white/30"
/>
</div>
<h2 className="text-2xl font-bold text-white mb-3">
Press Kit Coming Soon
</h2>
<p className="text-white/60 max-w-md mx-auto">
We're preparing our press assets. Check back soon for logos,
videos, and promotional materials.
</p>
</div>
)}
{/* Contact Section */}
<div className="mt-20 text-center py-12 px-6 rounded-3xl bg-white/5 border border-white/10 backdrop-blur-sm">
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-4">
Need Something Specific?
</h2>
<p className="text-white/70 mb-6 max-w-lg mx-auto">
For specific press inquiries, interview requests, or custom
assets, reach out to us on Discord. Or text Brandon at <a href="tel:678-744-6080">(678) 744-6080</a>.
</p>
<a
href="https://discord.gg/artcraft"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-primary hover:bg-primary/90 text-white font-semibold transition-colors"
>
Contact Us on Discord
</a>
</div>
</div>
</main>
<Footer />
{/* Video Modal */}
{activeMedia && (
<VideoModal asset={activeMedia} onClose={handleCloseMedia} />
)}
</div>
);
}
@@ -0,0 +1,2 @@
import Pricing from "./pricing";
export default Pricing;
@@ -0,0 +1,33 @@
import Footer from "../../components/footer";
import Seo from "../../components/seo";
import { PricingTable } from "../../components/pricing-table";
const Pricing = () => {
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
<Seo
title="Pricing - ArtCraft"
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50" />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<PricingTable
title="Invest in ArtCraft"
subtitle="Support open-source development. Your subscription makes ArtCraft better and gets you tons of generations."
/>
</main>
<p className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
Note: Pricing table is wrong, but we'll fix this shortly.
</p>
<Footer />
</div>
);
};
export default Pricing;
@@ -0,0 +1,2 @@
import SignUp from "./signup";
export default SignUp;
@@ -0,0 +1,47 @@
import { Link, useNavigate } from "react-router-dom";
import { SignupForm } from "../../components/auth";
import Seo from "../../components/seo";
const Signup = () => {
const navigate = useNavigate();
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
title="Sign Up - ArtCraft"
description="Create your ArtCraft account."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Create an Account</h1>
<p className="text-white/60 text-sm">Join thousands of creators</p>
</div>
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
<div className="mt-8 text-center text-sm text-white/60">
Already have an account?{" "}
<Link
to="/login"
className="text-primary hover:text-primary-400 font-semibold transition-colors"
>
Log in
</Link>
</div>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
);
};
export default Signup;
@@ -0,0 +1,2 @@
import Welcome from "./welcome";
export default Welcome;
@@ -0,0 +1,161 @@
import {
faCheckCircle,
faDownload,
faDesktop,
faRocket,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Link } from "react-router-dom";
import { isMobile, isMacOs } from "react-device-detect";
import { DOWNLOAD_LINKS } from "../../config/downloads";
import Seo from "../../components/seo";
import { PricingTable } from "../../components/pricing-table";
const Welcome = () => {
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
return (
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
<Seo
title="Welcome - ArtCraft"
description="Welcome to ArtCraft. Get started with your subscription."
/>
{/* Background gradient */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-primary/40 via-purple-600/30 to-blue-500/20 opacity-30 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8">
{/* Success Header */}
<div className="text-center mb-12">
<div className="inline-flex items-center gap-3 bg-primary/10 border border-primary/30 rounded-full px-6 py-3 mb-6">
<FontAwesomeIcon
icon={faCheckCircle}
className="text-xl text-primary"
/>
<span className="text-white font-medium">
Account created successfully!
</span>
</div>
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-4 tracking-tight">
Welcome to <span className="text-primary">ArtCraft</span>
</h1>
<p className="text-xl text-white/70 max-w-2xl mx-auto">
Support open-source development and unlock premium AI art features.
Your subscription keeps ArtCraft free for everyone.
</p>
</div>
{/* Pricing Table */}
<div className="mb-16">
<PricingTable
showHeader={false}
compact={true}
className="max-w-5xl mx-auto"
/>
</div>
{/* Next Steps Section */}
<div className="max-w-3xl mx-auto">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-10">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
<FontAwesomeIcon
icon={faRocket}
className="text-primary text-lg"
/>
</div>
<h2 className="text-2xl font-bold text-white">Getting Started</h2>
</div>
<p className="text-white/60 mb-6">
Your download should have started automatically. Follow these
steps to begin creating:
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
{[
{ step: 1, text: "Open the installer" },
{ step: 2, text: "Follow setup instructions" },
{ step: 3, text: "Log in to your account" },
{ step: 4, text: "Start creating!" },
].map((item) => (
<div
key={item.step}
className="bg-[#252529] p-4 rounded-xl flex items-center gap-4"
>
<div className="w-8 h-8 shrink-0 rounded-full bg-primary/60 flex items-center justify-center text-sm font-bold text-white">
{item.step}
</div>
<div className="text-white/90 font-medium">{item.text}</div>
</div>
))}
</div>
{/* Download Contingency */}
{!isMobile && (
<div className="pt-6 border-t border-white/10">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<span className="text-white/50 text-sm">
Download didn't start?
</span>
<div className="flex flex-wrap items-center gap-4">
<Button
as="link"
href={downloadUrl}
className="bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold rounded-xl"
>
<FontAwesomeIcon icon={faDownload} className="mr-2" />
Download for {isMacOs ? "Mac" : "Windows"}
</Button>
<div className="flex gap-4 text-sm font-medium text-white/30">
<a
href={DOWNLOAD_LINKS.WINDOWS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
Windows
</a>
<a
href={DOWNLOAD_LINKS.MACOS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
Mac
</a>
</div>
</div>
</div>
</div>
)}
{isMobile && (
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
<div className="flex items-center justify-center mb-3 text-orange-400">
<FontAwesomeIcon icon={faDesktop} className="text-2xl" />
</div>
ArtCraft is a powerful desktop experience. <br />
Please head to your computer to download and install.
</div>
)}
</div>
{/* Footer Link */}
<div className="text-center mt-8">
<Link
to="/"
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
>
Back to Home
</Link>
</div>
</div>
</main>
</div>
);
};
export default Welcome;
+20 -1
View File
@@ -3,8 +3,27 @@
@tailwind utilities;
@layer base {
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Outfit", sans-serif;
}
body {
font-family: "Poppins", sans-serif;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
color: #ffffff;
background-color: #1b1b1f;
}
@@ -7,7 +7,7 @@ module.exports = {
content: [
join(
__dirname,
"{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}"
"{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}",
),
...createGlobPatternsForDependencies(__dirname),
"./app/index.html",
@@ -23,6 +23,7 @@ module.exports = {
white: colors.white,
gray: colors.gray,
black: colors.black,
red: colors.red,
blue: colors.blue,
orange: colors.orange,
green: colors.green,
@@ -37,6 +37,12 @@
"src/components/footer"
],
"references": [
{
"path": "../../libs/components/tab-selector/tsconfig.lib.json"
},
{
"path": "../../libs/state/subscription/tsconfig.lib.json"
},
{
"path": "../../libs/model-list/tsconfig.lib.json"
},
@@ -52,14 +58,14 @@
{
"path": "../../libs/components/loading-spinner/tsconfig.lib.json"
},
{
"path": "../../libs/markdown-content/tsconfig.lib.json"
},
{
"path": "../../libs/api/tsconfig.lib.json"
},
{
"path": "../../libs/components/button/tsconfig.lib.json"
},
{
"path": "../../libs/markdown-content/tsconfig.lib.json"
}
]
}
+9 -3
View File
@@ -2,6 +2,12 @@
"files": [],
"include": [],
"references": [
{
"path": "../../libs/components/tab-selector"
},
{
"path": "../../libs/state/subscription"
},
{
"path": "../../libs/model-list"
},
@@ -17,15 +23,15 @@
{
"path": "../../libs/components/loading-spinner"
},
{
"path": "../../libs/markdown-content"
},
{
"path": "../../libs/api"
},
{
"path": "../../libs/components/button"
},
{
"path": "../../libs/markdown-content"
},
{
"path": "./tsconfig.app.json"
},
@@ -32,6 +32,9 @@ export default defineConfig(() => ({
target: 'https://api.storyteller.ai',
changeOrigin: true,
secure: true,
headers: {
Origin: 'https://api.storyteller.ai',
},
},
},
},

Some files were not shown because too many files have changed in this diff Show More