GIF Creator Web — Vue 3 frontend for the GIF Creator stack.
10K+
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.
| Tool | Minimum version |
|---|---|
| .NET SDK | 10.0 |
| Node.js | 20 LTS |
| npm | 10+ |
| FFmpeg | 6+ (system install or bundled) |
| Docker + Compose | 24+ (optional, for containerised run) |
cd src/GifCreator.Api
dotnet run
# API available at http://localhost:5232
# Swagger UI at http://localhost:5232/swagger
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.
Pre-built multi-platform images (linux/amd64, linux/arm64) are published to Docker Hub on every tagged release.
| Image | Pull command |
|---|---|
| API backend | docker pull garrardkitchen/gif-creator-api |
| GIF Encoder | docker pull garrardkitchen/gif-creator-encoder |
| Web frontend | docker pull garrardkitchen/gif-creator-web |
Pin to a specific version with a tag, e.g. :1.0.0.
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.
# 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.
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
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.
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 ID | Notes |
|---|---|
gpt-4o | Recommended default — fast, accurate, widely available |
gpt-4.1 | Latest GPT-4.1 |
gpt-4.1-mini | Lighter / lower cost |
gpt-4.1-nano | Lightest 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__DefaultModelIdto 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.
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<Guid> · 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
| Colour | Represents |
|---|---|
| 🔵 Blue | Browser / Vue 3 SPA |
| 🩵 Teal | nginx reverse proxy |
| 🟣 Purple | ASP.NET Core API + Infrastructure layer |
| 🩷 Pink | API key middleware (auth gate) |
| 🟢 Green | EF Core / SQLite data store |
| 🟠 Orange | Encoder microservice + FFmpeg |
| 🔷 Indigo | Shared storage volume |
| 💚 Emerald | External — GitHub Models API |
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
| Concern | Approach |
|---|---|
| API key auth | 256-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 responses | Result<T> envelope — { success, data, error, traceId } |
| Real-time progress | ASP.NET Core SignalR (frame extraction) + SSE from encoder (GIF encoding) |
| Encode isolation | Dedicated encoder container — independently scalable, never blocks the API |
| Blur storage | Regions stored as JSON metadata only — raw frames are never modified on disk |
| Encryption | AES-256-GCM + Argon2id KDF for password-protected projects |
| Storage abstraction | IStorageProvider — swap LocalStorageProvider for AzureBlobStorageProvider without code changes |
All notable changes to GIF Creator are documented here.
Format: date · type · description
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..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.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 -.docker-compose.yml examples: one using pre-built Docker Hub images, one building from source.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.
SwapFrameOrderAsync repository method (atomic 2-step swap via temp sentinel index); new PUT /api/projects/{id}/frames/{frameId}/move endpointmoveFrame 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.
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.paletteuse filter receives a mixed-format stream and aborts with Internal bug, should not have happened.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.preFilters.Count == 0 branch in GifEncoderService — format=rgb24 is always present so the branch could never be reached.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.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.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).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.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.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.TitleFrameEditor panel instead of the blur canvas; "Save Title Frame" persists changes to server.TitleFrameRenderService covering PNG validity, dimensions, opacity 0 transparency, all animation effects, all alignments, all vertical positions.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).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.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.-vf filter chain per strategy: scale={w}:-1,select=scene>{threshold} (combined) or each filter alone as needed.scene>X expression for the select filter (no commas) to avoid FFmpeg filter-graph escaping issues with gt(scene,X).-vsync vfr with -fps_mode vfr (FFmpeg 5.1+).AllFrames strategy now also correctly applies scaling.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..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.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.CompleteAsync from VideoProcessorService — it now only reports per-frame progress. VideoController owns the Complete signal, firing it only after all DB inserts are done."Saving to library" 0–100%) in VideoController so the progress bar advances during the DB phase.onProgress handler in EditorView by operationId to prevent events from other operations bleeding into the active progress modal.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.
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:
frames.value optimistically (matching the pattern used by deleteFramesOnwards)loadFrames(true) to get a full server-fresh list with correct orderIndex values<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=.
Content type
Image
Digest
sha256:c07b89545…
Size
24.9 MB
Last updated
7 months ago
docker pull garrardkitchen/gif-creator-web