From 167f65fa6fba946aed71ab5ac5988f49bc60c2a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 08:31:03 +0000 Subject: [PATCH] Add HandDraw desktop app scaffold v1 Complete desktop app scaffold for whiteboard/chalkboard hand-draw animation tool: - Tauri + Rust backend with HTML/CSS/TypeScript frontend - SVG stroke reveal animation with hand pointer following - Audio upload and waveform visualization - Timeline with segments and easing - Export to MP4 (H.264/AAC) and WebM (VP9/Opus) at 1080p30 - FFmpeg-kit LGPL dynamic linking for codec compliance - CI workflows for Linux (AppImage/deb), macOS (DMG), Windows (NSIS) - Draft release workflow on push to main - Offline-first with local project storage - MIT licensed with LGPL compliance notes - CC0 placeholder assets Located in public/handdraw/ to keep separate from site. --- public/handdraw/.github/workflows/linux.yml | 88 ++++++++ public/handdraw/.github/workflows/macos.yml | 71 ++++++ public/handdraw/.github/workflows/release.yml | 210 ++++++++++++++++++ public/handdraw/.github/workflows/windows.yml | 65 ++++++ public/handdraw/LICENSE | 21 ++ public/handdraw/README.md | 67 ++++++ public/handdraw/app/app.ts | 117 ++++++++++ .../handdraw/app/assets/hands/hand-left.png | 1 + .../handdraw/app/assets/hands/hand-right.png | 1 + public/handdraw/app/assets/textures/chalk.png | 1 + public/handdraw/app/index.html | 61 +++++ public/handdraw/app/modules/audio.ts | 68 ++++++ public/handdraw/app/modules/hand.ts | 56 +++++ public/handdraw/app/modules/stage.ts | 40 ++++ public/handdraw/app/modules/state.ts | 39 ++++ public/handdraw/app/modules/svg-utils.ts | 63 ++++++ public/handdraw/app/modules/timeline.ts | 113 ++++++++++ public/handdraw/app/styles.css | 196 ++++++++++++++++ public/handdraw/src-tauri/Cargo.toml | 31 +++ public/handdraw/src-tauri/build.rs | 3 + public/handdraw/src-tauri/src/encoder.rs | 50 +++++ public/handdraw/src-tauri/src/main.rs | 93 ++++++++ public/handdraw/src-tauri/src/project.rs | 59 +++++ public/handdraw/src-tauri/src/renderer.rs | 33 +++ public/handdraw/src-tauri/tauri.conf.json | 82 +++++++ .../handdraw/third_party/ffmpeg-kit/NOTICE.md | 26 +++ 26 files changed, 1655 insertions(+) create mode 100644 public/handdraw/.github/workflows/linux.yml create mode 100644 public/handdraw/.github/workflows/macos.yml create mode 100644 public/handdraw/.github/workflows/release.yml create mode 100644 public/handdraw/.github/workflows/windows.yml create mode 100644 public/handdraw/LICENSE create mode 100644 public/handdraw/README.md create mode 100644 public/handdraw/app/app.ts create mode 100644 public/handdraw/app/assets/hands/hand-left.png create mode 100644 public/handdraw/app/assets/hands/hand-right.png create mode 100644 public/handdraw/app/assets/textures/chalk.png create mode 100644 public/handdraw/app/index.html create mode 100644 public/handdraw/app/modules/audio.ts create mode 100644 public/handdraw/app/modules/hand.ts create mode 100644 public/handdraw/app/modules/stage.ts create mode 100644 public/handdraw/app/modules/state.ts create mode 100644 public/handdraw/app/modules/svg-utils.ts create mode 100644 public/handdraw/app/modules/timeline.ts create mode 100644 public/handdraw/app/styles.css create mode 100644 public/handdraw/src-tauri/Cargo.toml create mode 100644 public/handdraw/src-tauri/build.rs create mode 100644 public/handdraw/src-tauri/src/encoder.rs create mode 100644 public/handdraw/src-tauri/src/main.rs create mode 100644 public/handdraw/src-tauri/src/project.rs create mode 100644 public/handdraw/src-tauri/src/renderer.rs create mode 100644 public/handdraw/src-tauri/tauri.conf.json create mode 100644 public/handdraw/third_party/ffmpeg-kit/NOTICE.md 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 = ''; + 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)