Compare commits
12 Commits
befd7c8d49
...
95bdb0a6e8
| Author | SHA1 | Date | |
|---|---|---|---|
| 95bdb0a6e8 | |||
| 1e76052c64 | |||
| 622491c3d0 | |||
| a8ffd6417a | |||
| 43fe1b8909 | |||
| 2365824313 | |||
| 1ff985d64f | |||
| 05097d58f9 | |||
| ca6f2a0346 | |||
| 55c26fb1f5 | |||
| 49e06fd3c4 | |||
|
|
9bca1bc20f |
@ -49,28 +49,45 @@ jobs:
|
|||||||
id: build_backend
|
id: build_backend
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
DOCKER_BUILDKIT=0 docker build \
|
for attempt in 1 2 3; do
|
||||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-backend:${{ env.IMAGE_TAG }} \
|
echo "Build backend attempt $attempt/3..."
|
||||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-backend:latest \
|
DOCKER_BUILDKIT=0 docker build \
|
||||||
./backend 2>&1 | tee /tmp/build.log
|
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-backend:${{ env.IMAGE_TAG }} \
|
||||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-backend:${{ env.IMAGE_TAG }}
|
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-backend:latest \
|
||||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-backend:latest
|
./backend 2>&1 | tee /tmp/build.log && break
|
||||||
|
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||||
|
done
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-backend:${{ env.IMAGE_TAG }} && \
|
||||||
|
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-backend:latest && break
|
||||||
|
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||||
|
done
|
||||||
|
|
||||||
- name: Build and Push Web
|
- name: Build and Push Web
|
||||||
id: build_web
|
id: build_web
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
DOCKER_BUILDKIT=0 docker build \
|
for attempt in 1 2 3; do
|
||||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-web:${{ env.IMAGE_TAG }} \
|
echo "Build web attempt $attempt/3..."
|
||||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-web:latest \
|
DOCKER_BUILDKIT=0 docker build \
|
||||||
./web 2>&1 | tee -a /tmp/build.log
|
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-web:${{ env.IMAGE_TAG }} \
|
||||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-web:${{ env.IMAGE_TAG }}
|
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-web:latest \
|
||||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-web:latest
|
./web 2>&1 | tee -a /tmp/build.log && break
|
||||||
|
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||||
|
done
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-web:${{ env.IMAGE_TAG }} && \
|
||||||
|
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/video-web:latest && break
|
||||||
|
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||||
|
done
|
||||||
|
|
||||||
- name: Setup Kubectl
|
- name: Setup Kubectl
|
||||||
run: |
|
run: |
|
||||||
if ! command -v kubectl &>/dev/null; then
|
if ! command -v kubectl &>/dev/null; then
|
||||||
curl -LO "https://mirrors.aliyun.com/kubernetes/kubectl/v1.28.0/bin/linux/amd64/kubectl"
|
for attempt in 1 2 3; do
|
||||||
|
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && break
|
||||||
|
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
|
||||||
|
done
|
||||||
chmod +x kubectl && mv kubectl /usr/local/bin/
|
chmod +x kubectl && mv kubectl /usr/local/bin/
|
||||||
fi
|
fi
|
||||||
kubectl version --client
|
kubectl version --client
|
||||||
@ -79,11 +96,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p $HOME/.kube
|
mkdir -p $HOME/.kube
|
||||||
if [[ "${{ github.ref_name }}" == "master" ]]; then
|
if [[ "${{ github.ref_name }}" == "master" ]]; then
|
||||||
echo "${{ secrets.VOLCANO_PROD_KUBE_CONFIG }}" > $HOME/.kube/config
|
printf '%s\n' '${{ secrets.VOLCANO_PROD_KUBE_CONFIG }}' > $HOME/.kube/config
|
||||||
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
||||||
echo "${{ secrets.VOLCANO_TEST_KUBE_CONFIG }}" > $HOME/.kube/config
|
printf '%s\n' '${{ secrets.VOLCANO_TEST_KUBE_CONFIG }}' > $HOME/.kube/config
|
||||||
fi
|
fi
|
||||||
chmod 600 $HOME/.kube/config
|
chmod 600 $HOME/.kube/config
|
||||||
|
echo "kubeconfig lines: $(wc -l < $HOME/.kube/config)"
|
||||||
|
grep server $HOME/.kube/config || echo "WARNING: no server found in kubeconfig"
|
||||||
|
|
||||||
- name: Deploy to K3s
|
- name: Deploy to K3s
|
||||||
id: deploy
|
id: deploy
|
||||||
@ -113,38 +132,43 @@ jobs:
|
|||||||
sed -i "s|redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.ivolces.com:6379/0|${{ env.REDIS_URL }}|g" k8s/backend-deployment.yaml
|
sed -i "s|redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.ivolces.com:6379/0|${{ env.REDIS_URL }}|g" k8s/backend-deployment.yaml
|
||||||
sed -i "s|redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.ivolces.com:6379/0|${{ env.REDIS_URL }}|g" k8s/celery-deployment.yaml
|
sed -i "s|redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.ivolces.com:6379/0|${{ env.REDIS_URL }}|g" k8s/celery-deployment.yaml
|
||||||
|
|
||||||
# Create/update image pull secret for CR
|
# All kubectl operations with retry (K3s 内网连接可能抖动)
|
||||||
kubectl create secret docker-registry cr-pull-secret \
|
for attempt in 1 2 3; do
|
||||||
--docker-server="${{ env.CR_SERVER_ACTIVE }}" \
|
echo "Deploy attempt $attempt/3..."
|
||||||
--docker-username="${{ env.CR_USERNAME_ACTIVE }}" \
|
{
|
||||||
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
|
# Create/update image pull secret for CR
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
kubectl create secret docker-registry cr-pull-secret \
|
||||||
|
--docker-server="${{ env.CR_SERVER_ACTIVE }}" \
|
||||||
|
--docker-username="${{ env.CR_USERNAME_ACTIVE }}" \
|
||||||
|
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
# Create/update secrets (业务密钥,DB 已写在 yaml 里)
|
# Create/update secrets (业务密钥,DB 已写在 yaml 里)
|
||||||
kubectl create secret generic video-backend-secrets \
|
kubectl create secret generic video-backend-secrets \
|
||||||
--from-literal=ARK_API_KEY='${{ secrets.ARK_API_KEY }}' \
|
--from-literal=ARK_API_KEY='${{ secrets.ARK_API_KEY }}' \
|
||||||
--from-literal=TOS_ACCESS_KEY='${{ secrets.TOS_ACCESS_KEY }}' \
|
--from-literal=TOS_ACCESS_KEY='${{ secrets.TOS_ACCESS_KEY }}' \
|
||||||
--from-literal=TOS_SECRET_KEY='${{ secrets.TOS_SECRET_KEY }}' \
|
--from-literal=TOS_SECRET_KEY='${{ secrets.TOS_SECRET_KEY }}' \
|
||||||
--from-literal=DJANGO_SECRET_KEY='${{ secrets.DJANGO_SECRET_KEY }}' \
|
--from-literal=DJANGO_SECRET_KEY='${{ secrets.DJANGO_SECRET_KEY }}' \
|
||||||
--from-literal=ALIYUN_SMS_ACCESS_KEY='${{ secrets.ALIYUN_SMS_ACCESS_KEY }}' \
|
--from-literal=ALIYUN_SMS_ACCESS_KEY='${{ secrets.ALIYUN_SMS_ACCESS_KEY }}' \
|
||||||
--from-literal=ALIYUN_SMS_ACCESS_SECRET='${{ secrets.ALIYUN_SMS_ACCESS_SECRET }}' \
|
--from-literal=ALIYUN_SMS_ACCESS_SECRET='${{ secrets.ALIYUN_SMS_ACCESS_SECRET }}' \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
# Apply manifests
|
# Apply manifests
|
||||||
set -o pipefail
|
kubectl apply -f k8s/backend-deployment.yaml
|
||||||
{
|
kubectl apply -f k8s/celery-deployment.yaml
|
||||||
kubectl apply -f k8s/backend-deployment.yaml
|
kubectl apply -f k8s/web-deployment.yaml
|
||||||
kubectl apply -f k8s/celery-deployment.yaml
|
kubectl apply -f k8s/ingress.yaml
|
||||||
kubectl apply -f k8s/web-deployment.yaml
|
|
||||||
kubectl apply -f k8s/ingress.yaml
|
|
||||||
|
|
||||||
# Preserve real client IP
|
# Preserve real client IP
|
||||||
kubectl patch svc traefik -n kube-system -p '{"spec":{"externalTrafficPolicy":"Local"}}' 2>/dev/null || true
|
kubectl patch svc traefik -n kube-system -p '{"spec":{"externalTrafficPolicy":"Local"}}' 2>/dev/null || true
|
||||||
|
|
||||||
kubectl rollout restart deployment/video-backend
|
kubectl rollout restart deployment/video-backend
|
||||||
kubectl rollout restart deployment/celery-worker
|
kubectl rollout restart deployment/celery-worker
|
||||||
kubectl rollout restart deployment/video-web
|
kubectl rollout restart deployment/video-web
|
||||||
} 2>&1 | tee /tmp/deploy.log
|
} 2>&1 | tee /tmp/deploy.log && break
|
||||||
|
echo "Attempt $attempt failed, retrying in 10s..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
|
||||||
# ===== Log Center: failure reporting =====
|
# ===== Log Center: failure reporting =====
|
||||||
- name: Report failure to Log Center
|
- name: Report failure to Log Center
|
||||||
@ -204,3 +228,13 @@ jobs:
|
|||||||
\"run_url\": \"https://gitea.airlabs.art/${{ github.repository }}/actions/runs/${{ github.run_number }}\"
|
\"run_url\": \"https://gitea.airlabs.art/${{ github.repository }}/actions/runs/${{ github.run_number }}\"
|
||||||
}
|
}
|
||||||
}" || true
|
}" || true
|
||||||
|
|
||||||
|
# ===== Cleanup: remove unused Docker resources =====
|
||||||
|
- name: Docker Cleanup
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker container prune -f
|
||||||
|
docker image prune -a -f
|
||||||
|
docker builder prune -a -f
|
||||||
|
echo "Disk usage after cleanup:"
|
||||||
|
df -h / | tail -1
|
||||||
|
|||||||
@ -29,4 +29,4 @@ RUN chmod +x /app/entrypoint.sh
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "config.wsgi:application"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--worker-class", "gevent", "--worker-connections", "200", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "config.wsgi:application"]
|
||||||
|
|||||||
23
backend/apps/generation/migrations/0017_add_asset_type.py
Normal file
23
backend/apps/generation/migrations/0017_add_asset_type.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-04-04 05:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('generation', '0016_add_is_deleted_to_generationrecord'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='asset',
|
||||||
|
name='asset_type',
|
||||||
|
field=models.CharField(choices=[('Image', '图像'), ('Video', '视频'), ('Audio', '音频')], default='Image', max_length=10, verbose_name='素材类型'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='asset',
|
||||||
|
name='url',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=1000, verbose_name='素材URL'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -136,12 +136,17 @@ class AssetGroup(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Asset(models.Model):
|
class Asset(models.Model):
|
||||||
"""虚拟人像素材 — 单张图片。"""
|
"""虚拟人像素材 — 图片/视频/音频。"""
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('processing', '处理中'),
|
('processing', '处理中'),
|
||||||
('active', '可用'),
|
('active', '可用'),
|
||||||
('failed', '失败'),
|
('failed', '失败'),
|
||||||
]
|
]
|
||||||
|
ASSET_TYPE_CHOICES = [
|
||||||
|
('Image', '图像'),
|
||||||
|
('Video', '视频'),
|
||||||
|
('Audio', '音频'),
|
||||||
|
]
|
||||||
|
|
||||||
group = models.ForeignKey(
|
group = models.ForeignKey(
|
||||||
AssetGroup, on_delete=models.CASCADE,
|
AssetGroup, on_delete=models.CASCADE,
|
||||||
@ -149,7 +154,8 @@ class Asset(models.Model):
|
|||||||
)
|
)
|
||||||
remote_asset_id = models.CharField(max_length=100, default='', verbose_name='火山Asset ID')
|
remote_asset_id = models.CharField(max_length=100, default='', verbose_name='火山Asset ID')
|
||||||
name = models.CharField(max_length=100, default='', verbose_name='素材名称')
|
name = models.CharField(max_length=100, default='', verbose_name='素材名称')
|
||||||
url = models.CharField(max_length=1000, blank=True, default='', verbose_name='图片URL')
|
url = models.CharField(max_length=1000, blank=True, default='', verbose_name='素材URL')
|
||||||
|
asset_type = models.CharField(max_length=10, choices=ASSET_TYPE_CHOICES, default='Image', verbose_name='素材类型')
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态')
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态')
|
||||||
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
|
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||||
|
|||||||
@ -21,19 +21,29 @@ def poll_video_task(self, record_id):
|
|||||||
from apps.generation.models import GenerationRecord
|
from apps.generation.models import GenerationRecord
|
||||||
from utils.airdrama_client import query_task, map_status
|
from utils.airdrama_client import query_task, map_status
|
||||||
|
|
||||||
|
# 防重复:同一 record 同一时刻只允许一个 poll 在执行
|
||||||
|
from django.core.cache import cache
|
||||||
|
lock_key = f'poll_lock:{record_id}'
|
||||||
|
if not cache.add(lock_key, '1', timeout=POLL_INTERVAL * 3):
|
||||||
|
logger.info('poll_video_task: record %s already being polled, skipping', record_id)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
record = GenerationRecord.objects.get(pk=record_id)
|
record = GenerationRecord.objects.get(pk=record_id)
|
||||||
except GenerationRecord.DoesNotExist:
|
except GenerationRecord.DoesNotExist:
|
||||||
logger.warning('poll_video_task: record %s not found', record_id)
|
logger.warning('poll_video_task: record %s not found', record_id)
|
||||||
|
cache.delete(lock_key)
|
||||||
return
|
return
|
||||||
|
|
||||||
ark_task_id = record.ark_task_id
|
ark_task_id = record.ark_task_id
|
||||||
if not ark_task_id:
|
if not ark_task_id:
|
||||||
logger.warning('poll_video_task: record %s has no ark_task_id', record_id)
|
logger.warning('poll_video_task: record %s has no ark_task_id', record_id)
|
||||||
|
cache.delete(lock_key)
|
||||||
return
|
return
|
||||||
|
|
||||||
if record.status not in ('queued', 'processing'):
|
if record.status not in ('queued', 'processing'):
|
||||||
logger.info('poll_video_task: record %s already in terminal state: %s', record_id, record.status)
|
logger.info('poll_video_task: record %s already in terminal state: %s', record_id, record.status)
|
||||||
|
cache.delete(lock_key)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Poll Volcano API
|
# Poll Volcano API
|
||||||
@ -42,12 +52,14 @@ def poll_video_task(self, record_id):
|
|||||||
new_status = map_status(ark_resp.get('status', ''))
|
new_status = map_status(ark_resp.get('status', ''))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('poll_video_task: API query failed for %s, will retry', ark_task_id)
|
logger.exception('poll_video_task: API query failed for %s, will retry', ark_task_id)
|
||||||
|
cache.delete(lock_key)
|
||||||
raise self.retry(countdown=POLL_INTERVAL)
|
raise self.retry(countdown=POLL_INTERVAL)
|
||||||
|
|
||||||
if new_status in ('queued', 'processing'):
|
if new_status in ('queued', 'processing'):
|
||||||
# Still running — update status, then re-enqueue
|
# Still running — update status, then re-enqueue
|
||||||
record.status = new_status
|
record.status = new_status
|
||||||
record.save(update_fields=['status', 'updated_at'])
|
record.save(update_fields=['status', 'updated_at'])
|
||||||
|
cache.delete(lock_key)
|
||||||
raise self.retry(countdown=POLL_INTERVAL)
|
raise self.retry(countdown=POLL_INTERVAL)
|
||||||
|
|
||||||
# Terminal state reached — process result
|
# Terminal state reached — process result
|
||||||
|
|||||||
@ -32,7 +32,7 @@ User = get_user_model()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# File validation constants
|
# File validation constants
|
||||||
ALLOWED_IMAGE_EXTS = {'jpeg', 'jpg', 'png', 'webp', 'bmp', 'tiff', 'gif'}
|
ALLOWED_IMAGE_EXTS = {'jpeg', 'jpg', 'png', 'webp', 'bmp', 'tiff', 'gif', 'heic', 'heif'}
|
||||||
ALLOWED_VIDEO_EXTS = {'mp4', 'mov'}
|
ALLOWED_VIDEO_EXTS = {'mp4', 'mov'}
|
||||||
ALLOWED_AUDIO_EXTS = {'mp3', 'wav'}
|
ALLOWED_AUDIO_EXTS = {'mp3', 'wav'}
|
||||||
MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
|
MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
|
||||||
@ -287,37 +287,39 @@ def video_generate_view(request):
|
|||||||
reference_snapshots = []
|
reference_snapshots = []
|
||||||
content_items = []
|
content_items = []
|
||||||
seen_urls = set() # 去重:同一个素材只引用一次
|
seen_urls = set() # 去重:同一个素材只引用一次
|
||||||
_asset_cache = {} # group_id → resolved_url,避免同一素材组重复查询
|
_asset_cache = {} # group_id → [(asset_url, asset_type), ...],避免同一素材组重复查询
|
||||||
|
|
||||||
from .models import Asset as AssetModel
|
from .models import Asset as AssetModel
|
||||||
|
|
||||||
def _resolve_asset_group(gid, lbl):
|
def _resolve_asset_group_all(gid, lbl):
|
||||||
"""查询本地 DB + 必要时刷新火山状态,返回 Asset://xxx 或原始 asset:// URL。"""
|
"""查询本地 DB 获取组内所有 active 素材,返回 [(asset_url, asset_type), ...] 列表。
|
||||||
asset = AssetModel.objects.filter(
|
processing 的素材会尝试实时刷新状态。"""
|
||||||
|
assets = list(AssetModel.objects.filter(
|
||||||
group_id=gid, status__in=['active', 'processing']
|
group_id=gid, status__in=['active', 'processing']
|
||||||
).order_by(
|
).exclude(remote_asset_id='').order_by('created_at'))
|
||||||
Case(When(status='active', then=0), default=1)
|
if not assets:
|
||||||
).first()
|
logger.warning('No assets found for group %s (label=%s)', gid, lbl)
|
||||||
if not asset or not asset.remote_asset_id:
|
return []
|
||||||
logger.warning('No asset found for group %s (label=%s)', gid, lbl)
|
resolved_list = []
|
||||||
return f'asset://group-{gid}'
|
for asset in assets:
|
||||||
# 本地 processing → 实时查火山刷新
|
# 本地 processing → 实时查火山刷新
|
||||||
if asset.status == 'processing':
|
if asset.status == 'processing':
|
||||||
result, _ = _assets_api_call(assets_client.get_asset, asset.remote_asset_id)
|
result, _ = _assets_api_call(assets_client.get_asset, asset.remote_asset_id)
|
||||||
if result and result.get('Status') == 'Active':
|
if result and result.get('Status') == 'Active':
|
||||||
asset.status = 'active'
|
asset.status = 'active'
|
||||||
asset.url = result.get('Url', asset.url)
|
asset.url = result.get('Url', asset.url)
|
||||||
asset.save(update_fields=['status', 'url'])
|
asset.save(update_fields=['status', 'url'])
|
||||||
logger.info('Asset %s refreshed to active from Volcano', asset.remote_asset_id)
|
logger.info('Asset %s refreshed to active from Volcano', asset.remote_asset_id)
|
||||||
else:
|
else:
|
||||||
logger.warning('Asset %s still processing on Volcano', asset.remote_asset_id)
|
logger.warning('Asset %s still processing, skipped', asset.remote_asset_id)
|
||||||
return f'asset://group-{gid}'
|
continue # 跳过未就绪的素材
|
||||||
aid = asset.remote_asset_id
|
aid = asset.remote_asset_id
|
||||||
if aid.startswith('asset-'):
|
if aid.startswith('Asset-'):
|
||||||
aid = 'Asset-' + aid[6:]
|
aid = 'asset-' + aid[6:]
|
||||||
resolved = f'Asset://{aid}'
|
resolved_url = f'asset://{aid}'
|
||||||
logger.info('Asset resolved: group=%s -> %s', gid, resolved)
|
resolved_list.append((resolved_url, asset.asset_type))
|
||||||
return resolved
|
logger.info('Asset group %s resolved: %d assets', gid, len(resolved_list))
|
||||||
|
return resolved_list
|
||||||
|
|
||||||
from utils import assets_client
|
from utils import assets_client
|
||||||
|
|
||||||
@ -347,30 +349,37 @@ def video_generate_view(request):
|
|||||||
snap['thumb_url'] = thumb_url
|
snap['thumb_url'] = thumb_url
|
||||||
reference_snapshots.append(snap)
|
reference_snapshots.append(snap)
|
||||||
|
|
||||||
# 转换 asset://group-{id} 为火山 Asset://Asset-xxx 格式(仅用于 content_items)
|
# 转换 asset://group-{id} → 展开为组内所有 active 素材(全发)
|
||||||
resolved_url = url
|
|
||||||
if url.startswith('asset://group-'):
|
if url.startswith('asset://group-'):
|
||||||
try:
|
try:
|
||||||
group_id = int(url.replace('asset://group-', ''))
|
group_id = int(url.replace('asset://group-', ''))
|
||||||
# 跨迭代缓存:同一 group_id 不重复查询/刷新
|
|
||||||
if group_id in _asset_cache:
|
if group_id in _asset_cache:
|
||||||
resolved_url = _asset_cache[group_id]
|
asset_list = _asset_cache[group_id]
|
||||||
else:
|
else:
|
||||||
resolved_url = _resolve_asset_group(group_id, label)
|
asset_list = _resolve_asset_group_all(group_id, label)
|
||||||
_asset_cache[group_id] = resolved_url
|
_asset_cache[group_id] = asset_list
|
||||||
|
if not asset_list:
|
||||||
|
return Response({
|
||||||
|
'error': 'asset_not_ready',
|
||||||
|
'message': f'素材「{label}」尚未就绪,请在素材库中确认状态为"可用"后重试',
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
for asset_url, asset_type in asset_list:
|
||||||
|
if asset_type == 'Video':
|
||||||
|
content_items.append({'type': 'video_url', 'video_url': {'url': asset_url}, 'role': 'reference_video'})
|
||||||
|
elif asset_type == 'Audio':
|
||||||
|
content_items.append({'type': 'audio_url', 'audio_url': {'url': asset_url}, 'role': 'reference_audio'})
|
||||||
|
else:
|
||||||
|
content_items.append({'type': 'image_url', 'image_url': {'url': asset_url}, 'role': 'reference_image'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('Failed to resolve asset group URL %s: %s', url, e)
|
logger.warning('Failed to resolve asset group URL %s: %s', url, e)
|
||||||
|
return Response({
|
||||||
# 未解析成功的 asset URL → 返回明确错误,不再静默跳过
|
'error': 'asset_not_ready',
|
||||||
if resolved_url.startswith('asset://'):
|
'message': f'素材「{label}」解析失败,请重试',
|
||||||
logger.error('Unresolved asset URL: %s (label=%s)', resolved_url, label)
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
return Response({
|
continue # 素材组已展开为多个 content_items,跳过下面的单项处理
|
||||||
'error': 'asset_not_ready',
|
|
||||||
'message': f'素材「{label}」尚未就绪,请在素材库中确认状态为"可用"后重试',
|
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
if ref_type == 'image':
|
if ref_type == 'image':
|
||||||
item = {'type': 'image_url', 'image_url': {'url': resolved_url}}
|
item = {'type': 'image_url', 'image_url': {'url': url}}
|
||||||
# API 文档要求:参考图模式下所有图片的 role 必须为 reference_image
|
# API 文档要求:参考图模式下所有图片的 role 必须为 reference_image
|
||||||
if mode == 'universal':
|
if mode == 'universal':
|
||||||
item['role'] = 'reference_image'
|
item['role'] = 'reference_image'
|
||||||
@ -2923,12 +2932,36 @@ def _assets_api_call(func, *args, **kwargs):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_asset_type(file):
|
||||||
|
"""Detect asset type from file content_type. Returns ('Image'|'Video'|'Audio', error_response|None)."""
|
||||||
|
ct = (file.content_type or '').lower()
|
||||||
|
if ct.startswith('video/'):
|
||||||
|
if ct not in ('video/mp4', 'video/quicktime'):
|
||||||
|
return None, Response({'error': '仅支持 MP4 和 MOV 格式的视频'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if file.size > MAX_VIDEO_SIZE:
|
||||||
|
return None, Response({'error': '视频文件不能超过 50MB'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return 'Video', None
|
||||||
|
elif ct.startswith('audio/'):
|
||||||
|
if ct not in ('audio/mpeg', 'audio/wav'):
|
||||||
|
return None, Response({'error': '仅支持 MP3 和 WAV 格式的音频'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if file.size > MAX_AUDIO_SIZE:
|
||||||
|
return None, Response({'error': '音频文件不能超过 15MB'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return 'Audio', None
|
||||||
|
else:
|
||||||
|
ext = file.name.rsplit('.', 1)[-1].lower() if '.' in file.name else ''
|
||||||
|
if ext and ext not in ALLOWED_IMAGE_EXTS:
|
||||||
|
return None, Response({'error': f'不支持的图片格式: {ext}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if file.size > MAX_IMAGE_SIZE:
|
||||||
|
return None, Response({'error': '图片文件不能超过 30MB'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return 'Image', None
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET', 'POST'])
|
@api_view(['GET', 'POST'])
|
||||||
@permission_classes([IsTeamMember])
|
@permission_classes([IsTeamMember])
|
||||||
@parser_classes([MultiPartParser, JSONParser])
|
@parser_classes([MultiPartParser, JSONParser])
|
||||||
def asset_groups_view(request):
|
def asset_groups_view(request):
|
||||||
"""GET /api/v1/assets/groups — list groups for current team.
|
"""GET /api/v1/assets/groups — list groups for current team.
|
||||||
POST /api/v1/assets/groups — create a group with an initial image.
|
POST /api/v1/assets/groups — create a group with an initial asset (image/video/audio).
|
||||||
"""
|
"""
|
||||||
team = request.user.team
|
team = request.user.team
|
||||||
|
|
||||||
@ -2975,32 +3008,39 @@ def asset_groups_view(request):
|
|||||||
|
|
||||||
file = request.FILES.get('file')
|
file = request.FILES.get('file')
|
||||||
if not file:
|
if not file:
|
||||||
return Response({'error': '请上传一张素材图片'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': '请上传素材文件'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Validate image dimensions (Volcano Assets API requires 300-6000px)
|
# Detect asset type and validate format/size
|
||||||
try:
|
asset_type, err = _detect_asset_type(file)
|
||||||
from PIL import Image
|
if err:
|
||||||
img = Image.open(file)
|
return err
|
||||||
w, h = img.size
|
|
||||||
if w < 300 or h < 300:
|
# Validate image dimensions (only for images)
|
||||||
return Response(
|
if asset_type == 'Image':
|
||||||
{'error': f'图片太小了,请上传更大的图片(当前 {w}x{h},最小要求 300x300)'},
|
try:
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
from PIL import Image
|
||||||
)
|
img = Image.open(file)
|
||||||
if w > 6000 or h > 6000:
|
w, h = img.size
|
||||||
return Response(
|
if w < 300 or h < 300:
|
||||||
{'error': f'图片太大了,请压缩后重试(当前 {w}x{h},最大支持 6000x6000)'},
|
return Response(
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
{'error': f'图片太小了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||||
)
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
file.seek(0) # Reset after PIL read
|
)
|
||||||
except ImportError:
|
if w > 6000 or h > 6000:
|
||||||
pass # Pillow not installed, skip validation
|
return Response(
|
||||||
except Exception:
|
{'error': f'图片太大了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||||
pass # Not an image or corrupted, let TOS handle it
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
file.seek(0)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Upload to TOS
|
# Upload to TOS
|
||||||
|
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
|
||||||
try:
|
try:
|
||||||
tos_url = tos_upload(file, folder='assets')
|
tos_url = tos_upload(file, folder=folder)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception('TOS upload failed for asset')
|
logger.exception('TOS upload failed for asset')
|
||||||
return Response(
|
return Response(
|
||||||
@ -3020,7 +3060,7 @@ def asset_groups_view(request):
|
|||||||
# Create remote asset
|
# Create remote asset
|
||||||
remote_asset_id = ''
|
remote_asset_id = ''
|
||||||
if remote_group_id:
|
if remote_group_id:
|
||||||
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name)
|
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name, asset_type=asset_type)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@ -3040,6 +3080,7 @@ def asset_groups_view(request):
|
|||||||
remote_asset_id=remote_asset_id,
|
remote_asset_id=remote_asset_id,
|
||||||
name=name,
|
name=name,
|
||||||
url=tos_url,
|
url=tos_url,
|
||||||
|
asset_type=asset_type,
|
||||||
status='processing' if remote_asset_id else 'active',
|
status='processing' if remote_asset_id else 'active',
|
||||||
error_message='',
|
error_message='',
|
||||||
)
|
)
|
||||||
@ -3087,6 +3128,7 @@ def asset_group_detail_view(request, group_id):
|
|||||||
'id': a.id,
|
'id': a.id,
|
||||||
'name': a.name,
|
'name': a.name,
|
||||||
'url': a.url,
|
'url': a.url,
|
||||||
|
'asset_type': a.asset_type,
|
||||||
'status': a.status,
|
'status': a.status,
|
||||||
'remote_asset_id': a.remote_asset_id,
|
'remote_asset_id': a.remote_asset_id,
|
||||||
'error_message': a.error_message,
|
'error_message': a.error_message,
|
||||||
@ -3141,7 +3183,7 @@ def asset_group_detail_view(request, group_id):
|
|||||||
@permission_classes([IsTeamMember])
|
@permission_classes([IsTeamMember])
|
||||||
@parser_classes([MultiPartParser])
|
@parser_classes([MultiPartParser])
|
||||||
def asset_group_add_asset_view(request, group_id):
|
def asset_group_add_asset_view(request, group_id):
|
||||||
"""POST /api/v1/assets/groups/<id>/assets — add an image to a group."""
|
"""POST /api/v1/assets/groups/<id>/assets — add an asset (image/video/audio) to a group."""
|
||||||
team = request.user.team
|
team = request.user.team
|
||||||
try:
|
try:
|
||||||
group = AssetGroup.objects.get(pk=group_id, team=team)
|
group = AssetGroup.objects.get(pk=group_id, team=team)
|
||||||
@ -3152,32 +3194,39 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
if not file:
|
if not file:
|
||||||
return Response({'error': '请上传文件'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': '请上传文件'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Validate image dimensions (Volcano Assets API requires 300-6000px)
|
# Detect asset type and validate format/size
|
||||||
try:
|
asset_type, err = _detect_asset_type(file)
|
||||||
from PIL import Image
|
if err:
|
||||||
img = Image.open(file)
|
return err
|
||||||
w, h = img.size
|
|
||||||
if w < 300 or h < 300:
|
# Validate image dimensions (only for images)
|
||||||
return Response(
|
if asset_type == 'Image':
|
||||||
{'error': f'图片太小了,请上传更大的图片(当前 {w}x{h},最小要求 300x300)'},
|
try:
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
from PIL import Image
|
||||||
)
|
img = Image.open(file)
|
||||||
if w > 6000 or h > 6000:
|
w, h = img.size
|
||||||
return Response(
|
if w < 300 or h < 300:
|
||||||
{'error': f'图片太大了,请压缩后重试(当前 {w}x{h},最大支持 6000x6000)'},
|
return Response(
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
{'error': f'图片太小了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||||
)
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
file.seek(0)
|
)
|
||||||
except ImportError:
|
if w > 6000 or h > 6000:
|
||||||
pass
|
return Response(
|
||||||
except Exception:
|
{'error': f'图片太大了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||||
pass
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
file.seek(0)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
name = request.data.get('name', '').strip() or file.name
|
name = request.data.get('name', '').strip() or file.name
|
||||||
|
|
||||||
# Upload to TOS
|
# Upload to TOS
|
||||||
|
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
|
||||||
try:
|
try:
|
||||||
tos_url = tos_upload(file, folder='assets')
|
tos_url = tos_upload(file, folder=folder)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception('TOS upload failed for asset')
|
logger.exception('TOS upload failed for asset')
|
||||||
return Response(
|
return Response(
|
||||||
@ -3190,7 +3239,7 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
remote_asset_id = ''
|
remote_asset_id = ''
|
||||||
if group.remote_group_id:
|
if group.remote_group_id:
|
||||||
result, err = _assets_api_call(
|
result, err = _assets_api_call(
|
||||||
assets_client.create_asset, group.remote_group_id, tos_url, name,
|
assets_client.create_asset, group.remote_group_id, tos_url, name, asset_type=asset_type,
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
@ -3202,11 +3251,12 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
remote_asset_id=remote_asset_id,
|
remote_asset_id=remote_asset_id,
|
||||||
name=name,
|
name=name,
|
||||||
url=tos_url,
|
url=tos_url,
|
||||||
|
asset_type=asset_type,
|
||||||
status='processing' if remote_asset_id else 'active',
|
status='processing' if remote_asset_id else 'active',
|
||||||
error_message='',
|
error_message='',
|
||||||
)
|
)
|
||||||
|
|
||||||
# If first asset, set thumbnail
|
# If first asset or no thumbnail, set thumbnail
|
||||||
if not group.thumbnail_url:
|
if not group.thumbnail_url:
|
||||||
group.thumbnail_url = tos_url
|
group.thumbnail_url = tos_url
|
||||||
group.save(update_fields=['thumbnail_url'])
|
group.save(update_fields=['thumbnail_url'])
|
||||||
@ -3215,23 +3265,49 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
'id': asset.id,
|
'id': asset.id,
|
||||||
'name': asset.name,
|
'name': asset.name,
|
||||||
'url': asset.url,
|
'url': asset.url,
|
||||||
|
'asset_type': asset.asset_type,
|
||||||
'status': asset.status,
|
'status': asset.status,
|
||||||
'remote_asset_id': asset.remote_asset_id,
|
'remote_asset_id': asset.remote_asset_id,
|
||||||
'created_at': asset.created_at.isoformat(),
|
'created_at': asset.created_at.isoformat(),
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['PUT'])
|
@api_view(['PUT', 'DELETE'])
|
||||||
@permission_classes([IsTeamMember])
|
@permission_classes([IsTeamMember])
|
||||||
@parser_classes([JSONParser])
|
@parser_classes([JSONParser])
|
||||||
def asset_update_view(request, asset_id):
|
def asset_update_view(request, asset_id):
|
||||||
"""PUT /api/v1/assets/<id> — rename an asset."""
|
"""PUT /api/v1/assets/<id> — rename an asset. DELETE — delete an asset."""
|
||||||
team = request.user.team
|
team = request.user.team
|
||||||
try:
|
try:
|
||||||
asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team)
|
asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team)
|
||||||
except Asset.DoesNotExist:
|
except Asset.DoesNotExist:
|
||||||
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
# Delete from Volcano first
|
||||||
|
if asset.remote_asset_id:
|
||||||
|
from utils import assets_client
|
||||||
|
try:
|
||||||
|
assets_client.delete_asset(asset.remote_asset_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Failed to delete remote asset %s: %s', asset.remote_asset_id, e)
|
||||||
|
|
||||||
|
group = asset.group
|
||||||
|
asset.delete()
|
||||||
|
|
||||||
|
# Update group thumbnail if needed
|
||||||
|
remaining = Asset.objects.filter(group=group).exclude(status='failed').order_by('-created_at').first()
|
||||||
|
if remaining:
|
||||||
|
if group.thumbnail_url != remaining.url:
|
||||||
|
group.thumbnail_url = remaining.url
|
||||||
|
group.save(update_fields=['thumbnail_url'])
|
||||||
|
else:
|
||||||
|
group.thumbnail_url = ''
|
||||||
|
group.save(update_fields=['thumbnail_url'])
|
||||||
|
|
||||||
|
return Response({'message': '素材已删除'})
|
||||||
|
|
||||||
|
# PUT — rename
|
||||||
new_name = request.data.get('name')
|
new_name = request.data.get('name')
|
||||||
if not new_name:
|
if not new_name:
|
||||||
return Response({'error': '请提供素材名称'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': '请提供素材名称'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|||||||
@ -189,7 +189,7 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
LANGUAGE_CODE = 'zh-hans'
|
LANGUAGE_CODE = 'zh-hans'
|
||||||
TIME_ZONE = 'Asia/Shanghai'
|
TIME_ZONE = 'Asia/Shanghai'
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = False
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = 'static/'
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|||||||
@ -201,12 +201,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assetCard {
|
.assetCard {
|
||||||
|
position: relative;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--color-border-card);
|
border: 1px solid var(--color-border-card);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assetDeleteBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assetCard:hover .assetDeleteBtn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.assetThumb {
|
.assetThumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
|
|||||||
@ -6,6 +6,90 @@ import { ImageLightbox } from './ImageLightbox';
|
|||||||
import type { AssetGroup, AssetItem } from '../types';
|
import type { AssetGroup, AssetItem } from '../types';
|
||||||
import styles from './AssetLibraryModal.module.css';
|
import styles from './AssetLibraryModal.module.css';
|
||||||
|
|
||||||
|
/** Validate asset file before upload. Returns error message or null if valid. */
|
||||||
|
async function validateAssetFile(file: File): Promise<string | null> {
|
||||||
|
const ct = file.type || '';
|
||||||
|
|
||||||
|
if (ct.startsWith('image/')) {
|
||||||
|
// Format: accept all image/* since backend checks ext
|
||||||
|
if (file.size > 30 * 1024 * 1024) return '图片文件不能超过 30MB';
|
||||||
|
// Dimension check
|
||||||
|
try {
|
||||||
|
const dims = await new Promise<{ w: number; h: number }>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
img.onload = () => { resolve({ w: img.naturalWidth, h: img.naturalHeight }); URL.revokeObjectURL(url); };
|
||||||
|
img.onerror = () => { reject(); URL.revokeObjectURL(url); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
if (dims.w <= 300 || dims.h <= 300) return `图片尺寸过小(${dims.w}×${dims.h}),宽高需在 300~6000 像素之间`;
|
||||||
|
if (dims.w >= 6000 || dims.h >= 6000) return `图片尺寸过大(${dims.w}×${dims.h}),宽高需在 300~6000 像素之间`;
|
||||||
|
const ratio = dims.w / dims.h;
|
||||||
|
if (ratio <= 0.4 || ratio >= 2.5) return `图片比例不支持(${dims.w}×${dims.h}),宽高比需在 0.4~2.5 之间`;
|
||||||
|
} catch {
|
||||||
|
// Can't read dimensions (e.g. HEIC), skip — backend will validate
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.startsWith('video/')) {
|
||||||
|
if (ct !== 'video/mp4' && ct !== 'video/quicktime') return '仅支持 MP4 和 MOV 格式的视频';
|
||||||
|
if (file.size > 50 * 1024 * 1024) return '视频文件不能超过 50MB';
|
||||||
|
// Duration + dimension check
|
||||||
|
try {
|
||||||
|
const info = await new Promise<{ dur: number; w: number; h: number }>((resolve, reject) => {
|
||||||
|
const vid = document.createElement('video');
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const timeout = setTimeout(() => { reject(); URL.revokeObjectURL(url); }, 10000);
|
||||||
|
vid.addEventListener('loadedmetadata', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({ dur: vid.duration, w: vid.videoWidth, h: vid.videoHeight });
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
vid.addEventListener('error', () => { clearTimeout(timeout); reject(); URL.revokeObjectURL(url); });
|
||||||
|
vid.src = url;
|
||||||
|
});
|
||||||
|
if (info.dur < 2 || info.dur > 15.4) return `视频时长需在 2~15 秒之间(当前 ${info.dur.toFixed(1)} 秒)`;
|
||||||
|
if (info.w < 300 || info.h < 300) return `视频尺寸过小(${info.w}×${info.h}),宽高需在 300~6000 像素之间`;
|
||||||
|
if (info.w > 6000 || info.h > 6000) return `视频尺寸过大(${info.w}×${info.h}),宽高需在 300~6000 像素之间`;
|
||||||
|
const ratio = info.w / info.h;
|
||||||
|
if (ratio < 0.4 || ratio > 2.5) return `视频比例不支持(${info.w}×${info.h}),宽高比需在 0.4~2.5 之间`;
|
||||||
|
const pixels = info.w * info.h;
|
||||||
|
if (pixels < 409600) return `视频像素过低(${info.w}×${info.h}=${pixels.toLocaleString()}),需在 409,600~927,408 之间`;
|
||||||
|
if (pixels > 927408) return `视频像素过高(${info.w}×${info.h}=${pixels.toLocaleString()}),需在 409,600~927,408 之间`;
|
||||||
|
} catch {
|
||||||
|
// Can't read metadata, skip — backend will validate
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.startsWith('audio/')) {
|
||||||
|
if (ct !== 'audio/mpeg' && ct !== 'audio/wav') return '仅支持 MP3 和 WAV 格式的音频';
|
||||||
|
if (file.size > 15 * 1024 * 1024) return '音频文件不能超过 15MB';
|
||||||
|
// Duration check
|
||||||
|
try {
|
||||||
|
const dur = await new Promise<number>((resolve, reject) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const timeout = setTimeout(() => { reject(); URL.revokeObjectURL(url); }, 10000);
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(audio.duration);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
audio.addEventListener('error', () => { clearTimeout(timeout); reject(); URL.revokeObjectURL(url); });
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
if (dur < 2 || dur > 15.4) return `音频时长需在 2~15 秒之间(当前 ${dur.toFixed(1)} 秒)`;
|
||||||
|
} catch {
|
||||||
|
// Can't read metadata, skip
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '不支持的文件类型';
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -23,7 +107,6 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const addFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const groups = useAssetLibraryStore((s) => s.groups);
|
const groups = useAssetLibraryStore((s) => s.groups);
|
||||||
const loading = useAssetLibraryStore((s) => s.loading);
|
const loading = useAssetLibraryStore((s) => s.loading);
|
||||||
@ -111,10 +194,12 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
}, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]);
|
}, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]);
|
||||||
|
|
||||||
const handleFileSelect = useCallback((file: File) => {
|
const handleFileSelect = useCallback(async (file: File) => {
|
||||||
|
const error = await validateAssetFile(file);
|
||||||
|
if (error) { showToast(error); return; }
|
||||||
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
|
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
|
||||||
setUploadFile(file);
|
setUploadFile(file);
|
||||||
setUploadPreview(URL.createObjectURL(file));
|
setUploadPreview(file.type.startsWith('image/') ? URL.createObjectURL(file) : null);
|
||||||
}, [uploadPreview]);
|
}, [uploadPreview]);
|
||||||
|
|
||||||
const refreshGroupDetail = useCallback(async () => {
|
const refreshGroupDetail = useCallback(async () => {
|
||||||
@ -127,6 +212,8 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
|
|
||||||
const handleAddAsset = useCallback(async (file: File) => {
|
const handleAddAsset = useCallback(async (file: File) => {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
|
const error = await validateAssetFile(file);
|
||||||
|
if (error) { showToast(error); return; }
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
try {
|
try {
|
||||||
@ -158,7 +245,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const file = e.dataTransfer.files[0];
|
const file = e.dataTransfer.files[0];
|
||||||
if (file && file.type.startsWith('image/')) {
|
if (file && (file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/'))) {
|
||||||
handleFileSelect(file);
|
handleFileSelect(file);
|
||||||
}
|
}
|
||||||
}, [handleFileSelect]);
|
}, [handleFileSelect]);
|
||||||
@ -290,26 +377,12 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
{view === 'detail' && selectedGroup && (
|
{view === 'detail' && selectedGroup && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button className={styles.actionBtn} onClick={() => addFileInputRef.current?.click()}>
|
|
||||||
+ 追加图片
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className={styles.actionBtnOutline}
|
className={styles.actionBtnOutline}
|
||||||
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
|
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
|
||||||
>
|
>
|
||||||
✎ 改名
|
✎ 改名
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
ref={addFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) handleAddAsset(file);
|
|
||||||
e.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingName && editingName.id === selectedGroup.id && (
|
{editingName && editingName.id === selectedGroup.id && (
|
||||||
@ -342,35 +415,100 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groupAssets.length === 0 ? (
|
{/* ── 按类型分区显示 ── */}
|
||||||
<div className={styles.empty}>暂无素材图片</div>
|
{(['Image', 'Video', 'Audio'] as const).map((assetType) => {
|
||||||
) : (
|
const typeAssets = groupAssets.filter((a) => (a.asset_type || 'Image') === assetType);
|
||||||
<div className={styles.assetGrid}>
|
const typeLabel = assetType === 'Image' ? '肖像(图片)' : assetType === 'Video' ? '视频' : '音频';
|
||||||
{groupAssets.map((asset) => (
|
const acceptMap = { Image: 'image/*', Video: 'video/mp4,video/quicktime', Audio: 'audio/mpeg,audio/wav' };
|
||||||
<div key={asset.id} className={styles.assetCard}>
|
const hintMap = {
|
||||||
<img
|
Image: '支持 JPG、PNG、WEBP、HEIC,单张不超过 30MB',
|
||||||
src={tosThumb(asset.url, 300)}
|
Video: '支持 MP4、MOV,单个不超过 50MB',
|
||||||
alt={asset.name}
|
Audio: '支持 MP3、WAV,单个不超过 15MB',
|
||||||
className={styles.assetThumb}
|
};
|
||||||
style={{ cursor: 'zoom-in' }}
|
const warningMap = {
|
||||||
onClick={() => setLightboxSrc(asset.url)}
|
Image: '⚠️ 宽高 300~6000 像素,宽高比 0.4~2.5',
|
||||||
/>
|
Video: '⚠️ 时长 2~15 秒,宽高 300~6000 像素,帧率 24~60 FPS',
|
||||||
<div className={styles.assetInfo}>
|
Audio: '⚠️ 时长 2~15 秒',
|
||||||
<div className={styles.assetName}>{asset.name}</div>
|
};
|
||||||
<span className={`${styles.statusBadge} ${
|
return (
|
||||||
asset.status === 'active' ? styles.statusActive
|
<div key={assetType} style={{ marginBottom: 20 }}>
|
||||||
: asset.status === 'processing' ? styles.statusProcessing
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||||
: styles.statusFailed
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{typeLabel}</span>
|
||||||
}`}>
|
<label className={styles.actionBtn} style={{ cursor: 'pointer', fontSize: 12, padding: '3px 10px' }}>
|
||||||
{asset.status === 'active' && '可用'}
|
+ 追加
|
||||||
{asset.status === 'processing' && '处理中'}
|
<input
|
||||||
{asset.status === 'failed' && '失败'}
|
type="file"
|
||||||
</span>
|
accept={acceptMap[assetType]}
|
||||||
</div>
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleAddAsset(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div style={{ fontSize: 11, color: 'var(--color-text-disabled)', marginBottom: 2 }}>{hintMap[assetType]}</div>
|
||||||
</div>
|
<div style={{ fontSize: 11, color: '#e8952e', marginBottom: 8 }}>{warningMap[assetType]}</div>
|
||||||
)}
|
{typeAssets.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-disabled)', padding: '12px 0' }}>暂无,点击上方按钮上传</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.assetGrid}>
|
||||||
|
{typeAssets.map((asset) => (
|
||||||
|
<div key={asset.id} className={styles.assetCard}>
|
||||||
|
{assetType === 'Video' ? (
|
||||||
|
<video src={asset.url} className={styles.assetThumb} muted preload="metadata" />
|
||||||
|
) : assetType === 'Audio' ? (
|
||||||
|
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}>♫</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={tosThumb(asset.url, 300)}
|
||||||
|
alt={asset.name}
|
||||||
|
className={styles.assetThumb}
|
||||||
|
style={{ cursor: 'zoom-in' }}
|
||||||
|
onClick={() => setLightboxSrc(asset.url)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={styles.assetDeleteBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm('确认删除此素材?删除后无法恢复。')) {
|
||||||
|
assetsApi.deleteAsset(asset.id).then(() => {
|
||||||
|
showToast('素材已删除');
|
||||||
|
if (selectedGroup) {
|
||||||
|
assetsApi.getGroupDetail(selectedGroup.id).then(({ data }) => {
|
||||||
|
setGroupAssets(data.assets || []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadGroups(page);
|
||||||
|
}).catch(() => showToast('删除失败,请重试'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="删除素材"
|
||||||
|
>×</button>
|
||||||
|
<div className={styles.assetInfo}>
|
||||||
|
<div className={styles.assetName}>{asset.name}</div>
|
||||||
|
<span
|
||||||
|
className={`${styles.statusBadge} ${
|
||||||
|
asset.status === 'active' ? styles.statusActive
|
||||||
|
: asset.status === 'processing' ? styles.statusProcessing
|
||||||
|
: styles.statusFailed
|
||||||
|
}`}
|
||||||
|
title={asset.status === 'failed' ? (asset.error_message || '素材处理失败,请删除后重新上传') : undefined}
|
||||||
|
>
|
||||||
|
{asset.status === 'active' && '可用'}
|
||||||
|
{asset.status === 'processing' && '处理中'}
|
||||||
|
{asset.status === 'failed' && '失败'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -389,7 +527,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.inputLabel}>角色图片</div>
|
<div className={styles.inputLabel}>素材文件</div>
|
||||||
<div
|
<div
|
||||||
className={`${styles.dropZone} ${dragOver ? styles.dropZoneActive : ''}`}
|
className={`${styles.dropZone} ${dragOver ? styles.dropZoneActive : ''}`}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
@ -397,25 +535,32 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
onDragLeave={() => setDragOver(false)}
|
onDragLeave={() => setDragOver(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
{uploadPreview ? (
|
{uploadFile ? (
|
||||||
<>
|
<>
|
||||||
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
|
{uploadPreview ? (
|
||||||
<div className={styles.dropZoneHint}>点击重新选择</div>
|
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 32, padding: '16px 0' }}>
|
||||||
|
{uploadFile.type.startsWith('video/') ? '🎬' : '♫'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.dropZoneHint}>{uploadFile.name}</div>
|
||||||
|
<div className={styles.dropZoneHint} style={{ color: 'var(--color-text-disabled)' }}>点击重新选择</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={styles.dropZoneText}>上传角色图片</div>
|
<div className={styles.dropZoneText}>上传素材文件</div>
|
||||||
<div className={styles.dropZoneHint}>将角色的正面图或三视图拖拽到这里,或点击选择文件</div>
|
<div className={styles.dropZoneHint}>将素材拖拽到这里,或点击选择文件</div>
|
||||||
<div className={styles.dropZoneHint}>支持 JPG、PNG 格式,单张不超过 30MB</div>
|
<div className={styles.dropZoneHint}>支持图片(JPG/PNG/WEBP/HEIC)、视频(MP4/MOV)、音频(MP3/WAV)</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={styles.dropZoneWarning}>⚠️ 素材上传后无法删除,请确认后再上传</div>
|
<div className={styles.dropZoneWarning}>⚠️ 图片:宽高 300~6000px,比例 0.4~2.5</div>
|
||||||
<div className={styles.dropZoneWarning}>⚠️ 图片尺寸要求:宽高均需在 300~6000 像素之间</div>
|
<div className={styles.dropZoneWarning}>⚠️ 视频:2~15秒,≤50MB | 音频:2~15秒,≤15MB</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*,video/mp4,video/quicktime,audio/mpeg,audio/wav"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|||||||
@ -424,6 +424,8 @@ export const assetsApi = {
|
|||||||
api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
||||||
updateAsset: (id: number, data: { name: string }) =>
|
updateAsset: (id: number, data: { name: string }) =>
|
||||||
api.put(`/assets/${id}`, data),
|
api.put(`/assets/${id}`, data),
|
||||||
|
deleteAsset: (id: number) =>
|
||||||
|
api.delete(`/assets/${id}`),
|
||||||
search: (q: string) =>
|
search: (q: string) =>
|
||||||
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
|
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
|
||||||
pollStatus: (id: number) =>
|
pollStatus: (id: number) =>
|
||||||
|
|||||||
@ -435,6 +435,7 @@ export interface AssetItem {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
asset_type: 'Image' | 'Video' | 'Audio';
|
||||||
status: 'processing' | 'active' | 'failed';
|
status: 'processing' | 'active' | 'failed';
|
||||||
remote_asset_id: string;
|
remote_asset_id: string;
|
||||||
error_message: string;
|
error_message: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user