Sign inSign up

garrardkitchen/gif-creator-web

By garrardkitchen

•Updated 7 months ago

GIF Creator Web — Vue 3 frontend for the GIF Creator stack.

Image
0

10K+

garrardkitchen/gif-creator-web repository overview

⁠GIF Creator

A cross-platform desktop web application that converts video recordings into GIFs with a primary differentiator: the ability to blur sensitive information before encoding. Blurring is delivered through two complementary options — AI-assisted detection and manual rectangle placement — and both can be combined.


⁠Prerequisites

ToolMinimum version
.NET SDK10.0
Node.js20 LTS
npm10+
FFmpeg6+ (system install or bundled)
Docker + Compose24+ (optional, for containerised run)

⁠Running Locally (Development)

⁠1. Backend API
cd src/GifCreator.Api
dotnet run
# API available at http://localhost:5232
# Swagger UI at http://localhost:5232/swagger
⁠2. Frontend
cd src/GifCreator.Web
npm install
npm run dev
# Frontend available at http://localhost:5173

The Vite dev server proxies /api and /hubs to the API automatically.


⁠Docker Hub Images

Pre-built multi-platform images (linux/amd64, linux/arm64) are published to Docker Hub on every tagged release.

ImagePull command
API backenddocker pull garrardkitchen/gif-creator-api
GIF Encoderdocker pull garrardkitchen/gif-creator-encoder
Web frontenddocker pull garrardkitchen/gif-creator-web

Pin to a specific version with a tag, e.g. :1.0.0.


⁠Running with Docker Compose

⁠Option A — Docker Hub images (no source required)

Create a docker-compose.yml and a .env file, then run docker compose up.

docker-compose.yml

services:
  api:
    image: garrardkitchen/gif-creator-api:latest
    container_name: gif-creator-api
    restart: unless-stopped
    volumes:
      - gif_data:/data
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__DefaultConnection=Data Source=/data/gifcreator.db
      - Storage__BasePath=/data/storage
      - Ai__GitHubToken=${GH_TOKEN}
      - Ai__DefaultModelId=gpt-4o
      - Ai__CropLeftPercent=37
      - Ai__CropTopPercent=28
      - Cors__AllowedOrigins=http://localhost
      - Encoder__BaseUrl=http://encoder:8002
      - Api__InternalBaseUrl=http://api:8002
    ports:
      - "5232:8002"
    depends_on:
      - encoder
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s

  encoder:
    image: garrardkitchen/gif-creator-encoder:latest
    container_name: gif-creator-encoder
    restart: unless-stopped
    volumes:
      - gif_data:/data
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - Storage__BasePath=/data/storage
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s

  web:
    image: garrardkitchen/gif-creator-web:latest
    container_name: gif-creator-web
    restart: unless-stopped
    depends_on:
      - api
    ports:
      - "8001:80"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80"]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  gif_data:
    driver: local

.env

GH_TOKEN=ghp_your_token_here
docker compose up -d

# Frontend: http://localhost:8001
# API:      http://localhost:5232
# Swagger:  http://localhost:5232/swagger

Pin to a specific version by replacing :latest with e.g. :1.0.0.


⁠Option B — Build from source (local development)
# Clone and start — images are built locally from source
git clone https://github.com/garrardkitchen/gif-creator.git
cd gif-creator

# Set your GitHub token (required for AI blur detection)
export GH_TOKEN=ghp_your_token_here

docker compose up --build

# Frontend: http://localhost:8001
# API:      http://localhost:5232
# Swagger:  http://localhost:5232/swagger

# Stop
docker compose down

# Stop and remove volumes (data)
docker compose down -v

The root docker-compose.yml builds all three images from the local source tree.


⁠Running Tests

dotnet test tests/GifCreator.Tests/GifCreator.Tests.csproj

With code coverage:

dotnet test tests/GifCreator.Tests/GifCreator.Tests.csproj \
  --collect:"XPlat Code Coverage"

