Compare commits

..
Author SHA1 Message Date
bflatastic 00696060c4 artcraft-native: generate with Midjourney or fal (provider choice)
Images can run on Midjourney with the user's account or on fal with their
API key, picked from the model selector's "Select Provider" submenu.
Midjourney connects through a sign-in window (system webview), fal through
Settings. Jobs are tracked locally and their results uploaded into the
library, resuming after a restart.

The Midjourney credential manager and the router prompt helper move into
midjourney_client and artcraft_router, shared by both desktop apps.
2026-10-11 20:46:39 +07:00
254 changed files with 1507 additions and 3160 deletions
-6
View File
@@ -1,6 +0,0 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+8 -27
View File
@@ -1,30 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
- package-ecosystem: "cargo" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
interval: "weekly"
-93
View File
@@ -1,93 +0,0 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/artcraft-linux-publish.yml':
- 'actions/checkout@v7.0.1'
- 'actions/download-artifact@v8.0.2'
- 'actions/setup-node@v7.1.0'
- 'actions/upload-artifact@v7.0.2'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/rust-cache@v2.9.2'
- 'step-security/tauri-action@v1.0.0'
- 'storytold/craft-common-actions@main'
'.github/workflows/artcraft-macos-publish.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/tauri-action@v1.0.0'
'.github/workflows/artcraft-windows-publish.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/tauri-action@v1.0.0'
'.github/workflows/freebsd.yml': []
'.github/workflows/house-rules.yml': []
'.github/workflows/lockfile.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/packaging-lint.yml': []
'.github/workflows/sonarcloud.yml': []
dependencies:
'actions/cache@caa296126883cff596d87d8935842f9db880ef25':
ref: 'v5.1.0'
commit: 'sha1-caa296126883cff596d87d8935842f9db880ef25'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/download-artifact@v8.0.2':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/setup-node@v7.1.0':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/upload-artifact@v7.0.2':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'step-security/dtolnay-rust-toolchain@v1.0.0':
ref: 'v1.0.0'
commit: 'sha1-8dd607977d06adf4ed49b190381e74c17a9dd25f'
owner_id: 88700172
repo_id: 1249865941
'step-security/harden-runner@v2.22.4':
ref: 'v2.22.4'
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
owner_id: 88700172
repo_id: 422287306
'step-security/rust-cache@v2.9.2':
ref: 'v2.9.2'
commit: 'sha1-8871c8ba063416da7c32246932ea6ae08700453e'
owner_id: 88700172
repo_id: 822636713
'step-security/tauri-action@v1.0.0':
ref: 'v1.0.0'
commit: 'sha1-dc662d498edbd35ec8ed564b45d44e955444d1bc'
owner_id: 88700172
repo_id: 1283860856
'storytold/craft-common-actions@main':
ref: 'main'
commit: 'sha1-293de72429d3ed5d9fa92aded5e500544c0fa28f'
owner_id: 76897702
repo_id: 1414429108
uses:
- 'actions/cache@caa296126883cff596d87d8935842f9db880ef25'
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
@@ -1,215 +0,0 @@
name: ArtCraft Linux release
on:
push:
branches: [release, artcraft-release, artcraft-linux]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: linux-release-${{ github.ref }}
cancel-in-progress: false
jobs:
packaging-check:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: rpm jq binutils zsync
- run: packaging/linux/package-test.sh
build:
strategy:
fail-fast: false
matrix:
include:
- {arch: x86_64, runner: ubuntu-22.04, target: x86_64-unknown-linux-gnu, bundles: "deb,rpm,appimage"}
- {arch: aarch64, runner: ubuntu-22.04-arm, target: aarch64-unknown-linux-gnu, bundles: "deb,rpm,appimage"}
- {arch: riscv64, runner: ubuntu-24.04, target: riscv64gc-unknown-linux-gnu, bundles: "deb,rpm"}
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
env:
SQLX_OFFLINE: 'true'
VITE_ENVIRONMENT_TYPE: production
TAURI_FRONTEND_PATH: ${{ github.workspace }}/frontend
TAURI_APP_PATH: ${{ github.workspace }}/crates/desktop/artcraft
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libclang-dev libasound2-dev libxdo-dev libdbus-1-dev build-essential clang file jq perl cmake nasm rpm zsync gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav
- uses: storytold/craft-common-actions/actions/install-riscv64-cross@main
if: matrix.arch == 'riscv64'
- name: Remove conflicting host Pango development files
if: matrix.arch == 'riscv64'
# Ubuntu's Pango GIR differs by architecture despite being in a shared path.
# Only the target GTK development packages are needed for this cross build.
run: sudo apt-get remove -y libpango1.0-dev:amd64
- name: Install RISC-V WebKitGTK dependencies
if: matrix.arch == 'riscv64'
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: libwebkit2gtk-4.1-dev:riscv64 libappindicator3-dev:riscv64 libssl-dev:riscv64 libxdo-dev:riscv64 libdbus-1-dev:riscv64 g++-riscv64-linux-gnu
- name: Configure RISC-V cross compilation
if: matrix.arch == 'riscv64'
run: |
cat >> "$GITHUB_ENV" <<'ENV'
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER=riscv64-linux-gnu-gcc
CC_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-gcc
CXX_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-g++
AR_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-ar
PKG_CONFIG_ALLOW_CROSS=1
PKG_CONFIG_PATH_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_LIBDIR_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
ENV
echo "CMAKE_TOOLCHAIN_FILE_riscv64gc_unknown_linux_gnu=$GITHUB_WORKSPACE/packaging/linux/riscv64.cmake" >> "$GITHUB_ENV"
- uses: actions/setup-node@v7.1.0
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- uses: step-security/dtolnay-rust-toolchain@v1.0.0
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: step-security/rust-cache@v2.9.2
with:
key: tauri-linux-${{ matrix.arch }}
cache-on-failure: true
- run: npm ci --ignore-scripts
working-directory: frontend
- run: ./node_modules/.bin/nx run artcraft:build
working-directory: frontend
- uses: step-security/tauri-action@v1.0.0
with:
projectPath: crates/desktop/artcraft
args: --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config ${{ github.workspace }}/packaging/linux/tauri.conf.json
- name: Package and verify Linux outputs
env:
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: packaging/linux/package.sh "$ARCH" "$TARGET"
- uses: actions/upload-artifact@v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
arch:
if: always() && !cancelled()
needs: build
runs-on: ubuntu-24.04
strategy:
matrix:
arch: [x86_64, aarch64]
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: actions/download-artifact@v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Find tarball
id: tarball
env:
ARCH: ${{ matrix.arch }}
run: |
shopt -s nullglob
files=(dist/linux/artcraft-*-linux-"$ARCH".tar.gz)
test "${#files[@]}" -eq 1
echo "path=${files[0]}" >> "$GITHUB_OUTPUT"
- uses: storytold/craft-common-actions/actions/arch-package@main # extra-depends support (#161)
with:
tarball: ${{ steps.tarball.outputs.path }}
arch: ${{ matrix.arch }}
extra-depends: webkit2gtk-4.1 gtk3 openssl
- name: Verify Arch runtime dependencies
run: |
for package in dist/release/*.pkg.tar.zst; do
tar --zstd -xOf "$package" .PKGINFO | grep -Fx 'depend = webkit2gtk-4.1'
done
- uses: actions/upload-artifact@v7.0.2
with:
name: release-arch-${{ matrix.arch }}
path: dist/release/*.pkg.tar.zst
if-no-files-found: error
flatpak:
if: always() && !cancelled()
needs: build
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- {arch: x86_64, runner: ubuntu-24.04}
- {arch: aarch64, runner: ubuntu-24.04-arm}
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: actions/download-artifact@v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: flatpak flatpak-builder elfutils librsvg2-common skopeo
- name: Allow Flatpak build sandboxes
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- uses: storytold/craft-common-actions/actions/import-signing-key@main
with:
key: ${{ secrets.LINUX_SIGNING_KEY }}
- name: Build Flatpak and publish OCI
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: packaging/linux/flatpak.sh
- uses: actions/upload-artifact@v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: |
dist/release/*.flatpak
dist/release/*.oci-manifest.json
if-no-files-found: error
release:
needs: [packaging-check, build, arch, flatpak]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- name: Read version
id: version
run: echo "value=$(jq -r .version crates/desktop/artcraft/tauri.conf.json)" >> "$GITHUB_OUTPUT"
- name: Require signing key
env:
SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
run: test -n "$SIGNING_KEY"
- uses: storytold/craft-common-actions/actions/draft-release@main
with:
version: ${{ steps.version.outputs.value }}
app-title: ArtCraft
tag-prefix: artcraft-v
retain: "true"
github-token: ${{ secrets.GITHUB_TOKEN }}
signing-key: ${{ secrets.LINUX_SIGNING_KEY }}
+16 -31
View File
@@ -10,13 +10,6 @@ on:
# This workflow creates Universal macOS builds with signing certificate support.
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
# One release build per branch at a time. A run in progress is never cancelled, because it may
# already have created or uploaded to the draft release. If more pushes arrive while one runs,
# GitHub keeps only the newest as pending, so the next build is of the latest commit.
concurrency:
group: macos-release-${{ github.ref }}
cancel-in-progress: false
jobs:
publish-artcraft:
permissions:
@@ -35,24 +28,17 @@ jobs:
# args: ''
runs-on: ${{ matrix.platform }}
# Successful builds took 10-22 min (2026-04 to 2026-11).
timeout-minutes: 60
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: step-security/dtolnay-rust-toolchain@v1.0.0
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
@@ -77,7 +63,7 @@ jobs:
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci --ignore-scripts
npm ci
echo "=== Version Information ==="
echo "Node version:"
@@ -98,7 +84,7 @@ jobs:
echo "Starting certificate import process..."
# Create certificate file
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Debug: Check if certificate file exists and has content
echo "Certificate file info:"
@@ -107,25 +93,25 @@ jobs:
# Debug: Check certificate contents without password (just structure)
echo "Certificate structure (may show error if password protected):"
openssl pkcs12 -info -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -noout || echo "Could not read certificate structure"
openssl pkcs12 -info -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -noout || echo "Could not read certificate structure"
# Debug: Try to list what's in the p12 file
echo "Attempting to list certificate contents:"
security cms -D -i certificate.p12 || echo "Not a CMS file, trying PKCS12..."
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
# Debug: Extract certificate details to verify type and expiration
echo "Extracting certificate details..."
# Extract just the certificate portion and examine it
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
# Also try to see what type of certificate this is
echo "Certificate type check:"
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
# Debug: Check if private key exists
echo "Checking for private key..."
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
@@ -153,7 +139,6 @@ jobs:
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Add the keychain to the search list
# shellcheck disable=SC2046
security list-keychains -d user -s build.keychain $(security list-keychains -d user | sed 's/^[[:space:]]*"//;s/"$//')
# List all certificates to debug
@@ -196,11 +181,11 @@ jobs:
security find-certificate -c "Learning Machines" build.keychain -p | openssl x509 -text -noout | grep -E "(Subject:|CN=)" || echo "Could not parse certificate"
# Set the correct signing identity
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> "$GITHUB_ENV"
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> $GITHUB_ENV
echo "Proceeding with identity: Developer ID Application: Learning Machines LLC (DJ6XS33FX8)"
# Also set the team ID
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> "$GITHUB_ENV"
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> $GITHUB_ENV
- name: Pre-build frontend to avoid Tauri build issues
run: |
@@ -210,11 +195,11 @@ jobs:
# Increase Node.js memory limit for the build
export NODE_OPTIONS="--max-old-space-size=8192"
echo "Building with nx..."
./node_modules/.bin/nx run artcraft:build
# Use npx to ensure we use the local nx version
echo "Building with npx nx..."
npx nx run artcraft:build
echo "Checking build output..."
# shellcheck disable=SC2012
ls -la apps/artcraft/dist/ | head -10
# Update Tauri config to skip the build (since we already built)
@@ -230,7 +215,7 @@ jobs:
- name: build and publish
uses: step-security/tauri-action@v1.0.0
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
+5 -22
View File
@@ -10,13 +10,6 @@ on:
# This workflow creates Windows builds.
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
# One release build per branch at a time. A run in progress is never cancelled, because it may
# already have created or uploaded to the draft release. If more pushes arrive while one runs,
# GitHub keeps only the newest as pending, so the next build is of the latest commit.
concurrency:
group: windows-release-${{ github.ref }}
cancel-in-progress: false
jobs:
publish-artcraft-windows:
permissions:
@@ -30,19 +23,11 @@ jobs:
args: ''
runs-on: ${{ matrix.platform }}
# Successful builds took 17-36 min (2026-04 to 2026-11); one hung run used the full 6-hour default.
timeout-minutes: 90
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@v4
- name: install Rust stable
uses: step-security/dtolnay-rust-toolchain@v1.0.0
with:
toolchain: stable
uses: dtolnay/rust-toolchain@stable
- name: install wreq dependencies
run: |
@@ -54,12 +39,11 @@ jobs:
shell: cmd
- name: setup node
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install frontend dependencies
shell: pwsh
run: |
# Navigate to the frontend directory to install dependencies
cd ./frontend
@@ -67,7 +51,7 @@ jobs:
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci --ignore-scripts
npm ci
echo "=== Version Information ==="
echo "Node version:"
@@ -79,7 +63,6 @@ jobs:
npm list --depth=0 | findstr nx
- name: Pre-build frontend to avoid Tauri build issues
shell: pwsh
run: |
# Build the frontend app from the correct directory
cd ./frontend
@@ -107,7 +90,7 @@ jobs:
$config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json
- name: build and publish
uses: step-security/tauri-action@v1.0.0
uses: tauri-apps/tauri-action@v0
env:
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
RUSTFLAGS: "-C target-feature=+crt-static"
-24
View File
@@ -1,24 +0,0 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners).
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on pull requests.
on:
schedule:
- cron: "27 4 * * *"
push:
branches: [release]
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
-28
View File
@@ -1,28 +0,0 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: false
-34
View File
@@ -1,34 +0,0 @@
name: Lockfile
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
# Docs: https://gh.io/actions-lockfile
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
jobs:
lockfile:
name: Lockfile is in sync
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
run: gh extension install github/gh-actions-lock --pin v0.4.1
- name: Verify the lockfile
run: gh actions-lock --verify
-23
View File
@@ -1,23 +0,0 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
name: Packaging lint
on:
push:
branches: [main, release]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: packaging-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
-22
View File
@@ -1,22 +0,0 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
-22
View File
@@ -1,25 +1,3 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
# AGENTS.md
This file provides guidance for coding agents when working with the Artcraft monorepo.
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
Generated
+68 -175
View File
@@ -304,7 +304,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -383,7 +383,6 @@ dependencies = [
"chrono",
"cookie_store 0.0.1",
"cookie_store_wrapper",
"craft-update",
"directories",
"dirs",
"discord-rich-presence",
@@ -554,6 +553,28 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "assertor"
version = "0.0.3"
@@ -617,6 +638,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -646,6 +678,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -839,12 +882,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.6.0"
@@ -1431,9 +1468,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.45"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -1775,31 +1812,6 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2a24c10817173d26d0ea4689acbbade36f91b5dc#2a24c10817173d26d0ea4689acbbade36f91b5dc"
dependencies = [
"chrono",
"dunce",
"flate2",
"getrandom 0.3.1",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
"sha2 0.10.8",
"tar",
"tempfile",
"ureq",
"verifysign",
"winreg 0.55.0",
"winsafe",
"zip",
]
[[package]]
name = "crc"
version = "3.0.1"
@@ -1817,9 +1829,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.5"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
"cfg-if",
]
@@ -2124,7 +2136,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2496,7 +2508,7 @@ dependencies = [
"rustc_version",
"toml 0.8.23",
"vswhom",
"winreg 0.52.0",
"winreg",
]
[[package]]
@@ -2686,7 +2698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2850,16 +2862,6 @@ dependencies = [
"walkdir",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -4766,7 +4768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]
[[package]]
@@ -5115,7 +5117,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6270,6 +6272,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "polycool"
version = "0.4.0"
@@ -6980,6 +6988,7 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"glib-sys",
@@ -6991,7 +7000,9 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -7161,7 +7172,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7181,7 +7192,6 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -7232,33 +7242,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls 0.23.36",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.9",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.60.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@@ -8092,7 +8075,6 @@ dependencies = [
"sqlx",
"tempfile",
"tokens",
"tokio",
]
[[package]]
@@ -8720,16 +8702,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
]
[[package]]
name = "target-lexicon"
version = "0.12.14"
@@ -9114,7 +9086,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9624,7 +9596,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9688,12 +9660,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "typed-path"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
[[package]]
name = "typeid"
version = "1.0.2"
@@ -9723,7 +9689,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9875,35 +9841,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
"rustls 0.23.36",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -9993,12 +9930,6 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -10114,16 +10045,6 @@ dependencies = [
"vello_common",
]
[[package]]
name = "verifysign"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6efd25aadc22c756a9012267fae0b130278d335d6ceb512ec02ce82029b5ca9"
dependencies = [
"core-foundation 0.10.1",
"windows-sys 0.61.2",
]
[[package]]
name = "version-compare"
version = "0.2.0"
@@ -11459,22 +11380,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
]
[[package]]
name = "winsafe"
version = "0.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -12037,19 +11942,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711e8c9646791c49b7eb799a19c4ae70a9b8372ddc6a1fa4778defd1e2aa5d5e"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.13.0",
"memchr",
"typed-path",
]
[[package]]
name = "zmij"
version = "1.0.21"
@@ -12117,6 +12009,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.0",
"zcheapstr",
"zvariant_derive",
+2 -9
View File
@@ -1,9 +1,4 @@
<h1 align="center">ArtCraft</h1>
<p align="center">
<a href="https://sonarcloud.io/project/overview?id=storytold_artcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/artcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center"><strong>The IDE for artists.</strong></p>
<!-- Main video: https://www.youtube.com/watch?v=kzvQMdg66Go
@@ -131,15 +126,13 @@ Additional provider integrations are planned for Kling, Google, Runway, and Luma
## Downloads
- [Stable releases for Windows, macOS, and Linux](https://getartcraft.com/)
- [Latest builds on GitHub](https://github.com/storytold/artcraft/releases) (including AppImage, DEB, RPM, Arch, and Flatpak packages)
- [Linux packaging notes](./packaging/linux/README.md)
- [Stable releases for Windows and macOS](https://getartcraft.com/)
- [Latest builds on GitHub](https://github.com/storytold/artcraft/releases)
- [Build from source](./_docs/dev_setup.md), including Linux
## Documentation
- [Development setup](./_docs/dev_setup.md)
- [Linux packaging](./packaging/linux/README.md)
- [Developer documentation](./_docs)
- [Desktop build and development scripts](./script/artcraft)
- [Roadmap](./ROADMAP.md)
+1 -1
View File
@@ -34,7 +34,7 @@ log.workspace = true
open = "5"
reqwest = { workspace = true, features = ["multipart"] }
resvg = "0.45"
rfd = { version = "0.16", default-features = false, features = ["gtk3"] }
rfd = "0.16"
rodio = { version = "0.20", default-features = false, features = ["symphonia-all"] }
serde.workspace = true
serde_json.workspace = true
@@ -33,7 +33,6 @@ chrono.workspace = true
cookie.workspace = true
log.workspace = true
reqwest = { workspace = true, features = ["multipart", "gzip"] }
tokio = { workspace = true, features = ["fs"] }
url.workspace = true
# Serialization
@@ -60,7 +60,7 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_bytes = tokio::fs::read(args.path.as_ref()).await
let file_bytes = std::fs::read(args.path.as_ref())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
@@ -69,7 +69,7 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_bytes = tokio::fs::read(args.path.as_ref()).await
let file_bytes = std::fs::read(args.path.as_ref())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
@@ -35,7 +35,7 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
let client = storyteller_client_builder(api_host)
.build()?;
let file_bytes = tokio::fs::read(path.as_ref()).await?;
let file_bytes = std::fs::read(path.as_ref())?;
let file_name = path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
@@ -61,7 +61,7 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
let client = storyteller_client_builder(args.api_host)
.build()?;
let file_bytes = tokio::fs::read(args.path.as_ref()).await?;
let file_bytes = std::fs::read(args.path.as_ref())?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
@@ -1,3 +1,5 @@
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use log::{info, warn};
@@ -66,7 +68,13 @@ pub async fn download_media_file<P: AsRef<Path>>(
let bytes = download_bytes(cdn_url).await?;
// 4. Write to disk.
tokio::fs::write(&output_path, &bytes).await
let mut file = fs::File::create(&output_path)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.write_all(&bytes)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.flush()
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
info!("Downloaded {} bytes to {:?}", bytes.len(), output_path);
@@ -31,7 +31,7 @@ reqwest.workspace = true
serde.workspace = true
wreq.workspace = true
serde_derive.workspace = true
tokio = { workspace = true, features = ["fs"] }
tokio.workspace = true
uuid.workspace = true
[dev-dependencies]
@@ -24,7 +24,7 @@ pub(crate) async fn upload_to_kinovi_web(
.unwrap_or_else(|| "jpg".to_string());
let file_bytes = match maybe_local_path {
Some(local_path) => match tokio::fs::read(local_path).await {
Some(local_path) => match std::fs::read(local_path) {
Ok(bytes) => {
info!("Reusing predownloaded file for kinovi upload: {}", source_url);
bytes
@@ -93,7 +93,7 @@ impl MidjourneyCredentialManager {
let _guard = self.mutation.lock().await;
// Remove every format, so a later startup cannot migrate a logged-out account.
for path in self.legacy_paths.iter().chain([&self.credential_path]) {
match tokio::fs::remove_file(path).await {
match std::fs::remove_file(path) {
Ok(()) => {},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {},
Err(err) => return Err(err.into()),
@@ -21,6 +21,10 @@ impl TaskId {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_string(&self) -> String {
self.0.to_string()
}
}
impl Display for TaskId {
@@ -11,7 +11,7 @@ use errors::AnyhowResult;
use log::{debug, error, warn};
use serde_derive::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::path::Path;
use std::{fs::File, io::Write, path::Path};
use url::Url;
use wreq::Client;
@@ -271,7 +271,9 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> AnyhowResult<()> {
let dir = Path::new(dir);
tokio::fs::create_dir_all(dir).await?;
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
for generation in generations {
let response = wreq::get(generation.url.clone()).send().await?;
let image_bytes = response.bytes().await?;
@@ -280,13 +282,15 @@ pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> A
let ext = url.path().split(".").last().unwrap_or("png");
let path = Path::new(dir).join(format!("{}.{}", generation.id, ext));
tokio::fs::write(path, &image_bytes).await?;
let mut file = File::create(path)?;
file.write_all(&image_bytes)?;
let thumbnail_url = generation.encodings.thumbnail.path.clone();
let thumbnail_response = wreq::get(thumbnail_url).send().await?;
let thumbnail_bytes = thumbnail_response.bytes().await?;
let thumbnail_path = Path::new(dir).join(format!("{}_thumbnail.{}", generation.id, ext));
tokio::fs::write(thumbnail_path, &thumbnail_bytes).await?;
let mut thumbnail_file = File::create(thumbnail_path)?;
thumbnail_file.write_all(&thumbnail_bytes)?;
}
Ok(())
@@ -141,8 +141,8 @@ mod tests {
async fn test_requests() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Debug);
let refresh_token = std::env::var("WORLDLABS_REFRESH_TOKEN")?;
let refresh_token = WorldLabsRefreshToken::new(refresh_token);
let refresh_token = "AMf-vByUgA0J9S93vULECe-sD50cbR85AFVYF60gKAmLFF1MBYwXlKRJCsv7z3oSpLtP0ApyOU5fAl52qHWM2U0yKgEu5gQ5UCUDt5SEG4UBRHWvHEmLLcu-Zl04Fq4ljocSEO3qJzuX1wyshmptZxlDPRkjdLYgyn-Kp03woI2yRPKLuQF5hQtABHabsNUh9Zhs129sgxuUlOuw4zsFI4L8UdE3bEuF3k6Mic7KlE3440YwyQ8Qtmk9zHtXIx2ob56N-1WTGSqwMUuQ1mX4OOvLve9bw2zDW3UzchomTWkh732886RPrP-DNRkPbO3FejUEMxuYQFWYX3EEeIEdfNGXFNGPL1dNniSXlSaKadpX699Ishjviv-x9o-pPQa3zoAj3NzWLyd8mn5rDBh9qAO_iDETaeo01Sjhb4mGYfAWZ-g4S6nW778";
let refresh_token = WorldLabsRefreshToken::new(refresh_token.to_string());
let result = google_refresh_token(GoogleRefreshTokenArgs {
refresh_token: &refresh_token,
-1
View File
@@ -22,7 +22,6 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "2a24c10817173d26d0ea4689acbbade36f91b5dc" }
toml.workspace = true
browser_emulation.workspace = true
cookie_store_wrapper = { workspace = true, features = ["wreq"] }
@@ -12,5 +12,4 @@ pub mod platform_info_command;
pub mod providers;
pub mod response;
pub mod task_queue;
pub mod deprecated;
pub mod updates;
pub mod deprecated;
@@ -1,8 +0,0 @@
use crate::updates::{UpdateInfo, Updates};
use std::sync::Arc;
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn check_for_updates_command(app: AppHandle, updates: State<'_, Arc<Updates>>) -> Result<UpdateInfo, String> {
updates.inner().run("The update check stopped unexpectedly", move |state| state.check(&app)).await
}
@@ -1,9 +0,0 @@
use crate::updates::{require_confirmation, Updates};
use std::sync::Arc;
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn install_update_command(app: AppHandle, updates: State<'_, Arc<Updates>>, confirmed: bool) -> Result<(), String> {
require_confirmation(confirmed)?;
updates.inner().run("The update installer stopped unexpectedly", move |state| state.install(&app)).await
}
@@ -1,2 +0,0 @@
pub mod check_for_updates_command;
pub mod install_update_command;
@@ -31,6 +31,8 @@ use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{
update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs,
};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use tauri::AppHandle;
@@ -253,7 +255,8 @@ async fn download_file(
let filename = format!("fal_{}_{}.{}", generate_random_uuid(), index, extension);
let download_path = tempdir.join(filename);
tokio::fs::write(&download_path, bytes).await?;
let mut file = File::create(&download_path)?;
file.write_all(&bytes)?;
Ok(download_path)
}
@@ -94,7 +94,7 @@ async fn download_generated_results(
}
ensure!(!urls.is_empty(), "Completed task has no downloadable results");
let path = checkpoint_path(root, task);
let mut checkpoint: AutoDownloadCheckpoint = match tokio::fs::read(&path).await {
let mut checkpoint: AutoDownloadCheckpoint = match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes)?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
AutoDownloadCheckpoint { download_time: Local::now(), saved: BTreeMap::new() }
@@ -1,6 +1,8 @@
use errors::AnyhowResult;
use reqwest::Client;
use reqwest::Url;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
const USER_AGENT: &str = "storyteller-client/1.0";
@@ -20,7 +22,13 @@ pub async fn simple_http_download<P: AsRef<Path>>(url: &Url, download_path: P) -
//let response : Response = reqwest::get(url).await?;
let bytes = response.bytes().await?;
tokio::fs::write(download_path, bytes).await?;
let mut file = OpenOptions::new()
.create(true) // To create a new file
.write(true)
.truncate(true)
.open(download_path)?;
file.write_all(&bytes)?;
Ok(())
}
+2 -26
View File
@@ -6,11 +6,6 @@ use crate::core::commands::generate::generate_mesh_command::generate_mesh_comman
pub mod core;
pub mod services;
pub mod version;
pub mod updates;
use std::sync::Arc;
use crate::core::commands::updates::check_for_updates_command::check_for_updates_command;
use crate::core::commands::updates::install_update_command::install_update_command;
use tauri::Manager;
@@ -160,20 +155,11 @@ pub fn run() {
println!("Initializing backend runtime...");
let builder = tauri::Builder::default()
.manage(Arc::new(updates::Updates::default()))
.on_window_event(|window, event| {
if window.try_state::<Arc<updates::Updates>>().is_some_and(|updates| updates.prevents_exit()) {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
}
}
})
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_upload::init())
.setup(move |app| {
updates::show_previous_outcome(app.handle());
// TODO(bt): This is broken on windows
// log_environment_details();
@@ -239,8 +225,6 @@ pub fn run() {
// TODO: Break this out into another module, because RustRover/IntelliJ lags with these macros.
// My first attempt at naively doing this didn't work because the macros can't find their codegen'd targets.
let builder = builder.invoke_handler(tauri::generate_handler![
check_for_updates_command,
install_update_command,
check_sora_session_command,
download_directory_reveal_command,
download_media_file_command,
@@ -306,14 +290,6 @@ pub fn run() {
worldlabs_receive_bearer_command,
]);
let app = builder.build(tauri::generate_context!("tauri.conf.json"))
.expect("error while building tauri application");
app.run(|handle, event| {
let updates = handle.state::<Arc<updates::Updates>>();
match event {
tauri::RunEvent::ExitRequested { api, .. } if updates.prevents_exit() => api.prevent_exit(),
tauri::RunEvent::Exit => updates.finish(),
_ => {}
}
});
builder.run(tauri::generate_context!("tauri.conf.json"))
.expect("error while running tauri application");
}
-5
View File
@@ -2,10 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
if let Some(result) = artcraft_app_lib::updates::finish_helper() {
let failed = result.is_err();
if let Err(error) = result { log::error!("Update failed: {error}"); }
std::process::exit(i32::from(failed));
}
artcraft_app_lib::run();
}
@@ -119,7 +119,7 @@ pub(super) async fn upload_midjourney_batch(maybe_app_handle: Option<&AppHandle>
}
}
if let Err(err) = tokio::fs::remove_file(MidjourneyUploadCheckpoint::path(app_data_root, local_task)).await {
if let Err(err) = std::fs::remove_file(MidjourneyUploadCheckpoint::path(app_data_root, local_task)) {
log::warn!("Could not remove completed Midjourney upload checkpoint: {}", err);
}
Ok(())
@@ -2,6 +2,8 @@ use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use errors::AnyhowResult;
use midjourney_client::utils::get_image_url::get_image_url;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use url::Url;
use midjourney_client::utils::image_downloader_client::ImageDownloaderClient;
@@ -26,7 +28,8 @@ pub async fn download_midjourney_image(
let download_filename = format!("{}_{}.{}", job_id, index, ext);
let download_path = tempdir.join(download_filename);
tokio::fs::write(&download_path, &image_bytes).await?;
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
Ok(download_path)
}
@@ -14,7 +14,7 @@ pub async fn initialize_sora_jwt_bearer_token(app_data_root: &AppDataRoot) -> An
return Ok(()); // Bearer token already exists.
}
let cookie = tokio::fs::read_to_string(app_data_root.get_sora_cookie_file_path()).await?
let cookie = std::fs::read_to_string(app_data_root.get_sora_cookie_file_path())?
.trim()
.to_string();
@@ -22,7 +22,7 @@ pub async fn initialize_sora_jwt_bearer_token(app_data_root: &AppDataRoot) -> An
let bearer = generate_bearer_jwt_with_cookie(&cookie).await?;
tokio::fs::write(app_data_root.get_sora_bearer_token_file_path(), bearer).await?;
std::fs::write(app_data_root.get_sora_bearer_token_file_path(), bearer)?;
Ok(())
}
@@ -8,7 +8,8 @@ use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use openai_sora_client::creds::sora_sentinel_token::SoraSentinelToken;
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use tokio::fs::{read_to_string, write};
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentialSet> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
@@ -20,7 +21,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file));
}
let value = read_to_string(cookie_file).await?
let value = read_to_string(cookie_file)?
.trim()
.to_string();
@@ -31,14 +32,14 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
let mut sentinel_token = None;
if bearer_file.exists() {
let value = read_to_string(&bearer_file).await?
let value = read_to_string(&bearer_file)?
.trim()
.to_string();
bearer = Some(SoraJwtBearerToken::new(value)?);
}
if legacy_sentinel_file.exists() {
let value = read_to_string(&legacy_sentinel_file).await?
let value = read_to_string(&legacy_sentinel_file)?
.trim()
.to_string();
@@ -46,7 +47,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
}
if sentinel_token_file.exists() {
let value = read_to_string(&sentinel_token_file).await?
let value = read_to_string(&sentinel_token_file)?
.trim()
.to_string();
@@ -72,7 +73,14 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
info!("Persisting bearer token value...");
let value = bearer.token_str();
write(&bearer_file, value.as_bytes()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&bearer_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
@@ -81,7 +89,14 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
info!("Persisting sentinel value...");
let value = sentinel.get_sentinel();
write(&legacy_sentinel_file, value.as_bytes()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&legacy_sentinel_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
@@ -90,7 +105,14 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
info!("Persisting sentinel token...");
let value = sentinel_token.to_persistent_storage_json()?;
write(&sentinel_token_file, value.as_bytes()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&sentinel_token_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
@@ -162,7 +162,14 @@ impl SoraCredentialManager {
self.holder.set_credentials(&creds)?;
tokio::fs::write(self.app_data_root.get_sora_legacy_sentinel_file_path(), sentinel.as_bytes()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(self.app_data_root.get_sora_legacy_sentinel_file_path())?;
file.write_all(sentinel.as_bytes())?;
file.flush()?;
Ok(creds)
}
@@ -33,6 +33,8 @@ use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
@@ -211,7 +213,8 @@ async fn download_generation_item(
info!("Writing to path: {:?}", download_path);
tokio::fs::write(&download_path, image_bytes).await?;
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
Ok(download_path)
}
@@ -39,6 +39,8 @@ use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
@@ -376,7 +378,8 @@ async fn download_spz(
info!("Writing to path: {:?}", download_path);
tokio::fs::write(&download_path, image_bytes).await?;
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
Ok(download_path)
}
-567
View File
@@ -1,567 +0,0 @@
//! Manual update checks and verified installation shared with the native Craft apps.
use craft_update::pins::{Choice, Pins};
use craft_update::self_update::Install;
use craft_update::{self_update, pick_asset, App, Http, Os, PackageKind, Paths, Platform, Release, Stop};
use enums::tauri::tasks::task_status::TaskStatus;
use serde_derive::Serialize;
use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::DialogExt;
use crate::core::state::task_database::TaskDatabase;
use crate::version::ARTCRAFT_VERSION;
const APP: App = App { bundle_id_override: Some("ai.artcraft.app"), ..App::new("artcraft", "ArtCraft", "Create images and video", "storytold/artcraft") };
#[derive(Default)]
pub struct Updates {
busy: AtomicBool,
installing: AtomicBool,
maybe_release: Mutex<Option<Release>>,
maybe_restart: Mutex<Option<Restart>>,
}
#[derive(Debug, Serialize)]
pub struct UpdateInfo {
pub current_version: String,
pub maybe_available_version: Option<String>,
pub can_install: bool,
pub tasks_running: bool,
pub releases_url: String,
}
type Restart = Box<dyn FnOnce() -> Result<(), String> + Send>;
/// Only effects cross this boundary. Tests exercise the same state transitions without
/// networking, changing an installed copy, reading personal preferences, or exiting a process.
trait Backend {
fn stable_choice(&self) -> Result<(), String>;
fn check(&self) -> Result<Option<Release>, String>;
fn idle(&self) -> Result<(), String>;
fn installation(&self) -> Option<(Install, Platform)>;
fn can_replace(&self) -> bool;
fn prepare(&self, install: &Install, release: &Release, platform: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result<Restart, String>;
fn request_exit(&self);
}
struct NativeBackend<'a>(&'a AppHandle);
struct Installing<'a>(&'a AtomicBool);
pub(crate) struct Operation {
updates: Arc<Updates>,
}
impl Updates {
pub(crate) fn begin(self: &Arc<Self>) -> Result<Operation, String> {
self.busy.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).map_err(|_| "An update operation is already running. Please wait.".to_owned())?;
Ok(Operation { updates: Arc::clone(self) })
}
/// Runs `work` off the async runtime while it holds the one-operation guard.
pub(crate) async fn run<T: Send + 'static>(self: &Arc<Self>, stopped: &str, work: impl FnOnce(&Updates) -> Result<T, String> + Send + 'static) -> Result<T, String> {
let operation = self.begin()?;
let state = Arc::clone(self);
tauri::async_runtime::spawn_blocking(move || {
let _operation = operation;
work(&state)
})
.await
.map_err(|error| format!("{stopped}: {error}"))?
}
pub(crate) fn check(&self, app: &AppHandle) -> Result<UpdateInfo, String> {
self.check_with(&NativeBackend(app))
}
fn check_with(&self, backend: &impl Backend) -> Result<UpdateInfo, String> {
backend.stable_choice()?;
let maybe_release = backend.check()?;
let tasks_running = backend.idle().is_err();
let self_updating = maybe_release.as_ref().is_some_and(|release| backend.can_replace() && backend.installation().is_some_and(|(install, platform)| release_fits_copy(release, &install, platform)));
let info = update_info(maybe_release.as_ref(), self_updating, tasks_running);
*self.maybe_release.lock().map_err(|_| "The update state is unavailable. Restart ArtCraft and try again.")? = maybe_release;
Ok(info)
}
pub(crate) fn install(&self, app: &AppHandle) -> Result<(), String> {
self.install_with(&NativeBackend(app))
}
fn install_with(&self, backend: &impl Backend) -> Result<(), String> {
self.installing.store(true, Ordering::Release);
let _installing = Installing(&self.installing);
backend.idle()?;
backend.stable_choice()?;
let release = self.maybe_release.lock().map_err(|_| "The update state is unavailable. Restart ArtCraft and try again.")?.clone().ok_or("Check for updates before installing one.")?;
let (install, platform) = backend.installation().ok_or("This installation cannot update itself. Open Releases or use its package manager.")?;
if !backend.can_replace() || !release_fits_copy(&release, &install, platform) {
return Err("Use Releases or your package manager to update this installation.".into());
}
// Recheck tasks and launcher choices immediately before replacing any application files.
let before_replace = || backend.idle().and_then(|()| backend.stable_choice());
let restart = backend.prepare(&install, &release, platform, &before_replace)?;
*self.maybe_restart.lock().map_err(|_| "The restart state is unavailable. Restart ArtCraft manually.")? = Some(restart);
// The helper starts in RunEvent::Exit, after Tauri accepts the normal exit request.
backend.request_exit();
Ok(())
}
/// Keep the native process and window alive until replacement finishes. A prepared helper
/// may request the normal exit itself, so its restart must not be cancelled here.
pub fn prevents_exit(&self) -> bool {
self.installing.load(Ordering::Acquire) && self.maybe_restart.lock().map_or(true, |pending| pending.is_none())
}
pub fn finish(&self) {
match self.maybe_restart.lock() {
Ok(mut pending) => {
if let Some(restart) = pending.take() {
if let Err(error) = restart() {
log::error!("Could not start the update helper: {error}");
}
}
},
Err(_) => log::error!("Could not read the prepared update helper"),
}
}
}
impl Drop for Installing<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
impl Drop for Operation {
fn drop(&mut self) {
self.updates.busy.store(false, Ordering::Release);
}
}
pub fn finish_helper() -> Option<Result<(), String>> {
self_update::finish(&APP, &std::env::args_os().skip(1).collect::<Vec<_>>())
}
pub fn show_previous_outcome(app: &AppHandle) {
let Ok(paths) = Paths::for_app(&APP) else {
return;
};
if let Some(message) = outcome_message(self_update::take_outcome(&paths, ARTCRAFT_VERSION)) {
app.dialog().message(message).title("ArtCraft update").show(|_| {});
}
}
fn outcome_message(outcome: Option<self_update::Outcome>) -> Option<String> {
match outcome? {
self_update::Outcome::Installed(version) => Some(format!("ArtCraft was updated to {version}.")),
self_update::Outcome::Failed(error) => Some(format!("The ArtCraft update failed: {error}")),
}
}
pub(crate) fn require_confirmation(confirmed: bool) -> Result<(), String> {
if confirmed {
Ok(())
} else {
Err("Confirm that you saved your work before installing the update.".into())
}
}
fn stable_choice(pins: &Pins) -> Result<(), String> {
match pins.choice(APP.id) {
Choice::Stable => Ok(()),
_ => Err("ArtCraft Launcher manages this app's version. Change its version there.".into()),
}
}
fn ensure_idle(app: &AppHandle) -> Result<(), String> {
let db = app.try_state::<TaskDatabase>().ok_or("ArtCraft is still starting. Try checking again in a moment.")?;
let tasks = tauri::async_runtime::block_on(list_tasks_for_frontend(db.get_connection())).map_err(|error| format!("Could not check running tasks: {error}"))?;
no_active_tasks(tasks.tasks.iter().map(|task| task.status))
}
fn no_active_tasks(statuses: impl IntoIterator<Item = TaskStatus>) -> Result<(), String> {
if statuses.into_iter().any(active_task) {
Err("Finish or cancel running tasks before installing an update.".into())
} else {
Ok(())
}
}
fn active_task(status: TaskStatus) -> bool {
matches!(status, TaskStatus::Pending | TaskStatus::Started | TaskStatus::AttemptFailed)
}
impl Backend for NativeBackend<'_> {
fn stable_choice(&self) -> Result<(), String> {
stable_choice(&Pins::load_default())
}
fn check(&self) -> Result<Option<Release>, String> {
let http = Http::new(&format!("ArtCraft/{ARTCRAFT_VERSION}"))?;
craft_update::check_for_update(&APP, &http, ARTCRAFT_VERSION)
}
fn idle(&self) -> Result<(), String> {
ensure_idle(self.0)
}
fn installation(&self) -> Option<(Install, Platform)> {
self_update::current(&APP).zip(Platform::current())
}
fn can_replace(&self) -> bool {
self_update::is_self_updating(&APP)
}
fn prepare(&self, install: &Install, release: &Release, platform: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result<Restart, String> {
let http = Http::new(&format!("ArtCraft/{ARTCRAFT_VERSION}"))?;
let paths = Paths::for_app(&APP)?;
let restart = self_update::prepare_update(&APP, install, release, platform, &http, &paths, &Stop::default(), &mut |_| {}, before_replace, Some(ARTCRAFT_VERSION))?;
Ok(Box::new(move || restart.start()))
}
fn request_exit(&self) {
self.0.exit(0);
}
}
fn release_fits_copy(release: &Release, install: &Install, platform: Platform) -> bool {
let kind = match install {
Install::MacApp(_) => PackageKind::Dmg,
Install::Msi(_) => PackageKind::Msi,
Install::Portable(_) => PackageKind::PortableZip,
Install::AppImage(_) => PackageKind::AppImage,
Install::Package(kind, _) => *kind,
Install::Flatpak => PackageKind::Flatpak,
// ArtCraft also ships NSIS. It must not be unpacked over as a loose portable copy;
// its registered installer remains its update path until the shared helper supports it.
Install::Standalone(_) if platform.os == Os::Windows => return false,
Install::Standalone(_) => PackageKind::TarGz,
};
pick_asset(release, &[APP.id], platform, &[kind]).is_some()
}
fn update_info(maybe_release: Option<&Release>, self_updating: bool, tasks_running: bool) -> UpdateInfo {
UpdateInfo { current_version: ARTCRAFT_VERSION.into(), maybe_available_version: maybe_release.map(Release::version_label), can_install: !tasks_running && self_updating && maybe_release.is_some(), tasks_running, releases_url: APP.releases_url() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_identity_and_display_version_match_the_release_configuration() {
let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).expect("release configuration");
assert_eq!(config["version"].as_str(), Some(ARTCRAFT_VERSION));
assert_eq!(config["identifier"].as_str(), Some(APP.bundle_id().as_str()));
let mac: serde_json::Value = serde_json::from_str(include_str!("../tauri-mac.conf.json")).expect("macOS release configuration");
assert_eq!(mac["version"].as_str(), Some(ARTCRAFT_VERSION), "the macOS build must report the same version");
}
#[test]
fn tauri_installers_and_flatpaks_are_offered_only_for_the_matching_copy() {
use craft_update::{Arch, Asset};
let release = Release { assets: ["ArtCraft_0.42.1_x64_en-US.msi", "ArtCraft_0.42.1_universal.dmg", "artcraft-0.42.1-linux-x86_64.flatpak"].into_iter().map(|name| Asset { name: name.into(), ..Asset::default() }).collect(), ..Release::default() };
assert!(release_fits_copy(&release, &Install::Msi("unused.exe".into()), Platform { os: Os::Windows, arch: Arch::X64 }));
assert!(release_fits_copy(&release, &Install::MacApp("unused.app".into()), Platform { os: Os::MacOs, arch: Arch::Arm64 }));
assert!(release_fits_copy(&release, &Install::Flatpak, Platform { os: Os::Linux, arch: Arch::X64 }));
assert!(!release_fits_copy(&release, &Install::Flatpak, Platform { os: Os::Linux, arch: Arch::Arm64 }));
assert!(!release_fits_copy(&release, &Install::Standalone("unused.exe".into()), Platform { os: Os::Windows, arch: Arch::X64 }));
}
#[test]
fn checks_allow_closing_but_an_unfinished_install_keeps_the_window_alive() {
let state = Arc::new(Updates::default());
let _check = state.begin().expect("checking");
assert!(!state.prevents_exit());
state.installing.store(true, Ordering::Release);
let installing = Installing(&state.installing);
assert!(state.prevents_exit());
drop(installing);
assert!(!state.prevents_exit());
}
#[test]
fn running_and_retrying_tasks_block_an_update_but_completed_tasks_do_not() {
for status in [TaskStatus::Pending, TaskStatus::Started, TaskStatus::AttemptFailed] {
assert!(active_task(status));
}
for status in [TaskStatus::CompleteSuccess, TaskStatus::CompleteFailure, TaskStatus::Dead, TaskStatus::CancelledByUser, TaskStatus::CancelledByProvider, TaskStatus::CancelledByUs] {
assert!(!active_task(status));
}
assert!(no_active_tasks([TaskStatus::CompleteSuccess, TaskStatus::Dead]).is_ok());
assert!(no_active_tasks([]).is_ok());
assert_eq!(no_active_tasks([TaskStatus::CompleteSuccess, TaskStatus::Started]), Err("Finish or cancel running tasks before installing an update.".into()));
}
#[test]
fn only_the_stable_track_lets_artcraft_update_itself() {
use craft_update::pins::VersionTrack;
let mut pins = Pins::default();
assert!(stable_choice(&pins).is_ok());
pins.set(APP.id, &VersionTrack::Pinned("0.41.0".into()));
assert!(stable_choice(&pins).unwrap_err().contains("ArtCraft Launcher manages this app's version"));
pins.set(APP.id, &VersionTrack::Nightly);
assert!(stable_choice(&pins).is_err());
pins.set(APP.id, &VersionTrack::Stable);
assert!(stable_choice(&pins).is_ok());
}
#[test]
fn a_previous_update_outcome_is_reported_once_with_its_version_or_error() {
let installed = craft_update::parse_version("0.42.1").expect("version");
assert_eq!(outcome_message(Some(self_update::Outcome::Installed(installed))).as_deref(), Some("ArtCraft was updated to 0.42.1."));
assert_eq!(outcome_message(Some(self_update::Outcome::Failed("disk full".into()))).as_deref(), Some("The ArtCraft update failed: disk full"));
assert_eq!(outcome_message(None), None);
}
#[test]
fn installing_requires_confirmation_and_an_ordinary_start_is_not_the_helper() {
assert!(require_confirmation(false).unwrap_err().contains("saved your work"));
assert!(require_confirmation(true).is_ok());
// The test harness's own arguments never carry the helper flag.
assert!(finish_helper().is_none());
}
#[test]
fn commands_run_one_operation_at_a_time_and_release_it_after_failures() {
let state = Arc::new(Updates::default());
let value = tauri::async_runtime::block_on(state.run("stopped", |_| Ok(7)));
assert_eq!(value, Ok(7));
let failed: Result<(), String> = tauri::async_runtime::block_on(state.run("stopped", |_| Err("offline".into())));
assert_eq!(failed, Err("offline".into()));
let held = state.begin().expect("guard released after a failure");
let busy = tauri::async_runtime::block_on(state.run("stopped", |_| Ok(())));
assert!(busy.unwrap_err().contains("already running"));
drop(held);
let panicked: Result<(), String> = tauri::async_runtime::block_on(state.run("The update check stopped unexpectedly", |_| panic!("worker crashed")));
assert!(panicked.unwrap_err().starts_with("The update check stopped unexpectedly:"));
assert!(state.begin().is_ok(), "a crashed worker still releases the guard");
}
#[test]
fn only_a_replaceable_idle_copy_can_offer_installation() {
let release = Release { tag: "artcraft-v99.0.0".into(), ..Release::default() };
assert!(!update_info(Some(&release), false, false).can_install);
assert!(!update_info(Some(&release), true, true).can_install);
assert!(!update_info(None, true, false).can_install);
assert_eq!(update_info(Some(&release), false, false).maybe_available_version.as_deref(), Some("artcraft-v99.0.0"));
assert_eq!(APP.bundle_id(), "ai.artcraft.app");
}
#[test]
fn operations_are_serialized_and_errors_release_the_guard() {
let state = Arc::new(Updates::default());
let first = state.begin().expect("first check");
assert!(state.begin().is_err());
drop(first);
assert!(state.begin().is_ok());
assert!(state.maybe_release.lock().expect("state").is_none());
}
#[test]
fn a_manual_check_offers_a_matching_idle_install_and_defers_its_helper_until_exit() {
let state = Updates::default();
let backend = FakeBackend::default();
let info = state.check_with(&backend).expect("checked");
assert!(info.can_install);
state.install_with(&backend).expect("prepared");
assert!(backend.exit_requested.load(Ordering::Acquire));
assert!(!backend.helper_started.load(Ordering::Acquire));
assert!(!state.prevents_exit(), "the prepared update may exit normally");
state.finish();
assert!(backend.helper_started.load(Ordering::Acquire));
state.finish();
assert_eq!(backend.helper_runs.load(Ordering::Acquire), 1, "exit events cannot run a helper twice");
}
#[test]
fn a_pin_stops_network_checks_and_replacement() {
let state = Updates::default();
let backend = FakeBackend::default();
backend.pinned.store(true, Ordering::Release);
assert!(state.check_with(&backend).unwrap_err().contains("pinned"));
assert!(!backend.checked.load(Ordering::Acquire));
assert!(state.install_with(&backend).unwrap_err().contains("pinned"));
assert!(!state.prevents_exit());
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn running_tasks_allow_a_check_but_block_the_install() {
let state = Updates::default();
let backend = FakeBackend::default();
backend.busy.store(true, Ordering::Release);
let info = state.check_with(&backend).expect("checking while busy");
assert!(info.tasks_running);
assert!(!info.can_install);
assert!(state.install_with(&backend).unwrap_err().contains("busy"));
assert!(!state.prevents_exit());
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn a_pin_or_task_started_during_download_stops_replacement_and_exit() {
for pin in [false, true] {
let state = Updates::default();
let backend = FakeBackend::default();
state.check_with(&backend).expect("checked");
backend.pin_during_download.store(pin, Ordering::Release);
backend.task_during_download.store(!pin, Ordering::Release);
assert!(state.install_with(&backend).is_err());
assert!(!state.prevents_exit(), "failed installation releases the close guard");
assert!(!backend.exit_requested.load(Ordering::Acquire));
assert!(state.maybe_restart.lock().expect("restart state").is_none());
}
}
#[test]
fn an_install_requires_a_previous_check_and_a_replaceable_matching_copy() {
let state = Updates::default();
let backend = FakeBackend::default();
assert!(state.install_with(&backend).unwrap_err().contains("Check for updates"));
state.check_with(&backend).expect("checked");
backend.managed.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("managed check").can_install);
assert!(state.install_with(&backend).unwrap_err().contains("package manager"));
backend.managed.store(false, Ordering::Release);
backend.missing_install.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("unknown install").can_install);
assert!(state.install_with(&backend).unwrap_err().contains("cannot update"));
backend.missing_install.store(false, Ordering::Release);
backend.wrong_arch.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("no matching asset").can_install);
assert!(state.install_with(&backend).unwrap_err().contains("package manager"));
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn no_new_release_clears_a_previous_offer_and_a_failed_check_is_reported() {
let state = Updates::default();
let backend = FakeBackend::default();
state.check_with(&backend).expect("first check");
backend.no_release.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("current version").can_install);
assert!(state.maybe_release.lock().expect("release").is_none());
backend.network_error.store(true, Ordering::Release);
assert!(state.check_with(&backend).unwrap_err().contains("offline"));
}
#[test]
fn a_failed_download_never_requests_exit_or_keeps_the_close_guard() {
let state = Updates::default();
let backend = FakeBackend::default();
state.check_with(&backend).expect("check");
backend.download_error.store(true, Ordering::Release);
assert!(state.install_with(&backend).unwrap_err().contains("download"));
assert!(!state.prevents_exit());
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn poisoned_state_returns_an_error_and_refuses_an_unsafe_exit() {
let state = Arc::new(Updates::default());
let other = Arc::clone(&state);
let _ = std::thread::spawn(move || {
let _guard = other.maybe_release.lock().unwrap();
panic!("simulate a previous worker failure");
})
.join();
let backend = FakeBackend::default();
assert!(state.check_with(&backend).is_err());
assert!(state.install_with(&backend).is_err());
let other = Arc::clone(&state);
let _ = std::thread::spawn(move || {
let _guard = other.maybe_restart.lock().unwrap();
panic!("simulate a previous helper-state failure");
})
.join();
state.installing.store(true, Ordering::Release);
assert!(state.prevents_exit());
state.finish();
}
#[test]
fn every_supported_install_kind_requires_its_own_asset() {
use craft_update::{Arch, Asset};
let linux = Platform { os: Os::Linux, arch: Arch::X64 };
let windows = Platform { os: Os::Windows, arch: Arch::X64 };
let release = Release { assets: ["artcraft-99.0.0-linux-x86_64.AppImage", "artcraft-99.0.0-linux-x86_64.tar.gz", "artcraft-99.0.0-linux-x86_64.deb", "artcraft-99.0.0-windows-x64-portable.zip"].into_iter().map(|name| Asset { name: name.into(), ..Asset::default() }).collect(), ..Release::default() };
for install in [Install::AppImage("unused".into()), Install::Standalone("unused".into()), Install::Package(PackageKind::Deb, "unused".into())] {
assert!(release_fits_copy(&release, &install, linux));
}
assert!(release_fits_copy(&release, &Install::Portable("unused".into()), windows));
assert!(!release_fits_copy(&release, &Install::Package(PackageKind::Rpm, "unused".into()), linux));
}
#[derive(Default)]
struct FakeBackend {
pinned: AtomicBool,
busy: AtomicBool,
managed: AtomicBool,
missing_install: AtomicBool,
wrong_arch: AtomicBool,
no_release: AtomicBool,
network_error: AtomicBool,
download_error: AtomicBool,
pin_during_download: AtomicBool,
task_during_download: AtomicBool,
checked: AtomicBool,
exit_requested: AtomicBool,
helper_started: Arc<AtomicBool>,
helper_runs: Arc<std::sync::atomic::AtomicUsize>,
}
impl Backend for FakeBackend {
fn stable_choice(&self) -> Result<(), String> {
if self.pinned.load(Ordering::Acquire) {
Err("pinned".into())
} else {
Ok(())
}
}
fn check(&self) -> Result<Option<Release>, String> {
self.checked.store(true, Ordering::Release);
if self.network_error.load(Ordering::Acquire) {
return Err("offline".into());
}
Ok((!self.no_release.load(Ordering::Acquire)).then(|| Release { tag: "artcraft-v99.0.0".into(), assets: vec![craft_update::Asset { name: "artcraft-99.0.0-linux-x86_64.flatpak".into(), ..Default::default() }], ..Default::default() }))
}
fn idle(&self) -> Result<(), String> {
if self.busy.load(Ordering::Acquire) {
Err("busy".into())
} else {
Ok(())
}
}
fn installation(&self) -> Option<(Install, Platform)> {
(!self.missing_install.load(Ordering::Acquire)).then(|| (Install::Flatpak, Platform { os: Os::Linux, arch: if self.wrong_arch.load(Ordering::Acquire) { craft_update::Arch::Arm64 } else { craft_update::Arch::X64 } }))
}
fn can_replace(&self) -> bool {
!self.managed.load(Ordering::Acquire)
}
fn prepare(&self, _: &Install, _: &Release, _: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result<Restart, String> {
if self.download_error.load(Ordering::Acquire) {
return Err("download failed".into());
}
if self.pin_during_download.load(Ordering::Acquire) {
self.pinned.store(true, Ordering::Release);
}
if self.task_during_download.load(Ordering::Acquire) {
self.busy.store(true, Ordering::Release);
}
before_replace()?;
let started = Arc::clone(&self.helper_started);
let runs = Arc::clone(&self.helper_runs);
Ok(Box::new(move || {
started.store(true, Ordering::Release);
runs.fetch_add(1, Ordering::AcqRel);
Ok(())
}))
}
fn request_exit(&self) {
self.exit_requested.store(true, Ordering::Release);
}
}
}
+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.42.1";
pub const ARTCRAFT_VERSION: &str = "0.42.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.42.1",
"version": "0.42.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.42.1",
"version": "0.42.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -1,3 +1,4 @@
/// When downloading a remote file, it makes sense to try to match the same file extension (with reasonable safeguards).
#[deprecated(note = "Use `Extension` in `url_utils::extension::extension` instead")]
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
@@ -12,7 +12,9 @@ pub fn extract_download_extension_from_url_str(url: &str) -> Option<DownloadExte
#[deprecated(note = "Use `extract_extension_from_url` in `url_utils::extension::extract_extension_from_url` instead")]
pub fn extract_download_extension_from_url(url: &Url) -> Option<DownloadExtension> {
let path = url.path();
let extension = std::path::Path::new(path).extension().and_then(|ext| ext.to_str())?;
let extension = std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())?;
DownloadExtension::try_from_str(extension)
}
@@ -24,14 +26,14 @@ mod tests {
#[test]
fn success_case() {
let url = "https://storyteller.ai/example.jpg";
let maybe_extension = extract_download_extension_from_url_str(url);
let maybe_extension= extract_download_extension_from_url_str(url);
assert_eq!(maybe_extension, Some(DownloadExtension::Jpg));
}
#[test]
fn difficult_url() {
let url = "https://videos.youtube.com/foo/bar/files/00000000-f19a-1275-a092-caa60b38f8d2%2Fraw?se=2025-11-21T22%3A26%3A53Z&sp=r&sv=2024-08-09&sr=b&skoid=8cfff87a-01fc-27c1-9091-32999dcd1389&sktid=aa8ccaf6-e6da-484e-ab11-9c7496c21c53&skt=2025-11-17T21%3A09%3A08Z&ske=2025-10-23T21%3A14%3A08Z&sks=b&skv=2024-08-04&sig=T2U6%2Brw9WJdJagBkCL4/GlFr72KhZGP3QPlobIO7JlU%3D&ac=oaisdsorprnorthcentralus";
let maybe_extension = extract_download_extension_from_url_str(url);
let maybe_extension= extract_download_extension_from_url_str(url);
assert!(maybe_extension.is_none());
}
}
@@ -1,4 +1,3 @@
#[expect(clippy::module_inception, reason = "Preserve the existing public module path")]
mod download_extension;
pub mod extract_download_extension_from_url;
+12 -29
View File
@@ -1,6 +1,5 @@
use std::borrow::Cow;
use std::fmt;
use std::hash::{Hash, Hasher};
/// A file extension that can be either a compile-time constant or a runtime-parsed value.
///
@@ -17,7 +16,7 @@ use std::hash::{Hash, Hasher};
/// assert_eq!(ext.without_period(), "mp4");
/// assert_eq!(ext.with_period(), ".mp4");
/// ```
#[derive(Clone, Eq)]
#[derive(Clone, Eq, Hash)]
pub struct Extension {
/// The extension without the leading period, e.g. "png", "mp4".
value: Cow<'static, str>,
@@ -35,14 +34,20 @@ impl Extension {
/// const MP4: Extension = Extension::from_static("mp4", ".mp4");
/// ```
pub const fn from_static(ext: &'static str, dotted: &'static str) -> Self {
Self { value: Cow::Borrowed(ext), dotted: Cow::Borrowed(dotted) }
Self {
value: Cow::Borrowed(ext),
dotted: Cow::Borrowed(dotted),
}
}
/// Creates an `Extension` from a `&str` at runtime.
/// The input must NOT include a leading period.
pub fn new(ext: &str) -> Self {
let dotted = format!(".{ext}");
Self { value: Cow::Owned(ext.to_string()), dotted: Cow::Owned(dotted) }
let dotted = format!(".{}", ext);
Self {
value: Cow::Owned(ext.to_string()),
dotted: Cow::Owned(dotted),
}
}
/// Returns the extension without the leading period, e.g. `"png"`.
@@ -62,12 +67,6 @@ impl PartialEq for Extension {
}
}
impl Hash for Extension {
fn hash<H: Hasher>(&self, state: &mut H) {
self.value.hash(state);
}
}
impl fmt::Debug for Extension {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Extension(\"{}\")", self.value)
@@ -136,28 +135,12 @@ mod tests {
#[test]
fn display() {
assert_eq!(format!("{PNG}"), "png");
assert_eq!(format!("{}", PNG), "png");
assert_eq!(format!("{}", Extension::new("xyz")), "xyz");
}
#[test]
fn debug() {
assert_eq!(format!("{PNG:?}"), "Extension(\"png\")");
}
#[test]
fn hash_matches_equality() {
let plain = Extension::from_static("png", ".png");
let inconsistent = Extension::from_static("png", ".not-a-suffix");
assert_eq!(plain, inconsistent);
assert_eq!(hash(&plain), hash(&inconsistent));
}
fn hash(extension: &Extension) -> u64 {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
extension.hash(&mut hasher);
hasher.finish()
assert_eq!(format!("{:?}", PNG), "Extension(\"png\")");
}
}
@@ -6,13 +6,24 @@ use url::Url;
// TODO: Lookups against these as lists will be slow
/// Known image extensions.
const KNOWN_IMAGE_EXTENSIONS: &[Extension] = &[Extension::from_static("png", ".png"), Extension::from_static("jpg", ".jpg"), Extension::from_static("jpeg", ".jpeg"), Extension::from_static("webp", ".webp")];
const KNOWN_IMAGE_EXTENSIONS: &[Extension] = &[
Extension::from_static("png", ".png"),
Extension::from_static("jpg", ".jpg"),
Extension::from_static("jpeg", ".jpeg"),
Extension::from_static("webp", ".webp"),
];
/// Known audio extensions.
const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[Extension::from_static("wav", ".wav"), Extension::from_static("mp3", ".mp3")];
const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[
Extension::from_static("wav", ".wav"),
Extension::from_static("mp3", ".mp3"),
];
/// Known video extensions.
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[Extension::from_static("mp4", ".mp4"), Extension::from_static("webm", ".webm")];
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[
Extension::from_static("mp4", ".mp4"),
Extension::from_static("webm", ".webm"),
];
/// Controls which extensions are accepted when extracting from a URL.
pub enum ExtractExtensions {
@@ -51,7 +62,9 @@ pub fn extract_extension_from_url_str(url: &str, accept: &ExtractExtensions) ->
/// Extracts a file extension from a parsed `Url`, filtered by `accept`.
pub fn extract_extension_from_url(url: &Url, accept: &ExtractExtensions) -> Option<Extension> {
let path = url.path();
let raw_ext = std::path::Path::new(path).extension().and_then(|ext| ext.to_str())?;
let raw_ext = std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())?;
let lower = raw_ext.to_lowercase();
let candidate = Extension::new(&lower);
@@ -59,40 +72,27 @@ pub fn extract_extension_from_url(url: &Url, accept: &ExtractExtensions) -> Opti
match accept {
ExtractExtensions::All => Some(candidate),
ExtractExtensions::Set(set) => {
if set.contains(&candidate) {
Some(candidate)
} else {
None
}
},
if set.contains(&candidate) { Some(candidate) } else { None }
}
ExtractExtensions::KnownImage => {
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) {
Some(candidate)
} else {
None
}
},
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) { Some(candidate) } else { None }
}
ExtractExtensions::KnownAudio => {
if slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) {
Some(candidate)
} else {
None
}
},
if slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) { Some(candidate) } else { None }
}
ExtractExtensions::KnownVideo => {
if slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) {
Some(candidate)
} else {
None
}
},
if slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) { Some(candidate) } else { None }
}
ExtractExtensions::KnownMedia => {
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) || slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) || slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) {
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate)
|| slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate)
|| slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate)
{
Some(candidate)
} else {
None
}
},
}
}
}
@@ -190,14 +190,19 @@ mod tests {
#[test]
fn set_matches_custom_list() {
let accept = ExtractExtensions::from_vec(vec![Extension::from_static("csv", ".csv"), Extension::from_static("tsv", ".tsv")]);
let accept = ExtractExtensions::from_vec(vec![
Extension::from_static("csv", ".csv"),
Extension::from_static("tsv", ".tsv"),
]);
let ext = extract_extension_from_url_str("https://example.com/data.csv", &accept);
assert_eq!(ext.unwrap().without_period(), "csv");
}
#[test]
fn set_rejects_non_member() {
let accept = ExtractExtensions::from_vec(vec![Extension::from_static("csv", ".csv")]);
let accept = ExtractExtensions::from_vec(vec![
Extension::from_static("csv", ".csv"),
]);
let ext = extract_extension_from_url_str("https://example.com/data.png", &accept);
assert!(ext.is_none());
}
@@ -210,7 +215,10 @@ mod tests {
#[test]
fn query_params_ignored() {
let ext = extract_extension_from_url_str("https://example.com/file.mp4?token=abc&format=hd", &ExtractExtensions::KnownVideo);
let ext = extract_extension_from_url_str(
"https://example.com/file.mp4?token=abc&format=hd",
&ExtractExtensions::KnownVideo,
);
assert_eq!(ext.unwrap().without_period(), "mp4");
}
@@ -222,7 +230,8 @@ mod tests {
}
fn extract_extension_all(url: &str) -> Option<String> {
extract_extension_from_url_str(url, &ExtractExtensions::All).map(|ext| ext.without_period().to_string())
extract_extension_from_url_str(url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
}
#[test]
+1 -2
View File
@@ -1,3 +1,2 @@
#[expect(clippy::module_inception, reason = "Preserve the existing public module path")]
pub mod extension;
pub mod extract_extension_from_url;
pub mod extract_extension_from_url;
+1 -2
View File
@@ -1,3 +1,2 @@
pub mod extension;
#[expect(deprecated, reason = "Keep the public compatibility module while downstream users migrate to Extension")]
pub mod download_extension;
pub mod download_extension;
@@ -19,7 +19,6 @@ tokens.workspace = true
anyhow.workspace = true
chrono.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["fs"] }
# TODO(bt,2025-07-09): Update sqlx ASAP. We're stuck on 0.7 because of the complex use of internal types and macros in the database crate.
# We're going to embed in-memory SQLite
+14 -29
View File
@@ -1,20 +1,7 @@
use std::env;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
pub fn main() {
println!("cargo:rerun-if-env-changed=DATABASE_URL");
println!("cargo:rerun-if-env-changed=SQLX_OFFLINE");
if let Ok(url) = env::var("DATABASE_URL") {
println!("cargo:rustc-env=DATABASE_URL={}", url);
return;
}
if env::var("SQLX_OFFLINE").as_deref() == Ok("true") || env::var("SQLX_OFFLINE").as_deref() == Ok("1") {
println!("cargo:rustc-env=SQLX_OFFLINE=true");
return;
}
// NB: This should help JetBrains' RustRover from highlighting failing query macros,
// but we do not want to interfere with the following:
//
@@ -27,29 +14,27 @@ pub fn main() {
Some("unix") => unix_temp_database_pathing(),
Some("windows") => windows_temp_database_pathing(),
_ => {
println!("cargo:rustc-env=SQLX_OFFLINE=true");
println!("cargo:warning=Unsupported target family, using Unix temp database pathing.");
unix_temp_database_pathing();
},
}
}
fn unix_temp_database_pathing() {
println!("cargo:rerun-if-changed=/tmp/tasks.sqlite");
if Path::new("/tmp/tasks.sqlite").exists() {
println!("cargo:rustc-env=DATABASE_URL=sqlite:/tmp/tasks.sqlite");
} else {
println!("cargo:rustc-env=SQLX_OFFLINE=true");
}
println!("cargo:rustc-env=DATABASE_URL=sqlite:/tmp/tasks.sqlite");
}
fn windows_temp_database_pathing() {
if let Ok(local_app_data) = env::var("LOCALAPPDATA") {
let path = PathBuf::from(local_app_data).join("Temp\\tasks.sqlite");
if path.exists() {
let path_str = path.to_str().expect("path should be valid");
println!("cargo:warning=LocalAppData path: {}", path_str);
println!("cargo:rustc-env=DATABASE_URL=sqlite:{}", path_str);
return;
}
let path = PathBuf::from(local_app_data);
let path = path.join("Temp\\tasks.sqlite");
let path = path
.to_str()
.expect("path should be valid")
.to_string();
println!("cargo:warning=LocalAppData path: {}", path);
println!("cargo:rustc-env=DATABASE_URL=sqlite:{}", path);
} else {
panic!("LOCALAPPDATA environment variable not set");
}
println!("cargo:rustc-env=SQLX_OFFLINE=true");
}
@@ -22,7 +22,7 @@ impl TaskDbConnection {
info!("Deleting and recreating SQLite database at {:?}", database_file.as_ref());
if let Err(err) = tokio::fs::remove_file(&database_file).await {
if let Err(err) = std::fs::remove_file(&database_file) {
error!("Error deleting SQLite database file: {:?}", err);
}
+3 -3
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>Storyteller API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" integrity="sha384-3nuX7df3EaAoiqLBeyS1Ola0Gpg9ryJKVtarubwfnA1cOH8AWHUdbPSIvEqPZ9VH" crossorigin="anonymous" />
<link rel="stylesheet" href="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" integrity="sha384-7xcoc6ZKDFF7Ek627QTC3Bg/K+5Y36NJ8MWAE43D2m6+3Sh9XO3tdsfHhrS8gNIQ" crossorigin="anonymous"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" integrity="sha384-FjFI+0PRyd5aAEoduVmUYk65iDC5+onNzam4WJtbAKKBim/kWvGUMk/2+9qRaYZb" crossorigin="anonymous"></script>
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" crossorigin></script>
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
@@ -120,7 +120,7 @@ export function GlobalFileDropHandler() {
}
};
void setup();
setup();
return () => { unlisteners.forEach((fn) => fn()); };
} else {
@@ -38,7 +38,6 @@ import {
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
import { clickOnEnterOrSpace } from "@storyteller/common";
type Mode = "image" | "text";
type Variant = "object" | "world";
@@ -70,7 +69,10 @@ const formatTime = (timestamp: number) => {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const generateId = () => uuidv4();
const generateId = () =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2, 10);
const WORLD_MODEL_PAGE = ModelPage.ImageTo3DWorld;
const OBJECT_MODEL_PAGE = ModelPage.ImageTo3DObject;
@@ -254,7 +256,7 @@ export const ImageTo3DExperience = ({
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
void handleFiles(event.target.files);
handleFiles(event.target.files);
event.target.value = "";
};
@@ -262,7 +264,7 @@ export const ImageTo3DExperience = ({
event.preventDefault();
event.stopPropagation();
setDragActive(false);
void handleFiles(event.dataTransfer?.files);
handleFiles(event.dataTransfer?.files);
};
const handlePickFromLibrary = () => {
@@ -597,9 +599,6 @@ export const ImageTo3DExperience = ({
<div className="flex justify-center">
{uploadedPreview ? (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
className={twMerge(
"group relative cursor-pointer overflow-hidden border border-white/30 bg-black/30 transition-all hover:border-white/60",
hasResults ? "aspect-square w-24" : "aspect-square w-48",
@@ -717,7 +716,7 @@ export const ImageTo3DExperience = ({
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer?.files)
void handleWorldFiles(
handleWorldFiles(
Array.from(e.dataTransfer.files).filter((f) =>
f.type.startsWith("image/"),
),
@@ -755,9 +754,6 @@ export const ImageTo3DExperience = ({
const worldImageThumbnail = (img: WorldImage) => (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
key={img.id}
className={twMerge(
"group relative aspect-square overflow-hidden rounded-[3px] border border-white/30 bg-black/30 transition-all",
@@ -948,7 +944,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"mx-auto grid h-full w-full grid-cols-[1fr_300px] gap-4 overflow-hidden pb-10",
"max-w-[1600px]",
variant === "world" ? "max-w-[1600px]" : "max-w-[1600px]",
)}
style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }}
>
@@ -1115,7 +1111,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"flex items-center justify-between gap-2.5",
"mt-3",
hasResults ? "mt-3" : "mt-3",
)}
>
<ClassyModelSelector
@@ -1181,7 +1177,7 @@ export const ImageTo3DExperience = ({
className="hidden"
onChange={(e) => {
if (e.target.files) {
void handleWorldFiles(Array.from(e.target.files));
handleWorldFiles(Array.from(e.target.files));
}
e.target.value = "";
}}
@@ -1206,7 +1202,6 @@ export const ImageTo3DExperience = ({
{previewImage && (
<div
role="presentation"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
onClick={() => setPreviewImage(null)}
>
@@ -248,7 +248,7 @@ function ItemActions({
e.stopPropagation();
const url = item.fullImage || item.thumbnail;
if (!url) return;
void applyMakeVideoFromImage(url, item.id);
applyMakeVideoFromImage(url, item.id);
},
[item.fullImage, item.thumbnail, item.id],
);
@@ -39,7 +39,6 @@ import {
type GalleryItem,
} from "@storyteller/ui-generation-list";
import { useTabStore } from "~/pages/Stores/TabState";
import { v4 as uuidv4 } from "uuid";
// Media item actions shared by the TopBar gallery modal / lightbox wiring and
// the create pages' generation feed (rows + cards). All of them operate on
@@ -131,7 +130,7 @@ export async function downloadMediaFilesToFolder(
export async function applyMakeVideoFromImage(url: string, mediaToken?: string) {
try {
const referenceImage: RefImage = {
id: uuidv4(),
id: Math.random().toString(36).substring(7),
url,
file: new File([], "library-image"),
mediaToken: mediaToken || "",
@@ -179,7 +178,7 @@ export function applyRecreateFromPromptData(data: {
for (const ci of contextImages) {
const base = {
id: uuidv4(),
id: Math.random().toString(36).substring(7),
url: ci.media_links.cdn_url,
mediaToken: ci.media_token,
};
@@ -295,8 +295,8 @@ export function useDesktopGenerationFeed(options: {
cancelled = true;
clearInterval(intervalId);
window.removeEventListener("task-queue-update", handleTaskUpdate);
void unlistenComplete.then((f) => f());
void unlistenFailed.then((f) => f());
unlistenComplete.then((f) => f());
unlistenFailed.then((f) => f());
};
}, [load]);
@@ -8,7 +8,7 @@ export function useDesktopUsername(): string | null {
useEffect(() => {
let cancelled = false;
void (async () => {
(async () => {
try {
const session = await new UsersApi().GetSession();
if (!cancelled && session.success && session.data?.user) {
@@ -66,9 +66,9 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
</div>
<div className="space-y-1">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Currency
</span>
</label>
<Select
options={selectOptions}
value={currency}
@@ -48,7 +48,7 @@ export const UploadFilesMedia = ({
return;
}
void uploadPlane({
uploadPlane({
title: uploadTitle.value,
assetFile: assetFile.value,
progressCallback: onUploadProgress,
@@ -1,75 +0,0 @@
import { PauseIcon, PlayIcon, SkipBackIcon, SkipForwardIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
interface VideoTransportControlsProps {
isPlaying: boolean;
isMuted: boolean;
volume: number;
currentTime: number;
duration: number;
onSeek: (to: "first" | "last") => void;
onTogglePlay: () => void;
onToggleMute: () => void;
onVolumeChange: (volume: number) => void;
formatTime: (seconds: number) => string;
}
/** The row under a video page's player: first / play / last frame, mute and volume, and the time. */
export const VideoTransportControls = ({
isPlaying,
isMuted,
volume,
currentTime,
duration,
onSeek,
onTogglePlay,
onToggleMute,
onVolumeChange,
formatTime,
}: VideoTransportControlsProps) => (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => onSeek("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon className="text-sm text-white" />
</button>
<button
onClick={onTogglePlay}
aria-label={isPlaying ? "Pause" : "Play"}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
>
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} className="text-white" />
</button>
<button
onClick={() => onSeek("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon className="text-sm text-white" />
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={onToggleMute}
aria-label={isMuted ? "Unmute" : "Mute"}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
>
<DynamicIcon icon={isMuted ? VolumeXIcon : Volume2Icon} className="text-sm text-white" />
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) => onVolumeChange(Number.parseFloat(e.target.value))}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
);
@@ -26,7 +26,7 @@ export default function ProfileDropdown() {
label: "Logout",
icon: LogOutIcon,
onClick: () => {
void logout();
logout();
},
},
];
@@ -67,7 +67,7 @@ export const SceneTitleInput = ({ pageName }: Props) => {
e.currentTarget.focus();
} else if (scene.value.token) {
setIsSaving(true);
void renameScene(
renameScene(
scene.value.title!, //guarunteed by input
scene.value.token,
);
@@ -164,7 +164,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
aria-label="Copy prompt"
onClick={(e) => {
e.stopPropagation();
void navigator.clipboard.writeText(prompt);
navigator.clipboard.writeText(prompt);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
}}
@@ -513,7 +513,7 @@ export const TaskQueue = () => {
const [failed, setFailed] = useState<FailedTask[]>([]);
const [lastReadAt, setLastReadAt] = useState<number>(() => {
const stored = localStorage.getItem("taskQueueLastReadAt");
return stored ? Number.parseInt(stored, 10) : 0;
return stored ? parseInt(stored, 10) : 0;
});
// remove unread state; unread tracking handled via IDs below
@@ -869,7 +869,7 @@ export const TaskQueue = () => {
};
cleanupOldEntries();
void load();
load();
const id = setInterval(load, 5000);
// Listen for cover image uploads to refresh and show new thumbnails
@@ -883,17 +883,17 @@ export const TaskQueue = () => {
let unlistenComplete: Promise<UnlistenFn> | null = null;
let unlistenFailed: Promise<UnlistenFn> | null = null;
void (async () => {
(async () => {
// Update immediately when Tauri signals a generation completion
unlistenComplete = listen("generation-complete-event", () => {
if (!cancelled) {
void load();
load();
}
});
// Also update immediately when a generation fails
unlistenFailed = listen("generation-failed-event", () => {
if (!cancelled) {
void load();
load();
// Refunded credits may take a moment to settle in the database
setTimeout(() => {
useCreditsState.getState().fetchFromServer();
@@ -906,10 +906,10 @@ export const TaskQueue = () => {
clearInterval(id);
window.removeEventListener("cover-image-uploaded", handleCoverUploaded);
if (unlistenComplete) {
void unlistenComplete.then((f) => f());
unlistenComplete.then((f) => f());
}
if (unlistenFailed) {
void unlistenFailed.then((f) => f());
unlistenFailed.then((f) => f());
}
};
}, [
@@ -125,7 +125,7 @@ export function storeEnqueueMeta(meta: EnqueueMeta) {
// Then convert blob URLs to base64 in the background for persistence
if (meta.refImageUrls && meta.refImageUrls.length > 0) {
void Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
const current = readEntries();
const found = current.find((e) => e.id === entryId);
if (found) {
@@ -12,7 +12,7 @@ export const withProtectionRoute = <P extends object>(
function ProtectionRoute(rest: P) {
useSignals();
useSignalEffect(() => {
void persistLogin();
persistLogin();
});
// const { status, userInfo } = authentication;
@@ -38,7 +38,7 @@ export const useActiveJobs = () => {
// then set new one with new interval
clearInterval(curr.timer);
const newTimer = setInterval(() => {
void PollRecentJobs();
PollRecentJobs();
}, newTimeInterval);
return {
timer: newTimer,
@@ -72,7 +72,7 @@ export const useActiveJobs = () => {
useSignalEffect(() => {
// if active jobs is not initiated, do do a first pull;
if (!activeJobs.value) {
void PollRecentJobs();
PollRecentJobs();
return;
}
// if there are no active jobs, stop making polls
@@ -115,7 +115,7 @@ export const useActiveJobs = () => {
//else make and set timer
const newTimer = setInterval(() => {
void PollRecentJobs();
PollRecentJobs();
}, curr.interval);
return {
...curr,
@@ -24,7 +24,7 @@ export const useBackgroundLoadingMedia = () => {
//CASE 1: first load
// if myMovies undefined, poll for the first time
if (!userMovies.value) {
void PollUserGeneratedMovies();
PollUserGeneratedMovies();
return;
}
@@ -45,7 +45,7 @@ export const useBackgroundLoadingMedia = () => {
//there are videos newly completed, set and poll
lastCompletedWorkflow.current = completedWorkflowJobs.value;
void PollUserGeneratedMovies().then((ret: boolean) => {
PollUserGeneratedMovies().then((ret: boolean) => {
if (ret) {
addToast(
ToastTypes.SUCCESS,
@@ -60,7 +60,7 @@ export const useBackgroundLoadingMedia = () => {
//CASE 1: first load
// if audioItems undefined, poll for the first time
if (!userAudioItems.value) {
void PollUserAudioItems();
PollUserAudioItems();
return;
}
@@ -82,7 +82,7 @@ export const useBackgroundLoadingMedia = () => {
//there are audio jobs newly completed, set and poll
lastCompletedAudioJobs.current = completedAudioJobs.value;
void PollUserAudioItems().then((ret: boolean) => {
PollUserAudioItems().then((ret: boolean) => {
if (ret) {
addToast(
ToastTypes.SUCCESS,
+2 -2
View File
@@ -26,12 +26,12 @@ const ENV = {
const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
console.log("GlobalSettingsManager()");
void SyncStorytellerApiConfig();
SyncStorytellerApiConfig();
useSignals();
useSignalEffect(() => {
void persistLogin();
persistLogin();
});
/// Initizations that depends on ENV vars ///
@@ -152,7 +152,7 @@ export const MainApp = ({ sceneToken }: Props) => {
// Session probe (runs once per shell mount).
useEffect(() => {
const usersApi = new UsersApi();
void usersApi.GetSession().then((result) => {
usersApi.GetSession().then((result) => {
console.log(
`User Info | Username: ${result.data?.user?.username}, Token: ${result.data?.user?.user_token}`,
);
@@ -11,7 +11,6 @@ import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { ModelPage } from "@storyteller/ui-model-selector";
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
import { v4 as uuidv4 } from "uuid";
const MAX_GALLERY_SELECTIONS = 1;
@@ -65,7 +64,7 @@ export const BaseImageSelector = ({
setIsBlankCanvasModalOpen(false);
const blankImage: BaseSelectorImage = {
url: "",
mediaToken: `blank_canvas_${width}x${height}_${uuidv4()}`,
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
isBlankCanvas: true,
blankCanvasWidth: width,
blankCanvasHeight: height,
@@ -80,12 +79,12 @@ export const BaseImageSelector = ({
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${uuidv4()}`,
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
if (newState.status === UploaderStates.success && newState.data) {
const mediaToken = newState.data || "";
void (async () => {
(async () => {
let finalUrl = reader.result as string;
let thumbnailUrlTemplate = undefined;
try {
@@ -1,7 +1,6 @@
import { create } from "zustand";
import type { GeneratedImage } from "@storyteller/tauri-events";
import type { ModelCreator } from "@storyteller/model-list";
import { v4 as uuidv4 } from "uuid";
export type TextToImageBatch = {
id: string;
@@ -44,7 +43,11 @@ export const useTextToImageStore = create<TextToImageState>((set, get) => ({
subscriberId?: string,
modelCreator?: ModelCreator,
) => {
const id = subscriberId || uuidv4();
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const batch: TextToImageBatch = {
id,
prompt,
@@ -3,7 +3,6 @@ import { listen } from "@tauri-apps/api/event";
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { v4 as uuidv4 } from "uuid";
import { captureModelThumbnail } from "./captureModelThumbnail";
import { completeGenerationAction } from "../Stores/completeGenerationResult";
export interface PendingExternalImage {
url: string;
@@ -73,7 +72,11 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
meshOnly: boolean,
subscriberId?: string,
) => {
const id = subscriberId || uuidv4();
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const result: ImageTo3DResult = {
id,
mode,
@@ -87,7 +90,52 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
set((s) => ({ results: [result, ...s.results] }));
return id;
},
completeGeneration: completeGenerationAction(set, "ImageTo3DStore", "Generated Model"),
completeGeneration: (
modelUrl: string,
mediaToken: string,
maybeSubscriberId?: string,
) => {
console.log("[ImageTo3DStore] completeGeneration", {
modelUrl,
mediaToken,
maybeSubscriberId,
});
const pending = maybeSubscriberId
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
: get().results.find((r) => r.status === "pending");
set((s) => {
const results = [...s.results];
const targetIdx = pending
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
: -1;
if (targetIdx === -1) {
const generatedId =
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
const result: ImageTo3DResult = {
id: generatedId,
subscriberId: generatedId,
mode: "image",
timestamp: Date.now(),
note: "Generated Model",
status: "completed",
modelUrl,
mediaToken,
};
return { results: [result, ...results] };
}
results[targetIdx] = {
...results[targetIdx],
status: "completed",
modelUrl,
mediaToken,
};
return { results };
});
},
uploadCoverImage: async (mediaToken: string, thumbnailDataUrl: string) => {
try {
// Check if already uploaded
@@ -2,7 +2,6 @@ import { create } from "zustand";
import { listen } from "@tauri-apps/api/event";
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { v4 as uuidv4 } from "uuid";
import { completeGenerationAction } from "../Stores/completeGenerationResult";
export type ImageTo3DWorldResult = {
id: string;
@@ -55,7 +54,11 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
previewUrl: string | undefined,
subscriberId?: string,
) => {
const id = subscriberId || uuidv4();
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const result: ImageTo3DWorldResult = {
id,
mode,
@@ -68,7 +71,52 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
set((s) => ({ results: [result, ...s.results] }));
return id;
},
completeGeneration: completeGenerationAction(set, "ImageTo3DWorldStore", "Generated World"),
completeGeneration: (
modelUrl: string,
mediaToken: string,
maybeSubscriberId?: string,
) => {
console.log("[ImageTo3DWorldStore] completeGeneration", {
modelUrl,
mediaToken,
maybeSubscriberId,
});
const pending = maybeSubscriberId
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
: get().results.find((r) => r.status === "pending");
set((s) => {
const results = [...s.results];
const targetIdx = pending
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
: -1;
if (targetIdx === -1) {
const generatedId =
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
const result: ImageTo3DWorldResult = {
id: generatedId,
subscriberId: generatedId,
mode: "image",
timestamp: Date.now(),
note: "Generated World",
status: "completed",
modelUrl,
mediaToken,
};
return { results: [result, ...results] };
}
results[targetIdx] = {
...results[targetIdx],
status: "completed",
modelUrl,
mediaToken,
};
return { results };
});
},
uploadCoverFromPreview: async (mediaToken: string) => {
try {
const result = get().results.find((r) => r.mediaToken === mediaToken);
@@ -148,7 +148,7 @@ export const RemoveBackground = () => {
}
};
void processExternalUrl();
processExternalUrl();
}, [
pendingExternalUrl,
setPendingExternalUrl,
@@ -4,7 +4,6 @@ import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
import { useStoryboardStore, type Board } from "./StoryboardStore";
import { clickOnEnterOrSpace } from "@storyteller/common";
const PIXELS_PER_SECOND = 80;
const MIN_BLOCK_WIDTH = 40;
@@ -74,9 +73,8 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-title" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
<input
id="storyboard-title"
type="text"
value={board.title}
onChange={(e) => onUpdate({ title: e.target.value })}
@@ -86,9 +84,8 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-dialogue" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
<textarea
id="storyboard-dialogue"
value={board.dialogue}
onChange={(e) => onUpdate({ dialogue: e.target.value })}
placeholder="Character dialogue..."
@@ -98,9 +95,8 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-action" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
<textarea
id="storyboard-action"
value={board.action}
onChange={(e) => onUpdate({ action: e.target.value })}
placeholder="On-screen action..."
@@ -110,9 +106,8 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-notes" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
<textarea
id="storyboard-notes"
value={board.notes}
onChange={(e) => onUpdate({ notes: e.target.value })}
placeholder="Director notes..."
@@ -122,14 +117,13 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-duration" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
<input
id="storyboard-duration"
type="number"
min={0}
step={0.5}
value={board.duration}
onChange={(e) => onUpdate({ duration: Number.parseFloat(e.target.value) || 0 })}
onChange={(e) => onUpdate({ duration: parseFloat(e.target.value) || 0 })}
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 font-mono text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
/>
</div>
@@ -359,7 +353,6 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
{/* Color picker */}
<label className="relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-[3px] border border-white/15 hover:border-white/30" title="Stroke color">
<span className="sr-only">Stroke color</span>
<span
className="h-4 w-4 rounded-sm border border-white/20"
style={{ background: color }}
@@ -464,9 +457,6 @@ const ThumbnailItem = ({
onDragEnd,
}: ThumbnailItemProps) => (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => {
@@ -604,7 +594,7 @@ const Timeline = ({
const delta = ev.clientX - resizeRef.current.startX;
const newDuration = Math.max(
MIN_DURATION,
Number.parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
);
onUpdateDuration(resizeRef.current.id, newDuration);
};
@@ -1,5 +1,4 @@
import { create } from "zustand";
import { v4 as uuidv4 } from "uuid";
export type VideoItem = {
media_token: string;
@@ -34,7 +33,11 @@ type ImageToVideoState = {
export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
batches: [],
startBatch: (prompt: string, modelLabel: string, subscriberId?: string) => {
const id = subscriberId || uuidv4();
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const batch: ImageToVideoBatch = {
id,
prompt,
@@ -58,7 +61,7 @@ export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
? s.batches.findIndex((b) => b.id === pending.id)
: -1;
if (idx === -1) {
const id = uuidv4();
const id = Math.random().toString(36).slice(2);
const batch: ImageToVideoBatch = {
id,
prompt,
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { CheckIcon, ImagesIcon, RotateCwIcon, SaveIcon, SparklesIcon, VideoIcon } from "lucide-react";
import { CheckIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SaveIcon, SkipBackIcon, SkipForwardIcon, SparklesIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
@@ -12,7 +13,6 @@ import { v4 as uuidv4 } from "uuid";
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import { useTabStore } from "~/pages/Stores/TabState";
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
export const VideoFrameExtractor = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
@@ -420,7 +420,7 @@ export const VideoFrameExtractor = () => {
const file = new File([blob], fileName, { type: "image/png" });
const referenceImage: RefImage = {
id: uuidv4(),
id: Math.random().toString(36).substring(7),
url: frameUrl,
file: file,
mediaToken: uploadResponse.data,
@@ -515,7 +515,6 @@ export const VideoFrameExtractor = () => {
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
role="presentation"
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
onClick={handleProgressBarClick}
@@ -527,7 +526,6 @@ export const VideoFrameExtractor = () => {
}}
/>
<div
role="presentation"
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize bg-yellow-400"
style={{
left: `${duration ? (startTime / duration) * 100 : 0}%`,
@@ -568,18 +566,61 @@ export const VideoFrameExtractor = () => {
}}
/>
</div>
<VideoTransportControls
isPlaying={isPlaying}
isMuted={isMuted}
volume={volume}
currentTime={currentTime}
duration={duration}
onSeek={seekToFrame}
onTogglePlay={togglePlayPause}
onToggleMute={toggleMute}
onVolumeChange={setVolume}
formatTime={formatTime}
/>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon
className="text-sm text-white" />
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
>
<DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon
className="text-sm text-white" />
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
>
<DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
</div>
</div>
@@ -671,7 +712,7 @@ export const VideoFrameExtractor = () => {
setNumFrames(
Math.max(
1,
Math.min(50, Number.parseInt(e.target.value) || 1),
Math.min(50, parseInt(e.target.value) || 1),
),
)
}
@@ -694,7 +735,7 @@ export const VideoFrameExtractor = () => {
value={frameDistance}
onChange={(e) =>
setFrameDistance(
Math.max(1, Number.parseInt(e.target.value) || 1),
Math.max(1, parseInt(e.target.value) || 1),
)
}
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
@@ -1,11 +1,11 @@
import { useState, useRef, useEffect } from "react";
import { DropletIcon, ImagesIcon, RotateCwIcon, VideoIcon } from "lucide-react";
import { DropletIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SkipBackIcon, SkipForwardIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
export const VideoWatermarkRemover = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
@@ -255,7 +255,6 @@ export const VideoWatermarkRemover = () => {
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
role="presentation"
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
onClick={handleProgressBarClick}
@@ -267,18 +266,61 @@ export const VideoWatermarkRemover = () => {
}}
/>
</div>
<VideoTransportControls
isPlaying={isPlaying}
isMuted={isMuted}
volume={volume}
currentTime={currentTime}
duration={duration}
onSeek={seekToFrame}
onTogglePlay={togglePlayPause}
onToggleMute={toggleMute}
onVolumeChange={setVolume}
formatTime={formatTime}
/>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon
className="text-sm text-white" />
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
>
<DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon
className="text-sm text-white" />
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
>
<DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
</div>
</div>
@@ -30,7 +30,7 @@ interface TabState {
[K in TabId]?: string;
};
// Actions
setActiveTab: (tabId: TabId) => Promise<void>;
setActiveTab: (tabId: TabId) => Promise<boolean>;
updateTabData: (tabId: TabId, data: unknown) => void;
getTabData: <T>(tabId: TabId) => T | null;
clearTabData: (tabId: TabId) => void;
@@ -42,12 +42,13 @@ export const useTabStore = create<TabState>((set, get) => ({
setActiveTab: async (newTabId) => {
// Don't do anything if we're already on this tab
if (get().activeTabId === newTabId) return;
if (get().activeTabId === newTabId) return true;
// The drawing store outlives its React page. Keep its live image objects,
// selection, and undo history when switching tabs. Export/import is for
// files; round-tripping here reread every image and rebuilt the scene.
set({ activeTabId: newTabId });
return true;
},
updateTabData: (tabId, data) => {
@@ -1,50 +0,0 @@
import { v4 as uuidv4 } from "uuid";
interface GenerationResult {
id: string;
mode: "image" | "text";
timestamp: number;
note?: string;
status: "pending" | "completed";
subscriberId: string;
modelUrl?: string;
mediaToken?: string;
}
/**
* The results after a 3D job finishes: the one with that subscriber id (else the first still
* pending) becomes completed with the model; with none to update, a completed one is added first.
*/
export function completeGenerationResult<T extends GenerationResult>(
results: readonly T[],
subscriberId: string | undefined,
completed: { modelUrl: string; mediaToken: string; note: string },
): T[] {
const { modelUrl, mediaToken, note } = completed;
const index = subscriberId
? results.findIndex((r) => r.subscriberId === subscriberId)
: results.findIndex((r) => r.status === "pending");
if (index === -1) {
const id = uuidv4();
// The other fields of T are optional, as the 3D result types declare them.
const added = { id, subscriberId: id, mode: "image", timestamp: Date.now(), note, status: "completed", modelUrl, mediaToken } as T;
return [added, ...results];
}
const next = [...results];
next[index] = { ...next[index], status: "completed", modelUrl, mediaToken };
return next;
}
/** A store's `completeGeneration`: logs the finished job under `store` and applies completeGenerationResult. */
export function completeGenerationAction<T extends GenerationResult>(
set: (update: (state: { results: T[] }) => { results: T[] }) => void,
store: string,
note: string,
) {
return (modelUrl: string, mediaToken: string, maybeSubscriberId?: string) => {
console.log(`[${store}] completeGeneration`, { modelUrl, mediaToken, maybeSubscriberId });
set((s) => ({
results: completeGenerationResult(s.results, maybeSubscriberId, { modelUrl, mediaToken, note }),
}));
};
}
@@ -87,7 +87,7 @@ export const completedAudioJobs = computed(() => {
export const shouldPollActiveJobs = signal<boolean>(false);
export const startPollingActiveJobs = () => {
void PollRecentJobs(); //poll once regardless of polling intervals
PollRecentJobs(); //poll once regardless of polling intervals
shouldPollActiveJobs.value = true;
};
@@ -47,7 +47,7 @@ export const login = async ({
// technically user is login with the system now, HOWEVER,
// in storyteller studio, only having a sesison is not enough,
// we need session info and active subscription info as well
void getUserInfoAndSubcriptions();
getUserInfoAndSubcriptions();
window.location.href = "/"; // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
};
@@ -86,7 +86,7 @@ export const signUp = async ({
// technically user is login with the system now, HOWEVER,
// in storyteller studio, only having a sesison is not enough,
// we need session info and active subscription info as well
void getUserInfoAndSubcriptions();
getUserInfoAndSubcriptions();
return "";
};
@@ -95,12 +95,12 @@ export const persistLogin = async () => {
if (authentication.status.value !== AUTH_STATUS.INIT) {
return;
}
void getUserInfoAndSubcriptions();
getUserInfoAndSubcriptions();
};
// NB: Only for SyncStorytellerApiConfig.
export const forceGetUserInfoAndSubcriptions = async () => {
void getUserInfoAndSubcriptions();
getUserInfoAndSubcriptions();
};
async function getUserInfoAndSubcriptions() {
+1 -1
View File
@@ -40,7 +40,7 @@ export const isJobStatusTerminal = (curr: JobStatus) => {
};
export const isNumberString = (value: string) => {
return !Number.isNaN(Number(value));
return !isNaN(Number(value));
};
export const isJobStatusError = (curr: JobStatus) => {
+1 -1
View File
@@ -17,7 +17,7 @@ set -euxo pipefail
echo 'TEMPORARY HACK: Build internal dependencies of artcraft since nx is not finding them...'
# A 'clean' will remove nx
npm install --ignore-scripts
npm install
# TODO: Temp commented out
nx build "@storyteller/api"
@@ -1,4 +1,3 @@
export * from "./thumbnail-utils";
export * from "./media-format";
export * from "./referral";
export * from "./keyboard";
@@ -1,16 +0,0 @@
/**
* For an element with `role="button"`: Enter or Space clicks it, as they would a native button.
* Keys pressed on a control inside it are left to that control.
*/
export function clickOnEnterOrSpace(event: {
key: string;
preventDefault(): void;
target: unknown;
currentTarget: { click(): void };
}) {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.currentTarget.click();
}
}
@@ -251,7 +251,6 @@ export function WaveformAudioPlayer({
return (
<div
role="presentation"
ref={rootRef}
className={`flex items-center gap-2.5 ${compact ? "px-2.5 py-2" : "px-3 py-2.5"} ${className}`}
onClick={(event) => event.stopPropagation()}
@@ -274,7 +273,6 @@ export function WaveformAudioPlayer({
)}
{(waveState === "pending" || waveState === "failed") && (
<div
role="presentation"
className="absolute inset-0 flex cursor-pointer items-center gap-[1.5px]"
onClick={handleBarSeek}
>
@@ -135,7 +135,7 @@ export const ButtonDropdown = ({
)}
<div className="grow text-start">{option.label}</div>
<div className="ml-6 font-mono text-[11px] font-normal text-white/45">
{option.description}
{option.description && option.description}
</div>
{showSelected && (
<>
@@ -52,7 +52,6 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
"conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)",
}}
>
<span className="sr-only">Custom color</span>
<input
type="color"
value={
@@ -64,6 +64,7 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
if (e.key === "Enter") submit();
}}
className="w-full rounded-[3px] border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button

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