mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 06:00:09 +02:00
hands/rec/validate.py (standard library; Linux and Windows, Python 3.12+) checks an export before upload and when it's received: SHA256SUMS, an allow-list of files, the manifest's schema and keys, the consent version, a uuid4 contributor, no identifying fields in calibration.json or device.json, every sets.bin.zst decompressed to its end as a stream with each FHSET01 header checked against the manifest, jsonl lines, the total size. It decompresses with compression.zstd, zstandard or the zstd program. validate.py DIR [--json]. hands/rec/hub.py uploads an export with huggingface_hub, as a pull request to contributions/<contributor>/<session>: validate first, refuse a repeat of the same export, check the login (whoami) and access (auth_check), upload_folder(create_pr=True) with the manifest summary as the description, then record the PR under "uploads" in session.json. Errors are explained (terms not accepted, not found, 401/403, network). --dry-run makes no network calls. FT_HANDREC_DATASET overrides HF_DATASET (DeeJanuz/frametop-hands); while the texts are drafts a real upload needs FT_HANDREC_ALLOW_UPLOAD=1. The Upload page shows the login with "Check again" and how to run hf auth login in a terminal (the token never enters the window), then Upload with a phase, progress and Cancel (hub.py as a child process), the PR link, and a warning for an export uploaded before. The manual command stays as the fallback. ft-handrec --hub-dry-run. session.py also saves device.json: cv.cad_from_cal and head from /persist/device_config.json, the labeller's shape, nothing identifying; export copies it. Session ids with a -N suffix are accepted everywhere. hands/rec/rehearse.sh runs it all without the headset: ft-ringplay plays 30 s of a capture into a ring, session.py records a short test script with ft-handpanel --no-vr and a tracker, then export, validate and a dry-run upload (--repo ID uploads for real). It runs in one frame-job scope, deletes its data and stops its processes, also on Ctrl+C. hands/rec/tests/test_validate.py covers good and broken exports and hub.py without the network. The dev container gains python3-huggingface-hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1380 lines
65 KiB
QML
1380 lines
65 KiB
QML
// Frametop Hand Recorder (Kirigami). Backend: ft_handrec.py ("backend"); frame sets come from
|
|
// its image provider (image://frames/...).
|
|
import QtQuick
|
|
import QtQuick.Controls as Controls
|
|
import QtQuick.Layouts
|
|
import org.kde.kirigami as Kirigami
|
|
|
|
Kirigami.ApplicationWindow {
|
|
id: root
|
|
title: "Frametop Hand Recorder"
|
|
width: Kirigami.Units.gridUnit * 50
|
|
height: Kirigami.Units.gridUnit * 36
|
|
|
|
// The session chosen on the Export page, for the Upload page.
|
|
property string chosenSession: ""
|
|
|
|
globalDrawer: Kirigami.GlobalDrawer {
|
|
isMenu: false
|
|
modal: false
|
|
collapsible: true
|
|
collapsed: root.width < Kirigami.Units.gridUnit * 34
|
|
actions: [
|
|
Kirigami.Action { text: "Welcome"; icon.name: "help-about"; onTriggered: root.show(welcomePage) },
|
|
Kirigami.Action {
|
|
text: "Before you start"; icon.name: "view-task"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(checklistPage)
|
|
},
|
|
Kirigami.Action {
|
|
text: "Session"; icon.name: "media-record"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(sessionPage)
|
|
},
|
|
Kirigami.Action { text: "Review"; icon.name: "view-preview"; onTriggered: root.show(reviewPage) },
|
|
Kirigami.Action {
|
|
text: "Export"; icon.name: "document-export"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(exportPage)
|
|
},
|
|
Kirigami.Action {
|
|
text: "Upload"; icon.name: "cloud-upload"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(uploadPage)
|
|
}
|
|
]
|
|
}
|
|
|
|
// The texts wait for a legal review; until then nobody should contribute.
|
|
component DraftBanner: Kirigami.InlineMessage {
|
|
visible: backend.textsDraft
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Contributions aren't open yet. These texts are drafts waiting for a legal review: you can "
|
|
+ "record and review, but please don't upload anything until this message is gone."
|
|
}
|
|
|
|
// Read-only Markdown (CONSENT.md, UPLOAD.md), selectable for copying.
|
|
component MarkdownText: Controls.TextArea {
|
|
id: markdownText
|
|
property string markdown
|
|
// Links get the application palette's colour when the text is parsed, and Kirigami's theme
|
|
// doesn't set that palette: set it, then parse again (the theme resolves after creation).
|
|
readonly property color linkColor: Kirigami.Theme.linkColor
|
|
function parse() {
|
|
backend.setLinkColor(linkColor)
|
|
text = markdown
|
|
}
|
|
onLinkColorChanged: parse()
|
|
onMarkdownChanged: parse()
|
|
Component.onCompleted: parse()
|
|
textFormat: TextEdit.MarkdownText
|
|
readOnly: true
|
|
selectByMouse: true
|
|
wrapMode: Text.Wrap
|
|
background: null
|
|
padding: 0
|
|
onLinkActivated: link => Qt.openUrlExternally(link)
|
|
}
|
|
|
|
function plural(n, word) {
|
|
return n + " " + word + (n === 1 ? "" : "s")
|
|
}
|
|
|
|
function show(page) {
|
|
pageStack.clear()
|
|
pageStack.push(page)
|
|
}
|
|
|
|
// --page welcome|checklist|session|review|export|upload opens the app on that page; the
|
|
// Welcome page comes first until the consent is agreed to.
|
|
pageStack.initialPage: backend.needsConsent && startPage !== "review" ? welcomePage
|
|
: (({ welcome: welcomePage, checklist: checklistPage, session: sessionPage, review: reviewPage,
|
|
export: exportPage, upload: uploadPage })[startPage] || checklistPage)
|
|
|
|
Connections {
|
|
target: backend
|
|
function onMessage(text, isError) {
|
|
root.showPassiveNotification(text, isError ? "long" : "short")
|
|
}
|
|
}
|
|
|
|
// Asks before something is deleted for good: ask(title, text, function).
|
|
Kirigami.PromptDialog {
|
|
id: confirm
|
|
property var action: null
|
|
standardButtons: Controls.Dialog.Ok | Controls.Dialog.Cancel
|
|
onAccepted: if (action) action()
|
|
function ask(title, text, fn) {
|
|
confirm.title = title
|
|
confirm.subtitle = text
|
|
confirm.action = fn
|
|
confirm.open()
|
|
}
|
|
}
|
|
|
|
// Closing during a session asks first. Quitting closes the window again (Qt 6), so the
|
|
// answer is remembered; the backend stops the session as the app quits.
|
|
property bool quitting: false
|
|
onClosing: close => {
|
|
if (backend.sessionActive && !quitting) {
|
|
close.accepted = false
|
|
confirm.ask("Stop the session?", "A session is recording. Closing stops it; what's recorded so far is kept.",
|
|
() => { root.quitting = true; Qt.quit() })
|
|
} else if (backend.uploading && !quitting) {
|
|
close.accepted = false
|
|
confirm.ask("Cancel the upload?", "An upload is running. Closing cancels it; you can start it again later.",
|
|
() => { root.quitting = true; Qt.quit() })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Welcome
|
|
Component {
|
|
id: welcomePage
|
|
Kirigami.ScrollablePage {
|
|
title: "Welcome"
|
|
header: DraftBanner { }
|
|
|
|
ColumnLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
MarkdownText {
|
|
Layout.fillWidth: true
|
|
markdown: backend.consentText
|
|
}
|
|
|
|
Kirigami.Separator { Layout.fillWidth: true }
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.needsConsent
|
|
type: Kirigami.MessageType.Positive
|
|
text: "You agreed to this text (" + backend.consentAccepted + "). Your contributor id is "
|
|
+ backend.contributor + ": keep it if you might want to withdraw later."
|
|
}
|
|
Controls.Button {
|
|
visible: !backend.needsConsent
|
|
text: "Copy contributor id"
|
|
icon.name: "edit-copy"
|
|
onClicked: backend.copy(backend.contributor)
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.needsConsent && backend.contributor !== ""
|
|
type: Kirigami.MessageType.Information
|
|
text: "This text changed since you last agreed to it. Please read it again; your contributor id stays the same."
|
|
}
|
|
|
|
Controls.CheckBox {
|
|
id: adult
|
|
visible: backend.needsConsent
|
|
text: "I'm 18 or older"
|
|
}
|
|
Controls.CheckBox {
|
|
id: agree
|
|
visible: backend.needsConsent
|
|
text: "I agree to the text above"
|
|
}
|
|
RowLayout {
|
|
visible: backend.needsConsent
|
|
Controls.Label { text: "Handedness (optional):" }
|
|
Controls.ComboBox {
|
|
id: handed
|
|
model: backend.handednessChoices
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(backend.handedness))
|
|
}
|
|
}
|
|
Controls.Button {
|
|
visible: backend.needsConsent
|
|
enabled: adult.checked && agree.checked
|
|
text: "Agree and continue"
|
|
icon.name: "go-next"
|
|
onClicked: {
|
|
backend.acceptConsent(adult.checked, agree.checked, handed.currentValue)
|
|
if (!backend.needsConsent)
|
|
root.show(checklistPage)
|
|
}
|
|
}
|
|
Controls.Button {
|
|
visible: !backend.needsConsent
|
|
text: "Continue"
|
|
icon.name: "go-next"
|
|
onClicked: root.show(checklistPage)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Before you start
|
|
Component {
|
|
id: checklistPage
|
|
Kirigami.ScrollablePage {
|
|
id: checklist
|
|
title: "Before you start"
|
|
readonly property bool privacyOk: privacy1.checked && privacy2.checked && privacy3.checked
|
|
readonly property bool ready: privacyOk && lighting.currentIndex >= 0 && backend.diskOk
|
|
&& backend.runnerError === "" && !backend.sessionActive
|
|
|
|
function answers() {
|
|
const objects = []
|
|
for (let i = 0; i < objectBoxes.count; ++i) {
|
|
const box = objectBoxes.itemAt(i)
|
|
if (box.checked)
|
|
objects.push(box.value)
|
|
}
|
|
return {
|
|
objects: objects,
|
|
own_objects: ownObjects.text.split(",").map(s => s.trim()).filter(s => s !== ""),
|
|
controllers: straps.checked ? "straps" : "none",
|
|
sleeves: sleeves.currentValue,
|
|
rings: rings.checked,
|
|
watch: watch.checked,
|
|
notes: notes.text,
|
|
privacy: privacyOk
|
|
}
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DraftBanner { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.runnerError !== ""
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Error
|
|
text: backend.runnerError
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.sessionActive
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Information
|
|
text: "A session is running."
|
|
actions: [ Kirigami.Action { text: "Go to it"; onTriggered: root.show(sessionPage) } ]
|
|
}
|
|
}
|
|
|
|
Kirigami.FormLayout {
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Within reach" }
|
|
Controls.Label {
|
|
Kirigami.FormData.label: ""
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
text: "Put these on the desk or table in front of you. Untick what you don't have; "
|
|
+ "the session skips those."
|
|
}
|
|
Repeater {
|
|
id: objectBoxes
|
|
model: backend.objects
|
|
Controls.CheckBox {
|
|
required property var modelData
|
|
required property int index
|
|
readonly property string value: modelData.value
|
|
Kirigami.FormData.label: index === 0 ? "Objects:" : ""
|
|
text: modelData.text
|
|
checked: true
|
|
}
|
|
}
|
|
Controls.TextField {
|
|
id: ownObjects
|
|
Kirigami.FormData.label: "Your own:"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
placeholderText: "other things you use, separated by commas"
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Controllers" }
|
|
ColumnLayout {
|
|
Kirigami.FormData.label: "Ergonomic Kit straps:"
|
|
Controls.RadioButton { id: straps; text: "Yes, I have the controllers with the straps" }
|
|
Controls.RadioButton { text: "No"; checked: true }
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "With the straps, the controllers stay on your hands while your fingers move freely. "
|
|
+ "The controllers' tracking then says exactly where your hands are, which teaches the "
|
|
+ "model how far away a hand is. Without them, the two controller sections are skipped."
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Light" }
|
|
Controls.ComboBox {
|
|
id: lighting
|
|
Kirigami.FormData.label: "Lighting this round:"
|
|
model: backend.lightingChoices
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
currentIndex: -1
|
|
displayText: currentIndex < 0 ? "Choose…" : currentText
|
|
onActivated: backend.checkLighting(currentValue)
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Each round in a different light helps the most: dim, a normal room, daylight."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
Layout.fillWidth: true
|
|
visible: backend.lightingNote !== "" && lighting.currentIndex >= 0
|
|
Layout.preferredHeight: visible ? implicitHeight : 0
|
|
type: Kirigami.MessageType.Warning
|
|
text: backend.lightingNote
|
|
}
|
|
Controls.Button {
|
|
visible: backend.lightingNote !== "" && lighting.currentIndex >= 0
|
|
text: "Check the light again"
|
|
icon.name: "view-refresh"
|
|
onClicked: backend.checkLighting(lighting.currentValue)
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "You" }
|
|
Controls.ComboBox {
|
|
id: sleeves
|
|
Kirigami.FormData.label: "Sleeves:"
|
|
model: backend.sleeveChoices
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
}
|
|
Controls.CheckBox { id: rings; Kirigami.FormData.label: "Wearing:"; text: "Rings" }
|
|
Controls.CheckBox { id: watch; text: "A watch or bracelet" }
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Wear what you normally do: the dataset needs hands with and without them. This only notes it."
|
|
}
|
|
Controls.TextField {
|
|
id: notes
|
|
Kirigami.FormData.label: "Notes (optional):"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
placeholderText: "anything that helps, e.g. a bandage on a finger"
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Privacy" }
|
|
Controls.CheckBox {
|
|
id: privacy1
|
|
Kirigami.FormData.label: "I've checked:"
|
|
text: "I'm facing away from other people, mirrors and windows"
|
|
}
|
|
Controls.CheckBox {
|
|
id: privacy2
|
|
text: "No papers, photos, screens or other private things are in view"
|
|
}
|
|
Controls.CheckBox {
|
|
id: privacy3
|
|
text: "Nobody else's face or hands will be in view"
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Space" }
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Free space:"
|
|
text: backend.freeText + " (a round takes about 10 GB)"
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
Layout.fillWidth: true
|
|
visible: !backend.diskOk
|
|
type: Kirigami.MessageType.Error
|
|
text: "Not enough free space for a round. Export and delete earlier sessions (Review), "
|
|
+ "or free up space, first."
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "What will happen" }
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
text: "Press Start, then put the headset on. A panel in the headset says what to do, one "
|
|
+ "section at a time: hand poses, gestures, typing and the mouse, your objects, touching a "
|
|
+ "dot, and moves with the controllers on and off. Each section is recorded as one take.\n\n"
|
|
+ "Space in this window pauses and Esc stops. You can also skip a section.\n\n"
|
|
+ "Nothing leaves the headset. Afterwards you watch the takes in Review, delete anything "
|
|
+ "you don't want to share, and only then export."
|
|
}
|
|
Controls.Button {
|
|
text: "Start"
|
|
icon.name: "media-record"
|
|
enabled: checklist.ready
|
|
onClicked: {
|
|
if (backend.startSession(checklist.answers(), lighting.currentValue))
|
|
root.show(sessionPage)
|
|
}
|
|
}
|
|
Controls.Label {
|
|
visible: !checklist.ready && !backend.sessionActive
|
|
opacity: 0.7
|
|
text: !checklist.privacyOk ? "Tick the three privacy checks to start."
|
|
: lighting.currentIndex < 0 ? "Choose the lighting to start." : ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Session
|
|
Component {
|
|
id: sessionPage
|
|
Kirigami.Page {
|
|
id: sessionView
|
|
title: "Session"
|
|
readonly property var st: backend.status
|
|
readonly property string state: st.state || ""
|
|
readonly property var stateText: ({
|
|
starting: "Starting…", intro: "Get ready", running: "Recording", paused: "Paused",
|
|
between: "Between sections", done: "Done", stopped: "Stopped", error: "Error"
|
|
})
|
|
|
|
// Space and Esc while the window has focus (the panel in the headset says so too).
|
|
Shortcut {
|
|
sequence: "Space"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.togglePause()
|
|
}
|
|
Shortcut {
|
|
sequence: "Esc"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.stopSession()
|
|
}
|
|
|
|
// A hand chip like the panel's: seen (green), lost (orange) or hidden.
|
|
component HandChip: Controls.Label {
|
|
property var seen
|
|
visible: seen === true || seen === false || seen === "seen" || seen === "lost"
|
|
readonly property bool ok: seen === true || seen === "seen"
|
|
padding: Kirigami.Units.smallSpacing
|
|
leftPadding: Kirigami.Units.largeSpacing
|
|
rightPadding: Kirigami.Units.largeSpacing
|
|
color: "white"
|
|
background: Rectangle {
|
|
radius: height / 2
|
|
color: parent.ok ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.neutralTextColor
|
|
}
|
|
}
|
|
|
|
header: Kirigami.InlineMessage {
|
|
visible: backend.runnerError !== "" || sessionView.state === "error"
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Error
|
|
text: backend.runnerError || ("The session stopped with an error: " + (sessionView.st.error || "unknown"))
|
|
}
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Kirigami.Units.gridUnit * 4
|
|
visible: sessionView.state === ""
|
|
icon.name: "media-record"
|
|
text: "No session yet"
|
|
explanation: "Go through Before you start, then press Start there."
|
|
helpfulAction: Kirigami.Action {
|
|
text: "Before you start"; icon.name: "view-task"
|
|
onTriggered: root.show(checklistPage)
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
anchors.margins: Kirigami.Units.gridUnit
|
|
visible: sessionView.state !== ""
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
Kirigami.Heading {
|
|
level: 1
|
|
text: sessionView.stateText[sessionView.state] || sessionView.state
|
|
}
|
|
Controls.Label {
|
|
visible: (sessionView.st.section_index || 0) > 0
|
|
text: "Section " + sessionView.st.section_index + " of " + sessionView.st.section_count
|
|
+ (sessionView.st.title ? ": " + sessionView.st.title : "")
|
|
font.bold: true
|
|
}
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
visible: !!sessionView.st.prompt
|
|
wrapMode: Text.Wrap
|
|
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.4
|
|
text: sessionView.st.prompt || ""
|
|
}
|
|
Controls.Label {
|
|
visible: sessionView.st.seconds_left !== undefined && sessionView.st.seconds_left !== null
|
|
&& sessionView.st.seconds_left >= 0
|
|
text: Math.ceil(sessionView.st.seconds_left || 0) + " s left"
|
|
opacity: 0.7
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !!sessionView.st.note
|
|
// (A hidden InlineMessage keeps its height in a layout.)
|
|
Layout.preferredHeight: visible ? implicitHeight : 0
|
|
type: Kirigami.MessageType.Warning
|
|
text: sessionView.st.note || ""
|
|
}
|
|
RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
HandChip { text: "Left hand"; seen: sessionView.st.hands ? sessionView.st.hands.left : undefined }
|
|
HandChip { text: "Right hand"; seen: sessionView.st.hands ? sessionView.st.hands.right : undefined }
|
|
}
|
|
Controls.Label {
|
|
visible: !!sessionView.st.take
|
|
text: "Recording to take " + (sessionView.st.take || "")
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
|
|
Item { Layout.fillHeight: true }
|
|
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "The instructions appear in the headset. While this window has focus, Space pauses "
|
|
+ "and resumes, and Esc stops. Stopping keeps what's recorded so far."
|
|
}
|
|
RowLayout {
|
|
// No keyboard focus on the buttons, so Space always reaches the shortcut.
|
|
Controls.Button {
|
|
visible: !backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: "New session…"
|
|
icon.name: "media-record"
|
|
onClicked: root.show(checklistPage)
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: sessionView.state === "paused" ? "Resume" : "Pause"
|
|
icon.name: sessionView.state === "paused" ? "media-playback-start" : "media-playback-pause"
|
|
onClicked: backend.togglePause()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Skip section"
|
|
icon.name: "media-skip-forward"
|
|
onClicked: backend.skipSection()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Stop"
|
|
icon.name: "media-playback-stop"
|
|
onClicked: backend.stopSession()
|
|
}
|
|
Controls.Button {
|
|
visible: !backend.sessionActive && backend.sessionId !== ""
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Review this session"
|
|
icon.name: "view-preview"
|
|
onClicked: {
|
|
root.show(reviewPage)
|
|
pageStack.push(takesPage, { session: backend.sessionId })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Review
|
|
Component {
|
|
id: reviewPage
|
|
Kirigami.ScrollablePage {
|
|
title: "Review"
|
|
actions: [
|
|
Kirigami.Action { text: "Refresh"; icon.name: "view-refresh"; onTriggered: backend.refreshSessions() }
|
|
]
|
|
|
|
ListView {
|
|
model: backend.sessions
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Kirigami.Units.gridUnit * 4
|
|
visible: parent.count === 0
|
|
text: "No sessions yet"
|
|
explanation: "Recorded sessions appear here, to watch and trim before you export them."
|
|
}
|
|
|
|
delegate: Controls.ItemDelegate {
|
|
id: sessionRow
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
onClicked: pageStack.push(takesPage, { session: modelData.id })
|
|
|
|
contentItem: RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Kirigami.Icon {
|
|
source: sessionRow.modelData.active ? "media-record" : "folder-videos"
|
|
implicitWidth: Kirigami.Units.iconSizes.medium
|
|
implicitHeight: implicitWidth
|
|
}
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
Controls.Label {
|
|
text: sessionRow.modelData.label + (sessionRow.modelData.active ? " (recording)" : "")
|
|
+ (sessionRow.modelData.dry_run ? " (dry run)" : "")
|
|
font.bold: true
|
|
}
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: root.plural(sessionRow.modelData.takes, "take") + " · " + sessionRow.modelData.sizeText
|
|
+ (sessionRow.modelData.statusText ? " · " + sessionRow.modelData.statusText : "")
|
|
+ (sessionRow.modelData.lightingText ? " · " + sessionRow.modelData.lightingText : "")
|
|
+ (sessionRow.modelData.export_stale ? " · changed since its export"
|
|
: sessionRow.modelData.exported ? " · exported (" + sessionRow.modelData.exportText + ")" : "")
|
|
}
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "edit-delete"
|
|
text: "Delete session"
|
|
display: Controls.AbstractButton.IconOnly
|
|
enabled: !sessionRow.modelData.active
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: {
|
|
const sid = sessionRow.modelData.id
|
|
confirm.ask("Delete this session?",
|
|
"The session of " + sessionRow.modelData.label + " (" + root.plural(sessionRow.modelData.takes, "take")
|
|
+ ", " + sessionRow.modelData.sizeText + ") and its export are deleted from the "
|
|
+ "headset for good.",
|
|
() => { pageStack.pop(pageStack.items[0]); backend.deleteSession(sid) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: takesPage
|
|
Kirigami.ScrollablePage {
|
|
id: takesView
|
|
property string session: ""
|
|
property var rows: backend.takeList(session)
|
|
title: "Takes"
|
|
|
|
Connections {
|
|
target: backend
|
|
function onSessionsChanged() { takesView.rows = backend.takeList(takesView.session) }
|
|
}
|
|
|
|
ListView {
|
|
model: takesView.rows
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Kirigami.Units.gridUnit * 4
|
|
visible: parent.count === 0
|
|
text: "No takes in this session"
|
|
}
|
|
|
|
delegate: Controls.ItemDelegate {
|
|
id: takeRow
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
onClicked: pageStack.push(viewerPage, { session: takesView.session, take: modelData.id })
|
|
|
|
contentItem: RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Image {
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
|
|
Layout.preferredHeight: Kirigami.Units.gridUnit * 4.5
|
|
fillMode: Image.PreserveAspectFit
|
|
asynchronous: true
|
|
source: takeRow.modelData.sets > 0
|
|
? "image://frames/thumb/" + takesView.session + "/" + takeRow.modelData.id : ""
|
|
}
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
Controls.Label { text: takeRow.modelData.title; font.bold: true }
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: takeRow.modelData.status + " · " + takeRow.modelData.durationText + " · "
|
|
+ takeRow.modelData.sets + " sets"
|
|
+ (takeRow.modelData.deleted_sets ? " (" + takeRow.modelData.deleted_sets + " deleted)" : "")
|
|
+ " · " + takeRow.modelData.sizeText
|
|
}
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "edit-delete"
|
|
text: "Delete take"
|
|
display: Controls.AbstractButton.IconOnly
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: {
|
|
const take = takeRow.modelData.id
|
|
confirm.ask("Delete this take?",
|
|
"\"" + takeRow.modelData.title + "\" (" + takeRow.modelData.durationText
|
|
+ ") is deleted from the headset for good.",
|
|
() => { pageStack.pop(takesView); backend.deleteTake(takesView.session, take) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: viewerPage
|
|
Kirigami.Page {
|
|
id: viewer
|
|
property string session: ""
|
|
property string take: ""
|
|
property var info: backend.takeInfo(session, take)
|
|
property int index: 0
|
|
property int markStart: -1
|
|
property int markEnd: -1
|
|
title: info.title
|
|
readonly property bool deletedHere: isDeleted(index)
|
|
|
|
function step(n) {
|
|
index = Math.max(0, Math.min(info.count - 1, index + n))
|
|
}
|
|
function isDeleted(i) {
|
|
for (const r of info.ranges)
|
|
if (i >= r[0] && i <= r[1] && r[0] >= 0)
|
|
return true
|
|
return false
|
|
}
|
|
function deleteMarked() {
|
|
backend.deleteRange(session, take, markStart, markEnd)
|
|
markStart = markEnd = -1
|
|
info = backend.takeInfo(session, take)
|
|
}
|
|
|
|
Connections {
|
|
target: backend
|
|
function onSessionsChanged() { viewer.info = backend.takeInfo(viewer.session, viewer.take) }
|
|
}
|
|
|
|
// Keyboard stepping: arrows one set, Page Up/Down ten, Home/End, [ and ] mark a range,
|
|
// Delete deletes it.
|
|
Item {
|
|
id: keys
|
|
focus: true
|
|
Component.onCompleted: forceActiveFocus()
|
|
Keys.onPressed: event => {
|
|
const k = event.key
|
|
if (k === Qt.Key_Left) viewer.step(-1)
|
|
else if (k === Qt.Key_Right) viewer.step(1)
|
|
else if (k === Qt.Key_PageUp) viewer.step(-10)
|
|
else if (k === Qt.Key_PageDown) viewer.step(10)
|
|
else if (k === Qt.Key_Home) viewer.index = 0
|
|
else if (k === Qt.Key_End) viewer.index = Math.max(0, viewer.info.count - 1)
|
|
else if (k === Qt.Key_BracketLeft) viewer.markStart = viewer.index
|
|
else if (k === Qt.Key_BracketRight) viewer.markEnd = viewer.index
|
|
else if (k === Qt.Key_Delete && viewer.markStart >= 0 && viewer.markEnd >= 0) viewer.deleteMarked()
|
|
else return
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
visible: viewer.info.count === 0
|
|
text: "This take has no frame sets"
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
visible: viewer.info.count > 0
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Image {
|
|
id: frame
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
fillMode: Image.PreserveAspectFit
|
|
asynchronous: true
|
|
cache: false
|
|
retainWhileLoading: true
|
|
source: viewer.info.count > 0
|
|
? "image://frames/set/" + viewer.session + "/" + viewer.take + "/" + viewer.index : ""
|
|
MouseArea { anchors.fill: parent; onClicked: keys.forceActiveFocus() }
|
|
|
|
Rectangle {
|
|
visible: viewer.deletedHere
|
|
x: (frame.width - frame.paintedWidth) / 2
|
|
y: (frame.height - frame.paintedHeight) / 2
|
|
width: frame.paintedWidth
|
|
height: frame.paintedHeight
|
|
color: Qt.rgba(0.8, 0, 0, 0.35)
|
|
Controls.Label {
|
|
anchors.centerIn: parent
|
|
text: "Deleted: not exported"
|
|
color: "white"
|
|
font.bold: true
|
|
}
|
|
}
|
|
}
|
|
|
|
// The slider, with the deleted ranges (red) and the marked range (blue) under it.
|
|
Item {
|
|
Layout.fillWidth: true
|
|
implicitHeight: slider.implicitHeight
|
|
Repeater {
|
|
model: viewer.info.ranges
|
|
Rectangle {
|
|
required property var modelData
|
|
visible: modelData[0] >= 0
|
|
readonly property real unit: (slider.availableWidth) / Math.max(1, viewer.info.count - 1)
|
|
x: slider.leftPadding + modelData[0] * unit - 2
|
|
width: Math.max(4, (modelData[1] - modelData[0]) * unit + 4)
|
|
y: slider.topPadding + slider.availableHeight / 2 + 4
|
|
height: 4
|
|
color: Kirigami.Theme.negativeTextColor
|
|
}
|
|
}
|
|
Rectangle {
|
|
visible: viewer.markStart >= 0
|
|
readonly property int last: viewer.markEnd >= 0 ? viewer.markEnd : viewer.index
|
|
readonly property real unit: (slider.availableWidth) / Math.max(1, viewer.info.count - 1)
|
|
x: slider.leftPadding + Math.min(viewer.markStart, last) * unit - 2
|
|
width: Math.abs(last - viewer.markStart) * unit + 4
|
|
y: slider.topPadding + slider.availableHeight / 2 - 8
|
|
height: 4
|
|
color: Kirigami.Theme.highlightColor
|
|
}
|
|
Controls.Slider {
|
|
id: slider
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
focusPolicy: Qt.NoFocus
|
|
from: 0
|
|
to: Math.max(0, viewer.info.count - 1)
|
|
stepSize: 1
|
|
snapMode: Controls.Slider.SnapAlways
|
|
value: viewer.index
|
|
onMoved: viewer.index = Math.round(value)
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Controls.Label {
|
|
text: "Set " + (viewer.index + 1) + " of " + viewer.info.count + " · "
|
|
+ backend.setTime(viewer.session, viewer.take, viewer.index)
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
text: viewer.markStart >= 0 ? "Start: " + (viewer.markStart + 1) : "Mark start"
|
|
icon.name: "go-first"
|
|
onClicked: viewer.markStart = viewer.index
|
|
}
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
text: viewer.markEnd >= 0 ? "End: " + (viewer.markEnd + 1) : "Mark end"
|
|
icon.name: "go-last"
|
|
onClicked: viewer.markEnd = viewer.index
|
|
}
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
enabled: viewer.markStart >= 0 && viewer.markEnd >= 0
|
|
text: "Delete range"
|
|
icon.name: "edit-cut"
|
|
onClicked: viewer.deleteMarked()
|
|
}
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
visible: viewer.markStart >= 0 || viewer.markEnd >= 0
|
|
text: "Clear marks"
|
|
icon.name: "edit-clear"
|
|
onClicked: viewer.markStart = viewer.markEnd = -1
|
|
}
|
|
}
|
|
|
|
Flow {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.smallSpacing
|
|
visible: viewer.info.ranges.length > 0
|
|
Controls.Label { text: "Deleted:"; topPadding: Kirigami.Units.smallSpacing }
|
|
Repeater {
|
|
model: viewer.info.ranges
|
|
Controls.Button {
|
|
required property var modelData
|
|
required property int index
|
|
focusPolicy: Qt.NoFocus
|
|
flat: true
|
|
text: (modelData[0] < 0 ? "a range with no sets"
|
|
: "sets " + (modelData[0] + 1) + " to " + (modelData[1] + 1)) + ": restore"
|
|
icon.name: "edit-undo"
|
|
onClicked: backend.restoreRange(viewer.session, viewer.take, index)
|
|
}
|
|
}
|
|
}
|
|
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: "← → step one set, Page Up/Down ten, Home/End. [ and ] mark a range's start and end, "
|
|
+ "Delete deletes it. Deleted sets stay on the headset until export, which leaves them out."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Export
|
|
Component {
|
|
id: exportPage
|
|
Kirigami.ScrollablePage {
|
|
id: exportView
|
|
title: "Export"
|
|
property bool worn: backend.headsetWorn()
|
|
readonly property var chosen: {
|
|
const list = backend.sessions
|
|
for (const s of list)
|
|
if (s.id === sessionBox.currentValue)
|
|
return s
|
|
return null
|
|
}
|
|
|
|
Timer {
|
|
interval: 5000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: exportView.worn = backend.headsetWorn()
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DraftBanner { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: exportView.worn
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Someone seems to be wearing the headset. Exporting keeps the processor busy for a "
|
|
+ "while, which can make VR stutter: export when the headset is off, or go ahead anyway."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.zstdFound
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Error
|
|
text: "zstd isn't installed in the dev container, so nothing can be exported. Run setup/dev-container.sh."
|
|
}
|
|
}
|
|
|
|
Kirigami.FormLayout {
|
|
Controls.ComboBox {
|
|
id: sessionBox
|
|
Kirigami.FormData.label: "Session:"
|
|
model: backend.sessions
|
|
textRole: "label"
|
|
valueRole: "id"
|
|
enabled: !backend.exporting
|
|
Component.onCompleted: {
|
|
const i = indexOfValue(root.chosenSession)
|
|
currentIndex = i >= 0 ? i : (count > 0 ? 0 : -1)
|
|
}
|
|
onActivated: root.chosenSession = currentValue
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Recorded:"
|
|
visible: exportView.chosen !== null
|
|
text: exportView.chosen ? root.plural(exportView.chosen.takes, "take") + ", " + exportView.chosen.sizeText : ""
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Export:"
|
|
visible: exportView.chosen !== null
|
|
text: !exportView.chosen ? ""
|
|
: exportView.chosen.export_stale ? "changed since it was exported: export it again"
|
|
: exportView.chosen.exported ? "ready, " + exportView.chosen.exportText : "not exported yet"
|
|
}
|
|
RowLayout {
|
|
Controls.Button {
|
|
text: exportView.chosen && exportView.chosen.exported ? "Export again" : "Export"
|
|
icon.name: "document-export"
|
|
enabled: exportView.chosen !== null && !backend.exporting && backend.zstdFound
|
|
&& !exportView.chosen.active
|
|
onClicked: backend.exportSession(exportView.chosen.id, false)
|
|
}
|
|
Controls.Button {
|
|
visible: backend.exporting
|
|
text: "Cancel"
|
|
icon.name: "dialog-cancel"
|
|
onClicked: backend.cancelExport()
|
|
}
|
|
}
|
|
// Drawn here: the desktop style's ProgressBar paints nothing without a QApplication.
|
|
Rectangle {
|
|
Kirigami.FormData.label: "Progress:"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
implicitHeight: Kirigami.Units.smallSpacing * 2
|
|
visible: backend.exporting
|
|
radius: height / 2
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.15)
|
|
Rectangle {
|
|
width: parent.width * Math.max(0, Math.min(1, backend.exportFraction))
|
|
height: parent.height
|
|
radius: parent.radius
|
|
color: Kirigami.Theme.highlightColor
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
visible: backend.exportText !== "" && backend.exportSessionId === sessionBox.currentValue
|
|
wrapMode: Text.WrapAnywhere
|
|
text: backend.exportText
|
|
}
|
|
RowLayout {
|
|
visible: exportView.chosen !== null && exportView.chosen.exported && !backend.exporting
|
|
Controls.Button {
|
|
text: "Upload"
|
|
icon.name: "go-next"
|
|
onClicked: root.show(uploadPage)
|
|
}
|
|
Controls.Button {
|
|
text: "Delete export"
|
|
icon.name: "edit-delete"
|
|
onClicked: {
|
|
const sid = exportView.chosen.id
|
|
confirm.ask("Delete this export?", "The export is deleted; the session's recordings stay.",
|
|
() => backend.deleteExport(sid))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
footer: Controls.Label {
|
|
padding: Kirigami.Units.largeSpacing
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Export leaves out the ranges you deleted, compresses the rest and adds a manifest and "
|
|
+ "checksums, in " + backend.exportsDir + ". It runs at the lowest priority "
|
|
+ "and takes a few minutes per round. Nothing is uploaded until you press Upload on the Upload page."
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Upload
|
|
Component {
|
|
id: uploadPage
|
|
Kirigami.ScrollablePage {
|
|
id: uploadView
|
|
title: "Upload"
|
|
readonly property var exported: backend.sessions.filter(s => s.exported)
|
|
readonly property string session: exportBox.currentIndex >= 0 ? exportBox.currentValue || "" : ""
|
|
readonly property var chosen: exportBox.currentIndex >= 0 && exportBox.currentIndex < exported.length
|
|
? exported[exportBox.currentIndex] : null
|
|
readonly property var up: backend.upload
|
|
readonly property var upError: up.error || ({})
|
|
readonly property var upResult: up.result || ({})
|
|
// The upload shown is this export's (the window keeps the last one's result).
|
|
readonly property bool mine: up.session === session
|
|
property var info: ({ previous: {}, uploads: 0 })
|
|
readonly property bool loggedIn: backend.login.state === "ok"
|
|
// Why Upload is off, or "" when it can go.
|
|
readonly property string blocked: {
|
|
if (session === "") return "Choose an export"
|
|
if (chosen && chosen.export_stale) return "Export the session again first"
|
|
if (backend.exporting) return "Wait for the export to finish"
|
|
if (backend.uploading) return ""
|
|
if (backend.hubDryRun) return ""
|
|
if (!backend.uploadAllowed) return "Contributions aren't open yet"
|
|
if (!loggedIn) return "Log in to Hugging Face first (below)"
|
|
return ""
|
|
}
|
|
|
|
function refreshInfo() { info = backend.uploadInfo(session) }
|
|
onSessionChanged: refreshInfo()
|
|
Connections {
|
|
target: backend
|
|
function onSessionsChanged() { uploadView.refreshInfo() }
|
|
}
|
|
Component.onCompleted: {
|
|
refreshInfo()
|
|
if (backend.login.state === "unknown") backend.checkLogin()
|
|
}
|
|
|
|
function startUpload() {
|
|
if (info.previous && info.previous.export_sha)
|
|
confirm.ask("Upload this export again?",
|
|
"It was uploaded on " + info.previous.uploaded + " (" + (info.previous.pr_url || "no link")
|
|
+ "). Uploading it again opens a second pull request.",
|
|
() => backend.startUpload(uploadView.session, true))
|
|
else
|
|
backend.startUpload(session, false)
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DraftBanner { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.hubDryRun
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Information
|
|
text: "Dry run (--hub-dry-run): Upload checks the export and lists what it would send. "
|
|
+ "Nothing goes over the network."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.textsDraft && backend.allowUploadSet && !backend.hubDryRun
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Rehearsal: FT_HANDREC_ALLOW_UPLOAD=1 allows uploads to " + backend.dataset
|
|
+ " while the texts are drafts."
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.exported.length === 0
|
|
icon.name: "document-export"
|
|
text: "Nothing exported yet"
|
|
explanation: "Review a session, then export it. It can be uploaded from here."
|
|
helpfulAction: Kirigami.Action {
|
|
text: "Export"; icon.name: "document-export"
|
|
onTriggered: root.show(exportPage)
|
|
}
|
|
}
|
|
|
|
Kirigami.FormLayout {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.exported.length > 0
|
|
Controls.ComboBox {
|
|
id: exportBox
|
|
Kirigami.FormData.label: "Export:"
|
|
model: uploadView.exported
|
|
textRole: "label"
|
|
valueRole: "id"
|
|
enabled: !backend.uploading
|
|
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.chosenSession))
|
|
onActivated: root.chosenSession = currentValue
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Size:"
|
|
text: uploadView.chosen ? uploadView.chosen.exportText : ""
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Goes to:"
|
|
textFormat: Text.StyledText
|
|
text: "<a href=\"" + backend.datasetUrl + "\">" + backend.dataset + "</a>, as a pull request"
|
|
onLinkActivated: link => Qt.openUrlExternally(link)
|
|
}
|
|
RowLayout {
|
|
Kirigami.FormData.label: "Hugging Face:"
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 22
|
|
wrapMode: Text.Wrap
|
|
text: backend.login.state === "dry" ? "not checked in a dry run"
|
|
: backend.login.state === "unknown" ? "not checked yet"
|
|
: backend.login.state === "none" ? "not logged in"
|
|
: backend.login.text || ""
|
|
}
|
|
Controls.Button {
|
|
visible: backend.login.state !== "dry"
|
|
text: "Check again"
|
|
icon.name: "view-refresh"
|
|
enabled: backend.login.state !== "checking"
|
|
onClicked: backend.checkLogin()
|
|
}
|
|
}
|
|
}
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.chosen !== null && uploadView.chosen.export_stale
|
|
type: Kirigami.MessageType.Warning
|
|
text: "This session changed since it was exported. Export it again so the upload has your latest deletions."
|
|
}
|
|
|
|
// Not logged in: how to log in, in a terminal. The token never goes into this window.
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== "" && ["none", "read", "error", "missing"].indexOf(backend.login.state) >= 0
|
|
type: backend.login.state === "error" ? Kirigami.MessageType.Error : Kirigami.MessageType.Warning
|
|
text: backend.login.state === "none"
|
|
? "You're not logged in to Hugging Face. Open a terminal (Konsole) and run the command below. "
|
|
+ "Paste a token with the Write role when it asks (steps 1 to 3 below say how to make one). "
|
|
+ "The token goes only into that terminal, never into this window. Then press Check again."
|
|
: backend.login.text || ""
|
|
actions: backend.login.link ? [linkAction] : []
|
|
Kirigami.Action {
|
|
id: linkAction
|
|
text: "Open"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(backend.login.link)
|
|
}
|
|
}
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== "" && ["none", "read"].indexOf(backend.login.state) >= 0
|
|
Controls.TextField {
|
|
id: loginCommand
|
|
Layout.fillWidth: true
|
|
readOnly: true
|
|
font.family: "monospace"
|
|
text: backend.loginCommand + (backend.login.state === "read" ? " --force" : "")
|
|
}
|
|
Controls.Button {
|
|
text: "Copy command"
|
|
icon.name: "edit-copy"
|
|
onClicked: backend.copy(loginCommand.text)
|
|
}
|
|
}
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== "" && uploadView.info.previous.export_sha !== undefined
|
|
type: Kirigami.MessageType.Information
|
|
text: "This export was uploaded on " + (uploadView.info.previous.uploaded || "") + ": "
|
|
+ (uploadView.info.previous.pr_url || "") + ". There's no need to upload it again."
|
|
}
|
|
|
|
RowLayout {
|
|
visible: uploadView.session !== ""
|
|
Controls.Button {
|
|
text: backend.hubDryRun ? "Upload (dry run)" : "Upload"
|
|
icon.name: "cloud-upload"
|
|
enabled: uploadView.blocked === "" && !backend.uploading
|
|
onClicked: uploadView.startUpload()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.uploading
|
|
text: "Cancel"
|
|
icon.name: "dialog-cancel"
|
|
onClicked: backend.cancelUpload()
|
|
}
|
|
Controls.Label {
|
|
visible: !backend.uploading && uploadView.blocked !== ""
|
|
opacity: 0.7
|
|
text: uploadView.blocked
|
|
}
|
|
}
|
|
|
|
// Progress: a share while the export is checked, a sweeping bar while it's sent
|
|
// (huggingface_hub doesn't report progress). Drawn here, as on the Export page.
|
|
Rectangle {
|
|
id: track
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
implicitHeight: Kirigami.Units.smallSpacing * 2
|
|
visible: backend.uploading
|
|
radius: height / 2
|
|
clip: true
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.15)
|
|
readonly property bool unknown: uploadView.up.fraction === undefined || uploadView.up.fraction < 0
|
|
Rectangle {
|
|
id: fill
|
|
height: parent.height
|
|
radius: parent.radius
|
|
color: Kirigami.Theme.highlightColor
|
|
width: track.unknown ? parent.width / 4 : parent.width * Math.max(0, Math.min(1, uploadView.up.fraction))
|
|
x: 0
|
|
SequentialAnimation on x {
|
|
running: track.visible && track.unknown
|
|
loops: Animation.Infinite
|
|
onRunningChanged: if (!running) fill.x = 0
|
|
NumberAnimation { from: -fill.width; to: track.width; duration: 1600 }
|
|
}
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.mine && (uploadView.up.text || "") !== ""
|
|
&& (backend.uploading || uploadView.up.phase === "failed" && uploadView.upError.kind === "cancelled")
|
|
wrapMode: Text.Wrap
|
|
text: uploadView.up.text || ""
|
|
}
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.uploading && uploadView.mine && uploadView.up.phase === "done"
|
|
type: Kirigami.MessageType.Positive
|
|
text: uploadView.upResult.dry_run
|
|
? "Dry run: the export passed its checks. It would go to " + uploadView.upResult.repo + "/"
|
|
+ uploadView.upResult.path_in_repo + " (" + uploadView.upResult.files + " files). Nothing was sent."
|
|
: "Uploaded. Your pull request: " + (uploadView.upResult.pr_url || "")
|
|
+ ". The maintainer reviews it before it joins the dataset."
|
|
actions: uploadView.upResult.pr_url ? [openPr, copyPr] : []
|
|
Kirigami.Action {
|
|
id: openPr
|
|
text: "Open"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(uploadView.upResult.pr_url)
|
|
}
|
|
Kirigami.Action {
|
|
id: copyPr
|
|
text: "Copy link"
|
|
icon.name: "edit-copy"
|
|
onTriggered: backend.copy(uploadView.upResult.pr_url)
|
|
}
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.uploading && uploadView.mine && uploadView.up.phase === "failed"
|
|
&& uploadView.upError.kind !== "cancelled"
|
|
type: Kirigami.MessageType.Error
|
|
text: (uploadView.upError.text || "")
|
|
+ ((uploadView.upError.errors || []).length ? "\n\n• " + uploadView.upError.errors.join("\n• ") : "")
|
|
actions: uploadView.upError.link ? [openErrorLink] : []
|
|
Kirigami.Action {
|
|
id: openErrorLink
|
|
text: "Open"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(uploadView.upError.link)
|
|
}
|
|
}
|
|
Controls.TextArea {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.mine && (uploadView.up.log || "") !== ""
|
|
readOnly: true
|
|
selectByMouse: true
|
|
wrapMode: Text.WrapAnywhere
|
|
font.family: "monospace"
|
|
text: uploadView.up.log || ""
|
|
}
|
|
|
|
Kirigami.Separator {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== ""
|
|
}
|
|
Kirigami.Heading {
|
|
level: 3
|
|
visible: uploadView.session !== ""
|
|
text: "Or upload from a terminal"
|
|
}
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== ""
|
|
Controls.TextArea {
|
|
id: command
|
|
Layout.fillWidth: true
|
|
readOnly: true
|
|
selectByMouse: true
|
|
wrapMode: Text.WrapAnywhere
|
|
font.family: "monospace"
|
|
text: backend.uploadCommand(uploadView.session)
|
|
}
|
|
Controls.Button {
|
|
text: "Copy command"
|
|
icon.name: "edit-copy"
|
|
onClicked: backend.copy(command.text)
|
|
}
|
|
}
|
|
MarkdownText {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== ""
|
|
markdown: backend.uploadText(uploadView.session)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|