# Generate HTML report (requires reportgenerator tool)
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
  -reports:**/coverage.cobertura.xml \
  -targetdir:coverage-report

⁠Configuring the GitHub Token (AI Blur Detection)

AI-powered blur detection uses the GitHub Models API⁠. You need a GitHub Personal Access Token with read:models scope.

Development (user secrets):

cd src/GifCreator.Api
dotnet user-secrets set "Ai:GitHubToken" "ghp_your_token_here"

Docker Compose: Uncomment and set the environment variable in docker-compose.yml:

- Ai__GitHubToken=ghp_your_token_here

Security note: Never commit your GitHub token. It is stored in user secrets or environment variables only.

⁠Choosing a vision-capable model (Ai__DefaultModelId)

AI blur detection sends video frames as images to the model for analysis. The model must support vision (multimodal input) — text-only models will cause the AI blur detection feature to fail.

The following models are confirmed vision-capable on the GitHub Models API:

Model IDNotes
gpt-4oRecommended default — fast, accurate, widely available
gpt-4.1Latest GPT-4.1
gpt-4.1-miniLighter / lower cost
gpt-4.1-nanoLightest option

The app dynamically discovers additional vision models from the GitHub Models catalogue (any model tagged multimodal). You can see the full live list in the editor's model selector, or by calling GET /api/ai/models.

If you set Ai__DefaultModelId to a text-only model, AI blur detection will not work. The feature will either return an error or produce no blur regions. Use one of the models above to ensure it functions correctly.


⁠Architecture

graph TB
    subgraph Browser["🌐 Browser"]
        UI["Vue 3 SPA<br/>(Vite + Tailwind CSS)<br/>fetches x-api-key on mount"]
    end

    subgraph Docker["🐳 Docker Compose"]
        subgraph Web["web · :8001"]
            Nginx["nginx<br/>reverse proxy / SPA host"]
        end

        subgraph API["api · :5232 (host) / :8002 (internal)"]
            AspNet["ASP.NET Core 10<br/>REST API + SignalR hub"]
            ApiKey["🔑 ApiKeyMiddleware<br/>x-api-key header<br/>256-bit · per session"]
            EF["EF Core · SQLite<br/>(projects · frames · blur regions<br/>AI token usage)"]
            Infra["Infrastructure<br/>VideoProcessor · BlurRender · AI Client"]
        end

        subgraph Enc["encoder · :8003 (internal only — not port-exposed)"]
            Queue["Encode Queue Service<br/>Channel&lt;Guid&gt; · BackgroundService"]
            EncKey["🔑 ApiKeyMiddleware<br/>key seeded from first job"]
            FFmpeg["FFMpegCore<br/>Two-pass palette GIF encoding"]
        end

        Vol[("📦 gif_data volume<br/>/data/storage<br/>frames · GIFs · DB")]
    end

    subgraph External["☁️ External"]
        GHModels["GitHub Models API<br/>models.inference.ai.azure.com<br/>─────────────────────<br/>GET /models → model catalogue<br/>POST /chat/completions → vision analysis"]
    end

    UI -- "GET /api/config → { apiKey }<br/>(public bootstrap, exempt)" --> Nginx
    UI -- "HTTP/WS :8001<br/>x-api-key on all requests" --> Nginx
    Nginx -- "/api/* → :8002" --> ApiKey
    Nginx -- "/hubs/* → WS :8002<br/>?access_token=" --> ApiKey
    Nginx -- "GET /api/encode/* → :8002<br/>(SSE · read-only)" --> ApiKey
    ApiKey --> AspNet

    AspNet -- "POST /jobs + x-api-key<br/>+ SharedApiKey in payload" --> EncKey
    EncKey --> Queue
    Queue -- "callback POST /api/internal/...<br/>x-api-key" --> ApiKey

    AspNet <--> EF
    AspNet --> Infra
    Infra -- "GET /models (cached 1 hr)<br/>POST /chat/completions (vision)" --> GHModels

    EF -- "reads/writes" --> Vol
    Infra -- "frame files" --> Vol
    FFmpeg -- "reads frames / writes GIF" --> Vol

    Queue --> FFmpeg

    classDef browser  fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    classDef proxy    fill:#0f3b3b,stroke:#14b8a6,color:#5eead4
    classDef api      fill:#2d1b69,stroke:#8b5cf6,color:#c4b5fd
    classDef auth     fill:#4a1942,stroke:#ec4899,color:#f9a8d4
    classDef db       fill:#1a2e1a,stroke:#22c55e,color:#86efac
    classDef encoder  fill:#3b1f00,stroke:#f97316,color:#fdba74
    classDef volume   fill:#1f1f2e,stroke:#6366f1,color:#a5b4fc
    classDef external fill:#0f2b1a,stroke:#10b981,color:#6ee7b7

    class UI browser
    class Nginx proxy
    class AspNet,Infra api
    class ApiKey,EncKey auth
    class EF db
    class Queue,FFmpeg encoder
    class Vol volume
    class GHModels external
