feat(publish): keep the A/B variants to add in YouTube Studio after upload
The YouTube API cannot create Test & compare experiments, so only variant A is uploaded. Once the long video is sent, show variants B and C with a link to Studio, a thumbnail download and a title copy button.
783b8eb parent: 65f4be9 modified
web/src/i18n/locales/en/upload.ts +7 -0 | @@ -37,6 +37,13 @@ export default { | ||
| 37 | 37 | thumbManual: "set the thumbnail manually", |
| 38 | 38 | failed: "Failed: {error}", |
| 39 | 39 | schedule: "Schedule", |
| 40 | + ab: { | |
| 41 | + todo: "A/B test to finish in YouTube Studio: the API only uploads variant A. Add these variants under “Test & compare”, next to the thumbnail.", | |
| 42 | + openStudio: "Open in YouTube Studio →", | |
| 43 | + copy: "Copy title", | |
| 44 | + copied: "Title copied", | |
| 45 | + download: "Download thumbnail", | |
| 46 | + }, | |
| 40 | 47 | allSent: "All videos are uploaded. The project moves to “Published”.", |
| 41 | 48 | auditNote: "Until Google audits your Google Cloud project, YouTube keeps videos uploaded through the API private: switch them to public in YouTube Studio. That’s also where you add variants B and C of the A/B test.", |
| 42 | 49 | }; |
| @@ -37,6 +37,13 @@ export default { | |||
| 37 | thumbManual: "set the thumbnail manually", | 37 | thumbManual: "set the thumbnail manually", |
| 38 | failed: "Failed: {error}", | 38 | failed: "Failed: {error}", |
| 39 | schedule: "Schedule", | 39 | schedule: "Schedule", |
| 40 | + ab: { | ||
| 41 | + todo: "A/B test to finish in YouTube Studio: the API only uploads variant A. Add these variants under “Test & compare”, next to the thumbnail.", | ||
| 42 | + openStudio: "Open in YouTube Studio →", | ||
| 43 | + copy: "Copy title", | ||
| 44 | + copied: "Title copied", | ||
| 45 | + download: "Download thumbnail", | ||
| 46 | + }, | ||
| 40 | allSent: "All videos are uploaded. The project moves to “Published”.", | 47 | allSent: "All videos are uploaded. The project moves to “Published”.", |
| 41 | auditNote: "Until Google audits your Google Cloud project, YouTube keeps videos uploaded through the API private: switch them to public in YouTube Studio. That’s also where you add variants B and C of the A/B test.", | 48 | auditNote: "Until Google audits your Google Cloud project, YouTube keeps videos uploaded through the API private: switch them to public in YouTube Studio. That’s also where you add variants B and C of the A/B test.", |
| 42 | }; | 49 | }; |
modified
web/src/i18n/locales/fr/upload.ts +7 -0 | @@ -37,6 +37,13 @@ export default { | ||
| 37 | 37 | thumbManual: "miniature à poser à la main", |
| 38 | 38 | failed: "Échec : {error}", |
| 39 | 39 | schedule: "Programmer", |
| 40 | + ab: { | |
| 41 | + todo: "Test A/B à finir dans YouTube Studio : l’API n’envoie que la variante A. Ajoute ces variantes dans « Tester et comparer », à côté de la miniature.", | |
| 42 | + openStudio: "Ouvrir dans YouTube Studio →", | |
| 43 | + copy: "Copier le titre", | |
| 44 | + copied: "Titre copié", | |
| 45 | + download: "Télécharger la miniature", | |
| 46 | + }, | |
| 40 | 47 | allSent: "Toutes les vidéos sont envoyées. Le projet passe dans « Publiées ».", |
| 41 | 48 | auditNote: "Tant que Google n’a pas audité ton projet Google Cloud, YouTube garde en privé les vidéos envoyées par l’API : passe-les en public dans YouTube Studio. Les variantes B et C du test A/B s’y ajoutent aussi.", |
| 42 | 49 | }; |
| @@ -37,6 +37,13 @@ export default { | |||
| 37 | thumbManual: "miniature à poser à la main", | 37 | thumbManual: "miniature à poser à la main", |
| 38 | failed: "Échec : {error}", | 38 | failed: "Échec : {error}", |
| 39 | schedule: "Programmer", | 39 | schedule: "Programmer", |
| 40 | + ab: { | ||
| 41 | + todo: "Test A/B à finir dans YouTube Studio : l’API n’envoie que la variante A. Ajoute ces variantes dans « Tester et comparer », à côté de la miniature.", | ||
| 42 | + openStudio: "Ouvrir dans YouTube Studio →", | ||
| 43 | + copy: "Copier le titre", | ||
| 44 | + copied: "Titre copié", | ||
| 45 | + download: "Télécharger la miniature", | ||
| 46 | + }, | ||
| 40 | allSent: "Toutes les vidéos sont envoyées. Le projet passe dans « Publiées ».", | 47 | allSent: "Toutes les vidéos sont envoyées. Le projet passe dans « Publiées ».", |
| 41 | auditNote: "Tant que Google n’a pas audité ton projet Google Cloud, YouTube garde en privé les vidéos envoyées par l’API : passe-les en public dans YouTube Studio. Les variantes B et C du test A/B s’y ajoutent aussi.", | 48 | auditNote: "Tant que Google n’a pas audité ton projet Google Cloud, YouTube garde en privé les vidéos envoyées par l’API : passe-les en public dans YouTube Studio. Les variantes B et C du test A/B s’y ajoutent aussi.", |
| 42 | }; | 49 | }; |
modified
web/src/project/steps/UploadStep.vue +37 -1 | @@ -2,13 +2,15 @@ | ||
| 2 | 2 | /** |
| 3 | 3 | * Upload : chaque vidéo (la longue, les shorts) s'envoie à part sur YouTube par l'API, avec sa |
| 4 | 4 | * visibilité (ou sa date de publication). Une vidéo ne part que si ses vérifications passent : |
| 5 | - * vignette, titre, chapitres, test A/B, rendu à jour. | |
| 5 | + * vignette, titre, chapitres, test A/B, rendu à jour. L'API ne crée pas le test A/B : une fois la | |
| 6 | + * vidéo longue envoyée, ses variantes B et C restent affichées pour les poser dans YouTube Studio. | |
| 6 | 7 | */ |
| 7 | 8 | import { computed } from "vue"; |
| 8 | 9 | import { useRouter } from "vue-router"; |
| 9 | 10 | import { useI18n } from "vue-i18n"; |
| 10 | 11 | import { usePref } from "../../lib/composables"; |
| 11 | 12 | import { mmss } from "../../lib/format"; |
| 13 | +import { toast } from "../../lib/toast"; | |
| 12 | 14 | import { store } from "../store"; |
| 13 | 15 | import type { Visibility } from "../types"; |
| 14 | 16 | import { checkLabel, missing, thumbUrl, uploading, usePublish, VISIBILITY, type Video } from "./publish/usePublish"; |
| @@ -39,6 +41,20 @@ function state(v: Video): { label: string; tone: "" | "done" | "red" } { | ||
| 39 | 41 | } |
| 40 | 42 | |
| 41 | 43 | const done = (v: Video) => v.upload?.status === "done"; |
| 44 | + | |
| 45 | +// la vidéo longue envoyée avec un test A/B : ses variantes B et C restent à poser à la main | |
| 46 | +const abLeft = computed(() => { | |
| 47 | + const v = P.videos.value.find((x) => x.kind === "long"); | |
| 48 | + if (!v || !done(v) || !v.meta.ab_on || !v.meta.ab?.length) return null; | |
| 49 | + return { | |
| 50 | + studio: v.upload?.video_id ? `https://studio.youtube.com/video/${v.upload.video_id}/edit` : null, | |
| 51 | + variants: v.meta.ab.map((a, i) => ({ ...a, l: "BC"[i], img: thumbUrl(project.value, a.thumb) })), | |
| 52 | + }; | |
| 53 | +}); | |
| 54 | +async function copyTitle(title: string) { | |
| 55 | + await navigator.clipboard.writeText(title); | |
| 56 | + toast(t("upload.ab.copied")); | |
| 57 | +} | |
| 42 | 58 | const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k === vis)?.[1]; return key ? t(key) : vis; }; |
| 43 | 59 | </script> |
| 44 | 60 | |
| @@ -106,6 +122,22 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k = | ||
| 106 | 122 | </div> |
| 107 | 123 | </div> |
| 108 | 124 | |
| 125 | + <section v-if="abLeft" class="ab"> | |
| 126 | + <div class="ab-head"> | |
| 127 | + <span>{{ t("upload.ab.todo") }}</span> | |
| 128 | + <span class="spacer" /> | |
| 129 | + <a v-if="abLeft.studio" :href="abLeft.studio" target="_blank" rel="noopener" class="small">{{ t("upload.ab.openStudio") }}</a> | |
| 130 | + </div> | |
| 131 | + <div v-for="a in abLeft.variants" :key="a.l" class="ab-row"> | |
| 132 | + <span class="badge mono">{{ a.l }}</span> | |
| 133 | + <a v-if="a.img" :href="a.img" download><img :src="a.img" alt="" class="img wide" :title="t('upload.ab.download')"></a> | |
| 134 | + <div v-else class="img wide hatch" /> | |
| 135 | + <span class="title">{{ a.title }}</span> | |
| 136 | + <button class="link-btn small" @click="copyTitle(a.title)">{{ t("upload.ab.copy") }}</button> | |
| 137 | + <a v-if="a.img" :href="a.img" download class="small">{{ t("upload.ab.download") }}</a> | |
| 138 | + </div> | |
| 139 | + </section> | |
| 140 | + | |
| 109 | 141 | <p v-if="allSent" class="end">{{ t("upload.allSent") }}</p> |
| 110 | 142 | <p v-else-if="P.account.value?.connected" class="note"> |
| 111 | 143 | {{ t("upload.auditNote") }} |
| @@ -146,6 +178,10 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k = | ||
| 146 | 178 | .send .done { color: var(--ink); } |
| 147 | 179 | .track { height: 3px; width: 100%; background: var(--line-soft); } |
| 148 | 180 | .track > div { height: 3px; background: var(--red); transition: width .4s; } |
| 181 | +.ab { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border: 1px solid var(--red); border-radius: 4px; } | |
| 182 | +.ab-head { display: flex; align-items: baseline; gap: 12px; font-size: 13.5px; color: var(--red-dark); } | |
| 183 | +.ab-row { display: grid; grid-template-columns: 22px 71px minmax(0, 1fr) auto auto; gap: 14px; align-items: center; } | |
| 184 | +.badge { width: 20px; height: 20px; border-radius: 2px; background: var(--red); color: #fff; font-size: 11px; font-weight: 700; display: grid; place-items: center; } | |
| 149 | 185 | .end, .note { margin: 6px 0 0; font-size: 13.5px; color: var(--muted); line-height: 1.5; } |
| 150 | 186 | .note { font-size: 12.5px; color: var(--muted-2); } |
| 151 | 187 | </style> |
| @@ -2,13 +2,15 @@ | |||
| 2 | /** | 2 | /** |
| 3 | * Upload : chaque vidéo (la longue, les shorts) s'envoie à part sur YouTube par l'API, avec sa | 3 | * Upload : chaque vidéo (la longue, les shorts) s'envoie à part sur YouTube par l'API, avec sa |
| 4 | * visibilité (ou sa date de publication). Une vidéo ne part que si ses vérifications passent : | 4 | * visibilité (ou sa date de publication). Une vidéo ne part que si ses vérifications passent : |
| 5 | - * vignette, titre, chapitres, test A/B, rendu à jour. | 5 | + * vignette, titre, chapitres, test A/B, rendu à jour. L'API ne crée pas le test A/B : une fois la |
| 6 | + * vidéo longue envoyée, ses variantes B et C restent affichées pour les poser dans YouTube Studio. | ||
| 6 | */ | 7 | */ |
| 7 | import { computed } from "vue"; | 8 | import { computed } from "vue"; |
| 8 | import { useRouter } from "vue-router"; | 9 | import { useRouter } from "vue-router"; |
| 9 | import { useI18n } from "vue-i18n"; | 10 | import { useI18n } from "vue-i18n"; |
| 10 | import { usePref } from "../../lib/composables"; | 11 | import { usePref } from "../../lib/composables"; |
| 11 | import { mmss } from "../../lib/format"; | 12 | import { mmss } from "../../lib/format"; |
| 13 | +import { toast } from "../../lib/toast"; | ||
| 12 | import { store } from "../store"; | 14 | import { store } from "../store"; |
| 13 | import type { Visibility } from "../types"; | 15 | import type { Visibility } from "../types"; |
| 14 | import { checkLabel, missing, thumbUrl, uploading, usePublish, VISIBILITY, type Video } from "./publish/usePublish"; | 16 | import { checkLabel, missing, thumbUrl, uploading, usePublish, VISIBILITY, type Video } from "./publish/usePublish"; |
| @@ -39,6 +41,20 @@ function state(v: Video): { label: string; tone: "" | "done" | "red" } { | |||
| 39 | } | 41 | } |
| 40 | 42 | ||
| 41 | const done = (v: Video) => v.upload?.status === "done"; | 43 | const done = (v: Video) => v.upload?.status === "done"; |
| 44 | + | ||
| 45 | +// la vidéo longue envoyée avec un test A/B : ses variantes B et C restent à poser à la main | ||
| 46 | +const abLeft = computed(() => { | ||
| 47 | + const v = P.videos.value.find((x) => x.kind === "long"); | ||
| 48 | + if (!v || !done(v) || !v.meta.ab_on || !v.meta.ab?.length) return null; | ||
| 49 | + return { | ||
| 50 | + studio: v.upload?.video_id ? `https://studio.youtube.com/video/${v.upload.video_id}/edit` : null, | ||
| 51 | + variants: v.meta.ab.map((a, i) => ({ ...a, l: "BC"[i], img: thumbUrl(project.value, a.thumb) })), | ||
| 52 | + }; | ||
| 53 | +}); | ||
| 54 | +async function copyTitle(title: string) { | ||
| 55 | + await navigator.clipboard.writeText(title); | ||
| 56 | + toast(t("upload.ab.copied")); | ||
| 57 | +} | ||
| 42 | const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k === vis)?.[1]; return key ? t(key) : vis; }; | 58 | const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k === vis)?.[1]; return key ? t(key) : vis; }; |
| 43 | </script> | 59 | </script> |
| 44 | 60 | ||
| @@ -106,6 +122,22 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k = | |||
| 106 | </div> | 122 | </div> |
| 107 | </div> | 123 | </div> |
| 108 | 124 | ||
| 125 | + <section v-if="abLeft" class="ab"> | ||
| 126 | + <div class="ab-head"> | ||
| 127 | + <span>{{ t("upload.ab.todo") }}</span> | ||
| 128 | + <span class="spacer" /> | ||
| 129 | + <a v-if="abLeft.studio" :href="abLeft.studio" target="_blank" rel="noopener" class="small">{{ t("upload.ab.openStudio") }}</a> | ||
| 130 | + </div> | ||
| 131 | + <div v-for="a in abLeft.variants" :key="a.l" class="ab-row"> | ||
| 132 | + <span class="badge mono">{{ a.l }}</span> | ||
| 133 | + <a v-if="a.img" :href="a.img" download><img :src="a.img" alt="" class="img wide" :title="t('upload.ab.download')"></a> | ||
| 134 | + <div v-else class="img wide hatch" /> | ||
| 135 | + <span class="title">{{ a.title }}</span> | ||
| 136 | + <button class="link-btn small" @click="copyTitle(a.title)">{{ t("upload.ab.copy") }}</button> | ||
| 137 | + <a v-if="a.img" :href="a.img" download class="small">{{ t("upload.ab.download") }}</a> | ||
| 138 | + </div> | ||
| 139 | + </section> | ||
| 140 | + | ||
| 109 | <p v-if="allSent" class="end">{{ t("upload.allSent") }}</p> | 141 | <p v-if="allSent" class="end">{{ t("upload.allSent") }}</p> |
| 110 | <p v-else-if="P.account.value?.connected" class="note"> | 142 | <p v-else-if="P.account.value?.connected" class="note"> |
| 111 | {{ t("upload.auditNote") }} | 143 | {{ t("upload.auditNote") }} |
| @@ -146,6 +178,10 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k = | |||
| 146 | .send .done { color: var(--ink); } | 178 | .send .done { color: var(--ink); } |
| 147 | .track { height: 3px; width: 100%; background: var(--line-soft); } | 179 | .track { height: 3px; width: 100%; background: var(--line-soft); } |
| 148 | .track > div { height: 3px; background: var(--red); transition: width .4s; } | 180 | .track > div { height: 3px; background: var(--red); transition: width .4s; } |
| 181 | +.ab { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border: 1px solid var(--red); border-radius: 4px; } | ||
| 182 | +.ab-head { display: flex; align-items: baseline; gap: 12px; font-size: 13.5px; color: var(--red-dark); } | ||
| 183 | +.ab-row { display: grid; grid-template-columns: 22px 71px minmax(0, 1fr) auto auto; gap: 14px; align-items: center; } | ||
| 184 | +.badge { width: 20px; height: 20px; border-radius: 2px; background: var(--red); color: #fff; font-size: 11px; font-weight: 700; display: grid; place-items: center; } | ||
| 149 | .end, .note { margin: 6px 0 0; font-size: 13.5px; color: var(--muted); line-height: 1.5; } | 185 | .end, .note { margin: 6px 0 0; font-size: 13.5px; color: var(--muted); line-height: 1.5; } |
| 150 | .note { font-size: 12.5px; color: var(--muted-2); } | 186 | .note { font-size: 12.5px; color: var(--muted-2); } |
| 151 | </style> | 187 | </style> |