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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ When you start Scope, it will automatically use Cloudflare's TURN servers and yo
uv run daydream-scope
```

## Environment Variables

### `HF_TOKEN`
HuggingFace access token for using Cloudflare's TURN servers. See [Firewalls](#firewalls) for details.

### `RECORDING_ENABLED`
- **Default**: `true`
- **Description**: Enable/disable recording. When `false`, recording is disabled.

### `RECORDING_MAX_LENGTH`
- **Default**: `1h`
- **Description**: Maximum total recording length (sum of all segments). Recording stops when reached.
- **Format**: Supports `1h`, `30m`, `120s`, or plain seconds (e.g., `3600`)

### `RECORDING_STARTUP_CLEANUP_ENABLED`
- **Default**: `true`
- **Description**: Enable/disable cleanup of recording files from previous sessions at startup.

## Contributing

Read the [contribution guide](./docs/contributing.md).
Expand Down
79 changes: 79 additions & 0 deletions frontend/src/components/ExportDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Download } from "lucide-react";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";

interface ExportDialogProps {
open: boolean;
onClose: () => void;
onSaveGeneration: () => void;
onSaveTimeline: () => void;
}

export function ExportDialog({
open,
onClose,
onSaveGeneration,
onSaveTimeline,
}: ExportDialogProps) {
return (
<Dialog open={open} onOpenChange={isOpen => !isOpen && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Export</DialogTitle>
<DialogDescription className="mt-3">
Choose what you want to export
</DialogDescription>
</DialogHeader>

<div className="flex flex-col gap-3 mt-4">
<Button
onClick={() => {
onSaveGeneration();
onClose();
}}
variant="outline"
className="w-full justify-start gap-2"
>
<Download className="h-4 w-4" />
<div className="flex flex-col items-start">
<span className="font-semibold">Save Generation</span>
<span className="text-xs text-muted-foreground">
Downloads MP4 to default Downloads folder
</span>
</div>
</Button>

<Button
onClick={() => {
onSaveTimeline();
onClose();
}}
variant="outline"
className="w-full justify-start gap-2"
>
<Download className="h-4 w-4" />
<div className="flex flex-col items-start">
<span className="font-semibold">Save Timeline</span>
<span className="text-xs text-muted-foreground">
Downloads JSON to default Downloads folder
</span>
</div>
</Button>
</div>

<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
3 changes: 3 additions & 0 deletions frontend/src/components/PromptInputWithTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface PromptInputWithTimelineProps {
isDownloading?: boolean;
transitionSteps?: number;
temporalInterpolationMethod?: "linear" | "slerp";
onSaveGeneration?: () => void;
}

export function PromptInputWithTimeline({
Expand Down Expand Up @@ -77,6 +78,7 @@ export function PromptInputWithTimeline({
isDownloading = false,
transitionSteps,
temporalInterpolationMethod,
onSaveGeneration,
}: PromptInputWithTimelineProps) {
const [isLive, setIsLive] = useState(false);
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
Expand Down Expand Up @@ -565,6 +567,7 @@ export function PromptInputWithTimeline({
onScrollToTime={scrollFn => setScrollToTimeFn(() => scrollFn)}
isStreaming={isStreaming}
isDownloading={isDownloading}
onSaveGeneration={onSaveGeneration}
/>
</div>
);
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/components/PromptTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ChevronDown,
Trash2,
} from "lucide-react";
import { ExportDialog } from "./ExportDialog";

import type { PromptItem } from "../lib/api";
import type { SettingsState } from "../types";
Expand Down Expand Up @@ -159,6 +160,7 @@ interface PromptTimelineProps {
onScrollToTime?: (scrollFn: (time: number) => void) => void;
isStreaming?: boolean;
isDownloading?: boolean;
onSaveGeneration?: () => void;
}

export function PromptTimeline({
Expand All @@ -185,6 +187,7 @@ export function PromptTimeline({
onScrollToTime,
isStreaming = false,
isDownloading = false,
onSaveGeneration,
}: PromptTimelineProps) {
const timelineRef = useRef<HTMLDivElement>(null);
const [timelineWidth, setTimelineWidth] = useState(800);
Expand Down Expand Up @@ -383,7 +386,9 @@ export function PromptTimeline({
[selectedPromptId, onPromptSelect, onPromptEdit]
);

const handleExport = useCallback(() => {
const [showExportDialog, setShowExportDialog] = useState(false);

const handleSaveTimeline = useCallback(() => {
// Filter out 0-length prompt boxes and only include prompts array and timing
const exportPrompts = prompts
.filter(prompt => prompt.startTime !== prompt.endTime) // Exclude 0-length prompt boxes
Expand Down Expand Up @@ -438,6 +443,10 @@ export function PromptTimeline({
URL.revokeObjectURL(url);
}, [prompts, settings]);

const handleExport = useCallback(() => {
setShowExportDialog(true);
}, []);

const handleImport = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
Expand Down Expand Up @@ -679,6 +688,16 @@ export function PromptTimeline({
<Download className="h-4 w-4 mr-1" />
Export
</Button>
<ExportDialog
open={showExportDialog}
onClose={() => setShowExportDialog(false)}
onSaveGeneration={() => {
if (onSaveGeneration) {
onSaveGeneration();
}
}}
onSaveTimeline={handleSaveTimeline}
/>
<div className="relative">
<input
type="file"
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,27 @@ export const listLoRAFiles = async (): Promise<LoRAFilesResponse> => {
const result = await response.json();
return result;
};

export const downloadRecording = async (): Promise<void> => {
const response = await fetch("/api/v1/recording/download", {
method: "GET",
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Download recording failed: ${response.status} ${response.statusText}: ${errorText}`
);
}