ColourRepresents
🔵 BlueBrowser / Vue 3 SPA
🩵 Tealnginx reverse proxy
🟣 PurpleASP.NET Core API + Infrastructure layer
🩷 PinkAPI key middleware (auth gate)
🟢 GreenEF Core / SQLite data store
🟠 OrangeEncoder microservice + FFmpeg
🔷 IndigoShared storage volume
💚 EmeraldExternal — GitHub Models API
⁠Project layout
src/
├── GifCreator.Core/           # Domain models + interfaces (no infrastructure deps)
├── GifCreator.Infrastructure/ # EF Core/SQLite · FFMpegCore · BlurRender · AI client · Storage
├── GifCreator.Api/            # ASP.NET Core 10 REST API + SignalR hub
├── GifCreator.Encoder/        # Encode microservice — queue, two-pass FFmpeg, SSE progress
└── GifCreator.Web/            # Vite + Vue 3 + Tailwind CSS v4 SPA
tests/
└── GifCreator.Tests/          # xUnit 3 + Moq · unit + integration · code coverage
⁠Key design decisions
ConcernApproach
API key auth256-bit random session key (x-api-key header) generated on every startup — printed to terminal. SPA bootstraps via GET /api/config. Encoder seeded via first job dispatch. Timing-safe comparison (CryptographicOperations.FixedTimeEquals).
API responsesResult<T> envelope — { success, data, error, traceId }
Real-time progressASP.NET Core SignalR (frame extraction) + SSE from encoder (GIF encoding)
Encode isolationDedicated encoder container — independently scalable, never blocks the API
Blur storageRegions stored as JSON metadata only — raw frames are never modified on disk
EncryptionAES-256-GCM + Argon2id KDF for password-protected projects
Storage abstractionIStorageProvider — swap LocalStorageProvider for AzureBlobStorageProvider without code changes

⁠Changelog

All notable changes to GIF Creator are documented here.
Format: date · type · description


⁠2026-03-18

  • feat · Version info in footer from Docker image: AppFooter.vue now reads VITE_APP_VERSION and VITE_BUILD_DATE from Vite env vars injected at Docker build time. Locally (no build args) the footer shows dev and today's date. Released Docker images show the actual version tag and build date. Added src/vite-env.d.ts for TypeScript type declarations. Updated Dockerfile.web with ARG/ENV for both vars. Updated CI workflow to pass APP_VERSION and BUILD_DATE as build args to the web image.
  • ci · Docker Hub publish workflow: Added .github/workflows/docker-publish.yml — triggers on v* tag push; generates a GitHub Release with styled commit notes via git-cliff (conventional commits, scoped, emoji groups); builds and pushes all three images (gif-creator-api, gif-creator-encoder, gif-creator-web) to Docker Hub as multi-platform (linux/amd64, linux/arm64) with version and latest tags. Docker Hub description for each repository is automatically updated from a combined README.md + CHANGELOG.md file. Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets in the docker-production environment.
  • ci · git-cliff config (cliff.toml): styled-and-scoped template with emoji group headers (⛰️ Features, 🐛 Bug Fixes, 🚜 Refactor, 📚 Documentation, ⚡ Performance, ⚙️ CI/chore), conventional commits, GitHub commit links, automatic pre-release detection for tags containing -.
  • docs · Added Docker Hub images section to README with pull commands and two docker-compose.yml examples: one using pre-built Docker Hub images, one building from source.

