Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
dist_deb/
.DS_Store
.claude
.idea/
<<<<<<< HEAD
.idea
.local-tmp/
=======
.idea/
>>>>>>> origin/master
11 changes: 10 additions & 1 deletion Dockerfile.server
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM rust:1.87-slim-bookworm AS rust-bookworm-slim
FROM rust:1.88-slim-bookworm AS rust-bookworm-slim

# ----------------------------------

Expand Down Expand Up @@ -47,6 +47,15 @@ RUN apt-get -qy install libsqlite3-dev >/dev/null
RUN apt-get -qy install protobuf-compiler >/dev/null
RUN apt-get -qy install python3 python3.11 python3.11-venv >/dev/null

# Install MinIO for S3 integration tests
RUN apt-get -qy install curl >/dev/null
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then MINIO_ARCH="amd64"; \
elif [ "$ARCH" = "arm64" ]; then MINIO_ARCH="arm64"; \
else echo "Unsupported architecture: $ARCH" && exit 1; fi && \
curl -fsSL https://dl.min.io/server/minio/release/linux-${MINIO_ARCH}/minio -o /usr/local/bin/minio && \
chmod +x /usr/local/bin/minio

# Switch to regular user
RUN mkdir -p /app
RUN chown -R ${UID}:${GID} /app
Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Clapshot is an open-source, self-hosted tool for collaborative video/media revie
- **File Organization**: Hierarchical folder system with drag-and-drop, admin user management interface
- **Media Processing**: FFmpeg transcoding with configurable quality, thumbnail generation
- **Authentication**: Reverse proxy integration supporting OAuth, JWT, Kerberos, SAML, etc.
- **Storage**: SQLite database with automatic migrations, file-based media storage
- **Storage**: SQLite database with automatic migrations, local filesystem or S3-compatible object storage
- **Extensibility**: Plugin system for custom workflows and integrations

*For a comprehensive feature list, see [FEATURES.md](FEATURES.md).*
Expand Down Expand Up @@ -125,6 +125,39 @@ See [Upgrading Guide](doc/upgrading.md) for instructions on installing a new rel
**Want to customize media processing?** See the [Transcoding and Thumbnailing Guide](doc/transcoding.md) for configuring hardware acceleration, custom encoders, and specialized processing workflows.


### Object Storage (S3-compatible)

Clapshot can upload processed media and thumbnails to an S3-compatible object store while still staging files locally under `<data_dir>/videos`.

**Required settings** (CLI flags, `clapshot-server.conf`, or `CLAPSHOT_SERVER__*` env vars):
- `storage-backend = s3`
- `s3-endpoint = https://s3.example.com`
- `s3-region = us-east-1`
- `s3-bucket = clapshot-media`

**Authentication** uses the standard AWS SDK credential chain (in order of precedence):
1. Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`
2. AWS credentials file (`~/.aws/credentials`)
3. IAM instance roles (for EC2/ECS deployments)

**Optional settings:**
- `s3-prefix` – path inside the bucket (default: `videos`)
- `s3-public-url` – base URL used in playback links (defaults to `s3-endpoint/bucket`; set to your CDN/domain if different)

**Docker env example:**
```bash
-e CLAPSHOT_SERVER__STORAGE_BACKEND=s3 \
-e CLAPSHOT_SERVER__S3_ENDPOINT=https://s3.example.com \
-e CLAPSHOT_SERVER__S3_REGION=us-east-1 \
-e CLAPSHOT_SERVER__S3_BUCKET=clapshot-media \
-e AWS_ACCESS_KEY_ID=YOUR_KEY \
-e AWS_SECRET_ACCESS_KEY=YOUR_SECRET \
-e CLAPSHOT_SERVER__S3_PUBLIC_URL=https://cdn.example.com/clapshot-media
```

Ensure the bucket/prefix is readable at `s3-public-url` for playback, and keep enough local disk for staging uploads under `data_dir/videos`.


## Architecture Overview

Main components:
Expand Down
9 changes: 9 additions & 0 deletions client/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ else


let uploadUrl: string = $state("");
let transcodePreferred: boolean = $state(true);


// -------------------------------------------------------------
Expand Down Expand Up @@ -1133,11 +1134,19 @@ function onMediaFileListPopupAction(e: { detail: { action: Proto3.ActionDef, ite
<div class="my-6">
<!-- ========== upload widget ============= -->
{#if pit.folderListing.allowUpload}
<div class="flex justify-end items-center text-gray-400 text-sm mb-2 gap-3">
<span class="uppercase tracking-wide text-xs text-gray-500">Transcode</span>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={transcodePreferred} class="h-4 w-4 rounded border-gray-600 bg-slate-900" />
<span class="text-gray-200">{transcodePreferred ? "Transcode then upload" : "Upload directly to storage"}</span>
</label>
</div>
<div class="h-24 border-4 border-dashed border-gray-700">
<FileUpload
postUrl={uploadUrl}
listingData={pit.folderListing.listingData ?? {}}
mediaFileAddedAction={pit.folderListing.mediaFileAddedAction}
transcodePreferred={transcodePreferred}
>
<div class="flex flex-col justify-center items-center h-full">
<div class="text-2xl text-gray-700">
Expand Down
22 changes: 21 additions & 1 deletion client/src/__tests__/lib/NavBar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,26 @@ describe('NavBar.svelte', () => {
expect(matchingReport?.progress).toBe(0.7);
});

it('renders a progress bar for the active video', () => {
const report: MediaProgressReport = {
mediaFileId: 'video123',
msg: 'Uploading to storage…',
progress: 0.4,
received_ts: Date.now()
};
mediaFileId.set('video123');
curVideo.set(createMinimalMediaFile({
id: 'video123',
title: 'Test Video'
}));
latestProgressReports.set([report]);

const { container } = render(NavBar);
expect(screen.getAllByText('Uploading to storage…').length).toBeGreaterThan(0);
const bars = container.querySelectorAll('.bg-amber-500');
expect(bars.length).toBeGreaterThan(0);
});

it('should return undefined when no matching progress report', () => {
const reports: MediaProgressReport[] = [
{
Expand Down Expand Up @@ -523,4 +543,4 @@ describe('NavBar.svelte', () => {
expect(() => render(NavBar)).not.toThrow();
});
});
});
});
15 changes: 14 additions & 1 deletion client/src/__tests__/lib/asset_browser/VideoTile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@ describe('VideoTile', () => {
expect(container.querySelector('.flex-grow')).toBeInTheDocument();
});

it('shows a default icon when no preview or visualization exists', () => {
const mediaFile = createMediaFile({
id: 'no-preview',
title: 'No Preview Video',
previewData: undefined,
});

const { container } = render(VideoTile, { item: mediaFile });

const icon = container.querySelector('i.fa-video');
expect(icon).toBeInTheDocument();
});

it('should render with visualization override when no preview data', () => {
const mediaFile = createMediaFile({
id: 'test-video-3',
Expand Down Expand Up @@ -354,4 +367,4 @@ describe('VideoTile', () => {
expect(titleElement).toBeInTheDocument();
expect(titleElement.textContent).toBe(mediaFile.title);
});
});
});
21 changes: 18 additions & 3 deletions client/src/lib/NavBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ let { onbasicauthlogout, onaddcomments }: Props = $props();
let loggedOut = $state(false);
let localeOptions = $state(availableLocales);

// Watch for (transcoding) progress reports from server, and update progress bar if one matches this item.
// Watch for (transcoding/upload) progress reports from server, and show a quick status bar for the current video.
let videoProgressMsg: string | undefined = $state(undefined);
let videoProgressVal: number | undefined = $state(undefined);

onMount(async () => {
latestProgressReports.subscribe((reports: MediaProgressReport[]) => {
videoProgressMsg = reports.find((r: MediaProgressReport) => r.mediaFileId === $mediaFileId)?.msg;
const unsubscribe = latestProgressReports.subscribe((reports: MediaProgressReport[]) => {
const match = reports.find((r: MediaProgressReport) => r.mediaFileId === $mediaFileId);
videoProgressMsg = match?.msg;
videoProgressVal = match?.progress;
});
return () => unsubscribe();
});

$effect(() => {
Expand Down Expand Up @@ -160,6 +164,17 @@ function addEDLComments(comments: Proto3.Comment[]) {
</div>

</h2>

{#if videoProgressVal !== undefined}
<div class="flex flex-col items-center gap-1 mt-1 mb-2">
<div class="text-xs italic text-gray-500 text-center px-2">
{videoProgressMsg || 'Processing...'}
</div>
<div class="w-48 h-2 rounded-full bg-gray-200 overflow-hidden">
<div class="h-full bg-amber-500 transition-all duration-200" style={`width: ${(Math.max(0, Math.min(1, videoProgressVal)) * 100).toFixed(0)}%`}></div>
</div>
</div>
{/if}
<span class="mx-4 text-xs text-center">{$curVideo?.title}</span>
{#if videoProgressMsg}
<span class="text-cyan-800 mx-4 text-xs text-center">{videoProgressMsg}</span>
Expand Down
4 changes: 4 additions & 0 deletions client/src/lib/asset_browser/FileUpload.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ let files = {
// Passed to HTTP POST request:
listingData: Object;
mediaFileAddedAction: string|undefined;
transcodePreferred?: boolean;
children?: import('svelte').Snippet;
}

let {
postUrl,
listingData,
mediaFileAddedAction,
transcodePreferred = true,
children
}: Props = $props();

Expand Down Expand Up @@ -80,11 +82,13 @@ function upload() {
ajax.addEventListener("abort", abortHandler, false);
ajax.open("POST", postUrl);
ajax.setRequestHeader("X-FILE-NAME", encodeURIComponent(file.name));
ajax.setRequestHeader("X-CLAPSHOT-TRANSCODE", transcodePreferred ? "true" : "false");

let upload_cookies = { ...LocalStorageCookies.getAllNonExpired() };
if (mediaFileAddedAction)
upload_cookies["media_file_added_action"] = mediaFileAddedAction;
upload_cookies["listing_data_json"] = JSON.stringify(listingData);
upload_cookies["transcode_preference"] = transcodePreferred ? "true" : "false";
ajax.setRequestHeader("X-CLAPSHOT-COOKIES", JSON.stringify(upload_cookies));

ajax.send(formdata);
Expand Down
5 changes: 4 additions & 1 deletion client/src/lib/asset_browser/VideoTile.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ function fmt_date(d: Date | undefined) {
<div class="flex-grow">
<TileVisualizationOverride vis={visualization}/>
</div>
{:else}
<div class="flex-grow flex items-center justify-center text-slate-300">
<i class="fas fa-video text-5xl" aria-label="video icon"></i>
</div>
{/if}

<!-- Progress bar (if any) -->
Expand Down Expand Up @@ -119,4 +123,3 @@ function fmt_date(d: Date | undefined) {
}

</style>

Loading
Loading