Add per-task-type submission configuration with client-side movie spec checks

Coordinators can configure, per task type, accepted file extensions, a
naming convention, and (for video files) required resolution/format/
codec/frame rate. All checking happens entirely client-side in the
browser before upload - the backend only stores and validates the
configuration itself, never inspects submitted files.

- backend: submission_config_by_task_type JSON column on Project,
  schemas/submission_config.py for validation, GET/PUT
  /projects/{id}/submission-config endpoints. Added .mxf/.ma/.usd(a/c)
  to the supported file formats.
- frontend: new Submissions tab in Project Settings
  (SubmissionConfigManager.vue) to configure rules per task type.
  TaskSubmissions.vue enforces them before upload: extension/naming
  checks via regex, and real video resolution/codec/frame rate
  detection via mediainfo.js (parses container metadata directly,
  so it works for formats browsers can't play natively like MXF,
  ProRes, or DNxHD).
This commit is contained in:
2026-07-19 10:38:29 +08:00
parent 762bd34f74
commit 23740af816
13 changed files with 962 additions and 13 deletions
@@ -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!")
+4 -1
View File
@@ -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
+82
View File
@@ -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)
+1 -1
View File
@@ -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,
+103
View File
@@ -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)
+7 -5
View File
@@ -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)
+137
View File
@@ -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",
+1
View File
@@ -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",
@@ -0,0 +1,294 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Submission Configuration</h3>
<p class="text-sm text-muted-foreground">Configure accepted file types and naming conventions artists must follow when submitting work, per task type</p>
</div>
</div>
<div v-if="isLoading" class="text-center py-8 text-sm text-muted-foreground">
Loading submission configuration...
</div>
<div v-else class="space-y-4">
<Card v-for="taskType in taskTypes" :key="taskType">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-base capitalize">{{ formatTaskType(taskType) }}</CardTitle>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_required`" class="text-xs text-muted-foreground">Required</Label>
<Switch
:id="`${taskType}_required`"
:model-value="getConfig(taskType).required"
@update:model-value="(val) => (getConfig(taskType).required = !!val)"
/>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div>
<Label class="text-xs">Allowed File Types</Label>
<div class="flex flex-wrap gap-3 mt-1">
<label
v-for="ext in ALLOWED_EXTENSIONS"
:key="ext"
class="flex items-center gap-1.5 text-sm"
>
<Checkbox
:model-value="getConfig(taskType).allowed_extensions.includes(ext)"
@update:model-value="(val) => toggleExtension(taskType, ext, !!val)"
/>
{{ ext }}
</label>
</div>
<p class="text-xs text-muted-foreground mt-1">Leave all unchecked to accept any supported file type</p>
</div>
<div>
<div class="flex items-center justify-between">
<Label :for="`${taskType}_pattern`" class="text-xs">Naming Pattern</Label>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_check_naming`" class="text-xs text-muted-foreground">Check</Label>
<Switch
:id="`${taskType}_check_naming`"
:model-value="getConfig(taskType).check_naming"
@update:model-value="(val) => (getConfig(taskType).check_naming = !!val)"
/>
</div>
</div>
<Input
:id="`${taskType}_pattern`"
v-model="getConfig(taskType).naming_pattern"
placeholder="e.g. {name}_{task_type}_v{version}"
class="h-8"
/>
<p class="text-xs text-muted-foreground mt-1">
Tokens: <code>{name}</code> (shot/asset name), <code>{task_type}</code>, <code>{version}</code> (auto 3-digit)
</p>
</div>
<div>
<div class="flex items-center justify-between">
<Label class="text-xs">Movie Spec</Label>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_check_movie_spec`" class="text-xs text-muted-foreground">Check</Label>
<Switch
:id="`${taskType}_check_movie_spec`"
:model-value="getConfig(taskType).check_movie_spec"
@update:model-value="(val) => (getConfig(taskType).check_movie_spec = !!val)"
/>
</div>
</div>
<div v-if="getConfig(taskType).check_movie_spec" class="grid grid-cols-4 gap-3 mt-1">
<div>
<Input
v-model="getConfig(taskType).movie_resolution"
placeholder="e.g. 1920x1080"
class="h-8"
/>
</div>
<div>
<Select
:model-value="getConfig(taskType).movie_format || undefined"
@update:model-value="(val) => (getConfig(taskType).movie_format = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="fmt in MOVIE_FORMATS" :key="fmt" :value="fmt">{{ fmt }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="getConfig(taskType).movie_codec || undefined"
@update:model-value="(val) => (getConfig(taskType).movie_codec = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Codec" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="codec in MOVIE_CODECS" :key="codec" :value="codec">{{ codec }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="getConfig(taskType).movie_frame_rate ? String(getConfig(taskType).movie_frame_rate) : undefined"
@update:model-value="(val) => (getConfig(taskType).movie_frame_rate = val ? Number(val) : null)"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Frame rate" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="rate in MOVIE_FRAME_RATES" :key="rate" :value="String(rate)">{{ rate }} fps</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p class="text-xs text-muted-foreground mt-1">Checks resolution/format/codec/frame rate for video submissions only, in-browser before upload</p>
</div>
</CardContent>
</Card>
<div v-if="taskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No task types configured for this project yet.
</div>
</div>
<div class="flex justify-end">
<Button :disabled="isLoading || isSaving" @click="onSave">
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Save Submission Configuration
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type SubmissionTypeConfig } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType'
interface Props {
projectId: number
}
const props = defineProps<Props>()
const { toast } = useToast()
// Mirrors backend's file_handler.SUPPORTED_FORMATS - kept for the picker only; the actual check happens client-side at submit time
const ALLOWED_EXTENSIONS = [
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
'.pdf', '.txt', '.doc', '.docx',
'.zip', '.rar', '.7z',
'.ma', '.usd', '.usda', '.usdc'
]
const MOVIE_FORMATS = ['mov', 'mp4', 'avi', 'mkv', 'webm', 'mxf']
const MOVIE_CODECS = ['h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform']
const MOVIE_FRAME_RATES = [23.976, 24, 30, 48, 60]
const isLoading = ref(false)
const isSaving = ref(false)
const taskTypes = ref<string[]>([])
const formState = reactive<Record<string, SubmissionTypeConfig>>({})
const formatTaskType = (taskType: string) => taskType.replace(/_/g, ' ')
const getConfig = (taskType: string): SubmissionTypeConfig => {
if (!formState[taskType]) {
formState[taskType] = {
allowed_extensions: [],
naming_pattern: '',
check_naming: true,
required: false,
check_movie_spec: false,
movie_resolution: '',
movie_format: '',
movie_codec: '',
movie_frame_rate: null
}
}
return formState[taskType]
}
const toggleExtension = (taskType: string, ext: string, checked: boolean) => {
const config = getConfig(taskType)
if (checked) {
if (!config.allowed_extensions.includes(ext)) config.allowed_extensions.push(ext)
} else {
config.allowed_extensions = config.allowed_extensions.filter(e => e !== ext)
}
}
const load = async () => {
try {
isLoading.value = true
const [allTypes, config] = await Promise.all([
customTaskTypeService.getAllTaskTypes(props.projectId),
projectService.getProjectSubmissionConfig(props.projectId)
])
taskTypes.value = Array.from(new Set([...allTypes.asset_task_types, ...allTypes.shot_task_types]))
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
formState[taskType] = {
allowed_extensions: [...cfg.allowed_extensions],
naming_pattern: cfg.naming_pattern || '',
check_naming: cfg.check_naming,
required: cfg.required,
check_movie_spec: cfg.check_movie_spec,
movie_resolution: cfg.movie_resolution || '',
movie_format: cfg.movie_format || '',
movie_codec: cfg.movie_codec || '',
movie_frame_rate: cfg.movie_frame_rate ?? null
}
}
} catch (error) {
console.error('Failed to load submission configuration:', error)
toast({
title: 'Error',
description: 'Failed to load submission configuration',
variant: 'destructive'
})
} finally {
isLoading.value = false
}
}
const onSave = async () => {
try {
isSaving.value = true
const configByTaskType: Record<string, SubmissionTypeConfig> = {}
for (const taskType of taskTypes.value) {
const config = formState[taskType]
if (!config) continue
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
|| config.required || config.check_movie_spec
if (hasConfig) {
configByTaskType[taskType] = {
allowed_extensions: config.allowed_extensions,
naming_pattern: config.naming_pattern || null,
check_naming: config.check_naming,
required: config.required,
check_movie_spec: config.check_movie_spec,
movie_resolution: config.movie_resolution || null,
movie_format: config.movie_format || null,
movie_codec: config.movie_codec || null,
movie_frame_rate: config.movie_frame_rate || null
}
}
}
await projectService.updateProjectSubmissionConfig(props.projectId, { config_by_task_type: configByTaskType })
toast({
title: 'Submission Configuration Updated',
description: 'Submission rules have been saved successfully'
})
} catch (error: any) {
console.error('Failed to save submission configuration:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to save submission configuration',
variant: 'destructive'
})
} finally {
isSaving.value = false
}
}
onMounted(load)
</script>
@@ -201,7 +201,14 @@
<!-- Submissions Tab -->
<TabsContent value="submissions" class="flex-1 m-0 overflow-hidden">
<TaskSubmissions :task-id="taskId" :submissions="submissions" @submissions-updated="loadSubmissions" />
<TaskSubmissions
:task-id="taskId"
:submissions="submissions"
:task-type="task?.task_type"
:project-id="task?.project_id"
:name="task?.shot_name || task?.asset_name"
@submissions-updated="loadSubmissions"
/>
</TabsContent>
</Tabs>
</div>
@@ -22,6 +22,25 @@
<!-- Submit Work Form (Bottom) -->
<div class="flex-shrink-0 border-t bg-background p-2">
<div class="space-y-2">
<div v-if="submissionConfig" class="text-xs text-muted-foreground space-y-0.5 px-1">
<div class="flex items-center gap-1.5">
<span v-if="submissionConfig.allowed_extensions.length > 0">
Allowed: {{ submissionConfig.allowed_extensions.join(', ') }}
</span>
<Badge v-if="submissionConfig.required" variant="outline" class="text-[10px] px-1 py-0">Required</Badge>
</div>
<div v-if="submissionConfig.naming_pattern && submissionConfig.check_naming">
Naming: <code>{{ submissionConfig.naming_pattern }}</code>
</div>
<div v-if="submissionConfig.check_movie_spec && (submissionConfig.movie_resolution || submissionConfig.movie_format || submissionConfig.movie_codec || submissionConfig.movie_frame_rate)">
Movie spec:
<span v-if="submissionConfig.movie_resolution">{{ submissionConfig.movie_resolution }}</span>
<span v-if="submissionConfig.movie_format">.{{ submissionConfig.movie_format }}</span>
<span v-if="submissionConfig.movie_codec">({{ submissionConfig.movie_codec }})</span>
<span v-if="submissionConfig.movie_frame_rate">{{ submissionConfig.movie_frame_rate }}fps</span>
</div>
</div>
<div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors">
<input
ref="fileInput"
@@ -110,7 +129,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { Upload } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
@@ -124,12 +143,18 @@ import {
} from '@/components/ui/dialog'
import SubmissionCard from './SubmissionCard.vue'
import { taskService, type Submission } from '@/services/task'
import { projectService, type SubmissionTypeConfig } from '@/services/project'
import { useToast } from '@/components/ui/toast/use-toast'
import { apiClient } from '@/services/api'
import mediaInfoFactory, { isTrackType } from 'mediainfo.js'
import mediaInfoWasmUrl from 'mediainfo.js/MediaInfoModule.wasm?url'
const props = defineProps<{
taskId: number
submissions: Submission[]
taskType?: string
projectId?: number
name?: string
}>()
const emit = defineEmits<{
@@ -144,12 +169,184 @@ const submissionNotes = ref('')
const viewerOpen = ref(false)
const selectedSubmission = ref<Submission | null>(null)
const mediaBlobUrl = ref<string | null>(null)
const submissionConfig = ref<SubmissionTypeConfig | null>(null)
async function loadSubmissionConfig() {
submissionConfig.value = null
if (!props.projectId || !props.taskType) return
try {
const config = await projectService.getProjectSubmissionConfig(props.projectId)
submissionConfig.value = config.config_by_task_type[props.taskType] || null
} catch (error) {
console.error('Failed to load submission configuration:', error)
}
}
onMounted(loadSubmissionConfig)
watch(() => [props.projectId, props.taskType], loadSubmissionConfig)
const MOVIE_EXTENSIONS = ['.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf']
let mediaInfoPromise: ReturnType<typeof mediaInfoFactory> | null = null
function getMediaInfo() {
if (!mediaInfoPromise) {
mediaInfoPromise = mediaInfoFactory({ locateFile: () => mediaInfoWasmUrl })
}
return mediaInfoPromise
}
interface VideoTrackInfo {
width: number | null
height: number | null
format: string | null
codecId: string | null
formatCommercial: string | null
frameRate: number | null
}
// Parses container/codec metadata directly from the file's bytes (no playback) - works for
// containers browsers can't play natively, e.g. MXF, or codecs like ProRes/DNxHD
async function analyzeVideoFile(file: File): Promise<VideoTrackInfo | null> {
try {
const mediainfo = await getMediaInfo()
const getSize = () => file.size
const readChunk = (chunkSize: number, offset: number) =>
new Promise<Uint8Array>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer))
reader.onerror = () => reject(reader.error)
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize))
})
const result = await mediainfo.analyzeData(getSize, readChunk)
const videoTrack = result.media?.track.find(t => isTrackType(t, 'Video'))
if (!videoTrack) return null
const track = videoTrack as unknown as Record<string, unknown>
const commercial = [track.Format_Commercial, track.Format_Commercial_IfAny]
.filter((v): v is string => typeof v === 'string')
.join(' ')
return {
width: videoTrack.Width ?? null,
height: videoTrack.Height ?? null,
format: videoTrack.Format ?? null,
codecId: videoTrack.CodecID ?? null,
formatCommercial: commercial || null,
frameRate: videoTrack.FrameRate ?? null
}
} catch (error) {
console.error('Failed to analyze video file:', error)
return null
}
}
const CODEC_PATTERNS: Record<string, RegExp> = {
h264: /\b(avc|h\.?264)\b/i,
h265: /\b(hevc|h\.?265)\b/i,
mjpeg: /\bm?jpeg\b/i,
dnxhd: /\bdnxhd\b/i,
dnxhr: /\bdnxhr\b/i,
prores: /\bprores\b/i,
uncompressed: /\b(uncompressed|raw)\b/i,
avid: /\bavid\b/i,
cineform: /\bcineform\b/i
}
function normalizeCodec(info: VideoTrackInfo): string | null {
const haystack = [info.format, info.formatCommercial, info.codecId].filter(Boolean).join(' ')
for (const [key, pattern] of Object.entries(CODEC_PATTERNS)) {
if (pattern.test(haystack)) return key
}
return null
}
// All checking happens client-side, entirely in the browser, before the file is ever uploaded
async function findSubmissionViolation(file: File): Promise<string | null> {
const config = submissionConfig.value
if (!config) return null
const filename = file.name
const dotIndex = filename.lastIndexOf('.')
const stem = dotIndex > 0 ? filename.slice(0, dotIndex) : filename
const extension = dotIndex > 0 ? filename.slice(dotIndex).toLowerCase() : ''
if (config.allowed_extensions.length > 0 && !config.allowed_extensions.includes(extension)) {
return `File type '${extension}' is not accepted for ${props.taskType} submissions. Allowed types: ${config.allowed_extensions.join(', ')}`
}
if (config.check_naming && config.naming_pattern) {
if (config.naming_pattern.includes('{name}') && !props.name) {
return null // can't resolve {name} client-side
}
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = config.naming_pattern
.split(/(\{name\}|\{task_type\}|\{version\})/)
.map(part => {
if (part === '{name}') return escapeRegex(props.name || '')
if (part === '{task_type}') return escapeRegex(props.taskType || '')
if (part === '{version}') return '\\d{3}'
return escapeRegex(part)
})
.join('')
const regex = new RegExp(`^${pattern}$`)
if (!regex.test(stem)) {
const example = config.naming_pattern
.replace('{name}', props.name || 'name')
.replace('{task_type}', props.taskType || '')
.replace('{version}', '001')
return `Filename does not match the required naming convention '${config.naming_pattern}' (e.g. '${example}${extension}')`
}
}
if (config.check_movie_spec && MOVIE_EXTENSIONS.includes(extension)) {
if (config.movie_format && extension.slice(1) !== config.movie_format.toLowerCase()) {
return `Movie format '${extension}' does not match the required format '.${config.movie_format}'`
}
if (config.movie_resolution || config.movie_codec || config.movie_frame_rate) {
const info = await analyzeVideoFile(file)
if (info) {
if (config.movie_resolution && info.width && info.height) {
const actual = `${info.width}x${info.height}`
if (actual !== config.movie_resolution) {
return `Video resolution ${actual} does not match the required resolution ${config.movie_resolution}`
}
}
if (config.movie_codec) {
const actualCodec = normalizeCodec(info)
if (actualCodec && actualCodec !== config.movie_codec) {
return `Video codec '${actualCodec}' does not match the required codec '${config.movie_codec}'`
}
// If the codec couldn't be identified, don't false-block
}
if (config.movie_frame_rate && info.frameRate) {
if (Math.abs(info.frameRate - config.movie_frame_rate) > 0.05) {
return `Video frame rate ${info.frameRate}fps does not match the required frame rate ${config.movie_frame_rate}fps`
}
}
}
// If the file couldn't be analyzed at all, don't false-block
}
}
return null
}
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const violation = await findSubmissionViolation(file)
if (violation) {
toast({
title: 'Invalid submission',
description: violation,
variant: 'destructive'
})
if (fileInput.value) {
fileInput.value.value = ''
}
return
}
uploading.value = true
try {
await taskService.submitWork(
+26
View File
@@ -95,6 +95,22 @@ export interface ProjectSettings {
enabled_shot_tasks?: string[]
}
export interface SubmissionTypeConfig {
allowed_extensions: string[]
naming_pattern?: string | null
check_naming: boolean
required: boolean
check_movie_spec: boolean
movie_resolution?: string | null
movie_format?: string | null
movie_codec?: string | null
movie_frame_rate?: number | null
}
export interface ProjectSubmissionConfig {
config_by_task_type: Record<string, SubmissionTypeConfig>
}
export const projectService = {
async getUserProjects(): Promise<Project[]> {
const response = await apiClient.get('/projects/')
@@ -182,6 +198,16 @@ export const projectService = {
return response.data
},
async getProjectSubmissionConfig(projectId: number): Promise<ProjectSubmissionConfig> {
const response = await apiClient.get(`/projects/${projectId}/submission-config`)
return response.data
},
async updateProjectSubmissionConfig(projectId: number, config: ProjectSubmissionConfig): Promise<ProjectSubmissionConfig> {
const response = await apiClient.put(`/projects/${projectId}/submission-config`, config)
return response.data
},
async uploadThumbnail(projectId: number, file: File): Promise<{ message: string; thumbnail_url: string }> {
const formData = new FormData()
formData.append('file', file)
+16 -4
View File
@@ -33,7 +33,7 @@
<!-- Tabbed Interface -->
<Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full">
<TabsList class="grid w-full grid-cols-6">
<TabsList class="grid w-full grid-cols-7">
<TabsTrigger value="general">
<Settings class="h-4 w-4 mr-2" />
General
@@ -54,6 +54,10 @@
<ListChecks class="h-4 w-4 mr-2" />
Tasks
</TabsTrigger>
<TabsTrigger value="submissions">
<UploadCloud class="h-4 w-4 mr-2" />
Submissions
</TabsTrigger>
<TabsTrigger value="storage">
<FolderOpen class="h-4 w-4 mr-2" />
Storage
@@ -156,6 +160,13 @@
</div>
</TabsContent>
<!-- Submission Configuration Tab -->
<TabsContent value="submissions" class="mt-6">
<div class="bg-card rounded-lg border p-6">
<SubmissionConfigManager :project-id="projectId" />
</div>
</TabsContent>
<!-- Upload Location Tab -->
<TabsContent value="storage" class="mt-6">
<div class="bg-card rounded-lg border p-6">
@@ -175,9 +186,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
ListChecks, FolderOpen
import {
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
ListChecks, FolderOpen, UploadCloud
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -195,6 +206,7 @@ import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManag
import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue";
import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue";
import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue";
import SubmissionConfigManager from "@/components/project/SubmissionConfigManager.vue";
import { projectService } from "@/services/project";
import type { Project } from "@/stores/projects";