⁠2026-03-17

  • feat · Move title frame up/down: ↑ ↓ buttons appear on hover over title frames in the frame strip. Each click swaps the title frame one position with its neighbour. Buttons are disabled at list boundaries. Optimistic local reorder with automatic rollback on API failure.

    • Backend: new SwapFrameOrderAsync repository method (atomic 2-step swap via temp sentinel index); new PUT /api/projects/{id}/frames/{frameId}/move endpoint
    • Frontend: moveFrame API client method; ↑ ↓ hover buttons in EditorView.vue frame strip (title frames only)
  • fix · Font not updating in canvas preview: Canvas drew immediately on font @change before the Google Font had loaded. Fixed by awaiting document.fonts.load() for both title and subtitle fonts before redrawing. onMounted now waits for document.fonts.ready.

  • fix · Google Fonts + real shadow blur in encoder: Added 8 Google Fonts (Roboto, Open Sans, Lato, Montserrat, Raleway, Playfair Display, Merriweather, Source Code Pro) to Dockerfile.api and Dockerfile.encoder via apt + wget. Backend shadow now uses a true Gaussian blur layer (composited via DrawImage) instead of a simple offset copy, matching canvas preview intensity. Italic is now correctly rendered in encoded GIFs. Frontend canvas preview uses numeric CSS weights (300/400/700) and quoted font family names. Old generic values ("sans-serif", "serif", "monospace") auto-migrate to named fonts on load.

  • fix · Google Fonts CDN loads weight 300: Added wght@0,300;1,300 to all Google Fonts CDN entries so Light weight renders correctly in the canvas preview.

  • fix · normFont empty-string guard: normFont('') now correctly returns 'Roboto' instead of an empty string, preventing blank font family in canvas context.

  • fix · Root cause: FramesController called JsonSerializer.Serialize without options, storing enum fields (font weight, alignment, animation) as integers — selections appeared blank on refresh. Fix: use JsonStringEnumConverter options on all TitleConfigJson serialization.

  • fix · drawText in TitleFrameEditor cleared ctx.shadowBlur before ctx.fillText — text shadow never rendered. Moved shadow clear to after all drawing.

  • feat · Added italic toggle for title and subtitle in Typography tab.

  • feat · Added font family selector (Sans-serif / Serif / Monospace) for title and subtitle.

  • feat · Added colour swatches to title and subtitle colour pickers (Colours tab).

  • fix · parseTitleConfig now normalises old integer-enum values for backward compatibility with records saved before the serialisation fix.

  • fix · Encoder crash: GifEncoderService deserialised TitleConfigJson without JsonStringEnumConverter, failing on string enum values. Added _titleJsonOptions with allowIntegerValues: true so both old (integer) and new (string) records are handled.


  • 2025-03-17 · fix · Replace single-pass split palette filter with a 3-phase FFV1 lossless intermediate approach to fix title frames rendering as identical screen-content in encoded GIFs on ARM64 ffmpeg 6.1.1. Phase flow: PNGs → FFV1 MKV → palettegen (stats_mode=full) → paletteuse GIF. Restores temp-directory cleanup.

⁠2026-03-16

⁠fix: GIF encode failure when title frames are present
  • Root cause: title frames are rendered as RGBA (32-bit with alpha) by ImageSharp, while regular video frames extracted by ffmpeg are RGB24 (24-bit). When both types appear in the same frame sequence, ffmpeg's paletteuse filter receives a mixed-format stream and aborts with Internal bug, should not have happened.
  • Fix: prepend format=rgb24 as the first filter in the ffmpeg pre-filter chain. This strips the alpha channel from title frames and normalises the entire sequence to a single pixel format before palette generation and GIF encoding.
  • Removed dead preFilters.Count == 0 branch in GifEncoderService — format=rgb24 is always present so the branch could never be reached.
