|
(() => { |
|
"use strict"; |
|
|
|
/* |
|
* Mixamo bulk animation downloader |
|
* |
|
* Supports: |
|
* - Individual animations |
|
* - Animation packs |
|
* - 96 results per page |
|
* - Pages 1 through 26 |
|
* - Resume/checkpoint support |
|
* - Retry handling |
|
* - Firefox-compatible activation events |
|
* - Download modals with missing or varying export controls |
|
* |
|
* Recommended starting state: |
|
* - Animations tab |
|
* - 96 Per Page |
|
* - Page 1 |
|
* - No search term |
|
* |
|
* Browser setup: |
|
* - Allow multiple automatic downloads from Mixamo |
|
* - Disable "Always ask where to save files" |
|
* - Prevent the computer from sleeping |
|
* |
|
* Console controls: |
|
* mixamoDownloader.status() |
|
* mixamoDownloader.stop() |
|
* mixamoDownloader.listFailures() |
|
* mixamoDownloader.clearProgress() |
|
* mixamoDownloader.inspectModal() |
|
*/ |
|
|
|
const SETTINGS = { |
|
/* |
|
* Preferred export settings. |
|
* |
|
* Missing controls are logged and skipped rather than |
|
* treated as errors. |
|
*/ |
|
format: "FBX Binary", |
|
includeSkin: false, |
|
fps: 30, |
|
keyframeReduction: "none", |
|
inPlace: true, |
|
|
|
/* |
|
* Catalog range. |
|
*/ |
|
startPage: 1, |
|
endPage: 26, |
|
|
|
/* |
|
* null downloads every item. |
|
* Set to 3 for a short test. |
|
*/ |
|
maxItemsPerPage: null, |
|
|
|
/* |
|
* Timeouts. |
|
*/ |
|
selectionTimeoutMs: 30000, |
|
modalTimeoutMs: 20000, |
|
pageTimeoutMs: 30000, |
|
|
|
/* |
|
* Delays. |
|
*/ |
|
beforeCardClickDelayMs: 700, |
|
afterCardClickDelayMs: 1200, |
|
afterSelectionDelayMs: 1500, |
|
afterOptionChangeDelayMs: 600, |
|
afterDownloadClickDelayMs: 9000, |
|
packDownloadDelayMs: 18000, |
|
afterPageChangeDelayMs: 4000, |
|
retryDelayMs: 3500, |
|
|
|
/* |
|
* Additional attempts after the initial attempt. |
|
*/ |
|
retries: 2, |
|
|
|
/* |
|
* Save progress in localStorage. |
|
*/ |
|
resume: true, |
|
|
|
/* |
|
* Continue when an item fails all attempts. |
|
*/ |
|
skipFailedItems: true, |
|
}; |
|
|
|
const STORAGE_KEY = "mixamo-library-downloader-v5"; |
|
|
|
const state = { |
|
running: false, |
|
stopped: false, |
|
|
|
currentPage: SETTINGS.startPage, |
|
currentItem: 0, |
|
|
|
completedIds: new Set(), |
|
failures: [], |
|
}; |
|
|
|
/* |
|
* General helpers |
|
*/ |
|
|
|
const wait = (milliseconds) => |
|
new Promise((resolve) => { |
|
window.setTimeout(resolve, milliseconds); |
|
}); |
|
|
|
function normalizeText(value) { |
|
return String(value ?? "") |
|
.replace(/\s+/g, " ") |
|
.trim() |
|
.toLowerCase(); |
|
} |
|
|
|
function isElementVisible(element) { |
|
if (!element) { |
|
return false; |
|
} |
|
|
|
const style = window.getComputedStyle(element); |
|
const rect = element.getBoundingClientRect(); |
|
|
|
return ( |
|
style.display !== "none" && |
|
style.visibility !== "hidden" && |
|
style.opacity !== "0" && |
|
rect.width > 0 && |
|
rect.height > 0 |
|
); |
|
} |
|
|
|
async function waitForCondition( |
|
condition, |
|
{ |
|
timeoutMs = 10000, |
|
intervalMs = 200, |
|
description = "condition", |
|
} = {}, |
|
) { |
|
const startedAt = Date.now(); |
|
|
|
while (Date.now() - startedAt < timeoutMs) { |
|
if (state.stopped) { |
|
throw new Error("Downloader stopped."); |
|
} |
|
|
|
try { |
|
const result = condition(); |
|
|
|
if (result) { |
|
return result; |
|
} |
|
} catch { |
|
/* |
|
* Ignore temporary query failures while React rerenders. |
|
*/ |
|
} |
|
|
|
await wait(intervalMs); |
|
} |
|
|
|
throw new Error( |
|
`Timed out waiting for ${description}.`, |
|
); |
|
} |
|
|
|
/* |
|
* Saved progress |
|
*/ |
|
|
|
function loadState() { |
|
if (!SETTINGS.resume) { |
|
return; |
|
} |
|
|
|
try { |
|
const raw = localStorage.getItem(STORAGE_KEY); |
|
|
|
if (!raw) { |
|
return; |
|
} |
|
|
|
const saved = JSON.parse(raw); |
|
|
|
if ( |
|
Number.isInteger(saved.currentPage) && |
|
saved.currentPage >= SETTINGS.startPage && |
|
saved.currentPage <= SETTINGS.endPage |
|
) { |
|
state.currentPage = saved.currentPage; |
|
} |
|
|
|
if ( |
|
Number.isInteger(saved.currentItem) && |
|
saved.currentItem >= 0 |
|
) { |
|
state.currentItem = saved.currentItem; |
|
} |
|
|
|
state.completedIds = new Set( |
|
Array.isArray(saved.completedIds) |
|
? saved.completedIds |
|
: [], |
|
); |
|
|
|
state.failures = Array.isArray(saved.failures) |
|
? saved.failures |
|
: []; |
|
|
|
console.info("Restored Mixamo progress:", { |
|
page: state.currentPage, |
|
item: state.currentItem, |
|
completed: state.completedIds.size, |
|
failures: state.failures.length, |
|
}); |
|
} catch (error) { |
|
console.warn( |
|
"Could not restore saved Mixamo progress:", |
|
error, |
|
); |
|
} |
|
} |
|
|
|
function saveState() { |
|
if (!SETTINGS.resume) { |
|
return; |
|
} |
|
|
|
localStorage.setItem( |
|
STORAGE_KEY, |
|
JSON.stringify({ |
|
currentPage: state.currentPage, |
|
currentItem: state.currentItem, |
|
completedIds: [...state.completedIds], |
|
failures: state.failures, |
|
savedAt: new Date().toISOString(), |
|
}), |
|
); |
|
} |
|
|
|
function clearProgress() { |
|
localStorage.removeItem(STORAGE_KEY); |
|
|
|
state.currentPage = SETTINGS.startPage; |
|
state.currentItem = 0; |
|
state.completedIds.clear(); |
|
state.failures = []; |
|
|
|
console.info("Saved Mixamo progress cleared."); |
|
} |
|
|
|
/* |
|
* Catalog card queries |
|
*/ |
|
|
|
function getDownloadableCards() { |
|
return [ |
|
...document.querySelectorAll( |
|
[ |
|
".product-results-holder .product-list .product-animation", |
|
".product-results-holder .product-list .product-animation-pack", |
|
].join(", "), |
|
), |
|
]; |
|
} |
|
|
|
function getCardInfo(card, index) { |
|
const image = card.querySelector( |
|
".product-image img", |
|
); |
|
|
|
const name = |
|
card |
|
.querySelector(".product-info p") |
|
?.textContent?.trim() || |
|
`Mixamo Item ${index + 1}`; |
|
|
|
const description = |
|
card |
|
.querySelector(".product-metadata li") |
|
?.textContent?.trim() || ""; |
|
|
|
const thumbnailUrl = image?.src || ""; |
|
|
|
const isPack = card.classList.contains( |
|
"product-animation-pack", |
|
); |
|
|
|
const motionMatch = thumbnailUrl.match( |
|
/\/motions\/([^/]+)\/animated\.(?:gif|png|jpg|jpeg)/i, |
|
); |
|
|
|
const packMatch = thumbnailUrl.match( |
|
/\/motion_packs\/([^/]+)\/animated\.(?:gif|png|jpg|jpeg)/i, |
|
); |
|
|
|
const animationCount = isPack |
|
? Number.parseInt( |
|
card |
|
.querySelector(".product-count") |
|
?.textContent?.trim() || "", |
|
10, |
|
) || null |
|
: 1; |
|
|
|
let itemId; |
|
|
|
if (isPack) { |
|
itemId = |
|
`pack:${packMatch?.[1] || normalizeText(name)}`; |
|
} else if (motionMatch?.[1]) { |
|
itemId = `motion:${motionMatch[1]}`; |
|
} else if (thumbnailUrl) { |
|
itemId = `motion-url:${thumbnailUrl}`; |
|
} else { |
|
itemId = |
|
`item:${state.currentPage}:${index}:${normalizeText(name)}`; |
|
} |
|
|
|
return { |
|
card, |
|
index, |
|
itemId, |
|
isPack, |
|
animationCount, |
|
name, |
|
description, |
|
thumbnailUrl, |
|
}; |
|
} |
|
|
|
function findLiveItemById(itemId) { |
|
const cards = getDownloadableCards(); |
|
|
|
for ( |
|
let index = 0; |
|
index < cards.length; |
|
index += 1 |
|
) { |
|
const candidate = getCardInfo( |
|
cards[index], |
|
index, |
|
); |
|
|
|
if (candidate.itemId === itemId) { |
|
return candidate; |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
/* |
|
* Pagination |
|
*/ |
|
|
|
function getCurrentPage() { |
|
const active = document.querySelector( |
|
".pagination-holder .pagination li.active a", |
|
); |
|
|
|
const page = Number.parseInt( |
|
active?.textContent?.trim() || "", |
|
10, |
|
); |
|
|
|
return Number.isFinite(page) |
|
? page |
|
: null; |
|
} |
|
|
|
function getNextPageButton() { |
|
const icon = document.querySelector( |
|
".pagination-holder .pagination .fa-angle-right", |
|
); |
|
|
|
return icon?.closest("a") || null; |
|
} |
|
|
|
function hasNextPage() { |
|
const button = getNextPageButton(); |
|
|
|
if (!button) { |
|
return false; |
|
} |
|
|
|
const listItem = button.closest("li"); |
|
|
|
return !( |
|
listItem?.classList.contains("disabled") || |
|
button.getAttribute("aria-disabled") === "true" |
|
); |
|
} |
|
|
|
function getCatalogSignature() { |
|
const cards = getDownloadableCards(); |
|
|
|
return { |
|
page: getCurrentPage(), |
|
count: cards.length, |
|
|
|
firstImage: |
|
cards[0] |
|
?.querySelector(".product-image img") |
|
?.src || "", |
|
|
|
lastImage: |
|
cards[cards.length - 1] |
|
?.querySelector(".product-image img") |
|
?.src || "", |
|
}; |
|
} |
|
|
|
async function goToNextPage() { |
|
const previous = getCatalogSignature(); |
|
const nextButton = getNextPageButton(); |
|
|
|
if (!nextButton || !hasNextPage()) { |
|
return false; |
|
} |
|
|
|
dispatchActivationEvents(nextButton); |
|
|
|
await waitForCondition( |
|
() => { |
|
const current = getCatalogSignature(); |
|
|
|
return ( |
|
current.count > 0 && |
|
( |
|
current.page !== previous.page || |
|
current.firstImage !== previous.firstImage || |
|
current.lastImage !== previous.lastImage |
|
) |
|
); |
|
}, |
|
{ |
|
timeoutMs: SETTINGS.pageTimeoutMs, |
|
description: "the next catalog page", |
|
}, |
|
); |
|
|
|
await wait( |
|
SETTINGS.afterPageChangeDelayMs, |
|
); |
|
|
|
return true; |
|
} |
|
|
|
async function goToPage(targetPage) { |
|
let currentPage = getCurrentPage(); |
|
|
|
if (currentPage === null) { |
|
throw new Error( |
|
"Could not determine the current Mixamo page.", |
|
); |
|
} |
|
|
|
if (currentPage > targetPage) { |
|
throw new Error( |
|
`Mixamo is currently on page ${currentPage}, ` + |
|
`but saved progress expects page ${targetPage}. ` + |
|
`Open page ${targetPage} manually or clear progress.`, |
|
); |
|
} |
|
|
|
while (currentPage < targetPage) { |
|
const moved = await goToNextPage(); |
|
|
|
if (!moved) { |
|
throw new Error( |
|
`Could not navigate from page ${currentPage} ` + |
|
`to page ${targetPage}.`, |
|
); |
|
} |
|
|
|
currentPage = getCurrentPage(); |
|
|
|
if (currentPage === null) { |
|
throw new Error( |
|
"Lost the current page number while navigating.", |
|
); |
|
} |
|
} |
|
} |
|
|
|
/* |
|
* Preview queries |
|
*/ |
|
|
|
function getSelectedItemName() { |
|
return ( |
|
document |
|
.querySelector( |
|
".product-preview-holder .product-nav h2", |
|
) |
|
?.textContent?.trim() || "" |
|
); |
|
} |
|
|
|
function getMainDownloadButton() { |
|
const buttons = [ |
|
...document.querySelectorAll( |
|
".product-preview-holder .editor-sidebar .sidebar-header button", |
|
), |
|
]; |
|
|
|
return ( |
|
buttons.find( |
|
(button) => |
|
normalizeText(button.textContent) === |
|
"download", |
|
) || null |
|
); |
|
} |
|
|
|
function getInPlaceCheckbox() { |
|
return document.querySelector( |
|
'input[name="inplace"]', |
|
); |
|
} |
|
|
|
/* |
|
* Firefox-compatible activation |
|
*/ |
|
|
|
function dispatchActivationEvents(element) { |
|
if (!element) { |
|
return; |
|
} |
|
|
|
const rect = element.getBoundingClientRect(); |
|
|
|
const clientX = Math.round( |
|
rect.left + rect.width / 2, |
|
); |
|
|
|
const clientY = Math.round( |
|
rect.top + rect.height / 2, |
|
); |
|
|
|
const common = { |
|
bubbles: true, |
|
cancelable: true, |
|
composed: true, |
|
view: window, |
|
button: 0, |
|
clientX, |
|
clientY, |
|
}; |
|
|
|
const events = [ |
|
[ |
|
"pointerover", |
|
{ |
|
...common, |
|
buttons: 0, |
|
pointerType: "mouse", |
|
isPrimary: true, |
|
}, |
|
], |
|
[ |
|
"mouseover", |
|
{ |
|
...common, |
|
buttons: 0, |
|
}, |
|
], |
|
[ |
|
"pointerenter", |
|
{ |
|
...common, |
|
buttons: 0, |
|
pointerType: "mouse", |
|
isPrimary: true, |
|
}, |
|
], |
|
[ |
|
"mouseenter", |
|
{ |
|
...common, |
|
buttons: 0, |
|
}, |
|
], |
|
[ |
|
"pointerdown", |
|
{ |
|
...common, |
|
buttons: 1, |
|
pointerType: "mouse", |
|
isPrimary: true, |
|
}, |
|
], |
|
[ |
|
"mousedown", |
|
{ |
|
...common, |
|
buttons: 1, |
|
}, |
|
], |
|
[ |
|
"pointerup", |
|
{ |
|
...common, |
|
buttons: 0, |
|
pointerType: "mouse", |
|
isPrimary: true, |
|
}, |
|
], |
|
[ |
|
"mouseup", |
|
{ |
|
...common, |
|
buttons: 0, |
|
}, |
|
], |
|
[ |
|
"click", |
|
{ |
|
...common, |
|
buttons: 0, |
|
}, |
|
], |
|
]; |
|
|
|
for (const [type, options] of events) { |
|
try { |
|
const EventClass = |
|
type.startsWith("pointer") |
|
? PointerEvent |
|
: MouseEvent; |
|
|
|
element.dispatchEvent( |
|
new EventClass(type, options), |
|
); |
|
} catch { |
|
try { |
|
element.dispatchEvent( |
|
new MouseEvent(type, options), |
|
); |
|
} catch { |
|
element.dispatchEvent( |
|
new Event(type, { |
|
bubbles: true, |
|
cancelable: true, |
|
}), |
|
); |
|
} |
|
} |
|
} |
|
|
|
try { |
|
element.click(); |
|
} catch { |
|
// Native click is only a fallback. |
|
} |
|
} |
|
|
|
async function selectCatalogItem(item) { |
|
const liveItem = |
|
findLiveItemById(item.itemId); |
|
|
|
if (!liveItem) { |
|
throw new Error( |
|
`Could not re-find "${item.name}" in the current catalog.`, |
|
); |
|
} |
|
|
|
const card = liveItem.card; |
|
|
|
const clickTargets = [ |
|
card.querySelector(".product-image"), |
|
card.querySelector(".product-overlay"), |
|
card.querySelector(".product-description"), |
|
card, |
|
].filter(Boolean); |
|
|
|
card.scrollIntoView({ |
|
behavior: "auto", |
|
block: "center", |
|
inline: "nearest", |
|
}); |
|
|
|
await wait( |
|
SETTINGS.beforeCardClickDelayMs, |
|
); |
|
|
|
const previousName = getSelectedItemName(); |
|
const previousNormalized = |
|
normalizeText(previousName); |
|
|
|
const expectedNormalized = |
|
normalizeText(item.name); |
|
|
|
for (const clickTarget of clickTargets) { |
|
dispatchActivationEvents(clickTarget); |
|
await wait(350); |
|
|
|
const currentNormalized = |
|
normalizeText(getSelectedItemName()); |
|
|
|
if ( |
|
currentNormalized && |
|
currentNormalized !== previousNormalized |
|
) { |
|
break; |
|
} |
|
} |
|
|
|
await wait( |
|
SETTINGS.afterCardClickDelayMs, |
|
); |
|
|
|
await waitForCondition( |
|
() => { |
|
const currentName = |
|
getSelectedItemName(); |
|
|
|
const currentNormalized = |
|
normalizeText(currentName); |
|
|
|
if ( |
|
!currentNormalized || |
|
currentNormalized === "default character" |
|
) { |
|
return false; |
|
} |
|
|
|
const exactMatch = |
|
currentNormalized === expectedNormalized; |
|
|
|
const partialMatch = |
|
currentNormalized.includes(expectedNormalized) || |
|
expectedNormalized.includes(currentNormalized); |
|
|
|
const changed = |
|
currentNormalized !== previousNormalized; |
|
|
|
return ( |
|
Boolean(getMainDownloadButton()) && |
|
( |
|
exactMatch || |
|
partialMatch || |
|
changed |
|
) |
|
); |
|
}, |
|
{ |
|
timeoutMs: |
|
SETTINGS.selectionTimeoutMs, |
|
|
|
description: |
|
`"${item.name}" to load in the preview`, |
|
}, |
|
); |
|
|
|
await wait( |
|
SETTINGS.afterSelectionDelayMs, |
|
); |
|
} |
|
|
|
/* |
|
* In Place option |
|
*/ |
|
|
|
async function setCheckboxState( |
|
checkbox, |
|
desiredState, |
|
) { |
|
if (!checkbox) { |
|
return false; |
|
} |
|
|
|
if (checkbox.checked !== desiredState) { |
|
dispatchActivationEvents(checkbox); |
|
|
|
await waitForCondition( |
|
() => |
|
checkbox.checked === desiredState, |
|
{ |
|
timeoutMs: 4000, |
|
description: |
|
`In Place checkbox to become ${desiredState}`, |
|
}, |
|
); |
|
} |
|
|
|
return checkbox.checked === desiredState; |
|
} |
|
|
|
/* |
|
* Download modal |
|
*/ |
|
|
|
function getVisibleDownloadModal() { |
|
const candidates = [ |
|
...document.querySelectorAll( |
|
[ |
|
".static-modal .modal", |
|
".modal.in", |
|
".modal[role='dialog']", |
|
".static-modal", |
|
].join(", "), |
|
), |
|
]; |
|
|
|
return ( |
|
candidates.find((modal) => { |
|
if (!isElementVisible(modal)) { |
|
return false; |
|
} |
|
|
|
return Boolean( |
|
modal.querySelector( |
|
"select, .modal-footer, button", |
|
), |
|
); |
|
}) || null |
|
); |
|
} |
|
|
|
function inspectModal(modal = getVisibleDownloadModal()) { |
|
if (!modal) { |
|
console.warn( |
|
"No visible Mixamo download modal was found.", |
|
); |
|
|
|
return null; |
|
} |
|
|
|
const selects = [ |
|
...modal.querySelectorAll("select"), |
|
].map((select, index) => ({ |
|
index, |
|
name: select.name || "", |
|
id: select.id || "", |
|
className: select.className || "", |
|
value: select.value, |
|
options: [...select.options].map( |
|
(option) => ({ |
|
text: option.textContent.trim(), |
|
value: option.value, |
|
selected: option.selected, |
|
}), |
|
), |
|
})); |
|
|
|
const buttons = [ |
|
...modal.querySelectorAll("button, .btn"), |
|
].map((button, index) => ({ |
|
index, |
|
text: button.textContent.trim(), |
|
className: button.className || "", |
|
disabled: button.disabled, |
|
})); |
|
|
|
const result = { |
|
text: modal.textContent |
|
.replace(/\s+/g, " ") |
|
.trim(), |
|
selects, |
|
buttons, |
|
}; |
|
|
|
console.log( |
|
"Mixamo modal inspection:", |
|
result, |
|
); |
|
|
|
console.table( |
|
selects.map((select) => ({ |
|
index: select.index, |
|
id: select.id, |
|
name: select.name, |
|
value: select.value, |
|
options: select.options |
|
.map((option) => option.text) |
|
.join(" | "), |
|
})), |
|
); |
|
|
|
return result; |
|
} |
|
|
|
function findSelectContainingOptions( |
|
modal, |
|
optionTexts, |
|
) { |
|
const wanted = |
|
optionTexts.map(normalizeText); |
|
|
|
return ( |
|
[...modal.querySelectorAll("select")].find( |
|
(select) => { |
|
const available = [ |
|
...select.options, |
|
].map((option) => |
|
normalizeText( |
|
option.textContent, |
|
), |
|
); |
|
|
|
return wanted.some( |
|
(wantedText) => |
|
available.some( |
|
(availableText) => |
|
availableText.includes( |
|
wantedText, |
|
), |
|
), |
|
); |
|
}, |
|
) || null |
|
); |
|
} |
|
|
|
async function selectOptionByText( |
|
select, |
|
desiredTexts, |
|
{ |
|
settingName = "option", |
|
} = {}, |
|
) { |
|
if (!select) { |
|
console.info( |
|
`${settingName}: control not available; keeping Mixamo's current setting.`, |
|
); |
|
|
|
return false; |
|
} |
|
|
|
const desired = |
|
desiredTexts.map(normalizeText); |
|
|
|
const matchingOption = [ |
|
...select.options, |
|
].find((option) => { |
|
const optionText = normalizeText( |
|
option.textContent, |
|
); |
|
|
|
return desired.some( |
|
(desiredText) => |
|
optionText === desiredText || |
|
optionText.includes(desiredText) || |
|
desiredText.includes(optionText), |
|
); |
|
}); |
|
|
|
if (!matchingOption) { |
|
const availableOptions = [ |
|
...select.options, |
|
] |
|
.map((option) => |
|
option.textContent.trim(), |
|
) |
|
.join(", "); |
|
|
|
console.warn( |
|
`${settingName}: requested option not available. ` + |
|
`Keeping current value. Available options: ${availableOptions}`, |
|
); |
|
|
|
return false; |
|
} |
|
|
|
if ( |
|
select.value !== matchingOption.value |
|
) { |
|
select.value = |
|
matchingOption.value; |
|
|
|
select.dispatchEvent( |
|
new Event("input", { |
|
bubbles: true, |
|
}), |
|
); |
|
|
|
select.dispatchEvent( |
|
new Event("change", { |
|
bubbles: true, |
|
}), |
|
); |
|
|
|
await wait( |
|
SETTINGS.afterOptionChangeDelayMs, |
|
); |
|
} |
|
|
|
console.info( |
|
`${settingName}: ` + |
|
matchingOption.textContent.trim(), |
|
); |
|
|
|
return true; |
|
} |
|
|
|
async function configureDownloadModal( |
|
modal, |
|
item, |
|
) { |
|
/* |
|
* Log the controls once for diagnostic purposes. |
|
*/ |
|
const selects = [ |
|
...modal.querySelectorAll("select"), |
|
]; |
|
|
|
console.info( |
|
`"${item.name}" download modal contains ${selects.length} select control(s).`, |
|
); |
|
|
|
const formatSelect = |
|
findSelectContainingOptions( |
|
modal, |
|
[ |
|
"fbx binary", |
|
"fbx", |
|
"collada", |
|
"dae", |
|
], |
|
); |
|
|
|
await selectOptionByText( |
|
formatSelect, |
|
[SETTINGS.format], |
|
{ |
|
settingName: "Format", |
|
}, |
|
); |
|
|
|
const skinSelect = |
|
findSelectContainingOptions( |
|
modal, |
|
[ |
|
"with skin", |
|
"without skin", |
|
], |
|
); |
|
|
|
await selectOptionByText( |
|
skinSelect, |
|
SETTINGS.includeSkin |
|
? ["With Skin"] |
|
: ["Without Skin"], |
|
{ |
|
settingName: "Skin", |
|
}, |
|
); |
|
|
|
const fpsSelect = |
|
findSelectContainingOptions( |
|
modal, |
|
[ |
|
"24 fps", |
|
"30 fps", |
|
"60 fps", |
|
"frames per second", |
|
], |
|
); |
|
|
|
await selectOptionByText( |
|
fpsSelect, |
|
[ |
|
`${SETTINGS.fps} FPS`, |
|
String(SETTINGS.fps), |
|
], |
|
{ |
|
settingName: "Frames per Second", |
|
}, |
|
); |
|
|
|
const reductionSelect = |
|
findSelectContainingOptions( |
|
modal, |
|
[ |
|
"no keyframe reduction", |
|
"keyframe reduction", |
|
"uniform", |
|
"non-uniform", |
|
"non uniform", |
|
"none", |
|
], |
|
); |
|
|
|
const reductionOptions = { |
|
none: [ |
|
"None", |
|
"No Keyframe Reduction", |
|
], |
|
|
|
uniform: [ |
|
"Uniform", |
|
], |
|
|
|
"non-uniform": [ |
|
"Non-uniform", |
|
"Non Uniform", |
|
], |
|
}; |
|
|
|
await selectOptionByText( |
|
reductionSelect, |
|
reductionOptions[ |
|
SETTINGS.keyframeReduction |
|
] || ["None"], |
|
{ |
|
settingName: |
|
"Keyframe Reduction", |
|
}, |
|
); |
|
} |
|
|
|
function getModalDownloadButton(modal) { |
|
const buttons = [ |
|
...modal.querySelectorAll( |
|
"button, .btn", |
|
), |
|
].filter(isElementVisible); |
|
|
|
return ( |
|
buttons.find((button) => { |
|
const text = normalizeText( |
|
button.textContent, |
|
); |
|
|
|
return ( |
|
text === "download" || |
|
text.includes("download") |
|
); |
|
}) || |
|
modal.querySelector( |
|
".modal-footer .btn-primary", |
|
) || |
|
null |
|
); |
|
} |
|
|
|
async function openDownloadModal() { |
|
const mainButton = |
|
await waitForCondition( |
|
() => getMainDownloadButton(), |
|
{ |
|
timeoutMs: |
|
SETTINGS.modalTimeoutMs, |
|
description: |
|
"the main preview Download button", |
|
}, |
|
); |
|
|
|
dispatchActivationEvents( |
|
mainButton, |
|
); |
|
|
|
return await waitForCondition( |
|
() => getVisibleDownloadModal(), |
|
{ |
|
timeoutMs: |
|
SETTINGS.modalTimeoutMs, |
|
|
|
description: |
|
"the Mixamo download modal", |
|
}, |
|
); |
|
} |
|
|
|
async function waitForModalToClose( |
|
modal, |
|
) { |
|
try { |
|
await waitForCondition( |
|
() => |
|
!document.contains(modal) || |
|
!isElementVisible(modal), |
|
{ |
|
timeoutMs: 12000, |
|
description: |
|
"the download modal to close", |
|
}, |
|
); |
|
} catch { |
|
console.info( |
|
"Modal-close confirmation timed out; continuing after the download delay.", |
|
); |
|
} |
|
} |
|
|
|
/* |
|
* Download processing |
|
*/ |
|
|
|
async function downloadItem(item) { |
|
await selectCatalogItem(item); |
|
|
|
const inPlaceCheckbox = |
|
getInPlaceCheckbox(); |
|
|
|
if (inPlaceCheckbox) { |
|
await setCheckboxState( |
|
inPlaceCheckbox, |
|
SETTINGS.inPlace, |
|
); |
|
|
|
console.info( |
|
`In Place: ${SETTINGS.inPlace}`, |
|
); |
|
} else { |
|
console.info( |
|
`"${item.name}" has no In Place option.`, |
|
); |
|
} |
|
|
|
const modal = |
|
await openDownloadModal(); |
|
|
|
await configureDownloadModal( |
|
modal, |
|
item, |
|
); |
|
|
|
const finalDownloadButton = |
|
getModalDownloadButton(modal); |
|
|
|
if (!finalDownloadButton) { |
|
inspectModal(modal); |
|
|
|
throw new Error( |
|
`Could not find the final Download button for "${item.name}".`, |
|
); |
|
} |
|
|
|
dispatchActivationEvents( |
|
finalDownloadButton, |
|
); |
|
|
|
await waitForModalToClose(modal); |
|
|
|
await wait( |
|
item.isPack |
|
? SETTINGS.packDownloadDelayMs |
|
: SETTINGS.afterDownloadClickDelayMs, |
|
); |
|
} |
|
|
|
function recordFailure(item, error) { |
|
state.failures = |
|
state.failures.filter( |
|
(failure) => |
|
failure.itemId !== item.itemId, |
|
); |
|
|
|
state.failures.push({ |
|
page: state.currentPage, |
|
index: item.index, |
|
itemId: item.itemId, |
|
name: item.name, |
|
|
|
type: item.isPack |
|
? "animation-pack" |
|
: "animation", |
|
|
|
error: String( |
|
error?.message || error, |
|
), |
|
|
|
failedAt: |
|
new Date().toISOString(), |
|
}); |
|
|
|
saveState(); |
|
} |
|
|
|
async function processItem(item) { |
|
if ( |
|
state.completedIds.has( |
|
item.itemId, |
|
) |
|
) { |
|
console.info( |
|
`Skipping completed ${ |
|
item.isPack |
|
? "pack" |
|
: "animation" |
|
}: ${item.name}`, |
|
); |
|
|
|
return true; |
|
} |
|
|
|
const totalAttempts = |
|
SETTINGS.retries + 1; |
|
|
|
for ( |
|
let attempt = 1; |
|
attempt <= totalAttempts; |
|
attempt += 1 |
|
) { |
|
if (state.stopped) { |
|
throw new Error( |
|
"Downloader stopped.", |
|
); |
|
} |
|
|
|
console.group( |
|
`Page ${state.currentPage}, ` + |
|
`item ${item.index + 1}: ` + |
|
item.name, |
|
); |
|
|
|
console.info({ |
|
type: item.isPack |
|
? "animation pack" |
|
: "animation", |
|
|
|
itemId: item.itemId, |
|
|
|
animationCount: |
|
item.animationCount, |
|
|
|
description: |
|
item.description, |
|
|
|
attempt, |
|
totalAttempts, |
|
}); |
|
|
|
try { |
|
await downloadItem(item); |
|
|
|
state.completedIds.add( |
|
item.itemId, |
|
); |
|
|
|
state.failures = |
|
state.failures.filter( |
|
(failure) => |
|
failure.itemId !== |
|
item.itemId, |
|
); |
|
|
|
saveState(); |
|
|
|
console.info( |
|
`Completed "${item.name}". ` + |
|
`Total completed files: ` + |
|
state.completedIds.size, |
|
); |
|
|
|
console.groupEnd(); |
|
|
|
return true; |
|
} catch (error) { |
|
console.error( |
|
`Attempt ${attempt} failed:`, |
|
error, |
|
); |
|
|
|
console.groupEnd(); |
|
|
|
if ( |
|
attempt < totalAttempts |
|
) { |
|
await wait( |
|
SETTINGS.retryDelayMs, |
|
); |
|
} else { |
|
recordFailure( |
|
item, |
|
error, |
|
); |
|
} |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
/* |
|
* Public controls |
|
*/ |
|
|
|
window.mixamoDownloader = { |
|
stop() { |
|
state.stopped = true; |
|
saveState(); |
|
|
|
console.warn( |
|
"The downloader will stop after the current operation.", |
|
); |
|
}, |
|
|
|
status() { |
|
return { |
|
running: state.running, |
|
stopped: state.stopped, |
|
currentPage: |
|
state.currentPage, |
|
currentItem: |
|
state.currentItem, |
|
completedFiles: |
|
state.completedIds.size, |
|
failures: [ |
|
...state.failures, |
|
], |
|
settings: { |
|
...SETTINGS, |
|
}, |
|
}; |
|
}, |
|
|
|
listFailures() { |
|
console.table( |
|
state.failures, |
|
); |
|
|
|
return [ |
|
...state.failures, |
|
]; |
|
}, |
|
|
|
inspectModal() { |
|
return inspectModal(); |
|
}, |
|
|
|
clearProgress, |
|
}; |
|
|
|
/* |
|
* Main loop |
|
*/ |
|
|
|
async function start() { |
|
if (state.running) { |
|
console.warn( |
|
"The Mixamo downloader is already running.", |
|
); |
|
|
|
return; |
|
} |
|
|
|
state.running = true; |
|
state.stopped = false; |
|
|
|
loadState(); |
|
|
|
console.info( |
|
"Starting Mixamo downloader.", |
|
SETTINGS, |
|
); |
|
|
|
console.info( |
|
"Available controls:", |
|
{ |
|
status: |
|
"mixamoDownloader.status()", |
|
|
|
stop: |
|
"mixamoDownloader.stop()", |
|
|
|
failures: |
|
"mixamoDownloader.listFailures()", |
|
|
|
inspectModal: |
|
"mixamoDownloader.inspectModal()", |
|
|
|
clear: |
|
"mixamoDownloader.clearProgress()", |
|
}, |
|
); |
|
|
|
try { |
|
await goToPage( |
|
Math.max( |
|
SETTINGS.startPage, |
|
state.currentPage, |
|
), |
|
); |
|
|
|
while ( |
|
!state.stopped && |
|
state.currentPage >= SETTINGS.startPage && |
|
state.currentPage <= SETTINGS.endPage |
|
) { |
|
const visiblePage = |
|
getCurrentPage(); |
|
|
|
if (visiblePage !== null) { |
|
state.currentPage = |
|
visiblePage; |
|
} |
|
|
|
const initialCards = |
|
getDownloadableCards(); |
|
|
|
if ( |
|
initialCards.length === 0 |
|
) { |
|
throw new Error( |
|
`No downloadable items found on page ${state.currentPage}.`, |
|
); |
|
} |
|
|
|
const pageItemLimit = |
|
SETTINGS.maxItemsPerPage === null |
|
? initialCards.length |
|
: Math.min( |
|
initialCards.length, |
|
SETTINGS.maxItemsPerPage, |
|
); |
|
|
|
console.info( |
|
`Page ${state.currentPage}: ` + |
|
`${initialCards.length} downloadable items found; ` + |
|
`${pageItemLimit} will be processed.`, |
|
); |
|
|
|
const startingIndex = |
|
Math.min( |
|
state.currentItem, |
|
pageItemLimit, |
|
); |
|
|
|
for ( |
|
let index = startingIndex; |
|
index < pageItemLimit; |
|
index += 1 |
|
) { |
|
if (state.stopped) { |
|
break; |
|
} |
|
|
|
const freshCards = |
|
getDownloadableCards(); |
|
|
|
const card = |
|
freshCards[index]; |
|
|
|
if (!card) { |
|
console.error( |
|
`Item ${index + 1} disappeared after a React rerender.`, |
|
); |
|
|
|
state.currentItem = |
|
index + 1; |
|
|
|
saveState(); |
|
continue; |
|
} |
|
|
|
state.currentItem = index; |
|
saveState(); |
|
|
|
const item = |
|
getCardInfo( |
|
card, |
|
index, |
|
); |
|
|
|
const succeeded = |
|
await processItem(item); |
|
|
|
if ( |
|
!succeeded && |
|
!SETTINGS.skipFailedItems |
|
) { |
|
throw new Error( |
|
`"${item.name}" failed and skipFailedItems is false.`, |
|
); |
|
} |
|
|
|
state.currentItem = |
|
index + 1; |
|
|
|
saveState(); |
|
} |
|
|
|
if (state.stopped) { |
|
break; |
|
} |
|
|
|
state.currentItem = 0; |
|
saveState(); |
|
|
|
if ( |
|
state.currentPage >= SETTINGS.endPage || |
|
!hasNextPage() |
|
) { |
|
break; |
|
} |
|
|
|
const moved = |
|
await goToNextPage(); |
|
|
|
if (!moved) { |
|
break; |
|
} |
|
|
|
state.currentPage = |
|
getCurrentPage() || |
|
state.currentPage + 1; |
|
|
|
state.currentItem = 0; |
|
|
|
saveState(); |
|
} |
|
|
|
if (state.stopped) { |
|
console.warn( |
|
"Downloader stopped. Progress was saved.", |
|
); |
|
|
|
return; |
|
} |
|
|
|
console.info( |
|
"Mixamo download pass finished.", |
|
{ |
|
completedFiles: |
|
state.completedIds.size, |
|
|
|
failedFiles: |
|
state.failures.length, |
|
|
|
failures: |
|
state.failures, |
|
}, |
|
); |
|
|
|
alert( |
|
[ |
|
"Mixamo download pass finished.", |
|
"", |
|
`Completed files: ${state.completedIds.size}`, |
|
`Failed files: ${state.failures.length}`, |
|
"", |
|
"Check the browser download list and console.", |
|
].join("\n"), |
|
); |
|
} catch (error) { |
|
saveState(); |
|
|
|
console.error( |
|
"Mixamo downloader stopped with an error:", |
|
error, |
|
); |
|
} finally { |
|
state.running = false; |
|
} |
|
} |
|
|
|
start(); |
|
})(); |
Update downloader for current Mixamo UI
Update Mixamo bulk downloader for the current site UI
This PR updates the bulk animation download script to work with the current Mixamo interface.
The original script was no longer reliably selecting animations or configuring the download modal because several parts of Mixamo’s DOM and export workflow have changed.
Changes
Supports both individual animations and animation packs.
Processes the catalog at 96 results per page.
Supports the current 26-page animation catalog.
Uses more resilient selectors instead of fixed
nth-childpaths and dropdown indexes.Dispatches pointer and mouse events for better compatibility with Mixamo’s React interface.
Re-queries cards after UI rerenders instead of holding stale DOM references.
Detects animations and packs using stable thumbnail IDs.
Adds local-storage checkpoint and resume support.
Adds retry and failure logging.
Adds console controls for stopping, checking status, clearing progress, and inspecting the download modal.
Treats optional export controls gracefully when Mixamo does not show them for a particular animation or pack.
Attempts to select:
Usage
mixamo-bulk-downloader.js.Console controls:
Testing
Tested with Firefox against the Mixamo animation catalog in July 2026.
The current catalog showed 26 pages at 96 results per page. Animation-pack cards are included and downloaded through the same normal preview and download flow as individual animations.
Limitations
Mixamo does not expose a browser-side event confirming that a file has completely finished downloading. The script therefore waits for a configurable delay after clicking the final Download button.
The Mixamo UI is not a documented public API and may change again. The selectors are intentionally defensive, but future site changes may require additional updates.
For safety, users should initially set:
maxItemsPerPage: 3After confirming the first downloads work correctly, it can be changed back to:
maxItemsPerPage: nullHERE IS THE FULL CODE: