diff --git a/public/handdraw/.github/workflows/linux.yml b/public/handdraw/.github/workflows/linux.yml
new file mode 100644
index 0000000..7a1c8ea
--- /dev/null
+++ b/public/handdraw/.github/workflows/linux.yml
@@ -0,0 +1,88 @@
+name: Linux Build
+
+on:
+ push:
+ branches: [main, claude/**]
+ pull_request:
+
+jobs:
+ build-linux:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libwebkit2gtk-4.0-dev \
+ build-essential \
+ curl \
+ wget \
+ file \
+ libssl-dev \
+ libgtk-3-dev \
+ libayatana-appindicator3-dev \
+ librsvg2-dev
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: stable
+
+ - name: Cache Rust
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: public/handdraw/src-tauri
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install TypeScript
+ run: npm install -g typescript
+
+ - name: Compile TypeScript
+ run: |
+ cd public/handdraw/app
+ tsc --outDir ../src-tauri/dist --module es2020 --target es2020 --skipLibCheck --allowSyntheticDefaultImports app.ts
+
+ - name: Copy static assets
+ run: |
+ mkdir -p public/handdraw/src-tauri/dist
+ cp public/handdraw/app/index.html public/handdraw/src-tauri/dist/
+ cp public/handdraw/app/styles.css public/handdraw/src-tauri/dist/
+ cp -r public/handdraw/app/assets public/handdraw/src-tauri/dist/
+
+ - name: Download FFmpeg-kit LGPL
+ run: |
+ mkdir -p public/handdraw/src-tauri/libs
+ cd public/handdraw/src-tauri/libs
+ wget -q https://github.com/arthenica/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-linux-x86_64-6.0.tar.xz
+ tar -xf ffmpeg-kit-linux-x86_64-6.0.tar.xz
+ mv ffmpeg-kit-linux-x86_64-6.0/lib/* .
+ rm -rf ffmpeg-kit-linux-x86_64-6.0 ffmpeg-kit-linux-x86_64-6.0.tar.xz
+
+ - name: Build Tauri AppImage
+ run: |
+ cd public/handdraw/src-tauri
+ cargo install tauri-cli --version '^1.0'
+ cargo tauri build --bundles appimage
+
+ - name: Build Tauri deb
+ run: |
+ cd public/handdraw/src-tauri
+ cargo tauri build --bundles deb
+
+ - name: Upload AppImage
+ uses: actions/upload-artifact@v4
+ with:
+ name: handdraw-linux-appimage
+ path: public/handdraw/src-tauri/target/release/bundle/appimage/*.AppImage
+
+ - name: Upload deb
+ uses: actions/upload-artifact@v4
+ with:
+ name: handdraw-linux-deb
+ path: public/handdraw/src-tauri/target/release/bundle/deb/*.deb
diff --git a/public/handdraw/.github/workflows/macos.yml b/public/handdraw/.github/workflows/macos.yml
new file mode 100644
index 0000000..980d18f
--- /dev/null
+++ b/public/handdraw/.github/workflows/macos.yml
@@ -0,0 +1,71 @@
+name: macOS Build
+
+on:
+ push:
+ branches: [main, claude/**]
+ pull_request:
+
+jobs:
+ build-macos:
+ runs-on: macos-13
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: stable
+ targets: x86_64-apple-darwin,aarch64-apple-darwin
+
+ - name: Cache Rust
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: public/handdraw/src-tauri
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install TypeScript
+ run: npm install -g typescript
+
+ - name: Compile TypeScript
+ run: |
+ cd public/handdraw/app
+ tsc --outDir ../src-tauri/dist --module es2020 --target es2020 --skipLibCheck --allowSyntheticDefaultImports app.ts
+
+ - name: Copy static assets
+ run: |
+ mkdir -p public/handdraw/src-tauri/dist
+ cp public/handdraw/app/index.html public/handdraw/src-tauri/dist/
+ cp public/handdraw/app/styles.css public/handdraw/src-tauri/dist/
+ cp -r public/handdraw/app/assets public/handdraw/src-tauri/dist/
+
+ - name: Download FFmpeg-kit LGPL
+ run: |
+ mkdir -p public/handdraw/src-tauri/libs
+ cd public/handdraw/src-tauri/libs
+ curl -L -o ffmpeg-kit-macos.tar.xz https://github.com/arthenica/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-macos-universal-6.0.tar.xz
+ tar -xf ffmpeg-kit-macos.tar.xz
+ mv ffmpeg-kit-macos-universal-6.0/lib/* .
+ rm -rf ffmpeg-kit-macos-universal-6.0 ffmpeg-kit-macos.tar.xz
+
+ - name: Build Tauri Universal DMG
+ run: |
+ cd public/handdraw/src-tauri
+ cargo install tauri-cli --version '^1.0'
+ cargo tauri build --target universal-apple-darwin --bundles dmg
+
+ - name: Ad-hoc Sign (if no secrets)
+ run: |
+ if [ -z "${{ secrets.APPLE_CERTIFICATE }}" ]; then
+ echo "No Apple certificate, skipping signing"
+ codesign --force --deep --sign - public/handdraw/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg || true
+ fi
+
+ - name: Upload DMG
+ uses: actions/upload-artifact@v4
+ with:
+ name: handdraw-macos-dmg
+ path: public/handdraw/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
diff --git a/public/handdraw/.github/workflows/release.yml b/public/handdraw/.github/workflows/release.yml
new file mode 100644
index 0000000..372dfb1
--- /dev/null
+++ b/public/handdraw/.github/workflows/release.yml
@@ -0,0 +1,210 @@
+name: Release
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ create-release:
+ runs-on: ubuntu-latest
+ outputs:
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
+ release_id: ${{ steps.create_release.outputs.id }}
+ steps:
+ - name: Create Draft Release
+ id: create_release
+ uses: actions/create-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ tag_name: build-${{ github.sha }}
+ release_name: Build ${{ github.sha }}
+ draft: true
+ prerelease: false
+
+ build-linux:
+ needs: create-release
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libwebkit2gtk-4.0-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache Rust
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: public/handdraw/src-tauri
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install TypeScript
+ run: npm install -g typescript
+
+ - name: Compile TypeScript
+ run: |
+ cd public/handdraw/app
+ tsc --outDir ../src-tauri/dist --module es2020 --target es2020 --skipLibCheck --allowSyntheticDefaultImports app.ts
+
+ - name: Copy static assets
+ run: |
+ mkdir -p public/handdraw/src-tauri/dist
+ cp public/handdraw/app/index.html public/handdraw/src-tauri/dist/
+ cp public/handdraw/app/styles.css public/handdraw/src-tauri/dist/
+ cp -r public/handdraw/app/assets public/handdraw/src-tauri/dist/
+
+ - name: Download FFmpeg-kit LGPL
+ run: |
+ mkdir -p public/handdraw/src-tauri/libs
+ cd public/handdraw/src-tauri/libs
+ wget -q https://github.com/arthenica/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-linux-x86_64-6.0.tar.xz || true
+
+ - name: Build Tauri
+ run: |
+ cd public/handdraw/src-tauri
+ cargo install tauri-cli --version '^1.0'
+ cargo tauri build --bundles appimage,deb
+
+ - name: Upload AppImage to Release
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
+ asset_path: public/handdraw/src-tauri/target/release/bundle/appimage/handdraw_0.1.0_amd64.AppImage
+ asset_name: handdraw-linux.AppImage
+ asset_content_type: application/octet-stream
+
+ - name: Upload deb to Release
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
+ asset_path: public/handdraw/src-tauri/target/release/bundle/deb/handdraw_0.1.0_amd64.deb
+ asset_name: handdraw-linux.deb
+ asset_content_type: application/octet-stream
+
+ build-macos:
+ needs: create-release
+ runs-on: macos-13
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: x86_64-apple-darwin,aarch64-apple-darwin
+
+ - name: Cache Rust
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: public/handdraw/src-tauri
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install TypeScript
+ run: npm install -g typescript
+
+ - name: Compile TypeScript
+ run: |
+ cd public/handdraw/app
+ tsc --outDir ../src-tauri/dist --module es2020 --target es2020 --skipLibCheck --allowSyntheticDefaultImports app.ts
+
+ - name: Copy static assets
+ run: |
+ mkdir -p public/handdraw/src-tauri/dist
+ cp public/handdraw/app/index.html public/handdraw/src-tauri/dist/
+ cp public/handdraw/app/styles.css public/handdraw/src-tauri/dist/
+ cp -r public/handdraw/app/assets public/handdraw/src-tauri/dist/
+
+ - name: Download FFmpeg-kit LGPL
+ run: |
+ mkdir -p public/handdraw/src-tauri/libs
+ cd public/handdraw/src-tauri/libs
+ curl -L -o ffmpeg-kit-macos.tar.xz https://github.com/arthenica/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-macos-universal-6.0.tar.xz || true
+
+ - name: Build Tauri
+ run: |
+ cd public/handdraw/src-tauri
+ cargo install tauri-cli --version '^1.0'
+ cargo tauri build --target universal-apple-darwin --bundles dmg
+
+ - name: Upload DMG to Release
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
+ asset_path: public/handdraw/src-tauri/target/universal-apple-darwin/release/bundle/dmg/HandDraw_0.1.0_universal.dmg
+ asset_name: handdraw-macos.dmg
+ asset_content_type: application/octet-stream
+
+ build-windows:
+ needs: create-release
+ runs-on: windows-2022
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache Rust
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: public/handdraw/src-tauri
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install TypeScript
+ run: npm install -g typescript
+
+ - name: Compile TypeScript
+ run: |
+ cd public/handdraw/app
+ tsc --outDir ../src-tauri/dist --module es2020 --target es2020 --skipLibCheck --allowSyntheticDefaultImports app.ts
+
+ - name: Copy static assets
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force -Path public/handdraw/src-tauri/dist
+ Copy-Item public/handdraw/app/index.html public/handdraw/src-tauri/dist/
+ Copy-Item public/handdraw/app/styles.css public/handdraw/src-tauri/dist/
+ Copy-Item -Recurse public/handdraw/app/assets public/handdraw/src-tauri/dist/
+
+ - name: Download FFmpeg-kit LGPL
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force -Path public/handdraw/src-tauri/libs
+ cd public/handdraw/src-tauri/libs
+ Invoke-WebRequest -Uri "https://github.com/arthenica/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-windows-x86_64-6.0.zip" -OutFile "ffmpeg-kit-windows.zip" -ErrorAction SilentlyContinue
+
+ - name: Build Tauri
+ run: |
+ cd public/handdraw/src-tauri
+ cargo install tauri-cli --version "^1.0"
+ cargo tauri build --bundles nsis
+
+ - name: Upload NSIS to Release
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
+ asset_path: public/handdraw/src-tauri/target/release/bundle/nsis/HandDraw_0.1.0_x64-setup.exe
+ asset_name: handdraw-windows.exe
+ asset_content_type: application/octet-stream
diff --git a/public/handdraw/.github/workflows/windows.yml b/public/handdraw/.github/workflows/windows.yml
new file mode 100644
index 0000000..0110e71
--- /dev/null
+++ b/public/handdraw/.github/workflows/windows.yml
@@ -0,0 +1,65 @@
+name: Windows Build
+
+on:
+ push:
+ branches: [main, claude/**]
+ pull_request:
+
+jobs:
+ build-windows:
+ runs-on: windows-2022
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: stable
+
+ - name: Cache Rust
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: public/handdraw/src-tauri
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install TypeScript
+ run: npm install -g typescript
+
+ - name: Compile TypeScript
+ run: |
+ cd public/handdraw/app
+ tsc --outDir ../src-tauri/dist --module es2020 --target es2020 --skipLibCheck --allowSyntheticDefaultImports app.ts
+
+ - name: Copy static assets
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force -Path public/handdraw/src-tauri/dist
+ Copy-Item public/handdraw/app/index.html public/handdraw/src-tauri/dist/
+ Copy-Item public/handdraw/app/styles.css public/handdraw/src-tauri/dist/
+ Copy-Item -Recurse public/handdraw/app/assets public/handdraw/src-tauri/dist/
+
+ - name: Download FFmpeg-kit LGPL
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force -Path public/handdraw/src-tauri/libs
+ cd public/handdraw/src-tauri/libs
+ Invoke-WebRequest -Uri "https://github.com/arthenica/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-windows-x86_64-6.0.zip" -OutFile "ffmpeg-kit-windows.zip"
+ Expand-Archive -Path "ffmpeg-kit-windows.zip" -DestinationPath "."
+ Move-Item -Path "ffmpeg-kit-windows-x86_64-6.0/bin/*" -Destination "."
+ Remove-Item -Recurse -Force "ffmpeg-kit-windows-x86_64-6.0", "ffmpeg-kit-windows.zip"
+
+ - name: Build Tauri NSIS
+ run: |
+ cd public/handdraw/src-tauri
+ cargo install tauri-cli --version "^1.0"
+ cargo tauri build --bundles nsis
+
+ - name: Upload NSIS Installer
+ uses: actions/upload-artifact@v4
+ with:
+ name: handdraw-windows-nsis
+ path: public/handdraw/src-tauri/target/release/bundle/nsis/*.exe
diff --git a/public/handdraw/LICENSE b/public/handdraw/LICENSE
new file mode 100644
index 0000000..1b0034a
--- /dev/null
+++ b/public/handdraw/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 HandDraw Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/public/handdraw/README.md b/public/handdraw/README.md
new file mode 100644
index 0000000..dd9080f
--- /dev/null
+++ b/public/handdraw/README.md
@@ -0,0 +1,67 @@
+# HandDraw
+
+Whiteboard and chalkboard hand-draw animation desktop app. Offline-first, export to MP4 and WebM.
+
+## Download
+
+Go to [Releases](https://github.com/Nickpanek/patternripple-pages/releases) and download:
+- **Linux**: `.AppImage` or `.deb`
+- **macOS**: `.dmg` (macOS 13+)
+- **Windows**: `.exe` installer (Windows 10 22H2+)
+
+## Quickstart (GitHub Web UI Only)
+
+1. Navigate to this repository on GitHub.
+2. Go to the **Actions** tab to see builds.
+3. When builds complete, check **Releases** for installers.
+4. Download and run the installer for your platform.
+
+## Usage
+
+1. **Import SVG**: Click "Add SVG" to load vector artwork.
+2. **Add Audio**: Click "Add Audio" to upload MP3/WAV/M4A.
+3. **Create Segments**: In the timeline, add stroke segments with start/duration.
+4. **Preview**: Click Play to preview animation with hand pointer.
+5. **Export**: Click "Export MP4" or "Export WebM" to render final video at 1080p30 or 720p30.
+
+## Project Storage
+
+Projects are saved in:
+- **Linux**: `~/.local/share/handdraw/`
+- **macOS**: `~/Library/Application Support/HandDraw/`
+- **Windows**: `%AppData%\HandDraw\`
+
+Project files use `.handdraw` extension (JSON + asset folder).
+
+## Licensing
+
+### App License
+
+This app is licensed under the MIT License. See [LICENSE](LICENSE).
+
+### FFmpeg-kit LGPL Compliance
+
+This app uses [ffmpeg-kit](https://github.com/arthenica/ffmpeg-kit) LGPL build with **dynamic linking** to comply with LGPL v2.1. See [third_party/ffmpeg-kit/NOTICE.md](third_party/ffmpeg-kit/NOTICE.md) for details.
+
+FFmpeg-kit binaries are downloaded during CI builds and linked dynamically. Source code and build instructions are available at the ffmpeg-kit repository.
+
+### Placeholder Assets
+
+Placeholder hand pointers and chalk texture are CC0 (public domain):
+- `hand-right.png`, `hand-left.png`, `chalk.png`
+
+**Attribution**: Placeholders are minimal 1x1 PNGs. Replace with your own assets or download CC0 resources from [OpenGameArt](https://opengameart.org/) or [Pixabay](https://pixabay.com/).
+
+## Development
+
+Built with:
+- **Tauri** (Rust + HTML/CSS/TypeScript)
+- **FFmpeg-kit** (LGPL dynamic linking)
+- **Web Audio API** for preview
+- **SVG stroke reveal** with `stroke-dasharray`
+
+CI builds on GitHub Actions for Linux, macOS, and Windows.
+
+## Support
+
+Open an issue on GitHub for bugs or feature requests.
diff --git a/public/handdraw/app/app.ts b/public/handdraw/app/app.ts
new file mode 100644
index 0000000..10df774
--- /dev/null
+++ b/public/handdraw/app/app.ts
@@ -0,0 +1,117 @@
+import { initStage, renderFrame } from './modules/stage.js';
+import { initTimeline, addSegment, playTimeline, stopTimeline } from './modules/timeline.js';
+import { loadSVG } from './modules/svg-utils.js';
+import { loadAudio, renderWaveform } from './modules/audio.js';
+import { updateHandPosition } from './modules/hand.js';
+import { state } from './modules/state.js';
+
+declare global {
+ interface Window {
+ __TAURI__: any;
+ }
+}
+
+const { invoke } = window.__TAURI__;
+
+document.addEventListener('DOMContentLoaded', () => {
+ initStage();
+ initTimeline();
+
+ document.getElementById('btn-add-svg')?.addEventListener('click', async () => {
+ try {
+ const selected = await invoke('select_svg_file');
+ if (selected) {
+ await loadSVG(selected as string);
+ updateAssetsList();
+ }
+ } catch (err) {
+ console.error('Failed to load SVG:', err);
+ }
+ });
+
+ document.getElementById('btn-add-audio')?.addEventListener('click', async () => {
+ try {
+ const selected = await invoke('select_audio_file');
+ if (selected) {
+ await loadAudio(selected as string);
+ renderWaveform();
+ updateAssetsList();
+ }
+ } catch (err) {
+ console.error('Failed to load audio:', err);
+ }
+ });
+
+ document.getElementById('btn-play')?.addEventListener('click', () => {
+ playTimeline();
+ });
+
+ document.getElementById('btn-stop')?.addEventListener('click', () => {
+ stopTimeline();
+ });
+
+ document.getElementById('btn-add-segment')?.addEventListener('click', () => {
+ const pathId = state.svgPaths.length > 0 ? state.svgPaths[0].id : 'demo-path';
+ addSegment({
+ id: `seg-${Date.now()}`,
+ pathId,
+ startTime: 0,
+ duration: 3,
+ easing: 'linear'
+ });
+ });
+
+ document.getElementById('btn-export-mp4')?.addEventListener('click', async () => {
+ try {
+ await invoke('export_video', { format: 'mp4', width: 1920, height: 1080, fps: 30 });
+ alert('Export started. Check project folder.');
+ } catch (err) {
+ console.error('Export failed:', err);
+ }
+ });
+
+ document.getElementById('btn-export-webm')?.addEventListener('click', async () => {
+ try {
+ await invoke('export_video', { format: 'webm', width: 1920, height: 1080, fps: 30 });
+ alert('Export started. Check project folder.');
+ } catch (err) {
+ console.error('Export failed:', err);
+ }
+ });
+
+ document.getElementById('btn-zoom-in')?.addEventListener('click', () => {
+ state.zoom = Math.min(state.zoom * 1.2, 5);
+ renderFrame(state.currentTime);
+ });
+
+ document.getElementById('btn-zoom-out')?.addEventListener('click', () => {
+ state.zoom = Math.max(state.zoom / 1.2, 0.1);
+ renderFrame(state.currentTime);
+ });
+
+ document.getElementById('btn-zoom-reset')?.addEventListener('click', () => {
+ state.zoom = 1;
+ renderFrame(state.currentTime);
+ });
+
+ // Initial render with demo path
+ if (document.getElementById('demo-path')) {
+ loadSVG('demo');
+ }
+ renderFrame(0);
+});
+
+function updateAssetsList() {
+ const list = document.getElementById('assets-list');
+ if (!list) return;
+
+ let html = '
';
+ state.svgPaths.forEach(p => {
+ html += `- Path: ${p.id}
`;
+ });
+ if (state.audioBuffer) {
+ html += `- Audio: ${state.audioBuffer.duration.toFixed(2)}s
`;
+ }
+ html += '
';
+ list.innerHTML = html;
+}
diff --git a/public/handdraw/app/assets/hands/hand-left.png b/public/handdraw/app/assets/hands/hand-left.png
new file mode 100644
index 0000000..177b4b6
--- /dev/null
+++ b/public/handdraw/app/assets/hands/hand-left.png
@@ -0,0 +1 @@
+data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==
diff --git a/public/handdraw/app/assets/hands/hand-right.png b/public/handdraw/app/assets/hands/hand-right.png
new file mode 100644
index 0000000..177b4b6
--- /dev/null
+++ b/public/handdraw/app/assets/hands/hand-right.png
@@ -0,0 +1 @@
+data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==
diff --git a/public/handdraw/app/assets/textures/chalk.png b/public/handdraw/app/assets/textures/chalk.png
new file mode 100644
index 0000000..177b4b6
--- /dev/null
+++ b/public/handdraw/app/assets/textures/chalk.png
@@ -0,0 +1 @@
+data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==
diff --git a/public/handdraw/app/index.html b/public/handdraw/app/index.html
new file mode 100644
index 0000000..82cf14d
--- /dev/null
+++ b/public/handdraw/app/index.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+ HandDraw
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/handdraw/app/modules/audio.ts b/public/handdraw/app/modules/audio.ts
new file mode 100644
index 0000000..cf1c959
--- /dev/null
+++ b/public/handdraw/app/modules/audio.ts
@@ -0,0 +1,68 @@
+import { state } from './state.js';
+
+export async function loadAudio(filePath: string) {
+ try {
+ const audioData = await window.__TAURI__.invoke('read_audio_file', { path: filePath });
+ const arrayBuffer = base64ToArrayBuffer(audioData);
+
+ if (!state.audioContext) {
+ state.audioContext = new AudioContext();
+ }
+
+ state.audioBuffer = await state.audioContext.decodeAudioData(arrayBuffer);
+ } catch (err) {
+ console.error('Failed to load audio:', err);
+ }
+}
+
+export function renderWaveform() {
+ const waveformDiv = document.getElementById('waveform');
+ if (!waveformDiv || !state.audioBuffer) return;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = waveformDiv.clientWidth;
+ canvas.height = waveformDiv.clientHeight;
+ canvas.style.width = '100%';
+ canvas.style.height = '100%';
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const data = state.audioBuffer.getChannelData(0);
+ const step = Math.ceil(data.length / canvas.width);
+ const amp = canvas.height / 2;
+
+ ctx.fillStyle = '#252526';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ ctx.strokeStyle = '#0e639c';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+
+ for (let i = 0; i < canvas.width; i++) {
+ let min = 1.0;
+ let max = -1.0;
+ for (let j = 0; j < step; j++) {
+ const datum = data[i * step + j];
+ if (datum < min) min = datum;
+ if (datum > max) max = datum;
+ }
+ ctx.moveTo(i, (1 + min) * amp);
+ ctx.lineTo(i, (1 + max) * amp);
+ }
+
+ ctx.stroke();
+
+ waveformDiv.innerHTML = '';
+ waveformDiv.appendChild(canvas);
+}
+
+function base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binaryString = atob(base64);
+ const len = binaryString.length;
+ const bytes = new Uint8Array(len);
+ for (let i = 0; i < len; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
diff --git a/public/handdraw/app/modules/hand.ts b/public/handdraw/app/modules/hand.ts
new file mode 100644
index 0000000..5bd8711
--- /dev/null
+++ b/public/handdraw/app/modules/hand.ts
@@ -0,0 +1,56 @@
+import { state, SVGPathData } from './state.js';
+import { getPointAtLength, getTangentAtLength } from './svg-utils.js';
+
+const handImages: { [key: string]: HTMLImageElement } = {};
+
+export function loadHandImages() {
+ ['left', 'right'].forEach(type => {
+ const img = new Image();
+ img.src = `assets/hands/hand-${type}.png`;
+ handImages[type] = img;
+ });
+}
+
+loadHandImages();
+
+export function updateHandPosition(pathData: SVGPathData, progress: number) {
+ const canvas = document.getElementById('hand-canvas') as HTMLCanvasElement;
+ if (!canvas) return;
+
+ const stage = document.getElementById('stage') as SVGSVGElement;
+ if (!stage) return;
+
+ canvas.width = stage.clientWidth;
+ canvas.height = stage.clientHeight;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ const length = progress * pathData.totalLength;
+ const point = getPointAtLength(pathData.element, length);
+ const angle = getTangentAtLength(pathData.element, length);
+
+ const handImg = handImages[state.handType];
+ if (!handImg || !handImg.complete) return;
+
+ ctx.save();
+
+ // Map SVG coordinates to canvas
+ const bbox = stage.getBoundingClientRect();
+ const viewBox = stage.viewBox.baseVal;
+ const scaleX = bbox.width / viewBox.width;
+ const scaleY = bbox.height / viewBox.height;
+
+ const x = point.x * scaleX + state.handOffsetX;
+ const y = point.y * scaleY + state.handOffsetY;
+
+ ctx.translate(x, y);
+ ctx.rotate(angle);
+
+ const handSize = 60;
+ ctx.drawImage(handImg, -handSize / 2, -handSize / 2, handSize, handSize);
+
+ ctx.restore();
+}
diff --git a/public/handdraw/app/modules/stage.ts b/public/handdraw/app/modules/stage.ts
new file mode 100644
index 0000000..c768917
--- /dev/null
+++ b/public/handdraw/app/modules/stage.ts
@@ -0,0 +1,40 @@
+import { state } from './state.js';
+import { applyStrokeDashAnimation } from './svg-utils.js';
+import { getActiveSegmentProgress } from './timeline.js';
+import { updateHandPosition } from './hand.js';
+
+export function initStage() {
+ const stage = document.getElementById('stage') as SVGSVGElement;
+ if (!stage) return;
+
+ // Pan and zoom support can be added here
+}
+
+export function renderFrame(time: number) {
+ const activeData = getActiveSegmentProgress(time);
+
+ // Reset all paths
+ state.svgPaths.forEach(pathData => {
+ pathData.element.style.strokeDasharray = '';
+ pathData.element.style.strokeDashoffset = '';
+ });
+
+ if (activeData) {
+ const { segment, progress } = activeData;
+ const pathData = state.svgPaths.find(p => p.id === segment.pathId);
+
+ if (pathData) {
+ applyStrokeDashAnimation(pathData.element, progress, pathData.totalLength);
+ updateHandPosition(pathData, progress);
+ }
+ } else {
+ // Clear hand if no active segment
+ const handCanvas = document.getElementById('hand-canvas') as HTMLCanvasElement;
+ if (handCanvas) {
+ const ctx = handCanvas.getContext('2d');
+ if (ctx) {
+ ctx.clearRect(0, 0, handCanvas.width, handCanvas.height);
+ }
+ }
+ }
+}
diff --git a/public/handdraw/app/modules/state.ts b/public/handdraw/app/modules/state.ts
new file mode 100644
index 0000000..54419d3
--- /dev/null
+++ b/public/handdraw/app/modules/state.ts
@@ -0,0 +1,39 @@
+export interface SVGPathData {
+ id: string;
+ element: SVGPathElement;
+ totalLength: number;
+}
+
+export interface TimelineSegment {
+ id: string;
+ pathId: string;
+ startTime: number;
+ duration: number;
+ easing: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
+}
+
+export interface AppState {
+ svgPaths: SVGPathData[];
+ audioBuffer: AudioBuffer | null;
+ audioContext: AudioContext | null;
+ segments: TimelineSegment[];
+ currentTime: number;
+ isPlaying: boolean;
+ zoom: number;
+ handOffsetX: number;
+ handOffsetY: number;
+ handType: 'left' | 'right';
+}
+
+export const state: AppState = {
+ svgPaths: [],
+ audioBuffer: null,
+ audioContext: null,
+ segments: [],
+ currentTime: 0,
+ isPlaying: false,
+ zoom: 1,
+ handOffsetX: 20,
+ handOffsetY: -30,
+ handType: 'right'
+};
diff --git a/public/handdraw/app/modules/svg-utils.ts b/public/handdraw/app/modules/svg-utils.ts
new file mode 100644
index 0000000..209cd30
--- /dev/null
+++ b/public/handdraw/app/modules/svg-utils.ts
@@ -0,0 +1,63 @@
+import { state, SVGPathData } from './state.js';
+
+export async function loadSVG(filePath: string) {
+ if (filePath === 'demo') {
+ const demoPath = document.getElementById('demo-path') as SVGPathElement;
+ if (demoPath) {
+ const totalLength = demoPath.getTotalLength();
+ state.svgPaths.push({
+ id: 'demo-path',
+ element: demoPath,
+ totalLength
+ });
+ }
+ return;
+ }
+
+ try {
+ const svgContent = await window.__TAURI__.invoke('read_file', { path: filePath });
+ const parser = new DOMParser();
+ const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml');
+ const paths = svgDoc.querySelectorAll('path');
+
+ const stage = document.getElementById('stage') as SVGSVGElement;
+ paths.forEach((path, idx) => {
+ const imported = document.importNode(path, true) as SVGPathElement;
+ imported.id = imported.id || `path-${Date.now()}-${idx}`;
+ imported.setAttribute('stroke', '#ffffff');
+ imported.setAttribute('stroke-width', '4');
+ imported.setAttribute('fill', 'none');
+ stage.appendChild(imported);
+
+ const totalLength = imported.getTotalLength();
+ state.svgPaths.push({
+ id: imported.id,
+ element: imported,
+ totalLength
+ });
+ });
+ } catch (err) {
+ console.error('Failed to load SVG:', err);
+ }
+}
+
+export function getPointAtLength(pathElement: SVGPathElement, length: number): DOMPoint {
+ return pathElement.getPointAtLength(length);
+}
+
+export function getTangentAtLength(pathElement: SVGPathElement, length: number): number {
+ const delta = 1;
+ const p1 = pathElement.getPointAtLength(Math.max(0, length - delta));
+ const p2 = pathElement.getPointAtLength(length + delta);
+ return Math.atan2(p2.y - p1.y, p2.x - p1.x);
+}
+
+export function applyStrokeDashAnimation(
+ pathElement: SVGPathElement,
+ progress: number,
+ totalLength: number
+) {
+ const currentLength = progress * totalLength;
+ pathElement.style.strokeDasharray = `${currentLength} ${totalLength}`;
+ pathElement.style.strokeDashoffset = '0';
+}
diff --git a/public/handdraw/app/modules/timeline.ts b/public/handdraw/app/modules/timeline.ts
new file mode 100644
index 0000000..514e2eb
--- /dev/null
+++ b/public/handdraw/app/modules/timeline.ts
@@ -0,0 +1,113 @@
+import { state, TimelineSegment } from './state.js';
+import { renderFrame } from './stage.js';
+
+let animationFrameId: number | null = null;
+let startTime: number = 0;
+
+export function initTimeline() {
+ const timeline = document.getElementById('timeline');
+ if (!timeline) return;
+
+ // Setup timeline interaction
+ timeline.addEventListener('click', (e) => {
+ const rect = timeline.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const time = (x / rect.width) * 10; // 10 second timeline
+ state.currentTime = time;
+ renderFrame(time);
+ });
+}
+
+export function addSegment(segment: TimelineSegment) {
+ state.segments.push(segment);
+ renderSegments();
+}
+
+function renderSegments() {
+ const timeline = document.getElementById('timeline');
+ if (!timeline) return;
+
+ const existingSegments = timeline.querySelectorAll('.segment');
+ existingSegments.forEach(s => s.remove());
+
+ state.segments.forEach(seg => {
+ const div = document.createElement('div');
+ div.className = 'segment';
+ div.id = seg.id;
+ div.textContent = seg.pathId;
+
+ const left = (seg.startTime / 10) * 100; // 10 sec timeline
+ const width = (seg.duration / 10) * 100;
+
+ div.style.left = `${left}%`;
+ div.style.width = `${width}%`;
+
+ timeline.appendChild(div);
+ });
+}
+
+export function playTimeline() {
+ if (state.isPlaying) return;
+
+ state.isPlaying = true;
+ startTime = performance.now() - state.currentTime * 1000;
+
+ function animate(now: number) {
+ if (!state.isPlaying) return;
+
+ const elapsed = (now - startTime) / 1000;
+ state.currentTime = elapsed;
+
+ if (elapsed > 10) {
+ stopTimeline();
+ return;
+ }
+
+ renderFrame(elapsed);
+ animationFrameId = requestAnimationFrame(animate);
+ }
+
+ animationFrameId = requestAnimationFrame(animate);
+
+ // Play audio if available
+ if (state.audioBuffer && state.audioContext) {
+ const source = state.audioContext.createBufferSource();
+ source.buffer = state.audioBuffer;
+ source.connect(state.audioContext.destination);
+ source.start(0, state.currentTime);
+ }
+}
+
+export function stopTimeline() {
+ state.isPlaying = false;
+ if (animationFrameId !== null) {
+ cancelAnimationFrame(animationFrameId);
+ animationFrameId = null;
+ }
+
+ if (state.audioContext) {
+ state.audioContext.close();
+ state.audioContext = new AudioContext();
+ }
+}
+
+function easeValue(t: number, easing: string): number {
+ switch (easing) {
+ case 'ease-in': return t * t;
+ case 'ease-out': return t * (2 - t);
+ case 'ease-in-out': return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
+ default: return t; // linear
+ }
+}
+
+export function getActiveSegmentProgress(time: number): { segment: TimelineSegment; progress: number } | null {
+ for (const seg of state.segments) {
+ if (time >= seg.startTime && time <= seg.startTime + seg.duration) {
+ const localTime = time - seg.startTime;
+ const rawProgress = localTime / seg.duration;
+ const progress = easeValue(rawProgress, seg.easing);
+ return { segment: seg, progress };
+ }
+ }
+ return null;
+}
diff --git a/public/handdraw/app/styles.css b/public/handdraw/app/styles.css
new file mode 100644
index 0000000..0a2fd90
--- /dev/null
+++ b/public/handdraw/app/styles.css
@@ -0,0 +1,196 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background: #1e1e1e;
+ color: #d4d4d4;
+ overflow: hidden;
+}
+
+#app {
+ display: grid;
+ grid-template-columns: 200px 1fr 250px;
+ grid-template-rows: 1fr 180px;
+ grid-template-areas:
+ "sidebar stage inspector"
+ "sidebar timeline inspector";
+ height: 100vh;
+}
+
+#sidebar {
+ grid-area: sidebar;
+ background: #252526;
+ padding: 16px;
+ overflow-y: auto;
+ border-right: 1px solid #3c3c3c;
+}
+
+#sidebar h2 {
+ font-size: 14px;
+ margin-bottom: 12px;
+ text-transform: uppercase;
+ color: #888;
+}
+
+#sidebar button {
+ width: 100%;
+ padding: 8px;
+ margin-bottom: 8px;
+ background: #0e639c;
+ color: #fff;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+#sidebar button:hover {
+ background: #1177bb;
+}
+
+#assets-list {
+ margin-top: 12px;
+ font-size: 12px;
+}
+
+#stage-container {
+ grid-area: stage;
+ position: relative;
+ background: #2d2d30;
+ overflow: hidden;
+}
+
+#toolbar {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ z-index: 10;
+}
+
+#toolbar button {
+ padding: 6px 12px;
+ margin-right: 4px;
+ background: #3c3c3c;
+ color: #ccc;
+ border: 1px solid #555;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 12px;
+}
+
+#toolbar button:hover {
+ background: #505050;
+}
+
+#stage {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+#hand-canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
+
+#inspector {
+ grid-area: inspector;
+ background: #252526;
+ padding: 16px;
+ overflow-y: auto;
+ border-left: 1px solid #3c3c3c;
+}
+
+#inspector h2 {
+ font-size: 14px;
+ margin-bottom: 12px;
+ text-transform: uppercase;
+ color: #888;
+}
+
+#inspector label {
+ display: block;
+ margin-bottom: 10px;
+ font-size: 12px;
+}
+
+#inspector input,
+#inspector select {
+ width: 100%;
+ padding: 6px;
+ margin-top: 4px;
+ background: #3c3c3c;
+ color: #d4d4d4;
+ border: 1px solid #555;
+ border-radius: 3px;
+ font-size: 12px;
+}
+
+#timeline-container {
+ grid-area: timeline;
+ background: #1e1e1e;
+ border-top: 1px solid #3c3c3c;
+ padding: 12px;
+ overflow-y: auto;
+}
+
+#timeline-controls {
+ margin-bottom: 12px;
+}
+
+#timeline-controls button {
+ padding: 6px 12px;
+ margin-right: 6px;
+ background: #0e639c;
+ color: #fff;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 12px;
+}
+
+#timeline-controls button:hover {
+ background: #1177bb;
+}
+
+#timeline {
+ height: 60px;
+ background: #2d2d30;
+ border: 1px solid #3c3c3c;
+ border-radius: 3px;
+ position: relative;
+ margin-bottom: 8px;
+}
+
+#waveform {
+ height: 50px;
+ background: #252526;
+ border: 1px solid #3c3c3c;
+ border-radius: 3px;
+}
+
+.segment {
+ position: absolute;
+ top: 10px;
+ height: 40px;
+ background: rgba(14, 99, 156, 0.7);
+ border: 1px solid #0e639c;
+ border-radius: 3px;
+ cursor: move;
+ font-size: 10px;
+ color: #fff;
+ padding: 4px;
+ overflow: hidden;
+}
+
+.segment:hover {
+ background: rgba(17, 119, 187, 0.8);
+}
diff --git a/public/handdraw/src-tauri/Cargo.toml b/public/handdraw/src-tauri/Cargo.toml
new file mode 100644
index 0000000..bbbc9a3
--- /dev/null
+++ b/public/handdraw/src-tauri/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "handdraw"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+tauri = { version = "1.5", features = ["dialog-all", "fs-all", "shell-open"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+tokio = { version = "1", features = ["full"] }
+base64 = "0.21"
+chrono = "0.4"
+dirs = "5.0"
+
+[build-dependencies]
+tauri-build = { version = "1.5" }
+
+[lib]
+name = "handdraw_lib"
+crate-type = ["staticlib", "cdylib", "lib"]
+
+[[bin]]
+name = "handdraw"
+path = "src/main.rs"
+
+[profile.release]
+panic = "abort"
+codegen-units = 1
+lto = true
+opt-level = "z"
+strip = true
diff --git a/public/handdraw/src-tauri/build.rs b/public/handdraw/src-tauri/build.rs
new file mode 100644
index 0000000..d860e1e
--- /dev/null
+++ b/public/handdraw/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/public/handdraw/src-tauri/src/encoder.rs b/public/handdraw/src-tauri/src/encoder.rs
new file mode 100644
index 0000000..31c6c52
--- /dev/null
+++ b/public/handdraw/src-tauri/src/encoder.rs
@@ -0,0 +1,50 @@
+use crate::renderer::FrameRenderer;
+use std::process::{Command, Stdio};
+use std::io::Write;
+
+pub async fn export_video_async(params: crate::ExportParams) -> Result<(), String> {
+ let output_path = format!("output_{}.{}", chrono::Utc::now().timestamp(), params.format);
+
+ let codec = match params.format.as_str() {
+ "mp4" => ("libx264", "aac"),
+ "webm" => ("libvpx-vp9", "libopus"),
+ _ => return Err("Unsupported format".to_string()),
+ };
+
+ let ffmpeg_args = vec![
+ "-f", "rawvideo",
+ "-pixel_format", "rgba",
+ "-video_size", &format!("{}x{}", params.width, params.height),
+ "-framerate", ¶ms.fps.to_string(),
+ "-i", "pipe:0",
+ "-c:v", codec.0,
+ "-pix_fmt", "yuv420p",
+ "-preset", "medium",
+ "-crf", "23",
+ &output_path,
+ ];
+
+ let mut child = Command::new("ffmpeg")
+ .args(&ffmpeg_args)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .map_err(|e| format!("Failed to spawn ffmpeg: {}", e))?;
+
+ let stdin = child.stdin.as_mut().ok_or("Failed to open stdin")?;
+
+ let renderer = FrameRenderer::new(params.width, params.height);
+ let total_frames = params.fps * 10; // 10 seconds
+
+ for frame_idx in 0..total_frames {
+ let time = frame_idx as f64 / params.fps as f64;
+ let frame_data = renderer.render_frame(time)?;
+ stdin.write_all(&frame_data).map_err(|e| e.to_string())?;
+ }
+
+ drop(stdin);
+ child.wait().map_err(|e| e.to_string())?;
+
+ Ok(())
+}
diff --git a/public/handdraw/src-tauri/src/main.rs b/public/handdraw/src-tauri/src/main.rs
new file mode 100644
index 0000000..47f3612
--- /dev/null
+++ b/public/handdraw/src-tauri/src/main.rs
@@ -0,0 +1,93 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+mod encoder;
+mod project;
+mod renderer;
+
+use tauri::{command, Window};
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+#[derive(Serialize, Deserialize)]
+struct ExportParams {
+ format: String,
+ width: u32,
+ height: u32,
+ fps: u32,
+}
+
+#[command]
+async fn select_svg_file() -> Result {
+ use tauri::api::dialog::blocking::FileDialogBuilder;
+
+ let file = FileDialogBuilder::new()
+ .add_filter("SVG", &["svg"])
+ .pick_file();
+
+ match file {
+ Some(path) => Ok(path.to_string_lossy().to_string()),
+ None => Err("No file selected".to_string()),
+ }
+}
+
+#[command]
+async fn select_audio_file() -> Result {
+ use tauri::api::dialog::blocking::FileDialogBuilder;
+
+ let file = FileDialogBuilder::new()
+ .add_filter("Audio", &["mp3", "wav", "m4a"])
+ .pick_file();
+
+ match file {
+ Some(path) => Ok(path.to_string_lossy().to_string()),
+ None => Err("No file selected".to_string()),
+ }
+}
+
+#[command]
+async fn read_file(path: String) -> Result {
+ std::fs::read_to_string(path).map_err(|e| e.to_string())
+}
+
+#[command]
+async fn read_audio_file(path: String) -> Result {
+ let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
+ Ok(base64::encode(&bytes))
+}
+
+#[command]
+async fn export_video(
+ format: String,
+ width: u32,
+ height: u32,
+ fps: u32,
+) -> Result {
+ let params = ExportParams { format, width, height, fps };
+
+ tokio::spawn(async move {
+ if let Err(e) = encoder::export_video_async(params).await {
+ eprintln!("Export error: {}", e);
+ }
+ });
+
+ Ok("Export started".to_string())
+}
+
+#[command]
+async fn get_project_dir() -> Result {
+ project::get_project_directory()
+}
+
+fn main() {
+ tauri::Builder::default()
+ .invoke_handler(tauri::generate_handler![
+ select_svg_file,
+ select_audio_file,
+ read_file,
+ read_audio_file,
+ export_video,
+ get_project_dir
+ ])
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
+}
diff --git a/public/handdraw/src-tauri/src/project.rs b/public/handdraw/src-tauri/src/project.rs
new file mode 100644
index 0000000..9d42115
--- /dev/null
+++ b/public/handdraw/src-tauri/src/project.rs
@@ -0,0 +1,59 @@
+use std::path::PathBuf;
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize)]
+pub struct Project {
+ pub version: u32,
+ pub name: String,
+ pub assets: Vec,
+ pub timeline: Vec,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct Asset {
+ pub id: String,
+ pub asset_type: String,
+ pub path: String,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct Segment {
+ pub id: String,
+ pub path_id: String,
+ pub start_time: f64,
+ pub duration: f64,
+ pub easing: String,
+}
+
+pub fn get_project_directory() -> Result {
+ let base = if cfg!(target_os = "linux") {
+ dirs::data_local_dir()
+ .ok_or("Failed to get data directory")?
+ .join("handdraw")
+ } else if cfg!(target_os = "macos") {
+ dirs::data_dir()
+ .ok_or("Failed to get data directory")?
+ .join("HandDraw")
+ } else if cfg!(target_os = "windows") {
+ dirs::data_dir()
+ .ok_or("Failed to get data directory")?
+ .join("HandDraw")
+ } else {
+ return Err("Unsupported platform".to_string());
+ };
+
+ std::fs::create_dir_all(&base).map_err(|e| e.to_string())?;
+ Ok(base)
+}
+
+pub fn save_project(project: &Project, path: PathBuf) -> Result<(), String> {
+ let json = serde_json::to_string_pretty(project).map_err(|e| e.to_string())?;
+ std::fs::write(path, json).map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+pub fn load_project(path: PathBuf) -> Result {
+ let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
+ let project: Project = serde_json::from_str(&json).map_err(|e| e.to_string())?;
+ Ok(project)
+}
diff --git a/public/handdraw/src-tauri/src/renderer.rs b/public/handdraw/src-tauri/src/renderer.rs
new file mode 100644
index 0000000..92f9a58
--- /dev/null
+++ b/public/handdraw/src-tauri/src/renderer.rs
@@ -0,0 +1,33 @@
+pub struct FrameRenderer {
+ width: u32,
+ height: u32,
+}
+
+impl FrameRenderer {
+ pub fn new(width: u32, height: u32) -> Self {
+ Self { width, height }
+ }
+
+ pub fn render_frame(&self, _time: f64) -> Result, String> {
+ // Placeholder: Generate a solid frame
+ // In production, this would:
+ // 1. Render SVG paths at current time
+ // 2. Apply stroke-dasharray progress
+ // 3. Composite hand pointer
+ // 4. Return RGBA pixels
+
+ let pixel_count = (self.width * self.height * 4) as usize;
+ let mut pixels = vec![0u8; pixel_count];
+
+ // Fill with dark background
+ for i in 0..pixel_count / 4 {
+ let offset = i * 4;
+ pixels[offset] = 30; // R
+ pixels[offset + 1] = 30; // G
+ pixels[offset + 2] = 30; // B
+ pixels[offset + 3] = 255; // A
+ }
+
+ Ok(pixels)
+ }
+}
diff --git a/public/handdraw/src-tauri/tauri.conf.json b/public/handdraw/src-tauri/tauri.conf.json
new file mode 100644
index 0000000..a816cf1
--- /dev/null
+++ b/public/handdraw/src-tauri/tauri.conf.json
@@ -0,0 +1,82 @@
+{
+ "package": {
+ "productName": "HandDraw",
+ "version": "0.1.0"
+ },
+ "build": {
+ "distDir": "../dist",
+ "devPath": "../dist",
+ "beforeDevCommand": "",
+ "beforeBuildCommand": ""
+ },
+ "tauri": {
+ "allowlist": {
+ "all": false,
+ "fs": {
+ "all": true,
+ "scope": ["$HOME/**", "$APPDATA/**", "$APPLOCALDATA/**"]
+ },
+ "dialog": {
+ "all": true,
+ "open": true,
+ "save": true
+ },
+ "shell": {
+ "all": false,
+ "open": true
+ }
+ },
+ "bundle": {
+ "active": true,
+ "identifier": "com.handdraw.app",
+ "icon": [
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.icns",
+ "icons/icon.ico"
+ ],
+ "targets": ["appimage", "deb", "dmg", "nsis"],
+ "resources": [],
+ "externalBin": [],
+ "copyright": "",
+ "category": "Graphics",
+ "shortDescription": "Hand-draw animation tool",
+ "longDescription": "Create whiteboard and chalkboard hand-draw animations with SVG and audio sync.",
+ "deb": {
+ "depends": []
+ },
+ "appimage": {
+ "bundleMediaFramework": false
+ },
+ "macOS": {
+ "frameworks": [],
+ "minimumSystemVersion": "13.0",
+ "exceptionDomain": "",
+ "entitlements": null,
+ "signingIdentity": null
+ },
+ "windows": {
+ "certificateThumbprint": null,
+ "digestAlgorithm": "sha256",
+ "timestampUrl": "",
+ "wix": {
+ "language": "en-US"
+ }
+ }
+ },
+ "security": {
+ "csp": null
+ },
+ "windows": [
+ {
+ "title": "HandDraw",
+ "width": 1600,
+ "height": 900,
+ "resizable": true,
+ "fullscreen": false,
+ "decorations": true
+ }
+ ]
+ }
+}
diff --git a/public/handdraw/third_party/ffmpeg-kit/NOTICE.md b/public/handdraw/third_party/ffmpeg-kit/NOTICE.md
new file mode 100644
index 0000000..8e8e1e5
--- /dev/null
+++ b/public/handdraw/third_party/ffmpeg-kit/NOTICE.md
@@ -0,0 +1,26 @@
+# FFmpeg-kit LGPL Notice
+
+This application uses **FFmpeg-kit** under the LGPL v2.1 license.
+
+## Dynamic Linking
+
+To comply with LGPL v2.1, this application **dynamically links** to FFmpeg-kit libraries. The FFmpeg-kit binaries are:
+- Downloaded during CI builds from the official FFmpeg-kit releases
+- Packaged separately and loaded at runtime
+- Not statically compiled into this application
+
+## Source Code
+
+FFmpeg-kit source code and build instructions are available at:
+- **GitHub**: [https://github.com/arthenica/ffmpeg-kit](https://github.com/arthenica/ffmpeg-kit)
+- **License**: LGPL v2.1
+
+Users may replace the bundled FFmpeg-kit binaries with their own builds as permitted under LGPL.
+
+## FFmpeg License
+
+FFmpeg itself is licensed under LGPL v2.1 (or later) when built without GPL components. This application uses the LGPL build of FFmpeg-kit, which excludes GPL-only codecs.
+
+For more information:
+- FFmpeg: [https://ffmpeg.org/legal.html](https://ffmpeg.org/legal.html)
+- LGPL v2.1: [https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html](https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html)