// Get the blob and trigger download
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `recording-${new Date().toISOString().split("T")[0]}.mp4`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
23 changes: 22 additions & 1 deletion frontend/src/pages/StreamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ import { PIPELINES } from "../data/pipelines";
import { getDefaultDenoisingSteps, getDefaultResolution } from "../lib/utils";
import type { PipelineId, LoRAConfig, LoraMergeStrategy } from "../types";
import type { PromptItem, PromptTransition } from "../lib/api";
import { checkModelStatus, downloadPipelineModels } from "../lib/api";
import {
checkModelStatus,
downloadPipelineModels,
downloadRecording,
} from "../lib/api";
import { sendLoRAScaleUpdates } from "../utils/loraHelpers";
import { toast } from "sonner";

function buildLoRAParams(
loras?: LoRAConfig[],
Expand Down Expand Up @@ -619,6 +624,21 @@ export function StreamPage() {
}
};

const handleSaveGeneration = async () => {
try {
await downloadRecording();
} catch (error) {
console.error("Error downloading recording:", error);
toast.error("Error downloading recording", {
description:
error instanceof Error
? error.message
: "An error occurred while downloading the recording",
duration: 5000,
});
}
};

return (
<div className="h-screen flex flex-col bg-background">
{/* Header */}
Expand Down Expand Up @@ -803,6 +823,7 @@ export function StreamPage() {
onTimelineCurrentTimeChange={handleTimelineCurrentTimeChange}
onTimelinePlayingChange={handleTimelinePlayingChange}
isDownloading={isDownloading}
onSaveGeneration={handleSaveGeneration}
/>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ classifiers = [
dependencies = [
"aiortc>=1.13.0",
"fastapi>=0.116.1",
"ffmpeg-python>=0.2.0",
"httpx>=0.28.1",
"twilio>=9.8.0",
"uvicorn>=0.35.0",
Expand Down
46 changes: 45 additions & 1 deletion src/scope/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import torch
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
Expand All @@ -31,6 +31,11 @@
)
from .models_config import ensure_models_dir, get_models_dir, models_are_downloaded
from .pipeline_manager import PipelineManager
from .recording import (
cleanup_recording_files,
cleanup_temp_file,
get_recording_for_download,
)
from .schema import (
HardwareInfoResponse,
HealthResponse,
Expand Down Expand Up @@ -190,6 +195,9 @@ async def lifespan(app: FastAPI):
logger.error(error_msg)
sys.exit(1)

# Clean up recording files from previous sessions (in case of crashes)
cleanup_recording_files()

# Log logs directory
logs_dir = get_logs_dir()
logger.info(f"Logs directory: {logs_dir}")
Expand Down Expand Up @@ -331,6 +339,42 @@ async def handle_webrtc_offer(
raise HTTPException(status_code=500, detail=str(e)) from e


@app.get("/api/v1/recording/download")
async def download_recording(
background_tasks: BackgroundTasks,
webrtc_manager: WebRTCManager = Depends(get_webrtc_manager),
):
"""Download the recording file for the active session.
This will finalize the current recording and create a copy for download,
then continue recording with a new file."""
try:
download_file = await get_recording_for_download(webrtc_manager)
if not download_file or not Path(download_file).exists():
raise HTTPException(
status_code=404,
detail="Recording file not available",
)

# Schedule cleanup of the temp file after download
background_tasks.add_task(cleanup_temp_file, download_file)

# Generate filename with datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"recording-{timestamp}.mp4"

# Return the file for download
return FileResponse(
download_file,
media_type="video/mp4",
filename=filename,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error downloading recording: {e}")
raise HTTPException(status_code=500, detail=str(e)) from e


class ModelStatusResponse(BaseModel):
downloaded: bool

Expand Down
Loading
Loading