⁠fix: Title frame not visible in dashboard GIF preview after re-encoding
  • Root cause: DashboardView.playLatest cached the GIF URL with an early-return guard (if (latestGifUrl.value[projectId]) return). After encoding a new version the function returned early with the stale URL, so the old GIF (without the title frame) was always shown.
  • Fix: removed the cache guard so the latest version is always fetched on each play click.
  • Also fixed: loadingGif was a shared boolean — race condition when clicking different projects in rapid succession. Changed to Record<string, boolean> keyed by project ID; reset per-project on modal close.

⁠2026-03-17

⁠feat: Title frame insertion
  • Insert synthetic title frames anywhere in the frame strip — a special frame type that renders a solid-colour background with title and optional subtitle text and is baked into the exported GIF.
  • TitleFrameConfig model (GifCreator.Core): TitleText, SubtitleText, BackgroundColour, BackgroundOpacity (0-100%), TitleColour, SubtitleColour, TitleFontSizePx, SubtitleFontSizePx, TitleFontWeight, SubtitleFontWeight, TextAlignment (Left/Center/Right), VerticalPosition (Top/Center/Bottom), TextShadow (colour + blur + offset), TextStroke (width + colour), LetterSpacingPx, HoldDurationMs, FadeInDurationMs, FadeOutDurationMs, AnimationEffect (Fade/SlideUp/SlideDown/ZoomIn/ZoomOut).
  • EF migration AddTitleFrame: adds IsTitle (bool, default false) and TitleConfigJson (TEXT nullable) to Frames table.
  • ITitleFrameRenderService / TitleFrameRenderService: ImageSharp + SixLabors.ImageSharp.Drawing — renders background fill, title + subtitle text with shadow, stroke, alignment, opacity and animation offset.
  • GifEncoderService: title frames are expanded into PNG sequences at encode time — fade-in ramp → hold → fade-out ramp using smooth-step easing; slide/zoom animation offsets applied per frame.
  • API: POST /api/projects/{id}/frames/title (insert + initial render), PUT /api/projects/{id}/frames/{id}/title (update config + re-render).
  • FrameJobDto: extended with IsTitle and TitleConfigJson so title frames survive encoder service serialisation.
  • Frontend: TitleFrameConfig interface + defaultTitleFrameConfig() factory in types/index.ts; insertTitleFrame and updateTitleFrame in useApi.ts.
  • TitleFrameEditor.vue: full editor component — colour pickers, font controls, alignment/vertical-position button groups, shadow/stroke controls, animation selector, duration sliders, live Canvas preview, animated preview playback button.
  • EditorView integration: "🅣 Title Frame" toolbar button → insert modal; title frames show coloured "T" badge in strip; selecting a title frame shows the TitleFrameEditor panel instead of the blur canvas; "Save Title Frame" persists changes to server.
  • 16 unit tests for TitleFrameRenderService covering PNG validity, dimensions, opacity 0 transparency, all animation effects, all alignments, all vertical positions.
⁠fix: SceneChange strategy invalid FFmpeg expression + 500 empty body
  • scene>X is not valid FFmpeg filter-expression syntax — replaced with FFmpeg's thumbnail=N filter, which selects the visually best frame per group of N input frames using histogram analysis. This is more reliable than select=gt(scene,X) which fails to populate the scene variable for H.264 and many other codecs.
  • SceneChangeThreshold maps to thumbnail group size: groupSize = max(2, threshold × 100). Default 0.4 → group of 40 frames (≈0.75fps output at 30fps input).
  • Added global JSON exception handler in Program.cs (UseExceptionHandler) so unhandled server errors always return { success: false, error: "..." } instead of an empty 500 body, which caused "Unexpected end of JSON input" in the browser.
