Repository navigation
Expand file tree
/
Copy pathcomposition_editor.go
More file actions
510 lines (484 loc) · 20.7 KB
/
Copy pathcomposition_editor.go
File metadata and controls
510 lines (484 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
package main
// The video composer's Go surface — the third session-free editor beside
// subtitles and markdown: resolve a cmp_ artifact into the editor's document,
// resolve and measure one media artifact into a ref the picker can add, seed
// a draft from referenced media (the "open the video composer" intent's
// seeded-only contract), and commit an edited document back into its
// conversation as a NEW artifact. The source is never overwritten; every save
// is a revision. Nothing here renders — composition_render.go owns the
// ffmpeg side, and a draft that was never saved never touches the
// conversation, which is what lets a mid-stream editor launch open the
// workspace without writing a turn into a streaming conversation.
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"atelier/internal/mediasniff"
)
// compositionProbeTimeout bounds the measurement of one media ref — ffprobe
// on a local file is fast, and a ref that cannot be measured in this window
// is better refused than left hanging the picker.
const compositionProbeTimeout = 60 * time.Second
// CompositionSourceInfo is what the editor loads: the document plus the
// identity the reply chips and the picker's provenance labels need. For a
// seeded draft ArtifactID and URL are empty — nothing is persisted until the
// first save — and the EditorHeader shows its "unsaved draft" breadcrumb.
// MissingRefs names the referenced artifacts whose files are gone on disk
// (PR-06, identify-only): the timeline keeps them, flagged; a render refuses
// while any remain.
type CompositionSourceInfo struct {
ConversationID string `json:"conversationId"`
ConversationTitle string `json:"conversationTitle,omitempty"`
ArtifactID string `json:"artifactId,omitempty"`
URL string `json:"url,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Doc CompositionDoc `json:"doc"`
MissingRefs []string `json:"missingRefs"`
Warnings []string `json:"warnings"`
}
// resolveCompositionContent finds the composition content entry an editor
// open names, plus the conversation title for provenance labels. artifactID
// is exact; the entry must be a composition artifact.
func resolveCompositionContent(storage ConfigStorage, conversationID, artifactID string) (*HistoryContent, string, error) {
artifactID = strings.TrimSpace(artifactID)
if artifactID == "" {
return nil, "", errors.New("composition artifact id is empty")
}
detail, err := getConversation(storage, conversationID)
if err != nil {
return nil, "", err
}
for i := len(detail.Turns) - 1; i >= 0; i-- {
for j := range detail.Turns[i].Content {
content := detail.Turns[i].Content[j]
if content.ArtifactID == artifactID && compositionArtifactExtension(content) != "" {
return &content, detail.Conversation.Title, nil
}
}
}
return nil, "", fmt.Errorf("no composition artifact with id %q in this conversation", artifactID)
}
// ResolveCompositionEditSource loads a saved composition revision into the
// editor. Read-only — nothing here mutates the conversation. The document
// is validated on the way in, so a hand-edited artifact that drifted from
// the rules fails here with the rule named rather than inside the editor.
func (a *App) ResolveCompositionEditSource(conversationID, artifactID string) (CompositionSourceInfo, error) {
config, err := loadReadyConfig()
if err != nil {
return CompositionSourceInfo{}, err
}
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" {
return CompositionSourceInfo{}, errors.New("the video composer opens a conversation asset, and no conversation was given")
}
content, title, err := resolveCompositionContent(config.Storage, conversationID, artifactID)
if err != nil {
return CompositionSourceInfo{}, err
}
path, err := contentArtifactPath(config.Storage, conversationID, *content)
if err != nil {
return CompositionSourceInfo{}, err
}
data, err := os.ReadFile(path)
if err != nil {
return CompositionSourceInfo{}, fmt.Errorf("reading the composition artifact failed: %w", err)
}
doc, err := parseCompositionDoc(data)
if err != nil {
return CompositionSourceInfo{}, err
}
if err := validateCompositionDoc(&doc); err != nil {
return CompositionSourceInfo{}, fmt.Errorf("the saved composition is not valid: %w", err)
}
absPath, err := filepath.Abs(path)
if err != nil {
absPath = path
}
return CompositionSourceInfo{
ConversationID: conversationID,
ConversationTitle: title,
ArtifactID: content.ArtifactID,
URL: artifactPrefix + absPath,
MimeType: content.MimeType,
Doc: doc,
MissingRefs: compositionMissingRefs(doc),
Warnings: []string{},
}, nil
}
// compositionMediaRefFor resolves an artifact id into an unmeasured ref —
// conversation-locally first, then across the conversation's library when it
// belongs to a project (the picker's dual scope, and resolveReferencedAssets'
// widening rule). Video, audio, and image artifacts qualify — an image enters
// as a still on the video lane; the file must exist on disk, since a ref that
// is missing at add time is a mistake rather than a later loss.
func compositionMediaRefFor(storage ConfigStorage, conversationID, artifactID string) (CompositionMediaRef, error) {
artifactID = strings.TrimSpace(artifactID)
if artifactID == "" {
return CompositionMediaRef{}, errors.New("media artifact id is empty")
}
detail, err := getConversation(storage, conversationID)
if err != nil {
return CompositionMediaRef{}, err
}
var ref CompositionMediaRef
if content, ok := assetContentIndex(detail)[artifactID]; ok {
if !compositionMediaKind(content.Type) {
return CompositionMediaRef{}, fmt.Errorf("%s is %s media — the composer takes video, audio, and images", artifactID, compositionRefKindPhrase(content.Type))
}
path, err := contentArtifactPath(storage, conversationID, content)
if err != nil {
return CompositionMediaRef{}, err
}
ref = CompositionMediaRef{
ArtifactID: content.ArtifactID,
ConversationID: conversationID,
Path: path,
MimeType: content.MimeType,
Kind: content.Type,
}
if ref.ArtifactID == "" {
ref.ArtifactID = artifactID
}
} else {
projectID := detail.Conversation.ProjectID
if projectID == "" {
return CompositionMediaRef{}, fmt.Errorf("no video, audio, or image asset with id %q in this conversation", artifactID)
}
lookup, err := libraryAssetLookup(storage, projectID)
if err != nil {
return CompositionMediaRef{}, err
}
asset, found := lookup[artifactID]
if !found {
return CompositionMediaRef{}, fmt.Errorf("no video, audio, or image asset with id %q in this conversation or its library", artifactID)
}
if !compositionMediaKind(asset.Kind) {
return CompositionMediaRef{}, fmt.Errorf("%s is %s media — the composer takes video, audio, and images", artifactID, compositionRefKindPhrase(asset.Kind))
}
if asset.URL == "" {
return CompositionMediaRef{}, fmt.Errorf("the file for %s is missing on disk", artifactID)
}
ref = CompositionMediaRef{
ArtifactID: asset.ID,
ConversationID: asset.ConversationID,
Path: strings.TrimPrefix(asset.URL, artifactPrefix),
MimeType: asset.MimeType,
Kind: asset.Kind,
}
}
absPath, err := filepath.Abs(ref.Path)
if err == nil {
ref.Path = absPath
}
if _, err := os.Stat(ref.Path); err != nil {
return CompositionMediaRef{}, fmt.Errorf("the file for %s is missing on disk", ref.ArtifactID)
}
return ref, nil
}
// probeCompositionMediaRef fills a ref's facts snapshot: ffprobe when it
// resolves, else the pure-Go container sniffs (MP4 boxes for video, the
// WAV/MP3/FLAC/OGG/M4A headers for audio) — the join tools' measurement
// ladder. A clip the ladder cannot measure is refused: the timeline cannot
// lay out a clip of unknown length, and guessing would make every later
// trim wrong. A still needs no ladder: its own bytes name its display size
// (measureCompositionStill), and it has no duration to find.
func probeCompositionMediaRef(ctx context.Context, config AppConfig, ref CompositionMediaRef) (CompositionMediaRef, error) {
if ref.Kind == "image" {
still, err := measureCompositionStillFile(ref.Path)
if err != nil {
return CompositionMediaRef{}, fmt.Errorf("could not use %s as a still (%v) — convert it to PNG or JPEG (convert_image) to add it", ref.ArtifactID, err)
}
ref.Width, ref.Height = still.Width, still.Height
ref.DurationSeconds, ref.FPS, ref.HasAudio = 0, 0, false
return ref, nil
}
artifactURL := artifactPrefix + ref.Path
if ref.Kind == "audio" {
fact, ok := joinAudioClipFactFor(ctx, config, ref.Path, artifactURL)
if !ok || fact.Duration <= 0 {
return CompositionMediaRef{}, fmt.Errorf("could not measure %s — the composer needs its duration; install ffmpeg (ffprobe) to add this format", ref.ArtifactID)
}
ref.DurationSeconds = fact.Duration
ref.HasAudio = true
return ref, nil
}
fact, ok := joinClipFactFor(ctx, config, ref.Path, artifactURL)
if !ok || fact.Duration <= 0 {
return CompositionMediaRef{}, fmt.Errorf("could not measure %s — the composer needs its duration and frame size; install ffmpeg (ffprobe) to add this format", ref.ArtifactID)
}
ref.DurationSeconds = fact.Duration
ref.Width = fact.Width
ref.Height = fact.Height
ref.FPS = fact.FPS
ref.HasAudio = fact.HasAudio
return ref, nil
}
// compositionMediaKind reports whether an artifact kind can enter a
// composition: video and images on the video lane (an image as a still),
// audio on the lanes.
func compositionMediaKind(kind string) bool {
return kind == "video" || kind == "audio" || kind == "image"
}
// compositionStillFact is one still image's measurement — what the timeline
// places it by and the renderer reads it with: the DISPLAY size (the pixel
// size with any JPEG EXIF orientation applied — a portrait phone photo reads
// portrait), the ffmpeg decoder its magic bytes name (the render forces it
// rather than trusting the file extension), and the EXIF orientation
// (mediasniff.JPEGEXIFOrientation's 2–8 turn the picture; 0 or 1 leave it as stored) the
// render graph applies itself, so the result is the same on an ffmpeg that
// auto-rotates EXIF and on one that does not.
type compositionStillFact struct {
Width int
Height int
Decoder string
Orientation int
}
// measureCompositionStill sniffs a still from its bytes: PNG, JPEG, WebP, and
// GIF — the formats ffmpeg's image2 reader decodes under a forced decoder —
// sized by mediasniff.ImagePixelDimensions's header read (no codec import, no CLI). An
// animated WebP refuses (ffmpeg's webp decoder cannot read its frames, so the
// render would fail); an animated GIF holds its first frame. HEIC, AVIF, and
// everything else refuse — convert_image is the way in.
func measureCompositionStill(data []byte) (compositionStillFact, error) {
decoder := compositionStillDecoder(data)
if decoder == "" {
return compositionStillFact{}, errors.New("not a PNG, JPEG, WebP, or GIF image")
}
if decoder == "webp" && webpAnimated(data) {
return compositionStillFact{}, errors.New("an animated WebP cannot be held as a still")
}
width, height, ok := mediasniff.ImagePixelDimensions(data)
if !ok {
return compositionStillFact{}, errors.New("its pixel size is unreadable")
}
fact := compositionStillFact{Width: int(width), Height: int(height), Decoder: decoder}
if decoder == "mjpeg" {
fact.Orientation = mediasniff.JPEGEXIFOrientation(data)
if fact.Orientation >= 5 {
// Orientations 5–8 transpose the axes: the stored landscape
// pixels display portrait.
fact.Width, fact.Height = fact.Height, fact.Width
}
}
return fact, nil
}
// measureCompositionStillFile is measureCompositionStill over a file on disk.
func measureCompositionStillFile(path string) (compositionStillFact, error) {
data, err := os.ReadFile(path)
if err != nil {
return compositionStillFact{}, err
}
return measureCompositionStill(data)
}
// compositionStillDecoder names the ffmpeg decoder for a still's bytes by
// their magic, or "" for a format the composer does not hold as a still.
func compositionStillDecoder(data []byte) string {
switch {
case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}):
return "png"
case len(data) >= 2 && data[0] == 0xff && data[1] == 0xd8:
return "mjpeg"
case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP":
return "webp"
case len(data) >= 6 && (string(data[:6]) == "GIF87a" || string(data[:6]) == "GIF89a"):
return "gif"
}
return ""
}
// webpAnimated reports an extended (VP8X) WebP whose animation flag is set.
func webpAnimated(data []byte) bool {
return len(data) >= 21 && string(data[12:16]) == "VP8X" && data[20]&0x02 != 0
}
// resolveCompositionMedia is the shared resolve-and-measure the picker's add
// and the seed path both run.
func resolveCompositionMedia(config AppConfig, conversationID, artifactID string) (CompositionMediaRef, error) {
ref, err := compositionMediaRefFor(config.Storage, conversationID, artifactID)
if err != nil {
return CompositionMediaRef{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), compositionProbeTimeout)
defer cancel()
return probeCompositionMediaRef(ctx, config, ref)
}
// ResolveCompositionMedia resolves and measures one video, audio, or image
// artifact into a ref the editor appends to its document — the picker's add
// path. conversationID is the composition's OWNING conversation (its picker
// scope), not necessarily the artifact's: a library sibling's asset resolves
// through the widening rule and carries its own conversation on the ref.
func (a *App) ResolveCompositionMedia(conversationID, artifactID string) (CompositionMediaRef, error) {
config, err := loadReadyConfig()
if err != nil {
return CompositionMediaRef{}, err
}
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" {
return CompositionMediaRef{}, errors.New("adding media to a composition requires its conversation id")
}
return resolveCompositionMedia(config, conversationID, artifactID)
}
// SeedCompositionRequest asks for a fresh draft seeded with referenced media
// — the editor-launch intent's payload and the "New composition" action's
// (with no ids). ArtifactIDs are in reference order, which IS the seeded
// order.
type SeedCompositionRequest struct {
ConversationID string `json:"conversationId"`
Name string `json:"name,omitempty"`
ArtifactIDs []string `json:"artifactIds"`
}
// SeedComposition builds an unsaved draft from referenced media, in order —
// the seeded-only contract: the result is indistinguishable from the user
// having picked those assets from the picker in that order. Read-only;
// nothing is persisted until the editor's first save. An id that will not
// resolve or measure is skipped with a warning rather than failing the open
// (the picker is right there to add it by hand), so the workspace always
// opens.
func (a *App) SeedComposition(req SeedCompositionRequest) (CompositionSourceInfo, error) {
config, err := loadReadyConfig()
if err != nil {
return CompositionSourceInfo{}, err
}
conversationID := strings.TrimSpace(req.ConversationID)
if conversationID == "" {
return CompositionSourceInfo{}, errors.New("a composition belongs to a conversation, and no conversation was given")
}
detail, err := getConversation(config.Storage, conversationID)
if err != nil {
return CompositionSourceInfo{}, err
}
refs := make([]CompositionMediaRef, 0, len(req.ArtifactIDs))
warnings := []string{}
for _, id := range req.ArtifactIDs {
id = strings.TrimSpace(id)
if id == "" {
continue
}
ref, err := resolveCompositionMedia(config, conversationID, id)
if err != nil {
warnings = append(warnings, fmt.Sprintf("Skipped %s: %v", id, err))
continue
}
refs = append(refs, ref)
}
doc := seedCompositionDoc(req.Name, refs)
if err := validateCompositionDoc(&doc); err != nil {
return CompositionSourceInfo{}, err
}
return CompositionSourceInfo{
ConversationID: conversationID,
ConversationTitle: detail.Conversation.Title,
Doc: doc,
MissingRefs: []string{},
Warnings: warnings,
}, nil
}
// SaveCompositionEditRequest carries one commit of the editor's document back
// into its conversation — the single door the other editors have: a new
// cmp_ artifact plus a small assistant turn recording the save. The source
// artifact is provenance for the turn's summary; the original is never
// touched. Saving under a new name from an older revision IS duplication.
type SaveCompositionEditRequest struct {
ConversationID string `json:"conversationId"`
SourceArtifactID string `json:"sourceArtifactId,omitempty"`
Doc CompositionDoc `json:"doc"`
}
// SaveCompositionEditResult names what the save produced: the new artifact's
// identity (the reply chips, the assets panel, a later open, and an export
// all read it) plus the normalized document, so the editor re-baselines on
// exactly what landed.
type SaveCompositionEditResult struct {
ArtifactID string `json:"artifactId"`
URL string `json:"url"`
Doc CompositionDoc `json:"doc"`
Summary string `json:"summary"`
}
// SaveCompositionEdit commits the edited document to the conversation: the
// document lands as a fresh cmp_<hex>.json artifact and a short assistant
// turn records the save (name, clip count, lanes, length). Every save is
// additive. Refused while the conversation streams, like every other
// sanctioned rewrite.
func (a *App) SaveCompositionEdit(req SaveCompositionEditRequest) (SaveCompositionEditResult, error) {
conversationID := strings.TrimSpace(req.ConversationID)
if conversationID == "" {
return SaveCompositionEditResult{}, errors.New("saving a composition to a conversation requires a conversation id")
}
doc := req.Doc
if err := validateCompositionDoc(&doc); err != nil {
return SaveCompositionEditResult{}, err
}
a.streamsMu.Lock()
for _, active := range a.streamConversations {
if active == conversationID {
a.streamsMu.Unlock()
return SaveCompositionEditResult{}, errors.New("the conversation is still streaming — wait for the turn to finish before saving")
}
}
a.streamsMu.Unlock()
config, err := loadReadyConfig()
if err != nil {
return SaveCompositionEditResult{}, err
}
store := newHistoryStore(config.Storage)
loaded, err := store.loadForAppend(conversationID, "chat", "a chat", config.Tools.Filesystem.Root)
if err != nil {
return SaveCompositionEditResult{}, err
}
content, err := writeCompositionArtifact(loaded.ArtifactsDir, doc)
if err != nil {
return SaveCompositionEditResult{}, err
}
source := strings.TrimSpace(req.SourceArtifactID)
summary := fmt.Sprintf("Saved composition “%s” · %s", doc.Name, compositionDocSummary(doc))
if source != "" {
summary = fmt.Sprintf("Saved composition “%s” from %s · %s", doc.Name, source, compositionDocSummary(doc))
}
nowText := time.Now().Format(time.RFC3339)
assistantTurn := buildChatAssistantTurn(conversationID, loaded.NextTurnNumber, nowText, summary, "", "", "", "", 0, HarnessRun{}, "")
assistantTurn.Content = append(assistantTurn.Content, content)
loaded.Conversation.UpdatedAt = nowText
loaded.Conversation.Stats.TurnCount++
loaded.Conversation.Stats.ArtifactCount++
if err := store.writeConversation(loaded.Path, loaded.Conversation); err != nil {
return SaveCompositionEditResult{}, err
}
if err := store.writeTurn(loaded.TurnsDir, assistantTurn); err != nil {
return SaveCompositionEditResult{}, err
}
absArtifactsDir, err := filepath.Abs(loaded.ArtifactsDir)
if err != nil {
absArtifactsDir = loaded.ArtifactsDir
}
return SaveCompositionEditResult{
ArtifactID: content.ArtifactID,
URL: artifactPrefix + filepath.Join(absArtifactsDir, filepath.Base(content.Path)),
Doc: doc,
Summary: summary,
}, nil
}
// SaveComposition copies a composition document to a user-chosen location —
// the SaveTranscript twin for the project file itself (a backup, or a hand
// edit outside Atelier). The bytes must parse as a composition before the
// dialog opens.
func (a *App) SaveComposition(req SaveTranscriptRequest) (string, error) {
sourcePath := strings.TrimSpace(req.Path)
sourcePath = strings.TrimPrefix(sourcePath, artifactPrefix)
if sourcePath == "" {
return "", errors.New("composition path is empty")
}
data, err := os.ReadFile(sourcePath)
if err != nil {
return "", err
}
if _, err := parseCompositionDoc(data); err != nil {
return "", err
}
return a.saveArtifactDialog(data, req.SuggestedName, "atelier-composition", ".json", "Save composition", []runtime.FileFilter{
{DisplayName: "Composition Files", Pattern: "*.json"},
{DisplayName: "All Files", Pattern: "*.*"},
})
}