Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,29 @@
.license {
margin-block: 1em;
}

.aiDisclosureSection {
margin-block-start: 1.5em;
}

.aiDisclosureTitle {
font-size: 20px;
font-weight: 600;
margin-block-end: 12px;
}

.aiDisclosureHeader {
font-weight: 600;
font-size: 15px;
margin-block-end: 4px;
}

.aiDisclosureBody {
font-size: 14px;
Comment thread
caetano-dev marked this conversation as resolved.
Outdated
line-height: 1.4;
opacity: 0.9;
}

.aiDisclosureItem + .aiDisclosureItem {
margin-block-start: 12px;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<FtCard
v-if="shownDescription.length > 0"
v-if="shownDescription.length > 0 || showHowThisWasMade"
:class="{ videoDescription: true, short: !showFullDescription }"
>
<span
Expand All @@ -14,13 +14,37 @@
{{ $t("Description.Expand Description") }}
</span>
<FtTimestampCatcher
v-if="shownDescription.length > 0"
ref="descriptionContainer"
class="description"
:input-html="processedShownDescription"
:link-tab-index="linkTabIndex"
@timestamp-event="onTimestamp"
@click="expandDescriptionWithClick"
/>
<div
v-if="showHowThisWasMade && showFullDescription"
class="aiDisclosureSection"
>
<div class="aiDisclosureTitle">
{{ t('Video.How this was made') }}
</div>
<div
v-for="(item, index) in contentDisclosures.items"
:key="index"
class="aiDisclosureItem"
>
<div class="aiDisclosureHeader">
{{ getDisclosureHeader(item.label) }}
</div>
<div
v-if="item.description"
class="aiDisclosureBody"
>
{{ getDisclosureDescription(item.description) }}
</div>
</div>
</div>
<bdi
v-if="license && showFullDescription"
class="license"
Expand All @@ -44,6 +68,7 @@
import autolinker from 'autolinker'

import { onMounted, ref, computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FtCard from '../ft-card/ft-card.vue'
import FtTimestampCatcher from '../FtTimestampCatcher.vue'

Expand All @@ -59,9 +84,55 @@ const props = defineProps({
license: {
type: String,
default: null,
},
contentDisclosures: {
type: Object,
default: null
}
})

const { t } = useI18n()

const HEADER_TRANSLATIONS = {
'Made with AI': () => t('Video.Made with AI'),
'Altered or synthetic content': () => t('Video.Altered or synthetic content'),
'Auto-dubbed': () => t('Video.Auto-dubbed'),
'Captured with a camera': () => t('Video.Captured with a camera'),
}

const DESCRIPTION_TRANSLATIONS = {
'Sounds or visuals were altered or significantly edited.': () => t('Video["Sounds or visuals were altered or significantly edited."]'),
'Sounds or visuals were altered or fully generated.': () => t('Video["Sounds or visuals were altered or fully generated."]'),
'Sounds or visuals were significantly edited or digitally generated.': () => t('Video["Sounds or visuals were significantly edited or digitally generated."]'),
'Sounds or visuals were altered or digitally generated.': () => t('Video["Sounds or visuals were altered or digitally generated."]'),
'Audio tracks for some languages were automatically generated.': () => t('Video["Audio tracks for some languages were automatically generated."]'),
'The creator used a camera or other recording device to capture this video without altering the sounds or visuals.': () => t('Video["The creator used a camera or other recording device to capture this video without altering the sounds or visuals."]'),
}
Comment thread
caetano-dev marked this conversation as resolved.

/**
* @param {string} label
* @returns {string}
*/
function getDisclosureHeader(label) {
return HEADER_TRANSLATIONS[label]?.() ?? label
}

/**
* @param {string} description
* @returns {string}
*/
function getDisclosureDescription(description) {
if (!description) {
return ''
}
const normalized = description.endsWith('.') ? description : `${description}.`
return DESCRIPTION_TRANSLATIONS[description]?.() ??
DESCRIPTION_TRANSLATIONS[normalized]?.() ??
description
}

const showHowThisWasMade = computed(() => (props.contentDisclosures?.items?.length ?? 0) > 0)

const emit = defineEmits(['timestamp-event'])

let shownDescription = ''
Expand Down Expand Up @@ -135,7 +206,10 @@ function collapseDescription() {
*/
function isShortDescription() {
const descriptionElem = descriptionContainer.value?.$el
return descriptionElem?.clientHeight >= descriptionElem?.scrollHeight
if (!descriptionElem) {
return true
Comment thread
Shadorc marked this conversation as resolved.
Outdated
}
return descriptionElem.clientHeight >= descriptionElem.scrollHeight
}

onMounted(() => {
Expand Down
14 changes: 12 additions & 2 deletions src/renderer/components/WatchVideoInfo/WatchVideoInfo.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,20 @@
gap: 10px;
}

.unlistedBadge {
.videoBadges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-block-start: 4px;
}

.unlistedBadge,
.aiBadge {
background-color: var(--secondary-card-bg-color);
border-radius: 5px;
display: inline;
display: inline-flex;
align-items: center;
gap: 5px;
padding-inline: 5px;
padding-block: 2px;
white-space: nowrap;
Expand Down
28 changes: 25 additions & 3 deletions src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,24 @@
{{ title }}
</h1>
<div
v-if="isUnlisted"
class="unlistedBadge"
v-if="isUnlisted || showAiBadge"
class="videoBadges"
>
{{ t('Video.Unlisted') }}
<div
v-if="isUnlisted"
class="unlistedBadge"
>
{{ t('Video.Unlisted') }}
</div>
<div
v-if="showAiBadge"
class="aiBadge"
:title="aiTooltipText"
:aria-label="aiTooltipText"
>
<FontAwesomeIcon :icon="['fas', 'robot']" />
Comment thread
caetano-dev marked this conversation as resolved.
Outdated
<span>{{ t('Video.AI') }}</span>
</div>
</div>
</div>
<div class="videoMetrics">
Expand Down Expand Up @@ -231,6 +245,10 @@ const props = defineProps({
type: Boolean,
required: false
},
contentDisclosures: {
type: Object,
default: null
},
canSaveWatchedProgress: {
type: Boolean,
required: true
Expand All @@ -247,6 +265,10 @@ const USING_ELECTRON = process.env.IS_ELECTRON

const { locale, t } = useI18n()

const showAiBadge = computed(() => props.contentDisclosures?.hasAI || false)

const aiTooltipText = computed(() => props.contentDisclosures?.aiBadgeTooltip || 'AI: Content was made with AI')
Comment thread
caetano-dev marked this conversation as resolved.
Outdated

/** @type {import('vue').ComputedRef<boolean>} */
const hideSharingActions = computed(() => store.getters.getHideSharingActions)

Expand Down
64 changes: 63 additions & 1 deletion src/renderer/helpers/api/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ export async function getLocalVideoInfo(id) {

if ((info.playability_status.status === 'UNPLAYABLE' && (!hasTrailer || trailerIsAgeRestricted)) ||
info.playability_status.status === 'LOGIN_REQUIRED') {
return { info, poToken: undefined, clientInfo }
return { info, poToken: undefined, clientInfo, contentDisclosures: extractLocalContentDisclosures(info, nextResponse) }
}

if (hasTrailer && info.playability_status.status !== 'OK') {
Expand Down Expand Up @@ -767,6 +767,68 @@ export async function getLocalVideoInfo(id) {
poToken: contentPoToken,
clientInfo,
adEndTimeUnixMs,
contentDisclosures: extractLocalContentDisclosures(info, nextResponse),
}
}

/**
* Extracts AI badge tooltip from YouTube watch next response
* @param {object} nextResponse
* @returns {string|null}
*/
function extractAiBadgeTooltip(nextResponse) {
const data = nextResponse?.data ?? nextResponse
const contents = data?.contents?.twoColumnWatchNextResults?.results?.results?.contents
const primaryInfo = contents?.find(item => item?.videoPrimaryInfoRenderer)?.videoPrimaryInfoRenderer
const aiBadge = primaryInfo?.badges?.find(b => /\bai\b/i.test(b?.metadataBadgeRenderer?.label))

return aiBadge?.metadataBadgeRenderer?.accessibilityData?.label ?? null
}

/**
* Extracts content disclosures ('How this was made') from YouTube VideoInfo response
* @param {import('youtubei.js').YT.VideoInfo} info
* @param {object} [nextResponse]
* @returns {{ hasAI: boolean, aiBadgeTooltip: string|null, items: Array<{ label: string, description: string, isAI: boolean }> } | null}
*/
export function extractLocalContentDisclosures(info, nextResponse = null) {
Comment thread
Shadorc marked this conversation as resolved.
Outdated
if (!info) {
return null
}

const structuredDescPanel = info.page?.[1]?.engagement_panels?.find(
panel => panel.target_id === 'engagement-panel-structured-description'
)

const rawItems = structuredDescPanel?.content?.items?.filter(
item => item.type === 'HowThisWasMadeSectionView' || item.type === 'howThisWasMadeSectionViewModel'
)

const hasAiBadge = info.primary_info?.badges?.some(b => /\bai\b/i.test(b.label || '')) || false

if (!rawItems?.length && !hasAiBadge) {
return null
}

const items = (rawItems || []).map(item => {
const label = item.body_header?.text || item.bodyHeader?.content || ''
const rawDescription = item.body_text?.text || item.bodyText?.content || ''
return {
label,
description: rawDescription.replace(/\s*Learn more\.?$/i, '').trim(),
isAI: /\b(ai|artificial intelligence|altered|synthetic)\b/i.test(label)
}
})

const hasAI = hasAiBadge || items.some(item => item.isAI)
const aiBadgeTooltip = hasAI
? (extractAiBadgeTooltip(nextResponse) || 'AI: Content was made with AI')
: null

return {
hasAI,
aiBadgeTooltip,
items
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/renderer/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
faQuestionCircle,
faRandom,
faRetweet,
faRobot,
Comment thread
caetano-dev marked this conversation as resolved.
Outdated
faRss,
faSatelliteDish,
faSave,
Expand Down Expand Up @@ -215,6 +216,7 @@ library.add(
faQuestionCircle,
faRandom,
faRetweet,
faRobot,
Comment thread
caetano-dev marked this conversation as resolved.
Outdated
faRss,
faSatelliteDish,
faSave,
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/views/Watch/Watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export default defineComponent({
/** @type {string[]|null} */
customErrorIcon: null,
videoGenreIsMusic: false,
contentDisclosures: null,
/** @type {Date|null} */
streamingDataExpiryDate: null,
currentPlaybackRate: null,
Expand Down Expand Up @@ -400,6 +401,7 @@ export default defineComponent({
this.videoDescription = ''
this.videoDescriptionHtml = ''
this.license = ''
this.contentDisclosures = null
this.videoViewCount = 0
this.videoLikeCount = 0
this.videoDislikeCount = 0
Expand Down Expand Up @@ -502,7 +504,8 @@ export default defineComponent({

try {
const videoInfo = await getLocalVideoInfo(this.videoId)
const { info: result, poToken, clientInfo, adEndTimeUnixMs } = videoInfo
const { info: result, poToken, clientInfo, adEndTimeUnixMs, contentDisclosures } = videoInfo
this.contentDisclosures = contentDisclosures || null

const playabilityStatus = result.playability_status
this.playabilityStatus = playabilityStatus.status
Expand Down Expand Up @@ -1030,6 +1033,7 @@ export default defineComponent({
this.isFamilyFriendly = result.isFamilyFriendly
this.isPostLiveDvr = !!result.isPostLiveDvr
this.isUnlisted = !result.isListed
this.contentDisclosures = null

this.captions = sortCaptions(result.captions.map(caption => {
return {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/views/Watch/Watch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
:video-thumbnail="thumbnail"
:in-user-playlist="!!selectedUserPlaylist"
:is-unlisted="isUnlisted"
:content-disclosures="contentDisclosures"
:can-save-watched-progress="canSaveWatchProgress"
class="watchVideo"
:class="{ theatreWatchVideo: useTheatreMode }"
Expand All @@ -172,6 +173,7 @@
:description="videoDescription"
:description-html="videoDescriptionHtml"
:license="license"
:content-disclosures="contentDisclosures"
class="watchVideo"
:class="{ theatreWatchVideo: useTheatreMode }"
@timestamp-event="changeTimestamp"
Expand Down
12 changes: 12 additions & 0 deletions static/locales/en-US.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,18 @@ Video:
Premieres: Premieres
Upcoming: Upcoming
Unlisted: Unlisted
AI: AI
How this was made: How this was made
Made with AI: Made with AI
Altered or synthetic content: Altered or synthetic content
Auto-dubbed: Auto-dubbed
Captured with a camera: Captured with a camera
'Sounds or visuals were altered or significantly edited.': Sounds or visuals were altered or significantly edited.
'Sounds or visuals were altered or fully generated.': Sounds or visuals were altered or fully generated.
'Sounds or visuals were significantly edited or digitally generated.': Sounds or visuals were significantly edited or digitally generated.
'Sounds or visuals were altered or digitally generated.': Sounds or visuals were altered or digitally generated.
'Audio tracks for some languages were automatically generated.': Audio tracks for some languages were automatically generated.
'The creator used a camera or other recording device to capture this video without altering the sounds or visuals.': The creator used a camera or other recording device to capture this video without altering the sounds or visuals.
Station: Station
Live: Live
Live Now: Live Now
Expand Down