Extend submission config: .mxf/codec/frame-rate checks, regex naming, shot/asset tabs

- Movie spec checks now also cover codec (h264/h265/mjpeg/dnxhd/dnxhr/
  prores/uncompressed/avid/cineform) and frame rate (23.976/24/30/48/60),
  detected in-browser via mediainfo.js container parsing - works even
  for formats the native <video> element can't decode (MXF, ProRes,
  DNxHD). Added .mxf to the supported/allowed format lists.
- Naming pattern supports a raw regex mode (validated by compiling
  server-side at save time) as an alternative to the token pattern,
  and gained three more tokens: {task_name}, {project_name},
  {project_code} (fetched lazily only when referenced).
- Submission Configuration settings page now splits Shot Tasks /
  Asset Tasks into separate tabs instead of one merged list; the
  per-task-type card was extracted into SubmissionTypeConfigCard.vue
  to avoid duplicating it across both tabs.
This commit is contained in:
2026-07-19 19:15:05 +08:00
parent 23740af816
commit 981808b901
6 changed files with 284 additions and 184 deletions
@@ -11,132 +11,36 @@
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>
<Tabs v-else default-value="shot" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="shot">Shot Tasks</TabsTrigger>
<TabsTrigger value="asset">Asset Tasks</TabsTrigger>
</TabsList>
<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>
<TabsContent value="shot" class="space-y-4 mt-4">
<SubmissionTypeConfigCard
v-for="taskType in shotTaskTypes"
:key="taskType"
:task-type="taskType"
:config="getConfig(taskType)"
/>
<div v-if="shotTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No shot task types configured for this project yet.
</div>
</TabsContent>
<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>
<TabsContent value="asset" class="space-y-4 mt-4">
<SubmissionTypeConfigCard
v-for="taskType in assetTaskTypes"
:key="taskType"
:task-type="taskType"
:config="getConfig(taskType)"
/>
<div v-if="assetTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No asset task types configured for this project yet.
</div>
</TabsContent>
</Tabs>
<div class="flex justify-end">
<Button :disabled="isLoading || isSaving" @click="onSave">
@@ -150,15 +54,11 @@
<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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type SubmissionTypeConfig } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType'
import SubmissionTypeConfigCard from './SubmissionTypeConfigCard.vue'
interface Props {
projectId: number
@@ -167,30 +67,17 @@ interface Props {
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 shotTaskTypes = ref<string[]>([])
const assetTaskTypes = 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_is_regex: false,
naming_pattern: '',
check_naming: true,
required: false,
@@ -204,15 +91,6 @@ const getConfig = (taskType: string): SubmissionTypeConfig => {
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
@@ -221,11 +99,13 @@ const load = async () => {
projectService.getProjectSubmissionConfig(props.projectId)
])
taskTypes.value = Array.from(new Set([...allTypes.asset_task_types, ...allTypes.shot_task_types]))
shotTaskTypes.value = allTypes.shot_task_types
assetTaskTypes.value = allTypes.asset_task_types
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
formState[taskType] = {
allowed_extensions: [...cfg.allowed_extensions],
naming_pattern_is_regex: cfg.naming_pattern_is_regex,
naming_pattern: cfg.naming_pattern || '',
check_naming: cfg.check_naming,
required: cfg.required,
@@ -252,7 +132,7 @@ const onSave = async () => {
try {
isSaving.value = true
const configByTaskType: Record<string, SubmissionTypeConfig> = {}
for (const taskType of taskTypes.value) {
for (const taskType of [...shotTaskTypes.value, ...assetTaskTypes.value]) {
const config = formState[taskType]
if (!config) continue
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
@@ -260,6 +140,7 @@ const onSave = async () => {
if (hasConfig) {
configByTaskType[taskType] = {
allowed_extensions: config.allowed_extensions,
naming_pattern_is_regex: config.naming_pattern_is_regex,
naming_pattern: config.naming_pattern || null,
check_naming: config.check_naming,
required: config.required,