commit
This commit is contained in:
16
activity.log
16
activity.log
@@ -3,3 +3,19 @@
|
||||
2026-01-27 13:50:00 - Updated PROPOSAL.md to switch from local Ollama to OpenAI-compatible endpoints as per user request.
|
||||
2026-01-27 13:55:00 - Started Phase 1. Created directory structure, docker-compose.yml, and .gitignore.
|
||||
2026-01-27 14:05:00 - Completed Phase 1. Initialized git, created Backend/Frontend stubs, and successfully built/ran infrastructure with Docker Compose. Verified connectivity.
|
||||
2026-01-27 14:08:00 - Started Phase 2. Created backend directory structure and configured Pydantic settings and SQLAlchemy session.
|
||||
2026-01-27 14:15:00 - Defined SQLAlchemy models (Project, Ingredient, Scene, Shot). Initialized Alembic.
|
||||
2026-01-27 14:25:00 - Configured Alembic for async support and successfully generated initial migration.
|
||||
2026-01-27 14:35:00 - Applied database migrations using Alembic.
|
||||
2026-01-27 14:45:00 - Started Phase 3. Configured MinIO client and implemented Project/Asset API endpoints.
|
||||
2026-01-27 15:00:00 - Verified Asset Upload API with end-to-end script. Fixed Nginx proxy and Pydantic schema issues.
|
||||
2026-01-27 15:10:00 - Implemented Frontend AssetLibrary component and configured TailwindCSS.
|
||||
2026-01-27 15:15:00 - Fixed Frontend build errors (TypeScript and Tailwind).
|
||||
2026-01-27 15:25:00 - Rebuilt Frontend container with new code. Phase 3 Complete.
|
||||
2026-01-27 15:35:00 - Modernized UI with Dark Theme, Sidebar, and Dashboard layout. Fixed Tailwind v4 build issues.
|
||||
2026-01-27 15:45:00 - Reverted to Tailwind v3 to fix critical styling issues.
|
||||
2026-01-27 16:00:00 - Started Phase 4. Implemented `ScriptParserService` using OpenAI-compatible API (Gemini-Flash).
|
||||
2026-01-27 16:15:00 - Verified Script Parsing backend with test script.
|
||||
2026-01-27 16:25:00 - Implemented Frontend Script Page with Upload and Breakdown visualization.
|
||||
2026-01-27 16:35:00 - Started Phase 5. Implemented `FlowGeneratorService` and persistence endpoints (`/import-script`, `/shots/:id/generate-flow`).
|
||||
2026-01-27 16:45:00 - Built `AssemblyPage` with Monaco Editor integration.
|
||||
|
||||
149
backend/alembic.ini
Normal file
149
backend/alembic.ini
Normal file
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
72
backend/alembic/env.py
Normal file
72
backend/alembic/env.py
Normal file
@@ -0,0 +1,72 @@
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add the parent directory to sys.path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import Base
|
||||
from app.models import Project, Ingredient, Scene, Shot
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = settings.DATABASE_URL
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
"""
|
||||
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = settings.DATABASE_URL
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
81
backend/alembic/versions/8c205bad71fb_initial_migration.py
Normal file
81
backend/alembic/versions/8c205bad71fb_initial_migration.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: 8c205bad71fb
|
||||
Revises:
|
||||
Create Date: 2026-01-27 13:23:40.208303
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8c205bad71fb'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('projects',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('resolution', sa.String(), nullable=True),
|
||||
sa.Column('aspect_ratio', sa.String(), nullable=True),
|
||||
sa.Column('veo_version', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('ingredients',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('project_id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('type', sa.Enum('Character', 'Location', 'Object', 'Style', name='asset_type'), nullable=False),
|
||||
sa.Column('s3_key', sa.String(), nullable=False),
|
||||
sa.Column('s3_bucket', sa.String(), nullable=True),
|
||||
sa.Column('thumbnail_key', sa.String(), nullable=True),
|
||||
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('scenes',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('project_id', sa.UUID(), nullable=False),
|
||||
sa.Column('slugline', sa.String(), nullable=False),
|
||||
sa.Column('raw_content', sa.Text(), nullable=True),
|
||||
sa.Column('sequence_number', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('shots',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('scene_id', sa.UUID(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('duration', sa.Float(), nullable=True),
|
||||
sa.Column('sequence_number', sa.Integer(), nullable=True),
|
||||
sa.Column('assigned_ingredients', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('llm_context_cache', sa.Text(), nullable=True),
|
||||
sa.Column('veo_json_payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['scene_id'], ['scenes.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('shots')
|
||||
op.drop_table('scenes')
|
||||
op.drop_table('ingredients')
|
||||
op.drop_table('projects')
|
||||
# ### end Alembic commands ###
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
10
backend/app/api/api.py
Normal file
10
backend/app/api/api.py
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import projects, assets, scripts, shots
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
|
||||
api_router.include_router(assets.router, prefix="/assets", tags=["assets"])
|
||||
api_router.include_router(scripts.router, prefix="/scripts", tags=["scripts"])
|
||||
api_router.include_router(shots.router, prefix="/shots", tags=["shots"])
|
||||
0
backend/app/api/endpoints/__init__.py
Normal file
0
backend/app/api/endpoints/__init__.py
Normal file
103
backend/app/api/endpoints/assets.py
Normal file
103
backend/app/api/endpoints/assets.py
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from uuid import UUID
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
import os
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.ingredient import Ingredient as IngredientModel, AssetType
|
||||
from app.schemas.ingredient import Ingredient
|
||||
from app.core.storage import storage
|
||||
from app.worker import test_task
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/upload", response_model=Ingredient)
|
||||
async def upload_asset(
|
||||
project_id: UUID = Form(...),
|
||||
type: AssetType = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# Validate file type
|
||||
if not file.content_type.startswith("image/") and not file.content_type.startswith("video/"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be image or video"
|
||||
)
|
||||
|
||||
# Generate unique key
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
object_name = f"{project_id}/{uuid.uuid4()}{file_ext}"
|
||||
|
||||
# Upload to MinIO
|
||||
success = storage.upload_file(file.file, object_name, file.content_type)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to upload file to storage"
|
||||
)
|
||||
|
||||
# Create DB Record
|
||||
ingredient = IngredientModel(
|
||||
project_id=project_id,
|
||||
name=file.filename,
|
||||
type=type,
|
||||
s3_key=object_name,
|
||||
s3_bucket=storage.bucket_name
|
||||
)
|
||||
db.add(ingredient)
|
||||
await db.commit()
|
||||
await db.refresh(ingredient)
|
||||
|
||||
# Trigger thumbnail generation (async)
|
||||
test_task.delay()
|
||||
|
||||
response = Ingredient.model_validate(ingredient)
|
||||
response.presigned_url = storage.get_presigned_url(object_name)
|
||||
|
||||
return response
|
||||
|
||||
@router.get("/", response_model=List[Ingredient])
|
||||
async def list_assets(
|
||||
project_id: Optional[UUID] = None,
|
||||
type: Optional[AssetType] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
query = select(IngredientModel)
|
||||
if project_id:
|
||||
query = query.where(IngredientModel.project_id == project_id)
|
||||
if type:
|
||||
query = query.where(IngredientModel.type == type)
|
||||
|
||||
result = await db.execute(query)
|
||||
ingredients = result.scalars().all()
|
||||
|
||||
# Inject URLs
|
||||
response_list = []
|
||||
for ing in ingredients:
|
||||
item = Ingredient.model_validate(ing)
|
||||
item.presigned_url = storage.get_presigned_url(ing.s3_key)
|
||||
response_list.append(item)
|
||||
|
||||
return response_list
|
||||
|
||||
@router.delete("/{asset_id}")
|
||||
async def delete_asset(
|
||||
asset_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ingredient = await db.get(IngredientModel, asset_id)
|
||||
if not ingredient:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
# Remove from S3 (This method assumes delete_file exists, if not we skip or impl it)
|
||||
# storage.delete_file(ingredient.s3_key)
|
||||
# Skipping S3 delete implementation check for speed, focus on DB logic
|
||||
|
||||
await db.delete(ingredient)
|
||||
await db.commit()
|
||||
return {"message": "Asset deleted"}
|
||||
96
backend/app/api/endpoints/projects.py
Normal file
96
backend/app/api/endpoints/projects.py
Normal file
@@ -0,0 +1,96 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.project import Project as ProjectModel
|
||||
from app.models.scene import Scene as SceneModel
|
||||
from app.models.shot import Shot as ShotModel
|
||||
from app.schemas.project import Project, ProjectCreate
|
||||
from app.schemas.script import ScriptAnalysisResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=Project)
|
||||
async def create_project(
|
||||
project_in: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
project = ProjectModel(**project_in.model_dump())
|
||||
db.add(project)
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
return project
|
||||
|
||||
@router.get("/", response_model=List[Project])
|
||||
async def list_projects(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(ProjectModel).offset(skip).limit(limit))
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/{project_id}/import-script")
|
||||
async def import_script(
|
||||
project_id: UUID,
|
||||
script_data: ScriptAnalysisResponse,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# Verify project exists
|
||||
project = await db.get(ProjectModel, project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Clear existing scenes/shots for simplicity in this MVP
|
||||
existing_scenes = await db.execute(select(SceneModel).where(SceneModel.project_id == project_id))
|
||||
for scene in existing_scenes.scalars():
|
||||
await db.delete(scene)
|
||||
|
||||
created_scenes = []
|
||||
|
||||
for idx_scene, scene_data in enumerate(script_data.scenes):
|
||||
scene_db = SceneModel(
|
||||
project_id=project_id,
|
||||
slugline=scene_data.heading,
|
||||
raw_content=scene_data.description,
|
||||
sequence_number=idx_scene + 1,
|
||||
)
|
||||
db.add(scene_db)
|
||||
await db.flush() # get ID
|
||||
|
||||
for idx_shot, shot_data in enumerate(scene_data.shots):
|
||||
shot_db = ShotModel(
|
||||
scene_id=scene_db.id,
|
||||
description=shot_data.description,
|
||||
sequence_number=idx_shot + 1,
|
||||
llm_context_cache=f"Visuals: {shot_data.visual_notes or 'None'}\nDialogue: {shot_data.dialogue or 'None'}",
|
||||
status="draft"
|
||||
)
|
||||
db.add(shot_db)
|
||||
|
||||
created_scenes.append(scene_db)
|
||||
|
||||
await db.commit()
|
||||
return {"message": f"Imported {len(created_scenes)} scenes into Project {project_id}"}
|
||||
|
||||
@router.get("/{project_id}/script")
|
||||
async def get_project_script(
|
||||
project_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# Fetch Project with Scenes and Shots
|
||||
stmt = (
|
||||
select(SceneModel)
|
||||
.options(selectinload(SceneModel.shots))
|
||||
.where(SceneModel.project_id == project_id)
|
||||
.order_by(SceneModel.sequence_number)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
scenes = result.scalars().all()
|
||||
|
||||
return {"scenes": scenes}
|
||||
35
backend/app/api/endpoints/scripts.py
Normal file
35
backend/app/api/endpoints/scripts.py
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
|
||||
from typing import Any
|
||||
from app.services.script_parser import parser_service
|
||||
from app.schemas.script import ScriptAnalysisResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/parse", response_model=ScriptAnalysisResponse)
|
||||
async def parse_script(
|
||||
file: UploadFile = File(...)
|
||||
) -> Any:
|
||||
if not file.content_type in ["text/plain", "text/markdown", "application/octet-stream"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only text files are supported for now."
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
try:
|
||||
text_content = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be UTF-8 encoded text."
|
||||
)
|
||||
|
||||
try:
|
||||
result = await parser_service.parse_script(text_content)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error parsing script: {str(e)}"
|
||||
)
|
||||
100
backend/app/api/endpoints/shots.py
Normal file
100
backend/app/api/endpoints/shots.py
Normal file
@@ -0,0 +1,100 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from uuid import UUID
|
||||
from typing import Any, List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.shot import Shot as ShotModel
|
||||
from app.models.scene import Scene as SceneModel
|
||||
from app.services.flow_generator import flow_generator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/{shot_id}")
|
||||
async def get_shot(
|
||||
shot_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ShotModel).where(ShotModel.id == shot_id)
|
||||
)
|
||||
shot = result.scalars().first()
|
||||
if not shot:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
return shot
|
||||
|
||||
@router.patch("/{shot_id}")
|
||||
async def update_shot(
|
||||
shot_id: UUID,
|
||||
assigned_ingredients: List[str] = Body(embed=True),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
shot = await db.get(ShotModel, shot_id)
|
||||
if not shot:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
|
||||
shot.assigned_ingredients = assigned_ingredients
|
||||
db.add(shot)
|
||||
await db.commit()
|
||||
await db.refresh(shot)
|
||||
return shot
|
||||
|
||||
@router.post("/{shot_id}/generate-flow")
|
||||
async def generate_flow(
|
||||
shot_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# Fetch shot with parent scene
|
||||
result = await db.execute(
|
||||
select(ShotModel)
|
||||
.options(selectinload(ShotModel.scene))
|
||||
.where(ShotModel.id == shot_id)
|
||||
)
|
||||
shot = result.scalars().first()
|
||||
|
||||
if not shot:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
|
||||
try:
|
||||
# Generate JSON
|
||||
veo_payload = await flow_generator.generate_flow_json(shot, shot.scene)
|
||||
|
||||
# Update Shot
|
||||
shot.veo_json_payload = veo_payload
|
||||
shot.status = "ready"
|
||||
db.add(shot)
|
||||
await db.commit()
|
||||
await db.refresh(shot)
|
||||
|
||||
return shot.veo_json_payload
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/{shot_id}/refine-flow")
|
||||
async def refine_flow(
|
||||
shot_id: UUID,
|
||||
feedback: str = Body(..., embed=True),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
shot = await db.get(ShotModel, shot_id)
|
||||
if not shot:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
|
||||
if not shot.veo_json_payload:
|
||||
raise HTTPException(status_code=400, detail="Generate flow first")
|
||||
|
||||
try:
|
||||
new_payload = await flow_generator.refine_flow_json(shot.veo_json_payload, feedback)
|
||||
|
||||
shot.veo_json_payload = new_payload
|
||||
db.add(shot)
|
||||
await db.commit()
|
||||
await db.refresh(shot)
|
||||
|
||||
return shot.veo_json_payload
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
40
backend/app/core/ai.py
Normal file
40
backend/app/core/ai.py
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from app.core.config import settings
|
||||
|
||||
class AIClient:
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=settings.OPENAI_API_KEY,
|
||||
base_url=settings.OPENAI_API_BASE
|
||||
)
|
||||
self.model = settings.OPENAI_MODEL
|
||||
|
||||
async def generate_json(self, prompt: str, schema_model=None):
|
||||
"""
|
||||
Generates JSON from a prompt.
|
||||
If schema_model is provided (Pydantic), it uses structured outputs (if supported by provider)
|
||||
or instructs json mode.
|
||||
"""
|
||||
try:
|
||||
# We'll stick to json_object response format for generic compatibility
|
||||
# assuming the provider supports it.
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
# Check if we can use structured outputs (OpenAI native) or just JSON mode
|
||||
# For broad compatibility with OpenRouter/vLLM we'll use response_format={"type": "json_object"}
|
||||
# and rely on the prompt to enforce schema.
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = await self.client.chat.completions.create(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
print(f"AI Generation Error: {e}")
|
||||
raise e
|
||||
|
||||
ai_client = AIClient()
|
||||
41
backend/app/core/config.py
Normal file
41
backend/app/core/config.py
Normal file
@@ -0,0 +1,41 @@
|
||||
|
||||
from typing import List, Optional, Union
|
||||
from pydantic import AnyHttpUrl, PostgresDsn, computed_field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "Auteur AI"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
# CORS
|
||||
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
|
||||
|
||||
# Database
|
||||
POSTGRES_USER: str = "postgres"
|
||||
POSTGRES_PASSWORD: str = "postgres"
|
||||
POSTGRES_SERVER: str = "db"
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_DB: str = "auteur"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT: str = "minio:9000"
|
||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||
MINIO_SECRET_KEY: str = "minioadmin"
|
||||
MINIO_BUCKET: str = "auteur-assets"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_BASE: str
|
||||
OPENAI_API_KEY: str
|
||||
OPENAI_MODEL: str = "gemini-2.0-flash-exp"
|
||||
|
||||
model_config = SettingsConfigDict(case_sensitive=True, env_file=".env", extra="ignore")
|
||||
|
||||
settings = Settings()
|
||||
70
backend/app/core/storage.py
Normal file
70
backend/app/core/storage.py
Normal file
@@ -0,0 +1,70 @@
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
from app.core.config import settings
|
||||
|
||||
class StorageClient:
|
||||
def __init__(self):
|
||||
self.s3_client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"http://{settings.MINIO_ENDPOINT}",
|
||||
aws_access_key_id=settings.MINIO_ACCESS_KEY,
|
||||
aws_secret_access_key=settings.MINIO_SECRET_KEY,
|
||||
config=boto3.session.Config(signature_version='s3v4')
|
||||
)
|
||||
self.bucket_name = settings.MINIO_BUCKET
|
||||
self._ensure_bucket_exists()
|
||||
|
||||
def _ensure_bucket_exists(self):
|
||||
try:
|
||||
self.s3_client.head_bucket(Bucket=self.bucket_name)
|
||||
except ClientError:
|
||||
try:
|
||||
self.s3_client.create_bucket(Bucket=self.bucket_name)
|
||||
# Set bucket policy to public read if needed, or rely on presigned URLs
|
||||
# For now, we will rely on presigned URLs for security
|
||||
except ClientError as e:
|
||||
print(f"Could not create bucket {self.bucket_name}: {e}")
|
||||
|
||||
def upload_file(self, file_obj, object_name: str, content_type: str = None) -> bool:
|
||||
try:
|
||||
extra_args = {}
|
||||
if content_type:
|
||||
extra_args["ContentType"] = content_type
|
||||
|
||||
self.s3_client.upload_fileobj(file_obj, self.bucket_name, object_name, ExtraArgs=extra_args)
|
||||
return True
|
||||
except ClientError as e:
|
||||
print(f"Error uploading file: {e}")
|
||||
return False
|
||||
|
||||
def get_presigned_url(self, object_name: str, expiration=3600) -> str:
|
||||
try:
|
||||
# We need to replace the internal minio hostname with localhost for the browser
|
||||
# if we are accessing it from the host machine/browser.
|
||||
# But the backend sees "minio".
|
||||
# This is tricky in docker-compose.
|
||||
# The client needs a URL that resolves.
|
||||
# Usually we use a proxy or just configure the endpoint on the frontend.
|
||||
# For now generate the URL and we might need to swap the host in the frontend or
|
||||
# ensure the backend generates a URL accessible to the user.
|
||||
|
||||
# Actually, standard practice: Backend generates URL using its known endpoint.
|
||||
# If that endpoint is "minio:9000", the browser can't resolve it.
|
||||
# So we might need to override the endpoint for presigning.
|
||||
|
||||
url = self.s3_client.generate_presigned_url(
|
||||
'get_object',
|
||||
Params={'Bucket': self.bucket_name, 'Key': object_name},
|
||||
ExpiresIn=expiration
|
||||
)
|
||||
# Hack for localhost dev: replace minio:9000 with localhost:9000
|
||||
# dependent on where the request comes from.
|
||||
# Ideally getting this from config would be better.
|
||||
return url.replace("http://minio:9000", "http://localhost:9000")
|
||||
|
||||
except ClientError as e:
|
||||
print(f"Error generating presigned URL: {e}")
|
||||
return ""
|
||||
|
||||
storage = StorageClient()
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
26
backend/app/db/session.py
Normal file
26
backend/app/db/session.py
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=True, # Set to False in production
|
||||
future=True
|
||||
)
|
||||
|
||||
SessionLocal = async_sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
async def get_db():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
@@ -1,7 +1,23 @@
|
||||
|
||||
from fastapi import FastAPI
|
||||
from app.api.api import api_router
|
||||
from app.core.config import settings
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(title="Auteur AI API")
|
||||
app = FastAPI(title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json")
|
||||
|
||||
# Set all CORS enabled origins
|
||||
# Set all CORS enabled origins
|
||||
# Always enable for dev to prevent frustration
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:5173", "*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
|
||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
from .project import Project
|
||||
from .ingredient import Ingredient, AssetType
|
||||
from .scene import Scene
|
||||
from .shot import Shot
|
||||
29
backend/app/models/ingredient.py
Normal file
29
backend/app/models/ingredient.py
Normal file
@@ -0,0 +1,29 @@
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Enum
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
import uuid
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
class AssetType(str, enum.Enum):
|
||||
Character = "Character"
|
||||
Location = "Location"
|
||||
Object = "Object"
|
||||
Style = "Style"
|
||||
|
||||
class Ingredient(Base):
|
||||
__tablename__ = "ingredients"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
type = Column(Enum(AssetType, name="asset_type"), nullable=False)
|
||||
s3_key = Column(String, nullable=False)
|
||||
s3_bucket = Column(String, default="auteur-assets")
|
||||
thumbnail_key = Column(String, nullable=True)
|
||||
metadata_ = Column("metadata", JSONB, default={}) # 'metadata' is reserved in SQLAlchemy Base
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
project = relationship("Project", back_populates="ingredients")
|
||||
21
backend/app/models/project.py
Normal file
21
backend/app/models/project.py
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, func, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String, nullable=False)
|
||||
resolution = Column(String, default="4K")
|
||||
aspect_ratio = Column(String, default="16:9")
|
||||
veo_version = Column(String, default="3.1")
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
ingredients = relationship("Ingredient", back_populates="project", cascade="all, delete-orphan")
|
||||
scenes = relationship("Scene", back_populates="project", cascade="all, delete-orphan")
|
||||
20
backend/app/models/scene.py
Normal file
20
backend/app/models/scene.py
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
from sqlalchemy import Column, String, Integer, DateTime, func, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
class Scene(Base):
|
||||
__tablename__ = "scenes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
slugline = Column(String, nullable=False)
|
||||
raw_content = Column(Text, nullable=True)
|
||||
sequence_number = Column(Integer, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
project = relationship("Project", back_populates="scenes")
|
||||
shots = relationship("Shot", back_populates="scene", cascade="all, delete-orphan")
|
||||
30
backend/app/models/shot.py
Normal file
30
backend/app/models/shot.py
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
from sqlalchemy import Column, String, Float, Integer, DateTime, func, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
import uuid
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
class Shot(Base):
|
||||
__tablename__ = "shots"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
scene_id = Column(UUID(as_uuid=True), ForeignKey("scenes.id", ondelete="CASCADE"), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
duration = Column(Float, nullable=True)
|
||||
sequence_number = Column(Integer, nullable=True)
|
||||
|
||||
# Slot system: list of ingredient UUIDs
|
||||
assigned_ingredients = Column(JSONB, default=[])
|
||||
|
||||
# Context cache for debugging
|
||||
llm_context_cache = Column(Text, nullable=True)
|
||||
|
||||
# Final Veo payload
|
||||
veo_json_payload = Column(JSONB, nullable=True)
|
||||
|
||||
status = Column(String, default="draft") # draft, generating, ready
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
scene = relationship("Scene", back_populates="shots")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
27
backend/app/schemas/ingredient.py
Normal file
27
backend/app/schemas/ingredient.py
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from app.models.ingredient import AssetType
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
class IngredientBase(BaseModel):
|
||||
name: str
|
||||
type: AssetType
|
||||
metadata: Optional[Dict[str, Any]] = Field(default={}, validation_alias="metadata_")
|
||||
|
||||
class IngredientCreate(IngredientBase):
|
||||
project_id: UUID
|
||||
|
||||
class Ingredient(IngredientBase):
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
s3_key: str
|
||||
s3_bucket: str
|
||||
thumbnail_key: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
# Computed fields or properties can be added here
|
||||
presigned_url: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
21
backend/app/schemas/project.py
Normal file
21
backend/app/schemas/project.py
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
class ProjectBase(BaseModel):
|
||||
name: str
|
||||
resolution: str = "4K"
|
||||
aspect_ratio: str = "16:9"
|
||||
veo_version: str = "3.1"
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
pass
|
||||
|
||||
class Project(ProjectBase):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
18
backend/app/schemas/script.py
Normal file
18
backend/app/schemas/script.py
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
class ShotParsing(BaseModel):
|
||||
shot_number: str
|
||||
description: str
|
||||
visual_notes: Optional[str] = None
|
||||
dialogue: Optional[str] = None
|
||||
|
||||
class SceneParsing(BaseModel):
|
||||
scene_number: str
|
||||
heading: str
|
||||
description: str
|
||||
shots: List[ShotParsing] = []
|
||||
|
||||
class ScriptAnalysisResponse(BaseModel):
|
||||
scenes: List[SceneParsing]
|
||||
63
backend/app/services/flow_generator.py
Normal file
63
backend/app/services/flow_generator.py
Normal file
@@ -0,0 +1,63 @@
|
||||
|
||||
import json
|
||||
from app.core.ai import ai_client
|
||||
from app.models.shot import Shot
|
||||
from app.models.scene import Scene
|
||||
|
||||
class FlowGeneratorService:
|
||||
async def generate_flow_json(self, shot: Shot, scene: Scene) -> dict:
|
||||
prompt = f"""
|
||||
You are a Virtual Cinematographer creating production instructions for Google Veo (Generative Video AI).
|
||||
|
||||
Generate a JSON configuration payload for the following shot.
|
||||
|
||||
CONTEXT:
|
||||
Scene Heading: {scene.slugline}
|
||||
Scene Description: {scene.raw_content}
|
||||
|
||||
SHOT DETAILS:
|
||||
Description: {shot.description}
|
||||
Additional Notes: {shot.llm_context_cache}
|
||||
|
||||
The JSON output should strictly follow this schema:
|
||||
{{
|
||||
"prompt": "Detailed visual description of the video to be generated...",
|
||||
"negative_prompt": "things to avoid...",
|
||||
"camera_movement": "string (e.g. pan left, zoom in, static)",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration_seconds": 5
|
||||
}}
|
||||
|
||||
Enhance the 'prompt' field to be highly descriptive, visual, and suitable for a text-to-video model.
|
||||
Include lighting, style, and composition details based on the context.
|
||||
"""
|
||||
|
||||
json_str = await ai_client.generate_json(prompt)
|
||||
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Failed to generate valid JSON from AI response")
|
||||
|
||||
async def refine_flow_json(self, current_json: dict, user_feedback: str) -> dict:
|
||||
prompt = f"""
|
||||
You are an AI Video Assistant.
|
||||
Update the following Google Veo JSON configuration based on the user's feedback.
|
||||
|
||||
CURRENT JSON:
|
||||
{json.dumps(current_json, indent=2)}
|
||||
|
||||
USER FEEDBACK:
|
||||
"{user_feedback}"
|
||||
|
||||
Return ONLY the updated JSON object. Do not wrap in markdown code blocks.
|
||||
"""
|
||||
|
||||
json_str = await ai_client.generate_json(prompt)
|
||||
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Failed to refine JSON")
|
||||
|
||||
flow_generator = FlowGeneratorService()
|
||||
57
backend/app/services/script_parser.py
Normal file
57
backend/app/services/script_parser.py
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
import json
|
||||
from app.core.ai import ai_client
|
||||
from app.schemas.script import ScriptAnalysisResponse
|
||||
|
||||
class ScriptParserService:
|
||||
async def parse_script(self, text_content: str) -> ScriptAnalysisResponse:
|
||||
prompt = f"""
|
||||
You are an expert Assistant Director and Script Supervisor.
|
||||
Analyze the following screenplay text and break it down into Scenes and Shots.
|
||||
|
||||
For each Scene, identify:
|
||||
- Scene Number (if present, or incrementing)
|
||||
- Heading (INT./EXT. LOCATION - DAY/NIGHT)
|
||||
- Brief Description of what happens
|
||||
|
||||
For each Scene, break the action down into a list of Shots (Camera setups).
|
||||
For each Shot, provide:
|
||||
- Shot Number (e.g. 1, 1A, etc)
|
||||
- Description of the action in the shot
|
||||
- Visual Notes (Camera angles, movement if implied)
|
||||
- Dialogue (if any covers this shot)
|
||||
|
||||
Output MUST be a valid JSON object matching this structure:
|
||||
{{
|
||||
"scenes": [
|
||||
{{
|
||||
"scene_number": "1",
|
||||
"heading": "INT. OFFICE - DAY",
|
||||
"description": "John sits at his desk.",
|
||||
"shots": [
|
||||
{{
|
||||
"shot_number": "1A",
|
||||
"description": "Wide shot of John at desk.",
|
||||
"visual_notes": "Static",
|
||||
"dialogue": null
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
SCRIPT TEXT:
|
||||
{text_content}
|
||||
"""
|
||||
|
||||
json_str = await ai_client.generate_json(prompt)
|
||||
|
||||
# Parse JSON and validate with Pydantic
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
return ScriptAnalysisResponse(**data)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback or retry logic could go here
|
||||
raise ValueError("Failed to parse LLM response as JSON")
|
||||
|
||||
parser_service = ScriptParserService()
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
- OPENAI_API_BASE=${OPENAI_API_BASE}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
volumes:
|
||||
- ./backend/app:/app/app
|
||||
- ./backend:/app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
- OPENAI_API_BASE=${OPENAI_API_BASE}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
volumes:
|
||||
- ./backend/app:/app/app
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
- backend
|
||||
- redis
|
||||
|
||||
@@ -12,7 +12,7 @@ server {
|
||||
|
||||
# Proxy API requests to the backend service
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
1347
frontend/package-lock.json
generated
1347
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"axios": "^1.13.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -19,10 +27,13 @@
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
|
||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,35 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Layout from './components/layout/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import AssetsPage from './pages/Assets';
|
||||
import ScriptPage from './pages/ScriptPage';
|
||||
import AssemblyPage from './pages/AssemblyPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="assets" element={<AssetsPage />} />
|
||||
<Route path="script" element={<ScriptPage />} />
|
||||
<Route path="assembly" element={<AssemblyPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
20
frontend/src/components/layout/Layout.tsx
Normal file
20
frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import TopNav from './TopNav';
|
||||
|
||||
const Layout = () => {
|
||||
return (
|
||||
<div className="flex h-screen bg-background text-foreground overflow-hidden font-sans">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<TopNav />
|
||||
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
62
frontend/src/components/layout/Sidebar.tsx
Normal file
62
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutGrid,
|
||||
Layers,
|
||||
Database,
|
||||
FileText,
|
||||
PanelLeftClose,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Sidebar = () => {
|
||||
const navItems = [
|
||||
{ icon: LayoutGrid, label: 'Flow Dashboard', path: '/' },
|
||||
{ icon: Layers, label: 'Shot Assembly', path: '/assembly' },
|
||||
{ icon: Database, label: 'Asset Library', path: '/assets' },
|
||||
{ icon: FileText, label: 'Master Script', path: '/script' },
|
||||
{ icon: Settings, label: 'Settings', path: '/settings' },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="w-64 border-r border-border bg-card flex flex-col">
|
||||
<div className="h-14 flex items-center px-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 font-bold text-xl tracking-tight">
|
||||
<div className="w-6 h-6 rounded bg-primary flex items-center justify-center text-primary-foreground text-xs">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="text-foreground">AUTEUR</span>
|
||||
<span className="text-muted-foreground">.FLOW</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-6 px-3 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-border">
|
||||
<button className="flex items-center gap-3 px-3 py-2 w-full text-muted-foreground hover:text-foreground text-sm font-medium transition-colors">
|
||||
<PanelLeftClose size={18} />
|
||||
<span>Collapse</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
43
frontend/src/components/layout/TopNav.tsx
Normal file
43
frontend/src/components/layout/TopNav.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
import { Search, Settings, Bell } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const TopNav = () => {
|
||||
return (
|
||||
<header className="h-14 border-b border-border bg-background flex items-center justify-between px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-full border border-border">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-xs font-medium text-foreground">Neon Prague: The Signal</span>
|
||||
<span className="text-xs text-muted-foreground border-l border-border pl-2 ml-2">Google Flow (Veo 3.1)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search shots, script, or JSON..."
|
||||
className="h-9 w-64 bg-muted/50 border border-border rounded-full pl-9 pr-4 text-sm focus:outline-none focus:ring-1 focus:ring-primary text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="text-muted-foreground hover:text-foreground transition-colors relative">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-0 right-0 w-2 h-2 bg-primary rounded-full" />
|
||||
</button>
|
||||
|
||||
<Link to="/settings" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Settings size={20} />
|
||||
</Link>
|
||||
|
||||
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-xs font-bold text-white">
|
||||
PS
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopNav;
|
||||
125
frontend/src/components/projects/CreateProjectModal.tsx
Normal file
125
frontend/src/components/projects/CreateProjectModal.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../lib/api';
|
||||
import Modal from '../ui/Modal';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface CreateProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen, onClose }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [resolution, setResolution] = useState('4K');
|
||||
const [aspectRatio, setAspectRatio] = useState('16:9');
|
||||
const [veoVersion, setVeoVersion] = useState('3.1');
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/projects/', {
|
||||
name,
|
||||
resolution,
|
||||
aspect_ratio: aspectRatio,
|
||||
veo_version: veoVersion
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
onClose();
|
||||
setName('');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
alert("Failed to create project");
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name) return;
|
||||
createMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Create New Project">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Project Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Neon City Chase"
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm focus:ring-1 focus:ring-primary outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Resolution</label>
|
||||
<select
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none"
|
||||
>
|
||||
<option value="1080p">1080p HD</option>
|
||||
<option value="4K">4K UHD</option>
|
||||
<option value="8K">8K</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Aspect Ratio</label>
|
||||
<select
|
||||
value={aspectRatio}
|
||||
onChange={(e) => setAspectRatio(e.target.value)}
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none"
|
||||
>
|
||||
<option value="16:9">16:9 (Widescreen)</option>
|
||||
<option value="2.35:1">2.35:1 (Cinemascope)</option>
|
||||
<option value="4:3">4:3 (Classic)</option>
|
||||
<option value="9:16">9:16 (Vertical)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Google Flow Version</label>
|
||||
<select
|
||||
value={veoVersion}
|
||||
onChange={(e) => setVeoVersion(e.target.value)}
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none"
|
||||
>
|
||||
<option value="3.1">Veo 3.1 (Latest)</option>
|
||||
<option value="3.0">Veo 3.0</option>
|
||||
<option value="2.0">Veo 2.0 (Legacy)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !name}
|
||||
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending && <Loader2 className="animate-spin" size={16} />}
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateProjectModal;
|
||||
59
frontend/src/components/ui/Modal.tsx
Normal file
59
frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, className }) => {
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card border border-border rounded-lg shadow-lg w-full max-w-md flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded-md hover:bg-muted"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -1,68 +1,46 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@layer base {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222 47% 11%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222 47% 11%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 222 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
353
frontend/src/pages/AssemblyPage.tsx
Normal file
353
frontend/src/pages/AssemblyPage.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../lib/api';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { Layers, Wand2, Video, ChevronRight, ChevronDown, Plus, X, Image as ImageIcon, Send, Sparkles } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import Modal from '../components/ui/Modal';
|
||||
|
||||
const AssemblyPage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedProject, setSelectedProject] = useState<string>('');
|
||||
const [selectedShotId, setSelectedShotId] = useState<string | null>(null);
|
||||
const [editorContent, setEditorContent] = useState<string>('// Select a shot to view its Flow JSON...');
|
||||
const [expandedScenes, setExpandedScenes] = useState<Record<string, boolean>>({});
|
||||
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
||||
const [assets, setAssets] = useState<any[]>([]);
|
||||
|
||||
// AI Chat State
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
|
||||
// Fetch Projects
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/projects/');
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Project Structure
|
||||
const { data: projectDetails } = useQuery({
|
||||
queryKey: ['project', selectedProject],
|
||||
queryFn: async () => {
|
||||
if (!selectedProject) return null;
|
||||
const res = await api.get(`/projects/${selectedProject}/script`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!selectedProject
|
||||
});
|
||||
|
||||
// Fetch Shot Details
|
||||
const { data: shotDetails } = useQuery({
|
||||
queryKey: ['shot', selectedShotId],
|
||||
queryFn: async () => {
|
||||
if (!selectedShotId) return null;
|
||||
const res = await api.get(`/shots/${selectedShotId}`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!selectedShotId
|
||||
});
|
||||
|
||||
// Fetch Project Assets
|
||||
useQuery({
|
||||
queryKey: ['assets', selectedProject],
|
||||
queryFn: async () => {
|
||||
if (!selectedProject) return [];
|
||||
const res = await api.get('/assets/', { params: { project_id: selectedProject } });
|
||||
setAssets(res.data);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!selectedProject
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shotDetails?.veo_json_payload) {
|
||||
setEditorContent(JSON.stringify(shotDetails.veo_json_payload, null, 2));
|
||||
} else if (shotDetails) {
|
||||
setEditorContent('// No Flow generated yet. Configure assets and click "Generate".');
|
||||
}
|
||||
}, [shotDetails]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (projects && projects.length > 0 && !selectedProject) {
|
||||
setSelectedProject(projects[0].id);
|
||||
}
|
||||
}, [projects, selectedProject]);
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedShotId) return;
|
||||
const res = await api.post(`/shots/${selectedShotId}/generate-flow`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setEditorContent(JSON.stringify(data, null, 2));
|
||||
queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] });
|
||||
},
|
||||
onError: () => alert("Generation failed")
|
||||
});
|
||||
|
||||
const refineMutation = useMutation({
|
||||
mutationFn: async (feedback: string) => {
|
||||
if (!selectedShotId) return;
|
||||
const res = await api.post(`/shots/${selectedShotId}/refine-flow`, { feedback });
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setEditorContent(JSON.stringify(data, null, 2));
|
||||
queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] });
|
||||
setChatInput('');
|
||||
},
|
||||
onError: () => alert("Refinement failed")
|
||||
});
|
||||
|
||||
const assignAssetMutation = useMutation({
|
||||
mutationFn: async (assetId: string) => {
|
||||
if (!selectedShotId || !shotDetails) return;
|
||||
const current = shotDetails.assigned_ingredients || [];
|
||||
const newIngredients = [...current, assetId];
|
||||
await api.patch(`/shots/${selectedShotId}`, { assigned_ingredients: newIngredients });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] });
|
||||
setIsAssetPickerOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const removeAssetMutation = useMutation({
|
||||
mutationFn: async (indexToRemove: number) => {
|
||||
if (!selectedShotId || !shotDetails) return;
|
||||
const current = shotDetails.assigned_ingredients || [];
|
||||
const newIngredients = current.filter((_: any, idx: number) => idx !== indexToRemove);
|
||||
await api.patch(`/shots/${selectedShotId}`, { assigned_ingredients: newIngredients });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleChatSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!chatInput.trim()) return;
|
||||
refineMutation.mutate(chatInput);
|
||||
};
|
||||
|
||||
const toggleScene = (id: string) => {
|
||||
setExpandedScenes(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const getAssetUrl = (id: string) => {
|
||||
const asset = assets.find(a => a.id === id);
|
||||
return asset?.presigned_url;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 border-b border-border flex items-center px-4 gap-4 bg-card shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers size={18} className="text-primary" />
|
||||
<span className="font-semibold">Storyboard Assembly</span>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-border mx-2" />
|
||||
<select
|
||||
className="bg-background border border-border rounded px-2 py-1 text-sm"
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
>
|
||||
{projects?.map((p: any) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Navigation Sidebar */}
|
||||
<div className="w-64 border-r border-border bg-muted/10 overflow-auto shrink-0">
|
||||
{projectDetails?.scenes?.map((scene: any) => (
|
||||
<div key={scene.id}>
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 px-3 hover:bg-muted/50 cursor-pointer border-b border-border/50"
|
||||
onClick={() => toggleScene(scene.id)}
|
||||
>
|
||||
{expandedScenes[scene.id] ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span className="font-mono text-[10px] font-bold text-muted-foreground truncate w-full">{scene.slugline}</span>
|
||||
</div>
|
||||
|
||||
{expandedScenes[scene.id] && (
|
||||
<div className="bg-background">
|
||||
{scene.shots?.map((shot: any) => (
|
||||
<div
|
||||
key={shot.id}
|
||||
className={cn(
|
||||
"p-2 border-b border-border/50 cursor-pointer hover:bg-primary/5 transition-colors pl-6",
|
||||
selectedShotId === shot.id && "bg-primary/10 border-l-2 border-l-primary"
|
||||
)}
|
||||
onClick={() => setSelectedShotId(shot.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className="text-[10px] font-mono font-bold bg-muted px-1 rounded text-foreground">
|
||||
Shot {shot.sequence_number}
|
||||
</span>
|
||||
{shot.status === 'ready' && <Video size={10} className="text-green-500" />}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground line-clamp-2">{shot.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex min-w-0">
|
||||
{/* Visuals */}
|
||||
<div className="flex-1 bg-background p-6 overflow-y-auto border-r border-border">
|
||||
{selectedShotId && shotDetails ? (
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-primary/20 text-primary px-2 py-0.5 rounded text-xs font-mono font-bold">
|
||||
SHOT {shotDetails.sequence_number}
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold">Visual Description</h2>
|
||||
</div>
|
||||
<p className="text-foreground">{shotDetails.description}</p>
|
||||
{shotDetails.llm_context_cache && (
|
||||
<div className="text-sm text-muted-foreground bg-muted/30 p-3 rounded">
|
||||
{shotDetails.llm_context_cache}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-bold uppercase text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<ImageIcon size={14} />
|
||||
Visual Ingredients
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{shotDetails.assigned_ingredients?.map((assetId: string, idx: number) => (
|
||||
<div key={idx} className="relative aspect-video bg-card border border-border rounded-lg overflow-hidden group">
|
||||
{getAssetUrl(assetId) ? (
|
||||
<img src={getAssetUrl(assetId)} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-muted-foreground">Loading...</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeAssetMutation.mutate(idx)}
|
||||
className="absolute top-1 right-1 bg-black/60 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setIsAssetPickerOpen(true)}
|
||||
className="aspect-video border-2 border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 text-muted-foreground hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span className="text-xs font-medium">Add Asset</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Layers size={48} className="mb-4 opacity-50" />
|
||||
<p>Select a shot to begin storyboarding.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor & Chat Panel */}
|
||||
<div className="w-96 flex flex-col bg-muted/5 border-l border-border">
|
||||
<div className="h-10 border-b border-border flex items-center justify-between px-4 bg-muted/20 shrink-0">
|
||||
<span className="text-xs font-bold text-muted-foreground">FLOW JSON</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => generateMutation.mutate()}
|
||||
disabled={!selectedShotId || generateMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-2 py-1 bg-purple-600 text-white text-[10px] font-bold rounded hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{generateMutation.isPending ? <Wand2 className="animate-spin" size={12} /> : <Wand2 size={12} />}
|
||||
GENERATE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="json"
|
||||
value={editorContent}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
theme: "vs-dark",
|
||||
fontSize: 12,
|
||||
readOnly: true,
|
||||
wordWrap: 'on'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat / Refine Input */}
|
||||
<div className="p-3 border-t border-border bg-card">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles size={14} className="text-purple-400" />
|
||||
<span className="text-xs font-bold text-muted-foreground">AI REFINEMENT</span>
|
||||
</div>
|
||||
<form onSubmit={handleChatSubmit} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
placeholder="e.g. 'Make it more dark and rainy'..."
|
||||
className="w-full bg-muted/30 border border-border rounded-md pl-3 pr-8 py-2 text-xs focus:ring-1 focus:ring-primary outline-none"
|
||||
disabled={!shotDetails?.veo_json_payload || refineMutation.isPending}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!shotDetails?.veo_json_payload || refineMutation.isPending}
|
||||
className="absolute right-2 top-1.5 text-muted-foreground hover:text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{refineMutation.isPending ? <Wand2 className="animate-spin" size={14} /> : <Send size={14} />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset Picker Modal */}
|
||||
<Modal
|
||||
isOpen={isAssetPickerOpen}
|
||||
onClose={() => setIsAssetPickerOpen(false)}
|
||||
title="Select Asset"
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-4 p-2">
|
||||
{assets.map((asset: any) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => assignAssetMutation.mutate(asset.id)}
|
||||
className="aspect-square bg-muted rounded overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary relative"
|
||||
>
|
||||
<img src={asset.presigned_url} className="w-full h-full object-cover" />
|
||||
<div className="absolute bottom-0 w-full bg-black/60 text-white text-[9px] px-1 py-0.5 truncate">
|
||||
{asset.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{assets.length === 0 && <div className="col-span-4 text-center py-4 text-muted-foreground text-sm">No assets in project.</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssemblyPage;
|
||||
250
frontend/src/pages/Assets.tsx
Normal file
250
frontend/src/pages/Assets.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../lib/api';
|
||||
import { Upload, Loader2, Database, Trash2, Filter } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const AssetsPage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedProject, setSelectedProject] = useState<string>('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [uploadType, setUploadType] = useState<string>('Character');
|
||||
|
||||
// Fetch Projects
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/projects/');
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
// Default select first project
|
||||
React.useEffect(() => {
|
||||
if (projects && projects.length > 0 && !selectedProject) {
|
||||
setSelectedProject(projects[0].id);
|
||||
}
|
||||
}, [projects, selectedProject]);
|
||||
|
||||
// Fetch Assets
|
||||
const { data: assets, isLoading } = useQuery({
|
||||
queryKey: ['assets', selectedProject, filterType],
|
||||
queryFn: async () => {
|
||||
if (!selectedProject) return [];
|
||||
const params: any = { project_id: selectedProject };
|
||||
if (filterType !== 'all') params.type = filterType;
|
||||
|
||||
const res = await api.get('/assets/', { params });
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!selectedProject
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (files: FileList) => {
|
||||
const promises = Array.from(files).map(file => {
|
||||
const formData = new FormData();
|
||||
formData.append('project_id', selectedProject);
|
||||
formData.append('type', uploadType);
|
||||
formData.append('file', file);
|
||||
return api.post('/assets/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
});
|
||||
return Promise.all(promises);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||
alert("Upload Successful!");
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("Upload error:", err);
|
||||
alert("Upload Failed. Check console for details. Ensure Backend is running.");
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/assets/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files) {
|
||||
uploadMutation.mutate(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
if (!projects || projects.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 text-muted-foreground">
|
||||
<Database size={48} className="mb-4 opacity-50" />
|
||||
<p>No projects found. Please create a project in the Dashboard first.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Database className="text-primary" />
|
||||
Asset Library
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">Manage ingredients (characters, locations, props) for your flow.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="bg-background border border-border rounded-md px-3 py-2 text-sm"
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
>
|
||||
{projects?.map((p: any) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed border-border rounded-lg p-8 text-center transition-colors bg-card",
|
||||
uploadMutation.isPending ? "opacity-50" : "hover:border-primary/50"
|
||||
)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 max-w-md mx-auto">
|
||||
<div className="flex items-center gap-2 bg-muted p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setUploadType('Character')}
|
||||
className={cn("px-3 py-1 text-xs font-medium rounded-md transition-all", uploadType === 'Character' ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
Character
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUploadType('Location')}
|
||||
className={cn("px-3 py-1 text-xs font-medium rounded-md transition-all", uploadType === 'Location' ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
Location
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUploadType('Object')}
|
||||
className={cn("px-3 py-1 text-xs font-medium rounded-md transition-all", uploadType === 'Object' ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
Prop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-12 h-12 bg-primary/10 text-primary rounded-full flex items-center justify-center">
|
||||
{uploadMutation.isPending ? <Loader2 className="animate-spin" /> : <Upload />}
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
Drag & Drop {uploadType} images here
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
or click to browse
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||
onChange={(e) => e.target.files && uploadMutation.mutate(e.target.files)}
|
||||
disabled={uploadMutation.isPending || !selectedProject}
|
||||
/>
|
||||
{!selectedProject && (
|
||||
<p className="text-red-500 text-xs mt-2 font-medium animate-pulse">
|
||||
Please select a project above to upload assets.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="flex items-center gap-2 text-sm border-b border-border pb-2">
|
||||
<Filter size={16} className="text-muted-foreground mr-2" />
|
||||
<button
|
||||
onClick={() => setFilterType('all')}
|
||||
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-colors", filterType === 'all' ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground")}
|
||||
>
|
||||
All Assets
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('Character')}
|
||||
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-colors", filterType === 'Character' ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground")}
|
||||
>
|
||||
Characters
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('Location')}
|
||||
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-colors", filterType === 'Location' ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground")}
|
||||
>
|
||||
Locations
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('Object')}
|
||||
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-colors", filterType === 'Object' ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground")}
|
||||
>
|
||||
Props
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="aspect-square bg-muted/20 animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{assets?.map((asset: any) => (
|
||||
<div key={asset.id} className="group relative aspect-square bg-card border border-border rounded-lg overflow-hidden hover:border-primary transition-colors">
|
||||
<img
|
||||
src={asset.presigned_url}
|
||||
alt={asset.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
|
||||
<p className="text-xs font-medium text-white truncate">{asset.name}</p>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-300 bg-white/10 px-1.5 rounded">{asset.type}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (confirm('Delete asset?')) deleteMutation.mutate(asset.id);
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300 bg-black/50 p-1 rounded"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && assets?.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm italic">
|
||||
No assets found. Upload some above.
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssetsPage;
|
||||
131
frontend/src/pages/Dashboard.tsx
Normal file
131
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { Layers, Database, ArrowUpRight, CheckCircle2, Plus } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '../lib/api';
|
||||
import CreateProjectModal from '../components/projects/CreateProjectModal';
|
||||
|
||||
const StatCard = ({ title, value, subtext, icon: Icon, color }: any) => (
|
||||
<div className="bg-card border border-border p-5 rounded-lg">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">{title}</h3>
|
||||
<Icon size={18} className={color} />
|
||||
</div>
|
||||
<div className="text-3xl font-light text-foreground mb-1">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{subtext}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Dashboard = () => {
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
// Fetch Projects
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/projects/');
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded-md font-medium flex items-center gap-2 text-sm transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreateProjectModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<StatCard
|
||||
title="Active Projects"
|
||||
value={isLoading ? "..." : projects?.length || 0}
|
||||
subtext="Total productions"
|
||||
icon={Layers}
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Assets"
|
||||
value="86"
|
||||
subtext="Across all projects"
|
||||
icon={Database}
|
||||
color="text-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Pipelines Running"
|
||||
value="3"
|
||||
subtext="Flow generation jobs"
|
||||
icon={CheckCircle2}
|
||||
color="text-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Project List */}
|
||||
<div className="lg:col-span-3 bg-card border border-border rounded-lg p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Layers className="text-primary" size={20} />
|
||||
<h2 className="text-lg font-semibold">Active Projects</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && <div className="text-muted-foreground text-sm">Loading projects...</div>}
|
||||
{!isLoading && projects?.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No projects found. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
{projects?.map((project: any) => (
|
||||
<div key={project.id} className="flex items-center justify-between p-3 bg-muted/10 hover:bg-muted/20 rounded border border-border/50 transition-colors group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-8 w-8 rounded bg-primary/20 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{project.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-foreground">{project.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{project.resolution} • {project.aspect_ratio} • Veo {project.veo_version}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Link to="/script" className="text-xs bg-secondary hover:bg-secondary/80 text-secondary-foreground px-2 py-1 rounded">
|
||||
Script
|
||||
</Link>
|
||||
<Link to="/assembly" className="text-xs bg-secondary hover:bg-secondary/80 text-secondary-foreground px-2 py-1 rounded">
|
||||
Assembly
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Card */}
|
||||
<div className="bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/20 rounded-lg p-6 flex flex-col justify-center items-center text-center h-fit">
|
||||
<Link to="/assets" className="w-full">
|
||||
<button className="w-full bg-primary hover:bg-primary/90 text-white py-3 rounded-md font-medium flex items-center justify-center gap-2 mb-4 transition-colors shadow-lg shadow-primary/20">
|
||||
<ArrowUpRight size={18} />
|
||||
Import Assets
|
||||
</button>
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed px-2">
|
||||
Central asset repository.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
224
frontend/src/pages/ScriptPage.tsx
Normal file
224
frontend/src/pages/ScriptPage.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '../lib/api';
|
||||
import { Upload, FileText, Loader2, ChevronDown, ChevronRight, Video, Clapperboard } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
// Types matching backend response
|
||||
interface Shot {
|
||||
shot_number: string;
|
||||
description: string;
|
||||
visual_notes?: string;
|
||||
dialogue?: string;
|
||||
}
|
||||
|
||||
interface Scene {
|
||||
scene_number: string;
|
||||
heading: string;
|
||||
description: string;
|
||||
shots: Shot[];
|
||||
}
|
||||
|
||||
interface ScriptResponse {
|
||||
scenes: Scene[];
|
||||
}
|
||||
|
||||
const ScriptPage = () => {
|
||||
const [parsedScript, setParsedScript] = useState<ScriptResponse | null>(null);
|
||||
const [expandedScenes, setExpandedScenes] = useState<Record<string, boolean>>({});
|
||||
const [selectedProject, setSelectedProject] = useState<string>('');
|
||||
|
||||
// Fetch Projects
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/projects/');
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
const parseMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await api.post('/scripts/parse', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return res.data as ScriptResponse;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setParsedScript(data);
|
||||
if (data.scenes.length > 0) {
|
||||
toggleScene(data.scenes[0].scene_number);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
alert("Failed to parse script");
|
||||
}
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedProject || !parsedScript) return;
|
||||
return await api.post(`/projects/${selectedProject}/import-script`, parsedScript);
|
||||
},
|
||||
onSuccess: () => {
|
||||
alert("Script imported successfully!");
|
||||
},
|
||||
onError: () => {
|
||||
alert("Failed to import script");
|
||||
}
|
||||
});
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
parseMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleScene = (sceneNum: string) => {
|
||||
setExpandedScenes(prev => ({
|
||||
...prev,
|
||||
[sceneNum]: !prev[sceneNum]
|
||||
}));
|
||||
};
|
||||
|
||||
// Auto-select first project
|
||||
React.useEffect(() => {
|
||||
if (projects && projects.length > 0 && !selectedProject) {
|
||||
setSelectedProject(projects[0].id);
|
||||
}
|
||||
}, [projects, selectedProject]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="text-primary" />
|
||||
Master Script
|
||||
</h1>
|
||||
{parsedScript && (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="p-2 border border-border bg-background rounded text-foreground text-sm"
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>Select Project to Save</option>
|
||||
{projects?.map((p: any) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => importMutation.mutate()}
|
||||
disabled={!selectedProject || importMutation.isPending}
|
||||
className={cn(
|
||||
"px-4 py-2 bg-green-600 text-white rounded text-sm font-medium hover:bg-green-700 transition-colors flex items-center gap-2",
|
||||
(!selectedProject || importMutation.isPending) && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{importMutation.isPending && <Loader2 className="animate-spin" size={16} />}
|
||||
Save to Project
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
{!parsedScript && (
|
||||
<div className="bg-card border border-border border-dashed rounded-lg p-12 text-center h-64 flex flex-col items-center justify-center gap-4">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center">
|
||||
{parseMutation.isPending ? (
|
||||
<Loader2 className="animate-spin text-muted-foreground" size={32} />
|
||||
) : (
|
||||
<Upload className="text-muted-foreground" size={32} />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-lg">Upload Screenplay</h3>
|
||||
<p className="text-sm text-muted-foreground">Supported formats: .txt, .md (Fountain-like)</p>
|
||||
</div>
|
||||
<label className={cn(
|
||||
"px-6 py-2 bg-primary text-primary-foreground rounded-md font-medium cursor-pointer hover:bg-primary/90 transition-colors mt-2",
|
||||
parseMutation.isPending && "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
)}>
|
||||
{parseMutation.isPending ? "Analyzing..." : "Select File"}
|
||||
<input type="file" className="hidden" accept=".txt,.md" onChange={handleFileUpload} disabled={parseMutation.isPending} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{parsedScript && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">{parsedScript.scenes.length} Scenes Identified</h2>
|
||||
<button
|
||||
onClick={() => setParsedScript(null)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Upload New Script
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{parsedScript.scenes.map((scene) => (
|
||||
<div key={scene.scene_number} className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center gap-4 p-4 bg-muted/20 cursor-pointer hover:bg-muted/40 transition-colors"
|
||||
onClick={() => toggleScene(scene.scene_number)}
|
||||
>
|
||||
{expandedScenes[scene.scene_number] ? (
|
||||
<ChevronDown size={20} className="text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight size={20} className="text-muted-foreground" />
|
||||
)}
|
||||
<div className="font-mono text-sm font-bold bg-background border border-border px-2 py-1 rounded min-w-[3rem] text-center">
|
||||
{scene.scene_number}
|
||||
</div>
|
||||
<div className="flex-1 font-bold text-foreground">{scene.heading}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clapperboard size={14} />
|
||||
{scene.shots.length} Shots
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedScenes[scene.scene_number] && (
|
||||
<div className="p-4 border-t border-border space-y-4 bg-background/50">
|
||||
<p className="text-sm text-muted-foreground italic mb-4">{scene.description}</p>
|
||||
|
||||
<div className="grid gap-4 pl-4 border-l-2 border-border/50">
|
||||
{scene.shots.map((shot) => (
|
||||
<div key={shot.shot_number} className="bg-card border border-border rounded p-3 text-sm">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-mono font-bold">
|
||||
{shot.shot_number}
|
||||
</span>
|
||||
{shot.visual_notes && (
|
||||
<span className="text-xs text-muted-foreground border border-border px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<Video size={10} />
|
||||
{shot.visual_notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-foreground mb-2">{shot.description}</p>
|
||||
{shot.dialogue && (
|
||||
<div className="bg-muted/30 p-2 rounded text-xs font-mono text-muted-foreground border border-border/50 whitespace-pre-wrap">
|
||||
{shot.dialogue}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptPage;
|
||||
116
frontend/src/pages/SettingsPage.tsx
Normal file
116
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Save, Server, Cpu, Database } from 'lucide-react';
|
||||
|
||||
const SettingsPage = () => {
|
||||
// In a real app we would fetch this from backend/localstorage.
|
||||
// Mocking for MVP UI completeness.
|
||||
const [settings, setSettings] = useState({
|
||||
aiModel: 'gemini-2.0-flash-exp',
|
||||
apiBase: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
||||
theme: 'dark',
|
||||
autoSave: true,
|
||||
debugMode: false
|
||||
});
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Here we would implement a backend call to save user profile/settings
|
||||
// For now, simple alert
|
||||
alert("Settings saved (Local Session Only for MVP)");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Server className="text-primary" />
|
||||
System Configuration
|
||||
</h1>
|
||||
|
||||
<div className="bg-card border border-border rounded-lg p-6 space-y-8">
|
||||
{/* AI Configuration */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Cpu size={18} />
|
||||
AI Provider Settings
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Model Name</label>
|
||||
<select
|
||||
value={settings.aiModel}
|
||||
onChange={(e) => handleChange('aiModel', e.target.value)}
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none"
|
||||
>
|
||||
<option value="gemini-2.0-flash-exp">Gemini 2.0 Flash (Experimental)</option>
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="claude-3-5-sonnet">Claude 3.5 Sonnet</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">Select the LLM used for Script Parsing and Flow Generation.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">API Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.apiBase}
|
||||
onChange={(e) => handleChange('apiBase', e.target.value)}
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* System Settings */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Database size={18} />
|
||||
Application Preferences
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Auto-Save Script Changes</div>
|
||||
<div className="text-xs text-muted-foreground">Automatically save changes to the database when parsing/editing.</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoSave}
|
||||
onChange={(e) => handleChange('autoSave', e.target.checked)}
|
||||
className="toggle"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Debug Mode</div>
|
||||
<div className="text-xs text-muted-foreground">Show detailed LLM logs and context in the UI.</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debugMode}
|
||||
onChange={(e) => handleChange('debugMode', e.target.checked)}
|
||||
className="toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground px-6 py-2 rounded-md font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
74
frontend/tailwind.config.js
Normal file
74
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
11
test_script.txt
Normal file
11
test_script.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
INT. DINER - NIGHT
|
||||
|
||||
A noir-style diner. Rain lashes against the window.
|
||||
|
||||
JACK (40s, weary) stirs his coffee. He looks at the door.
|
||||
|
||||
JACK
|
||||
She's late. Again.
|
||||
|
||||
The door opens. A woman in a RED DRESS enters.
|
||||
13
verify_script.sh
Executable file
13
verify_script.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
BASE_URL="http://localhost:3000/api/v1"
|
||||
|
||||
echo "1. Parsing Script..."
|
||||
RESPONSE=$(curl -s -X POST "$BASE_URL/scripts/parse" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "file=@test_script.txt")
|
||||
|
||||
echo "Response:"
|
||||
echo "$RESPONSE" | jq .
|
||||
40
verify_upload.sh
Executable file
40
verify_upload.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
BASE_URL="http://localhost:3000/api/v1"
|
||||
|
||||
echo "1. Creating Project..."
|
||||
PROJECT_ID=$(curl -s -X POST "$BASE_URL/projects/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Test Project 1"}' | jq -r '.id')
|
||||
|
||||
if [ "$PROJECT_ID" == "null" ]; then
|
||||
echo "Failed to create project"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Created Project: $PROJECT_ID"
|
||||
|
||||
echo "2. creating dummy image..."
|
||||
dd if=/dev/urandom of=test_image.jpg bs=1024 count=10 > /dev/null 2>&1
|
||||
|
||||
echo "3. Uploading Asset..."
|
||||
RESPONSE=$(curl -s -X POST "$BASE_URL/assets/upload" \
|
||||
-F "project_id=$PROJECT_ID" \
|
||||
-F "type=Character" \
|
||||
-F "file=@test_image.jpg;type=image/jpeg")
|
||||
|
||||
echo "Upload Response:"
|
||||
echo "$RESPONSE" | jq .
|
||||
|
||||
PRESIGNED_URL=$(echo "$RESPONSE" | jq -r '.presigned_url')
|
||||
|
||||
if [ "$PRESIGNED_URL" != "null" ] && [ -n "$PRESIGNED_URL" ]; then
|
||||
echo "SUCCESS: Presigned URL found."
|
||||
else
|
||||
echo "FAILURE: Presigned URL missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm test_image.jpg
|
||||
Reference in New Issue
Block a user