feat(montage): retry a failed motion render or ask Daryl to fix it
The inspector only showed the render error. It now offers to queue the failed renders again, or to open a request to Daryl prefilled with the error so he fixes the composition.
ad42f3d parent: d49f56d modified
backend/studio/modules/projects/api.py +8 -0 | @@ -1501,6 +1501,14 @@ def put_montage(pid: str, req: MontageRequest): | ||
| 1501 | 1501 | return {**_montage_view(pid), "base": base} |
| 1502 | 1502 | |
| 1503 | 1503 | |
| 1504 | +@router.post("/{pid}/montage/renders") | |
| 1505 | +def retry_motion_renders(pid: str): | |
| 1506 | + """Relance les rendus de motions ratés (et ceux qui manquent).""" | |
| 1507 | + _check(pid) | |
| 1508 | + montage.enqueue_motion_renders(pid) | |
| 1509 | + return _montage_view(pid) | |
| 1510 | + | |
| 1511 | + | |
| 1504 | 1512 | @router.post("/{pid}/montage/undo") |
| 1505 | 1513 | def undo_montage(pid: str): |
| 1506 | 1514 | _check(pid) |
| @@ -1501,6 +1501,14 @@ def put_montage(pid: str, req: MontageRequest): | |||
| 1501 | return {**_montage_view(pid), "base": base} | 1501 | return {**_montage_view(pid), "base": base} |
| 1502 | 1502 | ||
| 1503 | 1503 | ||
| 1504 | +@router.post("/{pid}/montage/renders") | ||
| 1505 | +def retry_motion_renders(pid: str): | ||
| 1506 | + """Relance les rendus de motions ratés (et ceux qui manquent).""" | ||
| 1507 | + _check(pid) | ||
| 1508 | + montage.enqueue_motion_renders(pid) | ||
| 1509 | + return _montage_view(pid) | ||
| 1510 | + | ||
| 1511 | + | ||
| 1504 | @router.post("/{pid}/montage/undo") | 1512 | @router.post("/{pid}/montage/undo") |
| 1505 | def undo_montage(pid: str): | 1513 | def undo_montage(pid: str): |
| 1506 | _check(pid) | 1514 | _check(pid) |
modified
web/src/i18n/locales/en/editing.ts +3 -0 | @@ -71,6 +71,9 @@ export default { | ||
| 71 | 71 | renderReady: ".mov render ready", |
| 72 | 72 | renderNone: "Composition not written yet", |
| 73 | 73 | renderError: "Render failed: {error}", |
| 74 | + retryRender: "Retry render", | |
| 75 | + fixRender: "Ask Daryl to fix it", | |
| 76 | + askFixRender: "The render of motion {id} fails: {error}. Fix its composition so it renders, without changing the animation.", | |
| 74 | 77 | renderProgress: "Rendering · {pct}%", |
| 75 | 78 | renderPending: "Render queued", |
| 76 | 79 | volume: "Volume", |
| @@ -71,6 +71,9 @@ export default { | |||
| 71 | renderReady: ".mov render ready", | 71 | renderReady: ".mov render ready", |
| 72 | renderNone: "Composition not written yet", | 72 | renderNone: "Composition not written yet", |
| 73 | renderError: "Render failed: {error}", | 73 | renderError: "Render failed: {error}", |
| 74 | + retryRender: "Retry render", | ||
| 75 | + fixRender: "Ask Daryl to fix it", | ||
| 76 | + askFixRender: "The render of motion {id} fails: {error}. Fix its composition so it renders, without changing the animation.", | ||
| 74 | renderProgress: "Rendering · {pct}%", | 77 | renderProgress: "Rendering · {pct}%", |
| 75 | renderPending: "Render queued", | 78 | renderPending: "Render queued", |
| 76 | volume: "Volume", | 79 | volume: "Volume", |
modified
web/src/i18n/locales/fr/editing.ts +3 -0 | @@ -71,6 +71,9 @@ export default { | ||
| 71 | 71 | renderReady: "Rendu .mov prêt", |
| 72 | 72 | renderNone: "Composition pas encore écrite", |
| 73 | 73 | renderError: "Rendu raté : {error}", |
| 74 | + retryRender: "Relancer le rendu", | |
| 75 | + fixRender: "Faire corriger par Daryl", | |
| 76 | + askFixRender: "Le rendu du motion {id} échoue : {error}. Corrige sa composition pour qu'il passe, sans changer l'animation.", | |
| 74 | 77 | renderProgress: "Rendu · {pct} %", |
| 75 | 78 | renderPending: "Rendu en attente", |
| 76 | 79 | volume: "Volume", |
| @@ -71,6 +71,9 @@ export default { | |||
| 71 | renderReady: "Rendu .mov prêt", | 71 | renderReady: "Rendu .mov prêt", |
| 72 | renderNone: "Composition pas encore écrite", | 72 | renderNone: "Composition pas encore écrite", |
| 73 | renderError: "Rendu raté : {error}", | 73 | renderError: "Rendu raté : {error}", |
| 74 | + retryRender: "Relancer le rendu", | ||
| 75 | + fixRender: "Faire corriger par Daryl", | ||
| 76 | + askFixRender: "Le rendu du motion {id} échoue : {error}. Corrige sa composition pour qu'il passe, sans changer l'animation.", | ||
| 74 | renderProgress: "Rendu · {pct} %", | 77 | renderProgress: "Rendu · {pct} %", |
| 75 | renderPending: "Rendu en attente", | 78 | renderPending: "Rendu en attente", |
| 76 | volume: "Volume", | 79 | volume: "Volume", |
modified
web/src/project/steps/MontageStep.vue +1 -1 | @@ -381,7 +381,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey)); | ||
| 381 | 381 | :image-popup="imagePopup" @image-popup="(tab: ImageTab | null) => (imagePopup = tab)" /> |
| 382 | 382 | <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value" |
| 383 | 383 | :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat" |
| 384 | - @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" /> | |
| 384 | + @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" @rerender="M.retryRenders()" /> | |
| 385 | 385 | </div> |
| 386 | 386 | |
| 387 | 387 | <div class="toolbar"> |
| @@ -381,7 +381,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey)); | |||
| 381 | :image-popup="imagePopup" @image-popup="(tab: ImageTab | null) => (imagePopup = tab)" /> | 381 | :image-popup="imagePopup" @image-popup="(tab: ImageTab | null) => (imagePopup = tab)" /> |
| 382 | <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value" | 382 | <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value" |
| 383 | :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat" | 383 | :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat" |
| 384 | - @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" /> | 384 | + @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" @rerender="M.retryRenders()" /> |
| 385 | </div> | 385 | </div> |
| 386 | 386 | ||
| 387 | <div class="toolbar"> | 387 | <div class="toolbar"> |
modified
web/src/project/steps/montage/Inspector.vue +6 -1 | @@ -22,7 +22,7 @@ import { | ||
| 22 | 22 | const props = defineProps<{ |
| 23 | 23 | tl: Timeline; view: MontageView; available: AvailableMedia[]; t: number; total: number; |
| 24 | 24 | }>(); |
| 25 | -const emit = defineEmits<{ edit: [(t: Timeline) => void]; comment: [string?]; seek: [number]; insert: [AvailableMedia]; media: []; beat: [] }>(); | |
| 25 | +const emit = defineEmits<{ edit: [(t: Timeline) => void]; comment: [string?]; seek: [number]; insert: [AvailableMedia]; media: []; beat: []; rerender: [] }>(); | |
| 26 | 26 | const sel = defineModel<Selection | null>("selection", { required: true }); |
| 27 | 27 | const { t: tr } = useI18n(); |
| 28 | 28 | |
| @@ -225,6 +225,11 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu | ||
| 225 | 225 | : motionState.render === "error" ? tr("editing.inspector.renderError", { error: motionState.error ?? "" }) |
| 226 | 226 | : motionState.render === "rendering" ? tr("editing.inspector.renderProgress", { pct: Math.round(motionState.progress * 100) }) : tr("editing.inspector.renderPending") }} |
| 227 | 227 | </span> |
| 228 | + <div v-if="motionState?.render === 'error'" class="row"> | |
| 229 | + <button class="btn sm" @click="emit('rerender')">{{ tr("editing.inspector.retryRender") }}</button> | |
| 230 | + <button class="btn sm" @click="emit('comment', tr('editing.inspector.askFixRender', { id: item.data.id, error: motionState.error ?? '' }))"> | |
| 231 | + {{ tr("editing.inspector.fixRender") }}</button> | |
| 232 | + </div> | |
| 228 | 233 | </template> |
| 229 | 234 | |
| 230 | 235 | <template v-else-if="item.track === 'audio'"> |
| @@ -22,7 +22,7 @@ import { | |||
| 22 | const props = defineProps<{ | 22 | const props = defineProps<{ |
| 23 | tl: Timeline; view: MontageView; available: AvailableMedia[]; t: number; total: number; | 23 | tl: Timeline; view: MontageView; available: AvailableMedia[]; t: number; total: number; |
| 24 | }>(); | 24 | }>(); |
| 25 | -const emit = defineEmits<{ edit: [(t: Timeline) => void]; comment: [string?]; seek: [number]; insert: [AvailableMedia]; media: []; beat: [] }>(); | 25 | +const emit = defineEmits<{ edit: [(t: Timeline) => void]; comment: [string?]; seek: [number]; insert: [AvailableMedia]; media: []; beat: []; rerender: [] }>(); |
| 26 | const sel = defineModel<Selection | null>("selection", { required: true }); | 26 | const sel = defineModel<Selection | null>("selection", { required: true }); |
| 27 | const { t: tr } = useI18n(); | 27 | const { t: tr } = useI18n(); |
| 28 | 28 | ||
| @@ -225,6 +225,11 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu | |||
| 225 | : motionState.render === "error" ? tr("editing.inspector.renderError", { error: motionState.error ?? "" }) | 225 | : motionState.render === "error" ? tr("editing.inspector.renderError", { error: motionState.error ?? "" }) |
| 226 | : motionState.render === "rendering" ? tr("editing.inspector.renderProgress", { pct: Math.round(motionState.progress * 100) }) : tr("editing.inspector.renderPending") }} | 226 | : motionState.render === "rendering" ? tr("editing.inspector.renderProgress", { pct: Math.round(motionState.progress * 100) }) : tr("editing.inspector.renderPending") }} |
| 227 | </span> | 227 | </span> |
| 228 | + <div v-if="motionState?.render === 'error'" class="row"> | ||
| 229 | + <button class="btn sm" @click="emit('rerender')">{{ tr("editing.inspector.retryRender") }}</button> | ||
| 230 | + <button class="btn sm" @click="emit('comment', tr('editing.inspector.askFixRender', { id: item.data.id, error: motionState.error ?? '' }))"> | ||
| 231 | + {{ tr("editing.inspector.fixRender") }}</button> | ||
| 232 | + </div> | ||
| 228 | </template> | 233 | </template> |
| 229 | 234 | ||
| 230 | <template v-else-if="item.track === 'audio'"> | 235 | <template v-else-if="item.track === 'audio'"> |
modified
web/src/project/steps/montage/useMontage.ts +10 -1 | @@ -125,6 +125,15 @@ export function useMontage(pid: string) { | ||
| 125 | 125 | } |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | + /** Relance les rendus de motions ratés. */ | |
| 129 | + async function retryRenders() { | |
| 130 | + try { | |
| 131 | + receive(await post<MontageView>(`${base}/renders`)); | |
| 132 | + } catch (e) { | |
| 133 | + toast((e as Error).message, true); | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 128 | 137 | /** Une demande à Daryl : la suite de la conversation `task`, sinon une nouvelle demande mise en file. `shot` : |
| 129 | 138 | * l'image en pause dont on parle. Rend la demande (null si elle n'est pas partie). */ |
| 130 | 139 | async function send(text: string, selection: Selection | null, shot: Shot | null, task: string | null): Promise<string | null> { |
| @@ -179,7 +188,7 @@ export function useMontage(pid: string) { | ||
| 179 | 188 | window.removeEventListener("focus", loadAvailable); |
| 180 | 189 | }); |
| 181 | 190 | |
| 182 | - return { view, tl, available, dirty, holding, busy, total, edit, shiftBeats, save, flush, undo, send, stop, close, exportResolve, load, loadAvailable }; | |
| 191 | + return { view, tl, available, dirty, holding, busy, total, edit, shiftBeats, save, flush, undo, retryRenders, send, stop, close, exportResolve, load, loadAvailable }; | |
| 183 | 192 | } |
| 184 | 193 | |
| 185 | 194 | export type MontageState = ReturnType<typeof useMontage>; |
| @@ -125,6 +125,15 @@ export function useMontage(pid: string) { | |||
| 125 | } | 125 | } |
| 126 | } | 126 | } |
| 127 | 127 | ||
| 128 | + /** Relance les rendus de motions ratés. */ | ||
| 129 | + async function retryRenders() { | ||
| 130 | + try { | ||
| 131 | + receive(await post<MontageView>(`${base}/renders`)); | ||
| 132 | + } catch (e) { | ||
| 133 | + toast((e as Error).message, true); | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + | ||
| 128 | /** Une demande à Daryl : la suite de la conversation `task`, sinon une nouvelle demande mise en file. `shot` : | 137 | /** Une demande à Daryl : la suite de la conversation `task`, sinon une nouvelle demande mise en file. `shot` : |
| 129 | * l'image en pause dont on parle. Rend la demande (null si elle n'est pas partie). */ | 138 | * l'image en pause dont on parle. Rend la demande (null si elle n'est pas partie). */ |
| 130 | async function send(text: string, selection: Selection | null, shot: Shot | null, task: string | null): Promise<string | null> { | 139 | async function send(text: string, selection: Selection | null, shot: Shot | null, task: string | null): Promise<string | null> { |
| @@ -179,7 +188,7 @@ export function useMontage(pid: string) { | |||
| 179 | window.removeEventListener("focus", loadAvailable); | 188 | window.removeEventListener("focus", loadAvailable); |
| 180 | }); | 189 | }); |
| 181 | 190 | ||
| 182 | - return { view, tl, available, dirty, holding, busy, total, edit, shiftBeats, save, flush, undo, send, stop, close, exportResolve, load, loadAvailable }; | 191 | + return { view, tl, available, dirty, holding, busy, total, edit, shiftBeats, save, flush, undo, retryRenders, send, stop, close, exportResolve, load, loadAvailable }; |
| 183 | } | 192 | } |
| 184 | 193 | ||
| 185 | export type MontageState = ReturnType<typeof useMontage>; | 194 | export type MontageState = ReturnType<typeof useMontage>; |