⁠fix: SceneChange extraction strategy produced no frames
  • WithVideoFilters(scale) and WithCustomArgument(-vf select=...) both generated -vf flags; FFmpeg only honours the last one, so either scaling or scene detection was silently dropped.
  • Fixed by building a single -vf filter chain per strategy: scale={w}:-1,select=scene>{threshold} (combined) or each filter alone as needed.
  • Replaced scene>X expression for the select filter (no commas) to avoid FFmpeg filter-graph escaping issues with gt(scene,X).
  • Replaced deprecated -vsync vfr with -fps_mode vfr (FFmpeg 5.1+).
  • AllFrames strategy now also correctly applies scaling.
⁠fix: API container unhealthy; upload stuck; silent upload errors
  • Added missing GET /health endpoint to Program.cs — the health check URL was exempted from API key middleware but the route was never registered, causing the API container to report unhealthy and fail Docker dependency checks.
  • Added .catch() handler to the fire-and-forget extractFrames call in EditorView so upload failures surface in the progress modal instead of leaving it stuck on "Starting…" indefinitely.
⁠fix: frames not appearing after video upload; progress bar stuck
  • Root cause: VideoProcessorService.ExtractFramesAsync was calling CompleteAsync (SignalR complete signal) before returning, causing VideoController.Extract to insert frames into the DB after the frontend had already received Complete, called loadFrames(), and got an empty result.
  • Removed CompleteAsync from VideoProcessorService — it now only reports per-frame progress. VideoController owns the Complete signal, firing it only after all DB inserts are done.
  • Added per-insert progress reporting ("Saving to library" 0–100%) in VideoController so the progress bar advances during the DB phase.
  • Filtered onProgress handler in EditorView by operationId to prevent events from other operations bleeding into the active progress modal.

⁠2026-03-15

⁠feat: multi-frame delete and go-to-frame in editor

Multi-select delete mode — new "☑ Select" toggle button in the editor toolbar enters select mode. Each frame thumbnail gains a purple checkbox overlay; clicking toggles selection instead of editing. "All", "None", and "🗑 Delete (N)" buttons appear alongside. Delete confirms, removes frames optimistically, calls the existing single-frame DELETE endpoint in a loop with a progress bar, then does a full loadFrames(true) refresh. Falls back the active frame selection to the nearest surviving frame.

Go to frame — compact "# frame ▶" input added to the toolbar. Enter a frame number and press Enter or click ▶ to jump: if the frame is already loaded it is selected immediately and the strip scrolls to it; if it is beyond the current lazy-loaded page all frames from the current end up to (and including) the target are fetched in one request (GET /frames?skip=currentCount&take=gap) so the strip remains contiguous. Each frame strip item now carries a data-frame-id attribute used for scrollIntoView.

⁠fix: frame delete appeared to do nothing; subsequent frame thumbnails returned 404

deleteFrame called loadFrames() without forceReload: true, so the reload used skip = frames.value.length (still counting the just-deleted frame) and fetched the next page — never removing the deleted frame from the visible list. The frame stayed visible and could be re-selected, hitting a 404 on its now-gone file. Fixed by:

  1. Removing the frame from frames.value optimistically (matching the pattern used by deleteFramesOnwards)
  2. Calling loadFrames(true) to get a full server-fresh list with correct orderIndex values
⁠fix: GIF and frame images not displaying (API key blocked browser media requests)

<img :src="..."> tags send plain browser HTTP requests with no custom headers, causing the ApiKeyMiddleware to return 401 for all GIF and frame thumbnail/preview endpoints. Fixed by appending ?access_token=${_apiKey} to all three URL helpers in useApi.ts: getGifUrl, getFrameThumbnailUrl, getFramePreviewUrl. Also fixed a double-? bug in EditorView.vue where the cache-bust suffix was ?t= instead of &t=.

⁠feat: API key protection for API and encoder
  • Generate 256-bit cryptographically random session key on ev

Tag summary

Content type

Image

Digest

sha256:c07b89545…

Size

24.9 MB

Last updated

7 months ago

docker pull garrardkitchen/gif-creator-web