fix(canvas): 分镜视频生成 - 正确附加角色场景与声音资产及镜头台词
This commit is contained in:
@@ -109,6 +109,27 @@ func (r *Repository) ProjectUnitShotAssetReferences(projectID string, unitID str
|
||||
return references, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectAssetVersionsByIDs(projectID string, versionIDs []string) ([]model.AssetVersion, error) {
|
||||
if len(versionIDs) == 0 {
|
||||
return []model.AssetVersion{}, nil
|
||||
}
|
||||
var versions []model.AssetVersion
|
||||
err := r.db.Table("asset_versions").Select("asset_versions.*").
|
||||
Joins("JOIN project_asset_links ON project_asset_links.asset_id = asset_versions.asset_id").
|
||||
Where("project_asset_links.project_id = ? AND asset_versions.id IN ?", projectID, versionIDs).
|
||||
Find(&versions).Error
|
||||
return versions, err
|
||||
}
|
||||
|
||||
func (r *Repository) AssetRepresentationsByVersionIDs(versionIDs []string) ([]model.AssetRepresentation, error) {
|
||||
if len(versionIDs) == 0 {
|
||||
return []model.AssetRepresentation{}, nil
|
||||
}
|
||||
var representations []model.AssetRepresentation
|
||||
err := r.db.Where("asset_version_id IN ?", versionIDs).Order("asset_version_id asc, role asc, created_at asc").Find(&representations).Error
|
||||
return representations, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectUnitAssetCandidates(projectID string, unitID string) ([]model.ProjectAssetCandidate, error) {
|
||||
var candidates []model.ProjectAssetCandidate
|
||||
err := r.db.Where("project_id = ? AND (unit_id = ? OR unit_id = '')", projectID, unitID).Order("created_at asc").Find(&candidates).Error
|
||||
|
||||
@@ -49,12 +49,28 @@ type ProjectUnitWorkspace struct {
|
||||
Shots []model.Shot `json:"shots"`
|
||||
ShotRevisions []model.ShotRevision `json:"shotRevisions"`
|
||||
ShotArtifacts []model.ShotArtifact `json:"shotArtifacts"`
|
||||
ShotReferences []model.ShotAssetReference `json:"shotReferences"`
|
||||
ShotReferences []ProjectShotAssetReference `json:"shotReferences"`
|
||||
AssetCandidates []model.ProjectAssetCandidate `json:"assetCandidates"`
|
||||
Assets []ProjectAssetSummary `json:"assets"`
|
||||
Tasks []TaskSummary `json:"tasks"`
|
||||
}
|
||||
|
||||
// ProjectShotAssetReference keeps the immutable visual version selected by the
|
||||
// shot while also exposing the asset's current card configuration (notably its
|
||||
// voice binding) for generation-time prompt assembly.
|
||||
type ProjectShotAssetReference struct {
|
||||
model.ShotAssetReference
|
||||
Asset ProjectAssetSummary `json:"asset"`
|
||||
ReferencedVersion ProjectShotAssetReferenceVersion `json:"referencedVersion"`
|
||||
}
|
||||
|
||||
type ProjectShotAssetReferenceVersion struct {
|
||||
ID string `json:"id"`
|
||||
AssetID string `json:"assetId"`
|
||||
Version int `json:"version"`
|
||||
Representations []CharacterRepresentationSummary `json:"representations"`
|
||||
}
|
||||
|
||||
type ProjectCanvasPage struct {
|
||||
Canvases []model.CanvasProject `json:"canvases"`
|
||||
CanvasUnitLinks []model.CanvasUnitLink `json:"canvasUnitLinks"`
|
||||
@@ -167,6 +183,7 @@ func (s *Service) ProjectUnitWorkspace(userID string, projectID string, unitID s
|
||||
}
|
||||
result := ProjectUnitWorkspace{Unit: *unit}
|
||||
var projectTasks []TaskSummary
|
||||
var shotReferences []model.ShotAssetReference
|
||||
var group errgroup.Group
|
||||
group.Go(func() error {
|
||||
var err error
|
||||
@@ -185,7 +202,7 @@ func (s *Service) ProjectUnitWorkspace(userID string, projectID string, unitID s
|
||||
})
|
||||
group.Go(func() error {
|
||||
var err error
|
||||
result.ShotReferences, err = s.repo.ProjectUnitShotAssetReferences(projectID, unitID)
|
||||
shotReferences, err = s.repo.ProjectUnitShotAssetReferences(projectID, unitID)
|
||||
return err
|
||||
})
|
||||
group.Go(func() error {
|
||||
@@ -251,6 +268,54 @@ func (s *Service) ProjectUnitWorkspace(userID string, projectID string, unitID s
|
||||
if err := group.Wait(); err != nil {
|
||||
return ProjectUnitWorkspace{}, err
|
||||
}
|
||||
assetByID := make(map[string]ProjectAssetSummary, len(result.Assets))
|
||||
for _, asset := range result.Assets {
|
||||
assetByID[asset.ID] = asset
|
||||
}
|
||||
versionIDs := make([]string, 0, len(shotReferences))
|
||||
for _, reference := range shotReferences {
|
||||
versionIDs = append(versionIDs, reference.AssetVersionID)
|
||||
}
|
||||
versions, err := s.repo.ProjectAssetVersionsByIDs(projectID, versionIDs)
|
||||
if err != nil {
|
||||
return ProjectUnitWorkspace{}, err
|
||||
}
|
||||
storedRepresentations, err := s.repo.AssetRepresentationsByVersionIDs(versionIDs)
|
||||
if err != nil {
|
||||
return ProjectUnitWorkspace{}, err
|
||||
}
|
||||
versionByID := make(map[string]model.AssetVersion, len(versions))
|
||||
for _, version := range versions {
|
||||
versionByID[version.ID] = version
|
||||
}
|
||||
representationsByVersionID := make(map[string][]CharacterRepresentationSummary, len(versionIDs))
|
||||
for _, representation := range storedRepresentations {
|
||||
representationsByVersionID[representation.AssetVersionID] = append(representationsByVersionID[representation.AssetVersionID], CharacterRepresentationSummary{
|
||||
ID: representation.ID, ResourceID: representation.ResourceID, MediaType: representation.MediaType, Role: representation.Role,
|
||||
})
|
||||
}
|
||||
result.ShotReferences = make([]ProjectShotAssetReference, 0, len(shotReferences))
|
||||
for _, reference := range shotReferences {
|
||||
version, exists := versionByID[reference.AssetVersionID]
|
||||
if !exists {
|
||||
return ProjectUnitWorkspace{}, BadAuthRequest("镜头引用的资产版本不可用")
|
||||
}
|
||||
asset, exists := assetByID[version.AssetID]
|
||||
if !exists {
|
||||
return ProjectUnitWorkspace{}, BadAuthRequest("镜头引用的项目资产不可用")
|
||||
}
|
||||
representations := representationsByVersionID[version.ID]
|
||||
if representations == nil {
|
||||
representations = []CharacterRepresentationSummary{}
|
||||
}
|
||||
result.ShotReferences = append(result.ShotReferences, ProjectShotAssetReference{
|
||||
ShotAssetReference: reference,
|
||||
Asset: asset,
|
||||
ReferencedVersion: ProjectShotAssetReferenceVersion{
|
||||
ID: version.ID, AssetID: version.AssetID, Version: version.Version, Representations: representations,
|
||||
},
|
||||
})
|
||||
}
|
||||
shotIDs := make(map[string]struct{}, len(result.Shots))
|
||||
for _, shot := range result.Shots {
|
||||
shotIDs[shot.ID] = struct{}{}
|
||||
|
||||
@@ -21,6 +21,7 @@ func newProjectWorkbenchReadTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
if err := db.AutoMigrate(
|
||||
&model.Project{}, &model.ProjectUnit{}, &model.CanvasProject{}, &model.CanvasUnitLink{},
|
||||
&model.Asset{}, &model.AssetVersion{}, &model.AssetRepresentation{}, &model.ProjectAssetLink{}, &model.ProjectAssetCandidate{},
|
||||
&model.CharacterVoiceBinding{}, &model.VoiceProfile{},
|
||||
&model.Shot{}, &model.ShotRevision{}, &model.ShotArtifact{}, &model.ShotAssetReference{},
|
||||
&model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.Task{},
|
||||
); err != nil {
|
||||
@@ -29,6 +30,52 @@ func newProjectWorkbenchReadTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
return &Service{repo: repository.New(db)}, db
|
||||
}
|
||||
|
||||
func TestProjectUnitWorkspaceResolvesHistoricalVisualAndCurrentCharacterVoice(t *testing.T) {
|
||||
service, db := newProjectWorkbenchReadTestService(t)
|
||||
project := seedWorkbenchProject(t, db)
|
||||
unit := model.ProjectUnit{ID: "unit-1", ProjectID: project.ID, Title: "第一章", Status: model.ProjectUnitStatusReady}
|
||||
asset := model.Asset{
|
||||
ID: "character-1", UserID: "user-1", Title: "张天昊", Kind: "entity", Category: model.AssetCategoryCharacter,
|
||||
Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: "character-v2", PayloadJSON: `{}`,
|
||||
}
|
||||
seed := []any{
|
||||
&unit,
|
||||
&asset,
|
||||
&model.ProjectAssetLink{ID: "link-1", ProjectID: project.ID, AssetID: asset.ID},
|
||||
&model.AssetVersion{ID: "character-v1", AssetID: asset.ID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: `{}`},
|
||||
&model.AssetVersion{ID: "character-v2", AssetID: asset.ID, Version: 2, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: `{"voiceLanguage":"普通话","voiceAge":"青年男性","voiceTimbre":"略带疲惫和震惊"}`},
|
||||
&model.AssetRepresentation{ID: "representation-v1", TaskID: "visual-v1", AssetVersionID: "character-v1", ResourceID: "character-image-v1", MediaType: "image/png", Role: "primary"},
|
||||
&model.AssetRepresentation{ID: "representation-v2", TaskID: "visual-v2", AssetVersionID: "character-v2", ResourceID: "character-image-v2", MediaType: "image/png", Role: "primary"},
|
||||
&model.VoiceProfile{ID: "voice-1", UserID: "user-1", Name: "张天昊声音", Provider: "custom", VoiceKey: "voice-1", Language: "普通话", Timbre: "青年男声", SampleResourceID: "voice-sample-1", Status: "active", CompatibleModelsJSON: `[]`},
|
||||
&model.CharacterVoiceBinding{ID: "binding-1", AssetVersionID: "character-v2", VoiceProfileID: "voice-1", Instructions: "内心独白语气"},
|
||||
&model.Shot{ID: "shot-1", ProjectID: project.ID, UnitID: unit.ID, Position: 0},
|
||||
&model.ShotAssetReference{ID: "reference-1", ShotID: "shot-1", AssetVersionID: "character-v1", Role: "reference", Status: "linked"},
|
||||
}
|
||||
for _, item := range seed {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
workspace, err := service.ProjectUnitWorkspace("user-1", project.ID, unit.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(workspace.ShotReferences) != 1 {
|
||||
t.Fatalf("shot references = %d, want 1", len(workspace.ShotReferences))
|
||||
}
|
||||
reference := workspace.ShotReferences[0]
|
||||
if reference.ReferencedVersion.ID != "character-v1" || len(reference.ReferencedVersion.Representations) != 1 || reference.ReferencedVersion.Representations[0].ResourceID != "character-image-v1" {
|
||||
t.Fatalf("historical visual snapshot not preserved: %+v", reference.ReferencedVersion)
|
||||
}
|
||||
if reference.Asset.Character == nil || reference.Asset.Character.VersionID != "character-v2" || reference.Asset.Character.Voice == nil {
|
||||
t.Fatalf("current character card not resolved: %+v", reference.Asset.Character)
|
||||
}
|
||||
if reference.Asset.Character.Voice.Profile.SampleResourceID != "voice-sample-1" || reference.Asset.Character.Voice.Instructions != "内心独白语气" {
|
||||
t.Fatalf("current character voice not resolved: %+v", reference.Asset.Character.Voice)
|
||||
}
|
||||
}
|
||||
|
||||
func seedWorkbenchProject(t *testing.T, db *gorm.DB) model.Project {
|
||||
t.Helper()
|
||||
project := model.Project{ID: "project-1", UserID: "user-1", Name: "长篇短剧", Status: model.ProjectStatusActive}
|
||||
|
||||
Reference in New Issue
Block a user