diff --git a/backend/migrate_project_submission_config.py b/backend/migrate_project_submission_config.py
new file mode 100644
index 0000000..2fa79b6
--- /dev/null
+++ b/backend/migrate_project_submission_config.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""
+Migration script to add the submission_config_by_task_type column to the projects table.
+
+Usage:
+ python migrate_project_submission_config.py
+"""
+
+import sqlite3
+import sys
+from pathlib import Path
+
+
+def get_database_path():
+ """Get the database path, trying multiple possible locations."""
+ possible_paths = [
+ "vfx_project_management.db", # Primary database
+ "database.db",
+ "../vfx_project_management.db"
+ ]
+
+ for path in possible_paths:
+ if Path(path).exists():
+ return path
+
+ return "vfx_project_management.db"
+
+
+def check_column_exists(cursor, table_name, column_name):
+ """Check if a column exists in a table."""
+ cursor.execute(f"PRAGMA table_info({table_name})")
+ columns = [column[1] for column in cursor.fetchall()]
+ return column_name in columns
+
+
+def migrate_database():
+ """Add submission_config_by_task_type column to the projects table."""
+ db_path = get_database_path()
+ print(f"Using database: {db_path}")
+
+ conn = None
+ try:
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
+ if not cursor.fetchone():
+ print("Projects table not found. Creating new database schema...")
+ conn.close()
+ return
+
+ if check_column_exists(cursor, "projects", "submission_config_by_task_type"):
+ print("Column submission_config_by_task_type already exists, skipping...")
+ else:
+ print("Adding column: submission_config_by_task_type")
+ cursor.execute("ALTER TABLE projects ADD COLUMN submission_config_by_task_type JSON")
+
+ conn.commit()
+
+ cursor.execute("SELECT COUNT(*) FROM projects")
+ project_count = cursor.fetchone()[0]
+ print(f"Migration completed successfully! {project_count} projects unaffected (column defaults to NULL).")
+
+ except sqlite3.Error as e:
+ print(f"Database error: {e}")
+ if conn:
+ conn.rollback()
+ sys.exit(1)
+ except Exception as e:
+ print(f"Unexpected error: {e}")
+ if conn:
+ conn.rollback()
+ sys.exit(1)
+ finally:
+ if conn:
+ conn.close()
+
+
+if __name__ == "__main__":
+ print("VFX Project Management - Submission Configuration Migration")
+ print("=" * 60)
+
+ migrate_database()
+
+ print("\nMigration completed successfully!")
diff --git a/backend/models/project.py b/backend/models/project.py
index f2a721d..b302ceb 100644
--- a/backend/models/project.py
+++ b/backend/models/project.py
@@ -53,7 +53,10 @@ class Project(Base):
# Custom task statuses
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
-
+
+ # Submission configuration per task type
+ submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag per task type
+
# Project thumbnail
thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image
diff --git a/backend/routers/projects.py b/backend/routers/projects.py
index 66f77dd..e448a25 100644
--- a/backend/routers/projects.py
+++ b/backend/routers/projects.py
@@ -16,6 +16,7 @@ from schemas.project import (
ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS,
ProjectSettings, ProjectSettingsUpdate, DEFAULT_ASSET_TASKS, DEFAULT_SHOT_TASKS
)
+from schemas.submission_config import ProjectSubmissionConfig, SubmissionTypeConfig
from utils.auth import get_current_user, require_role, get_current_user_from_token
router = APIRouter()
@@ -1077,6 +1078,87 @@ async def delete_custom_task_type(
return _build_all_task_types_response(db_project)
+# Submission Configuration Endpoints
+
+@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
+async def get_project_submission_config(
+ project_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user_with_db)
+):
+ """Get the project's per-task-type submission configuration"""
+ project = db.query(Project).filter(Project.id == project_id).first()
+
+ if not project:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Project not found"
+ )
+
+ if current_user.role == UserRole.ARTIST:
+ member = db.query(ProjectMember).filter(
+ ProjectMember.project_id == project_id,
+ ProjectMember.user_id == current_user.id
+ ).first()
+ if not member:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Access denied to this project"
+ )
+
+ config_data = project.submission_config_by_task_type
+ if isinstance(config_data, str):
+ try:
+ config_data = json.loads(config_data)
+ except (json.JSONDecodeError, TypeError):
+ config_data = {}
+
+ return ProjectSubmissionConfig(config_by_task_type=config_data or {})
+
+
+@router.put("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
+async def update_project_submission_config(
+ project_id: int,
+ submission_config: ProjectSubmissionConfig,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_coordinator_or_admin)
+):
+ """Replace the project's per-task-type submission configuration"""
+ db_project = db.query(Project).filter(Project.id == project_id).first()
+
+ if not db_project:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Project not found"
+ )
+
+ all_types_response = _build_all_task_types_response(db_project)
+ valid_task_types = set(all_types_response.asset_task_types) | set(all_types_response.shot_task_types)
+ for task_type in submission_config.config_by_task_type.keys():
+ if task_type not in valid_task_types:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"'{task_type}' is not a valid task type for this project"
+ )
+
+ db_project.submission_config_by_task_type = {
+ task_type: config.dict()
+ for task_type, config in submission_config.config_by_task_type.items()
+ }
+
+ try:
+ db.commit()
+ db.refresh(db_project)
+ except Exception:
+ db.rollback()
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to update submission configuration"
+ )
+
+ return ProjectSubmissionConfig(config_by_task_type=db_project.submission_config_by_task_type or {})
+
+
# Project Thumbnail Management Endpoints
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py
index 3718b55..7d33057 100644
--- a/backend/routers/tasks.py
+++ b/backend/routers/tasks.py
@@ -1567,7 +1567,7 @@ async def submit_work(
# Validate file using file handler
file_handler.validate_file(file, file_handler.MAX_SUBMISSION_SIZE, db)
-
+
# Get next version number
latest_submission = db.query(Submission).filter(
Submission.task_id == task_id,
diff --git a/backend/schemas/submission_config.py b/backend/schemas/submission_config.py
new file mode 100644
index 0000000..8f83451
--- /dev/null
+++ b/backend/schemas/submission_config.py
@@ -0,0 +1,103 @@
+"""
+Pydantic schemas for per-task-type submission configuration
+"""
+from pydantic import BaseModel, Field, validator
+from typing import Optional, Dict, List
+import re
+
+from utils.file_handler import file_handler
+
+# Tokens supported in a naming_pattern: {name} (shot/asset name), {task_type}, {version}
+NAMING_PATTERN_TOKEN_RE = re.compile(r'\{(name|task_type|version)\}')
+NAMING_PATTERN_ALLOWED_CHARS_RE = re.compile(r'^[A-Za-z0-9_\-\.]*$')
+
+
+MOVIE_FORMATS = {ext.lstrip('.') for ext in file_handler.MOVIE_EXTENSIONS}
+MOVIE_CODECS = {'h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform'}
+MOVIE_FRAME_RATES = {23.976, 24, 30, 48, 60}
+RESOLUTION_RE = re.compile(r'^\d+x\d+$')
+
+
+class SubmissionTypeConfig(BaseModel):
+ """Submission rules for a single task type.
+
+ All checking against these rules happens client-side (in TaskSubmissions.vue) when an
+ artist picks a file to submit - this schema only validates and stores the configuration
+ itself, it is never used to inspect an uploaded file server-side.
+ """
+ allowed_extensions: List[str] = Field(default_factory=list, description="Accepted file extensions, e.g. ['.mov', '.exr']")
+ naming_pattern: Optional[str] = Field(None, description="Filename pattern using {name}, {task_type}, {version} tokens")
+ check_naming: bool = Field(True, description="Whether the naming pattern is actively checked")
+ required: bool = Field(False, description="Whether a submission is expected for this task type (informational only)")
+ check_movie_spec: bool = Field(False, description="Whether to check video submissions against movie_resolution/movie_format/movie_codec/movie_frame_rate")
+ movie_resolution: Optional[str] = Field(None, description="Required video resolution, e.g. '1920x1080'")
+ movie_format: Optional[str] = Field(None, description="Required video format, e.g. 'mov'")
+ movie_codec: Optional[str] = Field(None, description="Required video codec, e.g. 'h264'")
+ movie_frame_rate: Optional[float] = Field(None, description="Required video frame rate, e.g. 23.976")
+
+ @validator('allowed_extensions')
+ def validate_allowed_extensions(cls, v):
+ normalized = []
+ for ext in v:
+ ext = ext.lower()
+ if not ext.startswith('.'):
+ ext = f'.{ext}'
+ if ext not in file_handler.SUPPORTED_FORMATS:
+ raise ValueError(
+ f"Extension '{ext}' is not a supported file format. "
+ f"Supported formats: {', '.join(sorted(file_handler.SUPPORTED_FORMATS))}"
+ )
+ normalized.append(ext)
+ return normalized
+
+ @validator('naming_pattern')
+ def validate_naming_pattern(cls, v):
+ if v is None or v == '':
+ return None
+ # Strip out known tokens, then everything left must be safe literal characters
+ stripped = NAMING_PATTERN_TOKEN_RE.sub('', v)
+ if not NAMING_PATTERN_ALLOWED_CHARS_RE.match(stripped):
+ raise ValueError(
+ 'Naming pattern may only contain letters, numbers, "_", "-", "." '
+ 'and the tokens {name}, {task_type}, {version}'
+ )
+ return v
+
+ @validator('movie_resolution')
+ def validate_movie_resolution(cls, v):
+ if v is None or v == '':
+ return None
+ if not RESOLUTION_RE.match(v):
+ raise ValueError('Movie resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
+ return v
+
+ @validator('movie_format')
+ def validate_movie_format(cls, v):
+ if v is None or v == '':
+ return None
+ v = v.lower().lstrip('.')
+ if v not in MOVIE_FORMATS:
+ raise ValueError(f'Movie format must be one of: {", ".join(sorted(MOVIE_FORMATS))}')
+ return v
+
+ @validator('movie_codec')
+ def validate_movie_codec(cls, v):
+ if v is None or v == '':
+ return None
+ v = v.lower()
+ if v not in MOVIE_CODECS:
+ raise ValueError(f'Movie codec must be one of: {", ".join(sorted(MOVIE_CODECS))}')
+ return v
+
+ @validator('movie_frame_rate')
+ def validate_movie_frame_rate(cls, v):
+ if v is None:
+ return None
+ if not any(abs(v - rate) < 0.001 for rate in MOVIE_FRAME_RATES):
+ raise ValueError(f'Movie frame rate must be one of: {", ".join(str(r) for r in sorted(MOVIE_FRAME_RATES))}')
+ return v
+
+
+class ProjectSubmissionConfig(BaseModel):
+ """Submission configuration for a project, keyed by task type"""
+ config_by_task_type: Dict[str, SubmissionTypeConfig] = Field(default_factory=dict)
diff --git a/backend/utils/file_handler.py b/backend/utils/file_handler.py
index d8d94b9..21b487e 100644
--- a/backend/utils/file_handler.py
+++ b/backend/utils/file_handler.py
@@ -21,21 +21,23 @@ class FileHandler:
# Supported VFX media formats
SUPPORTED_FORMATS = {
# Video formats
- '.mov', '.mp4', '.avi', '.mkv', '.webm',
+ '.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
# Image formats
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
# Document formats
'.pdf', '.txt', '.doc', '.docx',
# Archive formats
- '.zip', '.rar', '.7z'
+ '.zip', '.rar', '.7z',
+ # Scene formats
+ '.ma', '.usd', '.usda', '.usdc'
}
-
+
# File size limits (in bytes)
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10MB for attachments
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback)
-
+
# Movie file extensions that should use global upload limit
- MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm'}
+ MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf'}
# Thumbnail settings
THUMBNAIL_SIZE = (200, 200)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index aee832a..9d8c0fc 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.294.0",
+ "mediainfo.js": "^0.3.7",
"pinia": "^2.1.7",
"reka-ui": "^2.6.1",
"shadcn-vue": "^2.3.2",
@@ -2789,6 +2790,60 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -4053,6 +4108,15 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
@@ -4817,6 +4881,21 @@
"node": ">= 0.8"
}
},
+ "node_modules/mediainfo.js": {
+ "version": "0.3.7",
+ "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.7.tgz",
+ "integrity": "sha512-mgsmb2GrCTAguVcTohW7KrF+QXNaihrbljj75pLWDQcTTQyqfGoZG4Dy++AZ+FDVCT14LHvVGC9SXxcF5VePag==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "mediainfo.js": "dist/esm/cli.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
@@ -7595,12 +7674,70 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
+ "node_modules/yargs": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^7.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 7658737..3d64fba 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,6 +18,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.294.0",
+ "mediainfo.js": "^0.3.7",
"pinia": "^2.1.7",
"reka-ui": "^2.6.1",
"shadcn-vue": "^2.3.2",
diff --git a/frontend/src/components/project/SubmissionConfigManager.vue b/frontend/src/components/project/SubmissionConfigManager.vue
new file mode 100644
index 0000000..39de198
--- /dev/null
+++ b/frontend/src/components/project/SubmissionConfigManager.vue
@@ -0,0 +1,294 @@
+
+ Configure accepted file types and naming conventions artists must follow when submitting work, per task type Leave all unchecked to accept any supported file type
+ Tokens: Checks resolution/format/codec/frame rate for video submissions only, in-browser before uploadSubmission Configuration
+ {name} (shot/asset name), {task_type}, {version} (auto 3-digit)
+
{{ submissionConfig.naming_pattern }}
+